大语言模型的优化与加速推理部署,说到底就是把一个“能在单卡上吭哧吭哧跑通”的权重文件,改造成一套对外扛得住并发、延迟可预测、单位成本算得过来的线上服务。我第一次被拉去干这件事的时候,手上只有一个跑得通的推理脚本和一句“下周要压测”,结果模型能出字,但一上并发就排队,p99 延迟从 800 毫秒飙到 12 秒,显存还时不时被打爆。后来踩了一圈坑才明白,这件事不是“换个更快的框架”能解决的,它是一套从指标定义、显存核算、量化取舍、调度机制到部署拓扑的完整工程。这篇内容写给两类人:一类是刚要把模型推上线、对着一堆参数不知道从哪下手的同学;另一类是线上已经跑起来了,但吞吐上不去、延迟抖动压不住、单卡成本降不下来的同行。我会把每个环节“为什么这么选”讲透,把能直接抄的参数和脚本给出来,也会把那些文档里不会写、只有被报警电话叫醒过才会记住的经验一起放进去。
1. 先定目标:延迟、吞吐、成本这三笔账怎么算
1.1 延迟和吞吐天生是冤家
很多人一上来就问“你这个框架能跑多少 tokens/s”,这个问题其实没有意义,因为脱离批大小和序列长度谈吞吐都是耍流氓。推理服务里真正要盯的是四个量:TTFT,也就是首 token 延迟,决定用户按下回车之后等多久看到第一个字;TPOT,也就是每输出一个 token 的平均耗时,决定字是一个一个蹦出来还是成段刷出来;端到端时延,等于 TTFT 加上输出长度乘 TPOT;以及系统吞吐,单位时间内所有请求加起来产出的 token 数。前三个是用户体验,最后一个是钱。
这四个量之间的关系,本质上是被硬件特性决定的。预填充阶段是算力密集的,输入 2000 个 token,计算量大概是 2 乘参数量乘 token 数,对 70 亿参数的模型来说差不多 28 TFLOPs,短期靠算力堆;解码阶段是访存密集的,每生成一个 token 都要把全部权重从显存里读一遍,7B 模型 FP16 权重 14GB,显存带宽按 2TB/s 算,理论下限就是 7 毫秒一个 token,你算力再翻倍也没用。这就解释了一个很反直觉的现象:单条请求的 TPOT 几乎不随算力提升而下降,只有把批大小拉起来,让一次权重读取服务多条请求,单位 token 的成本才会降下去。所以吞吐和延迟的矛盾不是调度器写得不好,而是物理规律,你要做的就是在这个矛盾里找一个符合业务的位置。
我一般的做法是先跟业务方把 SLO 敲死:比如对话场景 TTFT p99 不超过 1.5 秒、TPOT p99 不超过 80 毫秒;批量摘要场景只要求整体任务在 2 小时内跑完,延迟完全不管。这两类需求的优化路线几乎是相反的,前者要保延迟、宁可牺牲批大小,后者要榨干每一分显存、把批尽量撑大。定不下来 SLO 就别急着调参,方向错了后面全是白干。
1.2 把显存账本摊开算一遍
显存是这类服务里最硬的约束,几乎所有“跑不起来”“跑着跑着崩了”的问题,根子都在账没算清。显存占用可以拆成四块:模型权重、KV Cache、前向激活和临时缓冲区、框架自身开销。权重最好算,参数量乘每参数字节数,FP16 是 2 字节,INT8 是 1 字节,INT4 是 0.5 字节,7B 模型分别是 14GB、7GB、3.5GB,70B 模型分别是 140GB、70GB、35GB。这里要注意 INT4 实际占用会略高,因为分组量化要额外存缩放因子和零点,一般比理论值多 5% 到 10%。
真正容易被低估的是 KV Cache。它的公式是:2(键和值)乘层数乘 KV 头数乘每头维度乘序列长度乘并发序列数乘每元素字节数。以 32 层、隐藏维度 4096、32 个注意力头的 7B 模型为例,MHA 结构下每个 token 的 KV 占用是 2×32×4096×2 字节,正好 0.5MB,一条 4096 token 的会话就是 2GB。也就是说,哪怕权重只占 3.5GB,只要你想同时服务 20 条长会话,显存立刻见底。换成 GQA 结构,8 个 KV 头、每头 128 维,每 token 降到 128KB,同样 4096 token 只要 512MB,差了四倍。这就是为什么现在的新模型几乎都上 GQA,省的不是权重,是缓存。
| 模型规模 | 精度 | 权重显存 | 每 token KV(MHA) | 每 token KV(GQA-8) |
|---|---|---|---|---|
| 7B / 32 层 | FP16 | 14 GB | 0.5 MB | 128 KB |
| 7B / 32 层 | INT8 | 7 GB | 0.5 MB | 128 KB |
| 7B / 32 层 | INT4 | 约 3.8 GB | 0.5 MB | 128 KB |
| 70B / 80 层 | FP16 | 140 GB | 2.5 MB | 320 KB |
| 70B / 80 层 | INT4 | 约 37 GB | 2.5 MB | 320 KB |
拿 70B INT4 举个完整的例子:单张 80GB 加速卡,权重占 37GB,框架和激活留 8GB,剩下 35GB 给 KV Cache。每 token 320KB,总共能放约 11 万个 token。如果平均每会话 4096 token,理论并发上限就是 26 条左右。这个数字决定了你的单卡吞吐天花板,也决定了要不要上多卡并行。我见过太多人先调参数后算账,结果调了半天发现显存根本不够撑起目标并发,纯属浪费时间。
提示:算这笔账的时候一定要按“输入长度加输出长度”的最大值算,不能按平均值。线上一定是长短请求混合到达的,平均值好看没用,峰值一到就被打爆。
1.3 场景不同,路线完全不同
同一个模型,放在在线对话和放在离线批处理里,优化手段几乎是两套东西。在线对话的特点是请求短、到达随机、对首字延迟敏感,优化重点是调度器的抢占策略、分块预填充、前缀缓存命中率,批大小通常压在 8 到 32 之间,不敢往上冲。离线批处理的特点是一次性丢过来几十万条、可以等、对单条延迟完全不敏感,优化重点是尽可能打满显存和算力,批大小往 128 甚至 256 冲,甚至可以关掉流式输出、关掉部分安全兜底逻辑来省开销。
还有一种场景容易被忽略,就是长文档问答和代码补全。前者输入动辄几万 token,预填充一次要几百毫秒甚至几秒,这时候前缀缓存和分块预填充的收益极大;后者输出短、并发高、请求极碎,真正的瓶颈在调度开销和 kernel 启动开销上,图捕获和算子融合带来的收益比量化还明显。所以我在动手之前一定会先抽样看线上真实的请求分布:输入长度分布、输出长度分布、并发曲线、缓存命中潜力。这份分布图比任何调参指南都有用。
2. 模型侧的瘦身:量化、蒸馏与结构裁剪的取舍
2.1 权重量化的三档精度与真实收益
量化是投入产出比最高的一刀,几乎不需要改代码,换个权重文件就能把显存砍掉一半以上。但量化不是无脑选最低位宽,得看你卡在哪。常见的三档是:W8A8,权重和激活都量化到 8 位;W4A16,只量化权重到 4 位,计算时反量化回 16 位;还有 W4A8 这类更激进的组合。W8A8 的收益是把权重显存砍半、同时因为用了整数矩阵乘,算力利用率能提一截,精度损失通常在千分位级别,绝大多数场景可以放心用。它对激活的异常值比较敏感,需要做通道级的缩放均衡,把难量化的通道压力摊到好量化的通道上,否则个别层的输出会明显劣化。
W4A16 的收益是把权重压到四分之一,7B 模型从 14GB 降到 3.8GB 左右,代价是计算前要反量化,理论上会多一点开销,但实测下来因为访存量大降,解码阶段的 TPOT 反而经常是下降的。这条路线最适合显存吃紧、又想保持精度的场景。W4A8 更激进,权重和激活都压,理论吞吐最高,但对校准和 Kernel 支持要求高,我一般只在对延迟极度敏感、且已经做完充分评测的场景才上。
选择的时候有个简单的判断法:如果瓶颈是“显存装不下”,优先 W4A16,因为收益直接且精度可控;如果瓶颈是“算力打不满、吞吐上不去”,优先 W8A8,因为整数算力才是吞吐的来源;如果两个都卡,才考虑更激进的组合。另外要提醒一句,量化对预填充和解码的收益是不对称的,预填充吃算力、解码吃带宽,同一份量化权重对两者的提升幅度可能差一倍,压测的时候一定要分开测,别拿一个平均值当结论。
2.2 校准集怎么选,精度怎么验
量化一定会掉精度,问题是怎么把损失控制在业务可接受的范围内,而不是自欺欺人地说“几乎无损”。校准集的选取是第一道关。我的经验是校准数据必须贴近真实线上分布,不能随便拿几百条通用语料糊弄。如果业务是法律合同问答,校准集里就该有合同文本;如果是代码补全,就该有真实代码片段。校准样本量一般 128 到 512 条就够,再多边际收益很低,但一定要覆盖长文本,因为长序列下的激活分布和短文本差别很大。
验证环节我一般分三层。第一层是困惑度,在留出集上对比量化前后,W8A8 一般上升不到 1%,W4A16 上升 2% 到 5% 属于正常,超过 8% 就要怀疑校准集或者分组大小有问题。第二层是下游任务指标,拿业务里真实的评测集跑一遍,看准确率、抽取的字段完整率、格式合规率这些硬指标有没有掉,这一步最能反映真实影响。第三层是人工抽检,随机抽一两百条真实请求,让熟悉业务的人盲评,看有没有出现重复、跑题、指令遵循变差这类软性退化。三层都过,才算量化验收通过。
注意:困惑度不掉不代表业务不掉。我遇到过量化后困惑度几乎没变,但模型对 JSON 格式的遵循率从 98% 掉到 82% 的情况,原因是个别层对结构化标记的敏感度被量化误差放大了。所以格式类、工具调用类的业务,一定要单独构造评测集。
2.3 KV Cache 量化和稀疏化:省钱但危险的地方
权重之外,KV Cache 是第二块大头,尤其是长上下文场景。KV Cache 量化到 FP8 在实践中已经比较成熟,收益是把缓存占用直接砍半,等效于并发数翻倍,精度损失通常很小,因为缓存本身的数值分布比激活温和。再往下的 INT8 就需要更细的粒度,按 token、按头分别算缩放因子,否则长序列尾部的误差会累积得很厉害。我一般的原则是:上下文长度在 8K 以内,KV 量化收益有限、风险不小,可以先不做;上下文超过 32K,KV 缓存已经比权重还大,这时候不做量化基本没法上生产。
稀疏化这条路要谨慎得多。结构化的头剪枝、层剪枝确实能转化成真实的速度提升,因为剪掉的部分是真的不计算了;非结构化稀疏在绝大多数通用硬件上只是省了存储,计算还是按稠密走,跑起来该多慢还多慢,属于看着很美但不落地。至于“只保留最近的若干 token 的 KV”这种滑窗策略,效果取决于业务,摘要类任务里丢掉早期内容问题不大,多轮对话里丢掉开头的系统提示和关键约束会直接翻车。这类激进手段我建议只在明确能接受信息损失的场景试,并且一定要有 A/B 对比数据兜底。
3. 推理引擎侧的加速机制怎么用
3.1 连续批处理:吞吐提升最猛的一刀
传统批处理是静态的,一个 batch 里所有请求必须等最慢的那条跑完才能释放,一条生成长回答的请求能把整个 batch 卡住,短请求的槽位全被浪费。连续批处理把这个粒度打散到每一轮解码:每一步都重新组批,谁生成完了谁立刻退出、空出的槽位马上让排队的请求补进来。这个改动听起来简单,实际对吞吐的影响是数倍的,尤其是在请求长度差异很大的真实流量里。
我在一个混合长度场景里实测过,静态批处理下 GPU 利用率长期在 30% 上下晃,换成连续批处理后稳定在 70% 以上,同样的硬件吞吐翻了差不多 2.4 倍,而且 p99 延迟还降了,因为短请求不再被长请求拖住。这个机制现在是标配,如果你在用的推理服务没有它,那第一个该换的就是它,优先级远高于量化。要注意的是连续批处理会带来一个副作用:批内请求数在动态变化,显存占用也就跟着忽高忽低,如果对显存余量估计不足,很容易在高峰期触发抢占,导致请求被换出重算,延迟反而更糟。所以并发上限一定要留 15% 到 20% 的余量。
3.2 PagedAttention 与显存碎片治理
KV Cache 的另一个问题是内存管理。早期做法是给每条请求预分配一块连续的、按最大长度算的显存,实际用了 500 token、预分配了 4096 token,剩下 87% 全浪费了。业界常见的统计是这种预分配方式下缓存利用率只有两三成,剩下全是碎片。分页机制借的是操作系统虚拟内存的思路:把 KV Cache 切成固定大小的块,常见块大小是 16 个 token,逻辑上连续的块通过一张块表映射到物理上不连续的显存,按需分配、用完即还。
这一刀的价值不只是省显存,更关键的是让“并发数”这个数字变得可预测。预分配模式下并发数是一个拍脑袋的保守值,分页之后可以按“总 token 数除以平均序列长度”来精确估算,缓存浪费率能压到个位数百分比,等效并发往往能提升 2 到 4 倍。块大小这个参数值得调一调:块取小一点,内部碎片少,但块表变大、访问开销略增;块取大一点反过来。16 是个比较均衡的默认值,超长上下文场景可以试 32,短请求密集的场景可以试 8。
提示:开了分页之后,显存不足时的处理策略要从“直接拒绝”改成“抢占加重算或换出到主机内存”。重算的代价是浪费算力,换出的代价是占用带宽,长请求密集时换出更划算,短请求密集时重算更划算。
3.3 分块预填充与前缀缓存:治 p99 抖动的两副药
线上延迟抖动最常见的原因,是一条几万 token 的长请求突然进来,预填充要霸占算力好几百毫秒,这段时间里所有正在解码的请求全被卡住,p99 直接爆炸。分块预填充就是治这个的:把长请求的预填充切成若干小块,比如每块 512 或 1024 个 token,每算完一块就插回去和正在解码的请求混着跑,解码请求的步进不会被打断,代价是长请求自己的首字延迟略微变长。这是一个用少量首字延迟换整体稳定性的交易,非常划算。块大小选择上,取太小会让预填充的整体效率下降,取太大又起不到平滑作用,我一般从 1024 起调,观察 TTFT 和 TPOT 的 p99 曲线找一个拐点。
前缀缓存治的是另一类浪费:很多请求共享同一段开头,比如一大段系统提示、固定的工具定义、同一篇被反复提问的文档。这些内容每次都要重新做一遍预填充,纯属浪费。前缀缓存把已经算过的 KV 按块存起来,下次遇到相同前缀直接复用,命中率高的时候首字延迟能降一个数量级。判断要不要开很简单,看两个数:前缀长度占输入长度的比例,以及相同前缀的请求占比。如果系统提示有 1500 token,而用户提问平均只有 200 token,那命中率接近 100%,收益极其明显;如果每条请求内容都不同,那开了也没用,还多占显存。我见过一些团队系统提示写了两三千字的角色设定,这种情况下前缀缓存基本是必选项。
3.4 投机解码与算子融合:用算力换延迟
投机解码的思路是拿一个小模型先猜后面几个 token,再让大模型一次性验证这几个位置,猜对了就一次吐出好几个。理论上限很诱人,但实际收益完全取决于接受率。假设草稿长度是 4,接受率 0.7,期望能接受的 token 数大概是 2.8 个,如果草稿本身的成本是大模型的 15%,整体加速比大约 1.7 倍;但如果接受率掉到 0.4,期望接受 token 数只有 1.6 个,加速比连 1.2 都不到,还要多占一份草稿模型显存,纯属亏本。所以这条路只适合高可预测的文本,比如代码补全、格式化输出、模板化改写;开放式创作里接受率普遍偏低,不建议上。
还有一种不占额外模型的变体,直接从当前请求的历史文本里匹配草稿,也就是检索式的草稿生成,在摘要、改错、复制类任务上接受率很高,成本几乎为零,性价比比独立草稿模型更好。至于算子融合和图捕获,这是延迟优化的“细活”:解码阶段每生成一个 token 要启动上百个 kernel,单次启动开销三四微秒,累计下来就是好几毫秒,占 TPOT 的比例不小。把归一化、投影、门控这些相邻算子融合成一个,再用图捕获把整个解码循环固化成一次提交,能把大量启动开销消掉。这一块的收益在模型小、批大小小、延迟要求严的场景里特别明显,我见过融合加图捕获把 TPOT 从 45 毫秒压到 28 毫秒的案例。
4. 从单卡到多卡:并行与部署拓扑
4.1 张量并行、流水并行的选择与代价
单卡装不下就必须切。张量并行是把每一层的矩阵按列或按行切开,分到多张卡上算,每层算完要做一次全规约把结果拼回来。好处是单层延迟随卡数下降,坏处是通信极其频繁,每层两次集合通信,通信量跟批大小和隐藏维度成正比。所以张量并行的度数强烈依赖卡间互联带宽:同一台机器内部的高带宽互联,做到 8 路张量并行还算舒适;跨机做张量并行,带宽掉一到两个数量级,延迟会不降反升,我基本不考虑。实践中张量并行度不超过单机卡数,这是条硬线。
流水并行是另一个思路,把不同层整段分到不同卡上,一张卡算完把激活传给下一张。它通信量小、对带宽要求低,适合跨机切分大模型,但代价是“气泡”:前面的卡在算的时候后面的卡在等,除非把批切成足够多的微批塞满流水线。经验上微批数量要至少是流水线度数的 4 倍以上,气泡才能压到 10% 以内,而这又会让显存占用上升。所以常见组合是:机器内用张量并行,机器间用流水并行,规模再大的时候再叠加专家并行处理稀疏结构。切分方案不是越复杂越好,能两级解决就别上三级,多一级就多一堆通信和调试成本。
| 并行方式 | 切分维度 | 通信频率 | 对带宽要求 | 适用位置 |
|---|---|---|---|---|
| 张量并行 | 层内矩阵 | 每层两次 | 极高 | 单机内 |
| 流水并行 | 层间 | 每微批两次 | 中等 | 跨机 |
| 专家并行 | 稀疏专家 | 按路由 | 高 | 超大稀疏模型 |
| 数据并行 | 副本 | 无 | 无 | 提高并发容量 |
4.2 服务拓扑、灰度与弹性
单机调好之后,真正的麻烦在集群层。一个完整的推理服务至少要分四层:接入网关做鉴权和限流,路由层按模型版本和租户把请求分发到不同实例,推理实例组按模型和精度分组,最后是监控和日志。限流这一层特别容易被省掉,然后就被上游的重试风暴打死。我的经验是每个实例都要有排队上限,队列满了直接快速失败返回,而不是无限排队——排队的请求最终都会超时,白占显存还给用户更差的体验,早拒绝反而更好。
灰度发布是另一条命。模型换了量化版本、框架升了级、参数动了,都必须走小流量验证。我一般的节奏是:先起一个影子实例,把 1% 的真实流量复制过去只做推理不出结果,对比输出一致性和延迟;没问题再切 5% 真实流量,观察 30 分钟;再逐步放量到全量。中间任何一步指标异常就立刻回滚。推理服务的回滚必须做到秒级,因为模型加载本身就要几十秒到几分钟,不可能靠重启回滚,得保证旧版本实例一直热着,切换只在路由层做。
弹性伸缩这块要认清一个事实:推理实例的冷启动非常慢,加载权重、编译图、预热都算上,大模型动辄几分钟。所以扩容策略不能等到负载上来了再扩,必须按预测提前扩,或者保留一批低优先级的常驻实例。缩容也要慢,宁可多留十分钟也不要刚缩完又来一波流量。
5. 一次完整的压测与调优实录
5.1 压测脚本与指标口径
压测最容易犯的错是拿固定长度的请求测出一个漂亮数字,然后上线被真实流量打脸。真实流量的输入长度是长尾分布,输出长度也不可控,所以压测必须按真实分布来。我一般从线上采样一千条真实请求,统计输入输出长度的分布,压测时按这个分布随机采样,并用泊松到达模拟真实并发节奏,而不是让所有请求同时发出。下面这个脚本不依赖特定框架,只要服务提供标准的对话接口就能跑,重点是它分别记录了首 token 时间和后续每个 token 的时间。
import asyncio, time, json, random, statistics import httpx URL = "http://127.0.0.1:8000/v1/chat/completions" async def one_request(client, in_len, out_len, results): payload = { "messages": [{"role": "user", "content": "测" * in_len}], "max_tokens": out_len, "stream": True, } t0 = time.perf_counter() ttft, last, n = None, t0, 0 async with client.stream("POST", URL, json=payload) as resp: async for line in resp.aiter_lines(): if not line.startswith("data:"): continue body = line[5:].strip() if body == "[DONE]": break now = time.perf_counter() if ttft is None: ttft = now - t0 n += 1 last = now total = last - t0 tpot = (total - ttft) / max(n - 1, 1) results.append({"ttft": ttft, "tpot": tpot, "total": total, "n": n}) def pct(xs, p): xs = sorted(xs) return xs[min(int(len(xs) * p), len(xs) - 1)] async def main(concurrency=32, duration=120): results = [] limits = httpx.Limits(max_connections=concurrency * 2) async with httpx.AsyncClient(timeout=300, limits=limits) as client: end = time.perf_counter() + duration tasks = [] while time.perf_counter() < end: in_len = random.choice([256, 512, 1024, 2048, 4096]) out_len = random.randint(64, 512) tasks.append(asyncio.create_task( one_request(client, in_len, out_len, results))) # 泊松到达间隔,均值 1/concurrency 秒 await asyncio.sleep(random.expovariate(concurrency)) await asyncio.gather(*tasks, return_exceptions=True) ttfts = [r["ttft"] for r in results] tpots = [r["tpot"] for r in results] tokens = sum(r["n"] for r in results) print(json.dumps({ "requests": len(results), "throughput_tok_s": round(tokens / duration, 1), "ttft_p50": round(pct(ttfts, 0.5), 3), "ttft_p99": round(pct(ttfts, 0.99), 3), "tpot_p50": round(pct(tpots, 0.5), 4), "tpot_p99": round(pct(tpots, 0.99), 4), }, indent=2)) asyncio.run(main())指标口径要统一,不然横向对比没意义。TTFT 从发出请求算到收到第一个内容块,TPOT 用总时长减去 TTFT 再除输出 token 数,吞吐用总输出 token 数除以压测时长——注意只算输出 token,输入 token 的处理速度单独用“预填充吞吐”衡量。压测时还要同步采集服务端的显存占用、运行中请求数、排队数、抢占次数这几项,客户端看到的延迟只是结果,服务端指标才是病因。
5.2 参数调优的先后顺序
调参最忌讳一把梭,改了十几个参数然后不知道哪个起了作用。我的顺序是固定的,从上到下逐层收敛。
第一步定精度。先选量化方案,跑通功能,做精度验收,这一步不改任何服务参数。第二步定显存上限比例。一般设 0.85 到 0.9,留出余量给临时缓冲和碎片,设到 0.95 以上短期看着能多塞几条请求,实际很容易在某个峰值被系统直接杀掉,非常不划算。第三步定最大序列长度。这个值设得越大,调度器预留的显存就越多,实际业务只要 8K,就别设 32K,白白浪费并发能力。第四步调单批最大序列数,这是吞吐和延迟的核心旋钮,从小到大加,每加一次都跑一轮压测,直到 p99 开始明显恶化,然后退回上一档。第五步开分块预填充,观察 TTFT 和 TPOT 的 p99 是否被抚平。第六步开前缀缓存,前提是命中率够高。第七步再看要不要投机解码、图捕获这些锦上添花的。
这个顺序背后的逻辑是:前三步决定“能装多少”,第四步决定“用多满”,后面几步都是在这个基础上做体验优化。顺序颠倒的典型后果是先调了一堆高级特性,结果发现显存根本不够、并发上限只有个位数,前面的调优全部作废。
5.3 一份可以直接抄的配置基线
下面这份启动参数是我在中等规模在线对话场景下比较常用的一套基线,模型的量化格式假定是 INT4 权重、FP16 激活,单机 8 卡张量并行。参数名各家框架略有差异,逻辑是通用的。
# 单机 8 卡,INT4 权重,在线对话基线 MODEL=/models/xxx-int4 python -m serve \ --model $MODEL \ --tensor-parallel-size 8 \ --max-model-len 8192 \ --gpu-memory-utilization 0.88 \ --max-num-seqs 64 \ --max-num-batched-tokens 8192 \ --enable-chunked-prefill \ --chunked-prefill-size 1024 \ --enable-prefix-caching \ --kv-cache-dtype fp8 \ --swap-space 16 \ --disable-log-requests几个参数值得单独说。max-num-batched-tokens控制的是一个调度步里最多处理多少个 token,设小了调度频繁、吞吐上不去,设大了单步耗时变长、延迟抖动明显,一般按预填充块大小的 4 到 8 倍取。max-num-seqs是并发请求上限,和 KV Cache 容量强相关,设超了会频繁抢占重算,我在 8K 上下文、FP8 缓存的配置下一般从 64 开始试。swap-space是主机内存换出空间,单位 GB,设太小了长请求直接被拒,设太大了会掩盖显存规划问题,16 到 32 比较合适。--disable-log-requests这条别小看,请求日志在高并发下会带来明显的 CPU 开销和磁盘压力,压测和生产环境都该关掉,只在排查问题时临时打开。
6. 常见问题排查速查表
6.1 六类典型故障的定位路径
线上出问题的时候,最怕的是没有排查路径,东看一眼西看一眼。我把遇到过的高频故障整理成了一张表,基本覆盖了八成场景。
| 现象 | 最可能的原因 | 快速验证方法 | 处理动作 |
|---|---|---|---|
| 启动即 OOM | 显存上限设太高、序列长度预留过大 | 把显存比例降到 0.8 重试 | 降比例、减最大长度 |
| 跑几分钟后 OOM | 并发突增、KV 缓存撑爆 | 看缓存使用率曲线是否触顶 | 降并发上限、开分页 |
| 吞吐上不去 | 批大小太小、调度开销大 | 看硬件利用率是否低于 50% | 加批、开连续批处理 |
| p99 延迟尖刺 | 长请求预填充霸占算力 | 看延迟尖刺是否与长输入同时出现 | 开分块预填充 |
| 输出重复、乱码 | 量化过度、KV 量化误差累积 | 换回 FP16 对比 | 降低量化激进程度 |
| 请求卡住不返回 | 队列积压、实例假死 | 看排队数是否持续增长 | 加限流、健康检查重启 |
排查顺序我一般是从外往内:先看客户端错误率,再看网关排队和超时,再看实例的运行中请求数和排队数,最后才看硬件指标。因为绝大多数“服务变慢”其实是排队问题,不是计算问题。判断方法很简单:如果硬件利用率不高但延迟很高,那一定是调度或排队出了问题;如果硬件利用率拉满了延迟还高,那才是真的算力不够,该加卡或者该上量化了。
6.2 几条踩出来的经验
第一条,别信“默认参数能跑就先不管”。默认配置往往是保守的,为了兼容各种场景牺牲了性能,我在一个项目里只调了三个参数——并发上限、批处理 token 数、分块预填充大小——吞吐就翻了一倍多,代码一行没改。
第二条,量化之后一定要重新压测,不能沿用 FP16 的参数。四比特权重让显存大幅释放,同样的并发上限下显存根本用不满,等于白白浪费了一半容量;同时反量化带来的额外计算可能让解码变慢,最优批大小也会跟着变。换了权重就像换了台机器,所有参数都要重来一遍。
提示:每次只改一个参数,改完立刻压测,记录数据。我用一张简单的表格记录每次实验的参数组合和四项指标,一周之后回头看,哪个参数影响最大一目了然。凭感觉调参是最容易原地打转的。
第三条,监控一定要盯“抢占次数”这个指标。它不像延迟、吞吐那么直观,但它是很多隐性问题的信号。抢占次数持续增长意味着 KV 缓存不足、调度器在反复换出重算,表现出来就是吞吐莫名其妙地低、算力被浪费在重算上。我遇到过吞吐只有理论值一半的情况,查了半天最后发现就是这个指标在报警,把并发上限从 96 降到 64 之后,吞吐反而涨了 30%。
第四条,长上下文场景要单独设一条限制。哪怕模型支持 128K,也不要对所有请求放开,因为一条 100K 的请求能瞬间吃掉几十条普通请求的缓存。我的做法是按租户或者按接口分组,普通对话接口限制 8K,文档分析接口单独开一组实例限制到 32K,两组资源物理隔离,互不干扰。这样长请求再疯也影响不到主链路,这是最省心的隔离方式。
第五条,别迷信“更快的框架”。换个推理框架能带来百分之二三十的提升已经算多了,而搞清楚自己的瓶颈在哪、把批大小和缓存调对,往往是两三倍的差距。我建议先把现有服务压到极限、把指标看明白,再考虑换技术栈,否则换了也未必知道新东西好在哪。
最后分享一个我在实际部署里养成的习惯:每次上线前,我都会用同一份固定的压测脚本、同一份固定的请求样本跑一遍,把结果存成一份基线。之后不管是升级框架、换量化版本还是调参数,都拿新结果和基线对比,而不是凭印象说“好像快了点”。这份基线在半年里帮我拦下过两次看起来“优化了”实际是回退的变更,也让我对每个参数到底值多少钱有了具体的数字感。这套流程跑顺之后,再去看那些关于推理加速的新技术,判断值不值得试,心里就有底了。