这篇写给两类人。 想了解「这件事对我们有什么价值」的读者,读每节开头带 📌 的段落就够了;想看工程细节的,往下读即可。全文所有数字都来自真实训练记录与评测报告,包括对我们不利的那些。

📌 先说结论

我们用 LoRA 微调了一个 8B 视觉语言模型(Qwen3-VL-8B-Instruct),让它把工程图纸里的零件明细表转成结构化 JSON。两轮训练(含第一轮规则错误的「学费」),算力按云端按需实例、以分钟计量,开销可控。

结果是:零件号、名称、规格、材料、数量五个字段,在合成留出集上从「基本读不对」变成「31 行全对」;描述文本的保真度从 0.51 提升到 0.9971,超过了未微调的基础模型。

但同一篇报告里也写着三件我们必须主动说出来的事:

  1. 第一轮的 train_loss 低到 0.018,看起来完美无缺,实际上模型学错了——根因是我们数据脚本里的一行逗号切分规则。
  2. 后来做数据审计时发现,训练集和留出集有 2 处字节级重复。因此上面那些漂亮数字已从「独立准确率」降级为「历史诊断」,不再用于对外宣传。
  3. 视觉塔是冻结的,模型只学了「怎么把图变成 JSON」,没有学「怎么看图」。

如果一个团队只给你看第一条,不给后两条,那份报告不值得信。


一、我们要解决的是什么问题

离散制造企业(机加工、模具、钣金、3D 打印)拿到客户图纸后,第一件事是拆零件明细表:一行行读出零件号、名称、规格、材料、数量,再拿去核价、报价。一张 A3 图纸的明细栏动辄几十行,人工录入一张要 20–40 分钟,且极易出错。

传统做法是 OCR 识字 + 规则拼结构。问题是工程图纸的版式千差万别:跨页多栏、合并单元格、竖长表、扫描歪斜,规则写到最后是一坨补丁。

我们的判断是:这一环应该交给视觉语言模型,但不该交给通用大模型的 zero-shot。

图 1 · 智图引擎的流水线:大模型只负责其中一环

一张图纸从上传到产出 BOM,中间有六步。大模型只负责第五步——把「定位好的那一小块图」变成「结构正确的 JSON」。前四步是传统计算机视觉,最后一步是规则与工程计算。

这个划分很重要,因为它决定了微调的价值怎么衡量:不是「模型变强了多少」,而是「这一环变准了多少」。

为什么不让通用大模型直接读整页

我们做过对照:基础模型 zero-shot 确实能认出零件,但它把零件号塞进 spec 字段、把图号塞进 item_no 字段——内容读对了,位置放错了,在下游流程里等于零分。而它在「描述保真度」上反而很强,因为它会整段照抄。

这正是值得微调的典型形态:模型不缺能力,缺的是对我们 schema 的服从。


二、平台与硬件:卡在 28 GiB 这道门槛上

选卡的理由不是「越大越好」,而是一道硬门槛:8B 模型做 LoRA + bf16,实测需要约 28 GiB 显存。

图 2 · 选卡对比:显存 28 GiB 是分水岭

这道门槛把 24G 的卡全部挡在门外(RTX 4090 / 3090 都只有 24G,只能退而求其次做 QLoRA,掉点)。我们最终选了 vGPU-32GB(RTX 4080 SUPER / 32GB / Ada sm_89)——它是当时能稳定提供 28G 以上显存的按需实例。同档位的 V100-32GB 有三个 Volta 架构坑(不支持 bf16、不支持 FlashAttention-2、fp16 数值稳定性差),实测训练吞吐反而更低。

结论:这个任务是显存瓶颈,不是算力瓶颈。 半精算力只有 4090 的 62%,但训练时长几乎一样。

平台侧踩过的坑(都是真金白银)

