第一次跑通 Codex 接进 TencentDB Agent Memory 的时候,我差点以为这事已经结了。结果真的沉下心来去读那套记忆服务的源码,才发现所谓的"接记忆"根本不是往对话历史里塞一段上下文那么简单——两边对"记忆"的底层假设几乎是反的。这篇博文把我在源码里碰到的三处硬冲突完整还原出来:任务边界的对不上、向量召回和 token 预算打架、版本号被 checkpoint 分支冲垮。适合正在做 Agent 记忆接入、或者想搞明白这类存储/索引/适配层代码的人,按图索骥去复现,顺便避开我们踩过的那几个大坑。
1. 先说结论:这个项目到底在折腾什么
1.1 TencentDB Agent Memory 是什么
TencentDB Agent Memory 不是一个简单的"数据库表存聊天记录",它本质上是给 AI Agent 用的持久化记忆中间件。从源码划分来看,它包含四层能力:结构化记忆(消息、任务、元数据)、向量记忆(embedding 检索)、事务日志(版本回溯)、以及给外部 Agent 接入的 SDK 层。
我读的这份源码里,核心目录大概是这样的:
agent_memory/ ├── client/ # SDK 入口,外部 Agent 只看到这一层 │ ├── AgentMemoryClient.py │ └── session.py ├── task/ # 任务管理与生命周期 │ ├── task_manager.py │ └── task_state.py ├── store/ # 底层存储 │ ├── message_store.py │ ├── version_log.py │ └── schema.sql ├── index/ # 向量索引 │ ├── embedding_service.py │ └── vector_index.py └── adapter/ # 外部 Agent 适配层 └── codex_adapter.py这份源码给的记忆模型有一个核心概念:Task。它把一次"有明确边界的人机协作过程"当作记忆的基本单元,Task 下面挂消息,消息之间靠seq_no排序,全局再给一个version_seq做版本管理。这个模型的假设是:Agent 的工作像事务一样,有开始、有提交、有回滚。
问题就出在这个假设上。Codex 的工作流根本不是一个事务,它是一个无休止的 Agent Loop。
1.2 为什么非要把 Codex 接进去
Codex 是 OpenAI 出的编码智能体,能自主执行多步任务、调用工具、修改文件,但它有个先天毛病:会话结束就忘。它不会默认把"用户偏好 A 文件不允许动""之前已经验证过方案 B 不可行"这类信息沉淀下来。
我们希望的是:Codex 跑完一个任务后,下次再启动时记得这个项目的约束、偏好、历史决策,以及哪些代码是它自己改过的。这就是 TencentDB Agent Memory 要补的位置。
但接入这件事的关键不在"能不能调通 API",而在"两边对记忆语义的定义是否一致"。源码读得越深,越能发现一致性的裂缝。
1.3 读源码的三条主线
我建议按三条主线程读这份源码,不要从第一个文件顺序读到最后一个:
- 消息写入路径:
AgentMemoryClient.append_message()到message_store,看一条消息是怎么被落库、怎么被编版本号的。 - 消息读取路径:
AgentMemoryClient.recall()到vector_index,看一次记忆检索到底走了哪几步。 - 任务生命周期路径:
task_manager怎么 commit、abort 一个 Task,这是和 Codex 冲突最密集的地方。
我这次踩到的三个硬冲突,分别落在这三条主线上。
2. 从入口模块开始拆:记忆服务的骨架
2.1 先让人能跑起来的第一层:SDK 与任务上下文
AgentMemoryClient是外部 Agent 接入的唯一入口。它的方法不多,核心就这几个:
begin_task(task_id, meta):开启一个记忆任务append_message(task_id, role, payload):往里追加消息recall(query, top_k, filters):按语义检索相关记忆commit_task(task_id):正式把任务落库abort_task(task_id):丢弃这个任务
从代码注释和命名能看出,这个客户端设计时的参照对象是传统数据库连接:begin_task对标BEGIN TRANSACTION,commit_task对标COMMIT。它假设 Agent 一定知道"整个任务从哪开始、到哪结束"。
但 Codex 不是这么工作的。Codex 的循环是:
while not done: step = agent_loop.step() # 推理一步 observation = tool_executor.run(step.tool_call) # 执行一个工具 context_fragments.append(observation) # 观察结果塞回上下文 if user_interrupts: agent_loop.revise_plan() # 用户随时打断、改方案这个循环里没有commit这个概念,只有"继续跑"和"被打断"两种状态。
2.2 存储层:消息表、元数据与版本号的关系
存储层是最直白的地方。message_store.py里的建表语句核心字段长这样:
CREATE TABLE message_records ( id BIGINT PRIMARY KEY, task_id VARCHAR(64) NOT NULL, session_id VARCHAR(64) NOT NULL, role TINYINT NOT NULL, -- 0: user, 1: assistant, 2: tool channel VARCHAR(32) DEFAULT 'chat', payload MEDIUMBLOB NOT NULL, -- 序列化的消息内容 payload_size INT NOT NULL, seq_no INT NOT NULL, -- 任务内自增序号 version_seq BIGINT NOT NULL, -- 全局线性版本号 prev_version_seq BIGINT, -- 指向前一个版本 created_at DATETIME NOT NULL, UNIQUE KEY uk_task_seq (task_id, seq_no), UNIQUE KEY uk_version (version_seq) );这个表的设计思路很清晰:task_id + seq_no唯一确定一条会话内消息,version_seq用来做全局回滚和审计。version_log表则记录每次提交(commit)对应的version_seq区间:
CREATE TABLE version_log ( version_seq BIGINT PRIMARY KEY, task_id VARCHAR(64), status ENUM('OPEN','COMMITTED','ROLLED_BACK'), parent_version_seq BIGINT );到这里为止,一切都是线性的:一次任务一段区间,区间内消息连续,全局版本号单调递增。
2.3 检索与索引层:异步向量索引的读写路径
向量索引的写入路径很有意思。EmbeddingService.embed()本身是同步的,但vector_index的写入是异步的——消息先写进message_records,再丢进一个后台队列,由 worker 批量计算 embedding 后写入向量库。
def append_message(task_id, role, payload): record = insert_message_record(task_id, role, payload) index_queue.enqueue(record.id, payload) # 异步进入 embedding 队列 return record这样做有它的道理:不能因为 embedding 服务抖动,把核心消息写入拖垮。但代价在哪儿?代价是记忆的"读己之写"(read-your-writes)得不到保证。这个细节会在第四章节引爆。
3. 硬冲突之一:任务边界的幻觉 vs 自由 Agent Loop
3.1 代码里 Task 的概念是怎么定的
我读task_manager.py的时候发现,Task 状态机有四个状态:PENDING、ACTIVE、COMMITTED、ABORTED。
class TaskState(Enum): PENDING = 0 ACTIVE = 1 COMMITTED = 2 ABORTED = 3task_manager里有一段关键逻辑:
def commit_task(task_id): messages = get_messages_by_task(task_id) if messages and messages[-1].role != "assistant": raise IncompleteTaskError("任务必须以 assistant 消息结束") write_version_log(task_id, status="COMMITTED")注意这个判断:任务必须以 assistant 消息结束。这个规则的潜台词是:一个完整任务应该是"用户发指令 → Agent 执行 → Agent 汇报结果",Agent 的最后一句话是任务的收尾。
但这个规则放在 Codex 上完全不成立。Codex 的交互流程里,用户和 Agent 是来回穿插的:
- 用户:"帮我修复这个函数的边界条件"
- Codex 分析代码,改了文件
- 用户:"等一下,先别提交,把另一个文件也改了"
- Codex 继续改另一个文件
- 用户:"算了,还是回退到方案 A"
- Codex 回滚代码,重新实现
整个过程里到底有几个 Task?如果把整个对话算一个 Task,那seq_no会一直累加,commit_task会等到对话彻底结束才提交。但问题来了:如果对话持续三个小时,任务期间 Agent 崩溃了怎么办?ACTIVE状态的 Task 没有超时机制,所有未提交的消息全部悬在那里。
3.2 Codex 的会话流为什么对不上
反过来,如果我们把每次工具调用都当作一个 Task,又会遇到另一个问题。Codex 一次"让我改个 bug"的请求,内部可能触发十几次工具调用:搜索文件、读文件、改文件、跑测试、再改文件、再跑测试。每个 Task 只有一条用户消息加几条 assistant/tool 消息,数据碎片化严重。后续做语义检索时,top_k召回的片段跨了好几个 Task,上下文拼起来完全不成句。
我在codex_adapter.py里找到了最初团队写的映射逻辑,大概是这样:
def map_codex_turn_to_task(turn_id, session_state): if session_state.is_complete(): return commit_task(turn_id) else: return keep_task_active(turn_id)这个映射最大的问题:is_complete()在 Codex 的 Agent Loop 里根本无法精确判断。用户沉默五分钟不代表任务结束,Codex 内部在思考也不代表任务结束。结果就是大量的 Task 被白白挂在ACTIVE状态,永远是"未完成"。
3.3 冲突现场:一条"幽灵 Task"和一个过度切分问题
我们用测试复现的时候,出现了两个典型症状。
第一个症状叫"幽灵 Task"。某个会话里有三条消息,但task_manager因为 commit 前的最后一条消息是 tool 消息(不是 assistant 消息),直接拒绝了 commit。数据实际上已经写入message_records,但version_log里没有对应记录。从记忆服务的视角看,这些消息是"不存在"的;从 Codex 的视角看,这段对话明明发生了。
第二个症状是"过度切分"。一次 10 分钟的 Codex 会话,因为每次工具调用都被映射成独立 Task,最后生成了 47 个 Task。每个 Task 里只有一两条消息,retrieval 的时候按 Task 粒度召回片段,选出来的内容鸡零狗碎,甚至把"用户确认 OK"这样的短消息和"修改了 300 行代码"的完整决策拆在两个 Task 里,语义完全割裂。
我还特意去查了git log,发现codex_adapter.py里曾经有人加过一段"按时间间隔切割 Task"的逻辑——如果两条消息间隔超过 5 分钟就切一个新 Task。这个逻辑在测试里跑得还行,一上真实场景就崩:Codex 跑测试可能卡 6 分钟,于是同一个逻辑任务被硬生生切成两半。后来这段逻辑被注释掉了,但注释掉之后又回到了"整个会话一个 Task"的状态。这个循环往复,其实就是任务边界定义不清的直接体现。
4. 硬冲突之二:召回的一致性与 Codex 的 token 预算打架
4.1 写后不读:异步索引的墙
第二个冲突出在检索路径上,核心是"异步索引"和"Codex 的实时推理"之间的时序矛盾。
读vector_index.py能看到索引流程:消息写入后,它要经历index_queue.enqueue()→ worker 消费 → 调 embedding 服务 → 计算向量 → 写入向量库 → 刷新索引。这个过程短则几百毫秒,长则几秒甚至更久,取决于队列积压情况。
而 Codex 的推理节奏是:每隔几步就可能调用一次recall()来翻记忆。比如它刚和用户确认"保持现有日志格式不要动",马上又需要回忆"这个项目日志格式的原始要求是什么"。对 Codex 来说,这句"保持现有日志格式不要动"是它自己刚说出来的,应该是最高置信度的记忆。但实际执行recall()的时候,这条消息可能还在 embedding 队列里排队,根本搜不到。
我一开始以为是参数问题:top_k不够、阈值太严。后来在源码里加了日志,发现队列积压的副本还在,索引里确实没有新增的数据。这种"写后不读"在传统数据库里是不可接受的,但在异步索引系统里却很常见——它优化了写入吞吐,牺牲了立即读的一致性。
这还不是最坏的。最坏的情况发生在 Codex 生成过程中反复切换上下文时:用户要求它同时维护"代码修改"和"项目规范"两条线索,Codex 每次切换都要召回,而每次召回都可能撞上刚写入未索引的消息,导致它反复看到过时的记忆,并基于过时记忆做出决策。
4.2 top-k 召回选中的偏偏是最旧的内容
第二个子冲突更隐蔽,也更致命。Codex 本身有 token 预算管理,超过窗口的消息会被截断或压缩。它的处理方式是"保最近的,丢最远的"——因为最近的消息通常和当前工作最相关。
但 TencentDB Agent Memory 的recall()不是这样工作的。recall()按语义相似度取top_k个 chunk,它不关心消息新旧。一个项目早期用户写下的详细需求说明,embedding 和当前任务相似度极高,会被前排召回;而 Codex 刚刚产生的中间决策,因为内容比较短小、语义密度低,反而排在后面。
结果就形成了一个荒诞的配置:Codex 侧把旧消息截断了,记忆服务侧又把同一批旧消息以"高相似度 chunk"的形式塞回上下文。两边不仅没互补,还在打架。上下文窗口很快被旧内容塞满,Codex 的实际可用 token 反而变少了,表现为"越接记忆越蠢"。
我用一个表格对照过两边的行为差异:
| 维度 | Codex 自带上下文管理 | TencentDB Agent Memory recall |
|---|---|---|
| 时间偏好 | 倾向于保留最近消息 | 只按语义打分,不看时间 |
| 容量策略 | 超出预算就截断/压缩 | top-k 固定取 N 条 |
| 冲突代价 | 旧内容被丢弃 | 旧内容被重新注入 |
| 最终影响 | 上下文被召回内容挤占 | Codex 忽略关键近期指令 |
recall()的实现里其实支持filters参数,可以按created_at做时间过滤。但codex_adapter.py里根本没有传递这个 filter,默认裸调recall()。这算是一个典型的"接口提供了能力,但没有接入"的适配层缺失。
4.3 如何用日志和 SQL 复现这个冲突
复现这个冲突不需要复杂的链路。我当时做了三件事:
第一,打开vector_index的 DEBUG 日志,观察队列积压时间。如果enqueue到write index的间隔持续超过 2 秒,就说明异步滞后已经存在。
第二,在recall()调用点打印返回结果的created_at分布。如果你发现召回结果里最早的 chunk 比 Codex 当前会话开始时间还早很多,而最近的几条消息一条都没召回,那基本就坐实了。
第三,直接查表对比:
SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM message_records WHERE task_id = '当前任务'; SELECT COUNT(*) FROM message_records WHERE task_id = '当前任务' AND id IN (SELECT message_id FROM vector_index WHERE status = 'INDEXED');第一行和第二行数据差得越多,说明索引越滞后。我在实测环境里见过INDEXED数量只有消息总量一半的情况。
这里还牵扯出一个细节:Codex 的 token 预算不是一个固定值,它随模型上下文长度变化。记忆服务如果不感知这个预算,top_k取太少则记忆没意义,取太多则挤占推理空间。最合理的做法是把top_k改成动态的,和当前剩余上下文成正比。这个我们后面改造时加了进去。
5. 硬冲突之三:线性版本号撞上 checkpoint 分支回滚
5.1 记忆版本号的设计意图
第三处硬冲突,也是我认为最严重的一处,出在版本管理上。
version_log的设计是线性版本:每次 commit 分配一个新的version_seq,parent_version_seq指向上一次 commit。回滚操作就是把当前指针指到某个历史version_seq,并把中间的版本标记为ROLLED_BACK。
这层设计的意图是好的:所有记忆有迹可循,出问题能回到任意历史点。它假设的模型是"一条时间线、顺序推进、偶尔回退"。
5.2 Codex checkpoint 的分支语义
但 Codex 的 checkpoint 是分支式的。用户可以在任意时刻创建一个 checkpoint,跑一段试验代码,发现不行,再回到 checkpoint 重新来。重新来之后,可能走一条完全不同的路线。
用版本控制的语言说,Codex 的工作流天然会产生一个 DAG(有向无环图),而不是一条线:
checkpoint A ├── 分支 1: 修改 security.py → 验证失败 └── 分支 2: 修改 auth.py → 验证通过当分支 2 产生新的记忆时,它的"父版本"应该指向 A,而不是指向分支 1 的最后一个 commit。但version_log表里没有branch_id,只有线性递增的version_seq。于是冲突就发生了。
5.3 从覆盖写事故到 DAG 改造
我们实际遇到了两种故障。
第一种是"版本号冲突":Codex 从 checkpoint A 回滚后开始新分支,新分支的消息写入时,version_seq仍然按全局自增分配,这没问题。但parent_version_seq指向哪里出了问题——如果指向回滚后的 A 的快照,那么 A 的旧分支记录和新分支记录共享同一个父节点,从时间线上看两条分支交织在一起,后续recall()按version_seq排序时,旧分支的消息会和新分支的消息交错出现,语义混乱。
第二种是"静默覆盖":某些会话级缓存直接把回滚前的分支消息从缓存里删掉了,如果此时保存了新分支的关联记录,旧分支的引用就成了悬空。读出来之后会出现"Codex 明明回滚了,但记忆里还残留着旧分支的执行结论"。
我在version_log的代码里找到了一个防范措施:
def rollback_to(version_seq): mark_range_rolled_back(version_seq + 1, current_version_seq)它只是把[version_seq + 1, current]区间标记成ROLLED_BACK,并没有引入分支维度。这个区间模型在单线程线性工作流里足够用,但在 Codex 这种随时产生 fork 的工作流里,区间标记根本无法表达"两个分支并行存在"。到最后,ROLLED_BACK的记录还是会被检索到,因为它们只是状态变了,语义内容还留在message_records里。
这次的教训是:版本管理如果做成"区间回滚",就只能支持一条时间线;一旦面对多分支的 Agent 工作流,必须升级为 DAG 模型,让每条记录都带着branch_id和parent_branch_id。
6. 基于源码冲突做的三层适配改造
6.1 会话适配层:把 Agent Loop 归一进 Task 框架
第一个冲突的解法,我们没有去改 TencentDB Agent Memory 的 Task 状态机(那属于核心存储层,改起来影响面太大),而是在codex_adapter.py里做了一个会话适配层。
适配层做的事情很简单:把 Codex 的 Agent Loop 显式地映射成 Task 边界。映射规则不是按"用户消息"切,也不是按固定时间间隔切,而是按里程碑切:
- 每次工具调用执行完成,生成一个
milestone; - 用户明确表达"继续""这样不行""改个方向",生成一个
milestone; - checkpoint 创建或回滚,生成一个
milestone。
每个milestone落地为一个"微型 Task",它的生命周期由适配层管理。适配层不会在 tool 消息后尝试 commit 任务,而是等下一个里程碑出现再关闭当前 Task。
def handle_milestone(session_state, milestone_type, payload): if current_task_has_messages(): if milestone_type in ("tool_done", "checkpoint", "user_redirect"): commit_task(current_task_id) current_task_id = new_task_id() append_message(current_task_id, role="assistant", payload=payload)这个改动的核心价值是:让存储层看到的依然是规范的 Task 序列,但 Task 的边界真正对齐了 Codex 的语义,而不是靠猜测。
6.2 短程缓存:先解决 read-your-writes
针对第二个冲突里的"写后不读",我们加了一层短程缓存,放在recall()前面。
具体做法是:每条消息 append 之后,除了进异步索引队列,还同时写入一个 Redis 短程缓存,key 是session_id,value 是最近 30 条消息内容,TTL 十分钟。recall()的路径改成了两级:
def recall(query, top_k): fresh = short_term_cache.get(session_id) if fresh: local_hits = rank_local(query, fresh) # 本地小范围语义匹配 if local_hits and len(local_hits) >= top_k: return local_hits return vector_index.recall(query, top_k)效果很直接:Codex 刚说过的话,90% 以上的场景能直接从短程缓存命中,不用等异步索引。短程缓存不命中时再走向量召回。
与此同时,我们把recall()的top_k改成和 Codex 剩余 token 预算联动:
def calculate_top_k(codex_remaining_tokens): return max(3, min(15, codex_remaining_tokens // 3000))token 越紧张,召回数量越少,避免旧 chunk 挤占解码空间。
至于"top-k 召回选中旧内容"的问题,我们在召回时加了时间权重因子:score = similarity * time_decay,相似度差不多的前提下,新消息优先。这个权重不改变向量索引本身,只影响排序,改动可控。
6.3 让版本号表达分支而不是覆盖
第三个冲突的改造最重,我们动了version_log的表结构。改动思路很朴素:从"线性区间"升级为"分支 DAG"。
新表结构增加了两个字段:
ALTER TABLE version_log ADD COLUMN branch_id VARCHAR(64) NOT NULL DEFAULT 'main', ADD COLUMN parent_branch_id VARCHAR(64) NULL, ADD COLUMN parent_version_seq BIGINT NULL;核心逻辑变成:
version_seq依然全局自增,但不再表示"时间线上的顺序",只表示"写入顺序";- 每次 checkpoint 回滚后创建新分支时,
branch_id重新生成; - 回滚时不再把
[version_seq+1, current]整段标记ROLLED_BACK,而是把旧分支整体归档,新分支的parent_branch_id指向回滚点的分支; recall()默认只检索当前分支链路上的记忆,除非显式传入branch_id要求跨分支检索。
def rollback_to(branch_id, version_seq): archive_branch(branch_id, reason="checkpoint_rollback") new_branch_id = f"{branch_id}-fork-{uuid4()[:8]}" create_branch(new_branch_id, parent_branch_id=branch_id, parent_version_seq=version_seq) return new_branch_id这个改动带来的收益是:旧分支记忆不会干扰新分支决策,但也保留在库里,随时可以做审计或回溯。Codex 的 checkpoint 再频繁,记忆层也能保持语义干净。
7. 读源码排冲突时的三个实操心得
如果你也要把 Codex 或其他 Agent 接进记忆服务,我最后分享三个排错心得。
第一,先复现,再读源码。我一开始是按目录结构从头读到尾,效率很低。后来改成"先写一个失败场景的测试,再顺着报错信息逆查源码",几乎每条调用链都是被迫读完的,记忆反而更牢。比如版本号冲突的问题,就是先撞上Duplicate entry错误,再倒查version_log的写入逻辑,很快就定位到了表结构缺陷。
第二,看适配层的git log比看代码本身更有价值。codex_adapter.py里有一堆注释掉的逻辑,比如按时间间隔切 Task、按轮次切 Task、强制提交未完成 Task。每段注释背后都是一次失败的尝试。读懂这些历史,能少走很多弯路。
第三,别把冲突全打成补丁。我一开始想着尽量不动 TencentDB Agent Memory 的源码,全在适配层绕开,结果绕到后面发现版本 DAG 这层根本绕不过去。后来果断改了version_log的表结构,一切才顺起来。记忆服务的"任务模型"和 Agent 工作流的语义偏差,有时候必须改到底层,否则只是一层补丁摞一层补丁。
这次梳理做完之后,我的体会是:所谓"接记忆"的难点,从来不在 API 调不通,而在两个系统对"一次任务""一条记忆""一个版本"的定义是否兼容。把这些定义对齐,剩下的都是体力活。