数据截至 (上游 commit 149589ae7e6b)
框架集成中间件:一行 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
三步职责一句话:
| 步骤 | 符号 | 干什么 | 文件 |
|---|---|---|---|
| ① 检索前注入 | transformParamsWithMemory | LLM 调用前取记忆、注进 system prompt | vercel/middleware.ts:316 |
| ② 透传 | target.doGenerate | 用注入后的 params 调真实模型 | vercel/index.ts:153 |
| ③ 响应后落库 | saveMemoryAfterResponse | 把整轮对话经 /v4/conversations 存回 | vercel/middleware.ts:151 |
分层结构: 这套东西刻意分成三层,好让 Vercel / Mastra / 其它框架共用同一套"取记忆"逻辑。
| 层 | 职责 | 关键文件 |
|---|---|---|
| 框架适配层 | 拦截该框架的调用链(Proxy / Processor),把 params ↔ 记忆字符串对接 | vercel/index.ts、mastra/wrapper.ts、mastra/processor.ts |
| 共享逻辑层 | 与框架无关的"取记忆 → 去重 → 套模板"和缓存 | shared/memory-client.ts、shared/prompt-builder.ts、shared/cache.ts |
| 后端客户端层 | 真正打 HTTP:/v4/profile(取)、/v4/conversations(存) | shared/memory-client.ts、conversations-client.ts |
主线走一遍(高层):你调 generateText → Proxy 的 doGenerate 接管(vercel/index.ts:154)→ 先 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:316)是这一步的总调度——它决定"要不要查缓存、查什么、注入什么"。profile 模式下即便没有用户消息也照查(画像不依赖当前问题);其它模式下没用户消息就直接跳过(vercel/middleware.ts:322-327)。
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:333-339)。判断"新轮次"靠 isNewUserTurn——看最后一条消息是不是 user(vercel/middleware.ts:311-314)。
为什么要"非新轮次"这个条件? 因为工具调用循环里,后续的 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 一路传给 buildMemoriesText → supermemoryProfileSearch 的 fetch;finally 里 clearTimeout 清理(vercel/middleware.ts:351-376)。
withSupermemory 内部固定把这个超时设成 DEFAULT_MEMORY_RETRIEVAL_TIMEOUT_MS = 5000(vercel/index.ts:15、vercel/index.ts:145),不对外开放——注释里明说这是内部时间预算(vercel/middleware.ts:225-226)。
超时/失败之后怎么办? 由 skipMemoryOnError 决定(vercel/index.ts:148,默认 true):失败时记一条 warn、用原始 prompt 继续调模型(记忆失效不拖垮主流程);设成 false 则把错误抛出去(记忆上"fail closed")。Proxy 的 doGenerate / doStream 各自包了这段兜底(vercel/index.ts:156-179)。
3.4 检索与提示装配:从 /v4/profile 到一段 markdown
要解决的小问题: 后端返回的是结构化画像 + 搜索结果,得把它去重、排版成一段能塞进 system prompt 的文本。
这一步是共享层的核心,链路是:supermemoryProfileSearch → deduplicateMemories → convertProfileToMarkdown → promptTemplate,全部由 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:126)。同一条记忆可能同时出现在静态画像、动态画像、搜索结果里。它按优先级 静态 > 动态 > 搜索结果 去重:用一个 seenMemories Set,先收静态,再收动态里没见过的,最后收搜索结果里没见过的(tools-shared.ts:145-174)。顺带 trim 掉空白、丢弃空串,并兼容 API 返回的两种形态(纯字符串或 { memory } 对象,见 getMemoryString tools-shared.ts:133)。
③ 排版(真实实现): 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:76的filterOutSupermemories靠split("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:61)跳过 system 消息,把 text 片段转 { type: "text" }、把 image/* 的 file 转 { type: "image_url" },最后追加一条 assistant 回复(vercel/middleware.ts:137-142)。assistant 文本由 extractAssistantResponseText(vercel/middleware.ts:384)从结果 content 里挑 type === "text" 的拼出。新版还带一个 includeToolCalls 开关(:64,默认关):打开后 tool-call/tool-result 也会按原始时序转进落库消息。
落库(真实实现): saveMemoryAfterResponse(vercel/middleware.ts:151)调 addConversation(conversations-client.ts:73)打 /v4/conversations(conversations-client.ts:77),用 customId 作 conversationId、containerTag 作容器标签。
两个关键工程细节:
- 不阻塞主流程: 在
doGenerate里saveMemoryAfterResponse(...)是不 await 的(vercel/index.ts:194)——落库在后台跑,结果照常立刻返回给调用方。 - 落库自己吞异常:
saveMemoryAfterResponse整个包在 try/catch 里,失败只logger.error(vercel/middleware.ts:183-187),不会把"存记忆失败"冒泡成"这次对话失败"。 - 流式版对齐:
doStream用一个TransformStream累加text-delta,在flush(流结束)时才落库(vercel/index.ts:253-282)。 - 落库的门槛: 只有
addMemory === "always"且用户消息非空白时才存(vercel/index.ts:186-190)。
4. 配置项:createSupermemoryContext 一览
所有选项在 withSupermemory 里被收进一个 context 对象,由 createSupermemoryContext(vercel/middleware.ts:248)构造。下表是完整配置面(字段定义见 vercel/middleware.ts:193-227 与 vercel/index.ts:17-69):
| 选项 | 类型 / 取值 | 默认 | 作用 |
|---|---|---|---|
containerTag | string(必填) | — | 记忆归属(用户 ID / 项目 ID),检索和落库都按它 scope |
customId | string(必填) | — | 把本轮消息归到同一份文档;空则构造时报错(vercel/index.ts:129) |
apiKey | string | 回退 SUPERMEMORY_API_KEY 环境变量 | 缺失则抛错(vercel/index.ts:122-127 的 providedApiKey 检查) |
mode | profile / query / full | profile | 取画像 / 按问题检索 / 两者都要(见 §3.4) |
addMemory | always / never | always | 响应后是否落库 |
memoryRetrievalTimeoutMs | number | 5000(内部固定) | 检索前 /v4/profile 的超时预算,withSupermemory 不对外开放 |
promptTemplate | 函数 | defaultPromptTemplate | 自定义记忆注进 prompt 的排版 |
baseUrl | string | https://api.supermemory.ai | 自定义后端;normalizeBaseUrl 去尾斜杠(shared/context.ts:9) |
verbose | boolean | false | 开详细日志(shared/logger.ts:9,关时是空函数) |
skipMemoryOnError | boolean | true | 检索失败:继续(true)还是抛错(false) |
兼容性:
withSupermemory用 Proxy 而非{ ...model }展开,是为了保住原型上的 getter 字段(如provider、modelId)(vercel/index.ts:150);并通过 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.add。supermemoryTools(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)。
两个处理器(真实实现):
| 处理器 | 钩子 | 干什么 | 位置 |
|---|---|---|---|
SupermemoryInputProcessor | processInput | 取记忆,messageList.addSystem(memories, "supermemory") 注入 | mastra/processor.ts:138 |
SupermemoryOutputProcessor | processOutputResult | addMemory==="always" 时经 /v4/conversations 落库 | mastra/processor.ts:241 |
它复用了同一套共享层:extractQueryText、MemoryCache.makeTurnKey、buildMemoriesText、addConversation 全是和 Vercel 版共享的(mastra/processor.ts:15-29)——框架适配层薄,核心逻辑只有一份。
Mastra 特有的一处: customId 可被每请求的 thread ID 覆盖。getEffectiveCustomId(mastra/processor.ts:83)优先从 requestContext 读 MASTRA_THREAD_ID_KEY,取不到才回退构造时的 customId——为的是服务端场景下"每个对话动态一个 ID"。
7. 巧妙之处(可带走的技术)
- per-turn 缓存把工具循环的检索折叠成一次。 key 靠归一化消息 + 容器 + mode 拼成,配合"新轮次强制重查"的判定,既省网络又不串味(
shared/cache.ts:21、vercel/middleware.ts:311)。 - 纯函数注入,不 mutate 原 params。
injectMemoriesIntoParams全程 map/展开出新对象(vercel/memory-prompt.ts:71-87),重试、并发都安全。 - 落库 fire-and-forget + 自吞异常。 "存 记忆"永远不拖慢、不拖垮"这次回答"(
vercel/index.ts:194、vercel/middleware.ts:183)。 - 一份核心逻辑喂多个框架。 Vercel(Proxy)、Mastra(Processor)只是薄适配层,取/存/去重/模板全在
shared/共享(shared/memory-client.ts、shared/prompt-builder.ts)。 - 去重带优先级。 静态 > 动态 > 搜索结果的顺序去重,保证"最稳的事实"先出、不被低优先级副本挤掉(
tools-shared.ts:145-174)。 - Proxy 而非展开。 用
new Proxy保住模型原型上的 getter(provider/modelId),这是{ ...model }会丢的(vercel/index.ts:150)。
8. 边界与局限(诚实)
filterOutSupermemories与默认模板前缀硬耦合。 它靠 split 字面量"User Supermemories: "(vercel/util.ts:76),自定义promptTemplate换了前缀就切不干净。- 检索超时不可配。
withSupermemory把它写死 5000ms(vercel/index.ts:145),外部无法调整;只有底层createSupermemoryContext才认这个字段。 - 落库失败无重试。
saveMemoryAfterResponse失败只记日志(vercel/middleware.ts:183),不排队重试;偶发网络抖动会丢这轮记忆。 - 缓存是进程内 LRU、容量 100。
MemoryCache(shared/cache.ts:9)不跨进程共享,多实例部署各缓存各的;超过 100 个 turnKey 会按 LRU 淘汰。 - 多模态落库有限。 转换默认只保 text 和
image/*(vercel/middleware.ts:104-116),其它 file 类型被丢弃;工具调用/工具结果需显式打开includeToolCalls才会纳入落库。 saveMemoryAfterResponse的首个参数_client未用。 实际落库走的是独立的addConversationfetch,而非注入的Supermemoryclient(vercel/middleware.ts:152)。
9. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| Vercel 包装入口(Proxy) | packages/tools/src/vercel/index.ts | wrapVercelLanguageModel / withSupermemory |
| context 构造 + 默认值 | packages/tools/src/vercel/middleware.ts | createSupermemoryContext |
| 检索前注入编排 | packages/tools/src/vercel/middleware.ts | transformParamsWithMemory |
| turnKey 生成(轮次判定) | packages/tools/src/vercel/middleware.ts | makeTurnKey / isNewUserTurn |
| 检索超时 AbortController | packages/tools/src/vercel/middleware.ts | transformParamsWithMemory(:267-292) |
| 响应后落库 | packages/tools/src/vercel/middleware.ts | saveMemoryAfterResponse / convertToConversationMessages |
| assistant 文本抽取 | packages/tools/src/vercel/middleware.ts | extractAssistantResponseText |
| system prompt 注入(纯函数) | packages/tools/src/vercel/memory-prompt.ts | injectMemoriesIntoParams |
| query 文本抽取(按 mode) | packages/tools/src/vercel/memory-prompt.ts | extractQueryText |
| per-turn LRU 缓存 | packages/tools/src/shared/cache.ts | MemoryCache / MemoryCache.makeTurnKey |
| 取记忆总装配 | packages/tools/src/shared/memory-client.ts | buildMemoriesText |
| 打 /v4/profile | packages/tools/src/shared/memory-client.ts | supermemoryProfileSearch |
| 默认模板 / 画像转 markdown | packages/tools/src/shared/prompt-builder.ts | defaultPromptTemplate / convertProfileToMarkdown |
| 去重(静态>动态>搜索) | packages/tools/src/tools-shared.ts | deduplicateMemories |
| 容器标签归一化 | packages/tools/src/tools-shared.ts | getContainerTags |
| 直连工具(search/add) | packages/tools/src/ai-sdk.ts | searchMemoriesTool / addMemoryTool / supermemoryTools |
| 打 /v4/conversations | packages/tools/src/conversations-client.ts | addConversation |
| Mastra config 包装 | packages/tools/src/mastra/wrapper.ts | withSupermemory |
| Mastra 输入/输出处理器 | packages/tools/src/mastra/processor.ts | SupermemoryInputProcessor / SupermemoryOutputProcessor |
| 每请求 thread ID 覆盖 | packages/tools/src/mastra/processor.ts | getEffectiveCustomId |
| baseUrl 归一化 / apiKey 校验 | packages/tools/src/shared/context.ts | normalizeBaseUrl / validateApiKey |