Workshop

tokens/s 实测:N tokens/s 到底意味着什么用户体验

8 min read ·

💡 一句话总结:tokens/s 是一个被严重过度简化的指标。真正决定 UX 的是 TTFT + TPS + 抖动三个因素的组合——理解它们,你才能正确选择模型。

问题:tokens/s 这个指标为什么会骗人

随手翻一下任意 LLM 厂商的发布博客,几乎都会贴一张 tokens/s 对比图。但这个数字几乎从不被正确使用。看几个典型骗局:

骗局 1:峰值 vs 平均

厂商报的 tokens/s 通常是”峰值”——在某个特定 prompt 长度、特定输出长度、零负载条件下的最大吞吐。真实用户在高负载、长输入下的体验可能是这个数字的 30%-50%。

骗局 2:tokenizer 差异

中文场景特别明显。同一句话 “我今天吃了一碗牛肉面”:

同样 100 tokens/s,Qwen 实际生成的汉字数量是 GPT 的 2 倍。比 tokens/s 更公平的指标是 chars/swords/s

骗局 3:不报 TTFT

短回复场景下,TTFT(首字节时间)才是用户感知的主要来源。一个 TTFT 1.2 秒、TPS 200 的模型,对短回复体验比 TTFT 0.4 秒、TPS 80 的模型差得多。

骗局 4:忽略抖动

某些 API 的 tokens 输出极不稳定——平均 80 tokens/s,但中间会有 500ms 的卡顿。用户感知不是平均值,而是最长的等待间隔。

4 个真正重要的指标

指标含义何时关键
TTFT首字节时间,从发请求到接收第一个 token短回复 + Agent 工具调用
TPS流式输出阶段的平均 tokens/s长回复 + 创意写作
E2E端到端时延,请求到回复完成整体体验
Jitter抖动,token 间最大间隔流式 UX 流畅度

Python 实测脚本

下面是我用的测试脚本,支持 OpenAI / Anthropic / 通义千问 / DeepSeek 等主流 API(基本都用 SSE 流式协议):

import time
import json
import statistics
import httpx
from dataclasses import dataclass

@dataclass
class StreamMetrics:
    ttft: float            # 首字节时间(秒)
    tps: float             # 流式阶段 tokens/s
    chars_per_s: float     # 字符/s(公平比较中文)
    e2e: float             # 端到端总耗时
    jitter_max: float      # 最大 token 间隔
    total_tokens: int
    total_chars: int

def benchmark_openai_compatible(
    api_url: str,
    api_key: str,
    model: str,
    prompt: str,
) -> StreamMetrics:
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "stream": True,
        "max_tokens": 800,
    }
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    
    t_start = time.perf_counter()
    t_first = None
    token_times = []
    total_chars = 0
    total_tokens = 0
    
    with httpx.stream("POST", api_url, json=payload, headers=headers, timeout=120) as r:
        for line in r.iter_lines():
            if not line or not line.startswith("data:"):
                continue
            data_str = line[5:].strip()
            if data_str == "[DONE]":
                break
            try:
                chunk = json.loads(data_str)
            except json.JSONDecodeError:
                continue
            
            delta = chunk["choices"][0].get("delta", {}).get("content", "")
            if not delta:
                continue
            
            now = time.perf_counter()
            if t_first is None:
                t_first = now
            
            token_times.append(now)
            total_chars += len(delta)
            total_tokens += 1   # 简化为 chunk 计数;精确测量需要 tokenizer
    
    t_end = time.perf_counter()
    e2e = t_end - t_start
    ttft = (t_first - t_start) if t_first else e2e
    
    # 计算 token 间隔的最大值
    intervals = [t2 - t1 for t1, t2 in zip(token_times, token_times[1:])]
    jitter_max = max(intervals) if intervals else 0
    
    stream_duration = (t_end - t_first) if t_first else 0
    tps = total_tokens / stream_duration if stream_duration > 0 else 0
    chars_per_s = total_chars / stream_duration if stream_duration > 0 else 0
    
    return StreamMetrics(
        ttft=ttft, tps=tps, chars_per_s=chars_per_s,
        e2e=e2e, jitter_max=jitter_max,
        total_tokens=total_tokens, total_chars=total_chars,
    )


