GPT-Live: Echtzeit-Sprachdialog — Architektur und Java-Integration

Zu diesem Artikel gibt es ein kurzes Video:YouTubeTikTok

1. Überblick

“GPT-Live” ist eine Sammelbezeichnung für LLM-Modelle, die gleichzeitig über einen einzigen Audio-Streaming-Kanal hören und sprechen können, anstatt sequenziell Speech-to-Text → LLM → Text-to-Speech auszuführen. Dieser Artikel erklärt, wie dieser Full-Duplex-Mechanismus auf Protokollebene funktioniert, und baut dann ein Spring-Boot-Beispiel auf, das sich über WebSocket verbindet, Audio sendet, Streaming-Antworten empfängt und Unterbrechungssituationen (Barge-In) verarbeitet. Nach der Lektüre wirst du verstehen, wie man einen Java-Client schreibt, um eine Echtzeit-Voice-API aufzurufen, und kennst die Fallstricke bei der Produktionseinführung dieser Funktion.

2. Warum die alte Pipeline (STT → LLM → TTS) nicht “live” ist

Voice Assistant wurden früher mit 3 separaten Modellen zusammengesetzt:

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

Jeder Schritt wartet darauf, dass der vorherige abgeschlossen ist. Die Verzögerung summiert sich auf (normalerweise 2–5 Sekunden), und Benutzer können die KI nicht mitten im Satz unterbrechen, da das System nicht “zuhört”, während es spricht.

GPT-Live verbindet alle drei Rollen in einem multimodalen Modell, das direktes Audio empfängt und erzeugt, über eine kontinuierliche bidirektionale (Duplex-)Verbindung. Es gibt keinen Schritt “auf Satzende warten” — das Modell verarbeitet jeden kleinen Audio-Chunk unmittelbar nach Ankunft und kann bereits mit der Antwort beginnen, bevor der Benutzer seinen Satz vollständig beendet hat, sofern genügend semantisches Vertrauen vorhanden ist.

Hinweis: “Live” bedeutet hier nicht, dass es keine Verzögerung gibt, sondern dass die Verzögerung in kleine Chunks (normalerweise 20–100ms) aufgeteilt wird, statt sich am Ende der Pipeline zu akkumulieren.

3. Full-Duplex-Architektur: Event-Stream über WebSocket

Im Gegensatz zu REST-APIs (einmaliger Request-Response) verwenden Echtzeit-APIs WebSocket, um die Verbindung offen zu halten und Events im JSON-Format bidirektional zu übertragen. Zwei Datenströme laufen gleichzeitig:

  • Client → Server: Audio-Chunks (Base64 PCM16), Steuerevents (Start/Ende des Redens).
  • Server → Client: Audio-Chunks der Antwort, partielle Transkripte, Event, das signalisiert, dass “das Modell erkannt hat, dass du sprichst” (um die Ausgabe selbst zu unterbrechen).
Client                         Server (GPT-Live)
  |--- audio.append (chunk1) -->|
  |--- audio.append (chunk2) -->|
  |                             |-- transcript.delta (partial) -->
  |--- audio.append (chunk3) -->|
  |<------ response.audio.delta -|  (Modell hat Antwort begonnen)
  |--- audio.append (unterbrochen)->|
  |<------ response.cancelled ---|  (Server bricht laufende Antwort ab)

Die Tatsache, dass zwei Audio-Streams parallel über einen Socket laufen, ist die Essenz von “gleichzeitiges Hören und Sprechen”.

4. Setup von WebSocket-Dependency aus Spring Boot

Spring Boot unterstützt WebSocket-Client von Haus aus über spring-websocket. Abhängigkeit hinzufügen:

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

