「实时」在 X2.0 这类交互系统里很难被当成一个开关式的属性:同一套东西,从哪一刻开始计时、到哪一刻结束,直接决定它被判成「很快」还是「说不好」。看演示觉得流畅,通常只说明那一次交互路径上没有明显排队,不能替代你自己环境下的计时记录。可落地的做法是先固定起点与终点事件,再用同一份输入反复计时,首次出画面和连续交互分开记,最后看分布而不是看某一次的手感。
判断实时性,建议先固定一套计时口径:起点取用户提交输入的本机时间戳,终点最好取画面真正可见(首帧渲染完成)的时刻,而不是应用层回调返回的时刻。回调时间只反映链路内耗时,端到端延迟要另算。用固定输入连续计时若干次,首次出画面与连续交互分两组记录,先看最大值与分位区间再看中位数。样本量、间隔和可接受阈值需要结合自己的业务场景确认,别把别人的数字当标准。
先定义实时:从哪一刻开始计时、到哪一刻结束
结论对不上,多数时候不是系统变了,而是两个人量的不是同一段。建议先把两个事件写死,之后所有记录都沿用。
- 起点事件:用户提交输入的那一帧,取本机单调时钟的时间戳。文本输入对应回车或点击发送的瞬间,音频输入对应最后一个音频帧推入发送缓冲的瞬间。不要从「服务端收到请求」开始算,那一段客户端排队、编码、网络往返都会被你丢掉。
- 终点 A:回调返回:应用层拿到首个响应片段(流式场景)或完整响应的那一刻。这个数好采,脚本里就能打点,但它只覆盖到「应用知道了」为止。
- 终点 B:画面可见:首帧文本或首段音频真正渲染到屏幕、用户能感知的那一刻。中间还有模型输出拼装、传输、渲染合成、音频播放缓冲,这一段常常是演示观感与脚本数字差距的来源。
两者的差别必须写进记录里。常用口径有两个:「端到端首帧可见」用 A 到 B 的可见时刻算,适合判断用户体感;「链路内耗时」用起点到回调返回算,适合定位是链路慢还是渲染慢。两个口径不要混在同一列里对比,建议在结果表里单独加一列「口径」,注明这次记的是回调还是可见。
写一段可重复的计时脚本,固定输入和次数
脚本的目的不是测出某个漂亮数字,而是让每次测量的条件一致。下面是一个通用骨架,把 call_placeholder 换成你实际使用的 SDK 调用或 HTTP 请求即可,其余部分不建议随意改动,尤其是输入内容和轮次间隔。
import json, time, uuid
def now_ms():
return time.time() * 1000
def one_round(seq, prompt):
rec = {'seq': seq, 'input_id': str(uuid.uuid4()), 'kind': 'first_paint'}
rec['t_submit'] = now_ms() # 起点:提交输入
try:
resp = call_placeholder(prompt) # 替换为真实调用
rec['t_callback'] = now_ms() # 终点 A:回调返回
rec['ok'] = True
rec['resp_len'] = len(str(resp))
except Exception as e:
rec['t_callback'] = now_ms()
rec['ok'] = False
rec['err'] = repr(e)
return rec
if __name__ == '__main__':
N = 30
prompt = '固定输入:同一段文本或同一个音频文件'
with open('x2_latency.jsonl', 'a', encoding='utf-8') as f:
for i in range(N):
f.write(json.dumps(one_round(i, prompt), ensure_ascii=False) + '\n')
f.flush()
time.sleep(1.0) # 间隔按自己场景定,但要固定
终点 B 无法在这个脚本里打点,需要在客户端首帧渲染回调里取时间戳,并带上同一个 input_id 回传或另写一份文件,事后按 input_id 关联。用序号去猜配对很容易错位,尤其是失败重试的那几轮。
跑完先做三项校验,确认记录本身是完整的,再谈快慢:
- 条数对得上:
wc -l x2_latency.jsonl的结果应等于轮次 N。 - 时间戳不缺:
jq -r 'select(.t_submit == null or .t_callback == null) | .seq' x2_latency.jsonl输出为空。 - 没有负值:
jq -r '.t_callback - .t_submit' x2_latency.jsonl里不出现负数,出现就说明时钟或打点位置写错了。
把首次出画面和连续交互下的延迟分开记录
首次那一轮通常包含建连、鉴权、冷缓存这些一次性开销,和后续连续交互的耗时往往不是一个量级。把两组混在一起算平均,会让首次看起来没那么慢,也让连续交互看起来没那么稳,两边都失真。建议用同一张表,靠「轮次」字段区分:
| 序号 | 轮次 | 起始时间 | 结束时间 | 耗时(ms) | 是否成功 | 口径/备注 |
|---|---|---|---|---|---|---|
| 1 | 首次出画面 | t_submit | t_visible | 差值 | 是/否 | 画面可见 |
| 2 | 连续第 1 轮 | t_submit | t_visible | 差值 | 是/否 | 画面可见 |
| 3 | 连续第 2 轮 | t_submit | t_callback | 差值 | 否 | 超时,需保留 |
两组合并记录时有几点容易被忽略:
- 失败的那几轮不要删。删掉之后分布会偏向乐观,而偶发的长等待恰恰是最该被看见的部分。
- 连续交互的轮次之间要固定间隔,间隔变了,服务端排队、连接复用的状态就变了,两组数据不可比。
- 同一份结果表只对应一次运行环境,客户端版本、网络方式、输入内容任一变化,建议另起一个文件,不要往老文件后面追加。
- 如果一轮里同时记了回调时间和可见时间,用
input_id对齐成一行两列,而不是记成两条序号相邻的记录。
用分布而不是平均值判断稳定性
平均值会把偶发的长等待摊薄。一次明显卡顿和若干次顺畅交互混在一起,平均值看起来很正常,但用户记住的恰恰是那一次卡顿。判断稳定性,建议先看最大值,再看分位区间:中位数反映常态,高位分位(如 p90 或 p95)反映「较慢的那部分」到底慢到什么程度,两者跨度大,说明抖动明显。
import json
rows = [json.loads(l) for l in open('x2_latency.jsonl', encoding='utf-8')]
d = sorted(r['t_callback'] - r['t_submit'] for r in rows if r.get('ok'))
def pct(v, p):
k = max(0, min(len(v) - 1, int(round(p / 100 * (len(v) - 1)))))
return v[k]
print('n =', len(d))
print('min =', d[0])
print('p50 =', pct(d, 50))
print('p90 =', pct(d, 90))
print('max =', d[-1])
样本量按自己的场景定:只是想确认「有没有偶发长尾」,几十次往往就能看到端倪;要对某个分位下判断,样本太少时分位本身就不稳定,这时候更该相信最大值和原始记录,而不是一个算出来的 p99。分位算法和口径也要在结果表里注明,不同取整方式在小样本上差异明显。至于多快算实时,取决于交互形态:逐字流式返回的对话和连续音频播放对抖动的容忍度不一样,建议先拿自己业务里已经被用户抱怨过的场景当参照,再决定这一版是否够用。