把吞吐和时延同时压下去,为什么难

做推理服务的人几乎都遇到过这个场景:业务方抱怨首字响应慢,你去调大并发想让整体更快,结果首字响应更慢了。这不是调参水平问题,而是推理系统里吞吐与时延天然互相牵制。GPU 每秒能产出的 token 总量是接近守恒的,你把它全部塞给某些请求,单个请求的解码速度就必然下降。真正的问题不是"要不要吞吐",而是在给定时延承诺的前提下,把吞吐做到最大——因为吞吐直接决定单位 token 成本,时延直接决定用户是否流失。

要把这两件事同时压下来,靠的是一套互相咬合的调度机制,而不是某一个"银弹优化"。本文按这个顺序拆解:先看静态批处理到底浪费在哪,再看 Continuous Batching 用了什么代价换来的收益,然后是显存分页、前缀缓存、投机解码、抢占与公平性,最后收敛成一套生产环境的调优顺序。所有机制都会同时讲收益和边界,因为任何一项优化在某个工作负载上都会反噬。

先约定两个贯穿全文的核心指标。一次请求的端到端时延可以粗略拆成:

总时延 ≈ TTFT + (输出 token 数 − 1) × TPOT

TTFT(Time To First Token,首 token 延迟)主要由排队时间和 prefill 计算决定,TPOT(Time Per Output Token,每个输出 token 的间隔)基本正比于单步解码的耗时。把这两者混在一起看,架构上的所有决策都会得出错误结论——后面专门有一节会展开。

静态批处理:为什么它注定浪费

一个被长请求绑架的 batch

静态批处理的流程非常朴素:等到凑够 batch_size 个请求,一起做一次 prefill,然后自回归解码到整个 batch 里最长的那个序列结束,剩下的短请求带着已经算好的结果返回,剩下那些 padding 位置直接丢弃。

问题出在最后一步。假设一个 batch 里 31 个请求各输出 20 个 token,1 个请求输出 500 个 token,那么这 31 个请求要陪着跑 480 个步,480 步里它们都在做无意义的计算,GPU 算力凭空蒸发。浪费比例接近 (500 − 20) / 500 = 96%。这就是"短请求被长请求拖住"的确切含义。

与之并列的第二重浪费是 padding。静态批处理必须按 batch 内最长序列来分配 KV Cache,最短的那些请求占着显存却几乎不用,导致有效并发被最长的请求单方面决定。

算力利用率低只是表象,内存墙才是根因

很多人看到推理时 GPU 利用率只有 40%,第一反应是"没吃满"。但对自回归解码来说,低利用率本身就是特性:每生成一个 token 都要把全部模型权重从 HBM 读一遍,而矩阵乘法在这个规模上远未达到算力屋顶。这是访存受限(memory-bound)而不是算力受限(compute-bound)。

真正卡住并发上限的不是算力,而是显存。显存要先放模型权重,剩下的给 KV Cache 和激活值,KV Cache 决定了一个 batch 能放多少条序列、每条序列能有多长。KV Cache 的规模大致是:

KV 字节数 ≈ 2 × 层数 × KV 头数 × 头维度 × 序列长度 × 每元素字节数

系数 2 来自 K 和 V 两份。以一个 60 层、48 个 KV 头、头维度 128 的模型为例,FP16 下每 token 大约是 2 × 60 × 48 × 128 × 2 ≈ 1.47 MB。一个 8K 的序列光 KV 就要 12 GB 左右,16K 就是 24 GB。哪怕在 80 GB 的卡上放一个 14B 稠密模型,留给 KV 的空间也只够同时跑几条长序列。

这条算式解释了后续几乎所有工程选择:分页是为了少浪费 KV 空间,前缀缓存是为了让已占用的 KV 产生复用收益,KV 量化是为了直接把公式里的"每元素字节数"砍半,MLA 是从公式的"KV 头数"这一项上动手。所有优化最终都指向同一件事——在同样的显存里塞进更多可用的 KV。

被高估的静态批处理优点

静态批处理唯一的优势是批内形状整齐:序列长度一致,kernel 参数固定,CUDA Graph 容易捕获,Tensor Core 利用率高。正因为它对齐了一切,所以也浪费了一切。连续批处理的核心思想就是放弃这份整齐,换取"任何序列都能在任意时刻进出一个 step"。

下面这张表把浪费来源拆开,后面每一项优化都会对应到其中一条:

浪费来源具体表现对应优化手段
批内长度不一致短序列陪着长序列空转到结束Continuous Batching
padding 占用按最长序列分配 KV,内存按最坏情况预留PagedAttention 分页
重复计算相同前缀被每个请求重新 prefill 一遍Prefix Cache
单步粒度过粗长 prefill 一次算几千 token,阻塞所有 decodeChunked Prefill
串行步数草稿串行生成 N 步,N 倍 kernel launch 开销Speculative Decoding
租户互相踩踏一个大客户把 batch 占满,小客户全在排队抢占与公平性调度

Continuous Batching:把调度粒度降到 token 级

它在做什么

Continuous Batching(也叫 in-flight batching、iteration-level scheduling)的做法只有一个改动:调度器每一轮都重新决定谁在跑。每个 step 开始时,跑完的序列立刻退出,排队的新请求立刻补进空出来的位置,prefill 和 decode 在同一个 step 里混合进行。

关键收益是"占用时间"从"序列长度"变成了"自己的长度"。上面那个 31×20 + 1×500 的例子,在连续批处理下,31 个短请求在自己的第 20 步就结束了,只有那个长请求继续跑。浪费基本被消除。

当然,形式化一点说,批处理窗口是一个动态集合:running = { s : s 未结束 }。每个 step 对 running 里所有序列各取一个 token(decode)或一段 token(prefill),然后集合更新。收益和代价都从这个"每步重算集合"里来。

实现代价清单

把"每步重算集合"这句话翻译成代码,需要付出的东西比想象的多。

1. 批内长度不再整齐,attention 路径先崩。静态批处理里所有序列可以从同一个 offset 读连续内存。连续批处理里每条序列的长度各不相同,KV Cache 物理上散落在各处,attention 必须支持"用一张间接索引表去 gather"。这就是分页注意力存在的直接原因——它不是内存优化,而是让连续批处理在 GPU 上能跑的前提。两件事必须一起做。

2. Python 调度器成为瓶颈。早期实现里,每个 step 都要跑一遍 Python 调度逻辑,做一批张量切片、索引更新、采样。几百并发下这部分 CPU 时间可能超过 GPU 计算时间,而且 GIL 让多线程也救不了。vLLM V1 引擎把调度器下沉到 C++、把请求状态和 KV 块表放在同一侧,减少跨语言边界的搬运,这是它能扛住高并发的主要工程改动之一。

3. CUDA Graph 与动态 batch 形状冲突。CUDA Graph 要求捕获时的形状固定,而连续批处理的 batch 大小每 step 都在变。通行解法是把 batch size 量化成一组"桶"(比如 1、2、4、8、16、32、64、128、256),用 padding 把实际 batch 补到下一个桶再捕获。桶太密显存占用高,太疏则 padding 浪费回升——这是又一处显存与时延的权衡。

4. 采样与同步开销。每 step 一次 kernel launch、同步、采样、开环取结果,在小 step 时长(几毫秒)下并不便宜。异步调度、采样器融合、避免 .cpu() 同步是这一层的常规优化。

一个最小的调度循环

下面这段代码不是任何生产引擎的实现,但它把连续批处理的调度语义写清楚了——注意每轮都要重建 batch 成员,并且 prefill 与 decode 混在同一个 step:

class Engine:
    def __init__(self, kv_manager, step_token_budget=8192):
        self.waiting = deque()      # 排队中的请求
        self.running = []           # 本轮要跑的序列(可能含 prefill 片段)
        self.kv = kv_manager
        self.budget = step_token_budget

    def step(self):
        batch = []

        # 1) 先给已在跑的序列分配额度,保证它们不会饿死
        #    decode 每条序列本轮只消耗 1 个 token 的预算
        decode_seqs = [s for s in self.running if not s.in_prefill]
        prefill_seqs = [s for s in self.running if s.in_prefill]
        spent = len(decode_seqs)

        # 2) 按预算把长 prefill 切块(chunked prefill)
        for s in prefill_seqs:
            room = self.budget - spent
            if room <= 0:
                break
            chunk = min(s.remaining_prompt, self.chunk_size, room)
            batch.append((s, chunk, is_prefill=True))
            spent += chunk

        # 3) 有空位就准入新请求,新请求只做第一段 prefill
        while self.waiting and spent < self.budget:
            s = self.waiting[0]
            room = self.budget - spent
            chunk = min(len(s.prompt_ids), self.chunk_size, room)
            if chunk < self.min_prefill_chunk:
                break                   # 碎片太小,等下一轮
            self.waiting.popleft()
            s.in_prefill = True
            batch.append((s, chunk, is_prefill=True))
            spent += chunk

        # 4) 跑 forward:一次混合了 prefill 片段和 decode 步的 batch
        logits = self.model.forward(batch, self.kv)

        # 5) 采样后立刻更新集合:这就是 continuous 的含义
        self.running = []
        for s in batch:
            tok = sample(logits[s])
            s.append(tok)
            if s.finished:
                self.kv.free(s.block_ids)   # 立刻释放,可被下个请求复用
            elif s.in_prefill and s.prompt_done:
                s.in_prefill = False
                self.running.append(s)
            else:
                self.running.append(s)

真实引擎在这个骨架上要补上抢占、优先级、块表维护、前缀查找、多模态输入等,但语义完全一致:第 5 步的"立刻释放、立刻准入"是全部收益的来源,也是全部复杂度的来源。

Chunked Prefill:别让一个长 prompt 卡住整个引擎

连续批处理没解决的问题

连续批处理解决了"短请求被长请求拖住",但没解决"长请求把短请求拖住"。一个 100K token 的 prompt,即使切到连续批处理,第一次 prefill 也要在一个 step 里算完 100K 个 token(prefill 阶段没有自回归依赖,可以整段并行)。这一个 step 可能耗时几百毫秒到几秒,期间所有 decode 请求无法推进,TPOT 出现明显尖刺。

后果是:引擎看起来整体吞吐不错,但每个请求的输出都是一顿一顿的。首字延迟(TTFT)也不一定变好——因为新请求要排队等那个大 prefill step 结束。这就是 prefill 和 decode 之间的队头阻塞。

切块,以及切块大小怎么定

Chunked Prefill 的做法是把 prefill 序列切成固定 token 数的块,一个 step 只处理一块,和其他请求的 decode 步混在一起。prefill 请求本身要跨多个 step 才能完成,它的 KV 分块写入,直到最后一块才产出第一个 token。

块大小的权衡是双向的:

  • 块太大:单步算力更饱和,prefill 总时长更短,但单个 step 变长,decode 请求的 TPOT 尖刺更明显。
  • 块太小:TPOT 平滑了,但每步的 batch 变小,GPU 访存效率下降(访存受限的场景里,batch 太小是致命的),prefill 本身的吞吐也掉。

所以典型配置是一个中间值,把"单步 token 预算"和"单个 prefill 块的 token 数"分开设置:前者是 max_num_batched_tokens 一类的总预算,后者是 chunk size。vLLM V1 把 chunked prefill 做成默认开启并与连续批处理统一调度,SGLang 则暴露单独的 chunked prefill size。经验上,默认值对多数工作负载够用,先别急着动它。

别忘了饥饿

混批的自然副作用是:decode 请求每步只消耗 1 个 token 预算,prefill 块消耗几千。如果按先来先服务朴素地填预算,一个大 prefill 序列会长期占着整个预算,让小请求反复处于等待。工程上的处理方式有几种——给 decode 保底最低额度、限制单个请求在批中的 prefill 占比、或者干脆把大文档请求路由到独立的 prefill 实例。后面讲 PD 分离时会回到这个问题。

显存管理:从连续分配到分页

连续分配为什么必然浪费

在分页之前,每个序列的 KV Cache 是一段连续显存,按预估的最大长度预留(多数引擎按 max_model_len 或动态增长)。这带来两个必然的浪费:

  1. 内部碎片:为 100K 上下文预留的空间,实际只生成了 500 token,剩下的永久闲置。
  2. 外部碎片:长短请求交替申请释放,显存出现无法满足任何请求的空隙。更麻烦的是显存碎片化会让 cudaMalloc 变慢甚至同步,这是运行时抖动的一个来源。

PagedAttention 的两个原语

PagedAttention 把 KV Cache 像虚拟内存一样管理:固定大小(通常 16 token 的一"页")的块池,加一张 block_table 记录"逻辑块号 → 物理块号"。attention kernel 读 KV 时按这张表间接寻址。这个设计恰好也是连续批处理的必要条件,两者是同一枚硬币。

它真正买到的是三样东西:

  • 几乎零浪费:只在序列末尾可能有半个块的内部碎片。PagedAttention 论文给出的对比是内存浪费从百分之二十到三十多的量级降到 4% 以内。
  • O(1) 分配与释放:分配就是从 free list 取一个块号,释放就是还回去,与碎片率无关,也不需要调用 cudaMalloc。
  • 块级共享:这是前缀缓存的物理基础,后面详述。

块大小是个不能拍的参数

块太大:最后一个块可能只用了几十%,长序列上内部浪费回升;block_table 本身也要放进 GPU 显存,每条序列一个块的索引。

块太小:block_table 变长,kernel 每读一个块要做一次间接寻址,访存合并被破坏;前缀缓存的有效命中单位也变细碎。16 是目前被广泛采用的默认值,某些实现会让它可配置。

SGLang 的另一条路:前缀树

SGLang 用 RadixAttention 把 KV Cache 组织成基数树(radix tree),而不是哈希块池。逻辑上等价——按最长公共前缀复用节点——但树的组织方式让多轮对话、树搜索、并行采样这类"分叉后再合并"的工作负载复用得更自然,而且能直接沿树路径做精确的 LRU 驱逐。vLLM 走的是块哈希表 + LRU 驱逐的路线。两条路殊途同归:把"共享前缀"变成可被数据结构直接表达的物理事实。

显存账本要自己算一遍

调参前先用上面的 KV 公式做一次预算,比盲调靠谱得多:

# 估算单卡能塞下多少并发
# 注意:这是粗算,实际还要给激活值、采样器、CUDA Graph、通信缓冲留空间

model_layers        = 61
kv_heads             = 48      # MLA 模型要另算,压缩潜变量小得多
head_dim             = 128
kv_bytes_per_elem   = 2       # fp16=2, fp8=1, int8=1
gpu_total_gb         = 80
weights_gb           = 14.2   # 14B FP16 权重大约值
reserved_ratio       = 0.03   # 分配器与显存碎片余量

kv_bytes_per_token = 2 * model_layers * kv_heads * head_dim * kv_bytes_per_elem
# 8K 上下文单条序列的 KV 占用
kv_gb_per_seq_8k   = kv_bytes_per_token * 8192 / (1024 ** 3)
usable_gb          = gpu_total_gb * (1 - reserved_ratio) - weights_gb
max_seqs_at_8k      = usable_gb // kv_gb_per_seq_8k

print(f"KV/token      = {kv_bytes_per_token/1024:.1f} KB")
print(f"单序列 8K KV   = {kv_gb_per_seq_8k:.2f} GB")
print(f"8K 下最大并发  = {int(max_seqs_at_8k)}")
print(f"若 KV 换 fp8,并发约 x{int(2 * 0.5)} 级别提升(视实际占用)")

这个算式的价值在于:当你发现并发上不去时,能立刻分辨是"显存不够"还是"调度器没喂饱",而不是在参数里瞎试。MLA 这类压缩注意力的模型之所以在长上下文场景受欢迎,就是因为它直接把这个公式里的 kv_heads × head_dim 这一项砍掉一大截,KV 占用能降到传统 MHA 的十分之一量级。

Prefix Cache:命中率工程,而不只是开一个开关

原理:块级哈希

前缀缓存的判定单位是块。调度器为每个 KV 块计算一个哈希,输入包含这个块的 token 序列和它父块的哈希,这样一个块的哈希就隐含了它之前所有内容的指纹。查找时用块的 token 逐段算出哈希,链式命中几个块,就复用几个块的 KV。父块哈希参与计算保证了链的正确性,不会出现"中间换了内容但前后哈希碰撞"的情况。