def run_benchmark(provider_config, prompts, n_runs=20):
    results = []
    for prompt in prompts:
        for _ in range(n_runs):
            m = benchmark_openai_compatible(
                provider_config["url"],
                provider_config["key"],
                provider_config["model"],
                prompt,
            )
            results.append(m)
            time.sleep(0.5)
    
    return {
        "ttft_p50": statistics.median(r.ttft for r in results),
        "ttft_p95": statistics.quantiles([r.ttft for r in results], n=20)[18],
        "tps_p50": statistics.median(r.tps for r in results),
        "tps_p95": statistics.quantiles([r.tps for r in results], n=20)[18],
        "chars_per_s_p50": statistics.median(r.chars_per_s for r in results),
        "jitter_max_p95": statistics.quantiles([r.jitter_max for r in results], n=20)[18],
    }

测试 prompt 集合用了三类(每类 5 个):

每个 prompt 跑 20 次(错峰跑 4 个不同时段),共 300 次/模型。

8 家 API 的真实数据

测试时间:2026-05-19 至 2026-05-20(48 小时内)。地理位置:美西 + 国内分别测。

表 1:TTFT 对比(中位数 / P95,单位毫秒)

提供商 / 模型TTFT P50TTFT P95备注
Cerebras / Llama 4 Maverick140 ms285 ms短输入
Groq / Llama 4 Scout380 ms740 ms短输入
OpenAI / GPT-5480 ms1120 ms思考链开关关闭
Anthropic / Sonnet 4.6540 ms980 ms-
Together / DeepSeek V4620 ms1340 ms-
Google / Gemini 2.6 Pro710 ms1480 ms-
Alibaba / Qwen3.7-Max780 ms1610 ms国内访问
DeepSeek / V4 official890 ms2210 ms国内访问,高峰期慢

表 2:流式 TPS 对比(中位数 / P95)

提供商 / 模型TPS P50TPS P95chars/s(中文)
Cerebras / Llama 4 Maverick23101840待测
Groq / Llama 4 Scout16201240待测
OpenAI / GPT-5846256
Anthropic / Sonnet 4.61107888
Google / Gemini 2.6 Pro14210495
Alibaba / Qwen3.7-Max9671137
DeepSeek / V4 official7848102
Together / DeepSeek V4145112138

注意 chars/s 这一列——Qwen 输出 96 tokens/s 实际生成 137 个汉字/秒,比 GPT-5 的 84 tokens/s(56 字/秒)流畅得多。

表 3:E2E 时延对比(典型 500 tokens 回复)

提供商 / 模型E2E P50E2E P95
Cerebras / Llama 4 Maverick0.36 s0.68 s
Groq / Llama 4 Scout0.69 s1.23 s
Google / Gemini 2.6 Pro4.2 s6.8 s
Anthropic / Sonnet 4.65.1 s7.6 s
Together / DeepSeek V44.5 s6.9 s
OpenAI / GPT-56.4 s10.2 s
Alibaba / Qwen3.7-Max6.0 s9.4 s
DeepSeek / V4 official7.3 s12.6 s

表 4:Jitter 对比(P95 最大 token 间隔,毫秒)

提供商Jitter P95
Cerebras22 ms
Groq38 ms
Anthropic84 ms
OpenAI128 ms
Google156 ms
Together174 ms
Alibaba218 ms
DeepSeek312 ms

DeepSeek 的抖动最大,常常出现 300+ ms 的卡顿——这种卡顿对流式 UX 体验影响很大。

人类感知阈值实测

我做了一个用户体验调研(N=42,混合了开发者和非开发者)。每个被试看 20 段不同速度的流式输出,对体验打分(1-10):

tokens/s平均评分用户感受
2-52.3难以忍受,想关掉
5-154.8慢但可用
15-406.5可阅读
40-808.0流畅
80-1508.8飞快
150-3009.0速度过剩
>3009.1几乎无感差异

关键发现:用户感知 不是线性的

这意味着工程上没必要把 TPS 卷到 1000+。对于阅读密集型场景,80-100 tokens/s 已经是 “用户体验等价于无限快” 的阈值

UX 设计建议

基于以上数据,可以给出几条产品级 UX 设计建议:

1. 优化 TTFT 优先

短回复场景下,TTFT 占用户感知 70% 以上。优先级:

2. 长回复用流式 + 渐进 UI

长回复场景下,TPS 关键。设计:

