# 实时语音 · 语音对话的实现

> Codex 的语音模式不是“语音转文字再喂给 agent”，而是两个模型分工：实时语音模型在前台陪你说话，遇到要干的活就交接给后台的 Codex agent，agent 的结果再回到它嘴里说出来。本篇讲交接的数据通路与两种回传方式、三种传输，以及把音频关在 codex-voice-host 子进程里的 WebRTC 实现。

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

# 实时语音 · 语音对话的实现

语音对话牵涉的 crate 不少：内核的 `realtime_conversation.rs` 负责会话与交接，`codex-api` 的 `realtime_websocket/` 与 `realtime_call.rs` 负责和实时模型说话，终端界面这边有 `codex-realtime-webrtc`，真正碰麦克风和扬声器的是一个独立的可执行文件 `codex-voice-host`。先看它们合起来是什么样子，再逐层拆开。

## 怎么用

在终端界面输入 `/voice` 或按 `F8` 开始、结束语音对话，对话中可以静音麦克风，`/voice settings` 选声音。功能开关 `realtime_conversation` 已是稳定且默认开启；但只在附带语音运行时的安装包里可用，平台限于 macOS、MSVC 构建的 Windows 和 glibc 的 Linux。app-server v2 另有一组实验性方法 `thread/realtime/start`、`appendAudio`、`appendText`、`stop` 等，给自带音频的前端使用。用法细节见手册的[斜杠命令](https://daiw.net/manual/codex/slash-commands)。

## 两个模型：前台说话，后台干活

打开语音后，同时在工作的是两个模型：

- **实时模型**：协议 v1、v2 默认 `gpt-realtime-1.5`，v3 默认 `gpt-live-1-codex`，负责听、说、判断何时插话。它的指令来自 `backend_prompt.md`，要点是把自己当作同一个助手的“对话界面”，不要提后台的存在；任何要执行的事都交给后台，而且不要替后台拒绝请求，能不能做、安不安全由后台判断。
- **Codex agent**：就是平常那个跑 [run_turn](https://daiw.net/manual/codex-source/turn-loop) 的主会话。语音开始时，它的开发者消息里会插入 `realtime_start.md`，告诉它自己是中间人背后的后台执行者，回复可能被中间人概括后才到用户那里，收到的用户文字是语音转写，可能没有标点、有识别错误，回复要简短、以行动为主；语音结束时再插入 `realtime_end.md` 恢复常态。

“把活交出去”在三个协议版本里写法不同：v1 是服务端事件 `conversation.handoff.requested`，v3 是 `delegation.created`，v2 则给实时模型注册函数工具。`codex-api` 为每个版本各写一个解析器（`RealtimeEventParser`），统一翻译成 `codex-protocol` 里的 `RealtimeEvent::HandoffRequested`，内核只认这一个事件。v2 的会话配置里注册了两个工具：

```rust
            tools: Some(vec![
                SessionFunctionTool {
                    r#type: SessionToolType::Function,
                    name: REALTIME_V2_BACKGROUND_AGENT_TOOL_NAME.to_string(),
                    description: REALTIME_V2_BACKGROUND_AGENT_TOOL_DESCRIPTION.to_string(),
                    parameters: json!({
                        "type": "object",
                        "properties": {
                            "prompt": {
                                "type": "string",
                                "description": "The user request to delegate to the background agent."
                            }
                        },
                        "required": ["prompt"],
                        "additionalProperties": false
                    }),
                },
                SessionFunctionTool {
                    r#type: SessionToolType::Function,
                    name: REALTIME_V2_SILENCE_TOOL_NAME.to_string(),
                    // ...
                },
            ]),
```

（`codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs:115`）

`background_agent` 的描述要求实时模型原样转交用户的话，不要改写；后台空闲就开新任务，后台正忙就作为指引去“转向”当前任务；用户说“做完这个再做那个”也要调用它把活排上，而不是口头答应。`remain_silent` 让模型在不该出声时明确选择沉默。同一份配置里还开着服务端语音检测（静音 500 毫秒算一句话结束，允许打断），输入转写用 `gpt-4o-mini-transcribe`，音频是 24 kHz PCM。

## 一次交接的来回

下图是内核默认的“自动回传”模式，以 v2 为例：

```mermaid
sequenceDiagram
  participant U as 用户
  participant R as 实时模型
  participant C as core 的实时会话
  participant A as Codex agent
  U->>R: 说话
  R->>C: 调用 background_agent，参数是用户原话
  C->>A: route_realtime_text_input，开新任务或转向当前任务
  A-->>C: 助手消息、推理摘要、审批请求
  C->>R: conversation.item.create，带 BACKEND 前缀的进度
  A-->>C: TurnComplete
  C->>R: function_call_output，告知后台已完成
  C->>R: response.create
  R-->>U: 用语音转述结果
```

去程在 `handle_start_inner` 起的一个分发任务里。它收下实时模型的每个事件转给前端，遇到交接请求就把文字送进会话：

```rust
            let maybe_routed_text = match &event {
                RealtimeEvent::HandoffRequested(handoff) => {
                    realtime_delegation_from_handoff(handoff)
                }
                _ => None,
            };
            if let Some(text) = maybe_routed_text {
                // The routed text can contain spoken prompts or workspace secrets.
                debug!("[realtime-text] realtime conversation text output");
                handoff_error = route_handoffs.route(&sess_clone, text).await.err();
            }
```

（`codex-rs/core/src/realtime_conversation.rs:1763`）

`route` 外面有一个许可数为 1 的信号量（`RealtimeHandoffAdmission`），保证交接按序进入；进入会话后走的是普通的用户输入路径，模式是 `TurnInputMode::StartOrSteer`、触发来源记为 `realtime`——agent 闲着就开一轮，忙着就把这句话当作转向插进正在跑的那一轮。

回程靠会话的事件出口。会话每发一个事件，都会顺路检查语音是否在进行，是就把其中的文字镜像给实时会话：

```rust
    async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {
        if self.conversation.running_state().await.is_none() {
            return;
        }
        match msg {
            // ...
        }
        let result = match realtime_text_for_event(msg) {
            Some(RealtimeEventText::Handoff(text, phase)) => {
                self.conversation.handoff_out(text, phase).await
            }
            Some(RealtimeEventText::QuietReasoning(text)) => {
                self.conversation.send_reasoning_status(&text).await
            }
            None => return,
        };
        if let Err(err) = result {
            debug!("failed to mirror event text to realtime conversation: {err}");
        }
    }
```

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

`realtime_text_for_event` 决定哪些事件值得说：完成的助手消息原样转过去；推理摘要只在 v3 且启动参数打开 `backend_reasoning_status` 时作为 `[STATUS]` 状态发送；命令审批、补丁审批、权限请求则变成一句“需要你的批准，请在 app 里查看”加上请求内容——审批仍在界面上点，不靠语音。回传给实时模型的文字每段最多约 1,000 token（`REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET`），v2 上还要加 `[BACKEND]` 前缀。一轮结束（`TurnComplete`）时，v2 发一条函数调用结果“后台已完成，用前面的 BACKEND 消息作答”，再请求实时模型生成回复。

终端界面并不用自动回传。它发 `thread/realtime/start` 时固定选 v3 与 WebRTC，关掉启动上下文，并打开 `clientManagedHandoffs`：内核照样把语音交来的任务送进会话，但不再把 agent 的输出推给实时模型，`handoff_out` 一进来就返回。改由界面挑出“这一轮由语音发起、最后那条助手消息”，经 `thread/realtime/appendSpeech` 交给实时模型念；超过 990 token 的（`MAX_SPEAKABLE_FINAL_TOKENS`，给内核那 1,000 token 的上限留出前缀的余量）只显示成文字，不念。

两个协议细节值得一提。一是 v2 同一时刻只允许一个进行中的回复，`RealtimeResponseCreateQueue` 在回复进行中把新的 `response.create` 记下来，等上一个结束再发。二是 v2 的插话：用户开口（`input_audio_buffer.speech_started`）时，内核按这条回复已收到的音频时长发 `conversation.item.truncate`，把正在说的那条回复截断。开始对话时还可以带一份“启动上下文”，总预算 5,300 token，由当前线程、最近的工作、机器与工作区目录三节组成，AGENTS.md 与记忆摘要不在其中（`codex-rs/core/src/realtime_context.rs:59`）。

## 三种传输

`ConversationStartTransport` 有三种取值，决定音频走哪条路：

- **WebSocket**：内核直连实时模型，音频帧经 app-server 进出（`thread/realtime/appendAudio` 进，`thread/realtime/outputAudio/delta` 出）。这条路只认 API key：提供方配置的 key 或 bearer token、API key 登录，或作为临时兜底读取的 `OPENAI_API_KEY` 环境变量（代码里标着 TODO）；都没有就报“realtime conversation requires API key auth”。
- **WebRTC**：前端先生成 SDP offer 交给内核，内核把 offer 和会话配置一起 POST 到 `/realtime/calls`，拿回 SDP answer 和从 `Location` 头解析出的 `call_id`，再用同一套认证在这个通话上挂一条“旁路”WebSocket，专门收发事件与交接。音频在前端与实时模型之间直连，从不经过 app-server。旁路断开会自动重连，间隔从 200 毫秒起翻倍，封顶 5 秒。
- **已有通话**：附着到别处创建好的通话上，只接旁路，不再发会话配置。

v1 与 v3 在请求头里带 `openai-alpha: quicksilver=v1` 或 `quicksilver=v2`，用 `conversation.handoff.append` 之类的专用消息交接，v3 在自动回传模式下还能按 200 毫秒的节奏把 agent 的输出流式追加过去；v2 用的是 `session.update`、`conversation.item.create`、`response.create` 这组事件加上面的函数工具。WebRTC 与已有通话只接受 v1、v3，选 v2 会报“AVAS realtime calls require realtime v1 or v3”。

```mermaid
flowchart LR
  subgraph 本机
    TUI[终端界面] -- 控制帧 --> VH[codex-voice-host<br/>WebRTC · 麦克风 · 扬声器]
    TUI -- thread/realtime/start<br/>带 SDP offer --> CORE[app-server 与 core<br/>RealtimeConversationManager]
    CORE --> AG[Codex agent 主会话]
  end
  CORE -- POST /realtime/calls<br/>换回 SDP answer --> RT[实时模型]
  CORE -- 旁路 WebSocket<br/>事件与交接 --> RT
  VH -- Opus RTP 音频 --> RT
```

## 终端界面与 codex-voice-host

`codex-rs/tui/src/chatwidget/realtime.rs` 开头一句话概括了终端这边的做法：由 app-server 负责信令、本地自己持有的 WebRTC 语音会话。按下 `F8` 后，界面在后台线程里调用 `RealtimeWebrtcSession::start`，拉起安装包 `codex-resources/voice/bin/` 下的 `codex-voice-host`，让它生成 offer；offer 经 `thread/realtime/start` 送到内核，收到 `thread/realtime/sdp` 通知里的 answer 后再交给子进程完成连接。

父子进程之间只传控制信息，协议写在 `codex-realtime-webrtc` 里：

```rust
//! Bounded same-build controls and redacted signaling; audio never crosses this pipe.
// ...
pub enum Message {
    Hello { protocol: u32, build_commit: String },
    Ready {},
    InitializeRuntime {},
    RuntimeReady {},
    StartTransport {},
    Offer { sdp: SessionDescription },
    ApplyAnswer { sdp: SessionDescription },
    TransportReady {},
    TransportTimedOut {},
    OpenDevices {},
    DevicesOpened {},
    SetAudioControls { controls: AudioControls },
    AudioControlsApplied {},
    InspectAudio {},
    AudioState { state: AudioState },
    Close {},
    Closed {},
}
```

（`codex-rs/realtime-webrtc/src/protocol.rs:1`）

每帧是 4 字节长度加 JSON，上限 128 KiB；握手时核对构建提交号，只接受同一次构建出来的助手程序；SDP 里含 ICE 凭据，类型本身就把调试输出替换成 `[REDACTED]`；`InspectAudio` 只回麦克风与扬声器的峰值电平，界面拿它画音量条。原生媒体全部留在子进程里：`codex-voice-host` 用 `webrtc` crate 建立对等连接，用 CPAL 打开设备，采集端经重采样、回声消除、降噪与自动增益，编码成 20 毫秒一帧的 Opus 走一条 RTP 音轨；播放端用 GStreamer 管线做抖动缓冲与解码。麦克风静音时并不断流，而是发生成的静音帧维持连接。子进程启动时先做进程加固，并把 GStreamer 的插件路径、注册表等环境变量固定死，不去扫描系统里的插件。

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

[子 Agent](https://daiw.net/manual/llm-to-agent/subagents) 那篇说，子 agent 就是“内部跑着一个完整 agent 循环的工具”。Codex 的语音模式把这个结构倒过来用：调用方是一个轻快的实时语音模型，被调用的 `background_agent` 工具里跑的是完整的 Codex agent，回传的只是精简后的结果。[打断](https://daiw.net/manual/llm-to-agent/interrupt)那篇区分的硬中断与转向在这里也都有对应：v2 里用户一开口，正在说的回复就被截断；后台忙时新的交接则作为转向插进当前任务，而不是另起炉灶。

---

上一篇：[登录与认证 · ChatGPT、API key 与网关](https://daiw.net/manual/codex-source/login-and-auth) · 下一篇：[遥测与分析 · OpenTelemetry、埋点与反馈](https://daiw.net/manual/codex-source/telemetry)
