1. 为什么你的 GPU 集群总在“加卡”和“降延迟”之间反复横跳
如果你正在维护一套大模型推理服务,大概率遇到过这种场景:业务方说“首 Token 太慢”,你加了两张卡,TTFT 从 2.1 秒降到 1.8 秒,但 QPS 一上来又打回原形。问题出在哪?不是卡不够,而是你手里没有一把能丈量“延迟预算”的尺子。
模型推理服务的 SLO 体系,本质上是一套把用户体验翻译成硬件资源的数学语言。它要回答三个问题:用户能忍受多长的首 Token 等待(TTFT)?每个 Token 之间的间隔(TPOT)多少才算流畅?在给定并发下,我需要多少张 GPU 才能同时守住这两条线?没有这套语言,“加多少卡够用”就只能靠猜,而猜出来的容量水位,要么浪费预算,要么在流量高峰时崩盘。
这篇文章面向正在做推理服务容量规划的工程师,我会把 SLO 指标定义、延迟预算拆解表、压测脚本、以及通过统一 API 通道做多模型基准验证的完整流程拆开讲。你不需要先有一套完美的监控体系,跟着步骤走,就能从零搭出一套能指导扩容决策的 SLO 框架。核心检索词就三个:SLO、延迟预算、容量规划——它们分别对应“定目标”“拆阶段”“算卡数”三个动作。
我试过在 70B 模型上直接用“端到端 P95 < 5s”做 SLO,结果发现排队延迟和 Decode 延迟混在一起,根本定位不到瓶颈。后来把延迟预算按请求生命周期拆成网络、排队、Pre-fill、Decode 四段,每一段单独设阈值,扩容决策才变得可解释。下面从场景问题开始,一步步把这条链路走通。
2. 推理 SLO 分阶段定义与延迟预算拆解表
2.1 一个请求到底经过了哪些延迟阶段
大模型推理请求和传统微服务最大的区别在于:它的延迟不是单一环节产生的,而是随并发量和序列长度动态漂移的。一个请求从发出到最后一个 Token 返回,至少穿过五个阶段:
网络传输与负载均衡:请求从客户端到推理网关的往返时间,通常受机房位置和 LB 策略影响,交互式场景下 P99 控制在 20ms 以内比较合理。
请求排队:请求进入推理引擎后,如果当前并发已满,会进入调度队列等待。这是最容易被忽视、也最容易在流量高峰时爆炸的一段。vLLM 的 continuous batching 虽然能缓解,但队列延迟仍然存在。
Pre-fill 阶段:模型处理完整输入 prompt、生成第一个 Token 的过程。这段直接决定 TTFT,是用户感知最强的延迟。
Decode 阶段:从第二个 Token 开始,每个 Token 的生成间隔,即 TPOT。它决定“流式输出”是否流畅。
流式返回:Token 通过 SSE 逐块推送到客户端,网络抖动可能导致到达时间不均匀。
把这五段拆开之后,延迟预算就变成了一张可分配的表。下面这张表是我在实际项目中用的模板,你可以直接改成自己业务的数值:
| 阶段 | 指标 | 交互式聊天 SLO | 代码补全 SLO | 批量分析 SLO |
|---|---|---|---|---|
| 网络 RTT | P99 | < 20ms | < 10ms | < 100ms |
| 排队 Queue Time | P99 | < 1s | < 200ms | < 30s |
| Pre-fill | TTFT P99 | < 2s | < 500ms | < 10s |
| Decode | TPOT P95 | < 40ms | < 20ms | < 100ms |
| 端到端 | Total P95 | < 5s | < 1s | < 60s |
注意,这张表里的数值不是拍脑袋来的。交互式聊天的 TTFT P99 < 2s 来自一个经验:用户在输入问题后,如果 2 秒内看不到任何输出,会开始怀疑服务是否卡死。而代码补全场景对 TTFT 更敏感,因为补全是在用户打字间隙触发的,超过 500ms 就会打断输入节奏。
2.2 延迟预算如何反推并发满足系数
有了分阶段 SLO,下一步是把它们合成一个能用于容量规划的公式。核心逻辑是 Little's Law:系统中的平均并发数等于到达率乘以平均驻留时间。
平均驻留时间 = TTFT 中位数 + 平均输出 Token 数 × TPOT 中位数
假设你的交互式聊天场景,TTFT 中位数 0.3s,平均输出 256 个 Token,TPOT 中位数 0.025s,那么单个请求平均驻留时间约 6.7s。如果目标 QPS 是 10,平均并发就是 67。
但这只是平均值。要守住 P99 TTFT < 2s,你还得留出排队余量。工程上常用的做法是引入一个并发满足系数:
并发满足系数 = 目标 QPS × P95 TTFT × (1 + Headroom)
Headroom 一般取 0.3 到 0.5,用来吸收流量突发和长尾请求。这个系数直接决定你需要多少张卡来承载并发。没有 P95 TTFT 这个 SLO,系数就无从算起,容量规划也就失去了锚点。
2.3 错误预算:SLO 不是“永远达标”
SLO 的另一个关键概念是错误预算。如果你定义月度可用性 99.9%,那么一个月允许的不可用时间约 43 分钟,或者允许 0.1% 的请求超时。错误预算的作用是给团队一个“可以犯错的空间”,而不是追求零违规。
在推理服务里,错误预算通常按请求数计算:
月度 Error Budget = (1 - SLO%) × 总请求数
比如月总请求 1000 万,SLO 99.9%,那么允许 1 万次超时。一旦某周就用掉了 80% 的预算,就应该触发告警,暂停非必要的发布,优先排查延迟劣化原因。这套机制比单纯看“平均延迟”更能反映真实的服务健康度。
3. 用 TaoToken 统一 Key 接入多模型做基准验证
3.1 为什么基准验证需要统一通道
做容量规划之前,你得先知道不同模型在你的硬件上的真实表现。但现实是,你可能同时要对比 7B、14B、70B 甚至不同厂商的模型,如果每个模型都单独配一套 Key 和 endpoint,压测脚本会变得难以维护。
TaoToken 提供的是一个统一 Key 和 API 通道,兼容 OpenAI 风格的接口。你可以用同一个 Key 调用不同模型,压测脚本只需要改 model 字段,不用改鉴权逻辑。这对做多模型基准验证非常省事。
接入信息如下:
- 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API Base URL:https://taotoken.net/api
- 模型对话入口:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/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
3.2 可复制的配置片段
如果你用的是 OpenAI SDK 或兼容的客户端,配置只需要三件套:Base URL、API Key、Model ID。下面是一个 Python 的配置示例,路径和字段名保持和官方一致:
# config.py import os TAOTOKEN_BASE_URL = "https://taotoken.net/api" TAOTOKEN_API_KEY = os.environ.get("TAOTOKEN_API_KEY", "sk-your-key-here") # 用于基准验证的模型列表 MODELS = [ "gpt-4o-mini", "claude-3-5-sonnet", "deepseek-chat", ] # 压测参数 BENCH_CONFIG = { "target_qps": 10, "duration_sec": 60, "max_tokens": 256, "temperature": 0.7, }如果你用的是 Cline 或 Claude Code 这类编码工具,配置方式类似。以 Cline 的 MCP 配置为例,在 settings 里填入:
{ "mcpServers": { "taotoken": { "command": "npx", "args": ["-y", "@taotoken/mcp-server"], "env": { "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_API_KEY": "sk-your-key-here", "TAOTOKEN_MODEL": "claude-3-5-sonnet" } } } }注意,Base URL 后面不要加/v1,TaoToken 的 API 路径已经内置了兼容层。如果你用的是 Codex 的 auth.json,格式如下:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-your-key-here", "model": "gpt-4o-mini" }这三件套(Base URL + Key + Model ID)是接入任何兼容 OpenAI 接口的工具的通用配置。配好之后,你就可以用同一个 Key 跑不同模型的基准测试了。
3.3 压测脚本:测量 TTFT 和 TPOT
下面这个脚本用 asyncio 并发发请求,分别记录 TTFT 和每个 Token 的到达时间。它不依赖特定的压测框架,直接跑就能出数据:
# bench_ttft.py import asyncio import time import aiohttp import statistics from config import TAOTOKEN_BASE_URL, TAOTOKEN_API_KEY, MODELS, BENCH_CONFIG async def single_request(session, model, prompt): url = f"{TAOTOKEN_BASE_URL}/chat/completions" headers = { "Authorization": f"Bearer {TAOTOKEN_API_KEY}", "Content-Type": "application/json", } payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": BENCH_CONFIG["max_tokens"], "temperature": BENCH_CONFIG["temperature"], "stream": True, } start = time.perf_counter() ttft = None token_times = [] async with session.post(url, json=payload, headers=headers) as resp: async for line in resp.content: if not line: continue now = time.perf_counter() if ttft is None: ttft = now - start token_times.append(now) tpot = None if len(token_times) > 1: intervals = [token_times[i+1] - token_times[i] for i in range(len(token_times)-1)] tpot = statistics.median(intervals) return {"ttft": ttft, "tpot": tpot, "total": time.perf_counter() - start} async def bench_model(model): prompt = "用一句话解释什么是延迟预算。" async with aiohttp.ClientSession() as session: tasks = [single_request(session, model, prompt) for _ in range(BENCH_CONFIG["target_qps"])] results = await asyncio.gather(*tasks, return_exceptions=True) ttfts = [r["ttft"] for r in results if isinstance(r, dict) and r["ttft"]] tpots = [r["tpot"] for r in results if isinstance(r, dict) and r["tpot"]] print(f"模型: {model}") print(f" TTFT P50: {statistics.median(ttfts):.3f}s") print(f" TTFT P99: {sorted(ttfts)[int(len(ttfts)*0.99)]:.3f}s") print(f" TPOT P50: {statistics.median(tpots):.4f}s") async def main(): for model in MODELS: await bench_model(model) if __name__ == "__main__": asyncio.run(main())跑之前先设置环境变量:
export TAOTOKEN_API_KEY="sk-your-key-here" python bench_ttft.py这个脚本会输出每个模型的 TTFT P50、P99 和 TPOT P50。你可以把 target_qps 逐步调高,观察 TTFT 在哪个 QPS 点开始明显上升,那个点就是当前副本数的容量水位。
4. 从 SLO 逆推 GPU 数量的容量规划函数
4.1 显存约束下的单卡最大并发
容量规划的第一步是算清单卡能扛多少并发。这取决于模型权重占用的显存和每个请求的 KV Cache 大小。
以 70B 模型、BF16 精度、TP=8 为例:
模型权重显存 = 70e9 × 2 bytes / 1024^3 ≈ 140 GB
单卡分摊 = 140 / 8 = 17.5 GB
H100 80GB 扣除 CUDA Context 和运行时开销,可用约 72 GB。剩余给 KV Cache 的空间约 54.5 GB。如果每个请求 8K 序列的 KV Cache 约 2.6 GB,那么单卡最大并发约 20。
这个数字是显存硬约束,超过就会 OOM。但它不代表你能跑满 20 并发还能守住 TTFT SLO,因为并发一高,排队延迟就会上升。
4.2 用 Little's Law 算平均并发需求
平均并发 = 目标 QPS × 平均驻留时间
平均驻留时间 = TTFT 中位数 + 平均输出 Token 数 × TPOT 中位数
假设 TTFT 中位数 0.3s,输出 256 Token,TPOT 0.025s,驻留时间约 6.7s。目标 QPS 20,平均并发就是 134。
如果单卡最大并发 20,134 / 20 ≈ 7 张卡。但这是平均值,要守住 P99 TTFT,还得留余量。工程上取 0.7 的安全系数,即实际可用并发按单卡 14 算,134 / 14 ≈ 10 张卡。再考虑 TP=8 的约束,最终取 16 张卡比较稳妥。
4.3 可复用的容量规划函数
把上面的逻辑写成一个函数,输入模型参数和 SLO 目标,输出建议 GPU 数:
# capacity.py def capacity_planning( model_params: int, dtype_bytes: int, kv_cache_per_req: float, target_qps: int, ttft_median: float, tpot_median: float, avg_output_tokens: int, tp_size: int = 8, gpu_mem: int = 80, safety_factor: float = 0.7, ) -> dict: weight_memory = model_params * dtype_bytes / (1024**3) usable_mem = gpu_mem * 0.90 mem_after_weight = usable_mem - (weight_memory / tp_size) max_seqs_per_gpu = int(mem_after_weight / kv_cache_per_req) avg_residence = ttft_median + avg_output_tokens * tpot_median avg_concurrency = target_qps * avg_residence effective_seqs = max_seqs_per_gpu * safety_factor min_gpus = int(avg_concurrency / effective_seqs) + 1 return { "weight_memory_gb": round(weight_memory, 1), "max_seqs_per_gpu": max_seqs_per_gpu, "avg_concurrency": round(avg_concurrency, 1), "min_gpu_count": max(min_gpus, tp_size), "recommended_gpu_count": max(min_gpus, tp_size) + 1, } # 示例 result = capacity_planning( model_params=70_000_000_000, dtype_bytes=2, kv_cache_per_req=2.6, target_qps=20, ttft_median=0.3, tpot_median=0.025, avg_output_tokens=256, ) print(result)输出大致是:weight_memory_gb 140.0,max_seqs_per_gpu 20,avg_concurrency 134.0,min_gpu_count 10,recommended_gpu_count 11。再结合 TP=8,实际取 16 卡。
这个函数的价值在于,当你调整 SLO 目标(比如把 TTFT 中位数从 0.3 改成 0.5),它能立刻告诉你 GPU 数量会怎么变。容量规划从“拍脑袋”变成了“改参数看结果”。
5. 常见报错与排查:401、local proxy failed、reading choices、OAuth
5.1 401 Unauthorized:Key 没传对
这是最常见的报错。如果你看到:
{"error": {"message": "Invalid API key", "code": 401}}先检查三件事:Key 是否复制完整(有没有多余空格)、环境变量是否生效、请求头格式是否正确。TaoToken 的鉴权头是Authorization: Bearer sk-xxx,注意 Bearer 后面有一个空格。
如果你用的是 Cline 或 Claude Code,检查 settings 里的TAOTOKEN_API_KEY是否和 API Keys 页面生成的一致。Key 泄露后可以在控制台重新生成,旧 Key 会立即失效。
5.2 local proxy failed:本地代理配置冲突
这个报错通常出现在你本地开了代理工具,但代理规则没有放行taotoken.net。表现是连接超时或 SSL 握手失败:
Error: local proxy failed: connection refused排查方法是先确认你的网络环境能直接访问 API 地址。如果你在公司内网,可能需要配置 NO_PROXY 环境变量:
export NO_PROXY="taotoken.net"注意,这里说的是本地网络配置,不涉及任何跨境网络工具。如果你在容器里跑压测脚本,检查容器的 DNS 和出网策略是否放行了 443 端口。
5.3 reading choices:响应格式不匹配
这个报错一般出现在你用非流式请求但代码按流式解析,或者反过来:
KeyError: 'choices'检查你的请求体里stream字段是否和解析逻辑一致。流式请求返回的是 SSE 格式,每行以data:开头,最后以data: [DONE]结束。非流式请求返回的是完整 JSON,choices在顶层。
如果你用的是 OpenAI SDK,确认base_url设置正确,不要手动拼接/v1/chat/completions,SDK 会自动处理路径。
5.4 OAuth 相关报错:认证方式不匹配
如果你在 Claude Code 或类似工具里看到 OAuth 报错,通常是因为工具默认走 OAuth 流程,但你配置的是 API Key 模式。解决方法是在工具的认证设置里选择“API Key”而不是“OAuth”,然后填入 TaoToken 的 Key。
以 Claude Code 为例,在 settings 里把认证方式改为 API Key,Base URL 填https://taotoken.net/api,Model ID 填你需要的模型。三件套配齐后,OAuth 报错就会消失。
5.5 压测时 TTFT 突然飙升
如果你在压测中发现 TTFT 从 0.3s 突然跳到 3s,先别急着加卡。检查两个地方:一是 GPU 温度是否过高导致降频,H100 在高负载下 SM 频率可能从 1980 MHz 降到 1830 MHz,直接影响 TPOT 和 TTFT;二是队列延迟是否已经成为瓶颈,用 vLLM 的 metrics 看queue_time指标,如果它占了 TTFT 的 80% 以上,说明瓶颈在排队而不是计算。
这时候加卡确实有效,但如果是单请求 Forward Pass 太慢,加卡反而可能因为 All-Reduce 通信开销让延迟更差。判断方法很简单:如果单请求在低并发下 TTFT 就已经超标,那是模型或 Kernel 的问题,不是容量问题。
6. 用实测数据校准容量水位并持续迭代
容量规划不是一次性的计算,而是一个持续校准的过程。你从公式里算出的 GPU 数量只是起点,真实水位要靠压测数据来修正。
具体做法是:先用容量规划函数算出建议卡数,然后在这个卡数下跑阶梯压测。从目标 QPS 的 50% 开始,每 2 分钟增加 20%,观察 TTFT P99 和 TPOT P95 的变化。当 TTFT P99 超过 SLO 阈值的 80% 时,记录当前的 QPS,这就是你的安全水位。如果安全水位低于目标 QPS,说明需要加卡;如果高于目标 QPS 很多,说明可以适当缩容。
我踩过的一个坑是:在冷启动状态下跑压测,GPU 温度还没上来,数据很好看。但持续跑 30 分钟后,温度稳定在高位,TPOT 上升了约 8%,TTFT P99 也跟着涨。所以压测至少要跑 30 分钟以上,让 GPU 进入热稳态,数据才有参考价值。
另一个经验是,不同模型的容量水位差异很大。7B 模型可能单卡就能扛 50 QPS,70B 模型在 TP=8 下可能 20 QPS 就到顶。用 TaoToken 的统一通道做多模型基准验证,可以快速摸清每个模型的真实水位,避免用同一个容量假设套所有模型。
最后,把 SLO 监控和错误预算追踪接进你的告警系统。当月度错误预算消耗超过 50% 时触发预警,超过 80% 时冻结非必要变更。这样 SLO 就不只是一张表,而是真正驱动容量决策和发布节奏的工程工具。