Donut微调实战:50张收据实现高鲁棒性Receipt信息抽取
1. 项目概述为什么 receipt 信息抽取值得专门花时间调 Donut最近帮一家本地连锁餐饮做数字化报销系统核心卡点不是 OCR 识别文字而是“识别完之后怎么把‘金额’‘日期’‘商户名’‘商品明细’这些字段从一堆杂乱文本里精准拎出来”。试过传统 OCR正则也跑过 LayoutLMv3结果都不理想——前者对拍照角度、阴影、手写批注完全没招后者在小样本下泛化差训练一次要 8 小时准确率还卡在 72% 上下。直到我盯上 DonutDocument Understanding Transformer才真正把这个问题从“勉强能用”推进到“交付给财务部敢直接上线”的阶段。Donut 不是 OCR 模型它压根不输出字符级 bounding box它是个端到端的文档理解模型输入一张收据图直接输出结构化 JSON比如{total: ¥128.00, date: 2025-04-12, store_name: XX便利店}。它的底层逻辑很聪明把整张图当“图像 token”喂进 Vision Transformer 编码器再用自回归解码器生成文本序列相当于让模型自己学会“看图说话”。这种设计天然规避了 OCR 的中间误差放大问题——OCR 错一个字后面所有字段都可能崩而 Donut 是整体理解容错率高得多。但直接拿 Hugging Face 上的预训练权重跑 inference效果会打五折。官方在 PubLayNet 和 DocVQA 上训的模型见过的全是学术论文 PDF、表格、法律文书对超市小票、外卖单、加油站小条这种带大量手写、模糊、倾斜、反光的消费凭证根本没学过“该关注什么”。这就必须 fine-tune。可问题来了网上教程要么只讲怎么跑 demo要么堆满 PyTorch 底层代码连 dataset 格式都没说清更现实的是很多人和我一样本地只有 CPU 或低配显卡Colab 免费版显存又常被抢光。这篇笔记就是把我踩了三周坑、重装七次环境、反复对比五个数据集构造方案后整理出的一套“能真正在小团队落地”的 Donut 微调实操路径。不讲大道理只说你打开终端后第一行该敲什么、参数为什么设成那个值、报错时看哪一行日志、以及最关键的——如何用不到 50 张真实收据让模型在测试集上达到 89.3% 的字段级 F1。2. 整体设计思路与关键决策依据2.1 为什么选 Donut 而非 LayoutLM / PaddleOCR / TableTransformer先说结论如果你的任务是“从消费类图像中抽固定字段”Donut 是当前开源方案里综合性价比最高的选择。这不是拍脑袋而是基于三组实测数据的权衡模型训练耗时RTX 309050 张样本微调后 F1部署内存占用对模糊/手写的鲁棒性数据标注成本LayoutLMv3-base6.2 小时76.1%3.2GB中等依赖 OCR 输入质量高需标注每个 token 的 bbox labelPaddleOCR 规则引擎0.3 小时68.5%1.8GB差OCR 失败即全盘崩溃低仅需文本TableTransformer4.7 小时71.9%2.9GB弱假设结构为表格中需标注 table cellDonut-base1.8 小时89.3%2.1GB强端到端视觉理解中仅需 JSON 标注关键差异在数据准备环节。LayoutLM 要求你提供每张图的 OCR 结果text bbox这意味着你得先跑一遍 OCR再人工校对 bbox 是否框准了“金额”二字——50 张图光校对就耗掉我两天。而 Donut 只需要你提供原始图片 对应的 JSON 标签比如{total: ¥128.00, items: [{name: 可乐, price: ¥5.00}]}。这个 JSON 怎么来我用的是半自动流程先用 PaddleOCR 快速提取所有文本再写个轻量脚本按关键词如“合计”、“TOTAL”、“”粗筛候选行最后人工确认并补全缺失字段。50 张图标注总耗时 3.5 小时效率提升近 3 倍。另一个常被忽略的点是推理延迟。Donut 的解码是自回归的生成一个 JSON 需要 12-15 步 token 预测。但实测发现只要把max_length从默认的 512 降到 128我们收据 JSON 最长也就 80 个 token单图推理时间从 2.1 秒压到 0.8 秒且不影响召回率——因为模型学到的是“模式”不是“背诵”。2.2 为什么放弃本地训练坚定选择 Colab Pro坦白说我最初坚持在本地 i7-10750H RTX 2060 笔记本上跑结果是CUDA out of memory报错 17 次OOM when allocating tensor日志刷屏。根本原因在于 Donut 的 ViT 编码器对显存胃口极大。查了 Hugging Face 官方 issue社区共识是Donut-base 在 batch_size1 时最低需 10GB 显存FP16。我的 2060 只有 6GB硬刚等于自杀。Colab 免费版看似有 T416GB但实际分配常缩水到 12GB且训练中途会因资源调度断连。我试过三次最长一次跑了 47 分钟后 kernel crash所有 checkpoint 彻底丢失。后来升级 Colab Pro$19.99/月稳定获得 A10040GB这才真正进入“可调试”状态。这里有个血泪经验别信 Colab 界面显示的 GPU 型号每次启动后务必运行nvidia-smi确认。我有次明明开了 Pro却分到 V100nvidia-smi一查显存只有 16GB立刻Runtime → Disconnect and delete runtime重新连接直到看到 A100 才开始下一步。至于为什么不选 AWS SageMaker 或 RunPod成本是硬约束。A100 小时费 $1.2按我 1.8 小时训练算单次 $2.16而 Colab Pro 月费摊到每天不到 $0.7且支持随时中断保存对迭代频繁的小项目更友好。2.3 数据集构造50 张图如何撑起一个可用模型很多人以为微调 AI 模型必须海量数据其实对 Donut 这类视觉语言模型高质量小样本更关键。我的 50 张图来源严格遵循“三三制”30 张真实收据覆盖 8 类场景超市小票、外卖单、加油站、停车场、奶茶店、药店、快递单、医院缴费单每类至少 3 张确保光照、角度、清晰度差异15 张增强图对 15 张原始图做针对性增强——不是盲目加噪而是模拟真实痛点用 OpenCV 的cv2.warpPerspective加 5°-15° 透视畸变模拟手机歪着拍用cv2.GaussianBlur加 3x3 模糊模拟对焦不准用PIL.ImageEnhance.Contrast降低对比度至 0.6模拟屏幕反光5 张“坏样本”故意收录 5 张极端案例——严重污损咖啡渍盖住金额、多张叠放两张小票重叠、纯手写无印刷体、超长商品列表滚动截屏拼接、二维码遮挡关键字段。这些不参与训练专用于验证模型鲁棒性。JSON 标注格式我强制统一为扁平化结构拒绝嵌套{ total: ¥128.00, date: 2025-04-12, store_name: XX便利店, items_count: 5, payment_method: 微信支付 }理由很实在Donut 解码器生成嵌套 JSON 时容易出错比如把items: [{name:可乐}]生成成items: [{name:可乐}]字符串而非数组。扁平化后所有字段都是同级 key模型更容易学稳。3. 核心细节解析与实操要点3.1 环境配置避开版本地狱的终极方案Donut 对依赖版本极其敏感尤其transformers、datasets、torch三者必须精确匹配。我踩过的最大坑是按 Hugging Face 文档装transformers4.36.0结果DonutProcessor初始化时报AttributeError: DonutProcessor object has no attribute feature_extractor。查源码才发现4.36.0 的 DonutProcessor 已废弃feature_extractor改用image_processor但示例代码没同步更新。最终锁定的黄金组合经 7 次重装验证pip install torch2.0.1cu118 torchvision0.15.2cu118 --extra-index-url https://download.pytorch.org/whl/cu118 pip install transformers4.30.2 datasets2.14.6 sentencepiece0.1.99 pip install githttps://github.com/clovaai/donut.gitv1.1.0提示githttps://github.com/clovaai/donut.gitv1.1.0这行必须执行不能只 pip install donut。官方 PyPI 包是旧版缺少DonutModelForConditionalGeneration的最新 fix。Colab 启动后第一件事不是加载数据而是运行import torch, transformers, datasets print(fPyTorch: {torch.__version__}) print(fTransformers: {transformers.__version__}) print(fDatasets: {datasets.__version__})三者版本必须与上述完全一致。任何偏差立刻!pip uninstall -y torch transformers datasets然后重装。别试图“差不多就行”Donut 的报错日志不会告诉你版本冲突只会抛出莫名其妙的KeyError或NoneType。3.2 数据集构建从文件夹到 DatasetDict 的完整链路Donut 要求数据集是DatasetDict格式包含train和validation两个 split。很多人卡在这步因为官方教程只给伪代码。下面是我生产环境用的完整脚本已封装为函数复制即用from datasets import Dataset, DatasetDict import json import os from PIL import Image import numpy as np def build_donut_dataset(image_dir, json_dir, train_ratio0.8): 构建 Donut 兼容的 DatasetDict image_dir: 图片文件夹路径 (e.g., ./data/images/) json_dir: JSON 标签文件夹路径 (e.g., ./data/jsons/) train_ratio: 训练集占比默认 0.8 # 收集所有图片文件名不含扩展名 image_files [f for f in os.listdir(image_dir) if f.lower().endswith((.png, .jpg, .jpeg))] image_names [os.path.splitext(f)[0] for f in image_files] # 确保每个图片都有对应 JSON missing_jsons [] for name in image_names: if not os.path.exists(os.path.join(json_dir, f{name}.json)): missing_jsons.append(name) if missing_jsons: raise ValueError(fMissing JSON for images: {missing_jsons}) # 划分训练/验证集固定随机种子保证可复现 np.random.seed(42) indices np.random.permutation(len(image_names)) train_end int(len(indices) * train_ratio) train_indices indices[:train_end] val_indices indices[train_end:] def load_sample(idx): name image_names[idx] # 加载图片转为 RGBDonut 要求三通道 img_path os.path.join(image_dir, f{name}.jpg) if not os.path.exists(img_path): img_path os.path.join(image_dir, f{name}.png) image Image.open(img_path).convert(RGB) # 加载 JSON 标签 with open(os.path.join(json_dir, f{name}.json), r, encodingutf-8) as f: label json.load(f) # Donut 要求标签是字符串化的 JSON target_json_str json.dumps(label, ensure_asciiFalse) return { image: image, target_json: target_json_str } # 构建 train 和 validation Dataset train_data [load_sample(i) for i in train_indices] val_data [load_sample(i) for i in val_indices] train_dataset Dataset.from_list(train_data) val_dataset Dataset.from_list(val_data) return DatasetDict({train: train_dataset, validation: val_dataset}) # 使用示例 dataset build_donut_dataset( image_dir./data/images/, json_dir./data/jsons/, train_ratio0.8 ) print(fTrain samples: {len(dataset[train])}, Val samples: {len(dataset[validation])})注意image.convert(RGB)这行绝不能省。我有 3 张 PNG 是 RGBA 模式带透明通道不转换会导致ValueError: Expected 3 channels, got 4。ensure_asciiFalse也是关键否则中文会变成\u4f60\u597d模型无法学习语义。3.3 模型加载与处理器初始化别被 deprecated 警告吓住加载 Donut 模型时你会看到满屏FutureWarning: TheDonutProcessorclass is deprecated。别慌这是 Hugging Face 的锅——他们想推新 API但 Donut 官方代码还没适配。正确做法是忽略警告用旧 APIfrom transformers import DonutProcessor, DonutModel import torch processor DonutProcessor.from_pretrained(naver-clova-ix/donut-base-finetuned-docvqa) model DonutModel.from_pretrained(naver-clova-ix/donut-base-finetuned-docvqa) # 关键冻结 ViT 编码器只微调解码器 for param in model.encoder.parameters(): param.requires_grad False # 验证冻结是否生效 print(fEncoder params requires_grad: {next(model.encoder.parameters()).requires_grad}) # 应为 False为什么冻结编码器因为 ViT 在 ImageNet 上预训练过特征提取能力已足够强而解码器是文本生成部分对收据领域知识零认知必须重训。实测表明冻结编码器后训练速度提升 2.3 倍显存占用从 10.2GB 降到 6.8GB且最终 F1 只下降 0.4%完全值得。4. 实操过程与核心环节实现4.1 训练脚本详解从零开始的每一行代码以下是我最终稳定运行的训练脚本train_donut.py已去除所有冗余只保留核心逻辑。重点看注释里的参数依据import argparse import torch from datasets import load_from_disk from transformers import ( DonutProcessor, DonutModel, Seq2SeqTrainingArguments, Seq2SeqTrainer, default_data_collator ) from torch.utils.data import DataLoader import numpy as np def main(): parser argparse.ArgumentParser() parser.add_argument(--dataset_path, typestr, requiredTrue, helpPath to saved DatasetDict (e.g., ./data/dataset_cache)) parser.add_argument(--output_dir, typestr, requiredTrue, helpOutput directory for checkpoints) parser.add_argument(--num_train_epochs, typeint, default10) parser.add_argument(--per_device_train_batch_size, typeint, default1) # Donut 内存大户batch_size1 是底线 parser.add_argument(--per_device_eval_batch_size, typeint, default1) parser.add_argument(--learning_rate, typefloat, default3e-5) # Donut 官方推荐 3e-5比 BERT 的 2e-5 略高因解码器更需调整 parser.add_argument(--warmup_steps, typeint, default0) # 小数据集无需 warmup直接全量学习率更稳 parser.add_argument(--weight_decay, typefloat, default0.01) parser.add_argument(--save_steps, typeint, default50) # 每 50 步保存一次防中断丢失 parser.add_argument(--eval_steps, typeint, default50) parser.add_argument(--logging_steps, typeint, default10) parser.add_argument(--fp16, actionstore_true, defaultTrue) # 必开 FP16显存减半速度翻倍 parser.add_argument(--report_to, typestr, defaultnone) # 关闭 wandb避免网络问题中断 args parser.parse_args() # 加载数据集 dataset load_from_disk(args.dataset_path) # 加载 processor 和 model processor DonutProcessor.from_pretrained(naver-clova-ix/donut-base-finetuned-docvqa) model DonutModel.from_pretrained(naver-clova-ix/donut-base-finetuned-docvqa) # 冻结 encoder for param in model.encoder.parameters(): param.requires_grad False # 数据预处理函数 def preprocess_examples(examples): pixel_values [] labels [] for image, target_json in zip(examples[image], examples[target_json]): # processor 处理图像 pixel_value processor(image, random_paddingTrue, return_tensorspt).pixel_values pixel_values.append(pixel_value.squeeze(0)) # 去掉 batch 维度 # 编码 target JSON 字符串 label processor.tokenizer( target_json, add_special_tokensTrue, max_length128, # 关键限制长度防 OOM paddingmax_length, truncationTrue, return_tensorspt ) labels.append(label.input_ids.squeeze(0)) # 转为 tensor pixel_values torch.stack(pixel_values) labels torch.stack(labels) return { pixel_values: pixel_values, labels: labels, } # 应用预处理 train_dataset dataset[train].map( preprocess_examples, batchedTrue, batch_size4, # 预处理 batch size非训练 batch remove_columns[image, target_json], num_proc2, descRunning tokenizer on train dataset ) eval_dataset dataset[validation].map( preprocess_examples, batchedTrue, batch_size4, remove_columns[image, target_json], num_proc2, descRunning tokenizer on validation dataset ) # 训练参数 training_args Seq2SeqTrainingArguments( output_dirargs.output_dir, num_train_epochsargs.num_train_epochs, per_device_train_batch_sizeargs.per_device_train_batch_size, per_device_eval_batch_sizeargs.per_device_eval_batch_size, learning_rateargs.learning_rate, warmup_stepsargs.warmup_steps, weight_decayargs.weight_decay, save_stepsargs.save_steps, eval_stepsargs.eval_steps, logging_stepsargs.logging_steps, fp16args.fp16, report_toargs.report_to, predict_with_generateTrue, # 必开否则不生成文本 generation_max_length128, # 与预处理一致 generation_num_beams1, # 贪心搜索快且稳小数据集不用 beam search load_best_model_at_endTrue, metric_for_best_modeleval_loss, greater_is_betterFalse, save_total_limit3, # 只留最近 3 个 checkpoint省空间 remove_unused_columnsFalse, dataloader_num_workers2, seed42, ) # Trainer trainer Seq2SeqTrainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, data_collatordefault_data_collator, tokenizerprocessor.tokenizer, ) # 开始训练 trainer.train() if __name__ __main__: main()运行命令python train_donut.py \ --dataset_path ./data/dataset_cache \ --output_dir ./checkpoints/donut-receipt-finetuned \ --num_train_epochs 10 \ --per_device_train_batch_size 1 \ --learning_rate 3e-5 \ --fp164.2 关键参数深度解析为什么是这些数字--per_device_train_batch_size 1Donut 的 ViT 编码器对显存需求呈平方级增长。A100 的 40GB 看似充裕但batch_size2时pixel_values占用显存达 28GB留给解码器的空间不足导致CUDA error: out of memory。batch_size1是唯一稳定选择。--learning_rate 3e-5Donut 官方在 DocVQA 微调中使用此值。我对比过 1e-5、2e-5、3e-5、5e-5 四组实验1e-5 收敛太慢10 epoch 后 loss 仍 2.55e-5 导致 early oscillationloss 在 1.8-2.3 间剧烈跳动3e-5 在第 6 epoch 达到最低 loss 1.42且验证 loss 稳定下降。--generation_max_length 128计算依据是我们最复杂的 JSON 标签含 10 个商品共 78 个 token用processor.tokenizer(..., return_lengthTrue)实测。设 128 是留 60% 余量既防截断又避免解码器无意义生成空格或pad。--generation_num_beams 1Beam search束搜索虽能提升生成质量但会显著增加解码时间。实测num_beams3时单图推理从 0.8 秒升至 2.3 秒而 F1 仅提升 0.2%。对报销系统这种追求吞吐量的场景贪心搜索num_beams1是理性选择。4.3 推理与评估如何验证你的模型真的“会干活”训练完只是开始关键是验证它能否在真实场景中稳定输出。我写了极简的推理脚本重点看三件事格式合法性、字段完整性、数值准确性。from transformers import DonutProcessor, DonutModel import torch from PIL import Image import json def infer_single_image(model_path, image_path, processor_pathnaver-clova-ix/donut-base-finetuned-docvqa): processor DonutProcessor.from_pretrained(processor_path) model DonutModel.from_pretrained(model_path) model.eval() image Image.open(image_path).convert(RGB) # 预处理 pixel_values processor(image, return_tensorspt).pixel_values # 生成 task_prompt s_rvlcdip # Donut 的任务前缀receipt 用 rvlcdip文档分类数据集代号 decoder_input_ids processor.tokenizer( task_prompt, add_special_tokensFalse, return_tensorspt ).input_ids outputs model.generate( pixel_valuespixel_values, decoder_input_idsdecoder_input_ids, max_lengthmodel.decoder.config.max_position_embeddings, early_stoppingTrue, pad_token_idprocessor.tokenizer.pad_token_id, eos_token_idprocessor.tokenizer.eos_token_id, use_cacheTrue, num_beams1, bad_words_ids[[processor.tokenizer.unk_token_id]], # 禁止生成 unk return_dict_in_generateTrue, ) # 解码 seq outputs.sequences[0].cpu().numpy() decoded processor.tokenizer.decode(seq, skip_special_tokensTrue) try: # 尝试解析为 JSON result json.loads(decoded) print(✅ 成功解析 JSON:) print(json.dumps(result, indent2, ensure_asciiFalse)) return result except json.JSONDecodeError as e: print(❌ JSON 解析失败:, str(e)) print(Raw output:, decoded) return None # 使用示例 result infer_single_image( model_path./checkpoints/donut-receipt-finetuned/checkpoint-500, # 选最佳 checkpoint image_path./data/images/test_receipt_01.jpg )评估时我手动检查 20 张测试图统计三个维度格式正确率输出能否被json.loads()解析目标 ≥95%字段召回率所有标注字段中模型输出了多少如标注了 total/date/store_name模型输出了其中几个数值准确率输出的数值与真实值完全一致的比例如 total¥128.00 vs ¥128。最终结果格式正确率 98%字段召回率 91.2%数值准确率 87.6%。未达标的 3 张图全是“手写金额被涂改”的极端案例——这已超出当前技术边界属于业务侧需加人工复核的环节。5. 常见问题与排查技巧实录5.1 典型报错与秒级解决方案我把训练过程中遇到的所有报错按出现频率排序并给出定位方法和修复命令。这不是理论是我在 Colab 里截图、复现、解决后的真实记录。报错信息精简出现场景根本原因定位方法修复命令CUDA out of memorytrainer.train()启动时batch_size过大或max_length过长运行nvidia-smi查显存占用若 38GB 则必超--per_device_train_batch_size 1 --generation_max_length 128ValueError: Expected 3 channels, got 4preprocess_examples中processor(image)报错PNG 图像含 Alpha 通道4 通道from PIL import Image; img Image.open(path); print(img.mode)在preprocess_examples中加image.convert(RGB)AttributeError: DonutProcessor object has no attribute feature_extractorprocessor DonutProcessor.from_pretrained(...)时transformers版本过高4.31print(transformers.__version__)pip install transformers4.30.2KeyError: input_idstrainer.train()中 data collator 报错自定义preprocess_examples未返回input_ids字段检查返回字典 keysprint(list(train_dataset.features.keys()))确保返回字典含pixel_values和labels不是input_idsRuntimeError: expected scalar type Half but found Floatfp16True时模型 forward 报错某些 layer 未正确 cast 到 FP16在trainer.train()前加model.half()删除--fp16参数用--fp16_full_eval替代提示nvidia-smi是你的第一道防线。每次报错前先运行它。如果显存占用已达 95%那 90% 的问题都源于内存不足别浪费时间查代码逻辑。5.2 “模型不学习”的五大征兆与急救包训练 loss 不降是比报错更折磨人的事。以下是我在 10 次失败训练中总结的“不学习”征兆及对应急救措施征兆loss 在 5.0-6.0 间横盘超过 3 epoch→急救检查preprocess_examples中label.input_ids是否全为0padding。实测发现若max_length128但 JSON 很短如只有{total:¥10}input_ids会被大量0填充模型学不到有效信号。修复将paddingdo_not_pad并在data_collator中动态 pad。征兆train loss 下降val loss 持续上升过拟合→急救立即启用--weight_decay 0.01并--save_total_limit 1删掉所有 checkpoint从头训。Donut 小样本过拟合极快早停是唯一解。征兆loss 剧烈震荡如 2.1→4.3→1.8→5.0→急救--learning_rate降为1e-5--warmup_steps 100。震荡说明学习率太大模型在最优解附近疯狂跳跃。征兆所有 epoch 的 loss 都是 nan→急救--fp16 False关闭混合精度。nan 通常源于 FP16 下梯度爆炸关掉后若正常则需在Trainer中加梯度裁剪--max_grad_norm 1.0。征兆loss 从 5.0 降到 1.5 后连续 5 epoch 不动→急救不是模型卡住是它已学到极限。此时--num_train_epochs加 5但--learning_rate乘 0.5如1.5e-5用更小步伐微调。5.3 生产部署避坑指南从 checkpoint 到 API 的最后一公里训练完的模型不能只躺在 checkpoint 文件夹里。我用 FastAPI 封装了一个轻量 API供公司内部系统调用。这里分享三个血泪教训教训 1不要用model.save_pretrained()直接保存Donut 的save_pretrained()会保存整个模型含冻结的 encoder体积达 1.2GB。而实际推理只需解码器权重200MB。正确做法只保存解码器model.decoder.save_pretrained(./decoder_only)加载时model.decoder DonutDecoder.from_pretrained(./decoder_only)。教训 2processor的random_paddingTrue在推理时必须关训练时开random_padding是为了增强鲁棒性但推理时它会让同一张图每次生成不同 pixel_values导致结果不稳定。修复在推理脚本中processor(image, random_paddingFalse)。教训 3API 响应超时不是模型慢是图片预处理阻塞我最初把Image.open()放在 FastAPI 的POST路由里结果并发 3 请求就超时。根本解法用concurrent.futures.ThreadPoolExecutor异步加载图片或提前将图片 decode 为 numpy array 缓存。FastAPI 示例核心代码from fastapi import FastAPI, UploadFile, File from PIL import Image import io import torch app FastAPI() # 预加载模型和 processor启动时执行 processor DonutProcessor.from_pretrained(./processor) model DonutModel.from_pretrained(./checkpoints/final) model.eval() app.post(/extract) async def extract_receipt(file: UploadFile File(...)): # 异步读取图片 contents await file.read() image Image.open(io.BytesIO(contents)).convert(RGB) # 预处理同步但极快 pixel_values processor(image, random_paddingFalse, return_tensorspt).pixel_values # 推理GPU with torch.no_grad(): outputs model.generate(...) return {result: json.loads(decoded)}6. 实操心得与延伸思考我在交付这个收据抽取模块后和财务部同事做了三次 walkthrough观察他们真实的使用场景。最大的收获不是技术参数而是两个反常识的发现第一准确率不是越高越好。当模型把“¥128.00”识别成“¥128”时财务系统能自动补零但当它把“¥128.00”识别成“¥12800”漏了小数点系统会直接拒收触发人工介入。所以我在后处理加了一条硬规则所有total字段若长度 6 且不含.则自动插入小数点12800→128.00

相关新闻