跳到主要内容

数据截至 (上游 commit b77d61291399)

CCR — 把有损压缩做成可逆:原文不删,模型可以要回来

30 秒导读: 上一章的压缩器会真的扔东西(丢行、丢字段、丢整段 base64)。CCR 是给这些"扔"配的后悔药:扔之前把原文存进本地仓库,在提示词里留一张写着 hash 的取货单,再给模型注册一个 headroom_retrieve 工具。模型发现信息不够,就凭取货单换原文;取回动作由代理自己执行,agent 端一行代码都不用改。

本章是 Headroom 系列的第三章。压缩主干怎么把内容路由到压缩器,见 01-pipeline-and-router;每种压缩器各自"不能破坏什么",见 02-compressors;为什么不能直接删历史消息,见 04-cache-safety;代理层怎么零改代码接进 agent,见 05-proxy-and-wrap


1. 这章讲什么(零基础也能懂)

有损压缩的死穴

压缩器把一个 500 行的搜索结果砍成 20 行,省了 95% 的 token。

问题出在第 7 轮:模型突然要看第 213 行。那行已经不在上下文里了,模型只能重跑一遍工具——省下的 token 连本带利吐回去,还多花一次工具调用。

Headroom 的判断写在源码注释第一句里:可逆压缩胜过不可逆压缩(headroom/cache/compression_store.py:6-7)。

CCR 是哪三步

CCR = Compress-Cache-Retrieve,一句话三步:

谁干的干什么
Compress压缩器砍内容,同时在砍掉的位置留一个带 hash 的 marker
CacheCompressionStore原文按同一个 hash 存进本地仓库(默认 SQLite 文件)
Retrieve代理 + 模型模型调 headroom_retrieve(hash=...),代理查仓库、把原文塞回去、续跑对话

用起来什么样

模型在上下文里看到的,是这么一行东西:

[{"path":"src/a.py"}, {"path":"src/b.py"}, {"_ccr_dropped":"<<ccr:a1b2c3d4e5f6 480_rows_offloaded>>"}]

它想要那 480 行,就发一次工具调用:

{"name": "headroom_retrieve", "input": {"hash": "a1b2c3d4e5f6"}}

这次调用不会到达模型厂商之外的任何地方——代理在响应路径上把它拦下来,自己查库、自己造 tool_result、自己再请求一次模型,最后只把最终答案交给客户端。Agent(Claude Code / Cursor / 你自己的脚本)全程不知道发生过这一轮。

一句话直觉

把上下文窗口当手上拿的几页纸,把 CompressionStore 当桌上的文件柜。压缩不是把纸撕了,是把大部分放回柜子、手上留一张取件条;模型念出取件条上的编号,就有人去柜子里取来递给它。


2. 顶层全景(它大概怎么转)

CCR 横跨请求和响应两条路,先看整体。怎么读这张图: 上半是请求路(压缩+注册工具),下半是响应路(拦截+取回+续跑),中间那个方框是唯一的共享状态。

┌──────────────── 请求路 ────────────────┐
client │ │
请求 ──┼─▶ ① 压缩器砍内容 │
│ └─ 留 marker <<ccr:HASH ...>> │
│ └─ 原文写入 ──────────┐ │
│ │ │
│ ② 扫描 marker → 验货 │ │
│ └─ 注册 headroom_retrieve 工具 │
└────────────────────────────┼───────────┘

╔═════════════════════════╗
║ CompressionStore ║
║ hash → 原文 + 元数据 ║
║ TTL 30 分钟 / 堆驱逐 ║
╚═════════════════════════╝

┌────────────── 响应路 ───────┼───────────┐
│ ③ 模型回了 headroom_retrieve │
│ └─ 代理拦下 ──────────┘ │
│ ④ 造 tool_result,再请求一次模型 │
最终 ◀─┼─── ⑤ 只把最后那次响应交给 client │
响应 └────────────────────────────────────────┘

部件与落点:

部件干什么文件
marker 文本模型看得见的"取货单",三种形态headroom/parser.py:30crates/headroom-core/src/transforms/smart_crusher/crusher.rs:940
CompressionStore原文仓库:存 / 取 / TTL / 驱逐 / 反馈headroom/cache/compression_store.py:210
存储后端内存 / SQLite / 第三方,协议解耦headroom/cache/backends/
CCRToolInjector扫 marker、验货、把工具塞进 toolsheadroom/ccr/tool_injection.py:160
CCRResponseHandler拦工具调用、执行取回、续跑对话headroom/ccr/response_handler.py:81
StreamingCCRHandler流式响应下的同一件事headroom/ccr/response_handler.py:619
ContextTracker跨轮追踪 + 主动展开headroom/ccr/context_tracker.py:125
HeadroomMCPServer把三个工具以 MCP 暴露(与注入二选一)headroom/ccr/mcp_server.py:354
批处理补偿批 API 结果里的取回请求异步补跑headroom/ccr/batch_processor.py:75

3. marker:模型看到的那张取货单

3.1 它要解决的小问题

压缩器扔掉内容之后,得在原地留一个模型能读、能复述、代理能认的凭据。这凭据必须短(不然省不下 token)、必须唯一(不然取错货)、必须显眼(不然模型不知道有东西可取)。

3.2 三种真实形态

