为什么对话历史会在你看不见的地方悄悄涨到爆
对话历史每轮在悄悄变长,多半是框架替你托管的,代码里看不见——只有实测增长曲线才照得出它离上限多远。
立靶:system prompt 才三千字,为什么长会话还是会爆
一个常见的错觉:「我的系统提示词就三千字,离模型上限远着呢,不用担心 token。」
这句话只算对了一半。模型每轮真正吃进去的,不只是那段静态的 system prompt,还有一路累积下来的全部对话历史。system 是静态的,写完就不动了;可历史是动态的,每多一轮,就在前一轮的基础上再叠一层。一通几分钟的语音通话十几二十轮下来,每轮都把之前所有轮的内容重新带进去一次,真正咬人的从来不是那三千字的 system,而是这条随轮数线性上涨的历史。
更麻烦的是:这条上涨曲线,你在代码里多半看不见。很多框架替你托管对话历史——你只管 append 一条消息,框架在背后决定每轮带多少历史进 LLM。你的代码里没有一个变量在记「现在历史多大了」,所以它默默涨到逼近上限、开始变慢变贵甚至被截断时,你常常是在线上才发现的。
这就是上一讲说的暗物质,在对话历史这一层的典型形态。这一讲要回答三个问题:对话历史到底归谁管?怎么在爆之前看见它涨?什么时候该自己接管压缩?
框架:先分清谁在托管历史,再谈压缩
动手之前先做一次概念辨析。同一个「状态」里其实塞了三样生命周期完全不同的东西,混在一起谈优化必然踩空。
| 对话历史(history) | 任务状态(state) | 长期记忆(memory) | |
|---|---|---|---|
| 生命周期 | 单 session 内累积 | 单 session 内推进 | 跨 session 持久 |
| 谁来管 | 多半框架托管(暗物质重灾区) | 自己代码维护 | 自己落盘(画像 / summary) |
| 爆量风险 | 随轮数线性涨 | 几乎不涨 | 注入时一次性 |
一句话定位:这一层出问题,绝大多数是 history——框架在背后拼,你看不见它涨;state 和 memory 通常是你自己代码控制的、可见的。把优化精力放在几乎不涨 token 的 state 上,是这一层最常见的用错力气。
那么 history 归谁管?分三种托管模式,模式决定了你能不能控。
| 模式 | 谁拼历史 | 你能控吗 | 典型形态 |
|---|---|---|---|
| A · 框架全托管 | 你只 append,框架决定每轮带多少进 LLM | 看不见、需实测才能控 | 某全托管 Agent 框架;某些 Assistants 类 API(默认全保留) |
| B · 自管 | 你自己维护 message 数组、自己决定何时压缩 | 完全可见可控 | 自己写 session 循环、自己存 summary |
| C · 供应商黑盒 | 端到端 session 内部自管历史,你碰不到 | 完全不可控 | 某些端到端语音 realtime session |
拿到一个项目,第一个动作就是问:这个项目的 history 是 A / B / C 哪一种? 这一问直接决定后面三件事——要不要实测增长曲线(A 必须)、要不要自己写压缩(A/B 才有得写)、还是干脆放弃这一层的优化(C 是黑盒,写不动)。
模式 A 是最危险的,因为它的默认值常常是「全保留」——框架不会主动帮你压,它只是忠实地把所有历史一轮轮带进去,直到撞上限。「框架会帮我处理」是这一层最贵的一厢情愿。
如果落在 A 或 B,就要做压缩设计。压缩不是一个开关,是五个要素:
| 要素 | 要回答什么 | 常见坑 |
|---|---|---|
| 水位线 | 触发阈值设多少(如历史超过某 token 数 / transcript 超过某字数) | 凭感觉拍,不对照模型上限 |
| 压缩什么 | 旧轮摘要化、大 tool_result 外置传引用、检索文档只留摘要 | 一刀切压掉关键槽位 |
| 谁来压 | 自己写压缩逻辑,还是委派框架 runtime | 以为委派了,其实没接通路 |
| session kickoff 续接 | 会话结束落一份 summary,下次启动注入续上 | 压完不续接,下个 session 从零开始 |
| 规则 ≠ 实现 | 写了一份压缩规则文档,实跑通路真的接了吗 | 文档躺着,通路根本没调用它 |
最后一条要单独拎出来强调,因为它是这一层最隐蔽的陷阱:写了一份压缩规则,不等于你实际跑的那条通路里实现了它。 设计稿上规则写得很漂亮,可如果 pipeline 通路压根没接这套逻辑,历史照样是全交框架黑盒——你以为压了,其实一点没压。这和「设计稿 vs 实跑」是同一类病:规则在文档里,实现不在通路里。
数据 / case:增长曲线的形状会替你说话
测这一层,唯一靠谱的办法不是读代码猜,而是画一条「轮数 → input token」的增长曲线。具体做法只有四步:跑一通完整的真实会话 → 在每次 LLM 调用处 dump 那一轮真实的 input_tokens → 按轮数记账画出来 → 对照模型的 context 上限,按斜率外推看离上限还有多远。
曲线的形状会直接告诉你这一层处在什么状态,比任何代码注释都诚实:
| 曲线形状 | 说明历史发生了什么 | 该做什么 |
|---|---|---|
| 线性上涨 | 没压缩,全部历史每轮全保留 | 按斜率外推,算清几轮后撞上限 |
| 锯齿(涨到某点突降) | 压缩在跑,到水位线就削一刀 | 验证水位线设得合不合理 |
| 阶跃(某一轮陡然跳高) | 某轮灌进了一大块(如一份长文档 / 大 tool_result) | 定位那块,考虑外置传引用 |
| 平线(基本不涨) | 固定窗口策略,只留最近 N 轮 | 确认丢掉的旧轮里没有关键信息 |
三个脱敏案例,对应三种典型事故:
- 某语音 Agent 的隐形累积:给一个实时语音 Agent 画上下文构成表时发现,对话历史由框架全托管(模式 A),代码里没有任何变量记录它的大小。设计稿里写了一份压缩规则,但实跑的 pipeline 通路根本没接——历史实际完全交给框架黑盒。结论是:在做任何优化之前,先得 dump 一通真实通话的每轮 input token,画出增长曲线,才知道几分钟的会话会不会逼近模型上限。「规则写了」和「通路实现了」之间,差了一整条实测曲线。
- 某假设的状态跟踪器背了黑锅:一个团队怀疑是「阶段切换」拖累了 token,花精力去优化阶段跟踪逻辑。实测曲线却显示,阶段跟踪几乎不贡献 token 增长——它只是个单向递进的标记,不切换 system prompt。真正线性上涨的是历史。分不清「几乎不涨的 state」和「线性涨的 history」,优化精力就会系统性地放错地方。(反过来要警惕另一种写法:如果阶段切换会重发整段 system prompt,那它就不再廉价了,每切一次都是一次全量重发——这笔账也得算清。)
- 某成熟产品端到端 session 的不可控:一个走端到端语音 session 的产品(模式 C),历史完全在供应商内部维护,代码碰不到 message 数组,也没有「每轮一次 chat completion」的边界可以插入压缩。它靠供应商 session 级自带的压缩不爆,但你既看不到也调不了。想要可控,唯一的路是退回到能看见每轮调用的 pipeline 架构。黑盒不爆不等于黑盒可控——看不见就是看不见。
还有一个公开产品的正面参照:某桌面客户端把这套做成了实时面板,上下文窗口里「对话消息」占多少 token 直接显示出来,刷新就能看着它随轮数往上爬。你手画的那张「轮数 → token 增长曲线」,被它做成了官方仪表盘。多数项目没有这个面板,才更要自己 dump——对照之下,你会更明白为什么必须测。
可操作做法:把这一层管起来
PM 视角——用它做判断和沟通:
- 听到「长会话变慢 / 变贵 / 被截断」,第一反应是问「历史归谁托管,A / B / C 哪种」,而不是急着换模型或改 prompt。
- 别被「我们设了压缩规则」安抚住——追一句「那条规则在你实际跑的通路里接了吗,有没有实测过增长曲线」。规则文档不等于实现。
- 谈任何「长会话优化」之前,先要一张真实会话的「轮数 → token 增长曲线」。没有它,团队还停在设计稿阶段,所谓优化都是凭感觉。
- 警惕把状态跟踪这类「几乎不涨」的东西当成本大头去优化——先让工程拿曲线指认,到底是哪一栏在涨。
工程视角——用它做架构决策:
- 接手项目先判模式:history 是框架全托管、自管、还是供应商黑盒。模式 A 必须实测,不能假设框架会压。
- 模式 A 的实测动作固定四步:跑一通完整会话 → 在 LLM 调用处 dump 每轮真实 input token → 画增长曲线 → 按斜率外推对照模型上限。看形状判性质:线性=没压、锯齿=压了、阶跃=灌了大块、平线=固定窗口。
- 真要自管压缩(模式 B),五要素一个都别省:定水位线、定压缩什么、定谁来压、做 session kickoff 续接、确认规则在通路里真的被调用了。
- 单轮临时指令一律旁路注入,绝不混进持久历史。 像「您还在吗」这类一次性话术,要走单轮注入的入口(只在这一轮生效、不进历史),而不是当成正式一轮
append进去。否则这些临时话术会作为正式 turn 永久累积,既污染历史、又白白膨胀 token。塞进 system 则每轮都带,更糟。
收口
测这一层不靠读代码猜,靠画一条「轮数 → token」的增长曲线——形状会替你说话:线性就是没压,斜率告诉你还有几轮撞墙。看不见的历史不会因为你没看它就不涨。