原生NodeJS维护Agent Memory实践

手把手教你维护 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₃]
...

这就带来两个必然问题:

  1. 上下文爆炸:聊得越久,每次发送的内容越多,迟早撞上模型的上下文窗口上限,还会拖慢响应、增加消耗
  2. 必须裁剪:而裁剪就意味着丢信息------怎么丢、丢多少、能不能把丢的信息"浓缩"回来,就是今天要解决的核心问题

历史随轮次增长

二、记忆体系:短期记忆与长期记忆

动手之前先对齐一个全景概念。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.jssummaryCompressByTokens

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:短聊几十轮都不触发,零额外开销;一旦粘贴长文,下一轮立即触发。这就是"动态"的含义------压缩节奏跟着内容体量自适应,这是轮数阈值永远做不到的。

想观察压缩节奏,把 .envMODEL_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?

  1. OpenAI 兼容接口本质就是一个 HTTP POST ,请求体是个 JSON,响应是 SSE 流------直接写 fetch 反而比理解 SDK 的封装层更快;今天换本地模型、明天换云端,只需改 BASE_URL
  2. 框架的黑盒是调试的天敌:本文三个坑(同引用清空、思考耗预算、摘要丢信息)都需要精确到字节地观察请求/响应,手写实现让每个环节都有日志可查;用框架时这些细节藏在封装里,出问题只能猜
  3. 学习价值:Agent 的核心不是框架的 API,而是"消息数组怎么组织、什么时候压、怎么压"这些决策------手写一遍,再看任何框架都能一眼看穿它在干什么

框架适合赶工期,原生适合打地基。这篇文章写的是地基。

十三、总结

维护 Agent History 的核心就四句话:

  1. 会话状态按 sessionId 隔离,靠引用一致性延续------原地修改数组,别换引用
  2. 压缩用双阈值------触发阈值和保留阈值分开,留出缓冲,避免每轮都压
  3. 阈值用 token 度量,别用轮数------预算 = 窗口 − 输出预留 − 人设 − 安全余量,压缩节奏跟着内容体量自适应
  4. 摘要压缩是信息换空间的交易------提示词要具体、空摘要要兜底、全过程要打日志

滑动窗口适合对延迟敏感的轻量场景;需要跨轮次记忆时,摘要压缩(或其进阶形态:向量化记忆检索)是必经之路。而本文讲的这些都发生在短期记忆 层------当业务需要跨会话"认出老朋友"时,就要把有价值的事实沉淀进长期记忆(实体记忆 / 检索式记忆),新会话开始时召回注入,两层配合才是完整的 Agent 记忆体系。

完整代码结构:start.js(会话与对话流程)+ utils/context.js(slidingWindow / summaryCompress)+ utils/sse.js(手写 SSE 解析)+ logger.js(log4js 日志),零 AI 框架依赖,欢迎留言交流。

相关推荐
人才瘾大1 小时前
写了几十个Skill之后,我总结出这套工程方法:从「触发不了」到「生产可用」
agent
星月日1 小时前
前端上手后端起手式
前端·后端
用户976104399211 小时前
第三章 大语言模型基础 3.1语言模型与Transformer架构
agent
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(一)
前端·后端
moMo1 小时前
Workflow 与 Agent:AI 应用的两大范式
agent·workflow
云烟成雨TD1 小时前
LlamaIndex 系列【18】关键词检索(Keyword Search):TF-IDF 算法
ai·agent·llamaindex
Lyy1 小时前
DevOps平台 — 第九篇:配置中心的设计与实现
后端·devops
吃饱了得干活1 小时前
MySQL 进阶:锁与事务、执行计划、内存管理、高可用架构及 8.0 新特性
后端·mysql
Json____1 小时前
基于 FastAPI + Vue3 的高校选课管理系统技术解析
后端·fastapi·wwwoop.com