面试知识库

M6 · 上下文压缩(Cache Breakpoint)—— 开发文档(面试向)#

讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话讲出来。

续集:本篇保住的是轮内缓存命中;「下一个 query 能不能吃上一个 query 的 KV cache」这个跨轮问题,见 M6.1-跨轮prompt缓存命中治理.md(system 静态化 + 运行时上下文外置到当轮 human + system 段独立缓存标记)。

一句话概括#

M6 解决一个很实在的钱包问题:一个会聊几十轮、还会跨平台搜一堆商品的购物 Agent,对话历史只增不减,几十轮下来上下文能涨到几万 token,而每次请求模型都要把这几万 token 全量发一遍——又慢又贵。直觉解法是”把历史压缩一下”,但这章最大的反直觉结论是:脱离缓存谈压缩,省下的都是假的。所以 M6 做的不是”暴力压缩”,而是 Cache Breakpoint(缓存断点)——把对话切成”能缓存的较旧区”和”易变的最近区”,只在不破坏缓存命中的前提下压缩。

打个比方:你寄快递,前面几页是固定不变的合同条款(可以提前打包好、重复用),最后一页才是这次的收件信息(每次都变)。聪明的做法是把固定那几页”封存复用”,只动最后一页;笨的做法是每次把整摞纸重抄一遍——抄得越”省纸”,反而越费工。


1. 痛点:上下文只增不减,token 成本失控#

AgentLoop 是 Think→Act→Observe→Reflect 循环,每转一圈就往历史里追加消息:模型的思考、工具调用、工具返回……只加不减。我们的工具又特别能”产出”——一次 item_search 返回几十件商品的结构化数据,一次跨平台并行能堆上万行。结果就是:

  • 成本:每轮请求都把全部历史重发,token 越堆越多,按量计费线性上涨。
  • 延迟:prompt 越长,首 token 越慢。
  • 质量:关键信息淹没在中间,模型”看不见”(业界叫 Lost in the Middle)。

这不是”优化项”,是”基础设施项”。

一处后来确认的事实(更正早期”会撞窗口崩掉”的说法):本项目主模型 deepseek-v4-flash 的上下文窗口高达 100 万 token,所以”堆到撞窗口崩掉”几乎不会发生。但这不代表可以不治理——窗口够大 ≠ 模型不会变笨:上下文越长,成本线性上涨质量因 Lost in the Middle 下滑。所以治理目标从”防溢出”转成了”控成本 + 防变笨”。这条贯穿后面 §8–§11 的所有补充。

面试怎么讲:这是所有长对话 Agent 都会撞的墙。根因是 AgentLoop 的历史单调增长,必须有一层专门治理上下文。即便窗口大到不会溢出,“长上下文让模型变笨 + 成本上涨”也逼着你治理——只是动机从”怕崩”变成了”怕笨、怕贵”。


2. 核心洞察:压缩和缓存是同一个问题的两面#

主流大模型都有 Prompt Cache:把请求的前缀缓存起来,下次请求只要前缀一字不差,就直接复用,省掉 40–50% 的输入费、50–80% 的首 token 延迟。但它有个苛刻前提:必须前缀逐字精确匹配,差一个空格都不算。

这就引出了关键矛盾:你每轮都去”微压缩”历史(删几条、改写几句),前缀就变了;前缀一变,缓存全废。 省下来的压缩费,被掉缓存后原价重算的部分吃回去还不止——综合成本不降反升。

所以正确的提问不是”怎么把历史压小”,而是”怎么在不动缓存前缀的前提下压小历史”。这就是 Cache Breakpoint 的全部出发点。

面试怎么讲:很多人做压缩只盯 token 数,忽略了缓存命中率。这俩是耦合的——脱离缓存命中率谈压缩率,就像脱离网络谈序列化方案,可能得出完全相反的结论。


3. Cache Breakpoint:把对话切成两段,各管各的#

做法是在历史里划一条线(断点):

  • 断点之前(较旧区):这段历史不再频繁变化,最适合当缓存前缀。我们在这里压缩——把过大的工具结果尾部截断——压完就”冻结”,之后一字不动,缓存可持续命中。
  • 断点之后(最近 K 轮工具调用):这是模型当前正在用的”工作集”,保留全文,让模型看到刚拿到的结果全貌。这段易变、不缓存,所以压不压都不影响缓存

断点位置怎么定?以”工具调用”为轮次标志,把最近 K 轮留在断点之后,更早的放进可压缩的较旧区(K 走配置,默认 3)。

