Codex 源码导读:第十部分——Session、Thread 持久化与 Memory

Session、Thread 持久化与 Memory

这部分只抓三条线:

层 保存什么 用途
Session.state.history 当前会话的内存历史 继续当前 Agent Loop、编译下一轮 Prompt
ThreadStore / Rollout 可恢复的过程记录 重启恢复、审计、分叉
Memory 从多个 Rollout 提取的长期知识 后续会话读取和引用

1. 一次消息怎样写入

需要进入模型历史的用户输入、完整模型输出、工具调用和工具结果,会进入 Session 的记录入口。这里以 record_conversation_items 为例:先把 ResponseItem 封装成带元数据的 ResponseItemEnvelope,再更新内存历史、记录持久化项并发送原始响应项通知。文本增量等实时事件不必先进入这条记录链。

核心入口位于 session/mod.rs:3245:

rust 复制代码
pub(crate) async fn record_conversation_items(
    &self,
    turn_context: &TurnContext,
    items: &[ResponseItem],
) {
    let (items, image_preparations) =
        self.prepare_conversation_items_for_history(turn_context, items);

    let items = items
        .into_owned()
        .into_iter()
        .map(ResponseItemEnvelope::new) // 给记录绑定统一 envelope/metadata
        .collect();

    self.record_prepared_conversation_items(
        turn_context,
        items,
        image_preparations,
    ).await;
}

进入 record_prepared_conversation_items() 后,先更新内存历史:

rust 复制代码
let mut state = self.state.lock().await;
state.history.record_annotated_items(
    &items,
    turn_context.model_info().truncation_policy.into(),
);

这保证同一进程内下一轮 clone_history() 能立即看到新数据,不需要重新读磁盘。

随后把同一批记录转换并落盘:

rust 复制代码
let rollout_items: Vec<RolloutItem> = items
    .into_iter()
    .map(RolloutItem::ResponseItem)
    .collect();

self.persist_rollout_items(&rollout_items).await;

最后发布原始响应项:

rust 复制代码
self.send_raw_response_items(items).await;

这个事件路径只负责通知展示层,不替代历史写入。

2. ThreadStore 怎样把过程写到磁盘

Session 的持久化入口是 session/mod.rs:4020:

rust 复制代码
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) {
    if let Some(live_thread) = self.live_thread()
        && let Err(e) = live_thread.append_items(items).await
    {
        error!("failed to record rollout items: {e:#}");
    }
}

LiveThread::append_items()(live_thread.rs:203)继续做两件事:

rust 复制代码
let items = self.persist_appended_items(raw_items).await?;
// 将新增 RolloutItem 写入本地记录

let update = self.metadata_sync.lock().await
    .observe_appended_items(items.as_slice());
// 从新增记录更新 Thread 可查询的 metadata

底层 Rollout 文件是一行一个 JSON 记录。rollout/src/recorder.rs:1980 的核心逻辑是:

rust 复制代码
let line = serde_json::to_vec(&RolloutLineRef::Item(item))?;
file.write_all(&line).await?;
file.write_all(b"\n").await?;
file.flush().await?;

所以 ThreadStore 保存的是可重放的过程,不是已经拼好的下一轮 Prompt;Prompt 仍由 Session 根据内存历史重新编译。

3. 重启后怎样恢复

恢复入口在 session/mod.rs:1395:

rust 复制代码
InitialHistory::Resumed(resumed_history) => {
    let turn_context = self.new_default_turn().await;
    let rollout_items = resumed_history.history;
    self.apply_rollout_reconstruction(&turn_context, &rollout_items).await;
}

它不是读取"最后一条消息",而是重放 RolloutItem,恢复用户消息、模型消息、reasoning、tool-call、tool-result、Turn 设置和压缩记录。恢复完成后,主循环仍然通过:

text 复制代码
clone_history() → for_prompt() → 模型请求

把内部对象转换成模型协议消息。

4. 压缩、分叉怎样回到主流程

上下文窗口、压缩和 Session fork 都是在"已有历史"上产生新的历史边界;它们不会绕过统一持久化入口。

上下文限制

context_window.rs:35 计算的是活动上下文:

rust 复制代码
let active_context_tokens = sess.get_total_token_usage().await;
let full_context_window_limit = model_info
    .resolved_context_window()
    .map(|window| window * model_info.effective_context_window_percent / 100);
let token_limit_reached = ...;

超过限制时,持久化历史仍保留;只有送给模型的活动窗口被压缩。replace_compacted_history() 会构造 CompactedItem,保存摘要、替换历史和窗口标识,然后更新内存历史并持久化 RolloutItem::Compacted。

Session fork

