先把延迟拆成采集、传输、识别三段——Fun-ASR-Realtime 实时字幕的耗时账

文章导读
只知道“字幕慢”没法调参:整体延迟是采集、传输、识别三段相加的结果,不拆开的话,改完一个参数也只能凭感觉说“好像快了点”。可行的做法是先把三段的起止点写死,各自打时间戳,用同一段音频复测,得到一张能对照的耗时表,再决定动哪个参数。
📋 目录
  1. A 定义三段计时的起点和终点并写进代码注释
  2. B 采集段:记录回调间隔与缓冲区大小
  3. C 传输段:记录发送到首次返回的耗时
  4. D 识别段:记录首字返回与整句返回的间隔
  5. E 固定同一段音频,改一个参数复测一次并记录
A A

只知道“字幕慢”没法调参:整体延迟是采集、传输、识别三段相加的结果,不拆开的话,改完一个参数也只能凭感觉说“好像快了点”。可行的做法是先把三段的起止点写死,各自打时间戳,用同一段音频复测,得到一张能对照的耗时表,再决定动哪个参数。

把实时字幕延迟拆成采集、传输、识别三段分别计时,前提是三段起止点不重叠、边界归属写进代码注释。先用同一段音频跑出基线耗时表,之后每次只改一个参数复测,并记录三段各自的耗时变化。适用场景是本地可复现的字幕链路调优;如果网络与服务端不可控,传输段和识别段的波动会掩盖参数效果,此时应先固定环境,再看单段的差异方向。

定义三段计时的起点和终点并写进代码注释

不打点就没人知道慢在哪一段。三段的定义建议直接写在代码里,而不是只留在脑子里:

  • 采集段:从音频回调产生第一帧数据(或一次发送周期的开始)到本次 chunk 在本地缓冲区凑齐、被标记为可发送为止。
  • 传输段:从发送调用真正被触发,到收到服务端第一条响应为止。
  • 识别段:从第一条响应到达,到首个字幕文本、再到整句收敛文本产生为止。

最容易混淆的边界是“采集完成到实际发送之间”的那段等待:chunk 已经凑齐,但还没进入发送函数(可能在队列里等,也可能被别的发送阻塞)。建议把这段归到传输段的前置等待,理由是它已经不是音频采集本身,而是链路调度;如果你更关心本地缓冲策略,也可以单独记为“排队段”,但必须三选一并固定下来,不能两段都算。

# 时间戳命名约定,写在音频模块顶部
# t_capture_start 本次 chunk 第一个采样的产生时刻(采集段起点)
# t_capture_end   chunk 在本地凑齐、标记为可发送的时刻(采集段终点)
# t_send_start    发送函数被调用的时刻(传输段起点,含队列等待)
# t_first_resp    收到服务端第一条响应的时刻(传输段终点,识别段起点)
# t_first_text    第一条带文本的响应到达时刻(首字)
# t_final_text    整句收敛(final 标记)到达时刻

注释写清楚之后,日志里所有耗时都能对应到不重叠的区间,后面改参数才有对照意义。凡是无法判断归属的时间,宁可在注释里写明“暂计入某段”,也不要让它同时出现在两段里。

采集段:记录回调间隔与缓冲区大小

采集段要回答的问题只有一个:音频是不是在本地就被攒住了。如果回调触发频率明显低于预期,或者间隔抖动很大,那延迟在数据还没离开机器时就已经产生,后面两段怎么调都补不回来。

先把延迟拆成采集、传输、识别三段——Fun-ASR-Realtime 实时字幕的耗时账

打点方式是在音频回调里记录时间戳,算相邻回调的间隔;同时把缓冲区相关参数从运行时对象或配置里读出来打印,不要凭记忆填。

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 发送”的时间算进来:发送间隔本身不属于传输耗时,只有在统计整句端到端延迟时才把它加回去。

识别段:记录首字返回与整句返回的间隔

识别段要区分两件事:是首字慢,还是收敛慢。首字慢通常指向服务端排队、模型加载或前置处理;收敛慢通常意味着服务端在等更多上下文,或者等静音判停才给最终结果。

先把延迟拆成采集、传输、识别三段——Fun-ASR-Realtime 实时字幕的耗时账

取点方式: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回退该参数是否复现

“现象备注”建议写成可复现的描述,例如某段耗时成倍跳、只在首句出现、随并发路数增长等,而不是只写“快了”或“慢了”。多轮复测时注意:每轮至少重复跑几次,看分布而不是单次值,因为传输抖动会让单次数值失真;记录原始日志时间戳,不要只填手算结果,方便回查;如果某个现象在下一轮无法重复出现,先当作噪声,不要据此改配置。改完参数后如果只有总延迟变化、三段各自没变,通常说明改动影响的是别处,需要回头检查埋点是否覆盖了真正的路径。