ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Codex 接入 TencentDB 做 Agent Memory:三大硬冲突与落地实践

Codex 接入 TencentDB 做 Agent Memory:三大硬冲突与落地实践

最近在给团队的 Agent 系统搭统一记忆层,后端存储直接选了 TencentDB,前端又想让 OpenAI Codex 这种编码 Agent 接进来共享同一份记忆。本来以为无非是改个连接串、换几张表,结果读了几晚源码之后我彻底改观:Codex 默认的存储架构跟 TencentDB 这种分布式云数据库之间,存在好几个硬冲突,不是靠调参能绕过去的。这篇文章把我梳理过的架构逻辑、源码路径和最终落地方式都写出来,给正在做 Agent Memory、或者想把 Codex 接入云数据库做记忆持久化的朋友一个参考。

1. 先厘清概念:TencentDB、Agent Memory 与 Codex 在架构里各自扮演什么

1.1 TencentDB 不是“数据库”,而是你的记忆层后端

很多人一听到 TencentDB 就下意识把它等同于“一个 MySQL”,其实这恰恰是后面所有问题的根源。TencentDB 是腾讯云上的托管数据库产品,底层兼容 MySQL 协议,但它本质上是分布式架构,数据在物理存储、网络传输、事务处理上都跟本地 SQLite 完全两码事。

举个例子:本地 SQLite 像一个放在你手边的抽屉,随手拉开就能拿东西,整个过程几毫秒。TencentDB 像一个银行金库,你有钥匙也得走到柜台、排队、出示凭证,办完一笔业务再走回来。把原本“抽屉式”的存储直接换成“金库式”的存储,原来的操作习惯全部要重来。

在 Agent Memory 的架构里,TencentDB 承担的是持久化记忆层的角色,它要把多轮对话、Agent 的工作记忆、用户长期偏好、甚至 Embedding 向量都存进去。它的价值在于:多实例共享、跨会话持久、可扩展、可备份。但这个价值不是白给的,它要求客户端具备连接池管理、事务边界控制、延迟容忍、冲突处理能力。而 Codex 的源码假设的是本地文件存储,完全没有这些能力。

1.2 Agent Memory 拆解:短期工作记忆与长期事实记忆

Agent Memory 不能笼统地理解成“给 LLM 存聊天记录”。按我的拆分方式,它至少分两层:

  • 短期工作记忆(Working Memory):当前任务上下文、正在编辑的代码片段、上一步推理结果、用户刚给的指令。这部分变更频繁,写入量大,但对一致性要求不高,丢了可以重来。
  • 长期事实记忆(Long-term Memory):用户偏好、项目架构决策、历史已解决问题、团队约定。这部分变化少,但必须持久、可靠、可检索,还要支持多 Agent 共享。

Codex 作为一个编码 Agent,它自身携带的是短期上下文窗口里的工作记忆;而我们要接入 TencentDB 的 Agent Memory,核心目标是让 Codex 能读取和写入长期记忆,同时把短期工作记忆也变成可恢复、可共享的。听着很美好,但源码跑起来就发现两边对“记忆怎么读写”的理解完全不同。

1.3 Codex 接入时的角色边界

Codex 在架构里是“记忆消费者”,TencentDB 是“记忆生产者/存储方”,两者之间需要一层适配。但这层适配不是简单的 SQL 语句映射,而是要把 Codex 的存储假设翻译成 TencentDB 能接受的模式。Codex 是单机单进程程序,它天然假设自己是存储的唯一用户,文件就在本地,不存在网络延迟,不存在并发冲突。当它必须面对一个远端、多用户、分布式存储时,源码里那些“隐藏假设”就开始连环爆雷。

2. 源码层面的冲突点一:Codex 的本地存储模型与远程数据库的“惯性冲突”

2.1 Codex 默认的 SqliteStore 读写路径分析

我手上读的这份 Codex 源码(基于当前开源版本的大致结构)里,存储层抽象了一个Storetrait,核心方法大概就是load_session、append_message、list_sessions。默认实现是SqliteStore,底层用的是rusqlite,所有数据库操作都是同步 API。

