wth2jhl.online

Back

Headroom:一次请求里,哪些字节允许被改#

仓库 headroomlabs-ai/headroom ↗,一个「AI Agent 的上下文压缩层」。我把它 clone 下来读完的时候,第一个感觉是:这不像一个压缩工具,更像一份关于「什么不能动」的工程契约。

0. 先说清楚它在解决什么#

现在几乎每个 Agent 都在烧同样的钱:工具输出塞进上下文。一次 grep 返回 200 行、一个 pytest 吐出 5000 行日志、一个 SQL 查询拉回 1 万行 JSON —— 这些东西原封不动进 prompt,token 账单按字收费,上下文窗口也被迅速吃光。

Headroom 的思路很直接:在内容进 prompt 之前先过一道压。但这件事的难点从来不是「怎么压」,而是——压完之后,你的 prompt cache 是不是废了?

这才是这个项目真正值得看的部分。

1. 数字先摆出来#

我下载的是默认分支,约 113MB(主要是 git 历史和 GIF)。代码体量:

部分体量说明
crates/headroom-core42967 行 Rust压缩器实现、CCR 存储、tokenizer
crates/headroom-proxy37811 行 Rustaxum 路由、SSE 状态机、缓存前缀稳定
crates/headroom-py1924 行 RustPyO3 扩展模块
crates/headroom-parity1130 行 Rust双语言一致性校验
headroom/(Python)1722 个 .py / 27.8 万行CLI、SDK 门面、代理处理器、插件生态
测试988 个 Python test + 1471 个 Rust #[test]

有意思的是:Python 那 27.8 万行里,压缩核心已经退休了。headroom/transforms/smart_crusher.py 现在只剩一个 dataclass 外壳 + 一个转发,真正干活的是 Rust。

官方跑出来的效果(仓库里 benchmarks/index_proof_table.py 的输出,seed 固定、tokenizer 固定):

场景压缩前压缩后节省
Code search(100 条结果)171991359721%
SRE 事故排查559572434057%
代码库探索588013389542%
GitHub issue 分诊460673242930%
合计17802410426141%

这个脚本的存在本身就很有意思 —— 它的文件头注释里写得很直白:

A number on the front page of the docs that nobody can regenerate is a liability, not evidence.

首页挂着的数字,谁都复现不了,那就是负资产,不是证据。所以他们把生成器、seed、tokenizer 全固定下来,让任何人 uv run python benchmarks/index_proof_table.py --seed 20260902 都能跑出同一张表。这种较真程度,在我见过的开源项目里排得上前三。

2. 核心机制一:live-zone,先划出「不许动」的地盘#

要理解 Headroom,得先理解 provider 的 prompt cache 是怎么工作的:它按前缀字节哈希。前缀一个 bit 变了,缓存全部 miss,账单重来一遍。

所以第一个设计决策不是「怎么压」,而是**「哪些字节有资格被压」**。Headroom 把这个区域叫 live zone,边界由两头夹出来:

  • 下界:frozen_message_count,由显式的 cache_control 标记算出来。这个下界以下的内容已经在缓存里了,必须字节相同。
  • 上界:最新的那条 user message。再往上的 assistant message 也算热区 —— 下一次回答要接着它写,不能碰。
  • live zone 内部:每个 block 都是候选。最常见的是 tool_result(工具输出本来就占 token 大头),text block 也算(比如用户粘了一大段日志)。

live zone 之外的一切,一律不动。system 不动,tools[] 不动,历史轮次不动。

Headroom 请求生命周期与 live-zone 边界

这张图里我特意标了三条不变量,因为整个项目基本就是围绕它们长出来的:

  • I1 字节保真 —— 没被改动的字节,送到上游必须和收到的一模一样(SHA-256 相等)
  • I2 热区不动 —— system / tools / 历史轮次一律不碰
  • I5 按 token 算 —— 压完 token 反而变多,那就不压,原样发

字节级拼接:为什么不反序列化再序列化#

这是整个项目最让我觉得「学到了」的一个细节。

