跳到主要内容

数据截至 (上游 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 压的是进去的东西,这个模块是请求侧针对产出的第一根杠杆,而杠杆只有两根:

  1. verbosity steering — 一段确定性的指令块,追加到 system prompt 的尾部
  2. 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 Completionsmessages 里最后一条 system / developerapply_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_tokensthinking.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 标记
1HEADROOM_VERBOSITY_LEVEL 环境变量env
2AIMD 控制器状态 verbosity_controller.json(需开 HEADROOM_VERBOSITY_AUTOTUNE)controller
3learn --verbosity 学出来的 verbosity.jsonlearned
4settings 默认值(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_beforetokens_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 位十六进制做确定性分配)。两个理由刚好指向同一个做法:

  1. 一个会话里混着塑形轮和不塑形轮,对比会被污染;
  2. 中途改 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。

协议调用点调的是
Anthropicheadroom/proxy/handlers/anthropic.py:3121-3162shape_request
OpenAI Responsesheadroom/proxy/handlers/openai.py:746-747shape_responses_request
OpenAI Chatheadroom/proxy/handlers/openai.py:4385-4388shape_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):

部件默认 / 可选实现文件
storeSQLite / 外部 entry pointadapters/sqlite.py:47 SQLiteMemoryStore
vectorAUTO → sqlite-vec 优先,退 HNSWadapters/sqlite_vector.py:180adapters/hnsw.py:188
textFTS5(BM25 + Porter 词干)adapters/fts5.py:38 FTS5TextIndex
embedderLOCAL / ONNX / OPENAI / OLLAMAfactory.py:144 _create_embedder
cache线程安全 LRUadapters/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)的顺序是固定的六步:

  1. Memory 对象(id 自动 uuid4);
  2. auto_embed 时向量化;
  3. 写 store;
  4. 有 embedding 就进向量索引;
  5. 进全文索引;
  6. 进缓存,然后判断要不要冒泡

