做 Agent 开发这段时间,我发现一个特别有意思的现象:大家讨论 TencentDB Agent Memory 的时候,总喜欢直接跳到“怎么存、怎么查”这个层面,但很少有人从源码和架构的角度去拆它。直到我为了给 Codex 接记忆,老老实实把 Agent Memory 的接口行为、数据模型和读写链路翻了一遍,才意识到真正的坑根本不是存储,而是两个系统在记忆组织方式上的硬冲突。这篇文章想做的,就是把这份源码梳理过程完整交代一遍——TencentDB Agent Memory 的模块边界怎么设计的、Codex 这种自带上下文管理的编码代理进来之后会在哪几个点撞上架构层面的冲突,以及最后我们实际用的绕过方案是什么。
适合谁来读呢?两类人:一类是正在做 Agent 基础设施、想把记忆服务接进自己 agent 体系的工程师,另一类是像我一样被 Codex 这类成熟 agent 的“封闭记忆管理”折磨过、想搞清楚它到底能不能跟外部记忆共存的开发者。我会尽量把源码里看到的东西翻译成人话,也会把代码层面查不到、但架构上必然存在的冲突点摊开讲清楚。
1. Agent Memory 到底在解决什么问题
1.1 没有记忆的 Agent 有多难用
先说一个基本事实:大模型本身是没有记忆的。每一次对话、每一个会话对模型来说都是全新的开始,它只能依赖当前请求里携带的上下文信息来做推理。一个没有记忆系统的 Agent,表现得就像一个人每次见面都把你当陌生人重新认识——你昨天让它改的配置、上周定的技术方案、刚才讨论出的结论,它一概不记得。
拿 Codex 举例,它是个典型的编码 agent,会在终端里跟你多轮交互完成编程任务。Codex 自己的记忆方式是维护一个会话窗口,把最近的对话、工具调用结果、文件内容都塞进上下文。但这个窗口是有限度的,超出之后旧信息会被截断或者压缩。更麻烦的是,你一旦关掉终端、重新开启一个新 session,之前那个 session 里发生的所有事情就跟它没关系了。
这就引出了 Agent Memory 存在的核心意义:把 Agent 的短期工作记忆(working memory)从模型上下文窗口里剥离出来,变成一种可持续存储、可检索的外部状态。这样 Agent 可以在新会话里恢复旧记忆,可以在长任务里把关键信息归档,也可以在多个 Agent 之间共享状态。
1.2 Agent Memory 在系统里的位置
从架构视角看,Agent Memory 不是简单地“给数据库包了一层 API”,它承担的是记忆管理层的职责。这跟普通数据库有本质区别:
普通数据库的思路是你给我什么我存什么,查询的时候你给我条件,我把匹配的行返回给你。Agent Memory 的定位更接近“带理解能力的记忆管家”——它需要知道存进来的内容是什么类型的记忆、属于哪个 Agent 的哪个会话、该以什么权重被检索到、什么时候该淡化甚至遗忘。
我梳理下来,TencentDB Agent Memory 在 agent 系统里的位置大致处于模型和应用之间的中间层。应用侧产生行为事件,模型侧消费记忆上下文,Agent Memory 负责在中间完成三个关键动作:
- 写入:接收 agent 运行过程中产生的结构化或半结构化信息,做清洗、向量化、归因
- 检索:在任务需要时,把最相关的记忆条目拼装成模型可以消费的上下文片段
- 生命周期管理:处理记忆的过期、衰减、合并摘要,保持记忆库不过度膨胀
用一个不严谨但好理解的类比:如果说模型上下文窗口是工作台面,那 Agent Memory 就是一个带标签系统的仓库。工作台面上放不下的东西可以归置进仓库,需要的时候再按标签精准取出来放回台面。
2. 读源码先看模块边界:TencentDB Agent Memory 是怎么组织的
2.1 从数据模型看设计意图
我读一个系统源码的习惯是先不看功能实现,直接看数据模型。数据模型往往能反映设计者最深层的思考,因为它定义了系统对问题域的认知边界。
TencentDB Agent Memory 的数据模型给我的第一感觉是:它把“记忆”分成了三个类型维度来管理:
| 类型 | 语义 | 对应 Agent 场景 |
|---|---|---|
| 事件型记忆 | 发生了什么事 | “修改了 billing.py 的 calculate 函数” |
| 实体型记忆 | 涉及哪些人、物、概念 | “项目使用了 FastAPI”、“数据库是 PostgreSQL” |
| 摘要型记忆 | 被压缩后的结论和决策 | “用户偏好全小写命名风格” |
这种三分法跟很多 agent 记忆框架的设计思路是吻合的。事件型记忆解决的是叙事连贯性问题——Agent 需要知道运行过程中发生了什么;实体型记忆解决的是引用准确性问题——Agent 需要知道某些名词、对象、概念之间的关联;摘要型记忆解决的是上下文成本问题——不可能把所有历史都塞进窗口,必须提炼核心结论。
2.2 写入和读取路径的架构分化
继续往下看接口行为时,会发现读写路径的设计思路是完全不同的。
写入路径走的是“管道化”逻辑:Agent 产生的事件或输出先进入一个规范化的中间结构,然后经历内容清洗、向量化、索引归置,最后才落进存储层。这个过程是异步解耦的,Agent 侧不会因为写入慢而被阻塞,但代价是写入后的数据不会立刻对检索可见,存在一个短暂的最终一致窗口。
读取路径走的是“路由化”逻辑:查询请求进来后,系统会做几件事——
- 基于 agent_id 和 session_id 定位记忆范围
- 把 query 做向量化,在对应范围内做相似度检索
- 对召回结果做相关性过滤和去重
- 按权重组装成最终注入给模型的上下文
这种设计本身没有任何问题,问题出在它假设了一套“规范化的记忆接入方式”——Agent 应该通过标准 API 写入结构化事件,再通过标准 API 检索记忆。但 Codex 显然不是这么工作的,这就埋下了冲突的伏笔。
2.3 接口抽象的代价
还有个值得说的点:Agent Memory 对外暴露的接口抽象得越“通用”,接入一个具体 Agent 时就越容易在语义转换层出问题。
我见过不少类似的系统,它们的做法是提供一个通用的记忆存取接口,比如:
# Agent Memory 风格的通用记忆接口 memory.create( agent_id="agent-codex", session_id="session-123", content="修改了 billing.py 的计算逻辑", memory_type="event", metadata={"file": "billing.py"} ) hits = memory.query( agent_id="agent-codex", query="billing 计算逻辑", top_k=5 )接口设计得很干净,但问题是像 Codex 这样的成熟 agent,它的内部记忆模型根本不会以这种“结构化事件”的方式暴露给你。Codex 自己的记忆是一个连续的、线性增长的上下文窗口,里面混杂了用户指令、工具调用、代码 diff、执行结果、系统报错。你想把它接入 Agent Memory,先得解决一个极其麻烦的问题:如何把 Codex 的上下文切分成 Agent Memory 认得的事件条目。
3. Codex 接记忆会遇到的硬冲突
3.1 冲突一:会话粒度的错位
第一个硬冲突来自会话粒度。Codex 的 session 概念是线性的、连续的——一个终端会话从你启动 Codex 开始,到你退出为止,这期间所有消息按时间顺序排列,形成一条不可分割的对话链。
但 Agent Memory 的会话模型是结构化、可跳转的——它允许一个 agent_id 下有多个 session,每个 session 内部有独立的记忆条目,条目之间可以是并行的、独立的事件。这种模型假设 Agent 能明确告诉你“当前这个事件属于哪个 session、哪个时间点”。
当 Codex 接进来时,冲突点在哪呢?Codex 自己管理 session 生命周期,它不会自动把“第 100 条消息”归一次档,也不会主动告诉 Agent Memory“这个 session 结束了,你可以封存了”。我实际测试下来,如果让 Codex 一直跑一个长任务,它内部可能产生上百条消息记录,但这些记录在 Agent Memory 里始终挂在同一个 conversation_id 下面,没有切分、没有归档、没有摘要。时间一长,检索质量会急剧下降,因为所有记忆都被揉在了同一个篮子里。
3.2 冲突二:工具调用历史的回写顺序问题
第二个冲突是有关于工具调用历史的。
Codex 作为一个编码 agent,它的上下文里有大量工具调用记录:读文件、搜索代码、执行 shell 命令、写文件。这些调用是嵌套、有依赖关系的——一个搜索操作的结果可能被用于后续一个编辑操作,而后续的编辑操作又可能触发一次编译报错,报错再触发新的搜索。
这种嵌套的执行链路是 Codex 完成复杂工程任务的关键。当消息被压缩之后,链路可能被简化,但上下文内部的顺序结构还在,Codex 依然能根据引用关系还原执行上下文。
Agent Memory 的问题在于,它的事件型记忆模型是扁平的。每个事件是独立条目,只有简单的关联关系,比如 metadata 里的 key-value,但无法表达“A 调用了 B,B 的结果被 C 引用,C 的失败导致了 D”这种复杂链路。
把 Codex 的工具调用历史写入 Agent Memory,本质上是把一个有向图强行压成了平面列表。写进去容易,但检索回来的时候就乱了——你拿回一堆孤立的事件片段,却拼不出来龙去脉。
3.3 冲突三:压缩策略互相覆盖
这一点我是在反复看上下文长度日志时才意识到的。Codex 和 Agent Memory 其实都有自己的上下文压缩机制,而且这两个机制是互不知晓、互相覆盖的。
Codex 侧,它会在上下文接近窗口上限时,主动把早期消息压缩成摘要 token,释放空间给更重要的新信息。这个压缩是在 Codex 内部完成的,不对外暴露细节。
Agent Memory 侧,如果你配置了记忆摘要策略,它也会对老旧的记忆条目做二次摘要,把更精简的版本存进摘要型记忆。
问题来了:一份对话历史,先被 Codex 压缩了一遍,再被 Agent Memory 压缩一遍,信息损耗是双重的。而且两边做的压缩是独立决策的,压缩的维度不同、粒度不同、时间点也不同,导致两边得出的摘要可能互相矛盾——Codex 认为重要的背景信息,Agent Memory 在压缩时可能认为不重要直接丢掉了。
3.4 冲突四:身份体系双轨制
最后一个硬冲突,是身份体系的映射问题。
Codex 内部有自己的身份体系:message ID、run ID、session ID;TencentDB Agent Memory 也有自己的身份体系:memory ID、conversation ID、agent ID。两边一对接,就要做大量 ID 映射和翻译。
比如 Codex 的上下文里有一条消息,ID 是msg_8f3a2c,内容是一条工具调用的结果:search_results: 42 -> triggered edit on billing.py。这条消息经接入层写入 Agent Memory 时,Agent Memory 给它分配了一个新的 memory ID。看上去没问题,但后续检索返回这条记忆时,Agent Memory 返回的是 memory ID 和它自己的 metadata,跟 Codex 内部的 message ID 完全对不上。如果接入层要把它重新注入回 Codex 的上下文,就得做 ID 反查、关联、去重,否则 Codex 会认为这是一条全新消息,跟之前的对话历史无法串联。
这种 ID 双轨的问题在简单场景下不明显,一旦任务复杂、会话变长、记忆条目变多,ID 映射表维护成本会急剧上升,最终变成一个你不愿意维护、但不维护就会出错的隐性负担。
4. 从报错反推架构断点
4.1 三个亲测踩过的报错场景
理论分析说完,分享几个我实际调试时遇到的报错场景。以前遇到报错就开始搜文档,这次带着架构视角去分析,完全是另一个体验。
场景一:接入之后立刻出现的ConversationNotFoundError。日志显示 Codex 向 Agent Memory 发起记忆写入,但系统返回“conversation not found”。排查下来发现是 Codex 创建新 session 时不会自动同步到 Agent Memory,Agent Memory 里根本没有对应的 conversation 记录。Codex 生成消息后,接入层尝试写入,但由于 conversation 不存在直接失败。
场景二:VersionConflictException。这个问题出现在长时间运行任务中:Codex 多次读取 Agent Memory 的某条记忆,做修改后写回,但因为读取和写入之间的时间差里另有线程更新了记录,触发版本冲突。Codex 内部根本没有这种竞态,因为它的上下文由它进程独占,谁能想到外部记忆服务会有并发问题。
场景三:检索结果不稳定的问题。同一个 session 跑同一个 query,Agent Memory 每次返回的记忆条目排序都不一样。一开始以为是向量检索的随机性,最后定位到是因为 Codex 持续向本已饱和的记忆空间写入大量新条目,导致相关度排序不断被新条目冲挤。
4.2 报错背后的架构层归因
把这些报错往架构层归因之后,脉络非常清晰:
| 报错场景 | 对应架构层 | 根因 |
|---|---|---|
| conversation 不存在 | 身份映射层 | Codex session 生命周期与 Agent Memory conversation 生命周期不同步 |
| 版本冲突 | 存储并发层 | 外部记忆服务的乐观锁机制与 Codex 单线程模型不兼容 |
| 检索结果不稳定 | 写入路径层 | 无节制的批量写入破坏了记忆空间的整体秩序 |
所以我觉得,接记忆碰到的绝大多数问题都不是“接口不会调”,而是两个系统的底层假设不适配。Codex 假设它独占控制上下文的生命周期、读写时序和压缩策略;Agent Memory 假设接入方会遵循规范化的事件流写入节奏、接受分布式系统的最终一致性。
5. 实操参考:三种接入姿势与取舍
5.1 姿势一:旁路只读,把 Agent Memory 当归档库
如果你不想动 Codex 的行为,最简单的方案是把 Agent Memory 当成一个纯归档库用:Codex 正常跑它的,等到一个 session 结束或者任务完成后,接入层把整个会话的关键信息提取出来,写成记忆条目存入 Agent Memory。
这个方案的优点是侵入性极小,不影响 Codex 自身的上下文管理机制,也不会引入竞态或版本冲突。你只要在会话结束后做一次异步写入,把会话摘要、关键文件变更、决策结论存进去即可。
缺点是实时性基本没有。Codex 运行过程中,Agent Memory 不会提供任何记忆支持,它更像是事后日志而非实时工作记忆。如果你期望的记忆是“下一次会话可以恢复前一次的工作状态”,旁路只读模式完全够用;如果你期望的是“当前会话中途就能利用历史记忆来决策”,那这个姿势满足不了。
5.2 姿势二:消息级适配层,转换 Codex 的上下文结构
第二种方案是写一个消息级适配层,在 Codex 产生消息时同步转发一份到 Agent Memory,同时把内部 message ID 与 memory ID 做映射。这个方案的核心工作量在多轮映射维护和一致性保证上。
我在测试中发现,最稳的消息级适配方式是让适配器按“事件边界”来切分记忆条目。Codex 的一轮完整交互包含:用户指令 + 工具调用 + 指令执行结果 + 新的决策方向,这四个要素凑成一个完整的事件单元,写入 Agent Memory 后才具备检索价值。单独写入任何一部分,检索引擎都很难理解整体语义。
比如这样:
def eventize_codex_messages(messages): events = [] pending = [] for msg in messages: pending.append(msg) # 当出现 tool output 并且下一帧是用户指令时,认为一轮交互结束 if msg.role == "tool" and pending_has_user_input(msg): event = { "instruction": pending[0].content, "tool_calls": [m.tool_name for m in pending if m.role == "assistant"], "tool_results": [m.content for m in pending if m.role == "tool"], "conclusion": pending[-1].content } events.append(event) pending = [] return events但我要提醒,这个方案带来的维护成本很高。你要处理 ID 映射、能处理断点续传,还要做好记忆去重,避免 Codex 内部已经处理过的事又被注入一次。如果代码量不打算投太多,请谨慎选这条路。
5.3 姿势三:中间网关,统一管控记忆读写
这是三个方案里最完整、同时也是工作量最大的方案:在 Codex 与 Agent Memory 之间加一层轻量网关,所有记忆读写都经过网关联邦,由网关负责任何格式转换、ID 映射、请求路由。
网关方案解决的痛点在于“一致性”。由于网关是唯一的数据出入口,它就能保证写入时序统一、并发版本可控。而且网关可以更聪明地控制检索时机——比如只允许在 Codex 的 planning 阶段注入记忆、在 tool execution 阶段就禁止注入更新,免得上下文污染。
实践证明,网关方案在应对 Codex 这种相当“自主”的 agent 时比接入层方案稳定得多。Codex 的上下文窗口决策行为确实有些隐蔽,你让接入层去做映射,遇到窗口变化、压缩触发时往往会失控;但网关层能感知到上下文长度的变化,主动调节记忆注入的量。
6. 避坑清单与排查顺序
6.1 接入步骤和排查顺序
我梳理的接入流程概括下来就是一个字:慢。一步一步来,不要并行上量。
很多团队接 Agent Memory 时犯的错误是全面接入:把代码检索、代码修改、终端执行全部接入记忆,写入了大量冗余事件,导致检索噪音高得离谱。
正确顺序应该是:
- 第一步,只写不读:先让 Codex 的所有关键事件写入 Agent Memory,观察写入链路稳定性和数据质量
- 第二步,只读特定类型:启动检索,但只对实体型记忆做召回,验证检索链路
- 第三步,逐步扩大召回类型:加事件型记忆,观察上下文注入后的效果
- 第四步,加入摘要和生命周期策略:让 Agent Memory 自动管理老记忆
排查问题时也是一样的顺序:先查数据流(事件有没有正常写入),再查映射(ID 是否对齐),最后查策略(摘要和 TTL 有没有误伤有效记忆)。我见过太多同事跳过前两步直接调检索参数,浪费了不少时间。
6.2 独家避坑记录
最后分享几个来自我实测项目的避坑心得,这些细节你在文档里基本见不到:
先说比较常见的坑,写事件时 metadata 必须保持极简。凡是跟检索没有直接帮助的字段,一律不要塞进去。检索模型不是越丰富的元数据查得越准,跟语义不相关的属性添加多了,反而会把向量空间搞乱,导致召回的条目排序很飘。
然后是摘要型记忆的分裂问题——这是比较隐蔽的坑。Codex 改代码时经常出现“改完方案 A,发现有问题,推翻改方案 B”的情况。如果你不做干预,Agent Memory 可能会根据时间窗口自动归约出两段摘要:一段记录方案 A 的决策,一段记录方案 B 的决策。检索时关键词一匹配,两段摘要可能同时被召回,而 Codex 根本分不清到底哪个才是当前应该遵循的结论。建议在写入侧加一个动作:当新的摘要覆盖旧结论时,显式把旧摘要标记为过期,而不是让检索模型自行分辨。
时区问题更基础也更需要注意。Codex 日志里的时间戳用的是本地时区,Agent Memory 的 TTL 和版本号用的是 UTC。接入层如果直接透传,会出现“记忆明明刚写完却被 TTL 清掉”的诡异现象。这种排列组合在实际操作中非常容易忽略,一定要在接入层统一时间轴。
你也别完全忽视注入顺序的敏感性。Agent Memory 的检索结果往往跟当前 Codex 的思考状态强相关。实测下来,把旧记忆注入得“太早”会干扰 Codex 对当前问题的判断,比如它会凭着过时印象去做决策;而注入得“太迟”又起不到引导作用。从表现来看,最合适的时机通常是在 Codex 完成当前步骤、即将进入下一步规划的那个缝隙写入,让它像“临时想到一条经验”一样自然。
这些坑我不觉得是 Agent Memory 本身设计不好,更本质的问题是:Codex 和外部记忆服务本质上都在争夺同一份上下文资源的控制权。我做下来的体会是,接入的关键并不在于把 Codex 变成理想的记忆写入者,而是要在接入层补上它不擅长、也不打算处理的“记忆规整”职责。把这件事当成核心工程量来规划,整个接入过程才会变得可控,而不是被源源不断的异常牵着走。