结果是:命中长度天然向下取整到块的倍数。一个块 16 token,你的共享前缀是 100 token,实际只能复用 96 token。这是个小但真实的机会成本,多轮对话累积到后面,前缀命中率会因为这个取整而略微下降。

命中率从哪来

生产环境的命中率差异极大,取决于你的流量形态。常见的几个高价值来源:

  • 稳定的 system prompt。几千 token 的系统提示在所有请求里重复,这是命中率的最大来源,也是收益最直接的部分——它把几千 token 的 prefill 变成零计算。
  • few-shot 示例。少样本模板天然是共享前缀。
  • 多轮对话。第 N 轮的输入包含前 N−1 轮的完整历史,理论上能复用绝大部分。前提是历史没有被改写或摘要压缩——一旦上游做了摘要,前缀就断了,命中率归零。
  • 结构化生成的固定骨架。工具调用、函数定义的 schema 前缀重复度很高。

失效场景:命中率上去了,时延反而更差

这是生产里最容易踩、也最少被写进文档的一类问题。前缀缓存有四个方向的坑:

  1. 前缀里混入了变化量。在 system prompt 里插时间戳、请求 ID、用户 ID、随机 nonce,是命中率归零的头号原因,而且改的人往往不在负责推理服务的团队里。规范做法是把变化量全部移到用户消息之后——前缀保持纯净,命中率才立得住。
  2. 命中率 100% 但收益有限。前缀缓存只省 prefill,不省 decode。如果工作负载是"长 prompt + 短输出",TTFT 收益巨大,TPOT 几乎不变;反过来"短 prompt + 超长输出"(比如长代码生成),前缀缓存省下来的那点时间会被几百步 decode 完全淹没。先判断负载形态,再决定值不值得投入命中率工程。
  3. 缓存挤占并发。复用的块同样占显存。命中率高意味着更多请求在共享同一批块,总的独立序列数反而可能下降,吞吐不一定上升。这一条反直觉但真实——它是"命中率"和"吞吐"两个指标必须分开看的原因。
  4. 驱逐策略打脸。LRU 驱逐下,一个正在流式输出的多轮请求,如果中间被别的请求挤掉缓存块,只能重算或中断。长对话场景要留意驱逐命中率相关的指标,而不是只看全局数字。

观测:至少盯这四个数

前缀缓存必须可观测,否则调优就是盲飞。vLLM 与 SGLang 都暴露了相关指标,形态略有差异:

# vLLM V1 常见前缀/调度相关指标(以实际 /metrics 输出为准)
vllm:prefix_cache_queries_total          # 块级查询总次数
vllm:prefix_cache_hits_total             # 命中次数 -> 两者相除得块级命中率
vllm:gpu_prefix_cache_hit_rate          # 引擎直接上报的命中率
vllm:num_requests_waiting                # 排队长度:前缀缓存省时间的主要受益者
vllm:num_requests_running                # 在跑并发
vllm:num_preemptions_total               # 抢占次数:缓存挤占导致抢占时这里会飙升
vllm:gpu_cache_usage_perc                # KV 块池水位

# PromQL 组合出可用的判断
# 命中率趋势
rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])

# 等待队列是否积压(配合命中率一起看)
vllm:num_requests_waiting

# 抢占风暴告警
rate(vllm:num_preemptions_total[1m]) > 0.5

SGLang 侧对应的是运行中/排队请求数、缓存命中率、生成吞吐、排队时延,以及投机解码的接受长度。指标名各家会随版本变化,用之前先拉一次 /metrics 核对,不要照抄。

命中率与收益的定性关系

场景典型命中率主要收益点风险
长稳定 system prompt + 短输出很高TTFT 大幅下降块共享后独立并发上限受总显存限制
多轮对话,历史不摘要随轮次上升第 N 轮几乎免 prefill长会话驱逐后重算,需要足够块池
few-shot 固定模板高模板部分免 prefill收益被示例长度占比稀释
每请求独立长文档 RAG低只有公共指令部分命中命中率低时开销主要是块表维护,可接受
短 prompt + 超长输出不适用几乎无不要为此投入命中率工程

批大小:一条曲线上的两个拐点

批大小是唯一一个同时出现在吞吐公式和时延公式里的参数,所以它值得单独一节。直觉上"batch 越大越快"只在前半段成立,曲线后面会连续翻两次。

前半段:为什么批变大一定涨吞吐

前面说过,解码阶段是访存受限:每生成一个 token,都要把全部模型权重从 HBM 读一遍。一个 batch 里只有一条序列时,这遍权重读取只服务一个 token,纯粹浪费带宽。批内有 N 条序列时,同一遍权重读取服务 N 个 token,每 token 的权重读取成本被摊薄到 1/N。这就是"加大 batch 一定涨吞吐"的全部理由,它的数学基础是权重复用,而不是算力利用率。

这也解释了为什么在低并发区间,吞吐随 batch 增长几乎是线性的,而 GPU 监控面板上的"利用率"数字可能一直很难看。利用率低恰恰是还有空间的证据。

第一个拐点:访存受限转算力受限

当批内 token 数大到让矩阵乘法的算术强度越过硬件的 roofline 分界线,单步耗时就不再下降,吞吐停止增长。这个拐点的位置取决于两个量的比值:模型权重占显存的比重,和单步实际参与计算的 token 数。

实践中有两个间接信号可以观察它:单步耗时的下降曲线开始变平,以及提高 max_num_seqs 后吞吐不再变化。如果这两个现象出现,继续加大批大小只是把显存换成了排队时间,没有收益。

第二个拐点:TPOT 越界

拐点之后,批大小继续增加会直接转化为 TPOT 的增长,而且这部分增长没有任何补偿——吞吐不再涨了,用户却实实在在变慢。SLO 一旦定死,这个拐点就是硬边界,它比性能拐点先到,而且不可协商。

所以正确的工作方式是:先从 TPOT P99 反推出 max_num_seqs 的上限,把这个值当约束;然后在约束之下再去追求吞吐。反过来的顺序(先追吞吐再回头看时延)几乎必然返工,因为吞吐的改善是渐进的、看起来很美好,而时延的恶化在 P50 上常常还看不出来。

批大小不是单一数字

还要意识到同一个引擎的"有效批大小"每天都在变:白天高峰时批内 128 条序列,凌晨只有 3 条。这意味着你为峰值调好的参数,在低峰期会让 GPU 大部分时间空转,TTFT 却没有明显变差(因为本来就没排队)。

有两种应对思路。一是接受它,用最低成本的资源覆盖谷值,成本浪费在深夜而不是白天。二是做弹性——按时间或按队列水位切换不同的批大小配置,但这条路的实现复杂度很高,多数团队会选择"多开一个小规格实例来兜底"。

一个容易被忽略的推论是:低峰期是投机解码收益最高的时段。前面算过,访存受限时空闲算力能吸收草稿的额外计算,而低峰期恰恰最接近访存受限。如果你的投机解码在高负载下没收益、在低负载下有收益,说明瓶颈判断是对的,可以考虑只在低峰开启。

请求形态分层:一个配置服务不了所有业务

很多团队上线初期只有一个模型实例、一个配置,然后把所有业务都接上去。跑一段时间后 P99 会开始以一种很难解释的方式劣化——平均指标看起来都正常。原因几乎总是同一个:不同形态的请求在同一个调度器里互相干扰。

五种典型形态

按"输入 token 数 / 输出 token 数"这个比值,线上流量大致可以分成几类,它们的最优配置差异很大:

  1. 长输入短输出:文档摘要、长文理解、RAG 问答。瓶颈在 prefill,TTFT 主导时延。前缀缓存和 chunked prefill 收益最大,投机解码基本无用(没有足够长的 decode 阶段)。
  2. 短输入长输出:长代码生成、思维链推理。瓶颈在 decode,TPOT 主导体感。前缀缓存几乎无用,投机解码收益最大,chunked prefill 的优先级可以降低。
  3. 短输入短输出:分类、意图识别、信息抽取、单轮对话。整体时延小、对排队极敏感。批大小必须压低,否则单请求时延不可接受;吞吐也不重要。
  4. 超长上下文多轮:长会话助手。KV 占用随轮次线性增长,块池压力最大,最容易触发抢占。这类的 SLO 要单独定,不能和前三类混算。
  5. 结构化与并行采样:需要输出 JSON schema 约束、需要在一条 prompt 上采样 n>1 个结果、或者做 beam search。这类的批内形状极不规则,constrained decoding 还要额外跑一遍语法约束,调度开销显著高于自由生成。

分层路由的做法与代价

直接的解法是按形态拆成不同规格的部署:短对话用一个批小、时延优先的实例;长文理解用一个 chunked prefill 调优、显存优先的实例;长代码生成用一个投机解码开启、大批次的实例。前端按请求特征路由过去。

这个方案的代价必须提前算清,主要是三块:第一,显存碎片和调度复杂度——多套实例意味着每套都要为峰值预留资源,利用率天然低于单一大池;第二,负载不均——形态判断可能出错,或者流量本身在时间上波动,某个实例会闲置而另一个排队;第三,运维复杂度——扩容、健康检查、版本管理都要做多份。

我的判断是:只有当 P99 确实被某一种形态拖垮、且形态占比足够高(通常是超过三成)时,拆分才划算。占比小的形态单独拆一个实例,闲置成本会吃掉全部收益。不确定的时候,先在单实例上把 chunked prefill 和前缀缓存调对,这两者的收益在所有形态上都是正的,风险也最低。

结构化输出的特殊调度开销

第五类形态值得单独提一句,因为它在很多团队里是后期才暴露的问题。当使用 guided decoding(按 JSON schema 或 grammar 约束采样)时,采样这一步不再是简单的 multinomial:从上一步的候选 token 里,引擎要维护一个"语法允许的状态",逐步剔除非法 token。这个过程包含序列级的状态推进和额外的 kernel 调度,在高并发下会明显拉长单步耗时。

后果是:structured 请求和 free-form 请求混跑时,前者会拖慢整个批次的单步耗时,表现为两边一起变慢。绕开的办法是让语法状态机的推进与采样 kernel 融合、或者把这类请求路由到独立实例。观察方法是看 TPOT 在结构化流量占比高的时段是否有规律性的抬升。

另外一个小而实用的事实:constrained decoding 的实际接受宽度通常远小于模型的完整词表,如果引擎没有做候选裁剪,每步都在几十个 token 上做完整的采样分布归一化,这是纯粹可省的计算。核对所用引擎是否启用了候选裁剪,是这一步性价比最高的检查。

提示词结构与缓存友好度

前面把前缀缓存当成一个引擎特性来讲,但它真正的效果几乎完全由提示词的组织方式决定。这一节从提示词侧讲,因为这是投入产出比最高的一类工作:不需要动任何引擎参数,收益却可能超过所有参数调优的总和。

按变化频率分层,而不是按长度分层

组织提示词时最容易犯的错误是按"重要程度"或"长度"来排序,把最重要的系统身份说明放在开头,把业务规则放在后面。这个排法在阅读体验上最自然,但恰好和缓存的机制相冲突——因为开头决定了整条链的哈希前缀,一旦开头有任何变化,后面全部失效。

正确的分层原则是按变化频率从低到高排列:永不变的身份与规则放最前;按租户或按产品线变化的部分放中间;每次请求都变的用户输入、检索结果、时间信息放最后。这个原则看起来简单,实践中却经常被破坏,因为新增一条业务规则的人会本能地插在系统提示的开头,完全意识不到这一插把整条缓存打穿了。

判断一段内容该不该放在前缀里,有一个简单的检验:把过去一小时内真实请求的前缀部分抽出来去重,看有多少种不同的组合。如果一个应该共享的部分有几十种变体,说明它里面混进了变化量,需要排查是哪一个字段。这比看总体命中率有用得多,因为它能直接定位到字段级别。

多轮历史的存储方式

多轮对话是前缀缓存收益最大的场景,但也是最容易悄悄破坏的场景。关键在于历史是否被原样重放。如果每一轮都把完整的历史原样拼回去,理论上除了本轮新增内容之外全部可以命中;但如果上游做了任何形式的改写——摘要、截断、重新排序、调整角色标签的措辞、补充时间戳——前缀就会在改写点之后全部失效。

常见的破坏方式有几种值得点名:把历史消息重新序列化时统一了空白或换行;摘要压缩在超过某个轮数后自动触发;为了安全策略在历史里插入过滤说明;多模态场景下把图片转成文字描述后重放。这些改写对模型效果的影响可能很小,但对缓存命中率的影响是灾难性的。

处理方式有两种。一种是在引擎侧做前缀匹配,即不假设前缀一定连续命中,而是逐块向后查找最长可匹配的部分,这样即使中间有一处改写也能保住后面的部分;另一种是在应用侧做稳定性约束,约定历史只允许追加不允许修改,并把摘要的结果作为新的一次"压缩注入"明确标记,让命中率下降变成可预期的事件而不是无声的退化。前者更彻底,后者成本更低。

分支与并行采样

需要一次生成多个候选结果的场景——采样多份、束搜索、智能体在同一个提示上尝试多条路径——它们的缓存复用情况比直觉复杂。如果实现是"复制成多份独立请求",每份都会完整地重新预填充,缓存完全用不上;如果实现是"一次预填充,多条分支共享同一份前缀缓存",那分支之间的公共部分只算一次,这是巨大的差异。

判断方法很直接:看采样条数增加一倍之后,首字延迟和预填充算量是翻倍还是基本不变。如果翻倍,说明分支没有共享前缀,这是最值得先修的一处。修正之后,分支采样几乎是唯一一种"请求量翻倍而算量基本不变"的场景,对多候选类业务是数量级的收益。

工具定义与结构约束的复用

工具调用的场景下,工具名称、参数模式、返回格式这一部分是高度重复的,它们天然应该落在共享前缀里。很多实现会把工具定义注入到提示末尾,而工具列表在每轮对话里是递增的——新工具不断加在后面,导致后面所有内容的位置都在移动,缓存不断失效。更稳的做法是保持工具定义的位置稳定:已经加载过的工具不改动位置,新增工具追加在固定位置之后。

结构化输出的模式定义也有同样的问题。如果每次请求的模式定义在提示里的位置不一致,或者顺序随内容变化,都会造成失效。把模式定义固定放在前缀的固定位置,是一条几乎没有成本的优化。

把提示词纳入版本管理

提示词已经成为一个工程资产,但很多团队仍然把它散落在代码、配置后台、各人的本地文件里。这带来两个直接风险:没有人能说清当前线上跑的是哪一版,以及无法在提示词变更时判断是提示词的问题还是引擎的问题。

务实的最低要求是三条:提示词有明确的版本标识并且随请求一起记录到日志里;变更走评审并且注明是否影响前缀结构;能在几分钟内回滚到上一版。做到这三点之后,前缀命中率的变化就能被正确归因——命中率的骤降要么是提示词改版造成的(可通过版本号确认),要么不是(那才是引擎侧的问题)。没有这个能力,命中率会成为一个没人能解释的指标,而一个没人能解释的核心指标,在出现异常时基本等于没有。

Speculative Decoding:接受率决定一切

它为什么在数学上是无损的

投机解码的逻辑是:用一个便宜的小模型(draft)串行猜出接下来的 γ+1 个 token,然后用大模型(target)一次并行前向验证这 γ+1 个位置。验证时按顺序比对,如果第 i 个 token 与 draft 一致就接受、继续看下一个,一旦不一致就丢弃它之后的所有猜测,从这个位置用 target 自己的分布重新采样。

关键性质是输出分布与只用 target 完全一致——这不是近似加速,是无损加速。draft 只是提出了候选,最终结果由 target 的分布决定。所以它可以放心用在生产里,不像量化那样需要额外的质量回归。

加速比是一个可以手算的数

设每个位置的接受概率为 α(这里假设独立,实际有相关性),长度 γ 的草稿期望被接受 token 数是等比数列求和:

期望接受长度 E[γ] = (1 − α^(γ+1)) / (1 − α)

代入几组值就能感受到 α 的支配地位(γ=3 时):α=0.9 约 3.44,α=0.7 约 2.92,α=0.5 约 1.88,α=0.3 约 1.42。接受率从 0.9 掉到 0.5,期望长度基本腰斩。

