🚀🚀【封闭结束7】Agent 为什么总是“失忆”?从长期记忆到上下文工程的生产实践

0 阅读24分钟

前言:封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可,尽量用几篇文章讲清。
这篇文章是系列第7篇:上下文窗口有上限,别当无限仓库乱塞。分清工作记忆、会话状态、长期记忆和 RAG,该写入的写入,不该混放的别混。
希望这篇文章,能给正在搭建 Agent 的朋友一些参考。

线上Agent产品,经常会出现几类问题:

  • 用户隔了一周回来,说「还是上次那个支付服务」,系统却不知道「上次那个」指什么。
  • 把整本产品手册塞进「用户记忆」,检索时返回一堆无关段落,Token 费用不断上涨,真正的个人偏好反而被淹没。
  • 上下文窗口一直往里追加,从不摘要,最终导致输入被截断、推理成本上升。
  • 模型只是猜测「可能是 payment-api 服务」,但系统却把它写进会话状态,当成已确认事实。下一轮用户说「查它的日志」,Agent 就可能继续查错服务。
  • 用户明确说过「不要重启生产实例」,但旧消息被摘要压缩掉了,Agent 后续又提出甚至尝试执行重启操作。

这些问题看起来是 Agent 的「记忆力」不够好,实际上涉及四个不同层面:上下文组织、会话状态管理、跨会话记忆和企业知识检索。

这篇文章重点讲清楚:

  1. 四个盒子分开:工作记忆、会话状态、长期记忆、RAG,各管各的;
  2. 该写才写:区分用户明确表达、工具验证和模型推断,避免把猜测写成事实。
  3. 窗口有预算:先保留系统规则和当前任务状态,再分配最近对话、记忆和检索结果。
  4. 按场景选方案: 普通问答、个性化助手、企业运维和长任务 Agent,不能用同一套记忆策略硬套。
  5. 关注工程成本: 对比数据库、向量检索、摘要压缩和 GraphRAG 的使用场景。

继续用案例 OpsPilot(企业运维助手,虚构案例)。文中示例用 TypeScript,团队线上服务是 Go;换成 Java / Node 也一样,关键是适用场景清楚。

Snipaste_2026-11-02_14-59-58.png

先把四个盒子分开:Agent 的记忆为什么不能混在一起?

很多人会把「记忆」和「检索」混为一谈。先明确一点:模型不会自动访问你的数据库。

每次调用模型时,Runtime 都需要根据当前任务,从会话状态、长期记忆、知识库和最近对话中选择相关信息,再组装成模型可以使用的上下文Prompt。它们不直接喂模型:

image.png

图上四块与文章目录对应,按照目录来理解效果更好:

  • 工作记忆(Working Memory,WM):本轮发给模型的那份 Prompt。这一轮调用结束就没了,下一轮要重新拼接,不单独另存到库表等。
  • 会话状态(Session State):这次任务停在哪(当前服务、环境等);落地挂在会话上,代码里写成 SessionState。有人也叫任务状态,指的是同一类东西,本文统一用会话状态。
  • 长期记忆(Long-term Memory,LTM):跨会话的个人记忆,多个会话都可以使用(单说 Memory 时常指整套记忆系统,不只这一个)。
  • 知识库(RAG):企业文档检索,和 LTM 分开处理。

读图时记住:模型只看工作记忆(WM)拼出来的那份 Prompt;会话状态、长期记忆(LTM)、知识库(RAG)都在 Runtime 外侧,本轮用得到才读进来。若用 LangGraph 时,MessagesState / checkpointer 更靠近「这次 Run 的断点 / 消息」,但代替不了长期记忆(LTM)和知识库(RAG);跨会话的个人偏好,要你自己做一层记忆表 + 读写接口。不使用 LangGraph 也一样,消息列表不等于完整的记忆系统 Memory。

再补一句别混用:

  • 用户偏好、说过的话:写入长期记忆(LTM)表,记得按用户隔离。同一偏好,说过两次只留一条。比如他说「改成默认测试」时,要能替换或删掉旧记录。
  • 文档、制度:放进共用知识库,用 RAG 检索。大家共用一份,不要按照某个用户单独去改。
  • 别反着放:个人说过的话不要进企业知识库;公司规范也不要写进某个用户的记忆表。

工作记忆与会话状态

工作记忆:这一轮发给模型的六块内容

