GPT-Live 边听边说 — 工作原理与 Java 集成示例

本文有一个短视频:YouTubeTikTok

1. 概述

“GPT-Live” 是对能够通过单一音频流实时同时进行听和说的 LLM 模型的统称,而不是依次运行 Speech-to-Text → LLM → Text-to-Speech。本文解释该全双工(full-duplex)机制在协议层如何工作,然后构建一个 Spring Boot 示例,连接 WebSocket、发送音频、接收流式响应并处理用户插话(barge-in)情况。读完本文后,你将足以理解如何编写 Java 客户端调用实时语音 API,并了解将此功能投入生产时的常见陷阱。

2. 为什么旧的管道(STT → LLM → TTS)不是“实时的”

过去,语音助手通常组合 3 个独立模型:

Mic → [Speech-to-Text] → text → [LLM] → text → [Text-to-Speech] → Speaker

每一步都要等待前一步完成才能运行。延迟会累积(通常 2–5 秒),用户无法在 AI 说话时打断它,因为系统在输出时并不“监听”。

GPT-Live 将所有三个角色合并为一个多模态(multimodal)模型,直接接收音频帧并生成音频帧,通过一个持续的双向(duplex)连接。没有“等待句子完成”的步骤 — 模型在音频块(chunk)到达时立即处理它们,如果语义置信度足够高,甚至可以在用户完全说完之前开始回应。

注意: “实时”在这里并不意味着零延迟,而是延迟被分散到每个块(通常 20–100ms)而不是在管道末尾累积。

3. 全双工架构:通过 WebSocket 的事件流

与一次性请求-响应的 REST API 不同,实时 API 使用 WebSocket 来保持连接打开,来回传输 JSON 格式的事件。两个数据流同时运行:

  • Client → server: 音频块(base64 PCM16)、控制事件(开始/停止说话)。
  • Server → client: 响应音频块、部分转录、事件通知“模型检测到你在说话”(以便中止输出)。
Client                         Server (GPT-Live)
  |--- audio.append (chunk1) -->|
  |--- audio.append (chunk2) -->|
  |                             |-- transcript.delta (partial) -->
  |--- audio.append (chunk3) -->|
  |<------ response.audio.delta -|  (模型已开始回应)
  |--- audio.append (插话)   -->|
  |<------ response.cancelled ---|  (服务器取消了正在进行的响应)

两条音频流在单个 socket 上并行运行正是“边听边说”的本质。

4. 从 Spring Boot 设置 WebSocket 连接依赖

Spring Boot 通过 spring-websocket 原生支持 WebSocket 客户端。添加依赖:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-json</artifactId>
</dependency>

