直接结论
可靠的 AI 图像理解系统不是一次视觉语言模型调用,而是一条证据流水线:
- 哈希并规范化来源,同时保留不可变原件;
- 按任务路由到 OCR、版面解析、VLM 或受控组合;
- 用统一坐标为字段或答案绑定页级证据;
- 执行确定性的 Schema、类型、算术和策略校验;
- 用校准分数和硬失败规则路由;
- 保留产物,使复核者能重放决策。
生产环境中,看似合理不等于可辩护。合法 JSON 只证明语法;可辩护结果还须记录源文件、页与区域、组件与 Prompt 版本及校验规则。
计算机视觉产生空间观察,多模态 AI组合视觉与文字证据,视觉语言模型据此推理;外围流水线决定结果能否安全使用。
流水线真正要决策什么
“理解这张图片”掩盖了多种失败成本不同的任务。选择模型前,先定义输出契约。
| 任务 | 必需输出 | 默认引擎 | 关键失败 |
|---|---|---|---|
| OCR 转写 | 文字、span ID、页码、polygon | OCR 引擎 | 一个字符改变身份或金额 |
| 版面解析 | 区域类型、阅读顺序、层级 | 版面模型 + 规则 | 分栏或表格单元格被合并 |
| 结构化抽取 | 类型化字段、证据引用、校验 | 解析器或 VLM + 验证器 | 业务值看似合理但实际错误 |
| 视觉问答 | 答案、拒答、证据 | 基于页面产物的 VLM | 答案碰巧正确但引用错区域 |
| 图表或图纸推理 | 数值、关系、视觉标记引用 | VLM + 专用解析器 | 编造视觉关系 |
| 分类或路由 | 标签、策略版本、分数 | 分类器 | 进入错误流程或留存策略 |
这样拆分可以避免两类常见设计错误:
- OCR 加解析器已经更可控,却仍为每页调用 VLM;
- 决策依赖版面或视觉关系,却只接受 OCR 纯文本。
更完整的模型和部署架构可阅读多模态 AI:图文理解 Pipeline 工程。本文专注 OCR、文档证据、VQA 与发布控制。
参考架构
原始字节必须保持不可变。推理页面、OCR 结果、裁剪图、Prompt 和输出都应是有版本的衍生产物,而不是对原件的覆盖。质量回退时,才能只重放一个阶段,而不重复之前的外部副作用。
输入与坐标契约
预处理前保存来源
改变图片前至少记录:
- 原始字节的 SHA-256;
- 根据字节而非只看扩展名识别的 MIME;
- 页数、来源宽高、色彩配置和 EXIF 方向;
- 摄取租户、留存等级和访问策略;
- 解码器与预处理版本。
可以为推理保存规范化副本,但不能覆盖原件。压缩、纠偏、切块和缩放都可能改变文字或几何关系。
统一规范坐标系
当 OCR、版面模型和 VLM 看到不同裁剪或分辨率时,bbox 很容易失真。可用契约应明确:
- 原点:左上角;
- 单位:
[0, 1]范围内的归一化坐标; - 顺序:
x1, y1, x2, y2; - 参照物:完成方向校正后的整页;
- 变换链:原图到校正页,再到 crop/tile 和模型输入;
- 形状:旋转文字使用 polygon,可额外提供轴对齐 bbox 供展示。
{
"page": 2,
"page_size": {"width": 2480, "height": 3508},
"polygon_norm": [[0.071, 0.181], [0.314, 0.176], [0.317, 0.204], [0.074, 0.208]],
"source_span_id": "ocr-p2-s184",
"transform_version": "deskew-v3"
}
不要假设模型返回的坐标可以直接定位原文件。视觉 API 可能在推理前缩放图片,坐标估计也可能只是近似值。系统必须反转已记录的变换,并在原页面上验证映射区域。
尽早拒绝坏输入
质量分析应检测空白页、文件截断、不支持的编码、严重模糊、反光、边缘裁切、极小文字、旋转和重复页。质量标记不是装饰性评分,它决定系统应重试预处理、要求重新拍摄,还是禁止自动化。
OCR、版面与 VLM 路由
路由策略取决于任务与错误后果,而不是哪个模型更新。
| 场景 | 推荐路径 | 原因 |
|---|---|---|
| 包含嵌入文字的可搜索 PDF | 原生文字 + 几何信息提取 | 避免额外 OCR 错误 |
| 清晰印刷表单 | OCR + 模板或键值解析器 | 稳定且成本低 |
| 总数必须严格一致的表格 | OCR + 表格结构 + 算术规则 | 数值仍可追溯 |
| 手写或不规则版面 | OCR 候选 + VLM 解释 | 结合字面与上下文信号 |
| 图表问答 | 图表解析器或带引用区域的 VLM | 答案依赖视觉标记 |
| 身份、支付或合规字段 | 两条独立抽取路径,分歧进入复核 | 错误后果值得冗余 |
把 VLM 限制为边界明确的推理器:
- 只传入完成任务所需的页面或裁剪;
- 把 OCR span 和版面对象作为候选证据;
- 强制返回字段 ID 与证据 ID;
- 允许输出
unknown或not_visible; - 禁止用通用知识补齐必填字段。
VLM 可以修复阅读顺序,或把标签与邻近值关联起来,但不应静默改写来源文字。字面 OCR 与规范化业务值都要保留。
证据契约
证据记录是连接答案与来源的最小单元:
{
"evidence_id": "ev-0184",
"document_sha256": "65b87d0f...",
"page": 2,
"kind": "ocr_span",
"text": "Total due USD 1,248.00",
"bbox_norm": [0.621, 0.744, 0.913, 0.781],
"artifact_version": "ocr-v17",
"transform_version": "deskew-v3"
}
每个发布字段再携带:
{
"field": "total_amount",
"raw_value": "USD 1,248.00",
"normalized_value": 1248.0,
"calibrated_score": 0.973,
"evidence_refs": ["ev-0184"],
"normalizer_version": "money-v4",
"validation_refs": ["currency-iso4217", "invoice-total-v2"]
}
该契约回答六个审计问题:
- 处理的是哪份不可变来源?
- 哪个区域支持该字段?
- 哪个组件生成了证据?
- 哪个规范化步骤改变了字面文字?
- 哪些规则接受或拒绝该值?
- 重现结果需要哪些版本?
只有证据引用仍然不够,还要评测引用区域是否真的蕴含答案。近期文档 grounding 研究指出:文本答案可能正确,空间解释却不可靠。因此,答案质量和定位质量必须分别过门禁。
结构化抽取
分离语法、语义与策略
这些校验层回答的问题不同:
| 层级 | 示例检查 | 无法证明什么 |
|---|---|---|
| JSON 解码 | 响应能解析 | 字段完整或正确 |
| Schema | currency 是字符串 |
币种与文档一致 |
| 语义类型 | ISO 日期与 ISO 4217 币种 | 来源确实支持该值 |
| 跨字段不变量 | 小计 + 税额 = 总额 | 每个明细行都读对 |
| 证据完整性 | 每个字段都能解析到源区域 | 该区域蕴含字段值 |
| 业务策略 | 供应商已获批准 | 抽取本身准确 |
JSON Schema 无法因为 1,248.00 是数字,就判断它一定是发票总额。业务语义仍需确定性校验和来源证据。
保留缺失状态
不要把这些状态折叠为同一个空值:
- 文档中不存在;
- 存在但无法辨认;
- 多个候选之间存在歧义;
- 解析器失败;
- 因策略被脱敏;
- 不适用。
每种状态对应不同的重试、复核和分析路径。用空字符串或零代替,会永久破坏这层区别。
取证后再规范化
对于日期、金额、标识符和单位,应同时保存:
- 来源字面文字;
- Locale 假设;
- 规范化值;
- Normalizer 版本;
- 校验结果。
这样可以反向追踪规范化过程,避免干净的规范化值掩盖 OCR 错误。
有依据的视觉问答
生产 VQA 输出应是声明包,而不只是一句话:
{
"answer": "Operating margin increased from 14% to 18%.",
"answer_type": "comparison",
"evidence_refs": ["chart-p7-series-a", "chart-p7-series-b"],
"calibrated_score": 0.91,
"abstained": false,
"limitations": ["Values were read from labeled bars, not the source data table."]
}
出现以下情况时,VQA 控制器应拒绝回答:
- 问题中的实体不可见;
- 证据指向错误页面、图例或数据系列;
- OCR 与视觉读取对决策关键数字存在分歧;
- 问题需要图片之外的信息;
- 在允许的裁剪或分辨率复核中,答案不稳定。
对简短文档答案,精确匹配可能过于严格。DocVQA Challenge 使用 Average Normalized Levenshtein Similarity(ANLS),为近似答案提供部分得分。文本答案可以使用 ANLS 或任务专用规范化,但证据定位仍要单独评测。
置信度校准与选择性自动化
原始 confidence 不是自动化概率
OCR confidence、Token 概率、VLM 口头自评分和校验通过数量是不同信号。用任意权重相加,不会自动得到校准概率;置信度校准必须绑定明确结果与代表性标签。
构建字段级校准数据:
- 收集具有代表性的文档和经过裁决的标签;
- 保存 OCR、解析器、VLM 和验证器的原始信号;
- 按时间或来源拆分校准集,降低数据泄漏;
- 在留出集上拟合 Isotonic Regression 或 Logistic Calibration;
- 报告期望校准误差与布里尔分数;
- 按文档类型、采集渠道、语言和字段监控漂移。
校准与字段和策略绑定。发票日期的分数不能复用为“图表解释正确”的概率。
在固定风险下优化覆盖率
通过明确的选择性预测策略允许系统拒答。更有业务意义的曲线是:
- coverage:自动处理样本占比;
- selective risk:自动处理样本中的错误率。
选择不超过业务错误预算时的最高 coverage。例如,搜索索引与支付指令可以接受的错误率完全不同。阈值、样本量、置信区间和复核容量必须一起报告。
如果任务需要一组可能标签,或需要满足有限样本边际 Coverage 目标的数值区间,应单独评估保形预测。它不会把 VLM 自评分变成正确性保证。
以下硬失败始终覆盖置信度:
- 来源哈希不一致;
- 证据引用无法解析;
- 坐标非法;
- 算术或身份不变量失败;
- 文档类型不受支持;
- 触发被禁止的副作用。
评测矩阵
单一“文档准确率”无法证明系统可靠。
| 层级 | 主要指标 | 必需切片 |
|---|---|---|
| OCR | Character Error Rate、Word Error Rate | 文字系统、字体、字号、模糊、旋转 |
| 版面 | 区域 precision/recall/F1、IoU、阅读顺序分数 | 分栏、表格、表单、图片 |
| 表格解析 | 单元格准确率、结构相似度、总额一致性 | 合并单元格、无线框、多页 |
| 字段抽取 | 字段 precision/recall/F1、精确匹配 | 字段、供应商/模板、语言 |
| VQA | 精确匹配、ANLS、不受支持回答率 | 问题类型、答案类型 |
| 证据 | 引用召回率、蕴含 precision、区域 IoU | 文字、表格、图表、图纸 |
| 校准 | ECE、Brier Score、可靠性曲线 | 字段、来源、语言 |
| 自动化 | 固定风险下 coverage、复核推翻率 | 后果等级 |
| 运行 | p50/p95 延迟、单页成本、重试率、超时率 | 路由、页数、供应商 |
Google Document AI 的评测文档使用有标签测试数据计算字段级 precision、recall 和 F1,并说明置信度阈值变化带来的 precision-recall 取舍;它明确不提供用于该场景的 accuracy 指标。内部评测也应保持同样严谨:每个数字都要说明评测单元、分母与匹配规则。
评测需要分层。OmniDocBench 覆盖九类文档、19 类版面和 15 种属性标签,适合发现通用解析短板;但发布门禁仍然必须包含代表自家表单、语言、供应商和错误后果的私有 Fixture。
真实采集条件切片
干净 PDF 基准会低估相机与扫描链路的失败。至少单独维护这些切片:
- 扫描:压缩、复印噪声、透印;
- 形变:页面弯曲和透视畸变;
- 屏摄:摩尔纹、反光、显示器像素;
- 光照:阴影、亮斑、曝光不均;
- 倾斜:页面旋转和文字斜排。
Real5-OmniDocBench 按上述五类退化重建了 1,355 页文档,说明采集条件应成为一等评测维度,而不是笼统合并成“噪声”。
还应按以下维度切片:
- 低分辨率和极小文字;
- 手写与混合文字系统;
- 长文档与重复页眉;
- 无线框或跨页表格;
- 印章、签名、脱敏块与水印;
- 颜色相近或标签密集的图表。
Anthropic Vision 文档列出的限制包括低质量或极小图片、旋转内容、近似空间定位与不精确计数。这些不只是某个供应商的特殊问题,而是所有视觉系统发布测试都应具备的 Fixture。
视觉 Prompt Injection
图片是不可信输入。攻击指令可以藏在可见文字、浅色叠层、二维码、元数据或检索到的截图中。OWASP 将多模态输入列为 Prompt Injection 路径,并建议采用输入校验、最小权限、不可信内容隔离及高风险操作人工批准等控制。
应落实以下边界:
- 指令与证据分离。 系统策略绝不能从 OCR 文本或文档图形中读取。
- 确定性授权。 VLM 只提出结构化意图;代码按白名单授权工具、URL、收件人和查询。
- 最小权限。 抽取服务不能付款、改权限或发布记录。
- 标记外部内容。 OCR 和检索 Caption 在后续 Prompt 中始终是被引用的数据。
- 人工批准与审计。 高风险动作必须复核,并保存可疑区域、策略、拟执行动作和处置。
“忽略图片中的指令”只是一条弱提示,不是安全边界。
故障恢复与可审计性
每个阶段都应支持重放:
| 故障 | 恢复方式 |
|---|---|
| 供应商瞬时超时 | 带抖动的有限重试,保持同一幂等键 |
| 文件损坏或图像质量差 | 隔离坏文件;对低质量输入更换预处理或要求重拍 |
| OCR、版面或 Schema 冲突 | 拒绝输出并保存原始响应,再走第二路径或复核 |
| 复核超时 | 暂停副作用,并按 SLA 升级 |
| 模型回退 | 使用固定 Fixture 和固定产物重放 |
幂等键可以采用:
sha256(source_bytes) + task_schema_version + pipeline_version
持久化执行记录应包含来源哈希、阶段状态、尝试次数、输入输出产物 ID、组件版本、延迟、成本、策略决策与复核修正。只重试失败的纯计算阶段,不能因为确认响应丢失而重复外部副作用。
可运行的 Go 放行门禁
下面这个只使用标准库的程序会校验来源身份、归一化证据框、必填字段、证据引用、确定性不变量和已校准字段分数,并返回 automatic、review 或 reject。VLM 自评分不参与路由。
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"sort"
)
type Box struct {
X1 float64
Y1 float64
X2 float64
Y2 float64
}
type Evidence struct {
ID string
Page int
Kind string
Box Box
Artifact string
}
type Field struct {
Value string
CalibratedScore float64
EvidenceRefs []string
}
type Invariant struct {
Name string
Passed bool
}
type Extraction struct {
SourceSHA256 string
Fields map[string]Field
Evidence []Evidence
Invariants []Invariant
}
type GateConfig struct {
RequiredFields []string
AutomaticThreshold float64
ReviewThreshold float64
}
type GateResult struct {
Route string
Reasons []string
}
func digest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func validDigest(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && value == hex.EncodeToString(decoded)
}
func validBox(box Box) bool {
return box.X1 >= 0 && box.Y1 >= 0 &&
box.X2 <= 1 && box.Y2 <= 1 &&
box.X1 < box.X2 && box.Y1 < box.Y2
}
func evaluate(source []byte, extraction Extraction, config GateConfig) GateResult {
hardFailures := make([]string, 0)
reviewReasons := make([]string, 0)
if !validDigest(extraction.SourceSHA256) || extraction.SourceSHA256 != digest(source) {
hardFailures = append(hardFailures, "source hash mismatch")
}
evidenceByID := make(map[string]Evidence, len(extraction.Evidence))
for _, item := range extraction.Evidence {
if item.ID == "" || item.Page < 1 || item.Artifact == "" || !validBox(item.Box) {
hardFailures = append(hardFailures, "invalid evidence: "+item.ID)
continue
}
if _, exists := evidenceByID[item.ID]; exists {
hardFailures = append(hardFailures, "duplicate evidence: "+item.ID)
continue
}
evidenceByID[item.ID] = item
}
for _, name := range config.RequiredFields {
field, exists := extraction.Fields[name]
if !exists || field.Value == "" {
hardFailures = append(hardFailures, "missing required field: "+name)
continue
}
if math.IsNaN(field.CalibratedScore) ||
math.IsInf(field.CalibratedScore, 0) ||
field.CalibratedScore < 0 ||
field.CalibratedScore > 1 {
hardFailures = append(hardFailures, "invalid calibrated score: "+name)
}
if len(field.EvidenceRefs) == 0 {
hardFailures = append(hardFailures, "field has no evidence: "+name)
}
for _, ref := range field.EvidenceRefs {
if _, exists := evidenceByID[ref]; !exists {
hardFailures = append(hardFailures, "unresolved evidence: "+name+" -> "+ref)
}
}
if field.CalibratedScore < config.ReviewThreshold {
hardFailures = append(hardFailures, "score below review threshold: "+name)
} else if field.CalibratedScore < config.AutomaticThreshold {
reviewReasons = append(reviewReasons, "score requires review: "+name)
}
}
for _, invariant := range extraction.Invariants {
if !invariant.Passed {
hardFailures = append(hardFailures, "failed invariant: "+invariant.Name)
}
}
if len(hardFailures) > 0 {
sort.Strings(hardFailures)
return GateResult{Route: "reject", Reasons: hardFailures}
}
if len(reviewReasons) > 0 {
sort.Strings(reviewReasons)
return GateResult{Route: "review", Reasons: reviewReasons}
}
return GateResult{Route: "automatic"}
}
func validateConfig(config GateConfig) error {
if math.IsNaN(config.ReviewThreshold) ||
math.IsNaN(config.AutomaticThreshold) ||
math.IsInf(config.ReviewThreshold, 0) ||
math.IsInf(config.AutomaticThreshold, 0) ||
config.ReviewThreshold < 0 ||
config.ReviewThreshold > 1 ||
config.AutomaticThreshold < 0 ||
config.AutomaticThreshold > 1 ||
config.ReviewThreshold >= config.AutomaticThreshold {
return errors.New("invalid gate thresholds")
}
return nil
}
func main() {
source := []byte("fixture-page-v1")
config := GateConfig{
RequiredFields: []string{"invoice_number", "currency", "total_amount"},
AutomaticThreshold: 0.92,
ReviewThreshold: 0.70,
}
if err := validateConfig(config); err != nil {
panic(err)
}
extraction := Extraction{
SourceSHA256: digest(source),
Fields: map[string]Field{
"invoice_number": {
Value: "INV-1042",
CalibratedScore: 0.98,
EvidenceRefs: []string{"ev-invoice"},
},
"currency": {
Value: "USD",
CalibratedScore: 0.96,
EvidenceRefs: []string{"ev-total"},
},
"total_amount": {
Value: "1248.00",
CalibratedScore: 0.94,
EvidenceRefs: []string{"ev-total"},
},
},
Evidence: []Evidence{
{
ID: "ev-invoice",
Page: 1,
Kind: "ocr_span",
Box: Box{X1: 0.12, Y1: 0.18, X2: 0.31, Y2: 0.21},
Artifact: "ocr-v17",
},
{
ID: "ev-total",
Page: 1,
Kind: "table_cell",
Box: Box{X1: 0.62, Y1: 0.74, X2: 0.91, Y2: 0.78},
Artifact: "table-v9",
},
},
Invariants: []Invariant{
{Name: "subtotal_plus_tax_equals_total", Passed: true},
{Name: "currency_matches_total_symbol", Passed: true},
},
}
result := evaluate(source, extraction, config)
fmt.Printf("route=%s reasons=%v\n", result.Route, result.Reasons)
}
预期输出:
route=automatic reasons=[]
真实服务中的校准分数应来自基于留出标签拟合、且有版本的校准器。门禁只消费这些分数,不能用任意权重临时制造 confidence。
生产发布门禁
开启自动副作用前,每个门禁都必须有证据:
| 门禁 | 通过证据 |
|---|---|
| 来源与坐标完整性 | 哈希可回溯原件,反转变换后裁剪叠层与 Fixture 匹配 |
| 抽取、答案与证据质量 | 逐字段 F1、精确匹配/ANLS、引用蕴含与空间定位分别达标 |
| 校准与选择性风险 | 时间留出集的 ECE/Brier 稳定,coverage 满足批准的错误预算 |
| 安全 | Prompt Injection Fixture 无法触发高权限动作 |
| 韧性 | 超时、重试、重复请求和部分失败演练通过 |
| 运行 | 延迟、成本、复核队列和漂移告警满足 SLO |
新版本应先运行 Shadow。对相同输入比较决策,对分歧样本做人工裁决,再按文档来源或租户灰度。模型、Prompt、解析器、校准器和策略应作为同一个有版本发布单元一起回滚。
常见问题
什么是 AI 图像理解流水线?
它分阶段产出 OCR、版面、字段或答案,并以来源、证据、校验与重放保证可审计。
文档理解应该使用 OCR 还是视觉语言模型?
精确字符与坐标使用原生文本或 OCR;视觉关系使用 VLM;高影响字段复核分歧。
文档自动处理应该如何使用置信度?
在裁决标签上校准字段分数并按 coverage-risk 选阈值;自评分不能放行或覆盖硬失败。
如何评测 OCR、结构化抽取与视觉问答?
分层使用 CER/WER、版面指标、字段 F1、ANLS、证据蕴含与定位,并按条件切片。
如何防御文档 AI 中的视觉 Prompt Injection?
把图片视为不可信数据,隔离指令,由代码限制权限和目标,高风险动作必须人工批准。