手把手教你维护 Agent 的多轮对话 History:从滑动窗口到摘要压缩的完整实践
用 Node.js + 本地模型(qwen3-1.7b)实现多轮对话,完整记录上下文管理的设计、踩坑与实测数据。
全文方案完全原生 :不用 LangChain、不用 OpenAI SDK、不用任何 AI 框架,只有 Node.js 内置
fetch+ 手写 SSE 解析 + 手写会话管理,每一行逻辑都可拆解。
一、问题:模型是"金鱼脑"
大模型本身没有记忆 。每次请求都是独立的,模型不知道你们上一轮聊了什么。所谓"多轮对话",本质是客户端在维护一个消息数组(History),每轮把全部历史重新发给模型:
sql
第1轮发送:[system, user₁]
第2轮发送:[system, user₁, assistant₁, user₂]
第3轮发送:[system, user₁, assistant₁, user₂, assistant₂, user₃]
...
这就带来两个必然问题:
- 上下文爆炸:聊得越久,每次发送的内容越多,迟早撞上模型的上下文窗口上限,还会拖慢响应、增加消耗
- 必须裁剪:而裁剪就意味着丢信息------怎么丢、丢多少、能不能把丢的信息"浓缩"回来,就是今天要解决的核心问题

历史随轮次增长
二、记忆体系:短期记忆与长期记忆
动手之前先对齐一个全景概念。Agent 的记忆通常分成两大类,本文后面要讲的全部属于前者:
| 短期记忆(Short-term Memory) | 长期记忆(Long-term Memory) | |
|---|---|---|
| 范围 | 单次会话内(session 级) | 跨会话(用户级,持久化) |
| 载体 | 内存中的消息数组(本文的 History) | 数据库 / 向量库 / 结构化 KV |
| 生命周期 | 会话结束即丢失 | 长期保存,按需召回 |
| 约束 | 上下文窗口大小 | 几乎无上限,但需要检索筛选 |
| 核心课题 | 有限空间内怎么裁剪、怎么保意 | 怎么沉淀、怎么存、怎么精准召回 |

短期记忆与长期记忆
两者的关系可以类比成工作台与仓库:
- 短期记忆是工作台:模型只能"看见"工作台上的东西,所以本文的滑动窗口、摘要压缩、双阈值,本质上都是"整理工作台的技巧"------在有限的桌面上摆最有用的信息
- 长期记忆是仓库:对话中有价值的信息(用户姓名、偏好、约定)被沉淀进仓库,新会话开始时检索召回摆上工作台,模型就能跨会话"认出老朋友"
本文的滑动窗口和摘要压缩优化的是短期记忆;第十一节里的实体/键值记忆、检索式记忆(RAG 化)则是长期记忆的典型实现。记住这个分法,后面看业界任何记忆方案都能一眼定位它在哪一层做文章。
三、基础架构:会话管理
先用一个 Map 按 sessionId 隔离每个会话,每个会话是一个独立的消息数组:
csharp
const sessions = new Map();
function createSession() {
return [{ role: "system", content: SYSTEM_PROMPT }];
}
const getSession = (sessionId) => {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, createSession());
}
return sessions.get(sessionId);
};
两个关键设计点:
- 懒创建:某个 sessionId 第一次出现时才创建会话
- 引用一致性 :
getSession返回的是 Map 里那个数组的同一引用,后续对它的原地修改(清空、push)会直接反映到会话存储------这是多轮历史得以延续的根基(也是后面第一个坑的来源,先记住这句话)
每一轮对话的核心流程:
ini
async function chat(userMessage, history, handlers) {
// 1. 压缩历史(下文详述)
const compressed = compress(history);
// 2. 先拷贝快照,再原地重建(顺序不能反!)
const snapshot = compressed.slice();
history.length = 0;
history.push(...snapshot, { role: "user", content: userMessage });
// 3. 流式请求模型
const fullContent = await streamChat(history, handlers);
// 4. 只把正文写回历史(思考过程不存,避免上下文膨胀)
history.push({ role: "assistant", content: fullContent });
}
四、两种压缩策略
我把压缩逻辑封装成了独立工具模块 utils/context.js,支持两种策略,整体分流如下:

压缩策略分流
策略一:滑动窗口(简单粗暴)
只保留最近 N 轮,超出部分直接丢弃:
javascript
export function slidingWindow(history, { maxTurns = 10 } = {}) {
const [systems, turns] = splitSystems(history);
if (turns.length <= maxTurns * 2) return history;
return [...systems, ...turns.slice(-(maxTurns * 2))];
}
- 优点:零成本,同步执行
- 缺点 :早期信息彻底消失。用户第 1 轮说"记住数字 7",到第 11 轮模型就完全不认识了
策略二:摘要压缩(有损但保意)
超出阈值时,把较早的轮次交给模型生成一段摘要,替换原文;最近几轮保留原文:
css
压缩前:[人设, 轮1, 轮2, 轮3, 轮4, 轮5, 轮6, 轮7, 轮8]
压缩后:[人设, 前情摘要(轮1~2浓缩), 轮3~轮8 原文]
核心实现:
javascript
export async function summaryCompress(history, { triggerTurns = 8, keepTurns = 6, summarize }) {
const [systems, turns] = splitSystems(history);
if (turns.length <= triggerTurns * 2) return history; // 未触发
const old = turns.slice(0, -keepTurns * 2); // 待压缩的旧轮次
const recent = turns.slice(-keepTurns * 2); // 原样保留的近期轮次
// 已有旧摘要时,并入输入一起重新摘要------多次压缩不丢早期信息
const existing = systems.find((m) => m.content.startsWith(SUMMARY_PREFIX));
const input = existing
? `${existing.content}\n\n新增对话:\n${renderMessages(old)}`
: renderMessages(old);
const digest = await summarize(input);
return [
...baseSystems,
{ role: "system", content: `${SUMMARY_PREFIX}\n${digest}` },
...recent,
];
}
几个设计细节:
- 摘要以 system 消息身份存回会话,随会话持久化
- 滚动摘要:第二次压缩时,旧摘要 + 新增对话一起作为输入,早期信息经过多次压缩依然在
- summarize 函数由调用方注入,工具模块不依赖具体的模型调用方式,保持纯工具属性
五、关键设计:双阈值缓冲
第一版我只用了一个阈值:超过 6 轮就压缩、压缩后保留 6 轮。结果实测发现------压完正好停在阈值边上,下一轮必超线,从此每轮都触发一次摘要调用。日志里连续几轮都在压缩,又慢又浪费。
解法是借鉴温控器的迟滞区间思想,拆成双阈值:
ini
const TRIGGER_TURNS = 10; // 超过 10 轮才触发压缩
const KEEP_TURNS = 6; // 压缩后保留最近 6 轮

双阈值节奏
节奏变成:
10 轮 → 触发压缩,保留 6 轮
7~10 轮 → 缓冲期,正常对话,零额外开销
11 轮 → 再次触发......
实测同样 7 轮对话,摘要模型调用从 7 次降到 3 次。权衡点很清晰:缓冲越大,调用越少,但上下文峰值越高。
六、进阶设计:双阈值搬上 token 维度
上一节的双阈值解决了"多久压一次",但度量单位还是轮数,这有个致命问题:一轮的长度天差地别。10 轮短问答可能连窗口的零头都填不满,2 轮长文粘贴就可能把窗口撑爆------轮数阈值对这两种情况毫无分辨力。真正稀缺的资源是 token,阈值自然也应该用 token 来度量。
先算对预算等式
先澄清一个概念:约束 History 的不是 max_tokens,而是上下文窗口 (输入 + 输出的总上限);max_tokens 只是窗口里留给模型回复的那一块。所以 History 能吃到的空间是个"剩余值":
erlang
|←--------------------------------------------- 上下文窗口 context_length ---------------------------------------------→|
| 人设 | History 预算(动态) | 输出预留 max_tokens |
|←------------------ 触发线 80% · 目标线 50% ------------------→|
三个输入各有所来:
- 上下文窗口 :模型的固定属性,标准 OpenAI 兼容接口不会返回它,务实做法是在
.env里配置(MODEL_CONTEXT_LENGTH=32768),换模型改配置 - 输出预留 :请求体里显式传
max_tokens,并且预算计算与请求共用同一个常量,两边才不会各写各的数 - 安全余量:本文的 token 数是粗估(长度 × 0.7),天然有误差,预算再打 8 折,不贴线
ini
const MODEL_CONTEXT_LENGTH = Number(process.env.MODEL_CONTEXT_LENGTH || 32768);
const OUTPUT_RESERVE = 2048; // 即请求体的 max_tokens,预算计算与请求共用
const SAFETY_FACTOR = 0.8;
const CONTEXT_BUDGET = Math.floor((MODEL_CONTEXT_LENGTH - OUTPUT_RESERVE) * SAFETY_FACTOR) - estimateTokens(SYSTEM_PROMPT);
const TRIGGER_TOKENS = Math.floor(CONTEXT_BUDGET * 0.8);
const TARGET_TOKENS = Math.floor(CONTEXT_BUDGET * 0.5);
温控器思想不变,只换度量单位
| 轮数版 | token 版 | |
|---|---|---|
| 触发阈值 | 10 轮 | 预算 × 80% |
| 压缩后目标 | 保留 6 轮 | 落回预算 × 50% |
| 保留近期 | 固定 6 轮 | 从最新往回累加到保留线,至少 2 轮兜底 |
核心实现在 utils/context.js 的 summaryCompressByTokens:
ini
export async function summaryCompressByTokens(history, { triggerTokens, targetTokens, minKeepTurns = 2, incomingTokens = 0, summarize }) {
const [systems, turns] = splitSystems(history);
const usage = totalTokens(history) + incomingTokens; // 本轮用户消息也计入预算
if (usage <= triggerTokens) return history; // 未达触发线,零开销
// 从最新一轮往回累加到保留线(取目标线 7 成,给摘要留空间)
const recentBudget = Math.floor(targetTokens * 0.7);
let recentTokens = 0, keptTurns = 0, keepFrom = turns.length;
for (let i = turns.length - 2; i >= 0; i -= 2) {
const turnTokens = estimateTokens(turns[i].content) + estimateTokens(turns[i + 1].content);
if (keptTurns >= minKeepTurns && recentTokens + turnTokens > recentBudget) break;
recentTokens += turnTokens; keptTurns++; keepFrom = i;
}
const old = turns.slice(0, keepFrom);
if (old.length === 0) return history; // 单轮已超保留线,无可压缩的旧轮次,交给服务端处理溢出
const [baseSystems, summaryMsg] = await summarizeOld(systems, old, summarize);
return [...baseSystems, summaryMsg, ...turns.slice(keepFrom)];
}
两个设计细节:
- 保留线不再数轮数,而是按预算往回累加 :长文保留轮数自动变少、短问答自动变多,连贯性始终刚好;
minKeepTurns = 2兜底 - 单条超长消息不在压缩射程内:用户一轮粘一万字长文时没有旧轮次可压,跳过并告警------压缩救得了"轮数多",救不了"单轮太长"
实测效果
按 32K 窗口配置,触发线约 1.9 万 tokens:短聊几十轮都不触发,零额外开销;一旦粘贴长文,下一轮立即触发。这就是"动态"的含义------压缩节奏跟着内容体量自适应,这是轮数阈值永远做不到的。
想观察压缩节奏,把
.env的MODEL_CONTEXT_LENGTH临时改小(如 2048),即可复现上一节十几轮触发的效果。
七、踩坑实录:三个真实 Bug
这部分是全文最值钱的内容------全部来自真实运行日志。
坑一:先清空、后拷贝,历史凭空消失
症状:模型每次只收到最新一条消息,连 system 指令都没了。
根因:未超长时压缩函数返回的是 history 本身(同一引用) 。我最初的代码是:
ini
history.length = 0; // ① 清空 history
history.push(...compressed.slice()); // ② compressed 是同一个数组------已被清空!
①执行完,②里的 compressed.slice() 拷贝的是空数组。修复:先快照,后清空:
ini
const snapshot = compressed.slice(); // 顺序不能反
history.length = 0;
history.push(...snapshot, { role: "user", content: userMessage });
教训:函数返回"原数组或新数组"时,调用方必须在任何原地修改之前完成拷贝。
坑二:思考模式耗尽输出预算,摘要为空
症状:会话里出现 "前情摘要:\n" 的空壳,后续对话上下文断档。
根因:qwen3 开着 thinking 模式,非流式请求中思考内容和正式回复共享 max_tokens 预算 。输入较长时思考过程暴涨,512 的预算被思考吃光,轮到正文输出时 content 为空。
修复三件套:
arduino
enable_thinking: false, // 关闭思考(实测耗时 4147ms → 1665ms)
max_tokens: 1024, // 加大预算双保险
// 兜底:仍为空时降级截取原文前 500 字,绝不写入空摘要
教训:thinking 模式是把双刃剑,功能性请求(摘要、抽取、分类)建议一律关掉。
坑三:摘要提示词太笼统,关键信息被丢
症状:12 轮测试中,第 1 轮要求"记住数字 7",压缩后模型答错(答 15,正确是 18)。查日志发现摘要只保留了算式结果"2、4、6",把"记住 7"丢了。
根因:摘要指令只写了"保留关键事实",小模型(1.7B)无法判断什么算"关键"。
修复:把要求说具体:
必须优先保留:用户要求记住的信息(数字、名称、约定)、
用户身份设定、未决问题;普通问答只留结论。
复测一次通过。
教训:给小模型的指令要具体到"举例子"的程度,抽象形容词等于没说。
延伸:这就是提示词工程(Zero-Shot / One-Shot / Few-Shot)
坑三的本质是提示词工程问题。"笼统指令"和"具体指令"都属于 Zero-Shot(只给指令不给示例),而它还有两个进阶形态:
| 模式 | 做法 | 特点 |
|---|---|---|
| Zero-Shot | 只给指令 | 省 token,依赖模型自己理解;大模型通常够用,小模型容易翻车 |
| One-Shot | 指令 + 1 个"输入→输出"示例 | 用例子展示期望的格式与取舍标准 |
| Few-Shot | 指令 + 多个示例 | 覆盖不同类型的情况,效果最稳,但示例本身也占上下文 |
本文的修复是把模糊的 Zero-Shot 改成了具体的 Zero-Shot(枚举保留优先级)。如果还想再稳一档,可以升级为 One-Shot,直接给模型看一个"什么该留"的示范:
sql
示例:
输入:user: 记住我的生日是3月5日。assistant: 好的已记住。
user: 今天天气怎么样?assistant: 晴,22度。
输出:用户生日:3月5日。(日常闲聊不保留)
实践经验:模型越小,Few-Shot 的边际收益越大;对 qwen3-1.7b 这种小模型,一个高质量的 One-Shot 示例往往比十句描述性要求更管用。但注意示例也计入上下文预算------在摘要这种高频调用里,一个示例就够,别贪多。
八、可观测性:日志是调试的眼睛
压缩逻辑涉及异步、引用、多策略,肉眼根本跟不上。我给每个关键节点都加了日志(log4js):
ini
[摘要压缩] 当前上下文:11 轮,约 1679 tokens
[摘要压缩] 达到触发阈值 10 轮!将最早 5 轮压成摘要,最近 6 轮原样保留,4 轮内不再触发压缩
[摘要压缩] 交给模型总结的输入(约 122 tokens):...
[摘要压缩] 模型生成的摘要(约 21 tokens):已记住数字7;用户托尼·斯塔克;1+1=2,2+2=4,3+3=6
压缩后(summary):23 → 15 条,结构 [system, system, user, assistant × 6]
三个坑全部是靠这些日志定位的。建议:压缩前后各打一条结构化日志(轮数、token 估算、消息结构),压缩的输入输出全文落盘。配合本地模型服务端日志(LM Studio 会记录每次请求的完整 body),客户端和服务端双向对照,任何数据丢失都藏不住。
九、12 轮实测数据
最终用脚本模拟真实 12 轮对话(前 2 轮埋记忆点,中间 9 轮加法题,最后 1 轮跨压缩回忆):
| 验证点 | 结果 |
|---|---|
| 前 11 轮 | 全部 未达触发阈值,无需压缩,零额外调用 ✅ |
| 第 12 轮 | 精确触发一次:23 条 → 15 条 ✅ |
| 摘要内容 | 已记住数字7;用户托尼·斯塔克;1+1=2,2+2=4,3+3=6 ✅ |
| 跨压缩回忆 | 问"最早记住的数字+11",答 7+11=18 ✅ |
| 普通轮次耗时 | 1~6 秒;触发压缩的轮次 22 秒(多一次摘要调用) |
模型服务端日志同步验证:请求消息数从 22 → 摘要请求(2 条)→ 15,与客户端日志严丝合缝。
十、参数调优速查
| 参数 | 建议 |
|---|---|
MODEL_CONTEXT_LENGTH |
按模型配置在 .env,预算等式的"总量" |
OUTPUT_RESERVE |
即请求体 max_tokens,预算计算与请求共用,切勿两处各写各的 |
TRIGGER_TOKENS / TARGET_TOKENS |
预算的 80% / 50%,差值即缓冲 |
SAFETY_FACTOR |
0.8,token 粗估误差的折扣 |
摘要 temperature |
0.3 左右,求稳不求创意 |
摘要 enable_thinking |
必须 false |
摘要 max_tokens |
≥ 预估摘要长度 × 2,防思考残留耗预算 |
正文 enable_thinking |
按需开关:开启后页面可实时展示思考过程,但响应变慢 |
| 历史存储 | 只存 content,不存 reasoning_content |
十一、上下文压缩还有哪些玩法?
滑动窗口和摘要压缩只是入门。业界在"用更少的 token 保住更多记忆"这件事上,已经卷出了很多层次:
1. 应用层:历史管理(本文覆盖)
| 方式 | 记忆归属 | 思路 | 适用场景 |
|---|---|---|---|
| 滑动窗口 | 短期 | 只留最近 N 轮,旧的直接丢 | 延迟敏感、无需长期记忆 |
| 摘要压缩 | 短期 | 旧轮次浓缩成摘要,滚动更新 | 需要跨轮记忆,本文实现 |
| 分级摘要 | 短期 | 短期保原文、中期保摘要、长期保"摘要的摘要",金字塔式分层 | 超长会话(百轮以上) |
| 实体/键值记忆 | 长期 | 不存对话,只抽取事实:姓名=托尼·斯塔克、记住的数字=7,存成结构化字段按需注入 |
用户画像、偏好记忆 |
| 检索式记忆(RAG 化) | 长期 | 历史消息向量化入库,每轮只检索与当前问题相关的片段拼入上下文 | 海量历史,代表:MemGPT |
2. 提示层:token 级压缩
如 LLMLingua 系列:用一个小模型逐 token 评估重要性,删掉对语义贡献低的词,能把 prompt 压到 1/2~1/5 且效果损失很小。适合检索拼接后的大 prompt,但需要额外部署压缩模型。
3. 推理层:不碰文本,直接压缓存(了解即可)
这一层在模型推理引擎内部,应用层无感,但值得知道名字:
- StreamingLLM(注意力沉锚) :只保留开头几个"沉锚 token" + 最近窗口,无限长度流式对话不掉点,代价是无法召回中间内容(和滑动窗口同构,但在 KV 缓存层实现)
- H2O 等 KV 缓存逐出:按注意力分数动态淘汰不重要的 KV 缓存,省显存换长上下文(LM Studio 里的"KV 缓存量化"是同类思路的省显存变体)
选型建议(由浅入深)
短会话(<20 轮) → 滑动窗口,零成本
中等会话 + 需要记忆 → 摘要压缩(本文)
长期用户关系 → 实体记忆 + 检索式记忆组合拳(MemGPT 思路)
检索拼接的超长 prompt → 叠加 LLMLingua 类 token 压缩
十二、为什么坚持完全原生?
整个方案的全部依赖只有三个,没有一个是 AI 框架:
| 依赖 | 用途 |
|---|---|
express |
Web 服务 |
dotenv |
读环境变量 |
log4js |
日志 |
模型调用用的是 Node 18+ 内置的 fetch,SSE 流解析是手写的 30 行 parseSSEStream,会话管理是一个 Map。
为什么不用 LangChain / OpenAI SDK?
- OpenAI 兼容接口本质就是一个 HTTP POST ,请求体是个 JSON,响应是 SSE 流------直接写
fetch反而比理解 SDK 的封装层更快;今天换本地模型、明天换云端,只需改BASE_URL - 框架的黑盒是调试的天敌:本文三个坑(同引用清空、思考耗预算、摘要丢信息)都需要精确到字节地观察请求/响应,手写实现让每个环节都有日志可查;用框架时这些细节藏在封装里,出问题只能猜
- 学习价值:Agent 的核心不是框架的 API,而是"消息数组怎么组织、什么时候压、怎么压"这些决策------手写一遍,再看任何框架都能一眼看穿它在干什么
框架适合赶工期,原生适合打地基。这篇文章写的是地基。
十三、总结
维护 Agent History 的核心就四句话:
- 会话状态按 sessionId 隔离,靠引用一致性延续------原地修改数组,别换引用
- 压缩用双阈值------触发阈值和保留阈值分开,留出缓冲,避免每轮都压
- 阈值用 token 度量,别用轮数------预算 = 窗口 − 输出预留 − 人设 − 安全余量,压缩节奏跟着内容体量自适应
- 摘要压缩是信息换空间的交易------提示词要具体、空摘要要兜底、全过程要打日志
滑动窗口适合对延迟敏感的轻量场景;需要跨轮次记忆时,摘要压缩(或其进阶形态:向量化记忆检索)是必经之路。而本文讲的这些都发生在短期记忆 层------当业务需要跨会话"认出老朋友"时,就要把有价值的事实沉淀进长期记忆(实体记忆 / 检索式记忆),新会话开始时召回注入,两层配合才是完整的 Agent 记忆体系。
完整代码结构:
start.js(会话与对话流程)+utils/context.js(slidingWindow / summaryCompress)+utils/sse.js(手写 SSE 解析)+logger.js(log4js 日志),零 AI 框架依赖,欢迎留言交流。