一个推理服务原本"攒批 300ms 再返回",P99 1.2 秒、吞吐 900 tok/s。改成"流式返回首 token"后,首字延迟从 800ms 降到 200ms,而吞吐掉到 520 tok/s——因为流式让"批内序列"无法再等彼此,于是"批变小、GPU 利用率下降"。
批处理与流式是两种不同的优化目标:批处理优化"吞吐",流式优化"首字延迟"。而它们在"调度"这一层是冲突的——因为"攒批"必须等,"流式"必须尽快发。
1. 两种目标的度量
| 目标 | 指标 |
|---|---|
| 吞吐 | tokens/s(整体) |
| 首字延迟 | TTFT(p50/p99) |
| 完整延迟 | 端到端 p99 |
第一项与第二项常常"反向":"等 300ms 攒批"让吞吐高,但 TTFT 至少 300ms。这个"反向"可以量化:把等待从 300ms 降到 30ms,TTFT 改善 270ms,而 batch 可能从 16 掉到 4——吞吐掉一半,首字快一倍,而这正是"体验与成本"的交换。
2. "攒批"为什么提高吞吐
| 机制 | 说明 |
|---|---|
| GPU 并行 | 一个 batch 的矩阵运算"摊薄"了 per-token 成本 |
| 权重读取复用 | 一次读权重,服务多条序列 |
| 调度开销摊薄 | 每次调度服务更多 token |
第一项的"数字感"很直观:一条序列占不满一个 SM 阵列,于是"batch 8"相当于"把 8 条挤进同一批矩阵乘"——per-token 成本降到 1/6–1/4。
第一项是"根本":GPU 的算力在"小 batch"下大量闲置——于是"batch 从 1 到 8"通常能带来"2–4 倍吞吐"。
3. "流式"为什么会掉吞吐
| 原因 | 机制 |
|---|---|
| 批内"最长者"拖尾 | 短序列等长序列 |
| 无法"等更多请求" | 每个请求都要快发 |
| KV 缓存碎片 | 序列结束时释放,新序列插入 |
第一项在"长度差异大"时最明显:**批里一个 4000 token 的请求,让 20 个短请求"陪跑"**第一项的"利用率"可以算:批内平均有效长度 / 最长长度——20 个 100 token + 1 个 4000 token,利用率只有 ~50%。
——而流式下"短请求早就结束了却占着位置"。
"连续批"的核心是一张"每步状态表",而它解释了为什么"短序列能退出":
// 一次"每步调度"(示意)
step k: 活跃序列 [A(剩 3), B(剩 7), C(剩 1)]
step k+1: C 完成 → 退出并释放 KV;新请求 D 加入(若有空位)
step k+2: A、B、D 共享"同一批矩阵运算"
// 收益:B 不再"陪跑到 7",而 D 不必"等整批结束"
4. 三种"共存"的做法
| 做法 | 效果 |
|---|---|
| 连续批(continuous batching) | 兼顾(主流) |
| 双队列(流式队列 + 批队列) | 简单(按场景分流) |
| 分池(交互/离线两套) | 最稳(成本高) |
第一项的"难点"是"调度开销":每步都要"算谁完成、谁能加入"——而在"批很大"时这点开销可忽略,在"批很小"时反而变显著。
第一项是"现代引擎的解法":每步重新组批,完成的序列退出、新请求加入——于是"流式"与"高吞吐"可以同时成立。
第三项是"产品层面的解法":**交互式请求进"低延迟池",离线批任务进"高吞吐池"**第三项的"风险"是"峰值互挤":批任务把显存占满时,交互请求"排队到超时"——所以要"硬配额"(每池固定显存),而不是"谁先来谁占"。
——于是"各自优化自己的目标"。
// 分池(两套"调度策略",共享同一批 GPU)
pools:
interactive: { max_batch: 8, max_wait_ms: 30, stream: true } # 低延迟
batch: { max_batch: 64, max_wait_ms: 300, stream: false } # 高吞吐
// 关键:两池"配额"要留余量,否则"批任务"会把"交互"挤住
"该不该流式"可以用"两个数"决策,而不是"凭体验":
// 两个数一起看(同一业务)
TTFT_p50:流式 200ms / 非流式 800ms → 用户感知差 4 倍
tokens_per_sec:流式 520 / 非流式 900 → 成本差 1.7 倍
// 判据:若"首字延迟"影响转化(如搜索/对话),接受成本;否则保吞吐
5. "TTFT"的三个来源
| 来源 | 典型量级 |
|---|---|
| 排队(等批) | 0–300ms |
| prefill(算首 token) | 100ms–1s(与输入长度成正比) |
| 调度/传输 | 10–50ms |
第二项的"优化"是"前缀缓存":同一系统提示在多个请求间复用时,prefill 可以"跳过已算部分"——于是"长输入"的 TTFT 主要取决于"新增部分"。
第二项是"长输入的痛点":4000 token 的 prefill 可能 800ms——于是"等批"只占 TTFT 的一小部分,而"优化攒批时间"收益有限。
"流式断线"要能"续",否则"用户看到半句话然后从头":
// 三段"可续"的实现
1 服务端按"请求 ID"保留已生成片段(如 30 秒)
2 客户端断线后带"已收到的 token 数"重连
3 服务端从"saved_len"继续生成(或直接返回剩余)
// 判据:断线重连"不重算已生成部分",用户"看不到重复文字"
6. 排错:五种"流式相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 加了流式吞吐腰斩 | 批变小 | 连续批/分池 |
| 首字快但整段慢 | prefill 未优化 | 前缀缓存/分块 prefill |
| 尾包延迟大 | 批内最长者拖尾 | 限制批内长度差 |
| 断线后重连重算 | 无"续传" | 记录已生成片段 |
| 流式下 OOM | KV 碎片 | 限制并发/固定形状 |
"批内长度差"这条也可以直接约束,而不必"换引擎":
// 一条"低成本"的调度规则
1 组批时限制"最长/最短 ≤ 4"
2 超了另开一批(批可能装不满,但利用率上升)
3 流式请求"单独成批"(不与长请求混)
// 收益:不用换引擎,吞吐常能回来 20–40%
7. 边界:什么情况不必追求"两者兼得"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 离线批任务(无人等) | 只需吞吐 | 纯批 |
| 低流量(QPS < 1) | 批攒不起来 | 纯流式 |
| 极短输出(< 20 token) | 流式收益小 | 非流式 |
| 已用托管服务(引擎自优化) | 由平台管 | 跟随 |
判断口径一句话:"用户是在"等第一句",还是"等全部结果"。最后一条与"成本"有关的边界:"流式"不便宜:它牺牲吞吐换延迟,而吞吐直接换算成"每百万 token 的成本"——于是"全站流式"往往意味着"成本翻倍"。
等第一句(对话、搜索联想),流式必要;等全部(报表、批处理),攒批更好——而"两者都要"时,先看"有没有连续批",没有就"分池"。
再补一条"分池"的实操细节:两池的"配额"要按"显存"分,而不是按"请求数"分——因为"批任务的显存占用"是"批大小 × 序列长度",而"请求数"完全反映不出它。于是"批任务 20 个长请求"可能比"交互 200 个短请求"占得多得多;只按"请求数"限流,必然在"批任务高峰"把交互池挤死。
而"TTFT"这个指标本身也有口径问题:它算不算"排队时间"——算(用户感知),还是不算(引擎能力)。报告里不写清,两个团队会拿出"同一系统、差 5 倍"的数字互相质疑。
还有一个"产品"细节:流式只在"真的会滚动显示"时才有意义——如果前端"收完再渲染",那 TTFT 的改善是零,而吞吐的损失是实的。所以"改流式"应当先确认"前端是否边收边画"。
8. 自测
- 批处理与流式分别优化什么指标?为什么它们"在调度层冲突"?
- "攒批"提高吞吐的三个机制是什么?为什么"batch 1→8"常带来 2–4 倍?
- "流式掉吞吐"的三个原因里,哪一个在"长度差异大"时最明显?
- "连续批"如何让两者共存?它与"分池"的差别是什么?
- TTFT 的三个来源里,长输入时哪一个占主导?它意味着什么?
- 判断"该流式还是该攒批"的那个问题是什么?