跳到主要内容

Mastra — 记忆三件套(处理器式记忆)

本章讲 Mastra 怎么给 agent"记忆"。核心洞见:记忆是处理器,不是服务——它在 §01 那张图的"输入处理器"和"输出处理器"两端各插一脚。

1. 这一章解决的问题

LLM 没有记忆。每次调用它只看到你这次给的消息。要让 agent"记得"上次说了什么、记得这个用户的偏好、记得三个月前另一个会话里提过的事,你得在每次调用前,把相关的历史塞进 prompt

Mastra 把"塞什么、怎么塞"拆成三个独立的处理器,各管一块:

处理器管什么一句话
MessageHistory最近的对话把当前 thread 的最近 N 条消息载进来
SemanticRecall久远但相关的对话用向量检索,跨 thread 找"语义相似"的旧消息
WorkingMemory结构化的"用户档案"维护一份模板化的持久笔记,作为系统消息注入

它们都在 packages/core/src/processors/memory/

2. 两个核心概念:thread 与 resource

读记忆代码前先记住两个 ID:

  • thread:一次会话(一个聊天窗口)。
  • resource:一个用户 / 实体。一个 resource 下可以有很多 thread。

记忆的作用域就是围绕这两者:scope: 'thread' 只在当前会话里找,scope: 'resource' 跨该用户的所有会话找(语义召回默认 'resource',semantic-recall.ts:52@default 'resource')。

3. 记忆怎么被装配进管线

Memory 抽象类(packages/core/src/memory/memory.ts)是门面。它把上面三个处理器按配置实例化并塞进 agent 的输入/输出处理器数组。看装配逻辑(memory.ts:713 起):

  • 工作记忆开启且没手动加过 → new WorkingMemory({...})(memory.ts:746)。
  • 没手动加过 MessageHistory、且没启用观察式记忆 → 加 new MessageHistory({...})(memory.ts:781)。注意:观察式记忆接管消息载入时,MessageHistory 被跳过(memory.ts:778 的注释)——两者互斥。
  • 语义召回开启且没手动加过 → new SemanticRecall({...})(memory.ts:827)。

每次都先检查"用户是否已手动加过"(p.id === 'working-memory' 等),避免重复——处理器有稳定的 id(如 semantic-recall.ts:117readonly id = 'semantic-recall')。

直觉: Memory 就是个"按你的配置,把对的处理器以对的顺序排进管线"的装配工。

4. 机制一:MessageHistory(最近对话)

要解决的小问题: 把当前会话的最近几条消息载进来,这样模型有"上下文"。

思路: 最简单的一种——从 storage 按 thread 拉最近 N 条,加进 MessageList。它是 packages/core/src/processors/memory/message-history.ts,作为 input 处理器运行。

它和语义召回的区别就一句话:MessageHistory 按时间拉最近的;SemanticRecall 按相似度拉相关的。 两者互补。

5. 机制二:SemanticRecall(语义召回)

要解决的小问题: 用户三个月前在另一个会话里说"我对花生过敏"。现在他问"推荐个菜"。最近消息里没有这条,但它至关重要——怎么找回来?

思路: 把每条历史消息做成向量(embedding)存进向量库;用户新提问时,把提问也做成向量,找最相似的 K 条捞回来。这就是 RAG 用在对话记忆上。

SemanticRecall 既是输入处理器又是输出处理器(semantic-recall.ts:91 的类注释):

  • 输入侧(召回): 用用户提问做向量搜索,把相关旧消息加进上下文。
  • 输出侧(写入): 给新产生的消息做 embedding 存库,供将来召回。

5.1 输入侧 processInput 走一遍

processInput(semantic-recall.ts:165),流程清晰:

  1. 取记忆上下文。 parseMemoryRequestContext(requestContext) 拿到 threadresourceId(semantic-recall.ts:176);没有就原样返回。
  2. 提取查询。 从最后一条用户消息提取 userQuery(semantic-recall.ts:191 extractUserQuery);没有可搜的就返回。
  3. 向量搜索。 performSemanticSearch({ query, threadId, resourceId })(semantic-recall.ts:199)。
  4. 去重。 召回结果里,已经在 MessageList 里的(被 MessageHistory 或当前输入加过的)要过滤掉(semantic-recall.ts:212-214,用 existingIds 这个 Set)。这避免同一条消息出现两次。
  5. 分流加入。 同 thread 的直接 add(..., 'memory')(semantic-recall.ts:237);若 scope: 'resource' 且有跨 thread的结果,格式化成一条系统消息 addSystem(formattedSystemMessage, 'memory')(semantic-recall.ts:231)——因为跨会话的消息直接混进对话会让模型困惑,所以包成"这是你和该用户过去对话的片段"的系统提示。
  6. 失败不致命。 整个搜索包在 try/catch 里,出错只记日志、返回原列表(semantic-recall.ts:240-244)——记忆召回挂了不应该让整个对话失败。

5.2 一个稳定性细节:召回结果要排序

向量搜索的返回顺序依赖相似度分数,同一组结果在不同运行可能顺序不同。如果直接按这个顺序渲染进 prompt,prompt 就不稳定(影响缓存、影响可复现)。所以 sortMessagesForRecall(semantic-recall.ts:255)按固定顺序重排:createdAt → threadId → role(user→assistant→tool→system)→ id,且用纯字符串比较保证跨机器一致(semantic-recall.ts:248 的注释)。

教学示例,体会召回的核心(简化):

// 示意,非源码:语义召回的核心三步
const queryVec = await embedder.embed(userQuery); // 1. 把提问变向量
const hits = await vector.query({ queryVector: queryVec, topK: 4 }); // 2. 找最相似的 K 条
const fresh = hits.filter(m => !alreadyInContext.has(m.id)); // 3. 去掉已在上下文里的
messageList.add(stableSort(fresh), 'memory'); // 排序后加入,标来源 memory
// 重点看:topK 默认 4(semantic-recall.ts:14 DEFAULT_TOP_K),排序是为了 prompt 稳定

5.3 关键参数

参数默认作用源码
topK4召回最相似的几条semantic-recall.ts:14 DEFAULT_TOP_K
messageRange1每个命中点前后各带几条上下文消息semantic-recall.ts:15 DEFAULT_MESSAGE_RANGE
scope'resource'thread 内还是跨该用户所有 threadsemantic-recall.ts:52
threshold相似度低于此分的过滤掉semantic-recall.ts:58

messageRange 是个贴心设计:命中一条孤立消息往往看不懂,带上它前后各 N 条,模型才有上下文。

6. 机制三:WorkingMemory(工作记忆)

要解决的小问题: 有些信息你希望始终在上下文里、而且是结构化的——用户的名字、偏好、当前任务进度。这不该靠"碰运气召回",而该是一份持续维护的笔记。

思路: 维护一份模板化的笔记(Markdown 或 JSON 模板,如"姓名: __ / 偏好: __"),每次对话把它作为系统消息注入;模型通过一个 updateWorkingMemory 工具来更新这份笔记。

processInput(packages/core/src/processors/memory/working-memory.ts:82):

  1. 取作用域数据。 默认 scope: 'resource'(working-memory.ts:101)。thread 作用域从 thread.metadata.workingMemory 读(working-memory.ts:108);resource 作用域从 resource.workingMemory 读(working-memory.ts:112)——所以工作记忆可以跨该用户的所有会话共享。
  2. 取模板。 优先动态模板提供者,其次配置的模板,最后默认模板(working-memory.ts:117-132)。
  3. 选指令模式。 三种:只读 / vNext / 普通(working-memory.ts:139-145)。
  4. 注入。 把渲染好的指令 addSystem(instruction, 'memory')(working-memory.ts:149)。

注意 working-memory.ts:44 的注释:更新发生在 updateWorkingMemory 工具里,处理器只负责"读出来 + 注入指令"。指令里会告诉模型"如果信息可能再被引用,就调用 updateWorkingMemory 存起来"(working-memory.ts:172 起的指令文本)。

直觉: 工作记忆 = 一张贴在模型眼前的便利贴,模型自己负责把重要信息写上去。

7. 三者怎么配合(一张图)

用户新消息进来


┌─────────────────┐
│ MessageHistory │ 按时间:最近 N 条对话
└────────┬────────┘
│ add 进 MessageList

┌─────────────────┐
│ SemanticRecall │ 按相似度:向量召回相关旧消息(去重、排序)
└────────┬────────┘
│ add('memory')

┌─────────────────┐
│ WorkingMemory │ 结构化档案:作为系统消息注入
└────────┬────────┘
│ addSystem('memory')

组好的 MessageList → 交给 loop() 调模型

怎么读: 三个处理器依次往同一个 MessageList 里加内容;顺序由 Memory 装配决定。去重(§5.1 第 4 步)正是因为它们可能加到重叠的消息。

8. 巧妙之处

  • 记忆即处理器。 把记忆做成 input/output processor,而不是硬编码进 agent,意味着你能自由增删、自定义记忆策略,甚至塞进非记忆的处理器(守卫、改写)。这是整套设计的地基。
  • 召回去重 + 稳定排序。 existingIds 去重(semantic-recall.ts:213)+ sortMessagesForRecall 稳定排序(semantic-recall.ts:255)——两个小细节保证 prompt 干净且可复现。
  • 跨 thread 结果包成系统消息。 不把别的会话的对话直接混进来,而是格式化成"过去对话片段"系统提示(semantic-recall.ts:228-231),避免模型把旧对话当成当前对话。
  • 召回失败静默降级。 try/catch 包住搜索(semantic-recall.ts:240),记忆挂了不拖垮对话。

9. 边界与局限

  • 语义召回要 vector + embedder + storage。 三样都得配(semantic-recall.ts:21-31 的必填项),否则用不了。
  • MessageHistory 与观察式记忆互斥。 启用 OM 时 MessageHistory 被跳过(memory.ts:778)——因为 OM 自己接管消息载入(见 03-observational-memory.md)。
  • 工作记忆靠模型自觉更新。 处理器只注入指令,真正写入靠模型主动调 updateWorkingMemory 工具;模型不调,笔记就不更新。

10. 横向对比

同 shelf 的记忆系统取舍不同:Mastra 的特点是把记忆拆成可插拔处理器、并区分"最近(时间)/ 相关(向量)/ 档案(结构化)"三条线。语义召回这条线本质是把 RAG 套在对话历史上;工作记忆这条线更像"持久结构化状态"。进一步的"压缩超长历史"则交给观察式记忆(下一章)。

11. 代码地图

主题文件关键符号
记忆装配packages/core/src/memory/memory.tsMemorynew WorkingMemorynew MessageHistorynew SemanticRecall
最近历史packages/core/src/processors/memory/message-history.tsMessageHistory
语义召回packages/core/src/processors/memory/semantic-recall.tsSemanticRecallprocessInputperformSemanticSearchsortMessagesForRecallDEFAULT_TOP_K
工作记忆packages/core/src/processors/memory/working-memory.tsWorkingMemoryprocessInputgetWorkingMemoryToolInstruction
embedding 缓存packages/core/src/processors/memory/embedding-cache.tsglobalEmbeddingCache