:微调模型上线全流程:打包、vLLM 部署与版本管理)
承接与场景上一篇在重训 vs 合并的岔路口选了重训加回放分诊模型带着回归集的合格分数进入发布环节——然后团队遇到了全系列最没技术含量也最致命的阶段一个 12GB 的目录怎么安全地交到生产手里。本篇场景是连锁零售集团的 IT 服务台模型上线走完打包清单、推理容量规划、版本管理与灰度回滚这条流水线。核心认知先立起来微调模型不是能对话的文件夹是一个多文件、强耦合、缺一比特就行为漂移的软件制品发布流程上你给任何二进制制品的严谨度这里都只多不少。实验一用真实的 hashlib 做字节级操作实验二是确定性排队模拟生产推理引擎为 vLLM或 TGI/SGLang机制相同。导出物清单合并语义改变了什么LoRA 训练产物是底座 几十 MB 旁路上线有两条路动态加载vLLM 支持多 LoRA 热挂载一个基座喂多个业务或合并导出merge_and_unload把 s·B·A 加回 W产出标准 HF 目录第三篇实验已证数值等价。合并导出的目录里每个文件都不是摆设config.json决定架构与层数加载错一个字直接崩chat template 是最高危文件——训练时用的 Jinja 模板与推理时拼的不一致模型会把指令当历史对话分数无声地掉 10~20 个点且没有任何报错tokenizer与generation_config决定编码与采样默认值分片权重加model.safetensors.index.json提供文件级寻址。所以打包第一步不是压缩上传而是生成逐文件清单微调模型打包发布: 建目录 - SHA256 清单 - 再导出 diff, 真实 hashlib。importhashlibimportjsonimportosimportrandomimportshutil ROOTos.path.join(os.environ.get(TEMP,/tmp),exp485_model)FILES[config.json,tokenizer.model,generation_config.json,model-00001-of-000002.safetensors,model-00002-of-000002.safetensors,adapter/README.md]defblob(name,rng,mutateFalse):ifnameconfig.json:returnjson.dumps({arch:LlamaForCausalLM,hidden_size:4096,torch_dtype:bfloat16}).encode()ifname.endswith(.md):returnb# merged adapter notes v1\ndatabytearray(rng.randrange(256)for_inrange(64))ifmutateandname.endswith(00002-of-000002.safetensors):data[0]^0xFF# 重训后第二个分片真实变化returnbytes(data)defbuild(seed,mutateFalse):shutil.rmtree(ROOT,ignore_errorsTrue)rngrandom.Random(seed)namesFILES([chat_template.jinja]ifmutateelse[])forfinnames:ifmutateandf.startswith(adapter):continue# 合并导出后不再随包携带 adapterpos.path.join(ROOT,*f.split(/))os.makedirs(os.path.dirname(p),exist_okTrue)withopen(p,wb)asfh:fh.write(blob(f,rng,mutate))defmanifest():out{}fordirpath,_,filesinos.walk(ROOT):fornameinfiles:pos.path.join(dirpath,name)relos.path.relpath(p,ROOT).replace(os.sep,/)withopen(p,rb)asfh:out[rel]hashlib.sha256(fh.read()).hexdigest()[:12]returnout build(7)m1manifest()withopen(os.path.join(ROOT,MANIFEST.json),w)asfh:json.dump({version:1.0.0,files:m1},fh,indent1)print(v1.0.0 发布包 (%d 个文件):%len(m1))forfinsorted(m1):print( %-38s sha256:%s%(f,m1[f]))build(7,mutateTrue)m2manifest()changed[fforfinm2iffinm1andm1[f]!m2[f]]added[fforfinm2iffnotinm1]removed[fforfinm1iffnotinm2]print(v1.0.1 diff: 变更 %s | 新增 %s | 移除 %s%(changed,added,removed))print(结论: 单文件字节级 diff 即可支撑灰度回滚决策, 无需重传整包)运行输出v1.0.0 发布包 (6 个文件): adapter/README.md sha256:2d3813d66569 config.json sha256:86e350e3e27e generation_config.json sha256:a5f8f50b6daa model-00001-of-000002.safetensors sha256:7b4d94a5a1e9 model-00002-of-000002.safetensors sha256:cdb72a2bc34b tokenizer.model sha256:8d8bfac911cd v1.0.1 diff: 变更 [model-00002-of-000002.safetensors] | 新增 [chat_template.jinja] | 移除 [adapter/README.md] 结论: 单文件字节级 diff 即可支撑灰度回滚决策, 无需重传整包别小看这段几十行脚本干成的三件事。其一MANIFEST.json与发布包同仓归档之后任何人拿到一个目录都能回答这到底是哪次训练的哪个版本——模型仓储HF Hub / 自建 MinIO里同名不同内容的目录是事故高发区。其二v1.0.1 的 diff 精确到分片两个版本间只有一张分片变了灰度对比时只需怀疑那一段参数来源比如那次重训恰好只动了后半层数立刻有了排查方向回滚也可以是文件级的。其三新增 chat_template.jinja、移除 adapter 目录这两条正是语义变更模板显式入包、合并后不再挂旁路——diff 报告逼着发布评审逐项确认而不是看一眼大小没差多少就签字。推理容量KV 池算出来的并发数模型跑得动训练不代表跑得动服务。推理显存公式与训练不同权重之外每个在线请求要占一块KV cache每层每 token 的注意力键值缓存字节 2 × 层数 × kv 头数 × 头维度 × 精度字节。GQA 模型在这一点上对部署极其友好——kv 头从 32 砍到 8缓存直接省四分之三。有了池子还要回答12 路并发到底能给用户多快vLLM 的连续批处理continuous batching让完成的序列立刻释放槽位、新请求随时插入正在解码的批对比攒齐一批、整批等最慢的静态批差距用排队模拟量出来vLLM 容量规划: KV 显存预算 - 并发数, 静态批 vs 连续批吞吐模拟。importrandom L,KV_H,HEAD,DT32,8,128,2# 层数 / GQA kv 头 / 头维 / bf16KV_PER_TOKEN2*L*KV_H*HEAD*DT# K 与 V 各一份GPU,WEIGHTS24.0,5.93e9*2/1024**3# 4090 容量 / 合并导出 bf16 底座POOLGPU*0.90-WEIGHTS-1.0# 0.9 利用率上限再扣运行缓冲AVG_CTX1800kv_limitint(POOL*1024**3/(KV_PER_TOKEN*AVG_CTX))SLOTSmin(kv_limit,12)# 引擎侧 max_num_seqs 收紧到 12print(KV %d B/token | 池 %.1fGB | KV 可支撑 %d 路 | 实际取 %d 路%(KV_PER_TOKEN,POOL,kv_limit,SLOTS))rngrandom.Random(42)HORIZON,RATE120.0,40.0# 模拟 2 分钟; 每路解码 40 token/sarrivals,t[],0.0whiletHORIZON:trng.expovariate(0.4)# 到达率约 0.4 请求/秒 - 接近满载iftHORIZON:arrivals.append(t)jobs[(a,200int(rng.expovariate(1/550.0)))forainarrivals]print(120 秒到达 %d 个请求, 平均解码 %.0f token%(len(jobs),sum(nfor_,ninjobs)/len(jobs)))done,t,lat_s,busy_s0,0.0,[],0.0whiledoneSLOTSlen(jobs):# 静态批: 攒满一批一起跑, 全批等最慢batchjobs[done:doneSLOTS]startmax(t,batch[0][0])endstartmax(nfor_,ninbatch)/RATEifendHORIZON:lat_s[end-afora,_inbatch]busy_ssum(min(n,(end-start)*RATE)for_,ninbatch)t,doneend,doneSLOTS rem[0.0]*SLOTS# 连续批: 槽位空出立即补新请求meta[None]*SLOTS info,ptr,t,busy_c[],0,0.0,0.0whiletHORIZON:foriinrange(SLOTS):ifrem[i]0:stepmin(rem[i],RATE)rem[i]-step busy_cstep/RATEifrem[i]0.0:st,n,ameta[i]ifstn/RATEHORIZON:info.append((stn/RATE)-a)foriinrange(SLOTS):ifrem[i]0.0andptrlen(jobs)andjobs[ptr][0]t:rem[i]jobs[ptr][1]meta[i](t,jobs[ptr][1],jobs[ptr][0])ptr1t1.0meanlambdaxs:sum(xs)/len(xs)ifxselse0.0print(静态批: 完成 %2d | 均延迟 %5.1fs | 槽位利用率 %.0f%%%(len(lat_s),mean(lat_s),100*busy_s/(SLOTS*HORIZON*RATE)))print(连续批: 完成 %2d | 均延迟 %5.1fs | 槽位利用率 %.0f%%%(len(info),mean(info),100*busy_c/(SLOTS*HORIZON)))运行输出KV 131072 B/token | 池 9.6GB | KV 可支撑 43 路 | 实际取 12 路 120 秒到达 57 个请求, 平均解码 831 token 静态批: 完成 24 | 均延迟 71.7s | 槽位利用率 32% 连续批: 完成 44 | 均延迟 17.1s | 槽位利用率 65%逐行读每 token 的 KV 是 128KB9.6GB 的池子名义上能挂 43 路 1800 token 的并发但引擎配置把 max_num_seqs 收在 12——KV 容量常常不是第一瓶颈单卡带宽与调度开销才是模拟里的每路 40 token/s 就是这种约束的体现。真正的看点在最后两行同样的到达流与同样的 12 个槽位静态批两分钟只送走 24 个请求、平均要等 71.7 秒、四分之三的时间槽位在空转等最长的那条序列连续批送走 44 个吞吐 ×1.8、平均延迟 17.1 秒÷4.2。这就是 vLLM 论文里 PagedAttention 之外另一个常被低估的机制解码中的请求随时退场、排队请求随时进场长尾回答不再绑架整批。容量规划的正确姿势由此变成一句话KV 池决定你能开多大连续批决定你实际多好压测要按输出长度分布的 P95 而不是均值。版本管理与灰度制品版本沿用语义化三段号但含义要按 ML 重新定义主版本底座或模板变了换基座、chat template 重写任何客户端都要重测次版本同底座重训新数据配比/新 checkpoint灰度后全量修订号导出链路变化重新合并、量化方式、分片数。每次发布同时冻结三样东西模型目录哈希、评测集版本与分数第六篇的五件套、推理配置引擎版本、max_model_len、采样默认值——三者缺一回滚就无从谈起。灰度按请求头分流 5%新旧双跑第六篇评测的线上子集影子请求看分数分布与拒答率任一红线破防自动切回。回滚演练要真实做从 MANIFEST 校验到重新加载全程计时回滚要快过事故升级。常见陷阱模板漂移HF transformers 升级顺手改了默认 chat template训练推理各用一套。模板文件必须入包入哈希禁止依赖库默认值。只压单并发容量测试用 curl 一条一条测上线后发现并发 10 就 OOM——KV 没按 max_model_len × 并发算。压测参数对照第一行 KV 公式。多 LoRA 热挂载不看显存放大每个 adapter 都要常驻权重与加载缓存10 个业务 adapter 挤一张卡时先算第三篇的显存账再排租户。重启即事故模型目录塞满/tmp、重启自动清理或对象存储最终一致性读到半上传分片——发布前跑一遍下载→哈希→加载的端到端演练。回滚滚一半只换权重不换推理配置或反之新旧链路拼出没人测过的组合。版本三元组必须整体切换。上线清单合并导出 → 生成 MANIFEST逐文件 SHA256→ 与评测报告、训练配置三方互链归档加载冒烟固定 5 条金样例哈希一致 输出逐字一致才算包可用容量三问算清KV/token 多少、池子多大、P95 输出长度下并发几路灰度 5% 起步 影子流量对分 自动回滚触发线拒答率/格式错误率/延迟 P99每次发布打主/次/修订标签说明里写清变了哪个分片、动了哪个模板、分数差多少 CI 多少流水线跑通了还剩一个悬而未决的架构问题第二篇到第八篇全在讲进权重可知识类内容依然不该进权重。下一篇《LLM 微调实战9RAG 与微调怎么配合知识的存放位置决策》给出混合架构的分拣规则与检索实测。参考来源Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM)https://arxiv.org/abs/2309.06180vLLM 官方文档部署与量化https://docs.vllm.ai/en/stable/Safetensors 格式说明https://github.com/huggingface/safetensorsHugging Face Hub 模型卡规范https://huggingface.co/docs/hub/model-cardsSGLang: Efficient Execution of Structured Language Model Programshttps://arxiv.org/abs/2312.07104