这段时间我在做一件听起来顺理成章、做起来却很折磨人的事:把腾讯云 TencentDB Agent Memory 当作 Codex CLI 的长期记忆后端,让同一个编码 Agent 在不同会话之间记得项目背景、记得上次改过哪几个文件、记得已经踩过的坑。单元测试里记忆写入和向量检索都正常,召回结果也漂亮,可真接到 Codex 上,思维链路跑得通,代码照写,唯独“记忆”像根本不存在。查了两天,把两边关键源码翻完,结论不是配置写错,也不是网络时延,而是架构层面的硬冲突——两边对“记忆”这个词的理解完全不是一个东西。这篇文章把整个梳理过程、源码定位和最终采用的桥接方案完整记下来,给同样想给 Agent 接持久记忆的人一个参考。
1. 先别急着调参:还原一下冲突现场
1.1 我最初设想的集成路径
先说背景。我手上有个长期维护的代码库,用 Codex CLI 在终端里做日常编码辅助。Codex 本身只有会话内上下文,关掉终端它就“失忆”了,所以我想给它加一层长期记忆:把每次会话里产生的关键信息——需求背景、文件结构、踩坑记录、决定用的方案——抽出来,写进 TencentDB Agent Memory。
我最初的架构设想很直白:
- Codex CLI 作为交互入口;
- 通过 MCP 协议暴露一个记忆工具集,供 Codex 在对话中调用;
- 记忆工具内部走 TencentDB Agent Memory 的 SDK,做向量化、写入、检索;
- 每次会话结束后,把本地会话记录汇总、固化回记忆库。
这个方案在当时看起来是最“正统”的:MCP 是 Codex 官方支持的扩展方式,TencentDB Agent Memory 官方也主打“给 Agent 用的一站式记忆存储”。结果一联调就露馅。
1.2 冲突的三种典型表现
我在不同阶段踩到了三种非常典型的现象:
现象一:新会话对历史完全没有感知。我手动把一批记忆写进库,向量检索单独测都能搜到。但只要开一个新的 Codex 会话,问“我们之前是不是定过这个模块的命名规范”,模型一律回答“我没有之前会话的记录”。
现象二:记忆明明被工具搜到了,模型却不会用。我换了思路,让模型在开场时主动调用记忆检索工具。工具确实返回了文本,但模型经常把检索结果当成“工具输出”来看待,不会把它当作背景知识吸收进推理。更麻烦的是,返回内容一旦长了,回答风格还会被带偏,好像突然换了个说话习惯。
现象三:自动注入记忆后,上下文直接被挤爆。我试过在启动脚本里预取记忆、强行拼进 prompt,结果长会话里记忆块占了几千 token,本来够用的上下文窗口变得捉襟见肘,代码文件反而放不进去了。
这三件事指向同一个结论:不是我不会调参数,而是这两个系统的数据模型和运行机制不匹配。
1.3 为什么我愿意称之为“硬冲突”
“硬冲突”这个说法,是我在翻源码之后才敢用的。它区别于“软冲突”——软冲突调调参数、换个 prompt 模板就能解决;硬冲突是接口契约层面的分歧,除非改一端的实现,否则绕不过去。
具体来说,TencentDB Agent Memory 眼里的“记忆”是一个个可独立度量的条目:有内容、有重要性、有访问时间、有向量表示,可以被精确检索。而 Codex 眼里的“记忆”是线性消息流:一条条消息按顺序排列,靠模型窗口硬记。这两套模型之间没有天然的翻译层,这才是所有问题的根源。
2. TencentDB Agent Memory 的架构拆解
2.1 存储模型:一行记忆里到底塞了什么
先把 TencentDB Agent Memory 这一侧拆清楚。它本质是一个“为 Agent 设计的数据库”,底层兼顾了结构化数据和向量数据。以我手头接触到的 SDK 为例,一条记忆记录的核心字段大概是这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| memory_id | 字符串 | 记忆唯一 ID,写入时自动生成 |
| agent_id | 字符串 | 属于哪个 Agent,租户级隔离 |
| session_id | 字符串 | 来源会话 ID,可空 |
| content | 文本 | 记忆正文,模型直接可读 |
| content_type | 枚举 | fact / decision / error / preference 等 |
| embedding | 向量 | 文本向量,用于相似度检索 |
| importance | 浮点 | 重要性评分,比如 0.0~1.0 |
| access_count | 整数 | 被检索次数,用于温热管理 |
| created_at / last_access_at | 时间 | 记录时间与最后访问时间 |
| metadata | JSONB | 自定义扩展字段,按需过滤 |
这个设计有一个很关键的点:记忆是“扁平条目”,不是“对话消息”。它不关心这条记忆来自哪一轮对话的哪一条消息,只关心它本身的内容、权重和可检索性。这是一个面向“查找”的数据模型,而不是面向“重放”的数据模型。
2.2 写入链路:谁在负责把对话变成记忆
实际写入流程比我预想的更复杂。调用方给出一条文本,SDK 内部大致做了四件事:
- 对文本做向量化,生成 embedding;
- 做近似去重,计算新文本与已有记忆的余弦相似度,超过阈值(比如 0.92)就认为是重复;
- 把向量、元数据、正文组装成一个事务写入数据库;
- 返回新的 memory_id。
这套链路在 LangChain、LlamaIndex 这类框架里通常是以 Memory 抽象类或 Retriever 接口提供的。你只要调add()或save_context(),框架层面会处理向量化和去重。
但这里有个容易被忽略的隐含假设:写入之前,必须有人负责“提炼”。数据库只做存储和检索,它不会自动把一段几千字的对话总结成“项目决定使用 X 方案”这样一句话。这个“提炼”动作,要么由 orchestration 层的 LLM 做,要么由调用方手动做。在传统 LangChain 应用里,RunnableWithMessageHistory 这类组件会替你完成提炼;但在 Codex 这种模型主动驱动的 Agent 里,没有人替你做这件事。
2.3 检索链路:数据库是怎么把记忆捞出来的
检索链路才是 TencentDB Agent Memory 的核心价值所在。一次查询大概走这几步:
- 用户拿到一个自然语言查询;
- 查询文本向量化;
- 在向量索引上找 top-K 相近的候选(比如 K=20);
- 用 metadata 过滤掉不符合条件的条目,比如限定 agent_id、时间范围、importance 下限;
- 对剩余结果按相似度和其他规则排序,返回最终 top-N。
这一步会暴露另一个隐含假设:调用方是“主动查询”的。检索是被动触发的,必须有一个外部逻辑决定“现在该查记忆了”,然后把查询词准备好。这个假定在 LangChain 里成立,因为编排器控制每一步;但放到 Codex 里,这个编排器变成了模型自己,它想不想查记忆、什么时候查,完全不受你控制。
2.4 它天生适合当“记忆”的几个设计点
TencentDB Agent Memory 能被官方定位成 Agent 专用存储,是有道理的:
- 混合存储:向量和元数据在一套库里,不用像老方案那样“向量库管语义、关系库管元数据”搞两套系统同步;
- 托管运维:扩缩容、备份、监控都不用自己管;
- TTL 和重要性:支持按时间衰减、按重要性过滤,避免记忆无限膨胀;
- 租户隔离:不同项目、不同 Agent 可以完全隔离。
这些能力单独拿出来都是好用且必要的。问题在于,这些设计面向的是“应用方自己编排记忆流程”的场景,而 Codex 不是这种场景。
3. Codex 的会话模型与记忆扩展点
3.1 Codex 眼里的“记忆”是什么
很多人以为 Codex 有记忆能力,其实它的“记忆”来自三个地方:
- 项目上下文文件:主要是 AGENTS.md,由用户在项目里显式书写,属于静态文本;
- 本地会话记录:每次会话存成一个 JSONL 文件,记录全部消息和工具调用,但只是在本地躺着,默认不参与新会话;
- 上下文窗口:模型当前能看到的所有内容,包括系统提示、历史消息、工具结果。
换句话说,Codex 的“记忆”是瞬态的、线性的,依靠上下文窗口硬存。它没有“长期记忆”的概念,也不存在一个对外暴露记忆增删改查的接口。如果你希望它“记得”上上次的会话内容,唯一原生手段是手动把历史录进新的提示词,或者把关键信息写进 AGENTS.md。
这就是第一块拼图:Codex 的设计哲学里,记忆是上下文工程的一部分,而不是存储架构的一部分。
3.2 从 CLI 启动到单轮请求的完整链路
为了定位冲突,我把 Codex CLI 的主链路读了一遍。简化后的执行流程是:
- 读取配置,包括模型参数、MCP 服务器配置、工作区信息;
- 创建或恢复会话,会话内容从本地 JSONL 读入;
- 进入 turn loop(对话轮次循环);
- 每一轮:构造请求上下文 → 调用模型 → 如果模型要求调用工具,就执行工具并把结果追加进上下文 → 重复;
- 用户输入结束或达到终止条件后,退出循环,把完整会话写入本地 JSONL。
关键在第 4 步的“构造请求上下文”。Codex 在这里做的事情是:把 AGENTS.md 内容、系统提示、历史消息、最近工具结果按顺序拼成一个请求体,送给 Responses API。整个过程是同步、一次性的。它没有一个“在构造上下文之前执行外部代码”的钩子。
我用伪代码表示一下这个 turn loop:
def run_turn(user_input): context = build_context( system_prompt=load_system_prompt(), project_docs=load_project_docs(), # AGENTS.md 等 transcript=load_current_transcript(), ) response = call_responses_api(context) while response.has_tool_calls(): for call in response.tool_calls: result = execute_tool(call) # MCP 工具在这里才执行 response = call_responses_api(context + result) return response注意这个顺序:工具调用发生在模型“决定调用”之后,而不是上下文构造之前。这对记忆是致命的。
3.3 Codex 官方留下的三条扩展路径
Codex 不是完全不能扩展,它给了三条路:
- AGENTS.md:写死在项目里的说明文件,模型每轮都会读到。它是注入静态知识的合法渠道,但只能放静态内容,无法动态查库;
- MCP 工具:通过
codex mcp add注册外部工具,模型在对话中决定是否调用。这是动态能力接入的官方通道,但受制于“模型必须主动调用”这个前提; - SDK 定制:用官方提供 Codex 的 Python/TypeScript 包自己封装交互流程,在调用底层接口前做任何想做的事。这是自由度最高的路径,但需要放弃现成的 CLI 交互体验。
这三条路我都试了一遍,最后确认:只靠其中任何一条,都接不好持久记忆。真正的解法是把多条路径组合起来,再在外部补一个“会话结束沉淀”的环节。这个后面细说。
4. 读源码之后,我定位到的四个硬冲突
4.1 冲突一:记忆是“片段”,Codex 要的是“消息”
这是第一个在数据模型层面撞上的冲突。
TencentDB Agent Memory 返回的检索结果是扁平文本片段,天然没有“这条记忆是用户说的还是助手说的”这种角色属性,也没有“它应该插在对话序列的哪个位置”的顺序属性。而 Codex 提交给 Responses API 的上下文,是一个严格有序的 messages 数组,每条消息都有明确的 role(system / user / assistant / tool)。
这就导致一个尴尬的问题:你想把记忆塞进去,塞在哪?
- 塞进 system,模型会把记忆内容当成系统指令,有时照搬执行很危险;
- 塞进 user,模型可能把它当成当前用户输入,产生错误的对话流;
- 塞进 assistant,等于伪造了自己的发言,后续引用会出问题。
我试过最“不坏”的做法是:把记忆块打包成一条 user 消息,前面加一句“以下是从长期记忆中检索到的历史信息,仅供参考”。实测能用,但污染消息流,而且一旦多条记忆内容互相矛盾,模型会陷入混乱。这个冲突,我在前面提的“现象二”里就是这么踩出来的。
4.2 冲突二:拉取的时机对不上请求的时机
记忆检索是个“事前动作”,但 Codex 的上下文构造是个“同步封装”。
你需要记忆来支撑本轮回答,所以理想时序应该是:用户输入 → 查记忆 → 组装上下文 → 调模型。但 Codex 的内部逻辑是:用户输入 → 组装上下文 → 调模型 → 模型决定调工具 → 才查记忆 → 再把结果喂回模型。
这就多了一个“模型的中间决策环节”。如果模型这一轮压根没想到要查记忆,那这轮回答就是无记忆的;而如果我们硬把记忆查询做成一个工具让模型“必须调用”,那每次对话都额外消耗一次模型往返,延迟和成本都上去了。
更麻烦的是,工具调用返回的记忆,本质上是“事后补救”。模型看到检索结果时,可能已经基于无记忆的上下文写出了第一版推理,不一定会回头修正。
4.3 冲突三:写回的路径根本不存在
读 TencentDB Agent Memory 的 SDK 时,我发现一个问题:它提供了完美的写入接口,但没有提供一个“接住 Codex 会话结束事件”的挂载点。
Codex 这边,每次会话结束只是默默把 JSONL 写到本地;TencentDB Agent Memory 这边,add()接口安安静静等着被调用。两边之间没有任何机制自动触发“把这次会话沉淀成记忆”。
理论上,Codex 可以在对话过程中让模型自己调用memory_add工具来写记忆,但让模型“自觉做记忆管理”这件事本身就很不可靠:它可能在无关紧要的地方疯狂写入,也可能整个会话一份都不写。靠 prompt 约束,效果全看模型心情。
这就是为什么我在最终方案里增加了一个“会话结束后的外部固化脚本”,而不是依赖模型自觉。
4.4 冲突四:检索排序逻辑和 Codex 的 token 预算打架
TencentDB Agent Memory 的检索排序,优化目标是“语义相似度”。它默认只关心“这条记忆和查询像不像”,不关心“在固定的 token 预算下,哪些记忆值得占用空间”。
可 Codex 的上下文是一个有限资源。一个中等规模项目,AGENTS.md 加历史消息就能花掉几万 token。这时候你再往里面塞 top-5 的记忆,每条记忆平均 300 token,就是 1500 token 的额外开销。如果检索结果里有几条只是“字面相似但实际毫无用途”的旧记录,浪费就更明显。
我在源码里也确认了 TencentDB Agent Memory 的检索结果是带score和importance的,但 Codex 这一侧没有任何消费这些元数据的逻辑。两边各自的“排序指标”不对齐,结果就是:数据库觉得“这条最相关”,Codex 却觉得“这玩意占地方”。
所以后面桥接方案里,我必须在网关层自己做一道“二次筛选”,按重要性和新鲜度压缩记忆块,再交给模型。
5. 我采用的桥接方案:用 MCP 包一层“记忆网关”
5.1 方案选型:为什么不改 Codex 源码
面对四个硬冲突,最直接的思路是 fork Codex 改源码,在上下文构造前硬插一个记忆检索步骤。这确实能打通“事前注入”的问题,但我不推荐。
原因有三个:第一,Codex 迭代很快,我 fork 的版本撑不过两轮升级;第二,改源码需要重新编译和维护,个人项目扛不住;第三,Codex 上游其实也在演进记忆能力,现在做深度定制,很可能做几个月就被官方功能覆盖了。
所以我的选择是:保留 Codex CLI 原样,在 MCP 工具层解决“动态读”,在外部脚本层解决“自动写”,在 AGENTS.md 层解决“模型自觉性”,再用一个轻量 Python 服务把三者串起来。这样所有改动都在“外围”,升级 Codex 不受影响。
5.2 记忆网关的组成与实现
整个桥接方案由四块组成:
- 一个 MCP 服务器,暴露三个工具:
memory_search、memory_add、memory_recent; - 一份 Codex 的 MCP 配置,把记忆网关挂上去;
- 一份 AGENTS.md,告诉模型“什么时候查、什么时候写”;
- 一个会话结束后的固化脚本,把本地 JSONL 里的关键信息提炼成记忆条目。
先看 MCP 服务器的核心代码,我用 Python 写,尽量精简:
# memory_bridge.py from mcp.server.fastmcp import FastMCP import agent_memory_sdk as sdk # TencentDB Agent Memory 的 Python SDK,示例命名 mcp = FastMCP("memory-bridge") @mcp.tool() def memory_search(query: str, limit: int = 5) -> str: """按语义检索与当前问题相关的历史记忆,返回文本列表。""" hits = sdk.search(query=query, top_k=limit, agent_id="my-project") if not hits: return "没有检索到相关记忆。" return "\n---\n".join(f"[重要性 {h.importance:.1f}] {h.content}" for h in hits) @mcp.tool() def memory_add(content: str, importance: float = 0.5) -> str: """把一条值得长期保留的信息写入记忆库。""" sdk.add(content=content, metadata={"importance": importance}) return "已写入记忆。" @mcp.tool() def memory_recent(hours: int = 24, limit: int = 10) -> str: """获取最近指定时间内写入的记忆,用于会话开始时的背景补全。""" items = sdk.recent(hours=hours, agent_id="my-project", top_k=limit) if not items: return "最近没有写入记忆。" return "\n---\n".join(i.content for i in items)然后把它挂进 Codex 的配置,我这边用的是~/.codex/config.toml:
[mcp_servers.memory] command = "python" args = ["memory_bridge.py"]AGENTS.md 里我写了一份很明确的规范,让模型的行为可预期:
## 记忆使用规范 - 每次会话开始,先调用 memory_recent 查看近期进展,再调用 memory_search 检索与本任务直接相关的记忆。 - 当确定了关键决策、踩坑经验、模块约定时,调用 memory_add 写入,importance 按影响范围给 0.3~0.9。 - 记忆检索结果只作为背景参考,如果与当前代码冲突,以当前代码为准。 - 不要写入临时性细节(如当前行号、临时调试输出),不要写入显而易见的事实。这一步把“记忆读取”从系统级前置动作,变成了模型可执行的工具调用。虽然不是 100% 可靠,但配合 AGENTS.md 的强制语气,实测大多数会话都会遵守。
5.3 会话结束时的记忆固化脚本
模型自觉写记忆靠不住,所以我还做了一个外部兜底:用一个包装脚本包住codex exec命令,会话结束后自动解析最新生成的 JSONL 会话记录,让一个便宜的总结模型提炼出该保留的条目,统一写入记忆库。
简化版本的思路是这样的:
codex exec "$@" LATEST_SESSION=$(ls -t ~/.codex/sessions/*.jsonl | head -1) python summarize_session.py "$LATEST_SESSION"summarize_session.py里做的事情不复杂:把会话里所有 user 消息和 assistant 消息抽出来,喂给一个文本模型,让它输出三条以内的“值得长期保留的事实”,每条不超过 80 字,然后逐条调memory_add写入,importance 由总结模型给定。
实测下来有几个注意点:
- 去重要控制好:同一个项目反复跑,很容易写出重复记忆。我利用 TencentDB Agent Memory 的相似度去重机制,把去重阈值调高了一些,保证同义表述也能命中。
- 只总结决策与坑,不总结过程:会话里大部分内容是“改了这个函数再跑测试”,这种过程性内容没有长期价值,固化前就要过滤掉。
- 时间粒度要统一:固化脚本建议用 cron 或终端集成跑,不要每次手动执行,否则坚持不下来。
5.4 实测效果与仍然存在的边界
接入之后,效果是肉眼可见的:新会话开头,模型会主动说“我先查一下相关记忆”,然后给出之前定过的规范和踩坑记录。至少“同一个问题问两遍”这种蠢事很少再发生了。
但也要说实话,这个方案不是银弹。边界很清楚:
- 依赖模型自觉:AGENTS.md 能提高自觉率但不是 100%,偶尔还是会忘;
- 时效性差:固化脚本是“会话结束后”才写记忆的,会话中途崩溃、被强杀,本轮内容就丢了;
- 并发会话会打架:同一个项目同时开两个 Codex 会话时,记忆写入需要额外的锁和版本控制,否则后写的把先写的覆盖了;
- 检索结果仍可能“占着位置没用”:网关层的二次压缩只是缓解,没有根治排序指标不对齐的问题。
这个方案我用了大概三周,整体收益大于成本,尤其是对跨天、跨周的项目维护场景价值很明显。
6. 常见问题排查与速查表
把这段时间踩过的坑整理成一张速查表,遇到问题先对号入座:
| 症状 | 可能原因 | 排查与解决 |
|---|---|---|
| Codex 从不调用记忆工具 | AGENTS.md 没被加载,或规范语气不强 | 确认 AGENTS.md 在项目根目录且命名正确;把“先查记忆”写成命令式语气 |
| memory_search 总是返回空 | agent_id 过滤过严,或记忆库里本来就空 | 先不传 agent_id 试一次;检查固化脚本是否真的成功写入了 |
| 模型把记忆当用户输入 | 记忆以 user 角色注入,模型无法分辨 | 改用 MCP 工具返回值注入,不要直接拼进消息流 |
| 上下文长度经常超限 | 记忆检索 top_k 太大,或记忆块本身过长 | 调小 limit;在网关层按 importance 截断,只保留摘要部分 |
| 重复写入大量相同记忆 | 去重阈值太低,同义句被判为不同内容 | 调高相似度阈值;固化脚本里先 search 再 add |
| 会话中途崩溃,本轮记忆全丢 | 固化脚本只在会话正常结束时执行 | 把固化逻辑改成轮询本地 JSONL 文件变化,增量更新 |
如果你也想复现这套方案,我的建议是:先把最小闭环跑起来——只接一个 MCP 服务器,手动memory_add几条记忆,然后在新会话里问“有没有关于 X 的记录”。确认模型能搜到、能引用之后,再加自动化固化脚本。千万不要一上来就把四个组件全铺开,出了问题根本不知道是哪一环坏了。
我个人在这几天的实践里,最大的体会是:给 Agent 接记忆,难的从来不是存储,而是“时机的适配”。数据库做得再好,如果 Agent 的运行时没有在正确的时机给你入口,一切能力都等于不存在。所以在选型前,先花时间读一读 Agent 的 turn loop 源码,搞清楚它在哪里暴露钩子、在哪里是封闭的,能帮你省掉后面几周的折腾。