跳到主要内容

框架集成中间件:一行 withSupermemory 给 agent 挂记忆

30 秒导读: 你有一个用 Vercel AI SDK(或 Mastra)写的 agent,想让它"记住上次聊过什么"。 这一章讲的中间件让你不改业务代码——把模型 openai("gpt-4")withSupermemory(...) 包一层, 之后每次调用会自动在 LLM 请求前把该用户的记忆塞进 system prompt、在响应后把整轮对话存回 Supermemory。

本章聚焦 packages/tools 里的框架集成层,即 Supermemory 的"分发路径二":不走独立进程(那是 MCP,见 02-mcp-server.md),而是直接嵌进框架的模型调用链,做到"检索前注入、响应后落库"。 数据形态(文档 / 记忆 / 画像 / 项目)见 01-data-model.md;Claude 记忆工具单独成章(见 04-claude-memory-tool.md)。


1. 这是什么(零基础也能懂)

一句话定义: 一组"包装器 / 中间件",把 Supermemory 的取记忆 + 存记忆两个动作,悄悄挂进你 agent 每一次 LLM 调用的前后。

解决什么问题: 无状态的 LLM 每次对话都"失忆"。你当然可以手动——每次调用前去查数据库、拼进 prompt,调用后再把对话写回去。但这些样板代码会污染每一个 agent。这一层把它收敛成一行包装

它给谁用: 用 Vercel AI SDK 的 generateText / streamText、或用 Mastra Agent 搭 agent 的工程师。

用起来什么样: 核心 API 就是 withSupermemory——传入一个模型,返回一个"带记忆的模型",别的什么都不用改。

// 示意,基于 packages/tools/src/vercel/index.ts 的真实用法
import { withSupermemory } from "@supermemory/tools/ai-sdk"
import { openai } from "@ai-sdk/openai"
import { generateText } from "ai"

// 把普通模型包成"带记忆的模型"
const modelWithMemory = withSupermemory(openai("gpt-4"), {
containerTag: "user-123", // 记忆归谁(用户/项目)
customId: "conversation-456", // 这轮对话归到哪个文档
mode: "full", // 既取静态画像,也按当前问题语义检索
addMemory: "always", // 响应后自动落库
})

// 之后照常调用,记忆的取/存全自动发生
const result = await generateText({
model: modelWithMemory,
messages: [{ role: "user", content: "我最喜欢的编程语言是什么?" }],
})

一句话直觉: 把它想成给模型套了个"记忆滤镜"——进去的 prompt 被偷偷补上了这个用户的背景,出来的对话 被偷偷抄了一份存进档案。业务代码对此毫无察觉。

本节不谈实现。目标:知道"这层是干嘛的、怎么用"。


2. 顶层全景(它大概怎么转)

withSupermemory 的本质是一个 Proxy:它拦截模型的 doGenerate / doStream 两个方法,在前面插"取记忆",在后面挂"存记忆"。中间的真实 LLM 调用原样透传。

怎么读下面这张图: 从上到下是一次 generateText 的时间线,左边是你的调用,中间是 Proxy 拦截层,右边是 Supermemory 后端。

你的 agent Proxy 拦截层 (withSupermemory) Supermemory 后端
─────────── ───────────────────────────────────── ──────────────────
generateText ──▶ ① transformParamsWithMemory
(params) · 算出本轮 turnKey,查 per-turn 缓存
· 未命中 → buildMemoriesText ───────POST /v4/profile──▶ 取画像+检索
· 去重 + 套模板 → memories 字符串 ◀────────────────────── 静态/动态/搜索结果
· 注入 system prompt


② target.doGenerate(注入后的 params) ───真实 LLM 调用───▶ (OpenAI 等)

▼ 拿到 assistant 回复
③ saveMemoryAfterResponse (不 await)
· 整轮对话 → ConversationMessage[] ──POST /v4/conversations──▶ 落库
result ◀─────────── 原样返回 result

三步职责一句话:

步骤符号干什么文件
① 检索前注入transformParamsWithMemoryLLM 调用前取记忆、注进 system promptvercel/middleware.ts:232
② 透传target.doGenerate用注入后的 params 调真实模型vercel/index.ts:146
③ 响应后落库saveMemoryAfterResponse把整轮对话经 /v4/conversations 存回vercel/middleware.ts:78