源码中的 ForkPersistence 有两种边界:

  • Copied:把父 Session 的历史前缀复制到新 Rollout。
  • Referenced:保存父历史基线和继承项数量,恢复时重放继承部分。

fork 是调用方单独触发的操作,不是每次模型回合后的固定步骤。它只改变新 Session 的历史起点,不改变"记录 → 持久化 → 下一轮 Prompt"的主流程。

5. Memory:从 Rollout 派生长期知识

Memory 不是 Session 历史的别名,而是异步派生管线。启动任务在 memories/write/src/start.rs:17 中跳过 ephemeral、禁用 Memory、非 root agent 或缺少 State DB 的场景,然后启动两个阶段。

Phase 1:单个 Rollout

memories/write/src/phase1.rs:55 从 State DB 领取候选任务,并以并发上限处理。sample() 会加载 Rollout、过滤不需要的响应项,构造模型请求并要求结构化输出:

text 复制代码
RolloutRecorder::load_rollout_items()
    → serialize_filtered_rollout_response_items()
    → 模型生成 StageOneOutput
       { raw_memory, rollout_summary, rollout_slug }
    → mark_stage1_job_succeeded()

这一阶段只写 State DB,不修改当前 Session。

Phase 2:全局合并

memories/write/src/phase2.rs:45 先领取全局锁,再把 Phase 1 产物同步到 $CODEX_HOME/memories,计算 workspace diff 并校验合并产物。只有"没有变化且产物有效"才直接结束;有变化,或产物缺失/无效,都需要启动 consolidation Agent。全局锁避免多个进程并发修改同一套 Memory 文件。

Memory 读取

Memory 工具的根目录由 ext/memories/src/local.rs:29 定位到 $CODEX_HOME/memories。list / read / search / add_ad_hoc_note 都经过 MemoriesBackend,本地实现拒绝 ..、绝对路径、隐藏路径和符号链接逃逸。

读取不是"把所有 Memory 全部塞进 Prompt",而是先给摘要,再由模型按任务选择具体文件:

build_memory_tool_developer_instructions()(ext/memories/src/prompts.rs:26)只读取并截断记忆摘要文件,然后将"记忆摘要 + 查找规则"放入 developer instructions:

rust 复制代码
let memory_summary =
    fs::read_to_string(&memory_summary_path).await.ok()?;
let memory_summary = truncate_text(
    &memory_summary,
    TruncationPolicy::Tokens(
        MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT,
    ),
);

查找规则要求模型按"摘要 → MEMORY.md 索引 → 1~2 个相关摘要或 Skill → 必要时原始 Rollout"的顺序进行轻量检索。因此,程序提供入口和工具,模型根据当前问题决定关键词与文件。

memories.search 的执行代码在 ext/memories/src/tools/search.rs:59:模型传入 queries / path / max_results 等参数,后端再对本地文件做受限文本搜索;搜索结果给出路径后,模型再调用 memories.read 读取详情。它不是自动向量召回,也不是固定读取某几个文件。

最后,模型依据检索结果在最终回答中生成 <oai-mem-citation> 隐藏引用块。Core 从 assistant 输出中剥离该块,再交给 memories/read/src/citations.rs:6 解析引用条目和 Rollout 标识。search/read 工具提供检索内容与路径,不是直接产生最终引用块。

6. 最终关系

text 复制代码
Session.history
    = 当前进程继续 Loop 所需的事实

Thread / Rollout
    = 可恢复、可重放、可审计的原始过程

Memory
    = 从多个 Rollout 提取并合并后的长期知识

三者不是三份重复数据:Session 负责"现在怎么继续",Thread/Rollout 负责"之前发生过什么",Memory 负责"哪些经验值得下次复用"。

源码基线:OpenAI Codex d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。

相关推荐
美好世界1 小时前
Codex 源码导读:第四部分——上下文压缩与继续执行
架构
据说幸运很容易1 小时前
TestNG 分组接入现有框架:实现、踩坑与解法
后端·架构
farerboy1 小时前
WEB 项目如何禁用 F12 等功能
前端·vue.js·架构
美好世界1 小时前
Codex 源码导读:第六部分——模型请求与流式网络层
架构
美好世界1 小时前
Codex 源码导读:第三部分——沙箱真实执行
架构
mldong1 小时前
引擎里没有 setStatus:状态迁移收口,不用状态机框架
后端·架构
她的男孩1 小时前
开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑
java·后端·架构
据说幸运很容易1 小时前
接口测试框架重构:YAML 用例驱动 + 分层设计
后端·架构
mldong1 小时前
聚合边界:为什么 ProcessTask 没有自己的 Repository
后端·架构