TencentDB Agent Memory 的 MemoryProxy:一条请求经过它发生了什么
想搞明白一个 LLM 代理到底做了什么,只看 README 里那句一句话简介是不够的,更直接的办法是找到路由注册的那个函数,从上往下读一遍。TencentDB Agent Memory 仓库里的 MemoryProxy 模块恰好把这件事集中放在了一个文件里。
先交代几个前提,免得你照着找不到:这个仓库的默认分支是 feat/server_team,不是 main 也不是 master,下文所有路径都在这个分支上;我们采集时间是 2026-08-16,对应快照 97f9465;MemoryProxy 是目录名,它 package.json 里的包名是 context-proxy,版本 0.1.0(MemoryProxy/package.json:2-3),别把目录名当包名写进安装命令。这个仓库还在变动之中:主模块 MemoryCore 的 package.json 版本是 2.0.0-beta.1,含 MemoryProxy 在内的另外三个模块都还停在 0.1.0,下文提到的路由、配置项与默认值随版本变动,请以仓库最新内容为准。另外,我们没有部署、没有启动过它,下面全部是静态读源码得到的结论。
请求还没进业务逻辑,先撞两道门
config.example.yaml:26 里 server.port 是 8096,Dockerfile 也 EXPOSE 8096(MemoryProxy/Dockerfile:96)。一条 POST 打进来,最先遇到的不是路由,是两个中间件,注册在所有业务路由之前(src/server.ts:35-76)。
第一道看 config.costGuard.markerOptIn。这个开关为 false 时,任何路径里含 /cost-guard/ 段的请求直接返 404,错误体的 error 字段是 cost_guard_marker_disabled,message 里直接告诉你要么把这段从路径里去掉、要么把开关打开。第二道对称,看 config.injection?.assetReflection?.markerOptIn,含 /analyse/ 段的请求同样 404,错误码 analyse_marker_disabled。
有意思的地方在于 costGuard 这一整段配置在 config.example.yaml 里根本没有可配置的段——它只在注释里出现过 3 次(config.example.yaml:58、:60、:450),而代码侧 src/config.ts:65-70 定义了默认值、:201-232 定义了解析,src/server.ts 有四处直接读 config.costGuard.*。同类情况还有 workbuddyRequestRouting,代码默认 enabled: true(src/config.ts:144),示例配置里没有这一段。这只是两处口径的差异,我们只陈述位置,不推断原因。
路径归一化:把花样繁多的前缀削回标准后缀
门过了之后要判断这是不是一条该被代理的 LLM 请求。判断依据集中在 src/routes/whitelist.ts,文件头自述它是「路由、URL 拼接、handler 分派三处逻辑的单一数据源」(src/routes/whitelist.ts:3-9)。
WHITELIST_ENDPOINTS 表里共 14 条,字段是 pathSuffix / upstreamEndpoint / protocol / supportsStream / isPrimary。protocol 只有 "anthropic" | "openai" 两种取值,注释写明它决定鉴权头格式:anthropic 用 x-api-key,openai 用 Authorization: Bearer(src/routes/whitelist.ts:24-29)。isPrimary=true 的只有三条:/v1/messages、/v1/chat/completions、/v1/responses。其余走 handleAuxiliaryEndpoint,注释说明辅助端点「跳过路由,仅做鉴权 + 转发 + credit」(src/routes/whitelist.ts:31-36)。
匹配之前先做归一化,normalizeWhitelistRequestPath 的五步顺序写在注释里(src/routes/whitelist.ts:232-253):剥 query、剥 /cost-guard marker、剥 /analyse marker、剥 /proxy/{spaceId} 前缀、剥 /{agent}/{spaceId} 前缀。剥完之后按 pathSuffix 长度从长到短做精确后缀匹配。
单说剥 marker 那步就能看出这类代理的琐碎程度。/cost-guard 的正则原样是 /(?<=(?:\/[^/]+){2,})\/cost-guard(?=\/)/(src/routes/whitelist.ts:198),旁边一大段注释解释它为什么不会被 /cost-guarded/、/cost-guarding/、/pre-cost-guard/ 误触发,以及 spaceId 恰好就叫 cost-guard 时不会误命中。
路由表的注册顺序就是优先级
createApp(config) 在 src/server.ts:19-331,注册顺序即匹配优先级,最后一行是兜底的 app.post("/*", ...) → handleChatCompletions(src/server.ts:328)。
中间的排布不是按功能分组的,是按客户端分组的:Codex 专属 8 条(src/server.ts:221-229)、WorkBuddy 专属 8 条(:237-245)、dsh 相关 5 条加 4 条 marker 路由(:288-305),然后才是通用的 /:agent/:spaceId/v1/...(:307-312)。
为什么要给某个客户端单独铺 8 条路由?注释自己给了理由:codex 客户端源码里 endpoint 常量是 /responses,不带 /v1,而 Claude Code / CodeBuddy 那侧是 /v1/messages、/v1/chat/completions(src/server.ts:210-219)。所以 codex 那 8 条是同一组路径的带 /v1 与不带 /v1 两份。文件里还有多处注释解释「不显式注册就会 fall through 到 catch-all」的后果:把 Anthropic 格式的 body 打到 OpenAI 端点导致上游 400(src/server.ts:198-201)、把 Responses API 的 body 打到 /chat/completions 让客户端崩(:249-252)。
顺带一提,/v1/responses 这类 Codex 端点被显式列进白名单的原因也写在注释里:防止上游 URL 拼接走 fallback 兜底到 /chat/completions(src/routes/whitelist.ts:88-95)。
还有两组路由带着自我标注:/:agent/v1/messages 这种不带 spaceId 的写法被注释标为 deprecated: no credit reporting(src/server.ts:314-316);/proxy/:spaceId/... 这组 legacy 前缀的注释是「no agent info, defaults to codebuddy」(:318-325)。
进了 handler:README 写了 8 段,代码里还多几段
README 给的流程是 8 段(MemoryProxy/README.md:43-54,中文版同位置):auth → systemUser → sessionInit → injection → rateLimit → forward → extract → report。
src/handler.ts 是 OpenAI 侧主 handler,2076 行。它的分段注释按行号排下来,除了上面那 8 段之外,至少还有这么几个阶段:模型白名单门,拒绝 model 不在已注册显示名里的请求(src/handler.ts:569);模型别名改写,把客户端看到的 modelName 换成真实 model_id(:591);请求类别分类(:693);dsh CLI headless / no-preset 旁路(:706);mem: 命令拦截(:922)。README 那 8 段没有提到这些。差异就记录到这里。
几个阶段值得单独看:
鉴权不缓存。 verifyUserKey 每次都直连鉴权服务,注释原文是「Every call goes directly to the auth service (no caching)」(src/auth.ts:1-8)。接受条件写死为三个同时成立:body.code === 0、body.data?.valid === true、body.data.user?.user_id 存在(src/auth.ts:102-103),注释里那句「Any result that is NOT valid=true with a user_id → reject the request」写得相当直白。
系统用户短路有前置依赖。 config.example.yaml:353-360 描述了流程:先用 apiKey 走 verify 拿 user_id,命中 systemUsers 列表就短路透传到上游,跳过会话初始化与上下文注入,但仍记 usage/credit。同一段还写明它「依赖 auth.enabled=true —— auth 关掉时 verify 返回空 user_id,短路永远不会触发」。代码侧 src/config.ts:479-486 会把 userId 为空的条目静默丢弃。
mem: 命令是在代理这一层被吃掉的。 KNOWN_COMMANDS 写死三个:sync、create-skill、help(src/mem-command/index.ts:24)。命中之后 proxy 直接返回一个伪造的 LLM 响应,不注入系统提示词、也不转发上游(config.example.yaml:614-618)。未知命令返回的中文提示原样是「❌ 未知命令:mem:<x>。输入 mem:help 查看可用命令。」(src/mem-command/index.ts:47)。示例配置里 memCommand.enabled 默认 false(config.example.yaml:622)。
注入:九个位置,顺序是写死的
注入管线在 src/injection/pipeline.ts,文件头写的流程是 raw body → Adapter.parse() → AgentContext → execute hooks → Adapter.serialize() → modified body。
注入点执行顺序固定为 9 个(src/injection/pipeline.ts:165-175):system.prefix、system.before_tools、system.after_tools、system.suffix、tools.prepend、tools.append、user.first_turn、user.before、user.after。
落位有两条路径(:340-381):hook 声明了 anchor、有匹配的 AgentProfile、且解析出了真实结构键时按锚点精确落位;否则回落到粗粒度的 point。system.before_tools 与 system.after_tools 锚点解析不到时统一改为追加到系统提示词末尾,注释给的理由是不要把资产块甩到用户 persona 前面(:416-433)。
hook 缓存有三种策略(:154-162):none 是默认,直接执行;session_init 从 hookCacheRepo 读预热块、跳过执行;hybrid 把预热块和执行结果按 metadata.cacheKey ?? content 去重合并。没有 hookCacheRepo 或请求没有 sessionId 时,所有 hook 一律回落 none(:280-282)。单个 hook 抛错不致命,捕获后打 console.error 继续下一个(:229-250)。
Agent Profile 注册表只有三条:codebuddy、claude-code、workbuddy,cursor 那一行是被注释掉的(src/injection/index.ts:371-376)。
转发与超时:三类 handler 三种口径
到了转发这一步,超时的写法在不同 handler 里并不一致,这是读这个模块时最该自己核一遍的地方。
config.example.yaml:27 的 forwardTimeoutMs: 600000 带注释「默认 600000(10 分钟);0 = 不超时」。OpenAI 侧确实用 if (forwardTimeoutMs > 0) 包住了 AbortSignal.timeout(...)(src/handler.ts:353-355),系统用户透传路径同样带这个判断(src/systemUserPassthrough.ts:530-532)。Anthropic 侧两处则是无条件调用 AbortSignal.timeout(forwardTimeoutMs)(src/anthropicHandler.ts:452、:493),取值处写的是 config.server.forwardTimeoutMs ?? 600_000(:1231)。Codex 与 WorkBuddy 的流式读取用的又是另一套,setTimeout(..., 5 * 60 * 1000) 硬编码,超时打 STREAM_TIMEOUT 并强制收尾(src/codexHandler.ts:1150-1156、src/workbuddyHandler.ts:673-679),这两个 handler 里 grep 不到 forwardTimeoutMs。
三处写法不同,位置都在上面了。这些都是代码里的默认值与写法,不是运行表现,别拿它去推算任何耗时或稳定性结论。
限流那一层的常量同样是写死的:窗口 const WINDOW_SECONDS = 60(src/rate-limit/redis-store.ts:6),Redis key 的 EXPIRE 设为窗口的两倍。触发限流要求 config.rateLimit 存在、tpm 与 qpm 不同时 ≤0、instanceId 非空,否则直接 return(src/rate-limit/guard.ts:35-36)。Redis 不可用时是 fail-open:直接放行,按 30 秒节流打一条 rate_limit.fail_open 警告(:39-50)。
代理自己不存记忆
最后一件容易误解的事:README 原文写「The proxy itself persists no memory data — all Memory / Skill / Knowledge reads and writes go through the MemoryCore Gateway (default :8420)」(MemoryProxy/README.md:7)。方向是 MemoryProxy 主动调 MemoryCore。
示例配置里三处 endpoint 都指向 http://127.0.0.1:8420:tdai.endpoint(config.example.yaml:547)、skill.endpoint(:573)、knowledge.endpoint(:591)。而代码内建默认值里只有 coreSkill.endpoint 与 knowledge.endpoint 是这个地址(src/config.ts:120、:127),tdai.endpoint 的代码默认是空字符串(src/config.ts:105)。配置优先级写在 buildConfig 注释里:CLI overrides > YAML config file > defaults(src/config.ts:260-261),默认配置文件路径是 "config.yaml"(:263)——而这个文件被 .gitignore 排除了,快照里没有,所以线上真实生效的值我们一个也不知道。
要自己核这条链路,可执行的动作就三个:打开 src/server.ts 从 createApp 往下读注册顺序,grep pathSuffix src/routes/whitelist.ts 数那 14 条,再对着 src/config.ts 的 DEFAULT_CONFIG 和 config.example.yaml 逐字段比一遍。这三处对不上的字段不少,值得逐条比对着看。
需要提醒的是,这条链路上流动的是团队的对话内容与提示词,会话初始化、注入、抽取、上报几个阶段都在读写这些数据,属于敏感数据,是否引入请结合自身合规要求评估。
延伸阅读
- 从头读起:TencentDB Agent Memory 是什么:团队级 Agent 记忆中枢怎么读
- 本专题共 40 篇,完整分组目录见专题页
- TencentDB Agent Memory 注入的标签:README 与代码产出的不是同一个
- TencentDB Agent Memory 的四级降级:源码里有一档直接 FATAL
本文依据 TencentDB Agent Memory 官方仓库(github.com/TencentCloud/TencentDB-Agent-Memory)
feat/server_team 分支上的 README、INSTALL、CHANGELOG、ROADMAP 与四个模块的源码整理,
核对日 2026-08-16,对应仓库快照 97f9465。该仓库的默认分支即为 feat/server_team。
本文内容为仓库源码与文档口径,我们没有部署、也没有运行过该项目的任何一个模块,
因此不涉及运行效果、检索质量与性能的任何描述。
该项目主模块处于 beta 阶段、其余模块版本号仍为 0.1.0,参数与接口随版本变动,请以仓库最新内容为准。
该项目会采集并存储团队的对话、文档与代码,属于敏感数据,是否使用请结合自身合规要求评估。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。