[控场AI]
· 7 分钟阅读· 3,936 字

聊天机器人每轮重发全部历史:实测上下文成本与压缩策略

聊天机器人每轮重发全部历史:实测上下文成本与压缩策略

聊天API无状态导致历史token反复计费,摘要加滑动窗口可降本76%并保留关键事实。

聊天 API 无状态,每轮都要重发全部历史,实测 99.3% 的处理 token 都是输入 token,账单大头是历史而非回答。作者用客服机器人做 40 轮对照实验:全量历史每条消息 $0.001599,最近 6 轮滑动窗口降到 $0.000413(−74%),摘要加最近 4 轮降到 $0.000388(−76%)。但纯滑动窗口会在 3/3 次测试中忘掉开头给出的账户 ID 和条款,而摘要方案几乎同价却全部记住。生产方案是保留最近 8 轮原文、超过 12 轮时用便宜模型压缩更早历史,并把摘要持久化只压缩一次。系统提示、RAG 片段和工具 schema 也是常见输入泄漏点,应精简或配合 prompt caching。

聊天 API 是无状态的。为了回答第 41 条消息,你的应用要把前 40 条消息重新发一遍,而这些历史消息全部按输入 token 计费——每一条消息都要重付一次。

[图片]

这不是个小数目。据一项基于真实 API 流量的分析,在 7 天约 411,000 次请求中,99.3% 的已处理 token 都是输入 token;中位数请求输入 3,782 token,只返回 147 token。大多数 LLM 账单的真正大头不是回答太长,而是历史太长。

一次真实对照实验:三种上下文策略

为了量化“保留多少历史”的代价,作者设计了一个客服机器人场景:经过 40 轮对话后,再问最后一个问题,并对比三种策略:

  1. 全量历史:大多数教程的做法
  2. 最近 6 轮:滑动窗口
  3. 摘要 + 最近 4 轮:用便宜模型把更早的对话压缩成一段 60 词的笔记

测试脚本 trim_context.py 使用 claude-sonnet-5 模型,输入价格 $0.3835/1M、输出价格 $1.5339/1M。三种策略发送的消息不同,但模型与输出长度保持一致。

实测结果如下:

策略输入 token每条消息成本相对全量
全量历史(40 轮)3,690$0.001599—
最近 6 轮596$0.000413−74%
摘要 + 最近 4 轮531$0.000388−76%

同样的模型、同样的输出长度,账单只剩大约四分之一。摘要步骤本身有额外开销:调用一次 GLM 5.3 Flash 约 $0.00002,而且只在历史超过你设定的限制时才需要支付,并非每条消息都付。

无状态(stateless) 指 API 本身不保存任何会话记忆:每次请求都是独立的,服务端不会“记住”你上一次说了什么。因此要实现多轮对话,客户端必须把此前的 user 和 assistant 消息一并放进本次请求的 messages 数组中。这也是历史 token 被反复计费的根源——同一段历史每轮都作为输入重新发送。部分平台提供“会话状态”托管能力(如 OpenAI 的 Responses API 或 Assistants 的状态存储),由服务端保存历史,但计费逻辑通常仍按实际处理的输入 token 计算,理解这一机制有助于判断成本优化的着力点。

便宜有代价:滑动窗口会“失忆”

成本只是一半,另一半是质量。作者在对话开头埋了一个事实:“我的客户是 Northwind,账户 ID 7731,net-45 条款。”再经过 40 轮后,让机器人复述这个 ID 和条款,每种策略跑 3 次:

策略输入 token是否记住“7731, net-45”
全量历史3,751✅ 3/3
最近 6 轮600❌ 0/3(“我没有收到过任何账户 ID”)
摘要 + 最近 4 轮~530✅ 3/3

纯滑动窗口最便宜,但它会忘掉窗口之外的一切,包括用户只说过一次的关键事实。摘要方案几乎以同样的价格把这些事实保留下来。要提高可靠性,需要在摘要提示里明确要求:"Keep names, IDs and terms."(保留姓名、ID 和条款)。

15 行生产级代码:摘要 + 窗口

作者给出了一个可以直接落地的模式:保留 MAX_TURNS = 8 轮原文,当历史超过 SUMMARY_AT = 12 轮时,把更早的部分交给便宜模型压缩成摘要,然后把摘要和最近轮次一起发送。

