← 返回资讯

多轮对话越来越贵:对话历史怎么管才不烧钱

2026-08-07

上线两周后账单翻了三倍,但日活没怎么涨——这种情况我见过好几次,最后查下来几乎都是同一个原因:用户开始把对话聊长了。

单看每一次调用,都不贵。用户发一句「帮我把这段改得口语一点」,二三十个字,怎么看都是笔小钱。但模型是无状态的,它不记得你们上一轮聊过什么,你要让它接着上文说话,就得把之前所有的问答原样再发一遍。于是同样一句二三十字的话,在第 1 轮和第 20 轮的价格完全是两回事。

这篇讲的就是这件事:为什么会这样,怎么裁,裁的时候会踩哪些坑——尤其是裁剪和前缀缓存之间的冲突,这一点几乎没人提前想到,但它能把你省下来的钱原样吐回去。

一、先把账算清楚:增长的形状不是直线

下面这笔账用的是假设参数,不对应任何厂商的实际计费,目的只是把增长的形状算出来。真实的 token 数请用你自己的分词器实测。

假设条件:

  • 系统提示词固定 500 token,每次调用都要带
  • 每轮用户输入平均 100 token
  • 每轮模型回复平均 200 token
  • 每轮结束后,这一问一答共 300 token 进入历史,下一轮原样带上

那么第 n 轮的输入 token 是:

输入(n) = 系统提示 500 + 历史 300×(n-1) + 本轮提问 100
        = 600 + 300×(n-1)

算几个点出来:

轮次本轮输入 token相对第 1 轮
第 1 轮6001.0×
第 5 轮1,8003.0×
第 10 轮3,3005.5×
第 15 轮4,8008.0×
第 20 轮6,30010.5×

第 20 轮用户还是打了一百来个 token,但这次调用要吃掉 6,300 个输入 token。同样一句话,贵了十倍半。

再看总账。20 轮的输入总量是一个等差数列求和:

总输入 = 20 × (600 + 6300) / 2 = 20 × 3450 = 69,000 token

如果历史不累加、每轮都只发 600,20 轮总共才 12,000。69,000 ÷ 12,000 = 5.75 倍

这就是形状的关键:单轮成本随轮次线性上升,累计成本随轮次接近平方级上升。轮次翻倍,总账不是翻倍,是接近翻四倍。输出侧倒是老实的——20 轮共 4,000 输出 token,跟轮次成正比,不累加。所以长对话产品里,输入侧永远是大头,优化的力气都该往这儿使。

顺带说一句,这条曲线还有个更难受的终点:涨到某一天它会撞上模型的上下文上限,然后开始报错。那是另一个话题,可以看 上下文超限怎么办

二、三种裁剪策略,各自的代价

裁历史无非三条路。它们不是好坏之分,是代价形态不同

2.1 滑动窗口:只留最近 N 轮

最土也最好用。保留最近 N 轮问答,更早的直接丢掉。

按上面的参数,取 N=6:

  • 第 7 轮开始,历史恒定为 6×300 = 1,800,输入恒定 500+1800+100 = 2,400 token
  • 前 7 轮照常爬坡,合计 7×(600+2400)/2 = 10,500
  • 第 8 到 20 轮,13 轮 × 2,400 = 31,200
  • 总计 41,700,比不裁的 69,000 省了 27,300,降幅约 39.6%

好处非常实在:实现十行代码,成本可预测——每轮输入有硬上限,你能准确说出「最贵的一次调用是多少 token」,容量规划和限流阈值都好定。

代价也非常实在:早期信息无声消失。用户在第 3 轮说「我不吃辣、预算三百以内」,到第 20 轮窗口早滑过去了,模型开始推荐水煮鱼。这类 bug 最恶心的地方在于它不报错,用户只是觉得「这 AI 怎么越聊越傻」,然后走了。

2.2 摘要压缩:把早期历史压成一段话

到阈值时,发一次额外调用,把前面若干轮压成一段摘要,用摘要替代原始历史。

