面试知识库

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 分钟版#

  1. 压缩不自己写。自写的 block 级视图压缩截断点每轮后移,和前缀缓存打架,2026-09-14 整块删了(93167f6),交框架 compress_context。
  2. 但框架只有一招:整体超 trigger_ratio 就 LLM 写摘要,没有「清最旧工具返回」这种粗活。
  3. 补第二层(D3,3f5a292):tool_result_pruner(pre_think,priority 10)在工具返回总量超 PRUNE_TOOL_RESULTS_TOKENS=24000 时,从最旧开始换占位削到一半,最近 PRUNE_KEEP_RECENT=6 条不动。
  4. 原地改 Msg 不只改视图:ctx["messages"] 与 agent.state.context 是同一批对象,改 state 是「断一次然后稳定」,改视图是「轮轮都断」。
  5. 第三层是标记:CacheAwareOpenAIFormatter 只给 system 那条打一个 ephemeral 标记,位置和形态都不动。
  6. 前缀纪律:运行时每轮变的内容一律放 system 之后(长期记忆、近期行为、会话约束),system prompt 装配期定稿、钩子只许往末尾追加。
  7. 拿门禁盯住它(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_ratio 0.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 正文,本次未跑)。

机制怎么跑#

  1. 装配期定稿 system prompt:agents._run_system_prompt_hooks 跑 on_system_prompt 钩子,钩子只能往 context["append"] 里加,由它拼到末尾——不给改写整段的权力(怕有人悄悄删掉 <termination>)。
  2. 追加两段:context_shaping.append_system_prompt_blocks(priority 50)追加 <learned_strategies>(按用户原话匹配的在役策略)和 <trade_state>(待决议确认卡 + 本会话订单)。所以 system prompt 只在一次任务内字节稳定。
  3. 建 Agent:agents._assemble 传 system_prompt=base_prompt,不传 ContextConfig(走框架默认 0.8)。
  4. 每轮 pre_think:prune_old_tool_results(priority 10,排在 budget_router 前)扫 context["messages"] 里所有 tool_result block,总量 >24000 token 就从最旧开始 _set_block_output 换占位,直到降到 24000 // 2。
  5. planner 返回后:inject_long_term_memory(post_tool_call 50)把 select_tier_one_facts 选出的 tier-one 事实渲染成块,塞进 context["inject_messages"](role="system")。
  6. 注入落 state:adapter._persist_injections 把本轮新增注入写进 agent.state.context,形态是 HintBlock(framework 原生,formatter 渲染成 role="user")。
  7. 发请求前格式化:llm._formatter() 看 COMPRESS_CACHE_CONTROL(代码默认 False,.env.example:365 写 true)决定用不用 CacheAwareOpenAIFormatter。
  8. 打标记:format() → super().format() 得到真正的 payload dict 列表 → 第一条不是 system 就原样返回 → count_tokens 不足 1024 就不打 → 否则 _mark(formatted[0])(role="tool" 一律跳过,OpenAI 兼容端点要求 tool 消息 content 是字符串,改成 block 列表会 400)。
  9. 框架压缩:reply 开头估 input token,≥ trigger_ratio × context_size 时把较早消息交给模型写 continuation summary 进 state.summary,随 session.json 持久化。
  10. 记账: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-06da2d8b4断点下沉 block 级、标记钉死 system、注入落 state标记跟断点走会让前缀逐轮变;注入不落 state 下一轮就蒸发
2026-09-06ae4073e摘掉 LangChain,运行时收口 AgentScope统计口径与缓存基线随之重写
2026-09-1493167f6删 block 级压缩三处,压缩交框架 compress_context自写视图压缩与前缀缓存天然冲突,维护面还大
2026-09-193f5a292(D3)加 tool_result_pruner:最旧工具返回换占位,先于框架摘要框架只有 LLM 摘要一招;能白削的体积不该先花一次调用
2026-09-19484ad68(D5)加缓存命中率门禁:三遍中位 + 0.12 容差前缀塌方零报错,恒为真的断言守不住
2026-09-198b64026(M2)长期记忆注入改 tier-one facts,仍落在 planner 之后它每轮都变,混进 system 前缀会打断跨轮 prompt cache
2026-09-1953a48ea(S1)system prompt 瘦身 144 → 126 行,细则搬进 skill前缀短一截,且每轮都用不上的内容不该占前缀
2026-09-198b851f4(S2)删 skill 预注入,改模型自觉 Skill(...) + 同轮发出预注入会让同一份正文在多轮会话里躺 N 份(实测 3 轮会话出现 6 次)

数字与证据#

数字指什么来源状态
0.8框架压缩触发比例 ContextConfig.trigger_ratioagents.py:_assemble 注释(不传即默认)✓
24000 / 6PRUNE_TOOL_RESULTS_TOKENS / PRUNE_KEEP_RECENTapp/harness/hooks/context_shaping.py:40,43✓
削到 24000 // 2pruner 的停手线同上 target = PRUNE_TOOL_RESULTS_TOKENS // 2✓
1024 / 4缓存最小写入阈值 / 单请求标记上限app/harness/formatter.py 常量,实际恒 1 个✓
22.5kround3 基线「普通轮输入」,24k 阈值的依据3f5a292 正文 + context_shaping.py 注释✓(旧版核对过)
0.674 / 0.12门禁基线中位 / 容差scripts/eval/snapshot/test_cache_hit_gate.py✓
0.600 vs 0.852同一条 query 两遍命中率,网关侧噪声 ±0.13A0-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 / 约 102400OpenAIChatModel 默认 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;代码里没做。

坑与易混点#

  1. formatter.py 类 docstring 仍写「本仓 system prompt 纯静态、无运行时注入」,与 on_system_prompt 追加策略块 / 交易状态块的现状不符——只在一次任务内静态。
  2. S1 正文写 system prompt「144 → 126 行」,当前是 136 行(<trade_policy> 等后续又加回),别把 126 当现状引用。
  3. preference_inject 靠「planner 一轮只成功跑一次」这个惯例保证只注入一次,hook 自己没有闩——模型真再调一次 planner 就会再注入一次(docstring 自己标了)。
  4. 门禁的基线 0.674 绑死在一条 query(通勤双肩包,A0-3)上;换 query 或换工具序列,这个数不成立。
  5. COMPRESS_CACHE_CONTROL 代码默认 False、.env.example 写 true——看「标记有没有打」要看部署的 env,不是看代码默认值。

本章和别章的接口#

  • 网关闸门(并发 / 起点间隔 / 限流后推)与模型档位在第 9 章。
  • 记忆事实表、tier-one 选取、P_t 不参与摘要在第 5 章。
  • hook 点注册表、priority 顺序、run 状态表在第 6 章;skill 体系在第 14 章。