分层结构: 这套东西刻意分成三层,好让 Vercel / Mastra / 其它框架共用同一套"取记忆"逻辑。

职责关键文件
框架适配层拦截该框架的调用链(Proxy / Processor),把 params ↔ 记忆字符串对接vercel/index.tsmastra/wrapper.tsmastra/processor.ts
共享逻辑层与框架无关的"取记忆 → 去重 → 套模板"和缓存shared/memory-client.tsshared/prompt-builder.tsshared/cache.ts
后端客户端层真正打 HTTP:/v4/profile(取)、/v4/conversations(存)shared/memory-client.tsconversations-client.ts

主线走一遍(高层):你调 generateText → Proxy 的 doGenerate 接管(vercel/index.ts:147)→ 先 transformParamsWithMemory 补 system prompt → 再调真实模型 → 响应回来后异步触发 saveMemoryAfterResponse → 原样把结果还给你。


3. 核心原理(逐个机制,由浅入深)

3.1 检索前注入:transformParamsWithMemory

要解决的小问题: LLM 调用发出去之前,得把"这个用户是谁、之前聊过啥"塞进 prompt。

思路: 拦下 params(里面有 prompt 消息数组),取到记忆字符串,追加到已有 system 消息后面;没有 system 消息就新建一条放最前。全程不改原对象(纯函数)。

注入这一步(真实实现): injectMemoriesIntoParams(vercel/memory-prompt.ts:59)。已有 system prompt 时用 ${prompt.content} \n ${memories} 拼接;否则在数组头插一条 { role: "system", content: memories }

// 示意,浓缩自 vercel/memory-prompt.ts:59 injectMemoriesIntoParams
// 重点看:纯函数,map 出新数组,不 mutate 原 params
if (systemPromptExists) {
const newPrompt = params.prompt.map((p) =>
p.role === "system" ? { ...p, content: `${p.content} \n ${memories}` } : p,
)
return { ...params, prompt: newPrompt }
}
// 没有 system 消息 → 造一条塞最前
return { ...params, prompt: [{ role: "system", content: memories }, ...params.prompt] }

编排入口(真实实现): transformParamsWithMemory(vercel/middleware.ts:232)是这一步的总调度——它决定"要不要查缓存、查什么、注入什么"。profile 模式下即便没有用户消息也照查(画像不依赖当前问题);其它模式下没用户消息就直接跳过(vercel/middleware.ts:238-243)。

3.2 per-turn 缓存:makeTurnKey——同一轮只检索一次

要解决的小问题: agent 一个"轮次"里常常反复调 LLM(工具调用循环:模型→工具→模型→工具…)。如果每次 doGenerate 都去打 /v4/profile,同一个问题的记忆会被重复检索好几次,既慢又费。

思路: 给"这一轮"算一个 key,第一次检索完把记忆字符串缓存起来;同一轮后续的模型调用直接复用,不再打网络。

key 怎么算(真实实现): MemoryCache.makeTurnKey(shared/cache.ts:21)。把消息 trim 并把连续空白折叠成单个空格,再和 containerTag / threadId / mode 拼成字符串——同一用户、同一对话、同一模式、同一句话 = 同一个 key

// 真实源码 shared/cache.ts:21-29
static makeTurnKey(containerTag, threadId, mode, message) {
const normalizedMessage = message.trim().replace(/\s+/g, " ")
return `${containerTag}:${threadId || ""}:${mode}:${normalizedMessage}`
}

什么时候用缓存(关键判断): 只有当"不是新用户轮次"且缓存命中时,才走缓存(vercel/middleware.ts:249-255)。判断"新轮次"靠 isNewUserTurn——看最后一条消息是不是 user(vercel/middleware.ts:227-230)。

为什么要"非新轮次"这个条件? 因为工具调用循环里,后续的 doGenerate 最后一条消息是 tool 结果(不是 user),此时应复用缓存;而用户真的发了新消息时,最后一条是 user,即使 key 恰好撞上也强制重查。缓存本身是容量 100 的 LRU(shared/cache.ts:9)。

