· LLM Agent 工程实践 · 第 3 / 4 讲

LLM Agent 长期记忆架构:从无脑抽取到原子操作与分层注入

为什么大部分 LLM 记忆系统最终都会变成垃圾场?本文从真实生产工程痛点出发,深入解析为什么个人记忆不需要重量级 RAG,如何通过线索与静默双通道准入、ADD/UPDATE/DELETE 原子操作以及分层常驻与轻量检索,构建收敛、精准且低消耗的长期记忆系统。

前两篇分别讨论了 Agent 的两块上下文工程:系统提示词如何按需加载、自愈与反幻觉,以及 文件、图片与指令标记如何协调进当前会话。提示词决定“角色与能力”,附件与会话历史决定“这一轮看见了什么”。还缺一块:跨会话之后,系统到底该记住这个用户是谁。

“拥有长期记忆”听起来像迈向真正助手的关键一步。落地几天后,体验却常常迅速崩塌:模型要么假装没记忆,要么把一次性调试报错、API 参数格式误当成用户的永久画像。本文将从生产踩坑出发,讲一套无需向量库、基于原子操作与分层注入的轻量长期记忆架构。


困境:为什么大多数 LLM 记忆系统“像没有一样”?

多数初期实现非常朴素:

  1. 后台高频轮询:对话一停顿、聊满几轮、或字数一超,就抽一次:“请从以上对话中提取值得记忆的事实”。
  2. 无限累加(Append-only):把模型吐出的 JSON 列表直接 INSERT INTO memories
  3. 读取时全量塞入或粗暴 RAG:把前 N 条拼进 System Prompt,或用 Embedding 做余弦相似度召回。

这种方案几天内就会失效。根因不是“检索算法不够聪明”,而是写入源头、边界定义和生命周期都失控了

1. 职责错位与边界混淆

Agent 里的上下文至少有三层,不能混成一种“记忆”:

抽取缺少强约束时,临时任务碎屑会被无差别升格为全局用户记忆

flowchart TD
    A[单次对话产生临时调试/测试内容] --> B[无脑自动抽取缺乏强准入控制]
    B --> C[作为永久全局记忆写入数据库]
    C --> D[记忆库迅速被临时技术细节淹没]
    D --> E[检索器无论怎么召回, 拿到的都是高噪的调试碎片]
    E --> F[体验崩塌: 像没有记忆, 或记了一堆垃圾]

用户问“你记得我什么”,模型却把一堆工具测试结论说成“关于你的画像”。这不是召回失败,是库里本来就没有干净的用户事实

2. 为什么数十至数百条个人记忆不适合上向量库?

向量检索在大规模非结构化知识库里很强,但在**个人 Agent 记忆(通常 20~200 条)**里往往弊大于利:

IMPORTANT

个人记忆的目标不是“语义相近的段落”,而是少量、稳定、可解释的长期事实。先把库做干净,再谈检索。脏库上无论 RAG 还是关键词,召回的都是噪音。


一、写入源头治理:双通道准入

记忆系统的首要原则是:宁缺毋滥。但“严”应该严在准入判定上,而不是严到让系统根本不开口。

早期实现里常见 MAX_TURNS = 4(每 4 轮自动抽取)或字数超限就抽。用户在写代码、排错、重试工具时,后台每隔几分钟就偷偷抽一次,把临时排错过程写进数据库。高频触发意味着高频犯错,Prompt 写得再严也会漏网。

更稳的设计是只保留两个低频入口,并给它们不同的信任等级:

  1. 显式线索(Cue):真正跨会话有效的事实,绝大多数伴随明确意图——用户会说“记住”“以后都”“别再”“我现在改用”“忘掉”。命中线索立即抽取,置信度门槛宽松(如 ≥ 0.55)。
  2. 静默兜底(Idle):Cue 正则总会漏掉一些“没说记住、但确实是长期偏好”的句子。对话安静一段窗口(如 45 秒)后抽取一次,但门槛更高(如 ≥ 0.75)——只有模型非常确信的长期事实才入库。每段静默只跑一次,游标推进后不重复。