还是上面的参数,假设第 11 轮时把前 10 轮(3,000 token)压成 300 token 的摘要:

  • 前 10 轮照旧:10×(600+3300)/2 = 19,500
  • 第 11 轮起:输入 = 500 + 摘要 300 + 新历史 300×(n-11) + 100
    • 第 11 轮 900,第 20 轮 3,600,10 轮合计 10×(900+3600)/2 = 22,500
  • 主链路合计 42,000
  • 再加压缩本身那一次调用:输入约 3,100(3,000 历史 + 100 指令),输出 300
  • 实际总输入约 45,100

比滑动窗口的 41,700 还略贵一点,还多了一次调用的延迟。摘要不是更省钱的方案,它是用一点钱换「早期信息不丢」的方案。 想清楚这一点再选。

摘要写什么,直接决定它值不值这个钱。我的取舍是:

必须进摘要的:

  • 用户明确表达的偏好、约束、禁忌(“别用 Python”、“预算 300 以内”、“文风要冷一点”)
  • 已经确认的事实(订单号、版本号、选定的方案、对方给的参数)
  • 已达成的结论和还没解决的悬置问题(“A 方案否决,因为不支持离线;B 方案待定,等对方确认授权”)
  • 任务当前状态(做到哪一步了、下一步要干嘛)

可以丢的:

  • 寒暄、致谢、“好的""明白了”
  • 模型自己的免责声明和客套话
  • 已经完成且结论已写入摘要的中间推理步骤——过程可以丢,结论必须留
  • 被后续修正过的旧版本内容(用户改过三次需求,只留最终那版)

一个实践上很有用的约束:让摘要输出成固定结构,而不是自由散文。 比如强制分成「用户偏好 / 已确认事实 / 已完成 / 待办 / 悬置问题」五个小节。散文摘要每次压缩都会随机丢掉一点东西,压第二次、第三次之后信息衰减得非常快;结构化的槽位至少能保证”偏好”这一栏不会因为模型这次心情不好就整个消失。这一点和 Agent 记忆设计 里的思路是一脉相承的。

摘要还有个隐藏成本:它会被重复压缩。第二次触发压缩时,你是在压「摘要 + 新历史」,摘要被二次转述,细节再掉一层。所以我倾向于摘要只压一次原文,之后新的摘要用「旧摘要原文照抄 + 新增部分总结」的方式增量更新,而不是让模型重写整段。

2.3 结构化记忆:关键事实不走对话历史

把「用户不吃辣」「预算 300」「已选 B 方案」这类事实抽成键值对,单独存一份,每次调用时以固定格式拼进系统提示,完全不参与对话历史的滑动

对话历史那边可以裁得很凶——比如只留最近 4 轮。假设记忆块常驻 200 token:

输入(n) = 500(系统) + 200(记忆) + 300×min(n-1, 4) + 100
  • 第 5 轮起进入稳态:500+200+1200+100 = 2,000 token/轮,恒定
  • 前 4 轮:800 + 1,100 + 1,400 + 1,700 = 5,000
  • 第 5 到 20 轮:16 × 2,000 = 32,000
  • 合计 37,000——三种方案里最省,而且关键事实一个没丢

代价在别处:你得设计抽取逻辑。什么算”关键事实”、谁来抽(规则还是模型)、抽错了怎么改、用户改主意了怎么覆盖旧值、记忆本身涨太大了怎么淘汰。这是实打实的工程量,还需要一套自己的测试。

但它的好处也是独一份的:可控。记忆是一张你能打印出来看的表,不是一段模型写的散文。出问题时你能直接查「哦,第 3 轮那条偏好压根没被抽出来」,而不是对着一段摘要猜它把什么弄丢了。

2.4 怎么选

场景推荐理由
客服问答、短会话(一般 5 轮内结束)不裁,或大窗口滑动根本涨不到痛的地步,别过度设计
通用聊天助手、陪伴类滑动窗口 + 结构化记忆闲聊内容本身没保留价值,但用户画像必须留
长任务协作(写文档、改代码、做方案)摘要压缩 + 结构化记忆中间推理有价值,但可压缩;决策必须精确保留
Agent 多步执行结构化记忆为主工具调用结果是结构化的,本来就该进状态而不是进对话
成本敏感 + 对话可能无限长结构化记忆 + 小窗口唯一能让单轮成本封顶的组合