一个必须诚实交代的取舍#

文档里很容易写成”前缀永远不变、缓存永远命中”——这是夸大。真相是:断点会随对话推进往后移,所以一条工具结果在”最近区”时是全文,等它被新轮次挤出窗口、跨进较旧区的那一轮,会被截断一次(此后冻结)。也就是说:

  • 真正逐字稳定、能持续命中的,是较旧区 [:断点],而且这块只增不减、逐轮单调变长——这才是缓存吃到的那段。
  • 跨界的那条消息会”重算一次”,这是滚动增量缓存的正常代价,不是 bug。

为什么不干脆”位置无关地”把所有大结果都压了(那样前缀就严格不变了)?因为我故意让最近 K 轮保留全文——模型需要看到它刚搜回来的商品全貌才能好好精挑。用”边界处一次性重算”换”最近结果的精度”,这是有意识的设计选择,不是疏忽。

面试怎么讲:我会主动说清楚这个边界行为。如果有人问”你这前缀真的稳定吗”,我答:稳定的是较旧区,且它单调增长;最近区是易变工作集、本就不指望缓存。这种”诚实标注代价”比吹”零成本”更可信。


4. 落到真实端点:隐式缓存才是默认主路径(一处重要更正)#

这一节整体更正了早期版本。 早期文档写的是”cache_control 只有 Anthropic 原生 API 才认,在我们的 OpenAI 兼容端点是无害的空操作,留着是为了可移植性”。后来查实本项目端点(阿里云 DashScope)的真实缓存机制,结论变了——而且是好消息

DashScope 的 OpenAI 兼容端点同时支持隐式 + 显式两种缓存,每次请求二选一、互斥

  • 请求里 cache_control 标记 → 走显式缓存;
  • 不带 → 系统自动走隐式缓存(零配置)。