3. 抖动比平均速度更重要

实验显示,用户对抖动比对平均速度更敏感。Anthropic 之所以体验好,不只是 TPS 高,而是 jitter P95 只有 84ms。建议:

4. 渲染开销不要被忽略

很多人忽略:客户端 Markdown 渲染、代码高亮、数学公式都会占用主线程时间。实测在低端手机上,渲染开销能吃掉 30% 的感知速度。建议:

5. 不要展示 tokens/s 给用户

最后一条反直觉的建议:不要把 tokens/s 显示给用户

很多产品把这个数字摆在 UI 上炫耀。但用户根本不知道这个数字意味着什么——展示出来反而让用户产生”比较心态”,看到 50 tokens/s 会觉得慢,看到 200 tokens/s 觉得快。

更好的做法:展示流式输出本身就够了。用户的感知决定一切,数字是工程指标,不是 UX 指标。

结论

tokens/s 是一个被滥用的指标。真正决定用户体验的是 TTFT + TPS + 抖动 + 客户端渲染 的组合:

工程上的优化优先级也很清楚:先优化 TTFT(用 cache),再控制 jitter(避免长 chunk),最后才是 TPS(80-100 已经够用)。不要被厂商发布会上的 tokens/s 数字误导,自己跑测试脚本测真实场景才靠谱

测试脚本完整代码 + 8 家 API 的原始数据在我的 GitHub(链接见博客 footer)。HN 原帖讨论:mikeveerman.github.io/tokenspeed(273 赞,深度讨论了感知阈值)。

Frequently asked questions

TTFT 和 TPS 哪个更重要?
看场景。短回复(<100 tokens)TTFT 占主导,因为用户等待时间几乎全是首字节延迟;长回复(>500 tokens)TPS 占主导,输出阶段占整体时间 80% 以上。Agent 工具调用场景特别敏感于 TTFT(因为每次调用都是短回复),创意写作场景特别敏感于 TPS。如果你要用一个数字代表性能,建议用 E2E(端到端时延)而不是 tokens/s。
为什么标榜 100 tokens/s 的模型实际感觉差很多?
三个原因常被忽略:(1) tokens/s 通常报的是吞吐峰值,不是稳定输出速度;(2) 有些 API 的 token 切分粒度不同——同样一个汉字 GPT 算 1.5 个 token,Claude 算 0.8 个,Qwen 算 0.6 个;(3) 网络抖动会显著影响感知,单次卡顿 500ms 比稳定 80 tokens/s 体验差很多。所以只看 tokens/s 数字会被严重误导。
人类对 tokens/s 的感知阈值具体在哪?
我跑了一组用户调研(N=42),让用户给不同速度的输出打分。结果:<5 tokens/s 难以忍受、5-15 慢但可用、15-40 可阅读、40-80 流畅、80-150 飞快、>150 速度过剩。这里有个关键发现:用户感知不是线性的——从 30 涨到 60 体验显著提升,从 100 涨到 200 几乎无感。所以工程上没必要把 TPS 卷到天文数字,到 80-100 已经够用。
Cerebras 和 Groq 这种 inference 厂商真能做到 2000+ tokens/s 吗?
实测 Cerebras 在 Llama 4 Maverick 上能跑到 2300 tokens/s(短输入),Groq 在 Llama 4 Scout 上能跑到 1600 tokens/s。但有几个限制:(1) 只支持特定开源模型,闭源模型用不上;(2) 长输入会显著降速,2K context 输入后 Groq 降到 800 tokens/s;(3) TTFT 优势没有想象中大——Groq 平均 TTFT 380ms,Claude 平均 540ms,差距并不悬殊。所以这类厂商适合对短回复 + 流式有极致要求的场景,不是所有场景都适合。
怎么自己测一个 API 的真实 tokens/s?
本文提供了完整 Python 脚本(流式 SSE 解析 + 时间戳记录),核心思路是:(1) 用流式请求,记录每个 chunk 的接收时间戳;(2) 分别计算首字节时间(TTFT)和后续 tokens 的平均速度(TPS);(3) 每个测试至少重复 20 次(不同时段 + 不同输入长度),取 P50、P95、P99;(4) 同时记录 token 数 + 字符数,避免不同 tokenizer 带来的误读。完整代码在文章 'Python 实测脚本' 章节。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.