推理引擎换代:换一个引擎要重算的五件事

发布时间:2026/10/25 7:32:26
推理引擎换代:换一个引擎要重算的五件事

说明:本文讨论的是把在线推理从一套引擎产品换到另一套引擎产品时,需要重新核算的五类事项,属于推理侧工程话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。

一、换引擎换掉的到底是什么

换引擎的决定很少从技术评审开始,多半从一句"这个引擎在我们这类输入上不行"开始。但"不行"要拆开:首 token 慢、长输入下吞吐塌、量化格式加载不了、拿不到运维指标。四种原因对应四种动作,只有最后一种值得换引擎。

引擎不是"把权重跑起来的程序",它至少有四个可替换面:

  • 请求调度:谁先跑、能不能插队、超长请求怎么排队
  • KV 缓存管理:块怎么分、怎么回收、显存不够时拒谁
  • 批策略:一次前向拼多少条、按条数拼还是按 token 拼
  • 算子实现:哪套 kernel、支持哪些 dtype、头维度怎么对齐

权重是同一份,这四层一变,跑出来的就是两个服务。换引擎改的不是模型的输出,是服务的形状。

先划一条线。本文说的"换代",是从一套引擎产品换到另一套:配置项要重写,字段名对不上,语义要重新理解。同一引擎从小版本到大版本的升级,键名基本不动,属于依赖版本矩阵与环境漂移那类问题,不在本文范围。判断方法很直接:启动配置里一个键都不用改,那就不算换代。

四个替换面,六处影响

影响面换引擎后变了什么需要重算什么没算会怎么暴露
请求调度排队顺序、超时语义、拒绝还是等待超时值、重试策略、并发上限偶发长尾超时,压测复现不了
KV 缓存分配单位、复用条件、上限算法并发容量、最大序列长度上线后 OOM,或并发低于预期
批策略拼批维度、prefill 与 decode 混合吞吐基线、批上限参数吞吐对不上,找不到原因
算子实现支持的量化格式、对齐约束权重格式、量化档位、可用长度加载失败,或长输入下输出异常
默认参数采样默认值、stop、截断行为采样参数、后处理逻辑输出风格突变,用户先发现
可观测性指标名、单位、桶边界监控适配层、告警阈值大盘断线,故障时看不见

前四行压测能压出来,后两行只会在用户反馈里冒头——迁移出事故的,八成是后两行。

三样不在代码里的东西

调过的默认值。为了让旧引擎输出稳定,你多半在网关或客户端动过手脚:压温度、加重复惩罚、在提示末尾补一句"不要输出 markdown"。这些散落在业务代码里,换引擎时不会跟着走,而它们恰恰是输出风格的主要来源。

长度假设。分页、截断提示、摘要阈值,都按旧引擎的输出长度分布定。旧引擎习惯写长,阈值设在两千字;新引擎写得短,阈值就成了永不触发的死代码——不报错,但那句"内容过长"再也不会出现。

监控口径。指标名、单位、分位桶全绑死在旧引擎的暴露格式上。它决定迁移期间你还能不能看见问题。

这一章可以带走的一句:换引擎不是换一个进程,是换一整套默认行为。先把四个面和三样资产列成清单,再谈配置怎么写。

二、配置语义不通用

两套引擎的配置项,名字经常能对上,语义基本对不上。最容易出事的是四组:并行度、批上限、显存占比、最大序列长度——共同点是数字抄过去了,含义没抄过去,而且不会报错。

并行度:卡数不等于并行度

旧引擎一行tensor_parallel_size: 8就把八张卡吃满。新引擎往往要分别声明张量并行、流水线并行、数据并行,三者还有整除约束:

  • 张量并行度要整除 attention 头数(分组查询的模型看 KV 头数)
  • 流水线并行度要整除层数,还要定嵌入层和归一化层挂在哪一段
  • 剩下的卡用来起副本——常被漏算,八张卡只跑出一个副本