还用 OpsPilot 示例来看。用户上一句已确认查生产环境的 payment-api,现在说:「它的错误日志呢」。

这一轮需要拼接的上下文,通常包括下面六块:

系统 Prompt          → 你是运维助手,只能查日志,写操作要等人确认…
工具 Schema          → query_logs 需要 serviceId、env、timeRange…
最近对话             → 用户刚才说了什么、助手回了什么(留最近几轮原文)
会话状态摘要         → 当前服务=payment-api,环境=production(从 SessionState 摘过来)
本轮检索/记忆召回    → 按需从库里取片段放进 Prompt。来源可以是长期记忆(如「该用户默认看生产」),也可以是知识库片段(如查日志规范);本轮如果用不上,可以为空。
最新工具结果         → 若刚调过工具,放刚返回的内容;还没调过工具时,可以为空。

这六块是上下文组装时的逻辑分组,不代表每轮都必须填满这些,也不代表它们合起来就是工作记忆的唯一标准。实际发送给模型前,还要经过相关性筛选、权限检查和 Token 预算控制。

拼接好之后,应用层的工作对象可以长这样(字段和上面六块内容对应,注释即字段含义):

const workingMemory: { // 本轮送进模型的工作记忆
  systemPrompt: string
  toolSchemas: unknown[]
  recentMessages: Array<{ role: 'user' | 'assistant' | 'tool'; content: string }>
  sessionStateSummary: Record<string, unknown>
  recalled: Array<{ source: 'ltm' | 'rag' | 'graph'; id: string; text: string; score?: number }>
  latestToolResult?: { toolName: string; content: string }
  runNotes?: string
  tokenBudget: { maxInput: number; reservedForOutput: number }
} = {
  systemPrompt: '你是 OpsPilot…写操作必须等人确认。', // 系统 Prompt:身份、权限、安全规则
  toolSchemas: [{ name: 'query_logs', parameters: { /* serviceId, env… */ } }], // 工具 Schema:本轮可调哪些工具、参数长什么样
  recentMessages: [ // 最近对话原文
    { role: 'user', content: '查一下生产环境 payment-api' },
    { role: 'assistant', content: '已定位到 payment-api(production)。' },
    { role: 'user', content: '它的错误日志呢' }
  ],
  // 会话状态摘要:从 SessionState 摘来;不是再搜一遍聊天记录
  sessionStateSummary: {
    currentService: { id: 'svc_pay', name: 'payment-api' },
    env: 'production'
  },
  recalled: [ // 本轮召回片段:长期记忆或知识库手册等;source 标明来源
    { source: 'ltm', id: 'mem_1', text: '该用户默认看生产环境监控', score: 0.82 }
  ],
  latestToolResult: undefined, // 最新一次工具返回;本轮未调工具可空,调过则放裁剪后的内容
  runNotes: undefined,         // 本轮笔记(可选):多步时有长度上限的短摘要,见下一段
  tokenBudget: {               // 组装预算:控制裁剪,一般不整段写入 Prompt 正文
    maxInput: 24_000,          // 输入 Token 上限
    reservedForOutput: 1_500   // 预留给模型输出
  }
}

多步执行时还有一个常见问题。假设第一步已经调用 search_resource,返回了一大段 JSON。若下一步把“第一步整段 JSON + 第二步新结果”原样追加进历史,输入会随着执行轮次不断膨胀。

更省 Token 的做法是:

  • 把已确认的结论写成短字符串,放进上面的 runNotes,例如 已确认 payment-api 在 production,资源 id=svc_pay,并设长度上限;
  • 每步只带上当前决策需要的最新工具结果;
  • 更早几步的大段原文可以存到外部,再通过结果 ID 按需取回,不必每轮原样重放。

这时 runNotes 有值,latestToolResult 只放当前这一步的返回。

组装时注意:

  • 最近 2 到 6 轮可以作为起点,尽量保留与当前指代和任务有关的原文。具体轮数需要按任务复杂度和 Token 预算调整。
  • 更早的对话压成摘要;摘要里要留下环境、服务名、已确认结论和重要限制,不要只剩无信息量的概述。
  • 工具结果不要整段丢进 Prompt。例如 query_logs 返回 2000 行日志,Prompt 里只放摘要或关键几行(错误码、时间、条数);完整原文存到别处,需要时再用结果 ID 取回。
  • 系统 Prompt 和工具定义尽量少改,并尽量保持稳定的输入前缀。如果所使用的模型和 API 支持 Prompt Caching,稳定前缀可能有助于提高缓存命中率,但并非所有模型和 API 都保证支持或命中缓存。

