【AI 工程化第十八篇】Spring AI 语音 Agent 实战:实时转写、WebSocket、流式 TTS 与打断机制

【AI 工程化第十八篇】Spring AI 语音 Agent 实战:实时转写、WebSocket、流式 TTS 与打断机制

语音 Agent 不是简单的"上传 MP3 → 返回一段文字"。真正自然的体验需要解决:实时音频分片、端点检测、转写、LLM 流式生成、语音合成、用户打断、连接治理和隐私合规。

技术栈:Java 17 · Spring Boot 3.5.x / 4.1.x · Spring AI 2.0.x · WebFlux WebSocket · STT · TTS · Redis


语音 Agent 的延迟是累加的

text 复制代码
用户说完等待 + 音频上传 + STT + LLM 首 Token + TTS 首音频 + 网络播放

如果每一段都等完整结果,用户可能等待 8~15 秒。生产方案必须流水线化:

text 复制代码
边说边上传
→ 检测一句话结束
→ 流式转写
→ LLM 流式生成
→ 按句切分文本
→ 流式生成音频
→ 边收边播放

关键指标不是总耗时,而是:

text 复制代码
转写首结果时间
LLM 首 Token 时间
首音频时间 TTFA
用户打断生效时间

一、总体架构

1.1 功能模块图

text 复制代码
浏览器 / App
麦克风 → AudioWorklet → PCM/Opus Chunk
   │                              ▲
   ▼                              │ 音频帧 / 字幕 / 状态
WebSocket Gateway ─ 鉴权 · 心跳 · 连接限额 · 背压
   ▼
Voice Session Orchestrator
   ├─ VAD 端点检测
   ├─ STT 转写
   ├─ ChatClient / RAG / Tools
   ├─ Sentence Segmenter
   ├─ Streaming TTS
   ├─ Barge-in 打断
   └─ Metrics / Audit
       │
       ├─ Redis:会话、序号、限流
       └─ PostgreSQL:摘要、审计、用户授权

1.2 语音轮次时序

text 复制代码
客户端      WebSocket      VAD/STT        LLM          TTS
 │ audio ─────▶│              │             │            │
 │ audio ─────▶│─────────────▶│             │            │
 │             │◀─ partial ───│             │            │
 │◀─ subtitle ─│              │             │            │
 │ end ───────▶│─────────────▶│             │            │
 │             │◀─ final ─────│             │            │
 │             │────────────────prompt─────▶│            │
 │◀─ text token│◀──────────────────────────│            │
 │             │────────────────句子────────────────────▶│
 │◀─ audio ────│◀──────────────────────────────────────│
 │ 新语音/打断 ▶│──── cancel LLM / TTS ────────────────▶│

二、WebSocket 消息协议

WebSocket 只提供双向通道,不定义业务语义,必须自己约定事件协议。

2.1 客户端事件

json 复制代码
{"type":"session.start","conversationId":"conv-001","audio":{"codec":"pcm16","sampleRate":16000}}
{"type":"audio.append","sequence":1,"data":"BASE64_AUDIO"}
{"type":"audio.commit","sequence":42}
{"type":"response.cancel","responseId":"resp-001"}
{"type":"ping","timestamp":1792990000000}

2.2 服务端事件

json 复制代码
{"type":"transcript.partial","text":"帮我查一下"}
{"type":"transcript.final","text":"帮我查一下订单状态"}
{"type":"response.text.delta","responseId":"resp-001","text":"订单目前"}
{"type":"response.audio.delta","responseId":"resp-001","sequence":1,"data":"BASE64_AUDIO"}
{"type":"response.done","responseId":"resp-001"}
{"type":"error","code":"RATE_LIMITED","message":"请求过于频繁"}

每个音频帧要有序号,用于检测丢帧和重复帧。生产上二进制帧效率更高,但 JSON 控制帧更容易调试;可以采用"JSON 控制 + binary 音频"的混合协议。


三、WebFlux WebSocket 接入

3.1 路由配置

java 复制代码
@Configuration
public class VoiceWebSocketConfig {

    @Bean
    public HandlerMapping voiceHandlerMapping(VoiceWebSocketHandler handler) {
        SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
        mapping.setUrlMap(Map.of("/ws/v1/voice", handler));
        mapping.setOrder(-1);
        return mapping;
    }

    @Bean
    public WebSocketHandlerAdapter webSocketHandlerAdapter() {
        return new WebSocketHandlerAdapter();
    }
}

3.2 Handler

java 复制代码
@Component
public class VoiceWebSocketHandler implements WebSocketHandler {

