1. 为什么 wrk 压不动 LLM 推理服务
如果你之前用 wrk、hey、vegeta 压过普通 Web 接口,第一次拿它们去压 LLM 推理服务,大概率会得到一个"看起来还行"的 QPS 数字,然后被线上真实流量打脸。原因不复杂:通用 HTTP 压测工具理解的世界是"请求发出→响应返回",而 LLM 推理服务的响应是一条 SSE 流,是一串 Token 逐个吐出来的过程。这两者的时间结构完全不是一回事。
具体来说有三个根本性不匹配。第一是请求结构不匹配,LLM 的流式响应不是单个 HTTP Response,而是 Token 序列,通用工具只统计"响应完成"的总时间,无法区分 TTFT(首 Token 延迟)和 TPOT(每 Token 输出延迟),而这两个指标恰恰是用户体验的核心。第二是负载模式单一,wrk 用固定连接池发相同请求,但真实 LLM 流量里 Prompt 长度是长尾分布,P50 可能只有 512 Token,P99 却能到 8K,固定长度压测会严重低估 Pre-fill 阶段的延迟。第三是缺少 Token 粒度的延迟分析,推理的性能瓶颈在逐 Token 的生成速度,传统 QPS 视角根本捕获不到"单次请求的生成流畅度"。
所以我们需要的是一个专门为 LLM 推理设计的压测框架,它要能模拟变化的 Prompt 长度分布、记录分阶段延迟(TTFT + TPOT + Token Interval)、并画出从 1 到 max_num_seqs 的并发负载曲线。这篇就围绕负载建模、压测执行、结果统计三段链路,给你一套可复制的工程化方案,同时把 TaoToken 统一 Key 接入配置也一并说清楚,让团队能快速搭出可复现的推理压测基线。
2. TaoToken 统一 Key 接入:压测通道的前置准备
压测框架要跑起来,第一件事是有一个稳定的、可编程调用的模型通道。自己本地起 vLLM 当然可以,但很多团队的实际场景是:要压的是线上或准线上的推理服务,或者要在多个模型之间做对比基线。这时候用 TaoToken 的统一 Key 和 API 通道会省掉大量对接成本——一套 Key、一个兼容 OpenAI 协议的入口,压测脚本不用为每个后端改一遍请求格式。
TaoToken 在这里扮演的角色是统一的模型调用入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api 。它的接口兼容 OpenAI 的/v1/chat/completions,支持stream: true的 SSE 流式返回,这正是我们压测框架需要的——因为 TTFT 和 TPOT 的测量完全依赖流式响应。
你需要先拿到一个 API Key。进入控制台的 API Keys 页面创建一个,建议给压测单独建一个 Key,方便后续按 Key 维度统计用量和排查问题。创建入口在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。拿到 Key 之后,先别急着写压测代码,用最简单的 curl 确认通道是通的:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "用一句话解释什么是 TTFT"}], "stream": true, "max_tokens": 64 }'如果能看到一行行data: {...}的 SSE 输出,最后以data: [DONE]结束,说明通道没问题。这里有个细节要注意:压测时模型选择会直接影响基线数字,建议固定一个模型做纵向对比,不要今天压 A 明天压 B 然后比吞吐。如果你只是想先验证模型对话行为是否符合预期,可以到模型对话页面手动试几条,确认流式输出正常再进压测环节:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
对于需要长期跑压测、做容量回归的团队,用按量计费的 Key 可能会让成本不太好控,这时候可以看看 Coding Plan 这类套餐,适合高频、长期的调用场景:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有完整的参数说明和错误码,排障时会用到。
3. 可复制的 config.toml 骨架与负载建模
压测框架最容易失控的地方是配置散落在代码里,改一个并发数要重新编译。所以第一步是把所有可调参数抽到config.toml。下面这份骨架覆盖了负载建模、执行控制和结果输出三块,你可以直接拿去改。
# config.toml —— LLM 推理压测配置骨架 [target] # TaoToken 统一入口,压测脚本只认这一个 base_url base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" # 从环境变量读,别写死在文件里 model = "gpt-4o-mini" endpoint = "/v1/chat/completions" timeout_sec = 120 [load] # 并发曲线:从 start 递增到 end,每 step 持续 step_duration concurrency_start = 1 concurrency_end = 128 concurrency_step = 8 step_duration_sec = 30 # Prompt 长度分布:对数正态,覆盖长尾 prompt_len_mean = 512 prompt_len_sigma = 0.8 prompt_len_min = 32 prompt_len_max = 8192 # 输出 Token 数分布 max_output_tokens = 256 output_len_mean = 128 [warmup] # 预热阶段,结果丢弃,避免冷启动污染基线 enabled = true duration_sec = 90 [metrics] # 分阶段延迟记录 record_ttft = true record_tpot = true record_token_interval = true # 百分位 percentiles = [50, 90, 95, 99] # 输出 report_dir = "./reports" prometheus_histogram = true这份配置里最关键的是[load]段。concurrency_start到concurrency_end的递增曲线,是为了画出饱和曲线——单点压测只能告诉你"某个并发下的表现",递增曲线才能告诉你"服务在哪个并发点开始劣化"。prompt_len_sigma = 0.8控制长尾程度,sigma 越大长尾越明显,真实流量里这个值通常在 0.6 到 1.0 之间。
负载建模的核心是 Prompt 长度采样。用对数正态分布而不是均匀分布,是因为真实用户的输入长度天然是长尾的:大部分人问短问题,少数人贴长文档。采样逻辑用 Go 写出来是这样:
// samplePromptLength 采样对数正态分布,确保长尾 Prompt 被覆盖 func samplePromptLength(mean int, sigma float64, minLen, maxLen int) int { mu := math.Log(float64(mean)) - sigma*sigma/2 sample := math.Exp(rand.NormFloat64()*sigma + mu) if sample < float64(minLen) { return minLen } if sample > float64(maxLen) { return maxLen } return int(sample) }这里有个我踩过的坑:一开始用均匀分布采样,压出来的 TTFT P99 特别好看,上线后真实流量的 P99 直接翻倍。换成对数正态之后,压测数字和线上才对得上。所以负载建模这一步不能偷懒,分布选错了,后面所有统计都是自欺欺人。
4. 压测执行:SSE 流式解析与分阶段延迟记录
配置就绪后,压测执行的核心是两件事:并发生成请求,以及逐 Token 解析 SSE 流并打时间戳。并发用 goroutine 池实现,1000+ 并发对 Go 来说没什么压力。关键是 SSE 解析部分,必须逐行读、逐 Token 记时间。
// sendRequest 发送单次流式请求并记录分阶段延迟 func sendRequest(client *http.Client, cfg TargetConfig, prompt string, maxOut int) RequestMetrics { start := time.Now() m := RequestMetrics{PromptLen: len(prompt)} body := fmt.Sprintf(`{ "model": "%s", "messages": [{"role": "user", "content": "%s"}], "max_tokens": %d, "stream": true }`, cfg.Model, escapeJSON(prompt), maxOut) req, _ := http.NewRequest("POST", cfg.BaseURL+cfg.Endpoint, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv(cfg.APIKeyEnv)) resp, err := client.Do(req) if err != nil { m.Error = err.Error() return m } defer resp.Body.Close() if resp.StatusCode != 200 { m.Error = fmt.Sprintf("status=%d", resp.StatusCode) return m } scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) var firstTokenAt, lastTokenAt time.Time tokenCount := 0 for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "[DONE]" { break } now := time.Now() if tokenCount == 0 { firstTokenAt = now m.TTFT = now.Sub(start) // 首 Token 延迟 } else { m.TPOTs = append(m.TPOTs, now.Sub(lastTokenAt)) // 相邻 Token 间隔 } lastTokenAt = now tokenCount++ } m.OutputTokens = tokenCount m.TotalTime = time.Since(start) m.Success = tokenCount > 0 return m }几个工程细节值得单独说。scanner.Buffer那行必须加,默认的 Scanner 缓冲区只有 64KB,遇到长响应会直接报token too long然后静默截断,你的 Token 计数就全错了。escapeJSON也要处理好,Prompt 里如果有引号或换行,不转义会导致请求体格式错误,返回 400。
并发调度部分,用带缓冲的 channel 收集 metrics,避免 worker 阻塞:
func (lt *LoadTester) Run(ctx context.Context) []RequestMetrics { var wg sync.WaitGroup for i := 0; i < lt.cfg.Concurrency; i++ { wg.Add(1) go func() { defer wg.Done() client := &http.Client{ Timeout: time.Duration(lt.cfg.TimeoutSec) * time.Second, Transport: &http.Transport{ MaxIdleConnsPerHost: lt.cfg.Concurrency, }, } for { select { case <-ctx.Done(): return default: } plen := samplePromptLength(lt.cfg.PromptLenMean, lt.cfg.PromptLenSigma, lt.cfg.PromptLenMin, lt.cfg.PromptLenMax) prompt := generatePrompt(plen) lt.metrics <- sendRequest(client, lt.cfg.Target, prompt, lt.cfg.MaxOutputTokens) } }() } wg.Wait() close(lt.metrics) var results []RequestMetrics for m := range lt.metrics { results = append(results, m) } return results }预热阶段单独跑一轮,把结果丢掉。GPU 首次推理要 JIT 编译 CUDA Kernel、预热 Tensor Core,冷启动延迟能比热机高好几倍。不预热直接测,你的 P99 会被前几条请求拉爆,基线完全不可用。
5. 结果统计:从原始 metrics 到可用基线
压测跑完拿到一堆RequestMetrics,接下来是统计。核心指标就四个:TTFT 的 P50/P95/P99、TPOT 的均值/P95/P99/方差、Token Interval 的抖动、以及不同并发下的吞吐。用 Prometheus Histogram 格式记录,方便后续接 Grafana。
// summarize 计算分阶段延迟的百分位统计 func summarize(results []RequestMetrics, percentiles []int) Report { var ttfts []float64 var tpots []float64 var throughput int for _, m := range results { if !m.Success { continue } ttfts = append(ttfts, m.TTFT.Seconds()*1000) // ms for _, d := range m.TPOTs { tpots = append(tpots, d.Seconds()*1000) } throughput += m.OutputTokens } sort.Float64s(ttfts) sort.Float64s(tpots) return Report{ TTFT: percentileMap(ttfts, percentiles), TPOT: percentileMap(tpots, percentiles), TPOTMean: mean(tpots), TPOTStdDev: stddev(tpots), TotalTokens: throughput, Requests: len(results), } }统计出来之后,最有价值的产出是延迟-吞吐曲线。横轴是并发数,纵轴是吞吐(Token/s),同时在图上标注 TTFT P99 的等值线。这样你能一眼看出:在 TTFT P99 不超过 2 秒的约束下,最大有效吞吐是多少。这个数字才是容量规划的依据。
这里要纠正一个常见误读:很多人把"GPU 100% 利用率时的 Token/s"当作标称吞吐量。但在这个运行点上,TTFT 和 TPOT 已经严重劣化,TTFT P99 可能超过 10 秒,这个吞吐值对实际服务没有任何参考意义。压测的目的不是刷一个漂亮的 Token/s 数字,而是确定服务的容量边界。
还有一个参数误解要澄清:压测工具的concurrency=128和 vLLM 的max_num_seqs=128不是一回事。前者是"同时进行的 HTTP 请求数",包含网络排队和连接建立时间;后者是"引擎内部并行处理的序列数",只统计 Pre-fill + Decode。两者的差值就是 Queue Time 的主要来源。如果你发现压测的 TTFT 比引擎内部指标高很多,先查这个。
6. 本篇常见错排查
压测跑不起来或者数字不对劲,八成是下面这几个问题。
报错401 Unauthorized或invalid api key:检查TAOTOKEN_API_KEY环境变量是否真的导出到了当前 shell。用echo $TAOTOKEN_API_KEY确认,别在 config.toml 里写死 Key。如果 Key 刚创建,确认没有多余空格。接入文档里有完整的错误码说明:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
报错token too long或 Token 计数明显偏少:Scanner 缓冲区没调大。加上scanner.Buffer(make([]byte, 64*1024), 1024*1024)这行,长响应就不会被截断。
TTFT 数字特别大,P99 超过 10 秒:先确认预热阶段跑了没有。没预热的话冷启动会污染前几条请求。如果预热了还是大,检查是不是并发给太高,服务端排队了。把concurrency_start降到 1 重新跑一遍,看单并发的 TTFT 基线是多少。
TPOT 方差特别大,Token Interval 抖动严重:可能是网络不稳定,也可能是服务端在做动态 batching。压测时尽量在稳定的网络环境跑,别在办公网高峰期测。另外确认max_output_tokens没有设得太大,输出太长会触发服务端的调度策略变化。
吞吐数字和线上对不上:检查 Prompt 长度分布。如果压测用的是固定长度,而线上是长尾,数字必然对不上。把prompt_len_sigma调到 0.8 左右,重新采样。
请求全部超时:timeout_sec设太小。LLM 长响应可能跑几十秒,设 120 秒比较稳妥。同时确认MaxIdleConnsPerHost不小于并发数,否则连接池会成为瓶颈。
排障时如果怀疑是模型通道本身的问题,可以先用模型对话页面手动发一条流式请求,确认通道正常再回来查压测脚本:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。如果是要长期跑容量回归、需要更稳定的调用配额,Coding Plan 会比按量 Key 更省心:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。新建压测专用 Key 的入口在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,建议每个压测项目单独一个 Key,方便按项目统计用量。
最后给一个实操建议:把每次压测的 config.toml、原始 metrics 和生成的报告一起归档,用 git 管理。这样下次改了一个参数,能直接 diff 出性能变化。压测框架的价值不在于跑一次,而在于能反复跑、能对比、能复现。基线一旦建立起来,任何一次模型升级或服务端调优,你都能在十分钟内知道它到底是变快了还是变慢了。