同一张图片两次识别结果不一致时,先不要直接归因于 MiniCPM-OCR 模型本身不稳定。可复现性问题通常出在四层:输入图片的字节、读图与预处理路径、采样相关参数、精度与批大小。任意一层在两次运行之间发生变化,输出就会漂移。可以先按「固定输入、固定参数、固定配置」的顺序逐层收敛,每一层都用可核验的产物确认收敛,例如图片校验和、参数快照、输出哈希。
判断方向:当两次运行的图片 sha256 相同、采样参数已显式固定、精度与批大小一致,输出仍然出现内容级差异,才需要往模型权重版本、推理后端或算子实现层面查。多数不一致能在读图路径和参数默认值上收敛。边界:不同硬件、不同推理后端、不同权重版本之间不保证比特级一致;目标应当是「同一环境、同一配置下可重复」,而不是跨环境完全一致。
确认两次输入的图片字节是否一致
先排除读图环节。把同一张图用两条不同路径读进来,例如一次从本地文件读取、一次从下载缓存读取,字节可能已经不同;图片经过二次压缩、转码、加水印后,sha256 也会变。建议在执行识别前先打印校验和:
sha256sum input.jpg
# 或在 Python 中读取原始字节
python -c "import hashlib;print(hashlib.sha256(open('input.jpg','rb').read()).hexdigest())"不同读图库的行为差异会直接改变送入模型的张量:PIL 打开图片后不会自动处理 EXIF 方向时,需要显式调用 ImageOps.exif_transpose;OpenCV 返回的是 BGR 通道,且是否读取 EXIF 方向与版本有关;带 alpha 通道的 PNG 在转 RGB 时,各库对透明区域的填充方式不同;缩放插值默认值也不同(PIL 常用 BILINEAR,OpenCV 常用 INTER_LINEAR)。建议统一成一个读图函数,后续所有调用都走这个入口:
from PIL import Image, ImageOps
import hashlib, io
def load_image(path):
with open(path, 'rb') as f:
raw = f.read()
print('sha256:', hashlib.sha256(raw).hexdigest())
img = Image.open(io.BytesIO(raw))
img = ImageOps.exif_transpose(img)
img = img.convert('RGB')
return img如果图片来自 URL,建议先保存到本地并记录校验和,再用同一个 load_image 读取。不要一次走 URL 流式解码、一次走本地文件,那样两次输入的字节是否一致本身就无法确认。
把采样与随机相关参数显式固定
确认输入一致后,下一步是让两次运行处在同一参数条件下。很多推理脚本在未显式传参时会走框架默认值,默认值可能随版本变化;部分部署还会在服务端再覆盖一次客户端参数,所以除了改脚本,也要检查请求实际生效的参数。通用骨架如下,具体参数名需要按所用接口取舍:
import random, numpy as np, torch
def set_seed(seed=1234):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
gen_kwargs = dict(
do_sample=False, # 关闭采样,走贪心解码
num_beams=1,
max_new_tokens=1024,
)
# 若接口必须开启采样,再显式压住随机性:
# temperature=0.0, top_p=1.0, top_k=0需要注意,do_sample=False 时 temperature、top_p 一类参数通常不起作用,写上去只是为了在日志里留痕;反过来,某些框架用 temperature=0 表达贪心,不能把一套参数名直接套到另一套接口上。建议把每次运行实际传入的参数整份打印或写进日志,再把日志和输出一起归档,后面比对时才有依据。
固定精度与批大小后重跑
精度切换是另一个常见来源。同一份权重用 float32、float16、bfloat16 加载,或在加载时启用量化,数值路径不同,输出就可能出现字词级别的差异。批大小同样会影响结果:一次只跑一张和一次跑多张,padding 方式和同批样本的数值都会变化。复现阶段建议把精度和批大小都显式写出:
model = AutoModel.from_pretrained(
weight_path,
torch_dtype=torch.float16, # float32 / bfloat16 同理
device_map='cuda:0',
)
# 推理时固定 batch_size=1,不做动态合并注意力实现方式(eager、sdpa 或融合内核)也可能带来细微数值差异,如果两次运行的环境不同,需要一并记录。为了定位问题,建议每次运行填一张记录表:
| 字段 | 说明 |
|---|---|
| 运行时间 | 到分钟即可 |
| 图片 sha256 | 读图函数打印的值 |
| 配置快照 sha256 | 参数文件内容哈希 |
| 权重路径与版本 | 本地目录或版本号 |
| dtype / 量化 | float16、bfloat16、是否 8bit |
| batch_size | 本次实际批大小 |
| seed 与解码方式 | 种子值、贪心或采样 |
| 输出 sha256 | 结果文件的哈希 |
两三次运行后把表摊开,哪一列变了,基本就能定位差异来源。
逐字比对两次输出差异
笼统说「结果不稳定」对排查没帮助,需要把差异落到具体类型。建议先用 difflib 做一次逐字符与逐行比对,再按类别统计:
import difflib
def compare(a, b):
sm = difflib.SequenceMatcher(None, a, b)
return [(tag, a[i1:i2], b[j1:j2]) for tag, i1, i2, j1, j2 in sm.get_opcodes() if tag != 'equal']
def normalize(s):
s = s.replace('\u3000', ' ')
s = s.replace(',', ',').replace(':', ':')
return ' '.join(s.split())差异大致可以分成几类:一是空白差异,只有空格、换行、缩进不同;二是标点差异,全角与半角、中英文标点混用;三是错字,通常表现为等长的单字替换;四是漏行或多行,表现为整行或整块缺失;五是结构差异,例如表格列数变化、列表编号丢失。统计时建议先比归一化后的文本,再比原始文本:归一化后一致,说明只是格式问题,对下游影响有限;归一化后仍有差异,再看替换位置和长度。
diffs = compare(normalize(out_a), normalize(out_b))
for tag, left, right in diffs:
print(tag, repr(left), '->', repr(right))把同一张图跑三次,按上面五类分别计数,如果三次之间只在空白和标点上摆动,问题多半出在后处理或格式化;如果错字位置固定在某个区域,往往和图片局部清晰度、裁剪或缩放有关,值得把这部分区域单独裁出来再测一次。
把确定性配置写进调用脚本
排查完成后要把结论固化,否则下一批任务又会回到默认值。建议把参数外置成配置文件,脚本只负责读配置、跑推理、写记录,配置本身的哈希也一并落到输出里:
# config.yaml
seed: 1234
dtype: float16
batch_size: 1
max_new_tokens: 1024
decode: greedy
image_dir: ./images
out_dir: ./runsimport yaml, json, hashlib
from pathlib import Path
cfg = yaml.safe_load(Path('config.yaml').read_text())
run_id = hashlib.sha256(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
set_seed(cfg['seed'])
dtype = {'float16': torch.float16, 'bfloat16': torch.bfloat16}[cfg['dtype']]
# 加载模型、读图、推理、写出 runs/{run_id}.json验证步骤建议连跑三次同样输入,比较三次输出文件的 sha256:
for i in 1 2 3; do
python run_ocr.py `--image` images/a.jpg `--out` runs/r$i.json
done
python -c "import hashlib;[print(hashlib.sha256(open(f'runs/r{i}.json','rb').read()).hexdigest()) for i in (1,2,3)]"三次哈希完全一致,说明在该机器、该权重、该配置下可以复现,可以把这份配置作为基线;如果三次不一致,回到记录表逐列对照,重点看 dtype、batch_size、注意力实现和权重版本是否在运行之间发生过变化。换机器或升级框架后,建议重新跑一次这组三次验证,再决定是否沿用同一份基线判断。