跳到主要内容

数据截至 (上游 commit b77d61291399)

活区与缓存安全 — 为什么「删历史消息」是错的

30 秒导读: 几乎所有「上下文压缩」项目的第一直觉是"历史太长了,删几条老消息"。Headroom 在自己的代码库里把这个直觉判成了架构级错误:老消息躺在 provider 的 prompt cache 里,读它们只要 0.1 倍价钱;你删一条老消息,后面所有缓存全部作废,要按 1.25 倍重写一遍。本章讲 Headroom 怎么把「能动的字节」缩小成一个叫 活区(live zone) 的窄窗口,以及围绕这个窗口建的一整套安全设施。


1. 先算一笔账:删历史为什么是亏的

先不谈代码,谈钱。Anthropic 的 prompt cache 对同一段前缀有三种计价:

token 类型相对普通输入 token 的价钱代码里的常量
普通输入(没进缓存)1.0——
缓存写入 cache_creation(5 分钟档)1.25CACHE_WRITE_MULTIPLIER
缓存写入(1 小时档)2.0CACHE_WRITE_MULTIPLIER_1H
缓存读取 cache_read0.1CACHE_READ_MULTIPLIER

三个常量都写死在 crates/headroom-core/src/compression_policy.rs:136-144(CACHE_WRITE_MULTIPLIER / CACHE_WRITE_MULTIPLIER_1H / CACHE_READ_MULTIPLIER)。

关键在于 prompt cache 命中要求字节完全相同,而且是「前缀」语义:缓存命中的是从头开始一段连续的字节。你在第 K 条消息上改了一个字节,第 K 条之后的所有缓存全废。

于是"删一条老消息省 2000 token"的真实收支是:

省下: 2000 token 不再被读 → 2000 × 0.1 = 200
赔上: 它后面 50000 token 的缓存作废,
下一轮要重新写一遍 → 50000 × (1.25 − 0.1) = 57500

净亏两百多倍。项目自己在 REALIGNMENT/00-overview.md 的开篇把旧架构的心智模型直接判死:旧的 IntelligentContextManager 给每条消息打分、按分数丢消息,还把 frozen_message_count 硬编码成 0——等于每次压缩都从第 0 条开始删,"为每个触发它的客户炸掉 Anthropic prompt cache"。整改的结论一句话:passthrough is sacred(不动字节是神圣的),只压活区

提醒:上面这段是作者自述的改造史,可作背景;本章所有结论以当前源码为准。


2. 顶层全景:一次请求里,哪些字节允许被动

这张图从上到下就是 Anthropic /v1/messages 请求体的规范顺序。读法:越靠上越"冷"(在缓存里、绝不能动),越靠下越"热"(模型马上要读它作答)。

┌──────────────────────────────────────────────┐
│ system 提示词 │ ← 缓存热区
├──────────────────────────────────────────────┤ 一个字节都不动
│ tools[] 工具定义 │ (E1/E2 的确定性排序除外)
├──────────────────────────────────────────────┤
│ messages[0 .. frozen_message_count-1] │ ← 地板以下 = 冻结区
│ (客户自己打了 cache_control 标记的前缀) │
├──────────────────────────────────────────────┤
│ 中间的历史轮次 / 最新 assistant 消息 │ ← 仍然不动
├══════════════════════════════════════════════┤
│ 最新的 user 消息 │ ★ 活区(唯一可改)
│ ├ tool_result 块 → 可压 │
│ ├ text 块 → 可压 │
│ └ tool_use/thinking/compaction → 排除 │
└──────────────────────────────────────────────┘

术语先钉死,后面不换词:

  • 缓存热区(cache hot zone) —— 已经进了 provider 缓存、字节必须原样的部分。
  • 活区(live zone) —— 模型将要"针对它"作答的那些块,改它们不会让已缓存的前缀失效。
  • 地板 frozen_message_count —— 活区的下界,消息下标。
  • 天花板 —— 最新那条 role == "user" 的消息。

3. 活区怎么划出来:一个地板、一个天花板、一张黑名单

3.1 地板:客户自己的 cache_control 标记说了算

Headroom 不猜哪些消息被缓存了,它读客户自己打的标记。compute_frozen_count(crates/headroom-core/src/cache_control.rs:109)走一遍 messages[i].content[*].cache_control,取最高的那个下标 i,返回 i + 1:

// cache_control.rs:132
highest_message_index.map(|i| i + 1).unwrap_or(0)

+1 让这个地板是"排他"的:messages[i] 本身也在缓存前缀里,所以也冻结。没有任何标记时返回 0——整个 messages 都可以进活区候选。

一个容易忽略的细节:systemtools[*] 上的标记不抬高这个地板(cache_control.rs:120-126walk_system / walk_tools 只做日志和 TTL 顺序检查)。因为那两个字段本来就是无条件热区,跟消息下标无关。

