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

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

作者 David更新于 第 51 篇(共 57 篇)

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

语音对话牵涉的 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 等,给自带音频的前端使用。用法细节见手册的斜杠命令。

两个模型:前台说话,后台干活

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

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

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

            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 为例:

图表加载中…

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

            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 闲着就开一轮,忙着就把这句话当作转向插进正在跑的那一轮。

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

    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”。

图表加载中…

终端界面与 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 里:

//! 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 那篇说,子 agent 就是“内部跑着一个完整 agent 循环的工具”。Codex 的语音模式把这个结构倒过来用:调用方是一个轻快的实时语音模型,被调用的 background_agent 工具里跑的是完整的 Codex agent,回传的只是精简后的结果。打断那篇区分的硬中断与转向在这里也都有对应:v2 里用户一开口,正在说的回复就被截断;后台忙时新的交接则作为转向插进当前任务,而不是另起炉灶。


上一篇:登录与认证 · ChatGPT、API key 与网关 · 下一篇:遥测与分析 · OpenTelemetry、埋点与反馈

本页目录