关键路径简化之后长这样:

fn append_message(&self, session_id: &str, msg: &Message) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO messages (session_id, role, content, ts) VALUES (?1, ?2, ?3, ?4)", params![session_id, msg.role, msg.content, msg.timestamp], )?; Ok(()) }

这段代码有两个特点:第一,每次写入都要先拿Mutex<Connection>锁;第二,写完立即提交,没有显式事务。这在本地 SQLite 上完全没问题,因为本地文件访问本身就很快,锁竞争也低,大概率只有 Codex 这个进程在读写。

但放到 TencentDB 上,问题立刻浮现。append_message每次调用都要走一次“建立连接或从连接池拿连接 → 网络传输 → 服务端执行 SQL → 返回结果 → 释放连接”的完整链路,单次延迟从原来的 1 毫秒飙到几十甚至几百毫秒。而 Codex 的核心循环是异步单线程的,一旦在关键路径里插入同步 DB 操作,整个 Agent 的执行就会被卡住,表现就是“每说一句话要卡一下”,甚至直接超时。

2.2 为什么直接把 SQLite 换成 MySQL 会出大问题

很多人的第一反应是“写一个 MySQLStore,把 SQL 换成 MySQL 语法不就行了”。但真正实施后发现,不是语法不兼容,而是调用模式不兼容。

本地 SQLite 模式下,Codex 可以用“高频小写入”的方式记录每条消息,因为每次写就是一次本地文件 I/O,成本几乎可以忽略。但远程数据库模式下,一次普通 INSERT 的代价包含了网络往返和服务端日志刷盘。如果你每生成一条消息就往 TencentDB 插一行,那等于让 Agent 在每次生成过程中都背上几百毫秒的同步开销。

更难受的是超时问题。TencentDB 的默认连接超时、Socket 超时都是秒级,但 Codex 内部可能有自己的执行期限。如果数据库端慢查询、锁等待、网络抖动叠加在一起,Codex 很容易抛出数据库超时异常,进而中断整个会话。这不是“增大超时时间”能解决的,因为根因是“不该在热路径里做远程 I/O”。

2.3 关键差异:同步、事务、连接复用

我用一张表把核心差异列出来,方便你对照自查:

维度本地 SQLite 假设TencentDB 现实
连接管理单连接常驻,无连接池必须连接池复用,否则握手开销巨大
延迟毫秒级甚至更低通常 10ms~100ms,不稳定
并发模型天然串行,一把锁管到底需要多连接并发,事务隔离
事务开销隐式事务即可显式事务更好,但要控制时长
连接中断基本不会网络闪断、服务端重启都可能
数据量单机文件,千万行没问题分布式存储,需要分库分表/分区设计

所以硬冲突背后的本质是:Codex 的存储代码是为“本地、低延迟、串行”场景写的,TencentDB 要求的是“远程、可控延迟、并发安全”的使用方式。这两者之间的适配,必须放在一个中间层去做,而不是指望改两行 SQL 就能糊弄过去。

3. 冲突点二:会话恢复时全量读历史,TencentDB 扛不住

3.1 Codex 的会话恢复机制:全量读而不是按需加载

Codex 每次恢复一个会话时,会调用类似load_history的方法,把我的源码简化后是这样的:

pub fn load_history(&self, session_id: &str) -> Result<Vec<Message>> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT role, content FROM messages WHERE session_id = ?1 ORDER BY ts" )?; // 把所有行收集到 Vec<Message> 返回 }

注意这里的ORDER BY ts是全表扫描后排序,然后一次性全部加载到内存。本地 SQLite 这样做的原因是:文件就在本地,一次读取几千条消息可能只需要几次磁盘 IO,非常快。而且 Codex 的系统提示、工具定义这些内容本身也就在内存里,全量加载历史消息是“最省心”的做法。

3.2 全量读与 Agent Memory 分页/切片设计的矛盾

