一条回答首字 100 毫秒返回,后面却每隔半秒才出一个字;另一条首字稍慢,随后连续输出。只看 TTFT,前者更漂亮,读起来却未必更舒服。再把超时请求从报表里删掉,剩下的曲线还能更好看,但用户不会少遇到一次失败。
10 月 16 日 vLLM 的 Kimi K3 优化文章专门讨论了正确性与跨配置性能验证。性能补丁需要回答它对哪些输入、哪些批次有效,而不是只展示最快的一点。官方工程文章
时间戳要从同一条请求取
TTFT 是从约定起点到首个输出 Token 的时间;ITL 是相邻输出 Token 的间隔。起点可以放在客户端发起、网关接收或引擎入队,但报表必须写清楚,不能把不同起点的数据直接比较。流式网络分块也不一定逐 Token 对齐;把 HTTP chunk 当 Token,会得到另一种指标。
| 数据 | 建议保留的字段 | 容易造成的误读 |
|---|---|---|
| 请求 | ID、开始、状态、结束 | 失败没有首字,于是被消失 |
| 输出流 | 首个Token、各Token时间或块边界 | 一个chunk被当成一个Token |
| 负载 | 输入长度、输出长度、并发、到达率 | 不同工作量被平均到一起 |
| 缓存 | 命中长度、冷热状态 | 热缓存数字被当作冷启动 |
从三条教学记录算起
下面全部时间都是人为构造的毫秒数,用来检查统计口径,不是线上性能。程序显式使用 nearest-rank 分位数,避免不同库默认插值规则造成差异。
import math from statistics import mean def percentile(values, p): if not values or not 0 < p <= 1: raise ValueError("empty data or invalid percentile") xs = sorted(values) if any(not math.isfinite(x) for x in xs): raise ValueError("non-finite sample") return xs[math.ceil(p * len(xs)) - 1] rows = [ {"start": 0, "tokens": [100, 120, 180], "ok": True}, {"start": 0, "tokens": [300, 340], "ok": True}, {"start": 0, "tokens": [], "ok": False}, ] ok = [r for r in rows if r["ok"]] ttft = [r["tokens"][0] - r["start"] for r in ok] itl = [b-a for r in ok for a, b in zip(r["tokens"], r["tokens"][1:])] assert ttft == [100, 300] and itl == [20, 60, 40] print("TTFT mean/p99:", mean(ttft), percentile(ttft, 0.99)) print("ITL mean/p99:", mean(itl), percentile(itl, 0.99)) print("success:", len(ok), "/", len(rows))两条成功请求的平均 TTFT 是 200 毫秒,nearest-rank P99 是 300 毫秒,成功率是 2/3。这里只有两个成功样本,所谓 P99 实际上落在最大值上,不能用它描述稳定的尾延迟。样本数、窗口和置信程度应与指标一起展示。
Python statistics 文档说明了均值和分位统计的不同用途,也提醒 NaN 等值可能破坏排序结果。坏样本应该单独计数和调查,不能静默吞掉后宣称数据更干净。官方文档
把成功率放回决策
比较两个方案时,至少同时看满足延迟目标的成功请求数、完整输出量和总资源时间。若方案通过更早拒绝请求降低延迟,报表要显示拒绝率;若通过截断输出增加吞吐,要显示完成条件变了。否则统计优化会伪装成系统优化。
还要拆开 Prefill 主导的长输入、Decode 主导的长输出,以及多轮缓存命中场景。全局一个平均数无法告诉我们哪种用户变快了。这里没有运行模型、网关或浏览器;离线代码只验证三条记录的统计计算,真实流式采集与集群负载仍需单独验证。