# 记忆 · 从历史会话里提炼经验

> 打开 memories 后，每开一轮新对话，Codex 都会在后台把闲置已久的旧会话分两阶段提炼成 ~/.codex/memories 下的记忆：Phase 1 用一次模型调用把单个会话抽成原始记忆存进 SQLite，Phase 2 由一个锁死权限的整合子代理把它们归并成 MEMORY.md 与 memory_summary.md。之后每个会话把摘要注入开发者指令，模型引用了哪条记忆就给它记一次使用，决定它下次还能不能留下。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/memories
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 记忆 · 从历史会话里提炼经验

记忆是一个默认关闭的功能开关 `memories`（稳定）。打开后，Codex 会把你过去的会话提炼成一个本地的记忆目录，新会话开始时先读一份摘要，需要时再翻细节。TUI 的 `/memories` 可以分别开关“使用记忆”和“生成记忆”，或者清空全部记忆；相关配置见手册的[配置参考](https://daiw.net/manual/codex/config-reference)与[斜杠命令](https://daiw.net/manual/codex/slash-commands)。这一篇看记忆从哪里来、存在哪里、怎么被用上、怎么清掉。

## 三个 crate，读写分开

| crate | 目录 | 职责 |
| --- | --- | --- |
| `codex-memories-write` | `codex-rs/memories/write` | 后台管线：Phase 1 抽取、Phase 2 整合、记忆目录的 git 基线与清理 |
| `codex-memories-read` | `codex-rs/memories/read` | 解析回复里的记忆引用、读取行为的遥测分类 |
| `codex-memories-extension` | `codex-rs/ext/memories` | 扩展：把记忆摘要注入提示词，可选地提供专用记忆工具 |

数据分两处放。记录与任务在单独的 SQLite 库里：`memories_1.sqlite`（`memories.version = "v2"` 时是 `memories_v2_1.sqlite`），默认在 `~/.codex` 下，表是 `stage1_outputs`（每个会话一行原始记忆）和 `jobs`（任务租约与重试）。文件在 `~/.codex/memories/`（v2 是 `memories_v2/`），是给模型读的。

<Callout type="warn">
  `codex-rs/memories/README.md` 有两处过时：它说 Phase 1、Phase 2 的编排“still lives in `codex-core` under `codex-rs/core/src/memories/`”，但 v0.158.0 没有这个目录，编排就在 `memories/write` 里；它列的读取模板 `read/templates/memories/read_path.md` 实际在 `ext/memories/templates/memories/read_path.md`。README 里对两个阶段的描述大体仍与代码一致。
</Callout>

## 什么时候跑

README 说管线在根会话启动时触发，代码里的触发点其实是 app-server 的 `turn_start_inner`：一次 `turn/start` 真的开了新一轮（不是插进进行中的轮次）、并且配置了主执行环境，就调用 `start_memories_startup_task`。它在临时会话、功能没开、子代理会话时直接返回；每个要生成的版本各起一个后台任务（`memories.dual_write = true` 时 v1、v2 都跑），状态库打不开就跳过。任务里先建好目录（记忆根目录是符号链接会拒绝），然后按这个顺序走：

```rust
            // Clean memories to make preserve DB size. This does not consume tokens so can be
            // done before the quota check.
            phase1::prune(context.as_ref(), &config).await;

            if !guard::rate_limits_ok(&auth_manager, &config).await {
                context.counter(
                    MEMORY_STARTUP,
                    /*inc*/ 1,
                    &[("status", "skipped_rate_limit")],
                );
                return;
            }

            // Run phase 1.
            phase1::run(Arc::clone(&context), Arc::clone(&config)).await;
            // Run phase 2.
            phase2::run(context, config, parent_permission_profile).await;
```

（`codex-rs/memories/write/src/start.rs:75`）

`prune` 删掉当前没被 Phase 2 选用、最后一次使用（没用过就看会话更新时间）早于 `memories.max_unused_days`（默认 30 天）的原始记忆，每次最多 200 条。额度检查只在认证走 Codex 后端（如 ChatGPT 登录）时生效：已经撞上额度上限，或主、次额度窗口任一已用超过 `100 - min_rate_limit_remaining_percent`（默认要求剩余 25%），就不跑；其它认证方式查不到额度，按“允许”处理。每开一轮都会走到这里，靠数据库里的租约和锁保证不重复干活。

```mermaid
flowchart TB
  S[turn/start 开了新一轮] --> G{根会话 非临时<br/>memories 已开}
  G -->|否| X[不跑]
  G -->|是| P0[清理久未使用的记录<br/>检查额度]
  P0 --> P1[Phase 1 认领最多 2 个<br/>闲置 6 小时以上的会话]
  P1 --> M1[每个会话一次模型调用<br/>得到 raw_memory 与 rollout_summary]
  M1 --> DB[stage1_outputs 表]
  DB --> P2[Phase 2 抢全局锁<br/>按使用次数取前 256 条]
  P2 --> FS[同步到记忆目录<br/>与 git 基线比对]
  FS --> A[整合子代理改写<br/>MEMORY.md 与 memory_summary.md]
  A --> R[之后的会话<br/>摘要注入开发者指令]
  R --> C[回复里引用了记忆<br/>usage_count 加一]
  C --> DB
```

## Phase 1：一次模型调用，抽一个会话

`claim_stage1_jobs_for_startup` 从状态库的 `threads` 表里挑会话。来源只限 `INTERACTIVE_SESSION_SOURCES`（CLI、VS Code 和两个自定义来源 `atlas`、`chatgpt`，所以 `codex exec` 的会话不算），再加上这几条：

```rust
        builder.push(" AND threads.memory_mode = 'enabled'");
        builder
            .push(" AND threads.id != ")
            .push_bind(current_thread_id.as_str());
        builder
            .push(" AND ")
            .push("threads.updated_at_ms")
            .push(" >= ")
            .push_bind(max_age_cutoff);
        builder
            .push(" AND ")
            .push("threads.updated_at_ms")
            .push(" <= ")
            .push_bind(idle_cutoff);
```

（`codex-rs/state/src/runtime/memories.rs:241`）

即：会话的 `memory_mode` 是 `enabled`、不是当前会话、最近 `max_rollout_age_days`（默认 10 天）内动过、但已闲置 `min_rollout_idle_hours`（默认 6 小时）以上，并且在上次抽取之后又有更新。按更新时间倒序最多扫 5000 个，每次最多认领 `max_rollouts_per_startup`（默认 2）个，每个带 1 小时租约。

抽取不开线程，而是直接用 `ModelClient` 发一次请求：读出会话的 rollout，丢掉开发者消息、推理、压缩记录等，只留用户与助手消息、工具调用和输出，序列化后过一遍 `redact_secrets` 脱敏；系统提示是 `stage_one_system.md`（开头就是 “Memory Writing Agent: Phase 1”，并说明没有值得记的就什么都别写），输出用严格的 JSON schema 约束成 `raw_memory`、`rollout_summary`、`rollout_slug` 三个字段，结果再脱敏一次。模型默认取模型提供方给的偏好（默认实现是 `gpt-5.6-luna`，Bedrock 另有映射），推理强度 low，可用 `memories.extract_model` 改；最多 8 个会话并发。成功的写进 `stage1_outputs`，没产出的记为 `succeeded_no_output`，失败的 1 小时后重试。

## Phase 2：一个锁死权限的整合子代理

Phase 2 先抢全局锁：已有人在跑、正在退避、或者上次成功不到 6 小时，就跳过。拿到锁后：

1. 确保记忆根目录是一个 git 仓库，作为比对基线；
2. 从 `stage1_outputs` 里只看最近 `max_unused_days` 内用过（没用过就看会话更新时间）的记录，按 `usage_count` 从高到低、再按最近使用时间排序，取前 `max_raw_memories_for_consolidation`（默认 256）条，同步成 `rollout_summaries/` 下的逐会话摘要和合并的 `raw_memories.md`，顺带删掉 `extensions/` 下超过 7 天的资源文件；
3. 与基线做 git diff：没有变化、且成品文件都合法，就直接记成功；否则把差异写进 `phase2_workspace_diff.md`（上限 4 MiB），启动整合子代理。

整合子代理是一个内部线程（`SessionSource::Internal(InternalSessionSource::MemoryConsolidation)`），工作目录就是记忆根目录，配置被这样收紧：

```rust
        agent_config.cwd = root.clone();
        // Consolidation threads must never feed back into phase-1 memory generation.
        agent_config.ephemeral = true;
        agent_config.memories.generate_memories = false;
        agent_config.memories.use_memories = false;
        // Background memory work must not send user-facing completion notifications.
        agent_config.notify = None;
        agent_config.include_apps_instructions = false;
        agent_config.mcp_servers = Constrained::allow_only(HashMap::new());
        // Approval policy
        agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
        // Consolidation runs as an internal worker and must not recursively delegate.
        let _ = agent_config.features.disable(Feature::Collab);
        let _ = agent_config.features.disable(Feature::MemoryTool);
        let _ = agent_config.features.disable(Feature::Apps);
        let _ = agent_config.features.disable(Feature::Plugins);
```

（`codex-rs/memories/write/src/phase2.rs:304`）

父会话用 Codex 管理的沙箱时，子代理只能写记忆根目录、不能联网；父会话关了沙箱则沿用父会话的选择。模型默认同样取提供方的偏好（默认实现是 `gpt-5.6-terra`，可用 `memories.consolidation_model` 改），推理强度 medium。提示词 `consolidation.md` 要它维护几样东西：必须以一行 `v1` 开头、会被注入提示词的 `memory_summary.md`，供检索的手册 `MEMORY.md`，以及可选的 `skills/`。子代理跑的时候每 90 秒给锁续一次租约；结束后校验成品，重置 git 基线，把用到的记录标成 `selected_for_phase2`，任务记成功。

## 下一个会话怎么用上

读的一侧是扩展（机制见[上一篇](https://daiw.net/manual/codex-source/extension-api)）。`codex-memories-extension` 在功能开着、且 `memories.use_memories` 为真时，作为 `ContextContributor` 读出 `memory_summary.md`，截到 2500 token，填进模板 `read_path.md`，作为一段开发者指令放进上下文。模板告诉模型：摘要已经给你了；`MEMORY.md` 是可搜索的主索引；再往下是 `skills/` 和 `rollout_summaries/`；动手前先做一次“快速记忆检索”，最好不超过 4 到 6 步。模型用普通的 shell 工具去读这些文件；打开 `memories.dedicated_tools` 则会多一组 `memories` 命名空间的工具：`list`、`read`、`search`、`add_ad_hoc_note`。

回路靠引用闭合。模板要求用到记忆时，在最终回复末尾附一个 `<oai-mem-citation>` 块，列出引用的文件行号和 `rollout_ids`。内核在条目完成时把这个块从可见文本里剥掉，用 `codex-memories-read` 解析出 id，再调 `record_stage1_output_usage` 给对应记录的 `usage_count` 加一、把 `last_usage` 设为当前时间（`codex-rs/core/src/stream_events_utils.rs:201`）。被引用得多的记忆在 Phase 2 排得靠前，久不被引用的超过 `max_unused_days` 就被清理掉。用户明确要求改记忆时，模板要求模型只往 `extensions/ad_hoc/notes/` 写一个小笔记文件，由下一次 Phase 2 归并，而不是直接改记忆文件。

## 怎么关、怎么清

- `memories.generate_memories = false`：新建的会话在状态库里记为 `memory_mode = "disabled"`，Phase 1 不会挑它；
- `memories.use_memories = false`：不再注入摘要；
- `memories.disable_on_external_context = true`：会话里出现网页搜索、工具搜索等外部内容时，把它标成 `polluted`，同样不参与抽取；
- 清空：TUI `/memories` 里的 “Reset all memories” 走 app-server 的实验方法 `memory/reset`；隐藏的 CLI 子命令 `codex debug clear-memories` 做同样的事。两者都清空两个版本记忆库里的 `stage1_outputs` 与记忆相关的 `jobs`，再清空 `memories`、`memories_v2`、`memories_extensions` 三个目录的内容（目录本身保留，是符号链接的拒绝处理）。

## 和《从 LLM 到 Coding Agent》对照

那本的[会话历史](https://daiw.net/manual/llm-to-agent/session-history)用 append-only 的 JSONL 把一次会话落盘，解决的是“活过进程”；Codex 的记忆以这些 rollout 为原料，把许多次会话蒸馏成跨会话的经验。注入方式也对得上那本的[上下文注入](https://daiw.net/manual/llm-to-agent/context-injection)：项目记忆一类稳定内容前置，按相关性检索的细节按需取用。Codex 把截断后的 `memory_summary.md` 放进开发者指令，`MEMORY.md` 与逐会话摘要则让模型自己去翻。

对照 [Grok Build 的记忆](https://daiw.net/manual/grok-build/memory)，两边骨架很像：grok 的 `/flush` 把单个会话写成日志、`/dream` 把多次会话合并进 `MEMORY.md`，对应 Codex 的 Phase 1 与 Phase 2。差别在触发与检索：grok 在压缩前、空闲时或手动触发，检索用 SQLite 的 FTS5 加向量混合搜索；Codex 只在新一轮开始时于后台处理闲置已久的旧会话，检索交给模型自己搜文件，再用回复里的引用给记忆计数。

---

上一篇：[扩展 API · goal 与内部扩展点](https://daiw.net/manual/codex-source/extension-api) · 下一篇：[从别的 agent 迁移 · 导入 Claude Code 与 Cursor 的配置](https://daiw.net/manual/codex-source/external-agent-migration)
