AI 应用里的 Memory,不是“保存聊天记录”,而是管理上下文预算

很多人一开始做聊天机器人、RAG 助手或者 Agent,都会把 Memory 理解成一件很朴素的事:把用户和模型的对话一条条存起来,下次继续带上就行。

这个理解不算错,但只对了一半。

真正到了工程落地阶段,你很快会碰到两个现实问题:

  1. 上下文窗口不是无限的,消息越堆越多,成本、延迟和失败率都会上升。
  2. "历史消息"并不都同样重要,有些要保留原文,有些只需要摘要,有些根本不该直接塞进 prompt。

所以,Memory 的本质并不是"存消息",而是"管理哪些信息在什么时机、以什么形式进入当前上下文"。

这也是我对这类问题的核心判断:

在大多数 AI 应用里,默认最优解不是只用截断、也不是只用向量检索,而是分层记忆:短期上下文靠截断控制窗口,中期连续性靠总结压缩信息,长期召回靠检索恢复关键事实。

如果你把这件事想明白,很多常见误区就会自然消失。比如:

  • 为什么"把所有历史消息都带上"不是一个可持续方案。
  • 为什么摘要明明会损失信息,却依然是必须要有的能力。
  • 为什么检索很强,却不能替代最近几轮原始对话。
  • 为什么 Memory 不该只被看成一个框架 API,而应该被设计成系统链路的一部分。

下面就沿着这条主线,把截断、总结、检索三种策略讲清楚。

为什么 AI 应用一定会遇到 Memory 问题

大模型本身是无状态的。

你这次调用模型,和下一次调用模型,从模型视角看是两次独立请求。它之所以能"记住"你前面说过的话,不是因为模型内部真的保留了会话,而是因为应用层把之前的消息重新组织进了这次 prompt。

也就是说,所谓"模型记忆",本质上是应用在做上下文重建。

一个最典型的调用链路是这样的:

  1. 应用维护一个 messages 列表。
  2. 每次用户发来新问题,把这条 HumanMessage 追加进去。
  3. 如果模型上一轮调用过工具,还要把 AIMessageToolMessage 一起保留下来。
  4. 再把这份消息列表整体发给模型,让模型基于"看起来连续"的上下文继续回答。

问题就出在这里。

这套机制短期内很好用,但只要对话足够长,就一定会出现以下问题:

  • token 持续增长,请求成本越来越高。
  • 历史太长,响应速度变慢。
  • 工具调用链一长,消息结构更复杂,容易碰到模型或 SDK 的上下文限制。
  • 太旧、太碎、太嘈杂的信息混进 prompt,反而降低当前回答质量。

所以 Memory 管理的目标,从来都不是"尽量多保留",而是"尽量高质量地保留"。

先把概念摆正:存储层和策略层不是一回事

很多文章容易把这两件事混在一起:

  • 一类问题是:消息存在哪里?
  • 另一类问题是:消息怎么用?

这是两个不同维度。

1. 存储层:历史消息放在哪里

这是持久化问题,常见选项包括:

  • 进程内存:适合本地 demo 和单进程实验。
  • 文件:适合简单持久化、调试和个人工具。
  • Redis:适合在线会话、低延迟访问。
  • 数据库:适合审计、归档、复杂查询和多租户管理。
  • 对象存储或日志系统:适合超长历史归档。

存储层解决的是"我能不能把历史找回来"。

2. 策略层:哪些历史应该进入当前 prompt

这是上下文编排问题,常见做法就是本文要讲的三种:

  • 截断:只保留最近的一段消息。
  • 总结:把旧消息压缩成摘要。
  • 检索:按语义或结构化条件召回历史片段。

策略层解决的是"就算我都存下来了,这一轮到底该拿哪些给模型看"。

工程上,很多系统失败不是因为"没存",而是因为"乱取"。消息存储做得再完整,如果每轮 prompt 还是简单拼接全部历史,系统依然会越来越差。

三种策略分别解决什么问题

截断:解决窗口失控和成本失控

截断是最基础、也最容易被低估的一种策略。

它做的事非常直接:当历史消息太长时,只保留最近的一部分,把更早的消息排除在本轮上下文之外。

很多人觉得截断太粗暴,但在工程上,它往往是第一道必须存在的防线。原因很简单:

  • 最近几轮对话通常和当前问题最相关。
  • 成本、延迟、稳定性问题,首先需要一个硬边界。
  • 就算你后面还会做总结和检索,截断依然是兜底机制。

