直接结论

可靠的 AI 图像理解系统不是一次视觉语言模型调用,而是一条证据流水线:

  1. 哈希并规范化来源,同时保留不可变原件;
  2. 按任务路由到 OCR、版面解析、VLM 或受控组合;
  3. 用统一坐标为字段或答案绑定页级证据;
  4. 执行确定性的 Schema、类型、算术和策略校验;
  5. 用校准分数和硬失败规则路由;
  6. 保留产物,使复核者能重放决策。

生产环境中,看似合理不等于可辩护。合法 JSON 只证明语法;可辩护结果还须记录源文件、页与区域、组件与 Prompt 版本及校验规则。

计算机视觉产生空间观察,多模态 AI组合视觉与文字证据,视觉语言模型据此推理;外围流水线决定结果能否安全使用。

流水线真正要决策什么

“理解这张图片”掩盖了多种失败成本不同的任务。选择模型前,先定义输出契约。

任务 必需输出 默认引擎 关键失败
OCR 转写 文字、span ID、页码、polygon OCR 引擎 一个字符改变身份或金额
版面解析 区域类型、阅读顺序、层级 版面模型 + 规则 分栏或表格单元格被合并
结构化抽取 类型化字段、证据引用、校验 解析器或 VLM + 验证器 业务值看似合理但实际错误
视觉问答 答案、拒答、证据 基于页面产物的 VLM 答案碰巧正确但引用错区域
图表或图纸推理 数值、关系、视觉标记引用 VLM + 专用解析器 编造视觉关系
分类或路由 标签、策略版本、分数 分类器 进入错误流程或留存策略

这样拆分可以避免两类常见设计错误:

  • OCR 加解析器已经更可控,却仍为每页调用 VLM;
  • 决策依赖版面或视觉关系,却只接受 OCR 纯文本。

更完整的模型和部署架构可阅读多模态 AI:图文理解 Pipeline 工程。本文专注 OCR、文档证据、VQA 与发布控制。

参考架构

flowchart TD A["原始字节"] --> B["哈希、MIME、恶意文件与页数检查"] B --> C["无损来源归档"] B --> D["方向、纠偏与质量分析"] D --> E{"任务路由器"} E -->|"精确文字"| F["OCR + 阅读顺序"] E -->|"表格/表单"| G["版面 + 表格解析"] E -->|"视觉推理"| H["VLM"] F --> I["规范化证据存储"] G --> I H --> I I --> J["类型化抽取或有依据答案"] J --> K["Schema、算术、策略与证据校验"] K --> L["字段级校准分数"] L --> M{"决策路由器"} M -->|"硬失败"| N["拒绝 / 隔离"] M -->|"不确定"| O["人工复核"] M -->|"风险预算内"| P["自动执行"] O --> Q["纠正结果与裁决标签"] Q --> R["评测与校准数据集"]

原始字节必须保持不可变。推理页面、OCR 结果、裁剪图、Prompt 和输出都应是有版本的衍生产物,而不是对原件的覆盖。质量回退时,才能只重放一个阶段,而不重复之前的外部副作用。

输入与坐标契约

预处理前保存来源

改变图片前至少记录:

  • 原始字节的 SHA-256;
  • 根据字节而非只看扩展名识别的 MIME;
  • 页数、来源宽高、色彩配置和 EXIF 方向;
  • 摄取租户、留存等级和访问策略;
  • 解码器与预处理版本。

可以为推理保存规范化副本,但不能覆盖原件。压缩、纠偏、切块和缩放都可能改变文字或几何关系。

统一规范坐标系

当 OCR、版面模型和 VLM 看到不同裁剪或分辨率时,bbox 很容易失真。可用契约应明确:

  • 原点:左上角;
  • 单位:[0, 1] 范围内的归一化坐标;
  • 顺序:x1, y1, x2, y2;
  • 参照物:完成方向校正后的整页;
  • 变换链:原图到校正页,再到 crop/tile 和模型输入;
  • 形状:旋转文字使用 polygon,可额外提供轴对齐 bbox 供展示。
json
{
  "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 限制为边界明确的推理器:

  1. 只传入完成任务所需的页面或裁剪;
  2. 把 OCR span 和版面对象作为候选证据;
  3. 强制返回字段 ID 与证据 ID;
  4. 允许输出 unknown 或 not_visible;
  5. 禁止用通用知识补齐必填字段。

VLM 可以修复阅读顺序,或把标签与邻近值关联起来,但不应静默改写来源文字。字面 OCR 与规范化业务值都要保留。

证据契约

证据记录是连接答案与来源的最小单元:

json
{
  "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"
}

每个发布字段再携带:

json
{
  "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"]
}