3.2 天花板:最新的 user 消息,而且必须在地板之上

find_latest_user_message_index(live_zone.rs:944)从尾巴往前找第一条 role == "user";一旦扫到低于地板的下标就直接返回 None:

// live_zone.rs:946-953
for (offset, msg) in messages.iter().enumerate().rev() {
if offset < start { return None; }
if msg.get("role").and_then(Value::as_str) == Some("user") { return Some(offset); }
}

返回 None 意味着"最新 user 消息落在缓存热区里"——那就整包放行,一个字节不改。

注意天花板不包括最新的 assistant 消息。模块注释把理由写得很直白:那条 assistant 消息是下一次回复要接着往下写的东西,同样属于热区(live_zone.rs:40-42)。

3.3 黑名单:活区内部还有几类块不能碰

即便在活区那条消息里面,也不是所有块都能改。HOT_ZONE_BLOCK_TYPES(live_zone.rs:515-522)明确列了四种:

块类型为什么排除
tool_use模型自己发的调用参数,属于缓存热区
thinking带签名(signature),改了签名验不过
redacted_thinking加密内容,改一个字节就废
compactionAnthropic 的压缩项,注入后跟 tool_use 一样黏在缓存上

注释还特别说明"显式列举、不做字符串前缀匹配",目的是让这个安全面可以被 grep 到。反过来说,tool_resulttext 是允许压的——而工具输出恰恰是吃掉 token 预算的大头。

被排除的块会以 BlockAction::Excluded { reason: ExclusionReason::HotZoneBlockType } 记进 manifest。顺带一个诚实的观察:ExclusionReason 声明了三个变体(live_zone.rs:347-356),但全仓库只有 HotZoneBlockType 真的被构造过——BelowFrozenFloorAboveLiveZone 对应的情况在规划阶段之前就被结构性地排除了,从来走不到记账这一步。


4. 字节区间外科手术:改一个块,别的字节"复制"过去

4.1 先建立直觉

天真做法是:JSON 反序列化 → 改对象 → 重新序列化。这在缓存语义下是灾难——重新序列化会改动空白、键序、数字格式,而 provider 缓存比的是字节。模块注释把这条写成了硬约束(live_zone.rs:81-86)。

Headroom 的做法是:先算出每个要改的块在原始字节缓冲区里的区间 [start, end),然后拼:

out = body[..block_start] || replacement || body[block_end..]

区间外的字节是从输入里逐字节复制过去的,连解析都不重新做。

4.2 怎么拿到字节偏移

serde_json 不直接给偏移。Headroom 用了一个漂亮的指针小技巧:&RawValue 借用的切片就指向输入缓冲区本身,于是用指针相减就能还原偏移。

// live_zone.rs:1229 bytes_offset_of
let parent_start = parent.as_ptr() as usize;
let child_start = child.as_ptr() as usize;
if child_start < parent_start || child_start + child.len() > parent_end { return None; }
Some(child_start - parent_start)

规划器 plan_block_replacements(live_zone.rs:1029)用它逐层往下算:body → 目标 message → content → 每个 block → 块内的字符串字段,把偏移一路累加成"相对整包 body 的绝对区间"。BodyView / MessageView / BlockHeader(live_zone.rs:963-981)三个结构体故意只声明用得到的那几个字段,别的部分根本不解析

4.3 拼装

apply_replacements(live_zone.rs:1249)按起点排序后一次遍历拼出新 buffer:

// live_zone.rs:1257-1263
let mut cursor = 0usize;
for r in replacements.iter() {
out.extend_from_slice(&original[cursor..r.range.0]); // 原样复制
out.extend_from_slice(&r.replacement); // 换掉这一段
cursor = r.range.1;
}
out.extend_from_slice(&original[cursor..]); // 尾巴原样复制

这个不变量在 CI 里被 byte_fidelity_outside_compressed_block 钉住(crates/headroom-core/tests/live_zone_dispatch.rs:346),断言压缩块前缀和后缀的 SHA-256 与输入完全一致。

拼完之后代码故意不做回环校验(不重新 from_slice 验一次 JSON),理由写在 live_zone.rs:780-784:每个替换都是"JSON 字符串槽换成另一个 JSON 字符串",类型纪律保证合法,重新解析会让热路径的解析成本翻倍。


5. 三套 walker 为什么不能共用

Headroom 有三个入口函数,长得很像但各写各的:

入口函数端点活区定义源码
compress_anthropic_live_zone/v1/messages地板之上最新的 user 消息里的各个块live_zone.rs:618
compress_openai_chat_live_zone/v1/chat/completions最新的 role:"tool" 消息 最新的 role:"user" 消息(两条,不是一段连续区间)live_zone.rs:1875
compress_openai_responses_live_zone/v1/responses当前帧里所有 *_call_outputlive_zone.rs:2332