截断常见有两种方式。

按消息条数截断

这是最简单的方案,比如只保留最近 8 条消息或最近 4 轮对话。

优点是实现快、行为可预测。

缺点也明显:

  • 一条消息可能只有一句话,也可能是一大段 JSON。
  • 工具调用场景里,一轮可能带上 assistant tool_calls + tool result,按条数很容易截断得不完整。

所以条数截断适合 demo,不适合作为生产系统唯一策略。

按 token 截断

这是更实用的方案。因为模型真正关心的不是"多少条",而是"多少 token"。

如果你在 LangChain JS 里做这件事,trimMessages 是一个很自然的入口。核心思想不是依赖某个"黑盒 Memory 类",而是在模型调用前,显式地对消息做裁剪。

js 复制代码
import { trimMessages } from "@langchain/core/messages";

async function buildRecentMessages(messages, tokenCounter) {
  return trimMessages(messages, {
    maxTokens: 3000,
    strategy: "last",
    startOn: "human",
    endOn: ["human", "tool"],
    tokenCounter,
  });
}

这段代码做了三件事:

  • maxTokens:给本轮历史上下文设置硬预算。
  • strategy: "last":优先保留最近消息。
  • startOn / endOn:避免把消息裁成非法结构,尤其是在 tool call 场景下。

这里最关键的不是 API 本身,而是预算意识。

生产环境里我通常不会把上下文窗口全部给历史消息,而会预留一部分给:

  • 当前用户问题
  • system prompt
  • tools 描述
  • 模型输出空间

比如模型上下文是 32k,你可能只给历史消息分 8k 到 12k,而不是全部占满。

总结:解决长对话连续性问题

只靠截断有一个明显问题:系统会越来越"健忘"。

假设用户前面聊了 30 轮,最近几轮在讨论接口报错,但更早的时候已经明确说过:

  • 他是哪个企业租户的管理员
  • 当前环境是 staging
  • 之前已经尝试过哪些排查步骤

如果这些信息被简单截掉,模型就会开始重复提问,或者给出明显不贴合上下文的建议。

这时就需要总结。

总结策略的核心不是"把历史变短",而是"把低频但高价值的信息提炼出来"。它适合保留这几类内容:

  • 用户画像:角色、偏好、语言风格、权限身份
  • 长期任务:目标、约束、验收条件
  • 关键事实:已经确认的结论、排查结果、已执行动作
  • 决策过程:为什么选择 A,而不是 B

不适合原样压成摘要的内容则包括:

  • 最近几轮正在进行的细粒度推理过程
  • 需要逐字准确保留的结构化结果
  • 工具调用的原始返回体

总结本质上是有损压缩,因此它一定不是"越多越好",而应该在阈值触发时执行。

检索:解决跨时间跨度的精准召回问题

总结能保住连续性,但它无法解决另一个问题:用户突然问一个很久以前聊过、但这几轮完全没提的话题。

例如:

  • "我上周提过的 API 密钥轮换方案是什么来着?"
  • "我们上次确认过哪个服务是主调用方?"
  • "我之前说过自己更偏向 Java 方案还是 Python 方案?"

这类问题不一定在最近消息里,也不适合全靠摘要兜底。因为摘要会压缩信息,很多细节未必会保留下来。

所以你需要检索型记忆。

它的工作方式和 RAG 很像:

  1. 把历史中的关键片段做 embedding。
  2. 存进向量数据库或可检索存储。
  3. 用户发起新问题时,对当前 query 做向量化。
  4. 检索最相关的历史片段,再拼回 prompt。

但这里要特别强调一个常见误区:

检索记忆不是把所有聊天原文都无脑扔进向量库。

更合理的做法通常是存三类对象:

  • 每轮对话的成对摘要
  • 已确认的用户事实卡片
  • 任务阶段性总结

原因很简单。原始聊天很碎、噪声很多,直接按消息粒度做向量检索,召回质量往往并不好。相比之下,经过整理的"事实"和"阶段摘要"更适合作为长期记忆单元。

哪个方案应该作为默认方案

如果只给一个工程结论,我的建议是:

默认推荐:截断 + 总结 + 检索的分层组合

分工如下:

  • 截断负责控成本,保证每次调用都不会失控。
  • 总结负责保连续性,让系统在长对话中不至于"失忆"。
  • 检索负责远距离召回,让旧信息在需要时重新出现。

这是绝大多数"长期聊天 + 任务协作 + 事实记忆"场景下更稳妥的默认方案。