核心函数 build_messages(system, turns, summary, new_msg) 的逻辑是:

  • 当 len(turns) > SUMMARY_AT 时,调用 summarize() 把 turns[:-MAX_TURNS] 并入已有摘要,并只保留最近 MAX_TURNS 轮原文;
  • 消息数组以 system 提示开头,如果存在摘要就追加一条 system 消息 Earlier: {summary};
  • 最后拼上保留的轮次和新消息。

关键工程细节:把 summary 存在对话旁边,这样同一段历史只会被压缩一次,不会反复付费。

MAX_TURNS = 8         # keep this many recent turns verbatim
SUMMARY_AT = 12       # compress once history passes this

def build_messages(system, turns, summary, new_msg):
    if len(turns) > SUMMARY_AT:
        summary = summarize(summary, turns[:-MAX_TURNS])  # cheap model
        turns = turns[-MAX_TURNS:]
    msgs = [{"role": "system", "content": system}]
    if summary:
        msgs.append({"role": "system", "content": f"Earlier: {summary}"})
    return msgs + turns + [new_msg], turns, summary

滑动窗口(sliding window) 是最简单的历史裁剪策略:只保留最近 N 轮对话,更早的内容直接丢弃。它的优点是实现简单、token 可控,缺点是窗口之外的信息彻底消失,模型无法再引用。与之相对的滚动摘要(rolling summary) 则把超出窗口的旧内容交给一个便宜模型压缩成一段简短笔记,随对话不断更新并持久化。由于摘要只在新历史溢出时生成,压缩成本被摊薄,而关键事实(姓名、ID、条款)可通过在摘要提示中显式要求保留。二者常组合使用:近期原文保证细节准确,远期摘要保证长期记忆。

其他值得排查的输入 token 泄漏点

除了对话历史,还有几处常见的“隐形输入浪费”:

  • 膨胀到 2,000 token 的系统提示:每次调用都会发送,应该精简,或使用支持 prompt caching 的服务商。
  • RAG 塞入 10 个片段,实际 3 个就够:先重排(rerank),再裁剪。
  • 工具 schema:每个函数定义在每次调用中都是输入 token,只发送当前步骤真正需要的工具。

作者的流量数据来自 Qubax——一个兼容 OpenAI 接口、聚合 400+ 模型的 API 服务;文中裁剪代码可配合任何 OpenAI 兼容端点使用。

Prompt caching(提示缓存) 是服务商提供的优化机制:当多次请求共享相同的前缀(如固定的 system 提示或历史对话的初始部分)时,这段前缀只需在首次请求时完整计费和处理,后续请求可命中缓存,从而降低输入 token 的价格并减少延迟。OpenAI、Anthropic 等均提供该能力,通常需要显式标记缓存边界,缓存有生存时间(如 5 分钟或 1 小时),过期后需重新写入。注意缓存只能让重复前缀更便宜,并不能缩短总上下文长度,因此长历史仍会占用上下文窗口并抬高延迟,最佳实践是把裁剪与缓存结合使用。

Rerank(重排) 是 RAG 流程中的一步:初步检索通常按向量相似度召回较多片段(如 10-20 个),但这些片段未必都与问题最相关。重排模型(cross-encoder 或专用 reranker)会同时阅读问题和候选片段,输出更精确的相关性排序,再截取分数最高的少数片段送入 LLM。由于重排发生在调用 LLM 之前,能显著减少塞入上下文的 token 量,同时提升答案质量。常见做法是召回 10-20 个、重排后只保留 3-5 个。

常见问题

滑动窗口够用吗?

对短生命周期对话够用。但在测试中,它 3/3 次忘掉了对话开头给出的账户 ID。如果用户陈述过一次事实并期望被记住,就需要对更早轮次做摘要。

摘要该用哪个模型?

便宜、快速的模型。本测试中摘要调用约 $0.00002,且只在历史超过限制时付费。

Prompt caching 能解决这个问题吗?

部分能。缓存让重复前缀更便宜,但长历史仍会增加延迟,并可能把你推过上下文上限。裁剪和缓存配合使用效果最好。

参考资料

#LLM聊天历史成本#输入token优化#上下文窗口压缩#滑动窗口摘要#prompt caching#聊天API无状态#OpenAI兼容API
分享:

相关推荐