不能共用一个 walker,原因是三家的请求形状在承重的位置上分叉:

  • Anthropic 把工具结果嵌在 user 消息的 content 数组里;OpenAI Chat 把它放在独立的 role:"tool" 消息里(live_zone.rs:1846-1851)。
  • OpenAI Responses 根本不叫 messages,叫 input,里面是显式类型的 item:function_call_outputreasoningapply_patch_call_output……(live_zone.rs:2271-2290)。
  • Responses 还有个 Anthropic 没有的特性:Codex 常在一帧里并行塞好几个工具结果,它们全都是下一轮的活输入,所以这里不是"取最后一个",而是全收(live_zone.rs:2358-2390)。

那共用的是什么?结果类型和后端LiveZoneOutcome / BlockAction / CompressionManifest 被刻意设计成 provider 无关(live_zone.rs:486 / :265 / :361),而单块压缩逻辑全都落到同一个 compress_one_block(live_zone.rs:822)。走法各写各的,记账和压缩共用一套——这是这个模块最值得抄的分层。

Responses 那条路还多两个细节:

  1. CCR 的自我保护。 扫一遍 input,把名字是 headroom_retrievefunction_callcall_id 收进 HashSet,对应的输出项跳过压缩(live_zone.rs:2362-2385)。模型好不容易要回来的原文,再被压一次就白要了。CCR 的完整机制见 03-ccr.md
  2. 额外的 512 字节地板。 输出项要先过 RESPONSES_OUTPUT_MIN_BYTES(live_zone.rs:2299),才轮到按内容类型的阈值(live_zone.rs:2443-2455)。

6. 单块压缩的四道闸

compress_one_block(live_zone.rs:822)是三条路共用的收口。四道闸依次是:

块内容

├─① 字节阈值:< 512 B ? ──是──▶ BelowByteThreshold(连 tokenizer 都不启动)
│ 否
├─② 按内容类型派发压缩器 ────▶ NoOp / Error 直接记账返回
│ 产出了更短的候选
├─③ 可选:追加 <<ccr:HASH>> 检索标记

└─④ tokenizer 校验:compressed_tokens >= original_tokens ?
│是 ─────────────▶ RejectedNotSmaller(保留原文)
│否 ─────────────▶ Compressed(记录字节区间替换)

四道闸各自的讲究:

① 阈值按内容类型分别定,全是 512 字节(live_zone.rs:152-171)。注释解释得很实在:小块的每块开销(tokenizer 计数、调度记账、日志行)比省下的 token 还贵。写成七个具名 const 而不是一个 match 里的魔数,是为了"一处可 grep、可评审"。

② 派发表dispatch_compressor(live_zone.rs:1330):JsonArray → SmartCrusherBuildOutput → LogCompressorSearchResults → SearchCompressorGitDiff → DiffCompressor,而 SourceCode / PlainText / Html 目前是 no-op。压缩器本身见 02-compressors.md,路由见 01-pipeline-and-router.md

③ CCR 标记maybe_inject_ccr_marker(live_zone.rs:1282)负责,它把 \n<<ccr:HASH>> 追加在压缩结果末尾。这里有个顺序上的小心思:标记在校验之前加,所以第四道闸算的是"带标记的最终字符串"——标记本身要花 ~6 个 token,不能让它把收益吃成负数(live_zone.rs:871-876)。

④ 校验用的是 token,不是字节(live_zone.rs:887-889)。注释举了反例:某些病态输入(密集 base64)字节变短了,但换一种形态后 tokenizer 切得更碎,token 反而涨。省钱按 token 算,那就按 token 判。

还有一个存储上的洁癖:CCR 的 store.put 放在校验通过之后才执行(live_zone.rs:908-910),否则会往存储里塞一堆"标记根本没上线"的哈希。


7. CacheAligner:一个从"改写者"退化成"检测器"的模块

Python 侧的 headroom/transforms/cache_aligner.py 是这套架构观最直白的一份自白。

它以前干什么: 把 system prompt 里的动态内容(时间戳、UUID)抠出来,重新插到别处,让缓存前缀稳定。听起来很合理。

它为什么被废: 这个改写路径违反不变量 I2——缓存热区(system prompt)永远不能被改。文件头第一段就写着这条(cache_aligner.py:3-8)。想稳定缓存却去改缓存,是自相矛盾。

它现在干什么: 只检测、只警告、绝不改。apply 方法的返回值里 transforms_applied=[] 后面直接跟着注释 # Never applies a rewrite.(cache_aligner.py:366)。消息做了深拷贝,但那份拷贝不被修改(cache_aligner.py:310-311)。

它识别易变内容的方式也值得单说:全程不用正则,四种模式各用结构化解析器:

模式判定方法源码
UUIDuuid.UUID() 解析,且只认带横杠的 36 字符规范形cache_aligner.py:105 _is_uuid
ISO 8601 时间戳datetime.fromisoformat()(Z 后缀先换成 +00:00)cache_aligner.py:125 _is_iso8601
JWT只验形状:三段、点分、每段 base64url 可解码,不验签名cache_aligner.py:144 _is_jwt_shape
十六进制哈希长度 ∈ {32, 40, 64} 且 int(token, 16) 不抛异常cache_aligner.py:168 _is_hex_hash

有个反直觉的取舍写在 cache_aligner.py:79-82:不认 32 字符无横杠的 UUID。因为它和 MD5 摘要在结构上完全一样,认了就会把哈希误判成 UUID。宁可漏,不要错分类。

分类顺序也是有意排的(_classify_token,cache_aligner.py:184):UUID → JWT → ISO8601 → hex hash,越具体越靠前。

检测到之后只做一件事:发一条 warning,告诉用户"你的缓存前缀不稳定,把动态值挪出 system prompt"(cache_aligner.py:333-339)。日志里的样本还被截断成 前8位...后4位,免得把密钥打进日志(cache_aligner.py:238)。


8. 代理层的五件缓存稳定化设施

crates/headroom-proxy/src/cache_stabilization/ 把"怎么让缓存别掉"的机制全收在一个目录里。模块头(mod.rs:1-22)按是否动字节把它们分成三类,这个分类本身就是安全设计:

机制干什么动什么门禁
tool_def_normalize(E1/E2)tools[] 按名字排序 + JSON Schema 键递归排序请求体PAYG only;E1 额外要求"没有任何 tool 带 cache_control"
anthropic_cache_control(E3)在最后一个 tool 上自动放一个缓存断点请求体PAYG only;body 里已有任何标记就整个跳过
openai_cache_key(E4)注入 prompt_cache_key请求体PAYG only;客户已设则跳过
beta_sticky把客户历轮发过的 anthropic-beta token 求并集回填只动请求头所有 auth 模式;只回填客户自己发过的值
volatile_detector / drift_detector(E5/E6)检测易变内容 / 缓存击穿遥测什么都不动

8.1 E1/E2:排序为什么能省钱

tool_def_normalize.rs 的模块头点破了一个很多人踩过的坑:很多 SDK 的 tools[] 是从 Python set()dict 里攒出来的,而这些容器的迭代顺序在进程之间是哈希随机化的。客户源码一个字没改,进程一重启,proxy 看到的工具顺序就变了——缓存全灭(tool_def_normalize.rs:3-8)。

sort_tools_deterministically(tool_def_normalize.rs:70)按 tool["name"] 稳定排序,兼容 Anthropic 的 tool.name 和 OpenAI 的 tool.function.name 两种位置(sort_key,:95);没有名字的畸形输入退化成"该 tool 规范 JSON 的 MD5"。

sort_schema_keys_recursive(tool_def_normalize.rs:178)递归重建每个对象,键按字母序插入。这里有个不能想当然的边界:数组不排序。因为 oneOf / anyOf / prefixItems / enum 的元素顺序在 JSON Schema 里是有语义的,只递归进去、不动顺序(:152-159)。

两者的 cache_control 处理不同,理由值得抄:

  • E1 要检查标记(any_tool_has_cache_control,:138)。重排数组会改变"标记之前有哪些工具",等于悄悄改了客户的缓存范围。
  • E2 不用检查。因为标记挂在 tool 对象上,不在 input_schema 里面,排 schema 的键不会移动标记(:169-177)。

8.2 E3:替不懂行的客户放一个缓存断点

Anthropic 的 prompt cache 是逐块 opt-in 的:body 里没有任何 cache_control 就什么都不缓存。Claude Code 这类成熟客户自己会放;手搓 SDK、小型 agent、裸 curl 的用户压根不知道有这个字段(anthropic_cache_control.rs:4-11)。

auto_place_anthropic_cache_control(:227)在最后一个 tool 上插一个 {"type":"ephemeral"}。三道闸:

  1. auth 模式闸(调用方负责)——只在 PAYG 上跑。
  2. 客户优先闸——any_anthropic_cache_control(:160)扫 system 块、messages[].content 块、tools[] 顶层,发现任何一个标记就 Skipped { MarkerPresent },一个字节不动。
  3. 幂等——第二次跑时,自己第一次插的标记就成了闸 2 的信号。

这个 walker 不做任意深度递归(:154-159),理由很实在:客户的 JSON Schema 里可能正好有个属性叫 cache_control,深挖会误报。

只放一个标记也是刻意的:Anthropic 最多允许 4 个,但代码只启用"最后一个 tool"这一个槽,另外三个槽在注释里排好了优先级,等生产遥测确认后再开(:53-70)。

8.3 E4:给 OpenAI 注入 prompt_cache_key

OpenAI 的前缀缓存是自动的,不需要 opt-in;但 prompt_cache_key 决定缓存查找钉到哪个身份上,不设就退化成组织级查找,可能和别的租户撞车(openai_cache_key.rs:4-12)。