会话状态:任务停在哪

上面例子里,模型能把“它”解析为 payment-api,通常是因为最近对话中提到过这个服务;结构化状态也可以提供辅助。实际系统往往会结合两者,而不是只靠全文搜索历史消息。

本文把挂在会话上的结构化状态称为 SessionState。它可以存放在会话表、关联表或状态存储中;组装 Prompt 时,再按需摘取成 sessionStateSummary。

还是用户说“它的错误日志呢”这一轮,可以长这样:

const sessionState: SessionState = { // 挂在会话上的已确认事实,不是消息原文。
  conversationId: 'conv_123', // 属于哪个会话
  currentService: { id: 'svc_pay', name: 'payment-api' }, // 用户或工具已确认,不是模型瞎猜
  env: 'production', // 已确认环境
  updatedAt: '2026-10-28T10:00:00Z'
}

type SessionState = { // 会话结构化状态:这次任务停在哪
  conversationId: string
  currentService?: { id: string; name: string }
  env?: 'development' | 'testing' | 'production'
  updatedAt: string
}

和消息、Run 不要混存。系列里的会话服务常见的是:消息表、会话(可挂结构化状态)、Run 表。和“解析它 / 续跑”相关的三类东西:

存什么存的是什么干什么用
Message(消息表)聊天原文:谁说了哪一句页面历史、审计、事后摘要。Agent 不靠翻整张消息表来解析「它」
SessionState(会话结构化状态)已确认的服务、环境等下一轮决策;组装时摘成 sessionStateSummary
Run(这一次执行)用户点一次发送按钮对应的后台任务;interrupted 时等人确认确认后接着这次 Run 续跑。断点最小状态可放在 Run 的 checkpoint 字段;更细的中断记录见前几篇文章中的《Agent 中的 SSE 流式推送与 HITL 暂停恢复》

消息与 Run 示意(字段与多轮文对齐):

const message: Message = { // 消息表:一行一句聊天原文
  id: 'msg_101', // 消息 id
  conversationId: 'conv_123', // 属于哪个会话
  role: 'user', // 'user' | 'assistant' | 'tool'
  content: '它的错误日志呢', // 展示 / 落库的正文
  createdAt: '2026-10-28T10:00:01Z'
}

const run: Run = { // Run 表:用户点一次发送对应的一次后台任务
  id: 'run_9', // Run id
  conversationId: 'conv_123', // 落在哪个会话
  userMessageId: 'msg_101', // 由哪条用户消息触发
  status: 'interrupted', // running | interrupted | completed | failed | cancelled;等人确认时用 interrupted
  checkpoint: { /* 续跑所需最小状态;形状见 HITL 专文 */ },
  createdAt: '2026-10-28T10:05:00Z',
  updatedAt: '2026-10-28T10:05:00Z' // 状态最后变更时间
}

对照一下:

  • 刷新页面,聊过的句子还在 → Message。
  • 用户只说「再查一下它」,系统仍知道是 payment-api → SessionState(会话上的已确认事实)。
  • 点了「重启实例」,弹窗等确认;稍后点确认还能续跑 → Run(status: interrupted + checkpoint)。不管「用户默认看生产」这类跨会话长期记忆。

只有工具成功或用户明确确认的信息,才写进 SessionState。模型猜测的服务名、失败查询的参数,不要当成已确认事实。

Snipaste_2026-11-02_15-00-11.png

长期记忆与知识库文档

长期记忆不等于 RAG,也不要写进 RAG。 有人觉得“反正都是向量检索,用户说过的话一并丢进知识库就行”,但这会把个人偏好和公司手册混成同一池,权限、过期和删除都不好处理。

先分清两件事:

  • 长期记忆:关于「这个用户 / 这个 Agent」的东西,跨会话还在,按 userId 隔离。例如「他默认看生产」。
  • 知识库(RAG):关于「公司手册怎么写」的东西,多人共享、有版本。例如「运维手册:查日志须同时指定环境和时间范围」。

检索手段可以都类似(都可能用向量),但库、权限、写入通道、失败策略是两套。长期记忆走自己的存储与 search;公司手册变更走知识库索引。