我们的开关(COMPRESS_CACHE_CONTROL默认关,所以默认就在走隐式缓存。关键在于:隐式缓存按”前缀逐字匹配、自动命中”,而第 3 节那套”较旧区压完冻结、单调增长、前缀字节稳定”的设计,恰恰就是隐式缓存最爱的形状。也就是说——我们为缓存做的所有功一分没白费,只是命中它的是”隐式”机制,而不是手写的标记。 缓存这部分根本不是空操作,是实实在在生效的(还能用 §9 的观测看见命中率)。

补充几条查实的细节,免得面试被追问翻车:

  1. DeepSeek 原生就是纯自动缓存(无 cache_control,命中报在 prompt_cache_hit_tokens)——隐式是 DeepSeek 的”母语”,所以走隐式是最顺的选择。
  2. 显式那套的常量竟然对得上:DashScope 显式缓存也是「≤4 标记 / ≥1024 token / 5min TTL」——这几个数我早写进了打标记逻辑(当时以为是 Anthropic 专属),实测 DashScope 一致,虚惊一场。
  3. 但显式有个真坑:DeepSeek / Qwen3.5+ 只支持 message 级断点——同一条消息的 content 里塞多个标记不会生成多个断点。我们的代码只在一条消息上打一个标记,恰好兼容;可一旦想打多个就会踩这个坑。
  4. 所以显式保持默认关:开了显式就关掉隐式,而那一个标记缓存的范围和隐式自动缓存的差不多——等于用”禁用全自动”换”手动管一个断点”,收益≈0 还多了改写正文(str→content-block)扰动前缀的风险。

面试怎么讲:这一节我会主动讲”我更正过自己”。早期我把缓存当 Anthropic 专属空操作,是信息不全;查证后发现默认走的隐式缓存才是主路径、而且正好吃我稳定前缀的红利。这体现一个习惯——把”我以为”和”查证后”分开,发现矛盾就更正、不嘴硬。约束(≤4 / 阈值 / 逐字匹配 / message 级断点)我仍然当一等公民写进逻辑,因为缓存这东西约束比功能更容易踩坑。


5. 怎么挂到主 loop:改”模型看到的视图”,不改”存档的历史”#

压缩挂在哪、怎么挂,有个关键设计:用 LangChain 的 wrap_model_call 中间件,在每次请求模型之前把历史压一遍再发出去——但只改这一次请求送给模型的视图,checkpoint 里存的完整历史一字不动

这点很重要:压缩是”给模型的视图瘦身”,不是”把历史删了”。历史还在存档里,需要时(比如回放、审计)还能拿到全量。这样压缩既省 token 又不丢信息,是个纯收益的位置。

按项目一贯节奏(沿用 M2 的”先做原语、后挂主 loop”),M6 先把压缩函数和中间件做对、测透,真正挂到主 Agent 上是 M9 合龙时的事——中间件已经写成 create_agent(..., middleware=[...]) 能直接吃的形状,也用真实模型验证过能挂上去跑。

面试怎么讲:我会强调”压缩对象是请求视图而非持久状态”。很多实现直接改 message 历史,结果信息真丢了、还容易和 checkpoint/回放打架。把压缩限制在”出口处的一次性视图变换”,副作用最小。


6. 五层压缩体系里,这章只是其中一层#

Cache Breakpoint 属于偏轻的 L2(Cache-Aware 微压缩)。完整体系从轻到重是:

  • L0 工具侧防线(投入产出比最高):让工具返回在进上下文前就控住体积——item_search 默认只返 20 件、只留关键字段。源头省一刀,胜过事后压十刀。这一刀后来又磨利了一层(见 §10):候选里的 url / image_url 模型推理用不上、只供前端卡片,却随候选一路占输入又被回吐占输出——把它俩从模型视图里拿掉、改用旁路登记表供卡片,单次检索再省 ~25%。
  • L1 工程手段:动态 max_tokens、服务端缓存等。
  • L2 Cache Breakpoint(本章):断点 + 较旧区截断。
  • L3 会话压缩:上下文逼近阈值时用 LLM 把较旧区摘成一条结构化摘要。结论已更清楚(见 §11):1M 窗口下它不是”防溢出”(不会溢出),而是”防变笨 / 控成本”;已出了一份”上架备用”的设计,开不开由 §9 的观测数据决定,没盲目先建。
  • L4 Session Memory:用结构化记忆文件替代全量历史——这正是 M7 长期记忆要做的事。

面试怎么讲:我不会把压缩当成单点技术,而是分层兜底。L0 最便宜也最有效,所以工具一开始就限量返回(后来还把没用的字段也挤出模型视图);L2 管”已经进来的怎么办”;真扛不住才上 L3 的 LLM 摘要。越往上越重,能不用就不用——而”用不用得上”由观测数据说话,不拍脑袋。


7. 复盘:code review 抓出来的两个真坑#

高强度 review(多角度 finder + 验证)抓出两个真问题,已修 + 补了回归测试:

  1. 极小阈值下的负数切片:截断时”保留前多少字符”算成了 上限 - 提示语长度。如果有人把每条上限配得极小(比上限还短),这个数会变负,负数切片在 Python 里会从尾部切——结果”压缩”后几乎是原文、甚至更长,彻底失效。修法:把它钳到 ≥0。
  2. 结构化 content 被压坏:工具结果万一是”多模态 block 列表”(不是纯字符串),原逻辑会把它 str() 成一个 Python repr 字符串再截断存回去,结构就毁了、类型也从 list 变 str。我们当前工具都返字符串,所以这是潜伏坑——修法:只压字符串载荷,block 列表一律跳过不碰。

还有一类是 review 提出的”夸大稳定性”——我据此把注释/文档里”前缀逐字不变、持续命中”这种话改成了第 3 节那段诚实交代。

面试怎么讲:这两个 bug 都不是”现在会崩”,而是”配置一极端 / 数据一变形就崩”的潜伏坑。我会讲我怎么用对抗式 review 把它们提前挖出来,并补成回归测试钉死——而不是等线上出事。


8. token 得算准:从”÷4”到真分词器(CJK 修复)#

以下 §8–§11 是 M6 之后的一轮集中加固,顺带把前面”以为”的地方一并更正(已在 §1、§4 标注)。

L2 的截断要按”token 上限”切,但原实现用一个粗估:1 token ≈ 4 字符。这对英文还行,对中文严重失真——实测 Qwen 分词器下中文约 1.33 字/token、英文约 4.2 字/token,“÷4”把中文的 token 数低估约 3 倍

对一个跨语言电商 Agent(Lazada / Shopee / Shein 大量中文与东南亚语料),后果很实在:所谓”每条 1500 token 上限”对中文形同虚设——该压的没压,连缓存的”≥1024 token 才写入”阈值也会误判。一个看着无关紧要的常量,在跨语言场景就是个真 bug。

做法:换成真分词器为主、CJK 启发式兜底

  • 主路径用 DashScope 自带的本地分词器(Qwen byte-level BPE,离线、不耗网络);没装 / 非 Qwen 链路时自动降级到”按字符类别分桶”的 CJK 启发式(中文 ~0.75 token/字、拉丁 ~0.25)。一个统一入口收口,调用方不关心后端。
  • 性能:实测首次加载约 3.3s(一次性),但单次 encode 只 0.1ms。所以分词器实例只建一次、逐条计数再缓存,并在服务启动时预热,把那 3.3s 吃在 boot、不落用户请求。
  • 截断不切坏字:按”本段实际 字符/token 比例”换算字符预算再切,切点落在码点边界,不会切出半个 UTF-8 字 / 破坏 JSON。

一个诚实标注:主模型其实是 DeepSeek,我们用 Qwen 分词器当代理(两者都 BPE、CJK 密度相近,对”控体积”足够;要 DeepSeek 精确数得换它的分词器,对压缩不值得)。

面试怎么讲:我会讲怎么用真分词器把这个 bug 量出来——同一段中文 2080 字 = 1680 token,而”÷4”会算成 520,差 3 倍。一个常量,跨语言场景下就让整套 token 治理对中文失效。先实测、再修、再诚实标注代理的局限。


9. 测量驱动:把”携带量”和”缓存命中率”观测起来#

1M 窗口下,“L3 到底要不要上、什么时候上”不能拍脑袋——得有数据。所以加了一层每轮 token 用量观测:跑完一轮,把每次模型调用的用量聚合出来,进日志 + Langfuse。

看三个数:

  • carried / peak input tokens:本轮各次调用实际发出的 input(= 压缩后真实携带量)之和与峰值。峰值最贴近”会不会变笨”。
  • cache hit rate:从响应的 cached_tokens 算——这正好验证 §4 的隐式缓存是不是真命中。之前是”理论上应该命中”,现在能看见

这层的价值是闭环:① 证明缓存设计真生效(不是自我安慰);② 给 L3 提供判据闸门——峰值爬到”质量开始下滑”的点,才考虑上 L3。

一个诚实标注:命中率依赖 DashScope 回传 cached_tokens;它若不报会显示 0——那本身也是结论(得换别的方式验证),不会被一个假高的数字骗。

面试怎么讲:我不靠直觉决定要不要上重武器(L3),先把判据测出来。这是”测量驱动 vs 过早优化”最直接的体现——而且观测顺带验证了我前面对缓存的判断对不对。


10. L0 再深一层:让 url / 图片不穿过模型#

先得看清一个数据流真相:商品候选在工具间不是靠服务端传,而是靠 LLM 把候选列表当工具参数一路透传(item_search → 比价 → 运费 → 精挑 → 收尾)。所以每件商品的每个字段,在每一跳都既占输入、又被模型回吐占输出。

⚠️ 后续泛化(见 Mperf.1:本节只让 url/image 不穿过模型(旁路登记表)。后来把这个思路推到底——工具间只传 item_id、工具内按 id 从登记表 hydrate 回全量候选,整包候选彻底不再当参数穿过模型(原 list[候选] 参数降级为对模型不可见的注入参数)。所以下文「候选每跳被回吐」这个数据流真相,现在只在登记表未命中的降级路径上成立;主路径已无候选回吐。

哪些字段在白占?urlimage_url——又长、模型推理根本用不上,只有最后前端商品卡才需要。让它们穿过模型,纯属双向浪费(输出占用还正好压在延迟大头”解码”上)。

解法是把”模型可见的序列化”和”卡片要的真值”解耦

  1. 工具喂给模型的形态改成紧凑 JSON 且去掉 url/image(而不是冗长的对象 repr);
  2. item_search 把全量候选(含真实 url/image)按 item_id 登记到会话级登记表
  3. 收尾时按 item_id 从登记表回填卡片的图与链接——url 不再需要穿过模型,模型丢没丢、改没改都不影响卡片。

收益:单次 20 条候选的检索结果从 3773 → 2814 token(省 ~25%),而且这是单跳——四跳透传 + 输出回吐处处都省。

取舍 + 验收边界(诚实):只先砍了 url/image 两个最长字段(行为最安全,模型本就不用它们);score / weight_kg 等还有水分,留给 §9 的数据再决定要不要继续砍。另外——这套目前由单测 + token 实测验证过,但还没跑真实 LLM 端到端确认”卡片在真 fork 下仍正常填充、选品行为无漂移”,上线前应真跑一轮。

面试怎么讲:关键不是”删两个字段”,是先看清候选靠 LLM 透传这件事,才看出 url 在 input+output 双向白占。解法是解耦——一个紧凑 JSON 给模型看,一个旁路登记表按 item_id 供卡片。这跟 L0 的精神一致:别让没用的东西进上下文,只是这次连”进了也用不上的字段”都挤了出去。


11. L3 会话摘要:设计先上架,开不开由数据定#

L3 = 阈值触发、用快模型把较旧区摘成一条结构化”会话状态”。它和 L2 是不同压力区间、不是二选一:

L2 截断(已做)L3 摘要(设计上架)
频率每步罕见,逼近阈值才一次
成本零(纯函数)一次 LLM 调用
确定性确定 / 幂等非确定
对缓存保前缀稳定炸缓存(重写前缀)

所以 L3 是”断路器”,绝不替代 L2。设计上架,要点:

  • 触发:由 §9 的 peak token + eval 定标在”质量开始掉的点”(几万级,不是逼近 1M);带滞回防抖(摘完体量骤降,老区再涨一截才重摘)。默认关。
  • 摘什么:老区摘成结构化(意图 / 硬约束 / 软偏好 / 已搜平台 / 存活候选 / 决策 / 待办)。铁律——存活候选要保 item_id 的 JSON 行,不能揉成散文,否则按 item_id 引用断链、§10 的卡片回填也废。
  • 怎么和缓存共处:滞回让重摘很罕见 → 每次只一次性 miss、两次之间摘要块冻结后重新被缓存(和 L2”跨界一次重算”是同一哲学的粗粒度版)。
  • 怎么和记忆共处:摘要可顺带抽新偏好喂长期 Store;摘要本身就是”会话记忆”,可落盘供续聊替代全量回放(走向 L4 / M7)。

为什么没建:1M 窗口 + YAGNI + 测量未出。它有真实重量(多一次 LLM、炸缓存、引入非确定状态),在没有数据证明 L2 不够之前先建它就是过早优化。所以设计先上架、开关默认关,等 §9 的数据说话。

面试怎么讲:我把 L3 想清楚但不急着建,正是因为它重。判断”要不要上”的依据是 §9 的实测,不是感觉。这也呼应整篇的主线——分层兜底、越重越克制、用数据而非直觉决定何时加码。


12. 一句话总结 + 可能的追问#

总结:M6 把”长对话 token 失控”当基础设施问题治理。核心是认清”压缩与缓存是一体两面”,用 Cache Breakpoint 在不破坏缓存前缀的前提下压缩较旧区、保留最近区全文;压缩只改请求视图不改存档历史。后续一轮加固又补齐了四块:把 token 算准(真分词器替掉对中文失真的 ÷4)、把缓存看清(默认走的是 DashScope 隐式缓存,正好吃稳定前缀红利,不是空操作)、把 L0 挤干(url/图片不穿过模型、旁路登记表供卡片)、把测量接上(携带量 + 命中率进 Langfuse,作为是否上 L3 的判据)。给定 1M 窗口,治理目标是”控成本 + 防变笨”而非”防溢出”,L3 设计上架、按数据再开。

可能追问 & 我的答法

  • “你这前缀真稳定吗?” → 稳定的是较旧区且单调增长;跨界消息一次性重算是滚动缓存的正常代价,最近区本就不缓存。
  • “为什么不位置无关地全压,那样前缀就严格不变了?” → 那会把模型刚拿到的最新结果也压了,损精度。我用边界一次重算换最近全文,是有意取舍。
  • “OpenAI 兼容端点用得上缓存吗?” → 用得上,而且是默认隐式缓存(零配置、自动按前缀命中),我那套稳定前缀正好吃它的红利;显式 cache_control 是可选项、和隐式互斥、默认关。(这条更正了我早期”空操作”的说法,见 §4。)
  • “1M 窗口还压缩干嘛?” → 防溢出确实不用了,但防变笨(Lost in the Middle)+ 控成本还要;治理动机转了向,不是取消治理。
  • “怎么知道压缩 / 缓存真生效?” → 接了每轮用量观测(携带量峰值 + cached_tokens 命中率)进 Langfuse,用数据说话,也用它决定要不要上 L3(§9)。
  • “token 上限对中文也准吗?” → 早期 ÷4 对中文低估约 3 倍,已换真分词器(Qwen)+ CJK 启发式兜底;主模型是 DeepSeek,用 Qwen 分词器是够用的代理(§8)。
  • “压缩了历史,模型会不会丢信息答错?” → 压的是较旧区的超大工具结果(旧细节),留了提示可重取;最近区全文;且持久化历史没动。真要保关键信息靠 M7 长期记忆(L4),不靠压历史。