模拟面试 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 面试官声音

整体架构如下:

模拟面试 V2 自控语音链路总体架构

这张图里最重要的是:

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 里取:

  • token
  • sessionId

然后校验学生身份和面试会话。

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 STUDENTASSISTANT
content 对话内容
created_at 创建时间

现在 ASR_FINAL 会保存成:

role = STUDENT

AI 面试官最终文本会保存成:

role = ASSISTANT

4. 开始面试:为什么不创建 ASR

现在点击“开始面试”时,前端只做一件事:

连接 Java WebSocket

它不会立刻创建火山 ASR。

原因是火山 ASR 建好以后,如果一直没有收到音频,会超时:

Timeout waiting next packet

所以开始面试时的正确流程是:

开始面试时不创建 ASR WebSocket

这张图里重点看右侧:

火山 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 时序

这个设计有两个好处:

  1. 不会提前创建 ASR,避免超时。
  2. 不会丢第一段音频,因为麦克风先准备好,再通知 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 会做三件事:

  1. 保存学生回答到 mock_interview_message
  2. 关闭当前火山 ASR WebSocket
  3. 触发 AI 生成下一句话

8. ASR_FINAL 到 AI + TTS 的完整链路

这一步是当前 V2 的核心闭环。

说完了后从 ASR_FINAL 到 AI TTS 的完整时序

从代码角度看,流程在:

MockInterviewVoiceV2WebSocketHandler#forwardAsrEvent

当事件类型是:

ASR_FINAL

会执行:

saveStudentFinalMessage
closeCurrentAsrSession
generateAndSpeakAssistant

generateAndSpeakAssistant 负责:

  1. 调 YanQue-AI SSE 接口
  2. 接收 AI 文本 chunk
  3. 把 AI 文本 chunk 发给前端展示
  4. 把 AI 文本按标点或长度送给火山 TTS
  5. 接收火山 TTS PCM 音频
  6. 把 PCM BinaryMessage 发给前端
  7. 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 自己说的话识别成学生回答

所以现在前端做了两层控制:

前端 AI 播放状态控制

第一层是 UI:

AI 播放中
  ↓
开始回答按钮禁用
说完了按钮禁用
静音按钮禁用

第二层是底层音频采集:

if (assistantSpeakingRef.current) {
  return
}

也就是说,即使麦克风已经开着,只要 AI 正在播放,前端也不会把 PCM 发给 Java。

播放结束以后,前端不会自动开 ASR。

它只做:

assistantSpeaking=false
按钮解锁为“开始回答”

用户点击“开始回答”以后,才会创建 ASR。


11. 结束面试按钮

现在红色结束按钮已经不是简单关闭页面资源了。

它会发:

{"type":"END_INTERVIEW"}

Java 收到后:

  1. 关闭当前火山 ASR WebSocket
  2. 更新 mock_interview_session
  3. status 改成 FINISHED
  4. 写入 finished_at
  5. 返回 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": []
  }'