大模型本身是「无状态」的:你问一句,它答一句,仅此而已。它并不会真的记得上一轮说过什么。所谓的「多轮对话」,其实是我们把之前的对话记录一起再发给它。而一旦对话变长,历史就会不断膨胀,带来成本和延迟问题——于是「历史压缩」就成了绕不开的话题。这篇文章先讲怎么实现多轮对话,再讲几种实用的历史压缩方案。
一、多轮对话的本质
模型 API 的每次调用都是独立的。你上一条问「我叫小明」,下一条问「我叫什么」,如果只发后一句,模型当然不知道。要多轮,就得把历史消息一并带上。
在 LangChain 里,历史就是一个消息数组。第一次调用后拿到 AIMessage,把它和新的 HumanMessage 一起追加进数组,下一次调用把整个数组发过去即可:
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:
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 轮原文]
实现只有一次额外调用:
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 大幅下降;但再追问「你还记得我吗」,模型仍能完整复述姓名、年龄、住址、爱好等——信息不丢,成本可控。
要让摘要做得好,有几个细节:
- 总结「较旧的部分」,最近若干轮保留原文,兼顾细节与成本;
- Prompt 决定质量:明确要求保留人名、数字、偏好、时间、约定、未完成事项,否则容易把关键事实压没;
- 摘要以
SystemMessage放在最前,作为模型的既有背景知识; - 可以增量滚动:更省的做法是
新摘要 = 总结(旧摘要 + 刚被淘汰的几轮),每次只处理摘要加一小段新消息,成本恒定,不随对话变长而膨胀; - 超长历史用 Map-Reduce:先分块摘要、再合并,适合一次性处理很长的记录。
五、压缩方案 C:混合方案(推荐)
把 A、B 结合起来,按预算分层处理:
- 用
countTokensApproximately估算当前历史 token; - 没超阈值 → 直接用完整历史(最精确);
- 超过软阈值 → 先做摘要压缩(方案 B);
- 摘要后仍超硬上限 → 再用
trimMessages截断最近的(方案 A)兜底; - 系统提示词(人设 / 规则)永远保留在最前面。
这样既尽量不丢信息,又能把 token 控制在安全范围内,是生产环境最常用的策略。
六、压缩历史对缓存命中的影响
前面几种压缩方案都在改动「当前轮之前的内容」,这会带来一个容易被忽略的副作用:打断提示词缓存(prompt caching)。
大模型服务(DeepSeek、OpenAI、Anthropic 等)普遍支持对相同前缀的请求做缓存复用:命中的部分按很低的折扣价计费,还能省去重新 prefill 的时间。而缓存的匹配原则是「从头开始逐 token 比对,直到第一个不同的位置」。
- 正常的多轮对话是「只往后追加」:前缀原封不动,旧内容持续命中,每次只为新增的尾部付费——这是对缓存最友好的形态;
- 而压缩(截断 / 摘要 / 检索重组)会改动靠前内容:一旦前缀变了,从改动点向后的所有 token 都无法复用缓存。
用 DeepSeek 实测(它会在返回里报告 cache hit / miss token):
| 调用 | prompt_tokens | cache_hit | cache_miss |
|---|---|---|---|
| 长历史(首次) | 1485 | 0 | 1485 |
| 长历史(前缀不变,只追加) | 1485 | 1280 | 205 |
| 摘要压缩后 | 79 | 0 | 79 |
可以看到:只追加时命中了 1280;一旦做了摘要压缩,改写了系统提示之后的早期对话,缓存直接掉到 0。
但这里有一个反转:压缩虽然打掉了缓存,却把 prompt 从 1485 砍到了 79。 按 DeepSeek 的缓存价(命中约 0.1× 全价)粗算:
- 不压缩一次调用 ≈
1280 × 0.1 + 205 × 1 = 333 - 压缩后一次调用 ≈
79 × 1 = 79
也就是说,压缩即使全 miss,反而更便宜——省下的 token 远超缓存折扣带来的收益。缓存 miss 的主要代价其实是延迟(重新 prefill),而不是费用。
那么,怎么把这种影响降到最低?
- 稳定内容前置:缓存是「从头匹配到第一个不同 token」,所以把系统提示词、工具定义等大头放在最前且保持不变,它们永远命中,受影响的只有后面被压缩的动态历史;
- 低频压缩:不要每轮都压,只在越过阈值或每隔若干轮压一次;压完新前缀会重新稳定,后续追加又能命中,代价被摊销;
- 减少前缀变动次数:把摘要放在一个固定的「记忆槽位」、必要时才更新,而不是频繁重写;
- 按计费决策:若某家缓存命中价极低、且窗口够用,「少压缩、多靠缓存」可能更划算;反之则更该压缩。用 provider 报告的 hit / miss token 实测对比最靠谱。
一句话:压缩与缓存是一对天然冲突的量,任何改动历史前缀的压缩都会降低缓存命中;但因为压缩能大幅削减 token,它通常是净赚的。关键是把压缩做得「低频、把稳定内容前置」,把对缓存的冲击降到最低。
七、其他值得了解的思路
- 检索式记忆(RAG over history):把历史消息存进向量库,每次只检索与当前问题最相关的若干条,而不是无脑带上全部。适合话题跳跃的长会话。
- 结构化记忆:定期让模型把对话抽取成事实 / 用户画像(键值对或 JSON),只注入必要字段。比自由摘要更省 token、更稳定,也便于程序使用。
- 提示缓存(Prompt Caching):DeepSeek、OpenAI 等对相同前缀的请求有缓存折扣。保持历史前缀稳定、只往后追加,能显著降低成本。
- 分层记忆:短期(最近几轮原文)+ 中期(摘要)+ 长期(外部存储 / 检索),按需组合。
八、小结
多轮对话的本质是「每次把历史一起发给模型」,实现只需在消息数组上「入栈用户消息 → 带历史调用 → 入栈模型回复」。当历史膨胀后,核心矛盾就变成在「信息不丢」和「token 可控」之间取舍:
- 滑动窗口(
trimMessages) 简单可控,但会丢早期信息; - 摘要压缩 用一次额外调用把旧对话浓缩成要点,信息保留得更好;
- 混合方案 按预算先摘要、再截断兜底,是推荐做法。
同时要意识到:任何改动历史前缀的压缩都会降低提示词缓存的命中率,但因为压缩能大幅削减 token,它通常是净赚的——把系统提示等稳定内容前置、压缩保持低频,就能把对缓存的冲击降到最低。
再进一步,还可以叠加检索式记忆、结构化记忆和提示缓存,构建更健壮、更低成本的长期记忆系统。