700ms 内推送面试回答?AI 面试 copilot 延迟优化全链路拆解
下面把每一层怎么砍的拆开讲。 完整的"面试官说话 → 候选人看到答案"流程: 初版(2025 Q4)每一步都是"等一步做下一步"的串行模型,总延迟 2.3 秒: 最开始我们走 Chrome 扩展 + 最终方案是 OS 级别的 Loopback 捕获: 采集端固定 16kHz 单声道 16-bit PCM,对应 Deepgram 最优输入格式。缓冲帧长 160ms(太短增加 CPU 调度开销,太长直接吃延迟)。 我们做即答侠(HireMe AI,interviewasssistant.com)走的就是这条路——一个支持 Zoom/腾讯会议/飞书/钉钉全平台的 AI 面试 copilot,OS 层采集是把 RT 压到 700ms 的前提。这一步从 600ms 砍到 200ms 的关键: 新手最容易犯的错:等 STT 返回完整句子,再把句子喂给 LLM。这是 100% 串行的,总延迟 = STT 耗时 + LLM 耗时。 正确做法是"边转边喂": 反直觉的 trick:LLM prefill 可以"抢跑"。 只要 STT 输出了前 10 个字,我们就把它连同简历上下文一起发给 LLM 的 prefill 阶段(此时不生成 token)。等完整问题到位,再发 "generate" 指令,LLM 的 KV cache 已经预热。 这个优化在 Qwen 3.5 Flash 上实测,首字延迟从 500ms 降到 260ms。 传统 RAG 流程: 问题: 我们的做法是预建 + 缓存 + 本地小模型三件套: 预建:用户开始 Copilot session 时,简历和 JD 全部切片、算 embedding、存本地(SQLite + sqlite-vec 扩展)。session 期间这部分完全不变。 缓存:问题 embedding 走 LRU 缓存(key = 问题文本归一化 hash)。面试场景里重复问题率很高("讲讲你的项目经历""为什么离职"这种),命中率实测 ~35%。 本地小模型:query embedding 走 bge-small-zh-v1.5 本地版(ONNX runtime,~80MB),单次推理 40ms。云端模型只在 rerank 阶段用。 最后这一路优化,RAG 整体从 400ms 砍到 40ms。 体验层设计决策直接影响工程选型。 用户盯着浮窗的体验是:看到第一个字 → 知道"AI 开始回答了",然后再慢慢读全文。所以两个预算: 我们的做法: 模型路由策略: 坑:不要把模型选择放在主链路里,主链路只做 STT → 问题 → LLM,路由决策走异步预测(session 开始时就根据 JD 类型预分配)。 平均 700ms 不够。面试是一次性场景——P99 延迟如果是 3 秒,那 100 个问题里有 1 个会"卡住",而那 1 个很可能是关键问题。 P99 优化: 线上数据: 严格说不属于性能话题,但体验上是一体的——浮窗如果出现在屏幕共享里,再快也白费。 效果:屏幕共享看不到、屏幕录制录不到、Alt+Tab 不出现、任务栏不出现、Mission Control 不显示。实现成本约 50 行原生代码。 Q1:浏览器扩展能做到 700ms 延迟吗? Q2:自己跑本地 Whisper 模型可以吗? Q3:面试 copilot 会被屏幕共享看见吗? Q4:RAG 索引每次面试都要重建吗? Q5:LLM 预算超了 fallback 怎么做? 700ms 面试 copilot 不是靠单一神技,而是把每一层都压到极致: 如果你也在做实时音频+LLM 的产品,希望这些经验能省你一些时间。Quick Answer:把面试 copilot 延迟压到 700ms 的 5 条核心动作
一、整体链路:把 2.3s 砍到 0.7s 的总账
[面试官麦克风]
│
▼
[会议软件(Zoom/腾讯会议/飞书)音频输出]
│
▼ ① 系统音频采集(Core Audio Tap / WASAPI Loopback)
│
▼
[本地音频缓冲 160ms]
│
▼ ② 流式 STT(Deepgram Nova-2,WebSocket)
│
▼
[文本流:token-by-token 返回]
│
▼ ③ 问题边界检测(VAD + 标点 + 语义)
│
▼
[问题文本]
│
▼ ④ RAG 检索(简历 + JD 预建索引)
│
▼ ⑤ LLM 流式推理(Qwen 3.5 Flash / GPT-4.1-mini)
│
▼
[首字输出到浮窗 ~500ms]环节 初版延迟 优化后 音频采集 → STT 首字 600ms 200ms STT 完整句子 500ms 150ms(流式并行) 问题识别 200ms 50ms RAG 检索 400ms 40ms(预建+缓存) LLM 首字 600ms 260ms 总计(首字) 2300ms 700ms 二、第一个坑:音频采集必须绕开浏览器(同时这是即答侠选型的起点)
getDisplayMedia({audio:true})。问题一堆:Core Audio Tap API(AudioTee),可以直接 tap 任意进程音频输出WASAPI Loopback + IAudioClient::Initialize(AUDCLNT_STREAMFLAGS_LOOPBACK)三、流式 STT 和 LLM 必须并行而不是串行
STT WebSocket 持续吐 token:
"你" → "你能" → "你能讲" → "你能讲一下" → "你能讲一下你的项目经历吗"
同时维护一个"句子候选缓冲区",边缓冲边判断:
- 是否出现句末标点?(。?!)
- VAD 是否检测到 >400ms 静默?
- 语义上是否是一个完整问题?(用小模型做二分类)
一旦判定"问题成型"(哪怕面试官还在说"吗"这个字),立刻触发 LLM用这种"prefill 预热 + 流式判断"的方式做到了一个小目标:面试官说完问题那一刹那,答案已经在屏幕上出现第一个字了。
四、RAG 检索:不要每次都算 embedding
query → 算 embedding → 查向量库 → rerank → 拼 contextquery → embedding 这一步,就算用 text-embedding-3-small,一次网络 RTT 加推理 300-400ms,稳稳吃掉预算。五、LLM 层:"首字预算" 和 "全量预算" 要分开做
场景 模型 为什么 行为面试(STAR) Qwen 3.5 Flash 中文强 + 便宜 + 快 技术概念问答 GPT-4.1-mini 技术准确率更高 代码题 DeepSeek V3 代码能力 + 国内 RT 低 截屏解题(视觉) Claude Sonnet 视觉 + 长 context 六、一个容易忽视的指标:端到端 Tail Latency
指标 P50 P95 P99 首字延迟 480ms 780ms 1100ms 完整答案延迟 680ms 1200ms 1800ms 七、Stealth 层:不是透明,是 OS 级窗口
NSWindow.SharingType.none(不是 canJoinAllSpaces 那种老 API)SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)(Win 10 2004+)八、工程上的几条踩坑记录
常见问题
A:不能。Chrome 扩展走 getDisplayMedia 拿不到 Zoom/腾讯会议桌面版的音频,且 jitter buffer 自己就 300-500ms。要做到 700ms 必须走 OS Loopback。
A:Apple Silicon 上 Whisper Large 单卡能跑,但流式效果差(设计是离线场景)。Deepgram Nova-2 / 阿里云 Paraformer 这类专门做实时 ASR 的服务 RTT 一致性更好。
A:取决于实现。普通 web 浮窗会被看见;用 macOS NSWindow.SharingType.none / Windows SetWindowDisplayAffinity 之后屏幕共享和屏幕录制都看不到,Alt+Tab 也不显示。
A:简历的索引可以全局缓存(用户上传后一次切片),JD 索引每个职位重建一次(一般 1-2 秒)。session 期间不再动。
A:双发策略——主请求走云端 GPT-4.1-mini,备份请求走本地 Qwen 3.5 Flash INT4。云端 2 秒没响应就用本地结果,对用户透明。总结
层 优化手段 收益 音频采集 OS 级 Loopback,绕开浏览器 600→200ms STT 流式 + VAD + 语义判断 并行化 问题识别 小模型 + 标点混合 200→50ms RAG 预建索引 + embedding 缓存 + 本地小模型 400→40ms LLM prefill 预热 + 流式输出 + 模型路由 600→260ms Tail 双发 + 本地兜底 P99 控制在 1.8s