常规做法是:把请求体 parse 成 JSON 对象 → 改一改 → 再 json.dumps 出去。但这样一来,原始的空白、键序、数字格式(1.0 会不会变成 1?12345678901234567 会不会丢精度?)全都可能变,而 provider 的缓存是按字节算的。

Headroom 的做法是字节区间手术:

out = body[..block_start] || replacement || body[end..]
text

serde_json::value::RawValue 会保留每个 messages[*] 原始片段的字节偏移量,改写完只把动过的那几段替换掉,其余部分逐字节原样拷贝。Rust 侧的 Cargo.toml 里为此开了三个 feature:

serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision", "raw_value"] }
toml
  • preserve_order —— 用 IndexMap 而不是 BTreeMap,不然键序会被重排
  • arbitrary_precision —— 数字保留原始字面量,1.0 不会塌成 1
  • raw_value —— 拿到未解析的原始 JSON 片段

这三个开关,每一个都对应着一个具体的「不加就会出事」的场景。这种在依赖配置里写清楚「为什么」的注释,比注释本身更值钱。

3. 核心机制二:SmartCrusher,怎么把一份 JSON 压到只剩骨架#

live zone 划出来了,接下来是「里面装什么」的问题。Headroom 的管线是:

CacheAligner → ContentRouter → CCR
                 ├─ SmartCrusher   (JSON)
                 ├─ CodeCompressor (AST)
                 └─ Kompress-v2    (文本, HF 模型)
plaintext

ContentRouter 负责判断内容类型,分 8 类:JsonArray / SourceCode / SearchResults / BuildOutput / GitDiff / Html / Tabular / PlainText。判断方式是正则,不加载任何模型(上层还有个可选的 Magika ONNX 分类器,但注释里明确说了:失败时不静默回退到 PlainText,那种静默兜底是审计明确禁止的)。

我重点看了 SmartCrusher —— 处理 JSON 数组的那个。

五种策略,取决于数组长什么样#

SmartAnalyzer 先统计数组的结构特征(字段基数、数值是否单调、有没有时间戳),然后归到 5 类策略之一:

策略中文什么时候用怎么选
TimeSeries时序数值随时间单调变化留变化点,折叠平稳段
ClusterSample聚类抽样大量相似项去重,每簇留一个代表
TopN按分取前 N有明确相关性分数排序取头部
SmartSample智能抽样兜底统计抽样 + 锚点保留
Skip不压结构不适合压原样返回

注意「策略」说的是怎么选样本,不是「怎么写摘要」。

SmartCrusher 策略与约束栈

两道 must-keep 约束#

不管选哪种策略,有两类条目是必留的:

KeepErrorsConstraint —— 含 error / exception / failed / fatal / critical / crash / panic / abort / timeout / denied / rejected 这些关键词的项。

KeepStructuralOutliersConstraint —— 结构上不合群的项,两种:罕见字段(某个 key 只在少于阈值的项里出现),以及常见字段的罕见值(比如 999 条 level: "INFO" 里唯一的 level: "ERROR")。

这两条被抽成了 Constraint trait,OSS 默认栈由 default_oss_constraints() 提供。注释里说得很清楚:抽出 trait 之后,行为和 PR1 之前的硬编码逐字节相同,只是给企业版的自定义约束留了口子。

压多少?信息饱和曲线找拐点#

这是我觉得最漂亮的一段。

它不是拍脑袋定「留 20 条」,而是算信息量还够不够。compute_optimal_k 三层判断:

  1. 快速路径 —— n ≤ 8 全留;如果 simhash 之后只剩 ≤3 组,那就留 3
  2. 标准 —— 在「唯一 bigram 覆盖率」曲线上跑 Kneedle 找拐点。覆盖率不再增长的地方,就是信息已经饱和的地方
  3. 校验 —— 用 zlib 压缩比反查:如果留 k 条产生的子集比全集还冗余,那 k 往上浮 20%