一条经验:先上滑动窗口,把成本的天花板按住,再根据实际掉信息的投诉去补记忆。 反过来做——先花两周设计记忆系统——通常会发现有一半字段线上根本用不到。

三、和前缀缓存的冲突(这节最值钱)

这是我认为最多人栽跟头的地方。

主流的提示缓存都是前缀匹配:从请求的第一个 token 开始逐位比对,匹配到哪儿算哪儿,一旦出现第一个不同的 token,从那里往后全部算未命中。具体的命中规则、最小长度、有效期各家不一样,以官方文档为准;但”前缀匹配、遇到差异即断”这个基本性质是共通的。

现在把它和历史裁剪放到一起看。

正常的多轮对话,请求结构天然是追加式的:

第 n 轮:   [系统提示][轮1][轮2]...[轮n-1][本轮提问]
第 n+1 轮: [系统提示][轮1][轮2]...[轮n-1][轮n][本轮提问]
                     └────── 完全相同的前缀 ──────┘

前面那一大坨一字未变,缓存能吃得很饱。对话越长,缓存省得越多——这是长对话唯一的好消息。

然后你上了摘要压缩:

压缩前: [系统提示][轮1][轮2]...[轮10][轮11提问]
压缩后: [系统提示][摘要   ][轮11提问]
                  ↑ 从这个位置开始,后面全部不同

你把前缀的中间部分重写了。 缓存从「摘要」这个位置往后全部失效,而且不只是这一轮——后续所有轮次的缓存都得从新前缀重新建立。第一次压缩之后那一轮,缓存命中率基本归零。

我见过一个更糟的实现:每一轮都重新生成一次摘要,让摘要”保持最新”。结果是每一轮的前缀都跟上一轮不一样,缓存命中率长期趋近于零。省下来的历史 token 还不够赔掉的缓存折扣。

实操做法

做法一:摘要放尾部追加,不替换中间。

不要用摘要去替换原始历史的位置,而是把它作为一条新的消息追加在历史末尾——「以下是本次对话早期内容的要点:……」然后从这一轮起,只带摘要之后的内容。这样虽然仍会有一次断点(因为你砍掉了前面的原文),但如果你的系统提示 + 常驻记忆块足够长且完全稳定,断点之前的那一段前缀仍然是可缓存的

关键在于:把稳定的东西全部前置。 系统提示、工具定义、常驻记忆、few-shot 示例,这些一律排在最前面且逐字不变;变动的东西一律往后排。这套排布本身就是 提示词前缀设计 的核心,压缩场景下收益更大。

做法二:接受”压缩那一轮命中率归零”,然后老实重建。

这是更现实的选择。压缩是低频事件——20 轮对话可能就压 1 到 2 次。你要做的是:

  • 降低压缩频率。宁可让阈值高一点、每次压得狠一点,压 1 次好过压 5 次
  • 压缩后立刻稳定住新前缀。新的 [系统提示][记忆块][摘要] 一旦定下来,后面十几轮一个字都别改
  • 绝不每轮重算摘要。摘要一旦生成就冻结,下次压缩时把它当作不可改的原文往下传

做法三:把记忆块的更新和压缩对齐。

如果你同时用了结构化记忆,注意记忆块是在前缀里的——改一个字段,整个后缀的缓存全废。所以不要”每轮实时更新记忆”,而是攒着改:只在压缩触发的那一刻,把这一批新事实一次性写进记忆块。反正那一轮的缓存本来就要作废,把两次破坏合并成一次,这是净赚的。

一句话总结这一节:裁剪省的是输入 token 的单价基数,缓存省的是折扣率,二者会互相打架。判断标准很简单——压缩一次省下的 token,是否超过它导致的缓存失效损失。压得太勤,一定是亏的。

四、触发时机:按 token 数,不按轮数

我见过很多实现写的是「超过 10 轮就压缩」。这个判据不好用,因为每轮的长度差异极大。用户粘了一份三千字的报告进来,那一轮顶得上前面十轮;用户连着回了五个”嗯”,那五轮加起来还没系统提示长。按轮数触发的结果是:该压的时候没压(撞上限报错),不该压的时候压了(白花一次调用还废了缓存)。

