Agent 没有记忆是什么体验,写过两个 demo 的人都领教过:上一句刚说完"我喜欢蓝色",下一轮问"我喜欢什么颜色",它一脸真诚地反问你。会话状态是 Agent 从玩具走向产品的第一道坎——《AI Agent 开发》系列第 23 篇在 Python 栈用 checkpointer 加 Store 解决过一遍,《上下文工程》系列第 7 篇的结构化笔记与第 11 篇的记忆分层又是另一路手工方案。这一篇轮到 TypeScript 栈给答案:Mastra 把记忆做成了一等公民,一个 Memory 类盖住短期窗口、语义召回、工作记忆三层,配置即得。
这是《TypeScript Agent 开发(Mastra)》系列第 5 篇。全文按"双层标识 → 开箱即用 → 配置三件套 → 工作记忆深挖 → 存储后端 → 检索注入 → 内置与手写的取舍"组织,所有参数名与默认值已按 2026 年 10 月的 Mastra v1 官方文档逐一核实,文末留一个跨会话记忆的练习。
1、双层标识模型
Mastra 记忆的钥匙是两个 ID:resourceId 标识"谁",threadId 标识"哪次对话"。resource 是用户或实体的稳定标识,thread 是一次会话的隔离边界——同一个用户开三个会话,就是三个 thread 共享一个 resource。这层关系在存储层是硬约束:thread 一经创建就绑定了属主,拿另一个 resource 复用同一个 thread 会直接报错。
对照 LangGraph 就很清楚了,两边分层思想一致、叫法不同:
| 概念 | LangGraph | Mastra |
|---|---|---|
| 会话隔离 | thread_id + checkpointer |
threadId |
| 跨会话实体 | Store 的 namespace | resourceId |
| 长期记忆 | Store 的 put/search | 语义召回 + 工作记忆 |
LangGraph 的 记忆模型里,thread 内的短期记忆和跨 thread 的长期记忆是两套 API;Mastra 把它们收进同一个 Memory,靠 scope 决定共享范围。还有一个顺手的细节:Agent 委派子 Agent 时,Mastra 自动用"父 resourceId 加子 Agent 名"生成确定性的 resource 标识,thread 则每次新开——子 Agent 的记忆天然与父隔离,又可追溯到归属,这在 LangGraph 里要自己拼 namespace。
2、开箱即用的多轮记忆
先跑通最小闭环。沿用系列第 2 篇的工程,给 Agent 挂一个 Memory 实例,代码如下:
import { Agent } from '@mastra/core/agent'
import { Memory } from '@mastra/core/memory'
export const memoryAgent = new Agent({
id: 'memory-agent',
name: 'Memory Agent',
model: 'anthropic/claude-sonnet-5-5',
instructions: '你是一个带记忆的助手。',
memory: new Memory({ options: { lastMessages: 20 } }),
})
注意构造器里没写任何 storage——storage 和 vector 省略时默认走 LibSQL(本地文件),这就是"开箱即用"的底气:不装数据库,记忆先落进项目目录的一个 .db 文件。再用两轮对话验证记忆生效:
await memoryAgent.generate('记住:我最喜欢的颜色是蓝色', {
memory: { resource: 'user-123', thread: 'conv-1' },
})
const res = await memoryAgent.generate('我最喜欢的颜色是什么?', {
memory: { resource: 'user-123', thread: 'conv-1' },
})
解读:v1 里标识在调用侧以 memory: { resource, thread } 传入,generate 会自动完成"召回历史、注入 prompt、落库新消息"三件事,第二轮的回答里就有蓝色了。把 thread 换成 'conv-2',Agent 立刻失忆——对话历史只按 thread 隔离;但 resource 没变,后面配置的工作记忆和 resource 作用域的语义召回还能把信息捞回来。这正是双层标识的意义:thread 管对话内延续,resource 管用户级沉淀。
顺手再做一步验证:重启 dev 进程,用同一组 ID 问第三遍,答案依旧在——记忆落在 LibSQL 文件里而不是进程内存里。这也是它和"把历史手动塞回 prompt"的本质区别:状态持久化交给了框架和存储层,业务代码只管传 ID。
3、记忆配置三件套
Memory 的 options 里有三个核心键,正好对应三层记忆,完整配置示例如下:
new Memory({
options: {
lastMessages: 20, // 短期:最近消息窗口
semanticRecall: { // 长期:向量检索旧对话
topK: 4,
messageRange: 2, // 每个命中前后附带的消息数
scope: 'resource', // 检索范围:用户全部会话
},
workingMemory: { enabled: true }, // 工作记忆:运行时笔记
},
})
三层各自的职责与 v1 默认值整理成表:
| 配置项 | 作用 | v1 默认值 |
|---|---|---|
lastMessages |
每轮注入的最近消息条数 | 10(v0 为 40) |
semanticRecall |
对历史消息做向量检索召回 | 关闭,需显式开启 |
workingMemory |
一块 Agent 可自主更新的笔记 | 关闭,需显式开启 |
从 v0 升级的话有三处默认值变化值得盯紧:lastMessages 从 40 砍到 10,省 token 但更容易"失忆",按对话密度显式设置;semanticRecall 从默认开启改为默认关闭;工作记忆与语义召回的 scope 默认从 thread 改成 resource——同样的配置,v1 下会跨会话共享。官方提供了迁移指南与配套 codemod,Memory.query() 已改名 recall(),老代码照着清单过一遍即可。
三层怎么叠进同一轮请求?工作记忆作为 Markdown 块拼进 system message,lastMessages 圈定的最近消息按时间序跟在后面,语义召回命中的旧消息再作为附加上下文注入。记住这个注入顺序,排查"Agent 为什么想起或想不起某件事"就有了抓手——先确认信息到底进了哪一层、有没有被窗口裁掉。
4、工作记忆深挖
三层里最值得单独讲的是工作记忆——上下文工程第 7 篇手写过"结构化笔记",Mastra 把同一套东西做成了框架内置。
4.1、模板与更新机制
工作记忆是一块 Markdown 便签,随每轮对话注入 system message。不配 template 时用默认模板,自定义模板写法如下:
workingMemory: {
enabled: true,
template: `# 用户画像
## 基本信息
- 姓名:
- 城市:
- 时区:
## 偏好
- 沟通风格:[正式/随意]
- 项目目标:
- 关键截止日:
- [事项]:[日期]`,
}
解读:你只定义格式,Mastra 负责注册内置的 updateWorkingMemory 工具、把便签注入 system message、持久化到存储——Agent 对话中发现"用户叫张三、在上海",会自己调工具把便签填上。这正对应上下文工程第 7 篇的"读笔记、干活、更新笔记"循环:手写版要自己实现 note_write 工具和笔记纪律,框架版一行配置。注意模板是全量替换语义:Agent 每次提交完整的新便签内容,框架不做增量 diff,所以模板别太长。
代码侧也能直接写入,绕过 Agent 自主更新:
const memory = await memoryAgent.getMemory()
await memory.updateWorkingMemory({
threadId: 'conv-1',
resourceId: 'user-123',
workingMemory: '# 用户画像\n- 姓名:张三',
})
这段适合在业务侧做初始化或纠偏——用户登录时把已有画像预填进去,比让 Agent 从零问一遍效率高。
4.2、作用域与只读
workingMemory 的 scope 默认 resource:同一用户的便签跨所有 thread 共享,新会话开场 Agent 就"认识"用户;改成 thread 则退化为单会话的草稿纸。v1 还加了 schema 模式:用 Zod 声明结构,Agent 以 JSON 读写,对象深合并、数组整体替换——@mastra/pg 等原子合并适配器上还能用 mergeWorkingMemory 做并发安全的部分更新。
只读模式是路由与子 Agent 场景的刚需:便签看得到、改不了,按请求粒度开启即可:
await routerAgent.generate('帮我转给技术支持', {
memory: {
resource: 'user-123', thread: 'conv-1',
options: { readOnly: true },
},
})
路由 Agent 只需要读用户画像做分发,绝不该污染数据——readOnly 把写权限在请求层锁死。
5、存储后端切换
5.1、从 LibSQL 到 Postgres
LibSQL 是本地开发的默认选项,生产环境换 Postgres 只动 storage 一处:
import { PostgresStore } from '@mastra/pg'
new Memory({
storage: new PostgresStore({
id: 'prod-storage',
connectionString: process.env.DATABASE_URL,
}),
options: { lastMessages: 20, workingMemory: { enabled: true } },
})
解读:换库不换 API,recall、listThreads 等方法在所有适配器上行为一致。官方存储适配器有二十余种——MongoDB、Upstash、Cloudflare D1、DynamoDB、LanceDB、ClickHouse 都在列。选型时查一眼能力矩阵:resource 作用域的工作记忆要求适配器支持 mastra_resources 表,目前是 libSQL、PostgreSQL、MongoDB、Upstash、OracleDB 五家。
什么时候必须切?判断标准就一条:是否有多个进程实例。单实例本地开发,文件版 LibSQL 足够;一旦服务部署多实例、或多个 Agent 服务要共享同一份用户记忆,各自为政的本地文件就失效了,必须换集中式数据库。
5.2、向量与嵌入
semanticRecall 依赖向量检索,必须同时提供向量库和 embedder,缺一个直接报错。完整配置:
import { LibSQLStore, LibSQLVector } from '@mastra/libsql'
import { ModelRouterEmbeddingModel } from '@mastra/core/llm'
new Memory({
storage: new LibSQLStore({ id: 'st', url: 'file:./local.db' }),
vector: new LibSQLVector({ id: 'vec', url: 'file:./local.db' }),
embedder: new ModelRouterEmbeddingModel('openai/text-embedding-3-small'),
options: { semanticRecall: true },
})
解读:semanticRecall: true 是布尔简写,对象写法可细调 topK 与 messageRange。工作机理:每条消息落库时同步算嵌入存进向量库,每轮对话用当前输入做语义检索,命中的旧消息连同上下文一并注入——相当于对自己的历史做 RAG。Postgres 阵营用 pgvector,还可在 semanticRecall 里配 HNSW 索引参数。注意 scope: 'resource' 的跨会话检索只有 libSQL、PostgreSQL、MongoDB、Upstash 等部分适配器支持。
6、记忆检索与注入
记忆不只服务于 Agent 生成,也是业务数据:做"历史会话"列表、做审计、做数据分析,都要从应用侧直接读。Mastra 的 Memory API 如下:
const memory = await memoryAgent.getMemory()
// 读某个 thread 的全部消息
const { messages } = await memory.recall({
threadId: 'conv-1',
perPage: false, // false = 不分页全量返回
})
// 列出某用户的全部会话
const { threads, hasMore } = await memory.listThreads({
filter: { resourceId: 'user-123' },
page: 0,
perPage: 10,
})
解读:recall 是读消息的统一入口,支持分页、日期范围过滤,传 vectorSearchString 还能对历史做语义搜索;listThreads 按 resourceId 拉会话列表,返回的 hasMore 正好喂给列表页的加载更多。单取一个会话用 getThreadById。完整参数见消息历史文档。
注意:官方明确记忆系统不做访问控制——哪个用户能读哪个
resourceId的记忆,鉴权必须在你自己的业务层把关,别把裸的 Memory API 暴露给前端。
7、内置与手写的取舍
把 Mastra 的三层放回《上下文工程》系列的坐标系,对应关系一目了然:
| Mastra 内置 | 手工方案(上下文工程系列) |
|---|---|
lastMessages 裁剪 |
第 2 篇窗口解剖、第 4 篇 token 预算 |
semanticRecall |
第 10 篇检索注入:对自己历史做 RAG |
workingMemory |
第 7 篇结构化笔记 |
内置的收益很实际:几行配置换一条生产级管线,存储抽象、注入格式、Studio 里逐轮查看注入内容的可视化,全都白拿。代价是策略被框架定死:压缩比、笔记更新纪律、注入顺序都不归你管;v1 甚至把 Memory 上的 processors 整个移除,token 限制统一挪到 Agent 的 inputProcessors(如 TokenLimiter)——框架在持续收权,深度定制注入逻辑的人会越来越觉得束手束脚。顺带一提,v1 还在加第四层 observationalMemory:后台 Agent 自动压缩陈旧消息,方向仍是往框架里收。
我的判断:产品向的多轮对话 Agent 用内置,长任务与特殊上下文结构手写。内置版把精力省下来花在 prompt 与工具上;强审计、上下文结构特殊的场景,手写笔记与检索依然占优。两者也不互斥——内置记忆管对话层,关键任务状态照旧落到自己的笔记文件里。
8、总结
这篇把 Mastra 的记忆系统走了一遍:resourceId 与 threadId 的双层标识是地基,thread 管会话内延续、resource 管用户级沉淀;lastMessages、semanticRecall、workingMemory 三件套分别盖住短期窗口、长期召回和运行时笔记;存储从默认 LibSQL 一行切到 Postgres,检索侧 recall 与 listThreads 把记忆变成可编程的业务数据。与 LangGraph 相比,它把两套 API 收成一个类;与上下文工程的手工方案相比,它用默认值换走了灵活度。
练习:给 Agent 配上 semanticRecall(scope: 'resource')和工作记忆,在两个不同的 thread 里分别告诉它两条你的偏好(比如喜欢的颜色和时区),然后开第三个 thread 问"我告诉过你哪些偏好"。验证两件事:语义召回能否捞回两个旧会话里的信息;工作记忆的便签里是否已自动累积了这些画像。对于正要在 TS 栈做对话产品的团队,建议就从默认 LibSQL 加三件套起步,先跑通体验再谈换库。

