当前位置:首页 > 编程开发 > 正文

给你的模型做真实压测:TTFT、吞吐、分位延迟怎么测才可信

约 6399 字 · 预计阅读 16 分钟
一句话总结:官方说的 680 tok/s 和你实测的 80 tok/s 往往都没错,只是测的不是同一件事——压测要固定输入长度和输出长度、区分 TTFT 与解码速度、跑多次取分位值,再把吞吐换算成每百万 Token 成本,这三步做完数字才能用来做决策。

上一篇讲怎么把 Clover 跑起来,这一篇讲跑起来之后怎么知道它到底多快。

这件事比看起来重要。你选模型、报预算、决定要不要自建,依据都是这些数字;而官方给的 tok/s 几乎总是偏乐观,不一定是造假,是口径对你没用。

三个指标,别混成一个

大部分人说的「速度」其实是三件不同的事:

TTFT(Time To First Token):从发出请求到收到第一个 token 的时间。对话场景的体感几乎全靠它。1000 token 的回复,用户等 3 秒才看到第一个字,就是卡。

ITL / TPOT(Inter-Token Latency):输出中相邻 token 的间隔。它决定「字往外蹦的速度」。人阅读大概每秒 10 到 20 个 token,超过这个数就感觉是瞬间出完。

吞吐(Throughput):单位时间内所有请求加起来产出的 token 数,单位 tok/s。它只跟成本有关,跟单个用户的体感无关。

一个模型可以吞吐 2000 tok/s 但单请求 TTFT 5 秒,也可以 TTFT 0.2 秒但吞吐只有 300。报数字不带指标名,等于没说。

图 1:左边是三个指标在时间轴上的位置,右边是并发上去之后吞吐和延迟的典型走势(示意图)

官方数字为什么不可比

拿 Clover 那组数据举例,官方给了四组:GB300 上 680 tok/s、H200 上 515 tok/s、5070 Ti INT4 上 101.8 tok/s、49 项任务中位 1.52 秒。四个数字都对,但不能互相换算,因为至少有五个变量在变:

变量 影响
batch size官方明说 680 是 batch=1,但服务化时 batch 上去吞吐会翻几倍
输入长度长输入吃满显存带宽,短输入跑不满
输出长度短输出被 TTFT 主导,长输出被解码速度主导
精度BF16 和 INT4 差 2 到 3 倍很正常
硬件同样的卡,PCIe 代际和内存带宽也能差 10%

所以看到「某某模型 680 tok/s」,第一反应应该是问:batch 多少、输入多长、输出多长、什么精度。 四个说不出来,这个数字就只能当宣传。

工具一:用框架自带的压测脚本

vLLM 和 SGLang 都带 benchmark 工具,最省事。vLLM 的形式大概是这样:

Python -m vllm.entrypoints.OpenAI.API_server \
  --model <你的模型路径> \
  --port 8000 \
  --max-model-len 8192

# 另开一个终端,用官方 benchmark 脚本扫并发
python benchmarks/benchmark_serving.py \
  --backend openai-chat \
  --base-uRL http://127.0.0.1:8000 \
  --model <你的模型路径> \
  --dataset-name random \
  --random-input-len 512 \
  --random-output-len 256 \
  --num-prompts 200 \
  --max-concurrency 16

关键是 --random-input-len 和 --random-output-len 这两个参数。不指定它们,测出来的数字没有意义,因为输入输出长度随机浮动,两次结果没法比。

SGLang 有类似的 bench_serving,参数名不同但思路一样。

工具二:自己写,二十行能测准

框架自带的脚本够用,但有个问题:它测的是「平均延迟」,不给你分位值。而线上出问题的永远是 p95,不是平均值。

自己写一个不复杂。这段用 aiohttp 流式请求,同时拿到 TTFT 和总时长:

# bench.py —— 需要先 pip install aiohttp
import asyncio, json, statistics, time
import aiohttp

URL = "http://127.0.0.1:8000/v1/chat/completions"
PROMPT = "写一个 Python 函数,用滑动窗口求数组的最大值,要求 O(n)。"   # 固定输入
MAX_TOK = 256                                                          # 固定输出上限


async def one_request(session):
    payload = {
        "model": "local",
        "messages": [{"role": "user", "content": PROMPT}],
        "max_tokens": MAX_TOK,
        "stream": True,
    }
    t0 = time.perf_counter()
    ttft, ntok = None, 0

    async with session.post(URL, json=payload) as r:
        r.raise_for_status()
        while True:
            raw = await r.content.readline()
            if not raw:
                break
            line = raw.strip()
            if not line.startswith(b"data:"):
                continue
            data = line[5:].strip()
            if data == b"[DONE]":
                break
            try:
                chunk = json.loads(data)
            except json.JSONDecodeError:
                continue
            delta = chunk["choices"][0]["delta"].get("content")
            if delta:
                if ttft is None:
                    ttft = time.perf_counter() - t0       # 首个内容块
                ntok += 1

    return ttft, time.perf_counter() - t0, ntok


def pct(xs, p):
    xs = sorted(xs)
    if not xs:
        return 0.0
    return xs[min(int(len(xs) * p), len(xs) - 1)]