这里的 simhash 有个细节:用字符 4-gram 的 MD5,取前 64 bit 当 u64。Python 侧是 int(hex[:16], 16),Rust 侧是 big-endian 读前 8 字节 —— 完全等价,但要跨语言对齐就得写清楚。

find_knee 要求在归一化空间里偏离对角线 > 0.05,而且是严格不等号(< 返回 None)。这种「阈值差一丁点结果就翻盘」的地方,只有靠 fixture 锁死才敢动。

4. 核心机制三:CCR,让「有损压缩」变成「端到端无损」#

上面所有压缩都是有损的 —— 99% 的重复日志被丢掉了,信息是真的没了。

Headroom 的解法叫 CCR(Compress-Cache-Retrieve):

工具输出 1000 条
  → SmartCrusher 压到 20 条
  → 原件整份存进 CCR 存储,prompt 里留一个 hash
  → 给模型注入 headroom_retrieve 工具
        │
        ├─ 模型用 20 条就答完了  → 90% 省钱
        └─ 模型觉得不够,调 headroom_retrieve(hash) → 全文取回
plaintext

关键在于这个回取是模型自己发起的,不是框架替它做主。模型有判断力:大多数时候 20 条够了,偶尔它需要看第 847 条,那就调工具取。

代理层拦截这个工具调用,从本地存储取回原文,再把结果塞回对话,客户端完全看不到这次 CCR 工具调用。

存储后端的三个选择#

Rust 侧 CcrStore trait 后面挂了三套实现:

  • InMemoryCcrStore —— 进程内 DashMap,测试默认,重启就没了
  • SqliteCcrStore —— 生产默认,WAL 模式 + 预编译语句 + 读时惰性清理 TTL
  • RedisCcrStore —— 多 worker 可选,不需要负载均衡器做 sticky session

有个细节我觉得挺贴心:默认 TTL 是 30 分钟,而且注释里解释了为什么从 5 分钟提到 30 分钟:

Session-scale: agentic sessions routinely outlive the old 5-minute default, and an expired entry silently converts “lossless with retrieval” into “lossy”.

一句话点出了「静默降级」的可怕 —— 你的兜底机制悄悄没了,你却不知道。

更妙的是 TTL 是空闲窗口而不是墙钟时间:每次成功 get 都会重置计时。所以一次长时间的 Agent 交互里,模型反复回取同一条记录,那条记录就能一直活着。

5. 核心机制四:CacheAligner,以及它「被阉割」的历史#

有个细节我一开始没看懂,后来读到 git 历史才明白。

CacheAligner 现在的实现是只检测、不改写。它只干一件事:用结构化解析器(不是正则)找出 system prompt 里的易变内容 —— UUID 用 stdlib 的 uuid 模块,ISO 8601 时间戳用 datetime.fromisoformat,JWT 只查形状(三段 base64url,不验签),十六进制哈希查长度和字符集(MD5=32 / SHA1=40 / SHA256=64)—— 然后打一条 warning 日志,提示用户「你的缓存前缀不稳定」。

它不碰 prompt 一个字节。

而文件头注释里写着:

The previous rewrite path (which strips dynamic content from the system prompt and re-inserts it as a context block) violated invariant I2 — the cache hot zone (system prompt) must never be mutated. That path has been removed.

所以这是先踩了坑,然后把路堵死的典型。系统提示词是缓存热区,任何”聪明的”改写都会让缓存失效。与其做一个聪明但会毁掉缓存的优化,不如老实只报警。

同理,REALIGNMENT/ 目录下有 13 个文档(01-bug-list.md 到 12-decisions-needed.md),把整个重构过程按 Phase A~I 记下来了。其中 01-bug-list.md 是一份 bug 清单,12-decisions-needed.md 是「还没想清楚要拍板的问题」。这个自我审计的密度,比代码本身更能说明团队的工程态度。

6. 核心机制五:同一个算法写两遍,还要逐字节对齐#

这是我觉得整个项目最费力、也最有价值的一部分。