该契约回答六个审计问题:

  1. 处理的是哪份不可变来源?
  2. 哪个区域支持该字段?
  3. 哪个组件生成了证据?
  4. 哪个规范化步骤改变了字面文字?
  5. 哪些规则接受或拒绝该值?
  6. 重现结果需要哪些版本?

只有证据引用仍然不够,还要评测引用区域是否真的蕴含答案。近期文档 grounding 研究指出:文本答案可能正确,空间解释却不可靠。因此,答案质量和定位质量必须分别过门禁。

结构化抽取

分离语法、语义与策略

这些校验层回答的问题不同:

层级 示例检查 无法证明什么
JSON 解码 响应能解析 字段完整或正确
Schema currency 是字符串 币种与文档一致
语义类型 ISO 日期与 ISO 4217 币种 来源确实支持该值
跨字段不变量 小计 + 税额 = 总额 每个明细行都读对
证据完整性 每个字段都能解析到源区域 该区域蕴含字段值
业务策略 供应商已获批准 抽取本身准确

JSON Schema 无法因为 1,248.00 是数字,就判断它一定是发票总额。业务语义仍需确定性校验和来源证据。

保留缺失状态

不要把这些状态折叠为同一个空值:

  • 文档中不存在;
  • 存在但无法辨认;
  • 多个候选之间存在歧义;
  • 解析器失败;
  • 因策略被脱敏;
  • 不适用。

每种状态对应不同的重试、复核和分析路径。用空字符串或零代替,会永久破坏这层区别。

取证后再规范化

对于日期、金额、标识符和单位,应同时保存:

  • 来源字面文字;
  • Locale 假设;
  • 规范化值;
  • Normalizer 版本;
  • 校验结果。

这样可以反向追踪规范化过程,避免干净的规范化值掩盖 OCR 错误。

有依据的视觉问答

生产 VQA 输出应是声明包,而不只是一句话:

json
{
  "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 口头自评分和校验通过数量是不同信号。用任意权重相加,不会自动得到校准概率;置信度校准必须绑定明确结果与代表性标签。

构建字段级校准数据:

  1. 收集具有代表性的文档和经过裁决的标签;
  2. 保存 OCR、解析器、VLM 和验证器的原始信号;
  3. 按时间或来源拆分校准集,降低数据泄漏;
  4. 在留出集上拟合 Isotonic Regression 或 Logistic Calibration;
  5. 报告期望校准误差与布里尔分数;
  6. 按文档类型、采集渠道、语言和字段监控漂移。

校准与字段和策略绑定。发票日期的分数不能复用为“图表解释正确”的概率。

在固定风险下优化覆盖率

通过明确的选择性预测策略允许系统拒答。更有业务意义的曲线是:

  • 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 路径,并建议采用输入校验、最小权限、不可信内容隔离及高风险操作人工批准等控制。

应落实以下边界:

  1. 指令与证据分离。 系统策略绝不能从 OCR 文本或文档图形中读取。
  2. 确定性授权。 VLM 只提出结构化意图;代码按白名单授权工具、URL、收件人和查询。
  3. 最小权限。 抽取服务不能付款、改权限或发布记录。
  4. 标记外部内容。 OCR 和检索 Caption 在后续 Prompt 中始终是被引用的数据。
  5. 人工批准与审计。 高风险动作必须复核,并保存可疑区域、策略、拟执行动作和处置。

“忽略图片中的指令”只是一条弱提示,不是安全边界。

故障恢复与可审计性

每个阶段都应支持重放:

故障 恢复方式
供应商瞬时超时 带抖动的有限重试,保持同一幂等键
文件损坏或图像质量差 隔离坏文件;对低质量输入更换预处理或要求重拍
OCR、版面或 Schema 冲突 拒绝输出并保存原始响应,再走第二路径或复核
复核超时 暂停副作用,并按 SLA 升级
模型回退 使用固定 Fixture 和固定产物重放

幂等键可以采用:

text
sha256(source_bytes) + task_schema_version + pipeline_version

持久化执行记录应包含来源哈希、阶段状态、尝试次数、输入输出产物 ID、组件版本、延迟、成本、策略决策与复核修正。只重试失败的纯计算阶段,不能因为确认响应丢失而重复外部副作用。

可运行的 Go 放行门禁

下面这个只使用标准库的程序会校验来源身份、归一化证据框、必填字段、证据引用、确定性不变量和已校准字段分数,并返回 automatic、review 或 reject。VLM 自评分不参与路由。

go
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)
}

预期输出:

text
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?

把图片视为不可信数据,隔离指令,由代码限制权限和目标,高风险动作必须人工批准。

权威来源