Codex 与 Claude Code 的持久化记忆:两条不用向量库的路线
一提「给 Agent 加长期记忆」,很多人脑子里立刻蹦出那套标准答案:切块、Embedding、向量库、相似度 Top-K、RAG。这套方案在知识库场景确实能打,但落到「Agent 记住用户偏好」这件事上,它反而是被市场淘汰最快的一档。
OpenAI 的 Codex 和 Anthropic 的 Claude Code,两家头部 coding agent 都做了跨会话记忆,都没有用 Embedding,也都没有向量库。一个把记忆做成离线两阶段管线 + SQLite 状态机 + 四层 Markdown;另一个做成回合内直接写文件 + 分层目录 + 按需召回。
这篇文章把两套实现拆开对比。Codex 侧依据 openai/codex 仓库的 Rust 源码(codex-rs/memories/{read,write} + codex-rs/memories/README.md);Claude Code 侧依据其泄露源码的静态分析仓库(src/memdir/、src/services/SessionMemory/、src/tools/AgentTool/)。两边都给了文件路径,你可以自己对着看。
一、先厘清:Agent 的「记忆」到底在解决什么
聊实现之前先把概念钉死,不然后面全是误解。
1.1 上下文窗口不是记忆
模型的上下文窗口是一次请求内 能看到的东西,请求结束就没了。所谓「Agent 记忆」要解决的是:下一次开会话时,怎么让模型知道你上次教过它什么。这两件事的边界很清楚:
| 上下文窗口 | 持久化记忆 | |
|---|---|---|
| 生命周期 | 单次请求 | 跨会话、跨天、跨月 |
| 载体 | 模型输入 | 磁盘文件 / 数据库 |
| 谁管理 | 框架自动裁剪 | 框架 + 模型 + 用户共同维护 |
| 成本形态 | 每回合都付 token | 生成一次,注入多次 |
一句话:记忆的本质是「把这一次的 token 花在下一次不用再花」。
1.2 为什么第一反应总是向量库,而这两个产品都没走
向量库方案的隐含假设是:记忆条目量大、查询词和存储文本用词不一致、并且需要一个独立检索服务来兜底。但在 Agent 记忆这个场景里,这三条都不太成立:
- 个人用户的记忆条目通常是几十到几百条,不是百万级文档;
- 记忆内容往往是「用户偏好」「项目约定」这类短事实,短文本的语义相似度噪声极大;
- 后台「记忆代理」最难的不是检索,而是判断什么时候该写------这个时机问题向量库根本不管。
真实项目里踩的坑也很具体:嵌入有损、检索命中不相关的、排序不可解释、每回合跑 embedding 的成本、以及最致命的------出问题时你没法指着某几个字节说「就是它害的」。
Codex 和 Claude Code 都绕开了这条路,代价是接受「换个说法就搜不到」这个缺陷(后面第五章细说)。
1.3 对比口径
只谈 harness 层的开发者记忆机制,不谈 ChatGPT 网页端那种消费级「记忆功能」(那个是模型侧通道,两码事)。Codex 侧还有个容易混淆的点:AGENTS.md 和 Memories 不是一件事。
| AGENTS.md | Memories(本文主角) | |
|---|---|---|
| 谁写 | 人写 | Agent 自己生成 |
| 放哪 | 仓库里,进版本控制 | ~/.codex/memories/,本机状态 |
| 作用 | 项目宪法,必须遵守 | 经验沉淀,下次少走弯路 |
| 大小上限 | 默认 32 KiB,超出静默截断 | 无硬上限,靠淘汰机制控制 |
Codex 自己也知道这两者容易串味,所以在 Phase 1 抽取时会主动把 AGENTS.md 的注入片段从输入里剔除,防止小模型把「项目宪法」再抄一份成「经验」。
二、Codex 的记忆管线:从 rollout 到四层 Markdown
先给结论:Codex 的记忆是会话外(off-session)重批处理 + 会话内(in-session)轻注入。生成记忆的算力和当前对话完全解耦,用户在当前回合几乎不付额外成本。
流水线一句话概括:
原始记录(rollout)
→ 结构化解析(Phase 1:小模型抽成 raw_memory + rollout_summary)
→ SQLite 落库(stage1_outputs 表)
→ 归纳整合(Phase 2:大模型子代理 morpheus 合并)
→ 四层 Markdown 持久化(总览 / 索引 / 任务记录 / 标准流程)
2.1 两张表把架构说清楚了
Codex 用 SQLite 做状态层,核心就两张表:
| 表 | 粒度 | 关键字段 |
|---|---|---|
stage1_outputs |
一条 rollout 一行 | raw_memory、rollout_summary、rollout_slug、cwd、usage_count、last_usage、selected_for_phase2 |
jobs |
Phase 1 每 thread 一行;Phase 2 全局一行 | kind、status、lease_until、retry_at、input_watermark |
jobs.kind = 'memory_stage1' 时 job_key 是 thread_id;kind = 'memory_consolidate_global' 时 job_key 固定为常量 "global"。并发边界全靠 BEGIN IMMEDIATE 和原子条件 INSERT 撑住,不指望外部进程礼貌行事。Phase 1 有 8 路并发 + 3600 秒租约 + 3600 秒退避;Phase 2 是全局唯一锁 + 90 秒心跳。这组参数就是一套「避免雪崩」的完整约束,想 fork 的人只改一个必挂。
2.2 Phase 1:把一条 rollout 抽成三个字段
Phase 1 干的事很朴素------读 rollout,喂给一个便宜模型,拿回结构化输出:
raw_memory:详细的原始记忆rollout_summary:精简的会话摘要rollout_slug:文件名用的短标识(可选)
工程细节才是关键:
- Schema 强约束。输出走 JSON Schema strict 模式,模型只能吐这三个键,多一个字段直接解析失败。
- 双重脱敏 。序列化阶段过一次
redact_secrets,模型输出阶段再过一次,prompt 里还明确要求模型自己也做一次。三道防线,任何一道漏了都不会被自动补救。 - 注入即排除 。序列化时显式丢弃
developer角色消息、AGENTS.md 注入片段、<skill>片段。理由很直白:这些是「下一次会话本来就该看到」的内容,不该被当成「经验」再提炼一遍,否则MEMORY.md里会堆满规则副本。 - 输入要截断。按模型有效 context window 的 70% 作为 rollout 输入上限,给系统指令、输出、推理留 30%。取不到模型元数据时回退 150,000 tokens。
- 失败要退避。抽取失败按 1 小时退避重试,不会出现「启动一次连撞 50 次模型」。
结果写回 SQLite。注意这一步:rollout 是原料,raw_memory 是初加工产物,DB 是存放初加工产物的仓库。
2.3 SQLite 到底是「临时存储」还是「事实源」
✋ 这里要纠正一个流传很广的说法:Codex 的 SQLite 不是临时缓存,它是事实源(source of truth)。
~/.codex/memories/raw_memories.md 和 rollout_summaries/ 这两个文件,每一次 Phase 2 跑的时候都会从 SQL 重新生成。就算你手工把它们删了,下一次 Phase 2 会原样补回来------除非这条 thread 在 DB 里也已经被 prune 掉了。
这个设计取向和很多开源记忆项目恰好相反(那些项目通常主张「Markdown 是事实源,SQLite 只是可重建的索引」)。Codex 的排序是:
SQLite(不可重建,事实源) → Markdown 派生物(可重建) → 注入 prompt 的摘要(截断后)
好处是数据一致性有保证、并发安全能靠事务兜住;代价是记忆的「真相」在 DB 里,用户看到的 Markdown 只是投影。
2.4 Phase 2:用 git diff 决定「要不要干活」
Phase 2 是全局串行的合并阶段,流程是线性的十步:
- 抢全局锁
- 准备 workspace(确保 git baseline 存在)
- 构造被严格降级的 agent 配置
- 按选择规则加载要参与合并的 stage-1 输入
- 同步
rollout_summaries/和raw_memories.md - 用 git diff 判断 workspace 是否有变化
- 有变化才写
phase2_workspace_diff.md(上限 4MB) - spawn 子代理(内部代号 morpheus)
- 后台接管心跳、ownership 校验、baseline 重置
- 上报指标
有两个设计特别值得说:
第一,用 git 工作区当 dirty 指示器。 ~/.codex/memories/ 本身是个 git 仓库。上一轮成功的状态是 baseline,本轮先同步文件、再 diff。没变化就直接 succeeded_no_workspace_changes 退出,连模型都不调 。这个设计让「用户手工编辑记忆」变成了可被感知的输入------你在 MEMORY.md 里删了一段、加了一条,下次 Phase 2 跑起来时 morpheus 通过 diff 能「看见」你的改动,而不是无脑覆盖。
⚠️ 注意:这个 baseline 每次成功后都会被重置 ,官方 PR 里写得很直接------不打算通过 .git 保留历史。所以 git log ~/.codex/memories 看不到记忆的演化史,想做审计得自己另外备份。
第二,morpheus 是被阉割过的子代理。 它跑在:ephemeral = true、generate_memories = false、use_memories = false、无网络、无审批、只有本地写权限、collab 被禁用。前三项是为了杜绝自递归(合并代理自己再生成记忆,会无限套娃),禁网络和禁 collab 是为了把一个「改文件的模型」关进最小的盒子里。
2.5 四层 Markdown:总览 / 索引 / 任务记录 / 标准流程
合并完成后,~/.codex/memories/ 里是这样一套结构:
| 层 | 文件 | 角色 | 何时被读 |
|---|---|---|---|
| 总览 | memory_summary.md |
导航层,下次会话第一眼看到的东西 | 每回合注入(截断后) |
| 索引 | MEMORY.md |
长期手册,可检索的知识主体 | 模型按需 grep |
| 任务记录 | rollout_summaries/*.md |
证据层,每个会话一份回放 | 命中时才打开 |
| 标准流程 | skills/<name>/SKILL.md |
可复用流程,能带脚本和模板 | 命中时才打开 |
MEMORY.md 的结构是被 schema 管住的,按任务组组织,每个任务块要按固定顺序给子节:
markdown
# Task Group: <cwd 或工作流分组>
applies_to: cwd=/Users/xxx/work/api-service
## Task 1: <任务描述, outcome=success|partial|fail|uncertain>
### rollout_summary_files
- 2026-02-17T21-23-02-LN3m-weekly_memory_pivot.md (cwd=/Users/xxx/work)
### keywords
- model routing, gateway api, prompt cache
### Preference signals
- 用户说过:"先追实际路由路径再回答"
-> 回答模型选择问题前,先查网关路由配置
### Reusable knowledge
- 网关 portal 有 per-model 容量看板 /portal/capacity
### Failures and how to do differently
- 直接用 CLI 查 GPU 容量撞了鉴权墙 -> 改走申请表单
### References
- /portal/capacity, /portal/request
applies_to: cwd= 这行是跨项目隔离的关键:Codex 只有一个全局记忆目录,不分项目,靠内容里的 cwd 标注 + 读路径过滤来实现多项目共存。合并模型的 prompt 是 841 行,很大一部分就在教它怎么在多次更新中维持这套 schema。
2.6 淘汰机制:调用次数 + 最后使用时间
这是整套系统里最「反 RAG 直觉」的一块。大多数 RAG 框架只管写入,不管退役;Codex 把退役做成了三条口子。
先说排序和筛选。Phase 2 选记忆的 SQL 长这样:
sql
-- 内层:谁进 top-N
ORDER BY
COALESCE(so.usage_count, 0) DESC, -- 被引用过几次
COALESCE(so.last_usage, so.source_updated_at) DESC, -- 最近一次用是什么时候
so.source_updated_at DESC,
so.thread_id DESC
LIMIT ?
-- 外层:进 top-N 的集合按 thread_id 升序固定
ORDER BY selected.thread_id ASC
外层那个 ORDER BY thread_id ASC 不是为了排序好看,是为了让 raw_memories.md 的文本顺序在多次连续运行里保持稳定,否则排名一变,git diff 全是噪声。
淘汰规则:
| 机制 | 作用对象 | 怎么算 |
|---|---|---|
max_unused_days(默认 30) |
记忆条目 | last_usage 超窗的直接不进选择集 |
usage_count 排序 |
记忆条目 | 被引用越多越靠前 |
prune_stage1_outputs_for_retention |
DB 记录 | 按时间窗硬删除,每次启动最多 200 行 |
selected_for_phase2 = 0 |
DB 记录 | 从未进过 baseline 的优先淘汰 |
mark_thread_memory_mode_polluted |
thread | 主动遗忘 API |
⚠️ 两个坑:
prune跑在限速检查之前。源码注释说得很清楚------它不花 token,所以可以提前跑。结果就是:即使 Phase 1/2 因为配额不足被跳过,清理照样执行。极端情况下会出现「还没合并就被先清掉」。usage_count依赖模型自觉引用 。它靠模型在最终回复里输出<oai-mem-citation>块来计数,模型忘了引用,信号就丢了。这是个会静默失效的回路------模型升级后引用行为一变,淘汰逻辑就跟着漂。
至于「只有低频又长期闲置的才会被清理」------这句话基本准确,但要补一句:高使用频率的记忆即使很久没用,因为 usage_count 排在前面,仍然能进选择集;真正被清掉的是「既没被引用过、又过了时间窗」的那批。
2.7 读路径:只注入一个被截断的摘要
写路径六千多行,读路径三百来行。这个比例本身就是设计态度。
会话启动时,Codex 只做一件事:
rust
// 伪代码,对应 read 路径的注入函数
let summary = read("~/.codex/memories/memory_summary.md");
let summary = truncate_text(summary, Tokens(SUMMARY_TOKEN_LIMIT)); // 硬截断
// 把截断后的 summary 塞进 developer 指令模板,其余文件靠模型自己 grep
其余 MEMORY.md、rollout_summaries/、skills/ 全部不注入 ,模型需要时自己用 shell 去 read / grep。社区文章普遍写的「5K token 上限」来自早期版本,源码分析时点(v0.137 附近)这个常量已经收紧到 2,500 tokens;再算上模板自身约 1,500 tokens,等于每回合花约 4,000 tokens 教模型「怎么用记忆」。
反馈闭环在这里闭合:
xml
模型回复里带 <oai-mem-citation>
→ parse_memory_citation 按行解析
→ record_stage1_output_usage 事务更新 usage_count / last_usage
→ 下一次 Phase 2 排序时生效
另外还挂了一个独立的只读 MCP 服务(list / read / search 三个工具),意味着别的客户端也能挂载 Codex 的记忆目录 。路径校验严格到什么程度:禁止 ..、禁止根目录、禁止隐藏文件、逐层拒绝 symlink,等于把记忆目录当 chroot 监狱管。
2.8 什么情况下记忆系统根本不跑
四道前置门,任何一条没过就整体跳过:
- 会话是
--ephemeral或一次性 exec - 用户关掉了
Feature::MemoryTool - 当前会话是子代理(包括 Phase 2 自己的 morpheus)
- state DB 不可用
还有一条容易忽略的触发条件:会话必须空闲够久才有资格合并 。mem0 的拆解写的是默认 6 小时,Codex 配置里对应 min_rollout_idle_hours(会被 clamp 到 1~48 小时)。含义是------今天学到的东西,通常要等明天那次启动才会真正进记忆。
三、Claude Code 的记忆体系:回合内写、分层目录、按需召回
Claude Code 走的是完全相反的路线。它没有两阶段管线,没有 SQLite,没有后台抽取任务(后期补了一个 auto-dream 做整合)。核心就一句话:让 agent 在回合内,用和写代码完全相同的文件工具,把记忆写成 Markdown。
3.1 四层记忆,各管一件事
先看职责划分:
| 层 | 解决的问题 | 存哪 |
|---|---|---|
| Auto Memory | 用户 / 项目的长期协作信息 | <memoryBase>/projects/<sanitized-git-root>/memory/ |
| Session Memory | 当前会话摘要,服务长会话与 compact | Session Memory 目录 |
| Agent Memory | 某类 agent 的专属长期记忆 | user / project / local 三种 scope |
| Team Memory | 团队共享的 repo 级知识 | 带同步机制的共享层 |
它们没被塞进一套 schema,而是各自有独立目录、独立 prompt、独立更新策略。设计意图是透明(用户能直接打开看)、可治理(单独开关)、可组合(同时存在但职责不同)。
3.2 底层约定:MEMORY.md 是索引,不是正文
src/memdir/memdir.ts 里三个常量定了整套规矩:
typescript
export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000
含义:
- 每条记忆是一个独立 Markdown 文件
MEMORY.md只维护索引,一行一条,- [标题](文件.md) --- 一句话钩子,不超过约 150 字符,不写正文- 会话启动时只保证模型看到
MEMORY.md,需要细节再去读具体文件 - 超过 200 行 / 25KB 会被硬截断(超了会在注入内容里附带警告)
写入是两步法 ,系统 prompt 里写得很死:先写 topic 文件,再在 MEMORY.md 加一行指针。**「绝不要把记忆正文写进 MEMORY.md」**是硬规矩。
为什么不在一个大文件里堆?因为大文件会同时带来 prompt 爆炸、更新冲突、历史垃圾无法治理、单条错误污染整份上下文。
3.3 写入时机:主回合内,看得见
Claude Code 的写入发生在用户还在键盘前的时候 :agent 判断「这个值得记」,当场调 Write / Edit 落盘。用户可以看着文件生成,也可以当场反对。
这带来一个 Codex 没有的性质:记忆的写入是可审计的人类动作 ,而不是后台管线的产物。代价是复杂度转移给了模型------它得自己守规范,系统没有 schema 校验器会拒绝一个 type: foo 的文件。
记忆被约束成四种类型(memdir/memoryTypes.ts):
| 类型 | 内容 | 特点 |
|---|---|---|
user |
用户角色、目标、知识背景 | 写得少,偏静态 |
feedback |
用户对工作方式的纠正与肯定 | 数量最多,含规则的「为什么」和「怎么用」 |
project |
项目代号、业务背景等非代码信息 | 不进版本控制的那些上下文 |
reference |
需要反复查的技术细节 | 自由格式 |
关键约束写在注释里:代码模式、架构、git 历史、文件结构这些能从当前项目状态推导出来的东西,不该存成记忆。这条边界是这套设计能保持干净的根本原因。
3.4 召回:不灌注全文,让轻量模型挑 5 个
src/memdir/findRelevantMemories.ts 的做法:
scss
memoryDir
→ scanMemoryFiles() 只读文件头
→ 过滤 alreadySurfaced 别每轮都挑同一批
→ formatMemoryManifest() 生成「文件名 + 描述」清单
→ sideQuery(轻量模型) 从清单里选
→ 最多返回 5 个文件路径
两个细节:选的是文件名不是正文 (不先把所有记忆全文塞进去),以及 MEMORY.md 本身不参与这次选择 (它已经单独注入 system prompt 了)。选择器用的是默认 Sonnet,max_tokens 只有 256。
这是一台轻量检索器,不是向量库。
3.5 Session Memory:长会话的摘要层
Session Memory 不解决「跨会话记住什么」,它解决长会话跑到一半怎么不撑爆。
触发阈值写死在 sessionMemoryUtils.ts:
typescript
export const DEFAULT_SESSION_MEMORY_CONFIG = {
minimumMessageTokensToInit: 10000, // 没到 1 万 token 不启用
minimumTokensBetweenUpdate: 5000, // 涨了 5 千 token 才考虑更新
toolCallsBetweenUpdates: 3, // 至少 3 次工具调用
}
判断逻辑里有个容易被忽略的巧思:token 阈值始终必要 ,然后要么「双阈值都满足」,要么「token 够了且上一轮没有 tool_use」------后者是在找自然断点,防止在工具调用链中间截断,生成一份孤立摘要。
文件权限也说明它被当成敏感状态而不是普通缓存:
typescript
await fs.mkdir(sessionMemoryDir, { mode: 0o700 }) // 目录:仅属主
await writeFile(memoryPath, '', {
mode: 0o600, // 文件:仅属主可读写
flag: 'wx', // 不存在才创建,防覆盖已有记忆
})
更新由一个后台 forked subagent 完成,权限被掐到极致------只允许对唯一那个精确路径调 FileEdit,连 Read 和 Write 都被拒。这是个严密沙箱化的摘要代理。
配套的 compact 也很有意思:上下文要压缩时,它不再额外调一次 API 让模型总结 ,而是直接拿 Session Memory 当断点;裁切消息时优先保证尾部保留下限 token 的原文;如果切断位置落在 tool_use / tool_result 链中间,会把索引往前平移把这些记录包进来,绝不切出孤立的 tool_result(否则会直接吃厂商 API 的校验错误)。压缩完还会把文件附件和工具能力声明重新灌回去,让模型「醒来」时技能蓝图依然齐装满员。
3.6 Agent Memory:记忆可以随项目分发
Agent Memory 是「某个 agent 类型的长期记忆」,三种 scope:
| scope | 典型目录 | 用途 |
|---|---|---|
user |
<memoryBase>/agent-memory/<agentType>/ |
跨项目复用 |
project |
<cwd>/.claude/agent-memory/<agentType>/ |
项目内共享 |
local |
<cwd>/.claude/agent-memory-local/<agentType>/ |
本机本地 |
agentType 会先做路径清洗(插件命名空间里的 : 会被换成 -),local scope 在 remote 环境里会重定位到远端挂载盘的 project namespace 下------它不是真的「只在本地磁盘」。
写权限是自动注入的:agent 定义里声明了 memory,系统就强制把 FileWriteTool / FileEditTool / FileReadTool 加进这个 agent 的工具列表,并把记忆 prompt 拼到它的 system prompt 后面。这两步缺一不可------只给 prompt 不给工具,agent 看得到规则没法落盘;只给工具不给 prompt,agent 有权限却不知道怎么维护。
最有意思的是 snapshot 机制 :snapshot 目录固定在 <cwd>/.claude/agent-memory-snapshots/<agentType>/,用 snapshot.json(记录 updatedAt)和 .snapshot-synced.json(记录本地同步自哪个时间点)两个文件,判定三种动作:
- 没有 snapshot → 什么都不做
- 本地记忆目录里一个
.md都没有 → 初始化(把 snapshot 拷进去) - 本地有记忆但没有同步标记,或 snapshot 比本地新 → 提示可更新
这一手把 agent memory 从「运行时副产物」提升成了「可分发的角色资产」:项目不仅能下发 prompt,还能一起下发这个 agent 已经积累好的协作经验,并且支持后续升级。
3.7 auto-dream:补上的后台整合
Claude Code 后期引入了一个后台整合流程(src/tasks/DreamTask/DreamTask.ts 是它在 UI 上的可见化外壳)。forked 出来的 dream agent 按四阶段跑:orient(定位)/ gather(收集)/ consolidate(整合)/ prune(清理),配一把 consolidation lock 防止并发打架。
这算是向 Codex 那种「离线整合」思路靠了一步,但入口依然是显式的、可被用户察觉的(UI 里有 pill 和对话面板能看),不像 morpheus 那样几乎全黑。
四、逐维度硬碰硬
4.1 总览对比
| 维度 | Codex | Claude Code |
|---|---|---|
| 记忆载体 | ~/.codex/memories/*.md + git baseline + SQLite |
项目/用户级目录下的 Markdown 文件 |
| 是否自动生成 | 是(两阶段管线) | 部分(回合内主动写 + auto-dream 整合) |
| 写入时机 | 会话外(空闲数小时后,下次启动跑) | 回合内同步 |
| 事实源 | SQLite(Markdown 是可重建的派生物) | 文件(没有 DB) |
| 索引 | memory_summary.md(注入)+ MEMORY.md(手册) |
MEMORY.md(注入,上限 200 行 / 25KB) |
| 召回方式 | 注入截断摘要 + 模型自己 grep | 轻量模型选 ≤5 个文件 + 索引常驻 |
| 淘汰 | usage_count / last_usage / 时间窗 / prune |
无自动衰减,靠读取时的年龄提醒 |
| 脱敏 | 三道模式匹配 + 多次 redact | 文件权限 0600/0700 + 团队同步侧扫描 |
| 跨项目隔离 | 单一全局目录 + 内容里 cwd: 标注 |
每个 cwd 一个编码后的独立目录 |
| 分发 / 协作 | 无(本机、按用户) | Agent Memory snapshot + Team Memory 同步 |
| 可观察性 | 主要是 OTel 指标,用户侧缺诊断命令 | 文件可直接打开,UI 有文件选择器 |
| 代码量 | 记忆三 crate + 状态层约一万一千行 | 分模块,相对轻 |
4.2 三组根本分歧
分歧一:写入放回合内还是回合外。
Codex 赌「当前回合越便宜越好」,代价是新鲜度延迟------今天学会的东西明天才生效(<oai-mem-citation> 的计数是即时更新的,但内容合并要等)。Claude Code 赌「当场写、当场可见」,代价是模型必须自己守写入规范,而且用户会看到写入动作。
分歧二:事实源是数据库还是文件。
Codex 把 DB 当权威,文件是投影。好处是并发安全、可恢复、幂等;代价是「真相」不在用户能直接读的地方。Claude Code 把文件当权威,好处是 cat 就能看、git diff 就能审;代价是没有任何机制保证文件结构不被写坏。
分歧三:淘汰靠机制还是靠人。
Codex 有完整的自动退役(usage 衰减 + 时间窗 + prune + 污染标记)。Claude Code 完全没有 usage_count、没有 last_usage、没有 max_unused_days ------一个第 1 天写的记忆,第 365 天还在 MEMORY.md 里,除非 agent 或用户手动删。它的替代方案是在读取时提醒年龄,让「过时」在用的那一刻暴露出来,而不是在存储时被静默清掉。
4.3 各自的代价
| 主要代价 | 具体表现 | |
|---|---|---|
| Codex | 新鲜度延迟 | 快速演进的偏好(新项目、新代号、新规则)要等一轮空闲窗口 |
| Codex | 依赖模型自觉 | 不引用 → usage_count 不涨 → 好记忆被误淘汰,且静默失败 |
| Codex | 用户侧黑箱 | 没有 codex memories status 类的诊断命令,只能看 OTel |
| Codex | 无跨设备 / 无团队 | 换台机器、换个容器,记忆从零开始 |
| Claude Code | 无自动衰减 | 过时记忆会持续污染上下文,靠人清理 |
| Claude Code | 索引硬上限 | MEMORY.md 200 行封顶,记忆多了必然挤占 |
| Claude Code | 检索靠词面 | 只记得意思、不记得原词时召回不稳 |
| Claude Code | 单工具锁定 | 记忆不跨工具,换 Codex 得重头养 |
五、为什么都不用 Embedding 和向量库
拆完两套实现,可以回答最初那个问题了。
5.1 三条被反复验证的判断
- 嵌入是有损的,短事实上的语义相似度尤其吵。 「用户不喜欢连字符」和「用户喜欢简洁写作」在向量空间里可能挨得很近,但它们是两条不同的规则。
- 后台记忆代理最难的不是检索,是触发时机。 什么时候该写、写哪一条、和已有条目冲突了怎么办------这些问题向量库一个都答不了。
- 可调试性。
cat一个文件能看的东西,比 query 一个黑盒索引要可信得多。生产上出问题时,你能指着具体的字节说「就是它」。
Codex 侧更直接:主代理模型本身就足够强,模型自己 grep 比再架一层检索服务更划算,还少了一个服务依赖和一层排序不确定性。
5.2 赢下来的组合其实很朴素
| 组成 | 说明 |
|---|---|
| 一个 LLM | 判断什么值得记、怎么合 |
| 一堆 Markdown | 唯一存储格式,人可读可改 |
| 文件工具 + bash | Read / Write / Edit / grep,就这些 |
这套「文本文件 + grep」的组合在软件工程里用了四十年,现在被搬到了 Agent 记忆上。有意思的问题不在数据结构,而在「读写时的纪律」------记忆在 prompt 里放哪、谁能写、怎么防止 prompt cache 每轮失效、什么时候淘汰。
5.3 诚实说清楚它的代价
不用向量库不是没有代价的,两套实现都栽在同一个坑里:
- 换个说法就搜不到。你记得讨论过「部署时端口冲突」,但记忆里写的是「修改 docker-compose 的 port mapping」------grep 找不到。这是纯文本检索的物理上限。
- 规模上不去 。
MEMORY.md涨到几十 MB 时,靠模型 grep 的效率会成为瓶颈。这是 Codex 当前没解决、社区也还没共识的开放问题。 - 不是制度替代品 。团队规范、部署要求、安全边界这些「必须始终生效」的规则,应该写进
AGENTS.md/CLAUDE.md这类静态文件,而不是指望自动生成的记忆。两家的官方文档都说了这件事------记忆是辅助召回层,不是规则的唯一来源。
5.4 别忽略的选型信号
Codex 的 Memories 在发布时不支持 EEA、英国、瑞士地区 ,那些用户只有 AGENTS.md 这一层。另外它是本机状态、按用户隔离,没有官方跨设备同步,也没有团队共享。如果你的场景要求「团队共享 + 跨设备」,原生方案不够用,得看外部记忆层(例如 Mem0 这类 MCP 接入方案)或者自建。
六、你真正需要记住的 6 件事
- 记忆不等于向量库。 两家头部 coding agent 的跨会话记忆都用 Markdown + grep,靠的是模型自身的检索能力,不是嵌入检索。
- Codex 是「会话外重、会话内轻」。 两阶段管线(小模型抽取 + 大模型合并)、SQLite 事实源、git baseline 做 dirty 检测,读路径只注入一个被截断的摘要。
- Codex 的 SQLite 不是临时存储,是权威数据源。
raw_memories.md和rollout_summaries/每次都会从 SQL 重建。 - Codex 的四层是「总览 / 索引 / 任务记录 / 标准流程」。 分别对应
memory_summary.md、MEMORY.md、rollout_summaries/、skills/,前一层注入,后三层按需读。 - Claude Code 是「回合内写、分层目录、按需召回」。
MEMORY.md只是索引(200 行 / 25KB 封顶),正文一条一个文件;召回用一个轻量模型从清单里挑不超过 5 个。 - 淘汰策略是两套设计最大的分水岭。 Codex 用
usage_count+last_usage+ 时间窗自动退役;Claude Code 不衰减,改用读取时的年龄提醒把「过时」暴露在使用现场。
验证清单
- 打开
~/.codex/memories/,确认能看到memory_summary.md、MEMORY.md、raw_memories.md、rollout_summaries/、skills/五类内容 - 在 Codex 配置里找到
[memories]段,确认存在「写入开关」和「读取开关」两个独立配置 - 确认
~/.codex/memories/下存在.git/,并验证git log看不到历史(baseline 每次成功的合并都会被重置) - 打开
~/.claude/projects/<编码后的cwd>/memory/MEMORY.md,数一下行数是否在 200 行以内 - 抽查一份
feedback_*.md,确认正文没有写进MEMORY.md(索引里应该只有一行指针) - 删除一条
MEMORY.md里的索引行,观察下一次会话是否还会召回这个文件(验证「索引即入口」) - 对你自己的场景做一次判断:你需要「团队共享 + 跨设备」,还是「本机单人」?前者原生方案不够用
参考资源
官方文档
- Codex Memories 官方文档:developers.openai.com/codex/memor...
- Codex 配置参考(
[memories]段):developers.openai.com/codex/confi... - AGENTS.md 分层发现指南:developers.openai.com/codex/guide...
- AGENTS.md 开放格式规范:agents.md/
- ChatGPT / Codex 本地记忆说明:learn.chatgpt.com/docs/custom...
源码
- openai/codex 记忆管线设计说明:github.com/openai/code...(
README.md与write/src/是核心) - 本地 Claude Code 源码分析仓库:
claude-code-analysis(重点看analysis/04-agent-memory.md、src/memdir/、src/services/SessionMemory/、src/tools/AgentTool/agentMemory*.ts)
第三方拆解(可交叉验证)
- mem0 :《Codex CLI Memory: How It Works + What Mem0 Adds》
- Nicolas Bustamante:《Agent Memory Engineering》(Hermes / Codex / Claude Code 三家横向对比,含淘汰策略细节)
- codex-source-analysis 第 20 章《记忆系统》(两阶段管线的逐文件源码分析)