创见博客
多轮对话与历史压缩:让大模型既记得住、又花得少
七崽爱吃小饼干2026/10/14阅读 0

大模型本身是「无状态」的:你问一句,它答一句,仅此而已。它并不会真的记得上一轮说过什么。所谓的「多轮对话」,其实是我们把之前的对话记录一起再发给它。而一旦对话变长,历史就会不断膨胀,带来成本和延迟问题——于是「历史压缩」就成了绕不开的话题。这篇文章先讲怎么实现多轮对话,再讲几种实用的历史压缩方案。

一、多轮对话的本质

模型 API 的每次调用都是独立的。你上一条问「我叫小明」,下一条问「我叫什么」,如果只发后一句,模型当然不知道。要多轮,就得把历史消息一并带上。

在 LangChain 里,历史就是一个消息数组。第一次调用后拿到 AIMessage,把它和新的 HumanMessage 一起追加进数组,下一次调用把整个数组发过去即可:

js
import { initChatModel } from "langchain";
import { SystemMessage, HumanMessage } from "@langchain/core/messages";

const model = await initChatModel("deepseek:deepseek-chat", {
  apiKey: process.env.DEEPSEEK_API_KEY,
});

const history = [
  new SystemMessage("你是一个亲切的中文助手,请记住用户告诉你的信息。"),
];

async function chat(userInput) {
  history.push(new HumanMessage(userInput));   // 1. 记下用户输入
  const aiResp = await model.invoke(history);  // 2. 带着完整历史调用
  history.push(aiResp);                        // 3. 把模型回复也存进历史
  return aiResp.content;
}

await chat("你好,我叫小明,今年 28 岁。");
await chat("我住在北京,养了一只叫豆豆的猫。");
await chat("我喜欢喝美式咖啡。");
console.log(await chat("你还记得我叫什么、住哪、养的宠物叫什么吗?"));
// 模型能准确回答:小明、北京、豆豆

就这三行——入栈用户消息、带历史调用、入栈模型回复——就实现了多轮对话。

二、历史为什么会成为问题

每次调用都要把全部历史发给模型,于是对话越长,问题越明显:

  • 成本上升:输入 token 随轮次线性增长,长对话的 token 消耗非常可观;
  • 延迟增加:输入越长,生成越慢;
  • 触碰上限:超出模型的上下文窗口会直接报错;
  • 噪音干扰:无关的旧内容会稀释模型的注意力,影响回答质量。

所以,一个成熟的多轮应用,必须要能对历史做「压缩」。

三、压缩方案 A:滑动窗口 + token 截断

最直接的办法:只保留最近的若干轮对话,把更早的丢掉。LangChain 提供了 trimMessages:

js
import { trimMessages, countTokensApproximately } from "langchain";

const trimmed = await trimMessages(history, {
  maxTokens: 1000,                          // token 预算
  tokenCounter: countTokensApproximately,   // 估算 token
  strategy: "last",                         // 从最新往旧保留
  includeSystem: true,                      // 系统消息始终保留
  startOn: "human",                         // 从一条用户消息开始
  endOn: "ai",                              // 到一条助手消息结束,保证成对
});

startOn / endOn 很关键:它保证裁剪后不会出现「只有 AI 回复却没有对应提问」的残缺对话。

优点:实现简单、token 严格可控。 缺点:早期信息被直接丢弃,模型会「失忆」。比如前面提到的「小明、北京」,多聊几轮后就彻底记不得了。

另外要注意 countTokensApproximately 只是「字符数 / 4」的粗略估算,对中文会明显低估,生产环境最好换成真实分词器,或直接用模型自身的 getNumTokens。

四、压缩方案 B:摘要压缩

思路:不再简单丢弃旧消息,而是让模型把旧对话浓缩成一段摘要,用这条摘要替换掉那一批原文,再拼上最近的几轮:

旧消息(多条) ──拼接──► [总结指令 + 旧对话] ──► 模型 ──► 摘要(一条)
压缩后 = [系统提示, 摘要消息, 最近 N 轮原文]

实现只有一次额外调用:

js
async function summarizeOlder(messages, keepLast = 4) {
  const system = messages.find((m) => m.type === "system");
  const rest = messages.filter((m) => m.type !== "system");
  if (rest.length <= keepLast) return messages;

  const older = rest.slice(0, -keepLast);   // 要压缩的旧消息
  const recent = rest.slice(-keepLast);     // 保留的最近几轮

  const summary = await model.invoke([
    new SystemMessage(
      "把下面这段对话浓缩成要点。务必保留所有关键事实(人名、数字、偏好、时间、约定等)和未完成事项。"
    ),
    new HumanMessage(
      older.map((m) => `${m.type === "human" ? "用户" : "助手"}: ${m.content}`).join("\n")
    ),
  ]);

  return [system, new SystemMessage(`【对话历史摘要】\n${summary.content}`), ...recent];
}

效果:实测一份 17 条的历史,压缩后只剩 6 条,token 大幅下降;但再追问「你还记得我吗」,模型仍能完整复述姓名、年龄、住址、爱好等——信息不丢,成本可控。