Agent Memory 的常规设计完全不是这个思路。当记忆作为长期存储存在时,一般会有三条策略:

  • 只加载最近 N 轮对话作为“近期上下文”,更早的对话用摘要替代;
  • 长期事实记忆先做 Embedding,存入向量索引,需要时用 RAG 召回 TopK;
  • 对记忆做标签/分区,不同项目或任务只加载相关子集。

这套思路可以在一个 Agent Memory 系统里跑得很好,但 Codex 的load_history没有这种概念。它天然认为“一个 session 的所有消息都是我需要的上下文”,于是在记忆库变成长期共享库之后,一个会话里堆积了几千条历史消息时,Codex 每次启动都会全部拉出来,塞进上下文窗口。

这就形成了直接冲突:Agent Memory 想让你按需加载,Codex 源码非要全量加载。

3.3 实测表现:几轮对话后 Token 超过上下文窗口

我拿一个真实场景测了一下。一个编码 Agent 会话跑了十轮,中间涉及文件修改、报错分析、版本回滚,历史消息全部加起来大概五万 Token。如果这时业务方想把这个会话复制到另一个实例继续干活,Codex 会尝试把五万 Token 历史全部加载,再叠加上系统提示和工具定义,直接冲爆上下文窗口上限。

更麻烦的是,TencentDB 里如果存了多个 Agent 共享的长期事实记忆,比如团队架构决策、API 约定,这些东西不会自动“按需注入”,而是需要查询接口。但 Codex 的load_history只能返回一个平铺的 message 列表,根本没有地方去区分“短期对话”和“长期记忆”。想让它正确读取,你得先改源码,或者在接入层做记忆截断,把历史消息做摘要后只保留最近几轮。

4. 冲突点三:记忆写入的粗粒度与分布式事务不匹配

4.1 Codex 如何写入记忆:一次写入一个完整消息

Codex 的记忆写入模式是单条 append,每次把一整条Message(role、content、timestamp)插入数据库。这个粒度在本地 SQLite 上很合理:一次会话可能产生几十条消息,每条消息都是独立记录,顺序写入即可。

但到了 TencentDB,这种“一次一插、插完就走”的模式会产生大量网络往返。假设一次会话产生 100 条消息,那就是 100 次远端 INSERT。即使每次 20ms,也有 2 秒的纯 I/O 延迟,而且这 2 秒还是分散在 Agent 执行流程里的,用户体感就是“每一步都慢半拍”。

更关键的是,Codex 本身不维护批量写入接口。你没法让它攒够 10 条消息再统一提交,因为每次生成完一条消息,它都要立刻把消息写入存储,以便异常退出后能恢复。

4.2 多实例并发时的乐观锁与版本号冲突

如果只有一个 Codex 实例,单条写入没问题。但 Agent Memory 场景下,多个实例很可能同时操作同一个 session。比如你在笔记本上开了一个 Codex 会话,下午又想在另一台电脑上继续同一个任务,两个实例同时在线时,它们都在往同一个 session 写消息。

最简单的并发控制是加版本号:

UPDATE sessions SET version = version + 1 WHERE id = ? AND version = ?;

两个实例同时读取 version=5,然后各自尝试把 version 改成 6。这时第一个 UPDATE 成功,第二个 UPDATE 影响行数为 0,冲突出现。但 Codex 源码里没有处理这种冲突的逻辑,它不会重试,也不会合并,只会在下一轮写入时把对方的新消息覆盖掉。

这就好比两个人同时编辑同一个文件,最后保存的那个人把对方的修改全部吞了。对 Coding Agent 来说,丢了上下文比报错更致命,因为模型不会知道你忘了什么,只会一本正经地按残缺记忆继续干活。

4.3 TencentDB 行锁、锁等待与死锁

如果你天真地在 TencentDB 上给这个场景加事务,比如先SELECT ... FOR UPDATE锁住 session 行,再执行消息插入,又会引入新的问题:事务内做网络请求、长事务持锁、锁等待超时。

