最近看到一个叫 Headroom 的项目,Netflix 高级工程师 Tejas Chopra 个人开源的,号称能帮你把发给 LLM 的 token 减少 30-70%,而且不丢信息。

我把源码读了一遍,发现里面有几个设计很有意思,记下来。

项目地址:https://github.com/chopratejas/headroom

本文分析的主要源文件:


它解决的是什么问题

你在用 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},
  ...
]

typeversion 在每一条都一样,说 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 算法(帕累托算法,一种找"少数派"的统计方法):

  1. 统计某个字段的所有取值及频次
  2. 排序,找最小的 K,使得 top-K 个值覆盖 ≥80% 的记录
  3. K ≤ 5 的情况下,剩余的"稀有值"对应的记录,全部强制保留

比如 997 条 status=200,3 条 status=500——那 3 条错误请求,正好是你最需要让 LLM 注意的。这个算法保证它们不会被抽样丢掉。

还有一种检测:如果某条记录的任意字段包含 errorfailexceptionpanic 这类关键词,也会被强制保留。


日志压缩:按重要性分级

日志文件是 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 0x7fff001Error at 0x7fff002 就是两条不同的错误,不会被合并。


CCR:压缩 ≠ 丢数据

这是整个系统里我觉得最聪明的一个设计——CCR(Compress-Cache-Retrieve,压缩-缓存-取回)

它解决的问题是:压缩必然会丢弃一部分数据,万一 LLM 恰好需要那部分怎么办?

做法是:

  1. 压缩时,把丢弃的内容存到本地(SQLite 或内存),生成一个 hash
  2. 压缩标记里写上这个 hash:[1000 条 → 30 条,hash=f3a8c2]
  3. 同时注入一个工具给 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 的核心思路不复杂:

  1. 识别内容类型,而不是一刀切地截断
  2. 提取公因式(constant factoring),重复字段只说一次
  3. 保护异常值,确保稀有但重要的数据不被抽样丢掉
  4. 可逆压缩(CCR),LLM 需要时能取回完整数据

如果你在用 LLM 做 agent,工具调用的返回值里有大量结构化数据,这个项目值得看。