1. 从一次线上延迟抖动说起:为什么端到端视角比单点优化更重要
很多人第一次接触大模型推理,注意力会立刻被某个具体环节吸走——有人盯着量化精度,有人死磕KV Cache的显存占用,还有人一上来就研究连续批处理(continuous batching)的调度策略。这些当然都重要,但我在实际排查问题时发现,真正让人抓狂的往往不是某个单点不够快,而是整条链路里各环节的假设对不上:上游按一种方式算token,下游按另一种方式切分;调度器以为请求是均匀到达的,实际流量却是脉冲式的。结果就是单看每个模块的指标都挺漂亮,端到端一跑就抖得厉害。
这篇内容想做的事情,是把大模型推理从请求进来到结果返回的完整链路拆开讲一遍。它不是某篇论文的复述,也不是某个框架的官方文档翻译,而是一个做过若干次推理服务搭建和调优的人,把这条链路上真正需要理解的环节、容易踩的坑、以及各环节之间的耦合关系讲清楚。适合的读者是:已经跑通过至少一个推理Demo、想搞清楚背后到底发生了什么的人;或者正在做推理服务、被延迟和吞吐折腾过的人。如果你完全没接触过Transformer,建议先补一下注意力机制的基础,否则读起来会有点吃力。
我先把整条链路的一句话版本放在这里,后面再逐段展开:请求进来 → 分词与预处理 → 调度与批处理 → 模型前向(含KV Cache管理)→ 采样与后处理 → 流式返回。这六步听起来平平无奇,但每一步都有它自己的"隐藏假设",而这些假设正是端到端性能的胜负手。
2. 请求进入推理引擎之前:分词、模板与预处理里被低估的开销
2.1 分词不是"顺手做一下"那么简单
分词(tokenization)在很多人眼里是个无足轻重的步骤,毕竟它就是个查表加切分。但实测下来,当请求量上去之后,分词环节的CPU占用会变得相当可观,尤其是当输入文本很长、或者用了那种基于BPE的复杂词表时。我见过一个场景:模型前向只占了总延迟的六成,剩下四成里有一大半耗在了分词和模板拼接上。
这里的关键在于,分词是纯CPU操作,而模型前向通常在GPU上。如果分词和推理串行执行,GPU就会周期性地空转等数据。合理的做法是把分词放进独立的进程池或线程池,让它和前向计算形成流水线。Python里因为GIL的存在,多线程对CPU密集任务帮助有限,所以更稳的是用多进程,或者干脆用Rust/C++写一个分词服务。
另一个容易被忽略的点是词表与模型的匹配。有些团队为了省事,直接拿一个通用分词器配一个微调过的模型,结果就是同样的文本被切成了和训练时不一样的token序列,输出质量肉眼可见地下降。分词器必须和模型训练时用的完全一致,这不是可选项。
2.2 对话模板:一个字符的差异就能毁掉输出
如果你用的是对话模型,那在分词之前还有一步:把多轮对话按模型要求的模板拼成一段文本。这个模板通常包含角色标记、分隔符、以及特殊的起止token。不同模型的模板差异极大,有的用<|user|>,有的用[INST],有的靠换行和特定前缀区分。
我踩过的一个坑是:模板里多了一个空格,模型就开始胡言乱语。原因是这个空格改变了token序列,让模型进入了一个它训练时没见过的分布。所以模板拼接这件事,一定要用模型官方给出的格式,并且写测试用例去校验拼接结果和预期完全一致。别凭感觉。
2.3 预处理阶段值得单独计时的几个指标
为了后面排查问题方便,我建议在预处理阶段至少埋这几个点:
| 指标 | 含义 | 关注点 |
|---|---|---|
| 分词耗时 | 从原始文本到token id序列 | 长文本时是否线性增长 |
| 模板拼接耗时 | 多轮对话组装 | 轮数多时是否退化 |
| 队列等待时间 | 进入调度器前的排队 | 是否成为瓶颈 |
| 输入token数分布 | 请求长度分布 | 决定批处理策略 |
这些指标单独看没什么,但当你发现端到端延迟抖动时,它们能帮你快速定位是"数据没准备好"还是"算力不够"。
3. 调度与批处理:吞吐和延迟在这里做交易
3.1 静态批处理为什么在真实场景里不够用
最早的推理服务用的是静态批处理:攒够一批请求,一起送进模型,等这批全部算完再返回。这个方案在离线评测里很漂亮,吞吐数字很好看。但真实场景里,请求是陆续到达的,而且每个请求的输出长度差异巨大。静态批处理会导致两个问题:一是短请求要等长请求算完才能返回,延迟被拉长;二是GPU在等待新批次凑齐时处于空闲。
这就是连续批处理(continuous batching)出现的背景。它的核心思想是:不按"批次"为单位,而按"迭代步"为单位。每一步只算当前还在生成的那些序列,某个序列生成完了就立刻退出,腾出的位置马上给新来的请求。这样GPU的利用率能维持在高位,同时短请求的延迟也不会被长请求拖累。
3.2 连续批处理的调度器到底在做什么决策
调度器每一步要回答几个问题:这一轮哪些请求参与计算?新请求能不能插进来?显存够不够放新的KV Cache?如果不够,该驱逐谁?
这里有个关键概念叫分页注意力(PagedAttention),它把KV Cache切成固定大小的块,像操作系统管理内存页一样管理显存。好处是显存碎片少,而且不同序列可以共享块(比如相同的系统提示词前缀)。调度器在决定接纳新请求时,实际是在问:还有没有空闲的块?如果没有,能不能通过驱逐低优先级序列来腾出块?
调度策略直接影响用户体验。一个常见的做法是给请求分优先级,比如交互式请求优先于批量任务。但优先级不能滥用,否则低优先级请求可能饿死。我一般会设一个最大等待时间,超过就提升优先级,保证公平性。
3.3 批大小不是越大越好
很多人有个直觉:批越大,吞吐越高。这在某个范围内成立,但超过临界点后,单个token的生成延迟会上升,因为每一步要算的序列变多了。对于延迟敏感的在线服务,这个临界点通常比想象中低。
我的经验是,先测出在可接受延迟下的最大批大小,然后留20%的余量。同时要监控每步耗时和队列长度,如果队列持续增长,说明吞吐跟不上到达率,这时候要么加机器,要么优化单步耗时,而不是盲目调大批大小。
4. 模型前向:KV Cache、注意力计算与那些省不掉的成本
4.1 自回归生成为什么是"一步一个token"
大模型生成文本是自回归的:给定前面的token,预测下一个,然后把预测结果接到后面,再预测下一个。这意味着生成N个token需要做N次前向。每次前向都要重新计算注意力,但前面token的Key和Value其实没变,所以可以缓存起来复用,这就是KV Cache的由来。
没有KV Cache的话,生成第N个token要重新算前N-1个token的K和V,计算量是平方级的。有了缓存,每步只需要算当前token的K和V,然后和缓存的拼接。这是推理能跑起来的基础。
4.2 KV Cache的显存账要提前算清楚
KV Cache的大小是可以估算的,公式大致是:
KV Cache大小 = 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × 批大小 × 数据类型字节数举个例子,一个70亿参数的模型,32层,32个头,头维度128,用FP16(2字节),序列长度2048,批大小16:
2 × 32 × 32 × 128 × 2048 × 16 × 2 ≈ 17.2 GB这还没算模型权重本身。所以显存规划必须把KV Cache算进去,否则跑到一半OOM(显存溢出)就很尴尬。这也是为什么长上下文场景下,即使用了PagedAttention,也要限制并发数。
4.3 注意力计算的两种形态:预填充与解码
前向其实分两个阶段。**预填充(prefill)**是处理输入prompt,所有token一次性并行计算,计算密集,GPU利用率高。**解码(decode)**是逐个生成token,每步计算量小,但受限于显存带宽,GPU计算单元大量空闲。
这两个阶段的特性完全不同,所以现在很多引擎会把它们分开调度,甚至放在不同的硬件上。预填充适合大batch并行,解码适合小batch低延迟。理解这个区别,才能明白为什么有些优化对预填充有效、对解码无效。
4.4 量化:省显存和保精度之间的平衡
量化是把权重或激活从FP16降到INT8、INT4甚至更低。好处是显存占用下降、带宽压力减小,解码阶段尤其受益,因为解码是带宽瓶颈。
但量化不是免费的。权重量化相对成熟,INT8基本无损,INT4在多数模型上也能接受。激活量化就麻烦一些,因为激活的分布是动态的,需要校准。我的一般建议是:先做权重量化,如果显存还是不够再考虑激活量化,并且一定要用真实任务评测,别只看困惑度(perplexity)。
5. 采样与后处理:生成策略如何影响最终体验
5.1 贪心、束搜索与采样的取舍
模型前向输出的是每个位置上的logits,也就是词表上的分数。怎么从这些分数里选token,就是采样策略的事。
贪心解码每步选分数最高的,结果确定但容易重复。束搜索维护多个候选序列,适合翻译这类任务,但计算开销大。随机采样按概率分布抽,配合温度(temperature)控制随机性。温度高则多样,温度低则保守。
实际服务里,我见过最多的是top-p(核采样)加温度的组合。top-p只从累积概率达到p的最小token集合里采样,能动态调整候选范围,比固定top-k更灵活。
5.2 重复惩罚与存在惩罚的实际效果
模型有时会陷入重复循环,尤其是长文本生成。重复惩罚(repetition penalty)对已经出现过的token降低分数,存在惩罚(presence penalty)则是对出现过的token统一降分,不管出现几次。
这两个参数调起来很微妙。惩罚太弱没效果,太强会导致输出不连贯甚至语法错误。我的经验是从1.1左右的重复惩罚开始试,观察输出质量再微调。别一上来就设1.5,那样模型会开始说胡话。
5.3 流式返回:让用户感觉"快"的关键
端到端延迟里,用户感知最强的其实是首token时间(TTFT),而不是总生成时间。流式返回就是每生成一个token就立刻推给客户端,用户看到文字一个个蹦出来,主观上觉得很快。
实现流式返回要注意:一是要处理好token到文本的转换,因为一个词可能被切成多个token,要等拼完整了再显示;二是要处理网络中断和超时;三是要考虑背压,如果客户端消费慢,服务端不能无限缓冲。
6. 端到端串联:那些只有跑起来才会暴露的耦合问题
6.1 预处理慢会伪装成"模型慢"
前面提过分词和模板拼接的开销。实际排查时,如果只监控GPU指标,会看到GPU利用率不高,然后误以为是模型或调度的问题。正确的做法是在请求入口和模型入口各打一个时间戳,算出预处理耗时。我遇到过预处理占了总延迟35%的情况,优化分词器之后端到端直接降了三成。
6.2 批处理策略和显存管理的冲突
连续批处理想多塞请求提高吞吐,但KV Cache的显存是有限的。如果调度器不感知显存压力,就可能接纳了太多请求,导致频繁的块驱逐和重计算,反而拖慢整体。所以调度器必须和显存管理器联动,把显存作为一等公民来考虑。
6.3 采样参数不一致导致的"玄学"问题
有时候同一个请求,两次返回的结果差异很大,用户会以为是模型不稳定。其实可能是采样参数没固定,比如温度默认值在不同接口里不一样。生产环境里,对于需要确定性的场景,应该显式设置温度接近0,并固定随机种子。
6.4 一个可复现的排查顺序
当端到端延迟异常时,我一般按这个顺序查:
- 看请求入口到模型入口的耗时,排除预处理问题。
- 看队列等待时间,判断是吞吐不足还是到达率突增。
- 看每步解码耗时,判断是计算瓶颈还是带宽瓶颈。
- 看KV Cache的块使用率和驱逐次数,判断显存是否吃紧。
- 看采样和后处理耗时,这部分通常小,但流式返回的网络开销不能忽略。
这个顺序的逻辑是:从外到内,从便宜到贵。先排除那些改起来成本低的环节,再动模型和调度。
7. 我在实际部署中攒下的几条经验
第一条,别在没测过端到端的情况下调单点参数。我见过有人把量化精度调到最低,单看模型前向确实快了,但输出质量崩了,用户投诉一堆,最后还得回滚。端到端评测要包含真实任务的成功率和延迟分布,不能只看平均值。
第二条,首token时间和总生成时间要分开优化。前者靠预填充优化和调度优先级,后者靠解码效率和批处理。混在一起谈"推理慢"是没法定位的。
第三条,显存规划要留余量。KV Cache的实际占用会随着序列长度动态变化,峰值可能比估算高。留20%到30%的余量,能避免很多半夜被报警叫醒的情况。
第四条,流式返回的边界情况要测。客户端提前断开、网络抖动、超长输出,这些在Demo里不会遇到,但上线后一定会遇到。提前写好超时和清理逻辑,比事后补要省心得多。
第五条,监控要覆盖全链路。只监控GPU利用率是不够的,预处理耗时、队列长度、块驱逐次数、采样耗时,这些都要有指标。出问题时,数据比直觉可靠。
这条链路看起来环节不多,但每个环节都有自己的脾气。把它们的假设对齐,把耦合关系理清,端到端才能稳。我自己的体会是,推理优化到最后,拼的不是某个单点技术多先进,而是对整个链路的理解有多深。