【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 接入、扩缩容和观测。
参考资料
- Spring AI Transcription API:https://docs.spring.io/spring-ai/reference/api/audio/transcriptions.html
- Spring AI Text-To-Speech:https://docs.spring.io/spring-ai/reference/api/audio/speech.html
- Spring WebFlux WebSocket:https://docs.spring.io/spring-framework/reference/web/webflux-websocket.html
语音交互真正的分水岭不是声音是否自然,而是用户能否随时打断,以及打断后系统是否真的停止消耗资源。