夜雨聆风学习资料网

ARTICLE · 1278852

Codex 源码-记忆系统——两阶段 LLM 蒸馏与 git baseline 工作区

Codex 源码-记忆系统——两阶段 LLM 蒸馏与 git baseline 工作区

Codex 源码解析系列

第 36 讲:记忆系统——两阶段 LLM 蒸馏与 git baseline 工作区

基于 OpenAI Codex 源码 · 2026-10-30

💡 本讲一句话:Codex 的记忆是一条"两阶段蒸馏流水线"——Phase1 用便宜模型并行把旧会话压成 raw memory,Phase2 拿全局锁、靠 git diff 判断有没有变化,再派一个被彻底关进沙箱的 agent 把它们合并成每次新会话都注入的摘要。读完你能说清一条记忆从 rollout 文件到 prompt 的全路径。

一、启动闸门:谁有资格触发记忆流水线

前面几讲拆过 rollout(第 40 讲会细讲录制)、state runtime store(第 29 讲的 SQLite 状态库)。记忆系统正好架在这两块之上:输入是旧会话的 rollout 文件,账本记在 state DB。整条流水线的入口只有一个函数——start_memories_startup_task(memories/write/src/start.rs 第 24-94 行),由 app-server 的 turn_processor 在根会话启动时调用:

📄 codex-rs/memories/write/src/start.rs (第 33-92 行)

