两条链路的差距通常不在模型本身快慢,而在中间多了两段排队:文本要收敛成可合成的一段,TTS 还要排队出首包。Realtime 流式通话把这两段压进一次端到端会话,音频边生成边下发;回退到普通 TTS 后,链路变成“输入结束 → 文本收敛 → 合成请求 → 首包 → 播放”,首字延迟的增量主要落在文本收敛点和合成排队点,网络往返并不是主因。要判断回退损失在哪一段,先让两条链路共用同一套打点,再用分位值说话,避免拿一次“感觉卡”的通话下结论。
回退到普通 TTS 的体验损失,绝大部分体现为“首个可听字”被推后:文本必须收敛到可合成粒度,合成请求又要等首包返回,两段串行叠加。建议先用同一套打点分别采集流式与拼接链路的首字等待和总时长分位值,再据此把回退触发阈值写成可判定规则;播报类单轮内容通常可以接受回退,多轮追问和强打断场景需谨慎。阈值、冷却窗口和可接受范围都要结合自己的环境确认,不要照搬他人数值。
把两条链路画成同一套计时口径
对比之前先把打点位置钉死,否则流式侧量的是“首包到达”,拼接侧量的是“播放开始”,两边的差值就会被口径污染。建议在业务侧(而不是供应商 SDK 内部)埋点,四条时间戳两边都打,缺一条就标为无效通话,不进统计。
| 打点 | 定义 | 流式侧来源 | 拼接侧来源 |
|---|---|---|---|
| input_end_ts | 用户输入结束、VAD 判定说完或文本提交完成 | 同一个 VAD 回调 | 同一个提交事件 |
| upstream_first_ts | 上游返回第一个数据包(音频帧或 token 均可) | 会话首帧回调 | LLM 首个 token 回调 |
| first_audible_ts | 设备侧真正开始出声的第一个可听字 | 播放器起播回调 | 播放器起播回调 |
| play_end_ts | 本次回复最后一帧播完 | 播放完成回调 | 播放完成回调 |
注意 upstream_first_ts 在两侧含义不同:流式侧它已经是音频,拼接侧它还只是文本,所以它只能用来定位“上游何时开始动”,不能直接比较。真正可比的是 first_audible_ts - input_end_ts 和 play_end_ts - input_end_ts 这两个区间。
流式链路的分段耗时采集
每次通话落一行 JSON 日志,按通话 ID 聚合。日志片段只保留与计时相关的字段,方便直接喂给统计脚本:
{"call_id":"c-001","path":"realtime","input_end_ts":1710000000.120,"upstream_first_ts":1710000000.410,"first_audible_ts":1710000000.780,"play_end_ts":1710000006.940,"err":null}
{"call_id":"c-002","path":"realtime","input_end_ts":1710000030.005,"upstream_first_ts":1710000030.360,"first_audible_ts":1710000031.020,"play_end_ts":1710000038.500,"err":null}
把日志文件路径作为参数传入,脚本只输出分位值,不打印单次通话,避免被个别长尾带偏:
import json, sys
def pct(xs, p):
xs = sorted(xs)
if not xs:
return None
k = (len(xs) - 1) * p
lo = int(k)
hi = min(lo + 1, len(xs) - 1)
return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)
rows = [json.loads(l) for l in open(sys.argv[1]) if l.strip()]
rows = [r for r in rows if r.get("err") is None]
first = [r["first_audible_ts"] - r["input_end_ts"] for r in rows]
total = [r["play_end_ts"] - r["input_end_ts"] for r in rows]
for name, xs in (("first_audible", first), ("total", total)):
for p in (0.5, 0.9, 0.95):
print(name, p, round(pct(xs, p), 3))
建议至少积累几十通再判断分布,分位差比均值更能说明“用户偶尔觉得卡”的那种体感。若 p95 明显偏离 p50,先查是不是集中在某个网络条件或某类长回答上,再去改配置。
拼接链路的分段耗时采集
拼接链路多出来的时间要靠额外三个点拆开,否则只能看到总时长变长却不知道卡在文本还是合成。三个点都记在业务侧:text_done_ts(文本生成完成,即最后一句话进入可合成状态)、tts_req_ts(合成请求实际发出的时间,含排队前的准备开销)、tts_first_packet_ts(合成首包到达,注意是到达而不是播放)。
{"call_id":"c-101","path":"tts_fallback","input_end_ts":1710000100.200,"text_done_ts":1710000101.450,"tts_req_ts":1710000101.470,"tts_first_packet_ts":1710000102.130,"first_audible_ts":1710000102.400,"play_end_ts":1710000108.900}
由此可算出三段:text_done_ts - input_end_ts 是文本收敛时间,tts_first_packet_ts - tts_req_ts 是合成排队加首包时间,first_audible_ts - tts_first_packet_ts 是本地缓冲与起播开销。判断回退损失点时,先比较拼接链路的“文本收敛时间”与流式链路的首包时间;如果前者更长,说明瓶颈在让文本说完,而不在 TTS。
配置回退触发条件
回退不该是运维手工拨的开关,而应是一组可判定、可回放的规则。下面是一份配置骨架,阈值、窗口和冷却时间都留空,由你按上一步得到的分位值再留余量填写,例如首包阈值可取流式 p90 附近偏上的值。
realtime:
enabled: true
fallback:
triggers:
- name: first_packet_timeout
metric: upstream_first_packet_ms
threshold_ms: # 参考流式 p90 留余量后填入
window: 20 # 统计最近多少通
ratio: # 超过阈值的占比达到多少即触发
action: switch_to_tts
- name: first_audible_timeout
metric: first_audible_ms
threshold_ms: # 以首个可听字为准,比首包更贴近体感
action: switch_to_tts
- name: upstream_error
codes: [429, 500, 503]
consecutive: 2 # 连续失败次数,按自身重试策略填
action: switch_to_tts
cooldown_s: # 回退后多久再尝试流式
retry_stream_on_next_call: true
规则落地后要能被验证:命中触发条件时打一条带 trigger 名称的日志,事后可按 trigger 分组统计回退次数,确认不是某条阈值过紧导致频繁抖动。失败码集合按你实际接入返回的码表调整,不要把鉴权失败也纳入回退,那类错误换成普通 TTS 同样不通。
列出回退可接受的场景
把技术结论翻成业务取舍,判断依据应落在“用户是否能感知到首字变慢”和“是否需要随时打断”上,而不是笼统地说体验差。
| 场景 | 可接受的依据 | 不可接受的情况 |
|---|---|---|
| 播报通知 | 内容可由模板或固定字段生成,文本收敛几乎不占时间;用户不追问,等待一次起播可被接受 | 通知内容依赖长上下文实时生成,收敛本身就慢,回退只会放大等待 |
| 多轮对话 | 轮次间隔长、回答偏信息型、用户以听完为主 | 用户频繁用“嗯”“对”等短反馈,需要即时回应,首字延后会明显打断节奏 |
| 打断频繁场景 | 几乎不适用;仅当打断只发生在句间且用户能容忍半句停顿 | 需要句内随时打断时不可回退:拼接链路在首包前没有可打断的音频流,barge-in 只能等到句子边界 |
建议先把回退限定在播报类、无打断要求的入口,多轮对话与强打断入口保留流式,必要时对超长回答做分句合成以缩短首个可听字的等待;是否放宽到其他场景,用前面采集的分位值和回退触发日志再判断。