Erstelle eine Konfiguration, um eine Verbindung zum Echtzeit-Endpoint herzustellen (beispielhaft wss://api.gptlive.example/v1/voice):

@Configuration
public class VoiceSessionConfig {

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

StandardWebSocketClient ist die Standard-Implementierung von Spring und reicht für Client-seitige Verwendung ohne Servlet-Container aus.

5. Audio-Chunks senden und Streaming-Antworten empfangen

Die Kernidee: Jedes Mal, wenn das Mikrofon einen Audio-Abschnitt erfasst (z. B. 20ms), sende sofort ein audio.append-Event an den Server, statt auf den vollständigen Satz zu warten.

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 -> { /* Events ignorieren, die nicht benötigt werden */ }
        }
    }

    private void playAudioChunk(String base64Audio) {
        byte[] pcm = Base64.getDecoder().decode(base64Audio);
        // pcm in den Audio-Ausgabepuffer des Lautsprechers einzufügen
    }

    private void cancelOngoingPlayback() {
        // Laufende Audiowiedergabe stoppen — dies ist der Barge-In-Mechanismus
    }
}

Erklärung:

  • sendAudioChunk wird kontinuierlich vom Mikrofon-Aufzeichnungsthread aufgerufen (z. B. alle 20ms), ohne zu sammeln, um die Latenz niedrig zu halten.
  • handleTextMessage ist der Callback, der Events vom Server empfängt; response.audio.delta bedeutet, dass das Modell spricht, während input_audio.speech_started bedeutet, dass der Server erkannt hat, dass der Benutzer anfängt zu sprechen — in diesem Fall muss der Client den Lautsprecher sofort stoppen.
  • event.get("type").asText() verwendet Jackson JsonNode, um das Feld type zu lesen, ohne eine feste DTO zu definieren — hilfreich, da das Event-Schema neue Typen hinzufügen kann.

6. Barge-In-Mechanismus: Wer spricht wann?

Barge-In ist die Fähigkeit des Benutzers, die KI mitten im Satz zu unterbrechen. Dazu muss der Server einen Voice Activity Detector (VAD) haben, der kontinuierlich auf dem eingehenden Audio-Stream läuft, unabhängig davon, ob der Server gerade Audio ausgibt oder nicht.

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" -> {
                // Benutzer spricht, während das Modell spricht → unterbrechen
                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("Audio-Wiedergabe unterbrochen, sende response.cancel zum Server");
    }
}

Erklärung: TurnStateMachine verfolgt, wer “das Mikrofon hält”, mit 3 Zuständen. Wenn input_audio.speech_started ankommt, während sich der Zustand im MODEL_SPEAKING befindet, bedeutet das, dass der Benutzer eingegriffen hat — der Client muss den Lautsprecher sofort stoppen und den Server benachrichtigen, die laufende Antwort abzubrechen (sende event response.cancel), um zu vermeiden, dass alte und neue Audio sich überlagern.

Hinweis: Ohne Barge-In-Behandlung spricht die KI über den Benutzer — dies ist die unangenehmste Erfahrung von Voice Assistant. Dies ist der Teil, der bei schnellen Demos leicht übersehen wird, aber in der Produktion obligatorisch ist.

7. Session-Management und Kontext in Sprachdialogen

Da die Verbindung zustandsorientiert ist (sie bleibt während des gesamten Anrufs bestehen), benötigst du ein Session-Objekt, um den Gesprächsverlauf und Spracheinstellungen zu speichern, getrennt von der Transport-Logik.

public class VoiceSession {

    private final String sessionId;
    private final List<ConversationTurn> history = new ArrayList<>();
    private final String voice; // z. B. "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) {}
}

Beim Initialisieren der Session sendest du ein session.update-Event, um die Sprachausgabe und die System-Instruktion festzulegen, bevor du Audio aufzeichnest:

Map<String, Object> sessionUpdate = Map.of(
    "type", "session.update",
    "session", Map.of(
        "voice", "alloy",
        "instructions", "Du bist ein Assistent zur Vereinbarung von Arzttermine, antworte kurz und bündig.",
        "turn_detection", Map.of("type", "server_vad")
    )
);