const MEMORY_CUE_RE =
  /(?:记住|记得|别再|不要再|以后都|以后请|下次不要|我的偏好|我现在改用|忘掉|不要记住|from now on|remember(?:\s+that)?|don't\s+(?:ever\s+)?(?:again|do)|prefer(?:ence)?|forget)/i;

export function looksLikeMemoryCue(text: string): boolean {
  return MEMORY_CUE_RE.test(String(text || '').trim());
}

调度器因此变成漏斗,而不是轮询定时器:

flowchart TD
    A[一轮对话结束] --> B{待处理消息里有明确记忆线索?}
    B -- 有 --> D[立即抽取, 宽松门槛 0.55]
    B -- 无 --> C[等待 45s 静默窗口]
    C --> C2[抽取一次, 严格门槛 0.75]
    D --> E[输出 ADD / UPDATE / DELETE]
    C2 --> E
    E --> F{代码级通用守卫}
    F -- 拦截 --> G[丢弃]
    F -- 通过 --> H[写入后端并同步前端]

两个入口的共同点是都低频,区别只在信任等级:用户明说了“记住”,系统可以宽松;用户没说,系统必须严格。这样既不会漏掉没明说的偏好,也不会让普通排错对话溜进库。

这和系统提示词那篇的按需加载是同一思路:不需要的时候,1 个 Token 都不花。 高频轮询看起来“更智能”,实际是在用后台账单换垃圾记忆。


二、记忆生命周期:从 Append-only 到原子操作

记忆库不能只做加法。用户会改技术栈、收回旧偏好、要求忘掉某件事。抽取模型不应只吐文本数组,而应基于**已有记忆列表(带 id)**输出标准化操作:

{
  "operations": [
    {
      "action": "add",
      "kind": "preference",
      "content": "代码示例默认使用 TypeScript 并开启严格模式",
      "confidence": 0.95
    },
    {
      "action": "update",
      "target_id": "mem_102",
      "kind": "profile",
      "content": "主力前端框架已全面迁移至 Next.js App Router",
      "confidence": 0.90
    },
    {
      "action": "delete",
      "target_id": "mem_045",
      "reason": "用户明确要求忘掉旧的构建工具偏好",
      "confidence": 0.92
    }
  ]
}

四类 kind 对应四类长期事实,而不是四种检索算法:

kind含义典型内容
profile身份与稳定背景角色、主力技术栈、常用环境
preference跨会话习惯语言、解释风格、默认代码语言
instruction用户明确要求长期遵守的规则“提交信息用 Conventional Commits”
decision用户确认过的持久标准“这个产品暂不上向量库”

没有持久事实时,必须返回 {"operations":[]}。宁缺毋滥写进抽取 Prompt 的第一行,比事后做复杂召回更有效。

路由侧还要做确定性校验:update / deletetarget_id 必须落在本次下发的已有记忆 id 上,禁止模型发明 id;置信度低于阈值的操作直接丢弃。兼容旧的 {"memories":[...]} 形状时,一律按 add 处理,避免协议升级把历史抽取链路弄断。

去重必须看全量,而不是聊天用的 Top N

这是最早踩到、也最容易被忽略的坑。即时聊天为了省 Token,只注入 Top 40 条相关记忆。如果抽取去重顺手复用同一个函数,模型看见的就是第 140 条,**第 41100 条旧记忆对抽取模型不可见**。

后果很具体:几个月前的旧偏好会被再次 ADD;已经 UPDATE 过的技术栈,又会被写成一份旧版本。

- getExistingMemories: () => enabledMemoriesPayload()   // Top 40,去重不完整
+ getExistingMemories: () => getAllEnabledMemories()    // 全量,去重完整

IMPORTANT

聊天注入看效率(Top N 足够),抽取去重看完整(必须全量)。 两个接口必须拆开。这是会反复踩的低级坑,值得单独写成回归测试:41 条启用记忆里,第 41 条也必须进入去重上下文。

后端批量写入还可以按规范化文本做一层幂等:相同内容更新时间戳,而不是再插一条。模型侧的 UPDATE/DELETE 负责语义收敛,存储侧的规范化去重负责字面重复。两层一起,库才不会只涨不缩。


三、代码级通用守卫:纵深防御

只靠 Prompt 约束抽取模型不够稳:模型会漏判,也会在复述复杂上下文时把不该记的东西抽出来。反过来,在代码里写死业务专有名词(某个工具名、某次测试页标题)又会过拟合——今天拦 A 工具,明天接入 B 工具又要加规则,还容易误杀“记住:以后不要把密码写进配置”。

工业上几乎总是两道防线一起用:

flowchart TD
    subgraph 第一道防线 [1. 意图准入与语义理解 - Prompt]
        A[用户对话] --> B{Cue 命中或静默窗口到期}
        B -- 命中 --> C[LLM 抽取: 泛化准入原则<br/>输出结构化操作]
    end

    subgraph 第二道防线 [2. 确定性守卫 - Code Guard]
        C --> D{通用结构守卫<br/>- 凭证特征<br/>- 原始机器 Dump<br/>- 长度有效性}
        D -- 通过 --> E[(持久化)]
        D -- 拦截 --> F[丢弃]
    end

Prompt 负责理解“这是长期习惯还是单次排错”;代码负责跨业务的硬保障,不含具体产品名:

const GENERIC_SECRET_PATTERNS: RegExp[] = [
  /bearer\s+[a-z0-9._~+/-]{16,}/i,
  /(?:api[_-]?key|access[_-]?token|app[_-]?secret|private[_-]?key|password|passwd)\s*[:=]\s*['"]?[a-z0-9._~+/-]{8,}['"]?/i,
  /-----BEGIN\s+[A-Z0-9\s_-]*PRIVATE\s+KEY-----/i,
  /\b(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36}\b/,
  /\bsk-[a-zA-Z0-9]{20,}\b/,
];

const RAW_MACHINE_PATTERNS: RegExp[] = [
  /^\s*\{[\s\S]*\}\s*$/,
  /at\s+[\w$./\\-]+\s+\([^)]+:\d+:\d+\)/,
  /(?:status\s*code|http\s*status)\s*[:=]?\s*\b[45]\d{2}\b/i,
];

export function isInvalidMemoryContent(content: string): boolean {
  const text = String(content || '').trim();
  if (text.length < 3 || text.length > 500) return true;
  if (GENERIC_SECRET_PATTERNS.some((pattern) => pattern.test(text))) return true;
  if (RAW_MACHINE_PATTERNS.some((pattern) => pattern.test(text))) return true;
  return false;
}

三类规则对应三类确定性风险:

  1. 凭证:Bearer / API Key / PEM 私钥块。记忆库一旦进密钥,危害远大于“少记一条偏好”。
  2. 机器输出:整段 JSON dump、栈追踪、裸 HTTP 状态码。这几乎不可能是用户画像。
  3. 长度:过短像口头禅,过长像把整段对话粘进去。

守卫故意匹配具体工具名或“测试页第 N 期”。那些噪声应由准入门槛和抽取 Prompt 挡住;代码层只做跨业务的安全与结构校验。历史库里已经写下的旧碎片,则交给管理界面的“勾选疑似噪音”——复用同一套守卫,让用户一键选出明显非法条目,而不是让正则去猜业务语义。


四、读取与注入:分层常驻 + 轻量倒排

库干净且稳定在 20~60 条 之后,注入策略可以很简单。不必为“你记得我什么”单独写正则补丁;只要注入的是真实画像,模型自然能回答。

flowchart LR
    A[用户发送消息] --> B[从最新 user 消息提取纯文本]
    B --> C[1. 常驻层: Profile & Preference]
    B --> D[2. 关联层: Instruction & Decision 关键词打分]
    B --> E[3. 兜底层: 按更新时间补齐至 Limit]
    C & D & E --> F[组装为 System Prompt 片段]
  1. 常驻层(Pinned)profilepreference 优先注入。用户问普通问题时,也不该丢掉“习惯用中文”这类事实。
  2. 关联层(Matched)instruction / decision 用中英文分词和 2-gram 对当前问题打分。代码标识符(snake_case、连字符工具名)会被拆开,所以“更新页面”能打到对应的工具规则,而不是靠语义近似。
  3. 时间兜底(Recency):前两层未满上限(例如 40 条)时,按 updatedAt 补齐。

注入形态保持极简清单:

Known facts about the user (account memory). Treat as durable preferences/context unless the user overrides them in this chat. If the user asks what you remember or know about them, summarize these facts naturally:
- [profile] 用户是全栈架构师,主要技术栈为 TypeScript 与 Next.js App Router
- [preference] 偏好使用中文回复,代码示例默认给出完整类型声明
- [instruction] 代码提交信息严格遵循 Conventional Commits 规范

Prompt 只陈述两件事:这些是持久偏好;用户问“你记得什么”时,就总结这些事实。不需要再为元问题做一套特殊召回。

常驻层也要有配额意识。若未来积累了几十条 profile,会挤掉所有指令。更稳的做法是 kind 内再限额,例如 profile 8、preference 12、匹配到的 instruction/decision 16、时间兜底 4。当前阶段库还小,可以先不分这么细,但不要把“所有 profile 同等重要”写成永久假设


五、读取还有一个前置条件:用户到底在问什么

召回准不准,先取决于 query 有没有被正确提取。很多 Agent 的 user message 不是纯字符串,而是多模态数组:

{
  role: 'user',
  content: [
    { type: 'image_url', image_url: { url: '...' } },
    { type: 'text', text: '根据这张架构图帮我规划模块划分' },
  ],
}

如果提取逻辑写成 typeof content === 'string' ? content : '',带图或附件的消息会得到空 query。画像层还在,技术规则层会退化成按时间兜底。

这和附件上下文那篇是同一类问题:附件越来越常见,但“取用户最新一句话”这种 helper 往往被写成一次性闭包,还在直连流式和后台 run 两条路径各写一遍。

export function extractTextFromMessageContent(content: unknown): string {
  if (typeof content === 'string') return content.trim();
  if (Array.isArray(content)) {
    return content
      .map((part) => {
        if (typeof part === 'string') return part;
        if (part && typeof part === 'object' && 'text' in part) {
          return String((part as { text?: unknown }).text || '');
        }
        return '';
      })
      .filter(Boolean)
      .join('\n')
      .trim();
  }
  return '';
}

TIP

把它做成可复用纯函数,所有发聊路径共用。记忆召回的上限,常常卡在这个不起眼的前置步骤,而不是打分公式。


六、记忆在 Agent 全链路里站哪一层

记忆不是孤立模块。它和 Prompt、当前会话、工具文档的边界一旦糊掉,又会回到“什么都往记忆库里塞”。

flowchart TD
    subgraph 上下文分层 [Context Stack]
        P[Prompt 工程<br/>角色、能力、产品契约] --> M[记忆注入<br/>用户长期画像与偏好]
        M --> C[当前会话<br/>消息、文件、工具状态]
    end
    subgraph 生命周期 [Memory Lifecycle]
        W[Cue / 静默窗口触发] --> E[抽取 ADD/UPDATE/DELETE]
        E --> G[通用守卫] --> D[(账号级持久化)]
    end
    C -.仅当用户要把临时偏好升格为长期事实.-> W

三层职责不同:

边界转移点也很明确:用户说“记住 / 忘掉”,是在把会话里的一次临时偏好提升或降级为账号级长期记忆。工具参数、报错码、某次测试是否通过,应留在工具回执和会话里,不应升格。

这也解释了为什么不该为“你记得我什么”单独做一套召回:元问题问的是记忆层本身。只要注入的是干净画像,主模型按常驻清单总结即可。若库里全是工具碎片,再精巧的元问题策略也只是把垃圾分类得更整齐。


七、什么时候才真的需要 RAG?

本文反对的是在个人小规模记忆里盲目上向量库,不是“永远不用 RAG”。分清边界,本身就是架构成熟度。

内容类型规模匹配需求更合适的方案
用户画像、偏好、明确规则几十到一两百条精确、稳定、可解释分层常驻 + 关键词
产品文档、FAQ、研究报告千条以上同义改写、语义接近向量 / 混合检索
工具参数、API 协议随工具版本变必须与当前 Schema 一致Skills / MCP / 错误回执,而不是记忆

实用判断:

记忆库涨到上千条、跨语言同义匹配成为明确痛点时,可以只对 instruction / decision 做向量召回,常驻层仍然全量注入。向量是关联层的升级,不是把画像也丢进黑盒相似度。在那之前,RAG 通常是过度设计。


八、一次真实的迭代记录

这套方案不是一次写成的。下面是一段已脱敏的工程记录,对应“记忆像没有一样”到可用 V1 的路径。

迭代 A:症状像没记忆,根因是库脏了

用户问“你记得我什么”,模型把大量一次性工具测试结论当成用户画像。当时后台按闲置和轮次自动抽取,只做加法。库很快被调试碎片填满。检索器怎么改,拿到的都是噪音。

这一步最大的教训:先不要升级召回算法。 脏库上的 BM25、正则补丁、RAG 都是在更聪明地召回垃圾。

迭代 B:源头准入 + 原子操作

去掉“每 4 句 / 每超字数”的高频触发,只保留显式线索与静默窗口两个低频入口,静默入口配更高的置信度门槛。抽取输出从“记忆数组”改成 ADD / UPDATE / DELETE。聊天注入继续用 Top N,抽取去重改看全量启用记忆。

效果立刻可见:普通排错不再偷偷写库;用户说“忘掉旧偏好”时,旧条目真的能删掉;没明说“记住”的长期偏好,也能在静默窗口里被高门槛筛出来。

迭代 C:纵深防御 + 富媒体提取

第一版代码过滤器里写了具体工具名和测试页模式,止血快,但过拟合。后来改成通用凭证、机器 Dump、长度校验,Prompt 只保留泛化准入原则。同时修掉 content 为数组时 query 为空的问题,图文混发不再把召回打回时间兜底。

管理界面补了“勾选疑似噪音”:新写入靠双通道准入 + Prompt + 通用守卫;旧库里的残骸交给用户批量清理。系统不会假装能自动理解所有历史业务词。

每一步都配了单元测试:线索触发、操作解析、守卫、富媒体提取、Markdown 导入导出。记忆这种“看起来像产品功能、实际是后台静默链路”的模块,没有测试就很容易在某次重构里悄悄退化回 Append-only。


总结与工程检查清单

维度传统无脑抽取 + RAG本文轻量原子架构
外部依赖向量库 + Embedding零外部检索依赖
抽取开销闲置 / 轮次高频抽取仅 Cue + 静默两个低频入口,分层门槛
注入体积噪音多,常超过一千 Token紧凑清单,约 200~400 Token
记忆库形态只增不减,调试碎屑膨胀30~50 条核心事实,可更新删除
可解释性相似度黑盒画像常驻,规则按关键词关联

建议对照这张清单,而不是一上来选向量库:

graph TD
    Check1["1. 抽取是否只有 Cue 与静默两个低频入口, 静默入口门槛更高?"] --> Yes1[是]
    Check2["2. 抽取是否输出 ADD/UPDATE/DELETE, 而不是只 Append?"] --> Yes2[是]
    Check3["3. 去重是否看到全量启用记忆, 而不是聊天用的 Top N?"] --> Yes3[是]
    Check4["4. 代码守卫是否只做通用凭证/Dump/长度, 不含业务专有词?"] --> Yes4[是]
    Check5["5. 多模态消息能否抽出用户文本, 而不是空 query?"] --> Yes5[是]
    Check6["6. 工具协议和单次调试是否被明确排除在用户记忆之外?"] --> Yes6[是]

记忆系统的目标不是记住说过的一切,而是保留关于用户的长期持久上下文。先做减法、管写入、让库收敛;读取层才能保持简单。提示词、附件、记忆这三块拼在一起,才是一套完整的 Agent 上下文工程:角色与能力、本轮看见的东西、以及跨会话仍然成立的那一小部分事实。