而单步成本是:target 前向 ×1 加上 draft 的 γ 次小步。所以实际加速比是期望接受长度 ÷(1 + γ × draft 相对成本)。draft 相对 target 越便宜,γ 才能越大;α 越低,越要靠"draft 足够便宜"来补。真实系统的分布远不止"一个数"这么简单:温度高时 α 迅速下降,长推理链后 draft 和 target 的分布漂移也会压低 α——所以要实测接受长度而不是套公式。

# 投机解码的收益手算:先决定"值不值得开",再去调参
def expected_accepted(alpha, gamma):
    """长度 gamma 的草稿,期望被接受的 token 数"""
    if alpha >= 1.0:
        return gamma + 1
    return (1 - alpha ** (gamma + 1)) / (1 - alpha)

def speedup(alpha, gamma, draft_cost=0.05, mem_bandwidth_free=0.5):
    """
    draft_cost   : draft 单步耗时 / target 单步耗时
    mem_bandwidth_free:
        0.0 -> GPU 已经完全算力饱和,多花的计算无法重叠,加速比被完全吃掉
        1.0 -> GPU 处于访存受限状态,draft 的计算几乎免费,加速比接近期望长度
    """
    if alpha <= 0.0:
        return 0.0
    cost = 1.0 + gamma * draft_cost
    gain = expected_accepted(alpha, gamma)
    # 空闲带宽能吸收一部分 draft 计算成本
    return 1.0 + (gain - 1.0) * mem_bandwidth_free / cost

print("gamma  alpha  期望长度  访存受限  算力饱和")
for alpha in (0.9, 0.8, 0.7, 0.5):
    for gamma in (1, 3, 5):
        e = expected_accepted(alpha, gamma)
        lo = speedup(alpha, gamma, mem_bandwidth_free=1.0)
        hi = speedup(alpha, gamma, mem_bandwidth_free=0.0)
        print(f"{gamma:>5}  {alpha:>5}  {e:>8.2f}  {lo:>8.2f}x {hi:>8.2f}x")

这个表要说明的事:同一个配置,在访存受限(低并发、小 batch)和算力饱和(高并发、大 batch)两种状态下,加速比可能差好几倍。高负载时 GPU 本来就没闲余算力,再塞进 draft 的计算就是纯粹的开销,此时投机解码可能让吞吐变差。所以"开了投机解码就能快"的预期不成立,它和 batch 大小是耦合的。

草稿模型怎么选

路线大致分四类,各有适用边界:

  • 独立小模型(n-gram / prompt lookup / REST 那一路):不引入任何额外模型,纯粹靠文本自重复——把 prompt 或最近输出里出现过的 token 片段当作草稿。零显存、零权重开销,适合摘要改写、文档问答、代码补全这类高重复负载;创造性生成几乎无效。收益上限低,但胜在简单可靠、不需要训练。
  • 同族小模型:用一个同 tokenizer、同系列的小模型做 draft。实现简单,α 取决于两个模型的分布接近度,质量差的近邻模型反而拉低 α。
  • 特征/多头的自投机(EAGLE、Medusa 这一路):不额外加载完整模型,而是在主模型上加轻量的"下一特征预测"头或多个解码头,复用主模型的隐状态预测未来 token。α 明显更高,是目前主流选择。
  • MTP 自投机:如果基座模型本身训练时就带多 token 预测头(MTP),那些头天然就是最贴合的 draft——它们和主模型共享表示、显存增量极小、分布一致性最好。这是架构层面的"免费午餐",但只对原生带 MTP 的模型成立。

必须实测的三个数

不要在白板上决定要不要上投机解码,直接测:接受长度、开启前后 TPOT、开启前后整体吞吐。三个数缺一不可——只看 TPOT 会漏掉"单请求快了但整体吞吐掉了"的陷阱。典型观测方式:

# 投机解码相关观测(参数名以所用版本文档为准)
# vLLM 侧
#   --speculative-config '{"method":"ngram","n":5,"prompt_lookup_min":2,"num_speculative_tokens":5}'
#   method 也可为 "eagle" / "eagle3" / "medusa",需配套加载 draft 权重
# SGLang 侧
#   --speculative-algorithm EAGLE3
#   --speculative-draft-model-path /path/to/draft
#   --speculative-num-steps 3        # 草稿步数 gamma
#   --speculative-eagle-topk 4       # 候选分支宽度

# 上线前必须同时记录的三个数
#   1) 实际接受长度 spec_accept_length 分布(不是均值,要看 P10)
#   2) TPOT P50/P99
#   3) 集群总吞吐 tokens/s
# 判定规则:若总吞吐下降,无论 TPOT 改善多少,都不开启。

还有一个容易被忽略的边界:多模态请求和超长上下文上,draft 的输入构造和缓存组织更复杂,实际收益需要单独验证,不要外推纯文本的结论。

多租户调度:抢占、公平与 QoS

显存不够时只能抢

连续批处理没有静态预留,KV 块是动态分配的。某时刻新来的请求需要一大块连续 KV,块池不够了,引擎只有三条路:拒绝(返回 503 或排队,取决于实现)、换出(swap)把受害者的 KV 块写到主机内存、重算(recompute)直接丢弃让它从头再来。

重算的代价是灾难性的:一个 20K 上下文的会话被抢,重算意味着重新付 20K token 的 prefill。所以主流引擎都会优先选择按"最近最少被访问"或"重算代价最高"来挑受害者,尽量避开长上下文和已生成大量内容的请求。swap 便宜但会引入一次几 GB 级的 PCIe 传输,NVLink 主机互联可用时会好很多。

排队策略的选择

先来先服务(FCFS)实现简单、延迟可预测,但有个反直觉的性质:在显存受限的场景下,LIFO(后到先服务,即后来的请求优先)反而能给出明显更高的集群吞吐,原因是新请求的上下文通常比已经跑了一半的老请求短,占的 KV 更少,单位显存能支撑更多并发;vLLM 的相关工作里报告过在内存墙场景下这一差距可达数倍。

但 LIFO 对交互式服务是不可接受的——老请求可能被无限期饿死。实用的折中是:默认 FCFS 保证公平,检测到显存压力或抢占频繁时切到 LIFO 式的短作业优先,或者用"按剩余长度/剩余时间最短优先"。这不是纯理论选择,它直接决定了你集群在压力下的实际容量。

公平性:真正的多租户问题

同一集群服务多个业务方时,"公平"要拆成三层看:

  • 准入公平:一个租户能不能把 batch 占满。答案是能——只要它的请求足够多,FCFS 下后到的其他租户会一直排队。对策是按租户做令牌桶限流,或者在调度器里给每个租户一个 batch 槽位上限。
  • 排队公平:长请求和短请求混排时,长的会等更久。批内并发越高,等待时间方差越大。对交互业务通常要限制 max_num_seqs,宁可吞吐低一点也不要让 P99 排队爆炸。
  • 抢占公平:最危险的租户隔离缺口——租户 A 的长请求被反复抢,等于让 A 反复重付 prefill。要监控按租户拆分的抢占次数。

QoS 上的实用做法是分层:给不同 SLA 的业务不同的优先级和独立的并发配额,调度器按优先级出队,同优先级内 FCFS。配合准入侧的限流,比在调度器里做复杂的公平共享算法要稳得多,也更容易解释和排查。

PD 分离:把混批这个根因拆掉

chunked prefill 是在"混批"框架里缓解队头阻塞,而 PD 分离(prefill/decode disaggregation)是直接拆开:prefill 实例和 decode 实例用不同的部署、不同的 batch 策略,各自最优,再通过网络把 KV 传过去。

好处很实在:prefill 是算力受限、batch 越小越快;decode 是访存受限、需要大 batch 摊薄权重读取。混在一起时它们互相妥协,谁都跑不到最优。分开之后两边可以各自调参,还可以用不同规格的机器(prefill 用算力强的、decode 用带宽大的)。

代价是 KV 传输。长上下文的 KV 有几百 MB 到几 GB,跨机传输很容易成为新瓶颈,必须走 RDMA 之类的直通路径,这也是 KV 传输库(NIXL 这一类)出现的原因。判断该不该上 PD 分离的经验标准:长 prompt(数千 token 以上)占比高、且 TPOT 抖动明显时收益最大;短 prompt 短输出的负载上,传输开销往往吃掉全部收益。

一个调度步内部的时间构成

前面所有的分析都建立在"单步耗时"这个量上,但很少有人真正去看一个调度步里到底装了什么。理解这一点,才能解释为什么同样的模型、同样的配置,不同实现之间的性能差距可以有一倍以上。

一个步的六个阶段

从调度器决定这一轮跑什么,到结果被用户拿到,中间大致是六段:

  1. 状态准备:把这一批序列的当前长度、块表、位置编号整理成设备能直接消费的索引张量。这一步在动态批处理下不是免费的——每轮序列集合都在变,索引要重算。
  2. 前向计算:注意力加前馈网络,这是唯一真正做数学运算的部分,也是唯一能在显卡上大规模并行的部分。
  3. 采样:从输出分布里取一个词元。包括概率归一化、随机数生成、受限解码时的语法过滤、以及多序列场景下的批量处理。
  4. 通信与同步:多卡之间的张量同步,以及设备与主机之间的数据搬运。
  5. 状态更新:把新词元写回序列、追加块引用、释放结束的序列、更新前缀缓存的命中与驱逐。
  6. 结果送达:把新生成的词元发回请求方。

关键洞察是:只有第二段是"必要成本",其余五段都是调度开销。当单步耗时里前向只占一半时,另一半就是在为调度付账。模型的规模越小、批内的序列越短,调度开销的占比越高——这也是小模型上引擎实现的差异被放大的原因。

同步点是最贵的部分

一个容易被低估的杀手是主机与设备之间的同步等待。任何一次需要等待显卡算完才能继续的调用(读回结果、取状态、做分支判断)都会让显卡的计算流水线被强制排空。批内序列数量少的时候,计算本来就填不满流水线,再被同步打断,显卡的实际利用率会掉到很低的水平。

这也解释了为什么"批内序列越多越好"这个直觉在某个范围内成立:序列多,显卡有足够多的工作可以掩盖调度开销;序列少,一次同步的固定成本被摊在很少的词元上,单位成本急剧上升。批大小曲线的第一个拐点,本质上就是调度开销被完全摊薄的那个点。

可以自己量的方法

不要靠猜。多数推理框架都提供逐阶段计时或性能分析能力,典型的做法是把一个步拆成几段分别打时间戳,重点看三件事:前向计算占单步的比例、采样与状态更新的绝对耗时、以及是否存在意外的同步等待。

如果前向占比明显偏低,优先怀疑批内序列数不足或者存在同步点;如果采样占比异常高,检查受限解码的语法状态机是不是没有做候选裁剪;如果状态更新占比高,多半是块表维护或前缀查找的开销,需要确认相关实现是不是放在了关键路径上。

这套方法还有一个额外好处:它把"这个引擎为什么慢"从一个玄学问题变成了一个可以定位到具体代码段的问题。调优的每一条建议都应该能对应到一个可测量的阶段,否则就只是别人的经验值。

从单机到多机:并行的代价

前面所有讨论都默认了"一个模型放在一套设备上"。真实生产里模型常常放不下,必须切分到多卡甚至多机,而并行本身要付出通信代价。这一节的结论可能会和很多人的直觉相反。

三种切分方式

  • 张量并行:把权重矩阵按行或按列切开,每张卡只算一部分,层内做一次全规约或全归约再往下走。通信最频繁,量级是每层若干次、每次传输激活值。它能扩展的前提是设备之间有高速互联。
  • 流水并行:把不同的层分给不同的设备,一个设备的输出直接喂给下一个。通信量小(只有层边界的激活值),但存在流水气泡:所有设备都做同一种计算,效率受最慢那张卡限制。
  • 专家并行:针对混合专家结构的模型,把不同的专家分给不同设备,每个词元只路由到少数几个专家。通信是稀疏的、每层一次,量级介于前两者之间。

实际部署里通常是组合:设备数少时用张量并行,设备数多时再加流水并行分层。但组合方式的选择直接决定性能上限,选错的代价往往比调参数大得多。

通信量的粗略估算

张量并行的通信量随"隐藏维度 × 序列内词元数"线性增长,与层数相乘后总量相当可观。用一个直观的方式判断:每层的全归约至少要传一次隐藏维度的激活,而隐藏维度的激活张量在长上下文下并不小。当上下文长度从几千涨到几万,通信量也跟着涨,而此时互联带宽如果走的是通用外设通道而不是卡间直连,差距会被放大到无法忽略。

所以有一条实用的经验:优先把并行切分放在同一台物理机内完成,并且优先使用带高速卡间直连的机器。跨机切分只在单机放不下时才用,而且要接受吞吐上的明显损失。这一条在采购和部署规划阶段就该定下来,而不是等到压测发现不对再返工。

并行度不是越大越好

张量并行有一个反直觉的规律:并行度增加时,单卡上的矩阵变小,算术强度下降,访存受限问题反而恶化。原本单卡就能高效运行的批次,切到多卡之后每卡都吃不饱,还要额外付通信。同时,所有同步点必须等最慢的那张卡,整体延迟由最慢设备决定,木桶效应明显。

这就是为什么"用更多的卡跑同一个模型"经常让单请求时延变差、只有总吞吐小幅提升。具体的最优点取决于模型的隐藏维度、上下文长度、互联带宽和批内序列数,没有通用答案——唯一可靠的方法是切分粒度逐级实测,记录每一步的单请求时延和总吞吐两个数。经验上,矩阵在切到某个尺寸之前,加速比接近线性;越过之后开始明显衰减。

多实例还是单机切分

另一个更早的决策是:是买更多的小机器各跑一份完整模型,还是买大机器切分模型。前者是数据并行思路,天然隔离、扩展线性、没有通信、没有跨机的可靠性问题;后者是模型并行思路,延迟最优但通信和故障域都集中。

对大多数以吞吐为目标的在线服务,答案偏向多实例:多个完整副本可以让负载均衡器按实时负载分流,某个实例出问题可以直接摘掉,其余不受影响,也没有跨机通信。代价是单请求的绝对时延不会因为设备变多而变快。什么时候需要模型切分?只有当单个副本的显存装不下模型与足够并发的组合时——这是硬约束,不是性能选择。

多实例方案要额外解决的是请求路由的质量。按最短队列路由是最常见的做法,但不同请求的形态差异很大(前面讲的短对话和长文总结),纯粹按队列长度路由会把长请求都堆到某个实例上。改进的方向是路由时同时考虑长度预估,或者干脆按形态路由到不同规格的实例池。路由策略不当造成的尾延迟,在监控上表现为各实例负载严重不均——这是一个很实用的排查信号。

请求链路上的非推理开销

前面所有分析都从"请求进入调度器"开始算起,但用户等待的时间还包括进来和出去的部分。这些部分在首字延迟的分解里经常被整体忽略,而它们在短请求场景下可能占一半以上。

进来的一段:分词与准入

请求从网关进来要经过鉴权、限流、格式校验、提示词组装、分词,然后才交给引擎。这几件事都是逐请求的固定成本,在短请求上非常显著。更隐蔽的是分词本身的耗时随输入长度线性增长,而且很多分词实现是纯字符扫描,不是矩阵并行运算,在超长输入上会明显拖慢。

如果首字延迟的分解显示"引擎内部只占三分之一",那么剩下的三分之二大概率在这里。能做的优化按性价比排序:把能在网关侧确定的工作前置(限流、鉴权、格式校验的判定尽量不阻塞后续步骤)、分词并行化(长输入按字节边界切开后并行处理,注意要保证切分不会破坏多字节字符和多词元的合并)、避免重复分词(有些实现会在提示词组装和引擎入口各分词一次)。

出去的一段:还原词元与流式推送

生成端的开销同样被低估。引擎内部拿到的是词元编号,要还原成文本需要一次解码;如果是流式输出,还要在每个词元上做一次解码并处理跨词元的字符拼接问题——一个字符可能被拆在两个词元里,不能直接拼接,否则会出现乱码。流式推送还要应对背压:当客户端消费得慢时,发送侧要么缓冲要么阻塞,两种选择都会占用服务端资源。

