ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

TensorFlow SavedModel 重复保存与覆盖导出避坑指南

TensorFlow SavedModel 重复保存与覆盖导出避坑指南 搜saved_model这个关键词的时候搜索引擎常常会把一堆 TF 卡存储卡的内容推给你——什么 SPI 上拉电阻、卡槽 9 脚怎么接、量产修复工具。如果你正好还在调 TensorFlow 的模型导出那种感觉就跟在五金店里问螺丝刀结果被领到了内存条货架一样。这篇东西不聊存储卡聊的是 TensorFlow 的SavedModel尤其是同一个模型反复保存、反复覆盖、反复导出时踩过的坑。先说清楚适用人群你如果只是训一次、存一次、然后部署到生产环境再也不动那这篇文章对你价值有限但只要你有下面任意一种情况这篇就值得从头看到尾——训练脚本写成循环反复跑、每跑一轮就把模型往同一个目录覆盖一遍、需要保留多个版本做 A/B 对比、需要在 TF 1.x 和 2.x 之间来回切换、每次加载模型想继续训练却发现指标不对劲、保存成功但部署端加载报签名找不到。这些场景我都真实踩过有些坑排查了大半天最后发现问题就出在一个rm -rf没执行干净上。SavedModel 是 TensorFlow 推荐的跨语言、跨平台模型序列化格式它把一个训练好的计算图、变量、资产文件和签名打包成一个目录供 Java、Go、JS、C 以及 TensorFlow Serving 等运行时直接加载。它看起来只是个存文件的动作但当你把同一个路径当成复用目标TensorFlow 并不会帮你擦黑板新旧内容会叠在一起问题就从这里开始长出来。1. 先把 SavedModel 的目录结构和保存语义吃透1.1 一个 SavedModel 目录里到底躺着什么很多人用model.save(mymodel)之后只看到生成了一个名为mymodel的文件夹就没再深究。但你要处理多次保存的问题必须知道里面每一层是什么否则报错时根本不知道该删哪个、该留哪个。一个 TF 2.x 导出的标准 SavedModel 目录大致长这样mymodel/ ├── saved_model.pb # 图定义 签名 元图MetaGraphDef ├── fingerprint.pb # TF 2.4 之后加入的指纹文件用于校验 ├── assets/ # 词表、查找表、自定义资产文件 └── variables/ ├── variables.index # 变量索引分片时的键值目录 └── variables.data-00000-of-00001 # 变量实际数据saved_model.pb是核心它用 protobuf 序列化了图的节点结构、输入输出签名signature_def以及恢复变量所需的映射关系。注意一个关键点它里面记录的是变量的引用关系和名字变量真正的数值存在variables/目录下。这就意味着如果saved_model.pb是从新模型写的而variables/里还残留着上一次保存的旧分片文件加载时就会出现结构对不上数据或者读到了多余的变量文件的诡异现象。assets/目录容易被忽略。如果你的模型里有tf.lookup.StaticVocabularyTable或者TextVectorization层词表会落在这里。反复保存时如果新旧词表文件同名但内容不同加载后会静默地用错词表——不报错但效果直接崩掉这个坑非常阴。fingerprint.pb是较新版本引入的用来记录 SavedModel 的图、签名和对象的指纹加载时会做一致性校验。如果你用混合版本比如保存用 2.11加载用 2.4可能出现指纹相关的不兼容提示。提示判断一个 SavedModel 是否健康先看目录里有没有saved_model.pb和完整的variables/两个文件缺少任何一个这个目录基本就是残废的。1.2 三种保存方式的差异与选型逻辑TensorFlow 里保存模型至少有四种写法很多人混着用这才是多次保存出问题的根源之一。保存方式代码写法保存内容能否继续训练典型用途SavedModeltf.saved_model.save(model, path)或model.save(path)图结构、变量、签名、资产不能直接续训无优化器状态部署、跨语言推理Keras 格式model.save(path.keras)结构权重优化器状态编译配置可以续训训练中间交接H5 格式model.save(path.h5)结构权重不含图签名可以续训旧项目兼容Checkpointcp.save()/ModelCheckpoint变量含优化器槽变量可以续训断点续训、滚动保留选型逻辑其实很清晰要部署就给 SavedModel要继续训练就给 Checkpoint 或.keras。我见过最常见的错误就是把 SavedModel 当成断点续训的载体每次训练完存一遍下次加载继续训结果发现 loss 曲线每次都像重新开始——因为优化器的动量、二阶矩估计这些槽变量根本没在 SavedModel 里。另一个常见混淆是save_format参数。在 TF 2.x 到 2.15 这个区间model.save(path, save_formattf)会走 SavedModel 分支save_formath5会走 H5。而到了 Keras 3对应 TF 2.16 之后save_format参数被移除model.save()只写.keras或.h5要导出 SavedModel 得用model.export(path)。版本一升级老脚本直接报TypeError这也是多次保存场景里很扎心的一个变化点。注意不要把.keras和 SavedModel 混在一个目录里。有些同事图省事model.save(exp/model)之后再model.save(exp/model.keras)目录结构混乱不说后续脚本里路径判断逻辑很容易写错。1.3 反复保存时TensorFlow 到底做了什么这是全文最关键的一节。理解了这个后面所有报错你都能自己推出来。tf.saved_model.save在 TF 2.x 里的实现思路大致是把图写到目标目录的saved_model.pb把变量写到variables/子目录资产写到assets/。它并不会在写入前自动清空目标目录。也就是说如果目标路径已经存在上一次保存的产物本次写入是叠加在旧文件之上的行为。这就带来三个直接后果第一variables/目录下的分片文件命名带有编号of_00001。当变量数量变化导致分片数从 2 个变成 1 个时旧的第 2 个分片可能还留在目录里。加载器读取时按variables.index的索引走多余分片通常被忽略但某些版本会报变量列表长度不一致的错误。第二assets/里的文件是按原始文件名写入的。如果你换了词表但文件名相同写入是覆盖没问题但如果新旧词表文件名不同旧文件会残留某些加载路径会扫描整个assets/目录把旧词表也塞进初始化表里导致查找表规模对不上。第三也是最隐蔽的签名signature是累积感知不到的。每次保存都会写一份新的saved_model.pb里面包含当次导出的签名定义。如果新旧签名同名但输入结构不同覆盖后以新文件为准看起来没事但如果你的保存逻辑是追加式的比如 TF Serving 的多版本目录1/、2/版本目录之间的签名不一致服务端切换版本时就会报输入张量不匹配。所以我的结论很直接凡是需要多次保存到同一位置的场景都应该把先写临时目录再原子替换作为默认动作。这个模式我在第 3 章会给出完整代码。它的好处是写入过程中崩了旧模型完好无损写入完成后替换不存在新旧混杂的中间态。关于原子性还有个细节值得说。文件的原子替换用os.replace是同盘内的操作跨文件系统比如临时目录在/tmp目标在挂载的网络存储上会失败。稳妥做法是把临时目录建在目标路径的同一层例如export/3.tmp替换成export/3这样一定在同一个文件系统内。2. 多次保存最容易翻车的四个机制性原因2.1 目录没清干净残留文件让新旧图混在一起这是最高频的一类问题没有之一。典型症状是保存过程没有任何报错但加载时报KeyError或变量形状不匹配或者干脆加载出来的模型精度掉到随机水平。排查时我的第一反应永远是ls -R看一眼目标目录然后再ls -l看修改时间。如果variables/下有几个分片的修改时间不一致那基本就锁定了——有的是这一次写的有的是上一次留下的。修法很简单就是保存前做一次彻底清理或者干脆换一个新目录。但要注意不要在同一目录下同时存在saved_model.pb和一个叫keras_metadata.pb的文件再加.h5这种什么格式都往里塞的目录加载器会按优先级猜格式猜错就是一堆莫名其妙的错误。我在项目里定了一条铁律模型导出目录只放 SavedModel一个版本一个目录目录名用纯数字或时间戳不允许任何中间产物写进去。日志、TensorBoard、checkpoint 全部走另外的目录。2.2 签名与函数名冲突tf.saved_model.save(model, path, signatures...)里的signatures参数是控制导出签名的关键。如果你不显式给TF 会尝试从被tf.function装饰的方法里自动推断通常会生成一个serving_default。问题出在多次保存的循环里。假设你写了个函数每轮训练都重新构建一次tf.function并注册签名函数对象是新的但签名名字没变。TF 内部会用函数名或者 traced 签名做键同名不同实现的情况下可能保留旧的 concrete function导致导出的是上一轮的图。更常见的是自定义签名冲突。比如你这样写tf.function(input_signature[tf.TensorSpec([None, 128], tf.float32, namex)]) def predict(x): return {y: model(x, trainingFalse)} tf.saved_model.save(model, path, signatures{serving_default: predict})第一轮没问题。第二轮你把输入维度改成 256但签名名还是serving_default。如果目标目录没清或者 TF Serving 加载的是老版本目录服务端按 128 维构造请求就直接报维度错误。这类问题在现场环境排查起来很费劲因为模型文件看起来是新的。我的做法是签名的input_signature里能写None的动态维度就写None把 batch、序列长度这类维度留活口同时在导出目录旁边写一个signature.json把当次的输入输出名字、类型、形状落一份文本记录。多版本对比时直接看 JSON比用命令行工具查快得多。2.3 变量命名漂移与权重错配这个坑在循环建模型的场景里特别常见。TF/Keras 对匿名层有自动命名机制第一次建Dense(64)叫dense第二次建就叫dense_1第三次dense_2以此类推。看起来无害但它会连锁影响三件事保存的变量路径、saved_model.pb里的变量引用名、以及你后续用by_nameTrue加载权重时的匹配。举个真实例子。我之前写过一个超参搜索脚本在 for 循环里逐个组合建模型、训练、保存。前几轮都正常跑到第五轮开始报加载权重时只有一半变量匹配上。原因就是层名漂移导致model.save_weights生成的变量名和后一轮模型对不上。解决办法有两个。一是显式给每一层指定namex tf.keras.layers.Dense(64, activationrelu, namefc1)(inputs) x tf.keras.layers.Dense(32, activationrelu, namefc2)(x) outputs tf.keras.layers.Dense(num_classes, namelogits)(x)二是每轮循环都显式清空 Keras 的命名计数器也就是tf.keras.backend.clear_session()。这一句是神器它会重置图、重置层命名计数、释放一部分内存。在循环训练的脚本里我基本是雷打不动放在循环体开头的。提示clear_session()之后之前定义的模型对象会失效别再引用它。很多人踩的坑就是清完 session 又去用旧的 model 变量报了一堆找不到 tensor 的错误。2.4 优化器状态与指标变量被一起带走的副作用这一条听起来矛盾既然 SavedModel 不保存优化器状态那优化器怎么会有影响答案在 Keras 的model.save()上。在 TF 2.x 中途的若干版本里当模型是通过model.compile()编译过的model.save()走 SavedModel 分支时会把一部分训练状态的变量也写进去——包括optimizer的迭代步数iterations、以及 metrics 的累加器变量比如total和count。后果是你加载模型准备在验证集上评估model.evaluate()出来的数字很怪或者加载后继续训练第一批数据的 loss 里混着上一轮累积的指标状态。实测下来最稳的加载方式是这样loaded tf.keras.models.load_model(./export/3, compileFalse)compileFalse明确告诉 Keras 不要恢复编译配置和关联的训练状态变量。需要评估或者继续训练时自己重新compile一遍指标从头开始算干净。如果你确实需要恢复历史指标状态比如分布式训练中断后想无缝继续那就别用 SavedModel老老实实用tf.train.Checkpoint方案。这套组合我后面会细说。3. 一套能反复跑的保存流程可直接抄3.1 目录规划与命名规范先把目录结构定死后面所有代码都围绕它写。我推荐的结构是这样runs/ ├── exp_baseline/ │ ├── ckpt/ # 断点续训用Checkpoint │ │ ├── ckpt-1.index │ │ ├── ckpt-1.data-00000-of-00001 │ │ └── checkpoint │ ├── export/ # 部署用SavedModel │ │ ├── 1/ │ │ ├── 2/ │ │ └── 3/ │ ├── logs/ # TensorBoard │ └── signature_history.json三条规则断点续训和部署导出彻底分家永远不会互相污染导出目录用递增整数或时间戳做子目录名绝不覆盖写每次导出都在signature_history.json里追加一条记录包含版本号、输入签名、输出签名、参数量、导出时间。子目录用整数还有一个好处TensorFlow Serving 默认按目录名排序来识别版本纯数字目录名可以直接被它当版本号用不用额外配置。3.2 保存代码模板逐行拆解下面这个函数我在几个项目里复用到现在核心就是临时目录 原子替换。import os import json import shutil import datetime import tensorflow as tf def export_saved_model_atomic(model, export_root, version, signaturesNone, extra_metaNone): 原子化导出 SavedModel。 - 先写到同层的 version.tmp - 成功后删除旧目录再把 tmp 替换成正式目录 export_root os.path.abspath(export_root) os.makedirs(export_root, exist_okTrue) target os.path.join(export_root, str(version)) tmp_dir target .tmp # 1. 清理上一轮可能残留的临时目录 if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) # 2. 先写临时目录 tf.saved_model.save(model, tmp_dir, signaturessignatures) # 3. 校验临时目录的基本完整性 pb os.path.join(tmp_dir, saved_model.pb) var_dir os.path.join(tmp_dir, variables) if not os.path.exists(pb) or not os.path.isdir(var_dir): raise RuntimeError(导出产物不完整PB 或 variables 缺失已中止替换) # 4. 删旧、替换同盘内原子操作 if os.path.exists(target): shutil.rmtree(target) os.replace(tmp_dir, target) # 5. 记录签名元信息方便多版本对比 meta { version: str(version), exported_at: datetime.datetime.now().isoformat(timespecseconds), path: target, } if extra_meta: meta.update(extra_meta) hist_path os.path.join(export_root, .., signature_history.json) hist_path os.path.abspath(hist_path) history [] if os.path.exists(hist_path): with open(hist_path, r, encodingutf-8) as f: try: history json.load(f) except json.JSONDecodeError: history [] history.append(meta) with open(hist_path, w, encodingutf-8) as f: json.dump(history, f, ensure_asciiFalse, indent2) print(f[export] version{version} - {target}) return target逐点解释为什么这么写。第一步清理.tmp不是多余的。如果上一次导出过程中进程被 kill.tmp目录会残留里面可能有半截文件。不清的话这次写入会叠加在半截文件上问题重现。第二步用tf.saved_model.save而不是model.save。原因是前者的行为更可控不会因为save_format参数在版本间的变化而改变行为也更明确地表达我要的就是 SavedModel。第三步的完整性校验很关键。在一些异常情况下磁盘满、权限问题saved_model.pb可能写了一半或者variables/目录压根没建起来。加了这道校验宁可这次导出失败报错也不要生成一个看起来存在但加载会崩的目录。第四步用os.replace而不是shutil.move。前者在同文件系统内是原子的后者是复制删除中间有窗口期。注意os.replace对目录的要求是目标不存在或为空目录所以我先rmtree再替换。第五步的元信息记录是我后来加的最有用的东西。有了它部署端出问题时可以直接对比两个版本的输入签名几分钟定位。签名本身建议这样定义class ExportModule(tf.Module): def __init__(self, model): super().__init__() self.model model tf.function(input_signature[ tf.TensorSpec([None, 224, 224, 3], tf.float32, nameimage) ]) def serving_fn(self, image): prob self.model(image, trainingFalse) return {probabilities: prob, class_id: tf.argmax(prob, axis-1)}用tf.Module包一层的好处是签名函数归属清晰导出时不会和 Keras 内部自动生成的签名打架。trainingFalse必须显式写因为 dropout 和 BN 在训练/推理模式下行为不同如果你不小心在导出的图里保留了训练模式的分支部署端每次推理结果都不一样——这个坑排查起来极其痛苦因为本地测试可能恰好没开 dropout。3.3 保存之后必须做的自检保存成功不等于模型可用。我一般做三层自检。第一层是目录体检用 shell 就够了find runs/exp_baseline/export/3 -type f | sort du -sh runs/exp_baseline/export/3看文件清单是否符合预期看体积是否和上一版量级相当。如果新版本突然只有几十 KB 而旧版本有几十 MB那说明变量根本没写进去。第二层是签名体检用官方工具saved_model_cli show --dir runs/exp_baseline/export/3 --tag_set serve --all重点看signature_def里的serving_default确认输入张量的名字、dtype、shape 和你预期一致。批量维度和序列维度应该是-1也就是未知/动态如果显示成了固定数字说明当时保存时用了静态形状后续换 batch size 就会失败。第三层是数值体检重新加载跑一批数据和原模型对比输出import numpy as np import tensorflow as tf loaded tf.saved_model.load(runs/exp_baseline/export/3) infer loaded.signatures[serving_default] x np.random.rand(4, 224, 224, 3).astype(np.float32) out infer(imagetf.constant(x)) prob_from_file out[probabilities].numpy() prob_original model(x, trainingFalse).numpy() max_diff np.abs(prob_from_file - prob_original).max() print(max diff:, max_diff) assert max_diff 1e-5, 导出前后数值不一致检查是否有随机层或训练模式残留这一步能抓出绝大多数导出后效果变差的问题。只要max_diff是量级在 1e-6 到 1e-5 之间基本可以放心如果差到 1e-2 以上那说明图的某处不一致回头查签名函数和training参数。3.4 断点续训与模型归档怎么分工前面反复强调这两件事要分开做这里给具体实现。断点续训用tf.train.Checkpoint加CheckpointManagerckpt tf.train.Checkpoint(steptf.Variable(1), optimizeroptimizer, modelmodel) manager tf.train.CheckpointManager( ckpt, ./runs/exp_baseline/ckpt, max_to_keep3 ) # 恢复 ckpt.restore(manager.latest_checkpoint) if manager.latest_checkpoint: print(restored from, manager.latest_checkpoint) # 训练循环里定期存 for step, batch in enumerate(dataset): train_step(batch) ckpt.step.assign_add(1) if int(ckpt.step) % 1000 0: manager.save()max_to_keep3保证只留最近三个断点不会把磁盘吃满。这套机制的优点是优化器状态、学习率调度器的状态、指标累加器全都包含在 checkpoint 里恢复后是真正的无缝续训。归档则用第 3.2 节的导出函数只在这一版要给别人用/要上线的时候调一次。两条轨道互不干扰出了问题也好定位是哪条轨道的事。提示CheckpointManager生成的checkpoint文件里记录了最新的断点路径。如果你手工删了断点文件但没删这个索引文件恢复时会报文件不存在的错误。批量清理断点时记得把索引文件一起处理或者重新生成。4. 高频报错速查与排查套路4.1 报错对照表下面这张表是我从多次实际问题里整理出来的按症状 → 最可能原因 → 处理动作三段式列出来方便直接对照。症状 / 报错关键词最可能原因处理动作SavedModel directory already exists目标目录已存在且某些版本不接受非空目录保存前清空或改用版本子目录Could not find meta graph/no meta graphsaved_model.pb缺失或损坏检查文件是否存在、大小是否正常重新导出KeyError: serving_default签名未正确注册或名称被覆盖用saved_model_cli show查看实际签名名Unresolved object in checkpoint变量名与图里的引用对不上检查层命名漂移显式指定nameis not a valid SavedModel目录里混了.keras/.h5等其他格式产物清理目录重新导出报形状不匹配维度数字对不上导出时用了静态形状在input_signature中用None表示动态维度加载成功但预测结果随机变量文件残留 / 加载了错误的版本目录清理目录做数值自检加载后指标数值异常metrics 累加器状态被恢复load_model(..., compileFalse)assets相关查找表越界旧词表文件残留被一起初始化清理assets/目录后再导出Keras 3 下save_format报 TypeError参数在新版本中被移除改用model.export()导 SavedModel4.2 三个通用排查动作遇到 SavedModel 相关的问题不管报什么我都先做这三件事八成能定位到方向。动作一看一眼目录文件的修改时间。ls -lR的输出里如果同一个版本的目录内文件时间戳跨越了不同的时间点说明这个目录不是一次性生成的。这是杂糅目录的典型特征。动作二用官方 CLI 打印完整签名。saved_model_cli show --all的输出信息量很大输入输出的名字、dtype、shape 都在里面。我习惯把它重定向到一个文本文件和上一版本的输出做 diff一眼就能看出哪变了。动作三跑一次数值对比。也就是 3.3 节那段代码。很多时候报错信息是误导性的真正的问题在数值层面。数值对比能快速回答到底是加载错了文件还是图本身有问题。4.3 我踩过的几个真实坑说几个印象最深的都是文档里不写、只有实操才会遇到的东西。第一个坑在 Jupyter 里反复执行同一个 cell 导出模型。Jupyter 的变量会驻留在内存里第二遍执行时model对象其实已经带着上一轮的状态加上文件残留导出的东西就变成了一个说不清的混合体。后来我养成了习惯Jupyter 里做导出前先重启内核或者干脆在导出逻辑里加一段检查目标目录必须不存在的断言。第二个坑多进程同时导出一个目录。分布式训练的时候几个 worker 都想往同一个导出目录写后写的会覆盖先写的或者留下半截文件。解决方式是指定 rank 0 负责导出其他 worker 只保存自己的 checkpoint主进程等所有 worker 完成后统一导出一次。第三个坑路径用了相对路径。导出脚本在/home/user/project下跑路径写./export/1没问题后来 CI 系统在别的目录下执行同一个脚本落的文件全跑到别处去了部署端按预期路径找不到白折腾半天。现在我一律用os.path.abspath把关键路径转绝对路径并且在导出日志里打印出来。第四个坑磁盘空间不足导致的半截导出。这个最难查因为tf.saved_model.save在 Space 不足时的报错信息不一定直接指向磁盘。表现是保存完没报错加载时报读取失败。解决方式是导出前后都做一次完整性校验并且监控磁盘水位。这个坑没有捷径只能靠流程守住。注意很多报错在本地复现不了因为本地目录是干净的、路径是对的、磁盘是空的。所以我在导出函数里内置了越多的自检线上出问题的概率就越低。所谓防御式导出说的就是这个。5. 跨版本与部署环节的连带问题5.1 版本矩阵与兼容性SavedModel 的向后兼容做得不错但不错不等于随便来。根据我的实践下面这些组合要特别小心。保存侧版本加载侧版本实测情况建议TF 2.4 之前的版本2.5 之后缺少fingerprint.pb一般能加载尽量同版本导出TF 2.5 到 2.15同小版本正常推荐组合TF 2.x 保存TF 1.x 加载基本不行别尝试改用 TF1 模式重导TF 1.x 保存TF 2.x 加载部分可以需处理图模式差异在 2.x 里用兼容接口谨慎处理Keras 3 导出的 SavedModel旧版 TFS 服务可能有签名差异上线前必须做端到端验证经验是导出环境和部署环境的 TensorFlow 小版本尽量一致。做不到一致时把主版本对齐都是 2.x并且在部署前用与线上完全相同的运行时环境跑一遍加载和推理这是唯一可靠的验证方式。还有一个跨语言的问题值得提一句。SavedModel 被设计成语言无关Java、Go、C 都能加载。但自定义 op 和自定义层的逻辑是 Python 里写的导出时会被 traced 成图。如果里面调用了 Python 侧才能跑的东西比如用tf.py_function包了一段 numpy 计算跨语言加载会失败。所以导出部署模型之前最好确认图里没有PyFunc节点saved_model_cli show --dir runs/exp_baseline/export/3 --all | grep -i pyfunc没有任何输出说明干净。5.2 上线前用 saved_model_cli 做最后一道体检saved_model_cli除了show还有个run子命令可以脱离 Python 环境直接跑一次推理。这个能力在做部署验证时非常好用。先用show拿到输入的名字和类型然后构造一次假数据跑通saved_model_cli run \ --dir runs/exp_baseline/export/3 \ --tag_set serve \ --signature_def serving_default \ --input_exprs imagenp.zeros((1,224,224,3),np.float32)这条命令能跑通说明图是完整的、签名是可用的、变量能正确加载。跑不通的话错误信息通常在 Python 侧加载时看不到因为路径不同、初始化顺序不同。我一般把这条命令写进 CI 脚本每次导出后自动执行跑不通就直接 fail 掉发布流程。加这一道关卡之后线上加载失败的事故基本归零了。5.3 多模型、多版本共存的目录组织最后说说服务端场景。TensorFlow Serving 支持同一个模型名下的多版本共存目录结构长这样/models/ ├── my_model/ │ ├── 1/ # 版本 1 │ │ ├── saved_model.pb │ │ └── variables/ │ ├── 2/ # 版本 2 │ │ ├── saved_model.pb │ │ └── variables/ │ └── 3/ └── other_model/ └── 1/关键点在于每次新增版本时只能新增目录不能改动已有版本的目录内容。这是因为服务端在热切换版本时会并行加载新旧两份模型并做流量灰度如果旧版本目录里文件被改了正在处理的请求可能读到不一致的数据。版本上线的顺序也有讲究。新增版本后服务端默认的策略是等新版本加载完成、校验通过后才切换流量如果加载失败会继续用旧版本。这个机制的前提是新目录本身是完整健康的所以第 3.2 节的原子导出在这里价值最大——它保证了目录一旦出现就是完整的不会出现服务端扫描到一个半成品版本然后加载失败的情况。另外多版本共存会成倍占用内存。一个几百 MB 的模型同时挂三个版本就是三份内存。生产环境一般保留两个版本当前上一个准备回滚的就够更早的版本归档到冷存储需要时再拉回来。清理旧版本时也要注意先停掉服务端对那个版本的引用通过模型配置里的版本策略确认没有流量后再删目录避免正在推理的请求突然读不到文件。写到这里关于 SavedModel 多次保存的坑基本覆盖全了。我个人的体会是这套东西的复杂度不在于 API 有多难而在于保存这个动作在很多人的心智里被简化成了存个文件忽略了它其实是一次有状态的、会留下痕迹的输出过程。把目录规划、原子导出、三层自检、断点与归档分家这几件事固化进项目模板后面无论训练脚本怎么改、跑多少轮、导出多少版本都不会再因为模型文件本身的问题浪费时间了。
返回列表