存哪:起步很常见是 MySQL / PostgreSQL 一张记忆表(按 userId / tenantId 过滤,能改能删);条数多、要语义相近召回时,再给 content 加向量索引,或旁路一个向量库。这和「企业 RAG 知识库」仍是两套,不要共用一个索引池。

还是 OpsPilot。用户说过「以后默认看生产环境」。这不该写入企业知识库(RAG),应写成一条只属于该用户的长期记忆。

长期记忆:抽、筛、存、用

不要在对话一结束后,就把整段聊天原文原样塞进长期记忆表。更稳妥的方式是先抽出候选结论,再决定是否入库:

  1. 抽:本轮聊完后,从对话中提取可能有长期价值的内容。例如用户说“以后默认看生产”,得到候选:默认看生产环境监控。
  2. 筛:与该用户已有记忆进行去重、合并和冲突检查。若用户明确把“默认测试”改成“默认生产”,就更新旧记录;模型自己都没把握的内容,不应直接成为生效记忆。
  3. 存:将通过筛选的内容写入长期记忆表,并记录对应的 userId、tenantId 和来源。低置信度内容可以丢弃,不要写进企业 RAG 知识库。
  4. 用:下次用户再说“查一下错误日志”时,使用用户和租户范围进行检索,再把相关记忆按需加入工作集和上下文。

筛过之后,库表里的一行可以长这样:

const memoryItem: { // MySQL / PostgreSQL 长期记忆表的一行,不是 RAG 文档
  id: string
  userId: string
  tenantId: string
  type: string
  content: string
  sourceMessageIds: string[]
  confidence: number
  expiresAt?: string
  createdAt: string
  updatedAt: string
} = {
  id: 'mem_1',                              // 主键
  userId: 'u_42',                           // 用户隔离;查询必须带上
  tenantId: 't_acme',                       // 租户隔离;查询必须带上
  type: 'semantic',                         // 可选分类;起步可先固定写 semantic / preference
  content: '默认看生产环境监控',             // 只记结论;不记访问凭证、不记整段日志
  sourceMessageIds: ['msg_100'],            // 追溯到哪句原话,方便改错、删除
  confidence: 0.91,                         // 抽取把握;过低则本行不写入
  expiresAt: undefined,                     // 可选过期时间
  createdAt: '2026-10-10T08:00:00Z',
  updatedAt: '2026-10-10T08:00:00Z'
}

下一轮用户说“查一下错误日志”,召回可能命中 mem_1,于是工作集里的 recalled 就可以包含这条信息。模型在本轮上下文中看到它后,才知道用户通常默认查看生产环境;但如果当前指令与该偏好冲突,应优先遵循当前明确指令和业务权限规则。

写入时注意:

  • 查库时带上租户和用户:WHERE tenant_id = ? AND user_id = ?。不要指望在 Prompt 里写「别看别人的记忆」。实际中模型靠不住,SQL / 接口过滤才靠得住。
  • 用户能改、能删:他说「我改主意了,默认看测试」,旧的「默认看生产」要能更新或标记作废,不能改不了。
  • 敏感原文默认不记:访问凭证、整段日志正文不要进记忆表。只记「默认看生产」这类结论。
  • 别什么话都记:「嗯」「好的」没信息量,跳过。同一偏好出现两次,合并成一条,别堆重复行。

怎么落地?先有一张记忆表和两个接口就够:add(写入或更新一行)、search(按用户和权限检索相关行)。需要语义相近召回时,再给 content 增加向量检索。不必一上来就建设完整的“记忆运行时”产品。

知识库文档:和长期记忆怎么区分

内容放哪为什么
“我默认看生产”长期记忆(带 userId)只对这个用户成立
“运维手册:查日志须同时指定环境和时间范围”知识库(RAG)公司文档,按权限供相关人员使用
“架构说明:payment-api 依赖 redis-cluster”知识库(RAG);如果经常需要多跳查询,再评估 GraphRAG公司里的客观关系,不是某个人的偏好

本篇只约定:用户偏好走长期记忆,公司文档走 RAG。 一般问答先用基础 RAG;真要回答“谁依赖谁、故障会波及谁”这类需要连查多层关系的问题,再评估 GraphRAG。