坑后果处置
数据盘默认 50GB 不够基模 17G + 数据集 4.1G + pip 环境 15–20G ≈ 40G,加上日志和 checkpoint 直接卡死创建实例时就扩容到 150GB
系统盘只有 30GB装 torch + CUDA 工具链会爆环境装进数据盘,或直接用官方预装镜像
CUDA 版本很新(13.x)自己 pip 装 torch 容易版本错配,pip install vllm 会连带把 torch 换成 2.13.0 + CUDA 13用官方镜像;--dry-run 先看再装
余额不足会自动停机训练跑到第 15 小时被停,checkpoint 虽可续,但白等提前充值、留足余额
GPU 数量选成 2价格翻倍,而脚本是单卡的,第二张卡纯烧钱选 1

三、数据选型:先想清楚数据集能喂什么、喂不了什么

我们没有一开始就找数据,而是先列了一张「能力映射表」:产品需要哪些字段,哪个数据集能提供监督信号。

产品需要的能力公开数据集能给吗
明细表结构化(行/列还原、编号-件号-名称-数量分离)✅ 强,3,498 张表
跨页多栏、多表聚合✅ 有
标题栏字段(图号/材料/比例/设计/审核)❌ 无,得自己合成
材料牌号识别❌ 无
单重 / 重量计算❌ 无(这是规则计算,不训)
尺寸链 / 公差 / 干涉❌ 无(保持纯 Python 规则)
3D 精确几何重建❌ 无(光栅单视图,无尺寸真值,训不出)

一句话:Enginuity 是「表格理解」的燃料,不是「工程语义」的燃料。

我们最终用了 Enginuity(Apache-2.0,DOI 10.57967/hf/8702)——10 册公开美军技术手册(TM / RPSTL)的零件表图像与真值。选它有三个理由:许可干净可商用、真值是结构化 TSV 可直接用、论文基线公开可对标。

数据清洗:先减后加

图 3 · 数据选型:先看清数据集能喂什么、喂不了什么

原始 manifest 有 3,011 个「图 ↔ 表」配对。按硬规则过滤后剩 2,073 个训练样本(26,683 个零件行),评测集单独取 820 个(15,409 行)。

过滤规则(硬闸门,宁缺勿滥):

  • TSV 行数为 0(空表,实测大量存在)
  • TSV 行数 > 32(超出 max_length,长尾论文里 mean 23、median 17)
  • description 长度 < 3,或非 ASCII 乱码比例 > 30%
  • 同一 TSV 内 item_no 重复率 > 50%(结构还原失败)
  • 图像短边 < 400 px

然后只对训练集的图像侧做增强,label 不动:灰度化 + 对比度下降(30%)、JPEG 压缩 quality 40–70(20%)、高斯噪声 + 轻度模糊 σ1–3(20%)、小幅旋转 ±0.5°(15%,不能大角度,会破坏表格结构语义)、随机缩放 0.8–1.2(20%)。

配比:中文刻意占主导

最终 6,873 条训练样本里,Enginuity 英文只占 2,006 条,中文合成(标题栏 + 明细栏)占 4,867 条。

这是反直觉但必要的选择:Enginuity 是英文军用手册,域偏移风险极大。 美军 RPSTL 维修零件表 vs 中国 GB 机械图纸,语言、版式、字段全不同。让中文占 70% 更安全——宁可少学一点英文表格结构,也不能让模型在中文图纸上退化。

一个关键判断:不训端到端

现有检测节点已经能定位标题栏 / 明细栏 ROI,OCR 服务也已在链路里。所以我们把微调目标锁定为 「表格/标题栏 ROI 图 → 严格 JSON」,直接替换 extract 节点的 LLM 调用。整页直出只作对照实验,不作为主路线。

工程量最小、评测最干净、token 最省。


四、LoRA 是什么,以及我们怎么配的

给非技术读者: 完整微调一个 80 亿参数的模型,相当于把这栋楼每一层的承重墙都重砌一遍——贵、慢、容易塌。LoRA 的做法是:楼不动,只在每层加一副可拆卸的托架。 我们只训练 0.5% 的参数,就能让模型学会一套新的输出规范;不想用了,把托架拆掉,模型回到原样。

图 4 · LoRA 微调:只训 0.5% 的参数

具体配置:

项值
基模Qwen3-VL-8B-Instruct
LoRA rank / alpha / dropout16 / 32 / 0.05
目标模块all-linear(仅 language_model.*,不含视觉塔)
可训练参数43.65 M / 8,810 M = 0.4954%
学习率 / 调度1e-4,cosine,warmup 3%
epoch / batch2.0 / 1 × 8(梯度累积)
精度bf16 + 梯度检查点
显存峰值30.92 / 32 GiB(未 OOM)
只算 assistant 段 loss是

⚠️ 必须同时说明代价:target_modules 的正则只匹配 language_model.*,视觉塔是冻结的。这意味着模型的视觉识别能力 100% 沿用基模——它没有变得更会看图,只是变得更会按我们的格式输出。下一轮想提升细粒度读图(小零件行、图号),解开视觉塔是明确方向,代价是显存需求上升(当前 vGPU 余量只有 1.84G,做不了)。


五、第一轮:train_loss 很漂亮,模型错了

这是整个复盘里最值得看的一段。

第一轮训练跑完,日志非常好看:

train_loss(末)   0.01802
token_acc(末段)  0.997
train_runtime      3h40m

0.018 的 loss,意味着 98.2% 的 token 准确率。 任何只看这条曲线的人都会宣布成功。

但 held-out 评测给出了完全相反的信号:

维度LoRA vs 基模 zero-shot判定
结构合法性(JSON 可解析)1.000 vs 1.000持平
字段映射(零件号落到 item_no)0.979 vs 0.000✅ 决定性提升
数量列0.9962 vs 0.0000✅ 决定性提升
描述保真(token F1,共同行)0.5678 vs 0.9573❌ 负收益

在字段映射和数量上,LoRA 赢得干净利落;在描述保真上,它显著低于什么都没做的基础模型。

为什么低 loss 骗过了我们

必须纠正一个常见误诊:0.018 的 train_loss 不能说明过拟合。

因为输出是严格 schema 的 JSON,"spec":null,"material":null, 这类片段每行重复十余次,格式 token 近乎恒定。e^-0.018 ≈ 98.2% 的 token 准确率主要反映的是格式服从性,不是内容正确性。在这个任务上,低 train loss 是预期行为。

判断泛化只能靠 held-out 外部指标。

根因:脚本里的一行逗号

数据构造脚本 build_enginuity_sft.py 里有这样一个函数:

if "," in raw:
    name, rest = raw.split(",", 1)      # ← 问题在这里

这条规则假设「英文描述 = 名称,规格」——这是错的。 真实英文(美军 TM / RPSTL)零件描述的格式是 主名,限定语:

真值原文规则产出的 name实际语义
WASHER,FLATWASHER主名 + 限定语
ENGINE,DIESEL 1996 6.5 LITER...ENGINE主名 + 燃料类型 + 年份
FRAME ASSEMBLY USBL EFF 100,000 AND ABOVEFRAME ASSEMBLY USBL EFF 100千分位逗号被误当分隔符

三种错误同时发生:把限定语切走、千分位逗号被误切、切完的剩余部分被塞进 spec/remark——训练目标里的 description 从第一行起就是永久残缺的。

决定性证据:模型学得太好了

对 LoRA 匹配上的 812 行逐行比对:

类别行数占比
输出 == 训练规则的 name(即被截断的版本)73190.0%
输出 == 真值原文718.7%

这不是模型「没学会」,是模型「学得太好」——它精确复现了我们给它的错误标注。

也正因如此,这个缺陷在训练侧完全不可见(loss 一路降到 0.018),只有 held-out 外部指标 + 与基模同口径对比才能暴露。

顺带一个反直觉的观察:基模的「笨」在此处胜过了 LoRA 的「乖」。 基模没有这条先验,它整段照抄(.WASHER, FLAT),于是 F1 = 1.00。


六、第二轮:一行规则 + 一道闸门

修法很简单:name = raw 整段保留,逗号一律不作为切分依据;spec / remark 改为「附加抽取」——识别但不从原文删除。千分位逗号切分前先保护。

但修复之前先立闸门(tools/test_split_description.py):22 条硬编码断言 + 300 条真实抽样,全部通过才允许开训。