现在的状态是:Python 是参考实现,Rust 是热路径。压缩核心(SmartCrusher / CodeCompressor / LogCompressor / DiffCompressor / ContentDetector / AdaptiveSizer)全部迁到了 Rust,Python 侧只留 dataclass 外壳 + 一个转发到 headroom._core(PyO3 扩展模块)的 shim。

文件头注释写得很明确:

The headroom._core extension is a hard import: there is no Python fallback.

没有兜底路径。要么 Rust 扩展能 import,要么就是崩。

Rust + Python 双栈与 parity 契约

那怎么保证两套实现行为一致?靠 parity fixture:

  1. Python 参考实现跑一遍,把输入输出录制到 fixture 文件
  2. Rust 端口跑同一批 fixture,逐字节 diff
  3. 不一致 = CI 红

tests/parity/fixtures/ 下有 12 类 fixture(smart_crusher / log_compressor / diff_compressor / code_aware_compressor / content_detector / kompress / text_crusher / tokenizer / cache_aligner / ccr / codex_openai_contracts),smart_crusher 一个就有 17 组。

为什么值得较这个劲? 因为压缩器的输出会直接进 prompt。两侧算法哪怕只差一个字符的空白,LLM 看到的输入就不同,行为会漂移 —— 这种 bug 极难复现,极难定位。半吊子的一致性 = 随机性的 bug。

为了对齐,连「怪癖」都得复刻#

Rust 的 Cargo.toml 里那些 feature 开关,每一个都是踩过坑之后加的:

  • preserve_order —— 默认的 BTreeMap 会按字母重排键序,而 Python 的 str(dict) 保留插入顺序。不开这个,多键对象每个都对不上
  • arbitrary_precision —— 不开的话 Value::Number 走 f64,1.0 变成 1,12345678901234567 丢精度。注释里直接引用了 Realignment 不变量 I1
  • raw_value —— 让 Phase B 能把没改动的 messages[*] 当精确字节副本转发

CodeCompressor 更狠,注释里承认了一个 Python 侧的潜在 bug:

All slicing that the Python reference does by byte offset into a str (code[node.start_byte:node.end_byte]) is reproduced here as correct UTF-8 byte slicing (&code[start..end]). For ASCII inputs these are identical; for non-ASCII identifiers/strings the Python path is latently buggy (slices a str by byte index) and the two diverge. Parity fixtures are therefore ASCII; non-ASCII is out of parity scope.

翻译成人话:Python 按字节偏移切字符串,对 ASCII 没问题,遇到中文标识符就可能切坏。Rust 侧是正确实现,但为了「对齐」故意保留了 Python 的行为边界 —— fixture 只录 ASCII,非 ASCII 明确划出 parity 范围之外。

这种「我知道你那边有 bug,但我故意不修,因为一修 fixture 就全红了」的注释,是整个项目里信息密度最高的一段。 它同时告诉了你:哪里有坑、为什么没修、修了对谁有影响。

tree-sitter 那边也是同样的路子 —— 依赖版本钉死 =0.25.0,注释说 9 个样本 × 8 种语言做过 canary,确认 node-type 和行列 span 树 100% 一致。

几个具体的 bug 修复#

Rust 端口的注释里还记了一批 Python 侧的 bug,每一个都配了一个 fixed_in_3e5 标记:

TagProtector 的 O(n²) 和「替换第一个」的 bug Python 版用 result.replace(original, placeholder, 1) 替换占位符,但 replace 匹配的是第一个文本出现位置,不是匹配到的那个 offset。同一份输入里有两个一模一样的自定义标签块时,会塌成一个占位符 + 重复的另一半。Rust 版改成基于 offset 的单遍扫描,线性时间。

LogCompressor 的 stack-trace 状态机 Python 版遇到任何空行就终止,结果 chained exception 的 trace(中间有分隔空行)会丢一半帧。Rust 版按 flavor 分别定义终止规则。

LogCompressor 的去重过于激进 Python 的 _dedupe_similar 把数字、路径、hex 全部归一化成 token,结果「不同地址的 segfault」和「不同 ID 的测试失败」会被合并成一条。Rust 版保留了冒号前的消息前缀,只归一化尾部的变量区。