正确的做法是按实际 token 数触发。阈值这样倒推:

可用输入预算 = 上下文上限 − 预留输出空间 − 安全缓冲
压缩触发线   = 可用输入预算 × 触发系数(如 0.7~0.8)

四个量分别怎么定:

  • 上下文上限:查你实际调用的那个模型的官方文档,别凭印象填,也别写死在代码里——换模型时这是最容易漏改的常量。做成配置项。
  • 预留输出空间:按你设置的 max_tokens 留,而且要留够。这是最常见的翻车点:输入塞得满满当当,模型刚说到一半就被截断了。截断的回复用户看不懂,还得重问一遍,等于白花两次钱。输出长度本身也该管,见 输出长度控制
  • 安全缓冲:留 5%~10%。你本地估的 token 数和服务端实际计的一定有出入(分词器差异、消息封装开销、工具定义的序列化格式),中文场景下这个偏差往往还不小。缓冲就是给这份不确定性买的保险。
  • 触发系数:别设成 0.95。压缩本身要花一次调用,得留出执行它的余量;而且用户下一句话可能很长,你得扛得住。0.7 到 0.8 是比较稳的起点。

还有个细节:压缩要压到什么程度。触发线是 0.75,压完之后应该降到 0.3 左右,而不是降到 0.7。降到 0.7 的结果是下一轮又触发,你会陷入”每轮都压”的死循环——前面说过,那是缓存的噩梦。触发线和目标线之间要留出足够大的间隔,这跟做 GC 调优是一个道理。

五、永不裁剪区:有些东西丢了就完了

不管用哪种策略,都必须划出一块不参与任何裁剪的区域。放进去的东西只有三类:

  1. 系统级约束:角色定义、输出格式要求、安全边界、工具使用规则。这些丢了,模型的行为会突然变形——用户聊到第 15 轮突然发现它开始用 Markdown 输出了,就是因为格式约束被滑出了窗口。
  2. 用户明确表达的偏好:注意是”明确表达”,不是模型猜的。“我不吃辣""用简体中文""别写代码注释”这类。判断标准是——如果这条丢了,用户会觉得”我不是说过了吗”,那它就该进永不裁剪区。
  3. 已确认的事实:订单号、选定的方案、对方给出的参数、已达成的共识。这些一旦丢失,模型会开始重新询问,用户体验上是”失忆”,比答错还伤。

实现上就一句话:把它们放进稳定前缀里,物理上不在滑动窗口的数据结构中。

# 结构上就把两者分开,而不是靠裁剪逻辑去"记得跳过"
messages = (
    [system_prompt]        # 永不裁剪:角色 + 格式 + 安全约束
    + [memory_block]       # 永不裁剪:偏好 + 已确认事实(低频更新)
    + summary_if_any       # 低频变动:压缩产物,冻结后不再改写
    + recent_turns         # 唯一参与滑动的部分
    + [current_user_msg]
)

这样写的好处是裁剪逻辑不可能误伤——它拿到的输入就只有 recent_turns,压根碰不到前面三块。我见过的相反做法是把所有消息塞在一个列表里,靠一个 if msg.role == "system": skip 的判断去保护,然后某次重构加了个新的消息类型,保护条件没跟上,系统提示就这么被裁掉了。用数据结构保证的约束,永远比用 if 判断保证的可靠。

关于历史裁剪的更多实现细节,可以对照看 上下文压缩与裁剪

六、可观测性:两个指标,一个信号

上线之后必须盯的两个数:

一、平均历史长度(按 token 计,分 P50 / P95)。

P50 告诉你典型对话的成本水位,P95 告诉你尾部有多可怕。成本通常是被 P95 那批长对话吃掉的——5% 的会话贡献 40% 的账单是很常见的分布。只看平均值会漏掉这批人。

二、压缩触发频率(每会话触发次数,以及触发会话占比)。

这个数太高说明阈值定低了或者目标线定高了(在反复压缩),太低说明这套逻辑可能压根没生效。

一个信号:历史长度悄悄上涨。