这道闸门是本轮没有再走弯路的直接原因——它只花几分钟,却省下了 4 小时 GPU。

图 5 · 第二轮训练的真实曲线(trainer_state.json 直出)

同时补上了第一轮遗漏的 --val_dataset mix_val.jsonl --eval_steps 100。第一轮一条 eval 曲线都没有,本轮 18 个验证点、loss 单调下降无抬头。

结果

图 6 · 严格口径下 LoRA 全面胜出

但单看这个表会误导——必须做共同行对比。

图 7 · 消除覆盖率偏差后的决定性对比

Token F1 只在各自匹配上的行上取平均。基模只匹配 501 行(都是容易行),LoRA 匹配 806 行(含大量难行),分母不同,不可直接比。在基模能匹配的同一批 501 行上重算:

模型共同 501 行上的描述 token F1
基模 zero-shot0.9583
第一轮(旧规则)0.5675
第二轮(新规则)0.9971

结论:第二轮不仅在 61% 更高的覆盖率下工作,在基模最擅长的那批行上也比它准。

连真值的拼写错误都还原了

第二轮里最关键的一条证据:真值本身拼写是错的(FRAME ASEMBLY),模型输出 FRAME ASEMBLY——它如实还原了错误拼写,而不是按语义「纠正」成 ASSEMBLY。 这说明模型在读图,不是在猜。第一轮同一条被记作 FRAME ASY,属于读错。

成本

项第二轮第一轮
train_runtime3h56m3h40m
train_loss0.016290.01802
eval_loss0.00622(18 个点,单调下降)无(漏传参数)
显存峰值30.92 GiB30.55 GiB
best checkpoint1600(与 1700/1720 差异 1e-6,属噪声)—
费用云端按需实例计费,两轮训练 + 评测开销可控

第一轮是学费。两轮合计按云端按需实例、以分钟计量,开销可控。


七、部署与实机调试:六个不试就发现的坑

权重训好只是开始。真正吃掉时间的是把它接进产品链路。

#症状真因处置
1adapter 加载后推理慢 5 倍未合并 LoRA--merge_lora true:60 s/条 → 12–40 s/条
2prompt token 异常大(3748)推理分辨率没对齐训练的 max_pixels部署加 --max_pixels 786432;否则图片吃掉 3748 token,只剩 348 给输出,长 JSON 必被截断
3指标近 0 但输出「看着对」finish_reason: length 截断输出上限 ≥ 训练 p99(本任务 2408,max 3067)
4大量 __ERROR__timed out客户端并发 × 单条耗时 > 客户端超时--workers 1 + --timeout 900
5请求全 200 但结果从未写出ThreadPoolExecutor 卡 futex_wait 不退出改单线程 + 逐条落盘
6--max_model_len 不生效pt 后端忽略该参数靠控制 prompt 长度,或换 vLLM

还有一条方法论级别的:本项目累计出现过 5 次「验收工具本身写错」伪装成「被测对象故障」。 比如验收脚本硬编码了一个「近似」训练 prompt(没导入真 prompt)、只认 dict 不认 list、max_tokens 写死 2048——三个 bug 叠起来会把一个正常的端点误判为失败。

判定原则:结果异常时先自查工具链,再怀疑模型。

三层验收

层验什么结果
L1 服务可达/v1/models 返回模型名✅
L2 训练口径用训练时的同一份 prompt,9 个字段是否齐备✅ 忠实复现训练 schema
L3 线上口径产品真实 prompt 下能否解析⚠️ 能解析,但 reconstruct 字段缺失

L3 的缺口说明一件事:线上 prompt 与训练目标不是同一个任务。 线上输入含 detections + OCR + table_kind + 历史修正,输出还要含三维几何;训练时是「单图 ROI → 表格 JSON」。直接接入会掉点。这是下一轮必须对齐的地方。


八、回归测试:四道闸门

训练完成不等于可以上线。我们设计了四道互相独立的闸门,每道都排除了「静默成功」——serve 侧 API 计数与用例数吻合、逐条 preds 是真模型输出而非模板兜底、n_api_error = 0。

