青雲的博客
拆开 Codex 第五部:会话怎样保存、恢复并继续 第 28 章

Memory 不是更长的 History

从 rollout 候选、Phase 1 提取、独立 memories SQLite、Phase 2 全局合并锁一路追到文件语料、read path、citation 与 usage 回写,划清长期记忆和会话历史的所有权。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 27 章刚把四条“继续”路径分开:running resume 重连 live thread;cold resume 从 durable rollout 重建原 identity;resume(history) 用调用方给出的 ResponseItem history 创建新 identity,这份输入本身不是 durable rollout;fork 从 source rollout 创建新 identity。resume(history) 与 fork 都生成新 identity,但使用的材料不同。看到这里,很容易再顺着猜一步:既然 rollout 已经保存完整会话,Memory 大概就是把更多旧 rollout 塞回下一次 History。

固定版本恰好没有这么做。Memory 从 rollout 取材,却不参与 ContextManager 的 replay;它先做单 thread 提取,再做全局文件合并。只要 Memory read path 开启且 memory_summary.md 存在并非空,启动中的 thread 或配置刷新后重新生成 context 的 thread 都可能收到摘要和检索规则;这不是“只有新 thread”才发生的注入。更多细节来自后续文件读取;模型还要在最终回答里返回结构化 citation,core 才会给对应的 Phase 1 行记一次 usage。

先把完整链画出来:

flowchart LR
  accTitle: Memory 从 rollout 到 usage 回写的 provenance 链
  accDescr: eligible rollout 由 Phase 1 提取为 memories SQLite 行,Phase 2 在全局锁下选择并机械同步 raw memories 与 rollout summaries,受限 consolidation agent 只更新 curated memory 文件;未来 thread 在 read path 开启且 summary 非空时注入摘要,按需读取并返回 citation,core 再把被引用 rollout id 写回 usage metadata
  ROLLOUT["eligible rollout JSONL"] --> P1["Phase 1\nextract + redact"]
  P1 --> MEMDB["memories_1.sqlite\nstage1_outputs + jobs"]
  MEMDB --> SELECT["Phase 2 selection\nusage + recency"]
  SELECT --> SYNC["raw_memories.md\nrollout_summaries/*.md"]
  SYNC --> DIFF["Git workspace diff"]
  DIFF --> CONSOLIDATE["restricted consolidation agent"]
  CONSOLIDATE --> CURATED["memory_summary.md + MEMORY.md\noptional skills"]
  SYNC --> CORPUS["progressive-disclosure corpus"]
  CURATED --> CORPUS
  CORPUS --> READ["developer prompt + on-demand read"]
  READ --> CITE["citation_entries + rollout_ids"]
  CITE --> USAGE["usage_count + last_usage"]
  USAGE --> MEMDB

这条回路里至少有五个 owner,不能缩写成一个“Memory store”。