Headroom 现役的取回 marker 一共三种写法,全部由 CCR_RETRIEVAL_MARKER_RE 这一条正则的三段选择支覆盖(headroom/parser.py:30):

形态长相谁发的hash 长度
行卸载<<ccr:HASH 480_rows_offloaded>>Rust SmartCrusher 丢行路径12 hex
不透明块<<ccr:HASH,base64,4.5KB>>Rust 文档走查的长 blob 路径12 hex
括号式[100 items compressed to 10. Retrieve more: hash=…]Python 侧各压缩器24 hex
陈旧读[Read content stale/superseded: … Retrieve original: hash=…]read_lifecycle12–24 hex

行卸载 marker 的字面量在 Rust 里现拼:format!("<<ccr:{h} {dropped_count}_rows_offloaded>>")(crates/headroom-core/src/transforms/smart_crusher/crusher.rs:940)。

3.3 哨兵对象:marker 不能破坏数组结构

丢行发生在一个 JSON 数组里。如果直接把 marker 当裸字符串塞进数组,下游"把每个元素当同构记录来遍历"的代码就会炸。SmartCrusher 的做法是把它包成一个哨兵对象追加在保留项后面:

{"_ccr_dropped": "<<ccr:abc123def456 100_rows_offloaded>>"}

键名常量与两个配套工具函数在 headroom/transforms/smart_crusher.py:79(CCR_SENTINEL_KEY)、:82(is_ccr_sentinel)、:87(strip_ccr_sentinels)。注释把用法讲得很直白:任何"期望统一 schema 的遍历"都该先过一遍 strip_ccr_sentinels,别把哨兵当记录。

3.4 别搞混:<headroom:tool_digest> 不是取货单

同一段被压过的工具输出末尾还会追加另一个 marker:

<headroom:tool_digest sha256="9f2b…">

它由 create_tool_digest_marker(headroom/utils.py:133)经通用的 create_marker(headroom/utils.py:114,前缀 <headroom:)生成,hash 来自 compute_short_hash(SHA-256 截 16 位,headroom/utils.py:30),调用点在 headroom/transforms/smart_crusher.py:1301:1337

它是溯源标记,不是取回凭据。 16 位长度既不匹配 store 的 24 位默认键,也不匹配 Rust 的 12 位键;CCRToolInjector 的 marker 模式表(headroom/ccr/tool_injection.py:199-228)也不认这个形状。看到它只说明"这段被压过",不代表能取回。

3.5 两套 hash 长度,和那座桥

一个非常容易踩的坑:marker 里的 hash 和仓库里的键必须逐字相同,否则取回必然 404。

而 Headroom 有两个 hash 生产者:

  • Python CompressionStore.store 默认 SHA-256(original)[:24](headroom/cache/compression_store.py:343)。注释解释了为什么是 24 位:96 bit,生日界下要约 2⁴⁸ 条目才有 50% 碰撞,而旧的 16 位只要 2³²。
  • Rust SmartCrusher 的 hash_canonical 取 SHA-256 前 6 个字节 = 12 hex(crates/headroom-core/src/transforms/smart_crusher/crusher.rs:1181)。

Rust 那侧还有自己的进程内 CCR 仓库,而 /v1/retrieve 查的是 Python 仓库——两个不同的库。于是有了 Rust→Python 镜像桥:

Rust 压缩产出 rendered 文本

├─ ① 扫出所有 <<ccr:HASH>> _collect_ccr_hashes_from_string
│ (先试 JSON 树走查,失败退回字串扫描)

├─ ② self._rust.ccr_get(hash) 取回规范字节

└─ ③ store.store(..., explicit_hash=hash) ← 关键:用 12 位原样做键

三步分别落在 headroom/transforms/smart_crusher.py:1028(_mirror_ccr_markers_in_text)、:1081(_collect_ccr_hashes_from_string)、:1114(_mirror_single_hash_to_python_store)。

explicit_hash 就是为这座桥存在的。它的校验是硬失败而非静默降级(headroom/cache/compression_store.py:329-332):

if not explicit_hash or not all(c in "0123456789abcdefABCDEF" for c in explicit_hash):
raise ValueError(...)

注释写明理由:调用方明确要求了某个键,却悄悄回落到默认 hash,恰好会毁掉 marker 与仓库键的一致性——那正是这个参数要保护的东西。


4. 仓库:CompressionStore

4.1 它要解决的小问题

原文得放在代理进程能同步查到的地方:不能是远端服务(取回要在一次响应处理里同步完成),又不能只在内存里(代理重启、多 worker 就全丢)。

4.2 一条目长什么样

CompressionEntry(headroom/cache/compression_store.py:155)不只装原文,还装了一堆后面要用的元数据:

字段组字段用来干什么
内容original_content / compressed_content取回的正主 / 反馈学习的样本
计量original_tokens / compressed_tokens / 两个 item_count统计与取回结果里的 original_item_count
溯源tool_name / tool_call_id / query_context相关性匹配与反馈归因
生命期created_at / ttlis_expired()(:181)
学习tool_signature_hash / compression_strategy与 TOIN 关联,必须和 SmartCrusher 记录时用的同一个 hash
反馈retrieval_count / search_queries / last_accessedrecord_access()(:185),查询历史只留最近 10 条

4.3 store 的两条硬规矩

