最近看到一个叫 Headroom 的项目,Netflix 高级工程师 Tejas Chopra 个人开源的,号称能帮你把发给 LLM 的 token 减少 30-70%,而且不丢信息。
我把源码读了一遍,发现里面有几个设计很有意思,记下来。
项目地址:https://github.com/chopratejas/headroom
本文分析的主要源文件:
headroom/transforms/content_router.py— 内容识别与路由headroom/transforms/content_detector.py— 内容类型检测crates/headroom-core/src/transforms/smart_crusher/— SmartCrusher Rust 实现crates/headroom-core/src/transforms/log_compressor.rs— 日志压缩 Rust 实现headroom/transforms/cache_aligner.py— CacheAlignerheadroom/ccr/tool_injection.py— CCR 工具注入
它解决的是什么问题
你在用 Claude 或 GPT 做 agent 的时候,工具调用(tool call)的返回结果会吃掉大量 token。
比如你让 agent 查数据库,返回了 500 条记录,每条有 15 个字段。但其中 12 个字段在所有记录里都是完全相同的值,真正有用的只有 3 个字段。你把 500 × 15 的数据全塞给 LLM,它实际只需要 500 × 3。
这就是浪费。Headroom 做的事,就是在你把数据发给 LLM 之前,先把这些废话压掉。
整体架构:一条流水线
Headroom 的核心是一个 Transform Pipeline(变换流水线,多个处理步骤串联执行):
你的应用 → TransformPipeline → LLM
↓
1. CacheAligner(缓存对齐检测)
2. ContentRouter(内容路由,识别类型)
├── SmartCrusher ← JSON 数组
├── LogCompressor ← 日志/构建输出
├── CodeCompressor ← 源代码
├── SearchCompressor← grep 结果
└── DiffCompressor ← git diff
每条 tool 返回值进来,先判断是什么类型,再送给对应的压缩器处理。
关键原则:只动 tool 消息里的内容,不碰用户说的话和 assistant 的回复。冗余的来源在 tool result,从源头处理。
最重要的部分:SmartCrusher
JSON 数组是最典型的 tool output 格式,SmartCrusher 专门处理这个。
核心思路叫 Constant Factoring(常量提取)——把所有记录里值完全相同的字段提到头部说一次,后面每条记录就不用重复了。
举个例子。假设 tool 返回了 1000 条请求日志:
[
{"type": "request", "version": 2, "status": 200, "path": "/api/health", "ms": 12},
{"type": "request", "version": 2, "status": 200, "path": "/api/user", "ms": 45},
...
]
type 和 version 在每一条都一样,说 1000 次等于说了 1 次。SmartCrusher 把它们提出来:
[所有记录共有: type=request, version=2]
[1000 条 → 保留 30 条代表性记录]
{"status":200,"path":"/api/health","ms":12}
{"status":200,"path":"/api/user","ms":45}
...
但仅仅"提公因式"还不够——1000 条压到 30 条,怎么选这 30 条?
三种保留策略
1. 首尾锚点
始终保留前 3 条和后 2 条。这给 LLM 一个感知——数据从哪开始,到哪结束,大概是什么样的。
2. 均匀采样
中间部分按等间距抽样,保留整体分布的代表性。
3. 异常值必须保留
这是最关键的一条,用的是 Pareto 算法(帕累托算法,一种找"少数派"的统计方法):
- 统计某个字段的所有取值及频次
- 排序,找最小的 K,使得 top-K 个值覆盖 ≥80% 的记录
- K ≤ 5 的情况下,剩余的"稀有值"对应的记录,全部强制保留
比如 997 条 status=200,3 条 status=500——那 3 条错误请求,正好是你最需要让 LLM 注意的。这个算法保证它们不会被抽样丢掉。
还有一种检测:如果某条记录的任意字段包含 error、fail、exception、panic 这类关键词,也会被强制保留。
日志压缩:按重要性分级
日志文件是 agent 读取工具输出的另一个大头。一万行的构建日志,真正有用的可能就几十行。
LogCompressor 的策略很直接——给每一行打分,按分数高低选留:
| 日志级别 | 基础分 | 处理策略 |
|---|---|---|
| ERROR / FAIL | 1.0 | 全部保留 |
| WARN | 0.8 | 去重后保留 |
| INFO | 0.4 | 只保留前 5 条 |
| DEBUG | 0.1 | 只保留前 2 条 |
Stack trace(调用栈,程序崩溃时的函数调用链)整体保留——把异常信息截断是没有意义的。
另外有个去重的细节值得一提。旧版本的去重是把数字、路径、hex 字符串全部替换成占位符再做比较,导致不同地址的 segfault、不同 ID 的测试失败,都被当成"同一条"给去掉了。
新版(Rust 实现)改成:只找第一个 : 或 = 的位置,保留前面的消息前缀,只 normalize 后面的变量部分。这样 Error at 0x7fff001 和 Error at 0x7fff002 就是两条不同的错误,不会被合并。
CCR:压缩 ≠ 丢数据
这是整个系统里我觉得最聪明的一个设计——CCR(Compress-Cache-Retrieve,压缩-缓存-取回)。
它解决的问题是:压缩必然会丢弃一部分数据,万一 LLM 恰好需要那部分怎么办?
做法是:
- 压缩时,把丢弃的内容存到本地(SQLite 或内存),生成一个 hash
- 压缩标记里写上这个 hash:
[1000 条 → 30 条,hash=f3a8c2] - 同时注入一个工具给 LLM:
headroom_retrieve(hash),告诉它"如果你需要完整数据,用这个 hash 来取"
绝大多数时候 LLM 看摘要就够了,不会去调用 retrieve。偶尔它发现信息不够,会主动调这个工具,Headroom 拦截这个调用,从本地取出原始数据返回给它。
结果是:大部分情况节省 token,需要完整数据时自动补全,对 LLM 来说完全透明。
一个被悄悄放弃的设计
源码里能看到一些有意思的历史痕迹。
CacheAligner 最初是一个"主动修改 prompt"的模块——把 system prompt 里的动态内容(时间戳、UUID)替换成占位符,让 KV Cache 每次都能命中。
但后来这个功能被删掉了,改成了只读检测:发现 system prompt 里有动态内容,就输出一个警告,不再修改。
原因写在注释里:修改 system prompt 违反了"cache 热区不能被改变"的不变量(invariant)。你改了 system prompt,反而可能破坏本来能命中的缓存。
所以现在 CacheAligner 只做一件事:告诉你"你的 system prompt 里有 UUID,cache 每次都会失效,你自己去处理"。
关于 Rust
SmartCrusher 和 LogCompressor 的核心算法都已经用 Rust 重写了,Python 那边只剩一个薄薄的 wrapper。
有意思的是代码里专门留了一条注释,说明在迁移前用 17 个测试 fixtures 验证了 Python 和 Rust 的输出字节级别完全一致,才删掉 Python 实现。
压缩 1000 条 JSON、10000 行日志,对于延迟敏感的 agent loop 来说,Rust 的速度差距还是很明显的。
小结
Headroom 的核心思路不复杂:
- 识别内容类型,而不是一刀切地截断
- 提取公因式(constant factoring),重复字段只说一次
- 保护异常值,确保稀有但重要的数据不被抽样丢掉
- 可逆压缩(CCR),LLM 需要时能取回完整数据
如果你在用 LLM 做 agent,工具调用的返回值里有大量结构化数据,这个项目值得看。