资讯详情

资讯详情

TensorFlow加载.pb模型DecodeError:从字节到版本排查

前几天有人把一段报错直接甩给我只有一行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 编码的 tagtag 的高位是字段编号低三位是 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用一个第三方裁判来判断这串字节是不是合法 protobufprotoc的--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_textTrue 埋下的坑3.1 二进制和文本格式差别不止看不看得懂TensorFlow 1.x 里导出图有两个函数经常被混用tf.train.write_graph和冻结流程里的tf.graph_util.convert_variables_to_constants配合写文件。tf.train.write_graph有一个参数as_text默认是False写出来的是二进制但网上大量教程为了方便查看会写成as_textTrue于是写出一个后缀是.pb的文本格式 protobuf。这两者长得很像名字也一样但编码方式完全不同。文本格式长这样node { name: input op: Placeholder attr { key: dtype value { type: DT_FLOAT } } }二进制格式则是紧凑的字节流同样一张图体积能小一半以上。关键点在于ParseFromString只认二进制。你把上面那段带花括号的文本喂给它它读第一个字节n0x6E按 tag 解析字段编号是 13wire type 是 6——6 是保留值直接判非法抛出DecodeError。这就是无数人踩过的坑文件明明是好的图也没问题就是格式选错了入口。我个人的判断是as_textTrue只在需要人工检查图结构的时候用而且应该老老实实写成.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, encodingutf-8) as f: text_format.Merge(f.read(), graph_def) print(节点数量:, len(graph_def.node))这里有两个细节值得说。第一用text_format.Merge而不是text_format.Parse。Parse要求输入被完全消费尾部多一个空行或者注释都可能报错Merge更宽容追加解析到已有 message 上实际使用中省心得多。第二encodingutf-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, errorsstrict), 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, encodingutf-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.pbGraphDefgraph_def.ParseFromStringtf.import_graph_def元图model.ckpt-1000.metaMetaGraphDeftf.train.import_meta_graph(path)保存模型目录下的saved_model.pbSavedModeltf.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(graphtf.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)返回的是strParseFromString要的是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——但如果路径里恰好有合法转义组合可能打开到一个意料之外的文件。统一用原始字符串rC:\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_IMPLEMENTATIONpython的提示。这个属于版本冲突的友好版至少告诉你了原因。另一种就是本文的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 tensorflow1.15.0 pip install protobuf3.6.1装完之后写一个十行的验证脚本造一张最小的图导出、读回、打印节点数import tensorflow as tf # 造一张只有两个节点的图 g tf.Graph() with g.as_default(): a tf.constant([[1.0, 2.0]], nameinput_a) b tf.add(a, a, nameadd_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(bversion ): print([X] 这是大文件存储的指针文件不是模型) return if b!doctype in lowered or bhtml in lowered: print([X] 这是网页内容下载链接给错了) return if head.startswith(bPK): 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把版本号写死包括protobuf3.6.1、numpy1.16.4这种看起来老掉牙的组合。这套组合能跑就别动它。升级带来的收益远小于破坏兼容性的风险。最后再说一个心态问题。看到DecodeError第一反应应该是文件对不对而不是代码哪里写错了。因为加载图模型的代码就那么五行写错的可能性极低而文件从产生到传输到落地每一环都可能出问题。先把文件验明正身再谈代码这个顺序能让你少走很多弯路。我自己最初也被这个错误折磨过半天后来发现只是下载到了一个 HTML 页面——从那以后我加载任何.pb之前都会先xxd看一眼文件头这个动作已经变成肌肉记忆了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →