7. 上下文压缩与前缀缓存#
一句话结论:三层管上下文——formatter 打
cache_control保前缀、tool_result_pruner零成本削最旧工具返回、框架compress_context超 0.8 才花 LLM 写摘要;省钱真正靠「前缀字节逐轮不变」。
速背卡#
| # | hint | 展开一句 |
|---|---|---|
| 1 | 三层压缩:标记 / 削工具 / LLM 摘要 | formatter 保前缀、pruner 换占位、框架写 state.summary |
| 2 | 粗活在前,细活在后 | 能白削的体积不该先花一次 LLM 调用去总结 |
| 3 | 标记钉 system,≤4 个 | MAX_CACHE_MARKERS=4,实际恒为 1 个 |
| 4 | 不足 1024 token 不打标记 | MIN_CACHE_PREFIX_TOKENS,写了也进不了缓存 |
| 5 | 命中率靠前缀不靠标记 | 网关是隐式缓存,带不带标记第二次都命中 1024 |
| 6 | 每轮会变的,一律放 system 之后 | 记忆 / 历史 / 会话约束都走注入消息 |
| 7 | 门禁三遍中位,0.674−0.12 | 单遍噪声 ±0.13,恒为真的断言守不住塌方 |
30 秒版#
长对话的成本大头是每轮重发的历史。我分三层处理:cache_control 标记钉死在 system 那条上保住最长缓存层;tool_result_pruner 在 pre_think 把最旧的工具返回换成一行占位,零 LLM 成本;真超了 trigger_ratio 0.8 才让框架写 continuation summary。踩过的最大的坑是以为命中率来自标记,其实来自「前缀字节逐轮不变」,所以我加了一条三遍取中位的命中率门禁盯它。
3 分钟版#
- 压缩不自己写。自写的 block 级视图压缩截断点每轮后移,和前缀缓存打架,2026-09-14 整块删了(93167f6),交框架
compress_context。 - 但框架只有一招:整体超
trigger_ratio就 LLM 写摘要,没有「清最旧工具返回」这种粗活。 - 补第二层(D3,3f5a292):
tool_result_pruner(pre_think,priority 10)在工具返回总量超PRUNE_TOOL_RESULTS_TOKENS=24000时,从最旧开始换占位削到一半,最近PRUNE_KEEP_RECENT=6条不动。 - 原地改
Msg不只改视图:ctx["messages"]与agent.state.context是同一批对象,改 state 是「断一次然后稳定」,改视图是「轮轮都断」。 - 第三层是标记:
CacheAwareOpenAIFormatter只给 system 那条打一个 ephemeral 标记,位置和形态都不动。 - 前缀纪律:运行时每轮变的内容一律放 system 之后(长期记忆、近期行为、会话约束),system prompt 装配期定稿、钩子只许往末尾追加。
- 拿门禁盯住它(D5,484ad68):
scripts/eval/snapshot/test_cache_hit_gate.py跑三遍取中位,低于0.674 - 0.12就红。
它解决什么问题#
决策 1:压缩交框架,不自己维护视图压缩#
- 问题:多轮会话的 input 随轮数累积,需要削。
- 坏法:自写 block 级视图压缩,保留最近 3 个工具结果全文。截断点每轮往后挪,前缀每轮在变;而且要接管
on_compress_context,这个钩子每轮都调,得自己判阈值,否则每轮都截一次历史。坏了看不见——功能全正常,只有账单变贵。 - 修法:删掉(93167f6,删实现 219 行、测试 492 行),用框架默认
ContextConfig(app/agent/agents.py:_assemble不传,走默认trigger_ratio0.8)。 - 代价:摘要是二手上下文,过程细节会丢;这是有意接受的(
agents.py注释原话)。
决策 2:加一层工具返回式压缩(D3)#
- 问题:吃掉上下文的正是工具返回——十几条
item_search候选 JSON,每条几千 token,早几轮的模型早就不看了。 - 坏法:只等框架摘要。那意味着为一堆本来可以白扔的体积,先花一次 LLM 调用去总结它。
- 修法:
app/harness/hooks/context_shaping.py:prune_old_tool_results,超 24k 换占位到一半,占位文案写明「还需要这段内容就重新调用对应工具」。 - 代价:原文真的没了,模型要用得重调工具;阈值 24000 是按 round3 基线「普通轮输入 22.5k」拍的,正常一轮碰不到。
决策 3:cache_control 标记钉死在 system 那条#
- 问题:标记该打在哪条消息上。
- 坏法:第一版跟着压缩断点走。断点每轮后移,而打标记会把该条
content从字符串改写成 block 列表,前缀字节逐轮都变,缓存从第二轮起接不上(da2d8b4 正文)。 - 修法:
app/harness/formatter.py只看formatted[0],是 system 且 ≥1024 token 才打一个标记。 - 代价:Anthropic 直连时能显式缓存的只到 system 段,对话历史部分拿不到断点收益。
决策 4:每轮会变的东西不进 system 前缀#
- 问题:长期记忆、近期行为、会话约束都想塞给模型。
- 坏法:塞进 system prompt。它每轮都变,等于每轮从注入点起全部失配;2026-07 的注入块塌方就是同一个病,命中率 67.5% → 31.6%(5a27c27 正文,旧版核对过)。
- 修法:
<runtime_context>段在 prompt 里只写「这三者随用户本轮消息注入、不在本 prompt」;长期记忆由preference_inject(post_tool_call 50)在 planner 跑完后作为 system 注入消息发出(M2,8b64026)。 - 代价:模型要自己把
[constraint]写进工具入参,系统不替它并进检索。
决策 5:钩子注入先写进 state#
- 问题:漂移纠正 / 断言纠正 / 预算 hint 这类运行时提示怎么进上下文。
- 坏法:只加在这一次的
input_kwargs里。下一轮从state.context重建 messages,这条蒸发,上一轮第 n 条是「[漂移纠正]…」,这一轮第 n 条变成模型回复,前缀从注入点起全失配。实测 9 次调用的链前缀稳定率掉到 0.9,注入越多掉越狠。 - 修法:
app/harness/adapter.py:_persist_injections用HintBlock落进state.context。 - 代价:注入那一轮要多付一次重算;且注入会在历史里长驻,所以得按「进入状态时注入一次」设计。
决策 6:拿命中率当门禁,不拿「cached tokens > 0」#
- 问题:前缀塌方零报错、无测试变红。
- 坏法:断言
cached tokens > 0——永远绿,什么都没守住。 - 修法:
test_prefix_cache_hit_rate_not_below_baseline三遍取中位比BASELINE_MEDIAN 0.674 - TOLERANCE 0.12;另加一条「三遍全 0 也算失败」(通常是供应商换了 usage 字段名)。 - 代价:真 LLM 调用,标
-m llm不进默认 pytest;约 3 × 25s / $0.006(484ad68 正文,本次未跑)。
机制怎么跑#
- 装配期定稿 system prompt:
agents._run_system_prompt_hooks跑on_system_prompt钩子,钩子只能往context["append"]里加,由它拼到末尾——不给改写整段的权力(怕有人悄悄删掉<termination>)。 - 追加两段:
context_shaping.append_system_prompt_blocks(priority 50)追加<learned_strategies>(按用户原话匹配的在役策略)和<trade_state>(待决议确认卡 + 本会话订单)。所以 system prompt 只在一次任务内字节稳定。 - 建 Agent:
agents._assemble传system_prompt=base_prompt,不传ContextConfig(走框架默认 0.8)。 - 每轮 pre_think:
prune_old_tool_results(priority 10,排在budget_router前)扫context["messages"]里所有tool_resultblock,总量 >24000 token 就从最旧开始_set_block_output换占位,直到降到24000 // 2。 - planner 返回后:
inject_long_term_memory(post_tool_call 50)把select_tier_one_facts选出的 tier-one 事实渲染成块,塞进context["inject_messages"](role="system")。 - 注入落 state:
adapter._persist_injections把本轮新增注入写进agent.state.context,形态是HintBlock(framework 原生,formatter 渲染成role="user")。 - 发请求前格式化:
llm._formatter()看COMPRESS_CACHE_CONTROL(代码默认False,.env.example:365写true)决定用不用CacheAwareOpenAIFormatter。 - 打标记:
format()→super().format()得到真正的 payload dict 列表 → 第一条不是 system 就原样返回 →count_tokens不足 1024 就不打 → 否则_mark(formatted[0])(role="tool"一律跳过,OpenAI 兼容端点要求 tool 消息 content 是字符串,改成 block 列表会 400)。 - 框架压缩:reply 开头估 input token,≥
trigger_ratio × context_size时把较早消息交给模型写 continuation summary 进state.summary,随session.json持久化。 - 记账:
app/agent/usage.py:summarize_usage从记账树(app/harness/token_budget.py:run_snapshot)取carried_input_tokens/cache_read_tokens,算出cache_hit_rate;peak_input_tokens从消息侧补(树只累加、不留极值)。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 2026-09-06 | da2d8b4 | 断点下沉 block 级、标记钉死 system、注入落 state | 标记跟断点走会让前缀逐轮变;注入不落 state 下一轮就蒸发 |
| 2026-09-06 | ae4073e | 摘掉 LangChain,运行时收口 AgentScope | 统计口径与缓存基线随之重写 |
| 2026-09-14 | 93167f6 | 删 block 级压缩三处,压缩交框架 compress_context | 自写视图压缩与前缀缓存天然冲突,维护面还大 |
| 2026-09-19 | 3f5a292(D3) | 加 tool_result_pruner:最旧工具返回换占位,先于框架摘要 | 框架只有 LLM 摘要一招;能白削的体积不该先花一次调用 |
| 2026-09-19 | 484ad68(D5) | 加缓存命中率门禁:三遍中位 + 0.12 容差 | 前缀塌方零报错,恒为真的断言守不住 |
| 2026-09-19 | 8b64026(M2) | 长期记忆注入改 tier-one facts,仍落在 planner 之后 | 它每轮都变,混进 system 前缀会打断跨轮 prompt cache |
| 2026-09-19 | 53a48ea(S1) | system prompt 瘦身 144 → 126 行,细则搬进 skill | 前缀短一截,且每轮都用不上的内容不该占前缀 |
| 2026-09-19 | 8b851f4(S2) | 删 skill 预注入,改模型自觉 Skill(...) + 同轮发出 | 预注入会让同一份正文在多轮会话里躺 N 份(实测 3 轮会话出现 6 次) |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 0.8 | 框架压缩触发比例 ContextConfig.trigger_ratio | agents.py:_assemble 注释(不传即默认) | ✓ |
| 24000 / 6 | PRUNE_TOOL_RESULTS_TOKENS / PRUNE_KEEP_RECENT | app/harness/hooks/context_shaping.py:40,43 | ✓ |
| 削到 24000 // 2 | pruner 的停手线 | 同上 target = PRUNE_TOOL_RESULTS_TOKENS // 2 | ✓ |
| 1024 / 4 | 缓存最小写入阈值 / 单请求标记上限 | app/harness/formatter.py 常量,实际恒 1 个 | ✓ |
| 22.5k | round3 基线「普通轮输入」,24k 阈值的依据 | 3f5a292 正文 + context_shaping.py 注释 | ✓(旧版核对过) |
| 0.674 / 0.12 | 门禁基线中位 / 容差 | scripts/eval/snapshot/test_cache_hit_gate.py | ✓ |
| 0.600 vs 0.852 | 同一条 query 两遍命中率,网关侧噪声 ±0.13 | A0-3 实测,484ad68 正文 | ✓(commit 正文) |
| 3 × 25s / $0.006 | 门禁一次的耗时与花费 | 484ad68 正文 | 仅口径(本次未跑) |
| 67.5% → 31.6% | 2026-07 注入块塌方时的命中率 | 5a27c27 正文 | ✓(旧版核对过) |
| 78.1% | 修正统计口径后同一条 query 的命中率 | fc54ab6 正文 | ✓(旧版核对过) |
| 0.7459 → 0.789;58.4s → 41.1s | 全程关 thinking 前后命中率中位、墙钟中位,每组 3 遍 | latency_tier_ab.json(09-09) | ✓(旧版核对过) |
| 136 行 / 11133 字节 | 当前 system_prompt 段大小(9 个 XML 段) | prompt/prompts.yml 第 27~162 行 | ✓ |
| 6 次 | 预注入在 3 轮会话里重复出现的次数 | 8b851f4 正文 | ✓(commit 正文) |
| 128000 / 约 102400 | OpenAIChatModel 默认 context_size 与推算触发点 | 框架源码,本仓不传 context_size | 未验证(线上是否真触发过无计数) |
追问 10 题#
Q1. 三层压缩分别管什么?
① formatter 打 cache_control(保前缀,不削体积);② tool_result_pruner 削工具返回(机制层、零 LLM);③ 框架 compress_context 写摘要(花 LLM)。证据:context_shaping.py 模块 docstring 的「两层压缩的分工」+ formatter.py 模块尾段。
Q2. ⚠ 为什么粗活必须排在细活前面?
能白削掉的体积没必要先花一次 LLM 调用去总结它。pruner 在 pre_think priority 10,框架摘要在 reply 开头按 trigger_ratio 判——削完再判,摘要可能压根不触发。证据:3f5a292 正文。
Q3. ⚠ pruner 为什么原地改 Msg 而不是只改本轮视图?
ctx["messages"] 与 agent.state.context 是同一批对象,原地改等于改持久化状态。只改视图的话每轮都要重清、每轮断在不同位置,前缀轮轮都断,比不清还亏;改 state 是断一次然后形态稳定。代价是原文真没了,所以占位文案写「重新调用对应工具」。
Q4. 标记为什么打在 formatter,不打在 Msg 上?
TextBlock 是 pydantic 强类型不收 cache_control;Msg.metadata 是消息级的,而 AgentScope 一整轮(轮内所有 tool_call / tool_result)就是一条 assistant 消息,标它等于标一整轮。formatter 输出的 dict 就是线上 payload。
Q5. ⚠ 标记到底带来多少命中率?
在当前网关(DashScope 兼容口,隐式前缀缓存)是零:带标记组和不带标记组第二次都命中 1024 token(formatter.py 模块 docstring,仅口径)。继续打是因为成本为零、换 Anthropic 直连时必需。命中率真正来自前缀字节稳定。
Q6. 什么东西不能进 system 前缀?判据是什么?
判据一句话:每轮会变的一律放 system 之后。长期记忆、近期行为、会话约束都走注入消息,prompt 里 <runtime_context> 只留一段说明。证据:prompts.yml 的 <runtime_context>、context_shaping.inject_long_term_memory docstring 末段。
Q7. 那 system prompt 里追加的策略块和交易状态块不也会变?
会,所以它只在一次任务内字节稳定,跨会话共享的只是追加之前那段基础 prompt。钩子契约是只许追加、按 priority 升序拼,所以同一批策略每轮渲染的字节一致(agents.py:_run_system_prompt_hooks)。
Q8. S2 删 skill 预注入和缓存有什么关系?
HarnessSession 每轮新建而 state.context 跨轮累积,预注入会让同一份 skill 正文在多轮会话里躺 N 份(实测 3 轮出现 6 次)——既占 token 又让上下文形态每轮不同。改成模型自觉调 Skill(...),并要求「和本轮第一个检索/读取工具同一轮发出」。
Q9. ⚠ 缓存命中率怎么算的,为什么不从消息上数?
cache_hit_rate = cache_read_tokens / carried_input_tokens,分子分母都取自记账树(token_budget.run_snapshot)。从消息数会得到 model_calls 恒为 1,携带量和命中量只反映最后一次调用(usage.py 模块 docstring)。
Q10(压力题). 框架压缩到底触发过没有?
代码事实:build_model 没传 context_size,OpenAIChatModel 默认 128000,所以约 102400 token 触发;而 usage.py 写主模型是 1M 窗口。线上有没有触发过——框架只打一条 logger.info,本仓没有计数或产物,未验证。真要改,应该在建模型时按实际模型传 context_size,或显式传 ContextConfig,再给「触发压缩」加一个 metric;代码里没做。
坑与易混点#
formatter.py类 docstring 仍写「本仓 system prompt 纯静态、无运行时注入」,与on_system_prompt追加策略块 / 交易状态块的现状不符——只在一次任务内静态。- S1 正文写 system prompt「144 → 126 行」,当前是 136 行(
<trade_policy>等后续又加回),别把 126 当现状引用。 preference_inject靠「planner 一轮只成功跑一次」这个惯例保证只注入一次,hook 自己没有闩——模型真再调一次 planner 就会再注入一次(docstring 自己标了)。- 门禁的基线 0.674 绑死在一条 query(通勤双肩包,A0-3)上;换 query 或换工具序列,这个数不成立。
COMPRESS_CACHE_CONTROL代码默认False、.env.example写true——看「标记有没有打」要看部署的 env,不是看代码默认值。
本章和别章的接口#
- 网关闸门(并发 / 起点间隔 / 限流后推)与模型档位在第 9 章。
- 记忆事实表、tier-one 选取、P_t 不参与摘要在第 5 章。
- hook 点注册表、priority 顺序、run 状态表在第 6 章;skill 体系在第 14 章。