图 8 · 回归测试:四道闸门,三道拿到正面证据

791 条用例,串行,墙钟 61 分 25 秒,云端按需计费、开销可忽略。

T8b:0.6621 是一次「假失败」

这道闸门测「从规格串读出几何值」,得分 0.6621。表面看不及格,但三条证据表明问题出在数据和口径,不在模型:

证据一,直接看图。 样本 s0000.png 的视图上一个尺寸标注都没有,只有明细栏给了 3 个数(δ8×100×80)。而真值里 支架 的 geometry 是 {base_len:100, wall_height:80, width:60, thickness:8, hole_dia:6, hole_count:2}——width:60 和 hole_dia:6 在图上和规格串里都不存在。

证据二,全量统计(4,506 个真值几何值 / 751 个零件):

分类数量占比
出现在本零件规格串里(可读)2,01544.7%
不在本零件、但出现在本图其他规格里(大概率数字撞车)57112.7%
图上与串里都读不出来1,92042.6%

按几何键拆开更清楚:thickness / base_len / wall_height / length / outer_dia 可读率 100%,而 hole_dia / hole_count / fillet / inner_dia / bolt_dia / bolt_circle_dia 可读率 0%——它们全是 CAD 生成器的派生量,图上一个标注都没有。

证据三,算天花板。 让一个「完美的规格串解析器」上场(把规格列数字原样抄下来,绝不看图、绝不编造),按同一口径得分:

几何值 P = 1.0000   R = 0.4472   F1 = 0.6180

模型拿了 0.6621——已经略高于天花板。 这道闸门不可能通过「让模型更会看图」来提升,分数被 45–55% 不可读的真值锁死。

修正口径后重算(post-hoc 诊断,非官方指标):

诊断项值
可读真值召回90.7%(1991 中命中 1805)
模型给出规格串值的精确率100.0%(1805/1805)
对「读不出的值」的猜中率53.1%(2461 中命中 1308,按图形比例反推)

判定:假失败。修数据或改口径,不许改分数。 具体做法是把真值限制到「规格列可推出的键」,或在图上补尺寸标注——如果产品目标包含「看图量尺寸」,就必须做后者。

线上实测:瓶颈不在模型

把微调后的模型接进产品跑了 19 次串行新解析:

图 9 · 端到端耗时分解:瓶颈在 OCR,不在模型

  • 主测 12 张合成图纸共 31 行:五字段完整行 31/31,加入单重后 18/31(58.1%)
  • 标题栏只有 8/84 个字段匹配——当前只给模型明细栏 ROI,标题栏能力明显不足
  • 单次缓存复用约 41 ms(不能用缓存速度代表模型推理)
  • 相同图纸重跑两次,items 完全一致——稳定复现,包括稳定复现了错误的单重

同时也暴露了真实缺陷:

  • 白图(无明细表)生成了 3 条虚构明细。 根因是 PDF/DXF 路由下 images=0,而 trained 档位构造消息时既不带图也不带文本,却依然调用模型。这 5 个任务都返回了同样的 MP-01/02/03 Connecting Strip,规格、材料、数量、单重完全一致——与真值不符,白图更完全没有这些内容。
  • 生效表格配置是 PPStructureV3,但容器缺 paddleocr,实际输出 ppstructurev3_unavailable——而任务状态仍然显示 done、degraded=false。
  • 3D 默认底板出现在 7/19 个 assembly 产物中;washer 的网格是实心柱(忽略内半径);GLB 未恢复工程米制(480 mm 底板导出约 4.8 场景单位)。

这些都不是「下次注意」级别的问题。 空白输入必须阻断生成,状态位必须反映真实能力。


九、必须说清楚的两件事

1. 训练/留出重叠,旧指标已降级

第一轮报告曾写「评测集与训练集零重叠,无泄漏」。2026-10-15 的数据审计推翻了这个结论:

对 enginuity.jsonl(2,073 行)和 enginuity_holdout.jsonl(820 行)做路径归一与 SHA-256 比对:

  • 缺失图像引用:0
  • 训练/留出 exact image hash overlap:2 个哈希
    • data-2/tables/page_270.png 在训练中重复引用 3 次,且也出现在留出
    • data-1/tables/page_78.png 与 page_83.png 文件名不同但字节相同
  • 原始 payload 层面还有大量重复:6,484 个图像文件中 129 个重复,3,585 个 TSV 中 961 个重复

结论:历史文档中的「训练/验证无重叠」不能作为当前独立留出的证明,旧 Enginuity 指标降级为历史诊断。

这也是本文中所有 0.99xx 数字都带 ⚠️ 标注的原因。已修正 build_enginuity_sft.py:新的 holdout fence 同时按 figure path、table path 和 table bytes hash 三重排除。未重训、未重建旧 JSONL、未把修复后的结果虚报为新指标。

2. 合成数据不是真实企业图纸

四闸门里的 G3 拿了满分,但它有两个限定必须一起说:

  • 这是合成图(与规则生成器同源),只证明「合成分布内不掉」,不是真实图纸的证据
  • 真留出只有 8 条(±1 行就能翻盘),不能当上线门槛

线上实测的 17 张有正样本真值的图纸,49 行真值 / 47 行输出,严格完整行 20 行:precision 42.6%、recall 40.8%。这个数字远低于合成集上的表现——这才是更接近真实的区间。


十、沉淀下来的方法论

判断证据
数据规则错误在 train_loss 上完全不可见第一轮 loss 0.018 / token_acc 0.997,看着完美,实际描述保真 0.51
必须与未微调基模同口径对比只和「自己上一轮」比看不出问题;和基模比才发现 0.51 < 0.96
必须补「共同匹配集合」上的对比否则覆盖率偏差会让结论反转
改规则前先写断言集几分钟闸门省下 4 小时 GPU
模型会忠实复现你的标注错误90% 的输出是错误规则的产物——它学得没错,是标注错了
结果异常先自查工具链5 次「验收工具写错」伪装成「被测对象故障」
分母不对,指标就是谎言共同行集合、可读子集、白图不进正样本分母

附:复现命令

# 1) 改规则后先过断言(不过就不许开训)
python tools/test_split_description.py --sample 300

# 2) 重建数据(英文侧,holdout 三重 fence)
python tools/build_enginuity_sft.py
python tools/build_enginuity_sft.py --holdout-mode only --out data/sft/enginuity_holdout.jsonl

# 3) 冒烟 → 正式训练(带验证集)
BASE_MODEL=/root/autodl-tmp/models/Qwen3-VL-8B-Instruct GPU_MEM=32 bash tools/train_vlm.sh smoke
BASE_MODEL=/root/autodl-tmp/models/Qwen3-VL-8B-Instruct GPU_MEM=32 bash tools/train_vlm.sh train

# 4) 起服务(合并 adapter + 分辨率对齐训练)
CKPT=/root/autodl-tmp/out/zhitu-vlm-8b/v1-20261010-212620/checkpoint-1720
swift deploy --model /root/autodl-tmp/models/Qwen3-VL-8B-Instruct \
  --adapters $CKPT --merge_lora true --infer_backend pt --port 8000 \
  --served_model_name zhitu-vlm-8b --max_new_tokens 3584 --max_pixels 786432

# 5) 评测(预测逐条落盘,改指标可离线重算,不烧 GPU)
python tools/eval_enginuity.py --model zhitu-vlm-8b --split benchmark \
  --limit 60 --workers 1 --timeout 900 --max-tokens 3584 \
  --out data/sft/eval_lora_v2.json

适配器权重(adapter_model.safetensors 174,663,096 字节)是唯一不可重建的产物——基模、数据集、合成数据都能重新生成,训练 4 小时的权重不能。换机器时注意 adapter_config.json 里 base_model_name_or_path 写的是远端绝对路径,需要改成新机器的路径。


本文所有训练曲线、指标、耗时与成本数字均来自 trainer_state.json 与项目内评测报告的实测记录。涉及不利结论的部分(训练/留出重叠、白图幻觉、3D 非工程级、指标降级)未做修饰。