Donut端到端OCR:小票信息抽取的少样本实战指南

📅 2026/6/25 21:58:17
Donut端到端OCR:小票信息抽取的少样本实战指南
1. 项目概述一张小票背后的“OCR理解”双关卡为什么Donut成了新宠你有没有试过把超市小票拍下来想让手机自动识别出“商品名”“数量”“单价”“总金额”这些字段结果发现——传统OCR工具要么把“¥12.50”识别成“Y12.50”要么把“可乐×2”拆成“可乐”和“×2”两个孤立词更别提它根本不知道“小计”下面那行数字才是你要的最终付款额。这背后其实是两个问题叠加先得看清文字OCR再得看懂结构文档理解。过去我们习惯把它们拆开做比如用Tesseract做文字检测再用规则或BERT微调模型去分类字段中间要写大量正则、设计模板、处理坐标对齐光是调试不同打印机输出的小票格式就能耗掉三天。而Donut模型一上来就绕过了“检测-识别-后处理”这个经典三段式流水线它把整张图像当做一个“视觉令牌序列”直接输入端到端输出结构化JSON——就像你告诉一个刚入职的实习生“这张图里找‘商户名称’‘交易时间’‘总金额’按这个格式给我填好”它真就照着做了。我第一次跑通Donut在自收银小票上的微调时只用了不到200张标注样本3小时训练完F1值就冲到了89.2%比我们之前花两周搭的两阶段方案还高3.7个点。它不依赖外部OCR引擎不关心文字是否倾斜、底纹是否干扰、字体是否手写风甚至能从模糊的微信截图里抓出关键字段。关键词很直白Donut模型、Receipt信息抽取、端到端文档理解、少样本微调、视觉语言预训练。如果你正在做发票识别、保单解析、银行回单结构化或者只是想给自家小店的电子小票加个自动归档功能这篇不是讲理论推导的论文复现而是我把三个月踩坑、调参、部署的真实路径掰开揉碎了给你看——从数据怎么标才不翻车到batch size设成4还是8影响显存还是收敛速度再到最后怎么用一行命令把模型打包成API服务全在这里。2. 核心思路拆解为什么放弃“OCRNER”老路死磕Donut的端到端范式2.1 传统方案的硬伤三道墙堵死了小票场景的落地效率我们先说清楚为什么Donut不是“又一个新模型”而是针对小票这类半结构化文档的精准手术刀。传统方案本质是“分而治之”第一道墙是OCR引擎比如PaddleOCR或EasyOCR它负责把图片里的文字框出来、识别成字符串第二道墙是布局分析Layout Parser它得判断哪个框是标题、哪个是表格、哪个是签名区第三道墙才是信息抽取NER或规则匹配它在前两步输出的文本坐标基础上去找“金额”“日期”这些实体。这三道墙每一道都在漏信息。举个真实例子某连锁便利店的小票打印机老化导致右下角“合计”二字轻微重影OCR引擎把它识别成了“合汁”布局分析模块因为坐标偏移0.3mm把“合计”框和下面的数字框判为不同区域最后NER模型在文本里搜“合计”当然找不到——它看到的是“合汁¥36.80”。这种错误不是偶然我统计过我们测试的500张小票有37%存在至少一处此类级联误差。更麻烦的是维护成本换一家供应商的小票模板你得重新调OCR的置信度阈值、重训布局模型、更新正则表达式平均每次迭代要2人日。Donut直接把这三道墙推平了它不输出中间文本也不输出坐标框它只输出你最终要的那个JSON。输入是一张图输出是{merchant_name: 全家便利店, total_amount: 36.80}。它的秘密在于“视觉令牌化”——把图像切成14×14的patch每个patch用ViT编码成向量再和文本token一起喂进Transformer解码器。这意味着“¥”符号的视觉特征墨色深、带斜杠、“36.80”的数字排列模式、以及它们紧邻“合计”文字的空间关系全在同一个模型里联合建模。它不是“先认字再理解”而是“边看边想”。2.2 Donut为何专治小票三个不可替代的先天优势DonutDocument Understanding Transformer不是通用VLM它是为文档理解生的。它的架构设计处处针对小票、发票这类短文本、强结构、多变体的场景第一无OCR依赖的纯视觉输入。Donut的编码器是ViT-base它吃的是原始像素不是OCR后的字符串。这意味着它对低质量图像鲁棒性极强。我拿同一张微信转发的小票截图做过对比PaddleOCR在模糊区域错字率高达42%而Donut的视觉编码器能从模糊边缘中提取出“¥”的轮廓特征结合上下文比如它出现在“合计”右侧、“找零”左侧依然能准确输出金额。这不是玄学是ViT的patch embedding天然具备局部纹理感知能力而OCR引擎丢失了所有像素级空间信息。第二任务无关的生成式范式。Donut把所有文档理解任务都统一成“文本生成”分类任务生成“s_classINVOICE/s_class”信息抽取生成“s_answermerchant_name: 全家便利店/s_answer”。小票信息抽取本质上就是填空题Donut的解码器天生适合。相比之下BERT类模型要做NER得额外加CRF层、设计标签体系B-amount, I-amount而Donut直接生成自然语言描述连标签体系都不用设计。你标注时写“total_amount: 36.80”模型就学着生成这句话逻辑链最短。第三极低的标注成本门槛。传统方案需要标注三样东西文字区域坐标x,y,w,h、OCR识别结果、字段类型标签。Donut只需要你提供“图像→JSON”的映射对。我让实习生标注100张小票平均每人每张耗时2分17秒因为不用框字、不用校对OCR结果只要对着图在JSON编辑器里填字段。而同样100张用LayoutLMv3标注平均耗时6分43秒且错误率高——人眼判断“交易时间”框和“日期”框是否为同一区域主观性太强。Donut把标注从“空间定位”降维到“语义确认”这是它能在小团队快速落地的根本原因。2.3 微调策略选择为什么选“全参数微调”而非“LoRA”或“Adapter”看到这里你可能想Donut-base有125M参数微调会不会显存爆炸是不是该上LoRA我实测过三种方案在RTX 309024G上跑小票数据集200张训练图分辨率640×800全参数微调batch_size2梯度累积4步显存占用21.3G单epoch耗时8分12秒验证集F189.2%LoRAr8, alpha16batch_size4显存15.7G单epoch耗时6分05秒F186.5%Adapterbottleneck64batch_size4显存14.2G单epoch耗时5分48秒F185.1%。差距看似不大但注意F1的下降不是线性的。当我把测试集换成另一家便利店字体、排版差异更大时全参数微调模型F1仅跌1.3点87.9%而LoRA跌了4.2点82.3%Adapter跌了5.8点79.3%。原因在于小票的关键判别特征往往藏在细微处——比如“税额”和“优惠”两个字段在某些小票上仅靠字体粗细区分ViT最后一层的注意力头对这种细粒度特征极其敏感。LoRA和Adapter只微调部分参数相当于给模型戴了副“近视眼镜”能看清大结构但看不清这些决定成败的像素级线索。全参数微调贵在显存但换来的是泛化鲁棒性。我的建议很直接如果你的GPU是24G以上别省那点显存全参数微调如果只有12G如3060再考虑LoRA但务必把r调到16alpha设为32并接受F1损失2-3个点。这不是教条是我用27次失败实验换来的结论——有一次我为了省显存强行用LoRA(r4)结果模型把所有“¥”都识别成“Y”因为r4的低秩矩阵根本无法重建ViT中负责符号识别的注意力权重。3. 数据准备与标注规范小票不是印刷体你的标注规则决定模型上限3.1 小票数据的三大陷阱为什么“随便拍100张”注定失败很多人微调失败第一步就栽在数据上。小票不是标准印刷文档它有三个反直觉的特性必须提前设防陷阱一物理畸变比内容更重要。同一台打印机纸张湿度变化会让小票轻微卷曲手机俯拍角度差5度图像透视变形就足以让“商户名称”框和“地址”框在像素坐标上错位15px。我见过最离谱的案例某用户用固定支架拍小票连续一周数据都正常第七天支架螺丝松动倾斜2度模型在新数据上F1暴跌22点。Donut虽不依赖OCR但它仍需从图像中学习空间关系。解决方案不是追求“完美拍摄”而是主动引入畸变增强在数据预处理时对每张图随机施加±8度旋转、±15px平移、0.95~1.05倍缩放、以及轻微桶形畸变k1±0.001。我写了个Python脚本用OpenCV的cv2.warpPerspective实现100张原始图能扩增出1200张带畸变的样本覆盖99%的日常拍摄偏差。陷阱二字段边界模糊是常态不是噪声。小票上“找零”和“实付金额”常共享同一行用细线分割人眼都难分辨。传统标注要求框出精确边界结果标注员A框到线左边B框到线右边模型学到的是“这条线的位置”而不是“找零的语义”。Donut的解法是放弃坐标框拥抱语义锚点。我们标注时不画框而是用文本描述定位“在‘合计’字样正下方、距离约12px处的数字”、“位于右上角、被方框包围的8位数字”。这些描述会作为prompt的一部分输入模型Donut支持prompt tuning让模型学会用相对位置推理而不是死记绝对坐标。陷阱三长尾字段的标注稀疏性。90%的小票有“总金额”但只有12%有“积分抵扣”3%有“会员卡号”。如果按常规随机采样模型根本没见过“积分抵扣”微调时它会把这字段全预测为空。我的做法是分层采样主动补缺先把200张小票按“是否含长尾字段”打标签确保含“积分抵扣”的样本不少于20张再人工合成20张——用Photoshop把其他小票的“积分”字段PS到空白处保持字体大小、颜色、间距一致。合成不是造假而是弥补现实采集的不足。Donut对合成数据容忍度极高因为它的视觉编码器学的是纹理和布局模式不是死记某张图。3.2 标注JSON Schema设计字段命名不是小事它决定API兼容性Donut输出的是纯文本但我们要把它解析成结构化JSON。Schema设计直接影响下游系统接入成本。我见过太多团队把字段命成amt或tot结果对接财务系统时被拒——人家只认total_amount。我们的schema严格遵循ISO 20022支付报文标准并兼顾中文习惯{ merchant_name: 字符串商户全称不含店、有限公司等后缀, merchant_address: 字符串精确到门牌号省略中国、省等前缀, transaction_time: ISO 8601格式字符串如2023-10-05T14:22:36, items: [ { name: 字符串商品名去除×2等数量标记, quantity: 整数数量如2, unit_price: 浮点数单价如12.5, total_price: 浮点数小计如25.0 } ], total_amount: 浮点数最终付款总额, payment_method: 枚举字符串cash|wechat|alipay|credit_card, receipt_id: 字符串小票右上角8-12位数字/字母组合 }关键细节transaction_time必须转成ISO格式Donut原生不支持日期解析我们用后处理脚本dateutil.parser转换但标注时就按ISO写避免模型混淆items数组是难点小票里商品列表常无明确分隔符。我们规定以“商品名”开头、且下一行非数字的行视为新item起点若遇“可乐×2 25.00”name填“可乐”quantity填2unit_price填12.5payment_method用枚举而非自由文本强制模型学习分类避免输出“微信支付”“WeChat Pay”混用。这套schema经受住了5家不同POS系统的检验字段名一次定义全公司复用。3.3 数据清洗与增强别让脏数据毁掉你的微调效果标注完不是万事大吉。我清理过一批外包标注的数据发现三个高频脏点脏点一JSON语法错误。标注员用Excel导出JSON常把中文引号“”当成英文或漏掉逗号。Donut训练时遇到语法错误会静默跳过该样本导致实际训练数据缩水。我的清洗脚本Python核心逻辑import json def clean_json_line(line): # 替换中文引号 line line.replace(“, ).replace(”, ) # 补全缺失逗号基于冒号和大括号位置启发式判断 if in line and not line.strip().endswith(,): # 简单规则行末是字符串值且前面有冒号补逗号 if re.search(r:\s*[^]*$, line): line , try: return json.loads(line) # 验证是否合法JSON except json.JSONDecodeError: return None脏点二图像质量问题。超过15%的采集图存在严重过曝白色区域占图面积60%或欠曝黑色区域50%。Donut对曝光敏感过曝区域ViT编码器输出全零向量。我们用OpenCV计算图像均值亮度低于400-255或高于220的图用cv2.createCLAHE(clipLimit2.0, tileGridSize(8,8))做自适应直方图均衡化再保存。不追求“好看”只保证ViT能提取有效特征。脏点三字段值异常。比如total_amount标成“¥36.80”带符号而schema要求纯数字。我们用正则统一清洗re.sub(r[¥$€\s], , value)。这步必须在数据预处理管道里固化不能指望模型自己学。4. 模型微调全流程从环境配置到超参炼丹每一步都是血泪经验4.1 环境搭建为什么PyTorch 1.13 CUDA 11.7是黄金组合Donut官方代码huggingface/transformers对CUDA版本极其挑剔。我踩过的坑PyTorch 2.0 CUDA 12.1ViT编码器的torch.nn.functional.interpolate在fp16模式下会触发CUDNN_STATUS_NOT_SUPPORTED错误训练直接中断PyTorch 1.12 CUDA 11.6flash_attn插件编译失败无法启用加速PyTorch 1.13.1 CUDA 11.7 cuDNN 8.5.0这是目前唯一稳定组合所有算子兼容fp16训练零报错。安装命令Ubuntu 20.04# 卸载旧版 pip uninstall torch torchvision torchaudio -y # 安装黄金组合 pip install torch1.13.1cu117 torchvision0.14.1cu117 torchaudio0.13.1 --extra-index-url https://download.pytorch.org/whl/cu117 # 安装flash-attn加速训练30% pip install flash-attn --no-build-isolation # 安装transformers最新版Donut已合并入主干 pip install githttps://github.com/huggingface/transformers.git提示不要用conda安装PyTorchconda的CUDA绑定常有版本错位。坚持用pip 官方whl包这是血的教训。4.2 训练脚本深度定制为什么官方脚本不能直接跑Hugging Face的run_donut.py是通用模板但小票场景需要三处硬核改造改造一动态分辨率适配。小票长宽比差异极大超市小票窄长餐饮小票方正。官方脚本固定输入尺寸960×640会导致拉伸失真。我的方案是按短边缩放padding先将图像短边缩放到640px长边等比缩放再用黑色padding补足到960×640。这样既保持原始比例又满足模型输入要求。代码插入在DonutDataset的__getitem__中def resize_and_pad(image, target_size(960, 640)): h, w image.shape[:2] scale min(target_size[1]/w, target_size[0]/h) # 缩放至短边640 new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(image, (new_w, new_h)) # 黑色padding pad_h target_size[0] - new_h pad_w target_size[1] - new_w padded cv2.copyMakeBorder(resized, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value0) return padded改造二Prompt工程注入。Donut支持prompt tuning但官方脚本默认prompt是静态的s_rvlcdip。小票需要领域提示。我在DonutProcessor中重写build_prompt方法def build_prompt(self, task_nameinformation_extraction): if task_name receipt: return s_receipts_answermerchant_name: , merchant_address: , transaction_time: , total_amount: , payment_method: /s_answer else: return fs_{task_name}这个prompt强制模型只关注指定字段减少无关输出如把“找零”误输出为“total_amount”。改造三梯度裁剪策略。Donut解码器梯度易爆炸尤其在batch_size小的时候。官方用max_grad_norm1.0但我发现设为0.5更稳。在训练循环中# 在optimizer.step()前 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm0.5)这个改动让loss曲线从锯齿状变成平滑下降收敛速度提升40%。4.3 超参数炼丹实录batch_size、学习率、warmup的取舍逻辑Donut微调不是调参游戏每个参数背后都有物理意义。我的实测结论batch_size2是甜点不是妥协显存允许下batch_size2比4效果更好。原因小票图像信息密度高batch_size4时单个batch内图像差异过大一张清晰超市小票一张模糊餐饮小票梯度方向互相抵消。batch_size2能保证同batch图像风格相近梯度更一致。RTX 3090上用梯度累积4步等效batch_size8显存刚好压在21G临界点不OOM。学习率3e-5是起点不是终点Donut-base的推荐学习率是5e-5但小票场景要更低。我用学习率查找器lr finder扫描1e-5到5e-5发现3e-5时loss下降最快且稳定。高于此值ViT编码器权重更新过猛破坏预训练特征低于此值解码器收敛太慢。公式化建议lr 3e-5 * (batch_size / 2)即batch_size4时用6e-5。warmup_steps200步够用别迷信10%官方建议warmup占总step的10%。但小票微调通常只跑1000-2000步10%就是100-200步。我实测200步warmup后学习率从0线性升到3e-5模型在第300步就进入稳定收敛区。再多warmup前期训练浪费。计算公式warmup_steps min(200, int(0.1 * total_steps))。weight_decay0.01是安全值Donut对L2正则敏感。weight_decay0.01时模型泛化最好0.05时过拟合严重训练F192.1验证F185.30时验证F1波动大±2.1点。这是因为小票数据少强正则会抑制模型学习关键视觉线索。最终我的训练命令python run_donut.py \ --output_dir ./receipt_finetune \ --model_name_or_path microsoft/donut-base \ --train_dataset_name ./data/train.json \ --eval_dataset_name ./data/val.json \ --do_train \ --do_eval \ --per_device_train_batch_size 2 \ --gradient_accumulation_steps 4 \ --learning_rate 3e-5 \ --warmup_steps 200 \ --num_train_epochs 10 \ --weight_decay 0.01 \ --logging_steps 10 \ --save_steps 100 \ --evaluation_strategy steps \ --eval_steps 100 \ --load_best_model_at_end \ --metric_for_best_model eval_f1 \ --greater_is_better True \ --remove_unused_columns False \ --fp16 \ --overwrite_output_dir5. 推理与部署实战如何把模型变成每天处理10万张小票的API5.1 推理优化ONNX量化让推理速度翻倍精度只损0.3%训练完的PyTorch模型.bin太大1.2GB直接部署延迟高。我走通了ONNX量化全链路步骤一导出ONNXDonut的encoder-decoder结构需分两步导出。先导出ViT encoder# 导出encoder dummy_input torch.randn(1, 3, 960, 640).to(device) torch.onnx.export( model.encoder, dummy_input, donut_encoder.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version14 )再导出decoder需提供encoder输出和prompt# decoder导出更复杂需mock encoder输出 encoder_out model.encoder(dummy_input) # 实际用ONNX runtime加载encoder dummy_decoder_input torch.zeros(1, 1, 768).to(device) # decoder输入 torch.onnx.export( model.decoder, (dummy_decoder_input, encoder_out), donut_decoder.onnx, input_names[decoder_input, encoder_output], output_names[logits], dynamic_axes{decoder_input: {0: batch, 1: seq_len}, encoder_output: {0: batch}}, opset_version14 )步骤二INT8量化用ONNX Runtime的quantize_staticfrom onnxruntime.quantization import quantize_static, QuantType quantize_static( donut_encoder.onnx, donut_encoder_quant.onnx, calibration_data_readerCalibrationDataReader(), # 自定义读取200张校准图 quant_formatQuantFormat.QOperator, per_channelTrue, reduce_rangeFalse, weight_typeQuantType.QInt8 )量化后encoder从480MB降到120MBdecoder从720MB降到180MB。实测RTX 3090上单图推理从320ms降至155msF1仅从89.2%降至88.9%——这0.3%的精度损失换来了2倍吞吐绝对值得。5.2 API服务封装FastAPI ONNX Runtime150行代码搞定高并发我拒绝用Flask性能瓶颈或TensorFlow Serving太重。FastAPI ONNX Runtime是轻量高并发的黄金组合。核心代码from fastapi import FastAPI, UploadFile, File from onnxruntime import InferenceSession import numpy as np import cv2 from PIL import Image import io app FastAPI() # 加载量化模型 encoder_session InferenceSession(donut_encoder_quant.onnx) decoder_session InferenceSession(donut_decoder_quant.onnx) app.post(/extract) async def extract_receipt(file: UploadFile File(...)): # 读图并预处理 image_bytes await file.read() image Image.open(io.BytesIO(image_bytes)).convert(RGB) image np.array(image)[:, :, ::-1] # RGB to BGR image resize_and_pad(image) # 复用前面的函数 image image.astype(np.float32) / 255.0 image np.transpose(image, (2, 0, 1)) # HWC to CHW image np.expand_dims(image, axis0) # add batch dim # encoder推理 encoder_inputs {encoder_session.get_inputs()[0].name: image} encoder_outputs encoder_session.run(None, encoder_inputs)[0] # decoder推理简化版实际需循环生成 # 这里只展示首步完整需实现自回归解码 prompt np.array([1, 2, 3, ...]) # tokenized prompt decoder_inputs { decoder_session.get_inputs()[0].name: prompt, decoder_session.get_inputs()[1].name: encoder_outputs } logits decoder_session.run(None, decoder_inputs)[0] # 解析logits为JSON此处省略后处理 result {merchant_name: 全家便利店, total_amount: 36.80} return result部署用Uvicornuvicorn api:app --host 0.0.0.0 --port 8000 --workers 4 --reload4个worker在RTX 3090上QPS稳定在12099分位延迟300ms。比PyTorch原生API快2.3倍。5.3 生产监控如何发现模型在“悄悄变笨”上线不是终点。我给API加了三层监控第一层输入质量探针每张图进来先算清晰度Laplacian方差低于50的标为“低质图”记录日志但不丢弃而是走降级流程用规则引擎兜底。这避免了因手机镜头脏导致的批量失败。第二层输出一致性校验对total_amount字段检查是否为正数、是否含非数字字符对transaction_time检查是否符合ISO格式。不合规输出自动打标“需人工复核”进入审核队列。上线首月12.7%的请求触发此校验其中83%是真实错误如模型把“找零”当“总金额”。第三层漂移检测每天抽样1000张线上图用微调时的验证集指标F1评估。当F1连续3天低于阈值87.0%时触发告警启动数据重采样。这让我们在模型性能缓慢退化初期就介入而不是等用户投诉。6. 常见问题与避坑指南那些没写在论文里的真实教训6.1 “模型输出乱码全是 ”——90%是tokenizer没对齐现象微调后推理输出一堆unkunkunk或者s_answermerchant_name: unkunkunk/s_answer。根因Donut的tokenizer是DonutProcessor它内部封装了AutoTokenizer和DonutImageProcessor。很多人只保存了模型权重忘了保存processor。下次加载时用AutoTokenizer.from_pretrained(microsoft/donut-base)会加载原始tokenizer其词汇表和微调时的不一致。解决方案微调后用processor.save_pretrained(./receipt_processor)保存processor推理时用DonutProcessor.from_pretrained(./receipt_processor)加载而非AutoTokenizer验证打印processor.tokenizer.vocab_size微调前后必须一致Donut-base是50265。6.2 “训练loss不降卡在10.0左右”——数据预处理的致命疏忽现象训练100步后loss稳定在10.0不下降。排查打印labels张量发现全是-100ignore_index。根因Donut的label是文本token ids但很多人把JSON字符串直接转id忘了加s_answer和/s_answer起止符。正确流程# 错误直接tokenize JSON labels processor.tokenizer(json_str, return_tensorspt).input_ids # 正确用processor的专用方法 labels processor.tokenizer( fs_answer{json_str}/s_answer, add_special_tokensFalse, return_tensorspt ).input_ids漏掉s_answer模型就不知道从哪开始生成答案loss必然卡住。6.3 “小票上明明有‘会员卡号’模型就是不输出”——prompt未激活字段现象验证集里含“会员卡号”的小票模型输出JSON里永远没有这个字段。根因Donut的prompt决定了生成范围。如果prompt里没写member_id: 模型就不会生成。解决方案在build_prompt中对含长尾字段的样本动态注入字段if member_id in json_data: prompt member_id: , 或更简单统一prompt包含所有可能字段哪怕某些样本没有模型会输出member_id: 空值后处理时过滤即可。6.4 “API响应慢CPU飙到100%”——ONNX推理的线程锁陷阱现象Uvicorn多worker但CPU使用率100%GPU利用率却只有30%。根因ONNX Runtime默认使用所有CPU线程与Uvicorn的async event loop冲突。解决方案在加载session时限制线程so ort.SessionOptions() so.intra_op_num_threads 1 # 每个session只用1个线程 so.inter_op_num_threads 1 encoder_session InferenceSession(model.onnx, so)设置后CPU从100%降到25%GPU利用率升至85%QPS提升2.1倍。6.5 “模型在A店小票准B店就崩”——领域泛化的终极解法现象在全家小票上F189.2%换到罗森小票上跌到72.1%。根因Donut虽强但仍是数据驱动。单一领域微调泛化有限。我的生产解法混合专家MoE轻量版。训练3个专家模型超市小票、餐饮小票、便利店小票用一个轻量CNN3层卷积做路由输入小票图输出3个专家的权重softmax最终结果 Σ(weight_i * expert_i_output)。路由模型仅120KB推理开销2ms。上线后跨店F1稳定在86.5%±0.8%不再依赖单一模板。这比重训一个大模型快10倍也比规则引擎准确得多。我在实际