CCR 存储失败静默 Python 把存储的异常全吞了,Rust 改成 tracing::warn!,Python shim 也记 warning 级别 —— 运维能看见配错的 store。

这些不是「顺手修的小 bug」,是同一个算法在两种语言里各自长歪,然后靠 fixture 一个个揪出来。

7. 核心机制六:auth mode,同一套压缩对三种人三套策略#

最后一块拼图是 CompressionPolicy。请求进来先分类成三种付费模式:

模式策略具体差异
PAYG(按量付费)激进全量 live-zone 压缩、CCR、工具注入、Phase 3 稳定化
OAuth保守优先透传live-zone 只做无损压缩,不自动加 cache_control,不自动加 prompt_cache_key,不加 X-Forwarded-*
Subscription(订阅制)隐身优先OAuth 的全部,外加保留 accept-encoding、绝不向上游注入 X-Headroom-*、绝不改 User-Agent

为什么要分?因为订阅制用户的请求是要「假装成没经过代理」的 —— 很多 provider 检测到中间层就降级或拒绝。而 PAYG 用户只关心账单,能压多重压多重。

代码里专门解释了这个模式:

Centralisation. Without a policy struct, the per-mode decision is duplicated at every gate (E3 cache_control, E4 prompt_cache_key, the new live-zone gate, the new cache_aligner gate, …). When F2.2 wants to tune (e.g. allow OAuth users a relaxed live-zone gate but stricter volatile-detector threshold) we’d need to find every site.

用结构体而不是到处 match auth_mode { ... },理由有二:一是集中,二是可测 —— for_mode(AuthMode) -> CompressionPolicy 是纯函数,能便宜地做属性测试,不用跑一整套请求 fixture。

8. 顺便说说其他几个有意思的模块#

headroom learn —— 挖失败会话,把纠正写进 CLAUDE.local.md(默认,gitignore)/ CLAUDE.md / AGENTS.md / GEMINI.md / GROK.md。

它的分析器文件头写着:「No regex patterns, no static lookback windows, no hardcoded heuristics. A single LLM call understands the full conversation context」。整个 pipeline 是 Scanner → Digest Builder → LLM → Recommendations,一次 LLM 调用出结构化建议。

调用 CLI 后端(claude / gemini / codex)那段注释特别实在:

--tools "" and --strict-mcp-config leave the analysis session with zero tools: the digest is the whole input and the answer is one JSON object, but a headless claude -p is otherwise a full agent (Bash, Edit, Task, every MCP server) under the user’s own permissions. A model that goes off to read or verify things keeps streaming, so the idle cap never fires and the run dies at the hard cap with nothing written.

翻译:不加这两个 flag,claude -p 是个完整 Agent,拿着你的权限去到处读文件,模型自己跑去”核实”了,于是永远不 idle,最后被硬超时杀掉,什么建议都没写出来。

跨 Agent 共享记忆 —— headroom/memory/ 21 万行,默认后端是 SQLite + HNSW + 内存图,零配置不用起 Docker;生产模式切 Qdrant + Neo4j,API 不变。

headroom wrap —— 一条命令包住 coding agent:

headroom wrap claude|codex|grok|copilot|cursor|aider|opencode|cline|continue|goose|openhands|openclaw|vibe|omp|zcode
headroom unwrap <tool>    # 撤销
bash

Dashboard —— 官方晒的社区数据(项目原图):

Headroom 社区节省数据

59.0B tokens saved、190 万次请求被优化、1157 个活跃实例、$235.9K 成本节省。这数字是全球口径,我没法验证,但至少它的 dashboard 本身做得挺干净。

Headroom Dashboard

9. 我觉得最值得抄的三个设计#

看完之后我挑了三个我觉得可以直接搬到别的项目里的做法。

一、把「不许动」写成一等公民#

大部分做代理优化的项目,默认状态是「我会修改你的 prompt」。Headroom 反过来:默认不动,只有 live zone 才有资格动,而且连 live zone 内部改完还要 token 校验 + 字节保真。