⚠️ 代码待验证

defplan_parallelism(num_gpus:int,num_layers:int,num_kv_heads:int,tp:int,pp:int)->dict:"""把"我有几张卡"翻译成引擎要的三个并行度,并把约束提前撞出来。"""iftp*pp>num_gpus:raiseValueError(f"tp*pp={tp*pp}超过卡数{num_gpus}")ifnum_gpus%(tp*pp)!=0:raiseValueError("卡数不能被 tp*pp 整除,剩下的卡只能空转")ifnum_kv_heads%tp!=0:raiseValueError(f"KV 头数{num_kv_heads}不能被 tp={tp}整除")ifnum_layers%pp!=0:raiseValueError(f"层数{num_layers}不能被 pp={pp}整除")return{"tensor":tp,"pipeline":pp,"replicas":num_gpus//(tp*pp),# 余下的卡起副本,别当成浪费"kv_heads_per_rank":num_kv_heads//tp,"layers_per_stage":num_layers//pp,}

批上限:一个是条数,一个是 token 数

max_num_seqs: 256在旧引擎里指"同时驻留的请求条数"。新引擎常常还要再写一个"单步最多拼多少 token",两个上限先到先限。

平均输入八千 token 时,把 256 直接抄过来,实际并发会被 token 上限压到个位数,日志上却看着"并发远没打满"。判据是压测时看引擎每步统计:条数和 token 数都在涨,是条数在限;只有 token 数顶住不动,是 token 在限。

显存与序列长度:两个互相牵制的旋钮

给比例时,KV 空间 =(总显存 − 权重占用 − 激活峰值)× 比例。换了引擎,权重加载后的占用会变,同样的比例算出来的空间就不一样。给字节时你得自己算:层数 × 2 × KV 头数 × 每头维度 × dtype 字节 × 目标序列长度 × 目标并发。比例是给别人看的,字节是给自己算的。

max_model_len常被当成安全上限,其实它是 KV 池的分配单位。池子总量固定时,这个值翻倍,最坏情况下可驻留的请求数就减半。旧引擎按块分配,长请求只占实际用掉的块;新引擎如果按上限给每个请求预留,设三万多就等于每个请求先占三万多。这两项必须一起定。

⚠️ 代码待验证

# 旧引擎:一份配置管完,键名就是你熟悉的那几个model:/models/serving-bundletensor_parallel_size:8# 8 卡单机,一行吃满max_num_seqs:256# 同时驻留的请求条数gpu_memory_utilization:0.90# 占总显存的比例max_model_len:32768# 单请求最长上下文# 新引擎:同一个意图,要拆成三处才表达得出来model_path:/models/serving-bundleparallel:tensor:8# 必须整除 KV 头数pipeline:1# 必须整除层数data:1# 多副本时才有意义scheduler:max_running_requests:256# 含义是"单步最多 256 条"max_batch_tokens:16384# 旧引擎没有这一项,它才是真正的闸门memory:kv_cache_bytes:40GB# 不给比例,直接给字节context:max_seq_len:32768

这一章可以带走的一句:配置没有翻译表,只有意图表。先写下"要几路并发、单请求最长多少、首 token 分位不超过多少",再去各自的引擎里找旋钮;找不到旋钮的那一项,就是这次迁移要付的代价。

三、权重与精度格式的匹配

权重文件看起来最不该出问题——它又没变。实际上它常是第一天的拦路虎,因为它牵扯的不只是格式,还有卡型、算子和对齐约束。

先核支持矩阵,别先动手转格式

要核的是三个维度的交叉:权重格式、目标引擎、卡型架构。少核一个,就会在加载到一半时报一个看不懂的错。

格式新引擎能否直接加载需要做什么怎么核对
通用分片权重多数可以,索引要齐全核对分片数与索引一致加载后比对参数量与层数
旧引擎自有序列化通常不能先转回通用格式再加载转换脚本要能重跑
权重量化(分组整数)看 kernel 是否覆盖该卡型可能要换量化工具重做加载成功不等于算得对
激活量化(低精度浮点)依赖引擎的算子支持可能只支持部分层逐层比对,别只看首层
适配器权重支持但合并方式不同运行时挂载还是离线合并挂载顺序会影响结果

量化档位怎么验,不是怎么选

选档位到处是结论,验档位只能在自己的输入集上做。三个可复现的判据,都不用跑分。

贪心一致性:温度设零,同一批固定输入分别打新旧两个端点,逐字符比对,记录从第几个字符开始分叉。分叉很靠前,说明精度损失影响了大方向;几百字之后才分叉,通常只是措辞。

结构化输出合法率:让模型按固定 schema 输出 JSON,统计解析失败的比例。量化对格式遵循的影响比对闲聊更明显,因为格式错误是硬失败。

长输入边界:把输入拉到接近可用上限的六七成,看输出有没有整段重复、提前收尾、语言漂移。这三类现象在短输入下看不出来。

还有个容易漏的点:量化之后分词器没变,但实际可用的最大长度可能变了——某些算子对序列长度有对齐要求。上线前把"实际能跑多长"测出来,别信配置里写的那个数。

比对脚本长什么样

⚠️ 代码待验证

deffirst_divergence(old_client,new_client,prompts,max_new_tokens=128):"""温度为零时逐字符比对,返回每条输入的首次分叉位置。"""report=[]fori,pinenumerate(prompts):kw=dict(prompt=p,max_tokens=max_new_tokens,temperature=0.0,n=1)a=old_client.completions(**kw).choices[0].text b=new_client.completions(**kw).choices[0].text idx=next((jforj,(x,y)inenumerate(zip(a,b))ifx!=y),min(len(a),len(b)))report.append({"prompt_id":i,"diverged_at":idx,"old_len":len(a),"new_len":len(b),# 前几个字符就分叉,多半是模板或精度动了;靠后分叉通常只是措辞"suspicious":idx<8orabs(len(a)-len(b))>max_new_tokens//2,})returnreport

这一章可以带走的一句:权重能不能加载是格式问题,加载后算得对不对是精度问题。两件事分两步验,混着查会让你在错误的层面上找 bug。

四、对外行为会变

客户端一行不改,服务就能跑起来——这正是兼容层最危险的地方。它让你以为什么都没变,实际上有一批语义已经换了。

兼容层覆盖的是形状,不是语义

多数引擎都提供一套兼容主流协议的接口,字段名、请求体结构、流式分块格式都能对上。但兼容的是信封,不是信的内容。

字段表面兼容真实差异怎么探测
stop都是字符串数组有的匹配解码后文本,有的匹配 token 序列;条数还有上限给六个串,看生效几个
结束原因字段名一样取值集合不同,"达到上限"的叫法可能不一样用极小的输出上限跑一次
多次采样都支持有的退化成串行,延迟随采样数增长固定并发下比不同采样数
流式工具调用都有增量块增量粒度不同,逐字符还是整段打印每个增量块
用量统计都有 token 数缓存命中算不算、算在哪一项,口径不同同一请求连发两次
随机种子都收这个参数有的实现了,有的收下就丢同种子连发两次

最后一行值得单独测。"收下就丢"最坑人:你的回归测试依赖固定种子,换引擎之后测试开始随机失败,而你会先怀疑模型。

不生效的参数,和被吞掉的 stop

一种引擎遇到不认识的参数直接返回错误,这是好事,改起来快。另一种默默吃掉,最难查——你把某个参数设成极端值,期望得到确定性输出,实际它用了默认值,于是每次都不同,而你会把这归因于"模型不稳定"。

探测办法很土但有效:把参数设成会让输出肉眼可见变化的极端值,看变不变。不变就是没生效。

stop 是另一类会被静默处理的东西,三个坑依次来。

第一个坑是匹配层不同:在解码后文本上匹配,和在 token 序列上匹配,结果会不一样。差一个空格、差一个全角标点,后者就不命中。

第二个坑是命中之后的行为:那个 stop 串要不要保留在返回文本里。你的后处理如果假设"返回文本一定不含 stop",换引擎之后可能凭空多出一个尾巴,然后被拼进下一次请求的上下文。

第三个坑是条数上限。你塞了八个 stop 串进去,旧引擎全收,新引擎只认前几个——表现是输出没在预期位置停下,日志里什么都看不到。

用一组探针把差异钉出来

⚠️ 代码待验证

defprobe(client,prompt:str)->dict:"""四类探针,看新引擎能不能吃下旧引擎的那套参数。"""result={}# 1. stop 条数:给六个,看有几个真的命中stops=["\n\n","###","</answer>","END","【完】","%%END%%"]r=client.generate(prompt,max_tokens=64,stop=stops,temperature=0.0)result["stop_effective"]=[sforsinstopsifsnotinr.text]# 2. 未知参数:会被拒绝,还是被默默吃掉try:client.generate(prompt,max_tokens=8,top_k=1,unknown_guard=True)result["unknown_param"]="silently_ignored"exceptExceptionasexc:result["unknown_param"]=f"rejected:{type(exc).__name__}"# 3. 结束原因:截断和自然结束能不能分开r=client.generate(prompt,max_tokens=4,temperature=0.0)result["finish_reason"]=r.finish_reason# 4. 确定性:温度为零连发两次是否一致a=client.generate(prompt,max_tokens=32,temperature=0.0).text b=client.generate(prompt,max_tokens=32,temperature=0.0).text result["deterministic"]=(a==b)returnresult

这一章可以带走的一句:兼容层能让你不改客户端代码,但不能让你不改客户端的假设。上线前用探针把那批假设逐条打一遍,比读十页文档快。

五、性能基线必须重测

厂商给的数字和你压出来的数字对不上,是常态,因为测的往往不是同一件事。基线要重测的理由不是"比个高低",而是你后面所有的超时值、告警阈值、灰度比例,都挂在这几个数上。

四个数,先把定义统一

指标定义怎么测判据常见测错
首 token 时延发出请求到收到第一个输出片段流式请求,记录首块到达时间看分位,不看均值排队时间算不算进去没说
单 token 间隔首块之后平均每个 token 的间隔总耗时减首块时间,除剩余块数分位数加抖动幅度分母减不减一,各人不同
吞吐每秒输出 token 数;每秒完成请求数固定时长内统计完成量两个口径分别报只报 token,掩盖小请求开销
并发拐点单 token 间隔开始抬头的并发档并发梯度扫描,找曲线拐点找"开始变差"那一档报最大并发,那不是拐点

定义不统一是最常见的错。首 token 时延是从客户端发出算,还是从服务端收到算?包不包含排队?不写清楚,两个团队测出来的数能差一截。

输入分布和到达方式都要固定

输入长度必须分档且固定,比如短、中、长三档,输出长度统一成一个值。这样两个引擎之间的差异才能归因到引擎,而不是"这次随机到的输入更长"。

到达方式也要固定:定并发(上一个请求返回再发下一个)测稳态能力,可复现;定速率测排队行为,贴近真实流量但受调度抖动影响大。迁移对比至少要用定并发,否则每次的数都不一样,分不清差异来自引擎还是噪声。前若干次请求还要丢弃——有编译或图捕获环节的引擎,头几次的耗时不代表稳态。

⚠️ 代码待验证

#!/usr/bin/env bash# 固定输入分档 + 固定并发梯度,结果落 jsonl,后续只算分位set-euopipefail:"${ENDPOINT:?指向被测引擎的地址}":"${MODEL:?模型标识}":"${OUT:?结果输出路径}"forINPUT_LENin512409616384;doforCONCin148163264;do# 每个组合跑满 60 秒,前 4 个请求丢弃:冷启动不代表稳态python bench_client.py\--endpoint"$ENDPOINT"\--model"$MODEL"\--input-len"$INPUT_LEN"\--output-len256\--concurrency"$CONC"\--duration60\--discard-first4\--timeout120>>"$OUT"donedone# 均值没有意义,只出分位python summarize.py"$OUT"--percentiles50,90,99

并发拐点:找"开始变差"的那一档

拐点不是最大吞吐,是单 token 间隔的分位开始抬头的那一档并发。

沿并发梯度逐档扫,每档记录两个时延指标的分位。首 token 时延随并发上升是线性的,涨了很正常;单 token 间隔的尾部开始抬,说明批已经拼到影响单请求速度,那才是容量边界。

新旧引擎的拐点位置通常不同,这个位置直接决定灰度比例上限和超时值——超过拐点的流量不该被放进来,不管平均延迟看起来多好看。拐点找到之后怎么扩,属于容量规划的范畴,本文只到"测出位置"为止。

这一章可以带走的一句:别问"新引擎快不快",问"在我这个输入分布上,第几档并发开始变差,变差之后是排队还是变慢"。

完整版资料清单:本文用到的引擎配置对照项、压测参数与判定线都整理在里面了,扫码即可获取:

六、运维面

前五章解决"能不能跑对",这一章解决"出事时你能不能看见、能不能退回去"。迁移里真正让人难受的,往往不是性能不达标,而是出了问题没有退路。

指标名字和单位都对不上

同一件事,两套引擎可能用三个名字:队列长度、等待请求数、待处理数。单位也会变:耗时有的给秒有的给毫秒,直方图桶边界各切各的。直接接上旧大盘,结果是线断了,或者线贴着零——单位差一千倍,看起来像流量归零。

做法是在中间加一层指标适配,把新引擎的指标映射成旧名字、旧单位、旧桶边界,映射表放进代码仓库。好处不只是大盘不用改:回滚的时候,监控侧一行都不用动。

就绪信号:端口通了不等于能接流量

不少引擎在开始加载权重时就监听端口,健康检查立刻返回成功。这时候发请求,要么报错,要么排到超时。滚动发布遇上这一条,会把一批请求打到还没准备好的实例上。

真正的就绪判据只有一条:能完成一次极短的生成——一个字符的输入、一个 token 的输出,并且耗时在阈值内。把探针指到这个判据上,不要指向端口存活。

回滚:备齐四样,并实测一次耗时

回滚时间大致等于加载权重的时间加上预热时间。停掉新引擎、起旧引擎、发出第一个成功请求,掐表,这个数就是故障恢复的上限;它超过可接受的停机窗口,灰度就必须做成"两套并存加路由切换",而不是"原地换进程"。

四样是配置、权重、路由、监控:旧引擎的启动参数还在仓库里且能一键拉起;旧引擎要的权重格式还在,转换时别覆盖原文件;切回去的开关不需要发版;适配层能同时认两套指标。

支持热加载的引擎换权重不必重启进程,但缓存要重建,切换后一段时间的首 token 会偏高,这段也得算进切换耗时。

⚠️ 代码待验证

# 灰度路由:比例、回落、开关三件事都写在配置里,回滚不需要发版route:engine_new:enabled:trueweight:5# 百分比;按租户分桶,不按请求随机hash_key:tenant_id# 同一租户始终落在同一侧max_context_tokens:16384# 超过这个长度仍然走旧引擎bypass_paths:-/v1/embeddingsfallback:on_status:[500,502,503,504]on_timeout_ms:30000retry_old_engine:truemax_fallback_ratio:20# 回落比例超阈值就直接关掉新引擎rollback:switch:false# 出事只改这一行drain_seconds:30# 摘流后等在途请求结束,别硬切

按租户分桶而不是按请求随机,是刻意的:同一个用户在同一个会话里被切到两个引擎,输出风格会前后不一致,排查起来很费劲。

这一章可以带走的一句:验收标准里该有一条"能在几分钟内退回去"。回不去的新引擎,性能再好也不该上。

七、迁移路径与验收

影子对比:先不发结果,只比过程

影子流量的做法是把请求复制一份给新引擎,结果丢弃,不返回给用户。

对比的不是"回答好不好"——那需要人评,成本高且主观。对比的是过程指标是否同分布:输出长度、结束原因、stop 命中率、错误码、两个时延指标的分位。

比分布要看分位和直方图,不看均值:均值相同的两组数据,一组集中、一组两头分得很开,用户体验是两回事。影子期还要覆盖一个完整业务周期,至少包含一个高峰,否则会漏掉夜间批处理那批长输入。

小流量:每一档都要有进入条件

阶段进入条件观测项退出条件
影子配置与探针全部通过输出长度、结束原因、错误码分布分布明显偏离则回退重查
小比例影子期无异动错误率、首 token 分位、回落比例任一硬指标越线即回滚
中比例上一档窗口内零回滚同上,加单 token 间隔尾部同上
大比例中比例稳定覆盖一个高峰同上,加单位请求成本同上
全量大比例稳定全量业务指标保留旧引擎可回切一段时间

什么时候该放弃迁移

这一条比"怎么迁"更值得提前写下来,否则项目会陷在"再调调就好了"里。

出现下面任意两种,就该考虑停:三项硬指标里两项结构性不达标——不是参数没调对,而是引擎的处理方式和你的输入形态不匹配;团队里没人读得懂新引擎的调度日志;新引擎的发布节奏跟不上你的修复需求;收益只集中在单个场景,其余都要额外补偿逻辑。

把不合适的引擎硬推上线,再花半年打补丁,代价要大得多。

这一章可以带走的一句:迁移的终点不是"全量切完",是"全量切完并且随时能切回去"。判定线要在开始之前写,不能在结束之后补。

完整版资料清单:迁移判定线与灰度观测项都整理成表放进去了,扫码即可获取:

附表 A:关键取舍一览

取舍本文结论判断依据位置
什么情况才换引擎只在加载不了或运维不可观测时换慢和吞吐塌多半还有配置余地第一章
换代的边界键名要重写才算换代键名不变属于版本升级第一章
并行度怎么定先算整除约束,再看副本数余下的卡不是浪费,是副本第二章
最大序列长度与并发目标一起定它是分配单位,不是保护上限第二章
量化怎么验三个判据,不引用跑分只在自己的输入集上才有效第三章
兼容层怎么信只信形状,语义逐条探探针比文档快且准第四章
基线怎么测输入分档、到达固定、只报分位否则数字之间不可比第五章
拐点怎么找看单 token 间隔抬头那一档首 token 时延只反映排队第五章
回滚准备配置、权重、路由、监控四样齐少一样就回不去第六章
灰度分桶按租户,不按请求同会话跨引擎会风格漂移第六章
何时放弃结构性不达标、无人能读日志硬推的长期代价更高第七章

附表 B:术语速查表

术语含义
推理引擎负责调度请求、管理缓存、执行算子的服务程序,与模型权重是两件事
张量并行把单层的矩阵运算切到多张卡上,要求能整除头数
批上限单次前向能拼多少请求或多少 token,两个口径可能同时生效
KV 缓存存放已生成 token 的键值对,决定并发容量与最大上下文
首 token 时延从发出请求到收到第一个输出片段的耗时
单 token 间隔首块之后平均每生成一个 token 的耗时
并发拐点单 token 间隔开始明显抬头的并发档,容量边界的实测位置
影子流量复制一份请求给新引擎但不返回结果,用于比对过程指标

写在最后:这篇用到的资料

写这篇文章时,我把两套推理引擎的配置项、默认值和压测口径逐条对了一遍,顺手整理成几份配套的东西:

  • 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
  • 大模型全套教程:按主题分好的视频与文档清单
  • 大模型实战好书:24 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。