同传译文“慢半拍”通常不是单一原因,先把它拆成两个可观测环节:音频采集侧的缓冲策略,以及推理侧的排队与执行。可以先做一次粗分:如果延迟长期稳定在某个固定时长附近,并且这个时长随音频缓冲窗口一起变化,优先怀疑缓冲攒得太多;如果延迟忽长忽短,两路同传同时开启时明显变差,优先怀疑推理请求在排队。下面五个小节给出记录点、参数位置和判断规则,具体数值需要结合你的采样率、模型和机器环境确认。
不要凭感觉换模型。先在音频回调里记录帧到达时间与缓冲长度,再在推理调用前后记录提交、开始执行和首字时间,用同一条时间线核对总延迟落在哪一段。单路稳定、并发变长,先处理推理排队;延迟随缓冲窗口近似变化,先缩短缓冲窗口。两处都会引入额外请求和切句风险,改动后要同时看译文完整性和请求量。
在音频采集回调里打印每帧到达时间与缓冲长度
目的是确认音频是不是攒够固定时长才送入识别或模型。找到采集侧的回调或读取循环,用单调时钟打时间戳,并维护一个待发送缓冲的计数。下面是与具体 SDK 无关的伪代码,回调名和缓冲变量按你的实现替换:
# 音频采集回调(伪代码)
def on_audio_frame(frame, sample_rate):
now = monotonic_ms() # 单调时钟,避免系统时间跳变
buf.append(frame)
buf_ms = sum(len(f) for f in buf) * 1000.0 / sample_rate
log.info("audio_frame ts=%d frames=%d buf_ms=%.0f flush_ms=%d",
now, len(buf), buf_ms, FLUSH_MS)
if buf_ms >= FLUSH_MS: # 攒满才送
log.info("audio_flush ts=%d buf_ms=%.0f", monotonic_ms(), buf_ms)
send_to_asr(buf)
buf.clear()
验证方式:连续说一段话,观察日志里 buf_ms 的峰值是否稳定贴近 FLUSH_MS,以及 audio_flush 与 audio_frame 的时间差。如果每句话都要等缓冲攒满才出现第一次 flush,缓冲就是延迟的一部分。
在推理调用前后记录排队等待和执行耗时
目的是区分“在等模型”和“模型算得慢”。客户端至少记录提交时间、拿到首字时间、结束时间;如果服务端能打印开始执行时间,排队等待就能直接算出来。日志字段建议固定下来,便于后续按 req_id 串联:
t_submit = monotonic_ms()
resp = client.infer(payload) # 流式则逐块读取
t_first = monotonic_ms() # 首个 token 或首个结果块
t_done = monotonic_ms()
log.info("infer req_id=%s t_submit=%d t_first=%d t_done=%d "
"wait_ms=%d exec_ms=%d queue_hint=%s",
req_id, t_submit, t_first, t_done,
t_first - t_submit, t_done - t_first, queue_hint)
如果拿不到服务端开始执行时间,wait_ms 实际是“网络加排队加首字计算”的混合值,只能看分布,不能直接归因。此时可以对照服务端日志里的接收时间和开始处理时间,或者在网关侧记录请求进入和转发时间。字段含义要在团队内写清楚,避免把 wait_ms 当成纯排队时间使用。
缩短音频缓冲窗口并观察首字延迟
目的是验证缓冲策略对“慢半拍”的贡献。缓冲窗口一般在三个位置:采集侧的 FLUSH_MS 或固定帧数阈值、VAD 的静音判定时长、识别或模型的流式 chunk 配置。先只改采集侧阈值,其他参数保持不动,做一组对照,例如 40ms、80ms、160ms、320ms,每个值跑同一段语料,记录“首字上屏时间减去该句最后一帧到达时间”。
看结果时不要只看首字延迟变小,还要看切句是否变碎、请求条数是否上升。窗口过小会让模型收到更多短片段,推理请求数增加,可能把延迟从缓冲转移到排队。改动参数的位置和生效方式(热更新还是重启)也要记录下来,否则对照结果不可复现。
模拟两路并发请求观察推理排队长度
目的是判断多路同传是否把推理队列压长了。用脚本同时发起两路或更多路请求,复用同一份输入,记录每路的 wait_ms、exec_ms 以及服务端的待处理请求数。下面是最小骨架,客户端换成你的推理接口即可:
import threading, time
def one(i, results):
t0 = time.monotonic()
resp = client.infer(payload) # 替换为你的调用
t1 = time.monotonic()
results.append((i, t0, t1))
results = []
threads = [threading.Thread(target=one, args=(i, results)) for i in range(2)]
[t.start() for t in threads]
[t.join() for t in threads]
for r in results:
print("req=%d start=%.3f end=%.3f" % (r[0], r[1], r[2]))
排队长度可观察的指标包括:服务端 pending 或 running 请求数、线程池活跃数与队列长度、批处理 batch 大小、客户端 wait_ms 的分位数。判断方法很直接:单路时 wait_ms 很小,两路并发时 wait_ms 明显抬头而 exec_ms 变化不大,说明是排队;两者一起变长,则可能是算力或批处理策略问题。
按时间线判断主要延迟在缓冲还是推理
把前面几个记录点放到同一条时间线上,按下面的模板填一遍,单路和两路各做一次。表里只填自己观察到的耗时,不要估算。
| 环节 | 记录点 | 单路耗时 | 两路并发耗时 | 说明 |
|---|---|---|---|---|
| 帧到达→缓冲攒满 | audio_frame 到 audio_flush | 是否接近 FLUSH_MS | ||
| 提交→开始执行 | t_submit 到服务端开始时间 | 排队等待 | ||
| 开始→首字 | 开始时间到 t_first | 模型首字计算 | ||
| 首字→整句输出 | t_first 到 t_done | 生成速度 |
判断规则可以这样用:第一行耗时与缓冲窗口近似同比例变化,先改缓冲;第二行在两路并发时显著变长,先限制并发或补充推理资源;第三、四行占大头,则问题在模型或输出策略,缩短缓冲不会解决。两项都占时间时,把各段相加与端到端延迟核对一遍,先动占比最大且可验证的那一段,改完再用同一张表复测。