规矩一:重复 store 不能触发驱逐。 判断逻辑在 headroom/cache/compression_store.py:406-428:先看这个键在不在,不在才 _evict_if_needed()。注释讲了不这么做的后果——镜像桥在每一轮遇到同一个 marker 都会用同一个 explicit_hash 重存一次,重复 store 是常态;若每次都先驱逐,会白白干掉一条不相干的活条目,而那条目的 <<ccr:…>> marker 还在对话里躺着,直接变成不可兑换的 404。

规矩二:裸 marker 不许当原文存。 :361-373:

stripped = original.strip()
if stripped.startswith("<<ccr:") and stripped.endswith(">>") and "\n" not in stripped:
logger.error("CCR store: refusing to persist a bare retrieval marker ...")
return hash_key

一条 hash=abc → "<<ccr:abc,base64,2.0KB>>" 的条目,会用调用方正想解开的那个占位符去回答取回请求,还可能覆盖掉一条好条目。所以宁可让它 miss,也不返回占位符。

两个细节值得学:①判定刻意收窄成" marker",合法原文里含有 marker(嵌套卸载、工具回显)照样放行,否则会误伤可恢复数据;②报错日志不回显被拒的值——original 在一般情况下是携带凭据的载荷(该 issue 就是被 OAuth token 触发的),而错误分支恰恰是最容易漏审的泄漏点,hash_key 已经够定位了。

4.4 retrieve:三件事一起做

retrieve(:435)在一把锁里做完取、判过期、记账:

# 示意,非源码 —— 演示 retrieve 的四个动作
entry = backend.get(hash_key)
if entry is None: return None
if entry.is_expired(): # 过期即删,并记一笔堆脏数据
backend.delete(hash_key); stale += 1; return None
entry.record_access(query) # 计数 + 时间戳,写回后端
return replace(entry, search_queries=list(entry.search_queries)) # 深拷贝再出锁

最后那行深拷贝(真实代码 :490)不是洁癖:条目出锁之后随时可能被别的线程改或驱逐,search_queries 是可变 list,直接把引用交出去就是数据竞争。

4.5 miss 也要说人话

取回失败时,给模型的不是一句 "not found",而是一段可执行的补救说明(CCR_MISS_MESSAGE,:145):marker 里带着文件路径的话就重读那个文件(磁盘才是真相之源),是命令输出就重跑命令,并附上 TTL 与环境变量名。format_retrieval_miss_detail(:95)则把 expiredmissing 分开措辞,并带上 TTL 与实际年龄。

get_entry_status(:594)是这套话术的数据来源,返回 available / expired / missing 三态加时间戳,取回前先探一次状态就能给出精确原因。

4.6 TTL 与堆驱逐

默认 TTL 1800 秒 = 30 分钟(:51),环境变量 HEADROOM_CCR_TTL_SECONDS 可覆盖(:52)。Rust 侧的 DEFAULT_TTL 也是 1800(crates/headroom-core/src/ccr/mod.rs:66),两边刻意对齐,镜像桥因此不必显式传 TTL。

容量满时的驱逐用最小堆created_at 排(_evict_if_needed,:723)。麻烦在于条目被删/被覆盖后,堆里那条记录变成"脏"的,堆会无限膨胀。解法是显式记脏 + 到阈值重建:

_evict_if_needed()

├─ ① _clean_expired() 过期的先删,每删一条 stale += 1

├─ ② stale / len(heap) >= 0.5 ? ──是──▶ _rebuild_heap() 从后端重建,stale 归零

└─ ③ while count >= max_entries:
pop 最旧 (created_at, hash)
├─ 后端里还在 且 时间戳对得上 → 真驱逐(先记一次"驱逐即成功")
└─ 否则 → 这是脏记录,stale -= 1,继续

对应 :730(清过期)、:736-738(脏比例阈值 _heap_rebuild_threshold = 0.5)、:741-760(弹出与校验)、:774(_rebuild_heap)。时间戳比对是关键:pop 出来的 created_at 与后端条目不一致,说明这条已经被覆盖过,属于脏记录而不是待驱逐条目。

4.7 后端:协议只管 CRUD

CompressionStoreBackend(headroom/cache/backends/base.py:17)是个 Protocol,只有 get/set/delete/exists/clear/count/keys/items/get_stats。注释把边界划得很清:后端不做业务——搜索、反馈、驱逐策略、以及 TTL 的判定全留在 CompressionStore,后端只负责存取。

后端何时用特点
InMemoryBackend(backends/memory.py:17)HEADROOM_CCR_BACKEND=memory,或直接 CompressionStore() 构造一把锁 + dict,进程退出即失
SQLiteBackend(backends/sqlite.py:61)默认(env 为空或 sqlite)WAL 模式文件,跨重启、跨 worker
第三方其它值headroom.ccr_backend setuptools entry point 加载

选择逻辑在 _create_default_ccr_backend(:1000),失败一律降级到内存并明确警告"取回将无法跨代理重启"(:1019-1024)。

SQLite 之所以成为默认,理由写在模块 docstring 里(backends/sqlite.py:3-12):30 分钟的会话级 TTL 本来就假设条目要活过任何单个进程;而多 worker 场景下,headroom_retrieve 很可能落到与当初压缩不同的 worker 上,共享文件才能命中。

