# Session 与 TurnContext · 会话状态与每轮配置

> Codex 内核把状态分成三层：线程级的 Session、一轮一个的 TurnContext、每次模型请求一个的 StepContext。一轮开始时，设置先在锁内提交成线程默认值，再冻结成本轮只读的 TurnContext；每发一次请求，还要再冻结一次模型、工具与环境。

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

# Session 与 TurnContext · 会话状态与每轮配置

[上一部分](https://daiw.net/manual/codex-source/thread-manager)讲到，`CodexThread` 最重要的两个字段是一个 `Arc<Session>` 和一个 `SessionIo`。从这一篇开始进入 `codex-core` 的内核，先把 `Session` 打开：它存了哪些状态，这些状态分几层，一轮对话开始时配置怎样被“冻结”下来。

## 用户看到的样子

在 TUI 里，用 `/model` 换模型、`/permissions` 换权限预设、`/plan` 或 `Shift+Tab` 切到计划模式、`/fast` 开关 Fast 档位，都是在改“这个线程接下来怎么跑”（命令说明见[斜杠命令](https://daiw.net/manual/codex/slash-commands)）。通过 app-server 编程时，同样的东西可以随 `turn/start` 一起带上（模型、推理强度、审批策略、协作模式等），也可以单独用实验性的 `thread/settings/update` 修改（协议见 [SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)）。

有一条规则值得先记住：**正在跑的那一轮不受影响**。改动写进线程的默认设置，从下一轮起生效；本轮已经拿到的是一份冻结的快照。例外是实验性的 `turn/settings/update`，它只改正在进行的这一轮后续请求所用的设置，后面会讲到它的限制。

## 三层状态

| 层 | 类型 | 寿命 | 装什么 |
| --- | --- | --- | --- |
| 线程 | `Session` | 与线程同生共死 | 会话级可变状态 `SessionState`、长寿服务 `SessionServices`、当前轮 `ActiveTurn`、输入队列、事件通道 |
| 一轮 | `TurnContext` | 一个任务从开始到结束 | 本轮冻结的 `Config`、初始设置 `initial_settings`、环境快照、轮级元数据 |
| 一次请求 | `StepContext` | 一次采样请求 | 这次请求用的设置版本、环境、MCP 连接、工具路由、AGENTS.md |

一轮（turn）里通常要和模型来回好几次：模型要调工具，就得把结果送回去再问一次。每一次“问”在代码里叫一个 step，由 `StepContext` 描述。三层的关系是：`Session` 产出 `TurnContext`，`TurnContext` 再在每次请求前产出 `StepContext`。

## Session：一个线程一个

```rust
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
    pub(crate) thread_id: ThreadId,
    pub(crate) installation_id: String,
    pub(super) tx_event: Sender<Event>,
    pub(super) agent_status: watch::Sender<AgentStatus>,
    pub(super) state: Mutex<SessionState>,
    // ...
    pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
    pub(crate) async_hook_results: async_channel::Receiver<HookCompletedEvent>,
    pub(crate) input_queue: InputQueue,
    pub(crate) services: SessionServices,
```

（`codex-rs/core/src/session/session.rs:55`）

文档注释里那句“同一时刻至多一个运行中的任务”是整个内核的前提。几个字段各司其职：

- `state: Mutex<SessionState>`（`codex-rs/core/src/state/session.rs:69`）是会话级的**可变**状态：当前的线程设置 `session_configuration`、对话历史 `history`（一个 `ContextManager`，见[上下文与历史](https://daiw.net/manual/codex-source/context-history)）、最近的 token 用量与限额快照、上一轮用的模型 `previous_turn_settings`、自动压缩窗口 `auto_compact_window`，以及“下一轮是不是第一轮”这类标记。
- `services: SessionServices`（`codex-rs/core/src/state/service.rs:48`）是**长寿的服务句柄**，创建后基本不换：会话级的模型客户端 `model_client`、MCP 运行时、unified exec 进程管理器、hooks、执行策略、模型目录 `models_manager`、AGENTS.md 管理器、会话持久化句柄 `live_thread` 等。
- `active_turn: Mutex<Option<ActiveTurn>>` 是当前这一轮。`ActiveTurn`（`codex-rs/core/src/state/turn.rs:32`）里有运行中的任务 `RunningTask` 和一份**轮级可变状态** `TurnState`：等待中的审批、权限请求、提问、MCP elicitation 与动态工具回调（各存一个 `oneshot::Sender`），用户中途追加的输入 `pending_input`，本轮授予的额外权限，工具调用计数和开轮时的 token 用量。

这样一分，锁的粒度就清楚了：改历史拿 `state`，登记或唤醒审批等待拿 `active_turn` 里单独加锁的 `turn_state`，两类操作不必抢同一把锁。

## 启动：spawn 与 new

`Session::spawn`（`codex-rs/core/src/session/mod.rs:510`）先建两条通道：提交队列 `tx_sub` 是容量 512 的有界通道（`SUBMISSION_CHANNEL_CAPACITY`），事件通道不设上限。随后它解析出本线程的模型、基础指令、协作模式与服务档位，拼成一个 `SessionConfiguration`，校验后交给 `Session::new`。`Session::new` 把三件互不依赖的事用 `tokio::join!` 并行起来：初始化会话持久化（新建或恢复 rollout）、打开状态数据库、解析认证并准备 MCP 配置。之后依次建好 shell 快照、执行环境、AGENTS.md、网络代理、hooks，组装 `SessionServices`；对外**第一个**发出的事件是 `SessionConfigured`，恢复会话时还附带历史消息，好让界面立刻渲染。`spawn` 收尾时 `tokio::spawn` 一个 `submission_loop`，此后所有 `Op` 都从这个循环进来，按提交顺序逐个处理。

```mermaid
flowchart TB
  OP[turn/start 变成 Op::TurnInput<br/>submission_loop 逐个处理] --> TI[turn_input::handle<br/>开新轮还是插话]
  TI --> US[update_settings_if<br/>锁内提交线程设置]
  US --> BC[build_per_turn_config<br/>复制 Config 并覆盖本轮值]
  BC --> MT[make_turn_context<br/>冻结成 TurnContext]
  MT --> ST[start_task · RegularTask]
  ST --> RT[run_turn]
  RT --> CS[capture_step_context<br/>每次请求前冻结一次]
  CS --> SR[采样请求]
  SR -->|还要继续| CS
```

## 一轮开始：先提交，再冻结

用户的输入经 `turn_input::handle` 判定为“开新一轮”后，走到 `new_turn_with_sub_id_if`：

```rust
        let service_tier_for_turn = updates.service_tier_for_turn.clone();
        let commit = match self.update_settings_if(updates, should_start).await {
            Ok(Some(commit)) => commit,
            Ok(None) => return Ok(None),
            // ...
        let mut configuration = commit.configuration;
        // Apply the override only to the turn's copy, after persisting thread settings.
        if let Some(service_tier) = service_tier_for_turn {
            Arc::make_mut(&mut configuration.step_settings).service_tier = Some(service_tier);
        }
        // ...
        let turn_environments = self.activate_turn_environments(&configuration).await;
        let turn_context = self
            .new_turn_from_configuration(sub_id, configuration, turn_environments, options)
            .await;
        Ok(Some((turn_context, commit.snapshot)))
```

（`codex-rs/core/src/session/turn_context.rs:1071`）

这段代码把随请求而来的东西分成两类。`turn/start` 里的模型、推理强度、审批策略、协作模式等被整理成 `SessionSettingsUpdate`，先由 `update_settings_if`（`codex-rs/core/src/session/mod.rs:1863`）在 `state` 锁里校验、提交成**线程的新默认值**，以后每一轮都继承；而本轮专用的服务档位、结构化输出的 JSON Schema 只落在这一轮的副本上。`update_settings_if` 还接收一个 `should_start` 判定函数，在同一把锁里对比新旧配置，比如“自动发起的轮次不许进出 Plan 模式”就靠它拒绝，被拒时设置一点都不会改。

提交之后，`build_per_turn_config`（`codex-rs/core/src/session/turn_context.rs:843`）把会话启动时的原始 `Config` 整个复制一份，再用线程设置覆盖工作目录、审批策略、推理强度、摘要、服务档位、权限配置档等字段；函数头的注释提醒“不要再往 config 上加被改写的参数，我们正在去掉它”。随后解析模型元数据、加载插件与技能，交给 `make_turn_context` 组装成 `TurnContext`。

## TurnContext：本轮的只读快照

```rust
/// The context needed for a single turn of the thread.
#[derive(Debug)]
pub struct TurnContext {
    pub(crate) sub_id: String,
    // ...
    /// Turn-scoped configuration. Read step-specific settings such as service tier and
    /// approvals reviewer from the corresponding `StepContext` instead.
    pub config: Arc<Config>,
    // ...
    /// Frozen settings used to construct this context. Legacy turn consumers
    /// keep this view even when later steps use different settings.
    pub(crate) initial_settings: Arc<ResolvedStepSettings>,
    // ...
    /// Settings for the next step; environments are owned by `ThreadEnvironments`.
    pub(super) next_step_settings: ArcSwap<ResolvedStepSettings>,
```

（`codex-rs/core/src/session/turn_context.rs:304`）

`sub_id` 就是这一轮的 turn ID。`TurnContext` 创建后放进 `Arc`，整轮基本只读（少数计时、告警标记用原子量或内部锁），任务、工具、审批都拿它的引用。设置有两份：`initial_settings` 是开轮时冻结的版本，大量“旧式”访问器都读它，例如 `model_info()` 的注释写着“Legacy: returns the frozen initial-turn model metadata”；`next_step_settings` 是一个 `ArcSwap`，存“下一次请求”要用的版本。`ResolvedStepSettings`（`codex-rs/core/src/session/step_settings.rs:41`）把用户选的 `StepSettings`（协作模式里的模型与推理强度、摘要、服务档位、审批策略与评审方）和解析好的 `ModelInfo` 绑在一起，并算出真正要发的摘要与服务档位。

平时这两份是同一个对象。`Op::TurnSettings`（对应 `turn/settings/update`）可以在一轮中途换掉 `next_step_settings` 或执行环境：只换审批评审方或执行环境时直接可用，换模型、推理强度、摘要或服务档位还要打开 `step_model_switching` 功能开关（开发中，默认关闭）。`apply_turn_settings`（`codex-rs/core/src/session/step_activation.rs:229`）先在锁外解析新模型，再回到锁内确认目标任务还是同一个（比较每个任务独有的 `done` 通知指针，因为 turn ID 可能被后来的任务复用），重新校验托管要求；换模型时，只要新模型会改变审批策略、Guardian 覆盖范围、前缀规则这类仍由开轮快照决定的东西，就直接拒绝。

## StepContext：每次请求再冻结一次

```rust
/// Request-scoped state that may change between model sampling requests.
pub(crate) struct StepContext {
    pub(crate) turn: Arc<TurnContext>,
    // ...
    /// One immutable settings version captured before request preparation.
    pub(crate) settings: Arc<ResolvedStepSettings>,
    // ...
    pub(crate) environments: TurnEnvironmentSnapshot,
    // ...
    /// The exact MCP connections, configuration, and catalog captured for this step.
    pub(crate) mcp: Arc<McpBinding>,
    /// The finalized tool plan advertised and executed for this exact sampling request.
    pub(crate) tool_router: Arc<ToolRouter>,
    /// The canonical AGENTS.md value observed with this environment snapshot.
    pub(crate) loaded_agents_md: Option<Arc<LoadedAgentsMd>>,
}
```

（`codex-rs/core/src/session/step_context.rs:19`）

`capture_step_context_inner`（`codex-rs/core/src/session/mod.rs:3787`）在 `active_turn` 锁里同时读出 `next_step_settings` 与环境快照，保证“模型”和“环境”来自同一时刻；然后并行做两件事：刷新 AGENTS.md，以及解析能力根、准备 MCP 连接、构建工具路由。这样一次请求里，写进上下文的环境、发给模型的工具清单、随后真正执行工具调用的注册表，全是同一份视图，中途有人改设置也不会让它们对不上。`run_turn` 开轮时捕获的第一个 step 直接用于第一次请求，之后每次请求前再捕获新的（见[下一篇](https://daiw.net/manual/codex-source/turn-loop)）。

## 落盘：TurnContextItem

`TurnContext::to_turn_context_item`（`codex-rs/core/src/session/turn_context.rs:750`）把本轮的关键设置（turn ID、工作目录、审批与沙箱策略、权限配置档、模型、协作模式、推理强度与摘要等）序列化成一条 `TurnContextItem`，`StepContext` 版本还会补上本次请求的实时语音状态与摘要设置。它有两个用途：写进 rollout，恢复会话时据此还原上一轮的设置；同时存进历史管理器的 `reference_context_item`，作为下一轮计算“上下文差异”的基准——下一轮设置没变，就不必重复告诉模型。这正是[上下文与历史](https://daiw.net/manual/codex-source/context-history)要展开的“只追加”机制的起点。

读 `codex-core` 时会频繁看到 `original_config_do_not_use`、带 `#[deprecated]` 的 `cwd` 与一串“Legacy”注释：状态正从“一个大 `Config` 贯穿全程”迁往“每个 step 各自捕获的设置与环境”，两套路径暂时并存。判断某个值以哪份为准，看它读的是 `TurnContext` 还是 `StepContext`。

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

[Agent Loop](https://daiw.net/manual/llm-to-agent/agent-loop) 一篇在“跨轮状态”一节列出生产循环要携带的状态：当前模型、压缩边界、恢复计数器、本轮 token 花销，并说成熟的循环“本质是一个手写的状态机”。Codex 把这些状态按寿命拆进三层：跨轮的放进 `SessionState`，只活一轮的放进 `TurnState`，只对一次请求有效的冻结进 `StepContext`。教学实现里一个 `messages` 数组加几个局部变量就够了；到了支持中途改设置、并发工具与审批等待的生产实现，“谁在什么时候拿哪份快照”本身就成了需要精心设计的契约。

---

上一篇：[codex exec 与 SDK · 没有界面的用法](https://daiw.net/manual/codex-source/exec-and-sdk) · 下一篇：[run_turn 主循环 · 一轮对话是怎么转起来的](https://daiw.net/manual/codex-source/turn-loop)