    private final VoiceSessionService voiceSessionService;
    private final VoiceHandshakeAuthenticator authenticator;

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        return authenticator.authenticate(session)
                .flatMap(user -> {
                    VoiceSession voiceSession = voiceSessionService.open(user, session.getId());

                    Flux<WebSocketMessage> outbound = voiceSession.outboundEvents()
                            .map(event -> session.textMessage(toJson(event)));

                    Mono<Void> sender = session.send(outbound);
                    Mono<Void> receiver = session.receive()
                            .limitRate(32)
                            .concatMap(message -> voiceSession.handle(message))
                            .doOnError(voiceSession::recordError)
                            .doFinally(signal -> voiceSession.close(signal))
                            .then();

                    return Mono.when(sender, receiver);
                });
    }
}

连接建立时完成 JWT、Origin、租户和会话归属校验。不要把永久 Token 放在 URL 查询参数中,因为 URL 更容易进入访问日志。


四、音频分片与端点检测

推荐浏览器采集参数:

text 复制代码
16kHz
mono
PCM16 或 Opus
每帧 20~100ms
单帧设置大小上限

端点检测策略:

text 复制代码
连续语音达到 200ms → speech_started
静音达到 500~800ms → speech_stopped
单轮最长 30 秒 → 强制 commit
java 复制代码
public final class AudioBuffer {

    private final int maxBytes;
    private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    public synchronized void append(byte[] frame) {
        if (frame.length > 64 * 1024 || buffer.size() + frame.length > maxBytes) {
            throw new VoiceProtocolException("音频缓冲区超限");
        }
        buffer.writeBytes(frame);
    }

    public synchronized byte[] commitAndReset() {
        byte[] audio = buffer.toByteArray();
        buffer.reset();
        return audio;
    }
}

不要让每个 WebSocket 连接无限缓存音频。对于慢 STT 服务,应暂停接收、丢弃过旧帧或主动关闭连接,避免堆内存增长。


五、Spring AI STT 转写

Spring AI 提供统一的 TranscriptionModel:

java 复制代码
@Service
public class SpeechToTextService {

    private static final Logger log = LoggerFactory.getLogger(SpeechToTextService.class);
    private final TranscriptionModel transcriptionModel;

    public Mono<TranscriptionResult> transcribe(String traceId, byte[] audio) {
        return Mono.fromCallable(() -> {
                    long startedAt = System.nanoTime();
                    Resource resource = new ByteArrayResource(audio) {
                        @Override
                        public String getFilename() {
                            return "utterance.wav";
                        }
                    };
                    String text = transcriptionModel.transcribe(resource);
                    log.info("voice_stt traceId={} audioBytes={} textLength={} durationMs={}",
                            traceId, audio.length, text.length(), elapsedMs(startedAt));
                    return new TranscriptionResult(text, elapsedMs(startedAt));
                })
                .subscribeOn(Schedulers.boundedElastic())
                .timeout(Duration.ofSeconds(15));
    }
}

如果供应商支持实时转写,可以替换为流式适配器;领域层仍然使用 partial/final 事件,不要让前端绑定具体供应商协议。

日志不要保存原始音频和完整转写,默认记录:音频时长、字节数、语言、模型、耗时、内容哈希和用户授权状态。


六、LLM 流式生成与分句

TTS 不应该每收到一个 Token 就调用一次。应积累到句号、问号或长度阈值:

java 复制代码
@Component
public class SentenceSegmenter {

    private static final Pattern BOUNDARY = Pattern.compile("[。!?!?;;\\n]");

    public Flux<String> segment(Flux<String> tokens) {
        return Flux.create(sink -> {
            StringBuilder buffer = new StringBuilder();
            tokens.subscribe(
                    token -> {
                        buffer.append(token);
                        if (BOUNDARY.matcher(buffer).find() || buffer.length() >= 80) {
                            sink.next(buffer.toString());
                            buffer.setLength(0);
                        }
                    },
                    sink::error,
                    () -> {
                        if (!buffer.isEmpty()) sink.next(buffer.toString());
                        sink.complete();
                    });
        });
    }
}

实际项目建议实现为自定义 Reactor Operator,确保取消信号能够传播,避免内部 subscribe() 破坏链路。上面的代码主要说明分句规则。

java 复制代码
public Flux<String> generateText(VoiceContext context, String transcript) {
    return chatClient.prompt()
            .system("回答适合口语播报;先给结论;单句尽量不超过 40 个汉字。")
            .user(transcript)
            .advisors(advisor -> advisor.param("conversationId", context.conversationId()))
            .stream()
            .content()
            .timeout(Duration.ofSeconds(60));
}

七、流式 TTS

Spring AI 提供 TextToSpeechModel 和 StreamingTextToSpeechModel:

java 复制代码
@Service
public class StreamingTtsService {

    private static final Logger log = LoggerFactory.getLogger(StreamingTtsService.class);
    private final StreamingTextToSpeechModel speechModel;