关键在于它把这些约束变成了显式的不变量,并且给每条不变量配了一个测试门禁:

  • I1 → proxy_byte_faithful_anthropic_sha256
  • I2 → cache_hot_zone_unchanged_under_compression
  • I3 → append_only_invariant_under_recompression
  • I5 → proptest_compression_token_count_non_increasing

「不可变性」不该只活在 wiki 里,应该活在能跑失败的测试里。

二、宁可报错,不要静默兜底#

这个项目里我看到好几次同一句话的变体:

We deliberately do not silently return PlainText on error — that’s the kind of silent fallback the audit doc forbids.

Magika 模型加载失败不静默降级;CCR 存储异常不静默吞掉;用户传了自定义 scorer 就不实现,直接 NotImplementedError 抛出来。

smart_crusher.py 里这段尤其戳我:

Silently dropping a user-supplied scorer is a silent-fallback bug we explicitly refuse to ship.

静默降级是 AI 系统里最阴险的一类 bug —— 它不会报错,不会崩溃,只是在某个角落悄悄让结果变差。而 Headroom 选择「宁可崩」。

三、把「怪癖」也当成契约的一部分#

跨语言 parity 那套东西,表面上是「两份实现要一致」,实际上它逼着你回答一个更难的问题:这个算法的语义边界到底是什么?

Python 按字节切字符串对 ASCII 正确、对中文有 bug —— 那 parity 范围到底包不包含中文?答案写进注释:不包含,划出范围。这个决定比「两边跑通」重要得多,因为它把边界变成了可讨论、可审查的东西。

10. 一些没藏住的取舍#

这个项目也不是没有代价,有几个地方我认为是明显的坑:

磁盘换内存。 delete_original=True 才不留双份 checkpoint,否则磁盘直接翻倍。CCR 的原件也占地方,30 分钟 TTL 缓解了一点,但高频大对象场景存储压力不小。

压缩和缓存前缀天然冲突。 你压缩 tool_result,内容长度就变了,而 tool_result 往往在缓存前缀里。这个矛盾 Headroom 用 live-zone 边界化解了 —— 意味着你只能在最新一轮的工具输出上省 token,历史轮次动不了。这也是为什么基准表里 protect_recent=4 和 protect_recent=0 的数字完全一样(都是 41%):能压的都压了。

速度换钱。 多一次 tokenize + 一次内容解析,是净增的 CPU 开销。收益体现在账单和上下文窗口上,不在延迟上。

Rust 扩展是硬依赖。 headroom._core 没有 Python fallback,装不上就是装不上。对纯 Python 用户来说,这是个挺硬的门。

官方数字有水分。 41% 的总节省是在「合成的、特意构造得高度重复」的语料上测的。真实场景里,工具输出未必那么同质。仓库自己的文档也提醒了:「Savings depend on how repetitive your tool output is, so treat these as a shape, not a promise」。这种自我打折反而让数字更可信。

11. 小结#

Headroom 表面上是个压缩工具,实际上是一份**「在 LLM 代理链路上,什么绝对不能碰」的工程契约**。

它真正的贡献可能不是那 41%,而是这几件事:

  • 用 live-zone 把「能压」和「不能压」的边界画在了字节级别
  • 用 10 条不变量 + 对应测试门禁,把「不能碰」变成了可执行约束
  • 用 CCR 把「有损」变成了「线上有损、端到端无损」
  • 用 parity fixture 逼着跨语言实现对齐,顺带把算法的语义边界逼清楚了
  • 用「宁可报错不要静默兜底」的立场,挡掉了 AI 系统里最难查的一类 bug

如果我给自己的 Agent 加一层上下文治理,我会先照抄它的 live-zone 边界和 token 校验门禁这两条 —— 成本最低,收益最直接。

至于要不要上 Rust,那就看你的热路径有多热了。

深入解析 Headroom:给 AI Agent 的上下文层做「无损压缩」
https://wth2jhl.online/blog/headroom-context-compression-deep-dive/
Author 小吴同学
Published at October 24, 2026