只用截断,适合哪些场景

  • 会话很短
  • 用户问题独立性强
  • 对长期上下文依赖很低
  • 主要目标是先把系统跑稳、跑便宜

比如临时客服问答、一次性表单助手、低复杂度工具型机器人。

只用总结,不适合做默认方案

很多人看到总结后会觉得:"那我把旧对话全总结成一段,不就够了吗?"

不够。

因为摘要一定会丢信息,尤其对以下场景不友好:

  • 需要恢复精确措辞
  • 需要回看详细操作步骤
  • 需要追踪多分支任务状态

所以总结适合作为压缩层,不适合作为唯一记忆层。

只用检索,也不适合做默认方案

检索很强,但它解决的是"召回"问题,不是"连续对话流"问题。

如果你把最近几轮也全交给检索处理,会遇到两个问题:

  • 近场上下文本来就应该直接保留,检索是在浪费一次召回成本。
  • 检索结果是"找回来",不是"天然连续",模型对话流会变得跳跃。

因此,检索应该补长期记忆,而不是替代最近上下文。

一个更接近真实业务的 Demo:企业内部支持助手

为了更贴近真实开发,我们不再用"做菜助手"这种教学型场景,而是换成一个企业内部支持助手。

这个助手要解决的问题是:

  • 用户会连续多轮排查一个线上问题
  • 期间会调用工具读取日志、查配置、看工单
  • 会话可能跨天继续
  • 用户经常会引用"上次我们说到哪了"

这种场景非常适合分层记忆。

整体链路

我建议把 Memory 链路拆成四层:

  1. History Store 负责持久化原始消息,比如 Redis、数据库或文件。
  2. Recent Window 对最近消息做 token 截断,形成短期工作记忆。
  3. Summary Memory 对被挤出窗口的旧消息做阶段性总结。
  4. Retrieval Memory 对摘要、事实卡片、关键事件做向量化,按需召回。

最后在 prompt 组装阶段,把这几块信息按优先级拼进去:

text 复制代码
System Prompt
+ 会话摘要 Summary Memory
+ 检索到的长期记忆 Retrieval Memory
+ 最近原始消息 Recent Window
+ 当前用户输入

注意顺序很重要。

最近原始消息应该比摘要更靠后,因为它离当前问题更近;摘要和检索结果则更适合作为背景信息,而不是主对话流本身。

关键实现一:把"最近消息"作为短期工作记忆

下面这段代码的职责很明确:从原始消息里裁出一段可直接给模型的"工作区上下文"。

js 复制代码
import { trimMessages } from "@langchain/core/messages";
import { encodingForModel } from "js-tiktoken";

const encoder = encodingForModel("gpt-4o-mini");

function countTokens(messages) {
  return messages.reduce((total, message) => {
    const content =
      typeof message.content === "string"
        ? message.content
        : JSON.stringify(message.content);
    return total + encoder.encode(content).length;
  }, 0);
}

export async function buildRecentWindow(messages) {
  return trimMessages(messages, {
    maxTokens: 4000,
    strategy: "last",
    startOn: "human",
    endOn: ["human", "tool"],
    tokenCounter: countTokens,
  });
}

这里的设计意图不是"保留尽可能多",而是"保留最近、完整、合法的一段消息结构"。

为什么这样写:

  • maxTokens: 4000 不是模型上限,而是历史消息预算。
  • startOn: "human" 是为了减少截断后上下文从半截 assistant 开始的情况。
  • endOn: ["human", "tool"] 是为了尽量避免把 tool call 配对关系裁坏。

如果你换一种写法,直接 messages.slice(-8),当然也能跑,但在有工具调用和长文本响应时会明显不稳。

关键实现二:旧消息不是删除,而是压缩成摘要

当最近窗口已经装不下,而旧对话又仍然有价值时,就要把"历史对话"转成"摘要记忆"。

js 复制代码
import { SystemMessage, HumanMessage } from "@langchain/core/messages";

export async function summarizeConversation(model, messages) {
  const transcript = messages
    .map((msg) => `${msg.getType()}: ${msg.content}`)
    .join("\n");

  const prompt = [
    new SystemMessage(
      [
        "你是对话记忆压缩器。",
        "请只保留后续对话仍然需要的重要信息:",
        "1. 用户身份和偏好",
        "2. 已确认事实",
        "3. 已完成动作与结果",
        "4. 未完成任务和约束",
        "不要保留寒暄,不要展开推理。"
      ].join("\n")
    ),
    new HumanMessage(`请总结以下历史对话:\n\n${transcript}`),
  ];

  const response = await model.invoke(prompt);
  return response.content;
}

