资讯动态

自研深度学习框架上复现多模态OCR模型的实践与踩坑

发布时间:2026/10/21 5:07:04 来源:尧图企业网站定制
实习项目领到手那天我对着需求文档看了很久在自研深度学习框架F上把开源多模态OCR模型DeepOCR完整跑通还要封装成一个能对外提供服务的接口。当时我的第一反应是这东西能有多难视觉编码器加自回归文本解码器结构清清楚楚PyTorch代码是现成的一周总能跑完。事实证明这个预估至少少了三倍时间。这篇文章把我在F框架上复现DeepOCR的完整过程拆开讲模型结构怎么拆、PyTorch权重怎么搬进新框架、静态图模式下的算子怎么替换、精度怎么验证以及最后怎么把一个裸模型变成线上服务。如果你正准备在自研框架上接大模型类OCR项目或者被图模式的动态shape问题折腾得头疼这篇文章应该能帮你少走几个弯路。1. DeepOCR模型拆解一张图到Markdown的路线图1.1 传统OCR管线与多模态大模型OCR的本质差异传统OCR落地方式我很熟先做文本检测把画面里的文字框出来再做方向分类把倒着的图转正然后做文字识别把框内图像切成字符逐个识别最后还要接一堆后处理把识别结果拼成段落。这套管线的问题在于每一步都是独立模型误差会逐级放大。检测漏了一个框后面的识别再准也白搭。DeepOCR这类多模态OCR模型完全换了一条路。它把整张图片当成输入直接输出一段Markdown文本——文字、公式、表格、版面结构全都一起出来。这背后是一个视觉-语言模型图像经过视觉编码器变成特征序列投影到语言模型的token空间再由语言模型像写文章一样逐字生成OCR结果。这意味着你不再需要串联三个模型而是一个模型搞定所有事。这个思路对工程侧的影响非常深远。传统管线里任何一个环节发现问题只需要替换对应模型就行但在DeepOCR这种结构下整条链路是一个不可分割的整体任何一步的算子行为不一致最终都会体现在生成文本的错乱上。这也是后面所有痛苦的根源。1.2 编码器、投影器、解码器各管什么事DeepOCR的模型结构虽然整体上是一个自回归生成模型但内部可以清晰分成三个模块视觉编码器把输入图片划分成固定大小的patch每个patch经过多层卷积或注意力变换后拉成一个视觉特征向量序列。这一步相当于把“一张图”翻译成“一段模型能读懂的视觉Token”。投影器视觉特征序列的维度跟语言模型的embedding维度不一致投影器就是一个桥常见实现是几层MLP或者交叉注意力把视觉特征线性映射到语言token所在的向量空间。语言解码器这是真正的OCR“大脑”。它拿到视觉token序列后采用自回归方式一个一个生成输出token每一步都会参考前面已经生成的token最后拼成Markdown文本。理解这个三段式结构很重要因为后面所有的权重迁移、算子替换、精度对齐都是围绕这三个模块分别展开的。如果视觉编码器的输出就对不齐你花再多时间去调解码器的采样参数都是徒劳。1.3 F框架要接住这个模型难点在哪F框架是公司自研的深度学习框架优点是训练和推理的静态图优化做得很好在专用加速卡上性能不比主流框架差。但问题是DeepOCR这类模型的推理过程天然是动态的序列长度每次生成都在变、每一步都要更新KV Cache、还要在循环内部做采样和掩码控制。这类“动态结构”在F框架的静态图模型下是重点约束对象。你没法像在PyTorch里那样随手写一个Python循环让每个循环变量自由变化静态图要求计算图的结构在编译期就固定下来所有张量的shape要么是确定的要么必须满足框架定义的动态shape规则。所以在F框架上复现DeepOCR真正的难点不是“模型能不能跑”而是“模型的动态行为能不能被装进静态图的框子里”。2. 环境搭建与权重迁移第一个通宵的由来2.1 从Python环境到算子库的版本搭配我习惯先把环境问题一次性解决好免得后面一边调模型一边装依赖两头都要命。这次项目的环境搭配我踩过一些坑最终稳定下来的组合是这样的Python 3.9用虚拟环境隔离项目依赖避免污染系统环境。F框架2.x版本配套算子库选了与框架版本完全对齐的版本。F框架的算子和框架本体版本不匹配时会直接出现“算子找不到”或者“Graph编译失败”之类的低级错误。DeepOCR原始模型是基于PyTorch生态训练的所以还需要一份PyTorch环境负责加载原始权重并导出中间结果做对比。图像处理依赖Pillow、opencv-python分词器依赖tokenizers库。这里有个很容易忽略的点tokenizers库的版本要和原始模型训练时尽量接近。版本差异可能导致同一句话切出来的token id完全不同后面生成的文本就会变成乱码。环境准备看似简单但版本对齐这件事值得多花半小时。我的经验是每装一个关键依赖就把pip freeze快照存下来后面复盘和复现都会轻松很多。2.2 所有权重从PyTorch搬到F框架的完整流程DeepOCR的原始权重一般以PyTorch的.bin或.pth格式发布而F框架训练和推理用自己的.ckpt格式。两者本质上都是“字符串键到张量”的映射没有根本性差异所以迁移的核心只是“读出来、改名字、写进去”。我写了一个简单的转换脚本import torch import F as framework source_path deepocr_origin/pytorch_model.bin target_path deepocr_convert/deepocr.ckpt origin_state torch.load(source_path, map_locationcpu) new_state {} for key, value in origin_state.items(): # 统一去掉原始模型封装前缀 new_key key new_key new_key.replace(model., ) new_key new_key.replace(text_model., llm.) new_key new_key.replace(visual., vision.) # 转成F框架的Parameter格式 new_state[new_key] framework.Parameter( framework.Tensor(value.numpy()), namenew_key ) framework.save_checkpoint(new_state, target_path) print(fconverted {len(new_state)} params)这里的关键点在于PyTorch张量要先转成NumPy再转成F框架的Tensor不要直接跨库做算数运算不然很容易因为Tensor类型不兼容报错。另外save_checkpoint要求每个值是Parameter或Tensor对象不能塞普通的嵌套字典否则保存时会静默丢参数。权重转换完成后不要急着跑推理先做一件事把转换后的cpkt里每个参数的shape和原始模型打印出来做一次全量比对确认没有维度丢失。这一步能帮你过滤掉一大半的低级错误。2.3 权重名映射表用正则一次性处理90%的键权重转换脚本好写但真正费时间的是“键名对齐”。DeepOCR的PyTorch版本里很多层名是技术人员自定义的比如transformer.h.0.self_attention.q_proj.weight而F框架的网络定义如果参考的是另一套命名规范比如llm.layers.0.attention.query.weight那么加载时就会大量报“找不到参数”。我当时的做法分三步先把网络定义建好加载权重时让F框架把所有缺失参数名打印出来把这些缺失的键名和原始模型的键名放在一起对比找出命名规律用正则表达式批量替换而不是一个个手改。import re def convert_key(key: str) - str: key re.sub(r^transformer\.h\.(\d)\., rllm.layers.\1., key) key re.sub(rself_attention\.q_proj\., rattention.query., key) key re.sub(rself_attention\.k_proj\., rattention.key., key) key re.sub(rself_attention\.v_proj\., rattention.value., key) key re.sub(rself_attention\.out_proj\., rattention.output., key) return key那一轮我总共处理了大概60个键名其中一半以上能通过几个正则规则覆盖剩下的一些特殊层再单独写映射字典。建议这种名字映射表一定要留在项目仓库里后面如果有人要复现你的流程这张表能省去大量重复劳动。3. 图模式下算子替换与动态解码真正翻车的区域3.1 PyNative调试与Graph部署的思维反差F框架提供了两种模式PyNative模式也叫动态图模式和Graph模式静态图模式。PyNative模式的使用体验和PyTorch非常接近——写了算子立即执行shape不对马上报错非常直观。Graph模式则是先把整个计算流程编译成一张静态计算图再拿到加速卡上执行性能好很多但要求模型前向过程中的所有控制流、shape变化都必须在编译期被“描述”清楚而不是在运行时自然发生。我的建议非常明确先把模型在PyNative模式下完整跑通验证输出结果再切到Graph模式去处理性能。很多人在PyNative模式下跑通了就直接上Graph模式结果被一堆抽象报错砸懵那是因为你还没有完全理解模型前向里每个shape的变化过程。3.2 一张算子等价替换对照表模型在PyTorch中使用了很多算子F框架大多数都有对应实现但API签名和对shape的推断逻辑可能不同。我整理了一张高频替换表PyTorch写法F框架写法注意点torch.cat([a, b], dim1)framework.ops.concat((a, b), axis1)参数名是axis不是dimtorch.stack([a, b], dim1)framework.ops.stack([a, b], axis1)同样注意axis命名F.softmax(x, dim-1)framework.ops.softmax(x, axis-1)部分算子对float32/float64敏感x.unsqueeze(1)framework.ops.expand_dims(x, 1)返回值是新张量不复用xtorch.gather(x, 1, idx)framework.ops.gather(x, idx, axis1)参数顺序完全不同torch.einsum(bhqd,bhkd-bhqk, q, k)framework.ops.einsum(bhqd,bhkd-bhqk, q, k)用法几乎一样torch.where(cond, a, b)framework.ops.select(cond, a, b)注意条件张量类型要求bool这些看起来是小问题但实际运行时不光会直接报错还会出现一些“能跑、结果错”的隐蔽bug。比如gather函数的参数顺序互换后索引方向和维度不一致视觉特征投影后的信息错位最终生成的Markdown文本里就会出现某一列文字的字符错乱。所以我的习惯是每替换一个算子就用一个极小的随机张量同时跑PyTorch和F框架的两个实现对比输出是否一致。只有这种最低成本的验证过了才放心把算子留在模型里。3.3 动态序列长度在静态图里怎么活下来DeepOCR解码阶段是最棘手的地方。它的解码器是自回归式的当前长度每生成一个token就增加1而且每个batch里每条样本的真实长度不一样。在PyTorch里这只是一个普通的Python循环但到了Graph模式你不能再让循环次数在运行时随意变化。我当时的解决方案是“统一长度上限掩码控制”设定固定的最大解码长度比如max_new_tokens512把所有序列padding到这个长度超出部分用掩码遮住用循环控制算子在固定范围内迭代但每次迭代是否需要继续由is_finished标志决定。def generate_with_padding( encoder_output, start_token, max_len512 ): batch_size encoder_output.shape[0] tokens framework.ops.full((batch_size, 1), start_token, dtypeframework.int32) finished framework.ops.zeros((batch_size, 1), dtypeframework.bool) for step in range(max_len): logits model.decode(encoder_output, tokens) next_tokens framework.ops.argmax(logits[:, -1, :], axis-1) next_tokens framework.ops.expand_dims(next_tokens, -1) # 已经结束的样本不再更新token next_tokens framework.ops.select(finished, tokens[:, -1:], next_tokens) tokens framework.ops.concat((tokens, next_tokens), axis1) # 更新结束标志 eos_flag next_tokens eos_token_id finished framework.ops.logical_or(finished, eos_flag) return tokens这种写法的核心思路是循环次数固定但每次循环通过掩码让已经结束的样本“原地踏步”这样计算图的结构在编译期就是完全确定的。代码里还要注意select的分支条件如果是Ture取的到底是当前最后一个token还是即将生成的新token方向搞反了会出现“模型永远在重复最后一个字符”的诡异现象。3.4 一次具体报错的排查链路复现我在Graph模式调试时印象最深的一个报错是这样的ValueError: For Concat, the shapes of inputs must be equal on axes except axis, but the shape of input[0] is (1, 35, 1024), and the shape of input[1] is (1, 38, 1024).这个报错表面上是concat的维度不匹配但真正的原因藏在逻辑里我在解码循环里把“当前token序列的完整历史”和“新生成的token”拼到一起但循环外还有一个地方按照真实最大长度把序列预先补齐到了38结果padding后的序列长度和循环内的实际长度不一致。排查过程是这样的先在拼接前打印两个张量的shape确认一方是35、一方是38顺着代码找哪里产生了38发现是数据预处理阶段把批次内的所有句子按最大长度做了pad再查循环内部为什么是35发现循环变量在迭代中动态读取了当时的序列长度最后统一了思路循环内所有张量都用max_len512的固定shape不再动态换长度掩码在每一步负责“制造”真实长度的效果。这个排查过程花了我整个下午但收获是彻底的在静态图模式下你的脑子里必须时刻有一张“每个关键张量在每一行代码执行后shape是多少”的表。不建立这个习惯Graph模式的报错能让你调到怀疑人生。4. 精度对齐与验收基线复现成功与否不能靠眼睛4.1 分模块特征对齐法模型“跑起来”和“跑对了”是两回事。DeepOCR输出的Markdown看起来大致像样不代表每个token都正确。我采用的验证方法是逐模块对齐而不是直接对比最终文本准备一张标准测试图走同一套预处理流程分别用PyTorch原始模型和F框架复现模型加载权重提取视觉编码器输出的特征张量计算两个特征张量之间的平均绝对误差和余弦相似度如果视觉编码器输出差异已经很大优先排查权重转换和预处理如果视觉编码器对齐了再继续对比投影器输出、解码器每一步的logits。分模块对齐的好处是能快速定位问题在哪一段。我之前遇到过一种情况最终生成的文本看起来90%都对但某些公式的符号错了。单独看视觉编码器输出差异很小但对比解码器logits时发现在第25层某个注意力的输出上两个框架的softmax结果相差了千分之一。就是这个千分之一在自回归的累积误差下最终让几个符号跑偏了。4.2 七类测试样本组成的验收基线项目结束前我建了一个固定验收基线一共七类样本每类选了具有代表性的真实图片样本类型数量通过标准印刷体截图20字符错误率2%学术论文页20Markdown结构完整段落不串位印刷公式15公式匹配率≥80%手写体15字符错误率15%表格图片10表格行列结构与原图基本一致发票票据10关键字段抽取无遗漏复杂版面10版面区块顺序与原文一致每次改动模型或框架版本后我都会把这100张图全量跑一遍算出指标对比表。只看一两张图的输出效果就认为“没问题”在自研框架这类改动频繁的项目里是非常危险的。另外建议把指标计算脚本写进仓库用命令行一键触发不要每次都手动把结果贴到Excel里。4.3 我踩过的三个精度漂移原因第一图像resize插值方式不一致。原始模型在PyTorch里的预处理用的是PIL的BICUBIC插值而我在F框架的前处理里用了OpenCV的默认插值方式实际是BILINEAR。两者对同一张图的缩放结果存在肉眼看不出的差异但这些差异进入视觉编码器后会被逐层放大。这个问题不做模块级对比的话几乎不可能发现因为最终文本可能只错了一两个字。第二位置编码的插值处理。DeepOCR对输入分辨率有训练上限。我在测试长图时会把长边压到1024以内这时位置编码需要按比例插值。PyTorch和F框架的插值算子虽然都叫grid_sample或interpolate但对齐坐标的方式有一个像素的偏移。这个偏移在浅层几乎无感但到了深层注意力足以让一行公式的渲染顺序错乱。第三float64到float32的精度损失。原始权重中存在少量float64张量转换脚本在转成F框架Tensor时如果统一转成float32某些对精度极敏感的位置会丢一点点数值。大家通常不会察觉但自回归模型对这种微小扰动非常敏感。我的处理方式是保留原始权重的dtype信息转换时不强行降精度。5. 推理优化与接口封装从Demo到可被调用的服务5.1 预处理和热启动把首延迟降下来模型跑通以后我们第一版服务的性能非常难看接口响应要十几秒其中模型推理只占一半剩下的时间全耗在图像解码、预处理、图编译这些“看不见的地方”。第一个优化是把图像预处理从Python层下沉到尽可能早的环节。比如把上传的图片直接解码成RGB字节流统一缩放到模型要求的输入尺寸编码成二进制缓存到内存接口层不再需要重复走一遍Pillow的读图逻辑。这一步在并发场景下收益巨大因为Pillow的Image.open加resize在高并发时非常耗时是整个服务里的隐形瓶颈。第二个优化是热启动。Graph模式下F框架第一次跑模型时会把整个计算图编译一次这个时间可能长达几十秒。如果不做处理线上第一个请求会被直接拖垮。我在服务启动完成后立刻用一张全黑小图跑一次完整推理让编译结果缓存到内存后续请求再进来就会快很多。def warm_up(model, input_size): dummy framework.ops.zeros((1, 3, input_size, input_size)) _ model.generate(dummy, max_new_tokens8) print(warm up done)这个预热代码虽然很短但它是线上服务上线前必须执行的一步不做的话运维同事的第一轮压测就会在你这儿炸掉。5.2 静态batch与并发控制DeepOCR是自回归模型batch内每条样本的长度可能不一致如果一条样本已经生成结束但还在等待其他样本计算资源就会被浪费。最省事的做法是固定batch size1每次请求单独跑一条样本吞吐不一定最优但实现最简单、最稳定。如果并发量再往上走我推荐的方式是“请求排队单实例批量推理”。也就是说把多个请求攒起来凑成一个batch在框架内部按照max_new_tokens512补齐长度由掩码器控制每一条样本何时结束。这样在一次推理周期内同时处理多条请求加速卡的利用率会明显提升。但这里有一个权衡动态batch也会引入额外的padding开销如果大部分请求的实际序列都很短而你把它们全部pad到512那浪费的时间可能比收益还大。我的经验值是只有当平均并发数稳定在3以上时才值得做动态batch否则单请求推理更划算。5.3 HTTP接口的输入输出设计与结果解析最后一步是把模型包成HTTP服务。接口设计上我参考了常见视觉模型服务的实践支持两种输入方式直接上传图片文件传图片的base64编码字符串适合客户端不方便走multipart的场景。返回结果采用JSON结构包含识别出的Markdown文本、耗时和置信度信息from fastapi import FastAPI, UploadFile import base64 import time import json app FastAPI() app.post(/ocr) async def ocr_endpoint(file: UploadFile None, base64_image: str None): if file is not None: image_bytes await file.read() elif base64_image: image_bytes base64.b64decode(base64_image) else: return {error: no image provided} start time.time() result_text model.generate_from_bytes(image_bytes) elapsed_ms (time.time() - start) * 1000 return { text: result_text, elapsed_ms: round(elapsed_ms, 2), status: ok, }模型输出的原始token序列里会包含一些语言模型控制符比如表示图像开始、文本结束的特殊token。封装接口时需要在后处理里过滤掉这些控制符只保留真正的Markdown内容。这个过滤逻辑别写在模型生成函数里放在服务层单独做比较干净万一以后要换输出格式不用动模型代码。还有一个小细节接口层返回的文本统一走UTF-8编码老项目里遇到GBK乱码的概率很高这类低级问题在大模型OCR服务里非常扫兴。整个项目做下来我最大的体会是跨框架复现一个现代OCR模型最花时间的不是“跑不起来”而是“跑起来但结果不对”。任何一个算子语义的细微差异都会像滚雪球一样在自回归链路上越滚越大。另一方面的收获是在F框架的静态图约束下我被迫真正理解了模型前向里每一个张量的shape变化过程而不仅仅是“给进去、算出来”。如果你也要做类似的事情我强烈建议一开始就把调试日志体系建起来在视觉编码器输出、投影器输出、解码器每个关键节点都打印一次shape和均值。等项目做完你就会发现这些日志才是你排查所有问题最趁手的工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