多模态 AI 应用实战——图像理解、OCR、文档解析的工程化方案
多模态 AI 应用实战——图像理解、OCR、文档解析的工程化方案
适读人群:AI 工程师、有图像/文档处理需求的开发者 | 阅读时长:约18分钟 | 核心价值:多模态 AI 的三大应用场景完整工程实现
上个季度做了一个合同审核系统。客户的需求是:把扫描版 PDF 合同传进去,AI 自动提取关键条款(付款方式、违约条款、合同金额等),并标记风险点。
听起来简单,但实际上涉及三件事:PDF 转图像、图像 OCR 识别文字、结构化信息提取。
每一件单独做都不难,但组合在一起并且要在生产环境里稳定运行,坑不少。
多模态 AI 的能力范围
目前主流多模态模型(GPT-4o、Claude 3.5 Sonnet、Gemini 1.5 Pro)支持的能力:
| 能力 | 描述 | 适用场景 |
|---|---|---|
| 图像描述 | 理解图像内容,用文字描述 | 产品图片标注、内容审核 |
| 图像 OCR | 识别图片中的文字 | 扫描件识别、截图文字提取 |
| 文档理解 | 理解表格、图表、混排文档 | 合同审核、报告解析 |
| 视觉问答 | 针对图像回答问题 | 客服、教育 |
| 图像对比 | 对比两张图的差异 | UI 验收、质检 |
方案一:直接图像识别(最简单)
对于清晰的图片,直接用多模态模型处理:
@Service
@Slf4j
public class ImageAnalysisService {
private final ChatClient chatClient;
public ImageAnalysisService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
/**
* 分析图片,返回结构化信息
*/
public ImageAnalysisResult analyzeImage(byte[] imageBytes, String mimeType, String task) {
// 转 Base64
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
String response = chatClient.prompt()
.user(u -> u
.text(task)
.media(MimeType.valueOf(mimeType),
new ByteArrayResource(imageBytes))
)
.call()
.content();
return parseResponse(response);
}
/**
* 提取图片中的文字(OCR)
*/
public String extractText(byte[] imageBytes) {
return chatClient.prompt()
.user(u -> u
.text("""
请提取图片中的所有文字内容。
要求:
1. 保持原始的段落结构和格式
2. 表格内容用 Markdown 表格格式输出
3. 保留标点符号,不要遗漏任何文字
""")
.media(MimeType.valueOf("image/jpeg"), new ByteArrayResource(imageBytes))
)
.call()
.content();
}
/**
* 发票信息提取
*/
public InvoiceInfo extractInvoiceInfo(byte[] invoiceImage) {
return chatClient.prompt()
.user(u -> u
.text("""
请提取这张发票的信息,以 JSON 格式返回:
{
"invoiceNumber": "发票号码",
"invoiceDate": "开票日期 YYYY-MM-DD",
"sellerName": "销售方名称",
"buyerName": "购买方名称",
"totalAmount": 金额数字(不含单位),
"taxAmount": 税额数字,
"items": [{"name": "商品名", "amount": 金额}]
}
""")
.media(MimeType.valueOf("image/jpeg"), new ByteArrayResource(invoiceImage))
)
.call()
.entity(InvoiceInfo.class);
}
}方案二:PDF 文档解析(核心场景)
扫描版 PDF 需要先转图像,然后逐页识别:
@Service
@Slf4j
public class PdfDocumentParser {
private final ImageAnalysisService imageAnalysisService;
/**
* 解析 PDF,返回每页的文字内容
*/
public List<PageContent> parsePdf(InputStream pdfStream) throws Exception {
List<PageContent> pages = new ArrayList<>();
try (PDDocument document = PDDocument.load(pdfStream)) {
PDFRenderer renderer = new PDFRenderer(document);
int totalPages = document.getNumberOfPages();
log.info("PDF 共 {} 页,开始解析", totalPages);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
long startTime = System.currentTimeMillis();
// 渲染为图像(150 DPI 对 OCR 足够,300 DPI 更精准但慢)
BufferedImage pageImage = renderer.renderImageWithDPI(pageIndex, 150);
// 转为 JPEG bytes
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(pageImage, "JPEG", baos);
byte[] imageBytes = baos.toByteArray();
// OCR 识别
String text = imageAnalysisService.extractText(imageBytes);
long elapsed = System.currentTimeMillis() - startTime;
log.debug("第 {}/{} 页完成,耗时 {}ms,文字长度 {}",
pageIndex + 1, totalPages, elapsed, text.length());
pages.add(new PageContent(pageIndex + 1, text, imageBytes));
}
}
return pages;
}
/**
* 并发解析(多页 PDF 加速)
*/
public List<PageContent> parsePdfConcurrent(InputStream pdfStream) throws Exception {
List<PageContent> pages = new ArrayList<>();
try (PDDocument document = PDDocument.load(pdfStream)) {
PDFRenderer renderer = new PDFRenderer(document);
int totalPages = document.getNumberOfPages();
// 并发渲染,但每批最多 5 页(避免 API 限流)
ExecutorService executor = Executors.newFixedThreadPool(3);
List<CompletableFuture<PageContent>> futures = new ArrayList<>();
for (int i = 0; i < totalPages; i++) {
final int pageIndex = i;
// PDFRenderer 不是线程安全的,需要同步
CompletableFuture<PageContent> future = CompletableFuture.supplyAsync(() -> {
try {
BufferedImage img;
synchronized (renderer) { // 同步渲染
img = renderer.renderImageWithDPI(pageIndex, 150);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "JPEG", baos);
String text = imageAnalysisService.extractText(baos.toByteArray());
return new PageContent(pageIndex + 1, text, baos.toByteArray());
} catch (Exception e) {
throw new RuntimeException("第 " + (pageIndex+1) + " 页解析失败", e);
}
}, executor);
futures.add(future);
}
// 等待所有页面完成
pages = futures.stream()
.map(CompletableFuture::join)
.sorted(Comparator.comparingInt(PageContent::pageNumber))
.collect(toList());
}
return pages;
}
}方案三:合同审核(完整业务场景)
@Service
@Slf4j
public class ContractReviewService {
private final PdfDocumentParser pdfParser;
private final ChatClient chatClient;
public ContractReviewResult reviewContract(InputStream contractPdf, String contractType) {
// 1. 解析 PDF
List<PageContent> pages;
try {
pages = pdfParser.parsePdf(contractPdf);
} catch (Exception e) {
throw new ContractParseException("合同文件解析失败", e);
}
// 2. 合并文本
String fullText = pages.stream()
.map(PageContent::text)
.collect(joining("\n\n--- 第 %d 页 ---\n\n".formatted(0))); // 保留页码标记
// 合同文本过长时分段处理
if (fullText.length() > 30000) {
return reviewLargeContract(pages, contractType);
}
// 3. 信息提取
ContractInfo contractInfo = extractContractInfo(fullText, contractType);
// 4. 风险分析
List<RiskItem> risks = analyzeRisks(fullText, contractType);
return ContractReviewResult.builder()
.contractInfo(contractInfo)
.risks(risks)
.riskLevel(calculateOverallRisk(risks))
.summary(generateSummary(contractInfo, risks))
.pageCount(pages.size())
.build();
}
private ContractInfo extractContractInfo(String text, String contractType) {
return chatClient.prompt()
.user(u -> u.text("""
请从以下合同文本中提取关键信息,以 JSON 返回:
{
"contractTitle": "合同标题",
"parties": ["甲方名称", "乙方名称"],
"contractAmount": 合同金额(数字,无单位),
"currency": "货币单位",
"startDate": "合同开始日期",
"endDate": "合同结束日期",
"paymentTerms": "付款方式描述",
"deliverables": ["交付物1", "交付物2"]
}
合同文本:
{text}
""").param("text", text)
)
.call()
.entity(ContractInfo.class);
}
private List<RiskItem> analyzeRisks(String text, String contractType) {
String riskAnalysis = chatClient.prompt()
.system("""
你是一位专业的合同法律顾问,专门识别合同风险。
重点关注:违约责任、保密条款、知识产权归属、争议解决方式、
不合理的约束性条款、不平等条款。
""")
.user(u -> u.text("""
请分析以下合同文本的法律风险,以 JSON 数组返回:
[
{
"riskType": "风险类型",
"description": "风险描述",
"severity": "HIGH/MEDIUM/LOW",
"location": "合同中的位置或条款",
"suggestion": "处理建议"
}
]
只列出真实存在的风险,没有风险则返回空数组。
合同文本:
{text}
""").param("text", text)
)
.call()
.entity(new ParameterizedTypeReference<List<RiskItem>>() {});
return riskAnalysis;
}
}图像预处理优化
模型识别质量很大程度取决于图像质量:
@Component
public class ImagePreprocessor {
/**
* 优化图像质量,提升 OCR 准确率
*/
public byte[] preprocess(byte[] imageBytes) throws Exception {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
// 1. 灰度化(扫描件通常不需要彩色)
BufferedImage gray = toGrayscale(image);
// 2. 对比度增强(提升文字清晰度)
BufferedImage enhanced = enhanceContrast(gray);
// 3. 去噪(消除扫描噪点)
BufferedImage denoised = denoise(enhanced);
// 4. 调整大小(太大浪费 Token,太小影响识别)
BufferedImage resized = resize(denoised, 2000); // 长边不超过 2000px
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(resized, "JPEG", baos);
return baos.toByteArray();
}
private BufferedImage resize(BufferedImage image, int maxDimension) {
int width = image.getWidth();
int height = image.getHeight();
if (width <= maxDimension && height <= maxDimension) {
return image; // 不需要缩放
}
double scale = Math.min((double) maxDimension / width, (double) maxDimension / height);
int newWidth = (int) (width * scale);
int newHeight = (int) (height * scale);
BufferedImage resized = new BufferedImage(newWidth, newHeight, image.getType());
Graphics2D g = resized.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.drawImage(image, 0, 0, newWidth, newHeight, null);
g.dispose();
return resized;
}
}踩坑实录
坑一:图像 Token 消耗超预期
现象:一张 A4 扫描图片(300 DPI)发给 GPT-4o,消耗了 2800 tokens(仅图像部分),10 页 PDF 光图像就 28000 tokens,成本很高。
原因:GPT-4o 图像 Token 计算按图像分辨率,高分辨率图像按 512×512 的瓦片切割,每个瓦片消耗 512 tokens。
解法:
- 降低渲染 DPI(150 DPI 对 OCR 足够,比 300 DPI 图像 Token 减少 75%)
- 图像压缩,长边不超过 1568px(GPT-4o 的高精度模式阈值)
- 对于文字识别,不需要高精度图像细节,降低质量不影响 OCR
实测:300 DPI → 150 DPI,Token 消耗从 2800 降到 700,OCR 准确率从 98% 降到 96%,对大多数场景完全可接受。
坑二:表格识别格式混乱
现象:含有复杂表格的文档(如合同中的费用明细表),OCR 结果格式错乱,合并单元格的内容位置不对。
原因:多模态模型对复杂表格的理解受图像质量和表格复杂度影响很大。
解法:专门针对表格进行二次处理:
Prompt:图片中有一个表格,请按以下格式输出:
1. 首先识别表格的列头
2. 然后逐行输出数据,用 | 分隔列
3. 如果有合并单元格,在合并的范围内重复该值
不要输出 Markdown 格式,直接输出原始内容坑三:并发太高触发 API 限流
现象:并发处理多个 PDF 时,频繁收到 429 Too Many Requests 错误。
原因:图像处理 API 有 RPM(每分钟请求数)和 TPM(每分钟 Token 数)双重限制,高并发很容易触发。
解法:加令牌桶限流 + 指数退避重试:
private final RateLimiter rateLimiter = RateLimiter.create(5.0); // 5 QPS
@Retryable(
retryFor = {RateLimitException.class},
maxAttempts = 5,
backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 30000)
)
public String extractTextWithRetry(byte[] imageBytes) {
rateLimiter.acquire(); // 等待令牌
return imageAnalysisService.extractText(imageBytes);
}成本估算(供参考)
以处理一份 20 页扫描版 A4 合同(150 DPI)为例:
- 图像 Token(20页 × 700 tokens):14,000 tokens
- Prompt + 输出 Token:约 6,000 tokens
- 总 Token:约 20,000 tokens
- GPT-4o 成本:约 ¥0.45/份合同
- Claude 3.5 Sonnet:约 ¥0.7/份合同
对于企业来说,每份合同不到一块钱的成本,完全可以接受。
图像理解的高级场景
图像理解不只是描述图片,还有很多更有价值的应用场景。
产品图片质量检测:
电商平台每天有大量商家上传商品图片,人工审核效率低。用多模态 AI 自动检测:
@Service
public class ProductImageQualityChecker {
private final ChatClient chatClient;
public ImageQualityReport checkProductImage(byte[] imageBytes) {
return chatClient.prompt()
.user(u -> u
.text("""
请对这张商品图片进行质量审核,返回 JSON:
{
"overall_score": 1-10分,
"issues": [
{"type": "问题类型", "severity": "HIGH/MEDIUM/LOW", "description": "描述"}
],
"passed": true/false,
"reject_reason": "如果未通过,说明原因"
}
检查项目:
1. 图片清晰度(模糊、噪点)
2. 光照(过曝、欠曝、不均匀)
3. 商品完整性(是否完整展示商品)
4. 背景(是否干净,是否有水印/联系方式)
5. 商品与标题的匹配度(通过图片能确认是对应类别的商品吗)
""")
.media(MimeType.valueOf("image/jpeg"), new ByteArrayResource(imageBytes))
)
.call()
.entity(ImageQualityReport.class);
}
}实测结果(测试集 500 张):违规图片检测率 94%,误判率 2.8%,每张图片处理费用约 ¥0.02。比人工审核便宜 95%,速度快 100 倍。
UI 验收测试:
前端每次发版,用多模态 AI 对比新旧页面截图,自动发现 UI 变化:
def compare_ui_screenshots(before: bytes, after: bytes, page_name: str) -> UIChangeReport:
"""对比两个截图,找出 UI 变化"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": f"请对比这两张 {page_name} 页面截图(左边是旧版,右边是新版),找出所有视觉上的差异。"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encode_b64(before)}"}},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encode_b64(after)}"}}
]
}]
)
return parse_changes(response.choices[0].message.content)这个工具可以接入 CI/CD,每次前端发版自动运行,把差异报告发给 QA 审核,减少人工对比工作量。
文档解析的难点与解决方案
实际项目里,文档解析有几个让人头疼的场景。
难点一:表格跨页
PDF 里一个表格跨了两页,每页单独 OCR 出来的内容都不完整,合并时很难对齐。
解决方案:用更大的文档块处理,一次性把连续几页都传给模型,让模型自己理解跨页结构:
// 不要一页一页处理,连续的几页合并处理
List<byte[]> pageImages = getPdfPages(pdfStream, 1, 5); // 取第1-5页
// 转成多图对话
String result = chatClient.prompt()
.user(u -> {
u.text("请提取这几页中的所有表格内容,合并跨页表格:");
for (byte[] page : pageImages) {
u.media(MimeType.valueOf("image/jpeg"), new ByteArrayResource(page));
}
})
.call()
.content();难点二:混排文档(图文交叉)
有些技术文档图文混排,图片里有文字,文字里引用了图片,两者互相依赖才能完整理解。
解决方案:保留图片的位置信息,在输出时标注"[图X]",让后续处理能知道图片位于哪段文字旁边:
Prompt:请按文档的阅读顺序提取内容。
遇到图片时,不要描述图片,而是输出 [图1], [图2] 这样的占位符。
遇到图片下方的说明文字,标注为"[图1说明]:..."。
这样可以保留图片位置信息,方便后续处理。难点三:扫描件倾斜
某些扫描文件拍照时有角度偏斜,OCR 准确率大幅下降。
解决方案:预处理纠偏:
// 使用 OpenCV 检测并纠正倾斜角度
public byte[] deskewImage(byte[] imageBytes) {
Mat src = Imgcodecs.imdecode(new MatOfByte(imageBytes), Imgcodecs.IMREAD_GRAYSCALE);
// 二值化
Mat binary = new Mat();
Imgproc.threshold(src, binary, 0, 255, Imgproc.THRESH_BINARY_INV + Imgproc.THRESH_OTSU);
// 检测文字行角度
Mat lines = new Mat();
Imgproc.HoughLinesP(binary, lines, 1, Math.PI/180, 100, 100, 10);
double angle = calculateMedianAngle(lines);
// 旋转纠正
Point center = new Point(src.width() / 2.0, src.height() / 2.0);
Mat rotMat = Imgproc.getRotationMatrix2D(center, angle, 1.0);
Mat rotated = new Mat();
Imgproc.warpAffine(src, rotated, rotMat, src.size(),
Imgproc.INTER_LINEAR, Core.BORDER_CONSTANT, new Scalar(255));
MatOfByte output = new MatOfByte();
Imgcodecs.imencode(".jpg", rotated, output);
return output.toArray();
}纠偏后的图片传给多模态模型,OCR 准确率通常能从 75% 提升到 94% 以上。