这段代码的重点,不是"调用一次 LLM 做摘要"这么简单,而是摘要提示词的约束。

如果你只写一句"请总结以下对话",模型很容易产出一段可读但不可用的自然语言总结。它看起来通顺,但未必适合继续喂给模型。

一个可用的摘要,应该尽量接近"记忆对象",而不是"文章摘要"。它最好显式包含:

  • 用户是谁
  • 当前在做什么
  • 哪些事已经确认
  • 哪些事还没完成

如果业务再复杂一点,我甚至更推荐把摘要输出成结构化 JSON,而不是自然语言段落。

关键实现三:长期记忆不要直接存聊天原文

检索型记忆的关键,不只是接入向量数据库,而是"存什么"。

如果你把每一句聊天原文都单独 embedding,后面会出现三个问题:

  • 语义太碎,召回结果不稳定。
  • 相似句子很多,排序噪声大。
  • 缺乏结构字段,难以做过滤。

更稳妥的方式,是把长期记忆设计成文档对象:

js 复制代码
const memoryRecord = {
  memoryId: "tenant_42_task_20260329_001",
  userId: "u_1001",
  sessionId: "s_7788",
  type: "task_summary",
  topic: "支付服务排障",
  content:
    "用户在 staging 环境排查支付回调超时;已确认网关超时阈值为 3 秒;" +
    "日志显示下游库存服务偶发 5 秒响应;下一步需要检查库存服务线程池配置。",
  tags: ["staging", "payment", "timeout"],
  createdAt: "2026-03-29T10:30:00Z",
};

然后你再对 contentcontent + tags + topic 做 embedding,存入向量数据库。

这样做的好处是:

  • 检索单元更完整,不是碎片化句子。
  • 你可以按 userIdsessionIdtypetopic 做过滤。
  • 后续还能同时支持语义检索和结构化检索。

这也是为什么我不建议把"对话消息"和"长期记忆对象"混为一谈。前者是原始日志,后者是经过治理后的知识单元。

关键实现四:一次完整调用到底怎么组装

真正的工程关键,不在于某一个 API,而在于调用前的编排顺序。

下面给一个简化版流程:

js 复制代码
async function buildPrompt({
  historyStore,
  vectorStore,
  model,
  sessionId,
  userId,
  userInput,
}) {
  const allMessages = await historyStore.getMessages(sessionId);

  const recentMessages = await buildRecentWindow(allMessages);

  const summary = await historyStore.getSummary(sessionId);

  const retrievedMemories = await vectorStore.search(userInput, {
    k: 4,
    filter: { userId },
  });

  return [
    {
      role: "system",
      content:
        "你是企业内部支持助手。优先依据当前问题、最近上下文和已确认历史事实回答。",
    },
    summary
      ? {
          role: "system",
          content: `会话摘要:\n${summary}`,
        }
      : null,
    retrievedMemories.length
      ? {
          role: "system",
          content:
            "以下是与当前问题相关的历史记忆:\n" +
            retrievedMemories.map((item) => `- ${item.content}`).join("\n"),
        }
      : null,
    ...recentMessages,
    { role: "user", content: userInput },
  ].filter(Boolean);
}

这一段代码在整条链路中的位置,是"模型调用之前的上下文构建器"。

它体现的是四个工程判断:

  • 原始消息不直接全量透传。
  • 摘要不是替代最近消息,而是补背景。
  • 检索结果不是越多越好,而是只给当前 query 相关的几条。
  • prompt 组装是应用层职责,不应该交给一个黑盒对象自动决定。

这些参数别只会调,得知道为什么存在

maxTokens

它决定短期工作记忆的大小。

这个值太小,系统容易失去连续性;太大,成本和延迟会上升。通常我会先根据模型总窗口,反推出历史预算,再逐步调。

responseReserveTokens

虽然很多 demo 不会显式写这个参数,但生产系统一定要有"输出预留"意识。

如果你把上下文塞到接近上限,模型要么回答被截断,要么直接报错。经验上,给输出预留 15% 到 30% 的空间通常更稳。

summaryTriggerThreshold

这是触发摘要的阈值,可以按消息条数,也可以按 token 数。

工程上我更推荐按 token 数,因为消息长度波动太大。一个现实做法是:当历史消息预算用到 70% 到 80% 时,就开始触发总结,而不是等到完全顶满。