这是我最想强调的。裁剪逻辑失效几乎不会报错——它只是不裁了,然后成本慢慢爬。典型原因:

  • 加了新的消息类型(工具调用结果、多模态附件的描述),裁剪函数没认出来,直接跳过了
  • 消息结构从字符串变成了结构化数组,token 估算函数返回了 0,于是永远达不到阈值
  • 某个分支忘了调用裁剪,只有主路径调了
  • 摘要生成失败时的降级逻辑写成了”失败就用原文”,然后它一直在失败

这些全都不会抛异常,全都只表现为这条曲线开始往上飘。所以给平均历史长度加一条告警,比如 P95 超过阈值的 1.2 倍就报警。这一条告警能帮你在账单出来之前发现问题,是我认为性价比最高的一个监控项。

顺带记一下:日志里把「裁剪前 token / 裁剪后 token / 是否触发压缩 / 缓存命中情况」一起打出来。排查的时候你需要的是这四个数的组合,缺一个都得重现。

七、长对话要专门测

大部分 bug 只在第 15 轮之后才出现,而大部分测试用例只有 2 到 3 轮。这是个系统性的盲区。

最小可行的测法,不需要复杂框架:

1. 造一个 30 轮的固定脚本会话。

在第 2 轮埋一个偏好(“记住,所有回复用简体中文,不要用任何表情符号”),在第 5 轮埋一个事实(“我的订单号是 A-11073”),然后在第 20 轮、第 28 轮分别问回来。答不上来就是裁剪把不该丢的丢了。这个用例跑一次能抓住 80% 的记忆类问题。

2. 断言 token 曲线的形状,而不是断言具体数值。

具体数值会随提示词微调而变,测试会天天红。要断言的是性质

def test_history_is_bounded():
    tokens = [run_turn(i).prompt_tokens for i in range(30)]
    # 稳态之后必须封顶,不能持续爬升
    assert max(tokens[10:]) < CEILING
    # 后半程不应该显著高于前半程的稳态水位
    assert mean(tokens[20:]) < mean(tokens[10:20]) * 1.15

这两条断言就能抓住”裁剪逻辑静默失效”这类问题,而且不会因为提示词改了几个字就误报。

3. 造极端长度的单轮输入。

用户粘一份长文档进来是常态。测一下”单轮输入本身就超过压缩阈值”的情况——这时候裁历史没用,历史是空的,超的是这一轮本身。你的代码是报错、截断,还是死循环地反复尝试压缩?这个分支我见过写崩的,压缩函数发现压完还是超阈值,于是递归调自己,一路调到栈溢出。

4. 测压缩失败的降级路径。

把摘要调用 mock 成抛异常,看主流程会怎样。正确的行为是:降级成滑动窗口硬裁、记录告警、继续服务。错误的行为是:整个请求失败,或者悄悄跳过裁剪继续跑(然后成本失控)。

5. 至少人工看一遍摘要内容。

自动断言测不出”摘要写得对不对”。跑几个真实场景的长对话,把生成的摘要打印出来,自己读。你会发现一些自动化测不出来的问题,比如摘要把用户的问题当成了用户的观点,或者把模型的假设当成了已确认事实——后面这个尤其危险,它会让错误在对话里固化下来。

上线前自查清单

  1. 用真实分词器测过自己业务的 token 曲线,知道第 20 轮大概是第 1 轮的多少倍,而不是凭感觉
  2. 触发条件是按 token 数算的,不是按轮数;上下文上限是配置项不是硬编码,值来自官方文档
  3. 触发线和压缩目标线之间留了足够间隔,不会出现”每轮都压”
  4. 系统约束、用户明确偏好、已确认事实放在稳定前缀里,数据结构上就不参与滑动窗口
  5. 摘要生成后冻结,不每轮重算;记忆块的更新和压缩时机对齐,把两次缓存破坏合并成一次
  6. 摘要输出是结构化槽位(偏好/事实/已完成/待办/悬置),不是自由散文
  7. 监控了平均历史长度的 P50 与 P95、压缩触发频率,并对历史长度异常上涨设了告警
  8. 有一个 30 轮的长会话测试,包含早期偏好回溯、token 曲线封顶断言、超长单轮输入、压缩失败降级四种情况

相关阅读