derive_key(:193)拿三样东西做哈希:model、system 内容的 SHA-256、tools 的 SHA-256,中间用 0x00 分隔(UTF-8 模型名和十六进制摘要里都不可能出现 0x00,所以分隔无歧义,:187-192)。

user / assistant 消息被刻意排除——把它们算进去就每轮都换 key,等于没做(:48-50)。

8.4 beta_sticky:只回填客户自己发过的东西

anthropic-beta 头也是缓存 key 的一部分。交互式客户(Claude Code、Codex CLI)可能在第 N 轮和第 N+1 轮之间掉一个 beta token,前缀哈希就变了,缓存读失效,用户白付一次全量提示词的钱(beta_sticky.rs:5-14)。

apply_sticky_betas(beta_sticky.rs:262)按 (provider, session) 维护一个有界 LRU(容量 1000,:87),把本轮 token 和历轮见过的求并集回填。三条纪律:

  • 只记录客户自己发过的 token。 Headroom 自己加的 beta 不进 tracker——这样"转发出去的并集永远是客户已经上过线的值的子集",这是它和"注入 Headroom 状态"的根本区别(:31-37)。
  • 先合并多行头再记录。 按 RFC 9110 用 , 连接重复的头行再记账,否则后面 insert 单行会把 token 集合缩小(:270-274)。
  • 日志只记数量,不记内容。 beta token 可能带实验 ID,属于用户没同意分享的东西(:255-258)。

还有一处它主动偏离 Python 老实现:Python 按 (model, system prompt) 分桶,导致同一个 Claude Code 会话和它所有 subagent 共享一份 token 并集、互相污染;Rust 版改用 drift detector 那个按会话的 key,每个会话各管各的(:53-70)。

8.5 drift_detector:缓存击穿的遥测

E6 是纯观察者。compute_structural_hash(drift_detector.rs:128)对缓存热区做三轴指纹:

内容
systemsystem 提示词规范字节的 SHA-256
toolsbody.tools 规范字节的 SHA-256
early_messages前 3 条消息各自的 SHA-256(EARLY_MESSAGES_WINDOW = 3,:121)

canonicalize_for_hash(:165)做两件事:对象按键排序重建;剥掉 cache_control 成员。剥标记的理由很妙:客户每轮都把缓存断点挪到最新的块上,而挪断点从不会让已缓存的前缀失效,所以标记是"放置元数据"而不是结构,把它算进哈希会导致每轮都报漂移(:160-164)。

但在"不透明载荷"里(OPAQUE_PAYLOAD_KEYS = ["input","arguments","json","input_schema"],:147)不剥——用户字段里恰好叫 cache_control 的那是数据,剥了会让两个真的不同的载荷哈希成一样,反而把真漂移盖住。

比较逻辑同样有讲究:early_window_drifted(:468)是前缀感知的——之前是 None 现在有值(对话长进窗口里)是良性追加;之前有值现在变了或没了,才是真的把 provider 缓存打穿了。

observe_drift(:353)首见发 cache_drift_first_request(info),稳定时不发事件,漂移才发 cache_drift_observed(warn)并列出漂移的维度。日志里只有 session key 的 SHA-256 前 16 位,原始凭据永远不出这个模块(:46-59)。


9. 三档 auth 模式:压得多狠,看你怎么付钱

同一段内容,该压多狠,取决于这个请求怎么计费。classify_auth_mode(headroom/proxy/auth_mode.py:89)按最具体信号优先的顺序判:

  1. 订阅型 CLI 的 User-Agent 前缀 → SUBSCRIPTION(CLI 自己的身份压过它携带的 token 形状——Claude Code 用的是 sk-ant-oat* 但它是订阅客户,不是 OAuth)
  2. Bearer sk-ant-oat*OAUTH(排在下一条前面,因为它也以 sk- 开头)
  3. Bearer sk-ant-api* / Bearer sk-*PAYG
  4. Bearer <三段 JWT>OAUTH
  5. Authorization 但不是 Bearer(如 SigV4)→ OAUTH
  6. x-api-key / x-goog-api-keyPAYG
  7. 兜底 → PAYG

兜底选 PAYG 的理由写在注释里,很值得记:误判成 PAYG 最多是多压一次、白跑一遍;误判成订阅然后去改字节,可能招来订阅被封(auth_mode.py:113-115)。

分类之后落成策略结构体:

模式live_zone_onlycache_aligner_enabledvolatile_token_thresholdmax_lossy_ratiotoin_read_only
PAYGfalsetrue1280.45false
OAuthfalsetrue1280.45false
Subscriptiontruefalse320.25true

值来自 crates/headroom-core/src/compression_policy.rs:119-131(常量)和 :231-237(Subscription 那一档)。OAuth 今天和 PAYG 完全相同,注释说明这是故意的,等遥测出来再分家。

