LLM Agent 长期记忆架构:从无脑抽取到原子操作与分层注入
为什么大部分 LLM 记忆系统最终都会变成垃圾场?本文从真实生产工程痛点出发,深入解析为什么个人记忆不需要重量级 RAG,如何通过线索与静默双通道准入、ADD/UPDATE/DELETE 原子操作以及分层常驻与轻量检索,构建收敛、精准且低消耗的长期记忆系统。
前两篇分别讨论了 Agent 的两块上下文工程:系统提示词如何按需加载、自愈与反幻觉,以及 文件、图片与指令标记如何协调进当前会话。提示词决定“角色与能力”,附件与会话历史决定“这一轮看见了什么”。还缺一块:跨会话之后,系统到底该记住这个用户是谁。
“拥有长期记忆”听起来像迈向真正助手的关键一步。落地几天后,体验却常常迅速崩塌:模型要么假装没记忆,要么把一次性调试报错、API 参数格式误当成用户的永久画像。本文将从生产踩坑出发,讲一套无需向量库、基于原子操作与分层注入的轻量长期记忆架构。
困境:为什么大多数 LLM 记忆系统“像没有一样”?
多数初期实现非常朴素:
- 后台高频轮询:对话一停顿、聊满几轮、或字数一超,就抽一次:“请从以上对话中提取值得记忆的事实”。
- 无限累加(Append-only):把模型吐出的 JSON 列表直接
INSERT INTO memories。 - 读取时全量塞入或粗暴 RAG:把前 N 条拼进 System Prompt,或用 Embedding 做余弦相似度召回。
这种方案几天内就会失效。根因不是“检索算法不够聪明”,而是写入源头、边界定义和生命周期都失控了。
1. 职责错位与边界混淆
Agent 里的上下文至少有三层,不能混成一种“记忆”:
- 用户记忆(User Memory):关于用户的长期事实(“习惯用中文”、“全栈工程师,技术栈 Next.js”)。
- 项目 / 工具规范(Project / Tool Rules):属于仓库或工具的确定性协议(某个 API 必须传某个参数)。这些更适合 Skills、MCP 文档或项目规则,而不是账号级记忆。
- 单次调试经验 / 临时状态(Ephemeral State):只在当前任务有效(“测试页第三步已跑通”、“遇到 400 Bad Request”)。
抽取缺少强约束时,临时任务碎屑会被无差别升格为全局用户记忆。
flowchart TD
A[单次对话产生临时调试/测试内容] --> B[无脑自动抽取缺乏强准入控制]
B --> C[作为永久全局记忆写入数据库]
C --> D[记忆库迅速被临时技术细节淹没]
D --> E[检索器无论怎么召回, 拿到的都是高噪的调试碎片]
E --> F[体验崩塌: 像没有记忆, 或记了一堆垃圾]
用户问“你记得我什么”,模型却把一堆工具测试结论说成“关于你的画像”。这不是召回失败,是库里本来就没有干净的用户事实。
2. 为什么数十至数百条个人记忆不适合上向量库?
向量检索在大规模非结构化知识库里很强,但在**个人 Agent 记忆(通常 20~200 条)**里往往弊大于利:
- 语义漂移:用户说“今天天气不错”,向量库可能因为微弱距离波动召回“用户喜欢户外运动”,白白占 Prompt。
- 代码与标识符匹配差:Embedding 对
App RoutervsPages Router、函数名、工具参数名的精确度,通常不如关键词 / 分词倒排。 - 架构过重:额外 Embedding 调用和向量库实例,增加冷启动延迟和故障点。几十条短事实不该养一套检索基础设施。
IMPORTANT
个人记忆的目标不是“语义相近的段落”,而是少量、稳定、可解释的长期事实。先把库做干净,再谈检索。脏库上无论 RAG 还是关键词,召回的都是噪音。
一、写入源头治理:双通道准入
记忆系统的首要原则是:宁缺毋滥。但“严”应该严在准入判定上,而不是严到让系统根本不开口。
早期实现里常见 MAX_TURNS = 4(每 4 轮自动抽取)或字数超限就抽。用户在写代码、排错、重试工具时,后台每隔几分钟就偷偷抽一次,把临时排错过程写进数据库。高频触发意味着高频犯错,Prompt 写得再严也会漏网。
更稳的设计是只保留两个低频入口,并给它们不同的信任等级:
- 显式线索(Cue):真正跨会话有效的事实,绝大多数伴随明确意图——用户会说“记住”“以后都”“别再”“我现在改用”“忘掉”。命中线索立即抽取,置信度门槛宽松(如 ≥ 0.55)。
- 静默兜底(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[写入后端并同步前端]
- 连续对话中:零抽取开销,只有静默窗口到期才跑一次。
- 意图命中:用户明确说“记住:以后代码示例优先 TypeScript”时,立即抽取。
两个入口的共同点是都低频,区别只在信任等级:用户明说了“记住”,系统可以宽松;用户没说,系统必须严格。这样既不会漏掉没明说的偏好,也不会让普通排错对话溜进库。
这和系统提示词那篇的按需加载是同一思路:不需要的时候,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 / delete 的 target_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;
}
三类规则对应三类确定性风险:
- 凭证:Bearer / API Key / PEM 私钥块。记忆库一旦进密钥,危害远大于“少记一条偏好”。
- 机器输出:整段 JSON dump、栈追踪、裸 HTTP 状态码。这几乎不可能是用户画像。
- 长度:过短像口头禅,过长像把整段对话粘进去。
守卫故意不匹配具体工具名或“测试页第 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 片段]
- 常驻层(Pinned):
profile与preference优先注入。用户问普通问题时,也不该丢掉“习惯用中文”这类事实。 - 关联层(Matched):
instruction/decision用中英文分词和 2-gram 对当前问题打分。代码标识符(snake_case、连字符工具名)会被拆开,所以“更新页面”能打到对应的工具规则,而不是靠语义近似。 - 时间兜底(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 上下文工程:角色与能力、本轮看见的东西、以及跨会话仍然成立的那一小部分事实。