有一次凌晨改参数,把并发批处理的规模调小,首字延迟立刻好看,我以为账单也会跟着降。第二天早上才发现方向反了:小批次意味着 GPU 空转,单位 Token 的成本反而上去了。
那天我记住一件事:推理链路上的指标几乎都是联动的,动一个,另外两个会跟着变,而且经常是往坏的方向变。
首字延迟是体感,总耗时是账
用户判断一个系统快不快,用的是第一个字出现的时间,不是总耗时。这两个指标经常被混着说,混着说的时候,优化方向基本会错。
首字延迟由两段组成:请求在队列里等多久,以及模型跑完预填充、吐出第一个 Token 要多久。后者受输入长度影响很直接,材料越长,预填充越慢;前者受批次策略影响。总耗时则主要由输出长度决定,输出越多,时间线性增加。
中间的 Token 间隔是另一个维度,它决定流式输出看起来顺不顺。间隔超过人的阅读速度,用户会觉得卡,即使总量并不慢。这个指标在长回答场景比首字延迟更影响体感,只是很少有人单独监控它。
吞吐是成本侧的指标。同样的卡,每秒产出的 Token 越多,摊到每个 Token 上的钱越少。这就和延迟直接冲突了。
批处理能显著提高吞吐,因为它把显存读取的延迟摊薄了。但批次一大,排在后面的请求要先等前面的凑批,等待时间直接进首字延迟。所以“加批次”和“降首字”是同一枚硬币的两面,靠参数调不出两全。
排队时间要单独打点。首字延迟变高的时候,如果分不清是排队久了还是预填充慢了,优化就会做在错的地方。前者要调调度和容量,后者要看输入长度和算子,两件事的成本差很远。这个拆点在早期加上很便宜,事后补就要改埋点和看板。
连续批处理缓解了一部分。传统静态批处理要等一个批次里最长的序列跑完,短请求结束后算力还在给填充位做无用计算。连续批处理按迭代调度,某个序列一出结束符就换新请求进来,GPU 的有效计算占比高很多。这是 vLLM 这类框架真正值钱的地方,不是某个算子快了几个点。代价是调度本身变复杂,超时、抢占、优先级都要自己管。
显存这一侧,先爆的通常不是权重,是 KV Cache。自回归解码每一步都要用到之前所有 Token 的 Key 和 Value,缓存下来可以避免重复计算,但缓存随序列长度、批大小、层数线性增长。长上下文加高并发,这部分能轻易超过权重本身。
推理的两个阶段性质不同。预填充要把整段输入算一遍,是计算密集的,输入越长越明显;解码每步只算一个 Token,但要把权重和缓存从显存搬到计算单元,是访存密集的。这解释了一个常见现象:输入很长的请求慢在开头,输出很长的请求慢在全程,两者的优化手段不一样。前者可以靠分块计算和更高效的注意力实现,后者更依赖带宽和批次。
我现在的习惯是先把这笔账估出来再谈需求。一个看起来只是“多传几页文档”的功能,可能直接把并发能力砍掉大半。产品侧不会知道这件事,工程侧如果不提前说,上线后才补救就很被动。
分页管理解决的是碎片问题。早期实现要求 KV Cache 占连续显存,只能按最大长度预分配,短请求也占着长请求的位置,利用率很低。分页把缓存切成固定大小的块,物理上不连续,用一张映射表串起来,碎片基本消失,同样显存能塞进更大的批次。并行采样时还能共享前缀块,写时复制。这套思路不新,操作系统早就这么干了,有意思的是它在推理框架里带来的收益比很多模型侧的优化都直接。
量化是另一条路。权重从半精度压到更低位宽,显存和带宽都省下来,代价是精度。工程上真正有效的不是均匀压缩,而是识别出少数对输出影响大的通道,保留高精度,其余激进压缩。这个思路在不同实现里名字不一样,本质都是承认权重的重要性不均匀。上线前我会做两件事:对比量化前后的输出分布,再单独跑一个难例集,确认长尾没有被削平。
模型尺寸的选择也在这里定。不是越大越好,而是看延迟预算能容纳多大的模型,再决定用哪个。一个能稳定回答的较小模型,通常比一个频繁超时的大模型更有用。剩下那部分难题,可以走异步或者人工,不必都压在同一条实时链路上。
一个具体的习惯:任何参数改动都先在固定流量上跑一段时间,拿分位数说话。平均值会掩盖长尾,而长尾才是用户真正记住的那次卡顿。
CoT 把延迟放大,流式只是止痛
思维链本质是用更多 Token 换更好的答案。原来一句话能答的,现在要先写五六步推理。后端看到的是总耗时成倍增长,前端看到的是长时间空白。
流式输出解决的是感知,不是时间。第一个 Token 一出来就推给客户端,用户看到文字在动,等待感弱很多。工程上要注意别等整段推理结束再建连接,也别让网关的缓冲把流式吃掉,这两件事在本地测不出来,只有真实网络下才暴露。
结构化流式是这两年比较实用的做法:把思考过程包在一个可解析的标记里,前端实时渲染成可折叠的区域,正文仍然按普通流式输出。用户既能知道系统在工作,又不会被推理过程干扰。这属于产品层面的取舍,不是模型能力,但它对留存的影响比多数推理优化都大。
思维链也不是所有任务都值得开。事实查询、格式转换、简单的分类,加上推理步骤只会让答案变长、延迟变高,准确率不会提升。我一般只在多约束、需要比较、需要计算的场景打开它,并且允许用户关掉。把开关交给用户,比在后台猜意图更可靠。
真正缩短生成时间要靠投机采样这类手段。小模型先起草几个 Token,大模型一次前向并行验证,命中就一次吞掉多个。思维链里连接词和套话比例高,草稿命中率通常不错。但它对草稿模型和任务分布很敏感,草稿质量差的时候,验证失败反而更慢。我见过把它当万能加速开关的方案,上线后长尾延迟更难看,因为平均值被少数命中掩盖了。
并行化也有边界。多个子任务确实独立时,拆开并发能把线性累加变成取最大值;但如果后一步依赖前一步的结论,硬拆只会得到互相矛盾的中间结果,最后还要多一轮合并。判断标准不是“能不能并发”,而是“这些步骤之间有没有信息依赖”。
超长请求还得考虑连接本身。一次推理跑几十秒,网关的默认超时、负载均衡的空闲回收、客户端的重试,任何一环都可能把结果丢掉。我的做法是把这类请求转成异步任务,先返回一个标识,客户端轮询或走长连接取结果。代价是交互复杂了,前端要处理“进行中”的状态,但比在三十秒后收到一个 504 要好。
异步路径要能重复取结果,也要能取消。用户关掉页面之后任务还在跑,是纯粹的浪费;任务完成了但结果取不到,用户会重试,又产生一份计算。这两件事不做,异步只是把问题从连接层挪到了费用层。
降级是产品决策,不是运维补丁
线上最难的从来不是让系统更快,是让它别挂。
LLM 的故障和传统服务不一样。HTTP 200 也可能是故障:模型陷入重复、输出一段格式坏掉的 JSON、答非所问、触发风控。这些软故障不会报警,只会体现在用户的投诉里。所以监控不能只看错误码,还要看输出本身。
我的处理顺序是:先分类,再决定重试还是放弃。网络超时、限流这类瞬时错误,指数退避重试有意义,但要加抖动,否则一批请求会在同一时刻一起重试,把刚缓过来的服务再打一遍。参数错误、格式错误这类逻辑错误,重试通常只是多烧一次 Token。
重试之上要有熔断。某个上游连续失败到阈值,继续打过去只会让排队更长,应该直接切断,走备用路径。阈值定多少要看业务能容忍多少误杀,这个数没有通用值,只能根据历史故障曲线定,然后每次事故后回看。
降级我一般设计成三层:语义缓存、小模型、规则兜底。
语义缓存最便宜,命中就绕过了整个推理。但它有一个隐蔽的风险:相似不等于等价。用户问“能不能退”,另一个人问“退了能不能换”,向量距离很近,政策条款完全不同。所以缓存键里必须带上会影响结论的条件,比如用户类型、地区、时间窗、商品类目。命中率会掉,误答率也会掉,我认为这笔交换划算。
缓存还要能失效。政策更新之后,旧答案不能继续命中,否则错得整齐划一。我的做法是给缓存条目挂上知识库版本号,版本一变就整体作废。全量失效的代价是命中率短暂归零,但比返回过期答案安全。
小模型兜底要连带改 prompt。原来给大模型的长示例、复杂约束,小模型消化不了,直接丢过去反而更容易跑偏。降级不是换个模型名就完事,配套的指令要一起简化,输出解析也要放宽。很多“降级后更差”的案例,问题不在模型,在没改配套。
规则兜底听着落后,但它保证了系统在最低谷时还能给出一句确定的话,而不是一个转圈的前端。
降级的体验也要设计。用户不知道背后换了模型,只会感觉回答变短、变保守。如果这时候界面还显示“正在深度思考”,落差会更大。我倾向于在降级时明确缩短回复、收起复杂功能,并且给一个可重试的入口,而不是假装一切正常。
有损服务优于拒绝服务,但“有损”的程度要提前写进产品文档,而不是在事故里临时决定。
限流这一层,传统 QPS 在 LLM 场景里经常失效。一个简短问候占 GPU 几毫秒,一段几千字的长文分析能占几十秒显存。按请求频次限流,放进来的十个请求可能瞬间把显存吃光,后面的请求明明没超频次,却全部超时。
更合理的做法是按并发数和在途计算量限流。限制同时处于推理中的请求数,超出的排队或直接拒绝。再进一步,可以在入口用分词器估算输入长度,结合历史平均输出长度估一个成本,累计在途成本超过水位就拒新请求。
队列必须有界,而且要快速失败。让用户在队列里等一分钟再收到超时,是最差的体验,也浪费连接资源。请求出队时再检查一次剩余时间,明显跑不完的直接丢弃,把算力留给还有希望的请求。分级限流也常见:网关按 IP 拦粗粒度刷量,模型代理按 Token 速率控细粒度,两层都做,才不会顾此失彼。
宁可快速拒掉一部分,也不要让所有请求一起卡在队列里。
这话说起来轻松。被拒的那部分用户是真的走了,所以阈值最后还是要产品和工程一起定,不能只由工程拍。
上线之后我只看几个数
监控在 LLM 系统里不是看板,是排查工具。
首字延迟、单请求成本、检索或工具环节的耗时、输出 Token 数,这四个够用了。再细的指标如果没人看,就是负债,还会拖慢埋点改造。
链路追踪的价值在于归因。一次请求十几秒,是检索慢、重排慢、还是模型生成慢,没有 span 就只能猜。我见过的隐形瓶颈里,重排排第一:它经常被当成顺手加的一步,实际是 CPU 上最重的一段,而且延迟随候选数线性增长。
成本要按租户或用户维度记。总额只能说明这个月超支了,分维度才能说明是哪个功能在烧钱。很多“模型太贵”的问题,拆开看是某个循环在重复调用,或者上下文里塞了整份文档。
质量监控我用抽样加模型打分,比例很低,主要看趋势。它的作用是发现“这周开始答非所问”,不是给出精确分数。分数本身不可比,换一个裁判模型,整个基线就变了,所以我更愿意看同一批样本在不同版本之间的相对变化。
容量规划我一直做得不够细。压测能测出曲线的拐点,但真实流量是混合的,长短请求的比例一变,同一个并发数下的表现就完全不同。所以我更愿意按最坏情况留余量,接受一部分闲置,而不是把机器用满。这个选择在成本上不好看,在稳定性上省心。
最让我警惕的不是慢,是回归。改一个 prompt 修好了三个问题,同时弄坏了两个原本正确的。所以上线前我会跑一遍固定的小评测集,指标不降才放量。这套东西维护起来很枯燥,但它是我目前唯一信得过的确定性来源。
告警也要克制。GPU 利用率高不一定是故障,队列变长也不一定需要人介入,只有那些“必须现在做点什么”的情况才该把人叫起来。定得太密,值班的人会开始忽略它,那比没有告警更危险。我现在只留三类:错误率突增、队列持续不清空、成本曲线异常。其余的都进日报。日报没人看也没关系,它至少是可查的。
评论