Subscription 那一列是整个 F2.1 的用户可见收益:订阅用户继续享受活区压缩,只是不再被 CacheAligner 折腾缓存前缀(compression_policy.rs:40-46)。

9.1 Python 侧的手工镜像 + parity 测试

headroom/transforms/compression_policy.py 是 Rust 那份的手抄副本,文件头明说 Rust 是唯一真源(compression_policy.py:1-17)。为什么要抄一份?因为 Python 的 TransformPipeline 还在跑 CacheAligner 这类检测器,它需要读同一批开关。

同步靠一个 parity 测试(tests/test_compression_policy.py)。它的设计有个很克制的地方,注释专门解释了:parity 测试只断言字段名一致,不断言数值一致——两边都钉死数值会形成"双重锚定",反而会掩盖真正的分歧(compression_policy.py:30-33)。数值由各自语言的单测各自钉。

另一个一致性细节:Python 的 max()/min() 会从第一个参数传播 NaN,而 Rust 的 f32::max 会忽略 NaN。Python 侧只好显式加 NaN 守卫,好让两边行为一样(compression_policy.py:179-182)。这种"移植时被语言语义咬一口"的地方,是手工镜像最容易出事的位置。

policy_for_mode 在遇到未知枚举时直接 raise ValueError(compression_policy.py:272),不给静默兜底——新增一档模式必须显式处理。


10. 净成本闸门:什么时候"动缓存里的老东西"反而划算

前面讲的都是"别动热区"。但第 1 节那笔账其实是有条件的:如果要压掉的量足够大、后面的缓存后缀足够短、这段会话还要跑很多轮,那么动一次深处的编辑是可以回本的。Headroom 把这件事变成了一个可算的闸门,而不是拍脑袋。

10.1 收支平衡式

net_mutation_gain_with_write_multiplier(crates/headroom-core/src/compression_policy.rs:296):

gain = ΔT · (w + r·(R − 1)) − P_alive · (w − r) · (S + ΔT)

四个输入的白话意思:

符号含义谁提供
ΔT这次编辑能省下的 token 数压缩结果已算出,是精确值
S该槽之后所有消息的 token 总和(会被作废的缓存后缀)逆向前缀和,一次算好
R预期还要读这段缓存几次环境变量,默认 10
P_alive下一轮时这份缓存还活着的概率默认 1.0,或由空闲时长推导

有一处修正值得单独看:惩罚项是 (S + ΔT) 而不是 S。注释解释(compression_policy.rs:314-320):缓存活着时,那 ΔT 个 token 本身也已经被缓存写过了,留着它们只花读的钱,所以一次改动最多只能省下 ΔT·r·R,不是省下一次全新的写。早先那个只算 S 的版本会把收益高估 P_alive·(w−r)·ΔT——永远偏向"改",而且改得越狠偏得越多

10.2 两个锚点

break_even_reads(compression_policy.rs:374)给出温缓存(P_alive = 1)下的回本轮数:

R = ((w − r) / r) · S/ΔT = 11.5 · S/ΔT (Anthropic 5 分钟档)
场景ΔTS回本需要的剩余读次数结论
浅省2K50K287.5几乎永远不划算
深省50K10K2.3只要会话还剩几轮就赚

这两个数字在 Rust 单测 break_even_reads_matches_research_anchor(compression_policy.rs:585)里被钉死。

10.3 批量深编辑:同一次失效只收一次费

_net_cost_allows(headroom/transforms/content_router.py:4531)是 Python 侧的调用方。它的第一个巧思是批量复用:

在深度 K 上改一处,provider 里 K 之后的缓存已经作废了;那么任何更深的槽再改,增量的缓存作废成本是零

batch_state["floor"] 记录"已被判为净收益的最浅槽位";比它更深的候选把 S 记 0:

# content_router.py:4580-4582
floor = batch_state.get("floor") if batch_state is not None else None
batch_reclaim = floor is not None and slot_idx > floor
suffix = 0 if batch_reclaim else suffix_tokens[slot_idx + 1]

注意它没有batch_reclaim 时直接放行,而是照样把 S=0 喂进同一个 net_mutation_gain。注释说明理由(:4554-4557):走同一个公式意味着"绝不会放行一个真实经济学会拒绝的编辑",保守性没被打破。

而且 floor 只由全额 S 的准入来设置或下移,所以一个槽只能跟在真正被改过的更浅槽后面搭便车。

10.4 空闲衰减:临近过期的缓存不该再被计价

第二个巧思是 P_alive 的时间衰减。缓存有 TTL(Anthropic 5 分钟档);会话越闲,这份缓存活到下一轮的概率越低。当 P_alive → 0,惩罚项整体消失——反正它马上就要冷启重建了,现在改它是免费的(content_router.py:1121-1128)。

