ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LLM 推理压测框架设计:从负载建模到结果统计的工程化方案(TaoToken 统一 Key 接入版)

LLM 推理压测框架设计:从负载建模到结果统计的工程化方案(TaoToken 统一 Key 接入版) 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 TokenP99 却能到 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。对于需要长期跑压测、做容量回归的团队用按量计费的 Key 可能会让成本不太好控这时候可以看看 Coding Plan 这类套餐适合高频、长期的调用场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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.BaseURLcfg.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 数字而是确定服务的容量边界。还有一个参数误解要澄清压测工具的concurrency128和 vLLM 的max_num_seqs128不是一回事。前者是同时进行的 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果是要长期跑容量回归、需要更稳定的调用配额Coding Plan 会比按量 Key 更省心https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。新建压测专用 Key 的入口在 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 建议每个压测项目单独一个 Key方便按项目统计用量。最后给一个实操建议把每次压测的 config.toml、原始 metrics 和生成的报告一起归档用 git 管理。这样下次改了一个参数能直接 diff 出性能变化。压测框架的价值不在于跑一次而在于能反复跑、能对比、能复现。基线一旦建立起来任何一次模型升级或服务端调优你都能在十分钟内知道它到底是变快了还是变慢了。
返回列表