这里有一个经常被忽略的延迟来源:如果框架为了减少推送次数而做了微批处理,单个词元的到达会被人为推迟。这个微批时间通常是几毫秒量级,在每秒几十个词元的解码速度下并不明显,但会叠加到首字延迟上,并且在客户端做逐字渲染时是可见的。

网络往返被重复计算

流式服务有一个容易被误判的现象:客户端测到的"首字延迟"包含了完整的网络往返,而网络往返的次数往往不止一次。健康检查、重试、代理层缓冲、负载均衡的连接复用策略、是否启用协议压缩,都会影响实际往返次数。如果中间有一层做了响应缓冲,那么流式效果会被完全抹平——用户等到的不是逐字出现,而是一次性出现。

排查这类问题的方法是从客户端侧直接观察词元到达的时间戳序列,而不是看服务端的日志。服务端日志里的发送时间不能反映客户端的接收时间,两者的差值就是链路上的额外延迟。压测脚本如果用汇聚式的客户端库而不真正逐条读取时间戳,会系统性地低估这部分开销——这正是前面那个压测骨架坚持记录每个词元到达时刻的原因。

固定成本的实测方法

把首字延迟拆成"客户端观察到的"和"引擎内部记录的"两段对比,差值就是链路开销。前提是两个时间戳来自同一个单调时钟源——如果服务端和客户端的时钟不同步,差值不可信。稳妥的做法是在客户端记录到达时间,在服务端记录发出时间,两边都上报,靠日志对齐,这样不依赖跨机时钟精度。

对链路开销的判断标准是:它在请求之间是相对稳定的固定成本,还是随并发上升的。固定的那部分只能靠减少环节来优化,随并发上升的那部分说明存在连接数瓶颈、代理层排队或发送侧背压,这两类问题要用完全不同的手段处理。前者改架构,后者调容量。

为什么这一节值得单独讲

因为它决定了一个常见的判断错误:把链路开销误算成引擎性能不足,然后花几周去调引擎参数。如果短请求的首字延迟里有三成来自链路,那么把引擎吞吐翻倍对用户的体感改善非常有限,而把微批时间从十毫秒降到两毫秒,效果立竿见影。

排查这类误判的方法是尽早做一次链路剖析:在压测环境里绕过网关直连引擎,绕过流式改成一次性返回,再逐步把环节加回来。每加回一层就测一次,观察首字延迟的变化曲线。哪一段的斜率最大,收益就来自哪里,这比任何经验判断都可靠。

采样与解码参数的性能代价

采样在解码循环里每一步都要执行,但它到底是贵还是便宜,取决于具体启用了哪些功能。这一节容易被忽略,因为大多数人默认"采样就是个取样操作"。实际上在某些配置下,采样是除了注意力之外最贵的部分。

基础采样的成本

最简单的贪心解码只需要取最大概率的词元,代价可以忽略。换成随机采样就要先生成随机数、计算累积概率、定位采样位置,这几步都是逐序列的小规模运算,成本不高但也不为零。批量采样可以把这些运算向量化,代价可接受。真正的问题出在需要逐词元串行处理的功能上——下面几类都属于这一类。

逐词元的惩罚类处理

重复惩罚、频率惩罚、存在惩罚这类功能,需要在每一步都扫描该序列已经生成的全部词元,统计出现次数,然后对候选分布做调整。开销与"已生成长度"成正比,所以在长输出场景下它会随生成过程线性增长。

这带来一个反直觉的结论:同样的模型和同样的批大小,输出越长,采样开销占比越高。在长代码生成这类场景下,等到生成了几千个词元时,采样可能已经从可忽略变成可观的开销。检查方法很简单:对比启用和禁用惩罚项时的单步耗时曲线,看两者差值是否随生成长度单调增大。

这类功能能不能优化,取决于实现的算法。朴素实现是每步全量扫描,最坏情况下是二次复杂度;用增量维护的方式(生成一个词元就更新它的计数,每步只对候选集做处理)可以把成本降到接近线性。是否做了优化,是区分不同引擎实现质量的一个具体指标。

候选裁剪与受限解码

受限解码要让输出满足语法或模式约束,实现上需要维护一个"当前允许的词元集合"状态,每一步根据集合过滤候选,然后用新的状态继续。这部分工作天然是串行的、依赖上一步结果的,很难完全向量化。

两个有效的优化方向:候选裁剪——在不违反约束的前提下,把候选集缩到实际可能出现的十几个,而不是让模型在整个词表上做完整的概率归一化;状态缓存——同一个约束状态可能被多个请求共享,缓存可以避免重复的状态推进。如果不裁剪而直接在全词表上做,每步都要处理几万个词元的归一化,这部分开销完全可以省掉,属于检查一次就能拿到的收益。

随机数与可复现性

流式生成要保证同一随机种子下的输出可复现,这就要求随机数生成器在多请求并发时有确定的状态管理。实现上有两种常见做法:每请求一个生成器,状态独立、可复现,但生成器数量多时的管理开销和缓存局部性都变差;全局统一生成器,性能好但并发下不可复现。

生产上的折中通常是:默认走高性能路径,需要复现时通过请求参数指定确定性的生成方式,并接受这条路径的额外开销。值得检查的一点是:复现开关打开后的性能损失有多大,如果损失明显,说明生成器状态管理的方式有优化空间,而不是说复现本身很贵。

这些开销汇总到哪

回到前面"一个调度步的时间构成"这个框架:采样阶段的耗时占比是一个可以直接测量的量。如果它超过了单步耗时的两成,就值得投入精力去优化;如果不到一成,精力应该放到别的地方。这个判断避免了两种常见错误——为了优化一个占比百分之三的开销去改引擎源码,或者放任一个占比百分之三十的开销存在却一直在调批大小。

还有一个横向的观察角度:同一个引擎在不同功能组合下的性能排序是不同的。一个引擎在自由生成上领先,不等于在带约束的生成上领先,因为两者的瓶颈分别在计算和采样。当业务从自由生成转向大量结构化输出时,这个排序会发生变化,需要重新评估。这是一个容易被长期忽视的选型陷阱——用一种业务的表现去推断另一种业务的表现。

TTFT 与 TPOT 为什么必须分开看

一个指标能同时骗你两次

回到开头那个式子:总时延 = TTFT + (n−1) × TPOT。两个因素相乘相加,意味着同样的端到端时延可以由完全不同的组合产生。

设想两个引擎:A 的 TTFT 是 2 秒、TPOT 是 20 毫秒,输出 50 token,总时延 3 秒;B 的 TTFT 是 200 毫秒、TPOT 是 60 毫秒,总时延也是 3 秒。用户实际感受完全不同——A 是"等半天然后流畅喷出",B 是"秒回但一直在挤牙膏"。流式产品的体感几乎完全由 TPOT 决定,而用户是否放弃点击由 TTFT 决定。

更糟的是单看吞吐也会骗人。把 batch 从 32 调到 64,集群吞吐上升,TPOT 几乎翻倍,端到端时延没变甚至变差,但吞吐图的曲线很漂亮。吞吐和时延必须成对汇报,这也是所有严肃推理基准都同时输出两组数字的原因。

该盯的指标清单

指标定义主要影响因素劣化时的典型症状
TTFT P50/P99请求发出到第一个 token 返回排队长度、prefill 算力、prefix 命中率等待队列积压、命中率为 0
TPOT / ITL P50/P99相邻输出 token 的间隔batch 大小、chunked prefill 配置、量化 kernel大 prefill 造成周期性尖刺
排队时延进入引擎到开始被调度的时长准入速率、并发上限等待队列持续增长
吞吐 tokens/s集群单位时间总输出 token并发、批大小、量化利用率低但队列不长 → 调度器卡点
goodput满足 TTFT/TPOT SLO 的请求占比上述全部比吞吐更接近真实业务价值
抢占次数/秒被驱逐重算或换出的请求数KV 块池水位、max_model_len尖峰伴随 TTFT 恶化
接受长度投机解码平均接受 token 数draft 质量、负载饱和度下降说明负载变重或 draft 失配

务必看分位数而不是均值。推理服务的 P99 恶化往往先于均值变化出现,而均值会把尖刺完全抹平。线上建议至少对 TTFT 和 TPOT 各配一组 P50/P90/P99 告警。

一个能用的压测脚本骨架

调优的输入必须是可复现的压测数据。关键是用真实的到达模式——固定并发压测完全测不出排队行为,而排队恰恰是时延的主要来源:

# 压测骨架:同时产出 TTFT / TPOT / 吞吐三个视角
# pip install aiohttp numpy  (或直接用 openai SDK)
import asyncio, time, json, statistics, random
import aiohttp

TARGET = "http://127.0.0.1:8000/v1/completions"
MODEL  = "your-model"
PROMPT_TOKENS = 2000      # 覆盖你真实的 prompt 长度分布
OUT_TOKENS    = 300       # 覆盖真实的输出长度分布
CONCURRENCY   = 32
DURATION_S    = 300