这个后端里有三处值得抄的工程细节:

  • busy_timeout=5000 而不是失败(:88):多 worker 共写同一文件,写小而频,毫秒级即可化解争用。
  • 只有真损坏才重建库(_is_corruption,:112):OperationalError 同时覆盖 database is locked 这类瞬时错误,把它当损坏处理会在别的 worker 还持着句柄时删掉活数据(脑裂),所以按错误文本显式匹配 malformed / not a database
  • 开库即扫过期 + chmod 0600(:96-109):过期行平时只在写路径顺带清(_maybe_purge,:156,最快 60 秒一次),安静的库可能把含敏感工具输出的原文长期留在磁盘上;权限收到 0600 是因为原文可能是文件内容或命令输出。

4.8 谁来 new 这个 store

get_compression_store()(:1050)先看有没有请求级 store(ContextVar,:974,给多租户 SaaS 中间件按租户注入),没有才回落到进程级单例。所有 CCR 调用点都走这一个入口,包括 MCP 服务器(headroom/ccr/mcp_server.py:390),注释明说这是为了让"代理压的"和"MCP 压的"能互相看见。


5. 工具注册:为什么每次请求都要挂

5.1 三种 provider,同一把工具

create_ccr_tool_definition(headroom/ccr/tool_injection.py:37)按厂商吐三种壳子,内核完全相同(一个必填的 hash 字符串):

provider外层结构schema 字段名
openai{"type":"function","function":{...}}parameters
anthropic扁平 {"name","description",...}input_schema
google扁平parameters

未知 provider 回落 OpenAI 格式(:120-122)。工具名常量 CCR_TOOL_NAME = "headroom_retrieve"(:22)。

5.2 关键设计:sticky-on,而不是"压了才加"

直觉做法是"这一轮有压缩才注册工具"。Headroom 明确不这么干,理由是提示词缓存

第 1 轮:有压缩 → tools = [client_tools..., headroom_retrieve]
第 2 轮:没压缩 → tools = [client_tools...] ← 工具列表字节变了
← 前缀缓存全部作废
第 3 轮:又压缩 → tools = [client_tools..., headroom_retrieve]

工具定义位于请求的最前段,是缓存前缀的一部分;每轮开关它,等于每轮把缓存打碎一次。所以 inject_tool_definition 接受 session_has_done_ccr 参数(:379),一旦某个会话做过 CCR,后续每轮都挂(:414)。原始注释:"否则工具列表字节在会话中途开开关关,把提示词缓存打爆"。

生产路径更进一步——apply_session_sticky_ccr_tool(headroom/proxy/helpers.py:2341)不但记住"做过 CCR",还把第一次序列化出的字节原样存下来(golden bytes),后续轮次重放同一串字节,连键序抖动都不允许。会话状态存在有界 LRU 里(SessionCcrTracker,headroom/proxy/ccr_session_tracker.py:9)。

没有 session_id 的路径(如 WebSocket)另有兜底(helpers.py:2403-2423):历史里已经出现过 headroom_retrieve 的 tool_use,也必须重新注册——否则厂商会因为历史引用了一个未声明的工具而拒绝整个请求。

缓存这条主线的完整讲法见 04-cache-safety

5.3 验货:别认领别人的取货单

scan_for_markers(:244)是纯文本形状匹配,它能扫 string content、Anthropic 的 content blocks(含 tool_result 嵌套)、Google 的 parts / functionResponse。

问题是 [... compressed ... hash=...] 这个形状不是 Headroom 专有的,别的上下文工具也会产出长得一模一样的 marker。只按形状认领,会导致:注册工具 → 模型真去调 → 必然 miss → 模型把已经做过的活重做一遍。

于是有了 verify_ownership(:322),在扫描之后、注入之前,拿 store.exists() 把不属于自己的 hash 全部剔除:

self._detected_hashes = [h for h in self._detected_hashes if _safe_exists(h)]

_safe_exists 把异常一律当 False(:361-370)。注释解释了方向选择:漏掉一个真 marker,后果只是这一轮不能用取回工具(等同于 CCR 没开);而错认一个假 marker,后果是把模型送进一次注定失败的调用。process_request(:488)把扫描 + 验货 + 注入串成一步。

同一套验货逻辑也被 Anthropic handler 复用,用来决定"这次响应值不值得走缓冲路径"(headroom/proxy/handlers/anthropic.py:412,_outgoing_body_has_redeemable_marker)。

5.4 备选路:写进 system message

create_system_instructions(:125)是另一条分发路:不注册工具,而是往 system 里追加一段"可用 hash 清单 + 怎么取"的说明(超过 5 个只列前 5 个)。

注意它与 sticky-on 的非对称:工具定义可以粘着注册,但系统提示只在本轮真有 marker 时才改(process_request 的文档串,:502-506)——因为 system prompt 是缓存最热的区域,没有当轮理由就绝不动它。

5.5 解析回调:两个不起眼但要命的修正

parse_tool_call(:535)负责从四种 provider 形状里挖出 hash,末尾两道校验很有教学价值:

  • 长度只认 12 或 24(:603)。这正好是前面两个 hash 生产者的两种真实长度,其它一律判为畸形。
  • 强制转小写(:615)。仓库键永远是小写(hexdigest()explicit_hash.lower()),而十六进制校验本身大小写不敏感——模型把 marker 里的 hash 大写抄回来,就会通过校验却查不到条目。

6. 代理自己跑一遍:CCRResponseHandler

6.1 它要解决的小问题