要让摘要做得好,有几个细节:

  1. 总结「较旧的部分」,最近若干轮保留原文,兼顾细节与成本;
  2. Prompt 决定质量:明确要求保留人名、数字、偏好、时间、约定、未完成事项,否则容易把关键事实压没;
  3. 摘要以 SystemMessage 放在最前,作为模型的既有背景知识;
  4. 可以增量滚动:更省的做法是 新摘要 = 总结(旧摘要 + 刚被淘汰的几轮),每次只处理摘要加一小段新消息,成本恒定,不随对话变长而膨胀;
  5. 超长历史用 Map-Reduce:先分块摘要、再合并,适合一次性处理很长的记录。

五、压缩方案 C:混合方案(推荐)

把 A、B 结合起来,按预算分层处理:

  1. 用 countTokensApproximately 估算当前历史 token;
  2. 没超阈值 → 直接用完整历史(最精确);
  3. 超过软阈值 → 先做摘要压缩(方案 B);
  4. 摘要后仍超硬上限 → 再用 trimMessages 截断最近的(方案 A)兜底;
  5. 系统提示词(人设 / 规则)永远保留在最前面。

这样既尽量不丢信息,又能把 token 控制在安全范围内,是生产环境最常用的策略。

六、压缩历史对缓存命中的影响

前面几种压缩方案都在改动「当前轮之前的内容」,这会带来一个容易被忽略的副作用:打断提示词缓存(prompt caching)。

大模型服务(DeepSeek、OpenAI、Anthropic 等)普遍支持对相同前缀的请求做缓存复用:命中的部分按很低的折扣价计费,还能省去重新 prefill 的时间。而缓存的匹配原则是「从头开始逐 token 比对,直到第一个不同的位置」。

  • 正常的多轮对话是「只往后追加」:前缀原封不动,旧内容持续命中,每次只为新增的尾部付费——这是对缓存最友好的形态;
  • 而压缩(截断 / 摘要 / 检索重组)会改动靠前内容:一旦前缀变了,从改动点向后的所有 token 都无法复用缓存。

用 DeepSeek 实测(它会在返回里报告 cache hit / miss token):

调用prompt_tokenscache_hitcache_miss
长历史(首次)148501485
长历史(前缀不变,只追加)14851280205
摘要压缩后79079

可以看到:只追加时命中了 1280;一旦做了摘要压缩,改写了系统提示之后的早期对话,缓存直接掉到 0。

但这里有一个反转:压缩虽然打掉了缓存,却把 prompt 从 1485 砍到了 79。 按 DeepSeek 的缓存价(命中约 0.1× 全价)粗算:

  • 不压缩一次调用 ≈ 1280 × 0.1 + 205 × 1 = 333
  • 压缩后一次调用 ≈ 79 × 1 = 79

也就是说,压缩即使全 miss,反而更便宜——省下的 token 远超缓存折扣带来的收益。缓存 miss 的主要代价其实是延迟(重新 prefill),而不是费用。

那么,怎么把这种影响降到最低?

  1. 稳定内容前置:缓存是「从头匹配到第一个不同 token」,所以把系统提示词、工具定义等大头放在最前且保持不变,它们永远命中,受影响的只有后面被压缩的动态历史;
  2. 低频压缩:不要每轮都压,只在越过阈值或每隔若干轮压一次;压完新前缀会重新稳定,后续追加又能命中,代价被摊销;
  3. 减少前缀变动次数:把摘要放在一个固定的「记忆槽位」、必要时才更新,而不是频繁重写;
  4. 按计费决策:若某家缓存命中价极低、且窗口够用,「少压缩、多靠缓存」可能更划算;反之则更该压缩。用 provider 报告的 hit / miss token 实测对比最靠谱。

一句话:压缩与缓存是一对天然冲突的量,任何改动历史前缀的压缩都会降低缓存命中;但因为压缩能大幅削减 token,它通常是净赚的。关键是把压缩做得「低频、把稳定内容前置」,把对缓存的冲击降到最低。

七、其他值得了解的思路

  • 检索式记忆(RAG over history):把历史消息存进向量库,每次只检索与当前问题最相关的若干条,而不是无脑带上全部。适合话题跳跃的长会话。
  • 结构化记忆:定期让模型把对话抽取成事实 / 用户画像(键值对或 JSON),只注入必要字段。比自由摘要更省 token、更稳定,也便于程序使用。
  • 提示缓存(Prompt Caching):DeepSeek、OpenAI 等对相同前缀的请求有缓存折扣。保持历史前缀稳定、只往后追加,能显著降低成本。
  • 分层记忆:短期(最近几轮原文)+ 中期(摘要)+ 长期(外部存储 / 检索),按需组合。

八、小结

多轮对话的本质是「每次把历史一起发给模型」,实现只需在消息数组上「入栈用户消息 → 带历史调用 → 入栈模型回复」。当历史膨胀后,核心矛盾就变成在「信息不丢」和「token 可控」之间取舍:

  • 滑动窗口(trimMessages) 简单可控,但会丢早期信息;
  • 摘要压缩 用一次额外调用把旧对话浓缩成要点,信息保留得更好;
  • 混合方案 按预算先摘要、再截断兜底,是推荐做法。

同时要意识到:任何改动历史前缀的压缩都会降低提示词缓存的命中率,但因为压缩能大幅削减 token,它通常是净赚的——把系统提示等稳定内容前置、压缩保持低频,就能把对缓存的冲击降到最低。

再进一步,还可以叠加检索式记忆、结构化记忆和提示缓存,构建更健壮、更低成本的长期记忆系统。

评论
0/100