Mastra Observational Memory 实战:给你的 AI Agent 装一个"会遗忘"的大脑
上周在一个客服 Agent 项目里踩了个坑。用户跟 Agent 聊了 200 多轮之后,Agent 开始胡说八道------把用户上午说的需求和下午的完全搞混了。查了下原因,context window 塞满了 15 万个 token 的历史消息。GPT-4o 的 128k 窗口,纸面上够大,实际上塞满之后回答质量断崖式下跌。
这不是个例。长对话场景下 AI Agent 的记忆管理,是个工程问题,不是模型能力问题。
今年 2 月,Mastra(Gatsby.js 原班人马做的 TypeScript Agent 框架,GitHub 23k 星)发布了 Observational Memory(OM),用一种模仿人类记忆的方式来解决这个问题。我花了两周时间把它集成进项目,替换掉了之前自己糊的滑动窗口方案。这篇文章记录具体的实现原理、代码和踩坑过程。
先说问题:为什么"塞更多历史消息"是个死胡同
大部分 Agent 框架处理长对话的方式很粗暴:保留最近 N 条消息,或者用 token 计数做截断。这两种方案我都用过,各有各的烂法。
滑动窗口(保留最近 N 条):用户第 3 轮说了"我要做的是一个电商后台",到第 50 轮这条消息被丢掉了,Agent 开始问"你要做什么项目?"------用户骂人。
Token 截断:从头部开始删消息,直到总 token 数降到阈值以下。问题跟滑动窗口一样,只是截断位置不同。
RAG 语义召回:把历史消息存到向量数据库,每轮用当前问题检索相关消息。听起来完美,实际有个隐蔽 bug------向量检索对"用户在第 10 轮改了需求"这种顺序相关的信息,召回率很差。因为 embedding 模型不编码时序关系。
我之前的方案是混合型:最近 20 条完整保留 + 更早的消息做 RAG 召回。效果比纯截断好,但代码量上去了,而且需求变更类的信息仍然容易丢。
Observational Memory 的设计思路
Mastra 的 OM 换了个思路:不保留原始消息,改成让两个后台 Agent 把对话"看一遍",然后写笔记。
架构上分三层:
- 最近几轮的原始消息------完整保留,因为当前任务需要精确上下文
- Observer 生成的观察笔记------把更早的对话压缩成简短记录
- Reflector 生成的反思------当观察笔记本身也太长时,进一步压缩
这跟人类记忆确实有点像。你不记得上周一中午跟同事说了哪些原话,但你记得"上周讨论过数据库选型,最后决定用 PostgreSQL"。
实际压缩比在 5 到 40 倍之间。一个 Playwright MCP Agent,每次页面快照 5 万 token,Observer 处理后变成几百 token 的笔记------页面上有什么、做了什么操作、结果是什么。
最小可运行的代码
先跑起来,再讲原理。
1. 初始化项目
bash
mkdir mastra-om-demo && cd mastra-om-demo
npm init -y
npm install @mastra/core@latest @mastra/memory@latest @mastra/libsql@latest
npm install @ai-sdk/openai typescript tsx -D
2. 配置 Agent
typescript
// src/agent.ts
import { Mastra } from '@mastra/core';
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
// 存储层------开发阶段用内存数据库,生产换 PostgreSQL
const storage = new LibSQLStore({
id: 'demo-storage',
url: ':memory:',
});
const mastra = new Mastra({ storage });
// 创建带 OM 的 Agent
const agent = new Agent({
name: 'support-agent',
instructions: '你是一个技术支持助手。记住用户提到的所有技术栈和需求细节。',
model: 'openai/gpt-4o',
memory: new Memory({
options: {
lastMessages: 20, // 保留最近 20 条原始消息
observationalMemory: {
model: 'google/gemini-2.5-flash', // Observer/Reflector 用的模型
temporalMarkers: true, // 标记时间间隔
},
},
}),
});
export { agent };
核心就一行:observationalMemory: true。如果不指定 model,默认用 google/gemini-2.5-flash,这个模型便宜够快,做压缩任务绰绰有余。
3. 运行对话
typescript
// src/main.ts
import { agent } from './agent';
async function run() {
const memoryCtx = {
memory: {
resource: 'user-001',
thread: 'thread-001',
},
};
// 第 1 轮:用户描述需求
const r1 = await agent.generate(
'我在做一个 Next.js 14 的电商后台,用 Supabase 做认证,deadline 下周五',
memoryCtx,
);
console.log('Agent:', r1.text);
// 第 2 轮:追问技术细节
const r2 = await agent.generate(
'数据库用 PostgreSQL,ORM 是 Drizzle,部署到 Vercel',
memoryCtx,
);
console.log('Agent:', r2.text);
// 模拟 100 轮对话后...
// Observer 会在 token 超过 30000 时自动触发压缩
// 生成类似这样的观察笔记:
// - 🔴 用户在做 Next.js 14 电商后台,deadline 下周五
// - 认证:Supabase
// - 数据库:PostgreSQL + Drizzle ORM
// - 部署:Vercel
// - 🟡 用户询问了中间件配置的问题
// 第 101 轮:Agent 仍然记得第 1 轮的信息
const r3 = await agent.generate('我的项目 deadline 是什么时候?', memoryCtx);
console.log('Agent:', r3.text);
// 输出:你的 deadline 是下周五
}
run().catch(console.error);
resource 是用户 ID,thread 是会话 ID。同一个 resource 下可以有多个 thread,Observer 的观察笔记按 thread 隔离。
Observer 和 Reflector 的工作机制
说完用法,聊下内部机制,因为理解了机制才能调好参数。
Observer 什么时候触发
不是每轮都触发。Observer 监控当前 thread 的历史消息 token 数,用 tokenx 库做本地估算(不调 API),超过阈值(默认 30000 token)时才跑。
触发后,Observer 读取所有未处理的消息,生成一份观察笔记。笔记格式是带时间戳的条目,每条标注优先级:
- 🔴 红色:关键事实(项目名称、技术栈、deadline)
- 🟡 黄色:普通交互(问了个问题、确认了个方案)
这些笔记替换掉对应的原始消息。下次 Agent 回复时,context window 里看到的是笔记,不是原始对话。
Reflector 什么时候触发
当 Observer 的笔记本身超过 40000 token 时(对话特别长的场景),Reflector 启动。它把多份观察笔记合并、去重、归纳模式。
比如 Observer 记了 50 条关于"用户反复修改首页设计"的笔记,Reflector 可能压缩成一条:"用户对首页设计经历了 5 次迭代,从卡片布局改到瀑布流,最终选定双栏网格,核心诉求是移动端首屏加载速度。"
三层结构的最终形态
Agent 的 context window 大概长这样:
css
[系统 prompt]
[Reflections - 最顶层的长期总结]
[Observations - Observer 的近期笔记]
[最近 20 条原始消息 - 当前任务的精确上下文]
从底往上,信息颗粒度递减,时间范围递增。最近的对话是逐字保留的,更早的被压缩成笔记,最早的被进一步归纳。
生产环境的配置建议
在实际项目里用了两周,总结几个经验。
存储选 PostgreSQL
开发用 LibSQL 没问题,生产建议 PostgreSQL。原因很实际------观察笔记和反思记录需要持久化,SQLite 在并发写入时性能不行。
typescript
import { PostgresStore } from '@mastra/pg';
const storage = new PostgresStore({
connectionString: process.env.DATABASE_URL,
});
Observer 模型选便宜的
Observer 和 Reflector 的工作是压缩文本,不需要最强的推理能力。我测试过几个组合:
| Observer 模型 | 压缩质量 | 单次成本(10 万 token 输入) |
|---|---|---|
| gpt-4o | 优秀 | ~$0.25 |
| gemini-2.5-flash | 够用 | ~$0.02 |
| deepseek-reasoner | 优秀 | ~$0.14 |
gemini-2.5-flash 的性价比最高。压缩质量跟 gpt-4o 差距不大,成本低一个数量级。deepseek-reasoner 的压缩质量确实好,但推理 token 消耗多,成本介于两者之间。
调整触发阈值
默认 30000 token 触发 Observer,对于大多数场景合理。但如果你的 Agent 用了 MCP 工具(比如浏览器自动化),每次工具返回结果可能就有几万 token,可能需要调低阈值。
typescript
const memory = new Memory({
options: {
observationalMemory: {
model: 'google/gemini-2.5-flash',
// 这里的配置用默认值就行,框架目前没有暴露阈值参数
// 但可以通过 lastMessages 控制保留的原始消息数量
},
lastMessages: 10, // MCP 工具场景建议减少原始消息保留数
},
});
时间标记的实际用途
temporalMarkers: true 我强烈建议打开。它在消息间隔超过 10 分钟时插入一个时间标记。好处有两个:
- Agent 知道用户隔了多久回来,可以调整回复语气("好久不见"vs 接着聊)
- Observer 写笔记时能标注时间锚点:"用户在间隔 2 天后询问了部署问题"
踩坑记录
坑 1:客户端发了完整历史
这是官方文档特意警告的。如果你的前端用 AI SDK UI 之类的库,每次请求可能会把完整对话历史发过来。OM 自己也维护了一份存储的历史,两份历史的时间戳冲突会导致消息排序 bug。
解决方案:前端只发当前这条新消息,不发历史。
typescript
// 错误写法------前端发了整个 messages 数组
const response = await fetch('/api/chat', {
body: JSON.stringify({ messages: allMessages }),
});
// 正确写法------只发最新一条
const response = await fetch('/api/chat', {
body: JSON.stringify({ message: latestMessage }),
});
坑 2:resourceId 和 threadId 的关系
每个 thread 创建时会绑定一个 resourceId(用户 ID),之后不能改。如果你复用了同一个 threadId 但换了 resourceId,会报错。
实际开发中的建议:threadId 用 UUID,不要用业务含义的 ID(比如订单号),避免不同用户的会话被路由到同一个 thread。
坑 3:存储适配器的兼容性
OM 目前只支持三种存储:@mastra/pg、@mastra/libsql、@mastra/mongodb。如果你用了 Pinecone 或者 Qdrant 做向量存储,那是 RAG 的存储,跟 OM 的存储不是一回事。OM 需要的是关系型存储来放观察笔记和反思记录。
跟其他方案的对比
我之前用过三种长对话记忆方案,做个真实对比:
自己写滑动窗口 + RAG 召回:灵活但维护成本高。需要自己处理 embedding、向量数据库、召回逻辑、时序关系。500 行代码起步,而且时序相关信息的召回率始终不理想。
LangChain 的 ConversationSummaryBufferMemory:用一个 LLM 做实时摘要。问题是每轮都触发摘要,API 成本高,而且摘要是全量重写而非增量更新。
Mastra OM:增量压缩,按需触发,三层结构。配置简单------一行开启,存储适配器换一下就行。限制是目前只能在 Mastra 框架内用,不能独立引入。
完整示例:带工具调用的客服 Agent
最后给一个接近真实场景的例子------一个能查询订单状态的客服 Agent:
typescript
import { Mastra } from '@mastra/core';
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { PostgresStore } from '@mastra/pg';
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
const storage = new PostgresStore({
connectionString: process.env.DATABASE_URL!,
});
const mastra = new Mastra({ storage });
// 定义工具
const queryOrder = createTool({
id: 'query-order',
description: '查询订单状态',
inputSchema: z.object({
orderId: z.string().describe('订单编号'),
}),
execute: async ({ context }) => {
// 实际项目里这里查数据库
return {
orderId: context.orderId,
status: 'shipped',
trackingNumber: 'SF1234567890',
estimatedDelivery: '2026-05-10',
};
},
});
const supportAgent = new Agent({
name: 'customer-support',
instructions: `你是电商平台的客服。回答用户关于订单、物流、退款的问题。
记住用户之前提到的订单号和问题,不要让用户重复说。`,
model: 'openai/gpt-4o',
tools: { queryOrder },
memory: new Memory({
options: {
lastMessages: 15,
observationalMemory: {
model: 'google/gemini-2.5-flash',
temporalMarkers: true,
},
},
}),
});
这个 Agent 在 100 轮对话后,仍然记得用户第 3 轮提到的订单号,因为 Observer 会把"用户在 14:30 查询了订单 #20260501-003,状态是已发货"写进观察笔记。
总结
OM 不是万能方案,但它解决了长对话场景下最头疼的问题:怎么在有限的 context window 里保留足够的历史信息。
适合用 OM 的场景:多轮技术支持、项目管理助手、长期陪伴类 Agent------任何对话轮次可能超过 50 轮的场景。
不需要 OM 的场景:单轮问答、每次交互独立的工具调用。
框架本身还在快速迭代。从 1.1.0 加入 OM 到现在的版本,已经加了多模态附件支持、资源级跨线程记忆(实验性)等功能。代码在 GitHub 上,感兴趣可以直接翻 PR #12599 看实现细节。