pub fn start_memories_startup_task( // 入口:根会话启动时由 app-server 调用,整条记忆流水线从这里开始    thread_manager: Arc<ThreadManager>, // 线程管理器:Phase2 要靠它派生一个独立的"整理 agent"    auth_manager: Arc<AuthManager>, // 凭证管理:限流护栏和模型 provider 都依赖登录态    thread_id: ThreadId, // 触发流水线的根会话 id(后面认领任务时当 worker id 用)    thread: Arc<CodexThread>, // 根会话句柄:Phase1 采样要读它的上下文    config: Arc<Config>, // 全量配置:模型、记忆参数、feature flag 都从这里取    parent_permission_profile: PermissionProfile, // 父会话权限画像:决定整理 agent 的沙箱上限(第五节)    source: &SessionSource, // 会话来源:子代理会话不允许触发,防止重复抽取) {    if config.ephemeral // 临时会话(如一次性 exec)不产生记忆——没有长期价值        || !config.features.enabled(Feature::MemoryTool) // feature flag 关闭 → 整条流水线禁用        || source.is_non_root_agent() // 子代理一律排除:只有根会话能触发,避免 N 个子 agent 各抽一遍    {        return; // 三个条件命中任意一个就静默跳过——后台任务不打扰用户    }    let versions = if config.memories.dual_write { // dual_write 模式:V1/V2 两条流水线并行跑,用于灰度对比        vec![MemoryVersion::V1, MemoryVersion::V2]    } else {        vec![config.memories.version] // 常规模式:只跑配置里指定的版本    };    for version in versions { // 每个版本有独立记忆根目录(memories/ vs memories_v2/),互不污染        let mut pipeline_config = config.as_ref().clone(); // clone 一份配置,让每条流水线各自改自己的 version 字段        pipeline_config.memories.version = version; // 把这条流水线钉死在目标版本上        let context = Arc::new(MemoryStartupContext::new( // 组装启动上下文:线程管理、凭证、遥测打包在一起            Arc::clone(&thread_manager), // 共享同一个线程管理器(Arc clone,不深拷贝)            Arc::clone(&auth_manager), // 共享登录凭证            thread_id, // 记录是哪个根会话触发的            Arc::clone(&thread), // 根会话句柄            config.as_ref(), // 已钉版本的配置            source.clone(), // 会话来源(遥测归因用)        ));        tokio::spawn(async move { // 后台任务:完全不阻塞用户当前这一轮对话            if context.memory_store().await.is_none() { // state DB 不可用 → 流水线没法跑(认领任务全靠它)                warn!("state db unavailable for memories startup pipeline; skipping"); // 记日志退出,不向调用方抛错                return;            }            let root = config.codex_home.join(config.memories.version.directory_name()); // 记忆根:~/.codex/memories 或 memories_v2            if let Err(err) = ensure_layout(&root).await { // 建目录结构,且拒绝符号链接根(防止记忆文件被重定向到别处)                warn!("failed preparing memories root: {err}");                return;            }            phase1::prune(context.as_ref(), &config).await; // 先清理过期 raw memory——纯 DB 操作不烧 token,所以放在限流检查之前            if !guard::rate_limits_ok(&auth_manager, &config).await { // 限流护栏:Codex 配额快用完就跳过本轮(下面细看)                context.counter(MEMORY_STARTUP, /*inc*/ 1, &[("status", "skipped_rate_limit")]); // 记一条 skipped 指标,方便观测                return;            }            phase1::run(Arc::clone(&context), Arc::clone(&config)).await; // Phase1:并行抽取 raw memory(第二节)            phase2::run(context, config, parent_permission_profile).await; // Phase2:全局锁下合并成可注入摘要(第四节)        });    }}

注意 dual_write 这个细节:V1 和 V2 不是"升级替换",而是两条独立流水线各写各的目录(memories/ vs memories_v2/),读路径按配置选一个。这是典型的灰度迁移设计——新格式跑稳之前,旧格式随时可以回退。

为什么这样设计:记忆生成是"烧 token 的后台任务",所以入口闸门要同时回答三个问题:① 该不该跑(ephemeral/flag/子代理三重排除);② 跑得动吗(state DB、目录布局);③ 烧得起吗(限流护栏)。顺序也讲究——prune 不花钱所以放在限流检查之前,先做免费的清理。

限流护栏本身很克制(memories/write/src/guard.rs 第 51-66 行):

📄 codex-rs/memories/write/src/guard.rs (第 51-66 行)

fn snapshot_allows_startup(snapshot: &RateLimitSnapshot, min_remaining_percent: i64) -> bool { // 判断当前配额是否够跑记忆生成    if snapshot.rate_limit_reached_type.is_some() { return false; } // 已触发硬限流 → 直接跳过,不浪费一次请求    let max_used_percent = 100.0 - min_remaining_percent.clamp(0, 100) as f64; // 把"最少保留百分比"换算成"最多允许用掉百分之几"    window_allows_startup(snapshot.primary.as_ref(), max_used_percent) // 主窗口(5 小时配额)必须达标        && window_allows_startup(snapshot.secondary.as_ref(), max_used_percent) // 次窗口(周配额)也要达标——两个窗口都够才放行}fn window_allows_startup(window: Option<&RateLimitWindow>, max_used_percent: f64) -> bool { // 单个窗口的判定    match window {        Some(window) => window.used_percent <= max_used_percent, // 已用百分比 ≤ 阈值 → 允许        None => true, // 该窗口没有数据 → 不拦(fail-open:宁可多跑一次,也不因缺数据卡死功能)    }}

为什么这样设计:记忆生成是"锦上添花"型任务——用户配额快见底时,把 token 留给交互对话才是正事。所以护栏的语义是"剩余配额低于阈值就主动让路",而且 fail-open:查不到限流数据时不阻塞(本地模型、第三方 provider 根本没有这个接口)。这是后台任务与前台体验冲突时的标准取舍。

二、Phase1:并行抽取 raw memory

Phase1 的输入是 state DB 里"够老、够闲、来源合法"的历史会话(claim_stage1_jobs_for_startup,state/src/runtime/memories.rs 第 151 行起:按 max_age_days / min_rollout_idle_hours / allowed_sources 过滤,只认 INTERACTIVE_SESSION_SOURCES——交互会话才有记忆价值)。每个被认领的会话是一次独立的 LLM 调用,并发上限 8(stage_one::CONCURRENCY_LIMIT):

📄 codex-rs/memories/write/src/phase1.rs (第 180-192 行)

    futures::stream::iter(claimed_candidates) // 把认领到的会话列表变成流        .map(|claim| { // 每个 claim(一个历史会话)变成一个独立 async 任务            let context = Arc::clone(&context); // 共享启动上下文(Arc clone,不深拷贝)            let config = Arc::clone(&config); // 配置同理            let stage_one_context = stage_one_context.clone(); // 遥测上下文 Clone 很便宜            async move { job::run(context.as_ref(), config.as_ref(), claim, &stage_one_context).await } // 执行单个会话的抽取        })        .buffer_unordered(crate::stage_one::CONCURRENCY_LIMIT) // 并发上限 8:并行但不失控,避免打爆模型 API        .collect::<Vec<_>>() // 收集全部结果——顺序无所谓,每个 job 写自己的 DB 行,互不依赖        .await

为什么这样设计:buffer_unordered(而非 buffer)意味着谁先完成谁先返回——慢会话不拖累快会话的落库。配合 DB 层的 lease(JOB_LEASE_SECONDS=3600s),进程中途崩掉也不会丢任务:租约过期后别的 worker 可以重新认领。

单个会话的抽取核心是 sample()(phase1.rs 第 259-320 行)——读 rollout、按版本序列化、拼结构化 prompt、流式调用:

📄 codex-rs/memories/write/src/phase1.rs (第 259-320 行,节选)

    async fn sample( // 单会话抽取核心:读 rollout → 序列化 → 拼 prompt → 调模型 → 解析结构化输出        context: &MemoryStartupContext,        config: &Config,        rollout_path: &Path, // 该会话的 rollout 录制文件路径(JSONL)        rollout_cwd: &Path, // 当时的工作目录——帮模型判断这条记忆属于哪个项目        rollout_git_branch: Option<&str>, // git 分支:V2 输入会带上,用于限定记忆的适用范围        stage_one_context: &StageOneRequestContext,    ) -> anyhow::Result<(StageOneOutput, Option<TokenUsage>)> {        let (rollout_items, _, _) = RolloutRecorder::load_rollout_items(rollout_path).await?; // 反序列化整段会话录制(复用 rollout crate 的加载逻辑)        let rollout_contents = match config.memories.version { // V1/V2 两套序列化策略            MemoryVersion::V1 => serialize_filtered_rollout_response_items(&rollout_items)?, // V1:扁平序列化全部 item,简单直接            MemoryVersion::V2 => crate::rollout_input::serialize_tiered_input( // V2:分层预算分配(第三节细拆)                &rollout_items,                crate::prompts::rollout_token_limit(&stage_one_context.model_info), // token 上限 = 模型上下文窗口的 70%(留 30% 给系统提示和输出)            )?,        };        let mut prompt = Prompt::default(); // 构造一个"结构化输出"请求        prompt.input = match config.memories.version { // V1 单条 user message;V2 切成多条 contextual fragment(避免单条超长)            MemoryVersion::V1 => vec![ResponseItem::Message { /* …user 消息,内容即 input_text… */ }],            MemoryVersion::V2 => crate::rollout_input::extraction_messages(&input_text), // 按字符边界切成 ≤8900 字节的多条片段        };        prompt.base_instructions = BaseInstructions { // 系统提示是内嵌模板文件(include_str!),版本各一份            text: match config.memories.version {                MemoryVersion::V1 => crate::stage_one::PROMPT, // V1:templates/memories/stage_one_system.md                MemoryVersion::V2 => include_str!("../templates/memories/stage_one_system_v2.md"), // V2 模板强调"写任务史不写用户画像、区分证据与猜测"            }.to_string(),            provenance: None,        };        prompt.output_schema = Some(output_schema(config.memories.version)); // 强制模型按 JSON Schema 输出(raw_memory + rollout_summary)        prompt.output_schema_strict = true; // strict 模式:不允许多余字段——下游解析依赖固定形状        let (result, token_usage) = context.stream_stage_one_prompt(config, &prompt, stage_one_context).await?; // 流式调用抽取模型(Low reasoning effort,控制成本)        let output = StageOneOutput::parse(&result, config.memories.version)?; // 解析并校验结构化输出        Ok((output, token_usage)) // 连同 token 用量一起返回——后面要进指标账本    }

V2 的系统提示(templates/memories/stage_one_system_v2.md)里有一段很值得读,它直接规定了记忆的"认识论标准":"Write task history, not a user profile… Distinguish observed evidence, user-authorized actions, implemented changes, proposals, hypotheses, and uncertainty. Never claim completion, verification, deployment, ownership, a stable preference, or user approval beyond what the evidence supports."——写任务史,不写用户画像;证据、授权动作、猜测必须分开标注。甚至给了反例:用户说"show me the plan before editing this"只能记成"该次要求先看计划",不能推广成"用户偏好先看计划"。

为什么这样设计:记忆最大的风险不是"没记住"而是"过度概括"——把一次性的要求记成永久偏好,会误导未来所有会话。所以 Codex 把防幻觉写进了 prompt 的硬约束里:保留不确定性、区分证据等级、禁止无依据的完成声明。这是 LLM 记忆系统里少见的"保守主义工程"。

抽取结果还要过一道空值检查(phase1.rs 第 230-241 行):

📄 codex-rs/memories/write/src/phase1.rs (第 230-256 行)

        if stage_one_output.raw_memory.as_ref().is_some_and(String::is_empty) // raw memory 是空串 → 视为无产出            || stage_one_output.rollout_summary.is_empty() // 或者摘要为空 → 这个会话没有值得记的东西        {            return JobResult { outcome: result::no_output(context, claimed_thread.id, &claim.ownership_token).await, token_usage }; // 释放认领(标记 no-output),不写 DB——避免垃圾记忆污染 Phase2 输入        }        JobResult { // 有产出 → 落库:raw_memory + rollout_summary + slug 一起写入 stage1_outputs 表            outcome: result::success(                context,                claimed_thread.id, // 关联源会话 id——将来引用回写"使用记录"就靠它(第六节)                &claim.ownership_token, // 所有权令牌:只有认领者能写这行,防并发竞争                claimed_thread.updated_at.timestamp(), // 输入水位线用会话更新时间戳                stage_one_output.raw_memory.as_deref().unwrap_or_default(),                &stage_one_output.rollout_summary,                stage_one_output.rollout_slug.as_deref(),            ).await,            token_usage,        }

为什么这样设计:"空产出"是一等公民状态(no_output),而不是失败——模型完全可能看完一个会话后认为没什么可记。把它和 failed 分开计数,指标上才能区分"模型没抽到东西"和"调用挂了",两者的运维含义完全不同。

三、分层预算分配:serialize_tiered_input

V2 的序列化是整个 Phase1 最精巧的部分(memories/write/src/rollout_input.rs,321 行)。问题很实际:一个长会话可能几十万 token,但抽取模型只有 70% 上下文窗口可用——哪些内容必须保留、哪些可以砍?Codex 的答案是给每条 item 打"层级标签",按优先级分配预算。先给每条消息定级(第 109-163 行):

📄 codex-rs/memories/write/src/rollout_input.rs (第 109-163 行,节选)

        let tier = match &item { // 给每条 item 定级——决定预算紧张时谁先被保留(六级见下方卡片)            ResponseItem::FunctionCallOutput { .. } if question.is_some() => Tier::Human, // 用户对模型提问的回答 → 最高优先级"人类证据"(前面已把问答配对)            ResponseItem::AgentMessage { .. } => Tier::OtherAgent, // 其他 agent 发来的消息(多智能体场景)            ResponseItem::Message { role, content, phase, internal_chat_message_metadata_passthrough, .. } => { // 普通消息:按角色和元数据细分                let agent_content = internal_chat_message_metadata_passthrough // 先看元数据标记:是否带 multi_agent.* 前缀(agent 间通信)                    .as_ref()                    .and_then(|metadata| metadata.content_item_kinds.as_ref())                    .is_some_and(|kinds| kinds.iter().any(|kind| kind.0.starts_with("multi_agent."))) // 命中 → 按 agent 消息处理                    || InterAgentCommunication::is_message_content(content) // 或者内容本身就是 agent 间通信结构                    || content.iter().any(|part| match part { // 再兜底:文本前缀嗅探(兼容旧格式)                        ContentItem::InputText { text } | ContentItem::OutputText { text } => {                            let text = text.trim_start();                            text.starts_with("<subagent_notification>") // subagent 通知标记 → OtherAgent 级                                || (text.starts_with("Message Type:") && /* …Task name/Sender/Payload 三行头格式… */) // 旧版 agent 消息的固定头部                        }                        _ => false,                    });                if agent_content { Tier::OtherAgent } // agent 间通信 → 第二优先级(信息密度高但不是用户原话)                else if role == "user" { // user 消息:要区分"真人输入"和"harness 注入的上下文"                    let contextual = internal_chat_message_metadata_passthrough.as_ref() // user 角色也可能是框架注入的(如环境上下文)                        .and_then(|metadata| metadata.content_item_kinds.as_ref())                        .is_some_and(|kinds| !kinds.is_empty() && kinds.iter().all(|kind| !kind.0.starts_with("user."))) // 元数据不带 user.* 前缀 → 不是真人输入                        || content.iter().any(|part| matches!(part, ContentItem::InputText { text } // 或者文本被 <environment_context> 标记包裹                            if matches_marked_fragment(text, "<environment_context>", "</environment_context>"))); // 命中 → 框架上下文级(最低档之一)                    if contextual { Tier::Context } // 环境/系统注入的"伪 user"消息 → 低优先级                    else { Tier::Human } // 真人说的话 → 最高优先级,预算再紧也先保它                } else if matches!(phase, Some(MessagePhase::Commentary)) { Tier::Commentary } // assistant 的过程性评论(思考旁白)                else { Tier::Final } // assistant 的最终回答            }            _ => Tier::Tool, // 工具调用与输出 → 最低级(最冗长、信息密度最低,最先被砍)        };

六级优先级一览(预算紧张时从下往上砍):

① Human(最高)

来源 真人 user 消息 + 用户对模型提问的回答(问答自动配对成一条)

理由 记忆的核心是"用户要什么、纠正了什么",这是唯一不可再生的证据

② Final

来源 assistant 的最终回答(非 commentary)

理由 代表"当时实际交付了什么",是任务史的主干

③ OtherAgent

来源 多智能体场景下其他 agent 的消息(元数据标记或前缀嗅探识别)

理由 信息密度高,但不是用户原话——排真人之后

④ Commentary

来源 assistant 的过程性评论(MessagePhase::Commentary)

理由 旁白性质,有参考价值但可丢弃

⑤ Context

来源 harness 注入的"伪 user"消息(environment_context、非 user.* 元数据)

理由 框架产物,任何会话都类似——记忆价值最低之一

⑥ Tool(最低)

来源 工具调用与输出(单条先截到约 2000 token,再参与全局预算)

理由 最冗长、可复现性最强(重跑命令就能拿到),最先被砍

定级之后是预算分配循环(第 199-245 行)——注意它的遍历顺序和渲染顺序是故意不一致的:

📄 codex-rs/memories/write/src/rollout_input.rs (第 199-245 行,节选)

    let mut remaining = approx_bytes_for_tokens(token_limit).saturating_sub(OMITTED.len()); // 总预算:token 上限换算成字节,先预留"省略占位符"的空间    let mut selected = vec![None; rows.len()]; // 占位数组:None 表示该行最终被省略    for tier in [Tier::Human, Tier::Final, Tier::OtherAgent, Tier::Commentary, Tier::Context, Tier::Tool] { // 按优先级从高到低分配——高级别先吃饱        for (index, (row_tier, text)) in rows.iter().enumerate().rev() { // 同级内部从新到旧(倒序)取——最近的证据更有价值            if *row_tier != tier || remaining <= OMITTED.len() + TRUNCATION_RESERVE_BYTES { continue; } // 不是这一级、或预算不够放一条 → 跳过            let budget = remaining - OMITTED.len(); // 给这一行的可用额度            let text = if text.len() <= budget { text.clone() } else { truncate_text(&text, TruncationPolicy::Bytes(budget - TRUNCATION_RESERVE_BYTES)); }; // 超预算 → 硬截断到刚好放下            remaining = remaining.saturating_sub(text.len() + OMITTED.len()); // 扣减剩余预算(saturating 防下溢)            selected[index] = Some(text); // 标记为选中        }    }    let mut rendered = String::new(); // 最终输出缓冲    let mut gap = false; // 是否正处于"省略区间"内(避免连续插占位符)    for row in selected { // 按原始时间顺序重新拼装——模型看到的是会话时间线,不是优先级排序        match row {            Some(text) => { rendered.push_str(&text); gap = false; } // 选中行:原样追加            None if !gap => { rendered.push_str(OMITTED); gap = true; } // 被省略的区间:插一个 "[... response items omitted ...]" 标记,告诉模型这里缺东西            None => {} // 连续省略不重复占位符        }    }

为什么这样设计:这是"选择顺序 ≠ 呈现顺序"的经典手法——分配预算时按价值排序,渲染输出时恢复时间线。如果直接按优先级重排,模型会把"用户最后说的话"误读成"最先说的";保留原始顺序 + 显式省略标记,模型才能正确理解证据的时序关系。另外工具输出有双重截断(单条 2000 token → 全局预算),防止一条巨大的 grep 结果吃掉整个窗口。

四、Phase2:全局锁 + git baseline 工作区

Phase2(consolidation)把散落的 raw memory 合并成一份可注入的摘要。它比 Phase1 危险得多——要写文件、要跑 agent,所以整个流程被设计成一个严格线性的状态机(memories/write/src/phase2.rs 第 49-188 行),每一步失败都走统一的 job::failed 出口:

📄 codex-rs/memories/write/src/phase2.rs (第 60-187 行,节选)

pub async fn run( // Phase2 主流程:严格线性,任一步失败 → 标记 job failed(带重试延迟)后返回    context: Arc<MemoryStartupContext>,    config: Arc<Config>,    parent_permission_profile: PermissionProfile, // 父会话权限画像:决定整理 agent 的沙箱上限(第五节)) {    let root = config.codex_home.join(config.memories.version.directory_name()); // 记忆根目录(memories/ 或 memories_v2/)    // 1. Claim the global Phase 2 lock before touching the memory workspace.    let claim = match job::claim(context.as_ref(), &db).await { // 全局锁:跨机器、跨会话只允许一个 consolidation 在跑;输家拿到 Skipped* 直接退出        Ok(claim) => claim,        Err(e) => { context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", e)]); return; } // 认领失败(cooldown/running/retry_unavailable)→ 记指标退出,不重试    };    if let Err(err) = prepare_memory_workspace(&root).await { // 2. 确保记忆根有 git baseline 仓库——用来 diff"这次输入到底变没变"        job::failed(context.as_ref(), &db, &claim, "failed_prepare_workspace").await; return; // 失败 → 标记 failed(3600s 后允许重试)并退出    }    let Some(agent_config) = agent::get_config(config.as_ref(), parent_permission_profile, context.provider()) else { // 3. 构造整理 agent 的"锁死配置"(第五节细拆)        job::failed(context.as_ref(), &db, &claim, "failed_sandbox_policy").await; return;    };    let raw_memories = match db.get_phase2_input_selection(max_raw_memories, max_unused_days).await { // 4. 从 DB 取 Phase1 产出:条数封顶 + 按"最近使用天数"过滤        Ok(raw_memories) => raw_memories,        Err(err) => { job::failed(context.as_ref(), &db, &claim, "failed_load_stage1_outputs").await; return; }    };    let new_watermark = get_watermark(claim.watermark, &raw_memories); // 新水位线:本批输入的最大更新时间戳——记录"合并到哪了"    if let Err(err) = sync_phase2_workspace_inputs(&root, &raw_memories, &config).await { // 5. 把 DB 数据落成工作区文件(rollout_summaries/ + raw_memories.md),并清理过期 extension 资源        job::failed(context.as_ref(), &db, &claim, "failed_sync_workspace_inputs").await; return;    }    let workspace_diff = match memory_workspace_diff(&root).await { // 6. git diff 对比 baseline:输入文件到底有没有变化?        Ok(diff) => diff,        Err(err) => { job::failed(context.as_ref(), &db, &claim, "failed_workspace_status").await; return; }    };    if !workspace_diff.has_changes() && validate_consolidation_artifacts_for_version(&root, config.memories.version).await.is_ok() { // 没变化且现有产物合法 → 根本不用调模型        job::succeed(context.as_ref(), &db, &claim, new_watermark, &raw_memories, "succeeded_no_workspace_changes").await; return; // 直接标记成功(水位线照常推进)——这是 Phase2 最大的省钱点    }    if let Err(err) = write_workspace_diff(&root, &workspace_diff).await { // 7. 把 diff 落盘成 phase2_workspace_diff.md,让 agent 先读"这次改了什么"再动手        job::failed(context.as_ref(), &db, &claim, "failed_workspace_diff_file").await; return;    }    let prompt = agent::get_prompt(&root, config.memories.version); // 8. 构造 consolidation prompt(模板 + 记忆根路径)    let agent = match context.spawn_consolidation_agent(agent_config, prompt).await { // 派生一个独立的 Codex 线程当"整理工人"        Ok(agent) => agent,        Err(err) => { job::failed(context.as_ref(), &db, &claim, "failed_spawn_agent").await; return; }    };    agent::handle(Arc::clone(&context), claim, new_watermark, raw_memories.clone(), root, config.memories.version, agent, phase_two_e2e_timer); // 9. 交给后台任务:心跳轮询 → 产物校验 → baseline 重置 → 标记成功}

记忆目录里的关键产物(谁生产、谁消费):

文件 / 目录
生产者
消费者
raw_memories.md
Phase1 LLM 抽取(V1)
Phase2 整理 agent
rollout_summaries/
Phase1(每会话一个摘要文件)
Phase2 + 未来 agent 检索
memory_summary.md / MEMORY.md
Phase2 整理 agent(≤10KB)
每个新会话的 prompt 注入
phase2_workspace_diff.md
Phase2 流程(git diff,≤4MB)
整理 agent 动手前先读它
extensions/
外部扩展(7 天保留期)
Phase1/Phase2 prompt 引用其 instructions.md

为什么这样设计:三个关键决策。① 全局锁 + lease(3600s)+ watermark:consolidation 是"读全部输入、写全部输出"的重操作,并发跑必然互相覆盖;lease 让崩溃的 worker 自动释放,watermark 记录进度避免重复劳动。② git baseline diff 做"变化检测":文件 mtime 不可靠(sync 会刷新),而 git 只关心内容——输入没变就跳过模型调用,这是把 LLM 成本从 O(启动次数) 降到 O(真实变化次数)。③ "no changes → succeed"也算成功并推进水位线:否则每次启动都会重新走一遍完整流程。

agent 跑完之后还有一道收尾(phase2.rs agent::handle,第 362-469 行):先 shutdown_consolidation_agent(10s 超时),再 validate_consolidation_artifacts_for_version 校验产物存在且合法,然后重新 heartbeat 确认自己还持有锁才允许 reset_memory_workspace_baseline——注释写得很直白:"Do not reset the workspace baseline if we lost the lock"。丢锁后重置 baseline 会吞掉另一个 worker 的未提交工作。

为什么这样设计:这是分布式系统里经典的"写完成前二次确认所有权"——lease 可能在你干活期间过期被别人抢走,此时你的产物已经落盘但 baseline 不能动。宁可让这次结果作废(下轮重来),也不能破坏并发正确性。

五、整理 agent 的"锁死配置"

Phase2 派生的 consolidation agent 是一个完整的 Codex 线程——有 shell、能读写文件。所以它的配置必须逐条收紧(phase2.rs agent::get_config,第 290-350 行),每一行都是一道安全边界:

📄 codex-rs/memories/write/src/phase2.rs (第 290-350 行,节选)

    pub(super) fn get_config( // 为整理 agent 构造"锁死配置"——每一行都是安全边界        config: &Config,        parent_permission_profile: PermissionProfile,        provider: &dyn ModelProvider,    ) -> Option<Config> {        let root = config.codex_home.join(config.memories.version.directory_name()); // 唯一允许写入的目录:记忆根        let mut agent_config = config.clone(); // 从父配置拷贝起步,再逐项收紧        agent_config.cwd = root.clone(); // 工作目录钉死在记忆根——shell 命令默认在这里执行        agent_config.ephemeral = true; // 临时会话:它自己的 rollout 不录制(防止 worker 的产出反过来成为 Phase1 输入)        agent_config.memories.generate_memories = false; // 关掉它自己的记忆生成——否则无限递归(worker 生记忆 → 再触发一个 worker)        agent_config.memories.use_memories = false; // 也不注入现有记忆:整理必须基于 raw 输入,而不是旧摘要套娃        agent_config.notify = None; // 后台工作绝不发用户可见的完成通知        agent_config.include_apps_instructions = false; // 不带 apps 指令——与整理任务无关        agent_config.mcp_servers = Constrained::allow_only(HashMap::new()); // MCP server 全禁:worker 只需要读写本地文件        agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); // 永不请求审批:没有人在场,而且它本就不该需要        let _ = agent_config.features.disable(Feature::Collab); // 禁用多智能体协作——worker 不得再委派子任务(递归爆炸)        let _ = agent_config.features.disable(Feature::MemoryTool); // 禁用记忆工具(list/read/search):输入已同步到磁盘,直接 shell 读即可        let _ = agent_config.features.disable(Feature::Apps); // 禁 apps        let _ = agent_config.features.disable(Feature::Plugins); // 禁 plugins——最小化攻击面        match parent_permission_profile { // 沙箱策略:尊重父会话的显式选择,Managed 模式则收紧到"只写记忆根 + 无网络"            PermissionProfile::Disabled => agent_config.permissions.set_permission_profile(PermissionProfile::Disabled), // 父级显式禁用沙箱 → 照办(用户自担风险)            PermissionProfile::External { network } => agent_config.permissions.set_permission_profile(PermissionProfile::External { network }), // 外部托管画像 → 原样继承            PermissionProfile::Managed { .. } => { // Codex 托管模式:收缩为最小权限                agent_config.set_legacy_sandbox_policy(SandboxPolicy::WorkspaceWrite {                    writable_roots: vec![root], // 只有记忆根可写——其他目录一律只读                    network_access: false, // 无网络访问:整理是纯本地文件操作,联网毫无必要                    exclude_tmpdir_env_var: true, // $TMPDIR 豁免(标准临时目录惯例)                    exclude_slash_tmp: true, // /tmp 也豁免                })            }        }.ok()?; // 任何一步失败 → 返回 None,调用方标记 job failed        agent_config.model = Some(            config.memories.consolidation_model.clone() // 模型可显式配置(通常配更强的)                .unwrap_or_else(|| provider.memory_consolidation_preferred_model().to_string()), // 未配置 → 用 provider 推荐的整理专用模型        );        agent_config.model_reasoning_effort = Some(crate::stage_two::REASONING_EFFORT); // Medium:比 Phase1 的 Low 高一档——合并去重是更难的任务        Some(agent_config)    }

为什么这样设计:这是"用完整 agent 干后台活"的权限最小化范本。注意它禁的不是单个工具,而是整类能力:Collab(递归委派)、MemoryTool(自我引用)、MCP/Plugins/Apps(外部攻击面)。尤其 ephemeral + generate_memories=false 这一对——切断记忆系统的自指环:worker 的会话不录制、不再生成记忆,否则"整理记忆的 agent 的记忆"会无限套娃。而沙箱只给"写记忆根 + 无网络",即使模型被 prompt injection 带偏(rollout 内容是不可信输入!),它能做的最坏事情也只是改坏自己的记忆文件。

六、读路径:注入与引用回写

记忆的价值在"被用"。读路径分三块:prompt 注入、引用解析、使用回写。

6.1 prompt 注入:V2 的片段切分

memories 扩展通过 ContextContributor 插槽(第 35 讲拆过的 Extension API)把记忆说明注入每个新会话。V2 有个细节:单条 fragment 有字节上限,超长说明要按字符边界切分(ext/memories/src/extension.rs contribute_thread_context,第 75-100 行节选):

📄 codex-rs/ext/memories/src/extension.rs (第 75-100 行,节选)

            let instructions = match config.version { // V1 整段注入;V2 切分成多个 fragment                MemoryVersion::V1 => vec![instructions], // V1:单条 developer policy,简单直接                MemoryVersion::V2 => { // V2:受"每片段字节上限"约束,必须切分                    let mut remaining = instructions.as_str(); // 尚未切分的剩余文本                    let mut fragments = Vec::new();                    while !remaining.is_empty() { // 循环切到空为止                        let end = remaining.floor_char_boundary(remaining.len().min(8_900)); // 每段 ≤8900 字节,且落在字符边界上(不劈开多字节 UTF-8)                        fragments.push(MemoryContextFragment::ReadInstructions(remaining[..end].to_string()).render()); // 包成"读取说明"片段并渲染                        remaining = &remaining[end..]; // 推进到下一段                    }                    fragments                }            };            instructions.into_iter()                .map(|instructions| PromptFragment::developer_policy( // 每个片段都标记为 developer policy(系统级指令,优先级高于 user)                    instructions,                    ContentItemKind("memories.instructions".to_string()), // 统一命名空间——初始摘要和检索工具必须用同一版本(见 on_config_changed)                ))                .collect()

为什么这样设计:floor_char_boundary 是 Rust 里处理 UTF-8 切分的安全姿势——直接按字节下标切片会在多字节字符中间 panic。而"初始摘要与检索工具必须同版本"的约束(on_config_changed 里强制 config.version = previous.version)防止了 V1/V2 混用:摘要格式和检索路径不匹配会让模型读到对不上号的引用。

6.2 引用解析:从 assistant 输出里挖出"用了哪条记忆"

模型引用记忆时会输出一段特殊标记,core 在流式事件里把它剥出来(memories/read/src/citations.rs 第 6-70 行):

📄 codex-rs/memories/read/src/citations.rs (第 6-70 行,节选)

pub fn parse_memory_citation(citations: Vec<String>) -> Option<MemoryCitation> { // 把模型输出的引用标记解析成结构化对象    let mut entries = Vec::new(); // 引用条目:path + 行号范围 + note("这条记忆说了什么")    let mut rollout_ids = Vec::new(); // 源会话 id 列表——回写使用记录的关键    let mut seen_rollout_ids = HashSet::new(); // 去重集合    for citation in citations { // 模型可能输出多段引用标记,逐段解析        if let Some(entries_block) = extract_block(&citation, "<citation_entries>", "</citation_entries>") { // 提取条目块(自定义标签,不是 HTML——纯文本协议)            entries.extend(entries_block.lines().filter_map(parse_memory_citation_entry)); // 逐行解析成结构化 entry        }        if let Some(ids_block) = extract_ids_block(&citation) { // 提取源会话 id 块(兼容 rollout_ids / thread_ids 两种旧标签)            for id in ids_block.lines().map(str::trim).filter(|line| !line.is_empty()) {                if seen_rollout_ids.insert(id.to_string()) { // HashSet insert 返回 false = 已存在 → 跳过重复                    rollout_ids.push(id.to_string());                }            }        }    }    if entries.is_empty() && rollout_ids.is_empty() { None } else { Some(MemoryCitation { entries, rollout_ids }) } // 什么都没解析出来 → None(调用方当"没引用记忆"处理)}fn parse_memory_citation_entry(line: &str) -> Option<MemoryCitationEntry> { // 单行格式:path:start-end|note=...    let line = line.trim();    if line.is_empty() { return None; }    let (location, note) = line.rsplit_once("|note=[")?; // 从右往左切出 note(路径里可能含 |,rsplit 才安全)    let note = note.strip_suffix(']')?.trim().to_string();    let (path, line_range) = location.rsplit_once(':')?; // 再切出 path 和行号范围    let (line_start, line_end) = line_range.split_once('-')?;    Some(MemoryCitationEntry { path: path.trim().to_string(), line_start: line_start.trim().parse().ok()?, line_end: line_end.trim().parse().ok()?, note }) // 解析失败任何一环 → None,整行丢弃(宽容解析)}

6.3 使用回写:记忆"保鲜期"的唯一依据

解析出的源会话 id 会被回写到 state DB(core/src/stream_events_utils.rs 第 171-198 行)——这一步看似不起眼,却是 Phase1 prune 策略的唯一输入:

📄 codex-rs/core/src/stream_events_utils.rs (第 171-198 行)

async fn record_stage1_output_usage_and_detect_memory_citation( // 每条 assistant 消息后调用:检测是否引用了记忆,是则回写"使用记录"    state_db_ctx: Option<&codex_state::MemoryStore>,    item: &ResponseItem,) -> bool {    let Some(raw_text) = raw_assistant_output_text_from_item(item) else { return false; }; // 只处理 assistant 文本消息(工具输出不算)    let (_, citations) = strip_citations(&raw_text); // 先把引用标记从可见文本里剥掉——用户看到的回复不含这些内部标记    let Some(memory_citation) = parse_memory_citation(citations) else { return false; }; // 解析失败或没有记忆引用 → 什么都不做    record_stage1_output_usage_for_memory_citation(state_db_ctx, &memory_citation).await}async fn record_stage1_output_usage_for_memory_citation( // 回写"使用"——这是 Phase1 prune(max_unused_days)的唯一依据    state_db_ctx: Option<&codex_state::MemoryStore>,    memory_citation: &MemoryCitation,) -> bool {    let thread_ids = thread_ids_from_memory_citation(memory_citation); // 引用里的 rollout_ids → ThreadId(解析失败的 id 直接丢弃)    if thread_ids.is_empty() { return true; } // 没有可追溯的源会话 → 跳过    if let Some(db) = state_db_ctx {        let _ = db.record_stage1_output_usage(&thread_ids).await; // 更新对应 Phase1 输出行的 last_used_at——"被用过"的记忆不会被 max_unused_days 剪掉    }    true}

为什么这样设计:记忆系统最难的工程问题不是"怎么记"而是"什么时候忘"。Codex 的答案是引用计数式保鲜:raw memory 默认按 max_unused_days 过期,但只要未来某次会话真的引用了它(模型输出里带 citation),last_used_at 就刷新——用得上的记忆自动永生,没人用的自然淘汰。这比"全部保留"省存储、比"固定 TTL"更聪明:遗忘策略由实际使用驱动,而不是拍脑袋的时间窗口。

另外注意 strip_citations 的位置——引用标记是模型输出协议(自定义文本标签),在展示给用户之前被剥掉。用户永远看不到 "<citation_entries>" 这类内部语法,但系统拿到了完整的溯源信息。

七、全链路数据流图

写路径(启动时后台流水线)

① 闸门:start_memories_startup_task

ephemeral/flag/子代理三重排除 → prune 过期行(免费)→ 限流护栏(烧钱前检查配额)。

▼

② Phase1:claim → 并行抽取(并发 8)

state DB 认领"够老够闲"的会话,lease 3600s;每会话一次 Low-effort LLM 调用。

▼

③ 分层预算:serialize_tiered_input

六级优先级分配 token(Human→Tool),同级取最新;渲染恢复时间线 + 省略标记。

▼

④ Phase2:全局锁 + git diff

认领全局 job → sync 输入到工作区 → baseline diff;无变化直接 succeed(省钱点)。

▼

⑤ 整理 agent:锁死配置 + 沙箱

ephemeral、禁 Collab/MCP/审批,只写记忆根无网络;Medium effort 合并去重成 ≤10KB 摘要。

▼

⑥ 收尾:校验 → 确认持锁 → 重置 baseline

产物合法 + heartbeat 仍持有所有权才提交新基线、推进水位线、标记 succeed。

读路径(每个新会话)

① 注入:ContextContributor

memory_summary.md 说明按 ≤8900 字节切分,作为 developer policy 进 prompt。

▼

② 引用:模型输出 citation 标记

path+行号+note+源会话 id;展示前被 strip_citations 剥掉,用户不可见。

▼

③ 回写:record_stage1_output_usage

刷新 last_used_at → 被引用的记忆豁免 max_unused_days 剪枝(使用驱动遗忘)。

📌 本讲小结:Codex 的记忆系统 = 两阶段蒸馏(便宜模型抽 raw → 强模型合并摘要)+ 三道省钱闸(限流护栏、git diff 变化检测、空产出短路)+ 一个锁死的 worker agent(权限最小化 + 切断自指环)+ 使用驱动的遗忘(引用回写保鲜期)。它没有用向量库,靠的是"文件即记忆、git 即版本控制、DB 即账本"的朴素组合——这正是 Rust 工程风格的体现:能用文件系统解决的,不引入新抽象。

📚 系列导航

← 第 35 讲:Extension API 扩展层——贡献者插槽与三级状态存储

→ 第 37 讲:Multi-Agent Spawn 与角色

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料