表示保存什么主要 owner不能据此推出什么
rollout JSONL原 thread 的 durable replay itemsrollout / thread store不是整理后的长期知识,也不会被 consolidation 改写
ContextManager History当前 live session 的模型工作集core Session不会因为 Memory 生成而自动变长
memories_1.sqliteStage 1 输出、选择标记、usage、job lease/retry/watermarkMemoryStore不是直接拼进 prompt 的最终语料
Phase 2 bridge 文件raw_memories.mdrollout_summaries/*.mdstorage.rs 的机械同步不是 consolidation agent 自由改写的输出
curated memory 文件memory_summary.mdMEMORY.md 与可选 skills受限 consolidation agent不会自动进入 live History
ExtensionData本 thread 是否注入 Memory、是否暴露 dedicated tools、memory root 在哪里MemoriesExtension不是跨进程 durable corpus

先确认它是否真的开启

MemoryTool 是默认关闭的 Experimental Feature

rust-v0.144.6Feature::MemoryTool 放在 experimental 区,并把 default_enabled 设成 false。这不是文案上的保守说法,而是启动 pipeline、read-path extension 和专用工具共同依赖的 gate。没有显式启用 feature,本章后面的流水线不会自动出现。

配置里还有四个容易混在一起的开关:

配置控制面关闭或开启后的结果
generate_memories写入资格false 时新 thread 的 memory_mode 记为 disabled,不进入后续提取候选
use_memories读取注入false 时不向 developer prompt 注入 Memory read path
dedicated_tools工具暴露true 时才贡献 list/read/search/ad-hoc-note 这组专用工具
disable_on_external_contextprovenance 污染true 时 web/tool-search 等外部上下文可把当前 thread 标成 polluted

这些开关也说明“会生成”和“会读取”不是同一个状态。一个 thread 可以不贡献新的 Memory,却仍读取已有 corpus;consolidation worker 则反过来把两者都关掉,防止整理 Memory 的过程再次成为 Memory 输入。

本节源码依据(5 处)

ExtensionData 只保存这条 thread 的读取配置

MemoriesExtension 在 thread start 时把 MemoriesExtensionConfig 放进 thread-scoped ExtensionData,配置改变时替换它。prompt contributor 从这里决定是否读 memory_summary.md;tool contributor 还要再检查 dedicated_tools。它没有在 ExtensionData 中保存 MEMORY.md 正文、Stage 1 rows 或 Phase 2 lease。

读取 prompt 也不是把整个目录塞给模型。build_memory_tool_developer_instructions 只打开 memory_summary.md,按固定 token limit 截断,文件缺失或为空就不贡献 fragment。目录细节和检索规则由模板告诉模型,正文要在需要时再读。

本节源码依据(3 处)

Startup 是另一条异步流水线

user input 先提交,Memory startup 后触发

在 app-server 路径里,Op::UserInput 先交给 core,并拿到这次 turn id。只有请求确实带 input,processor 才调用 start_memories_startup_task。因此 Memory startup 不是 sampling loop 内的一个同步步骤,也不阻塞这次 user input 已经开始的事实。

入口随后做四个硬拒绝:ephemeral session、feature 关闭、non-root agent,以及没有 state DB。通过以后才在后台 task 中创建 memory root、seed extension instructions、先 prune,再做 rate-limit guard,最后按 Phase 1、Phase 2 的顺序调用。

这里的 non-root guard 很关键。subagent 的 rollout 可以有自己的 durable identity,但 startup worker 不会让每个后代都同时扫描全局旧 thread;consolidation agent 自己也是 ephemeral,后面还会再次关闭 Memory feature,形成两层防反馈。

本节源码依据(2 处)

rate-limit guard 是 best effort,不是认证门

这个 guard 只对使用 Codex backend 的 auth 查询配额窗口。没有 auth、不是 Codex backend、client 构造失败、请求失败或没找到 snapshot 时,内部返回 None,外层 unwrap_or(true) 允许 startup 继续。只有拿到明确 snapshot 且窗口低于阈值,或者服务标记已经到限额,才跳过这次 pipeline。

这是一条成本保护,不是 Memory corpus 的完整性证明。它不会改变已有文件,也不会把失败写成 thread 的 History event;下一次 eligible startup 仍可能重试。

本节源码依据(1 处)

Phase 1 先把单条 rollout 变成可选择的提取结果

本章把第一段写入流程称为 Phase 1;它交给模型的结构化产物类型叫 StageOneOutput,成功后落入 SQLite 的 stage1_outputs。后文出现 Stage 1 时,指这份产物及其存储行,不是另一套并行流程。

候选不是“最近所有 thread”

Phase 1 的 state query 只看 active、allowed source、memory_mode = enabledhistory_mode = legacy 的 thread。它排除当前 thread,要求更新时间落在最大年龄与最小 idle 窗口之间,并先用 scan_limit 截断 state DB 工作。paginated history 也被排除,因为固定版本的 Stage 1 仍然 full-load rollout JSONL。

拿到 metadata 以后还要去 memories DB 检查 staleness,再尝试为每个 thread claim 一份 job。claim 带 worker_id、随机 ownership_token、lease、retry backoff 和 input_watermark;已有输出或成功 watermark 足够新时直接 SkippedUpToDate。所以两个 startup 同时看见同一条 rollout,不代表两个 model extraction 都能合法提交。

本节源码依据(3 处)

提取输入也不是 resume reconstruction

claim 成功后,worker 才加载 rollout items。输入过滤保留 ResponseItem 和 inter-agent communication 的模型输入形式;SessionMeta、compaction checkpoint、TurnContextWorldState、普通 EventMsg 都不进入 Stage 1 prompt。developer messages 被丢掉,user message 中完整标记的 AGENTS instructions 与 Skill fragment 也被移除。

过滤后的内容仍不一定原样进入 extraction request。build_stage_one_input_message 会按当前模型的 effective input window 计算上限,只给 rollout payload 其中 70% 的 token budget,并用保留头尾的策略截断;模型没有可解析的 context window 时才退回固定上限。因此 Stage 1 看到的是经过筛选、还可能再次截断的提取材料。

这一步常被误解为另一套 History normalization。它实际给长期知识提取准备一份低污染材料;“模型下一轮应该看到什么”仍由 History normalization 负责。compaction checkpoint 被排除,并不意味着它从 rollout 消失;resume 仍由第 27 章的 reconstruction 路径处理。

model 输出必须符合 StageOneOutputraw_memoryrollout_summary,以及可空但 schema 中必需的 rollout_slug。空 memory 或 summary 走 no-output 成功;非空结果在写入前做 secret redaction。失败、无输出、成功分别更新 job 状态,只有持有当前 ownership token 的 worker 才能提交。

本节源码依据(5 处)

memories SQLite 是控制面,不是最终语料

State runtime 在同一个 Codex home 下分别打开 state、logs、goals 和 memories 数据库。Memory 使用自己的 memories_1.sqlite pool,同时持有 state pool 来读取 thread metadata。这一拆分不是说二者无关:候选的 cwd、rollout path、git branch 和 memory mode 仍来自 state DB;只是 Phase 1 rows 与 jobs 不挤在 thread 主投影表里。

初始 schema 很直白:

  • stage1_outputs 保存 thread id、source timestamp、raw memory、rollout summary、slug、生成时间、usage 和最近一次 Phase 2 选择 snapshot;
  • jobs 保存 kind/key、status、worker、ownership token、lease、retry、error 和 watermarks。

成功提取会 upsert Stage 1 row,并推进全局 consolidation job。无输出会删除已有 row,必要时同样推进 consolidation,让 Phase 2 有机会从文件语料中忘掉它。失败只更新 retry 状态,不会写一个半成品 corpus。

本节源码依据(4 处)

Phase 2 用全局锁把 DB 选择变成文件语料

选择先看 usage 和 recency,再按稳定 id 落盘

Phase 2 不是“把最新 N 条 raw memory 拼起来”。eligible rows 依次按 usage_count DESCCOALESCE(last_usage, source_updated_at) DESCsource_updated_at DESCthread_id DESC 排序;每一页还要回 state DB 确认 thread 仍是 enabled legacy history。选中的 top N 最后按 thread_id ASC 返回,给文件生成一个稳定顺序。

retention 也没有直接删除上次成功 consolidation 的输入。prune 只处理 selected_for_phase2 = 0 的 stale rows,并用 COALESCE(last_usage, source_updated_at) 计算年龄。这个标记表达“上次成功文件 baseline 用过哪一份 snapshot”,不是永久 pin;下一次成功 Phase 2 会把 exact selection 整体重写。

本节源码依据(3 处)

watermark 负责调度,Git diff 才回答“文件变了吗”

Phase 2 先 claim singleton global job,再初始化 memory root 的 Git baseline。拿到 DB selection 后,它重建 raw_memories.md,同步 rollout_summaries/*.md,删除不再选中的 summary;随后计算 workspace diff。没有 diff 就在当前 selection 上标记成功,不启动 model。存在 diff 才写 phase2_workspace_diff.md,再把 consolidation prompt 交给内部 agent。

这里用 Git,是为了给 incremental forgetting 提供文件事实,而不是沿用用户仓库的版本控制习惯。watermark 可以因为输入时间推进,却不能说明当前 materialized files 真有变化;反过来,selected row 被删除或 polluted 时,即使 watermark 解释不出一个“更新的时间”,workspace deletion 仍能进入 diff。

storage.rs 写出的 raw_memories.md 是机械 bridge input,包含 thread id、source time、cwd、rollout path 和 raw memory;同一模块还直接生成、裁剪 rollout_summaries/*.md。这两类文件都在 agent 启动前完成同步。真正由 consolidation prompt 约束的 agent 输出只有 MEMORY.mdmemory_summary.md 与可选 skills,不能把机械同步的 rollout summary 算成 agent 又蒸馏了一次。

本节源码依据(4 处)

consolidation agent 被刻意关进一个窄环境

内部 agent 的 cwd 就是 memory root,并设置 ephemeral = truegenerate_memories = falseuse_memories = false。它看不到 MCP servers、apps、plugins,也不能 collab 或 spawn;approval policy 固定为 Never。沙箱只允许写 memory root,network_access = false,同时排除环境 tmpdir 和 /tmp

这组限制不表示 Phase 1 完全离线。Phase 1 本身需要向 model provider 发 extraction request;被明确切断网络的是 Phase 2 consolidation worker 的工具环境。它只能根据已同步文件与 Git diff 整理语料,不能在 consolidation 期间顺手查网页,再把未经 provenance 标记的内容混进 corpus。

agent 运行期间 heartbeat 延长 global lease。完成后 worker 还会在 reset Git baseline 前再确认 ownership;丢锁就不提交 baseline。只有 baseline reset 成功且仍持有 token,SQLite 才把 job 标成 succeeded,并把 selected_for_phase2 重写为这次 exact snapshots。最后 agent 被 shutdown。这个顺序避免一个过期 worker 抹掉后来 worker 的 diff 起点。

本节源码依据(5 处)

consolidation 写的是渐进披露语料

consolidation prompt 把 memory root 的角色写得比代码注释更完整:memory_summary.md 被设计成短而可路由的常驻摘要,MEMORY.md 是可 grep 的 handbook,raw_memories.mdrollout_summaries 是机械同步的输入,skills 只在有可复用 procedure 时出现。实际 runtime 仍有门禁:Memory read path 必须开启,summary 文件必须存在且非空,内容还会按 token limit 截断;不满足这些条件就不会贡献 developer fragment。

它还明确规定原 rollout 是 immutable evidence,不能由 Memory agent 回写。incremental update 首先读 workspace diff;删除的 summary 或 extension resource 只应删除由它独占支持的记忆,不能因为一条来源消失就把还有其他证据的整块 handbook 一并抹掉。

这就是 Memory 与 History 最本质的区别。History reconstruction 追求“这条 thread 当时的模型工作集是什么”;Memory consolidation 追求“哪些跨 thread 规律值得以更低成本带进未来任务”。前者不能接受自由改写,后者如果没有选择、合并和遗忘,反而失去作用。

本节源码依据(2 处)
本节源码依据(1 处)

读取、citation 与 usage 回写形成最后一段闭环

dedicated tools 读文件,不直接查询 Stage 1 table

dedicated_tools = true 时,extension 贡献 add-ad-hoc-note、list、read、search 四个 namespaced executor。local backend 的根固定在 $CODEX_HOME/memories,拒绝 parent、root、prefix component 和 hidden component,也逐级拒绝 symlink。工具面向文件 corpus,不向模型暴露任意 SQL query。

默认 read path 即便不开专用工具,也会把 summary 与“怎样先 grep MEMORY.md、何时再打开 rollout summary/skill”的规则放进 developer prompt。模型可能改用已存在的安全 shell 读文件;因此 read telemetry 同时识别专用 memory tools 和已知安全 shell command,而不是把任何包含 memories/ 的命令都算成有效使用。

本节源码依据(3 处)

citation 先验证 rollout id,usage 才能回到 DB

模型最终输出中的 <citation_entries> 只给人和客户端看“文件哪几行支持这句话”;真正用于 usage writeback 的是 <rollout_ids>,并兼容旧 <thread_ids>。parser 去重字符串 id,后续只把能解析为 ThreadId 的值交给 MemoryStore::record_stage1_output_usage。没有 rollout id 的纯文件 citation 仍算“回答使用过 Memory”,却没有哪一条 Stage 1 row 可以增加 usage。

core 在 completed response item 已经记录进会话后解析 citation。合法 thread ids 才触发 usage update;usage_countlast_usage 以后会影响 Phase 2 selection 和 retention。它们没有修改原 rollout,也没有把引用文本写回 History。

如果 disable_on_external_context 打开,completed response 周围的 tool search/web search 路径还会把当前 thread 标成 polluted。若它属于上次 Phase 2 selection,MemoryStore 会 enqueue forgetting,让下一轮文件 diff 有机会移除这条来源。这里处理的是 provenance 资格,不是撤销已经发给模型的外部结果。

本节源码依据(4 处)

指定实验:先分清测试没跑到断言,还是实现失败

计划指定的命令在第五部导言从固定源码 commit 导出的 disposable 副本里运行:

: "${ARCHIVE_CODEX_RS:?先执行第五部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-memories-write memories_startup_phase2_tracks_workspace_diff_across_runs

第一次在标准 workspace sandbox 里,nextest 确实选中了 1 个测试,但在测试第 66 行创建 Wiremock loopback server 时就失败了:

Starting 1 test across 1 binary (35 tests skipped)
TRY 1 FAIL ... memories_startup_phase2_tracks_workspace_diff_across_runs
Failed to bind an OS port for a mock server.: PermissionDenied
Summary ... 0 passed, 1 failed, 35 skipped

这个结果不能支持“workspace diff across runs 已通过”。失败发生在 fixture 启动阶段,后面的 Stage 1 seed、Phase 2 request、旧 summary 删除和 Git baseline reset 断言都没有执行。它也不是产品回归证据:环境拒绝 loopback bind,测试还没走到被测逻辑。

在明确获批本地端口绑定后,用同一条命令重跑,目标测试实际执行并通过:

Starting 1 test across 1 binary (35 tests skipped)
PASS ... startup_tests::memories_startup_phase2_tracks_workspace_diff_across_runs
Summary ... 1 test run: 1 passed, 35 skipped

两次结果要同时保留。普通沙箱那次只证明 filter 命中与环境边界;扩大到 loopback 权限后的 1/1 才证明测试体在这个 checkout 上走完。测试本身断言第二次 selection 同步后只保留 memory B、删除 memory A 的 raw/summary 输入,并把 workspace baseline reset;它不证明真实远端 model、跨机器并发或任意网络环境。

本节源码依据(1 处)

失败边界

  • feature 关闭、session ephemeral、source 是 non-root agent,或 state DB 缺失:startup 直接不创建 Phase 1/2 工作,不应诊断成“模型没总结好”。
  • candidate 的 memory_mode disabled/polluted、history 非 legacy、rollout 太新/太旧或 paginated:它不会进入 Stage 1;rollout 本身仍可由 History/Resume 使用。
  • Stage 1 model request 失败:job 进入 retry;旧成功 row 不会被半个新输出覆盖。
  • Stage 1 得到空 output:这是有 ownership 的成功路径,会删除旧 row 并触发后续 forgetting;不是静默保留旧 Memory。
  • Phase 2 拿不到 singleton lock、处于 retry/cooldown 或 active lease:本次不碰文件 workspace。
  • DB watermark 前进但 materialized Git diff 为空:Phase 2 直接记录 no-work success,不凭 watermark 强行调用 model。
  • consolidation agent 丢失 ownership:不能 reset baseline,也不能把自己的 selection 标为最新成功。
  • memory_summary.md 缺失或为空:read-path prompt 不注入;这和 Stage 1 rows 是否存在是两件事。
  • citation 只有文件行、没有合法 rollout id:可以展示文件 provenance,却不会给任意 Stage 1 row 写 usage。
  • usage writeback 成功:只改变 selection/retention feedback metadata,不改变 rollout History。

交给第 29 章的边界

Memory 跨 thread 复用的是提取后的文件知识。它的主动工作发生在 startup pipeline 和 future read path,完成后不会因为“还有一个长期目标”自动再开 turn。下一章的 Goal 正好相反:它不从旧 rollout 蒸馏知识,而是在同一个 thread 上保存 objective、status、token/time usage,并在 thread idle 时决定是否继续。

第 29 章接收这张 provenance 图,但要换掉 owner:

  1. Memory 的 Stage 1/Phase 2 job ownership 不等于 Goal 的 expected_goal_id 并发保护;
  2. Memory usage 是“哪些来源被引用”的反馈,Goal accounting 是“当前目标消耗了多少 turn token 与 wall-clock”;
  3. Memory 的文件 corpus 不属于 turn,Goal 的 durable row 同样不属于某一个 turn,但它会通过 process-local runtime attach 到 active turn;
  4. 两者都通过 ExtensionRegistry 接入,却不能因此统称为 Plugin、History 或 planner。

接下来沿 GoalRuntimeHandle 追一条更短但并发要求更高的链:explicit mutation -> GoalStore -> runtime accounting -> idle continuation -> ThreadGoalUpdated