工具注册了,模型也调了——谁来执行? 客户端并不知道 headroom_retrieve 是什么(它是代理凭空加的),所以只能代理自己执行,并且执行完还要替客户端把对话续下去,让客户端只看到最终答案。

6.2 续跑循环

handle_response(headroom/ccr/response_handler.py:433)的主循环:

┌───────────────────────────────────────────┐
▼ │
拆分响应里的工具调用 │
(ccr_calls, other_calls) │
│ │
├─ 没有 ccr_calls ────────────────▶ 结束 │
│ │
├─ 有 other_calls ──▶ 整个跳过,原样交客户端 │
│ │
▼ │
逐个 _execute_retrieval → 查库拿原文 │
▼ │
追加 assistant 消息 + tool_result 消息 │
▼ │
再请求一次模型 ───────────────────────────────────┘
(最多 max_retrieval_rounds = 3 轮)

_execute_retrieval(:183)的顺序是先探状态再取:get_entry_status(clean_expired=True) 不是 available 就直接造 miss 结果,理由带 TTL 与年龄;取到了则把 original_contentoriginal_item_count 打包成 JSON。任何异常都被兜住转成失败结果(:258-272)——取回失败不该炸掉整个响应。

_create_tool_result_message(:274)按 provider 造回填消息,四种形状差别不小:Anthropic 是一条 user 消息带 tool_result blocks;OpenAI 是多条 role:tool 消息(用 _openai_tool_results 哨兵键标记,由调用方 extend 而非 append);Responses API 是 function_call_output items;Google 是 functionResponse parts。

6.3 混合工具调用:三态而非二态

模型可能在同一轮里既调 headroom_retrieve 又调客户端的真工具。这时代理无法构造合法续跑——厂商要求每个 tool_use 都有配对的 tool_result,而代理只有 CCR 那一半。

于是有了三态信号(:48-50),配 residual_ccr_status(:133)判定:

状态含义调用方该做什么
RESIDUAL_CCR_RESOLVED没有残留 CCR 调用正常返回
RESIDUAL_CCR_SKIPPED_MIXEDCCR 与客户端工具同时出现原样 200 透传,让客户端解所有工具调用
RESIDUAL_CCR_ERROR只剩 CCR 调用没解掉真失败,fail closed

这个区分是必要的:把"故意跳过"和"处理失败"混为一谈,会把一次完全正常的混合工具轮判成 5xx。

6.4 流式:先攒后放

流式下没法"先看完再决定",StreamingCCRHandler(:619)的做法是先缓冲、按结束标记决策:

chunk ─▶ StreamingCCRBuffer.add_chunk
│ 同时出现「工具调用起始标记」和 "headroom_retrieve" → detected_ccr

┌── 见到流结束标记 ──┐
│ │
├─ 没检出 CCR ──▶ 把缓冲的 chunk 原样吐给客户端(纯透传)

└─ 检出 CCR ──▶ 收完剩余流 ─▶ 解析 SSE 重建完整响应
─▶ 交给 handle_response 续跑
─▶ 把最终响应重新序列化成 SSE 吐出

四个细节:

  • 起始标记按 provider 分(:585-589):Anthropic 流里是 "type":"tool_use",OpenAI 兼容流里是 delta 中的 "tool_calls",只扫前者会导致 OpenAI 流永远检不出 CCR。
  • 结束标记同样分(:668):Anthropic 靠 "stop_reason",OpenAI 靠 data: [DONE]
  • 缓冲超过 10000 字节还没检出,就先放行(:691),避免长文本回复被硬憋住。
  • 迭代器耗尽后必须兜底 flush(:701-704):上游截断、缺哨兵、或流形状不认识时,缓冲区里剩的是客户端从未见过的真实数据,不能丢。

重建响应那两个函数(_reconstruct_anthropic_response :790_reconstruct_openai_response :910)里埋着若干"别人踩过"的坑,最典型的一个在 :998:带工具调用的消息必须把 finish_reason 改成 "tool_calls",写死 "stop" 会让按 finish_reason 驱动 agent 循环的客户端认为回合结束,重建出的工具调用永远不被执行。


7. 另外三条取回路

工具注入不是唯一出口。同一个仓库有四个入口,按场景选:

路径触发方式适用场景入口
工具注入 + 响应拦截模型主动调标准代理场景ccr/tool_injection.py + ccr/response_handler.py
内联解析代理在响应路径直接替换根本没有工具轮的场合ccr/marker_resolution.py:37
MCP 工具宿主自己挂 MCP serverClaude Code / Cursor 等 MCP 宿主ccr/mcp_server.py:354
批处理补偿批结果回来后异步补跑Batch APIccr/batch_processor.py:75
HTTP 端点POST /v1/retrieve外部/MCP 回落headroom/proxy/server.py:4679

7.1 内联解析:直接把 marker 换成原文

正常 CCR 假设"后面还有一轮,模型有机会调工具"。但如果 Headroom 是作为 LiteLLM 的 guardrail/代理跳板跑的,中间根本没有工具调用轮次,marker 就会当作裸文本漏给用户。

--ccr-inline-resolve 开关下,resolve_markers_in_text(headroom/ccr/marker_resolution.py:37)直接做正则替换,resolve_markers_in_response(:65)递归走整个响应结构的每个字符串字段——注释说明这是刻意的:与其为每个 provider 维护"marker 会出现在哪个字段"的清单,不如全走一遍。