常见死锁长这样:

  • 事务 A 先更新 session 元数据,再插入 message;
  • 事务 B 先插入 message,再更新 session 元数据;
  • 两边互相等待对方已持有的锁,于是死锁,数据库回滚其中一个事务。

在本地 SQLite 里,锁粒度细、事务时间短,死锁概率极低。但在远程数据库里,事务执行过程中涉及网络延迟,锁持有时间变长,死锁概率成倍上升。你还会经常看到 TencentDB 的监控里出现Lock wait timeout exceeded,这时候第一反应不应该是加锁超时时间,而是要重新设计事务边界。

5. 绕不开的硬冲突:上下文窗口与外部记忆动态加载

5.1 Codex 的上下文构造源码入口

Codex 每次发起模型请求时,会构造一个 messages 列表,把 system prompt、工具定义、历史消息、用户最新输入依次放进去,然后交给模型。源码层面的关键点在于:它在构建列表时有一个长度或 token 上限判断,一旦超过就直接截断或报错。

这个“历史消息列表”完全来自本地存储的load_history结果。也就是说,Codex 的上下文窗口是“静态”的,它只关心自己加载了哪些消息,不会动态去 TencentDB 里检索相关记忆。外部记忆如果没被塞进这个列表,那模型就完全看不到。

5.2 外部记忆注入的两种姿势(prompt 拼装 vs 工具调用)

想让 Codex 读取 TencentDB 里的长期记忆,通常只有两条路:

姿势一:prompt 拼装。在构建上下文之前,先通过外部逻辑从 Agent Memory 里召回 TopK 相关记忆,拼进 system prompt。好处是实现简单、模型一定能看到;坏处是每一次请求都会带上一大段记忆文本,Token 消耗暴涨,而且过期记忆还可能带偏推理。时间一长,上下文窗口仍然会被塞满,只是塞的内容从对话历史变成了记忆片段。

姿势二:工具调用。给 Codex 注册一个search_memory工具,让它自己决定何时去 TencentDB 检索记忆。好处是按需加载、Token 可控;坏处是模型不是每次都调用,而且工具返回延迟不可控,经常出现“模型以为自己在搜记忆,其实只用了猜的结果”这种尴尬场景。

我在项目里两种都试过,发现真正的困难在于:Codex 的上下文构造机制对“外部记忆”没有一等公民的支持。它预设的记忆就是“对话历史”,不是“检索到的知识”,所以你无论用哪种姿势,都是在跟它的设计理念做对抗。

5.3 硬冲突的本质:Codex 假设记忆在本地,TencentDB 假设记忆在服务端

走到这一步,我渐渐明白标题里说的“硬冲突”到底是什么意思了。

Codex 假设记忆是本地、私有、顺序追加的,像一本只给自己看的工作日记;TencentDB 作为 Agent Memory 的载体,假设记忆是远程、共享、随机检索的,像一个团队共用的图书馆。当你想让 Codex 直接“接”TencentDB 的记忆时,本质上是在让一个“日记本用户”去用“图书馆目录卡”,两边都对不上。

这也是为什么网上很多教程只会教你“改数据库连接配置”却总失败——因为他们没意识到,这不是存储层替换,而是记忆模型的迁移。搞不定这一层认知,后面所有代码写出来都是补丁摞补丁。

6. 冲突化解与最终落地实践

6.1 方案 A:本地缓存 + 异步同步(推荐)

如果团队对现有 Codex 流程改动越少越好,我强烈建议保留它的本地 SqliteStore,在它后面加一层异步同步器。

结构是这样:Codex 仍然写本地 SQLite,读写都走原来的代码路径,完全无感;单独的同步任务定期把本地新增消息批量写入 TencentDB,同时从 TencentDB 拉取其他实例产生的变更合并回本地。这样既解决了远程延迟阻塞问题,也避免了全量恢复的 Token 爆炸——因为本地 SQLite 仍然是“近水楼台先得月”。

同步任务的伪代码大概是:

for { rows := localStore.GetUnsynced() if len(rows) > 0 { batch := buildBatch(rows) tencentDB.InsertBatch(batch) localStore.MarkSynced(rows.IDs()) } remote := tencentDB.GetRemoteChanges(lastSyncVersion) localStore.ApplyRemoteChanges(remote) time.Sleep(5 * time.Second) }

这套方案解决了一大半硬冲突:Codex 主流程零改动,远程 I/O 被搬出热路径,写入变成批量提交,多实例冲突靠“最后写入合并”勉强兜底。缺点是实时性差,两台实例之间的同步延迟可能达到几秒甚至更长,不适合强一致场景。

6.2 方案 B:基于 Memory Bridge 做双写与版本收敛

如果业务方要求多个 Codex 实例实时共享同一份记忆,那就要在 Codex 与 TencentDB 之间加一个独立的 Memory Bridge 服务。这个服务对外提供 gRPC 接口,对内统一管理连接池、事务边界、版本冲突和按需加载。

Codex 侧只改很少的代码,实现一个自定义Storetrait:

struct BridgeStore { client: BridgeClient, } impl Store for BridgeStore { fn load_history(&self, session_id: &str) -> Result<Vec<Message>> { // 按窗口加载,不拉全量 self.client.load_window(session_id, 50) } }

Bridge 内部要做几件事:

  • 所有读写走连接池,不再每次建连;
  • 事务边界统一到 Bridge 层,不在 Codex 侧开远程事务;
  • 记忆写入变成批量提交,超过阈值才 flush;
  • 版本冲突时采用 last-write-wins 加日志告警,至少能追踪;
  • 上下文加载改成“最近 N 轮 + 长期记忆摘要 + 向量召回 TopK”的组合。

这个方案本质上是在 TencentDB 远端和 Codex 本地之间,插入了一个“翻译官”。它解决了前面所有的硬冲突,但要额外维护一个中间服务,对团队能力有要求。

6.3 实测避坑清单

最后我把踩过的坑整理成清单,每一条都是真金白银换来的:

  • 不要在 Codex 主循环里直接做同步 DB 调用。远程存储的延迟是不可控的,你唯一能控制的是调用模式。
  • 连接池大小要克制。曾经我为了压测把连接池开到 200,直接把 TencentDB 的 max_connections 打满,连带影响其他业务。建议连接池峰值不超过数据库限额的 70%。
  • 事务内不要放网络请求。这在远端数据库是自杀行为,锁会被无限拉长,最后一定死在锁等待上。
  • 如果短期改不了源码,就做历史摘要。定期把老对话总结成一段 summary 存起来,清空原始消息,这样全量加载时 Token 可控。
  • 版本冲突别想当然。last-write-wins 只适合低冲突场景,一旦多实例频繁同时工作,必须加冲突日志和人工介入机制。
  • 注意 max_allowed_packet。TencentDB 默认包大小有限制,一旦你往记忆表里塞大段代码或长日志,写入会静默失败,排查半天才发现是包太大被拒了。
  • 监控慢查询和锁等待。TencentDB 控制台有这些指标,出了异常先看指标,别瞎猜代码。

另外,网上有些安全框架比如 a-memguard,确实可以对 LLM Agent 的记忆做输入输出过滤和防护,但它解决的是记忆安全问题,不是存储架构冲突。你可以把这类框架挂在 Memory Bridge 前面做一层防护,但不要指望它能替你解决 Codex 和 TencentDB 之间的架构适配问题。

我在实际项目里最后选了方案 A,保留本地快照,异步同步到 TencentDB,只在需要跨实例恢复时才走远端全量拉取。跑了两周下来,唯一要忍受的就是同步延迟。但比起一开始“直接改连接串”的惨状,这次至少稳定了。

如果你也在做 Agent Memory 架构,我的建议是:动手改代码之前,先把 Codex 源码里存储相关的路径通读一遍,列出它对存储的所有隐含假设,再对照你选型的云数据库特性。别急着写代码,把“两边谁迁就谁”想明白,后面会省很多事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进