keepRecentTokens

触发摘要后,并不是把所有原消息都清掉,而是仍然保留最近的一段原始消息。

这是为了维持对话流的细节和语气连续性。一般来说,最近窗口应该始终保留,摘要只负责吸收更早的部分。

k

这是检索返回条数。

k 不是越大越好。拿太多只会把噪声重新塞回 prompt。多数业务里,35 通常比 10 更实用。

scoreThreshold

如果你的向量数据库支持分数阈值,最好加上。

因为很多时候"最相似"不代表"足够相关"。没有阈值时,系统会把一些勉强相关的历史也召回出来,污染当前上下文。

常见误区和坑

误区一:Memory 越完整越好

不对。

Memory 不是归档系统。归档的目标是尽量完整,Memory 的目标是尽量有用。把所有消息都塞进 prompt,很多时候是在增加噪声,而不是增加理解。

误区二:总结之后就可以把原始消息彻底删掉

不建议。

摘要适合进入 prompt,但原始消息更适合留在存储层做审计、回放和二次处理。进入上下文和是否持久化,应该分开考虑。

误区三:检索到的内容一定比摘要更可靠

不一定。

检索依赖 embedding 质量、召回粒度和过滤条件。如果你的长期记忆对象设计得很差,检索出来的内容一样会偏。

误区四:把每条消息都向量化就是长期记忆

这通常只是"做了 embedding",不等于"做好了 memory"。

长期记忆更像知识治理问题。你要定义记忆单元、元数据、过期策略、去重策略,而不是只接一个向量库。

误区五:工具调用消息可以随便裁

这是很多 Agent 场景下最容易出 bug 的地方。

tool_callsassistant 消息,往往必须和后续 tool 消息成对出现。你如果只保留了一半,模型下次调用时很容易出现结构错误或语义错乱。

实战建议:怎么选型更稳

如果你正在做一个真实 AI 应用,我的建议是:

第一阶段:先把截断做好

别一上来就搞复杂记忆系统。先把最近消息的 token 预算、工具消息完整性、输出预留空间这些基础问题解决掉。

第二阶段:补摘要层

当你发现系统开始出现"聊久了就忘""重复问用户背景""跨 20 轮后明显失忆",再把摘要层加进去。

第三阶段:再做长期检索

只有当业务真的需要跨会话、跨天、跨任务恢复历史事实时,检索型记忆才会真正体现价值。否则过早上向量库,只是在增加系统复杂度。

第四阶段:把长期记忆对象化

不要停留在"消息检索"层面。尽量把长期记忆沉淀成:

  • 用户事实
  • 任务摘要
  • 决策记录
  • 工单结论
  • 偏好配置

这会比"检索聊天原文"稳定得多。

一个更值得采用的工程结论

如果必须把这篇文章压缩成一句话,我会这样总结:

AI 应用里的 Memory,不是把消息存起来这么简单,而是要把"存储、压缩、召回、组装"做成一套分层上下文管理系统。

截断解决的是预算问题,总结解决的是连续性问题,检索解决的是远距离召回问题。三者不是竞争关系,而是不同时间尺度上的协作关系。

所以,真正可落地的默认方案通常不是"三选一",而是:

  • 用持久化存储保留原始历史
  • 用 token 截断维护短期工作记忆
  • 用摘要压缩中期上下文
  • 用检索恢复长期关键事实

当你用这种方式去理解 Memory,你会发现它不再只是聊天应用里的一个附属功能,而是整个 AI 系统质量、成本和可扩展性的关键控制点。

延伸阅读

相关推荐
慧一居士2 小时前
nuxt3 项目和nuxt4 项目区别和对比
前端·vue.js
威联通安全存储2 小时前
破除“重前端、轻底层”的数字幻象:如何夯实工业数据的物理底座
前端·python
inksci2 小时前
Js生成安全随机数
前端·微信小程序
吴声子夜歌3 小时前
TypeScript——泛型
前端·git·typescript
猩猩程序员4 小时前
Pretext:一个绕过 DOM 的纯 JavaScript 排版引擎
前端
竹林8184 小时前
从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录
前端·javascript
神舟之光4 小时前
jwt权限控制简单总结(乡村意见簿-vue-express-mongdb)
前端·vue.js·express
铭毅天下4 小时前
EasySearch Rules 规则语法速查手册
开发语言·前端·javascript·ecmascript
GISer_Jing4 小时前
AI Agent操作系统架构师:Harness Engineer解析
前端·人工智能·ai·aigc