冒泡(bubbling)是这套模型里最像"人"的一步。 _maybe_bubble(core.py:852):重要度 ≥ bubble_threshold(默认 0.7)的记忆,会在 USER复制一份,原件留在原层。复制件带 promoted_frompromotion_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/apiacme 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 32>&1),再截到第一个 |&& 之前(_normalize_bash_for_hash,:192)。所以 grep foo | head -50grep 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.md2000Claude 只保证前 200 行常驻上下文(≈2K token)
CodexMemoryWriter(writers/codex_writer.py:18)项目根 AGENTS.md3000AGENTS.md 沿目录树向上合并
CursorMemoryWriter(writers/cursor_writer.py:27).cursor/rules/*.mdc3000需要 YAML frontmatter,所以它自己覆写了 export
GenericMemoryWriter(writers/generic_writer.py:17)HEADROOM_MEMORY.md3000任何读 markdown 的 agent 的兜底

3.10 一个命名撞车,得说清楚

headroom/memory/tracker.py 不是记忆条目的追踪器,它追的是进程内存占用(RSS / 各组件字节数 / 预算百分比,ComponentStats:26MemoryTracker:167,可选依赖 psutil)。同一个词 memory 在这个包里承担了两个意思。读代码时别把它当成记忆系统的一部分——它是给"HNSW 索引吃了多少 MB"这类问题用的可观测性设施。


4. (c) 学习闭环 — 把这次的坑变成下次的规则

闭环有两条独立的线,数据源和产物都不一样:

线数据来自学出来的东西谁消费
TOIN代理运行时的压缩 / 取回事件recommendations.tomlRust 代理启动时读一次
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.jsonlJSONL
OpenCodePlugin(plugins/opencode.py:60)~/.local/share/opencode/opencode.dbSQLite 两张表(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-jsongemini -pcodex 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 -50head -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. 巧妙之处(可以直接搬走的)

  1. "改请求"是代理管输出的唯一合法手段,而且够用。 五档常量文本 + 只降不建的 effort 路由,两根杠杆撑起整条输出侧省法(output_shaper.py:1-40)。

  2. 每条安全规则都绑定一个具体故障,不是"要小心"。 "绝不注入 effort" 对应"不支持的模型 400";"绝不切 thinking.type" 对应"带 thinking block 时 400 + 炸缓存层"。规则写成这样才不会在重构中被人当成冗余删掉。

  3. 分类只看结构,不看内容。 classify_turn 只读 block 类型和 is_error;Responses 侧连错误嗅探都只读 JSON 字段不读散文(output_shaper.py:411)。内容启发式会让同样的输入走不同路径,那就是请求路径的不确定性。

  4. 反事实就老老实实叫估计。 只有 A/B holdout 出来的数配叫 "measured",合成对照永远标 "estimated",两个都带 95% 置信带;还额外提供一个不需要对照组的直接浪费指标 echo_ratio(output_savings.py:1-36)。

  5. 隔离靠物理,不靠过滤条件。 每个项目一个 DB 文件,串味在结构上不可能;解析不出项目时fail-closed(什么都不注入)而不是池化到全局(storage_router.py:1-6,:292)。

  6. sanitize 之后必须缀 digest。 任何"把用户串压成文件名"的函数都会制造碰撞,而碰撞在多租户存储里就等于数据泄漏(storage_router.py:170-178)。

  7. 内容稳定的贴热区尾,内容易变的贴活区尾。 steering(每轮一样)贴 system 尾;记忆(每轮不同)贴 user 消息尾。两者共用一条缓存纪律,方向相反。

  8. 学习必须离线,请求路径必须确定。 TOIN 观察-only 的契约、toin publish 只在部署边界跑,都是同一件事的两面(toin.py:12-20)。

  9. 实测的数据压过 LLM 猜的数。 循环浪费是从真实输出字节量算出来的下界,直接顶掉 LLM 给的估值(loops.py:196)。

  10. 写回别人的文件时,只碰自己的 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_requestshape_responses_requestshape_openai_chat_request
设置与档位解析headroom/proxy/output_shaper.pyOutputShaperSettingsresolve_verbosity_level
effort 路由headroom/proxy/output_shaper.pyroute_effortroute_openai_reasoning_effortroute_openai_text_verbosityroute_responses_effort
Responses 轮次分类headroom/proxy/output_shaper.pyclassify_responses_turn_responses_tool_output_is_error_RESPONSES_EFFORT_RANK
纯轮次分类headroom/proxy/output_turn_policy.pyTurnKindclassify_turnclassify_openai_responses_input
档位文本(常量,改即失效缓存)headroom/proxy/output_verbosity_policy.pyVERBOSITY_LEVELSSTEERING_SENTINELsteering_textreplace_or_append_steering_block
三种协议的注入器headroom/proxy/output_steering.pyapply_verbosity_steeringapply_openai_chat_verbosity_steeringapply_openai_responses_verbosity_steering
纯 effort 决策headroom/proxy/output_effort_policy.pyEFFORT_RANKlower_effort_valueclamp_legacy_thinking_budgetcan_create_openai_text_verbosity
AIMD 自适应控制器headroom/proxy/verbosity_controller.pyVerbosityController.observeSignalControllerState
反事实计量headroom/proxy/output_savings.pySavingsLedger.estimate_from_baseline / estimate_from_holdoutBaselineModel.lookupSavingsRecorderecho_ratio
分层与 holdout 分组headroom/proxy/output_savings_policy.pystratum_keyassign_armstratum_labelparse_stratum_label
学档位 + 建基线headroom/learn/verbosity.pyextract_signalsrecommend_levelanalyze
CLI 展示headroom/cli/output_savings.pyoutput_savings
调用点headroom/proxy/handlers/anthropic.pyheadroom/proxy/handlers/openai.pyshape_request / shape_responses_request / shape_openai_chat_request 调用块

(b) 记忆

主题文件关键符号
协调器headroom/memory/core.pyHierarchicalMemoryaddsearch_maybe_bubble
数据模型与作用域headroom/memory/models.pyMemoryScopeLevelscope_levelnormalize_entity_refs
选型配置headroom/memory/config.pyMemoryConfigVectorBackendEmbedderBackend
组件工厂 + embedder 进程缓存headroom/memory/factory.pycreate_memory_system_EMBEDDER_CACHE_load_external_backend
单次抽取的 prompt / 工具headroom/memory/extraction.pyFACT_EXTRACTION_PROMPTENTITY_EXTRACTION_PROMPTget_extraction_tools
项目隔离(重点)headroom/memory/storage_router.pyProjectResolver.resolve_identity_from_cwdBackendRouter._resolve_scopeMemoryStorageMode
存储适配器headroom/memory/adapters/SQLiteMemoryStoreSQLiteVectorIndexHNSWVectorIndexFTS5TextIndexInMemoryGraphStoreLRUMemoryCache
后端headroom/memory/backends/LocalBackendLocalBackendConfigMem0Backend
流量学习headroom/memory/traffic_learner.pyTrafficLearner.on_tool_result_accumulate_normalize_hash_keyPatternCategory
进程内存观测(名字撞车)headroom/memory/tracker.pyMemoryTrackerComponentStats
写回原生文件headroom/memory/writers/AgentWriter.exportClaudeCodeMemoryWriterCodexMemoryWriterCursorMemoryWriterGenericMemoryWriter
代理侧注入headroom/proxy/memory_handler.pyMemoryModesearch_and_format_context_append_to_latest_user_tail
检索三件套headroom/proxy/memory_query.pymemory_ranker.pymemory_injection.pyMemoryQuery.from_messagesRecencyBoostRanker.rankMemoryInjectionBudget.apply_to_text

(c) 学习闭环

主题文件关键符号
观察-only 契约 + 聚合键 + 隐私headroom/telemetry/toin.py模块 docstring、_make_pattern_keyToolPatternrecord_compressionget_recommendation(已退役)、_hash_field_name_anonymize_query_pattern
联邦合并headroom/telemetry/toin.pyimport_patterns_merge_patterns_calculate_confidence
离线发布headroom/cli/toin_publish.pypublish_eligible_rows_select_strategyTOML_HEADER
归一化模型headroom/learn/models.pyToolCallSessionDataRecommendationRecommendationTarget
扫描(兼容再导出)headroom/learn/scanner.pyClaudeCodeScanner(= ClaudeCodePlugin)、is_error_content
各家插件headroom/learn/plugins/ClaudeCodePluginCodexPluginGeminiPluginGrokPluginOpenCodePlugin
分析与 digestheadroom/learn/analyzer.pySessionAnalyzer.analyze_build_digest_SYSTEM_PROMPT_CLI_BACKENDSFailureAnalyzer
循环检测与加权headroom/learn/loops.pydetect_loops_canonical_signatureapply_loop_weightingLoopPattern
marker 块写回headroom/learn/writer.pyClaudeCodeWriter._resolve_context_path_merge_into_fileextract_marker_block