这条路上的 miss 无法回报给模型(没有工具往返),所以处理方式是保留 marker 并追加原因(:60):

return f"{match.group(0)} [unresolved: {detail}]"

7.2 MCP:与工具注入二选一

HeadroomMCPServer(headroom/ccr/mcp_server.py:354)暴露三个工具:

工具干什么处理函数
headroom_compress按需压缩一段内容,顺手存原文并返回 hash:752
headroom_retrieve按 hash 取原文:825
headroom_stats会话压缩统计:846

另有一个默认关闭的 headroom_read(HEADROOM_MCP_READ=on 才注册,:79-84),同文件重复读时只回一个缓存 marker。

为什么和工具注入二选一? 因为两条路挂的是同名工具。ccr/__init__.py:16 一句话点破:"配置了 MCP 时跳过工具注入,以免重复"。落地点在两处:inject_tool_definition 见到同名工具就不再追加(tool_injection.py:419-423),apply_session_sticky_ccr_tool 同样让客户端的字节优先(helpers.py:2387)。

MCP 取回的顺序是先本地后代理(_retrieve_content,:460):本地拿的是同一个 get_compression_store() 单例(:390),miss 了再 POST /v1/retrieve 问代理(_retrieve_via_proxy,:538)。MCP 自己压的内容 TTL 是 MCP_SESSION_TTL = 3600(:198),比代理默认的 1800 长一倍。

值得一提的措辞细节:MCP 的过期回复里明写 "Do not retry the same hash"(:500-511),因为模型面对失败的第一反应通常是重试同一个调用。

传输层由 ccr/mcp_http.py 提供:stdio 之外还能起 Streamable HTTP(create_streamable_http_app,mcp_http.py:37;serve_streamable_http,:58)。

7.3 批处理:异步补偿

Batch API 的难点是请求上下文早就没了:提交批次和拿回结果隔了几小时,而续跑一次对话需要原始 messages / tools / model。

解法是提交时先把上下文存下来:

提交批次 ──▶ BatchContext{batch_id, provider, requests{custom_id → messages/tools/model}}
└─ 存进 BatchContextStore,TTL 24 小时

(几小时后) │
结果回来 ──▶ 按 custom_id 取回上下文 ─┘
└─ 结果里有 headroom_retrieve? → 交给同一个 CCRResponseHandler
└─ api_call_fn 换成"直接打厂商 API"的续跑实现

数据结构在 headroom/ccr/batch_store.py:49(BatchContext)、:33(BatchRequestContext);仓库是 asyncio 锁保护的 BatchContextStore(:86),TTL DEFAULT_BATCH_CONTEXT_TTL = 86400(:26),满了删最旧的 10%(_cleanup_oldest,:207)。

处理器 BatchResultProcessor.process_results(headroom/ccr/batch_processor.py:119)复用同一个 CCRResponseHandler,只是把 api_call_fn 换成直连厂商的续跑调用(_make_continuation_call,:292,三个 provider 各一份)。三家的结果封装形状不同,由 _extract_response(:212)统一剥壳:Anthropic 在 result.message,OpenAI 在 response.body,Google 直接是 response

上下文找不到时的行为是原样透传并告警(:145-158),不是报错——批结果本身是有效的,只是没能补上取回那一轮。


8. 反馈回路:取回率反过来指导压缩

CCR 除了救急,还产出一个免费的质量信号:某种压缩策略的内容被取回得多,说明压过头了;从来没人取,说明压得刚好。

RetrievalEvent(headroom/cache/compression_store.py:197)是这个信号的载体。事件有两个来源:

① 真实取回 ② 驱逐时从未被取回
retrieve() → _log_retrieval _evict_if_needed → _record_eviction_success
retrieval_type = "full" retrieval_type = "eviction_success"
│ │
└──────────┬──────────────────────────┘

_pending_feedback_events(锁内排队)

process_pending_feedback() ← 取回后立刻跑一次,store 前也跑一次

┌─────────────┼──────────────┐
▼ ▼ ▼
CompressionFeedback Telemetry TOIN
(本地压缩提示) (数据飞轮) (跨用户情报)

几个设计点:

  • "驱逐即成功"是显式事件,不是沉默(_record_eviction_success,:792)。条目被驱逐且 retrieval_count == 0,说明模型从头到尾没需要过原文,压缩是成功的——这个信号必须让反馈系统知道,否则 store 与反馈之间会状态发散。
  • 它绝不能被当成一次取回compression_feedback.record_retrieval:286 显式提前返回:早期版本让 eviction_success 落进"搜索型取回"分支,反而抬高了取回率、把压缩推向更保守——信号完全反了。
  • 不在锁内调反馈(:811-813 注释):会死锁。所以先在锁内把条目数据抄出来排队,出锁再处理。
  • 时序刻意:store 在获取锁之前先跑一次 process_pending_feedback(:394-395),确保"马上要被驱逐的条目"的反馈先被采走。
  • 遥测与 TOIN 的异常一律吞掉(:932-934:968-970):它们不该打断反馈回路,更不该打断请求。

9. 检索日志的脱敏

取回日志会打出原文预览,而原文就是工具输出本身——文件内容、命令输出、可能带凭据。所以有两道处理(headroom/cache/compression_store.py:109-138):