    public Flux<AudioChunk> synthesize(String traceId, String responseId,
                                       Flux<String> sentences) {
        AtomicLong sequence = new AtomicLong();
        return sentences.concatMap(sentence -> {
                    long startedAt = System.nanoTime();
                    return speechModel.stream(sentence)
                            .map(bytes -> new AudioChunk(responseId,
                                    sequence.incrementAndGet(), bytes))
                            .doFinally(signal -> log.info(
                                    "voice_tts traceId={} responseId={} textHash={} durationMs={} signal={}",
                                    traceId, responseId, DigestUtils.sha256Hex(sentence),
                                    elapsedMs(startedAt), signal));
                }, 1)
                .onBackpressureBuffer(64, dropped -> log.warn(
                                "voice_tts_buffer_overflow responseId={}", responseId),
                        BufferOverflowStrategy.ERROR);
    }
}

句子按顺序 concatMap,避免后一句音频先返回导致播报顺序错乱。


八、Barge-in:用户打断 AI

没有打断机制的语音助手非常难用。

状态机:

text 复制代码
IDLE
 └─ 用户说话 → LISTENING
      └─ 端点结束 → THINKING
           └─ 首音频 → SPEAKING
                ├─ 正常结束 → IDLE
                └─ 检测用户新语音 → INTERRUPTING → LISTENING
java 复制代码
public final class ActiveResponse {

    private final String responseId;
    private final Disposable llmSubscription;
    private final Disposable ttsSubscription;
    private final AtomicBoolean cancelled = new AtomicBoolean();

    public void cancel() {
        if (cancelled.compareAndSet(false, true)) {
            llmSubscription.dispose();
            ttsSubscription.dispose();
        }
    }
}

打断时服务端执行:

text 复制代码
停止推送剩余音频
取消 TTS 上游
取消 LLM 上游
丢弃客户端尚未播放的 responseId 音频
保存已播报文本摘要,而不是保存完整未播报答案

前端收到 response.cancelled 后,应立即清空该 responseId 的 AudioBuffer。


九、会话编排

java 复制代码
public Flux<VoiceServerEvent> handleCommittedAudio(VoiceSession session, byte[] audio) {
    String responseId = newResponseId();
    long startedAt = System.nanoTime();

    Mono<TranscriptionResult> transcript = sttService
            .transcribe(session.traceId(), audio)
            .cache();

    Flux<VoiceServerEvent> subtitles = transcript
            .map(result -> VoiceServerEvent.transcriptFinal(result.text()))
            .flux();

    Flux<String> textTokens = transcript.flatMapMany(result ->
            agentService.generateText(session.context(), result.text()));

    Flux<VoiceServerEvent> textEvents = textTokens
            .map(token -> VoiceServerEvent.textDelta(responseId, token));

    Flux<AudioChunk> audioChunks = ttsService.synthesize(
            session.traceId(), responseId, sentenceSegmenter.segment(textTokens));

    Flux<VoiceServerEvent> audioEvents = audioChunks
            .map(VoiceServerEvent::audioDelta);

    return Flux.merge(subtitles, textEvents, audioEvents)
            .takeUntilOther(session.cancelSignal(responseId))
            .doFinally(signal -> metrics.finish(responseId, signal, elapsedMs(startedAt)));
}

同一个冷流 textTokens 被多处订阅会重复调用模型,生产代码要使用受控的 publish/replay 共享策略,并在最后一个订阅者取消时释放上游。


十、连接与资源治理

每个用户和租户都要限制:

text 复制代码
同时在线语音连接数
每分钟新建连接数
每轮音频最大时长
每会话最长持续时间
STT/LLM/TTS 并发
每日音频分钟数和 Token 预算
java 复制代码
public Mono<VoicePermit> acquireVoicePermit(SecurityUser user) {
    return distributedLimiter.acquire(
            "voice:" + user.tenantId() + ":" + user.userId(),
            Duration.ofMinutes(10));
}

WebSocket 心跳不能只依赖 TCP:

text 复制代码
客户端每 15 秒发送 ping
服务端 5 秒内返回 pong
45 秒没有任何消息关闭连接
关闭时取消 STT、LLM、TTS 和定时器

十一、隐私与合规

语音可能包含生物特征、客户身份和敏感业务信息。

默认策略:

text 复制代码
明确展示录音状态
首次使用获得用户授权
原始音频默认不落盘
转写文本按业务需要最小化保存
训练用途必须单独授权
日志只记录哈希和元数据
支持用户删除会话和长期记忆

如需质检录音,必须配置:用途、保存周期、访问角色、导出审批和删除任务。


十二、前端 TypeScript 示例

