为什么对话历史会在你看不见的地方悄悄涨到爆

对话历史每轮在悄悄变长,多半是框架替你托管的,代码里看不见——只有实测增长曲线才照得出它离上限多远。

Module E · 第 4 讲

立靶: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 视角——用它做判断和沟通:

  1. 听到「长会话变慢 / 变贵 / 被截断」,第一反应是问「历史归谁托管,A / B / C 哪种」,而不是急着换模型或改 prompt。
  2. 别被「我们设了压缩规则」安抚住——追一句「那条规则在你实际跑的通路里接了吗,有没有实测过增长曲线」。规则文档不等于实现。
  3. 谈任何「长会话优化」之前,先要一张真实会话的「轮数 → token 增长曲线」。没有它,团队还停在设计稿阶段,所谓优化都是凭感觉。
  4. 警惕把状态跟踪这类「几乎不涨」的东西当成本大头去优化——先让工程拿曲线指认,到底是哪一栏在涨。

工程视角——用它做架构决策:

  1. 接手项目先判模式:history 是框架全托管、自管、还是供应商黑盒。模式 A 必须实测,不能假设框架会压。
  2. 模式 A 的实测动作固定四步:跑一通完整会话 → 在 LLM 调用处 dump 每轮真实 input token → 画增长曲线 → 按斜率外推对照模型上限。看形状判性质:线性=没压、锯齿=压了、阶跃=灌了大块、平线=固定窗口。
  3. 真要自管压缩(模式 B),五要素一个都别省:定水位线、定压缩什么、定谁来压、做 session kickoff 续接、确认规则在通路里真的被调用了。
  4. 单轮临时指令一律旁路注入,绝不混进持久历史。 像「您还在吗」这类一次性话术,要走单轮注入的入口(只在这一轮生效、不进历史),而不是当成正式一轮 append 进去。否则这些临时话术会作为正式 turn 永久累积,既污染历史、又白白膨胀 token。塞进 system 则每轮都带,更糟。

收口

测这一层不靠读代码猜,靠画一条「轮数 → token」的增长曲线——形状会替你说话:线性就是没压,斜率告诉你还有几轮撞墙。看不见的历史不会因为你没看它就不涨。