11.把长期记忆注入 Agent 循环
做完基础框架的 Tool Calling 循环之后,我审视了一下整个系统,发现一个问题——
长期记忆模块赫然在目,但 Agent 从来不去读它。
MemorySystem 接口定义了短期+长期两层,DefaultMemorySystem 实现了 consolidate(),能把短期记忆里的关键事实存到长期存储。这些都没问题。问题在于:DefaultAgent 的 executeLoop 在加载历史对话时,只调了 shortTerm.getMessages(),长期记忆就像书架上落灰的书——整整齐齐摆在那里,但没人翻过。
诊断:Agent 的失忆症
看一下当前 DefaultAgent 的消息构建流程:
1. 从 Session 加载已有消息
2. 从短期记忆加载历史对话
3. 插入系统提示词
4. 追加用户输入
5. 进入 LLM 循环(Tool Calling 迭代)
每一步都很合理。但问题在哪里?步骤 2 只拿了短期记忆。 短期记忆是 SlidingWindowMemory,满了就丢弃旧消息。如果一个事实在三天前被 consolidated 到长期记忆了,今天用户回来继续对话,Agent 完全不知道这个事实存在。
这就是我所说的"失忆症"——记忆模块能存,但 Agent 不知道怎么读。
不是说框架不能工作,它当然能工作。每次对话都是白纸开始,用户自己把上下文再说一遍就行了。但这跟人脑的记忆模型差太远了——你不应该在每次对话里重新自我介绍一遍。
更关键的是:这决定了我们的记忆系统是一个"被动存储"还是一个"主动上下文"。前者只是个数据库,后者才是真正的记忆。
设计决策一:什么时候注入
有三个时机可以选择。
选项 A:对话开始时一次性注入。 用用户的第一次输入做检索 query,把所有相关记忆注入到上下文。后续的 LLM 调用不再搜索。
优点:开销小,只检索一次。
缺点:随着对话推进,话题可能转向,旧的注入内容变成了噪音。
选项 B:每次 LLM 调用前都搜一次。 用当前的最新对话内容做 query,每次重新检索。
优点:总是拿到最相关的记忆,话题转向时自动切换。
缺点:每次 LLM 调用都多一次记忆检索延迟。
选项 C:按策略混合。 比如用户第一次提问时全量注入,后续只当置信度低时才检索。但置信度判定本身就很复杂。
我选了选项 B。原因有两个:
第一,记忆检索的延迟对 LLM 调用来说几乎可以忽略。一次内存级的 TF-IDF 检索在几十万条记录里也就几毫秒,相比 LLM 调用的一两秒,完全不是瓶颈。
第二,Tool Calling 过程中,工具的返回结果可能触发新的话题。如果在第一次迭代时注入,后面 Agent 调了个天气工具拿到了数据,再跟用户聊气温偏好,之前的注入 context 已经落后了。
所以接口设计成 InjectionTiming 枚举,但默认值就是 before_every_call:
export type InjectionTiming = 'before_first_call' | 'before_every_call';
支持但不推荐 before_first_call,留给延敏感场景做降级。
设计决策二:注入到哪里
检索到相关记忆之后,怎么塞进消息列表?
选项 A:拼到 system prompt 末尾。 直接把记忆文本拼接在系统提示词后面。
问题是 system prompt 是固定的,每次注入都拼接等于修改了系统指令。如果 system prompt 很长,记忆内容被淹没在尾部,LLM 可能注意不到。
选项 B:作为独立消息插入。 创建一条 role: system 的新消息,内容就是记忆上下文,插在系统提示之后、对话之前。
这样记忆上下文是一个明确的消息块,LLM 容易识别,也不影响 system prompt 的完整性。我选了这种。
// 找到最后一条 system 消息的位置
const lastSystemIdx = messages.reduce((last, m, i) =>
m.role === 'system' ? i : last, -1
);
// 在 system 之后插入记忆上下文消息
const result = [...messages];
result.splice(lastSystemIdx + 1, 0, {
role: 'system',
content: memoryContext,
});
最终的消息结构变成:
[0] system → 系统提示词
[1] system → [记忆上下文 —— 以下是你需要知道的用户信息]
[2] user → 当前对话开始
[3] assistant → ...
选项 C:作为 user 消息插入。 有项目这么干,但我认为不自然——记忆不是用户说的,是系统从存储中检索的,语义上属于 system 更合理。
设计决策三:检索 query 怎么构建
这是个容易被低估的问题。检索效果好不好,query 的质量占了七八成。
最简单的做法是把整个对话历史拼成 query。但 token 开销太大——如果对话已经 200 条消息,拿去做检索 query,光 tokenize 就够呛。
我选择了另一种策略:只取最近的 N 条非系统消息。
private buildQuery(messages: Message[], windowSize: number): string {
const relevantMessages = messages.filter(
(m) => m.role === 'user' || m.role === 'assistant'
);
if (relevantMessages.length === 0) return '';
const window = relevantMessages.slice(-windowSize);
return window.map((m) => m.content).join('\n');
}
为什么取最近 N 条?
直觉上,对话的"当前话题"几乎完全由最近几轮对话决定。如果用户半小时前问了"A 框架怎么样",十分钟前转了话题聊"B 数据库",现在继续聊数据库,拿半小时前的对话做 query 会污染检索。
取多少条?我默认设了 3。这是一个经验值——太少(1 条)可能丢失上下文,太多(10 条)可能引入噪音。queryWindowSize 开放给用户配置。
接口设计:MemoryInjector
为了让注入逻辑可替换,我定义了一个新接口:
export interface MemoryInjector {
buildContext(
messages: Message[],
longTerm: LongTermMemory,
config?: MemoryInjectionConfig
): Promise<string>;
}
buildContext 返回一段格式化文本,如果检索不到相关记忆就返回空字符串。Agent 执行循环拿到文本后自己决定怎么嵌入消息列表。
这样设计的好处是 MemoryInjector 不耦合于 Message 结构。它只负责"给我相关记忆的文本",Agent 负责"怎么放进去"。后续你可以更换不同的注入策略实现——比如用 LLM 重排序检索结果再拼接,接口不需要变。
DefaultMemoryInjector 的实现
具体实现做了三件事:
- 从最近消息构建 query
- 用 query 检索长期记忆
- 格式化检索结果
格式化的文本是这样的:
[记忆上下文 —— 以下是你需要知道的用户信息]
- 📝 事实: 用户住在北京 (6/15, 重要度: 0.90)
- ❤️ 偏好: 用户喜欢吃辣 (2 小时前, 重要度: 0.80)
---
每个条目包含类型 emoji、内容、时间、重要度。时间用相对时间(2 小时前 / 6/15),让 LLM 能判断信息时效性。格式极简,减少 token 浪费。
这里有个小设计细节:前缀 emoji 不是装饰,是语义压缩。 "📝 事实"和"❤️ 偏好"用 emoji 区分,LLM 可以在一个 token 里感知到类型差异,不需要读文字标签。
集成到 Agent 循环
最核心的改动在 DefaultAgent 的 executeLoop 里。在每次 LLM 调用前,增加一步 prepareMessagesWithMemory:
// 【记忆注入】在 LLM 调用前,将长期记忆注入上下文
const llmMessages = await this.prepareMessagesWithMemory(messages);
// 用注入后的消息列表调用 LLM
response = await this.config.model.provider.complete({
messages: llmMessages,
...
});
prepareMessagesWithMemory 的逻辑:
- 检查是否配置了
memoryInjection,没有就返回原始消息 - 检查注入时机策略,
before_first_call且已经注入过就跳过 - 调用 injector.buildContext 获取记忆上下文文本
- 如果文本非空,在 system prompt 后插入一条 system 消息
- 返回新消息数组(不修改原始数组)
关键设计决策:不对原始消息做修改,每次都返回新数组。
为什么?因为循环中的 messages 变量记录了完整的对话历史,包括 tool 调用结果。如果修改它,会影响后续迭代的一致性。用不可变的方式创建临时副本,安全且可预测。
AgentConfig 的变更
使用方只需要在 AgentConfig 里加一段配置:
const agent = new DefaultAgent({
...,
memoryInjection: {
injector: new DefaultMemoryInjector(),
config: {
maxMemories: 5,
minRelevance: 0.15,
timing: 'before_every_call',
},
},
});
也可以完全不配置——没配就不注入,向下兼容,不影响原有行为。
一个隐藏的更深层问题
实现完这篇的内容之后,我停下来想了想,发现了一个更值得追问的问题:
现在我们把长期记忆注入到了 LLM 上下文,LLM 确实看到了记忆。但它是"看到"而不是"用"——我们说 Inject,其实是 RAG(检索增强生成)的一个变体。
你搜到了相关条目,拼到了 prompt 里,LLM 在生成时能引用那段文本。这跟 RAG 的核心原理一模一样。问题在于:RAG 只在检索命中了正确答案时有效。 如果检索到的记忆是错的、不相关的、甚至矛盾的,你会污染 LLM 的推理。
所以记忆注入的质量,本质上取决于检索的质量。而目前我们的检索是一个简单的 TF-IDF 风格关键词匹配——它对于"用户喜欢吃辣"这种关键词密集的条目还行,但对于"用户上周在讨论会上提出了一个架构建议,后来被团队采纳了"这种语义丰富的条目,关键词检索基本无能为力。
这就是接下来的主题。在这一篇里,我们打通了"存→读"的通路,但"读"的质量还需要提升。
改了哪些文件:
src/core/memory.ts— 新增 MemoryInjector 接口、MemoryInjectionConfig 类型src/core/agent.ts— AgentConfig 新增 memoryInjection 可选配置src/memory/memory-injector.ts— 新增 DefaultMemoryInjector 实现src/runloop/agent.ts— executeLoop 集成记忆注入,新增 prepareMessagesWithMemory 方法src/core/index.ts,src/memory/index.ts— 更新导出
评论
发表评论