typescript 复制代码
type VoiceEvent =
  | { type: 'transcript.partial'; text: string }
  | { type: 'transcript.final'; text: string }
  | { type: 'response.text.delta'; responseId: string; text: string }
  | { type: 'response.audio.delta'; responseId: string; sequence: number; data: string }
  | { type: 'response.done'; responseId: string }
  | { type: 'error'; code: string; message: string };

const socket = new WebSocket('wss://example.com/ws/v1/voice', ['voice-v1']);

socket.onmessage = event => {
  const message = JSON.parse(event.data) as VoiceEvent;
  if (message.type === 'response.audio.delta') {
    audioPlayer.enqueue(message.responseId, message.sequence, message.data);
  }
};

function interrupt(responseId: string): void {
  audioPlayer.clear(responseId);
  socket.send(JSON.stringify({ type: 'response.cancel', responseId }));
}

浏览器采集麦克风必须使用 HTTPS,并在 UI 中持续显示"正在录音/正在上传"。


十三、异常处理

java 复制代码
public VoiceServerEvent safeError(Throwable error) {
    if (error instanceof VoiceRateLimitException) {
        return VoiceServerEvent.error("RATE_LIMITED", "语音请求过于频繁");
    }
    if (error instanceof TimeoutException) {
        return VoiceServerEvent.error("VOICE_TIMEOUT", "语音服务响应超时");
    }
    return VoiceServerEvent.error("VOICE_UNAVAILABLE", "语音服务暂时不可用");
}

WebSocket 已升级后无法再返回普通 HTTP 错误码,因此协议必须定义 error 事件和关闭码。内部错误日志记录 traceId,不能向客户端暴露供应商响应或堆栈。


十四、可观测指标

text 复制代码
voice.connection.active
voice.audio.input.seconds
voice.stt.first_partial.ms
voice.stt.final.ms
voice.llm.ttft.ms
voice.tts.first_audio.ms
voice.turn.total.ms
voice.interrupt.count
voice.interrupt.effective.ms
voice.websocket.backpressure.count
voice.provider.error.count

AI 调用日志至少记录:

text 复制代码
traceId、sessionId、responseId
STT/LLM/TTS 模型名
音频时长、Prompt Token、Completion Token
各阶段耗时、是否打断、结束原因

十五、上线检查清单

  • WebSocket 握手校验 JWT、Origin、租户和会话归属
  • 音频帧有大小、序号和会话时长限制
  • 音频缓冲区有硬上限
  • STT、LLM、TTS 分别设置超时和并发隔离
  • 文本按句切分后再调用 TTS
  • TTS 音频保证顺序
  • 用户打断能取消 LLM 与 TTS 上游
  • 连接关闭释放订阅、缓冲区和定时器
  • 原始音频默认不落盘
  • 日志不记录完整语音与敏感转写
  • 有连接、分钟数、Token 和并发限额
  • 测量 STT、TTFT、TTFA 和打断延迟
  • 前端明确显示录音和上传状态
  • 弱网、丢帧、重复帧和代理断连经过测试

十六、总结

语音 Agent 的核心是一条可以被取消和观测的实时流水线:

text 复制代码
音频采集 → VAD → STT → LLM → 分句 → TTS → 播放
                       ▲                    │
                       └──── 用户打断 ──────┘

只要其中任何一段不能取消、不能限流或不能追踪,成本和体验都会迅速失控。

下一篇进入企业最关心的部署问题:如何使用 Ollama/vLLM 部署私有模型,并通过 Spring AI 接入、扩缩容和观测。


参考资料

语音交互真正的分水岭不是声音是否自然,而是用户能否随时打断,以及打断后系统是否真的停止消耗资源。

相关推荐
天远数科1 小时前
零信任架构实战:基于天远企业年报信息核验构建自动化供应商准入资质合规审查网关
运维·人工智能·架构·自动化
taotao0331 小时前
【AI通识课·第2期】AI 发展简史——从图灵测试到 ChatGPT,这 70 年发生了什么?
人工智能·chatgpt
步行cgn1 小时前
Spring MVC 核心配置文件详解:springmvc.xml 每一行配置的意义
xml·spring·mvc
cfm_29141 小时前
SpringPrototype作用域
java
zhippt_10141 小时前
图转PPT实测:截图和课件图片怎么变成可编辑幻灯片
人工智能·面试·powerpoint
TangGeeA1 小时前
Control CLI Tutorial:用 aio 从终端控制 aiopsterm
运维·人工智能
king13598745501 小时前
AI Agent 端云协同选型:Node.js 任务成本算例与权限检查清单(2027—2028 展望)
人工智能·ai·node.js·ai agent
了不起的云计算V1 小时前
DDR4与DDR5如何选?海光双路新品给出分层部署思路
人工智能