GraphRAG 并不是越复杂越好。它的优势是可以帮助处理多跳实体关系问题;代价是图谱构建、关系更新、索引维护和检索延迟都会增加。是否值得上,应该看业务问题是否真的需要关系推理,以及准确率收益能不能覆盖维护成本。

记忆表的 type 字段可选。多数业务起步时先存一行结论就够;以后若要区分信息类型、寿命或召回策略,再使用不同类型,例如:

type(示意)记什么例子
preference / 偏好相对稳定的个人偏好“该用户默认看生产”
episode / 经历某次排查经历“2026-10-10 排查过 payment-api 超时”
habit / 习惯对该用户怎么回复更合适(不是公司手册)“给该用户回故障时先结论后证据”

公司规范放知识库,不要写进某个用户的记忆表。起步一张记忆表、一套读写接口就够,不必按 preference / episode / habit 拆成三套 API。

Snipaste_2026-10-27_15-51-59.png

上下文工程:组装、预算与写入

前面四个盒子回答“东西分别存哪”。上下文工程回答的是:

  1. 每一轮往模型窗口里装什么;
  2. Token 不够时裁谁;
  3. 确认事实怎么写回会话状态(SessionState);
  4. 本轮答完后怎么抽取长期记忆(LTM)。

下面用同一条 OpsPilot 链路把这些跑通。一轮从读到写,顺序可以看成:

image.png

下面几段代码是同一条链路上的函数,不是几套新概念:buildAgentContext 负责读并组装,assemblePrompt 按预算拼 Prompt,writeSessionState / afterTurn 负责写回。

加载会话状态(SessionState)+ 按需召回长期记忆(LTM)/ 知识库(RAG)
  → 组装工作记忆(WM)(硬区几乎不裁,软区占剩余预算)
  → 模型决策与工具调用
  → 仅确认事实写回会话状态(SessionState)
  → 异步抽取长期记忆(LTM)(不要为了抽记忆让用户干等回复)
知识库文档变更走索引流水线,不走长期记忆(LTM)

按预算拼 Prompt

拼接工作记忆对应的上下文时,可以先把内容分成两拨:

  • 硬区:Token 再紧也尽量留着——系统规则、工具定义、会话状态。系统规则和工具定义尽量别每轮大改,拼 Prompt 时放在最前面(开头稳定时模型侧常能走 Prompt Caching,省token)。如果硬区本身超过模型输入预算,就必须检查、精简或调整配置。
  • 软区:用剩下的 Token 配额按相关程度往里塞——最近消息、长期记忆(LTM)、知识库(RAG);不够时,先裁剪低价值内容。

字段对应:

工作记忆(WM)字段进哪一拨
systemPrompt、toolSchemas(可并进系统侧)硬区
sessionStateSummary硬区
recentMessages、recalled、RAG 片段软区
tokenBudget只管裁剪上限,一般不整段写进 Prompt 正文
type ContextAssemblyPlan = { // 组装计划:硬区几乎不裁,软区按分数占剩余预算
  hard: {
    systemRules: string // 系统 Prompt / 工具定义等;尽量稳定,拼在 Prompt 最前
    sessionState: SessionState // 当前会话已确认状态
  }
  soft: {
    recentMessages: Message[] // 最近几轮原文,用来解析「它」
    longTermMemories: Array<{ id: string; content: string }> // 记忆表召回的行
    ragChunks: Array<{ id: string; text: string }> // 知识库片段
    graphFacts: Array<{ id: string; text: string }> // 可选;未上 GraphRAG 可空
  }
  budget: {
    maxTokens: number // 输入 Token 上限
    reserveOutput: number // 预留给模型输出
  }
}

function assemblePrompt(plan: ContextAssemblyPlan): string {
  // 先装硬区,计入已用 Token
  let used = countTokens(plan.hard.systemRules) + countTokens(JSON.stringify(plan.hard.sessionState))
  const parts: string[] = [plan.hard.systemRules, renderState(plan.hard.sessionState)]

  // 最近消息优先塞进剩余预算;否则模型无法解析「它」指什么
  for (const msg of plan.soft.recentMessages.slice().reverse()) {
    const t = countTokens(msg.content)
    if (used + t > plan.budget.maxTokens - plan.budget.reserveOutput) break // 超预算就停
    parts.unshift(renderMessage(msg))
    used += t
  }

  // 记忆与 RAG 按相关分排序、同源去重,再占剩余预算
  const candidates = rankAndDedupe([
    ...plan.soft.longTermMemories.map(asCandidate),
    ...plan.soft.ragChunks.map(asCandidate),
    ...plan.soft.graphFacts.map(asCandidate)
  ])

  for (const item of candidates) {
    if (used + item.tokens > plan.budget.maxTokens - plan.budget.reserveOutput) break
    parts.push(item.text)
    used += item.tokens
  }

  return parts.join('\n\n') // 拼成最终 Prompt 文本
}