推导只做一次,每请求一次,不是每个槽一次:

# content_router.py:5017
netcost_p_alive_override = max(0.0, 1.0 - idle_f / netcost_ttl)

TTL 的取值有优先级:请求级的 cache_ttl_seconds 最权威,拿不到才退回环境变量 HEADROOM_NET_COST_CACHE_TTL_SECONDS,再不行用 300 秒默认值(content_router.py:5017-5026)。拿到 TTL 之后顺手推导写入倍率:

# content_router.py:5004
netcost_write_multiplier = cache_write_multiplier_for_ttl(netcost_ttl)

cache_write_multiplier_for_ttl(headroom/transforms/compression_policy.py:72,Rust 同名函数在 compression_policy.rs:149)的规则很简单:TTL ≥ 3600 秒用 2.0,否则 1.25;非有限值和非正值都退回 5 分钟默认。1 小时档的写更贵,所以对深编辑要更保守——这条如果漏掉,长 TTL 用户会被算得过于激进。

10.5 遥测:每个决策都留痕

这个闸门默认关着(HEADROOM_NET_COST_POLICY == "1" 才开,content_router.py:5000),先攒遥测再考虑默认打开。每个决策都:

  • 打一条 INFO 日志,带上全部输入(slotdelta_tsuffixreadsp_aliveidle_derivedgainbatch_reclaim)。
  • 计数进 route_counts:netcost_allowed / netcost_skipped / netcost_idle_admitted / netcost_batch_admitted
  • 拒绝时追加一个 marker netcost:skip:<band>。这里的 band 是 _gain_bucket(:1160)把 gain 量化成 lt100 / lt1k / lt10k / gte10k 加正负号——不用原始数值,是为了不把 transforms_applied 聚合的基数炸掉,精确值留在 INFO 日志里。

顺带一个真实的踩坑记录值得读:_netcost_message_tokens(content_router.py:1185)以前会对未知块类型做 str(block),结果一张截图的 base64 被算成 ~100,000 token(实际约 1,600,高估 57-146 倍)。S 是缓存作废成本,一张图会把它前面每一条消息的 S 都抬爆,闸门于是拒绝压缩所有这些消息。修法是委托给统一的块计数器,新块类型只在一个地方定价。


11. 边界与局限(诚实的部分)

  • Responses 路径不压 message 文本。 compress_openai_responses_live_zonelet latest_message: Option<usize> = None;(live_zone.rs:2376)是硬编码,后面 if let Some(idx) = latest_message 那个分支永远进不去——尽管 plan_responses_item 里为 message 写了完整的 input_text/output_text 处理逻辑(live_zone.rs:2618-2673)。实际效果是这条路只压 *_call_output 项,manifest.latest_user_message_index 恒为 None
  • Phase E 的字节改动会整包重序列化。 E1/E2/E3 任一生效时,live_zone_anthropic.rs:307-317serde_json::to_vec(&parsed) 重建整个 body,然后才交给活区调度器。也就是说"字节级保真"是活区调度器内部的保证;Phase E 这三个 PAYG-only 的通道是显式承认要改字节的那一层(所以才用 auth 模式闸把非 PAYG 全挡在外面)。
  • 两个策略字段是"接好线但没人用"。 volatile_token_thresholdmax_lossy_ratio 在 Rust 和 Python 两边都定义齐了,但 F2.2 里没有任何检测器/压缩器读它们(compression_policy.rs:174-178 和 Python 同名 docstring 都写明了)。它们存在的目的是给后续 PR 一个稳定的读取点。
  • CCR 只接在 Anthropic 路径上。 OpenAI Chat 和 Responses 的调度器传给 compress_one_blockccr_store 是写死的 None(live_zone.rs:1956:2466)。
  • Phase E 的 volatile / drift 检测只覆盖 Anthropic 和 OpenAI 两种形状。 Bedrock / Vertex 留给后续(volatile_detector.rs:45-48)。
  • x-headroom-session-id 时,会话身份锚在对话第一条消息上。 客户端如果重写了那条消息(历史压实、滚动窗口截断),会重新 key 成一个新会话,漂移就表现为 cache_drift_first_request 而不是 cache_drift_observed。这是刻意的取舍——另一个方案(按凭据 key)会在每次切换对话时误报(drift_detector.rs:36-44)。

