模拟面试 V2 自控语音链路

模拟面试 V2 自控语音链路
AI Agent 工程实践教程 · 第 14 章
在 V1 实时语音基础上强化自控链路、状态机、打断恢复和前后端协作边界。
第十四天:模拟面试 V2 自控语音链路
第十三天我们完成的是第一版“端到端实时语音模型”链路。
那一版的特点是:
前端上传学生音频
↓
Java 转发给火山 Realtime
↓
火山同时完成 ASR、对话、TTS
↓
Java 把文本和音频返回前端
这条链路能快速跑通实时语音对话,但它有一个明显问题:
面试流程的控制权主要在火山端到端模型里。
如果我们只是做闲聊,这没有问题。
但是模拟面试不是闲聊。它后面要接:
- 题库主线
- 当前题目
- 当前题追问次数
- 下一题切换
- 学生回答评分
- 面试报告
这些流程不应该完全交给一个端到端语音模型自己决定。
所以第十四天开始,我们把语音链路拆开:
火山流式 ASR:只负责把学生语音转文字
YanQue-AI:只负责生成 AI 面试官下一句话
火山流式 TTS:只负责把 AI 文本转成语音
Java:控制主流程和业务状态
前端:控制用户交互、录音、播放
这就是 V2 自控语音链路。
这一版还没有接题库状态机。
但是它已经把 ASR、AI 生成、TTS、前端播放、对话入库、结束面试 这条主链路搭好了。
1. 今天完成了什么
这一节课对应当前已经完成的代码。
目前 V2 链路已经做到:
学生进入 V2 页面
↓
前端连接 Java WebSocket
↓
Java 调 YanQue-AI 生成 AI 面试官开场白
↓
Java 把 AI 文本流式送给火山 TTS
↓
前端播放 AI 面试官声音
↓
学生点击“开始回答”
↓
Java 创建火山 ASR WebSocket
↓
前端上传学生麦克风 PCM
↓
学生点击“说完了”
↓
Java 给火山 ASR 发送最后一包
↓
火山返回 ASR_FINAL
↓
Java 保存学生回答
↓
Java 关闭当前火山 ASR WebSocket
↓
Java 调 YanQue-AI 生成下一句话
↓
Java 调火山 TTS
↓
前端播放 AI 面试官声音
整体架构如下:
这张图里最重要的是:
Java 后端变成主流程控制者。
火山 ASR 和火山 TTS 都只是外部能力。
YanQue-AI 也只是生成面试官文本,不直接决定业务状态。
2. 为什么不继续用端到端语音模型
端到端语音模型的优点是链路短:
音频进去
文本和音频出来
但模拟面试更像一个业务流程,而不是一次普通聊天。
比如后面要做题库驱动时,Java 要知道:
现在问到第几题?
当前题最多追问几次?
学生这一题有没有答到关键点?
是继续追问,还是进入下一题?
面试什么时候结束?
结束后怎么生成报告?
如果继续用端到端语音模型,很多逻辑会变成:
让模型自己判断下一步
让模型自己决定问什么
让模型自己决定什么时候结束
这样做短期快,长期不好控。
所以 V2 选择半控制模式:
| 模块 | 职责 |
|---|---|
| Java | 控制面试主流程、保存状态、连接前端和外部服务 |
| 火山 ASR | 把学生语音转文字 |
| YanQue-AI | 根据上下文生成 AI 面试官下一句话 |
| 火山 TTS | 把 AI 文本转语音 |
| 前端 | 录音、播放、按钮状态和实时展示 |
一句话:
业务状态在 Java,语音能力在火山,语言生成在 YanQue-AI。
3. 当前代码清单
3.1 前端代码
V2 页面:
YanQue-Student-Web/src/pages/StudentMockInterviewVoiceV2Page.tsx
这个页面现在负责:
- 建立浏览器到 Java 的 WebSocket
- 接收 Java 返回的 JSON 事件
- 接收 Java 返回的 PCM 二进制音频
- 采集麦克风音频
- 把麦克风音频转成
pcm_s16le / 16000Hz / mono - 播放 AI 面试官的
pcm_s16le / 24000Hz / mono - AI 播放时锁住录音
- 点击“开始面试”
- 点击“开始回答”
- 点击“说完了”
- 点击“结束面试”
样式:
YanQue-Student-Web/src/styles.css
V2 页面路由和入口也已经接入学生端模拟面试页面。
3.2 Java WebSocket 处理器
YanQue-Admin/src/main/java/cn/yanque/studentFront/websocket/MockInterviewVoiceV2WebSocketHandler.java
这是 V2 的主控类。
它负责:
- 管理浏览器 WebSocket
- 接收前端 JSON 指令
- 接收前端 Binary PCM
- 控制什么时候创建 ASR
- 控制什么时候关闭 ASR
- 调 YanQue-AI 生成下一句话
- 调火山 TTS 合成语音
- 把音频 BinaryMessage 发回前端
- 保存学生和 AI 对话
- 结束面试时更新会话状态
握手拦截器:
YanQue-Admin/src/main/java/cn/yanque/studentFront/websocket/MockInterviewVoiceV2HandshakeInterceptor.java
它负责从前端 WebSocket URL 里取:
tokensessionId
然后校验学生身份和面试会话。
3.3 火山流式 ASR 客户端
YanQue-Admin/src/main/java/cn/yanque/studentFront/client/VolcengineStreamingAsrClient.java
YanQue-Admin/src/main/java/cn/yanque/studentFront/client/VolcengineStreamingAsrEventListener.java
YanQue-Admin/src/main/java/cn/yanque/config/DoubaoStreamingAsrProperties.java
它负责连接火山流式语音识别 WebSocket。
当前约定:
学生上传音频格式:pcm_s16le
采样率:16000Hz
声道:mono
火山 ASR 这里采用的是“一轮回答一个 ASR WebSocket”的方式。
也就是:
学生开始回答
↓
Java 创建火山 ASR WS
↓
前端上传 PCM
↓
学生点击说完了
↓
Java 发最后一包
↓
火山返回 ASR_FINAL
↓
Java 主动关闭当前 ASR WS
这样可以避免 ASR 连接长期空等。
3.4 火山双向流式 TTS 客户端
YanQue-Admin/src/main/java/cn/yanque/studentFront/client/VolcengineStreamingTtsClient.java
YanQue-Admin/src/main/java/cn/yanque/studentFront/client/VolcengineStreamingTtsEventListener.java
YanQue-Admin/src/main/java/cn/yanque/config/DoubaoStreamingTtsProperties.java
它负责连接火山双向流式语音合成 WebSocket。
当前约定:
AI 返回音频格式:pcm_s16le
采样率:24000Hz
声道:mono
Java 会把 YanQue-AI 返回的文本 chunk 缓存一下,然后按标点或长度送给 TTS:
AI_TEXT_CHUNK
↓
ttsBuffer 追加
↓
遇到标点或长度达到阈值
↓
sendText 给火山 TTS
↓
火山返回 PCM
↓
Java BinaryMessage 转发给前端
3.5 Java 调 YanQue-AI
YanQue-Admin/src/main/java/cn/yanque/studentFront/client/ai/PythonAiChatClient.java
新增了调用模拟面试下一句话的流式接口:
POST /api/mock-interview/interviewer/next-message/stream
Java 通过 SSE 接收:
message_start
chunk
done
error
3.6 YanQue-AI 代码
YanQue-AI/src/yanque_ai/api/mock_interview_api.py
YanQue-AI/src/yanque_ai/mock_interview/interviewer_service.py
YanQue-AI/src/yanque_ai/schemas/mock_interview.py
YanQue-AI/src/yanque_ai/core/config.py
YanQue-AI/src/yanque_ai/services/dependencies.py
YanQue-AI/src/yanque_ai/main.py
当前接口:
POST /api/mock-interview/interviewer/next-message/stream
它使用 LangChain 调模型,流式生成 AI 面试官下一句话。
目前它还不是题库驱动。
当前输入主要是:
{
"sessionId": 1,
"studentId": 1001,
"targetPosition": "Java后端开发",
"interviewType": "技术面试",
"difficulty": "实习/初级",
"currentQuestion": "请根据最近对话继续推进模拟面试。",
"messages": [
{"role": "assistant", "content": "你好,请先做自我介绍。"},
{"role": "student", "content": "我叫黄景,做 Java 后端开发三年。"}
]
}
它返回 SSE 文本流。
Java 再把文本流交给 TTS。
3.7 MySQL 表
已经用到两张表:
mock_interview_session
mock_interview_message
mock_interview_session 记录面试会话。
主要字段:
| 字段 | 作用 |
|---|---|
id |
面试会话 ID |
student_id |
学生 ID |
profile_json |
面试画像 |
status |
PROFILE_READY / IN_PROGRESS / FINISHED / CANCELED |
started_at |
面试开始时间 |
finished_at |
面试结束时间 |
mock_interview_message 记录对话文本。
主要字段:
| 字段 | 作用 |
|---|---|
session_id |
属于哪场面试 |
role |
STUDENT 或 ASSISTANT |
content |
对话内容 |
created_at |
创建时间 |
现在 ASR_FINAL 会保存成:
role = STUDENT
AI 面试官最终文本会保存成:
role = ASSISTANT
4. 开始面试:为什么不创建 ASR
现在点击“开始面试”时,前端只做一件事:
连接 Java WebSocket
它不会立刻创建火山 ASR。
原因是火山 ASR 建好以后,如果一直没有收到音频,会超时:
Timeout waiting next packet
所以开始面试时的正确流程是:
这张图里重点看右侧:
火山 ASR 这一列没有动作
也就是说:
开始面试
↓
只连接 Java WS
↓
AI 面试官先说开场白
↓
前端播放完开场白
↓
按钮解锁为“开始回答”
这个设计解决了一个实际问题:
不要让 ASR 在没有学生音频的时候空等。
5. 学生点击“开始回答”:这时才创建 ASR
AI 面试官说完以后,前端按钮会变成:
开始回答
学生点击这个按钮后,前端先打开本地麦克风。
但这时还不会立刻发送 PCM。
前端会先设置:
committedRef.current = true
这个变量的作用是暂停发送音频。
然后前端给 Java 发:
{"type":"READY_FOR_STUDENT_SPEECH"}
Java 收到后才调用:
streamingAsrClient.start(...)
火山 ASR 准备好后,Java 返回:
{
"type": "ASR_READY",
"data": {
"asrSessionId": "..."
}
}
前端收到 ASR_READY 后才放开:
committedRef.current = false
然后麦克风数据开始真正发给 Java。
完整时序如下:
这个设计有两个好处:
- 不会提前创建 ASR,避免超时。
- 不会丢第一段音频,因为麦克风先准备好,再通知 Java 创建 ASR。
6. 前端上传音频
前端使用浏览器麦克风:
navigator.mediaDevices.getUserMedia(...)
浏览器采集到的是 float32 音频,采样率通常是 44.1k 或 48k。
火山流式 ASR 当前只接收:
pcm_s16le / 16000Hz / mono
所以前端会做一次重采样和格式转换:
floatToPcm16(channelData, audioContext.sampleRate)
然后通过浏览器 WebSocket 发送二进制:
ws.send(pcmBuffer)
Java 后端收到 BinaryMessage 后,不做识别,只转发给火山 ASR:
streamingAsrClient.sendAudio(asrSessionId, audioBytes)
这里还有一个保护:
如果当前 ASR 没准备好,Java 不会偷偷创建 ASR,而是返回:
{"type":"ASR_NOT_READY"}
这样可以让前后端状态保持一致。
7. 点击“说完了”:怎么拿最终文本
学生回答完以后,前端发送:
{"type":"AUDIO_COMMIT"}
Java 收到后调用:
streamingAsrClient.finishAudio(asrSessionId)
火山流式 ASR 没有 Realtime 里的 EndASR(event=400)。
这里要发的是:
audio only request
flags = 0b0011
sequence = 负数
payload = 空
也就是告诉火山:
这一轮音频结束了
然后火山返回最终结果。
Java 判断最终结果主要看:
flags = 0b0010
或 sequence < 0
同时还保留了一个兜底:
如果前端已经点了“说完了”,下一条带文本的 ASR 响应也可以作为本轮最终文本处理。
拿到 ASR_FINAL 后,Java 会做三件事:
- 保存学生回答到
mock_interview_message - 关闭当前火山 ASR WebSocket
- 触发 AI 生成下一句话
8. ASR_FINAL 到 AI + TTS 的完整链路
这一步是当前 V2 的核心闭环。
从代码角度看,流程在:
MockInterviewVoiceV2WebSocketHandler#forwardAsrEvent
当事件类型是:
ASR_FINAL
会执行:
saveStudentFinalMessage
closeCurrentAsrSession
generateAndSpeakAssistant
generateAndSpeakAssistant 负责:
- 调 YanQue-AI SSE 接口
- 接收 AI 文本 chunk
- 把 AI 文本 chunk 发给前端展示
- 把 AI 文本按标点或长度送给火山 TTS
- 接收火山 TTS PCM 音频
- 把 PCM BinaryMessage 发给前端
- AI 生成完成后保存 ASSISTANT 消息
这里 Java 是一个桥:
YanQue-AI 文本流
↓
Java 分段
↓
火山 TTS
↓
PCM 音频流
↓
前端播放
9. 前端播放 AI 面试官声音
火山 TTS 返回的是 PCM 二进制音频。
Java 直接把音频用 BinaryMessage 发给前端。
前端收到非字符串消息时:
if (typeof event.data !== 'string') {
queueAssistantAudioBytes(audioData)
}
然后用 WebAudio 播放:
audioContext.createBuffer(...)
source.start(startAt)
当前播放格式是:
pcm_s16le / 24000Hz / mono
为了避免音量太小,前端还做了一个简单增益:
const boost = Math.min(6, Math.max(1.6, 18000 / peak))
它不是改变 TTS 内容,只是把 PCM 播放音量抬高。
10. AI 播放时为什么要锁住学生录音
实时语音面试里有一个很容易踩的坑:
AI 正在说话时,学生麦克风可能会把 AI 声音也录进去。
如果不处理,会发生:
AI 面试官说话
↓
浏览器麦克风收到了扬声器里的 AI 声音
↓
前端把这段声音发给 ASR
↓
ASR 把 AI 自己说的话识别成学生回答
所以现在前端做了两层控制:
第一层是 UI:
AI 播放中
↓
开始回答按钮禁用
说完了按钮禁用
静音按钮禁用
第二层是底层音频采集:
if (assistantSpeakingRef.current) {
return
}
也就是说,即使麦克风已经开着,只要 AI 正在播放,前端也不会把 PCM 发给 Java。
播放结束以后,前端不会自动开 ASR。
它只做:
assistantSpeaking=false
按钮解锁为“开始回答”
用户点击“开始回答”以后,才会创建 ASR。
11. 结束面试按钮
现在红色结束按钮已经不是简单关闭页面资源了。
它会发:
{"type":"END_INTERVIEW"}
Java 收到后:
- 关闭当前火山 ASR WebSocket
- 更新
mock_interview_session - 把
status改成FINISHED - 写入
finished_at - 返回
INTERVIEW_FINISHED
时序如下:
对应 mapper:
MockInterviewSessionMapper#finishSession
SQL 逻辑:
update mock_interview_session
set status = 'FINISHED',
finished_at = #{now},
updated_at = #{now}
where id = #{id}
and status != 'FINISHED'
前端收到 INTERVIEW_FINISHED 后,再清理:
- 本地麦克风
- 音频处理器
- 播放队列
- Java WebSocket
12. WebSocket 消息约定
V2 里浏览器和 Java 之间主要是两类消息:
JSON 文本消息
Binary 音频消息
12.1 前端发给 Java
| 类型 | 说明 |
|---|---|
READY_FOR_STUDENT_SPEECH |
用户点击“开始回答”,请求 Java 创建 ASR |
AUDIO_COMMIT |
用户点击“说完了”,提交本轮音频 |
END_INTERVIEW |
用户点击结束面试 |
| Binary PCM | 学生麦克风音频,16k PCM |
12.2 Java 发给前端
| 类型 | 说明 |
|---|---|
VOICE_V2_WS_READY |
Java WebSocket 已连接 |
ASR_READY |
火山 ASR 已准备好,可以开始发送音频 |
ASR_NOT_READY |
收到音频时 ASR 还没准备好 |
AUDIO_RECEIVED |
Java 收到并转发了一段学生音频 |
AUDIO_COMMITTED |
Java 已发送火山 ASR 最后一包 |
ASR_PARTIAL |
火山 ASR 中间识别结果 |
ASR_FINAL |
火山 ASR 最终识别结果 |
AI_ASSISTANT_START |
AI 面试官开始生成 |
AI_MODEL_START |
YanQue-AI 模型开始输出 |
AI_TEXT_CHUNK |
AI 面试官文本 chunk |
AI_ASSISTANT_DONE |
AI 面试官文本生成结束 |
TTS_EVENT |
火山 TTS 事件 |
TTS_ERROR |
火山 TTS 错误 |
INTERVIEW_FINISHED |
后端已结束面试 |
| Binary PCM | AI 面试官 TTS 音频,24k PCM |
13. 火山 ASR 协议里几个关键点
当前火山 ASR 客户端最容易出错的是 sequence。
一开始 full client request 虽然帧里不带 sequence,但火山服务端会把它计为第 1 包。
所以后续第一段音频不能从 1 开始。
当前代码里:
private final AtomicInteger sequence = new AtomicInteger(1);
第一段音频会用:
sequence = 2
结束本轮回答时,要发负 sequence:
sequence = -Math.abs(connection.nextSequence())
配套 flags:
0b0011
这个表示:
header 后 4 字节是 sequence number
并且这是最后一包
sequence 需要是负数
如果这里写错,就会看到类似:
autoAssignedSequence mismatch sequence
14. 火山 TTS 当前注意点
TTS 当前代码已经接好双向流式 WebSocket。
但是火山 TTS 有一个实际配置坑:
resource-id 必须和 speaker 匹配
如果不匹配,会报:
{"error":"resource ID is mismatched with speaker related resource"}
当前配置已经切到:
doubao:
streaming-tts:
resource-id: ${YQ_DOUBAO_STREAMING_TTS_RESOURCE_ID:volc.service_type.10029}
speaker: ${YQ_DOUBAO_STREAMING_TTS_SPEAKER:zh_female_vv_jupiter_bigtts}
但最终能不能发声,仍然取决于当前火山账号是否开通了对应资源和音色。
如果仍然不通,需要通过火山控制台或 ListSpeakers 查询:
某个 speaker 对应的 ResourceID 到底是什么
然后把这两个配置填成同一组。
15. YanQue-AI 的职责
现在 YanQue-AI 只做一件事:
生成 AI 面试官下一句话
它不负责:
- 创建 ASR
- 创建 TTS
- 播放音频
- 保存数据库
- 判断当前题库 index
- 控制面试结束
这些都在 Java。
YanQue-AI 的接口是:
POST /api/mock-interview/interviewer/next-message/stream
返回 SSE:
event: message_start
data: {"model":"..."}
event: chunk
data: {"content":"你好,"}
event: done
data: {"content":"完整文本", "tokens": {...}}
Java 收到 chunk 后,一边发给前端展示,一边送给 TTS。
16. 当前还没有完成什么
当前完成边界如下:
特别强调:
题库驱动状态机还没有完成。
现在 AI 面试官的下一句话还是根据最近对话自由生成。
还没有实现:
currentQuestionIndex
followUpCount
maxFollowUps
state
decisionAction
也还没有让普通文本模型返回:
{"action":"ASK_FOLLOW_UP"}
所以现在是:
语音闭环完成
文本入库完成
多轮 AI 回复完成
题库主线未完成
17. 题库状态机后面应该怎么接
后面推荐先做最小版状态机。
Java 维护:
sessionId
currentQuestionIndex
followUpCount
state
lastStudentAnswer
状态先用:
OPENING
ASKING_QUESTION
WAITING_STUDENT
DECIDING_NEXT_ACTION
ASKING_FOLLOW_UP
MOVING_NEXT_QUESTION
FINISHED
第一版不要马上接复杂决策模型。
可以先用规则版:
当前题追问次数 < 1
-> ASK_FOLLOW_UP
否则
-> COMMENT_AND_NEXT
跑通以后,再把规则改成短 JSON 决策模型:
{"action":"ASK_FOLLOW_UP"}
Java 仍然控制主线。
AI 只负责把 Java 给的意图说自然。
18. 当前验证命令
后端编译:
cd /Users/huangjin/IdeaProjects/YanQue/YanQue-Admin
/Users/huangjin/Documents/apache-maven-3.6.3/bin/mvn -DskipTests compile
前端构建:
cd /Users/huangjin/IdeaProjects/YanQue/YanQue-Student-Web
npm run build
YanQue-AI 编译检查:
cd /Users/huangjin/IdeaProjects/YanQue/YanQue-AI
AI_CHAT_MOCK_ENABLED=true uv run python -m compileall src/yanque_ai
模型流式接口测试:
curl -N \
-w '\n\nfirst_byte=%{time_starttransfer}s total=%{time_total}s\n' \
-H 'Content-Type: application/json' \
-X POST 'http://127.0.0.1:8000/api/mock-interview/interviewer/next-message/stream' \
-d '{
"sessionId": 1,
"studentId": 1001,
"targetPosition": "Java后端开发",
"interviewType": "技术面试",
"difficulty": "实习/初级",
"currentQuestion": "请你做一下自我介绍。",
"questionPoints": [],
"messages": []
}'