一、正则脱敏,三条(:59-64):

规则抓什么替换成
_SECRET_KEY_VALUE_RE键名含 API_KEY / TOKEN / SECRET / PASSWORD / CREDENTIAL / AUTH 的赋值KEY: [REDACTED]
_AUTH_VALUE_REBearer / Basic 后跟 ≥12 字符Bearer [REDACTED]
_API_KEY_VALUE_REsk- 开头 ≥12 字符sk-[REDACTED]

二、整体开关:HEADROOM_LOG_PAYLOAD_PREVIEW 设为 0/false/no/off 时只记字节数,预览为空串(_payload_preview_enabled,:115)。开着时预览也截到 4096 字符(_RETRIEVAL_LOG_PREVIEW_CHARS,:54)。

注释把动机讲得很实在:预览里是脱敏后的逐字工具内容,这让 proxy.log 敏感到用户不方便贴进 bug 报告里——所以给一个能彻底关掉的开关。

同一条隐私线也体现在 /v1/retrieve 的防护上:该端点要求 loopback + same-origin(headroom/proxy/server.py:4681),CORS 默认只放行本地来源,注释点名了旧的通配 origin + 带凭据组合会让任意网页读到未压缩的原始工具输出(CWE-346,server.py:3186-3196)。


10. 跨轮追踪与主动展开

ContextTracker(headroom/ccr/context_tracker.py:125)解决的是"上下文失忆":第 1 轮压掉的 100 个文件,第 5 轮用户问起其中一个,模型根本不知道它存在过。

做法是记账 + 相关性匹配 + 主动取回:

track_compression(hash, turn, tool, counts, workspace_key, query_context, sample_content)
│ (sample_content 截 2000 字符,LRU 上限 100 条)

新一轮用户消息 ──▶ analyze_query(query, workspace_key)
│ ├─ 工作区不匹配 → 跳过
│ ├─ 超过 300 秒 → 跳过
│ └─ 关键词重合打分 × 时间衰减 ≥ 0.3 → 推荐

execute_expansions() → 从 store 取原文 → format_expansions_for_context()
└─ 包进 <headroom_proactive_expansion> 块塞回上下文,每轮最多 2 条

对应 track_compression(:161)、analyze_query(:229)、_calculate_relevance(:307)、execute_expansions(:491)、format_expansions_for_context(:530)。

三处防线值得单独说:

workspace_key 是必填,不是可选。 CompressedContext 的文档串(:66-80)记着一次真实事故:共享的进程内 tracker 没有来源标识,导致 A 项目的 Python 文件出现在 B 项目的 Ruby 会话里。现在 analyze_query:279 按工作区过滤,空 workspace 直接返回空结果而不是"匹配一切"(:262-267,失败即关闭)。同一份文档串明确写着:把它改回可选就等于重开这个漏洞。

② 认出 Claude Code 的 /compact 摘要并跳过。 looks_like_claude_code_compact_summary(:34)匹配三种指纹(如 "this session is being continued from a previous conversation" 且含 summary 字样)。这类摘要本身就是上下文,拿它去做主动展开等于把陈旧会话状态反复重新注入。检测器刻意收窄,免得误伤只是碰巧提到 "summary" 的普通工具输出(:192)。

③ 注入块要防边界伪造。 格式化时会把载荷里出现的 </headroom_proactive_expansion> 转义掉(:566-567),并在块头声明工作区来源——让模型能判断适用性,而不是把这段当提示词注入。

模块级的 get_context_tracker()(:614)被明确标为仅测试用:生产的 tracker 挂在长生命周期的代理服务器对象上,因为一个进程要靠 workspace_key 参数同时服务多个工作区。