def make_prompt(seed):
    # 注意:这里刻意让每个请求的 prompt 不同,以排除 prefix cache 命中
    # 测 prefix cache 时要改成"共享长前缀 + 变化后缀"的形态
    head = f"[req {seed}] " + "context " * (PROMPT_TOKENS // 2)
    return head + " ".join(f"t{seed}_{i}" for i in range(PROMPT_TOKENS // 2))

async def one(session, sem, stats, seed):
    async with sem:
        body = {"model": MODEL, "prompt": make_prompt(seed),
                "max_tokens": OUT_TOKENS, "temperature": 0, "stream": True}
        t0 = time.perf_counter()
        first_t, last_t, n = None, None, 0
        try:
            async with session.post(TARGET, json=body,
                                    timeout=aiohttp.ClientTimeout(total=300)) as r:
                async for raw in r.content:
                    line = raw.decode("utf-8", "ignore").strip()
                    if not line.startswith("data: "):
                        continue
                    if "token" not in line:
                        continue
                    now = time.perf_counter()
                    if first_t is None:
                        first_t = now
                    last_t = now
                    n += 1
        except Exception as e:
            stats["err"] += 1
            return
        if first_t is None:
            stats["err"] += 1
            return
        stats["ttft"].append(first_t - t0)
        if n > 1:
            stats["tpot"].append((last_t - first_t) / (n - 1))
        stats["out_tok"] += n

def pct(xs, p):
    return statistics.quantiles(xs, n=100)[p - 1] if len(xs) > 5 else 0.0

async def main():
    sem = asyncio.Semaphore(CONCURRENCY)
    stats = {"ttft": [], "tpot": [], "out_tok": 0, "err": 0}
    t0 = time.perf_counter()
    conn = aiohttp.TCPConnector(limit=CONCURRENCY * 2)
    async with aiohttp.ClientSession(connector=conn) as session:
        async def worker_loop(wid):
            seed = 0
            while time.perf_counter() - t0 < DURATION_S:
                await one(session, sem, stats, f"{wid}-{seed}")
                seed += 1
        await asyncio.gather(*[worker_loop(i) for i in range(CONCURRENCY)])
    wall = time.perf_counter() - t0
    print(json.dumps({
        "wall_s": round(wall, 1),
        "ttft_p50": round(pct(stats["ttft"], 50), 4),
        "ttft_p99": round(pct(stats["ttft"], 99), 4),
        "tpot_p50": round(pct(stats["tpot"], 50), 5),
        "tpot_p99": round(pct(stats["tpot"], 99), 5),
        "throughput_tok_s": round(stats["out_tok"] / wall, 1),
        "errors": stats["err"],
    }, ensure_ascii=False, indent=2))

asyncio.run(main())

用法上有三点纪律:第一,基线要重跑,改一个参数就压一轮,不重跑基线的对比没有意义;第二,噪声要压住,同配置至少重复三次取中位数,压测机上不要同时跑别的任务;第三,测 prefix cache 和测基础 prefill 要用两套 prompt,用同一套等于把两件事混在一起,永远得不出干净结论。

量化与调度:互相制约的两套系统

权重量化与 KV 量化的收益完全不同

权重做 INT8 / FP8 / INT4(GPTQ、AWQ 这类)省的是模型那一份固定显存,直接换来更多 KV 空间和更大 batch,属于"结构性收益";KV Cache 做量化省的是 2 × L × H_kv × d × bytes 里的 bytes,同样直接放大并发。两者量化的对象不同,收益路径不同,应该分开评估、分开决策,不要打包成一个"开了量化"的结论。

代价侧也不同。权重量化主要影响模型输出的数值精度,属于可以离线回归的质量问题;KV 量化的风险随序列变长而累积——长上下文 + KV 量化是最容易出问题的组合,因为误差在长序列上会持续积累,表现为检索类任务准确率明显下降、或者长链路推理中途跑偏。

FP8 KV 的工程约束

FP8 KV Cache 需要 attention kernel 原生支持。FlashAttention 3、FlashInfer 以及各家推理引擎的定制 kernel 已经提供了不同粒度的支持:per-tensor、per-head、per-token 的缩放因子。粒度越细精度越好,但 kernel 里的额外计算和内存访问也越多——在访存受限的解码阶段,这个额外开销直接吃掉一部分并发收益。所以"换成 FP8 之后吞吐一定涨 80%"是过度承诺,真实提升取决于你的 kernel 成熟度、batch 大小和上下文长度。部署前务必用真实长上下文任务做精度对比,不能只看困惑度。

量化和调度的耦合点

这一点很少被明说:量化的目标是"用更少的显存换更大的 batch",而更大的 batch 会把系统推向算力饱和,算力饱和又会反过来改变所有调度类优化的最优解。

具体表现为三条:

  • 投机解码的收益下降。前面算过,访存受限时空闲带宽能吸收 draft 的计算;算力饱和后不能。量化把系统推向高 batch,很可能正好越过这个临界点。
  • 最优批大小漂移。同一个 max_num_seqs,FP8 下应该比 FP16 设得更高,之前的经验值全部作废。
  • 注意力计算占比上升。KV 变小后,权重读取在单步耗时中的占比下降,注意力 kernel 的效率权重上升,attention 实现质量的影响变大。

所以顺序必须是:先定调度结构,再定精度,最后才上投机解码。反过来做,每一步的结论都会在下一步被推翻。

长上下文:需要单独一套判断

把上下文长度当成一个普通参数来调是长上下文场景最常见的错误。8K 时代积累下来的所有经验,在 128K 上大部分不再成立,原因很简单:调度器的很多代价是随序列长度线性增长的。

线性增长的代价藏在哪儿

第一个直接代价是显存账本本身。按前面的公式,KV 占用与序列长度成正比,128K 上下文是 8K 的十六倍。这意味着即使 max_num_seqs 不变,实际能并发的请求数也只有十六分之一。这个数量级的下降会立刻引发连锁反应:

  • 并发塌缩到个位数,任何一次突发都会立刻排队,TTFT 的 P99 会被彻底拉平(永远不下降)。
  • 抢占变得极其昂贵——一次重算意味着重新 prefill 上万甚至十几万 token,代价是正常请求的几十倍。
  • 块池水位长期在 90% 以上抖动,LRU 驱逐持续发生,前缀缓存的命中率被驱逐噪声污染。

第二个代价是单步耗时的分布变得极不均匀。一个 128K 的 prefill 即使切块,每一步也要做几万 token 的注意力计算,而这部分计算量随序列长度增长;而 decode 步的计算量基本不增长。同一个 batch 里,prefill 步和 decode 步的耗时相差一个数量级,调度器很难找到让两者都满意的配置。

TTFT 的非线性恶化

长上下文下 TTFT 不会随上下文长度线性增长,而是更陡。原因是排队长度对可用显存极其敏感:并发塌缩导致排队,请求数增加又进一步压缩单请求能分到的 KV 块,触发的抢占又让部分请求重算、占用更久。这是一个正反馈。

所以长上下文场景的容量规划必须从"卡能放多少 KV"开始倒推,而不是从"单卡能跑多少 QPS"开始。实际做法是先用前面那个公式算出在可接受排队时延下的安全并发数,再乘上实例数得到总容量,然后反过来验证业务峰值是否落在容量内。留出的余量要比短上下文场景更大,因为长上下文的方差本身也更大。

RoPE 扩展带来的额外风险

当模型实际支持的上下文远大于训练长度时,通常需要做位置编码外推(YaRN、NTK 之类的插值方案)。这件事和调度系统看似无关,但它有一个被普遍低估的影响:外推后的分布和原生长度内不一样,草稿模型、量化模型、小模型辅助解码这些依赖"与主模型分布接近"的技术,接受率会明显下降。

换句话说,一个在 4K 上下文上接受率很好的投机解码配置,扩到 64K 上可能收益腰斩,因为它在大位置上已经跟不上主模型了。所以长上下文的投机解码参数必须重新实测,不能沿用短上下文的结论。同样的道理适用于 KV 量化:位置外推放大了输入分布的变化,量化误差在这种分布上更容易被放大。

长上下文的正确拆解方式

到这一步,PD 分离的价值才真正体现出来。长 prompt 让 prefill 成为一个巨大的、算力密集的、时长以百毫秒到秒计的独立阶段,把它和需要稳定小步长的 decode 混在一起,两个都做不好。分离之后:

  • prefill 实例可以设大 chunk、大批、高算力利用率,追求单请求 prefill 完成速度;
  • decode 实例可以设小批、KV 优先,追求 TPOT 平滑;
  • 两者之间用 KV 传输连接,长上下文的 KV 虽然大,但传一次而不是让 decode 反复读取。

代价前面说过是 KV 传输。这里补一个容易被忽略的工程细节:KV 传输的耗时必须显著小于它节省的排队时间,否则分离是净亏损。所以判断标准是看 prefill 实例是否因为混批导致的排队占据了 TTFT 的主要部分——如果 TTFT 里排队占比很小,PD 分离的收益就主要来自吞吐而非时延,传输开销会吃掉一部分。

KV 压缩的收益递减

MLA、跨步 KV 压缩、KV 驱逐策略这类技术的第一性目标是缩小 KV 占用。但在实际工程里,压缩带来的收益会递减,原因有两层。

第一层是占用不再是大瓶颈之后:如果你的 GPU 算力已经跑满,KV 变小并不会让吞吐继续涨,只是把瓶颈从显存挪到了算力,此时压缩的边际价值骤降。第二层是精度代价会随长度累积:压缩越激进,长上下文上的信息损失越明显,而长上下文恰恰是压缩想解决的问题本身——这是一个内在矛盾。

我的建议顺序是:先用分页把浪费降到最低(零精度代价),再上 FP8 KV(代价可控、有成熟 kernel),最后才考虑更激进的压缩或驱逐策略(需要针对自己的任务做精度验证)。跳过前面直接上激进压缩,往往是在为一个还不是瓶颈的问题付出精度代价。

可复现性:性能数字为什么会漂

推理调优最消耗人的部分不是分析指标,而是发现上周的结论这周复现不出来。性能数字漂移的原因大多可以归类,处理方式也不难,难的是第一次遇到时容易怀疑是代码改坏了。

冷启动与预热

新起的实例在承接真实流量前,有一段明显的性能爬坡期:权重从磁盘或网络加载、显存分配器初始化、图编译或内核自动调优(首次遇到某种形状时的开销可能远高于稳态)、KV 块池预分配。这些成本在压测的前几十秒里集中出现,会让 TTFT 的均值严重失真。

纪律性的做法是:压测前发一段固定配置的预热流量,预热时长至少覆盖到所有会出现的形状(尤其是最大批大小和最大 chunk),预热数据不计入统计。同时单独记录冷启动的 TTFT 分布——如果你的服务需要频繁扩缩容,冷启动本身就是一条独立的 SLO,很多线上抖动其实是扩容引起的。

稳态噪声源

即使进入稳态,以下因素也会让数字在几个百分点内波动:

  • 时钟与功耗:消费级或无锁频的卡在长负载下会热降频,压测越到后面越慢。跨轮次比较时温度是个隐藏变量。
  • 邻居噪声:同一台物理机上的其他进程、共享的 PCIe 链路、甚至同一机柜的风扇。
  • 请求分布的采样波动:压测请求的 token 长度是随机的,样本量不够时均值方差会很大。
  • 命中率的热身效应:前缀缓存的命中率在压测初期会随共享前缀被逐步"烘热"而上升,不预热就得出的命中率会系统性偏低。
  • 垃圾回收与内存整理:某些实现的周期性内存整理会造成可观测的周期性尖刺。

应对手段是同配置重复至少三次取中位数、锁定请求分布(预生成固定 token 序列而不是每次随机)、以及在报告里附上压测时的硬件状态。单次压测的差异小于 5% 时,应当视为噪声而不是改进。

做可信的 A/B

调优阶段的 A/B 有一个特殊困难:两个配置很难在同时段运行,而不同时段又刚好撞上上面那些噪声源。缓解办法有几个:交替运行(A、B、A、B)而不是先后跑完再换;固定请求序列回放;用同一台机器的同型号卡对比,避免跨机器。

还有一个方法论上的建议:每次只动一个变量,并且把改动理由写在压测记录里。推理系统的参数之间耦合很重,同时改三样然后看到吞吐涨了 20%,你无法知道是哪一样有效,也无法在效果回退时定位。中间过程里那些"看起来没用但其实重要"的结论,往往就是来自这条纪律。

最后一点:压测结论要标注负载假设。记录下来的应该是"在 32 并发、2K 输入 300 输出、命中率 X% 的负载下,配置 A 的 TPOT P99 为 Y 毫秒",而不是"配置 A 更快"。前者可以在三个月后被重新验证,后者只能被重新猜测。

把指标体系落到服务承诺上

前面给了指标清单,但指标本身不解决问题——难的是把它们收敛成一条可以对外承诺、可以对内追责的线。这一节讲怎么把指标变成服务承诺。

先定目标,再谈优化

绝大多数调优失败在起跑线上:没有一个明确的、写在纸上的目标时,每次改动的判断依据都是"感觉变好了",而感觉的分辨率远低于真实差异。写下来的目标必须满足三个条件——有具体数值、有明确的对象(哪个业务、哪类请求)、有截止时间。"提升响应速度"不是目标,"把文档问答场景的首字延迟九十分位从二点四秒降到一点五秒以内"才是。

目标还要分层:对外的用户体验承诺是一条,内部的可观测健康线是另一条。前者用来判断是否达成、是否需要回滚;后者用来提前预警——比如"命中率低于某个水平就告警"、"抢占次数超过阈值就告警",它们不等同于用户已经受到影响,但预示着即将受到影响。

分位数才是承诺的基准

承诺用什么分位数,是一个需要显式选择的决策。用平均值承诺最省事也最危险,因为平均值会被大量快请求拉低,等于把最差的体验排除在承诺之外。用九十九分位承诺最安全但成本最高,波动大的时候几乎不可能达标。

实践中比较可行的做法是用九十分位作为对外承诺、九十九分位作为内部红线:前者是多数用户能感知到满意且成本可控的线,后者是极端情况下的兜底,一旦触碰说明系统已经处于不健康状态。至于平均值,可以作为辅助参考展示,但不应该出现在任何承诺里。

指标要能拆到能行动

一个无法定位的指标是没有用的。总吞吐下降了百分之十,知道了也不知道该做什么。有用的指标是可以沿着因果链逐级下钻的:吞吐下降要先看是需求侧变化还是供给侧变化,然后看排队长度、块池水位、抢占次数,再下钻到批内序列数和单步耗时。

建设顺序建议是:先保证这几个层级都有数据,再考虑做更精细的归因。没有分层的看板比没有看板更危险——它会让人在错误的方向上花时间。

用告警而不是看板来驱动

看板是用来事后复盘的,告警是用来实时响应的,两者用途不同,不能互相替代。真正能救命的告警有三个特征:阈值来自服务承诺的推导而不是拍脑袋、能在用户投诉之前触发、触发后能直接指向一个排查方向。

按这个标准筛一遍,通常真正值得配的告警只有个位数:排队长度持续超过某个值(容量不足)、块池水位长期贴近上限(显存不足)、抢占频率异常(配置不匹配或流量异变)、前缀命中率骤降(提示词被改)、接受长度下界走低(负载变化或草稿失配)。其余的更适合放在看板上供排查时参考。

长稳运行:压测五分钟答不出的问题

短时压测验证的是性能,长时运行验证的是正确性。很多在生产上出问题的配置,压测十分钟都是好的。

需要长稳才能发现的三类问题

  • 缓慢的资源增长:请求对象没有随结束而释放、块引用计数泄漏、显存占用随时间单调上升。这类问题在几个小时的尺度上表现为"后面比前面慢",很容易被误判为负载波动。
  • 周期性尖刺:某些实现存在周期性的资源整理或缓存刷新动作,表现为每若干分钟一次的单步耗时尖峰。短时压测可能刚好错过,也可能恰好包含一次而当成噪声。
  • 状态机泄漏:前缀缓存的块表在特定序列模式下(比如反复的前缀追加加回滚)可能出现引用不一致,表现为命中率随时间缓慢变化,或者偶发的命中率断崖。

怎么跑才有意义

长稳测试的关键不是"跑得久",而是负载要能暴露问题。建议用生产流量回放而不是人造循环请求——循环请求的前缀完全一致,会让前缀缓存的命中率虚高到不真实的水平,掩盖很多问题。真实流量回放的优势是长度分布、前缀重复率、形态混合都是真实的。

观测上要每分钟落一次数据而不是只看总数:内存占用曲线、块池水位曲线、命中率的滑动平均、每秒抢占次数的滑动平均、以及关键时延的分位数。跑完之后把这些曲线画出来,任何一条向上的趋势都值得追下去,哪怕当时性能还没变差。

把长稳作为发布流程的一部分

长稳测试应该是发布流程的固定环节,而不是"出问题了才想起来跑一遍"。合理的安排是:每次配置或模型变更后跑一次覆盖典型负载的短时压测确认没有回归,重大变更(换模型、换引擎版本、改量化方案)再叠加一次长稳。

同时把长稳的结果沉淀成基线存档:配置、负载描述、各项指标的曲线和关键数值。下次改动时可以直接对比,这比"我记得上次好像不是这样"可靠得多。性能工程真正依赖的不是聪明,是可复现的记录。

生产环境的参数调优顺序

把前面所有内容收敛成一条可执行的路径。核心原则是每一步都要能被观测证实或者证伪,不做无法归因的改动。

第 0 步:写下 SLO

没有 SLO 的调优没有终点。先明确:TTFT 的 P99 上限、TPOT 的 P99 上限、目标吞吐或每日 token 预算、上下文长度分布、输出长度分布、峰值 QPS 与峰谷比。这五条决定了后面每一个参数的合理取值范围。

第 1 步:先量,别先调

部署默认配置,压一轮基线,把上面那张指标清单全项记下来,包括 TTFT/TPOT 的分位数、吞吐、抢占次数、块池水位、prefix 命中率。基线不完整的调优等于瞎调。

第 2 步:先调显存账本,不调算法

这一层影响最大也最容易被跳过。按前面那个 KV 公式算出理论并发,再对照实际:

  • max_model_len——按真实分布设,不要图省事设成模型上限。这一项直接决定单序列预留的 KV 上限,它对并发的杀伤力是所有参数里最大的。设成 128K 而业务最长只到 16K,并发直接掉一半。
  • gpu_memory_utilization——留出通信缓冲和采样空间,不要顶到 0.98。
  • max_num_seqs——批内并发上限。交互业务偏小,批处理业务可以放大。放大它之前先看 TPOT P99 会不会破线。
  • KV 块池水位——长期贴着 100% 就说明显存不够,应该减 max_model_len 或加卡,而不是继续调别的。

第 3 步:打开结构性开关

在显存账本正确的前提下,再依次确认:chunked prefill(现代引擎基本默认开)、prefix caching、CUDA Graph / cudagraph 模式、异步调度。这一层的收益通常最大,且几乎没有精度风险。

第 4 步:再动精度

在调度结构定型后做量化,并且权重和 KV 分开决策。做完必须跑精度回归,尤其是长上下文任务。如果这一步破坏了 SLO,回到第 3 步重新审视并发设置,不要试图用调参补偿。

第 5 步:最后上投机解码

投机解码是唯一一个可能让整体吞吐下降的优化,放在最后做,而且用上一节的判据:接受长度、TPOT、总吞吐三个数一起看。P10 接受长度太差就先关掉,别指望调参数救。

第 6 步:压测回归与长稳

每一步改动都留一份压测记录。全部定稿后跑一次长稳(数小时到一天),重点观察内存泄漏式的缓慢增长、块池水位随时间漂移、以及周期性尖刺。压测五分钟能过的配置,跑一天崩掉的情况并不罕见,尤其在多租户混合负载下。

调优顺序速查

顺序动作预期收益量级风险
0定义 SLO 与真实负载分布—不做则全盘无解
1基线压测,建立完整指标快照—不做则无法归因
2max_model_len / 显存水位 / max_num_seqs最大设错直接压死并发
3chunked prefill / prefix cache / cudagraph / 异步调度大低,主要是显存与驱逐
4PD 分离(长 prompt 占比高时)大KV 传输可能成新瓶颈
5权重量化 → KV 量化,逐个评估中到大精度,尤其长上下文
6多租户 QoS / 限流 / 抢占策略中策略复杂,需可观测支撑
7投机解码中,可能为负高负载下可能降吞吐
8长稳与回归—时间成本

一份起步用的启动配置

具体参数名随版本变动,落地前务必对照所用版本的文档核对:

# 起步配置:结构优先,精度和投机解码先不开
# vLLM V1 系(参数名请对照当前版本文档)
python -m vllm.entrypoints.openai.api_server \
  --model /models/your-model \
  --served-model-name your-model \
  --tensor-parallel-size 8 \
  --max-model-len 32768 \
  --max-num-seqs 128 \
  --gpu-memory-utilization 0.90 \
  --enable-prefix-caching \
  --enable-chunked-prefill \
  --max-num-batched-tokens 8192 \
  --port 8000

# 关键解释:
#   --max-model-len 32768     按真实 P99 长度设,别设成模型上限
#   --max-num-seqs 128         交互业务起点;TPOT P99 破线就往下调
#   --gpu-memory-utilization   留通信/采样余量,不要顶满
#   --enable-chunked-prefill  现代版本默认开,显式写出来便于核对
#   --max-num-batched-tokens  单步 token 预算;TPOT 有周期性尖刺就往下调

# 确认生效与观测
#   /metrics 里核对:num_requests_waiting / num_requests_running
#                    prefix_cache_hits_total / prefix_cache_queries_total
#                    num_preemptions_total / gpu_cache_usage_perc

# 之后再逐项开,一次只开一个,改完就压一轮:
#   量化(权重) -> 精度回归 -> 压测
#   量化(KV)  -> 长上下文精度回归 -> 压测
#   投机解码     -> 接受长度 / TPOT / 总吞吐 三个数一起看

常见误区:看起来正确但会反噬的做法

调优过程中最贵的成本不是算错,而是按一个看起来很合理的直觉去调,结果变慢之后再花一周回滚。下面这些是推理工程里反复出现的模式。

把 max_model_len 设成模型上限

这是收益最大、也最常犯的一个错误。这个参数决定单序列 KV 预留的上限,而 KV 预留在多数实现里是按"最坏情况"记账的。设成 128K 而业务实际 P99 只有 12K,等于把所有并发都按十六倍的成本在计费。

正确做法是从真实的长度分布出发,取一个能覆盖绝大多数请求的值(通常是 P99.9 附近),并且为超长请求单独准备一条降级路径——截断、分段、或者路由到专用实例。少数超长请求拖垮整个集群的吞吐,是这类错误最典型的表现。

只看平均时延

平均值在推理服务里几乎没有决策价值。同样的 P50 意味着完全不同的服务体验:一个"TTFT 平均 800ms、TPOT 平均 18ms"的服务,可能有 5% 的请求 TPOT 超过 200ms(被大 prefill step 砸中),这 5% 在用户投诉名单里,而它们对平均值的贡献不到 1ms。

纪律是:所有时延指标至少报 P50 和 P99,容量规划按 P99 划线。同时把"满足 SLO 的请求占比"(goodput)作为唯一对外汇报的主指标,因为它天然包含了尾部。

把投机解码当成免费加速

前面已经用公式和带宽余量的概念说明过,这里只强调实践中的形态:一个在低负载压测里能提速 2 倍的投机解码配置,接到生产高峰可能让整体吞吐下降,因为高峰时 GPU 已经在算力饱和,draft 的额外计算变成了纯粹的开销。

判断顺序必须是:先确定高峰时的负载状态,再决定要不要开;上线后同时监控接受长度、TPOT 和总吞吐三个数,只要总吞吐下降就关掉,哪怕 TPOT 改善明显。这个决策要写进值班手册,而不是留给下一次值班的人临场判断。

把量化当成免费的显存

量化省下的显存确实会转化成更多并发,但精度代价会在某个不明显的业务场景里冒出来——通常是长上下文的检索、或者需要精确数值的多步推理。这类回归很难在标准评测集上发现,因为它们的规模太小。

所以量化的验证集必须包含你自己业务里最长的那几个真实输入,而不是公开基准的样本。权重和 KV 也要分开评估:权重量化影响全局输出分布,KV 量化影响长序列,两者退化模式不同。

把 chunked prefill 的块大小调到极小

"把块调小让 TPOT 更平滑"这个推理是对的,但只对了一半。在访存受限的解码阶段,批内 token 数太少会直接让 GPU 的带宽利用率塌掉,prefill 吞吐也会掉。块太小是用整体吞吐换 TPOT 尖刺,而尖刺本来可能只影响 P99 的极小一部分。

正确的顺序是:先用默认配置测出 TPOT P99 的实际尖刺幅度,如果尖刺确实违反了 SLO,再往下调块大小,并且每调一次重测吞吐。如果尖刺幅度可以接受,就不要动——默认值的背后是大量真实负载的验证。

为了命中率高而重构 prompt

前缀缓存是收益最直接、风险最低的优化,于是很容易被过度追求。但"提高命中率"的手段如果走偏了,代价会转移到别处:把请求里的时间戳、用户标识、随机数全部删掉换命中率,会破坏业务语义;把所有 prompt 强行收敛到同一个模板,会限制业务灵活性;把易变内容挪到 prompt 开头(按"最长的公共部分在前"排布而忽略了变化性)会让命中率归零。

原则是按"变化频率"而不是"长度"来划分前缀和后缀:所有真正恒定的部分放最前面,所有每次都变的部分(包括看似只变一次的)放最后。这个划分要和业务方一起确认,不能由推理侧单方面决定。

用一次压测决定长期配置

压测通常用固定的并发和固定的长度分布,而生产的到达是波动的、在形态上是混合的。最常见的失败是按峰值吞吐配了资源,然后在谷值时看着大量闲置却不敢缩容——因为不确定缩了之后高峰会不会崩。

务实的处理方式是按分位数而不是均值做容量规划,并且接受夜间闲置;如果夜间闲置的成本确实不可接受,再考虑弹性方案。把"峰值配置"和"日常配置"分成两套参数、按时间或队列水位切换,在多数场景下比追求"一套参数通吃"更实际。

上线前的检查表

下面这张表是把全文收敛成可执行动作的版本。它不是流程规范,而是在出问题时用来回忆"哪些环节可能被我跳过了"的索引。

检查项怎么做合格标准不做的后果
SLO 明确写下 TTFT P99、TPOT P99、峰值吞吐三项数字有具体数值和目标日期调优没有终点
长度分布已知统计输入输出 token 的 P50/P99 与超长请求比例能画出分布图无法确定 max_model_len
基线完整默认配置压一轮,全指标快照分位数齐全,非均值无法归因
显存账本核对用 KV 公式反推并发,与块池水位对照理论值与实测值同量级分不清是显存还是调度问题
块池水位留余量高峰时观察 KV 使用率稳态低于 90%持续抢占,重算雪崩
抢占次数有告警对每秒抢占次数设阈值高峰期不持续触发抢占风暴无人知晓
前缀命中率可观测导出命中查询与命中计数能定位到具体业务命中率掉了不知道谁造成
提示词已按变化频率分层审计 system prompt 的构成无时间戳/随机量在头部命中率静默归零
长请求有降级路径超过阈值的长度如何处理有明确规则单请求拖垮全集群
多租户已限流按租户统计并发与排队单租户无法占满 batch大客户饿死小客户
量化精度已回归用业务最长真实输入对比关键指标无显著下降质量事故难定位
投机解码三数齐接受长度 P10、TPOT、总吞吐总吞吐不下降高峰时整体变慢
长稳已验证连续运行数小时以上无内存增长与周期性尖刺半夜被叫醒
压测可复现固定请求序列、同机对比、重复三次同配置波动小于 5%结论不可信
配置变更可回滚参数有版本、有基线快照十分钟内能退回故障时手忙脚乱

灰度、配置管理与多版本

调优产出的是一组参数,而把这组参数安全地推到生产是另一件事。调度系统的一个特殊之处在于:配置错误的影响面是全量的,一个把批大小上限设错的配置会同时影响所有请求,没有灰度可言。因此配置管理在这里比在普通业务里更重要。

参数必须版本化

最基本也最常被跳过的一条:每一个影响性能的参数都要有版本记录,并能追溯到具体实例。这意味着配置不能只存在于启动命令行里(实例无法自报家门),而要有一份集中式的配置记录,实例启动时拉取并把版本号写进自己的元信息和启动日志里。

版本化的直接收益在故障时才体现:出现异常时,第一件事就是确认"哪些实例跑的是哪一版配置",如果这一步需要靠人工回忆和翻聊天记录,处置时间会多出十几分钟。在多实例、部分灰度的状态下,这个能力更是不可替代——没有版本号就无法判断异常是否和某次发布相关。

影子流量

推理服务很难做传统的请求级灰度,因为同一个请求发给旧配置和新配置,结果可能不一样,业务无法接受。但影子流量是可以做的:把线上真实请求复制一份发给新配置的实例,只记录结果不返回给用户,然后对比两边的性能指标和输出质量。

影子流量的价值在于它能暴露压测发现不了的问题:真实流量的长度分布、形态混合、前缀重复率都和压测不同,真实流量里还有各种异常的输入格式。需要注意的成本是显存和带宽翻倍,所以通常只对一部分流量做影子,并且要确保影子实例的处理不会因为过载而反过来影响线上主实例。

参数变更的正确顺序

配置变更的灰度顺序应该按"影响面从小到大"来排:先改单实例观察,再改部分实例,最后全量。而不是一次性全量然后祈祷。这个顺序的价值在前面讲的几种事故里体现得特别明显——雪崩、命中率归零、量化回归,三者如果是在全量上才发现,代价是数量级的。

还有一条容易被忽略的纪律:变更之后要等足够长的时间再下结论。命中率、前缀缓存的块表、投机解码的接受长度都有热身期,几分钟内看到的数值不能代表稳态。反过来,某些退化是慢性的,需要几小时才显现。所以结论的正确时间点因指标而异,不能统一按"观察十分钟"处理。

多版本共存

模型升级期间新旧两个版本必然共存,这带来两个问题。一是显存和容量的分配:两个版本各自要预留最坏情况的显存,总量可能远超单版本的容量规划,导致升级期间的服务能力下降。解决办法是控制共存比例,或者在切换窗口内主动降级一部分功能(比如临时关闭投机解码、降低批大小上限)来腾出余量。

二是路由质量:新旧版本的性能特征不同,如果负载均衡只是随机分流或按最短队列,某些形态的请求会被系统性地分到不合适的版本上。切换期间应该用形态感知的路由,并明确哪一类请求先切、哪一类后切——通常是先切低风险高占比的形态,最后切对质量最敏感的那一类。

配置漂移的检测

最后一条长期纪律:定期核对"实际生效的配置"和"期望的配置"是否一致。漂移的来源包括手工在实例上做的临时调整、配置文件被环境变量覆盖、启动脚本在不同环境下的差异、以及回滚时只改了部分实例。常规做法是定期采集所有实例的实际启动参数做一致性校验,偏离期望的自动告警。

这件事看起来琐碎,但它是唯一能防止"问题实例混在健康实例里"长期存在的手段。而在按形态路由、按批次灰度的系统里,少数实例的配置不一致会在聚合指标上表现为持续的小幅异常——不触发任何告警,却永远无法通过调参解决。

驱逐策略:缓存满了扔谁

前缀缓存和块池共用同一块显存,所以"缓存"和"正在运行的请求"实际上在争抢同一个资源池。当池子满了就必须做选择,扔掉谁、留下谁,这个决策看起来是实现细节,实际上直接影响吞吐和时延的稳定性。

最常见的做法是最近最少使用策略:谁最久没被碰就扔谁。这个策略在通用缓存里表现不错,但在推理场景里有两个明显问题。第一个问题是它不看体积:一条两万词元的序列和一个两百词元的序列被驱逐的代价相差一百倍,按"最久未使用"排序会把它们当成等价的一次。第二个问题是它不看未来:长对话里当前轮还没写完的序列,如果它的历史前缀被驱逐,接下来的请求就必须从头重算,而这条序列恰恰是最有价值的缓存。

更有针对性的做法是按重算代价排序:优先驱逐那些重建成本高但访问价值低的块。这里有两个信号可以用,一是体积,体积大的块重算成本高,但体积本身也意味着它可能更重要;二是年龄,同一个会话的块倾向于保留在一起,避免驱逐一半导致另一半也失去意义。

还有一类思路是主动换出而不是直接丢弃。把冷的块写到主机内存而不是释放,代价是一次内存带宽的写入,收益是将来可能命中。这个策略在"长对话且对话间隔较长"的业务上收益明显,比如用户思考十分钟再问下一轮的场景。但如果命中率本身很低,写入的带宽成本就纯粹是浪费。是否开启换出,应该由命中率决定而不是由实现方便决定,先测命中率再决定。

实践中最有效的一个调整是给驱逐留出缓冲:不要等池子百分之百占满才开始驱逐,而是在到七八成时就启动。原因是等到占满时,驱逐动作会和新请求的分配动作在同一时刻大量发生,形成一波尖刺,提前触发可以把这一波摊平。这个改动很小,效果却通常很明显——抢占次数下降、尖刺减少,而吞吐几乎不变。

调度策略的组合

前面讲了先来先服务和后到先服务两种基本策略,真实生产里往往需要更复杂的组合。复杂度的代价是可解释性,所以组合的层数应该尽可能少,每加一层都要能回答"为什么这条请求被这样对待"。

第一层是优先级。给不同业务不同的优先级是最直观的需求,实现也最简单:优先级高的请求在队列里排前面,同优先级内按到达顺序。风险在于优先级配置一旦倾斜,饥饿就不可避免——高优先级流量如果超过系统容量,低优先级的请求会永远等不到。所以优先级必须配上配额,而不是单纯靠调度器的插队机制。

第二层是截止时间。带截止时间的请求需要保证在某个时刻之前完成,这类策略的核心思想是按"剩余松弛时间"排序——最紧急的先跑。这里有个容易犯的错误是只看总时长的估计值,而忽略了"排在他前面的请求还要多久"才是真正决定它能否按时完成的信息。实现上通常用请求的截止时间减去预估处理时长作为排序键,必要时还要在调度中重新评估。

第三层是成本感知。在按需付费的云环境里,不同硬件的性价比不同,可以把一部分流量放到便宜但慢的实例上。但这条策略的适用性比前两条弱得多,因为它要求业务对时延不敏感,而且要求调度器能准确判断哪个实例"更划算"——后者在负载波动时很容易算错,算错的后果是把流量送到一个实际很慢的实例上,而监控只会显示负载不均。

我的建议是最多启用前两层。超过两层之后,行为的可预测性下降,排障难度急剧上升,而收益通常是递减的。更重要的是:每加一层策略都要先回答一个问题——它解决的是不是一个真的发生过的问题。没有真实案例支撑的策略只会增加系统的复杂度,而复杂度本身就是故障来源。

硬件差异与引擎适配

最后要讲一个容易被低估的现实:同一份配置在不同硬件上的表现差异,可能大于不同配置在同一硬件上的差异。而且这些差异不是随机的,它们有明确的来源。

第一个来源是算子覆盖。推理引擎的性能高度依赖底层算子的实现质量,而这些算子由硬件厂商和开源社区共同提供,不同卡型的覆盖程度、融合程度、参数支持范围都不一样。某项优化在一种卡上有效,迁到另一种卡上可能因为缺少对应的融合算子而完全失效,甚至比不做还慢。因此"这份配置在别家的卡上跑得很好"这句话,在性能工程里几乎没有参考价值。

第二个来源是编译与自动调优。很多实现会在首次遇到某种形状时做自动调优,选择最优的算法和分块方式,并把结果缓存下来。这个过程在冷启动阶段可能持续数分钟。如果压测时间太短,测到的是未调优状态下的性能,结论会系统性偏悲观;如果生产流量在启动后很快达到某个新形状,会引发一次明显的性能抖动。压测时长必须超过自动调优的收敛时间,否则测出来的数字没有参考价值。

第三个来源是内存子系统。不同卡在显存容量、带宽、缓存层级、互联方式上差异很大,而这些直接决定了前面分析的每一个拐点位置:单卡能装多少缓存、访存受限能支撑多大的批次、并行切分在哪里开始不划算。这些数字应该成为每个环境的基线数据,在部署前就算出来,而不是靠压测反推。

综合起来,正确的做法是把调优流程本身标准化,让它可以在任意硬件上重跑一遍:固定负载描述、固定压测方法、固定指标口径,产出一份对比不同环境的基线表。这样做的好处不只是知道当前配置好不好,而是知道换硬件之后应该往哪个方向重新调——是显存不够要缩短上下文,还是带宽不够要改并行切分,还是算力不够要加设备。判断依据来自基线表里的那三个数字,比凭直觉猜要可靠得多。

部署形态与上线清单

性能调优之外的工程工作同样决定服务能不能长期运行。这一节不展开讲,只留一份检查性质的清单,用来自查有没有明显遗漏。

关于启动与加载:权重从哪来、冷启动耗时多少、是否有本地缓存、缓存失效后重新加载要多久。冷启动时间在需要频繁扩缩容的场景下会直接决定扩缩容的可行性——如果一个实例需要三分钟才能就绪,那么流量突增时前两分钟基本无法被承接,而这段时间的请求要么排队要么失败。

关于就绪与存活:健康检查应该检查什么。只检查进程存活是不够的——一个进程活着但调度器已经卡死的情况是真实存在的。合理的做法是发送一个最小请求并等待正确响应,同时检查调度器是否在正常推进。存活检查和就绪检查要分开,前者失败触发重启,后者失败只摘流量不重启,这个区分在抖动场景下能避免大量无谓的重启。

关于扩缩容:扩缩容的触发依据是什么,扩容后多久就绪,缩容时如何保证在途请求不丢。缩容通常需要先停止接收新请求、等在途请求完成、再释放,这个过程必须比扩容慢得多。缩容策略太激进会造成周期性抖动——扩上去、缩下来、再扩上去,每次都伴随一次冷启动和性能低谷。

关于日志与可观测:单步耗时、批内序列数、块池水位、命中率、抢占次数这些应该按周期上报,而不是只在出错时打印。日志级别要区分清楚,把性能相关的常规指标放在调试级别,避免在生产上为了看指标而打开高等级日志导致磁盘被写满。

关于容错:单个请求失败要不要重试、重试会不会加剧过载、超时设置多长合适。这里有个经典陷阱:服务端过载时,返回错误比无限期挂着更好,因为挂着的请求会一直占用资源,让过载持续下去。及时拒绝加快速失败是限流体系的一部分,不是可用性上的退步。

这份清单里没有一条和性能直接相关,但它们是性能成果能否保住的前提。一次配置错误的重启,可以抹掉几周的调优成果。

外部记忆还是长上下文

做长上下文应用时有一个反复出现的选择:把更多内容塞进上下文,还是在需要时从外部检索再注入。这个选择在工程上很实际,因为它直接决定显存开销和首字延迟。

从成本角度看,注入检索结果是明显更省的。检索通常返回几千条内容,而完整的历史可能有几十万条。省下来的不只是显存和预填充算力,还有首字延迟——预填充的耗时与注入长度成正比,这在长对话场景里可能是主要的等待来源。

但外部记忆有它自己的代价,而且是难以量化的那一类。检索会丢失原始的顺序和上下文关联,多轮对话里的指代关系("它""上面那个")在检索片段里往往无法解析,跨轮次的信息拼接可能产生矛盾。模型的输出质量会因为这些而下降,而下降的幅度取决于任务对连贯性的依赖程度——闲聊影响小,复杂推理影响大。

一个折中的做法是混合策略:保留最近若干轮的完整历史不做检索,保证近程连贯;更早的历史做检索和摘要注入,保证信息不丢。轮次阈值需要按业务调,它的调整方向很清楚——如果输出质量随对话变长而下降,说明检索丢失的信息开始重要,阈值应该调大;如果首字延迟不可接受,说明注入内容太多,阈值应该调小。

更值得注意的一点是这个选择会反过来影响缓存的收益。完整历史注入的方案,前缀几乎无法复用(每轮都变);检索注入的方案,如果检索结果稳定,前缀复用度更高。所以选混合策略时,把稳定的历史摘要放前面、检索结果放后面,能同时拿到质量、延迟和缓存三重好处。这三个维度不是互相独立的,找一个同时优化三者的排布方式,比逐个优化更有效。

最后提一个常被忽略的方案:如果部署的引擎支持缓存分层,也就是把前缀缓存溢出到主机内存甚至本地存储,那么完整历史的成本会显著下降——因为它不再是每次重算,而是读一次缓存。这时候选择就可能倒向"保留更多完整历史",因为长上下文的边际成本从"每次重算"变成了"每次读盘"。基础设施的能力会改变上层架构的最优解,这也是为什么调优要持续做而不是一劳永逸。

把调优固化下来

调优过程中最难的不是想出那一个关键改动,而是让这个改动不会在半年后被悄悄改回去。参数会漂移、版本会升级、新人会对某个看不懂的取值产生疑问,而只要没人能说清"这个数为什么是这么大",它就一定会被改掉。

第一件事是把判断依据写下来,而不是把结论写下来。结论文档写着"最大批内序列数设为一百二十八"没有价值,有价值的是"在二千输入、三百输出、三十二并发的负载下,这个值让单词元时间的高分位数保持在某条线以内;再往上加就破线,而往下加吞吐的下降幅度超过百分之五"。后者可以被人复核,也可以在负载变化时被重新推导,前者只能被盲目继承或者被推翻。

第二件事是把基线脚本化。压测脚本、负载描述、指标采集、结果落盘应该是一套可以直接重跑的脚本,输入是配置,输出是一份完整的指标快照。手工压测的问题不只是慢,而是每次的操作细节都不一样,导致两次结果不可比。脚本化的收益不在于省时间,在于让"改动前后对比"这个动作的成本降到接近零,而这恰恰是让"每次只动一个变量"这条纪律能长期执行下去的前提。

第三件事是建立回归基线并长期存档。每次调优产出都作为一条基线记录下来,包含配置、负载、各项指标。后续任何变更都和最近的基线对比,出现偏离就说明这次改动有影响,需要解释。这种对比不需要复杂,只需一张表和固定的指标口径。它的价值在出问题时才真正体现——能够快速判断"这个异常是这次改动引入的,还是本来就有"。

第四件事是把性能预算纳入设计评审。一个新的业务功能如果会显著增加上下文长度、会引入新的请求形态、或者会改变前缀结构,它对推理系统的资源消耗是可以在设计阶段就估算出来的。估算方法就是前面那些公式:上下文长度决定缓存占用,请求形态决定平均输出长度,两者决定总的词元产出。设计评审时把估算的容量影响写进去,扩容计划就能提前做,而不是等到线上告警才被动应对。

第五件事是设置看门狗,而不是只靠人工巡检。前面列过那几个真正值得配的告警,它们的共同特点是阈值可以从前面的推导里算出来,而不是拍脑袋。看门狗的价值在于覆盖人的记忆盲区——一个运行半年的服务,大部分时间没人看,出问题时第一反应是"好像哪里不对",而不是"命中率掉到了零"。前者要花几小时排查,后者看一眼监控就结束了。

这五件事的共同逻辑是:把一次性的聪明决策转换成可重复执行、不依赖个人记忆的流程。性能工程做到后期,真正起作用的往往不是某个精妙的技巧,而是这套流程在半年后依然完整地运行着。

常见配置为什么不能照抄

最后说一件看起来跑题但很实际的事:网上和内部文档里的配置示例,绝大部分不能直接用在生产上。原因不是作者写错了,而是那些配置离开了它成立的假设就没有意义。

一个最典型的例子是把最大上下文长度写成模型的物理上限。写这个数字的人想的是"这样能处理最长的输入",但他不知道实际业务的输入分布,也不知道这个数字会直接压低并发。在他的场景里影响不大,在你的场景里可能是吞吐腰斩。

另一个典型是把显存利用率写成一个很高的值。这通常是因为作者需要装下模型,他的显存确实紧。但对你来说,如果有富余显存却照抄了这个高值,你就在白白放弃并发;如果你照抄了别人的低值,你的模型可能根本加载不上去。这类参数的正确做法是每次重新算,而不是查找。

还有一类是纯粹的环境相关项:并行度、批大小上限、投机解码的步数。这些数字的正确值取决于硬件、模型、负载三者,只看示例里的数字没有任何参考价值。正确的做法是知道这个参数的作用机制和调整方向,然后在自己的环境上实测——而这正是本文反复强调的那条主线。

所以这篇文章里我尽量给的是机制、公式、判据和判断顺序,而不是一份可以直接粘贴的配置。原因很简单:机制是稳定的、可迁移的,具体数字是不可迁移的。掌握了机制,任何一份配置你都能推导出自己环境下的取值;拿着一份配置,你只能知道别人在别人的环境里测出了什么。

2026 年的进展与还没解决的

已成共识的三件事

引擎正在做大规模重写。vLLM V1 把调度器下沉、把 KV 管理统一、把跨语言边界压到最小,带来的不只是性能,还有可维护性——很多早期的 Python 侧逻辑消失了。SGLang 用 RadixAttention 在多轮和结构化生成上更自然。TensorRT-LLM 走编译路线,形状特化后性能上限更高但动态性更差。共识是:V0 时代那种"Python 调度器 + 张量拼接"的结构已经到头,V1 之后的引擎都是围绕"减少每 step 的 CPU 开销"重写的。

PD 分离从论文走向默认架构。prefill 和 decode 的资源画像完全不同,混批的代价已经被广泛认知。剩下的工程难点全在 KV 传输上:RDMA 直通、KV 压缩传输、以及多节点下前端的负载均衡决策。

KV Cache 分层是下一个战场。GPU 显存放不下长上下文的全量 KV,于是往主机内存、往 NVMe 分层。SGLang 的 HiCache 是这条路线的代表,核心难点是传输延迟和命中路径的预测——从哪一层读,取决于流量是否重复,本质上还是命中率工程换了个战场。

原生自投机是明显的方向

如果基座模型自带 MTP 头或者训练时就优化了多 token 预测,草稿部分几乎是零成本的,接受率也因为共享表示而更高。2026 年值得关注的不是"投机解码能不能用",而是模型侧和系统侧的协同设计:训练时就让 MTP 头对齐主模型的分布,比事后外挂一个 draft 模型有效得多。这也意味着换模型时应该把投机解码收益当成选型指标之一。

仍然不成熟的几个方向

自适应调度还没有定论。理想的调度器应该根据实时负载自动选择批大小、投机开关、PD 比例,但这些决策互相耦合,贪心式自动调参容易震荡。多数生产系统仍然是固定配置 + 人工调优。

量化收益正在递减。FP8 已经是基线,往下走 FP4 之类的格式在精度和 kernel 支持上都还不稳,而且对长上下文、复杂推理任务的伤害不成比例。短回答、分类、抽取类任务能吃到量化红利,长链推理吃不到——这意味着一个统一的量化策略会同时伤害两类负载。

非 NVIDIA 硬件的调度路径差异大。国产加速卡的算子覆盖和 kernel 成熟度决定了哪些优化真正可用:没有原生 FP8 attention 支持,KV 量化的收益就打折;CUDA Graph 的等价机制调优不好,动态 batch 的开销就压不下去。跨硬件迁移时,必须重新走一遍第 2 到第 7 步,不要假设配置可移植。

把性能换算成钱

性能指标最终要能回答一个业务问题:这个方案一年多花多少钱,值不值。没有这一步,前面的所有调优都停留在技术自嗨层面。

成本的两半

把推理成本拆成两项,思路会清楚很多:设备摊销和电费与机房。摊销是按设备购买价除以预期使用年限,再除以总运行小时数折算的固定成本;电费则随功率和利用率变化。后者常常被忽略,但在一台长期满载的加速卡上,它能占到总成本的三成以上。

这个拆分有一个直接推论:吞吐优化的收益是直接减少设备数量,而时延优化的收益是减少流失的业务。两者算在不同账上,不应该用同一个指标衡量。很多团队把注意力全放在吞吐上,是因为吞吐容易量化、容易汇报,而时延带来的业务收益需要业务方配合才能估计——但后者往往才是大头的。

单位成本怎么算

最实用的口径是"每百万词元的成本"。算法很简单:单位时间内所有实例产出的词元总数,除以这段时间的设备成本,再乘以一百万。但真正容易算错的是分母的口径——要不要把跑不动的时段算进去?低峰期大量闲置的设备,是分摊到所有时段,还是只分摊到有负载的时段?两种算法会得出完全不同的结论。

我的建议是按全天分摊,因为闲置的设备确实在花钱,忽略它会让优化决策失真。相应地,"夜间闲置"就是明确可见的成本项,这为前面提到的弹性方案提供了量化依据:当夜间闲置的摊销成本超过弹性方案的额外运维成本时,做弹性是划算的;反之就直接接受闲置。

质量与成本的取舍

这是最容易被算漏的一笔账。量化、小模型蒸馏、减少输出长度、降低上下文保留范围,这些手段都能降低成本,但都会在某处损失质量。问题是这个损失只有一部分能体现在技术指标上:能用自动化评测集测出来的下降是显性的,测不出来的(业务特定的长尾场景、需要专业判断的细微差别)是隐性的,而隐性的那部分往往更大。

处理办法不是拒绝降本,而是把质量验收也变成一道流程:用业务自己的、包含真实长尾样本的评测集做准入,而不是用公开基准。公开基准的作用是快速排除明显退化的改动,不能作为放行依据。这一条对所有降本手段都适用,包括量化、换更小的模型、以及限制上下文长度。

容量规划的两条曲线

把需求和供给画在同一张图上,容量规划就变成了一个很简单的问题。需求侧是词元消耗量随时间的曲线,注意它和请求数不是线性关系——同样一千个请求,输出长十倍,成本就涨十倍。供给侧是可用设备数折算成的吞吐能力。

规划时真正要盯的是两条曲线的相对位置随时间的变化,而不是它们的绝对值。峰谷比大的业务,即使资源完全够用,峰值时的排队时延也会很差——这是容量问题转化成时延问题的路径。常见的应对是按峰值配置、按谷值缩减,这已经讨论过;更彻底的做法是引入排队论意义上的准入控制,让系统在过载时优雅降级而不是无限排队。

三种最常见的线上事故

把前面所有讨论反过来看,能推出三种最常见的故障形态。识别它们,比记住任何一条参数建议都更有用。

一、单次超长请求引发的雪崩

形态:某个时刻出现一个远超常规的输入长度,或者一批请求同时提交。表现是块池水位在几秒内被打满,排队队列迅速堆积,抢占开始发生,然后是连锁反应——被抢占的请求需要重算,重算占用了更多时间,排队更长,块池水位更高。

根因:连续批处理没有单请求的资源上限,而按最坏情况预留的机制让超长请求的代价被放大到不可控。同时这类请求占用一个调度步的时间特别长,同步等待期间其他请求全部停摆。

处置:第一层是准入侧设硬上限,超过阈值直接拒绝或转异步队列,不要让它进入调度器;第二层是给单请求在批内设置词元预算上限,超长输入分块处理而不是一次吃完;第三层是保留一部分块池作为应急余量,不参与常规分配。关键是要在进入调度器之前拦住它,一旦进入调度器,回收成本极高。

二、命中率静默归零

形态:没有报错,没有告警,吞吐略微下降,排队略微变长。很容易被归因为"业务量涨了",实际上是前缀共享被破坏。

根因:提示词里的某个字段被悄悄改成了变化值。常见来源包括:有人在系统提示里加了时间戳或版本号、路由层往提示里注入了请求标识、业务方为了个性化在开头拼接了用户画像、多轮对话上游悄悄加上了摘要压缩。这些改动通常由推理服务之外的团队完成。

处置:这是监控问题而不是调参问题。必须对前缀命中率设置告警,并且能按业务、按请求类型拆分——只有知道"是哪一类请求的命中率掉了"才可能找到原因。修复的动作在提示词的组织方式上:把恒定部分放最前、变化部分放最后,并在提示词管理上建立评审约定,任何插入位置必须说明是否影响前缀。

三、量化回归的延迟爆发

形态:上线后一段时间一切正常,然后在某个特定的业务场景上突然出现质量下降,团队排查很久才定位到量化。因为标准评测集上的指标几乎没变化,回滚之后也没有立刻复现。

根因:退化是条件性的,依赖于特定的输入特征。常见的是长上下文(误差累积)、需要精确数值的任务(数值误差被放大)、以及罕见的长尾输入。评测集没有覆盖到这些条件。

处置:量化上线前必须有一轮用业务真实长尾样本的回归,并且在回归里刻意构造长输入和数值敏感样本。上线后保留快速回滚开关,并且把权重量化与键值量化分开开关——它们的退化模式不同,需要能分别回滚。这类事故的教训是:没有覆盖到的条件不会因为"没出问题"就等于安全,它只是还没被触发。

识别共性

三种事故有一个共同点:触发条件都不在推理服务自己的代码里——一个超长输入来自业务方,一个提示词改动来自算法侧,一次量化上线来自降本目标。这解释了为什么推理服务团队往往是在事故之后才知道发生了什么。

应对的方向也是共同的:把边界条件显式化并前置到准入层(长度上限、形态校验、准入限流),把跨团队的约定写成流程(提示词变更评审),把不可逆的操作配上可观测的验收标准(量化回归集)。这些工作都不在推理引擎的代码里,但它们决定了推理引擎在生产里能不能待住。

把链条串起来:一次完整的优化走查

前面的内容是横向拆解。真实的调优是纵向推进——每一次只沿一个方向走到底,走不动了再换方向。下面是一次典型的走查顺序,它不依赖任何具体模型,但每一步的判据和产出都是可复用的。

起点:P99 投诉与一个反常的曲线

症状是 TTFT 的 P50 还算正常,但 P99 经常顶到线上告警线,而集群吞吐远没跑满。第一个动作是拉基线:压一轮拿到完整快照。数据指向两个信号——等待队列长度在高峰时段持续增长(说明并发不足或单请求太慢),而块池水位只有六成(说明显存不是瓶颈)。

这两个信号放一起就已经能排除"显存不够"这个最常见的假设,方向立刻收窄到调度和请求形态。如果没有拉这组基线,而是直接去调 gpu_memory_utilization 或者加卡,会浪费一整轮迭代。

第一步:削峰——长输入的解耦

按长度分桶统计后发现,高峰流量的输入长度是双峰的:一簇在 1K 左右(短对话),一簇在 15K 以上(文档问答),而后者只占三成请求数却贡献了八成的排队时间。

这一轮只做一件事:确认 chunked prefill 生效,并把单步 token 预算从默认值往下调了两档。判据是 TPOT P99 的尖刺幅度确实下降了,同时重测吞吐没有明显损失(这是关键,没有重测吞吐就无从判断这是改进还是转移)。两轮压测重复取中位数,波动在 3% 以内,判定有效。

第二步:核对账本——发现真正的约束

削峰后 P99 改善了但没达标。回到账本:用 KV 公式反推并发,和实际对比发现实际并发只有理论值的三成左右。排查方向是 max_model_len——它被设成了 32K,而业务 P99.9 只有 19K,多出来的一倍预留在长请求集中出现时把块池水位顶了上去。

把 max_model_len 降到 24K 后,块池水位稳定在八成,排队队列明显缩短。P99 达标。这一步的教训是:账本上的浪费和账本上的不确定性都要清,前者是浪费显存,后者是浪费并发决策的置信度。

第三步:把命中率做实

前缀缓存默认开着,但命中率接近于零。审计提示词后发现,系统提示里嵌了每分钟变化的时间戳,而且时间戳放在头部——整个共享前缀在第一块就断裂了。

把时间戳移到用户消息末尾之后,命中率跳到很高的水平,TTFT 的 P50 下降幅度远超预期。值得注意的是这一步改动完全不在推理服务侧,而收益是整轮走查里最大的。这也是前缀缓存工程最值得强调的地方:它不是一个引擎参数问题,而是一个跨团队的接口约定问题。

第四步:拆分形态

剩下的 P99 缺口集中在长输出请求上——它们把批内占满,导致短对话请求跟着变慢。到这一步才做形态拆分是合理的,因为前面的结构性开关(chunked prefill、账本、命中率)已经就位,拆分后的两个实例各自都有健康的基线。短对话实例批小、时延优先;长输出实例开启投机解码、走大批次路线。

第五步:最后才动精度

投机解码上线前做了完整的三个数验证:接受长度 P10 明显偏低,说明长上下文外推影响了草稿质量,于是只在短输出实例上开启。长输出实例改用无额外模型的 n-gram 方案,在这类高重复的代码类任务上反而效果更好。

量化放在最后评估:权重先试,业务最长真实输入上的关键指标没有显著下降才保留;KV 量化因为长上下文回归里出现了检索准确率下降,被否决了——这一轮里它是唯一一个被主动放弃的优化。能明确说出"哪个优化我们决定不做、为什么",比多开一个开关更说明调度系统调清楚了。

回看这五步的共性

五步里有四步是"先量化再改",只有第三步是纯工程直觉驱动的(但也立刻被基线验证了)。更重要的是每一步都重新建立了基线:改动之后不重测基线,后续所有判断都建立在已经失效的对照上。这就是为什么压测方法本身的严谨性,和被压测的系统同等重要。

还有几个方向值得盯着

调度器的自适应

目前所有主流引擎的调度策略都是固定参数。理想的形态是根据实时负载自动选择批大小、chunk 大小、是否开启投机、甚至是否走 PD 分离路径。这件事的难点不在于算法,而在于这些决策互相耦合且都有延迟效应——调大批大小的收益要几十秒后才体现在指标上,而切换本身有代价。贪心式自动调参很容易产生震荡,表现是时延指标的周期性抖动,比固定参数更糟。

务实的中间路线是"有限自动":只对少数几个影响最大、且切换代价低的参数做自动调整(比如在低负载时自动降低并发上限、高负载时自动提高),其余保持固定。这类方案上线风险可控,收益也最容易解释。

KV Cache 的分层与预测

把 KV 从 GPU 溢出到主机内存、再溢出到 NVMe,是长上下文场景的必然方向。它的核心难题是选择:从哪一层读,取决于这个前缀未来会不会被再次命中。如果读错了层,收益直接归零还倒赔传输时间。

可行的启发式包括:按前缀的访问频率分层、同一轮对话内保证同层、对话结束时降层而不是驱逐。这些策略的正确性依赖流量形态,对工作负载稳定的业务好做,对流量突变的业务要保守。核心指标依然只有一个——命中率,以及它换算成的有效带宽节省。

模型与系统的协同设计

这是我认为最值得关注、也最容易被忽略的方向。如果模型训练时就带了 MTP 头,或者隐状态对多步预测有天然可分性,那么投机解码的草稿部分几乎是免费的,系统侧不需要任何额外投入。反过来,如果系统侧知道未来要做投机解码,训练时就可以针对性地优化这些头的接受率。

这改变了选型的评估维度:从"这个模型多大"变成"这个模型在你的负载形态下,端到端每 token 成本是多少"。而后者需要把推理配置一起算进去才能得出结论。所以实践中的建议是:做模型选型时把推理侧的收益一起测,不要拿一个默认配置下的吞吐数字去比。

硬件的边界在移动

几件事正在同时改变优化的可行边界:新硬件的单卡显存更大,意味着更大的 KV 块池和更少的分片;更快的卡间互联让 TP 通信和 PD 分离的 KV 传输代价下降,从而让"更大的逻辑批次"变得可行;更低的数值格式支持让量化的可行下限继续下移。

但每一项变化都要求重新验证前面整套结论。更大的块池可能让原来的批大小上限不再是瓶颈;更快的互联可能让 PD 分离从"不划算"变成"划算";更激进的数值格式可能让长上下文的精度问题从"不可接受"变成"可以接受"。换硬件等同于换一台机器,必须重走一遍调优顺序,这是硬性纪律,不能沿用旧配置的直觉。

换模型时系统侧要跟着改什么

换模型看起来是部署侧的动作,但在推理系统里它几乎必然要求一轮重新调优。原因很简单:前面所有结论都建立在模型的几个属性上,而这几个属性每换一个模型就全变了。这一节是一份联动清单,用来避免"以为不用调"的误判。

第一是缓存占用的量级。它由层数、键值头数、头维度、每元素字节数共同决定。这四个里任意一个变化,缓存占用就可能变化一个数量级,而并发数几乎与之成反比。一个用了压缩注意力结构的模型和一个传统多头注意力的模型,在同样的显存下能支撑的并发可能差十倍。直接后果是:原来调好的批大小上限和显存水位在换模型之后要么过于保守浪费容量,要么过于激进直接无法启动。所以换模型的第一步不是压测,而是用那个公式把账重算一遍。

第二是分词器。它决定了同样长度的中文文本会被切成多少个词元。同样的内容,换一个分词器后词元数可能差百分之几十,这会同时影响缓存占用、预填充算力、输出长度和链路上的分词耗时。更隐蔽的是长数字串、代码、表格、特殊符号的分词效率差异可能极大,如果业务里有这类内容,新模型的实际单请求成本可能显著高于按字符估算的结果。换模型后应该用真实业务样本统计词元数,而不是沿用旧的估算。

第三是上下文能力与实际最优值。新模型支持的上下文更长,不代表应该把上限设得更长。设得更长只会让缓存预留变大、并发变低,收益为零。正确做法是按新模型的业务分布重新选一个值,哪怕模型支持一百万,只覆盖实际需要的长度即可。如果业务确实需要用到更长的上下文,那么这是一次容量升级的机会,应该按新的账本重新规划设备数量,而不是简单地把上限调大然后看会不会出问题。

第四是草稿模型与主模型的兼容性。投机解码要求草稿和主模型的分词器一致,最好是同源模型。换了主模型之后,原来的草稿方案基本要重新评估:接受率可能大幅变化(分布不同了),也可能直接不兼容(分词器不同)。这一项属于"重新验证"而不是"重新配置"——必须实测接受长度和总吞吐,不能沿用旧结论。

第五是推理相关的架构特征。如果新模型自带多词元预测头,那投机解码的收益结构完全不同,草稿几乎零成本,接受率也更高,可以更激进地开启。如果新模型是混合专家结构,专家并行会成为主流切分方式,通信模式和权重分布都变了,对设备互联的要求也变了。如果新模型改变了推荐的精度格式,比如原生支持更低位宽的权重,那量化方案和它的收益上限都要重新评估。

第六是默认行为和推荐配置。同一个引擎家族,模型作者往往会在模型说明里给出推荐参数,这些推荐值和引擎的默认值可能不一样,而它们之间的差异往往就是新模型与旧模型性能特征不同的地方。正确做法是把推荐配置和当前生效配置逐项对比,差异项逐个验证,而不是直接采纳整套推荐配置——推荐配置是面向一般场景的,你的负载形态未必是那个场景。

把这六项串起来看,可以得到一个更一般的结论:推理系统的调优结论是有保质期的,而且保质期由模型的稳定性决定。模型迭代越快,基线失效得越频繁。所以前面讲的"把基线脚本化、把结论和推导过程一起记录"这两件事,在快速迭代的场景下价值会成倍放大——它们决定的不是这一次调优的效率,而是每一次换模型之后需要多少时间才能重新达到同样的服务水平。

症状驱动的排障路径

调优的效率取决于遇到问题时的排查速度。下面这份路径把前面提到的机制反向组织成"看到什么症状就查什么",每一条都从最容易验证、最可能成立的原因开始排。

症状是首字延迟高、但单词元时间正常。说明瓶颈在请求进入产出第一个词元之前的阶段。先看等待队列长度:队列长是容量或调度问题,队列不长但首字延迟依然高,那是单请求自身慢。再往下看输入长度分布和前缀命中率:如果命中率低,预填充计算量就是全量的;如果命中率高但首字延迟仍高,问题多半在链路而非引擎。按这个顺序查,通常在前两步就能定位。

症状是首字延迟正常、但单词元时间高。说明请求能进来,产出也不慢,但每个词元之间的间隔太长。先看批内序列数是否超过了吞吐拐点,如果已经越过拐点,那就是批大小和时延约束冲突,需要在时延约束下调低它。如果批内序列数不多而间隔仍然大,重点查单步耗时:看是不是有超长的预填充步骤混在里面(长请求的预填充会周期性打断),看是不是采样阶段占比过高(带重复惩罚的长输出),看是不是多卡同步点在拖慢每一步。这三者的区分靠一个逐步计时的剖析就能完成。

症状是吞吐上不去,但队列不长、块池也没满。这是最容易被误判的一类,因为它看起来像"资源够、需求不高"。实际上它通常意味着调度器没有把资源喂满。可能是单步耗时被调度开销主导(批内序列数太少,固定开销被摊薄得不够),可能是多卡通信占了大头(每步都在等最慢的那张卡),也可能是链路上有同步点在反复排空计算流水线。判断方法是在低负载下看单步耗时和批内序列数的关系:如果是线性的,说明还没吃满,往上调批大小就能解决;如果是平的,说明已经到瓶颈了,方向要换成减少单步开销或改善并行切分。

症状是性能随时间慢慢变差。先怀疑资源泄漏而不是负载变化。看显存占用和缓存水位是不是单调上升,看请求结束之后相关资源有没有归零。如果曲线是阶梯状而不是平滑上升,往往和特定的请求模式有关,比如反复的前缀追加与回滚。另一个常见原因是自动调优的缓存被逐渐撑大,查找和重算的开销上升。处理方式不是手工清理,而是复现那个请求模式并定位是哪一环的资源没有正确释放。

症状是性能周期性抖动。先确认周期是多少、和什么系统行为对齐。如果是固定的分钟级周期,优先怀疑内部的周期性维护动作、垃圾整理、监控采集或者日志刷盘。如果是和业务相关的周期,怀疑流量本身的波峰波谷导致缩容扩容来回触发。如果是和长请求相关的周期,怀疑长请求的预填充步骤对短请求的周期性打断——这类抖动在时延曲线上表现为规律的尖峰,很容易通过把输入长度按时间分桶画出来确认。

症状是某类请求特别慢,其他类正常。这通常不是调度问题而是形态问题。先确认这批请求的长度分布是否显著偏离整体。如果是长输入短输出,优先看分块预填充的配置和前缀命中情况;如果是短输入长输出,优先看采样阶段的额外开销和投机解码是否开启或关闭;如果是超长上下文,优先看缓存水位和抢占次数;如果是带结构约束的输出,优先看候选裁剪和语法状态机。形态确认之后,处置手段在前面各节里都有对应。

症状是升级引擎或换硬件之后性能下降。这一类不要从调参开始,要从"哪个假设变了"开始。引擎升级可能改变了默认开启的功能、改变了默认参数、或者改变了调度策略的实现方式;换硬件可能改变了访存与算力的比例、改变了编译缓存的收敛行为、或者改变了并行切分的性价比。正确的做法是把新旧版本的配置和硬件参数列成一张表逐项对比,找出差异项,再针对差异项做定向验证,而不是从默认值开始盲调。升级后的第一件事应该是重现旧基线,确认基础性能没有退化,再谈新能力。

这份路径的隐含逻辑是:先排除最可能且最容易验证的原因,再逐层往下。绝大多数排障时间消耗在跳过了上层验证直接去改深层参数上,而那些改动往往是无效的,还会引入新的变量让后续排查更难。把上层的验证做扎实,通常能在几分钟内排除掉一半的可能性。

结语

回看这条链路,规律其实很清晰:推理性能问题几乎全都是"资源在请求之间分配不均"的问题。静态批处理让短请求等长请求,连续批处理让长 prefill 阻塞 decode,分页让内存不再按最坏情况预留,前缀缓存让重复的计算只做一次,投机解码让便宜的计算替代昂贵的串行步,多租户调度让资源分配不再由某个大客户独揽。

而每一个"更优的分配"都对应着新的代价:整齐换来了浪费,分页换来了间接寻址开销,命中复用换来了显存被挤占,无损加速换来了对接受率的依赖。工程工作的实质就是在这些代价之间找到当前负载下的最优平衡点,而这个平衡点不是常量——它随 prompt 长度、输出长度、并发、上下文分布漂移。

所以唯一可靠的方法论就是:先量出 TTFT 与 TPOT 的分位数,画出吞吐-时延的帕累托前沿,然后按"显存账本 → 结构开关 → 精度 → 投机解码"的顺序一次只动一个变量,每一步都用同一套压测方法验证。跳过任何一步,你得到的只是一组在某个测试脚本下好看的数字,而不是一个能在生产环境待住的服务。

最后把这篇文章的所有内容压成一条主线:推理系统的性能问题,本质上是资源在请求之间分配得太粗。静态批处理把分配单位设成整个批次,于是短请求被长请求绑架;分页把分配单位从整条序列切到小块,于是显存终于可以按需使用;前缀缓存让重复的计算只发生一次,于是固定的提示词不再重复收费;投机解码把串行的步换成一次并行验证,于是草稿的算力被批量消化;连续批处理和分块预填充把调度单位从请求切到词元和块,于是长任务不再阻塞短任务;抢占和公平性则是在资源不足时决定损失由谁承担。

从这条主线能推出一个贯穿全文的判断标准:任何一项优化,如果它让分配更精细但没有解决分配的对象是谁,就可能在某个负载上反噬。分页让分配更精细,但如果不做驱逐缓冲,精细化反而带来尖刺;投机解码让计算更便宜,但如果负载已经算力饱和,额外计算就是纯开销;前缀缓存让计算复用,但复用的块同样占显存,命中率极高时独立并发反而可能下降。精度和时延之间的所有张力,都可以用这一条来解释。

也给不同处境的读者一个直接的建议。如果是第一次搭建,把顺序走对比把参数调对重要得多——先保证连续批处理和分块预填充是开的、上下文长度按真实分布设、前缀缓存有监控,然后再谈别的。这个基础做对了,后面每一项优化都有发挥空间;基础没做对,越调越乱。如果服务已经跑了一段时间且指标不好看,最有效的一步是建基线:把首字延迟和单词元时间的高分位数、吞吐、抢占次数、缓存水位、命中率拉齐,看哪个指标异常。多数情况下答案会直接指向显存账本或者提示词结构这两个地方,而这两个地方恰好是改动量最小、收益最大的。

如果是做引擎而不是用引擎,那本文里最有参考价值的其实是那些反向的约束:批大小曲线有两个拐点而不是一个、调度步里只有前向计算是必要成本、采样阶段在长输出场景会随长度线性变贵、驱逐太晚会造成尖刺。这些是"哪些优化会在什么条件下失效"的清单,比"哪些优化有效"的清单更长,也更有用——因为有效的东西别人也会告诉你,而不失效的边界只能自己踩出来。

还有一句关于学习方法的话。这个领域变化很快,引擎版本、硬件能力、甚至最优的批大小策略都在变,所以看具体结论的半衰期很短。但机制和公式的半衰期很长——带宽摊薄的逻辑不会因为换了代硬件而失效,前缀哈希的链式结构不会因为换了引擎而改变,第一百分位不能被平均值替代这件事更不会过时。所以判断一份技术材料值不值得读的标准是:它有没有讲清楚"为什么是这样、在什么条件下会不成立"。只给数字不给条件的,参考价值是一次性的;讲清机制的,才能被迁移到你自己完全不同的环境里。