async def run(concurrency, rounds=4):
    conn = aiohttp.TCPConnector(liMIT=concurrency)
    async with aiohttp.ClientSession(connector=conn) as s:
        await one_request(s)                              # 预热,丢掉
        t0 = time.perf_counter()
        res = await asyncio.gather(*[
            one_request(s) for _ in range(concurrency * rounds)
        ])
        wall = time.perf_counter() - t0

    ttfts = [r[0] for r in res if r[0]]
    totals = [r[1] for r in res]
    toks = sum(r[2] for r in res)
    print(f"并发 {concurrency:>2} | 吞吐 {toks / wall:7.1f} tok/s | "
          f"TTFT p50 {pct(ttfts, .5):.2f}s p95 {pct(ttfts, .95):.2f}s | "
          f"总时长 p50 {pct(totals, .5):.2f}s | 产出 {toks} tok")


if __name__ == "__main__":
    for c in (1, 2, 4, 8, 16):
        asyncio.run(run(c))

四个设计要点,每一个都影响结果可信度:

固定 PROMPT。 输入一变,结果就没法横向比。真实业务里输入长度分布很宽,那就取 p50 和 p95 两种长度各测一遍。

先跑一次丢掉。 第一次请求要加载 kernel、填显存缓存,会明显偏慢。这是预热。

用流式,不等待完整响应。 非流式接口拿不到 TTFT,只能拿到总时长,你就分不清是「首字慢」还是「解码慢」。

取分位不取平均。 平均值会被少数极慢的请求拉高,掩盖真实分布。线上容量规划看 p95。

扩散模型多两个变量

自回归模型的压测只看输入输出长度就行。Clover 这类块扩散模型还有两个参数会直接影响速度,必须固定住:

block_size(默认 32)。 块越大并行度越高,但超过训练时的块大小,质量会掉、速度收益也会衰减。

threshold(默认 0.95)。 这是「质量-速度」的主旋钮。0.95 意味着块内每个位置都要相当确定才填;降到 0.9,去噪轮数减少,速度上升,错误率也上升。

所以测 Clover 时,一组参数要写成 block=32, threshold=0.95 这样,单写一个 tok/s 没有意义。换参数就是换模型。

把吞吐换算成钱

这一步最有用。公式:

每百万 token 成本 = 机器时价 × (3600 ÷ 吞吐 tok/s) ÷ 1e6

举两个数:假设租一台 A100 每小时 2 美元,实测吞吐 2000 tok/s。

一小时产出:2000 × 3600 = 720 万 token

成本:2 ÷ 7.2 = 0.278 美元/百万 token

如果实测只有 500 tok/s,同样一台机器:2 ÷ 1.8 = 1.11 美元/百万 token。差 4 倍。

这个数字才有决策价值。拿它跟 API 报价比:API 输入 0.15、输出 0.60 美元每百万 token,而你自建算出 1.11,那就别自建。自建那篇的成本核算用的是同一套算法,可以对照着看。

顺带提醒:这个成本没算上闲置。自建最怕的是半夜没人用,机器照样计费。用「峰值吞吐」算出来的成本会严重低估真实成本,要按平均利用率打折。

五个坑,按命中率排

坑 1:拿厂商数字当基线。 硬件、batch、精度全不同,直接比会得出完全错误的结论。要比较就自己两台机器都跑一遍。

坑 2:只测单请求。 单请求的 tok/s 是延迟指标,不是吞吐指标。做容量规划时看着单请求数字买机器,一定买错。

坑 3:不控输出长度。 输出长度不固定,两次跑的 tok/s 能差一倍,因为短输出被 TTFT 主导。

坑 4:忽略流式与首包。 有些部署方案会先把整个结果算完再一次性返回,TTFT 等于总时长。压测时看 stream 参数有没有真的生效。

坑 5:用一次结果下结论。 GPU 有降频、有热墙,机器忙起来和空载状态差不少。至少跑三轮看波动,波动超过 15% 说明环境有问题,先查再测。

边界与下一步

这套方法测的是「你的部署在你的流量下有多快」,不是「模型有多强」。能力评估是另一件事,得看任务成功率,不能用速度代替。

另外,我上面那段脚本本机没有可用的大模型 endpoint,没有跑过端到端。逻辑是对的(流式解析、预热、分位统计都按标准做法写的),但你第一次跑建议先发 1 个请求确认能正常解析 SSE,再开并发。

下一篇换个方向,把站里逆向那条线串成一张路线图——从 Cheat Engine 找数值到 Ghidra 看伪代码,八个环节该按什么顺序走。

参考资料

目录导航
  • 三个指标,别混成一个
  • 官方数字为什么不可比
  • 工具一:用框架自带的压测脚本
  • 工具二:自己写,二十行能测准
  • 扩散模型多两个变量
  • 把吞吐换算成钱
  • 五个坑,按命中率排
  • 边界与下一步
    • 参考资料
  • 相关文章:

  • 自建 428B MoE:M3.1 Flash 权重部署与成本核算2026-10-29 11:03:49
  • 本地跑大模型工具横评:Ollama、LM Studio、vLLM 到底该选谁2026-10-29 11:03:49
  • 本地跑模型做应用:Ollama 与 vLLM 部署实战,6GB 显卡能干什么、什么时候必须换引擎2026-10-29 11:03:49
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。