12. 可借鉴的精华

  1. 先算账,再定架构。 "省 token"和"保缓存"是同一个成本函数的两项,而且后者的系数往往更大。把两者放进一个公式(net_mutation_gain),比任何"经验法则"都可靠。
  2. 把"能改的字节"变成一个可 grep 的窄窗口。 地板(compute_frozen_count)、天花板(find_latest_user_message_index)、黑名单(HOT_ZONE_BLOCK_TYPES)三样都是显式列举的常量或函数,不用前缀匹配、不用启发式。
  3. 想保字节就别反序列化。 RawValue 借用切片 + 指针算术还原偏移 + 区间拼接(bytes_offset_of / apply_replacements),是"必须字节保真"场景下的通用解法。
  4. 共用类型和后端,不共用 walker。 三家 provider 的形状在承重处分叉,强行抽象只会写出满是 if provider == 的怪物;共用 LiveZoneOutcome / compress_one_block 就够了。
  5. 想稳定缓存,就别去改缓存。 CacheAligner 从改写者退化成检测器,是这条原则最干净的示范:发警告让用户自己改 system prompt,比替用户改高明。
  6. 判定用结构化解析器,不用正则。 uuid.UUID / datetime.fromisoformat / base64 解码 / int(s, 16)——意图直白、无冷启编译成本、边界情况(如 32 字符无横杠 UUID)可以显式取舍。
  7. 动别人的字节要有明确的合同。 E1-E4 每个模块的文档头都列了"调用方必须先 gate 什么、什么情况下客户优先、幂等如何保证",而且每次 skip / apply 都发结构化事件。
  8. 遥测的基数要主动设计。 _gain_bucket 把连续值分桶再进 marker,精确值只进日志——避免可观测性字段爆炸。

13. 代码地图

主题文件路径符号名
活区心智模型总述crates/headroom-core/src/transforms/live_zone.rs模块头注释(第 1-96 行)
Anthropic 活区入口crates/headroom-core/src/transforms/live_zone.rscompress_anthropic_live_zone / compress_anthropic_live_zone_with_ccr
OpenAI Chat 活区入口crates/headroom-core/src/transforms/live_zone.rscompress_openai_chat_live_zone
OpenAI Responses 活区入口crates/headroom-core/src/transforms/live_zone.rscompress_openai_responses_live_zone
天花板 / 热区黑名单crates/headroom-core/src/transforms/live_zone.rsfind_latest_user_message_index / HOT_ZONE_BLOCK_TYPES
单块四道闸crates/headroom-core/src/transforms/live_zone.rscompress_one_block / threshold_for / dispatch_compressor
字节区间外科手术crates/headroom-core/src/transforms/live_zone.rsplan_block_replacements / bytes_offset_of / apply_replacements
CCR 标记注入crates/headroom-core/src/transforms/live_zone.rsmaybe_inject_ccr_marker
记账类型crates/headroom-core/src/transforms/live_zone.rsLiveZoneOutcome / BlockAction / ExclusionReason / CompressionManifest
活区地板crates/headroom-core/src/cache_control.rscompute_frozen_count / walk_messages
字节保真测试crates/headroom-core/tests/live_zone_dispatch.rsbyte_fidelity_outside_compressed_block
代理层 Anthropic 装配crates/headroom-proxy/src/compression/live_zone_anthropic.rsnormalize_tool_definitions
工具定义归一化crates/headroom-proxy/src/cache_stabilization/tool_def_normalize.rssort_tools_deterministically / any_tool_has_cache_control / sort_schema_keys_recursive
缓存断点自动放置crates/headroom-proxy/src/cache_stabilization/anthropic_cache_control.rsauto_place_anthropic_cache_control / any_anthropic_cache_control
OpenAI 缓存键注入crates/headroom-proxy/src/cache_stabilization/openai_cache_key.rsinject_prompt_cache_key / derive_key
beta 头粘性crates/headroom-proxy/src/cache_stabilization/beta_sticky.rsapply_sticky_betas / BetaStickyState
易变内容检测(Rust)crates/headroom-proxy/src/cache_stabilization/volatile_detector.rsdetect_volatile_content / VolatileKind
缓存击穿遥测crates/headroom-proxy/src/cache_stabilization/drift_detector.rscompute_structural_hash / canonicalize_for_hash / observe_drift / early_window_drifted
易变内容检测(Python,只警告)headroom/transforms/cache_aligner.pyCacheAligner / detect_volatile_content / VolatileFinding
策略结构体(真源)crates/headroom-core/src/compression_policy.rsCompressionPolicy::for_mode / net_mutation_gain_with_write_multiplier / break_even_reads
策略结构体(Python 镜像)headroom/transforms/compression_policy.pypolicy_for_mode / resolve_policy / cache_write_multiplier_for_ttl
auth 模式分类headroom/proxy/auth_mode.pyclassify_auth_mode / classify_client
净成本闸门headroom/transforms/content_router.py_net_cost_allows / _gain_bucket / _netcost_message_tokens / _net_cost_cache_ttl_seconds

同组其它章: 01-pipeline-and-router.md(内容怎么被路由到对的压缩器)· 02-compressors.md(每种压缩器不能破坏什么)· 03-ccr.md(有损压缩怎么做成可逆)· 05-proxy-and-wrap.md(代理层与零改代码接入)· 06-output-memory-learn.md(输入之外的三条省法)