实时 AI Copilot 端到端延迟预算分配:从 SLO 反推 5 段链路边界(含并行优化路径 + 实测数据)
实时 AI Copilot 端到端延迟预算分配:从 SLO 反推 5 段链路边界(含并行优化路径 + 实测数据)
TL;DR
实时 AI 对话系统的”卡顿感”几乎都源于一个工程错误:把端到端延迟当成单点指标做优化,而不是按链路分段做预算分配。下面是关键结论:
- 首字符延迟 SLO ≤ 1.2s 是真实”流畅感”分水岭(用户感知研究:>1.5s 即被判定为”反应慢半拍”)
- 5 段链路 各自必须有独立预算:VAD(80ms) + ASR 流式首包(280ms) + Prompt 装配(40ms) + LLM 首 Token(600ms) + TTS 首包(200ms)
- VAD 是最被忽视的瓶颈:默认 webrtcvad 第 30 帧策略就是 600ms 静音才出栈,把首字符直接拖到 1.8s+
- ASR 流式必须 partial-final 双轨:partial 走 word-level 增量推理,final 单独跑标点+话语结束判定
- LLM 首 Token 优化 70% 来自 prompt 长度,而不是模型本身:32K 上下文压缩到 4K 能从 720ms 降到 380ms
- TTS 流式合成必须 chunk 级播放:一次 sentence-level 合成等于损失 200-400ms
本文以一个生产级实时面试 Copilot 为样本,把 1800ms 端到端延迟拆解到 5 段,给出每段的优化路径、实测前后对比数据、以及踩过的坑。
一、为什么端到端延迟必须分段做预算
绝大多数 AI 对话项目的延迟优化路径长这样:
用户说”我的延迟到 2.5s 了,太慢” → 工程师跑 trace → 看到 LLM 调用占 1.4s → 换更快的模型 → 降到 1.1s → 好了上线。
但用户回过来说:”还是感觉卡。”
问题在于你优化的是平均值,不是用户感知。用户感知的”卡”分两种:
- 首字符延迟:从用户说完话到屏幕开始出第一个字的时间
- 节奏断点:句子中段突然停顿超过 200ms
这两个指标和”端到端延迟”几乎不相关。一个 P50 延迟 1.4s 但首字符 600ms 的系统,体感比 P50 延迟 900ms 但首字符 1.2s 的系统流畅得多。
所以实时系统的延迟优化必须从 SLO 反推:
目标:首字符 ≤ 1.2s(P95)
预算分配:
VAD 静音判定 80ms
ASR 流式首包 280ms
Prompt 装配 40ms
LLM 首 Token 600ms
TTS 首包合成 200ms
----------------------
合计 1200ms
每段超预算就触发该段的优化路径。这是工程上唯一可控的方法。
我们团队在做实时面试 Copilot 时,端到端延迟从 1.8s 优化到 920ms,全程没换 LLM 模型,没换 ASR 服务商,全部在这 5 段预算里折腾。下面逐段拆。
二、第 1 段:VAD(语音活动检测)— 最容易被忽视的 600ms
默认配置的坑:
webrtcvad 默认推荐 frame_duration=30ms,aggressiveness=2,连续 N 帧静音才判定为话语结束。常见 N=20,意味着 600ms 静音才出栈。这 600ms 直接进入端到端延迟。
优化路径:
| 策略 | frame_duration | N 帧 | 静音阈值 | 误判率 | 首字符贡献 |
|---|---|---|---|---|---|
| 默认 | 30ms | 20 | 600ms | <2% | +600ms |
| 短窗 | 30ms | 8 | 240ms | 4-6% | +240ms |
| 自适应 | 30ms | 4-12 动态 | 120-360ms | <3% | +120ms |
| 混合 VAD + 语义触发 | 30ms | 6 + 句末助词检测 | 90ms | <2% | +90ms |
我们最终落地的是第 4 种:webrtcvad 短窗 N=6(180ms)+ ASR partial 文本里检测句末助词(”吗/呢/吧/啊/嘛/呀/。/?/!”)。两个信号任一命中就触发下游处理。
这一段从 600ms 优化到 80ms,纯赚 520ms。
代码片段:
class HybridVAD:
def __init__(self, sample_rate=16000):
self.vad = webrtcvad.Vad(2)
self.frame_size = int(sample_rate * 0.03) # 30ms
self.silence_frames = 0
self.SILENCE_N = 6 # 180ms
self.END_PARTICLES = set("吗呢吧啊嘛呀。?!.?!")
def feed(self, pcm_chunk: bytes, partial_text: str) -> bool:
is_speech = self.vad.is_speech(pcm_chunk, sample_rate=16000)
if is_speech:
self.silence_frames = 0
return False
self.silence_frames += 1
# 路径 A:纯静音超过 N 帧
if self.silence_frames >= self.SILENCE_N:
return True
# 路径 B:partial 文本句末助词命中 + 静音 ≥3 帧(90ms)
if self.silence_frames >= 3 and partial_text and partial_text[-1] in self.END_PARTICLES:
return True
return False
踩坑:句末助词检测要看 partial_text 的最后一个非空字符,不能 strip。Whisper 流式 partial 偶尔会在末尾带空格或换行,去除后判断。
三、第 2 段:ASR 流式首包 — partial / final 双轨设计
关键认知:流式 ASR 有 2 种”首包”,含义完全不同:
- partial 首包:从用户开口到屏幕出现第一个识别字,目标 ≤ 280ms
- final 首包:从用户停止说话到完整识别结果出来,目标 ≤ 400ms(含标点)
很多团队只盯一个指标(通常是 final),结果 partial 慢得离谱,用户在屏幕上看不到自己正在说什么,会有”系统没在听”的错觉。
双轨策略:
audio_stream
├─ partial_track(word-level,无标点,2 层 transformer)
│ └─ 每 80ms 推理一次,覆盖最近 1.6s 滑动窗口
│
└─ final_track(utterance-level,含标点+口语化纠正,6 层)
└─ 在 VAD 触发后从 partial 取上下文,单次推理出 final
partial track 用轻量模型(参数量 1/3),只输出 char-level 序列,不做标点不做 normalize,专心追求延迟。final track 在 VAD 触发后启动,复用 partial 的 encoder 状态,直接 decode。
实测对比(客户端到屏幕显示首字符):
| 策略 | partial 首包 | final 首包 | 误识率 |
|---|---|---|---|
| 单轨流式(baseline) | 480ms | 720ms | 4.2% |
| 双轨 + encoder 共享 | 280ms | 380ms | 4.6% |
| 双轨 + 边缘 GPU 部署 | 180ms | 260ms | 4.6% |
踩坑:partial 不要直接 stream 给 LLM。partial 文本会反复修正(”我想做” → “我想做的” → “我想做的事情”),如果每次 partial 都触发 LLM 调用,会浪费 4-6 倍 token,且 LLM 的 prefix cache 命中率会从 92% 暴跌到 50% 以下。partial 只用于 UI 展示和 VAD 句末判定,LLM 永远只吃 final。
四、第 3 段:Prompt 装配 — 看似 0 成本,实际能爆 300ms
Prompt 装配听起来是 string concat,应该 1ms 完成。实际生产场景里,prompt 装配往往要做:
- 历史 context 召回(向量检索 ≈ 80ms)
- 候选人简历切片(按当前问题语义匹配 ≈ 60ms)
- 知识库片段(行业特定 + 公司特定 ≈ 120ms)
- 防止 prompt 超 token 上限的截断逻辑(≈ 20ms)
加起来 300ms 不夸张。这 300ms 全在 critical path 上,直接吃掉首字符预算。
优化路径:把”装配”从串行改为预热 + 增量。
| 阶段 | 串行装配 | 预热 + 增量 |
|---|---|---|
| 用户说话期间(partial 阶段) | 不做事 | 后台预拉简历 + 知识库(200ms) |
| VAD 触发后 | 全量装配 (300ms) | 只追加当前问题向量召回 (40ms) |
| LLM 调用 | 300ms 后才发起 | 40ms 后立即发起 |
效果:装配阶段从 300ms 压到 40ms,省 260ms。
代码骨架:
class PromptAssembler:
def __init__(self):
self.warm_context = None # 预热缓存
async def warm_up(self, candidate_id: str, partial_text: str):
"""用户说话期间后台预热:拉简历 + 拉知识库片段"""
if self.warm_context is None:
self.warm_context = await asyncio.gather(
self.fetch_resume_slices(candidate_id),
self.fetch_kb_snippets_by_partial(partial_text),
)
async def finalize(self, final_text: str) -> str:
"""VAD 触发后,只追加 final 问题召回"""
question_ctx = await self.retrieve_by_final(final_text, top_k=3)
resume, kb = self.warm_context
return self.template.format(
resume=resume,
kb=kb,
question_ctx=question_ctx,
final_text=final_text,
)
踩坑:partial 文本一直在变,warm_up 不能每次都重做(会撞到向量库 QPS 限制)。判定条件:partial 长度 ≥ 5 字 + 距离上次 warm 超 800ms 才触发。这个阈值用线下 trace 数据调参出来的。
五、第 4 段:LLM 首 Token — 70% 收益来自 prompt 压缩
很多人优化 LLM 延迟第一反应是换模型。换 GPT-4o → GPT-4o-mini,延迟从 1200ms 降到 700ms,看起来很爽,但回答质量掉一档,业务受不住。
真正的 70% 收益来自 prompt 压缩:
| 上下文 token 数 | 首 Token 延迟(GPT-4o) |
|---|---|
| 32K | 1240ms |
| 16K | 820ms |
| 8K | 540ms |
| 4K | 380ms |
| 2K | 280ms |
token 数和首 Token 延迟接近线性关系。压一半 token,首 Token 砍 40%。
压缩链路(5 个阶段):
- 历史对话切片化(不是堆 raw text):每 N 轮压成 1 个 summary,summary 用 50 token 顶 N 轮原文 500-1000 token
- 简历分块召回(不是全量塞):按当前问题向量检索,只取 top-3 块(约 600 token),舍弃其他
- 知识库 MMR 去重:top-5 召回后用 MMR 重排,去掉相似度 > 0.85 的冗余项
- 关键事实抽取(KFE):从历史对话里抽出”候选人主张的关键事实”,单独存为结构化字段,避免被切片化压缩掉
- Token 预算硬约束:装配后总 token > 4K 时强制截断,从滑动窗口冷区开始砍
实测:32K 原始上下文 → 4K 压缩后,首 Token 从 1240ms 降到 380ms,纯赚 860ms。
Streaming 配置:
async for chunk in client.chat.completions.create(
model="gpt-4o",
messages=messages,
stream=True,
temperature=0.3,
max_tokens=200, # 实时场景不要 800/1000
stream_options={"include_usage": True},
):
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
踩坑:max_tokens 不要照搬批处理场景的 800-2000。实时对话场景 200 足够(一句完整回复 80-150 字),过大会让 server 端 prefill 阶段做更长的 KV 分配,首 Token 延迟不可见地拉长。
六、第 5 段:TTS 流式合成 — chunk 级播放是分水岭
TTS 默认是 sentence-level 合成:把整句文字塞给 TTS API,等返回完整音频文件后播放。这种模式下:
- LLM 第一个字到达:t=600ms
- 等 LLM 出完整句(”你好我叫小明今天来面试。”,共 12 字):t=600 + 12 * 30 = 960ms
- TTS 合成整句音频:t=960 + 280 = 1240ms
- 用户听到第一个音:t=1240ms
chunk 级流式 TTS:
每攒够 4-6 字(一个语音单元)就立刻发给 TTS,让其返回该单元的音频片段,立刻播放。同时下一个 chunk 异步合成。
| 模式 | 首音延迟 | 句间停顿 |
|---|---|---|
| sentence-level(baseline) | 1240ms | 0ms |
| chunk-level(4-6 字) | 980ms | 80-120ms |
| chunk-level + 缓冲池 | 920ms | < 30ms |
关键工程点:
- chunk 切分要避免切到词中:用 jieba 做粗粒度分词,最小 chunk = 1 个完整词
- 音频拼接的 zero-crossing 对齐:相邻 chunk 边界用零交叉点拼接,避免咔哒声
- 缓冲池预合成下一句:当前 chunk 在播时,下一个 chunk 已经在 TTS 里跑了
代码骨架:
class StreamingTTS:
def __init__(self, min_chunk_chars=4):
self.buffer = ""
self.min_chunk = min_chunk_chars
async def feed(self, llm_text_delta: str):
self.buffer += llm_text_delta
chunk = self.try_extract_chunk()
if chunk:
audio = await self.synth(chunk)
await self.play(audio)
def try_extract_chunk(self) -> str | None:
if len(self.buffer) < self.min_chunk:
return None
# 用 jieba 找最近的词边界
words = list(jieba.cut(self.buffer))
if len(words) < 2:
return None
chunk = "".join(words[:-1]) # 留最后一个词在 buffer 里
self.buffer = words[-1]
return chunk
踩坑:chunk 太小(< 3 字)会让 TTS 韵律崩坏,每个 chunk 都是断断续续的”机器音”。4-6 字是测试出来的甜点。
七、5 段优化后的实测数据
把上述 5 段优化全部上线后的对比(同一台 GPU 服务器,gpt-4o,Whisper-large-v3 流式):
| 阶段 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| VAD 静音判定 | 600ms | 80ms | -520ms |
| ASR 流式首包 | 480ms | 280ms | -200ms |
| Prompt 装配 | 320ms | 40ms | -280ms |
| LLM 首 Token | 1240ms | 380ms | -860ms |
| TTS 首包 | 280ms | 200ms | -80ms |
| 首字符 P95 | 2920ms | 980ms | -1940ms |
我们做的实时面试 Copilot(即答侠)就是按这套预算分配做的,目前在咖啡厅 Wi-Fi 环境下首字符 P95 稳定在 980ms 左右。这套思路适用于任何实时 AI 对话场景:客服 bot、车机助手、直播翻译、远程辅助等。
八、监控埋点必须按段拆
延迟分段优化后,监控指标也必须按段拆。一个常见错误是只监控端到端延迟,导致某段悄悄退化也没人发现。
埋点设计:
@dataclass
class LatencyTrace:
request_id: str
vad_ms: float # 用户停说话 → VAD 触发
asr_partial_ms: float # 用户开口 → 屏幕首字
asr_final_ms: float # VAD 触发 → final 完成
prompt_assemble_ms: float
llm_first_token_ms: float
tts_first_chunk_ms: float
e2e_first_char_ms: float
告警阈值(基于 P95):
| 段 | 黄线 | 红线 |
|---|---|---|
| vad_ms | 150 | 250 |
| asr_partial_ms | 350 | 500 |
| prompt_assemble_ms | 80 | 150 |
| llm_first_token_ms | 500 | 800 |
| tts_first_chunk_ms | 280 | 400 |
任意一段红线触发就立刻告警 + 降级。不要等端到端 P95 红线,那时候用户已经流失。
九、5 个反直觉的工程细节
- partial ASR 和 final ASR 的 encoder 必须共享状态,否则 final 会重新算一遍特征,多花 60-80ms
- prompt 装配的 warm_up 必须用 partial 文本,等 final 出来再开始就晚了 200-300ms
- LLM 的 stream chunk 大小由 server 控制,client 没法调;但可以选 server 端开了
aggregated_chunk=False的服务商 - TTS chunk 切分用 jieba 粗粒度而不是字符,避免切到”今天/有点/累”中间形成”今天有/点累”
- VAD 触发后立刻给 UI 一个”思考中”信号,掩盖最后 100-200ms 不可避免的延迟
十、常见问题 FAQ
Q1:5 段预算分配是固定的吗?业务场景不一样能调整吗?
A:可以调整,原则是”给最贵的那段分最大预算”。比如车机场景 ASR 噪音大需要更长 windowing,可以把 ASR partial 给到 400ms,从 LLM 首 Token 那段抠。但总预算 1200ms 是用户感知硬线,不能突破。
Q2:用了 stream 还需要做 prompt 压缩吗?stream 不是已经流式输出了?
A:必须做。stream 解决的是”输出完成时间”,不是”首 Token 时间”。首 Token 是 prefill 阶段输出,prefill 时间和 prompt 长度强相关。32K 上下文 prefill 1.2s 是 stream 也救不回来的。
Q3:webrtcvad 能换更准的 silero-vad 吗?延迟会不会更长?
A:silero-vad 推理本身比 webrtcvad 慢 30-50ms(GPU 上更明显),但召回率高 8%。线上 trade-off 是:高吞吐场景用 webrtcvad(CPU 30ms 内搞定),低噪音 + 高准确率场景用 silero-vad。混合策略也可以——用 webrtcvad 第一道筛,silero-vad 第二道精判。
Q4:partial / final 双轨会不会让推理成本翻倍?
A:partial 用 1/3 参数量的轻量模型,单条请求 GPU 占用约 partial 是 final 的 35%。两轨并行总成本约为单轨的 1.35 倍,不是 2 倍。但延迟收益是 200ms+,远超成本。
Q5:TTS chunk-level 合成会不会让韵律变差?用户能听出来?
A:4-6 字 chunk 在中文场景人耳几乎听不出差异(因为中文本身是单音节驱动)。英文场景就要谨慎,英文需要 phrase-level(8-12 词)才不会听起来断。中文 TTS 团队有更激进的方案是 2-3 字 chunk + 韵律预测模型补全,能压到 600ms 首音但工程复杂度大。
Q6:Prompt 压缩到 4K 会不会丢信息?
A:用 KFE(关键事实抽取)+ MMR 去重的话,4K 通常能保留 92-95% 的关键信息。我们线下评测过:4K 压缩后回答质量分(人工评测)从 baseline 4.6 降到 4.4,但用户满意度从 78% 升到 89%(因为不卡了)。延迟收益完全压过质量损失。
Q7:LLM 首 Token 优化做完了,整体平均延迟还是高怎么办?
A:看 P50 还是 P95。如果 P95 高 P50 正常,就是长尾问题,常见原因是 KV cache 满 + cold start。解决方案:常驻热点 prompt 的 KV、预分配 batch slot、把超 P95 的请求路由到独立 pool。
总结
实时 AI 对话延迟优化的核心思路:
- 从 SLO 反推架构:先定首字符 P95 ≤ 1.2s,再分段做预算
- 5 段独立优化:VAD / ASR / Prompt / LLM / TTS 都有各自的优化路径
- 监控按段拆:端到端 P95 是结果指标,分段 P95 才是诊断指标
- 反直觉细节多:partial/final 双轨、chunk TTS、prompt warm_up,每个细节都值 100ms+
把这套预算+监控做完后,你会发现”换模型/换服务商”反而是最后才考虑的优化路径——前面的工程优化能榨出 1.5-2 秒,远比换硬件来得划算。