前几天有人把一段报错直接甩给我,只有一行:google.protobuf.message.DecodeError: Error parsing message。上下文是tensorflow1.0环境下加载一个冻结好的.pb图模型,代码照着教程抄的,路径也确认过存在,但就是在ParseFromString这一步炸了。这种报错最让人抓狂的地方在于——它不告诉你哪个节点有问题,不告诉你版本,甚至不告诉你它读到了第几个字节才断的。很多人第一反应是"模型文件坏了""要重训",然后花几个小时重新导出一遍,结果一模一样。
先把结论摆出来:这个报错几乎从来不是模型本身训练得不对,而是"你交给 protobuf 解析器的这串字节,不符合 GraphDef 的编码规则"。可能是文件根本不是二进制.pb,可能是下载到一半的残文件,可能是 Git LFS 留下的指针文本,也可能是打开了文本格式却用二进制接口去解。下面我按自己实际的排查顺序把它拆开讲,从"字节层"往上走到"API 层"再到"环境层",每一层都给可复制的检查手段。只要按这个顺序走一遍,绝大多数同类报错十分钟内能定位到具体原因。
1. DecodeError 不是"模型坏了",而是"字节对不上号"
1.1 ParseFromString 到底在干什么
要理解这个错误,得先知道 protobuf 的二进制格式长什么样。它不像 JSON 有花括号和冒号,整个文件就是一串紧凑的字节流,靠"标签—长度—内容"三件套往前推。每个字段开头是一个 varint 编码的 tag,tag 的高位是字段编号,低三位是 wire type(表示后面跟的是变长整数、固定长度还是长度前缀的字节块)。解析器就像一个只认格式的快递分拣员:看到 tag 就知道下一个包裹该按什么规格拆,拆完继续往下走,一直走到文件末尾。
只要中间任何一步对不上——tag 的值域非法、声明的长度超过了剩余字节、变长整数编码格式错误——分拣员立刻停工,抛出DecodeError。所以这个错误的本质是:解析器在读第 N 个字节时,发现这串数据不像任何一个合法的 GraphDef 字段。它跟"这个模型能不能跑""权重对不对"完全没关系,纯纯是编码层面的对不上。
打个比方:你拿一台身份证阅读器去刷一张地铁卡,机器报"无法识别"。不是卡坏了,是这张卡压根不是它该读的那种卡。DecodeError就是这个"无法识别"。
1.2 为什么报错信息里既没有节点名也没有版本号
这是很多人困惑的点。TensorFlow 加载图失败时,有些错误会明确告诉你Op type not registered 'FusedBatchNormV3',有些会告诉你KeyError: "The name 'input_tensor' refers to an Operation not in the graph",这些都带具体信息。而DecodeError什么都没有。
原因很简单:解析失败发生在语义解析之前。protobuf 是先把你给的字节还原成一个结构化的 message 对象,TensorFlow 再拿这个对象去建图。DecodeError出现在第一步,此时连一个 NodeDef 都没解析出来,自然不可能告诉你哪个节点有问题。它连"这是不是一张图"都还没判断出来。
理解这一点很重要,因为它直接决定了排查方向:不要去检查模型结构、不要去怀疑输出节点名字写错了、更不要重新训练。问题 100% 在"文件内容"和"解析方式"这两个点上。
1.3 把三类失败分开看,别在错的方向上使劲
我在帮人看问题时发现,很多人把下面三类完全不同的失败混成一类,导致排查南辕北辙。列个表对照一下:
| 失败层次 | 典型报错 | 根因所在 | 排查方向 |
|---|---|---|---|
| 解析层 | DecodeError: Error parsing message | 字节流不是合法 protobuf | 文件真身、格式、完整性 |
| 结构层 | Op type not registered、KeyError: name refers to an Operation not in the graph | 图能解析但节点/算子不匹配 | TF 版本、算子支持、节点名 |
| 运行层 | InvalidArgumentError: Dimensions must be equal、You must feed a value for placeholder | 图能建但输入输出不对 | feed/fetch 名字、shape、dtype |
只有第一行才是本文讨论的DecodeError。我见过有人因为看到DecodeError就去查Op type not registered的解决方案,装了一堆自定义算子,最后一无所获——因为那两个错误压根不在同一层。先确认自己站在哪一层,再往下走。
2. 用文件头把"假 .pb"揪出来
2.1 三条命令先看清文件真身
在碰任何 TensorFlow 代码之前,先干一件事:看看这个文件到底是什么。我习惯用三条命令,全部在终端里跑,不依赖 Python 环境。
第一条,看大小:
ls -lh frozen_inference_graph.pb一个正常的冻结图模型,规模通常在几 MB 到几百 MB 之间。如果你看到一个.pb只有 130 字节、1KB、或者干脆 0 字节,那基本可以收工了——它不可能是完整的图。我遇到过的案例里,最常见的"假 .pb"就是 130 字节左右的纯文本,那是版本管理工具的大文件指针留下的占位文件。
第二条,看文件头:
xxd frozen_inference_graph.pb | head -n 8xxd会给你十六进制的原始字节,左边是偏移量,右边是 ASCII 对照。这一眼能看出太多东西:如果右边那一列是清晰可读的英文句子,说明这是个文本文件;如果看到<!DOCTYPE或<html,说明你下载到了一个网页;如果开头是PK,那是个压缩包;如果是version开头,那是大文件指针;如果开头就是一堆不可读的乱码字节,那它大概率真的是二进制。
第三条,用系统自带的类型识别:
file frozen_inference_graph.pbfile命令会基于魔数猜类型,输出data说明是二进制,输出ASCII text或HTML document就一目了然了。Windows 上没有xxd,可以用 PowerShell 的Format-Hex,或者干脆跳到 2.3 节的 Python 脚本。
2.2 六种"李鬼"文件对照表
把我在实际场景里见过的"假 .pb"整理成一张表,对照文件头和文件大小基本能一秒定性:
| 文件头特征(ASCII 可读部分) | 文件大小特征 | 实际内容 | 处理方式 |
|---|---|---|---|
以version开头的纯文本 | 约 130 字节 | 大文件存储系统的指针文件 | 安装对应客户端后拉取真实文件 |
<!DOCTYPE html或<html | 几 KB 到几十 KB | 网盘的登录页/提示页 | 换直链重新下载 |
PK | 任意 | zip 压缩包 | 先解压再找里面的 pb |
不可读字节,但能看到node {片段 | 任意 | 文本格式的 pbtxt | 改用文本解析接口 |
大量可读文本、含node { | 几十 KB 到几百 MB | 就是 pbtxt,只是后缀被改了 | 改用文本解析接口或转换 |
| 空文件,0 字节 | 0 | 下载中断或没写入 | 重新获取 |
其中前两种占了绝大多数。特别是网盘、代码托管平台的"预览页"——你在浏览器里点开一个.pb链接,看到页面渲染出一堆看不懂的东西,然后右键另存为,存下来的其实是那个渲染页面本身,不是文件。这类文件大小通常在几 KB,比你预期的模型小两个数量级,但很多人不看大小,直接就扔进代码里跑。
一个很实用的小习惯:下载完模型文件后,第一件事是比对官方给出的校验值(如果有),第二件事是看一眼文件大小是否在合理量级。这两步加起来不超过三十秒,能省掉后面半小时的困惑。
2.3 用 protoc --decode_raw 做一次独立裁决
如果你想彻底甩开 TensorFlow,用一个"第三方裁判"来判断这串字节是不是合法 protobuf,protoc的--decode_raw参数是最好用的工具。它不需要任何.proto文件,直接对原始字节做结构猜测:
protoc --decode_raw < frozen_inference_graph.pb | head -n 30这个命令的判定逻辑非常干脆:
- 正常输出一串结构化的
1 { 1: "..." 2: "..." }—— 说明文件是合法 protobuf 二进制,格式层面没问题,问题在类型或加载方式上,跳到第 4 节。 - 报错
Failed to parse input—— 说明这串字节根本不是 protobuf 编码,问题在文件本身,回到 2.2 节。 - 输出可读的文本结构但也有乱码—— 可能是文本格式被强行喂给二进制解码器,跳到第 3 节。
--decode_raw输出的字段编号是纯数字,看不懂没关系,你只需要看它"能不能解"。这个判断比 TensorFlow 给的报错信息清晰一百倍,因为它是专门干这件事的工具。我第一次用它定位问题的时候,看到输出里明晃晃地写着2: "<!DOCTYPE html>",当场就知道是下载了个网页回来。
3. 文本格式的图:as_text=True 埋下的坑
3.1 二进制和文本格式,差别不止"看不看得懂"
TensorFlow 1.x 里导出图有两个函数经常被混用:tf.train.write_graph和冻结流程里的tf.graph_util.convert_variables_to_constants配合写文件。tf.train.write_graph有一个参数as_text,默认是False,写出来的是二进制;但网上大量教程为了"方便查看",会写成as_text=True,于是写出一个后缀是.pb的文本格式 protobuf。
这两者长得很像,名字也一样,但编码方式完全不同。文本格式长这样:
node { name: "input" op: "Placeholder" attr { key: "dtype" value { type: DT_FLOAT } } }二进制格式则是紧凑的字节流,同样一张图,体积能小一半以上。
关键点在于:ParseFromString只认二进制。你把上面那段带花括号的文本喂给它,它读第一个字节n(0x6E),按 tag 解析,字段编号是 13,wire type 是 6——6 是保留值,直接判非法,抛出DecodeError。这就是无数人踩过的坑:文件明明是好的,图也没问题,就是格式选错了入口。
我个人的判断是:as_text=True只在需要人工检查图结构的时候用,而且应该老老实实写成.pbtxt后缀;凡是准备被代码加载的模型,一律用二进制,别给自己埋雷。后缀是个强提示,用了.pb就默认二进制,这个约定能减少大量沟通成本。
3.2 正确的加载姿势,以及一个格式自适应封装
文本格式要用google.protobuf.text_format来解:
import tensorflow as tf from google.protobuf import text_format graph_def = tf.GraphDef() with open('graph.pbtxt', 'r', encoding='utf-8') as f: text_format.Merge(f.read(), graph_def) print('节点数量:', len(graph_def.node))这里有两个细节值得说。第一,用text_format.Merge而不是text_format.Parse。Parse要求输入被完全消费,尾部多一个空行或者注释都可能报错;Merge更宽容,追加解析到已有 message 上,实际使用中省心得多。第二,encoding='utf-8'一定要显式指定,如果文件是带 BOM 的(Windows 记事本另存为 UTF-8 就会带),要写成utf-8-sig,否则第一行会多出一个不可见字符,解析直接失败。
如果你手头文件来源混杂,不想每次都手动判断,可以写一个自适应的小函数,先试二进制再退回文本:
import tensorflow as tf from google.protobuf import text_format from google.protobuf.message import DecodeError def load_graph_def(path): with open(path, 'rb') as f: raw = f.read() if not raw: raise ValueError('文件是空的,先检查下载完整性: %s' % path) graph_def = tf.GraphDef() try: graph_def.ParseFromString(raw) if len(graph_def.node) > 0: return graph_def except DecodeError: pass graph_def = tf.GraphDef() try: text_format.Merge(raw.decode('utf-8-sig', errors='strict'), graph_def) if len(graph_def.node) > 0: return graph_def except Exception: pass raise ValueError('两种格式都解析不出节点: %s,请先用 xxd 查看文件头' % path)注意里面那个len(graph_def.node) > 0的判断。这是经验之谈:protobuf 的解析器有一种"宽容"特性——遇到不认识的字段编号,它不会报错,而是把这部分数据塞进"未知字段"里悄悄保留。也就是说,把一个别的类型的 protobuf 喂给 GraphDef,可能解析成功,但节点数是个位数甚至零。如果你只靠try/except判断,会得到一个空图,然后在后面import_graph_def时收到一个莫名其妙的错误。加上节点数校验,能把这个隐患挡在前面。
3.3 转回二进制,别让下游工具再踩一遍
文本格式在 TensorFlow 1.x 里勉强能用,但下游的推理工具、移动端转换脚本、图优化工具几乎都只吃二进制。所以一旦确认手头是文本格式,我建议顺手转成二进制存一份,后面所有流程都用转换后的文件:
import tensorflow as tf from google.protobuf import text_format graph_def = tf.GraphDef() with open('graph.pbtxt', 'r', encoding='utf-8-sig') as f: text_format.Merge(f.read(), graph_def) with open('graph_binary.pb', 'wb') as f: f.write(graph_def.SerializeToString()) print('已转换,节点数:', len(graph_def.node))转换完再跑一次protoc --decode_raw验证一下,输出正常就说明这一步彻底通了。这一步花不了两分钟,但能避免你在下一个环节再看到同一行DecodeError然后怀疑人生——因为很多人转换完用原来的文件名,结果新旧文件混在一起,又回到了原点。
4. 是二进制没错,但类型或读法对不上
4.1 GraphDef、MetaGraphDef、SavedModel 各有各的入口
文件确认是合法 protobuf 二进制之后,下一步要看的是"它是什么类型的 protobuf"。TensorFlow 1.x 里有三个容易混淆的容器,加载入口完全不同:
| 容器类型 | 典型文件名 | 序列化的 message | 正确加载方式 |
|---|---|---|---|
| 冻结图 | frozen_inference_graph.pb、graph.pb | GraphDef | graph_def.ParseFromString+tf.import_graph_def |
| 元图 | model.ckpt-1000.meta | MetaGraphDef | tf.train.import_meta_graph(path) |
| 保存模型 | 目录下的saved_model.pb | SavedModel | tf.saved_model.loader.load(sess, tags, export_dir) |
这里最容易出事的是后两种。有人把一个完整的保存模型目录里的saved_model.pb单独拷出来,用GraphDef().ParseFromString去读。因为SavedModel和GraphDef的字段编号有一部分重叠,解析器可能不报错,也可能在某个字段上撞车报DecodeError,具体取决于文件内容的字节分布——这种"时好时坏"的表现最难查。正确做法是传目录路径给loader.load,让它自己去找文件:
import tensorflow as tf export_dir = './saved_model_dir' with tf.Session(graph=tf.Graph()) as sess: tf.saved_model.loader.load(sess, [tf.saved_model.tag_constants.SERVING], export_dir) graph = sess.graph print('节点数:', len(graph.as_graph_def().node))同理,.meta文件要用import_meta_graph,它会同时把 Saver 恢复出来给你。如果你把.ckpt的权重文件(.index、.data-00000-of-00001)当成图模型去解析,那必然报错——权重文件根本不是 protobuf,它用的是另一套记录格式。
这里有个判断技巧:看文件名后缀。
.meta是元图,.index和.data-*是权重分片,.pb可能是冻结图也可能是保存模型的主体,.pbtxt是文本格式。后缀对不上就先去核对导出脚本,别硬猜。
4.2 打开方式、路径编码这些"低级"细节
有些DecodeError纯粹是文件读取环节出的问题,而且藏得很深。
第一个是打开模式。Python 3 里open(path, 'r')返回的是str,ParseFromString要的是bytes。在旧版 protobuf 上,把str传进去有时会被隐式按 latin-1 编码转换,转换出来的字节和原始文件不一致,解析就会在某个位置断掉,报DecodeError而不是TypeError。这种错误最迷惑人,因为代码看起来"能跑",只是结果不对。
# 错误写法:Python 3 下读到 str,字节被破坏 with open('model.pb', 'r') as f: graph_def.ParseFromString(f.read()) # 正确写法:必须用二进制模式 with open('model.pb', 'rb') as f: graph_def.ParseFromString(f.read())第二个是路径。TensorFlow 1.x 的tf.gfile.GFile在处理非 ASCII 路径时,在 Windows 上偶发读取截断——文件明明有 90MB,实际读进来的字节数少了一截,然后在截断点报解析错误。我遇到过一例,文件放在一个带中文的目录里,换成全英文路径就正常了。如果你怀疑是这个原因,可以先用 Python 内置的open读成 bytes,再把 bytes 交给解析器,绕过gfile那一层:
with open(path, 'rb') as f: raw = f.read() print('实际读到字节数:', len(raw)) graph_def = tf.GraphDef() graph_def.ParseFromString(raw)把读到的字节数和ls -l显示的文件大小对一下,这个动作能立刻暴露"读少了"的问题。
第三个是 Windows 路径分隔符。'C:\models\graph.pb'里的\m和\g会被当成转义序列,路径变成另一串东西,open直接找不到文件报FileNotFoundError——但如果路径里恰好有合法转义组合,可能打开到一个意料之外的文件。统一用原始字符串r'C:\models\graph.pb'或者正斜杠最省事。
4.3 文件被截断、被拼接、被反复解析的迹象
还有一类问题出在文件传输过程。断点续传没接上、压缩包解压时磁盘满了、拷贝到 U 盘时提前拔了线——都会产生一个"大小看起来还行但尾部缺失"的文件。protobuf 对这种情况的容忍度很低,因为它需要按长度前缀一步步走,走到一半没数据了,直接抛DecodeError。
判断方法是把文件大小和你预期的做对比。如果你知道原始模型大概 90MB,手上这个只有 87MB,那就是传输问题,重新下载比什么都快。
另一种情况是文件被拼接了。有人为了合并两个模型,直接cat a.pb b.pb > merged.pb。protobuf 的语义里,连续的两个同类型 message 拼在一起,解析时会做"字段合并"——但这只对重复字段有效,遇到 singular 字段会产生"后一个覆盖前一个"或者报错,具体表现很难预测。这种文件必须拆开处理,不能硬拼。
最后说一个很隐蔽的:复用同一个 GraphDef 对象。如果你在循环里反复解析,第一次用ParseFromString没问题,后面改用MergeFromString就会把新内容追加到旧内容上,节点数翻倍,属性冲突。反过来,如果你在一个被解析过的对象上再次ParseFromString,它会先清空,看起来是对的,但如果解析中途抛异常,对象会处于一个半残状态,后续代码拿到的就是脏数据。
# 想统计解析消耗的字节数,用它做完整性校验 graph_def = tf.GraphDef() consumed = graph_def.MergeFromString(raw) if consumed != len(raw): print('警告:尾部有 %d 字节未消费,文件可能被追加过内容' % (len(raw) - consumed))MergeFromString会返回实际消费的字节数,这个返回值是真·干货,很多人不知道。用它和文件长度一比,尾部有没有多余的垃圾数据一目了然。
5. 环境层面的连环坑:protobuf 版本与 TF 1.x 的绑定
5.1 版本不匹配时的报错长相
排除了文件和 API 的问题,如果还是报DecodeError,那就要怀疑环境了。TensorFlow 1.x 和 protobuf 的版本是强绑定的,尤其是 1.0 到 1.5 这一段,用的是 protobuf 3.1 到 3.5 之间的版本。如果你在同一个环境里装了新版 protobuf,会出现几种不同的症状:
一种是直接报TypeError: Descriptors cannot not be created directly,附带一段让你设置PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python的提示。这个属于版本冲突的"友好版",至少告诉你了原因。
另一种就是本文的DecodeError——但这通常不是 protobuf 版本直接导致的,而是版本错配引起的连带反应。比如某个图是用新版工具链导出的,里面带了旧版 protobuf 不认识的新字段编码,旧版解析器在跳过一个未知字段时,因为对 wire type 的处理差异,走到后面就乱了。
还有一种情况是混用了 C++ 实现和纯 Python 实现。protobuf 默认走 C++ 加速实现(upb或cpp),在某些平台上会回退到纯 Python 实现。这两个实现对边界情况的处理并不完全一致,同一份文件在一个环境下能解,在另一个环境下报DecodeError。这种"换台机器就好了"的玄学问题,八成就是这个原因。
排查方法很直接:
import google.protobuf import tensorflow as tf print('protobuf 版本:', google.protobuf.__version__) print('TensorFlow 版本:', tf.__version__) print('protobuf 实现:', google.protobuf.internal.api_implementation.Type())把这三行输出贴出来,问题基本就清晰了。我个人的做法是:TF 1.x 项目一律配 protobuf 3.6.1,不为别的,就因为这条组合我在多个环境上验证过是稳的。不要去追最新版,1.x 时代的代码和新版 protobuf 的兼容性只会越来越差。
5.2 搭一个可复现的最小验证环境
排查这类问题,最怕的是"改了半天不知道改对了没有"。我习惯先搭一个最小可复现环境,把变量降到最低:
python -m venv tf1_env source tf1_env/bin/activate pip install --upgrade pip pip install tensorflow==1.15.0 pip install protobuf==3.6.1装完之后写一个十行的验证脚本,造一张最小的图,导出、读回、打印节点数:
import tensorflow as tf # 造一张只有两个节点的图 g = tf.Graph() with g.as_default(): a = tf.constant([[1.0, 2.0]], name='input_a') b = tf.add(a, a, name='add_op') gd = g.as_graph_def() with open('/tmp/mini.pb', 'wb') as f: f.write(gd.SerializeToString()) gd2 = tf.GraphDef() with open('/tmp/mini.pb', 'rb') as f: gd2.ParseFromString(f.read()) print('节点数一致:', len(gd.node) == len(gd2.node)) print([n.name for n in gd2.node])这个脚本如果跑通,说明环境本身没问题,问题一定出在你要加载的那个文件上,回头走第 2 节的流程。如果这个脚本都跑不通,那就是环境配错了,先解决环境再谈别的。这个"最小闭环"的思路我用在很多问题上,它能干净地划出"是环境的锅还是文件的锅"这条界线。
6. 一套五分钟跑完的自检脚本与经验清单
6.1 把上面的判断串成一个脚本
排查得多了,我把前面这些零散判断整理成一个可以反复用的脚本。扔到项目里,任何.pb文件出问题先跑它:
import os import sys import tensorflow as tf from google.protobuf import text_format from google.protobuf.message import DecodeError def diagnose(path): print('=' * 60) print('文件:', path) if not os.path.exists(path): print('[X] 文件不存在') return size = os.path.getsize(path) print('[1] 大小: %d 字节 (%.2f MB)' % (size, size / 1024.0 / 1024.0)) if size == 0: print('[X] 空文件,重新下载') return with open(path, 'rb') as f: head = f.read(64) f.seek(0) raw = f.read() print('[2] 文件头(十六进制):', head[:24].hex()) asc = ''.join(chr(c) if 32 <= c < 127 else '.' for c in head[:48]) print(' 文件头(ASCII):', asc) lowered = head.lower() if lowered.startswith(b'version '): print('[X] 这是大文件存储的指针文件,不是模型') return if b'<!doctype' in lowered or b'<html' in lowered: print('[X] 这是网页内容,下载链接给错了') return if head.startswith(b'PK'): print('[X] 这是 zip 压缩包,先解压') return if head.startswith(b'\x1f\x8b'): print('[X] 这是 gzip 压缩流,先解压') return gd = tf.GraphDef() try: consumed = gd.MergeFromString(raw) if len(gd.node) > 0: print('[3] 二进制解析成功,节点数:', len(gd.node)) if consumed != len(raw): print('[!] 尾部有 %d 字节未消费,文件可能被追加过' % (len(raw) - consumed)) print('[4] 前 5 个节点名:', [n.name for n in gd.node[:5]]) return print('[!] 二进制解析通过但节点数为 0,可能类型不对') except DecodeError as e: print('[3] 二进制解析失败:', e) gd2 = tf.GraphDef() try: text_format.Merge(raw.decode('utf-8-sig'), gd2) if len(gd2.node) > 0: print('[3] 文本格式解析成功,节点数:', len(gd2.node)) print(' 建议转成二进制后使用') return print('[X] 文本解析通过但节点数为 0') except Exception as e: print('[X] 文本解析也失败:', e) print('[X] 两种格式都无法解析,文件很可能不是图模型或不完整') if __name__ == '__main__': diagnose(sys.argv[1])它的判断顺序是:文件存在性 → 大小 → 文件头魔数 → 二进制解析 → 文本解析 → 兜底结论。每一步都打印中间结果,跑一次就能知道自己卡在哪一环。我在好几个不同项目的排查里用这套流程,命中率相当高。
6.2 几个反直觉的细节,都是真金白银换来的
节点数为零不等于解析成功。前面说过,protobuf 会静默保留未知字段。我在一个案例里看到ParseFromString一声不吭跑完了,代码继续往下走,到import_graph_def时收到ValueError: graph_def must be a GraphDef proto——因为传进去的对象虽然类型对,但内容是空的。加上节点数校验能提前一半时间发现。
别在同一个对象上反复解析。这个坑我踩过:写了个循环批量加载图,为了省内存复用了同一个GraphDef变量,结果每一轮的属性都叠加上一轮的残留,节点属性全乱。改成每轮新建对象就没事了。protobuf 的 message 是可变的,ParseFromString虽然会清空,但异常路径下不会,状态很脏。
路径里的中文和空格是隐形杀手。别的不说,TensorFlow 1.x 在 Windows 上对非 ASCII 路径的支持一直不太好。我现在养成的习惯是:所有模型文件放在一个全英文、无空格、路径尽量短的目录下,比如D:/models/。这个习惯帮我省掉了至少三次莫名其妙的排查。
校验值比对别嫌麻烦。从任何地方拿到模型文件,先算一遍哈希,和来源处给的对比。sha256sum model.pb一条命令的事。我见过太多"文件损坏但大小看起来正常"的情况,这种不看哈希根本发现不了。
版本固定比升级重要。TF 1.x 是个历史版本,配套的 numpy、protobuf、Python 版本都应该固定住。我自己维护的项目里都有一个requirements.txt,把版本号写死,包括protobuf==3.6.1、numpy==1.16.4这种看起来"老掉牙"的组合。这套组合能跑,就别动它。升级带来的收益远小于破坏兼容性的风险。
最后再说一个心态问题。看到DecodeError,第一反应应该是"文件对不对",而不是"代码哪里写错了"。因为加载图模型的代码就那么五行,写错的可能性极低;而文件从产生到传输到落地,每一环都可能出问题。先把文件验明正身,再谈代码,这个顺序能让你少走很多弯路。我自己最初也被这个错误折磨过半天,后来发现只是下载到了一个 HTML 页面——从那以后,我加载任何.pb之前都会先xxd看一眼文件头,这个动作已经变成肌肉记忆了。