记忆系统:Mastra Memory 与上下文管理
工具和插件让Agent能行动了,但每次对话都是"全新"的——记不住之前的交流。这节给ai-chat-ui接入记忆系统。与5-3的分工不变:给Claude Code装claude-mem是使用者视角,这里是给自己的Agent接记忆。本节全部基于dev-server真实代码(
packages/dev-server/src/memory.ts及装配链),不构造教学版平行实现——记忆的每个能力都以Mastra Memory配置的形式落地,怎么配置、为什么这么配是主线;配置背后的向量检索原理在下一节RAG里展开。
定位:从使用者到实现者
给Claude Code装claude-mem插件时,你站在使用者侧:自动记录对话、跨会话注入上下文都是现成的。这节站到实现者侧:同样的能力在自己的Agent里怎么落地。
一个最直观的无记忆问题:
// 对话1
用户:我的名字叫Alice
Agent:你好Alice!
// 对话2(新session)
用户:我叫什么名字?
Agent:抱歉,我没有这个信息。
typescript
claude-mem解决的是Claude Code的这个问题,我们要解决的是ai-chat-ui的——这也是16章留下的遗留问题:agent多轮对话丢上下文、检索文档时记不住前文。方案不是从零手写一套存储/检索/注入——15-03接mastra时已经见过了,Mastra自带Memory能力,LibSQLStore一行配置就是持久化记忆。这节做的是把它接全:记忆怎么落盘、Studio和网关两条线怎么共用一份记忆、token怎么控制、配置项什么时候开。
先给一张Mastra的能力版图,定位本节位置:storage(存储底座)之上是memory(记忆——当前会话的上下文记忆集成后即生效,跨会话的语义召回则要走向量数据库),向量数据库又通过semanticRecall与记忆关联;再往外就是RAG(检索增强生成),Mastra同样有一整套方案,且RAG侧的向量库也能用libsql——和记忆同一个轻量底座,接口都封装好了,只需替换嵌入模型和向量数据库。本节讲记忆这条线,RAG那条线在下一节展开。
先看记忆系统在两条装配线里的位置:
两条线落同一个库文件——这是本节最重要的工程决策,下面展开。
记忆落盘:memory.ts 真实落地
dev-server的记忆配置全部集中在memory.ts。核心三个导出:
// packages/dev-server/src/memory.ts(真实代码,有删节)
import { MastraCompositeStore } from '@mastra/core/storage'
import { DuckDBStore } from '@mastra/duckdb'
import { LibSQLStore } from '@mastra/libsql'
import { Memory } from '@mastra/memory'
// 会话记忆落盘位置:包内 .temp/dev-server.db
const MEMORY_DB_URL = `file:${join(PKG_TEMP_DIR, 'dev-server.db')}`
/** 默认记忆存储:本地LibSQL文件库,进程重启对话保留 */
export function createMemory(dbUrl: string = MEMORY_DB_URL): Memory {
ensureDbDir(dbUrl) // libsql文件模式不自动建父目录,缺失直接崩启动
return new Memory({
storage: new LibSQLStore({ id: 'dev-server-memory', url: dbUrl }),
})
}
typescript
配置本身只有两行:存储引擎(LibSQL)+库文件路径。但这两行背后有四个踩过坑才定下来的工程决策:
决策一:网关与Studio共用同一个库文件。Mastra 1.60的memory REST API(Studio线程列表/历史消息的数据源)从实例级storage读线程。如果memory用一个库、实例storage用另一个库,就会写dev-server.db、读mastra.db——Studio里聊过天却查不到历史的split-brain。所以:
// Mastra实例级storage与memory同库
const STORAGE_DB_URL = MEMORY_DB_URL
typescript
本地dev便利优先于文件级分离;traces、workflow状态与记忆表同文件共存,可以接受。
决策二:库文件锚定在包根的.temp/。mastra dev是bundle运行(产物在.mastra/output/),rebuild会清空整个.mastra/目录——库文件锚在那下面就随重启陪葬。而且bundle内层的目录层级与src/不同,模块相对路径定位会漂移。解法是以包名做标记向上找包根:
// 向上找到 name === '@toimc/dev-server' 的 package.json 所在目录
// 再拼 .temp/ —— tsx直跑 / vitest / mastra dev三种形态殊途同归
function resolvePkgRoot(startDir: string): string { /* ... */ }
const PKG_TEMP_DIR = join(resolvePkgRoot(/* 当前模块 */), '.temp')
typescript
这段代码是Studio历史反复丢失的根因修复,值得记住的不是实现细节,而是教训:运行形态多变的服务(CLI bundle/直跑/测试),落盘位置锚定要用稳定标记(包名),不能信相对路径。
决策三:手动ensureDbDir。libsql本地文件模式不自动创建父目录,目录缺失时SQLITE_CANTOPEN直接崩启动。一行mkdirSync(dirname(path), { recursive: true })的事,但没有它服务起不来。
决策四:当前只传storage,用默认配置。lastN滑窗等行为走Mastra默认值,够用就不配置——配置项在需求出现时再加(哪些能配,见下文)。
Studio线:composite 双域路由
Studio线的Mastra实例级storage比网关线多一层考量:除了会话数据,还要落traces/logs/metrics观测数据。memory.ts里的解法是composite storage双域路由:
// packages/dev-server/src/memory.ts(真实代码,有删节)
export async function createCompositeStorage(): Promise<MastraCompositeStore> {
const defaultStore = new LibSQLStore({
id: 'dev-server-storage',
url: STORAGE_DB_URL, // 与记忆同库:会话数据动不得
})
const observabilityDomain = await createDuckDbObservabilityDomain()
return new MastraCompositeStore({
id: 'dev-server-composite',
default: defaultStore, // 会话/threads/workflow 走 LibSQL
domains: { observability: observabilityDomain }, // traces/logs 走 DuckDB
})
}
typescript
观测域为什么用DuckDB而不用LibSQL:@mastra/libsql 1.21.x未实现batchCreateLogs(core默认只警告不写,logs落不了库),DuckDB是官方quickstart的本地推荐组合,附带metrics聚合能力。独立文件(observability.duckdb)的意义:删掉它重置演示数据,不伤会话历史——可重建的派生数据与不可重建的用户数据分文件。
Studio线入口mastra/index.ts的装配:
export const mastra = new Mastra({
agents,
workflows: /* 注册表同源 */,
storage: await createCompositeStorage(), // 双域:会话 LibSQL + 观测 DuckDB
logger: new PinoLogger({ name: 'dev-server', level: 'info' }),
mcpServers: { /* 宿主侧MCP,17-01讲过 */ },
})
typescript
记忆挂载:从agent定义到网关
记忆实例有了,怎么挂到Agent上?答案在16-03的配置化注册架构里——memory是MastraAgentDefinition的一个可选字段:
// packages/server/src/mastra/index.ts(server包,真实类型定义)
export interface MastraAgentDefinition {
id: string
model: string | Record<string, unknown>
instructions?: string
tools?: Record<string, unknown>
/** Memory 实例工厂(惰性:注册时调用一次;不传则无记忆) */
memory?: () => unknown
/** memory.resource,默认 'ai-chat' */
resource?: string
}
typescript
两个设计点:
- memory是工厂函数而非实例(
() => unknown)。注册时惰性调用一次,agent定义保持纯配置对象(16-03的"agent即数据"),不需要记忆的agent(如纯工具agent)不传即可 - resource独立于memory存在,因为它是网关层的会话归属标识,语义上属于"这个agent服务谁",不属于存储配置
dev-server里chat-agent的挂载就一行:
// packages/dev-server/src/agents/chat-agent.ts(真实代码,有删节)
import { createMemory } from '../memory'
export function chatAgentDefinition(config): MastraAgentDefinition {
return {
id: CHAT_AGENT_ID,
model: resolveModelConfig(config),
instructions: CHAT_AGENT_INSTRUCTIONS,
tools: { getTimeTool, getWeatherTool },
memory: createMemory, // 工厂直接传函数引用
}
}
typescript
网关装配时逐个定义调用def.memory()(server/src/mastra/index.ts:57),Memory实例随Agent进入运行时。新增一个带记忆的agent = 定义文件里加一行memory: createMemory,这就是配置化注册在记忆维度的回报。
会话隔离:thread与resource双层
记忆写入后,下一轮怎么找回?调用时用两个ID定位记忆归属:
const stream = await agent.stream(message, {
threadId: conversationId, // 会话级:一个对话一个thread
resourceId: 'ai-chat', // 用户级:记忆的主人
})
typescript
threadId隔离单场对话的上下文(换个会话不串味)——网关透传的conversationId映射的就是thread(15-03讲过的映射,这里闭合)resourceId标识记忆的主人,支持后续按用户做跨会话召回。19-01做用户体系时,这里升级为user:{userId}并服务端强制覆写,防客户端透传伪造
Memory可配置项与token三档控制
当前项目只传了storage,但Memory的完整配置形态值得认识——它是token成本的调节旋钮:
export const memory = new Memory({
storage: new LibSQLStore({ id: 'chat-memory', url: 'file:.temp/mastra.db' }),
options: {
lastN: 20, // 每轮请求只携带最近20条消息
semanticRecall: { topK: 4 }, // 语义检索最相关的4条历史注入上下文
workingMemory: { enabled: true }, // 工作记忆:跨轮维护一份小而关键的状态
threads: { generateTitle: false }, // 不浪费token自动生成会话标题
},
})
typescript
三档机制各自解决一种token问题:
| 机制 | 行为 | token效果 |
|---|---|---|
lastN: 20 | 滑动窗口,旧消息自然"遗忘" | 上限封顶,长对话不爆 |
semanticRecall.topK | 只召回与当前问题相关的历史 | 相关的才付token |
workingMemory | 从对话中持续提取精简状态(用户偏好、关键事实),覆盖更新 | 一份小状态代替全量历史 |
"存得多、喂得少"是这套机制的精髓:lastN之外的对话不再进入prompt,但数据仍在LibSQL里,semanticRecall命中时照样能召回。
注意semanticRecall这一档——它背后是向量相似度检索(把当前问题embedding,在历史消息的向量里找最近的)。向量检索怎么工作、embedding模型怎么选、向量库怎么落,这些知识不在记忆场景展开,下一节RAG(文档知识库检索)里系统讲——那是向量技术的主战场,记忆召回只是同一技术的小型应用。
配置了Memory后无需手动store:每轮对话结束,Mastra自动把user/assistant消息写入LibSQL;下一轮请求进来,滑窗+召回自动拼进prompt。什么时候需要更细的控制:想精细控制记忆提取逻辑(比如按业务字段抽取、自定义打分)时,Mastra也留了口子;常规对话场景直接用配置,少写代码少踩坑。
小结
- 定位:给Claude Code装记忆是使用者视角,这节是给自己的Agent接Mastra Memory——不手写平行实现,全部真实代码
- 落盘决策:网关与Studio同库防split-brain;库文件锚定包根
.temp/(bundle清空与路径漂移的根因修复);手动建父目录;默认配置够用就不配 - 双域路由:composite storage把会话数据(LibSQL,不可重建)与观测数据(DuckDB,可重建)分文件分引擎
- 挂载方式:memory是agent定义的可选工厂字段,
memory: createMemory一行接入;thread/resource双层隔离会话与用户 - token三档:lastN滑窗封顶 + semanticRecall按需召回 + workingMemory精简状态,"存得多、喂得少"
记忆解决的是"Agent记住聊过什么";下一节解决"Agent知道什么"——把组件库文档向量化,让检索从字面匹配升级到语义匹配,那是RAG与向量数据库的主场。向量库在项目里已随RAG真实落地:.temp/docs-vector.db,与本节的dev-server.db刻意分文件——理由正是上文的"可重建的派生数据与不可重建的用户数据分库"。
↑