尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

第12章:压测入门与吞吐延迟指标——用TaoToken统一Key跑通TTFT/TPOT观测

第12章:压测入门与吞吐延迟指标——用TaoToken统一Key跑通TTFT/TPOT观测 1. 从一次“平均延迟 2.3 秒”的投诉说起如果你正在做 LLM 推理服务大概率遇到过这种场面监控大盘上平均延迟只有 2 秒出头看起来一切正常但用户群里不断有人喊“卡死了”“等了十几秒才出第一个字”。我第一次碰到这个问题时也懵了很久后来才想明白——LLM 服务的延迟分布是双峰的平均值会把长尾问题彻底掩盖掉。传统 Web 服务看 QPS 和平均响应时间就够了因为请求处理时间相对集中。但 LLM 推理完全不是这回事一个“11 等于几”的请求可能 0.3 秒就返回了而一个“请解释量子力学基本原理”的请求要跑 20 秒。这两类请求混在一起算平均得到的数字对用户体验毫无解释力。所以这一章我们要解决的核心问题是如何用一套统一的 Key 和 API 通道把压测脚本接进去围绕吞吐和延迟建立可观测基线并且把 TTFT首 Token 时间和 TPOT每 Token 时间的采集口径彻底拆清楚。适合谁看正在做 LLM 服务部署、性能调优、容量规划的后端和算法工程师以及需要给业务方解释“为什么监控数据好看但用户觉得慢”的技术负责人。我试过用 wrk 和 ab 去压 LLM 服务结果发现它们根本不支持 SSE 流式响应所有请求发出去只能等完整响应回来TTFT 和 TPOT 完全测不出来。后来换成自建脚本 统一 API 通道才把这条链路跑通。下面我把完整过程拆开讲。2. 用 TaoToken 统一 Key 打通压测链路的前置准备在开始写压测脚本之前有一个容易被忽略但非常关键的前置问题你的压测请求到底打到哪里。很多团队的做法是本地起一个 vLLM 服务然后压测脚本直接打localhost:8000。这样做在单机验证阶段没问题但一旦你要做多模型对比、多并发梯度测试、或者把压测结果和线上服务做对齐就会遇到几个麻烦第一本地服务的模型版本和线上不一致压测数据没有参考价值第二每换一个模型就要改一次 base_url 和 api_key脚本里到处是硬编码第三本地 GPU 资源和线上不同压出来的吞吐数字没法直接用于容量规划。我的做法是用 TaoToken 作为统一的 API 通道压测脚本只认一个 Base URL 和一个 Key模型通过 Model ID 切换。这样压测脚本本身不需要关心后端是 vLLM 还是其他推理框架也不需要关心模型部署在哪台机器上。你只需要在 TaoToken 的控制台里拿到 Key然后在脚本里配置三个东西Base URL、API Key、Model ID。具体来说TaoToken 的 API 地址是https://taotoken.net/api兼容 OpenAI 的/v1/chat/completions接口。你可以在控制台里创建 API Key然后选择你要压测的模型对应的 Model ID。如果你还没有 Key可以先到官网看一下接入文档里面有完整的模型列表和对应的 Model ID 说明。这里有一个实操细节压测脚本里的base_url要写成https://taotoken.net/api/v1而不是只写到/api。因为 OpenAI SDK 会自动在 base_url 后面拼/chat/completions如果你只写到/api最终请求路径会变成/api/chat/completions少了/v1这一层会直接返回 404。这个坑我踩过排查了半小时才发现是路径拼接问题。另外压测用的 Key 建议单独创建一个不要和线上业务共用。原因很简单压测会产生大量请求如果和线上共用 Key一方面可能触发限流影响真实用户另一方面压测的 Token 消耗和线上混在一起成本核算会很难看。TaoToken 的控制台支持创建多个 Key你可以给压测单独建一个设置好额度上限压完之后直接禁用或删除。还有一点压测脚本里不要用temperature0。这个后面会详细讲但这里先提一句——固定 temperature 和 seed 会导致所有请求输出完全一致KV Cache 在 Block 级别高度复用压出来的吞吐会虚高和真实场景差距很大。建议用temperature0.7加上随机 seed确保输出多样性。3. 可复制的压测配置片段与 TTFT/TPOT 采集口径这一节是全文的核心我会给出完整的压测脚本配置片段并且把 TTFT 和 TPOT 的采集口径逐行拆开讲。你可以直接复制到自己的项目里跑。先看依赖安装pip install openai1.30.0然后创建一个llm_bench.py核心配置如下import time import random import statistics import concurrent.futures from typing import List, Dict from openai import OpenAI # 统一 API 通道配置 BASE_URL https://taotoken.net/api/v1 API_KEY sk-your-taotoken-key # 替换成你在控制台创建的 Key MODEL_ID Qwen/Qwen2.5-7B-Instruct # 替换成你要压测的 Model ID client OpenAI(base_urlBASE_URL, api_keyAPI_KEY)这三个变量就是全部的前置配置。Base URL 固定Key 从控制台拿Model ID 按你要压的模型填。换模型只需要改MODEL_ID这一行脚本其他部分完全不用动。接下来是 Prompt 池的设计。这里有一个关键点不要用同一条 Prompt 重复发 100 次。因为前缀缓存会命中Prefill 阶段的开销被大幅削减TTFT 会虚低。我的做法是准备短、中、长三组 Prompt每组至少 3 条然后在每条前面加一个唯一编号[req_{i}]强制打破前缀缓存的完全命中。SHORT_PROMPTS [ 什么是机器学习, 11等于几, 今天天气怎么样, ] MEDIUM_PROMPTS [ 请详细解释深度学习和传统机器学习的区别包括算法原理、训练方式和应用场景。, 写一篇200字左右的关于环保的短文要提到具体的数据和案例。, ] LONG_PROMPTS [ 请根据以下产品描述写一份完整的市场分析报告包括竞品对比、目标用户画像、 SWOT分析和营销建议。产品XX智能学习平板面向中小学生核心功能是AI自适应学习、 错题本自动整理、家长远程监控。定价2999元主要竞品有步步高、小天才。, ] def generate_prompts(n: int, prompt_type: str medium) - List[dict]: pool {short: SHORT_PROMPTS, medium: MEDIUM_PROMPTS, long: LONG_PROMPTS}[prompt_type] return [ {role: user, content: f[req_{i}] {pool[i % len(pool)]}} for i in range(n) ]然后是单请求测量函数这是 TTFT 和 TPOT 采集的核心def measure_single_request(messages: List[dict], max_tokens: int 200) - Dict: result { ttft_ms: None, tpot_ms: None, e2e_ms: None, output_tokens: 0, error: None, } start time.perf_counter() token_times [] try: response client.chat.completions.create( modelMODEL_ID, messagesmessages, max_tokensmax_tokens, temperature0.7, streamTrue, ) for chunk in response: if chunk.choices[0].delta.content: now time.perf_counter() token_times.append(now) if result[ttft_ms] is None: result[ttft_ms] (now - start) * 1000 end time.perf_counter() result[e2e_ms] (end - start) * 1000 result[output_tokens] len(token_times) if result[output_tokens] 2: intervals [ token_times[i] - token_times[i - 1] for i in range(1, len(token_times)) ] result[tpot_ms] statistics.mean(intervals) * 1000 elif result[output_tokens] 1: result[tpot_ms] result[ttft_ms] except Exception as e: result[e2e_ms] (time.perf_counter() - start) * 1000 result[error] str(e)[:100] return result这里把采集口径说清楚TTFT 的采集口径从client.chat.completions.create调用发起的那一刻开始计时到收到第一个包含delta.content的 chunk 为止。注意有些 chunk 的delta.content是空的比如 role 声明或结束标记这些不算首 Token。只有真正包含文本内容的第一个 chunk 才计入 TTFT。TPOT 的采集口径从第二个 Token 开始计算相邻两个 Token 到达时间间隔的平均值。公式是(最后一个Token时间 - 第一个Token时间) / (Token数 - 1)。这里我用的是逐间隔求平均效果等价。注意 TPOT 不包含首 Token 的等待时间它衡量的是 Decode 阶段的生成速度。E2E 延迟从请求发起到最后一个 Token 到达的完整时间。它约等于TTFT TPOT × (输出Token数 - 1)。然后是并发压测的主函数def run_benchmark(num_prompts100, concurrency4, prompt_typemedium, max_tokens200): prompts generate_prompts(num_prompts, prompt_type) # Warmup预热 CUDA Graph 和连接池 print(预热中...) for p in generate_prompts(5, short): measure_single_request([p], max_tokens50) results [] start_time time.perf_counter() with concurrent.futures.ThreadPoolExecutor(max_workersconcurrency) as executor: futures [ executor.submit(measure_single_request, [p], max_tokens) for p in prompts ] for future in concurrent.futures.as_completed(futures): results.append(future.result()) total_time time.perf_counter() - start_time success [r for r in results if not r[error]] ttft_list sorted([r[ttft_ms] for r in success if r[ttft_ms]]) tpot_list sorted([r[tpot_ms] for r in success if r[tpot_ms]]) e2e_list sorted([r[e2e_ms] for r in success if r[e2e_ms]]) total_output sum(r[output_tokens] for r in success) def p(arr, n): if not arr: return 0 idx int(len(arr) * n / 100) return arr[min(idx, len(arr) - 1)] stats { success: len(success), errors: len(results) - len(success), total_time_s: round(total_time, 1), throughput_rps: round(len(success) / total_time, 1), token_throughput: round(total_output / total_time, 1), ttft_mean_ms: round(statistics.mean(ttft_list), 1) if ttft_list else 0, ttft_p95_ms: round(p(ttft_list, 95), 1), ttft_p99_ms: round(p(ttft_list, 99), 1), tpot_mean_ms: round(statistics.mean(tpot_list), 1) if tpot_list else 0, tpot_p95_ms: round(p(tpot_list, 95), 1), tpot_p99_ms: round(p(tpot_list, 99), 1), e2e_mean_ms: round(statistics.mean(e2e_list), 1) if e2e_list else 0, e2e_p95_ms: round(p(e2e_list, 95), 1), e2e_p99_ms: round(p(e2e_list, 99), 1), } print(f\n{*60}) print(f并发{concurrency} | Prompt{prompt_type} | 成功{stats[success]} | 错误{stats[errors]}) print(f请求吞吐: {stats[throughput_rps]} req/s | Token吞吐: {stats[token_throughput]} tok/s) print(fTTFT 均值{stats[ttft_mean_ms]}ms P95{stats[ttft_p95_ms]}ms P99{stats[ttft_p99_ms]}ms) print(fTPOT 均值{stats[tpot_mean_ms]}ms P95{stats[tpot_p95_ms]}ms P99{stats[tpot_p99_ms]}ms) print(fE2E 均值{stats[e2e_mean_ms]}ms P95{stats[e2e_p95_ms]}ms P99{stats[e2e_p99_ms]}ms) return stats最后是主入口跑不同并发梯度if __name__ __main__: for c in [1, 4, 8, 16]: run_benchmark(num_prompts50, concurrencyc, prompt_typemedium)这套配置跑下来你会得到一组随并发变化的 TTFT、TPOT、吞吐数据。把这些数据导出成 CSV画成曲线就能看到吞吐拐点和延迟悬崖在哪里。4. 验证请求与成功结果解读配置写完之后先别急着跑全量压测。第一步是发一个单请求确认 API 通道是通的。你可以用 curl 快速验证curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-your-taotoken-key \ -H Content-Type: application/json \ -d { model: Qwen/Qwen2.5-7B-Instruct, messages: [{role: user, content: 你好}], max_tokens: 20, stream: true }如果返回的是 SSE 流式数据每一行以data:开头最后以data: [DONE]结束说明通道正常。如果返回 401说明 Key 有问题如果返回 404大概率是 base_url 路径拼错了检查是不是漏了/v1。单请求通了之后跑一次小规模压测python llm_bench.py你会看到类似这样的输出 并发4 | Promptmedium | 成功49 | 错误1 请求吞吐: 2.7 req/s | Token吞吐: 423.5 tok/s TTFT 均值312.5ms P95567.8ms P99891.2ms TPOT 均值42.3ms P9558.1ms P9972.5ms E2E 均值3534.2ms P958123.5ms P9911234.8ms这组数据怎么解读我拆开说TTFT 均值 312msP99 891ms。说明大部分请求的首 Token 在 300ms 左右到达但最慢的 1% 请求要等将近 900ms。如果你的业务对首字响应敏感比如对话场景P99 TTFT 比均值更值得关注。TPOT 均值 42msP99 72ms。每个输出 Token 平均间隔 42ms换算成生成速度大约是 24 Token/s。这个数字决定了用户看到文字“打字机效果”的流畅度。如果 TPOT 超过 100ms用户会明显感觉到卡顿。E2E 均值 3.5 秒P99 11.2 秒。E2E 的 P99 是均值的 3 倍多这就是典型的双峰分布特征。如果你只看均值 3.5 秒会觉得体验还行但 P99 的 11 秒才是那部分用户真实感受到的等待时间。吞吐 423.5 tok/s。这个数字要和你的 GPU 配置、模型大小一起看。7B 模型在单卡上跑到 400-600 tok/s 是正常范围如果明显偏低可能是并发不够或者 KV Cache 竞争不充分。跑完并发梯度之后把数据整理成表格并发请求吞吐 (req/s)Token吞吐 (tok/s)TTFT P99 (ms)TPOT P99 (ms)E2E P99 (ms)11.2180.5420.348.25200.142.7423.5891.272.511234.883.1510.21520.698.318500.4163.2528.72800.5145.632000.2从这张表能看出并发从 1 加到 8吞吐还在涨但从 8 加到 16吞吐几乎不涨了而 TTFT P99 和 TPOT P99 都在飙升。这就是吞吐拐点和延迟悬崖。拐点大概在并发 8 附近超过这个点再加并发只会让延迟变差不会提升吞吐。这个结论直接决定了你的服务应该设置多大的并发上限。5. 本篇常见错误排查压测过程中最容易遇到的几个报错我按实际出现的频率排一下。401 Unauthorized。这个最常见原因通常是 Key 写错了、Key 被禁用了、或者请求头里的Authorization格式不对。检查两点一是 Key 有没有复制完整有些控制台会截断显示二是格式是不是Bearer sk-xxx注意Bearer和 Key 之间有一个空格。如果你用的是 OpenAI SDK它会自动帮你拼Bearer你只需要传api_key参数就行。404 Not Found。这个大概率是 base_url 路径问题。OpenAI SDK 的规则是base_url/chat/completions。所以如果你写https://taotoken.net/api最终请求会打到https://taotoken.net/api/chat/completions少了/v1。正确的写法是https://taotoken.net/api/v1。这个错误在自建脚本里特别容易犯因为很多人习惯把 base_url 写到域名根路径。local proxy failed / connection refused。这个报错说明你的脚本尝试走本地代理但代理没启动或者端口不对。检查环境变量HTTP_PROXY和HTTPS_PROXY有没有被设置。如果你不需要代理把它们清空unset HTTP_PROXY unset HTTPS_PROXY然后在脚本里显式指定不走代理import os os.environ[NO_PROXY] taotoken.netreading choices 相关报错。这个通常出现在流式响应解析阶段报错信息类似KeyError: choices或者IndexError: list index out of range。原因是有些 chunk 的choices数组是空的或者delta里没有content字段。修复方法是加一层判断for chunk in response: if not chunk.choices: continue delta chunk.choices[0].delta if delta and delta.content: # 处理 Token passOAuth 相关报错。如果你用的是某些需要 OAuth 认证的客户端可能会遇到 token 过期的问题。TaoToken 的 API Key 是长期有效的不需要 OAuth 刷新。如果你在脚本里看到了 OAuth 相关的报错检查是不是误用了其他认证方式或者 SDK 版本太旧。升级到最新版 OpenAI SDK 通常能解决。压测显示零错误但生产有 500。这个不是报错但比报错更危险。原因是压测的 Prompt 都是“友好”输入不包含超长文本、特殊 Unicode 字符、空消息等异常 case。解决办法是在压测脚本里加入 10% 的脏数据比如超长输入、空 content、不可见字符。压测不仅要测性能也要测鲁棒性。并发 16 时吞吐反而低于并发 1。这个现象通常是因为用了temperature0和固定 seed所有请求输出完全一致KV Cache 在 Block 级别高度复用导致测量结果失真。把temperature改成 0.7加上随机 seed重新跑一遍就能看到正常的吞吐曲线。6. 把压测基线接入你的日常流程跑通一次压测不难难的是把压测变成日常流程的一部分。我的做法是每次模型版本更新、推理框架升级、或者 GPU 驱动变更之后都跑一遍标准压测把 TTFT、TPOT、吞吐的 P95/P99 和上一次基线做对比。如果 P99 TTFT 劣化超过 20%就触发告警在灰度阶段就拦住性能回退。具体操作上你可以把压测脚本封装成一个命令行工具支持传入并发数、Prompt 类型、请求数等参数然后把结果输出成 JSON。再写一个对比脚本自动计算和基线的差异百分比。这样每次跑完压测你只需要看一眼对比报告就知道这次变更有没有引入性能问题。如果你还没有 TaoToken 的 Key可以先到控制台创建一个然后按照上面的配置把脚本跑起来。接入文档里有完整的 Model ID 列表和参数说明遇到路径或认证问题可以先查文档。压测用的 Key 建议单独管理设置好额度上限避免误用。最后留一个实操建议压测至少跑 5 分钟收集 1000 个以上样本。短时间压测的 P99 波动很大跑 30 秒得到的 P99 和跑 5 分钟得到的可能差一倍。样本量不够结论就不可靠。把压测时间拉长让系统进入稳态这时候采集到的 TTFT 和 TPOT 才是真正有参考价值的数据。
返回列表