GPT-Live 边听边说 — 工作原理与 Java 集成示例
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 应用中时,你的主要工作是管理轮次控制状态机、正确处理插话、以及将会话逻辑与传输逻辑分离以便于测试。如果你的用例不需要实时响应,传统管道仍然是更简单和更便宜的选择。