见面考复现:基于WebRTC、Whisper与本地LLM的在线面试系统实践
我上个月在技术社区闲逛时,看到有人放出了一个叫“见面考”的交互式面试评估原型,功能做得挺全,但代码一拉下来才发现问题不少:文档缺得厉害、音视频延迟高、评分逻辑写死、扩展性几乎为零。我手里刚好有项目要用到“在线面对面考核”这套东西,干脆就基于这个思路做了一次完整的复现,顺便把整个流程里踩过的坑、排查链路、性能调优方案都记录下来。这篇博文就围绕“见面考复现”这个项目展开,适合正在做在线面试、答辩、语言测评类系统的开发者,或者说任何需要自建“实时音视频 + AI评分”解决方案的技术团队参考。
先说清楚“见面考复现”到底复现的是什么。它不是简单地把别人代码拉下来跑通,而是把一个面向“见面式考核场景”的完整系统,从业务逻辑、音视频通信、语音识别、评分引擎到结果报告,重新设计并实现一遍。整个过程花了大半个月,中间推翻了一次架构。这篇内容我会按项目拆解、链路设计、选型理由、实操记录、踩坑排查、性能调优这几个角度来讲,尽量把“为什么这么做”也交代清楚,而不是只丢一堆配置。
1. 为什么非要在已有方案上再做一次复现
复现这个事情的直接起因,是原版“见面考”项目在 GitHub 上有几个很明确的短板,社区里提 issue 的人也不少,但维护者基本不回应。我拉下来跑了一遍,发现它确实能完成“创建房间、候选人进入、面试官提问、系统录音、自动出分”整个流程,但核心代码耦合度非常高,音视频模块和业务模块搅在一起,修改一个参数经常牵动一片逻辑。
更深层的问题是,原版把“考核结果”定义成了一种固定算法:基于关键词匹配算分。比如候选人回答里出现“敏捷开发”“需求评审”这些词,就给对应分值。这在真实面试场景里根本不够用,因为同一个意思换个说法就得分骤降,稍微口语化一点就可能挂零。真正可用的“见面考”类系统,得有能力理解“语义”,而不只是“词频”。
所以我做的第一件事是把原版定义成一个“可复现的中间件层”,而不是一个企业级成品。也就是先把原版的核心流程梳理清楚——它实现了哪些功能、用了什么技术栈、哪些设计可以直接借鉴、哪些需要重构——然后再用一个更适合二次开发的方式重新落地。复现的核心目标有三条:
- 把音视频链路和业务逻辑彻底拆开,互不干扰。
- 用语义评分替代关键词匹配评分,至少能理解“意思对不对”。
- 让整个系统可以分布式部署,不能一台服务器扛所有转码和识别压力。
原版还有一个小问题在登录鉴权上,它把面试官和候选人放在同一个角色体系里,权限只有“创建者”和“参与者”两层。复现时我专门把“考官”“监考员”“候选人”拆成三种独立角色,并且加入了操作审计日志,这是原版完全没有的。
复盘下来,复现一个项目最大的价值不是“把功能做出来”,而是通过重写去理解原版设计的取舍。比如原版之所以用关键词匹配评分,大概率是为了省成本,不想接大模型 API,也不想自己在本地部署推理服务。但放到实际项目里,这种省法会让产品失去核心竞争力。这个问题我会在后面的选型部分展开。
2. 核心链路剖析:从“见面”到“评分”的整体设计
整个“见面考”系统可以拆成六个模块:会议管理、音视频传输、录制归档、语音转写、语义评估、报告生成。这六个模块不是简单的串行关系,实际上它们是两条并行链路在跑:一条是实时交互链路,另一条是异步分析链路。
2.1 实时交互链路:考核现场怎么“见面”
实时交互链路负责的是“考官和候选人真正见面”的这段过程。它包含房间的创建与加入、音视频流的发布与订阅、屏幕共享、聊天通知、以及录制。
回到原版的设计,它用的是 Mesh 架构,也就是每个参会者直接跟其他人点对点建连。这种方案在两人面试的场景下体验还行,但如果是“一位候选人面对多位考官”的场景,上行带宽会成倍增加,移动端直接卡成 PPT。复现时我换成了 SFU 架构,服务端负责转发音视频流,客户端只维护一条上行线和若干条下行线,这样带宽压力就从客户端转移到了服务端。
房间状态管理我用了 Redis 做在线状态存储,配合 WebSocket 推送事件。这里有一个细节:原版把房间状态全部放在内存里,一旦服务重启,所有在进行的面试直接断掉。复现后在 Redis 里维护状态,服务重启还能恢复会议现场。
音频方面还加了 Dolby 风格的降噪参数——这里不是指必须上商业 SDK,而是用 WebRTC 内置的音频处理模块,配合高通滤波器去掉 80Hz 以下的环境噪声。实测下来,在普通办公室环境下,候选人说话的字准率能提升 4~6 个百分点。
2.2 异步分析链路:从录音到报告发生了什么
异步链路跑的是“识别、评估、出报告”这一段。面试结束后,系统不会马上销毁音频,而是把录制文件转存到 MinIO 对象存储,同时推送一条 MQ 消息让分析服务开始工作。
分析服务的第一个环节是语音转写。原版用的云厂商 ASR 接口,对中文口语的识别效果其实不差,但问题是数据要过第三方,很多企业客户直接拒绝。复现时我换成在本地部署 Whisper large-v3 模型做转写,虽然显存占用高了些,但数据不出内网,这个交易非常划算。
转写完成之后进入语义评估环节。我设计了一个“多维评估器”,每个维度是一个独立模型或规则集合,分别负责知识准确性、表达流畅度、逻辑条理、情绪稳定性,最后汇总成一个综合评分报表。知识准确性这一项直接调用本地 LLM 做裁判,把候选人的回答和面试官预设的参考答案放进同一个 Prompt,让它输出 1~10 分并附上理由。流畅度和条理性则用转写文本的特征统计来实现,包括停顿次数、语气词密度、段落结构完整度等。
报告生成模块会把所有评分结果渲染成一份 PDF 或者 HTML 报告,包含每个问题的逐字稿、每题得分、综合雷达图、面试官评语模板。原版这里只输出一个总分,过于粗糙,我给报告加了时间轴回放功能,考官可以在报告里点击任意一句话,直接跳转到录音的对应时间点。
2.3 模块间的数据流与控制流
两条链路之间的关系是通过“考核单”这个核心实体串联的。候选人进入房间即生成一张考核单,实时链路负责往考核单上写音视频流的状态信息,分析链路负责往考核单上写识别结果和评分结果。两边的服务互不直接调用,通过 PostgreSQL 和消息队列解耦。
控制流则围绕状态机展开:等待开始、面试中、正在分析、已出报告、报告已确认。每个状态的迁移都要求有明确触发条件,比如“正在分析”到“已出报告”必须等录音文件完成转写,并且至少有一个评分维度跑完。原版没有这层状态机,导致出现过“报告都生成了但录音还在转码”的错乱情况。
这张完整链路图画出来以后,我最大的感受是:一个面试考核系统的复杂度,根本不在音视频那一块,而在“如何把实时的音视频数据变成结构化的评估结果”。这个“从流到数据”的过程,才是真正的护城河。
3. 关键技术选型及理由:为什么是 SFU + Whisper + LLM 三件套
很多人看完架构第一反应是“你怎么不用更成熟的商业方案”,比如声网、腾讯会议 API 或者云厂商的“实时音视频 + 语音识别”全家桶。说实话我也纠结过,但最终选择自研复现,原因有三点。
3.1 音视频部分为什么选 SFU 而不是商业 SDK 或 P2P
商业 SDK 接入快,可是按分钟计费的价格在面试场景里很容易失控。一次完整的面试最少 30 分钟,假设一天有 500 场并发面试,按商业 SDK 的后付费价格算,光音视频成本就是一笔不小的数目。更重要的是商业 SDK 的黑盒属性,出了问题只能提工单等回复,无法在应用层做精细化控制。
P2P 我也测过,参考原版用的就是 P2P。测试结果很明确:四人以内的房间基本可用,超过四个人网络拓扑会变得极其复杂,NAT 穿透失败率显著上升。在这套系统里我选择 mediasoup 作为 SFU,主要看重三点:一是它底层基于 libwebrtc,音视频编解码能力和浏览器完美兼容;二是可以随时查看内部统计信息,比如每个消费者的丢包率、抖动、码率;三是不需要额外付费,代码可控。
为了降低 mediasoup 的部署门槛,我把它封装成了一个独立容器服务。WebRTC 网关对外只暴露 REST API 和 WebSocket,内部维护 worker、router、transport 等核心对象。业务层完全不需要关心这些底层细节。
3.2 语音转写为什么选本地 Whisper 而不是云 API
最开始原版用的是云 ASR,但有两个硬伤:数据合规和数据延迟。面试音频里包含大量个人信息,走第三方 API 意味着这些数据要经过别人的服务器,这在很多企业内部评审环节是直接被否决的。第二个问题是云 ASR 的响应时间不稳定,高峰期经常要 5 秒以上才返回一段结果,严重影响面试评估的流畅度。
本地 Whisper 的好处是延迟可预期。我用的部署方式是基于 Faster-Whisper 做推理,在单张 RTX 4090 上,一个 30 分钟的音频文件转写时间可以压到 4 分钟以内。对于需要准实时转写观看的场景,用流式二分切片配合 VAD 检测,也能实现 2 秒左右的延迟出字。
这里有个性能细节值得单独说明。Whisper 转写长音频如果只做一次
transcribe()
,会消耗大量内存,而且中途出错无法断点恢复。我按 VAD 把音频切成 10~15 秒的小段,逐段送入模型推理,每段结果按时间戳拼接。这样单段失败只需要重试该段,不会影响整场面试的转写数据。
3.3 语义评分为什么直接上本地 LLM
我有认真考虑过用 BERT 模型做语义相似度匹配方案,这样内存占用低、显存要求也低,但实际测试效果不理想。面试中的回答往往很长,包含转折、补充、举例,简单的相似度计算没办法区分“说得对但跑题”和“说得对且扣题”这两种情况。
最后我选择了本地部署 Qwen2.5 32B 模型,结合结构化 Prompt 做评判。评分 Prompt 里会包含面试问题、参考答案要点、候选人转写原文、评分维度说明四项信息。模型输出统一要求 JSON 格式,包含每个维度得分和理由。为了让评分更稳定,我让模型先“思考”再“打分”——在 Prompt 里直接要求先列举候选人回答中与参考答案相关的句子,再基于这些句子给出分数,这个方式的评分一致性比直接打分高不少。
如果显存不是特别充足,也可以用 7B 到 14B 之间的模型替代,但评分稳定性会有所下降。我测试过的最小可用配置是 8GB 显存跑 Qwen2.5 7B,知识准确性维度的 Pearson 相关系数从 0.72 降到了 0.58,差距还是挺明显的。
3.4 数据存储和中间件的组合
存储方面用了四种组件:一是 PostgreSQL 存业务核心数据,包括考核单、用户、报告;二是 Redis 存房间状态、Session 和热数据;三是 MinIO 提供兼容 S3 协议的对象存储,专门放录音和转写中间文件;四是 RabbitMQ 做异步任务调度。原版只有 MySQL 和本地磁盘,根本扛不住文件存储和消息解耦的需求。
4. 实操记录:核心服务搭建与两条踩坑排查链路
选型定下来之后,真正动手时,问题比想的要多。我挑三个最典型的环节记录原始操作过程,分别是 mediasoup 网关的私有化部署、音频切片的 VAD 策略调优、以及本地 LLM 评分的延迟优化。
4.1 mediasoup 网关的部署流程与 ICE 连接失败排查
部署 mediasoup 网关时,我先在 Ubuntu 22.04 服务器上安装了 Node.js 18 LTS 和 mediasoup 3.14 版本。安装完成后,单独写了一个最小可运行的服务,只暴露
CreateWebRtcTransport
接口,用来确认 transport 能正常创建。这里注意
listenIp
选项需要同时配置 IPv4 和
announcedIp
,如果服务器有公网 IP,必须显式填写公网地址,否则客户端拿到的 ICE candidate 是内网地址,浏览器根本无法连接。
我在这块第一次踩的坑就是漏配
announcedIp
。本地测试一切正常,部署到云服务器后,从公网过来的浏览器一直报
iceConnectionState: failed
。排查链路是这样的:先在服务端日志确认 ICE candidate 已经生成,然后用
tcpdump
抓包看 STUN 捆绑请求,发现客户端发的 Binding Request 目标是内网 IP,才定位到是 candidate 地址错误。补上
announcedIp
后连接秒开。
还有一次遇到 ICE 穿透不稳定的情况,表现为部分网络能连,部分网络连不上。排查思路是先在服务器上执行
sudo turnserver --list-all-users
看 TURN 服务是否正常,然后确认 mediasoup 的
listenIps
和
rtcMinPort/rtcMaxPort
端口范围是否放行。云服务器默认安全组只开放了 80/443,UDP 端口范围没放行,导致 TURN 转发失败。把
rtcMinPort=40000
到
rtcMaxPort=49999
加进安全组之后,问题就消失了。
4.2 录音切片与 Whisper 转写对齐的调试过程
录音模块按 15 秒一个切片落盘,转写时再把这些切片合并成完整文本。最初测试发现,合成后的文本经常出现一句完整的话被切成两半,中间还夹杂了重复文字。
定位后确认是切片边界切在词中间导致的。解决办法是在切片之前先跑一次 VAD,把静音段识别出来作为可切分点。如果 VAD 找不到静音段,就取 15 秒内最接近音量谷值的位置作为边界。按照这个策略重新切片后,跨段句子的比例降到了 5% 以下。
转写对齐还有个大坑:Whisper 返回的
segment
时间戳是按实际说话内容标定的,但如果音频中有大段静音,Whisper 会生成一个比实际偏后的时间戳。我在对齐转写文本和原始录音时,处理方式是先把整段音频按时间戳二分,分别转写后再合并,并且在合并时用两个分段的边界重叠区域做去重。也就是第一段末尾和第二段开头都转写一遍,取重复部分中最合理的一段作为连接点,这样文本就不会出现断句或重复。
4.3 本地 LLM 评分速度优化与并发控制
评分服务的初始实现走的是“整段对话塞进 Prompt”的路子,一个 2000 字的回答要生成近 1500 个 token,模型推理耗时 20 秒以上。面试高峰期一旦并发达到 10 个人以上,GPU 就被占满,整个分析服务基本瘫痪。
优化分两层做。第一层是并行切块,一个面试的多个问题按问题边界切分,每个问题的回答单独送进模型,平均每个 Prompt 的字数只有原来的三分之一。第二层是控制并发,用 Kafka 消息实现待处理队列,评分 worker 每次只拉取一个任务,通过 GPU 上的
torch.cuda.Stream
并发执行,将单张 RTX 4090 的推理吞吐从每秒 3 个请求提到了每秒 7 个请求。
这里直接给出一个可供参考的经验:本地 LLM 服务最容易成为性能瓶颈,因为面试系统的流量是突发的,平时一天就几十场,可一到月末考核的高峰时段,一小时可能涌进十几场。一定要在设计之初就把评分模块做成异步队列消费模式,绝不能做成同步接口调用。
5. 绩效数据复盘:复现后与原版的真实对比
项目落地后,我做了一轮对比测试,把原版“见面考”、复现版、以及一个商业 SDK 方案放在同一批模拟面试数据上做效果对比。数据集一共 20 场模拟面试,每场约 30 分钟,包含不同的口音、语速和回答质量。
5.1 音视频延迟与稳定性数据
音视频方面,复现版在 5 方会议场景下,端到端延迟中位数是 380ms,丢包率 0.8%,远好于原版 P2P 方案的 860ms 和 2.7%。特别是上行带宽占用,5 方会议时原版 P2P 模式下每个考官上行要推 4 路流,带宽占用接近 12Mbps,复现版只用推 1 路流,带宽占用 3.2Mbps,节省非常明显。
商业 SDK 方案的延迟和稳定性跟复现版数据相近,但它在服务端录音和转写对接上相对封闭,我们需要的“分析完成后跳到录音对应时间点”这个功能,商业 SDK 做起来很麻烦,需要额外维护一套时间戳映射。
5.2 评分准确性与一致性对比
评分准确性上,我们请了三位资深 HR 和两位业务专家对 20 场模拟面试逐题打分,然后分别计算各方案的评分结果与专家打分的相关性。
| 方案 | 知识准确度维度 | 表达流畅度维度 | 综合相关性 |
|---|---|---|---|
| 原版(关键词匹配) | 0.31 | 0.42 | 0.35 |
| 复现版(Whisper + Qwen2.5) | 0.82 | 0.73 | 0.79 |
| 商业 SDK + 通用大模型 | 0.76 | 0.69 | 0.73 |
看得出来复现版的优势集中在知识准确度上,因为原版使用的关键词匹配在“意思对但用词不同”的场景下完全失效,而我们的语义评估器可以抓住同义表达。“表达流畅度”维度上三者差异没有那么大,因为流畅度更多依赖转写文本的停顿和语气词统计,这部分相对容易实现。
5.3 资源消耗与成本估算
性能上最大的成本来自 GPU。单台 4090 同时跑转写和评分线程的话,处理一场 30 分钟面试的完整分析和报告生成流程,大约需要 5 分 20 秒。如果要求一个小时内出 400 场面试的报告,就需要 34 台 GPU,这个成本很多企业直接接受不了。
所以我在部署参数里默认把“出报告时间”放宽到 24 小时,通过消息队列把任务分散在半夜跑。对绝大多数面试场景来说,面试结束当天晚上出报告完全来得及,没必要追求分钟级出分。这是整个系统里最值得做的一个成本优化决策。
6. 复现完成后沉淀下来的可迁移经验
说到底,“见面考复现”这个项目不仅是一个工程成果,它背后有三条经验是能直接迁移到其他系统的,我单独提出来讲一下。
第一,在线考核类系统要把“实时交互”和“结果分析”做完全的异步化。很多人一上来就想把音视频流、语音转写、评分展示做成一条实时流水线,结果是把系统复杂度推高了数倍,性能还差。正确做法是实时交互保证“能见面、不卡顿”,异步分析保证“能评准、不阻塞”,两者通过消息队列解耦。
第二,语义评分一定要引入“先摘录后打分”的中间步骤。直接让大模型生成分数,解释性很差,考官看到报告时会觉得这是黑盒。现在系统里每个分数背后都有候选人的原话摘录作为依据。退一步讲,哪怕以后切换更聪明的模型,只要 Prompt 结构保持这个中间步骤,分数依然有根据。
第三,复现项目最重要的不是功能,而是留下可以继续扩展的底座。我把常用的面试评估维度做成了可配置项,考核单创建时可以选择启用哪些维度。增加一个“抗压能力”维度不需要改代码,只要在维度配置表里加一行,再在评估阶段对应的逻辑里补充一个测评方式即可。原版把评分算法写死在代码里的方式,扩展一个维度需要改五六个类,很不合理。
这个项目后续我还会做成一个内部基础组件维护着,下一步准备加上“候选人动作行为分析”。也就是通过摄像头画面判断候选人是否频繁看稿、是否有小动作、跟镜头眼神交流的比例是多少。这些信息结合语音分析,能更完整地还原一场真实见面考核的状态。
如果有人在看这篇内容的时候正打算做类似的在线考核系统,我的建议是先别急着写代码,把业务里的“考核对象”到底是谁这一步想明白。究竟是面人,面系统,还是面材料?不同对象对应的技术栈完全不同。像“见面考”这种强调临场互动的场景,音视频质量和语义理解就是生命线,成本投入应该向这两块倾斜。其他像 UI 精美程度、数据大屏之类的,说实话都不是决定性的东西。
最后再补一个实操小细节:整个系统所有服务的日志,我统一用了 JSON 结构化输出,每条日志带上
traceId
。遇到线上问题的时候,从浏览器请求的 header 里拿 traceId,就能把从 WebRTC 网关到转写服务再到评分服务的调用链完整串起来。这个习惯在很多项目里被当成“麻烦事”忽略了,直到出了线上事故排查无门,才会明白它的价值。
更多推荐
所有评论(0)