从说完话到首个声音 — 测量串行与重叠并对照预算
目标
把从听到说连成一条线,测量用户感受到的延迟。用同样的查询比较串行与重叠,并通过分布和预算找出该缩短的地方。
为什么重要
即使各个部件都很快,连起来也是以秒为单位的。要知道该缩短哪里,就必须通过分布来看“怎样连接的”以及“哪个阶段超出了预算”。评分器不看因机器而异的绝对时间,而是看记录内部的顺序(最终结果 ≤ 检索 ≤ 第一个 token ≤ LLM 结束)、在同一台机器上测得的两种方式的比较(重叠 < 串行),以及从原始数据重新计算的百分位数和预算超出情况。端点延迟(音频时间)则由评分器重新运行 VAD 来核对。
步骤
- 把 12 条查询(
/opt/lab/fixtures/voice/queries/q01~q12.wav)的端点(VAD 看到的最后话尾 + gap 0.5 秒)与实际话尾(refs.tsv第四列)之差,写入/root/voice/pipeline/endpoint.json。 - 创建把最终结果 → 检索 → LLM(全部)→ TTS(全部)连接起来的
/root/voice/pipeline/pipe.py,把串行运行 q01、q03、q05、q07、q09 的记录写入/root/voice/pipeline/serial.jsonl。 - 把同样的五条查询,按 LLM 一有片段就交给 TTS 的重叠方式运行,写入
/root/voice/pipeline/overlap.jsonl。 - 把一次重叠运行用 Chrome 跟踪格式画入
/root/voice/pipeline/trace.json。 - 把有答案的 10 条查询(q01 到 q10)按重叠方式运行,把用户体感延迟(端点延迟 + 判定后的第一个声音)和 p50、p95 写入
/root/voice/pipeline/e2e.json。 - 把各阶段的预算、测得的中位数以及超出预算的阶段,写入
/root/voice/pipeline/budget.json。 - 把 Python 进程的最大 RSS、LLM 服务器的 RSS 和 Pod 内存限制,写入
/root/voice/pipeline/memory.json。 - 创建汇总了测量结果的
/root/voice/pipeline/report.json。
参考
- 时刻是以端点为 0 的挂钟 ms。把声音全部输入流式 ASR 的过程(用户说话期间)不测量,从尾部静音和
input_finished()开始测量。 - 记录项:
id、asr_ms、retrieve_ms、llm_first_ms、llm_done_ms、first_audio_ms、tts_done_ms、text——全部是从 0 起测得的累计时刻。 - 需要的部件:
voicekit.models(streaming_asr、tts、Embedder)、voicekit.llm.chat_stream、模块 5 的段落检索、模块 7 的片段切分。要先执行voice-llm up。 - 常见错误:把音频时间和挂钟混在一起,在串行记录中把第一个声音写得接近第一个 token,把跟踪的 ts、dur 以 ms 放入(它们是微秒)。
- 文档:Trace Event Format · Perfetto UI · llama.cpp server
端点延迟——用音频时间
对 /opt/lab/fixtures/voice/queries/refs.tsv(名称、原文、语句开始、语句结束)中的每一条查询,把 silero VAD(threshold 0.5 · min_silence 0.1 · min_speech 0.1)最后一个区间的结束写作 vad_end_s,加上 gap 0.5 秒的值写作 endpoint_s,把 (endpoint_s − 발화 끝) × 1000(占位符为语句结束时刻)写作 delay_ms,写入 /root/voice/pipeline/endpoint.json(gap_s、rows)。
所有的值都是声音文件内部的时刻——这是机器再快也不会减少的部分,所以要单独测量。实际话尾是合成语音摆放的位置,所以是准确的。
串行连接
在 /root/voice/pipeline/pipe.py 中创建 run(wav, mode)。把声音以每次 100 ms 输入流式 ASR(不测量)后,把端点那一刻定为 0,依次记录最终结果(asr_ms)→ 用去掉口头赘词的查询做段落 top-3 检索(retrieve_ms)→ 附上找到的段落的 LLM 流(第一个片段 llm_first_ms,结束 llm_done_ms,max_tokens 为 60)→ 通过 TTS 接续,写下第一个声音(first_audio_ms)和合成结束(tts_done_ms)并返回。当 mode 为“serial”时,接收完整个回答后一次性合成整体。串行运行 q01、q03、q05、q07、q09,加上 id、text(最终结果),写入 /root/voice/pipeline/serial.jsonl。
模型(ASR、TTS、嵌入)和 KB 嵌入,只在加载模块时创建一次。如果用线程运行 TTS,下一步的重叠中就可以直接使用。在串行中,第一个声音就是合成结束。
重叠运行
让 run(wav, 'overlap') 随着 LLM 文字的累积,切出片段(句子结尾 · 超过五个词的逗号之前 · 十二个词)并交给 TTS 线程。把同样的五条查询按重叠方式运行,写入 /root/voice/pipeline/overlap.jsonl。评分:第一个声音的中位数必须比串行更快。
只有串行与重叠的差别仅仅是“什么时候交给 TTS”这一点,比较才公平。请在同一个 run 函数中用 mode 区分。
画成条形
用 overlap.jsonl 的第一条记录,把 asr_final、retrieve、llm(都是 tid 1)和 tts(tid 2,从第一个 token 到合成结束)条形做成 "ph": "X" 事件,把第一个声音做成 "ph": "i" 事件,将 {"traceEvents": [...]} 保存到 /root/voice/pipeline/trace.json。ts 和 dur 的单位是微秒。
ms × 1000 = µs。把文件拖放到 chrome://tracing 或 ui.perfetto.dev 中,就能看到 LLM 与 TTS 重叠的样子。
用户感受到的延迟的分布
把 q01 到 q10 按重叠方式运行,对每条查询,在 rows 中写入 endpoint_ms(endpoint.json 的 delay_ms)、first_audio_ms、e2e_ms(两者之和),并把 e2e_ms 的 p50_ms、p95_ms(最近秩)写入 /root/voice/pipeline/e2e.json。
音频时间(端点延迟)和挂钟时间(判定后的第一个声音)在这里是第一次相加。10 个样本的 p95 就是最大的那个值。
与预算对照
生成 /root/voice/pipeline/budget.json:在 budget_ms 中写下五个阶段(endpoint、asr_final、retrieve、llm_first_token、first_audio_after_token)的预算(正数,总和不超过 2500),在 measured_p50_ms 中写下中位数——endpoint 取 endpoint.json 的 delay_ms,其余从 overlap.jsonl 中取 asr_ms · (retrieve_ms − asr_ms) · (llm_first_ms − retrieve_ms) · (first_audio_ms − llm_first_ms)——并在 over_budget 中写下测量值超出预算的阶段名称。
累计时刻之差,就是那个阶段所占的份额。对每个超出的阶段,想一想可以用什么来缩短——模型、提示词,还是衔接。
内存也是预算
用 pipe 运行一条查询后,把这个 Python 进程的 /proc/self/status 中 VmHWM(最大 RSS)写作 python_peak_mb,把用 pgrep -f llama-server 找到的服务器的 VmRSS 写作 llm_rss_mb,把两者之和写作 total_mb,把 /sys/fs/cgroup/memory.max 的限制写作 limit_mb(不是数字则为 null),写入 /root/voice/pipeline/memory.json。
VmHWM、VmRSS 以 kB 为单位——除以 1024 换成 MB。限制是以字节为单位的。两个进程使用了限制的百分之几,就是能否再加载一个模型的答案。
报告
在 /root/voice/pipeline/report.json 中写入 serial_first_audio_p50_ms、overlap_first_audio_p50_ms(两个 jsonl 的 first_audio_ms 中位数)、e2e_p50_ms、e2e_p95_ms、over_budget、total_mb。
从前面步骤的文件中计算或抄写。