零代码多 Agent 记忆接入:拆解 TencentDB Agent Memory 的 MemoryProxy 注入管线
零代码多 Agent 记忆接入:拆解 TencentDB Agent Memory 的 MemoryProxy 注入管线
导读:本文以腾讯开源项目 TencentDB-Agent-Memory(
TencentCloud/TencentDB-Agent-Memory,分析版本v2.0.1-beta.1)为样本,选取其MemoryProxy模块作为切入点,剖析它如何做到 “不改客户端代码,只把 Agent 的 base URL 指向 Proxy,就能给 Claude Code / CodeBuddy / Codex / DeepSeek Harness / WorkBuddy 同时注入团队记忆、Skill 和知识工具”。核心是一条高度解耦的"协议适配 + 语义锚点注入"管线。文中附真实源码、表格对比与相关研究引用。
一、引言:给五个异构 Agent "同时装上记忆"到底有多难
如果你要给一个团队同时接上 Claude Code、CodeBuddy、Codex、DeepSeek Harness 和 WorkBuddy,让它们共享同一套团队记忆,最直接的方案是什么?
- 给每个框架写插件/Hook:成本随框架数量线性增长,且每个框架的扩展点、生命周期、API 都不同;
- 让每个 Agent 都接一个 MCP Server:需要每个框架都支持 MCP,且用户要手工配置;
- 侵入式改客户端:维护一个 fork,几乎不可行。
TencentDB Agent Memory 给出的答案非常克制:一个透明的转发代理。客户端不用装任何东西,只需要把 ANTHROPIC_BASE_URL / OPENAI_BASE_URL 指到 Proxy:
export ANTHROPIC_BASE_URL=http://localhost:8096
export ANTHROPIC_API_KEY=any-string-if-auth-disabled
# OpenAI 协议客户端类似:OPENAI_BASE_URL=http://localhost:8096/v1
Proxy 站在"客户端 ↔ 上游 LLM"之间,拦截请求 → 注入记忆资产 → 转发给上游 → 回写记忆。这一设计让"接入一个新 Agent"从"写一个插件"降维成"写一个配置/适配器"。
二、技术背景:这条路并不孤单,但工程化细节是关键
在展开源码前,先把这条技术路线放进坐标系里:
| 路线/系统 | 核心思想 | 与本文的关系 |
|---|---|---|
| MCP(Anthropic, 2024)[2] | 标准化的工具/上下文协议,Agent 显式连接 Server | Proxy 的"零代码接入"是 MCP 的互补替代:不改客户端,靠转发层注入 |
| LLM Gateway(LiteLLM / Portkey 等)[5] | 统一路由、计费、限流的 LLM 代理 | MemoryProxy 在"转发/路由/限流"之上叠加了"记忆注入"这一层语义 |
| Context Engineering(Anthropic, 2025)[3] | 主张把上下文当作可工程的资源去管理 | 注入点/缓存/降级的设计正属于 context engineering |
| Prompt Caching(Anthropic)[4] | 稳定的前缀可命中缓存、降成本 | Proxy 的"session 级稳定注入"正是为了不破坏上游 prompt cache |
| 设计模式:Adapter / Strategy / Chain of Responsibility(GoF, 1994)[1] | 解耦异构接口、可插拔策略、责任链 | 本文的两层适配 + 注入点链 + 缓存策略,都可回溯到这些模式 |
| 记忆综述(Zhang et al., 2024)[6] | Agent 记忆机制的系统化分类 | 提供"记忆如何喂给 Agent"的问题域地图 |
一个关键判断:把"记忆注入"放在代理层而非客户端层,意味着"记忆"从某个 Agent 的私有能力,变成了可跨框架复用的基础设施。这正是 Team Memory 想达到的效果——记忆资产与 Agent 框架解耦。
三、核心抽象:两层适配,把"异构"问题拆成两个正交维度
MemoryProxy 的注入模块最值得学习的地方,是它把"异构"拆成了两个正交的维度,各用一层适配器解决,互不纠缠:
- 协议层(ProtocolAdapter):解决"线上格式"的差异——OpenAI 的
messages[].content是字符串,Anthropic 是ContentBlock[]数组。 - 结构层(AgentProfile):解决"系统提示词内部结构"的差异——CodeBuddy 用 XML 标签(
<agent_skills>...</agent_skills>),Claude Code 用 Markdown 标题(# Memory),Cursor 用 JSON 字段。
先看协议层接口,它只有两个方法,极其干净:
// 摘自 MemoryProxy/src/injection/adapters/interface.ts
export interface ProtocolAdapter {
readonly protocol: Protocol; // "openai" | "anthropic"
parse(body: Record<string, unknown>, metadata: AgentContextMetadata): AgentContext;
serialize(ctx: AgentContext): Record<string, unknown>;
}
再看结构层接口,它定义了"把一个系统提示词结构化地解析、锚定、重装"的完整契约:
// 摘自 MemoryProxy/src/injection/agents/interface.ts
export interface AgentProfile {
readonly id: string; // "codebuddy" | "claude-code" | ...
readonly protocol: Protocol; // 决定用哪个 ProtocolAdapter 做 parse/serialize
detect(systemText: string): boolean; // 指纹识别:这段 system 属于哪个 agent?
parse(systemText: string): PromptSegment[]; // 切成结构片段
resolveSlot(slot: SemanticSlot): string | null; // 语义槽 → 该 agent 的具体结构 key
applyAnchor(segments, resolved, text): PromptSegment[]; // 把内容锚定到目标位置
rebuild(segments): string; // 无损重装回字符串
}
这两层分离带来的收益是巨大的:"新增一个 Agent"只需要实现一个 AgentProfile,而 hook(注入逻辑)和管线完全不用动。用一张表对比两者的职责边界:
| 维度 | 抽象 | 解决什么 | 实现者 | 数量 |
|---|---|---|---|---|
| 线上格式 | ProtocolAdapter |
消息/工具/参数的编解码 | OpenAIAdapter / AnthropicAdapter |
2 个,封闭 |
| 提示词结构 | AgentProfile |
系统提示词的解析与锚定 | CodeBuddyProfile / ClaudeCodeProfile / WorkbuddyProfile… |
每 Agent 1 个,开放扩展 |
四、注入管线:parse → 检测 Agent → 执行 Hook → serialize
所有注入逻辑由 InjectionPipeline 统一编排,主流程只有四步(MemoryProxy/src/injection/pipeline.ts):
// 摘自 pipeline.ts(process 主流程,简化)
async process(body, metadata): Promise<Record<string, unknown>> {
// 1. 选协议适配器
const adapter = this.adapters.get(metadata.protocol);
if (!adapter) throw new Error(`No adapter for "${metadata.protocol}"`);
// 2. parse:原始 body → 协议无关的 AgentContext
const ctx: AgentContext = adapter.parse(body, metadata);
// 2.5 检测 Agent Profile(优先按 URL path 前缀,零成本;兜底扫 system 内容)
let profile = this.agentProfiles?.get(metadata.agentSource) ?? null;
if (!profile && this.detectAgent) {
profile = this.detectAgent(getMessageText(getSystemMessage(ctx)));
}
if (profile) ctx.metadata.custom = { ...ctx.metadata.custom, agentProfile: profile };
// 3. 按注入点顺序执行所有 hook
const hookResults = await this.executeHooks(ctx);
// 4. serialize:改过的 AgentContext → 协议原生的 body,直接 fetch 转发
return adapter.serialize(ctx);
}
"把请求改完再原样转发"是这个管线的精髓:代理对上游而言是完全透明的,上游只看到一份合法的、只是 system/user 消息里多了一些内容的请求。
Hook 的"落在哪里"由 9 个注入点(InjectionPoint)定义,执行顺序固定:
| 注入点 | 语义 | 典型用途 |
|---|---|---|
system.prefix |
系统提示词最前 | 注入全局规则/开场身份 |
system.before_tools / after_tools |
工具描述前后 | 包裹工具/技能说明 |
system.suffix |
系统提示词末尾 | 注入记忆/知识工具块 |
tools.prepend / append |
工具列表前/后 | 追加只读检索工具(如 memory search) |
user.first_turn |
首轮用户消息前 | 冷启动引导 |
user.before / after |
最新用户消息前/后 | 按轮召回的上下文 |
Hook 之间还有优先级(HOOK_PRIORITY),数字越小越先执行:
// 摘自 types.ts
export const HOOK_PRIORITY = {
SYSTEM: 0, // 系统级注入,最先
MEMORY: 100, // 记忆
SKILL: 200, // 技能
WIKI: 300, // 知识库
CUSTOM: 1000, // 自定义,最后
} as const;
每一个具体能力(Skill、Knowledge、记忆、画像)都是一个 InjectionHook,被注册进 HookRegistry,管线遍历执行。能力的新增/裁剪,本质上是配置里 injectors 数组的增删(见 injection/index.ts 的 buildPipelineBundle),无需改动管线本身。
五、语义锚点:解决"跨 Agent 该落在哪"这一最难的问题
直接往 system prompt 的"前缀"或"后缀"塞内容虽然简单,但在不同 Agent 上效果天差地别:Claude Code 的 system prompt 有 # Memory 章节,CodeBuddy 有 <agent_skills> 标签,把记忆块硬塞到最前面,很可能把用户 persona 顶下去、污染开场。
于是项目引入了一个非常漂亮的概念——语义锚点(Semantic Anchor):
- Hook 只声明"我想落在哪一类区域"(语义槽
SemanticSlot)和"相对位置"(AnchorRelation); - 每个 AgentProfile 把语义槽翻译成它自己的具体结构 key(
resolveSlot); - 命中就把内容精确锚定过去,命中不了就降级回粗粒度的
point兜底。
语义槽与锚定关系定义如下:
// 摘自 types.ts
export type SemanticSlot =
| "persona" | "tools" | "skills" | "memory"
| "knowledge" | "rules" | "task_context" | (string & {});
export type AnchorRelation =
| "before" | "after" | "inside_prepend" | "inside_append";
看一个真实实现——Claude Code 的语义槽映射(agents/claude-code/index.ts):
// 摘自 claude-code/index.ts(语义槽 → Claude Code 的 Markdown 标题)
const CLAUDE_CODE_SLOT_MAP: Record<string, string | null> = {
persona: null, // 第一个 plain 段(# Harness 之前)
tools: null, // system prompt 里没有 tools 章节(tools 在请求 body)
skills: "Session-specific guidance", // 技能调用指引章节
memory: "Memory", // # Memory —— 文件式持久记忆
knowledge: "Memory", // 知识工具块与 memory 共址,锚到其 after
rules: "Harness", // # Harness —— 行为规则与安全准则
task_context: "Environment", // # Environment —— 工作目录/项目上下文
};
而 CodeBuddy 侧的同一个槽 skills 会解析成 <agent_skills> 标签——同一个 Hook 声明 { slot: "skills", relation: "before" },在两个 Agent 上分别落在正确的位置,Hook 代码零改动。
锚定的实现也体现了工程严谨:Claude Code 的 applyMarkdownAnchor 支持 before/after/inside_prepend/inside_append 四种关系,且 parse → rebuild 被明确要求无损往返(lossless round-trip)——注入前注入后,除了新增的块,原始 system prompt 一个字符都不能变:
// 摘自 claude-code/index.ts(inside_prepend 锚定,保留原始 heading 行)
if (isTarget && relation === "inside_prepend") {
const lines = seg.rawText.split("\n");
const heading = lines[0];
const body = lines.slice(1).join("\n");
const newRaw = [heading, text, body].filter(s => s.length > 0).join("\n");
result.push({ ...seg, rawText: newRaw, /* ... */ });
continue;
}
"无损往返"这件事看似细节,却是能不能上生产的分水岭:任何对原始 prompt 的意外裁剪,都可能破坏上游的 prompt cache 或触发 Agent 行为的微妙变化。
六、缓存策略:让"稳定注入"不破坏上游 Prompt Cache
每个请求都重新拉一遍"团队有哪些 Skill、哪些 Wiki 文档"是极大的浪费,而且会让 system prompt 每轮都变,破坏上游 LLM 的 prompt cache(Anthropic prompt caching 依赖稳定的前缀 [4])。于是注入层引入了三级缓存策略:
// 摘自 types.ts
export type CacheStrategy = "none" | "session_init" | "hybrid";
| 策略 | 何时取数 | 特点 |
|---|---|---|
none(默认) |
每请求都执行 execute(ctx) |
旧行为,无缓存 |
session_init |
仅在 session 注册时 prewarm 一次,之后直接读缓存 |
内容 session 内稳定,最适合 skill 列表/画像 |
hybrid |
缓存 + 每轮 execute 的并集,按 cacheKey 去重 |
稳定块走缓存,按轮召回的增量块每次现取 |
管线里的路由逻辑(pipeline.ts 的 resolveHookBlocks)把"cache miss 自愈"也做了进去——首次请求预热的 cache 可能还没写好(prewarm 是 fire-and-forget),此时回退到 hook.execute() 并回填 cache,后续轮次就能命中快路径:
// 摘自 pipeline.ts(session_init 的 miss 自愈,简化)
if (strategy === "session_init") {
const cached = await this.hookCacheRepo.get(spaceId, userId, agentSource, sessionId, hook.id);
if (cached !== null) return cached; // 命中 → 快路径
const fresh = await hook.execute(ctx); // miss → 兜底执行
const readOnly = ctx.metadata.readOnly === true; // FORK 请求不 self-heal
if (fresh.length > 0 && !readOnly) {
this.hookCacheRepo.put(spaceId, userId, agentSource, sessionId, hook.id, fresh);
}
return fresh;
}
这里有个细节值得单独点名:readOnly 标记用于 FORK 类请求——它们的目的是复用主对话的 cache 命中,如果 miss 时回填了内容与主对话不一致的块,反而会污染后续主对话的 cache。所以 FORK 请求被标记为"只读,不自愈"。
七、一个真实 Injector:L2/L3 画像只注索引,正文按需读
光有框架不够,看看一个真实的注入器怎么写。TdaiProfileMemoryInjector 负责把上一篇文章讲过的四层记忆里的 L2(场景)+ L3(画像) 注入到 system prompt。它的策略非常克制:
- L3(画像)直接注入全文:稳定且通常较短;
- L2(场景)只注入索引(path + summary),正文不预读——让 LLM 需要时再用工具拉取;
- 同时附一段
memory-tools-guide,告诉 LLM 工具怎么用、每轮调用上限。
// 摘自 injectors/tdai-profile-memory-injector.ts(核心渲染逻辑,简化)
export class TdaiProfileMemoryInjector implements InjectionHook {
id = "tdai-profile-memory-injector";
point = "system.suffix"; // 兜底注入点
anchor = { slot: "memory", relation: "inside_append" }; // 语义锚点优先
priority = HOOK_PRIORITY.MEMORY + 10;
cacheStrategy = "session_init"; // session 内稳定 → 预热一次
private async renderBlocksForContext(ctx) {
// 对每个 agent 独立拉 L3 + L2 索引(不读 L2 全文)
const groups = await Promise.all(ctxs.map(c => loadAgentProfile(client, c)));
// ...
for (const g of groups) {
if (g.l3?.content) {
lines.push("<l3_core_memory>", truncate(g.l3.content, 6000), "</l3_core_memory>");
}
if (g.l2Entries.length > 0) {
lines.push("<l2_scene_index>");
for (const e of g.l2Entries) {
lines.push(`- \`${e.path}\` — ${truncate(e.summary, 200)}`); // 只给索引行
}
lines.push("</l2_scene_index>");
}
}
lines.push("", MEMORY_TOOLS_GUIDE);
return [{ type: "text", content: lines.join("\n"), metadata: {/* ... */} }];
}
}
这个"只注索引、按需读全文"的决策,是 context engineering [3] 的典型实践:L2 场景全文动辄上千字符 × N 个,若每轮全量注入会显著拉高首轮 token 和 prompt cache 失效成本;而"索引 + 工具"让 LLM 在真正需要某个场景时才去读它,把"常驻上下文"和"按需召回"的边界画清楚了。
八、控制面与降级:一条请求在 Proxy 里的完整命运
注入不是无条件的。看 handler.ts 的主请求处理,一条请求在 Proxy 里要经过一串"闸门",每一道都可能让它短路:
early auth(鉴权,未通过直接 401)
→ 解析 body + 模型白名单校验(model 未注册直接 400)
→ system-user 短路(内部服务账号直接透传,跳过全部管线)
→ 请求分类(aux 辅助请求 / dsh headless 判定)
→ session init(状态机:可能需要弹表单 / 恢复 / bypass)
→ mem: 命令拦截(命中就地处理,零 token 消耗,不转发上游)
→ 上下文注入(injection pipeline)
→ 转发上游 + 失败重试
其中最值得说的一条是 mem: 命令拦截——这是 Proxy 内置的"带内控制面",用户在对话里直接输入 mem:sync、mem:create-skill [提示词]、mem:help,Proxy 就地拦截并返回结果,根本不转发给上游 LLM(零 token 消耗):
// 摘自 mem-command/index.ts(命令分发,简化)
const KNOWN_COMMANDS = new Set(["sync", "create-skill", "help"]);
export async function executeMemCommand(cmd, ctx) {
if (!KNOWN_COMMANDS.has(cmd.command)) {
return /* 未知命令 → 返回 mem:help 提示 */;
}
switch (cmd.command) {
case "help": return executeHelp(ctx);
case "sync": return executeSync(ctx);
case "create-skill": return executeCreateSkill(ctx);
default: return /* 未知命令 */;
}
}
而"降级"贯穿整个设计,汇总成一张表:
| 场景 | 降级行为 | 设计意图 |
|---|---|---|
| 单个 hook 执行失败 | 记录日志,跳过该 hook,继续后续 | 注入失败不阻断主请求 |
| 内核(memory-core)不可达 | 只注当前 agent 记忆 / 退化为 passthrough | 记忆服务宕机不影响对话可用性 |
| 辅助请求(compaction/title-gen) | 跳过 session-init/注入/L0 写入,直接透传 | 避免污染压缩/标题等子任务 |
| dsh headless(无 UI 无 preset) | bypass session-init,直接透传 | 无 UI 场景强弹表单无意义 |
会话未初始化却发 mem: 命令 |
返回"会话未初始化"文案,不转发 | 避免命令被上游 LLM 幻觉出错误文本 |
| 上游 4xx 且配了 retry | 自动用 retryTarget 重试一次 | 路由降级 |
这种"记忆注入是尽力而为,对话转发才是主链路"的优先级排序,是记忆系统能稳定上线的关键——它从架构上保证了记忆层永远不能成为可用性的单点。
九、几点值得点名的工程判断(资深视角)
抛开代码细节,下面几个判断是这套设计能成立的根本:
-
两层适配正交分解。协议差异(wire format)与结构差异(prompt structure)是两个独立维度,各用一层适配器解决,新增 Agent 只改
AgentProfile。这是教科书级的"变化隔离"。 -
锚点优先、兜底降级。语义锚点让注入"精准",
point兜底让注入"永不失败"。Hook 声明的是意图(slot),而非某个 Agent 的具体实现(rawKey),保持了可移植性。 -
cache 策略与 prompt cache 对齐。
session_init策略本质上是"把 session 内稳定的注入块提前算好、固定下来",让上游 prompt cache 持续命中——记忆注入的"好",不是注入得多,而是注入得稳且不破坏缓存。 -
控制面走带内、数据面走转发。
mem:命令是带内控制(零 token),记忆注入是数据面(随请求附带),二者解耦且互不干扰。 -
可观测性内建。每个 hook 的注入成功/失败、块数量、耗时都有
InjectionObserver(Langfuse / 日志 / noop 三档),并显式打印注入块前 120 字符预览——排查"为什么这一轮记忆没进来"时,日志里一眼就能看到。
十、局限与改进空间
客观地说,这套设计也有取舍:
- 依赖协议可解析:Proxy 必须能完整理解 OpenAI/Anthropic 的请求结构,一旦上游协议演进(如新的多模态块类型),适配器需要跟进;
- 锚点依赖 prompt 结构稳定:
AgentProfile的detect和resolveSlot硬编码了对各 Agent 系统提示词结构的认知(如 Claude Code 的# Memory标题),一旦客户端改版,锚点可能失配(此时会降级到point,但落位不再精准); - 带内命令有限:目前
mem:只有sync / create-skill / help三个(Roadmap 计划扩展 Task 相关指令),表达能力有限; - 注入的是"静态资产 + 按需工具":真正"该给这个 Agent 召回哪些记忆"的智能路由仍在迭代中,当前有相当人工绑定成分。
十一、结语
MemoryProxy 最打动我的,不是"它做了一个 LLM 代理"——市面上 LLM gateway 已经很多——而是它在转发层之上,把"记忆注入"做成了与 Agent 框架解耦的基础设施:
- 两层适配把"异构"拆成可独立演进的协议层与结构层;
- 语义锚点 + 兜底降级让注入既精准又永不失败;
- cache 策略与 prompt cache 对齐让"记得多"不牺牲"跑得快";
- 尽力而为的降级语义让记忆层永远不成为可用性单点。
如果你也在做"给多个 Agent 共享上下文/记忆"的工程,与其急着写插件,不妨先问自己一个问题:能不能把这件事下沉到一层透明的代理,让"接入一个新 Agent"的边际成本趋近于零? 答案往往就在这里。
参考文献
[1] Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.(Adapter / Strategy / Chain of Responsibility 等模式)
[2] Anthropic (2024). Model Context Protocol (MCP). https://modelcontextprotocol.io
[3] Anthropic (2025). Effective context engineering for AI agents. https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
[4] Anthropic. Prompt Caching. https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
[5] LiteLLM (2023). Call all LLM APIs using the OpenAI format. https://github.com/BerriAI/litellm
[6] Zhang, Z., Bo, X., Ma, C., Li, R., Chen, X., Dai, Q., et al. (2024). A Survey on the Memory Mechanism of Large Language Model based Agents. arXiv:2404.13501.
分析对象:Tencent/TencentDB-Agent-Memory(
v2.0.1-beta.1,MIT License)。
文中所引源码路径均为仓库内真实相对路径,逻辑以分析版本为准。
更多推荐



所有评论(0)