turn_detection: server_vad bedeutet, dass du die Erkennung, wann der Benutzer seinen Satz beendet, dem Server mit VAD überlässt, anstatt dass der Client das durch Stille errät — dies ist genauer, da der Server auch semantische Signale nutzen kann (z. B. ein Satz, der grammatikalisch unvollständig ist, wird nicht als beendet betrachtet).

8. Testen von Turn-Taking-Logik mit JUnit

Transport (echte WebSocket) ist schwer zu testen, aber die State Machine, die Redewechsel steuert, lässt sich vollständig unabhängig testen.

class TurnStateMachineTest {

    @Test
    void whenModelSpeaksAndUserStartsSpeaking_shouldInterruptPlayback() {
        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 whenNobodySpeaks_stateShouldBeIdle() {
        TurnStateMachine machine = new TurnStateMachine();

        machine.onServerEvent("response.done");

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

(Angenommen, du hast einen Getter getState() zur TurnStateMachine hinzugefügt.) Der erste Test bestätigt die korrekte Zustandsübergabe, wenn eine Unterbrechung erfolgt; der zweite Test bestätigt, dass das System nach Abschluss der Antwort in IDLE zurückkehrt — dies ist eine Voraussetzung, damit die nächste Dialog-Schleife sauber beginnt.

9. Häufige Fehler und wann man es nicht verwenden sollte

  • Audio in großen Batches senden (z. B. alle 2 Sekunden): Das hebt alle Vorteile des Streaming auf, die Latenz ist wie bei der alten Pipeline. Immer kleine Chunks senden (20–100ms).
  • Das Event response.cancel nicht verarbeiten: Wenn du nur die Audio-Wiedergabe auf dem Client stoppst, aber den Server nicht benachrichtigst, generiert der Server weiterhin überschüssiges Audio, verschwendet Kosten und kann zu Zustandsinkonsistenzen führen.
  • Client-seitigen VAD verwenden, um selbst zu entscheiden, wann der Benutzer seinen Satz beendet: Das tendiert dazu, Sätze zu schnell oder zu spät zu unterbrechen, besonders in lauten Umgebungen; besser ist, den Server-VAD zu verwenden, wenn die API dies unterstützt, außer wenn du Offline-Funktionalität brauchst.
  • Für Use Cases anwenden, die keine Echtzeit-Interaktion benötigen (z. B. Berichtserstellung, Zusammenfassung langer Dokumente): Echtzeit-Voice-Modelle sind teurer und deutlich komplexer als traditionelle STT/LLM/TTS-Pipelines und lohnen sich nicht, wenn du die niedrige Latenz nicht brauchst.
  • WebSocket-Client auf dem Haupt-UI-Thread ohne separaten Aufzeichnungs-/Wiedergabe-Thread ausführen: Das kann zu Ruckeln und Blockieren führen, wenn JSON-Parsing den Audio-Thread unterbricht.

Hinweis: Full-Duplex-Voice verbraucht kontinuierlich Bandbreite und API-Kosten während des gesamten Anrufs, nicht nur pro Text-Token. Erwäge Session-Zeitlimits oder Timeouts bei Inaktivität.

10. Fazit

GPT-Live funktioniert mit einer offenen WebSocket-Verbindung, die bidirektional Audio-Chunks überträgt und VAD nutzt, um zu erkennen, wer spricht — das ist der Mechanismus, der “gleichzeitiges Hören und Sprechen” ermöglicht, statt auf die alte STT/LLM/TTS-Pipeline zu warten. Bei der Integration in Java-/Spring-Boot-Anwendungen besteht deine Hauptaufgabe darin, die Turn-Taking-State-Machine zu verwalten, Barge-In korrekt zu verarbeiten und die Session-Logik vom Transport zu trennen, um Tests zu erleichtern. Wenn dein Use Case keine Echtzeit-Antworten benötigt, ist die traditionelle Pipeline immer noch die einfachere und kostengünstigere Wahl.

llmrealtime-aispring-bootwebsocketvoice-ai