创建连接到实时端点的配置(假设 wss://api.gptlive.example/v1/voice):

@Configuration
public class VoiceSessionConfig {

    @Bean
    public StandardWebSocketClient webSocketClient() {
        return new StandardWebSocketClient();
    }
}

StandardWebSocketClient 是 Spring 的标准实现,对于不需要 servlet 容器的客户端来说足够了。

5. 发送音频块并接收流式响应

核心思想:每当麦克风捕获一段音频(例如 20ms)时,立即向服务器发送一个 audio.append 事件,而不是等待完整的句子。

public class VoiceSessionHandler extends TextWebSocketHandler {

    private final ObjectMapper mapper = new ObjectMapper();

    public void sendAudioChunk(WebSocketSession session, byte[] pcmChunk) throws IOException {
        String base64Audio = Base64.getEncoder().encodeToString(pcmChunk);
        Map<String, Object> event = Map.of(
            "type", "input_audio.append",
            "audio", base64Audio
        );
        session.sendMessage(new TextMessage(mapper.writeValueAsString(event)));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        JsonNode event = mapper.readTree(message.getPayload());
        String type = event.get("type").asText();

        switch (type) {
            case "response.audio.delta" -> playAudioChunk(event.get("audio").asText());
            case "response.text.delta" -> System.out.print(event.get("delta").asText());
            case "input_audio.speech_started" -> cancelOngoingPlayback();
            default -> { /* 忽略不需要处理的事件 */ }
        }
    }

    private void playAudioChunk(String base64Audio) {
        byte[] pcm = Base64.getDecoder().decode(base64Audio);
        // 将 pcm 推入扬声器的音频输出缓冲区
    }

    private void cancelOngoingPlayback() {
        // 停止正在运行的音频播放 — 这是插话(barge-in)机制
    }
}

解释:

  • sendAudioChunk 从麦克风录制线程不断被调用(例如每 20ms),不进行批处理以保持低延迟。
  • handleTextMessage 是从服务器接收事件的回调;response.audio.delta 表示模型正在说话,而 input_audio.speech_started 表示服务器检测到用户开始说话 — 此时客户端必须立即停止扬声器。
  • event.get("type").asText() 使用 Jackson 的 JsonNode 读取 type 字段,而无需硬编码 DTO 定义 — 这很有用,因为事件架构可能会添加新的类型。

6. 插话(barge-in)机制:谁在什么时候说话?

插话(barge-in)是用户能够在 AI 说话过程中打断它的能力。为了做到这一点,服务器需要一个独立于服务器是否正在输出音频的话音活动检测器(Voice Activity Detection — VAD),在输入音频流上持续运行。

public class TurnStateMachine {

    public enum State { IDLE, USER_SPEAKING, MODEL_SPEAKING }

    private volatile State state = State.IDLE;

    public void onServerEvent(String eventType) {
        state = switch (eventType) {
            case "input_audio.speech_started" -> {
                // 用户在模型说话时说话 → 打断对话
                if (state == State.MODEL_SPEAKING) {
                    stopPlaybackImmediately();
                }
                yield State.USER_SPEAKING;
            }
            case "input_audio.speech_stopped" -> State.IDLE;
            case "response.audio.delta" -> State.MODEL_SPEAKING;
            case "response.done" -> State.IDLE;
            default -> state;
        };
    }

    private void stopPlaybackImmediately() {
        System.out.println("停止音频播放,向服务器发送 response.cancel");
    }
}

解释: TurnStateMachine 使用 3 个状态跟踪谁在“占用麦克风”。当 input_audio.speech_started 在 MODEL_SPEAKING 时到达时,这意味着用户插话了 — 客户端必须立即停止扬声器并通知服务器取消正在生成的响应(发送 response.cancel 事件),避免旧音频和新音频重叠。

注意: 如果不处理插话,AI 会与用户的语音重叠 — 这是语音助手最令人不适的体验。这是快速演示时容易被遗漏但在生产中必须具备的部分。

7. 在对话中管理会话和上下文

由于连接是有状态的(在整个呼叫中保持),你需要一个会话对象来存储对话历史和语音配置,与传输逻辑分离。

public class VoiceSession {

    private final String sessionId;
    private final List<ConversationTurn> history = new ArrayList<>();
    private final String voice; // 例如 "alloy"、"verse"

    public VoiceSession(String sessionId, String voice) {
        this.sessionId = sessionId;
        this.voice = voice;
    }

    public void appendTurn(String role, String transcript) {
        history.add(new ConversationTurn(role, transcript, Instant.now()));
    }

    public record ConversationTurn(String role, String transcript, Instant at) {}
}

初始化会话时,在开始录制音频之前,发送 session.update 事件来设置语音和系统指令:

Map<String, Object> sessionUpdate = Map.of(
    "type", "session.update",
    "session", Map.of(
        "voice", "alloy",
        "instructions", "你是医疗预约助理,请简明扼要地回答。",
        "turn_detection", Map.of("type", "server_vad")
    )
);

turn_detection: server_vad 意味着你将检测用户何时停止说话的职责委托给服务器用 VAD 处理,而不是由客户端通过检测沉默来猜测 — 这种方式更准确,因为服务器可以使用语义信号(例如,语法上不完整的句子不被认为是完成)。

8. 使用 JUnit 测试轮次控制逻辑

传输部分(实际 WebSocket)很难进行单元测试,但控制轮次的状态机可以完全独立测试。

class TurnStateMachineTest {

    @Test
    void khiModelDangNoiVaUserBatDauNoi_thiPhaiNgatPlayback() {
        TurnStateMachine machine = new TurnStateMachine();

        machine.onServerEvent("response.audio.delta");
        assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.MODEL_SPEAKING);

        machine.onServerEvent("input_audio.speech_started");
        assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.USER_SPEAKING);
    }

    @Test
    void khiKhongAiNoi_thiTrangThaiLaIdle() {
        TurnStateMachine machine = new TurnStateMachine();

        machine.onServerEvent("response.done");

        assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
    }
}

(假设你在 TurnStateMachine 中添加了 getState() getter。)第一个测试确认在发生插话时状态转换的顺序正确;第二个测试确认系统在响应完成后返回 IDLE — 这是下一个对话循环干净开始的条件。

9. 常见错误以及何时不应使用

  • 以大批次发送音频(例如每 2 秒一次): 完全抵消流式处理的好处,延迟又回到旧管道的水平。始终发送小块(20–100ms)。
  • 不处理 response.cancel 事件: 如果你只在客户端停止音频播放而不通知服务器取消,服务器仍会继续生成多余音频,浪费成本并可能导致会话状态不同步。
  • 使用客户端 VAD 自行决定用户何时停止说话: 在嘈杂环境中容易过早或过迟切割句子;应该让服务器 VAD 处理(如果 API 支持),除非你需要离线功能。
  • 应用于不需要实时交互的用例(例如生成报告、总结长文档):实时语音模型比传统的 STT/LLM/TTS 管道成本更高、复杂度更高,不值得为你不需要的低延迟付出代价。
  • 在 UI 主线程上运行 WebSocket 客户端而没有分离出单独的录制/播放线程:容易导致卡顿,当 JSON 解析阻塞音频线程时会出现延迟。

注意: 全双工语音在整个呼叫过程中持续消耗带宽和 API 成本,不仅仅按文本 token 计算。考虑限制会话时间或在没有活动时设置超时。

10. 结论

GPT-Live 基于一个保持打开的 WebSocket 连接,双向传输音频块,并使用 VAD 来确定谁在占用话轮 — 这就是实现“边听边说”的机制,而不是像旧的 STT/LLM/TTS 管道那样依次等待。当集成到 Java/Spring Boot 应用中时,你的主要工作是管理轮次控制状态机、正确处理插话、以及将会话逻辑与传输逻辑分离以便于测试。如果你的用例不需要实时响应,传统管道仍然是更简单和更便宜的选择。

llmrealtime-aispring-bootwebsocketvoice-ai