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 的实现

具体实现做了三件事:

  1. 从最近消息构建 query
  2. 用 query 检索长期记忆
  3. 格式化检索结果

格式化的文本是这样的:

[记忆上下文 —— 以下是你需要知道的用户信息]
- 📝 事实: 用户住在北京 (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 的逻辑:

  1. 检查是否配置了 memoryInjection,没有就返回原始消息
  2. 检查注入时机策略,before_first_call 且已经注入过就跳过
  3. 调用 injector.buildContext 获取记忆上下文文本
  4. 如果文本非空,在 system prompt 后插入一条 system 消息
  5. 返回新消息数组(不修改原始数组)

关键设计决策:不对原始消息做修改,每次都返回新数组。

为什么?因为循环中的 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 — 更新导出

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个