只知道“字幕慢”没法调参:整体延迟是采集、传输、识别三段相加的结果,不拆开的话,改完一个参数也只能凭感觉说“好像快了点”。可行的做法是先把三段的起止点写死,各自打时间戳,用同一段音频复测,得到一张能对照的耗时表,再决定动哪个参数。
把实时字幕延迟拆成采集、传输、识别三段分别计时,前提是三段起止点不重叠、边界归属写进代码注释。先用同一段音频跑出基线耗时表,之后每次只改一个参数复测,并记录三段各自的耗时变化。适用场景是本地可复现的字幕链路调优;如果网络与服务端不可控,传输段和识别段的波动会掩盖参数效果,此时应先固定环境,再看单段的差异方向。
定义三段计时的起点和终点并写进代码注释
不打点就没人知道慢在哪一段。三段的定义建议直接写在代码里,而不是只留在脑子里:
- 采集段:从音频回调产生第一帧数据(或一次发送周期的开始)到本次 chunk 在本地缓冲区凑齐、被标记为可发送为止。
- 传输段:从发送调用真正被触发,到收到服务端第一条响应为止。
- 识别段:从第一条响应到达,到首个字幕文本、再到整句收敛文本产生为止。
最容易混淆的边界是“采集完成到实际发送之间”的那段等待:chunk 已经凑齐,但还没进入发送函数(可能在队列里等,也可能被别的发送阻塞)。建议把这段归到传输段的前置等待,理由是它已经不是音频采集本身,而是链路调度;如果你更关心本地缓冲策略,也可以单独记为“排队段”,但必须三选一并固定下来,不能两段都算。
# 时间戳命名约定,写在音频模块顶部
# t_capture_start 本次 chunk 第一个采样的产生时刻(采集段起点)
# t_capture_end chunk 在本地凑齐、标记为可发送的时刻(采集段终点)
# t_send_start 发送函数被调用的时刻(传输段起点,含队列等待)
# t_first_resp 收到服务端第一条响应的时刻(传输段终点,识别段起点)
# t_first_text 第一条带文本的响应到达时刻(首字)
# t_final_text 整句收敛(final 标记)到达时刻
注释写清楚之后,日志里所有耗时都能对应到不重叠的区间,后面改参数才有对照意义。凡是无法判断归属的时间,宁可在注释里写明“暂计入某段”,也不要让它同时出现在两段里。
采集段:记录回调间隔与缓冲区大小
采集段要回答的问题只有一个:音频是不是在本地就被攒住了。如果回调触发频率明显低于预期,或者间隔抖动很大,那延迟在数据还没离开机器时就已经产生,后面两段怎么调都补不回来。
打点方式是在音频回调里记录时间戳,算相邻回调的间隔;同时把缓冲区相关参数从运行时对象或配置里读出来打印,不要凭记忆填。
last = None
def on_audio(indata, frames, time_info, status):
now = time.perf_counter()
if last is not None:
log.debug('cb_gap_ms=%.1f frames=%d', (now - last) * 1000.0, frames)
last = now
# 缓冲区参数以运行时读到的为准
log.debug('blocksize=%s samplerate=%s channels=%s',
getattr(stream, 'blocksize', '?'), samplerate, channels)
间隔明显大于预期时的现象通常是:每秒回调次数少于采样率除以每帧样本数的理论值;间隔不是稳定的小值,而是成倍跳;或者回调成对出现、中间夹一段长空档。这几种现象都指向本地缓冲:驱动块偏大、应用层又攒了一次、系统重采样引入延迟,或者为了等静音判定而故意攒句。这类情况先调采集侧参数,再讨论后面两段。
传输段:记录发送到首次返回的耗时
传输段要把网络与链路单独量出来,否则识别服务变慢和网络抖动会搅在一起。做法是在发送调用前后各打一个点,并把连接方式、是否长连接、是否加密、服务端地域这些变量记到同一行日志里。
t_send = time.perf_counter()
send_chunk(chunk) # WebSocket send / HTTP 分块写入 / 本地管道写入
first_resp = None
while True:
msg = recv_message()
if msg is None:
break
if first_resp is None:
first_resp = time.perf_counter()
log.info('send_to_first_resp_ms=%.1f transport=%s tls=%s region=%s',
(first_resp - t_send) * 1000.0, transport, tls, region)
handle(msg)
需要同时记录的变量至少包括:连接是复用还是每轮重建、是否走 TLS、是否经过本机回环或容器网络、服务端地域、同时跑了几路音频。这些变量不记录,下一轮复测就说不清差异来自参数还是来自环境。传输段另一个常见坑是把“客户端等待下一个 chunk 发送”的时间算进来:发送间隔本身不属于传输耗时,只有在统计整句端到端延迟时才把它加回去。
识别段:记录首字返回与整句返回的间隔
识别段要区分两件事:是首字慢,还是收敛慢。首字慢通常指向服务端排队、模型加载或前置处理;收敛慢通常意味着服务端在等更多上下文,或者等静音判停才给最终结果。
取点方式:t_first_text 取第一条带文本内容的响应到达时刻,t_final_text 取带 final 标记的那条响应到达时刻。两个差值含义不同:t_first_text - t_first_resp 是首字延迟,t_final_text - t_first_text 是收敛时间。
if msg.has_text and first_text is None:
first_text = time.perf_counter()
log.info('first_text_ms=%.1f', (first_text - t_first_resp) * 1000.0)
if msg.is_final:
final_text = time.perf_counter()
log.info('converge_ms=%.1f', (final_text - (first_text or final_text)) * 1000.0)
这里要避免把等待时间算进识别段。典型情况是客户端要等一段静音才触发尾部发送,或者两段音频之间本来就没有数据,这段空档不是识别耗时,而是采集侧策略造成的等待。判断方法是看这段时间内有没有数据发往服务端:没有,就归采集或排队,不归识别。若服务端返回的 final 依赖静音检测,建议在日志里把静音等待单独记一列,避免和模型收敛时间混在一起。
固定同一段音频,改一个参数复测一次并记录
复测的目的不是证明某次调参“变快了”,而是让每次调整都能被证伪。要求是同一段音频、同一采样率与编码、同一设备与网络、同一服务端配置、同一并发路数,一次只改一个参数。
| 轮次 | 改动参数 | 采集段 ms | 传输段 ms | 首字 ms | 收敛 ms | 总延迟 ms | 现象备注 |
|---|---|---|---|---|---|---|---|
| 基线 | 不改 | 环境与并发路数 | |||||
| 1 | 只改一个 | 与基线差异 | |||||
| 2 | 回退该参数 | 是否复现 |
“现象备注”建议写成可复现的描述,例如某段耗时成倍跳、只在首句出现、随并发路数增长等,而不是只写“快了”或“慢了”。多轮复测时注意:每轮至少重复跑几次,看分布而不是单次值,因为传输抖动会让单次数值失真;记录原始日志时间戳,不要只填手算结果,方便回查;如果某个现象在下一轮无法重复出现,先当作噪声,不要据此改配置。改完参数后如果只有总延迟变化、三段各自没变,通常说明改动影响的是别处,需要回头检查埋点是否覆盖了真正的路径。