一个用户轮次内:
user: "帮我改这个函数" ──▶ 新轮次, 检索 /v4/profile, 存进缓存 [key]
assistant: (调用工具)
tool: "文件内容..." ──▶ 非新轮次 + 命中 → 复用缓存, 不打网络
assistant: (再调工具)
tool: "测试结果..." ──▶ 非新轮次 + 命中 → 复用缓存, 不打网络
─────────────────────────
user: "再帮我加个测试" ──▶ 新轮次 → 强制重查

3.3 带 AbortController 的检索超时

要解决的小问题: 取记忆是一次网络请求。如果 Supermemory 后端慢了,不该把整个 LLM 调用无限期卡住

思路:/v4/profile 那次 fetch 挂一个超时:到点就 abort(),让检索失败,而不是干等。

真实实现: transformParamsWithMemory 里,当 memoryRetrievalTimeoutMs > 0 时建一个 AbortController,setTimeout 到点调 controller.abort(),把 signal 一路传给 buildMemoriesTextsupermemoryProfileSearch 的 fetch;finallyclearTimeout 清理(vercel/middleware.ts:267-292)。

withSupermemory 内部固定把这个超时设成 DEFAULT_MEMORY_RETRIEVAL_TIMEOUT_MS = 5000(vercel/index.ts:15vercel/index.ts:138),不对外开放——注释里明说这是内部时间预算(vercel/middleware.ts:144-145)。

超时/失败之后怎么办?skipMemoryOnError 决定(vercel/index.ts:141,默认 true):失败时记一条 warn、用原始 prompt 继续调模型(记忆失效不拖垮主流程);设成 false 则把错误抛出去(记忆上"fail closed")。Proxy 的 doGenerate / doStream 各自包了这段兜底(vercel/index.ts:149-172)。

3.4 检索与提示装配:从 /v4/profile 到一段 markdown

要解决的小问题: 后端返回的是结构化画像 + 搜索结果,得把它去重、排版成一段能塞进 system prompt 的文本。

这一步是共享层的核心,链路是:supermemoryProfileSearchdeduplicateMemoriesconvertProfileToMarkdownpromptTemplate,全部由 buildMemoriesText 串起来(shared/memory-client.ts:88)。

① 打后端(真实实现): supermemoryProfileSearch(shared/memory-client.ts:24)POST /v4/profile(shared/memory-client.ts:41)。有 queryText 就带 { q, containerTag },否则只带 { containerTag }——这正是三种 mode 的分野:

mode传给后端的 query结果里用什么语义
profile空字符串(extractQueryText 直接返回 "")只用静态+动态画像只要"这个人是谁",不管当前问题
query最后一条 user 消息文本只用搜索结果只要"跟这句话相关的记忆"
full最后一条 user 消息文本画像 + 搜索结果都用两者都要

extractQueryText(vercel/memory-prompt.ts:23)负责按 mode 抽取查询文本:profile 直接返回空串,其它模式反向找最后一条 user 消息、拼出其中的 text 片段。

② 去重(真实实现): deduplicateMemories(tools-shared.ts:121)。同一条记忆可能同时出现在静态画像、动态画像、搜索结果里。它按优先级 静态 > 动态 > 搜索结果 去重:用一个 seenMemories Set,先收静态,再收动态里没见过的,最后收搜索结果里没见过的(tools-shared.ts:140-169)。顺带 trim 掉空白、丢弃空串,并兼容 API 返回的两种形态(纯字符串或 { memory } 对象,见 getMemoryString tools-shared.ts:128)。

③ 排版(真实实现): convertProfileToMarkdown(shared/prompt-builder.ts:19)把画像排成 ## Static Profile / ## Dynamic Profile 两段 - 列表;搜索结果另拼成 Search results for user's recent message: 一段(shared/memory-client.ts:153-158)。注意 mode === "query" 时不出画像、mode === "profile" 时不出搜索结果(shared/memory-client.ts:143-158)。

④ 套模板(真实实现): 最后交给 promptTemplate。默认模板 defaultPromptTemplate(shared/prompt-builder.ts:10)产出:

User Supermemories:
## Static Profile
- ...
## Dynamic Profile
- ...
Search results for user's recent message:
- ...

想自定义外观(比如包一层 <user_memories> XML 标签)就传 promptTemplate 选项,拿到 { userMemories, generalSearchMemories, searchResults } 三个字段自己拼(类型见 shared/types.ts:40)。

小细节:vercel/util.ts:76filterOutSupermemoriessplit("User Supermemories: ")[0] 把注入的记忆从内容里切回去——它和默认模板的前缀是硬耦合的,换了模板前缀这个工具函数就失效。

3.5 响应后落库:整轮对话经 /v4/conversations

要解决的小问题: 模型答完了,这轮对话本身也是"新记忆",得存回去。

思路:params.prompt 里的历史消息 + 刚生成的 assistant 回复,转成后端要的 ConversationMessage[],POST /v4/conversations。后端支持结构化消息、多模态、以及基于 conversationId 的智能 diff/append(conversations-client.ts:50-56)——所以同一个 customId 反复提交,后端能识别"这是同一份对话的追加"而不是重复存。

转换(真实实现): convertToConversationMessages(vercel/middleware.ts:18)跳过 system 消息,把 text 片段转 { type: "text" }、把 image/* 的 file 转 { type: "image_url" },最后追加一条 assistant 回复(vercel/middleware.ts:68-73)。assistant 文本由 extractAssistantResponseText(vercel/middleware.ts:300)从结果 content 里挑 type === "text" 的拼出。

落库(真实实现): saveMemoryAfterResponse(vercel/middleware.ts:78)调 addConversation(conversations-client.ts:73)打 /v4/conversations(conversations-client.ts:77),用 customIdconversationIdcontainerTag 作容器标签。

两个关键工程细节:

  • 不阻塞主流程:doGeneratesaveMemoryAfterResponse(...)不 await 的(vercel/index.ts:187)——落库在后台跑,结果照常立刻返回给调用方。
  • 落库自己吞异常: saveMemoryAfterResponse 整个包在 try/catch 里,失败只 logger.error(vercel/middleware.ts:108-112),不会把"存记忆失败"冒泡成"这次对话失败"。
  • 流式版对齐: doStream 用一个 TransformStream 累加 text-delta,在 flush(流结束)时才落库(vercel/index.ts:245-273)。
  • 落库的门槛: 只有 addMemory === "always" 且用户消息非空白时才存(vercel/index.ts:179-183)。

4. 配置项:createSupermemoryContext 一览

所有选项在 withSupermemory 里被收进一个 context 对象,由 createSupermemoryContext(vercel/middleware.ts:166)构造。下表是完整配置面(字段定义见 vercel/middleware.ts:118-146vercel/index.ts:17-63):

选项类型 / 取值默认作用
containerTagstring(必填)记忆归属(用户 ID / 项目 ID),检索和落库都按它 scope
customIdstring(必填)把本轮消息归到同一份文档;空则构造时报错(vercel/index.ts:123)
apiKeystring回退 SUPERMEMORY_API_KEY 环境变量缺失则抛错(vercel/index.ts:117)
modeprofile / query / fullprofile取画像 / 按问题检索 / 两者都要(见 §3.4)
addMemoryalways / neveralways响应后是否落库
memoryRetrievalTimeoutMsnumber5000(内部固定)检索前 /v4/profile 的超时预算,withSupermemory 不对外开放
promptTemplate函数defaultPromptTemplate自定义记忆注进 prompt 的排版
baseUrlstringhttps://api.supermemory.ai自定义后端;normalizeBaseUrl 去尾斜杠(shared/context.ts:9)
verbosebooleanfalse开详细日志(shared/logger.ts:9,关时是空函数)
skipMemoryOnErrorbooleantrue检索失败:继续(true)还是抛错(false)

兼容性:withSupermemory 用 Proxy 而非 { ...model } 展开,是为了保住原型上的 getter 字段(如 providermodelId)(vercel/index.ts:143);并通过 union 类型同时支持 AI SDK 5(LanguageModelV2)和 6(LanguageModelV3)(vercel/util.ts:19-28)。


5. 另一种形态:直连工具(给 agent 当可调用工具)

中间件是"自动注入";但有时你想让模型自己决定何时查/存记忆——那就把记忆做成 agent 的 tool。这走的是 ai 包的 tool() + zod schema。

searchMemoriesTool(ai-sdk.ts:13)和 addMemoryTool(ai-sdk.ts:81)各返回一个标准 AI SDK 工具:前者 execute 里调 client.search.execute,后者调 client.addsupermemoryTools(ai-sdk.ts:362)一次性打包 7 个工具(搜索 / 添加 / 画像 / 文档增删查 / 遗忘)。

// 示意,基于 ai-sdk.ts:13 searchMemoriesTool 的形态
tool({
description: TOOL_DESCRIPTIONS.searchMemories, // "Search (recall) memories..."
inputSchema: z.object({
informationToGet: z.string().describe("要在用户记忆里搜什么"),
limit: z.number().optional().default(10),
}),
execute: async ({ informationToGet, limit }) => {
const response = await client.search.execute({ q: informationToGet, containerTags, limit })
return { success: true, results: response.results, count: response.results?.length || 0 }
},
})

容器标签归一化: 无论工具还是中间件,getContainerTags(tools-shared.ts:62)统一处理归属——给了 projectId 就转成 sm_project_<id>(前缀常量 tools-shared.ts:54),否则用传入的 containerTags,再否则回退到 ["sm_project_default"]

strict 模式: 给 OpenAI strict schema 用——config.strict 为 true 时把 schema 字段都做成 required(ai-sdk.ts:31-50),满足 OpenAI 严格校验。

中间件 vs 工具,怎么选:

中间件 withSupermemory直连工具 supermemoryTools
触发方式每次调用自动取/存模型主动调用才发生
侵入性包一层模型,业务无感需把工具注册进 generateText({ tools })
控制粒度由 mode/addMemory 统一决定由模型按对话临场决定
适合"总要带上用户背景"的助手"偶尔需要查/记"的 agent

6. Mastra 版:同名 withSupermemory,换成 Processor

Mastra 的 Agent 实例构造后属性是私有的、改不了,所以这里不用 Proxy,而是走 Mastra 的处理器管线(input/output processors)。withSupermemory(mastra/wrapper.ts:69)接收 agent config,往里注入两个处理器再返回新 config。

注入顺序有讲究: 输入处理器放最前(记忆先注入,后续处理器能看到)、输出处理器放最后(所有处理完了再落库)(mastra/wrapper.ts:94-104)。

两个处理器(真实实现):

处理器钩子干什么位置
SupermemoryInputProcessorprocessInput取记忆,messageList.addSystem(memories, "supermemory") 注入mastra/processor.ts:138
SupermemoryOutputProcessorprocessOutputResultaddMemory==="always" 时经 /v4/conversations 落库mastra/processor.ts:241

它复用了同一套共享层:extractQueryTextMemoryCache.makeTurnKeybuildMemoriesTextaddConversation 全是和 Vercel 版共享的(mastra/processor.ts:15-29)——框架适配层薄,核心逻辑只有一份。

Mastra 特有的一处: customId 可被每请求的 thread ID 覆盖。getEffectiveCustomId(mastra/processor.ts:83)优先从 requestContextMASTRA_THREAD_ID_KEY,取不到才回退构造时的 customId——为的是服务端场景下"每个对话动态一个 ID"。


7. 巧妙之处(可带走的技术)

  • per-turn 缓存把工具循环的检索折叠成一次。 key 靠归一化消息 + 容器 + mode 拼成,配合"新轮次强制重查"的判定,既省网络又不串味(shared/cache.ts:21vercel/middleware.ts:227)。
  • 纯函数注入,不 mutate 原 params。 injectMemoriesIntoParams 全程 map/展开出新对象(vercel/memory-prompt.ts:71-87),重试、并发都安全。
  • 落库 fire-and-forget + 自吞异常。 "存记忆"永远不拖慢、不拖垮"这次回答"(vercel/index.ts:187vercel/middleware.ts:108)。
  • 一份核心逻辑喂多个框架。 Vercel(Proxy)、Mastra(Processor)只是薄适配层,取/存/去重/模板全在 shared/ 共享(shared/memory-client.tsshared/prompt-builder.ts)。
  • 去重带优先级。 静态 > 动态 > 搜索结果的顺序去重,保证"最稳的事实"先出、不被低优先级副本挤掉(tools-shared.ts:140-169)。
  • Proxy 而非展开。new Proxy 保住模型原型上的 getter(provider/modelId),这是 { ...model } 会丢的(vercel/index.ts:143)。

8. 边界与局限(诚实)

  • filterOutSupermemories 与默认模板前缀硬耦合。 它靠 split 字面量 "User Supermemories: "(vercel/util.ts:76),自定义 promptTemplate 换了前缀就切不干净。
  • 检索超时不可配。 withSupermemory 把它写死 5000ms(vercel/index.ts:138),外部无法调整;只有底层 createSupermemoryContext 才认这个字段。
  • 落库失败无重试。 saveMemoryAfterResponse 失败只记日志(vercel/middleware.ts:108),不排队重试;偶发网络抖动会丢这轮记忆。
  • 缓存是进程内 LRU、容量 100。 MemoryCache(shared/cache.ts:9)不跨进程共享,多实例部署各缓存各的;超过 100 个 turnKey 会按 LRU 淘汰。
  • 多模态落库有限。 转换只保 text 和 image/*(vercel/middleware.ts:39-54),其它 file 类型被丢弃;工具调用/工具结果在 Vercel 版落库时未纳入(只转 text/image)。
  • saveMemoryAfterResponse 的首个参数 _client 未用。 实际落库走的是独立的 addConversation fetch,而非注入的 Supermemory client(vercel/middleware.ts:79)。

9. 代码地图(导航索引)

主题文件路径符号名
Vercel 包装入口(Proxy)packages/tools/src/vercel/index.tswrapVercelLanguageModel / withSupermemory
context 构造 + 默认值packages/tools/src/vercel/middleware.tscreateSupermemoryContext
检索前注入编排packages/tools/src/vercel/middleware.tstransformParamsWithMemory
turnKey 生成(轮次判定)packages/tools/src/vercel/middleware.tsmakeTurnKey / isNewUserTurn
检索超时 AbortControllerpackages/tools/src/vercel/middleware.tstransformParamsWithMemory(:267-292)
响应后落库packages/tools/src/vercel/middleware.tssaveMemoryAfterResponse / convertToConversationMessages
assistant 文本抽取packages/tools/src/vercel/middleware.tsextractAssistantResponseText
system prompt 注入(纯函数)packages/tools/src/vercel/memory-prompt.tsinjectMemoriesIntoParams
query 文本抽取(按 mode)packages/tools/src/vercel/memory-prompt.tsextractQueryText
per-turn LRU 缓存packages/tools/src/shared/cache.tsMemoryCache / MemoryCache.makeTurnKey
取记忆总装配packages/tools/src/shared/memory-client.tsbuildMemoriesText
打 /v4/profilepackages/tools/src/shared/memory-client.tssupermemoryProfileSearch
默认模板 / 画像转 markdownpackages/tools/src/shared/prompt-builder.tsdefaultPromptTemplate / convertProfileToMarkdown
去重(静态>动态>搜索)packages/tools/src/tools-shared.tsdeduplicateMemories
容器标签归一化packages/tools/src/tools-shared.tsgetContainerTags
直连工具(search/add)packages/tools/src/ai-sdk.tssearchMemoriesTool / addMemoryTool / supermemoryTools
打 /v4/conversationspackages/tools/src/conversations-client.tsaddConversation
Mastra config 包装packages/tools/src/mastra/wrapper.tswithSupermemory
Mastra 输入/输出处理器packages/tools/src/mastra/processor.tsSupermemoryInputProcessor / SupermemoryOutputProcessor
每请求 thread ID 覆盖packages/tools/src/mastra/processor.tsgetEffectiveCustomId
baseUrl 归一化 / apiKey 校验packages/tools/src/shared/context.tsnormalizeBaseUrl / validateApiKey