经验顺序可以概括为:安全与身份规则 → 当前会话状态 → 相关近期原文 → 重要工具结果和运行笔记 → 相关长期记忆 → RAG / 图谱片段。

这不是对所有任务都适用的绝对排序。例如,某个问题必须依据官方规范回答时,相关规范可能比一般历史摘要更重要。关键是有明确的优先级,并确保重要约束不会被低价值内容挤掉。

下面数字表示:拼进上下文时,每一类内容大约最多占多少 Token。具体数值需要按模型窗口和业务负载调整。

// 使用规则:`maxInput` 是整次输入总上限,下面几项加起来应落在总上限内,并给输出留出 `reserveOutput`。
const budget = {
  maxInput: 24_000,         // 整次输入总上限
  systemAndTools: 4_000,    // 硬区:系统 Prompt + 工具 Schema
  sessionState: 1_000,      // 硬区:会话状态;几乎不裁
  recentMessages: 6_000,    // 软区:最近原文;用来解析「它 / 上次那个」
  memories: 2_000,          // 软区:长期记忆(LTM)召回
  ragOrGraph: 6_000,        // 软区:知识库(RAG)/ 图谱片段
  toolObservations: 4_000,  // 软区:已裁剪的工具结果
  reserveOutput: 1_000      // 预留给模型输出,不算进上面的输入分项
}

不够用时分层压缩,不要整段一刀切成摘要:

  • 工具结果只留与当前决策相关的行;
  • 旧消息保留近期原文和滚动摘要,摘要里要有环境、服务名和关键结论;(可以省很多)
  • 检索结果先去重、过滤低相关内容,再按预算加入;
  • 重要的权限、环境和用户明确否定过的操作,需要显式保留,不能在摘要中丢失。

压缩并不一定能提高准确率。如果摘要删掉了“用户明确拒绝重启”这类关键信息,模型即使拿到更短的上下文,也可能做出更危险的决策。因此,评估上下文工程时,不能只看 Token 数量,还要看任务成功率、工具调用正确率、延迟和成本。

另外,隔离做错比没有记忆更危险:

  1. tenantId / userId 必须在存储层过滤;
  2. 长期记忆(LTM)与知识库(RAG)应有清晰的数据管理边界;
  3. 召回时标明来源,方便排查“为什么用错偏好”。

完整读写代码

还是用户说“它的错误日志呢”这一轮,读和写可以长这样。以下仍然是接近真实工程的示例代码。

sessionStore、ltm、rag、auth 和 memoryExtractor 都是应用层依赖,需要在具体项目中实现。

// 读:加载状态 → 召回记忆/文档 → 按预算拼 Prompt(接上 assemblePrompt)
async function buildAgentContext(input: {
  userId: string
  tenantId: string
  conversationId: string
  message: string // 用户本轮话,用作召回 query
  recentMessages: Message[] // 最近对话原文
}) {
  const state = await sessionStore.load(input.conversationId) // 读 SessionState

  const memories = await ltm.search({ // LTM:查询必须带 userId / tenantId
    userId: input.userId,
    tenantId: input.tenantId,
    query: input.message,
    limit: 5
  })

  const docs = await rag.retrieve({ // 知识库(RAG):按租户 + 角色过滤,不走长期记忆表
    tenantId: input.tenantId,
    roles: await auth.roles(input.userId),
    query: input.message,
    limit: 4
  })

  return assemblePrompt({
    hard: {
      systemRules: SYSTEM_PROMPT, // 硬区:系统规则
      sessionState: state // 硬区:当前会话状态
    },
    soft: {
      recentMessages: input.recentMessages.slice(-6), // 软区:最近 6 条
      longTermMemories: memories,
      ragChunks: docs,
      graphFacts: [] // 未上 GraphRAG 时留空
    },
    budget: {
      maxTokens: 24_000,
      reserveOutput: 1_500
    }
  })
}

// 写 SessionState:工具成功或用户确认后立刻写;只写已确认字段
async function writeSessionState(conversationId: string, patch: Partial<SessionState>) {
  await sessionStore.patch(conversationId, patch) // 例如 currentService、env
}

// 写长期记忆:回合结束后异步抽取,不挡用户看到回复
async function afterTurn(input: {
  userId: string
  tenantId: string
  messageIds: string[] // 追溯到哪几句原话
  transcriptSlice: string // 本轮对话片段,供抽取用
}) {
  const extracted = await memoryExtractor.extract(input.transcriptSlice) // 抽出候选结论
  await ltm.upsertMany(
    extracted
      .filter(item => item.confidence >= 0.7) // 把握太低的不写
      .map(item => ({
        ...item,
        userId: input.userId, // 写入时带上隔离字段
        tenantId: input.tenantId,
        sourceMessageIds: input.messageIds
      }))
  )
}

上面读完、模型跑完之后,还要把结果存回去,但两类东西的写入时机不同:

  • 会话状态(SessionState):当可信事件发生时及时更新。例如用户确认当前服务,或者工具成功返回了服务 ID。对于模型推断的内容,需要保留候选状态或等待确认。
  • 长期记忆(LTM):等本轮回答已经开始推给用户之后,再在后台从对话里抽结论写入记忆表,不要为了抽记忆让用户干等回复。

召回时也别搞混:长期记忆 LTM 没命中,可以继续聊;知识库 RAG 没命中,该拒答时就拒答。

开发分几个阶段

第一阶段(多数业务够用):最近 N 轮 + 滚动摘要、结构化会话状态(SessionState)、工具结果裁剪、企业知识走知识库(RAG)。复杂长期记忆(LTM)可以后上。

第二阶段(跨会话个性化):上用户级长期记忆(LTM)的抽取与检索;能更新、能删;Prompt 里分清「用户偏好」和「官方文档」。

第三阶段(复杂关系 / 长任务):真有连查多层关系的时候,再上 GraphRAG;子 Agent 上下文隔离;窗口快满时分层压缩、按预算裁剪;记忆和文档冲突时,官方文档优先于过期偏好。

上线前避开这些坑

  • 知识库(RAG)和长期记忆(LTM)不分开管理: 企业文档更新了,用户还抱着旧偏好;或把个人闲聊写进全员知识库。
  • 把上下文窗口当仓库: 每轮只往里追加,从不摘要,后面出现截断,费用不断上涨。
  • 只靠消息原文维持会话状态: 用户确认了当前是 payment-api 服务,却没有更新结构化状态数据;下一轮说“查它的日志”,系统仍可能无法可靠解析。
  • 长期记忆什么都写: 模型猜错的、访问凭证、整段日志,一起塞进记忆表,这是不对的。
  • 把模型输出直接当成已确认事实: 没有来源、确认状态和权限校验,错误信息就可能在后续轮次被不断放大。
  • 上来就上 GraphRAG: 业务还没有“谁依赖谁、故障会波及谁”这类问题,先把基础 RAG 跑稳,别为了架构图好看就上图谱。可能根本用不到。

Snipaste_2026-10-27_15-52-14.png

小结

记忆系统要落地,关键就三块:四个盒子别混、只有确认过的才写入、窗口按预算分配。

  • 工作记忆(WM)、会话状态(SessionState)、长期记忆(LTM)、知识库(RAG)各管各的。 用户偏好和「上次那个」进 LTM 或会话状态;公司手册、制度走 RAG。
  • 只有工具成功或用户明确确认的事实,才写进会话状态(SessionState)/ 长期记忆(LTM)。 LTM 按租户 / 用户隔离,能删、能更正,敏感原文默认不进。
  • 上下文窗口有预算,不是无限仓库。 先留系统规则和当前会话状态,再留最近原文,再给 LTM 和 RAG。工具结果先裁剪再送进 Prompt。

希望这篇文章能给正在搭建 Agent 的朋友一些参考。

也想听听大家的实践:你们现在的 Agent 是先做会话状态、长期记忆,还是先把 RAG 跑稳?我在生产环境中遇到过最棘手的是记忆冲突,你们是这个吗?还是上下文超预算,或者任务中断后无法恢复?

欢迎在评论区分享自己的实现方案或踩坑经历,也欢迎直接提问。