零代码多 Agent 记忆接入:拆解 TencentDB Agent Memory 的 MemoryProxy 注入管线

导读:本文以腾讯开源项目 TencentDB-Agent-MemoryTencentCloud/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 的注入模块最值得学习的地方,是它把"异构"拆成了两个正交的维度,各用一层适配器解决,互不纠缠:

  1. 协议层(ProtocolAdapter):解决"线上格式"的差异——OpenAI 的 messages[].content 是字符串,Anthropic 是 ContentBlock[] 数组。
  2. 结构层(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.tsbuildPipelineBundle),无需改动管线本身。


五、语义锚点:解决"跨 Agent 该落在哪"这一最难的问题

直接往 system prompt 的"前缀"或"后缀"塞内容虽然简单,但在不同 Agent 上效果天差地别:Claude Code 的 system prompt 有 # Memory 章节,CodeBuddy 有 <agent_skills> 标签,把记忆块硬塞到最前面,很可能把用户 persona 顶下去、污染开场。

于是项目引入了一个非常漂亮的概念——语义锚点(Semantic Anchor)

  1. Hook 只声明"我想落在哪一类区域"(语义槽 SemanticSlot)和"相对位置"(AnchorRelation);
  2. 每个 AgentProfile 把语义槽翻译成它自己的具体结构 key(resolveSlot);
  3. 命中就把内容精确锚定过去,命中不了就降级回粗粒度的 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.tsresolveHookBlocks)把"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:syncmem: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 重试一次 路由降级

这种"记忆注入是尽力而为,对话转发才是主链路"的优先级排序,是记忆系统能稳定上线的关键——它从架构上保证了记忆层永远不能成为可用性的单点。


九、几点值得点名的工程判断(资深视角)

抛开代码细节,下面几个判断是这套设计能成立的根本:

  1. 两层适配正交分解。协议差异(wire format)与结构差异(prompt structure)是两个独立维度,各用一层适配器解决,新增 Agent 只改 AgentProfile。这是教科书级的"变化隔离"。

  2. 锚点优先、兜底降级。语义锚点让注入"精准",point 兜底让注入"永不失败"。Hook 声明的是意图(slot),而非某个 Agent 的具体实现(rawKey),保持了可移植性。

  3. cache 策略与 prompt cache 对齐session_init 策略本质上是"把 session 内稳定的注入块提前算好、固定下来",让上游 prompt cache 持续命中——记忆注入的"好",不是注入得多,而是注入得稳且不破坏缓存

  4. 控制面走带内、数据面走转发mem: 命令是带内控制(零 token),记忆注入是数据面(随请求附带),二者解耦且互不干扰。

  5. 可观测性内建。每个 hook 的注入成功/失败、块数量、耗时都有 InjectionObserver(Langfuse / 日志 / noop 三档),并显式打印注入块前 120 字符预览——排查"为什么这一轮记忆没进来"时,日志里一眼就能看到。


十、局限与改进空间

客观地说,这套设计也有取舍:

  • 依赖协议可解析:Proxy 必须能完整理解 OpenAI/Anthropic 的请求结构,一旦上游协议演进(如新的多模态块类型),适配器需要跟进;
  • 锚点依赖 prompt 结构稳定AgentProfiledetectresolveSlot 硬编码了对各 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-Memoryv2.0.1-beta.1,MIT License)。
文中所引源码路径均为仓库内真实相对路径,逻辑以分析版本为准。

Logo

一站式 AI 云服务平台

更多推荐