1. 为什么平均 TTFT 会骗你:从一次线上抖动说起
如果你正在做 LLM 应用,或者负责推理服务的性能观测,大概率遇到过这种场景:监控大盘上平均 TTFT 只有 800ms,看起来一切正常,但用户投诉「有时候要等好几秒才出字」。你去翻日志,发现确实有一批请求的 TTFT 飙到了 6s 以上,只是被平均值稀释掉了。
这就是典型的尾延迟问题。平均值只告诉你「整体感觉」,不告诉你「最差的那批用户经历了什么」。而 ECDF(Empirical Cumulative Distribution Function,经验累积分布函数)恰好能补上这块:它的横轴是 TTFT,纵轴是「有多少比例的请求在这个时间内拿到了第一个 Token」。你一眼就能看出 P50 在哪、P90 在哪、P99 拖了多长的尾巴。
这篇要解决的问题很具体:怎么用 TaoToken 的统一 Key 和 API 通道,稳定采集大模型 TTFT 样本,再用 ECDF 把 P50、P90、P99 画出来,定位长尾延迟到底出在哪个环节。适合正在做推理性能观测、模型对比、或者要给团队出一份延迟报告的工程师。全文会给可复制的采集脚本骨架、settings.json 配置片段,以及分位点的验证动作,你可以直接跟着跑。
2. TaoToken 前置:统一 Key 让 TTFT 采样口径一致
做 TTFT 观测最怕的一件事是「口径不一致」。今天用 A 平台的 Key 测 GPT 系模型,明天用 B 平台的 Key 测 Claude 系模型,网络路径、鉴权耗时、限流策略都不一样,最后画出来的 ECDF 曲线根本没法横向对比。
TaoToken 在这里的价值是:它提供统一的 API 通道和统一的 Key 管理,你可以在同一个入口下切换不同模型,采样脚本不用改鉴权逻辑,TTFT 的测量口径就能保持一致。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api (这个不加 UTM)。
你需要提前准备的东西不多:
- 一个可用的 API Key,在控制台的 API Keys 页面创建,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- Python 3.9+ 环境,装好
httpx、numpy、matplotlib - 想清楚你要测哪个模型,模型名可以在模型对话页确认:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
注意:TTFT 的测量必须用流式请求。非流式请求你拿到的是完整响应,测出来的是总延迟,不是首 Token 时间。这一点后面脚本里会体现。
如果你后面要做长期的编码类 Agent 压测,可以考虑 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,它更适合持续性的调用场景。接入细节可以查文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
3. 可复制配置:settings.json 与采集脚本骨架
3.1 settings.json 配置片段
先把配置独立出来,避免 Key 硬编码在脚本里。下面这个settings.json结构可以直接用,字段含义我在注释里标了(JSON 不支持注释,实际使用时请去掉//部分,或者用settings.example.json做模板):
{ "base_url": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "model": "your-model-name", "stream": true, "max_tokens": 64, "temperature": 0, "timeout_seconds": 60, "sample_count": 300, "concurrency": 4, "prompt": "用一句话解释什么是首 Token 延迟。", "output_csv": "ttft_samples.csv" }几个关键参数说明:
| 参数 | 作用 | 建议值 |
|---|---|---|
stream | 必须为 true,否则测不到 TTFT | true |
max_tokens | 控制输出长度,越小越快结束 | 32–64 |
temperature | 降低随机性,让样本更可比 | 0 |
sample_count | 样本量,ECDF 至少 200 起 | 300+ |
concurrency | 并发数,用来观察排队影响 | 1 / 4 / 8 对比 |
Key 通过环境变量注入,不要写进文件:
export TAOTOKEN_API_KEY="你的Key"3.2 TTFT 采集脚本骨架
核心思路:发流式请求,记录「请求发出」到「收到第一个 content chunk」之间的时间差,这个差值就是 TTFT。注意要排除掉连接建立的时间干扰,所以我在脚本里先做了一次预热请求。
import os import json import time import csv import asyncio import httpx with open("settings.json", "r", encoding="utf-8") as f: CFG = json.load(f) API_KEY = os.environ[CFG["api_key_env"]] HEADERS = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", } async def one_request(client: httpx.AsyncClient) -> float: payload = { "model": CFG["model"], "stream": True, "max_tokens": CFG["max_tokens"], "temperature": CFG["temperature"], "messages": [{"role": "user", "content": CFG["prompt"]}], } start = time.perf_counter() ttft = None async with client.stream( "POST", f"{CFG['base_url']}/v1/chat/completions", headers=HEADERS, json=payload, timeout=CFG["timeout_seconds"], ) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line or not line.startswith("data:"): continue data = line[5:].strip() if data == "[DONE]": break try: chunk = json.loads(data) except json.JSONDecodeError: continue delta = chunk.get("choices", [{}])[0].get("delta", {}) if delta.get("content"): ttft = time.perf_counter() - start break return ttft if ttft is not None else float("nan") async def warmup(client): await one_request(client) async def main(): sem = asyncio.Semaphore(CFG["concurrency"]) results = [] async with httpx.AsyncClient() as client: await warmup(client) # 预热,排除 TLS 握手干扰 async def worker(): async with sem: try: t = await one_request(client) except Exception as e: print("request failed:", e) t = float("nan") results.append(t) tasks = [asyncio.create_task(worker()) for _ in range(CFG["sample_count"])] await asyncio.gather(*tasks) valid = [r for r in results if r == r] # 过滤 NaN with open(CFG["output_csv"], "w", newline="") as f: writer = csv.writer(f) writer.writerow(["ttft_seconds"]) for v in valid: writer.writerow([v]) print(f"collected {len(valid)} valid samples -> {CFG['output_csv']}") if __name__ == "__main__": asyncio.run(main())跑起来:
python collect_ttft.py实测下来,300 个样本、并发 4 的情况下,几分钟就能跑完,输出一个ttft_samples.csv。
3.3 画 ECDF 并标注 P50/P90/P99
拿到 CSV 之后,画图逻辑就是 excerpt 里那三步:排序、算累计比例、step 画图。我把它补全成可直接运行的版本:
import csv import numpy as np import matplotlib.pyplot as plt ttft = [] with open("ttft_samples.csv") as f: reader = csv.DictReader(f) for row in reader: ttft.append(float(row["ttft_seconds"])) x = np.sort(np.array(ttft)) y = np.arange(1, len(x) + 1) / len(x) p50, p90, p99 = np.percentile(x, [50, 90, 99]) print(f"P50={p50:.3f}s P90={p90:.3f}s P99={p99:.3f}s") fig, ax = plt.subplots(figsize=(8, 5)) ax.step(x, y, where="post", linewidth=2, label="TTFT ECDF") for p, v, c in [(50, p50, "green"), (90, p90, "orange"), (99, p99, "red")]: ax.axvline(v, linestyle="--", color=c, alpha=0.7) ax.annotate(f"P{p}={v:.2f}s", xy=(v, p / 100), xytext=(v + 0.1, p / 100 - 0.08), color=c) ax.set_xlabel("TTFT (seconds)") ax.set_ylabel("Cumulative ratio of requests") ax.set_title("LLM TTFT ECDF with P50 / P90 / P99") ax.grid(alpha=0.3) ax.legend() plt.tight_layout() plt.savefig("ttft_ecdf.png", dpi=150) plt.show()4. 验证请求:分位点对不对,用两个动作交叉确认
图能画出来不代表数据可信。我一般会做两个验证动作。
动作一:用 numpy 的 percentile 和手算分位点对齐。手算逻辑是:排序后取第ceil(p/100 * n)个元素。如果两者差距在 1 个样本以内,说明分位点计算没问题。脚本里已经打印了np.percentile的结果,你可以再补一段:
def manual_percentile(sorted_arr, p): import math idx = math.ceil(p / 100 * len(sorted_arr)) - 1 return sorted_arr[max(idx, 0)] for p in (50, 90, 99): print(p, np.percentile(x, p), manual_percentile(x, p))动作二:看 ECDF 曲线在 P90 到 P99 之间的斜率。如果这段曲线几乎是垂直往上冲的,说明尾延迟集中在很窄的区间,问题可能出在某个固定阈值(比如限流触发点)。如果这段曲线是缓慢爬升的长尾,说明慢请求是分散的,更可能是网络抖动或调度排队。
一个健康的采样结果大概长这样:P50 在 1.2s 左右,P90 在 3.5s 上下,P99 不超过 P90 的 2.5 倍。如果 P99 是 P50 的 6 倍以上,比如 P50=1.2s、P99=8.5s,那基本可以判定存在明显长尾,需要往下查。
提示:采样时把并发从 1 调到 4 再调到 8,分别画三条 ECDF 叠在一起,你能直观看到并发对尾延迟的放大效应。这比看单个平均值有用得多。
5. 本篇常见错排查:TTFT 采集与 ECDF 绘制的坑
坑一:把非流式请求当流式测。最常见的错误。如果你没设stream: true,服务端会等全部生成完才返回,你测到的是总延迟。表现是 P50 直接飙到好几秒,ECDF 曲线整体右移。检查方法:看响应头是不是text/event-stream。
坑二:没预热,第一个样本特别慢。TLS 握手、DNS 解析、连接池初始化都会算进第一个请求的 TTFT。表现是 CSV 里第一条数据明显偏大,ECDF 最左端有个突兀的台阶。解决办法就是脚本里的warmup,先发一个不计入统计的请求。
坑三:并发太高导致排队,误判成模型慢。当你把concurrency设成 16 甚至 32,请求会在服务端排队,TTFT 里混入了排队时间。这时候 P99 会异常高,但 P50 可能还行。判断方法:把并发降到 1 重测,如果 P99 大幅下降,说明是排队问题,不是模型本身慢。
坑四:ECDF 用plot而不是step。用ax.plot(x, y)画出来是折线,会在样本点之间做线性插值,视觉上会「平滑」掉真实的台阶,让你误以为分布很连续。ECDF 是阶梯函数,必须用ax.step(..., where="post")。
坑五:样本量太小。50 个样本画出来的 ECDF,P99 实际上就是最大值,没有统计意义。建议至少 200 个样本,要稳定看 P99 最好 500 以上。
坑六:Key 或模型名写错,请求全失败但没报错。如果脚本里异常被吞掉,results里全是 NaN,最后 CSV 是空的。表现是collected 0 valid samples。检查环境变量TAOTOKEN_API_KEY是否导出成功,模型名是否和模型对话页里一致。
6. 把 ECDF 接进你的观测链路
跑通上面这套之后,你手里就有了一条可复现的 TTFT 观测链路:统一 Key 采样、CSV 落盘、ECDF 出图、分位点交叉验证。接下来可以做的事很自然——把collect_ttft.py挂到定时任务里,每天跑一轮,把 P50/P90/P99 写进时序库,尾延迟一有恶化就能在曲线上看出来。
如果你要测更多模型做横向对比,直接在settings.json里换model字段就行,Key 和通道不用动,这也是统一入口最省心的地方。需要新建或轮换 Key 的时候去 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,接入参数有疑问就翻 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。想先在网页上手动发几个请求感受一下流式返回的节奏,模型对话页在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。长期跑编码类 Agent 压测的话,Coding Plan 会更合适:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
最后一个实用技巧:画 ECDF 时把横轴设成对数刻度(ax.set_xscale("log")),长尾部分会被拉开,P99 附近的细节看得更清楚。这个改动一行代码,但对定位尾延迟特别有用。