数据截至 (上游 commit b77d61291399)
输入之外的三条省法 — 让模型少写、让下次少说
30 秒导读: 前五章讲的都是"把送进去的东西压小"。这一章讲另外三条路:(a) 改请求,让模型这一轮就少吐字;(b) 把该记住的东西存下来,下次不用重新讲一遍;(c) 把这次踩的坑离线学成规则,下次直接绕开。三条互不依赖,可以单开单关。
1. 先分清:这三条各省的是什么
Headroom 的主干(见 01-pipeline-and-router.md)做的是输入压缩:同样的对话历史,送上去的 token 更少。这一章的三条路都不碰压缩器。
| 省法 | 省的是哪种 token | 什么时候生效 | 主入口 |
|---|---|---|---|
| (a) 输出侧塑形 | 输出 token(含 thinking 计费) | 本轮请求发出前 | headroom/proxy/output_shaper.py |
| (b) 跨会话 / 跨 agent 记忆 | 输入 token(不必重新解释背景) | 跨会话、长期 | headroom/memory/core.py |
| (c) 学习闭环 | 输入 + 输出(少走弯路、少重跑) | 离线,作用于下次会话 | headroom/learn/、headroom/telemetry/toin.py |
(b) 是一笔交易,不是纯赚。 记忆注入本身要花输入 token,所以它带硬预算(默认 1024 token,见 §3.7);它换回来的是"模型不用把上次已经查明白的事再查一遍"。
三条路在一次请求里的位置。 从左到右是时间顺序;虚线部分发生在这次请求之外。
本次请求 本次响应 下次会话
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐
│ 压缩输入 │──►│ 注入记忆 │──►│ 塑形请求│─►│ 记账/观测 │╌╌►│ 离线学习 │
│ (01~05 章) │ │ (b) │ │ (a) │ │ (a)(c) │ │ (c) │
└──────────────┘ └─────────┘ └────────┘ └──────────┘ └──────────┘
│
写回 CLAUDE.local.md / recommendations.toml
2. (a) 输出侧塑形 — 代理只能改请求,却能管住输出
2.1 它要解决的小问题
Agent 的输出里有大量你根本不看的东西:开场白("我现在要读这个文件")、收场白("我刚刚做了以下三件事")、把已经在上下文里的代码原样再贴一遍。这些都是按输出价计费的 token,而输出价通常是输入价的 3~5 倍。
代理的困境: 它不产生 token,没法"删掉模型说的话"。所以这里所有杠杆都只有一种形式——改请求。
模块头把这件事写死了(headroom/proxy/output_shaper.py:1-40):Headroom 的 transforms 压的是进去的东西,这个模块是请求侧针对产出的第一根杠杆,而杠杆只有两根:
- verbosity steering — 一段确定性的指令块,追加到 system prompt 的尾部。
- effort routing — agentic 循环里大部分轮次是机械续跑,给它们降 effort 档。
2.2 杠杆一:五档 verbosity 指令块
档位文本是写死的常量,不是运行时拼的(headroom/proxy/output_verbosity_policy.py:13 VERBOSITY_LEVELS)。档位是累加的——高档包含低档的全部要求。
| 档 | 白话 | 核心指令(原文要点) |
|---|---|---|
| 0 | 不塑形 | steering_text 返回 None,整条路径跳过 |
| 1 | 去 掉客套 | 不要预告要做什么、不要复盘刚做了什么,直接给实质 |
| 2(默认) | 再加"别复述" | 上下文里已有的代码 / 文件内容 / diff / 工具输出,用 path:line 指代;工具调用成功后不要叙述结果 |
| 3 | 只给结论 | 省掉理由(除非用户问为什么)、优先最小编辑而非重写整文件 |
| 4 | 极限 | 最少 token,可以用碎片句,只给答案和最小改动 |
渲染出来是一个带哨兵标签的块(steering_text,同文件 :39):
<headroom_output_shaping>
Skip preamble and postamble; start with the substance. Never restate code, ...
</headroom_output_shaping>
哨兵不是装饰,是幂等性的实现手段。 replace_or_append_steering_block(:47)先 find 哨兵:找到就整块替换,没找到才追加到尾部。所以同一个 body 被塑形两次,结果完全一致——不会出现两个指令块叠罗汉。
三种协议,三个注入点。 系统提示在每种协议里住的地方不一样,所以要三个注入函数:
| 协议 | 系统提示在哪 | 注入函数(headroom/proxy/output_steering.py) |
|---|---|---|
| Anthropic Messages | 顶层 system(字符串或 block 列表) | apply_verbosity_steering(:16) |
| OpenAI Responses | 顶层 instructions 字符串 | apply_openai_responses_verbosity_steering(:116) |
| OpenAI Chat Completions | messages 里最后一条 system / developer | apply_openai_chat_verbosity_steering(:54) |
第三个是后补的:Chat Completions 把系统提示放在消息数组里,前两个注入器根本够不着它——这正是 GitHub Copilot CLI 输出侧收益为零的根因(issue #2302,注释写在 output_steering.py:54-70)。
为什么必须追加到尾部。 追加在最后一个 system block 之后,前面 block 上的 cache_control 断点原封不动:缓存前缀没变,只有这一小段字节稳定的块要重新处理。这条和 04-cache-safety.md 讲的活区不变式是同一条纪律。
2.3 杠杆二:effort routing — 只给"机械续跑"降档
观察: agent 循环里,大多数轮次的最后一条消息是一个干干净净的 tool_result——读了个文件、跑过一次测试。这种轮次模型不需要深思;但 Claude Code 这类 harness 会把 output_config.effort 每轮都钉在 xhigh,而 thinking 是按输出计 费的。
分类必须是结构判定,不能看内容。 模块头明确写着:分类只看 block 类型、role、is_error 标志——没有内容正则、没有关键词(output_shaper.py:30-31)。原因很直白:内容匹配会让同样的输入在不同措辞下走不同路径,那就把请求路径变成了不确定的。
读最后一条 user 消息的 content 块(从上往下,命中即停)
有 text / image / document 块 ─────────► NEW_USER_ASK 不动
全是 tool_result,其中有 is_error ─────► ERROR_CONTINUATION 不动
全是 tool_result,全部成功 ────────────► MECHANICAL_CONT. 降档
空 / 不认识的形状 ────────────────────► UNKNOWN 不动
真实实现是 classify_turn(headroom/proxy/output_turn_policy.py:28),四种取值定义在同文件 TurnKind(:9)。只有 MECHANICAL_CONTINUATION 会被降档,报错续跑刻意 不降——正要 debug 的时候把脑子关小是最糟的时机。
降档动作有两根,都在 route_effort(output_shaper.py:204):
| 杠杆 | 条件 | 做什么 |
|---|---|---|
现代:output_config.effort | 客户端已经发了这个字段 | 降到 settings.mechanical_effort(默认 low) |
遗留:thinking.budget_tokens | thinking.type == "enabled" 且预算高于地板 | 夹到 LEGACY_THINKING_FLOOR = 1024 |
比较逻辑抽成了纯函数,不碰请求字典:lower_effort_value / clamp_legacy_thinking_budget(headroom/proxy/output_effort_policy.py:15,:26),排序表 EFFORT_RANK = {low:0, medium:1, high:2, xhigh:3, max:4}(:10)。
OpenAI 侧的两个变体。 route_openai_reasoning_effort(output_shaper.py:245)降 reasoning.effort;route_openai_text_verbosity(:266)管 text.verbosity。后者是唯一允许"从无到有创建"字段的地方,而且只对 gpt-5* 开口——判断在 can_create_openai_text_verbosity(output_effort_policy.py:42),因为只有这个族确定接受该参数。
Responses 格式还有一套自己的:classify_responses_turn(output_shaper.py:447)读 input 条目列表,route_responses_effort(:501)用独立的排序表 _RESPONSES_EFFORT_RANK(:397)——因为 Responses 的 effort 多一档地板 minimal,Anthropic 的 output_config.effort 没有。两张表不能共用。
Responses 没有 is_error 字段,所以错误只能结构性地嗅:_responses_tool_output_is_error(:411)只看 JSON 里的 exit_code != 0 / success: false / 真值 error,绝不读散文内容。
2.4 三条安全铁律
模块头把每条铁律和它防住的具体故障一一对应(output_shaper.py:20-28)。这是这个模块最值得抄的地方——不是"要小心",而是"这条防的是这个 400"。
| 铁律 | 防住的具体故障 | 落在哪 |
|---|---|---|
绝不注入客户端没发的 output_config.effort | 不支持该参数的模型直接 400;而"降低一个已存在的值"永远合法,因为它的存在本身就证明目标模型接受它 | lower_effort_value 只在 isinstance(current, str) 且值在 rank 表里时才返回目标(output_effort_policy.py:15-23) |
绝不切换 thinking.type | 历史里带 thinking block 时把 thinking 关掉,某些模型 400;而且这个切换会炸掉 messages 缓存层 | clamp_legacy_thinking_budget 只在 thinking_type == "enabled" 时改 budget_tokens,type 字段一个字不动(:26-39) |
| steering 文本幂等 + 字节稳定 | 同一会话每轮前缀不同 → 提供商前缀缓存全部 miss,省的那点输出还不够赔的 | 哨兵匹配 + 整块替换(output_verbosity_policy.py:47);档位文本是常量,注释直说"改这些字符串就是一次 cache-busting 变更"(:11-12) |
2.5 档位从哪来:三层来源 + 一个 AIMD 控制器
shape_request(output_shaper.py:326)本身不决定档位——它接收 level_override,保持"给定档位 → 确定性改写"的纯粹性。档位由 resolve_verbosity_level(:142)单独解析:
| 优先级 | 来源 | 返回的 source 标记 |
|---|---|---|
| 1 | HEADROOM_VERBOSITY_LEVEL 环境变量 | env |
| 2 | AIMD 控制器状态 verbosity_controller.json(需开 HEADROOM_VERBOSITY_AUTOTUNE) | controller |
| 3 | learn --verbosity 学出来的 verbosity.json | learned |
| 4 | settings 默认值(2) | default |
第 3 层是 (a) 和 (c) 的接缝。 headroom/learn/verbosity.py 从真实 transcript 里挖行为信号——用户很少说"简短点",但他们会打断、会在"这么长的答案根本读不完"的时间内就回下一句。recommend_level(:399)按 interrupt_rate + fast_skip_rate 的合并压力分档,而且顶格只给到 L3,不自动上 L4。
第 2 层是运行期自适应。 VerbosityController(headroom/proxy/verbosity_controller.py:59)是个纯状态机,借的 是拥塞控制的直觉:
| 信号 | 动作 | 为什么这样不对称 |
|---|---|---|
TOO_LITTLE(用户要求展开) | 立刻降一档,并进入 5 轮 cooldown | 惹恼用户是昂贵事件,像拥塞——反应要快,然后忍住别急着回去 |
TOO_MUCH(打断 / 秒跳过) | 连续 3 次才升一档;cooldown 期内不升 | 往简洁方向探,错也只错得很慢 |
NEUTRAL | 清零 up_streak,cooldown 递减 | 要求的是连续压力,不是累计压力 |
信号怎么检测故意不在这个模块里(verbosity_controller.py:16-19):检测留给代理侧,控制逻辑才能保持确定、可测。
2.6 省了多少?—— 一个诚实的反事实
这是整章工程含量最高的一段。输出侧的省量是反事实的:塑形之后模型吐了 N 个 token,但"不塑形它会吐多少"永远观测不到。输入压缩不同——tokens_before 和 tokens_after 两边都能看见。
headroom/proxy/output_savings.py:1-8 把这句话说得很硬:一句拍脑袋的"我们省 30%" 是营销,不是测量。于是分三档:
| 档 | 名字 | 怎么算 | 诚实度 |
|---|---|---|---|
| 1 | 估计(合成对照) | 用 learn --verbosity 从塑形上线之前的历史建的分层基线,Σ(baseline_mean[层] − 实测输出),带符号相加、绝不逐条截零(截零会系统性高估) | 永远标 "estimated" |
| 2 | 测量(A/B holdout) | 留一小撮会话不塑形当对照组,只有两组都有数据的层才参与,逐层均值差 | 唯一配叫 "measured" 的数 |
| 3 | 直接浪费(无反事实) | echo_ratio(:472):响应与上下文的 n-gram 重叠率 | 单条响应的属性,不需要对照组 |
分层只用请求时可见的特征,绝不用输出:轮次类型、输入 token 分桶、模型族、有没有带 tools(headroom/proxy/output_savings_policy.py:42 stratum_key)。这样线上分层和离线基线的分层才能对得上。
holdout 分组必须按会话而不是按请求(assign_arm,output_savings_policy.py:157,对哈希取前 8 位十六进制做确定性分配)。两个理由刚好指向同一个做法:
- 一个会话里混着塑形轮和不塑形轮,对比会被污染;
- 中途改 system prompt 尾部会炸掉前缀缓存。
记账搭的是现成的顺风车。 (arm, stratum) 被编码成一条 transforms_applied 标签(stratum_label / parse_stratum_label,:168,:174)。这样流式、非流式、backend 三条响应路径都能喂同一个账本,不用改 RequestOutcome 的任何构造点。
SavingsRecorder(output_savings.py:355)在内存里累,每 25 条刷一次盘。它有一个不显然的细节:_reload_baseline_locked(:410)在每次刷盘前先读盘上的 baseline——因为 learn --verbosity --apply 会就地重写同一个文件。不重读会同时坏两件事:新学的基线 要等重启才生效,以及刷盘会把 learn 刚写的基线用内存里的空基线盖掉。
给用户看的那一屏是 headroom output-savings(headroom/cli/output_savings.py:10),两种数都带 95% 置信带,并且明确告诉你想要"测量值"就把 HEADROOM_OUTPUT_HOLDOUT=0.1 打开。
2.7 挂在哪:三个 handler 的调用点
塑形是所有 body 改写之后的最后一步,这样轮次分类器看到的才是最终 messages。
| 协议 | 调用点 | 调的是 |
|---|---|---|
| Anthropic | headroom/proxy/handlers/anthropic.py:3121-3162 | shape_request |
| OpenAI Responses | headroom/proxy/handlers/openai.py:746-747 | shape_responses_request |
| OpenAI Chat | headroom/proxy/handlers/openai.py:4385-4388 | shape_openai_chat_request |
三处的结构完全一样:先算 arm 和 stratum、把标签挂上去,只有 arm == "treatment" 才真的塑形。控制组照样记账,但一个字节都不改。开关还受 rollout 门控和跟压缩同一个 bypass header 管——接法见 05-proxy-and-wrap.md。
3. (b) 跨会话 / 跨 agent 记忆 — 把结论存下来
3.1 一句话:记忆是有作用域、有时效的一条条事实
不是"把对话历史存下来"。存的是抽出来的、自洽的一条事实,带作用域和时间戳。数据模型在 headroom/memory/models.py:67 Memory。
作用域是推导出来的,不是手填的字段——填了哪一层就是哪一层(Memory.scope_level,:108):
| 层级 | 含义 | 触发条件 |
|---|---|---|
USER | 跨所有会话长期有效 | 只有 user_id |
SESSION | 一个任务 / 一段对话内有效 | 有 session_id |
AGENT | 一个 agent 生命周期内有效 | 有 agent_id |
TURN | 单次 LLM 调用,用完即弃 | 有 turn_id |
时效靠 valid_from / valid_until 两个字段:valid_until is None 才是"当前有效"(is_current,:118)。被取代的记忆不删,靠 supersedes / superseded_by 串成链。
3.2 顶层:一个协调器 + 五个可换部件
HierarchicalMemory(headroom/memory/core.py:34)自己不存东西,它只协调五个部件:
┌──────────────────────┐
│ HierarchicalMemory │ 协调器
└──────────┬───────────┘
┌──────────┬───────────┼───────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌───────┐ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌───────┐
│ store │ │ vector │ │ text │ │embedder│ │ cache │
│ 落库 │ │ 语义检索 │ │ 关键词 │ │ 向量化 │ │ 热数据 │
└───────┘ └─────────┘ └─────────┘ └────────┘ └───────┘
每一格都是可换实现,选型集中在 MemoryConfig(headroom/memory/config.py:53):
| 部件 | 默认 / 可选 | 实现文件 |
|---|---|---|
| store | SQLite / 外部 entry point | adapters/sqlite.py:47 SQLiteMemoryStore |
| vector | AUTO → sqlite-vec 优先,退 HNSW | adapters/sqlite_vector.py:180、adapters/hnsw.py:188 |
| text | FTS5(BM25 + Porter 词干) | adapters/fts5.py:38 FTS5TextIndex |
| embedder | LOCAL / ONNX / OPENAI / OLLAMA | factory.py:144 _create_embedder |
| cache | 线程安全 LRU | adapters/cache.py:19 LRUMemoryCache |
| graph | 内存图 / SQLite 图 | adapters/graph.py:19 InMemoryGraphStore |
为什么 sqlite-vec 是首选而不是 HNSW: 真删除(不是打标记)、内存受 SQLite page cache 约束、默认持久化(adapters/sqlite_vector.py:1-17)。HNSW 不设 max_entries 就是无界的(config.py:114)。
一个容易忽略的性能细节: factory.py:37 有个进程级 _EMBEDDER_CACHE,按 (backend, model) 缓存 embedder。没有它,BackendRouter 每开一个项目库就会把 sentence-transformers / ONNX 模型重新加载一遍。
3.3 一条记忆是怎么写进去的
HierarchicalMemory.add(core.py:131)的顺序是固定的六步:
- 建
Memory对象(id 自动 uuid4); auto_embed时向量化;- 写 store;
- 有 embedding 就进向量索引;
- 进全文索引;
- 进缓存,然后判断要不要冒泡。
冒泡(bubbling)是这套模型里最像"人"的一步。 _maybe_bubble(core.py:852):重要度 ≥ bubble_threshold(默认 0.7)的记忆,会在 USER 层复制一份,原件留在原层。复制件带 promoted_from 和 promotion_chain,所以"这条为什么会在用户层"永远可追。已经在 USER 层的不冒泡。
3.4 抽取:把 3~4 次 LLM 调用压成 1 次
headroom/memory/extraction.py:1-28 的架构注释算得很直白:
- 传统 Mem0 路线: 主模型说
memory_save(content)→ Mem0 自己再调 LLM 抽事实 → 再调一次抽实体 → 再调一次抽关系 = 每次保存 3~4 次 LLM 调用。 - Headroom 路线: 把抽取 prompt 直接塞进主模型,主模型在调
memory_save时顺手把 facts / entities / relationships 一起交出来 = 1 次调用。
三段 prompt 常量各管一件事:FACT_EXTRACTION_PROMPT(:39,要求每条事实带主语、自洽、时间落地)、ENTITY_EXTRACTION_PROMPT(:84)、RELATIONSHIP_EXTRACTION_PROMPT(:117)。工具 schema 由 get_extraction_tools(:469)生成——这是"把抽取塞进工具定义"的那条路。
3.5 项目隔离:不串味的四级解析
这是整个记忆子系统里最该学的一段(headroom/memory/storage_router.py)。修的是 GH #462:不同项目的记忆互相串。
解法不是加过滤条件,是物理隔离——每个项目一个独立的 SQLite 文件。串味变得结构上不可能:错误的库根本没被打开(:1-6)。
ProjectResolver.resolve(:149)按四级往下试,命中即停:
① header x-headroom-project-id ──► 显式项目 id
② header x-headroom-cwd ──► 显式工作目录
③ CLI --project-root 覆盖 ──► 启动参数
④ 解析 system prompt 里的 <env> 块 ──► "Primary working directory:" / "Working directory:" / "cwd:"
(字面 find,不用正则,_CWD_PREFIXES :44)
四级全空 ─────────────────────► None → 走 fallback
两个防碰撞细节值得单独说。
_sanitize_basename(:248)把所有非白名单字符压成一个 -。结果是 acme/api 和 acme api 都变成 acme-api——两个不同项目会共用一个库。所以 key 后面必须缀一段原始值的 SHA256 前 16 位:人读的部分保持可读,唯一性由 digest 保证(:170-178)。USER 模式里同样的道理:alice/qa / alice qa / alice@qa 塌成同一个,那就是跨用户数据泄漏——正是 USER 模式存在的唯一目的被反过来打破(:305-312)。
Fail-closed 是默认。 PROJECT 模式下解析不出项目时,默认行为是 "empty":返回一个 project_key=None 的哨兵 scope,handler 见到就完全跳过注入。注释里记着为什么(:292-345):2026-05-26 出过一次事故——上一个 TAM-550 会话的一条记忆被池化进 GLOBAL,又被塞进一个毫无关系的线程,模型把它当成了当下的指令。旧的 "global" 行为 保留成显式 opt-in,并且日志里直说这是跨项目泄漏向量。配置值不认识时直接抛异常,不静默兜底。
BackendRouter(:264)在这之上加一层按路径 key 的 LRU(默认 16 个),_get_or_create_backend(:380)持锁存取,淘汰只是丢 Python 引用、SQLite 连接交给 backend 自己的 finalizer。
3.6 注入点为什么必须落在活区尾部
这是本节最重要的一条。 MemoryMode(headroom/proxy/memory_handler.py:53)只有两种:
| 模式 | 行为 | 检索发生在 |
|---|---|---|
AUTO_TAIL(默认) | 请求入口检索,结果追加到最新那条 user 消息的尾部 | prompt 构造路径 |
TOOL | 完全关掉自动注入,模型想要就自己调 memory_search | 工具执行路径 |
AUTO_TAIL 的 docstring 把不变式写死了:缓存热区(system prompt / instructions / 冻结前缀)永不被这条路径改写。旧的 _inject_to_system_or_instructions 在 PR-A2 里被整个删掉了。
原因和 §2.2 的 steering 完全同源,但方向相反:steering 的内容每轮完全一样,所以能安全地贴在 system 尾部;记忆的内容每轮都可能不同,贴进热区就等于每轮炸一次缓存。内容稳定的放热区尾,内容易变的放活区尾——这是一条可以直接搬走的规则。详见 04-cache-safety.md。
落地函数是 _append_to_latest_user_tail(memory_handler.py:946),按 provider 分两条:Anthropic 走 _append_context_to_latest_non_frozen_user_turn(必须落在冻结前缀之外),OpenAI Chat 走 append_text_to_latest_user_chat_message。返回 bytes_appended == 0 表示没找到可写的位置,消息列表原样返回。
代价是必须显式声明"这是回忆,不是命令"。 块被追加进 user 消息里,在线上看就是用户说的话——模型没有任何形状线索能区分"检索回来的记忆"和"用户现在的要求"。所以注入块的头部有一段硬编码的 READ-ONLY 框定(memory_handler.py:883-898),直说:这些是过去会话的背景;如果某条写着"实现 X"那指的是过去的对话,除非用户在本线程里重新提出,否则不要照做。写这段的直接原因就是 §3.5 那次 2026-05-26 事故。
块里每行还带 [id],模型可以直接拿 id 调 memory_update / memory_delete,省掉一次 memory_search 往返。
3.7 检索三件套:每一件都在修一个具体的旧毛病
| 组件 | 修的是什么 | 关键点 |
|---|---|---|
MemoryQuery(proxy/memory_query.py:33) | 旧代码把查询截成"最新 user 消息的前 500 字符" | 三 个来源全保真:user 文本 + 最近 N 条工具输出 + 最近 K 条 assistant 轮次;不截断,交给 embedder 自己的窗口去处理 |
MemoryRanker(proxy/memory_ranker.py:106) | 旧代码纯按余弦排序,6 个月前的"强匹配"能压住今天的新事实 | RecencyBoostRanker(:120):cosine × exp(-age_days / decay_days),默认 30 天;created_at=None 记 1.0(中性),未来时间戳也记 1.0(防时钟偏移) |
MemoryInjectionBudget(proxy/memory_injection.py:34) | 旧代码完全没有 token 上限,top-K=10 × 约 400 token ≈ 4000 token/请求 | 三个独立旋钮:1024 token / 10 条 / 相似度地板 0.3;默认值写死,配错也回不到无界状态 |
三者的分工是清爽的:query 不许丢信息(输入侧全保真),budget 只管收口(输出侧封顶)。apply_to_text(memory_injection.py:56)截断时优先切在换行边界,保证最后一条是完整的。
MemoryQuery.to_embedding_input(memory_query.py:52)的拼接顺序也不是随手定的:先历史 assistant 轮次、再工具输出、user 文本放最后——因为 embedder 的位置权重通常偏向输入尾部。
ranker 的协议要求里有一条容易被当成废话的:必须确定性(memory_ranker.py:106-115)。同样的候选每轮排出同样的顺序,注入的字节才稳定,前缀缓存才活得下去。这又是同一条底线。
3.8 从流量里白捡记忆:TrafficLearner
headroom/memory/traffic_learner.py:1-17:挂在代理的请求 / 响应管线上,零 LLM 调用,纯规则地从代理本来就看得见的流量里抽:
类别(PatternCategory,:115) | 抽的是什么 |
|---|---|
ERROR_RECOVERY | 工具失败 → 下一次成功,这一对教会了"正确姿势" |
ENVIRONMENT | 哪些命令能跑、哪些路径存在、哪些工具装了 |
PREFERENCE | 重复出现的选择、用户的纠正 |
ARCHITECTURE | 文件结构、依赖选择、约定 |
on_tool_result(:744)在把这条加进历史之前先查 error→recovery 对,再抽环境事实。
去重是按"恢复意图"而不是按字面。 _normalize_hash_key(:160)对 error_recovery 做特殊处理:Read 只比 basename;Bash 先剥掉易变后缀(| head -50、-A 3、2>&1),再截到第一个 | 或 && 之前(_normalize_bash_for_hash,:192)。所以 grep foo | head -50 和 grep foo | head -100 折叠成同一条。
证据不够不落库。 _accumulate(:1243)第一次见到只记一笔就返回,攒够 _min_evidence 才丢进保存队列;待定表和已存哈希集都有上限,防止一次性的噪音把内存撑爆。已经存过的重复命中走 _bump_persisted_evidence,涨证据数而不是造重复行。
error_recovery 这个分区还额外做了衰减和封顶:5 天半衰期、21 天硬地板、最多 15 条(:50-53),渲染时重新校验。理由是显然的——"这个坑 怎么绕"的知识过期得比"这个项目长什么样"快得多。
3.9 写回 agent 自己的原生文件
记忆不只能靠注入起作用,也能写进各家 agent 本来就会读的文件。基类 AgentWriter.export(headroom/memory/writers/base.py:96)是四步固定流水:
排序(importance × recency × access)
└─► 按 content_hash 去重
└─► 按 token 预算截断
└─► 各家格式渲染 → 包进 marker → 合并进已有文件
排序公式在 MemoryEntry.score(:50),约 10 天的衰减尺度。合并靠 marker 对(MARKER_START/END,:19-20)+ 正则整块替换(_merge_section,:185):用户手写的部分永远不会被覆盖,只有 marker 之间的内容被换掉。
| Writer | 落到哪 | 预算 | 为什么是这个数 |
|---|---|---|---|
ClaudeCodeMemoryWriter(writers/claude_writer.py:19) | Claude Code 项目 memory 目录的 MEMORY.md | 2000 | Claude 只保证前 200 行常驻上下文(≈2K token) |
CodexMemoryWriter(writers/codex_writer.py:18) | 项目根 AGENTS.md | 3000 | AGENTS.md 沿目录树向上合并 |
CursorMemoryWriter(writers/cursor_writer.py:27) | .cursor/rules/*.mdc | 3000 | 需要 YAML frontmatter,所以它自己覆写了 export |
GenericMemoryWriter(writers/generic_writer.py:17) | HEADROOM_MEMORY.md | 3000 | 任何读 markdown 的 agent 的兜底 |
3.10 一个命名撞车,得说清楚
headroom/memory/tracker.py 不是记忆条目的追踪器,它追的是进程内存占用(RSS / 各组件字节数 / 预算百分比,ComponentStats:26、MemoryTracker:167,可选依赖 psutil)。同一个词 memory 在这个包里承担了两个意思。读代码时别把它当成记忆系统的一部分——它是给"HNSW 索引吃了多少 MB"这类问题用的可观测性设施。
4. (c) 学习闭环 — 把这次的坑变成下次的规则
闭环有两条独立的线,数据源和产物都不一样:
| 线 | 数据来自 | 学出来的东西 | 谁消费 |
|---|---|---|---|
| TOIN | 代理运行时的压缩 / 取回事件 | recommendations.toml | Rust 代理启动时读一次 |
| learn | 各家 agent 落盘的会话日志 | CLAUDE.local.md / AGENTS.md 里的 marker 块 | 下次会话的 agent 自己读 |
4.1 TOIN 的核心契约:只观察,不干预
ToolIntelligenceNetwork(headroom/telemetry/toin.py:423)聚合"什么样结构的工具输出该怎么压"。但它的模块头第一句就是观察-only 契约(:1-20):
TOIN 观察,它绝不改动请求期的压缩决策。
这个契约是踩出来的,不是设计出来的。 注释直接点名两个 bug 编号(P2-27、P5-56):按请求改动把压缩产出的字节数绑到了 TOIN 的可变状态上,于是同样的输入在不同运行里产出不同结果——前缀缓存被打烂,bug 也没法复现。
所以请求期的提示 API get_recommendation() 被退役但保留(:1022):签名还在(源码兼容),行为改成每进程发一次 DeprecationWarning 并永远返回 None。记录端(record_compression / record_retrieval)和存储端一个字没动——学习价值完整保留,只是取用方式改成了离线。
4.2 聚合键与隐私处理
聚合键是三元组 (auth_mode, model_family, structure_hash)(_make_pattern_key,:120)。意思是每个计费切片(PAYG / OAuth / 订阅)和每个模型族各学各的——它们的取回行为本来就不一样,混在一起学等于互相污染。两者未接通时落到显式的 "unknown",而不是空串,这样发布 CLI 可以有意识地过滤它。
序列化用 | 拼成字符串(JSON 的 key 必须是字符串),而 | 在三个分量里都不可能出现——注释把这个前提写明了(:110-113)。旧格式(只有裸 hash)读进来会被提升到默认切片(_deserialize_pattern_key,:143),不炸。
隐私是靠"根本不存"实现的(:31-35):
| 存什么 | 怎么处理 |
|---|---|
| 实际数据值 | 不存 |
| 工具名 | 只存结构哈希 |
| 字段名 | SHA256 前 8 位(_hash_field_name,:1107) |
| 用户标识 | 不存 |
| 查询串 | _anonymize_query_pattern(:1111)只保留 field:value 这类结构谓词,渲染成 field:*;匹配不到结构就返回 None |
最后一条尤其关键:注释说得很清楚,原样留下自由文本 prompt 就是在把 prompt 逐字持久化,违反隐私契约,而且可能产生几 MB 的 key。
存储也是有界的: 默认最多 10000 条 pattern、128MB(:103-104),超了就剪枝。TOIN 是诊断性的学习存储,不是归档。
4.3 联邦效应:跨实例合并,不共享原始数据
import_patterns(:1271)吃另一个 Headroom 实例导出的 pattern 表,按同一个三元组 key 合并。因为存的全是哈希和统计量,跨实例合并不需要交换任何原始数据。
合并按 sample_size 加权(_merge_patterns,:1323)。新来源的实例 id 记进 _seen_instance_hashes(上限 100),但 user_count 即使超了上限也照涨——因为置信度公式里有一段"多用户加成"(_calculate_confidence,:1091,上限 0.3,总体封顶 0.95)。这就是网络效应的落点:用的人越多,同一个结构哈希上的 optimal_* 越可信。
4.4 离线发布:一个 CLI,一个 TOML
python -m headroom.cli.toin_publish(headroom/cli/toin_publish.py:164 publish)把盘上的 TOIN 存储汇总成 recommendations.toml,Rust 代理启动时读一次。
模块头把"为什么是 CLI 而不是库钩子"讲明白了(:23-29):按请求改动正是 PR-B5 要斩断的危险耦合。发布只发生在部署边界,永远不在请求里面。
| 设计点 | 做法 |
|---|---|
| 门槛 | 一个切片至少 50 次压缩事件才出一行(DEFAULT_MIN_OBSERVATIONS_TO_PUBLISH,toin.py:98),低于此就是噪音 |
| 计数字段 | 用 total_compressions,不用 observations——后者数的是已退役的 get_recommendation() 调用,现在恒为 0(toin_publish.py:128-133) |
| 输出稳定 | 行按 (auth_mode, model_family, structure_hash) 排序,让 diff 干净(_eligible_rows,:123) |
| 溯源 | 文件头写死一段注释,告诉运维这文件哪来的、别手改(TOML_HEADER,:64) |
4.5 learn:从会话日志到 CLAUDE.local.md
另一条线读的是各家 agent 自己落盘的日志。管线是四段:
Plugin 扫描 → digest 构建 → 一次 LLM 调用 → marker 块写回
(各家日志格式) (含循环检测) (LiteLLM 或 CLI) (CLAUDE.local.md 等)
扫描端按 agent 插件化。 LearnPlugin(headroom/learn/base.py:35)把"识别 / 扫描 / 写回"打成一个单元,插件自动从 headroom.learn.plugins.* 发现,外部包可以走 entry point:
| 插件 | 读哪儿 | 格式 |
|---|---|---|
ClaudeCodePlugin(plugins/claude.py:27) | ~/.claude/projects/ | JSONL |
CodexPlugin(plugins/codex.py:25) | ~/.codex/sessions/ | JSON / JSONL |
GeminiPlugin(plugins/gemini.py:26) | ~/.gemini/tmp/<hash>/chats/ | JSON / JSONL |
GrokPlugin(plugins/grok.py:21) | ~/.grok/sessions/<ws>/<id>/updates.jsonl | JSONL |
OpenCodePlugin(plugins/opencode.py:60) | ~/.local/share/opencode/opencode.db | SQLite 两张表(message / part) |
headroom/learn/scanner.py 现在只是向后兼容的再导出(:1-8),真正的实现都搬去 plugins 了。老的 from headroom.learn.scanner import ClaudeCodeScanner 仍然能用。
所有插件都把日志归一化成同一组模型(headroom/learn/models.py):ToolCall(:45)、SessionEvent(:80)、SessionData(:105)。分析器只认这套,不认任何一家的原始格式。
分析端是一次 LLM 调用,不是一堆正则。 SessionAnalyzer.analyze(headroom/learn/analyzer.py:169)的模块头写着:没 有正则模式、没有静态回看窗口、没有硬编码启发式(:1-7)。
后端有两条通道,这一点对订阅制用户很关键:
| 通道 | 触发条件 | 内容 |
|---|---|---|
| LiteLLM | 环境里有 API key | 按 _MODEL_DEFAULTS(:44)顺序探:ANTHROPIC → OPENAI → GEMINI |
| CLI 子进程 | 没有 API key,但装了 CLI | _CLI_BACKENDS(:56):claude -p --output-format stream-json、gemini -p、codex exec |
claude 那条特意用流式 JSON 输出,为的是能分辨"还在干活"和"卡死了",从而执行空闲超时(60 秒无输出就杀)而不是只有一个 300 秒的墙钟上限(:65-73)。
digest 由 _build_digest(:254)构建,预算 80000 token(:50)。顺序是刻意的:循环放最前面,因为后面的逐会话事件流是会被预算截掉的;然后是"先前已学到的模式"(从现有 marker 块里读回来),让 LLM 拿到当前基线。
系统 prompt(:417)里有一条很实用的约定:重新给出的小节会整节替换旧的,所以模型必须输出该节的完整更新版(保留仍然成立的条目、修订、只在有明确新证据时才删);没有重新给出的小节由 writer 自动保留,不要为了原样回显而浪费输出 token。
4.6 循环检测:唯一按重复次数放大的浪费
headroom/learn/loops.py:1-23 论证得很清楚:循环是 learn 最值得抓的模式,因为它的浪费随重复次数线性增长,而不是一次性成本。两种形态:
| 形态 | 长什么样 | 为什么容易漏 |
|---|---|---|
| 错误循环 | 同一个调用反复失败(错路径读 N 次) | 好抓,is_error 就在那 |
| 重取循环 | grep foo | head -50 输出被截,agent 换个变体再来一次(head -100、换 offset) | 每次都成功,只看失败率的分析完全看不见 |
抓法是把变体折叠成规范签名(_canonical_signature,:88):shell 命令先剥掉分页 / 限量片段(_PAGINATION_PATTERNS,:47),再把剩下的裸整数统一替换成 N。这样 head -50 和 head -100 折叠成一条。
detect_loops(:109)在每个会话内部分组(循环是会话内现象,两个无关会话里出现同一条命令不算循环),达到 3 次(DEFAULT_MIN_OCCURRENCES,:37)才算。三次是"又来一次"和"失败重试一次"之间最小的分界。
浪费量的算法两种形态不一样,而且理由讲得通:
- 错误循环:全部计入。带着先验知识,这些调用一次都不该发生。
- 重取循环:第一次是正当工作,只算后面 N-1 次。
关键在于这个数是"实测下界",不是 LLM 猜的。 apply_loop_weighting(:196)拿它去顶掉 LLM 给的 estimated_tokens_saved:凡是文本与某个循环签名有过半 token 重合的建议,其估值被抬到该循环的实测浪费量,并打上 is_loop_guardrail。因为实测浪费聚合了很多次重复,这一步可靠地把循环护栏顶到一次性规则之上,而不需要指望 LLM 自己权重给对。
4.7 写回:marker 块与"别污染队友"
headroom/learn/writer.py 用和记忆 writer 同构的 marker 机制(_MARKER_START/END,:22-23),_merge_into_file(:184)整块替换。
ClaudeCodeWriter(:212)有个体现分寸感的默认值:项目级学习成果默认写 CLAUDE.local.md 而不是 CLAUDE.md(_resolve_context_path,:273)。理由写在类 docstring 里(issue #1072):按 Claude Code 的约定,CLAUDE.md 是团队共享、进 git 的;CLAUDE.local.md 是个人的、默认被 gitignore。学出来的东西天然是个人的——里面全是机器特定的绝对路径和工具探测副产物,写进共享文件就是给队友添堵。用 --target 可以显式覆盖。
老版本留在 CLAUDE.md 里的块会被迁移过去然后从共享文件里剥掉(_migrate_legacy_block)。
5. 巧妙之处(可以直接搬走的)
-
"改请求"是代理管输出的唯一合 法手段,而且够用。 五档常量文本 + 只降不建的 effort 路由,两根杠杆撑起整条输出侧省法(
output_shaper.py:1-40)。 -
每条安全规则都绑定一个具体故障,不是"要小心"。 "绝不注入 effort" 对应"不支持的模型 400";"绝不切 thinking.type" 对应"带 thinking block 时 400 + 炸缓存层"。规则写成这样才不会在重构中被人当成冗余删掉。
-
分类只看结构,不看内容。
classify_turn只读 block 类型和is_error;Responses 侧连错误嗅探都只读 JSON 字段不读散文(output_shaper.py:411)。内容启发式会让同样的输入走不同路径,那就是请求路径的不确定性。 -
反事实就老老实实叫估计。 只有 A/B holdout 出来的数配叫 "measured",合成对照永远标 "estimated",两个都带 95% 置信带;还额外提供一个不需要对照组的直接浪费指标
echo_ratio(output_savings.py:1-36)。 -
隔离靠物理,不靠过滤条件。 每个项目一个 DB 文件,串味在结构上不可能;解析不出项目时fail-closed(什么都不注入)而不是池化到全局(
storage_router.py:1-6,:292)。 -
sanitize 之后必须缀 digest。 任何"把用户串压成文件名"的函数都会制造碰撞,而碰撞在多租户存储里就等于数据泄漏(
storage_router.py:170-178)。 -
内容稳定的贴热区尾,内容易变的贴活区尾。 steering(每轮一样)贴 system 尾;记忆(每轮不同)贴 user 消息尾。两者共用一条缓存纪律,方向相反。
-
学习必须离线,请求路径必须确定。 TOIN 观察-only 的契约、
toin publish只在部署边界跑,都是同一件事的两面(toin.py:12-20)。 -
实测的数据压过 LLM 猜的数。 循环浪费是从真实输出字节量算出来的下界,直接顶掉 LLM 给的估值(
loops.py:196)。 -
写回别人的文件时,只碰自己的 marker 块,并且默认写个人文件。
CLAUDE.local.md而非CLAUDE.md(learn/writer.py:212-224)。
6. 边界与局限
输出侧塑形
- 它是建议,不是约束。指令块只是 prompt,模型可以不听——所以省量必须靠统计估计,没法逐条核对。
- Chat Completions 路径故意不做 effort 路由(
output_shaper.py:366-374):route_effort写的是 Anthropic 形状的配置,chat/completions 没有可移植的等价物,所以那条路径只跑 verbosity 一根杠杆。 text.verbosity是唯一允许"无中生有"的字段,并且只对gpt-5*开口——其它模型宁可什么都不做。- 改档位文本 = 一次缓存失效事件,不是无害的文案润色。
记忆
- 注入块在线上和用户消息长得一样。READ-ONLY 框定是文字上的防线,不是结构上的——同一类误读还可能复发。
- 默认 fail-closed 意味着:不带
x-headroom-project-id/x-headroom-cwd头、system prompt 里也没有 cwd 行的客户端,拿不到任何记忆,而且是静默跳过(只有一条 warning 日志)。 - 冒泡是复制而不是移动,同一条内容会在两个作用域各存一份。
MemoryQuery明确不截断输入,超出 embedder 窗口的部分"是模型自己的问题"——mean-pool 还是截断由 embedder 决定,Headroom 不管。- 相似度地板、条数、token 三个旋钮都是启发式;token 数用的是 4 字符/token 的粗估(
memory_injection.py:31),真实计费以上游 tokenizer 为准。
学习闭环
- TOIN 的
auth_mode/model_family检测还没接通,当前大量数据落在"unknown"切片(toin.py:85-90,注释说等 PR-F3)。 recommendations.toml只在代理启动时读一次,所以学习成果的生效延迟 = 一次重启。- learn 的分析依赖一次外部 LLM 调用;失败时
analyze只记一条 warning 然后返回只有统计、没有建议的结果(analyzer.py:203-207)。 - 循环检测是会话内的:跨会话反复犯的同一个错,不算循环。
memory/tracker.py名字里的 memory 指的是 RAM,不是记忆条目(见 §3.10)。
7. 横向对比:和前五章的关系
| 关切 | 前五章的做法 | 本章的做法 |
|---|---|---|
| 省 token | 压小送进去的内容(01/02) | 让模型少吐、让下次少问 |
| 有损了怎么办 | CCR:原文不删,模型能要回来(03) | 记忆同理——supersede 不删旧条,留链可查 |
| 缓存安全 | 不删历史消息、只动活区(04) | steering 贴热区尾且字节稳定;记忆贴活区尾 |
| 接入方式 | 代理 / wrap,零改代码(05) | 三条路都挂在同一批 handler 上,rollout 门控 + 同一个 bypass header |
8. 代码地图(导航索引)
(a) 输出侧塑形
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 两根杠杆 + 三条铁律(先读这个) | headroom/proxy/output_shaper.py | 模块 docstring、shape_request、shape_responses_request、shape_openai_chat_request |
| 设置与档位解析 | headroom/proxy/output_shaper.py | OutputShaperSettings、resolve_verbosity_level |
| effort 路由 | headroom/proxy/output_shaper.py | route_effort、route_openai_reasoning_effort、route_openai_text_verbosity、route_responses_effort |
| Responses 轮次分类 | headroom/proxy/output_shaper.py | classify_responses_turn、_responses_tool_output_is_error、_RESPONSES_EFFORT_RANK |
| 纯轮次分类 | headroom/proxy/output_turn_policy.py | TurnKind、classify_turn、classify_openai_responses_input |
| 档位文本(常量,改即失效缓存) | headroom/proxy/output_verbosity_policy.py | VERBOSITY_LEVELS、STEERING_SENTINEL、steering_text、replace_or_append_steering_block |
| 三种协议的注入器 | headroom/proxy/output_steering.py | apply_verbosity_steering、apply_openai_chat_verbosity_steering、apply_openai_responses_verbosity_steering |
| 纯 effort 决策 | headroom/proxy/output_effort_policy.py | EFFORT_RANK、lower_effort_value、clamp_legacy_thinking_budget、can_create_openai_text_verbosity |
| AIMD 自适应控制器 | headroom/proxy/verbosity_controller.py | VerbosityController.observe、Signal、ControllerState |
| 反事实计量 | headroom/proxy/output_savings.py | SavingsLedger.estimate_from_baseline / estimate_from_holdout、BaselineModel.lookup、SavingsRecorder、echo_ratio |
| 分层与 holdout 分组 | headroom/proxy/output_savings_policy.py | stratum_key、assign_arm、stratum_label、parse_stratum_label |
| 学档位 + 建基线 | headroom/learn/verbosity.py | extract_signals、recommend_level、analyze |
| CLI 展示 | headroom/cli/output_savings.py | output_savings |
| 调用点 | headroom/proxy/handlers/anthropic.py、headroom/proxy/handlers/openai.py | shape_request / shape_responses_request / shape_openai_chat_request 调用块 |
(b) 记忆
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 协调器 | headroom/memory/core.py | HierarchicalMemory、add、search、_maybe_bubble |
| 数据模型与作用域 | headroom/memory/models.py | Memory、ScopeLevel、scope_level、normalize_entity_refs |
| 选型配置 | headroom/memory/config.py | MemoryConfig、VectorBackend、EmbedderBackend |
| 组件工厂 + embedder 进程缓存 | headroom/memory/factory.py | create_memory_system、_EMBEDDER_CACHE、_load_external_backend |
| 单次抽取的 prompt / 工具 | headroom/memory/extraction.py | FACT_EXTRACTION_PROMPT、ENTITY_EXTRACTION_PROMPT、get_extraction_tools |
| 项目隔离(重点) | headroom/memory/storage_router.py | ProjectResolver.resolve、_identity_from_cwd、BackendRouter._resolve_scope、MemoryStorageMode |
| 存储适配器 | headroom/memory/adapters/ | SQLiteMemoryStore、SQLiteVectorIndex、HNSWVectorIndex、FTS5TextIndex、InMemoryGraphStore、LRUMemoryCache |
| 后端 | headroom/memory/backends/ | LocalBackend、LocalBackendConfig、Mem0Backend |
| 流量学习 | headroom/memory/traffic_learner.py | TrafficLearner.on_tool_result、_accumulate、_normalize_hash_key、PatternCategory |
| 进程内存观测(名字撞车) | headroom/memory/tracker.py | MemoryTracker、ComponentStats |
| 写回原生文件 | headroom/memory/writers/ | AgentWriter.export、ClaudeCodeMemoryWriter、CodexMemoryWriter、CursorMemoryWriter、GenericMemoryWriter |
| 代理侧注入 | headroom/proxy/memory_handler.py | MemoryMode、search_and_format_context、_append_to_latest_user_tail |
| 检索三件套 | headroom/proxy/memory_query.py、memory_ranker.py、memory_injection.py | MemoryQuery.from_messages、RecencyBoostRanker.rank、MemoryInjectionBudget.apply_to_text |
(c) 学习闭环
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 观察-only 契约 + 聚合键 + 隐私 | headroom/telemetry/toin.py | 模块 docstring、_make_pattern_key、ToolPattern、record_compression、get_recommendation(已退役)、_hash_field_name、_anonymize_query_pattern |
| 联邦合并 | headroom/telemetry/toin.py | import_patterns、_merge_patterns、_calculate_confidence |
| 离线发布 | headroom/cli/toin_publish.py | publish、_eligible_rows、_select_strategy、TOML_HEADER |
| 归一化模型 | headroom/learn/models.py | ToolCall、SessionData、Recommendation、RecommendationTarget |
| 扫描(兼容再导出) | headroom/learn/scanner.py | ClaudeCodeScanner(= ClaudeCodePlugin)、is_error_content |
| 各家插件 | headroom/learn/plugins/ | ClaudeCodePlugin、CodexPlugin、GeminiPlugin、GrokPlugin、OpenCodePlugin |
| 分析与 digest | headroom/learn/analyzer.py | SessionAnalyzer.analyze、_build_digest、_SYSTEM_PROMPT、_CLI_BACKENDS、FailureAnalyzer |
| 循环检测与加权 | headroom/learn/loops.py | detect_loops、_canonical_signature、apply_loop_weighting、LoopPattern |
| marker 块写回 | headroom/learn/writer.py | ClaudeCodeWriter._resolve_context_path、_merge_into_file、extract_marker_block |