2026/10/25 7:30:23

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

推理引擎换代:换一个引擎要重算的五件事 说明本文讨论的是把在线推理从一套引擎产品换到另一套引擎产品时需要重新核算的五类事项属于推理侧工程话题不涉及具体模型版本与价格。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*ppnum_gpus:raiseValueError(ftp*pp{tp*pp}超过卡数{num_gpus})ifnum_gpus%(tp*pp)!0:raiseValueError(卡数不能被 tp*pp 整除剩下的卡只能空转)ifnum_kv_heads%tp!0:raiseValueError(fKV 头数{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_tokens128):温度为零时逐字符比对返回每条输入的首次分叉位置。report[]fori,pinenumerate(prompts):kwdict(promptp,max_tokensmax_new_tokens,temperature0.0,n1)aold_client.completions(**kw).choices[0].text bnew_client.completions(**kw).choices[0].text idxnext((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:idx8orabs(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%%]rclient.generate(prompt,max_tokens64,stopstops,temperature0.0)result[stop_effective][sforsinstopsifsnotinr.text]# 2. 未知参数会被拒绝还是被默默吃掉try:client.generate(prompt,max_tokens8,top_k1,unknown_guardTrue)result[unknown_param]silently_ignoredexceptExceptionasexc:result[unknown_param]frejected:{type(exc).__name__}# 3. 结束原因截断和自然结束能不能分开rclient.generate(prompt,max_tokens4,temperature0.0)result[finish_reason]r.finish_reason# 4. 确定性温度为零连发两次是否一致aclient.generate(prompt,max_tokens32,temperature0.0).text bclient.generate(prompt,max_tokens32,temperature0.0).text result[deterministic](ab)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$OUTdonedone# 均值没有意义只出分位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 本附每本适合的阶段资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「大模型」优先通过。拿到之后建议先看学习路线图那一份先定位自己在哪个阶段再决定学什么比一上来就啃框架效率高得多。