11. 巧妙之处(可以直接抄的)

  • 可逆比不可逆好,而"可逆"的成本几乎为零。 原文本来就在手上,存一份的代价是一次磁盘写;换来的是压缩率可以往激进方向调,因为最坏情况从"信息永久丢失"降级为"多一次工具往返"。
  • marker 与仓库键的一致性被当作不变量守。 explicit_hash 非法就抛异常而不是静默降级(compression_store.py:329);裸 marker 拒绝入库(:361);hash 强制小写(tool_injection.py:615)。三处都在守同一条不变量。
  • 重复 store 不驱逐。 一个反直觉但正确的判断:为重复键腾地方,会让一条无关条目的 marker 变成死链(:397-411)。
  • 验货再广告。 形状匹配 + 存在性验证分成两个方法,前者纯文本可测,后者才碰仓库(tool_injection.py:300 / :322)。
  • 工具注册粘着不动。 为了提示词缓存,宁可多挂一个用不上的工具,也不让 tools 字节在会话中途抖动(helpers.py:2341)。
  • miss 消息是可执行指令,不是错误码。 告诉模型"重读那个文件"比告诉它 "404" 有用得多(CCR_MISS_MESSAGE,:145)。
  • 跳过与失败必须区分。 混合工具调用是合法情形,不是 5xx(response_handler.py:48-50)。
  • 驱逐也是信号。 没人来取的条目,证明压缩压对了(:792)。
  • 压过的 headroom_retrieve 结果不能再压。 否则会生成一个新的 <<ccr:hash>> marker,把模型拖进无限取回循环(smart_crusher.py:1316-1318,#1077)。

12. 边界与局限

  • TTL 是硬墙。 默认 30 分钟。超过就是 miss,模型只能按提示重跑命令/重读文件。长会话里早期的 marker 基本都会失效。
  • 仓库容量有限。 max_entries 默认 1000,满了按创建时间驱逐——老条目的 marker 会在对话里变成死链,即使还没到 TTL。
  • 默认 SQLite 解决了重启与多 worker,但没解决多机。 跨主机部署需要自己接 Redis 之类的后端(entry point headroom.ccr_backend,compression_store.py:1029)。
  • SQLite 初始化失败会静默降级到内存(只有一条 warning,:1019),此时取回不跨重启。
  • 续跑轮数上限 3(ResponseHandlerConfig.max_retrieval_rounds),超了就带着未处理的 CCR 调用返回并告警(response_handler.py:539-543)。
  • 混合工具调用下 CCR 完全不生效,marker 原样漏给客户端(:481-488)。
  • 流式重建是有损工程。 SSE 解析、响应重建、再序列化成 SSE,链路里每个 provider 的边角形状都得单独伺候;非 Anthropic/OpenAI 的兼容网关不保证可靠。
  • 相关性匹配只是关键词重合 + 时间衰减(context_tracker.py:307),没有向量检索;ContextTracker 自己的 5 分钟窗口(max_context_age_seconds = 300)还比仓库 TTL 短得多。
  • 取回始终是全量。 源码多处注明"retrieval is by hash: always returns the full original content";早期的部分/搜索式取回已经拿掉了。这意味着取回一次可能把省下的 token 一次性还回去。

13. 代码地图(导航索引)

主题文件路径符号名
条目与元数据headroom/cache/compression_store.pyCompressionEntryis_expiredrecord_access
存 / 取 / 状态headroom/cache/compression_store.pyCompressionStore.storeretrieveget_entry_statusexists
hash 一致性headroom/cache/compression_store.pystore(explicit_hash=...)
TTL 与驱逐headroom/cache/compression_store.py_evict_if_needed_clean_expired_rebuild_heap
反馈回路headroom/cache/compression_store.pyRetrievalEvent_record_eviction_successprocess_pending_feedback
miss 话术headroom/cache/compression_store.pyCCR_MISS_MESSAGEformat_retrieval_miss_detail
日志脱敏headroom/cache/compression_store.py_redact_retrieval_log_payload_payload_for_retrieval_log
store 解析与单例headroom/cache/compression_store.py_create_default_ccr_backendget_compression_storeset_request_compression_store
后端协议headroom/cache/backends/base.pyCompressionStoreBackend
内存后端headroom/cache/backends/memory.pyInMemoryBackend
SQLite 后端headroom/cache/backends/sqlite.pySQLiteBackend_is_corruption_handle_db_error_maybe_purge
marker 正则headroom/parser.pyCCR_RETRIEVAL_MARKER_RE
溯源 markerheadroom/utils.pycreate_markercreate_tool_digest_markercompute_short_hash
哨兵对象headroom/transforms/smart_crusher.pyCCR_SENTINEL_KEYis_ccr_sentinelstrip_ccr_sentinels
Rust→Python 镜像桥headroom/transforms/smart_crusher.py_mirror_ccr_markers_in_text_collect_ccr_hashes_from_string_mirror_single_hash_to_python_storeccr_get
Rust 侧 marker 与 hashcrates/headroom-core/src/transforms/smart_crusher/crusher.rshash_canonicalcanonical_array_json
工具定义headroom/ccr/tool_injection.pyCCR_TOOL_NAMEcreate_ccr_tool_definitioncreate_system_instructions
扫描与验货headroom/ccr/tool_injection.pyCCRToolInjector.scan_for_markersverify_ownershipprocess_request
工具调用解析headroom/ccr/tool_injection.pyparse_tool_call
跨 provider 提取headroom/ccr/tool_calls.pyextract_tool_callsparse_ccr_tool_callstool_call_id_for_provider
sticky 注册headroom/proxy/helpers.pyapply_session_sticky_ccr_toolget_session_ccr_tracker
会话 CCR 状态headroom/proxy/ccr_session_tracker.pySessionCcrTracker
响应拦截与续跑headroom/ccr/response_handler.pyCCRResponseHandler.handle_response_execute_retrievalresidual_ccr_status
流式处理headroom/ccr/response_handler.pyStreamingCCRBufferStreamingCCRHandler.process_stream_parse_sse_stream
内联解析headroom/ccr/marker_resolution.pyresolve_markers_in_textresolve_markers_in_response
跨轮追踪headroom/ccr/context_tracker.pyContextTracker.track_compressionanalyze_queryexecute_expansions
compact 摘要识别headroom/ccr/context_tracker.pylooks_like_claude_code_compact_summary
MCP 服务器headroom/ccr/mcp_server.pyHeadroomMCPServer_retrieve_content_handle_compress
MCP HTTP 传输headroom/ccr/mcp_http.pycreate_streamable_http_appserve_streamable_http
批上下文仓库headroom/ccr/batch_store.pyBatchContextBatchContextStoreget_batch_context_store
批结果补偿headroom/ccr/batch_processor.pyBatchResultProcessor.process_results_make_continuation_call
HTTP 取回端点headroom/proxy/server.pyccr_retrieve(POST /v1/retrieve)
取回率反馈headroom/cache/compression_feedback.pyrecord_retrieval