第1802篇:视觉语言模型在企业中的应用——发票识别、合同扫描的工程实践
第1802篇:视觉语言模型在企业中的应用——发票识别、合同扫描的工程实践
从一个真实的客户投诉说起
财务部门的王姐找到我,语气不太好:"你们那个AI系统,把'壹拾贰万叁仟肆佰伍拾陆元整'识别成了'12346元',少了一个零,差点出了大事!"
我当时第一反应是:OCR精度问题?后来排查下来,不是OCR的锅——OCR把数字读对了,是后处理逻辑在把大写金额转换成数字时搞错了进位。
但这个问题背后有一个更深的坑:我们当时的发票识别系统,是用传统OCR + 规则引擎做的,规则写了200多条,每次出新版发票模板就要改规则,改了新的又破坏旧的。维护成本越来越高,准确率却越来越低。
后来我们换了视觉语言模型(VLM)方案,不是说换了就万事大吉——坑一样有,但坑的性质变了,更可控了。
这篇文章就来聊聊,用VLM做发票识别和合同扫描,工程层面怎么落地。
为什么传统方案越来越撑不住
先说清楚传统方案的问题在哪,不然后面的VLM方案就没有说服力。
传统方案的架构大致是这样:
这套方案在发票格式固定、质量高的情况下,效果不错。问题是:
- 发票格式太多了:增值税专票、普票、电子发票、机动车发票、火车票……每种格式都要单独写规则
- 图片质量参差不齐:手机拍照有阴影、褶皱、透视变形,规则引擎完全应付不了
- 特殊情况处理:盖章遮挡金额怎么办?涂改液痕迹怎么处理?
- 维护成本爆炸:国税总局每年都在更新发票规范,规则得跟着改
VLM方案的核心优势是:不需要写规则,模型自己理解版面和语义。
VLM方案的整体架构
跟传统方案相比,几个关键变化:
- 类型识别放在VLM之前,不同类型用不同的Prompt策略
- 置信度分级代替全量人工复核,降低人工介入成本
- 审计日志是必须的,特别是财务场景,每条记录都要可追溯
发票识别的核心实现
1. 图像预处理
VLM对图像质量有一定容忍度,但太差的图还是要先处理:
@Component
public class InvoiceImagePreprocessor {
/**
* 发票图像预处理流水线
* 重点解决:倾斜、光照不均、透视变形
*/
public PreprocessResult preprocess(byte[] rawImage) {
Mat mat = imdecode(new MatOfByte(rawImage), IMREAD_COLOR);
// Step1: 检测倾斜角度并矫正
double skewAngle = detectSkewAngle(mat);
if (Math.abs(skewAngle) > 0.5) {
mat = correctSkew(mat, skewAngle);
}
// Step2: 透视矫正(针对拍照发票)
Mat perspectiveCorrected = correctPerspective(mat);
if (perspectiveCorrected != null) {
mat = perspectiveCorrected;
}
// Step3: 自适应光照均衡
mat = equalizeAdaptive(mat);
// Step4: 轻度锐化,增强文字清晰度
mat = sharpen(mat);
// 质量评分
double qualityScore = assessQuality(mat);
return PreprocessResult.builder()
.processedImage(matToBytes(mat))
.qualityScore(qualityScore)
.skewCorrected(Math.abs(skewAngle) > 0.5)
.perspectiveCorrected(perspectiveCorrected != null)
.build();
}
/**
* 透视矫正的核心:找到发票的四个角点
* 这里用的是Canny边缘检测 + 霍夫直线变换的方式
*/
private Mat correctPerspective(Mat src) {
Mat gray = new Mat();
cvtColor(src, gray, COLOR_BGR2GRAY);
Mat edges = new Mat();
Canny(gray, edges, 50, 150);
// 膨胀,连通断开的边缘
Mat kernel = getStructuringElement(MORPH_RECT, new Size(3, 3));
dilate(edges, edges, kernel);
// 找最大轮廓
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
findContours(edges, contours, hierarchy, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
if (contours.isEmpty()) return null;
// 找面积最大的四边形轮廓
MatOfPoint largestContour = contours.stream()
.max(Comparator.comparingDouble(Imgproc::contourArea))
.orElse(null);
if (largestContour == null) return null;
MatOfPoint2f contour2f = new MatOfPoint2f(largestContour.toArray());
MatOfPoint2f approx = new MatOfPoint2f();
approxPolyDP(contour2f, approx, 0.02 * arcLength(contour2f, true), true);
// 只处理四边形
if (approx.total() != 4) return null;
// 透视变换
return applyPerspectiveTransform(src, approx);
}
/**
* 图像质量评分
* 主要考量:清晰度(Laplacian方差)、对比度
*/
private double assessQuality(Mat mat) {
Mat gray = new Mat();
cvtColor(mat, gray, COLOR_BGR2GRAY);
// Laplacian方差衡量清晰度
Mat laplacian = new Mat();
Laplacian(gray, laplacian, CV_64F);
MatOfDouble mean = new MatOfDouble();
MatOfDouble std = new MatOfDouble();
meanStdDev(laplacian, mean, std);
double sharpness = std.toArray()[0];
// sharpness > 100 说明比较清晰,越高越好
return Math.min(1.0, sharpness / 100.0);
}
}2. 发票类型识别
用一个轻量级调用先判断类型,因为不同类型的提取字段是不同的:
@Component
public class InvoiceTypeClassifier {
@Autowired
private VisionModelClient visionClient;
// 支持的发票类型
public enum InvoiceType {
VAT_SPECIAL("增值税专用发票"),
VAT_GENERAL("增值税普通发票"),
ELECTRONIC_VAT("增值税电子发票"),
VEHICLE("机动车销售统一发票"),
TRAIN_TICKET("火车票"),
AIR_TICKET("机票行程单"),
TAXI_INVOICE("出租车票"),
RECEIPT("收据"),
UNKNOWN("未知类型");
private final String displayName;
InvoiceType(String displayName) { this.displayName = displayName; }
}
public InvoiceType classify(byte[] imageData) {
String prompt = """
这是一张票据图片。请判断它属于以下哪种类型,只回答类型代码:
VAT_SPECIAL - 增值税专用发票(左上角有"增值税专用发票"字样)
VAT_GENERAL - 增值税普通发票(左上角有"增值税普通发票"字样)
ELECTRONIC_VAT - 增值税电子发票(有"电子发票"字样,无防伪码)
VEHICLE - 机动车销售统一发票(有车辆信息)
TRAIN_TICKET - 火车票(有车次、座位号)
AIR_TICKET - 机票行程单(有航班号、起降地)
TAXI_INVOICE - 出租车票(面积小,有起步价)
RECEIPT - 其他收据
UNKNOWN - 无法判断
只回答一个类型代码,不要解释。
""";
String result = visionClient.analyze(imageData, prompt).trim();
try {
return InvoiceType.valueOf(result);
} catch (IllegalArgumentException e) {
return InvoiceType.UNKNOWN;
}
}
}3. 字段提取的Prompt设计
这是最核心的部分,Prompt质量直接决定提取效果。不同类型用不同Prompt:
@Component
public class InvoiceFieldExtractor {
@Autowired
private VisionModelClient visionClient;
/**
* 增值税专票的提取Prompt
* 关键:告诉模型字段名称和位置特征,让它精准提取
*/
private static final String VAT_SPECIAL_PROMPT = """
请从这张增值税专用发票中提取以下字段,以JSON格式返回:
{
"invoice_code": "发票代码(左上角或右上角,12位数字)",
"invoice_number": "发票号码(8位数字)",
"issue_date": "开票日期(格式:YYYY年MM月DD日)",
"buyer_name": "购买方名称",
"buyer_tax_id": "购买方纳税人识别号(18位)",
"buyer_address_phone": "购买方地址电话",
"buyer_bank_account": "购买方开户行及账号",
"seller_name": "销售方名称",
"seller_tax_id": "销售方纳税人识别号",
"seller_address_phone": "销售方地址电话",
"seller_bank_account": "销售方开户行及账号",
"items": [
{
"name": "货物或应税劳务名称",
"spec": "规格型号",
"unit": "单位",
"quantity": "数量",
"unit_price": "单价",
"amount": "金额(不含税)",
"tax_rate": "税率",
"tax_amount": "税额"
}
],
"total_amount": "合计金额(不含税)",
"total_tax": "合计税额",
"total_amount_with_tax": "价税合计(大写)",
"total_amount_with_tax_number": "价税合计(小写数字,¥开头)",
"remarks": "备注"
}
注意事项:
1. 金额字段保留原始格式,不要转换
2. 如果某个字段被遮挡或无法识别,填写null
3. 大写金额和小写金额都要提取,不要互相转换
4. 只返回JSON,不要添加任何说明
""";
/**
* 火车票的提取更简单,字段少但有特殊格式
*/
private static final String TRAIN_TICKET_PROMPT = """
请从这张火车票中提取信息,以JSON格式返回:
{
"train_number": "车次(如G1234)",
"departure_station": "出发站",
"arrival_station": "到达站",
"departure_time": "出发时间(格式:YYYY年MM月DD日 HH:mm开)",
"seat_type": "座位类型(如:二等座、一等座、商务座)",
"seat_number": "座位号",
"passenger_name": "乘客姓名",
"id_number": "身份证号(提取后脱敏处理:前3位+****+后4位)",
"ticket_price": "票价(数字,单位:元)",
"ticket_id": "票号"
}
只返回JSON,不要添加任何说明。
""";
public InvoiceExtractionResult extract(byte[] imageData, InvoiceType type) {
String prompt = switch (type) {
case VAT_SPECIAL, VAT_GENERAL -> VAT_SPECIAL_PROMPT;
case ELECTRONIC_VAT -> VAT_SPECIAL_PROMPT; // 基本相同
case TRAIN_TICKET -> TRAIN_TICKET_PROMPT;
case AIR_TICKET -> AIR_TICKET_PROMPT;
default -> GENERAL_RECEIPT_PROMPT;
};
String rawResponse = visionClient.analyze(imageData, prompt);
return parseAndValidate(rawResponse, type);
}
/**
* 解析响应并做业务校验
* 这一步很重要,模型输出不一定100%正确
*/
private InvoiceExtractionResult parseAndValidate(String rawResponse, InvoiceType type) {
// 清理可能的markdown代码块标记
String jsonStr = rawResponse
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
try {
Map<String, Object> fields = objectMapper.readValue(jsonStr, Map.class);
List<ValidationError> errors = validate(fields, type);
double confidence = calculateConfidence(fields, errors);
return InvoiceExtractionResult.builder()
.success(true)
.fields(fields)
.validationErrors(errors)
.confidence(confidence)
.rawResponse(rawResponse)
.build();
} catch (JsonProcessingException e) {
// JSON解析失败,可能是模型返回了非JSON内容
return InvoiceExtractionResult.builder()
.success(false)
.errorMessage("模型输出格式错误: " + e.getMessage())
.rawResponse(rawResponse)
.confidence(0.0)
.build();
}
}
/**
* 业务规则校验
* 不要完全信任模型输出,关键字段必须校验
*/
private List<ValidationError> validate(Map<String, Object> fields, InvoiceType type) {
List<ValidationError> errors = new ArrayList<>();
if (type == InvoiceType.VAT_SPECIAL || type == InvoiceType.VAT_GENERAL) {
// 校验发票代码格式(12位数字)
String invoiceCode = (String) fields.get("invoice_code");
if (invoiceCode != null && !invoiceCode.matches("\\d{12}")) {
errors.add(new ValidationError("invoice_code", "发票代码格式不正确"));
}
// 校验金额一致性:合计金额 + 合计税额 ≈ 价税合计
validateAmountConsistency(fields, errors);
// 校验纳税人识别号(18位)
validateTaxId(fields, "buyer_tax_id", errors);
validateTaxId(fields, "seller_tax_id", errors);
}
return errors;
}
private void validateAmountConsistency(Map<String, Object> fields,
List<ValidationError> errors) {
try {
BigDecimal totalAmount = parseMoney(fields.get("total_amount"));
BigDecimal totalTax = parseMoney(fields.get("total_tax"));
BigDecimal totalWithTax = parseMoney(fields.get("total_amount_with_tax_number"));
if (totalAmount != null && totalTax != null && totalWithTax != null) {
BigDecimal computed = totalAmount.add(totalTax);
if (computed.subtract(totalWithTax).abs().compareTo(new BigDecimal("0.02")) > 0) {
errors.add(new ValidationError("amount_consistency",
String.format("金额不一致:%s + %s ≠ %s", totalAmount, totalTax, totalWithTax)));
}
}
} catch (Exception e) {
// 解析失败就不做这项校验
}
}
}合同扫描:比发票更复杂
合同文档有几个特点让它比发票难处理得多:
- 内容量大:合同动辄几十页
- 格式多样:没有固定版面,每家公司合同格式不同
- 关键信息分散:合同金额可能在第二页,但付款条款在第十二页
- 法律术语多:需要一定的语义理解才能准确提取
我的方案是分段提取 + 二次汇聚:
@Service
public class ContractAnalysisService {
@Autowired
private DocumentPreprocessor preprocessor;
@Autowired
private VisionModelClient visionClient;
/**
* 合同分析分两个阶段:
* 阶段1:逐段提取关键信息
* 阶段2:汇聚所有段的信息,生成完整合同摘要
*/
public ContractAnalysisResult analyze(MultipartFile file) {
// 预处理,分块
DocumentContent content = preprocessor.process(file);
// 阶段1:逐页/逐段提取
List<PageExtraction> pageExtractions = new ArrayList<>();
// 将页面分成批次(避免单次token太多)
List<List<ContentBlock>> batches = partitionBlocks(content.getBlocks(), 3);
for (List<ContentBlock> batch : batches) {
String batchContext = buildBatchContext(batch);
PageExtraction extraction = extractFromBatch(batchContext);
pageExtractions.add(extraction);
}
// 阶段2:汇聚
return aggregateResults(pageExtractions, content);
}
/**
* 单批次提取Prompt
* 关键:让模型标记信息来源(哪页),便于后续校验
*/
private PageExtraction extractFromBatch(String batchContext) {
String prompt = String.format("""
以下是合同的部分内容(多页文字或图片分析):
%s
请从中提取以下信息(如果在这部分内容中能找到):
1. 合同名称/标题
2. 甲方(委托方)名称和地址
3. 乙方(承接方)名称和地址
4. 合同金额(含税/不含税,注明)
5. 付款方式和时间节点
6. 合同期限(起止日期)
7. 主要服务/产品描述(简述)
8. 违约责任条款要点
9. 争议解决方式(仲裁/诉讼,地点)
10. 签署日期和签署地点
对于找不到的信息,填写"未找到"。
如果找到信息,注明大约在第几页。
以JSON格式返回,结构如下:
{
"contract_name": {"value": "...", "page": X},
"party_a": {"name": "...", "address": "...", "page": X},
...
}
""", batchContext);
String response = visionClient.textAnalyze(prompt);
return parsePageExtraction(response);
}
/**
* 汇聚多段结果
* 关键:如果同一字段在多页都有,要做合并决策
*/
private ContractAnalysisResult aggregateResults(List<PageExtraction> extractions,
DocumentContent document) {
// 把所有提取结果拼在一起,让模型做最终汇聚
String allExtractions = extractions.stream()
.map(e -> e.toJsonString())
.collect(Collectors.joining("\n---\n"));
String aggregatePrompt = String.format("""
以下是对同一份合同各个部分的提取结果:
%s
请综合所有信息,生成这份合同的完整摘要。
注意:
1. 如果同一字段在不同部分有不同说法,以后出现的为准(可能是修改条款)
2. 合同金额如果有多处提及,要注意区分"总价"和"单价"
3. 付款节点如果分散在多处,要合并成完整的付款计划
返回JSON格式:
{
"contract_name": "合同名称",
"parties": {
"party_a": {"name": "", "address": "", "contact": ""},
"party_b": {"name": "", "address": "", "contact": ""}
},
"financial": {
"total_amount": "总金额",
"tax_included": true/false,
"payment_schedule": ["首付xxx万...", "验收后30天内..."]
},
"timeline": {
"contract_period": "xxx至xxx",
"signing_date": ""
},
"key_terms": {
"service_description": "服务描述摘要",
"liability_clause": "违约责任要点",
"dispute_resolution": "争议解决方式"
},
"risk_flags": ["需关注的风险点..."]
}
""", allExtractions);
String finalJson = visionClient.textAnalyze(aggregatePrompt);
return parseContractResult(finalJson);
}
/**
* 合同风险检测:单独一个步骤,专门找坑
* 这个功能在实际使用中反响最好
*/
public List<RiskFlag> detectRisks(ContractAnalysisResult contract) {
String riskPrompt = String.format("""
以下是一份合同的摘要信息:
%s
请从法律和商业风险角度分析,找出可能存在的风险点。
重点检查:
1. 付款条款是否对己方不利(如验收周期过长才付款)
2. 违约金条款是否不对等
3. 知识产权归属是否明确
4. 保密条款范围是否过宽
5. 单方面解除合同的条件是否合理
6. 争议解决地点是否方便己方
7. 合同期限是否自动续约(可能有坑)
8. 是否有不合理的连带责任条款
以JSON数组返回风险点:
[
{
"risk_level": "HIGH/MEDIUM/LOW",
"risk_type": "风险类型",
"description": "具体描述",
"recommendation": "建议处理方式"
}
]
""", contract.toJsonString());
String response = visionClient.textAnalyze(riskPrompt);
return parseRiskFlags(response);
}
}置信度机制:降低人工复核成本
这是整套方案里最有价值的工程设计之一。全量人工复核成本太高,但完全不复核又不放心,置信度分级是个好方法:
@Component
public class ConfidenceEvaluator {
/**
* 综合置信度计算
* 考虑:字段完整性、校验通过率、模型self-consistency
*/
public ConfidenceScore evaluate(InvoiceExtractionResult result) {
double score = 1.0;
// 因子1:关键字段缺失扣分
Map<String, Double> keyFieldWeights = Map.of(
"invoice_code", 0.2,
"invoice_number", 0.2,
"total_amount_with_tax_number", 0.2,
"seller_name", 0.1,
"buyer_name", 0.1,
"issue_date", 0.1
);
for (Map.Entry<String, Double> entry : keyFieldWeights.entrySet()) {
Object value = result.getFields().get(entry.getKey());
if (value == null || "null".equals(value) || "".equals(value)) {
score -= entry.getValue();
}
}
// 因子2:校验错误扣分
score -= result.getValidationErrors().size() * 0.1;
// 因子3:金额一致性
if (hasAmountInconsistency(result)) {
score -= 0.15;
}
score = Math.max(0.0, Math.min(1.0, score));
return ConfidenceScore.builder()
.score(score)
.level(classifyLevel(score))
.needsReview(score < 0.8)
.build();
}
private ConfidenceLevel classifyLevel(double score) {
if (score >= 0.9) return ConfidenceLevel.HIGH;
if (score >= 0.7) return ConfidenceLevel.MEDIUM;
return ConfidenceLevel.LOW;
}
/**
* Self-consistency check:对同一张发票调用两次,对比关键字段是否一致
* 成本稍高,但对于高金额发票值得做
*/
public boolean selfConsistencyCheck(byte[] imageData,
InvoiceExtractionResult firstResult) {
// 再调用一次,用稍微不同的Prompt
String verifyPrompt = """
请核实这张发票的以下关键信息是否正确:
- 价税合计金额(小写)
- 开票日期
- 购买方名称
- 销售方名称
只返回你确认看到的这四项信息,JSON格式。
""";
String secondResponse = visionClient.analyze(imageData, verifyPrompt);
// 对比关键字段
return compareKeyFields(firstResult, secondResponse);
}
}几个真实踩过的坑
坑1:印章遮挡金额
合同盖章通常盖在金额附近,会遮挡部分数字。VLM有时候会"脑补"被遮挡的内容——这个比识别不出来更危险。
解决方案:在Prompt里明确告诉模型:"如果某个字段有印章或遮挡,请标注uncertain,不要猜测"。
// 在提取Prompt里加入这段
"重要:如果某个字段被印章、涂改或其他内容部分遮挡,在字段值后加上[UNCERTAIN]标记,不要推测被遮挡的内容"坑2:金额单位混淆
有些发票上有"元"有"万元",模型有时候会搞混。特别是手写金额。
解决方案:提取后做数量级交叉校验——如果发票商品只有几件,但总价超过千万,肯定有问题,触发人工复核。
坑3:多语言发票
外资企业的发票可能是中英双语,甚至全英文。Prompt要同时支持两种语言:
// Prompt加上
"发票可能是中文、英文或中英双语,请都能正确识别"坑4:模型幻觉问题
最严重的坑:模型有时候会把不存在的字段"创造"出来。特别是在图像质量差的情况下。
解决方案:建立字段白名单 + 格式校验,任何不符合预期格式的字段输出都要标记复核。
性能和成本优化
每张发票调用一次VLM,成本不低。几个优化点:
@Component
public class CostOptimizer {
/**
* 快速路径:对于高质量标准格式发票,先试OCR + 规则
* 只有OCR失败或结果校验不通过,才走VLM
*/
public ExtractionResult extractWithFallback(byte[] imageData, InvoiceType type) {
// 先试传统OCR(便宜)
if (isStandardFormat(imageData, type)) {
try {
ExtractionResult ocrResult = ocrExtractor.extract(imageData, type);
if (ocrResult.getConfidence() > 0.95) {
return ocrResult; // 高置信度,直接用OCR结果
}
} catch (Exception e) {
// OCR失败,继续走VLM
}
}
// 走VLM(准确但贵)
return vlmExtractor.extract(imageData, type);
}
/**
* 批量处理时的token优化:
* 同类型发票合并到一个对话,共享系统Prompt
*/
public List<ExtractionResult> batchExtract(List<byte[]> images, InvoiceType type) {
// 每批最多5张,避免上下文混淆
List<List<byte[]>> batches = Lists.partition(images, 5);
return batches.parallelStream()
.flatMap(batch -> extractBatch(batch, type).stream())
.collect(Collectors.toList());
}
}总结
VLM做发票和合同识别,在实际生产中是完全可行的,而且比传统规则方案维护成本低很多。关键工程点:
- 预处理要做好,图像质量是VLM能力的下限
- Prompt设计要精细,字段定义越清晰准确率越高
- 置信度分级很重要,不是所有结果都要人工复核
- 关键字段必须校验,不能完全信任模型输出
- 成本意识,先走便宜的路径,VLM作为兜底
我们上线这套系统之后,发票识别准确率从78%提升到了94%,人工复核量从全量降到了约15%。王姐后来也说,好多了。
