并发压测方法 (M4 serving)
B10 的核心是把 LLM 服务「量化运营」。
阶段: B10 · p95/cost 测量 + 成本级联 + tau2-bench(Day 91-100) 标签: #concurrency #continuous-batching #radixattention #serving
今日导引(由浅入深)
B10 的核心是把 LLM 服务「量化运营」。 昨天 Day 91 只测了单请求串行基线——那是「没人抢资源时的理想延迟」。 但真实负载下请求要共享 GPU batch,互相影响。 今天把压测升级到并发维度,看连续批处理(continuous batching)如何在高并发下让 TTFT 上升、吞吐却提高这一对张力。 这一步在 B1→B18 曲线上是「从能跑到能扛」的关键过渡:会不会扛住并发,决定一个 agent 系统能不能上生产。 今天的「最小可判定产出」:跑 N=50 @ concurrency 1/4/8 三档,每档算 p50/p95/p99 并单独记 TTFT,落出里程碑数字——N=50/concurrency=8 的 p95 延迟(ms)(真实值待 key)。
1. 机理精读
串行压测只测单请求基线,测不出共享资源下的相互挤压。 真实负载下,多个请求会被服务引擎打包进同一个 GPU batch 一起算。 所以「8 个人同时问」的延迟,绝不是「1 个人问」的延迟简单复制 8 份。
连续批处理(continuous batching,又叫 in-flight batching)是 2026 serving 标配。 传统静态批处理要等整批请求都生成完才能放下一批,一个慢请求会拖死整批(队头阻塞)。 连续批处理在每一步 decode 后立刻把已完成的请求踢出、把排队的新请求填进空位,让 GPU 几乎不空转。 这把批处理的粒度从「整批」降到「每个 decode step」,是吞吐能拉满的关键。
这带来一个反直觉的权衡。 并发上升时,单请求的 TTFT 会上升(你的请求要排队等 batch 槽位、prefill 也要和别人抢算力)。 但系统总吞吐(tokens/s 或 req/s)反而提高(GPU 利用率拉满)。 所以「延迟变差」和「吞吐变好」在高并发下同时发生——这正是为什么必须同时报 p95 延迟和吞吐,只看一个会得出片面结论。
SGLang 的额外优化:RadixAttention。 vLLM 和 SGLang 都做连续批处理,SGLang 额外用 RadixAttention 把共享前缀的 KV cache 复用起来。 多个请求若有相同 system prompt / few-shot 前缀,前缀的 KV 只算一次。 这对 agent 场景(大量请求共享同一套工具描述 + system prompt)省得尤其多,直接压低 prefill 成本和 TTFT。
关键边界。
今天测的是「并发对延迟分布的影响 + TTFT/TPOT 拆分口径」。
不涉及把延迟换算成钱(Day 93),也不涉及级联路由(Day 94-95)。
压测的目的是拿到一张 并发档 × {p50,p95,p99,TTFT} 的表,作为后续容量规划与成本测算的输入。
2. 推导 / 手算 / 代码走读
本日继续复用 src/agent/eval/stats.ts 的 summarizeLatency(stats.ts:76),脚本侧加并发控制。代码走读要点:
- 统计函数就绪:
summarizeLatency已建并测试,返回{ n, p50, p95, p99, mean },三档并发各喂一次即可。 - 并发池实现:用 Promise 池(固定并发度 = concurrency,跑满 N=50 个请求)。
- 最朴素写法是维护「在飞请求计数」,达到上限就
await Promise.race(inflight)等一个完成再补一个——保证同时在飞数恒等于 concurrency。 - TTFT 必须单独记:对走 SSE 流式的请求,记「请求发出 → 收到第一个 chunk」的时间差作为 TTFT,而不是用整体
performance.now()端到端时间。 - 端到端时间 = TTFT + 生成耗时,二者要分开存,否则没法归因「慢在首字还是慢在生成」。
- 三档输出三张
LatencySummary:concurrency=1(≈ Day 91 串行基线)、=4、=8,对照看 p95 随并发抬升、TTFT 随并发抬升的趋势。
手算示例(说明吞吐 vs 延迟的权衡量级):
- 假设单请求纯生成 100 tokens、TPOT=20ms,则单请求端到端 ≈ TTFT(50ms) + 100×20ms = 2050ms。
- concurrency=1 时吞吐 ≈ 100 tokens / 2.05s ≈ 49 tok/s。
- 若 concurrency=8 让 batch 共享一次前向,每请求 TPOT 因显存带宽分摊升到 28ms、TTFT 因排队升到 120ms。
- 单请求端到端升到 ≈ 120 + 100×28 = 2920ms(p95 变差)。
- 但 8 路并行总吞吐 ≈ 8×100 / 2.92s ≈ 274 tok/s(吞吐 5.6×)。
- 这就是「延迟变差、吞吐变好」同时成立——具体数字以真实压测为准,这里仅演示量级关系。
三档对照表读法(拿到压测表后怎么解读):
- concurrency=1:≈ Day 91 串行基线,p95 应最低、吞吐最低,是「最好情况延迟」上界。
- concurrency=4:p95 略升、吞吐明显提升,多数是「甜点区」——延迟劣化可接受、利用率已上去。
- concurrency=8:p95 进一步升、吞吐继续涨但边际递减;若 p95 突然陡升说明已到 batch/显存瓶颈,再加并发只排队不提速。
- TTFT 列:随并发单调上升(排队加深);若 TTFT 涨幅远超 p95 涨幅,说明瓶颈在 prefill/排队而非 decode。
- 用这张表能回答容量问题:「在 p95 ≤ X ms 的 SLO 下,单实例最多扛多少并发」。
为什么 RadixAttention 对 agent 场景特别值(量级直觉):
- agent 请求的 system prompt + 工具描述常占 1000~3000 tokens,且每个请求几乎相同。
- 无前缀复用时,每请求 prefill 都要重算这几千 token 的 KV。
- RadixAttention 把这段前缀 KV 复用,prefill 只算「变化的用户输入」那几十~几百 token。
- 直接结果:高并发下 TTFT 显著下降、prefill 算力省一大块——这也是 SGLang 在多轮 agent 评测里占优的原因之一。
3. 今日实战
- 给
bench/loadtest.ts(Day 91 建的脚手架,仍属待建)加并发参数,用 Promise 池控制同时在飞请求数。 - 跑三档:N=50 @ concurrency 1、4、8。每档收集延迟数组喂
summarizeLatency得 p50/p95/p99。 - 对每个请求额外记录 TTFT(SSE 首 chunk 时间),与端到端延迟分开存。
- 输出一张压测表:行=并发档,列={p50, p95, p99, TTFT},落盘(如
bench/out/loadtest-concurrency.json)。 - 模型固定
deepseek-v4-flash,脚本里务必把 TTFT 与后续 token 时间分离,避免把网络排队当成模型 TPOT。
4. 今日实测 / 产出
- 统计函数 就绪(stats.ts,
summarizeLatency)——确定性部分可立即用。 - 3 档并发 × {p50,p95,p99,TTFT} 压测表 = 待跑(需 key 跑)。
- 跑后产出 里程碑数字之一:N=50/concurrency=8 的 p95 延迟(ms)。
- 状态如实:压测表待 key,不预填任何延迟数字。
压测脚本的几个实现陷阱点(确保测的是模型不是脚本):
- 预热:第一发请求常含连接建立/冷启动开销,跑正式压测前先丢弃 1~2 个预热请求。
- 时钟:用
performance.now()(单调时钟)而非Date.now(),避免系统时间跳变污染延迟。 - 隔离 TTFT:SSE 流必须在收到首个非空 chunk 时打点,而不是等整段读完——否则 TTFT 退化成端到端。
- 限流真实性:池子要在「某请求完成」时才补发下一个,保证瞬时在飞数恒等于 concurrency。
5. 常见误区 / 陷阱
- 把网络排队延迟当模型 TPOT:经远程 API(OpenRouter)压测时,并发会让网络侧也排队,必须在脚本里分离首字时间与后续 token 时间,否则吞吐/TPOT 全被网络抖动污染。
- N=50 就下显著性结论:50 个样本的 p95 已经不稳,p99 噪声更大;压测表只能作趋势参考,不能当精确 SLO。
- 静态批处理直觉套连续批处理:以为「并发越高单请求一定线性变慢」是错的——连续批处理下吞吐提升常常超过延迟劣化,要双指标一起看。
- 并发池没真正限流:用
Promise.all一次性发 50 个 ≠ concurrency=8;必须用池保证同时在飞数恒等于设定值,否则测的是 concurrency=50 不是 8。
6. 学习资源(每条带 YYYY-MM)
- vLLM PagedAttention 官方博客(2026-XX,执行当周重验)——连续批处理 + KV 分页机理。
- SGLang RadixAttention 文档(2026 重验)——共享前缀 KV 复用,agent 场景省 prefill。
- 「Orca: A Distributed Serving System for Transformer-Based Generative Models」OSDI(2022-07)——continuous/iteration-level batching 原始论文,机理打底。
- 项目源码
src/agent/eval/stats.ts(本仓,stats.ts:76)——p50/p95/p99 统计复用。
SOTA检查 (2026-06 更新)
- 当前主流:连续批处理是 2026 serving 标配;TTFT/TPOT 拆分口径仍 SOTA;vLLM V1 + SGLang RadixAttention 为活跃主线。
- 过时黑名单:不要把 OpenRouter 的网络排队延迟当作模型 TPOT(需脚本分离首字与后续 token 时间);不要用静态批处理的延迟直觉外推;继续禁用 legacy
deepseek-chat/-reasoner。 - 下次复查点:vLLM/SGLang 引擎版本与 RadixAttention 状态执行当周重验;并发-吞吐曲线随引擎升级会变,每次压测重测不复用旧数。
衔接
- 昨天:Day 91 — 延迟/吞吐指标体系(TTFT/TPOT 拆分 + p95/p99,串行基线)
- 今天:加并发池跑 N=50 @ concurrency 1/4/8,看连续批处理下「延迟变差、吞吐变好」的张力,产出 concurrency=8 的 p95
- 明天:Day 93 — $/query 成本测量(M4):把 token 计数乘单价转成每查询美元成本,报均值与 p95 成本