Codex 接入 TencentDB Agent Memory 源码级踩坑实录
1. 从一次 Codex 接入失败说起:为什么我要去翻 TencentDB Agent Memory 的源码
前段时间在做一个 Agent 项目,需要给 Codex 这类代码生成 Agent 挂上一层长期记忆,让它能记住用户的历史偏好、项目上下文、常用代码风格这些东西。第一反应是找个现成的记忆存储方案,TencentDB Agent Memory 进入了视野——它主打的就是给 Agent 提供结构化的记忆读写能力,支持向量检索、会话隔离、记忆分层这些特性,看起来正好对口。
结果接进去之后,Codex 那边一直报错,日志里反复出现local proxy failed while handling codex endpoint /responses这类信息,记忆写入时好时坏,读出来的上下文经常串味。网上搜了一圈,大部分文章都在讲怎么装、怎么配,没人讲清楚为什么 Codex 这种 Agent 和通用记忆架构之间会存在硬冲突。没办法,只能自己拉源码看。
这篇就是那次排查的完整记录。我会从 TencentDB Agent Memory 的整体架构讲起,拆开它的记忆分层模型、读写链路、存储引擎选型,然后重点分析 Codex 接入时那几个绕不过去的冲突点——请求生命周期不匹配、上下文窗口竞争、记忆写入的时序问题。适合正在做 Agent 记忆层、或者准备把 Codex 接进自己系统的同学参考。源码层面的东西我会尽量讲透,但不会贴大段代码,重点在架构逻辑和踩坑经验。
先说结论:Codex 和通用 Agent Memory 的冲突,本质不是 API 不兼容,而是两者对"记忆"这件事的假设根本不同。Codex 假设自己是一次性、无状态的代码生成器,而 Agent Memory 假设自己服务的是一个有持续会话、有长期记忆的智能体。这个假设差异,会在请求链路、上下文管理、写入时机三个地方集中爆发。
2. TencentDB Agent Memory 的记忆分层模型:它到底在存什么
2.1 三层记忆结构的设计意图
翻源码第一件事是搞清楚它的数据模型。TencentDB Agent Memory 把记忆分成三层,这个分层不是随便切的,每一层对应不同的生命周期和检索方式。
最底层是原始会话层(Raw Session Layer),存的是 Agent 和用户之间完整的交互记录,包括每轮对话的输入输出、时间戳、会话 ID。这一层基本是 append-only 的,写入后很少修改,主要作用是审计和回溯。源码里对应的是session_store这个模块,底层用的是 TencentDB 的宽表存储,按会话 ID 做分区。
中间层是记忆摘要层(Memory Summary Layer),这是整个架构的核心。它会把原始会话做压缩、抽取、归类,生成结构化的记忆条目。比如用户说了十句话描述自己的代码风格,这一层会抽成一条"用户偏好:使用 TypeScript 严格模式,函数式风格优先"这样的记忆。源码里这块逻辑在memory_extractor和summarizer两个模块,抽取策略是可插拔的,默认走的是基于规则的抽取加向量聚类。
最上层是语义索引层(Semantic Index Layer),把摘要层的记忆条目做向量化,存进向量索引,供检索时用。这一层不存原始内容,只存向量和指向摘要条目的引用。检索时先走向量召回,再回摘要层取完整内容。
注意:这三层的写入不是同步的。原始会话层是实时写,摘要层是异步批处理,语义索引层又是摘要层写完之后的二次异步。这个异步链路是后面很多问题的根源。
2.2 为什么这样分层:和纯向量库方案的对比
很多人第一反应是"我直接用向量数据库存记忆不就行了",我一开始也这么想。但实际跑下来,纯向量库方案在 Agent 场景下有几个硬伤。
第一是记忆的时效性和优先级无法表达。向量检索只看语义相似度,但 Agent 记忆里"用户上周说的偏好"和"用户三年前说的偏好"权重完全不同。TencentDB Agent Memory 的分层模型里,摘要层带了时间衰减因子和访问频次计数,检索时会做加权,这是纯向量库做不到的。
第二是记忆的更新和遗忘。纯向量库更新一条记忆基本是删了重插,但 Agent 记忆经常需要"合并"——用户这次说的偏好和上次说的有重叠,应该合并成一条而不是新增。摘要层的抽取逻辑里带了去重和合并策略,源码里叫memory_consolidation,这块逻辑相当复杂,也是整个架构里最容易出 bug 的地方。
第三是可解释性。纯向量库检索出来一堆相似片段,你很难说清楚为什么这条被召回。分层模型里,检索结果可以追溯到摘要条目,摘要条目可以追溯到原始会话,整条链路是清晰的。做 Agent 调试的时候这个太重要了。
| 维度 | 纯向量库方案 | TencentDB Agent Memory 分层方案 |
|---|---|---|
| 时效性表达 | 不支持,需自己加字段 | 摘要层内置时间衰减 |
| 记忆合并 | 需应用层自己实现 | 抽取层内置 consolidation |
| 检索可解释 | 弱,只有相似度分数 | 强,可追溯到原始会话 |
| 写入延迟 | 低,单次写入 | 高,三层异步链路 |
| 存储成本 | 中 | 高,三层冗余存储 |
2.3 存储引擎的选型逻辑
底层存储用的是 TencentDB 的宽表加向量索引的组合。宽表存原始会话和摘要条目,向量索引单独维护。这个选型的好处是事务性和检索性能可以分开优化,坏处是两层之间的一致性需要应用层保证。
源码里有个细节值得说:摘要层写入宽表之后,会发一个消息到内部队列,由消费者去更新向量索引。这个队列是 at-least-once 语义,意味着向量索引可能重复写入。源码里用了一个幂等键(记忆条目的 hash)来去重,但这个幂等键的生成逻辑在并发场景下有问题——两个内容相同但来源不同的记忆条目,hash 可能一样,导致其中一条被误去重。这个坑我在实际使用中踩到过,后面会细说。
3. Codex 的请求模型:它为什么"记不住"也不愿意"被记住"
3.1 Codex 的无状态假设
要理解冲突,先得理解 Codex 是怎么工作的。Codex 这类代码生成 Agent,核心请求模型是单次请求、单次响应、无状态。每次你给它一个 prompt,它独立生成一段代码,生成完这次请求就结束了。它不假设自己有跨请求的记忆,也不假设自己能访问历史上下文。
这个设计是有道理的。代码生成场景下,上下文污染是致命的——如果 Agent 记住了上一次生成的代码风格,这次用户想要完全不同的风格,记忆反而成了负担。所以 Codex 的默认行为是每次请求都从干净状态开始。
但问题来了:用户想要的是"记住我的项目结构、我的命名习惯、我常用的库",这又需要记忆。于是就有了把外部记忆系统挂到 Codex 上的需求,也就是我这次做的事。
3.2 Codex 的请求生命周期
Codex 的一次完整请求,从客户端到模型再到返回,大致经过这几个阶段:
- 客户端组装 prompt,包含系统指令、用户输入、可选的上下文片段
- 请求发到 Codex 的 endpoint(就是日志里那个
/responses) - 服务端做 prompt 预处理,可能包括截断、格式化
- 模型推理,生成响应
- 响应返回客户端,请求结束
关键在第三步和第五步。prompt 预处理阶段,Codex 会对上下文做截断,它有一个固定的上下文窗口预算,超出部分直接丢弃。而请求结束后,Codex 不会主动把这次交互写回任何地方,它没有"写回"这个概念。
3.3 记忆系统期望的请求模型
反过来看 Agent Memory 期望的请求模型,它假设的是:
- 请求开始前,Agent 主动查询记忆,把相关记忆注入 prompt
- 请求结束后,Agent 主动把这次交互写回记忆系统
- 记忆系统异步处理,更新摘要和索引
这两个模型放在一起,冲突就出来了。Codex 不主动查记忆,也不主动写记忆,它就是个"哑"的生成器。你要挂记忆,就得在 Codex 外面包一层代理,代理负责查记忆、注入 prompt、接收响应、写回记忆。我日志里那个local proxy failed就是这层代理出的问题。
提示:这层代理是必须的,不要指望 Codex 原生支持记忆。所有"Codex 接记忆"的方案,本质都是在 Codex 外面套一层记忆代理。
4. 硬冲突拆解:三个绕不过去的架构矛盾
4.1 冲突一:请求生命周期与异步写入链路的时序错配
这是最核心的冲突。Agent Memory 的写入是三层异步的,原始会话层实时写,摘要层和索引层异步。但 Codex 的请求是短生命周期的,一次请求可能几百毫秒就结束了。
问题在于:代理在请求结束后写回记忆,但记忆的摘要和索引还没生成,下一次请求查记忆时查不到刚写的内容。用户会感觉"我刚告诉它的偏好,它下一句就忘了"。
源码里这个异步链路的延迟,实测下来摘要层平均 2-5 秒,索引层再加 1-3 秒。也就是说,一次交互的记忆,要 3-8 秒后才能被检索到。对于连续对话场景,这个延迟是不可接受的。
我试过几个缓解方案:
- 方案 A:写回时同步等待摘要生成。改代理逻辑,写回后轮询摘要层直到生成完成再返回。缺点是请求延迟暴增,用户体验差。
- 方案 B:写回时直接把原始内容也写进一个短期缓存,检索时先查缓存再查索引。这个方案可行,但要在代理层维护一个额外的缓存,增加了复杂度。
- 方案 C:调整摘要层的触发策略,改成实时触发。源码里摘要层是定时批处理,可以改成消息驱动实时处理,但这样吞吐会下降,高并发场景下扛不住。
最后我选的是方案 B 的变体:代理层维护一个会话级的短期记忆缓存,写回时同时写缓存和记忆系统,检索时合并两边结果,缓存里的记忆带一个"未确认"标记,等摘要层生成后再去掉标记。这个方案实测下来延迟可以接受,代价是代理层逻辑复杂了不少。
4.2 冲突二:上下文窗口的零和博弈
Codex 的上下文窗口是有限的,这是硬约束。记忆注入会占用窗口预算,注入得越多,留给用户实际输入和代码上下文的空间就越少。
Agent Memory 的检索默认返回 top-k 条记忆,k 默认是 10。这 10 条记忆如果每条平均 100 token,就是 1000 token 的预算。对于上下文窗口只有 8k 的模型,这已经占了八分之一。如果记忆条目更长,或者 k 更大,窗口很快就不够用了。
源码里检索模块有个max_tokens参数可以限制返回记忆的总 token 数,但默认值设得比较宽松。我实际调的时候把它压到了 500 token,同时把 k 降到 5,并且加了一个基于相关度分数的动态截断——分数低于阈值的记忆直接不返回。
这里有个经验:记忆注入不是越多越好。我做过对比测试,注入 10 条记忆和注入 3 条高相关记忆,代码生成质量后者反而更好。原因是低相关度的记忆会干扰模型判断,让它把不相关的偏好也套用上去。所以宁可少注入,也要保证注入的都是高相关的。
| 注入策略 | 平均 token 占用 | 代码生成质量评分 | 上下文污染情况 |
|---|---|---|---|
| top-10 全注入 | ~1000 | 6.5/10 | 严重,风格串味 |
| top-5 全注入 | ~500 | 7.2/10 | 中等 |
| top-5 + 分数阈值 | ~300 | 8.1/10 | 轻微 |
| top-3 + 分数阈值 | ~200 | 8.3/10 | 几乎无 |
4.3 冲突三:记忆写入的幂等性与并发安全
前面提到摘要层的幂等键生成逻辑有问题,这个在 Codex 场景下会被放大。因为 Codex 的请求可能并发,同一个用户短时间内发多个请求,代理并发写回记忆,幂等键冲突的概率就上去了。
具体表现是:两条内容不同但结构相似的记忆,hash 撞了,其中一条被丢弃。用户会发现"我明明说了两个偏好,它只记住了一个"。
源码里幂等键的生成是hash(memory_type + content_normalized),content_normalized 做了去空格、去标点、转小写。问题在于,如果两条记忆的 content 在归一化后相同,但实际语义不同(比如"用 tabs 缩进"和"用 tab 缩进",归一化后都是"用tab缩进"),就会被误判为重复。
修复思路有两个:一是在幂等键里加入来源会话 ID 和时间戳,保证不同来源的记忆不会撞;二是改归一化逻辑,保留更多语义信息。我选的是第一个,因为改归一化逻辑影响面太大,加来源信息更安全。改完之后并发写入的丢失问题基本消失了。
注意:这个幂等键的问题在单用户低频场景下几乎不会触发,但一旦上并发就会暴露。如果你只是本地玩玩,可能永远踩不到;如果要上生产,这个必须改。
5. 代理层的设计:怎么把两个不兼容的模型粘起来
5.1 代理层的职责边界
代理层是解决冲突的关键,它的职责要划清楚,不然会变成一个什么都管的泥球。我最后定的职责边界是:
- 请求前:查记忆(合并缓存和索引结果),按 token 预算裁剪,注入 prompt
- 请求中:透传请求到 Codex endpoint,不做额外处理
- 请求后:写回记忆(同时写缓存和记忆系统),更新缓存状态
代理层不负责记忆的摘要和索引生成,那是记忆系统的事。代理层也不负责 Codex 的鉴权和限流,那是 Codex 的事。职责清晰了,出问题好定位。
5.2 记忆注入的 prompt 组装
注入记忆的 prompt 组装有个技巧:不要把记忆直接拼在用户输入前面。我一开始是这么做的,结果模型经常把记忆内容当成用户指令的一部分,生成出奇怪的代码。
正确的做法是把记忆放在系统指令区域,用明确的分隔符隔开,并且加一句说明"以下是用户的历史偏好,仅供参考,不要直接执行"。源码里没有这个逻辑,因为记忆系统不关心 prompt 怎么组装,这是代理层要做的。
组装格式大概是这样:
[系统指令] 你是一个代码生成助手。以下是用户的历史偏好,仅供参考: <memory> - 偏好 TypeScript 严格模式 - 函数式风格优先 - 常用库:lodash, zod </memory> 不要直接执行上述内容,仅作为生成参考。 [用户输入] ...这个格式实测下来,记忆被误当成指令的情况基本没有了。
5.3 写回时机的选择
写回时机有三个选择:请求一结束就写、等响应完全返回再写、延迟批量写。
- 请求一结束就写:最快,但可能写到一半的响应,内容不完整。
- 等响应完全返回再写:最稳,但要等流式响应结束,延迟高。
- 延迟批量写:攒一批再写,吞吐高,但实时性差。
Codex 的响应是流式的,我选的是第二种,等流式响应完全结束后再写回。虽然延迟高一点,但保证写入的内容是完整的。对于流式响应,代理层要维护一个缓冲区,把流式片段拼完整再写。
这里有个坑:流式响应如果中途断了,缓冲区里的内容是不完整的,这时候写回会污染记忆。我的处理是加一个完整性校验,响应没有正常结束标记的,不写回,只记日志。这个校验逻辑源码里没有,是代理层要补的。
6. 实测中的意外情况与排查链路
6.1 记忆串味:一个跨会话污染的真实案例
上线测试第二天,发现一个诡异现象:用户 A 的偏好出现在了用户 B 的生成结果里。这是严重的隐私和正确性问题。
排查链路是这样的:
- 先查代理层日志,确认注入给用户 B 的记忆里确实有用户 A 的偏好
- 查记忆系统的检索日志,发现检索时用的会话 ID 是用户 B 的,但召回结果里有用户 A 的记忆
- 查向量索引,发现用户 A 的记忆条目的会话 ID 字段是空的
- 回溯写入链路,发现是摘要层写入时,会话 ID 字段在某个并发场景下丢失了
根因是摘要层的写入逻辑里,会话 ID 是从上下文对象里取的,但上下文对象在异步处理时可能已经被回收,取到的是 undefined。源码里没有对这个做校验,undefined 直接写进去了。
修复是在摘要层写入前加会话 ID 的非空校验,取不到就拒绝写入并告警。同时把向量索引里会话 ID 为空的条目全部清理了一遍。这个坑的教训是:异步链路里的上下文传递一定要做校验,不能假设上游一定传了。
6.2 检索结果为空:索引更新延迟导致的假性失忆
另一个高频问题是用户反馈"它忘了我说的话"。查下来大部分是索引更新延迟导致的——记忆写进去了,但索引还没更新,检索时查不到。
这个问题的排查有个技巧:对比摘要层和索引层的记录数。如果摘要层有 100 条,索引层只有 95 条,说明有 5 条还没索引。源码里没有现成的对比工具,我写了个脚本定时跑,差异超过阈值就告警。
缓解方案前面说了,代理层加短期缓存。但缓存也有缓存的问题——缓存和索引的数据可能不一致,缓存里有但索引里没有(正常,还没索引完),或者索引里有但缓存里没有(异常,可能是缓存被清了)。我的处理是缓存带 TTL,过期后以索引为准,缓存只作为索引的补充。
6.3 性能瓶颈:摘要层的批处理窗口
压测的时候发现,并发上来之后摘要层处理不过来,队列积压,记忆写入延迟从秒级涨到分钟级。
查下来是摘要层的批处理窗口设得太保守,默认是攒够 100 条或者等 30 秒才处理一批。低并发时没问题,高并发时 30 秒的窗口导致大量记忆排队。
调参思路:把批处理窗口改成动态的,队列长度超过阈值就立即处理,不超过就等窗口。源码里这个参数是写死的,我改成了可配置,并且加了队列长度的监控。改完之后高并发下的写入延迟降到了 5 秒以内。
| 场景 | 默认配置延迟 | 调优后延迟 | 调优手段 |
|---|---|---|---|
| 低并发(<10 QPS) | 2-5s | 2-5s | 无需调优 |
| 中并发(10-50 QPS) | 10-30s | 3-8s | 动态批处理窗口 |
| 高并发(>50 QPS) | 60s+ | 5-10s | 动态窗口 + 队列监控 |
7. 几个值得记下来的实操经验
7.1 不要试图改 Codex,改代理层
我一开始想过改 Codex 的请求处理逻辑,让它原生支持记忆。试了两天放弃了,因为 Codex 的请求链路是封闭的,改动成本极高,而且升级会覆盖。所有定制逻辑都应该放在代理层,Codex 保持原样。这个原则适用于所有"给封闭 Agent 挂能力"的场景。
7.2 记忆的写入和读取要分开优化
写入追求的是不丢、不重、不乱序,读取追求的是快、准、省 token。这两个目标的优化手段是冲突的——写入要强一致就得同步,读取要快就得缓存。我的做法是写入走异步保证吞吐,读取走缓存加索引合并保证速度,两边用不同的策略,不要试图用一套逻辑同时满足。
7.3 监控要覆盖异步链路的每一段
异步链路最大的问题是出问题了不知道在哪一段。我在代理层、摘要层、索引层各加了埋点,记录每条记忆的写入时间、摘要完成时间、索引完成时间,形成一个完整的链路追踪。出问题时一看链路就知道卡在哪。这个埋点成本不高,但排查效率提升巨大。
7.4 会话隔离要做双重校验
前面那个跨会话污染的案例,根因是会话 ID 丢失。修复之后我加了个双重校验:写入时校验会话 ID 非空,检索时校验召回结果的会话 ID 和请求的会话 ID 一致,不一致的直接过滤掉。虽然多了一次校验,但杜绝了串味问题。记忆系统的会话隔离,不能只靠写入时保证,检索时也要兜底。
7.5 上下文预算要留余量
Codex 的上下文窗口是硬约束,记忆注入的预算不能按满打满算。我一开始按窗口的 20% 给记忆,结果用户输入一长就爆窗口。后来改成按窗口的 10% 给记忆,并且加了动态调整——用户输入长的时候自动压缩记忆注入量。留余量这个事,宁可保守一点。
8. 关于这套架构的后续扩展思路
这套代理加记忆系统的架构,跑通之后其实可以扩展出不少东西。比如记忆的主动遗忘——用户明确说"忘掉之前的偏好"时,代理层可以触发记忆系统的删除。再比如记忆的版本管理——用户偏好变了,旧偏好不是删掉而是标记为历史版本,检索时优先返回新版本。这些扩展都不需要改 Codex,全在代理层和记忆系统里做。
源码里其实留了一些扩展点,比如摘要层的抽取策略是可插拔的,你可以自己实现一个针对代码场景的抽取器,专门抽代码风格、库偏好、命名习惯这些。默认的抽取器是通用的,对代码场景的抽取精度一般。我试过写一个简单的代码场景抽取器,精度提升明显,这块后续可以单独展开讲。
最后说个我自己的体会:Agent 记忆这件事,难点从来不在存储,而在"什么时候记、记什么、什么时候用"。存储方案再先进,时机不对就是白搭。TencentDB Agent Memory 提供了不错的存储和检索能力,但时机控制这块,得靠代理层自己拿捏。Codex 接入的硬冲突,说到底就是时机冲突,把时机对齐了,剩下的都是工程问题。