产品说"用户上传截图自动填工单"
三月中旬产品提了个需求:用户报障时经常甩一张截图过来,客服要手工把里面的订单号、错误码、时间点一个个敲进工单系统,平均 90 秒一条,希望系统能自动识别和填充。听起来简单,做下来发现"看懂一张图"这件事在工程上的复杂度远超我的预期。
这篇记录我们在 Java 应用里接多模态能力的过程,包括图像理解、文档解析、音频转写三块,以及每块踩的坑和真实成本。
图像理解:比想象的贵,也比想象的慢
第一步很直接,调一个视觉模型。我们用的是 Spring AI 1.0.0-M6,多模态消息用 Media 挂载:
@Service
public class ScreenshotExtractor {
private final ChatModel visionModel; // qwen-vl-max
public TicketInfo extract(byte[] image) {
var media = new Media(MimeTypeUtils.IMAGE_PNG,
new ByteArrayResource(image));
var message = UserMessage.builder()
.text("""
从这张工单截图中提取以下字段,以 JSON 返回,不要解释:
{"orderNo":"", "errorCode":"", "occurTime":"", "module":""}
找不到就填 null,不要编造。
""")
.media(media)
.build();
return ChatClient.builder(visionModel).build()
.prompt(new Prompt(List.of(message)))
.call()
.entity(TicketInfo.class);
}
}
有两个点必须注意:
- prompt 里要写"找不到就填 null,不要编造"。第一版没写这句,遇到模糊截图模型会一本正经地编一个订单号出来,客服录入了才发现是假的,这个 bug 上线两天才被发现;
- 图片要压缩。原始截图动辄 2~4MB,视觉模型按图片 token 计费,一张 4MB 的图大概要 1600 token。我们在调用前统一缩放到长边 1280、转 JPEG 质量 0.85,token 降到 700 左右,识别准确率没变化,成本直接砍一半。
实测数据(500 张真实工单截图):
| 项目 | 数值 |
|---|---|
| 平均耗时 | 3.4s(P95 6.1s) |
| 订单号识别准确率 | 94.2% |
| 错误码识别准确率 | 88.7% |
| 发生时间识别准确率 | 81.3% |
| 单张成本 | ¥0.021(压缩前 ¥0.048) |
时间字段准确率偏低,原因是截图里的时间格式五花八门,还有相对时间("3分钟前")。最后我们改成让模型返回原始文本,时间解析交给 Java 侧的归一化逻辑,准确率提到 96%。能用确定性代码解决的部分,别交给模型。
文档解析:OCR 只是起点
同一时期还有个需求:把历史合同扫描件(大概 12 万页 PDF)转成结构化数据入知识库。我一开始的想法是"OCR 一下不就行了",做完才发现天真了。
纯 OCR 出来的东西是一堆文本碎片,没有结构。一份双栏排版的合同,OCR 会把左右两栏的文字混在一行;表格被拆成一串单元格,行列关系全丢;跨页的条款被切成两半。这些内容直接喂给 embedding 模型,检索效果非常差。
我们最后用的是版面分析 + OCR + 结构还原三段式:
- 版面分析:用版面分析模型标出每页的区域类型和坐标(标题/正文/表格/图片/页眉页脚),剔除页眉页脚这种噪声;
- OCR:按区域逐个识别,表格区域单独走表格识别,直接输出 HTML 表格结构;
- 结构还原:按坐标和阅读顺序(双栏要判断栏序)重新拼装成 Markdown。
这套流程我们用 Python 写成独立服务(版面分析和表格识别的开源模型都是 Python 生态的),Java 侧通过内部 HTTP 调用 + 消息队列异步处理。这个分工是刻意的,别在 Java 里硬造轮子。
Java 侧的调度代码大致这样:
@KafkaListener(topics = "doc.parse.task", concurrency = "8")
public void handle(DocParseTask task) {
try {
ParseResult result = layoutClient.parse(task.fileId());
List<Chunk> chunks = chunker.splitByLayout(result.markdown());
vectorStore.accept(chunks);
docRepo.markParsed(task.docId(), chunks.size());
} catch (Exception e) {
log.error("parse failed, docId={}", task.docId(), e);
docRepo.markFailed(task.docId(), e.getMessage()); // 不重试,人工看
}
}
有个反直觉的发现:扫描件直接视觉模型识别(跳过 OCR)在关键信息抽取上效果更好。我们抽了 200 份合同做对照,纯视觉模型端到端抽取甲方乙方、金额、签署日期的准确率是 91%,OCR + LLM 两段式是 86%。原因是 OCR 的错误会传导到 LLM,而视觉模型能看图理解上下文。但视觉模型贵 8 倍,所以我们最后只在高质量要求的关键字段抽取上用视觉模型,批量入库还是走 OCR + 版面分析。
| 方案 | 单页成本 | 单页耗时 | 字段抽取准确率 | 适用性 |
|---|---|---|---|---|
| 纯 OCR | ¥0.002 | 0.8s | 不可用(无结构) | 全文检索 |
| OCR + 版面分析 | ¥0.011 | 2.3s | 86% | 批量入库 |
| 视觉模型端到端 | ¥0.085 | 6.7s | 91% | 关键字段 |
音频转写:分片和说话人分离是坑
第三个场景是客服通话录音转写,平均一通 8 分钟,每天 3000 通左右。用的是 Whisper 的本地部署(faster-whisper large-v3,一张 A10 上跑)。
Java 侧要处理几件事:
- 音频预处理:录音是 8kHz 单声道电话音质,转成 16kHz 再送,字错率能降 3 个点。转换用 FFmpeg,Java 里
ProcessBuilder调; - 分片并行:一通 8 分钟的音频整段转写要 40 多秒。我们按静音检测(VAD)切成 8~12 段,并发调用三个转写实例,端到端降到 14 秒;
- 热词:产品名、专业术语误识别严重。Whisper 支持
initial_prompt注入热词,我们把 800 个业务术语拼成 prompt 传进去,"云控制台"被识别成"运控之台"这类错误基本消失。
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-i", src, "-ar", "16000", "-ac", "1", dst);
pb.redirectErrorStream(true);
Process p = pb.start();
if (!p.waitFor(30, TimeUnit.SECONDS)) {
p.destroyForcibly();
throw new TranscodeTimeoutException(src);
}
踩过一个很隐蔽的坑:ProcessBuilder 启动的 FFmpeg 如果不消费它的 stdout/stderr,缓冲区写满后进程会卡死。我们一开始只调用了 waitFor(),结果跑到第 200 多个文件时全部挂住,线程池被占满。加上 redirectErrorStream(true) 并且把输出读掉才解决。这个问题在压测阶段没暴露,因为压测量太小。
转写质量上,中文普通话的字错率(CER)实测:
- 不加预处理不加热词:12.4%
- 加 16kHz 重采样:9.1%
- 再加业务热词:5.8%
5.8% 对后续的工单分类已经够用了。方言重的通话还是不行(能到 20% 以上),这类我们标记为低置信度,转人工复核。
Java 侧比较通用的几个实践
三个场景做完,有几条经验是通用的:
- 多模态输入走对象存储 URL,别走 base64。早期我们直接在 JSON 里塞 base64,一个 3MB 的图片变成 4MB 的字符串,网关的 body 大小限制、日志打印、链路追踪采样全被搞崩。改成先传对象存储,模型调用只传 URL;
- 异步化是必选项。视觉模型 3.4s、文档解析 2.3s/页、音频转写 14s,这些耗时在同步链路里完全不可接受。我们的做法是所有多模态处理走消息队列,前端提交后轮询任务状态;
- 结果必须结构化校验。
.entity(TicketInfo.class)这种结构化输出是方便,但模型返回的 JSON 经常缺字段或者类型不对。我在转换外层统一包了一层校验和兜底,把缺失字段置 null 并记录,而不是让异常直接抛到前端; - 成本要在入口就预估。用户一次上传 200 页 PDF 是常事,我们在提交任务时就按页数算出预计成本,超预算的直接拒绝,避免有人误操作烧掉一天的钱。
小结
多模态在 Java 应用里落地,技术上不复杂——无非是调几个 HTTP 接口。真正的复杂度在工程侧:异步化、成本控制、结果校验、失败重试、质量评估。
另一个感受是,多模态 ≠ 一股脑丢给大模型。图像里的时间解析交给 Java 代码更准,文档的版面结构用专门的模型更便宜,音频的重采样和热词能显著提升准确率。把每个环节拆开,能用确定性手段解决的就不要花钱让模型猜,这既省钱又提质量。我们整个项目做完,成本比最开始的"全走视觉大模型"方案低了 76%。