📐 立底账 → 对账 → 补账 → 验账 → 收敛:Qwen3-VL 多模态表格解析的完整性闭环设计
基于一次真实的多模态表格解析执行日志沉淀(日志原文已归档于文末附录)。核心一句话:让模型只读字,让程序替它证明「该有的字都在、说好的修复真的做了」——通过「立底账 → 对账 → 补账 → 验账 → 收敛」五步闭环,把「结构合法但内容被静默丢弃」这类结构校验看不见的缺陷彻底堵死。
🎯 效果对比:原始表格 → 识别结果
图像备份: 访问
✅右侧 · Qwen3-VL 五步闭环收敛后的识别结果(19 行 × 7 列,89/89 覆盖)
[一级指标, 二级指标, 单位, 指标方向, 基准值, 判定依据, 所属生命周期阶段] [资源属性, 单位产品氯化钾消耗量(折百), kg/t, ≤, 1 335, 依据 A.1 计算, 产品生产] [资源属性, 单位产品新鲜水耗(折百), t/t, ≤, 4.5, 依据 A.2 计算, 产品生产] [能源属性, 单位产品综合能耗 离子膜法液碱≥48.0%, kgce/t, ≤, 375, 依据 A.3 计算, 产品生产] [能源属性, 单位产品综合能耗 离子膜法固碱≥90.0%, kgce/t, ≤, 475, 依据 A.3 计算, 产品生产] [能源属性, 单位产品综合能耗 离子膜法固碱≥95.0%, kgce/t, ≤, 495, 依据 A.3 计算, 产品生产] [环境属性, 单位产品废水排放量 离子膜法液碱≥48.0%, m³/t, ≤, 0.95, 依据 A.4 计算, 过程控制] [环境属性, 单位产品废水排放量 离子膜法固碱≥90.0%, m³/t, ≤, 1, 依据 A.4 计算, 过程控制] [环境属性, 单位产品废水排放量 离子膜法固碱≥95.0%, m³/t, ≤, 1, 依据 A.4 计算, 过程控制] [环境属性, 单位产品盐泥产生量(干基), kg/t, ≤, 10, 依据 A.5 计算, 过程控制] [环境属性, 废水氨氮含量, mg/L, ≤, 7, GB 15581, 过程控制] [环境属性, 废水总氮含量, mg/L, ≤, 10, GB 15581, 过程控制] [环境属性, 废气中颗粒物含量, mg/m³, ≤, 20, GB 15581, 过程控制] [环境属性, 废气中二氧化硫含量, mg/m³, ≤, 40, GB 15581, 过程控制] [环境属性, 废气中氮氧化物含量, mg/m³, ≤, 110, GB 15581, 过程控制] [环境属性, 废气中氯气含量, mg/m³, ≤, 1, GB 15581, 过程控制] [环境属性, 废气中氯化氢含量, mg/m³, ≤, 18, GB 15581, 过程控制] [环境属性, 固废处置率, %, =, 100, 依据 A.6 计算, 过程控制] [产品属性, 重金属(以 Pb 计)(干基计), mg/kg, ≤, 6, GB 25575, 产品生产]对比说明:左侧为模型的输入(纯表格裁切图,316 228 bytes);右侧为「立底账 → 对账 → 补账 → 验账 → 收敛」五步闭环收敛后的结构化结果。其中离子膜法液碱≥48.0%、离子膜法固碱≥90.0%、离子膜法固碱≥95.0%这类工艺规格子标签——视觉上位于指标名下一行、带缩进——正是首轮被结构校验看不见地整段丢弃、再由修复轮真正并入对应单元格的内容(详见第 3 节闭环实战复盘)。
1. 背景:为什么需要这套机制
1.1 上游的硬约束
在所用大模型网关(Qwen3-VL)上实测到三个不可回避的事实:
| 约束 | 现象 | 结论 |
|---|---|---|
| ~60s 生成硬超时 | 输出 token 一多就稳定504被掐断 | 输出必须纯文本、短输出 |
| 逐格坐标密集输出不可行 | 每格输出 4~6 个坐标数字,稳定超时 | 放弃「文字 + 坐标」的密集输出契约 |
| 合并单元格语法不可靠 | 模型对跨行/跨列计数常错、重试不收敛、长表底部行错位 | 放弃让模型输出合并语法 |
1.2 结构校验的盲区(本设计的直接动机)
早期「二维数组」契约只做行宽一致的结构校验,实测暴露出致命缺陷:
纠错轮会用「删除内容」来满足列数约束——实测 21 行被模型压到 19 行,结构校验通过,但整块内容被静默丢弃。
结构校验能防「格式错」,防不了「内容丢」。这是引入 OCR 覆盖校验的起点。
1.3 核心思路转变
模型只承担「按读序读字」,结构保真与内容完整性由确定性规则兜底。
- 模型侧:放弃坐标、放弃合并语法、放弃计数,只做「读字 + 归位」;
- 程序侧:二维结构校验 + OCR 覆盖校验 + 修复计划自洽校验,三层兜底。
2. 五步闭环:核心设计
一次调用的输出契约(三份输出):
{ "rows": [ ... ], // 结构化二维数组(表头 + 数据行,列数严格一致) "ocr": [ ... ], // 按阅读顺序的全量可见文字清单(每单元格/每段一条) "repair": [ ... ] // 修复轮输出:对遗漏文字的判因计划 }- rows:模型认为的逻辑表格(要交付的结构化结果);
- ocr:模型输出的「我看到了哪些字」的全量清单,作为 rows 的底账;
- repair:发现遗漏后进入修复轮,模型须对每段遗漏文字先判因再补。
2.1 立底账 —— 锁定一份「全量可见文字清单」
首次结构合法的输出即锁定基准 ocr(本次为 89 条)。后续每一轮覆盖校验都按这份底账比对,而不是按模型当轮自报的 ocr;一旦当轮 ocr 缩水到基准的 80% 以下,只告警,仍按基准校验。
为什么必须锁死:如果允许模型每轮重新定义自己的 ocr,它完全可以把清单缩成跟(残缺的)rows 一样,覆盖校验就形同虚设。锁定底账 = 让模型没法「自证无罪」。
2.2 对账 —— 逐条核对 rows 是否漏字
把 rows 所有单元格文字按行串成一个检索底文本(只判「文字是否整体出现」,与出现顺序无关),再与 ocr 逐条比对,采用两级判定:
一级判定 片段去空白后整体命中 rows 拼接文本 → 覆盖 二级兜底 整体未命中,但语义 token 覆盖率 ≥ 0.7 → 判定「分段差异」(分词方式不同,非真实缺失) ignore 豁免 修复计划判为 ignore 的片段 → 豁免 兜底 存在 rows 中完全缺失的 token → 「确定遗漏」语义 token 按「连续中文/字母/数字(含上下标 ²³₁)」切分:既能容忍模型对同一文字的拆分/合并方式差异,又能在离子膜法液碱≥48.0%这类整段文字在 rows 中一个 token 都找不到时如实报漏。
2.3 补账 —— 修复轮「先判因再补」
发现遗漏后,不让模型直接重写全表,而是:
- 把**当前 rows(带行号列号)**内嵌给模型,供其定位;
- 遗漏文字逐条编号渲染,避免一整块被当成单个片段;
- 模型必须为每段遗漏文字分类:merge(并入已有单元格,声明目标位置)/new_row(确属一整行独立数据)/ignore(核对后不在表格内)。
2.4 验账 —— 防「假修复」
对修复计划做三层自洽校验,杜绝「假修复」:
| 假修复形态 | 拦截手段 |
|---|---|
| 分类非法/拼错 | 只允许 merge / new_row / ignore |
| 遗漏文字未分类(漏判) | 计划完整性校验:仍遗漏且未分类 → 判定计划不完整 |
| 声明 merge 却不真正执行(只写计划、rows 原封不动) | merge 目标硬校验:声明的单元格必须真的出现该文字 |
| 为补字凭空加行(新行数超计划) | 行数一致性校验:新增行数 ≤ new_row 计划数 |
其中merge 目标硬校验是最锋利的一刀:模型倾向于「口头修复」——在计划里写 merge,但输出的 rows 原封不动。逐条核对「声明的单元格里是否真的出现了该文字」,没出现 = 不合格重试。
2.5 收敛 —— 循环重试直到完全对上
每轮调用后依次执行「结构校验 → 对账 → 修复计划校验 → 验账」,任一步不过就把具体错误(含缺字清单)反馈给模型重试,最多 3 轮。重试前清空会话记忆,切断模型对上一轮错误回答的「复读惯性」。全部通过才算收敛。
3. 闭环实战:三次循环复盘
输入:纯表格裁切图;基准底账共89 条可见文字。
| 维度 | 第 1 次 | 第 2 次 | 第 3 次 |
|---|---|---|---|
| 调用耗时 | 28 733 ms | 21 825 ms | 38 633 ms |
| 完整覆盖 | 83 / 89 | 83 / 89 | 89 / 89 |
| 确定遗漏 | 6 处 | 6 处 | 0 处 |
| 卡点 | 6 段工艺规格子标签被整段丢弃 | 声明 merge 但 rows 未真正并入,被验账拦截 | 真正补字,完全收敛 |
总耗时:89 214 ms(三次调用 + 校验开销)。
3.1 第 1 次:对账照出结构校验看不见的丢字
- rows 是合法的 19×7 二维数组,传统结构校验必然放行;
- 对账发现 6 处确定遗漏:
离子膜法液碱≥48.0% / 离子膜法固碱≥90.0% / 离子膜法固碱≥95.0%(各出现 2 次)。
它们是什么:
单位产品综合能耗与单位产品废水排放量下方还有按产品等级区分的工艺规格子标签(液碱≥48.0%、固碱≥90.0%、固碱≥95.0%),视觉上位于指标名的下一行、带缩进——正是「不得因换行/缩进而丢弃」的那类内容,首轮被整段丢掉。
3.2 第 2 次:验账拦下「口头修复」
- 模型输出修复计划:6 条 merge,各自声明目标位置;
- 但 rows 与第 1 次完全一致,目标单元格(如第 3 行第 1 列)仍是
单位产品综合能耗,并不包含遗漏文字; - merge 目标硬校验当场抓出:
merge 未执行: fragment[...] 未出现在声明的单元格,必须把该文字真正拼进该单元格; - 同时模型底账缩水为 6 条(< 基准 89 条),立底账的锁死机制让它无法靠缩水蒙混。
3.3 第 3 次:真正补字,89/89 收敛
- 目标单元格真的变长:
单位产品综合能耗 离子膜法液碱≥48.0% ← 原来是 单位产品综合能耗 单位产品综合能耗 离子膜法固碱≥90.0% 单位产品综合能耗 离子膜法固碱≥95.0% 单位产品废水排放量 离子膜法液碱≥48.0% 单位产品废水排放量 离子膜法固碱≥90.0% 单位产品废水排放量 离子膜法固碱≥95.0% - 对账89/89 全覆盖,确定遗漏 0,完全收敛。
4. 这套机制带来的提升
| 能力维度 | 之前(纯结构校验) | 之后(五步闭环) |
|---|---|---|
| 校验对象 | 只查「行宽一致」 | 结构 + 内容完整性(ocr 覆盖)+ 修复计划自洽 |
| 丢字检测 | 无法发现 | 整段缺字可被精确列出并反馈给模型 |
| 修复方式 | 笼统重试(模型自己猜哪错了) | 先判因再补:merge / new_row / ignore 显式分类 |
| 假修复拦截 | 无 | 计划完整性 + merge 目标硬校验 + 行数一致性 |
| 抗绕过 | 无 | 底账锁定,防模型缩水 ocr 自证无罪 |
| 收敛判定 | 「结构合法即通过」 | 「结构 + 覆盖 + 计划全部通过」才算收敛 |
| 不可收敛时的兜底 | 返回最后一行合法结果 | 返回缺字最少的一次 |
5. 过程中踩过的坑(经验沉淀)
- 结构校验防不了丢字:纠错轮会用「删除内容」满足列数约束,行宽一致 ≠ 内容完整。
- 丢字是真实存在的风险:工艺规格子标签在结构校验下被静默丢弃,必须给内容也立一份底账。
- 模型会「口头修复」:在计划里写 merge、输出却原封不动。对模型的承诺必须做一致性核验——说 merge 就查 rows 里是否真的变了;说 ignore 就查它是否真的解释了原因。
- 模型会缩水 ocr 试图自证无罪:多轮交互中一旦允许模型修改校验依据,它就掌握了通过校验的钥匙,必须把底账锁死在首轮。
- 模型输出格式不稳定:同一字段在不同轮次会漂移成数组 / 对象 / 纯文本,解析端要兼容多种形态,且一律以首轮底账为准。
- 让模型只做它擅长的事:读字、归位;记账、计数、一致性证明全部交给确定性程序。
- 失败的输出也是资产:保留缺字最少的一次,未收敛时也能交付相对最好的结果,而不是整单失败。
附录:执行日志整理(原始过程留档)
以下内容由一次真实执行日志整理而来,保留执行过程的完整事实(各轮输入输出、校验结果、反馈),作为留档。
1. 运行环境与输入
| 项目 | 值 |
|---|---|
| 执行时间 | 2026-08-03 11:43:22 起 |
| LLM 端点探活 | token.matpool.com:443— TCP 探活成功 |
| 输入图片 | 纯表格裁切图(316 228 bytes) |
2. 第 1 次调用(耗时 28 733 ms)
模型原始输出:rows(19 行 × 7 列)+ ocr(89 条可见文字数组)。
第 1 次 rows(工艺规格子标签被整段丢弃的状态):
[一级指标, 二级指标, 单位, 指标方向, 基准值, 判定依据, 所属生命周期阶段] [资源属性, 单位产品氯化钾消耗量(折百), kg/t, ≤, 1 335, 依据 A.1 计算, 产品生产] [资源属性, 单位产品新鲜水耗(折百), t/t, ≤, 4.5, 依据 A.2 计算, 产品生产] [能源属性, 单位产品综合能耗, kgce/t, ≤, 375, 依据 A.3 计算, 产品生产] [能源属性, 单位产品综合能耗, kgce/t, ≤, 475, 依据 A.3 计算, 产品生产] [能源属性, 单位产品综合能耗, kgce/t, ≤, 495, 依据 A.3 计算, 产品生产] [环境属性, 单位产品废水排放量, m³/t, ≤, 0.95, 依据 A.4 计算, 过程控制] [环境属性, 单位产品废水排放量, m³/t, ≤, 1, 依据 A.4 计算, 过程控制] [环境属性, 单位产品废水排放量, m³/t, ≤, 1, 依据 A.4 计算, 过程控制] [环境属性, 单位产品盐泥产生量(干基), kg/t, ≤, 10, 依据 A.5 计算, 过程控制] [环境属性, 废水氨氮含量, mg/L, ≤, 7, GB 15581, 过程控制] [环境属性, 废水总氮含量, mg/L, ≤, 10, GB 15581, 过程控制] [环境属性, 废气中颗粒物含量, mg/m³, ≤, 20, GB 15581, 过程控制] [环境属性, 废气中二氧化硫含量, mg/m³, ≤, 40, GB 15581, 过程控制] [环境属性, 废气中氮氧化物含量, mg/m³, ≤, 110, GB 15581, 过程控制] [环境属性, 废气中氯气含量, mg/m³, ≤, 1, GB 15581, 过程控制] [环境属性, 废气中氯化氢含量, mg/m³, ≤, 18, GB 15581, 过程控制] [环境属性, 固废处置率, %, =, 100, 依据 A.6 计算, 过程控制] [产品属性, 重金属(以 Pb 计)(干基计), mg/kg, ≤, 6, GB 25575, 产品生产]关键动作:锁定基准 ocr(89 条),后续覆盖校验一律以此为准。
校验报告:
OCR 片段总数: 89, 完整覆盖: 83, ignore 豁免: 0, 分段差异(非缺失): 0, 确定遗漏: 6确定遗漏(rows 整体缺字):
- 离子膜法液碱≥48.0%(2 处)
- 离子膜法固碱≥90.0%(2 处)
- 离子膜法固碱≥95.0%(2 处)
反馈(进入第 2 轮):rows 遗漏了 OCR 可见文字 6 处
3. 第 2 次调用(耗时 21 825 ms)
模型原始输出:rows(与第 1 次完全相同)+ ocr 缩水为对象(仅 6 条)+ repair 计划(6 条 merge)。
repair 计划(第 2 次,声明 merge 但 rows 未真正并入):
[{"fragment":"离子膜法液碱≥48.0%","action":"merge","row":3,"col":1},{"fragment":"离子膜法固碱≥90.0%","action":"merge","row":4,"col":1},{"fragment":"离子膜法固碱≥95.0%","action":"merge","row":5,"col":1},{"fragment":"离子膜法液碱≥48.0%","action":"merge","row":6,"col":1},{"fragment":"离子膜法固碱≥90.0%","action":"merge","row":7,"col":1},{"fragment":"离子膜法固碱≥95.0%","action":"merge","row":8,"col":1}]两条抗绕过告警(模型 ocr 形态为对象而非数组/文本,解析端返回空列表):
第 2 次模型未返回 ocr 字段,覆盖校验退化为空(无法判定完整性)第 2 次模型 ocr 缩水(0 条 < 基准 89 条),覆盖校验仍按基准 ocr 执行,防止绕过
校验报告:完整覆盖 83,确定遗漏 6(与第 1 次相同,仍按基准比对)。
merge 目标硬校验拦截:
merge 未执行: fragment[离子膜法液碱≥48.0%] 未出现在声明的单元格 (3,1),必须把该文字真正拼进该单元格反馈(进入第 3 轮):第 2 次输出 merge 未执行,准备重试
4. 第 3 次调用(耗时 38 633 ms)
模型原始输出:rows(6 个目标单元格已真正并入遗漏文字)+ ocr 缩水为文本(完整制表符内容 + 末尾 6 条片段)+ repair 计划(6 条 merge,声明位置同上)。
校验报告:
OCR 片段总数: 89, 完整覆盖: 89, ignore 豁免: 0, 分段差异(非缺失): 0, 确定遗漏: 0收敛判定:第 3 次输出通过全部校验(结构 + 修复计划 + OCR 覆盖)
5. 收敛结果
==== 收敛结果:rows (19 行 x 7 列),总耗时 89214 ms ====最终 rows(工艺规格子标签已并入对应单元格):
[一级指标, 二级指标, 单位, 指标方向, 基准值, 判定依据, 所属生命周期阶段] [资源属性, 单位产品氯化钾消耗量(折百), kg/t, ≤, 1 335, 依据 A.1 计算, 产品生产] [资源属性, 单位产品新鲜水耗(折百), t/t, ≤, 4.5, 依据 A.2 计算, 产品生产] [能源属性, 单位产品综合能耗 离子膜法液碱≥48.0%, kgce/t, ≤, 375, 依据 A.3 计算, 产品生产] [能源属性, 单位产品综合能耗 离子膜法固碱≥90.0%, kgce/t, ≤, 475, 依据 A.3 计算, 产品生产] [能源属性, 单位产品综合能耗 离子膜法固碱≥95.0%, kgce/t, ≤, 495, 依据 A.3 计算, 产品生产] [环境属性, 单位产品废水排放量 离子膜法液碱≥48.0%, m³/t, ≤, 0.95, 依据 A.4 计算, 过程控制] [环境属性, 单位产品废水排放量 离子膜法固碱≥90.0%, m³/t, ≤, 1, 依据 A.4 计算, 过程控制] [环境属性, 单位产品废水排放量 离子膜法固碱≥95.0%, m³/t, ≤, 1, 依据 A.4 计算, 过程控制] [环境属性, 单位产品盐泥产生量(干基), kg/t, ≤, 10, 依据 A.5 计算, 过程控制] [环境属性, 废水氨氮含量, mg/L, ≤, 7, GB 15581, 过程控制] [环境属性, 废水总氮含量, mg/L, ≤, 10, GB 15581, 过程控制] [环境属性, 废气中颗粒物含量, mg/m³, ≤, 20, GB 15581, 过程控制] [环境属性, 废气中二氧化硫含量, mg/m³, ≤, 40, GB 15581, 过程控制] [环境属性, 废气中氮氧化物含量, mg/m³, ≤, 110, GB 15581, 过程控制] [环境属性, 废气中氯气含量, mg/m³, ≤, 1, GB 15581, 过程控制] [环境属性, 废气中氯化氢含量, mg/m³, ≤, 18, GB 15581, 过程控制] [环境属性, 固废处置率, %, =, 100, 依据 A.6 计算, 过程控制] [产品属性, 重金属(以 Pb 计)(干基计), mg/kg, ≤, 6, GB 25575, 产品生产]基准 OCR 全量可见文字(89 条,覆盖校验以此为准):
展开查看 89 条基准 OCR 清单一级指标 二级指标 单位 指标方向 基准值 判定依据 所属生命周期阶段 资源属性 单位产品氯化钾消耗量(折百) kg/t ≤ 1 335 依据 A.1 计算 产品生产 单位产品新鲜水耗(折百) t/t ≤ 4.5 依据 A.2 计算 能源属性 单位产品综合能耗 离子膜法液碱≥48.0% kgce/t ≤ 375 依据 A.3 计算 产品生产 离子膜法固碱≥90.0% 475 离子膜法固碱≥95.0% 495 环境属性 单位产品废水排放量 离子膜法液碱≥48.0% m³/t ≤ 0.95 依据 A.4 计算 过程控制 离子膜法固碱≥90.0% 1 离子膜法固碱≥95.0% 1 单位产品盐泥产生量(干基) kg/t ≤ 10 依据 A.5 计算 废水氨氮含量 mg/L ≤ 7 GB 15581 废水总氮含量 mg/L ≤ 10 废气中颗粒物含量 mg/m³ ≤ 20 废气中二氧化硫含量 mg/m³ ≤ 40 废气中氮氧化物含量 mg/m³ ≤ 110 废气中氯气含量 mg/m³ ≤ 1 废气中氯化氢含量 mg/m³ ≤ 18 固废处置率 % = 100 依据 A.6 计算 产品属性 重金属(以 Pb 计)(干基计) mg/kg ≤ 6 GB 25575 产品生产最终判定:==== 解析状态:完全收敛(无遗漏) ====
🔗 参考
- https://pap-docs.pap.net.cn/
- https://gitee.com/alexgaoyh/pap4j-boot3