Administrator
发布于 2025-03-22 / 2349 阅读
44

多模态能力在 Java 应用中的集成

产品说"用户上传截图自动填工单"

三月中旬产品提了个需求:用户报障时经常甩一张截图过来,客服要手工把里面的订单号、错误码、时间点一个个敲进工单系统,平均 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 + 结构还原三段式:

  1. 版面分析:用版面分析模型标出每页的区域类型和坐标(标题/正文/表格/图片/页眉页脚),剔除页眉页脚这种噪声;
  2. OCR:按区域逐个识别,表格区域单独走表格识别,直接输出 HTML 表格结构;
  3. 结构还原:按坐标和阅读顺序(双栏要判断栏序)重新拼装成 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.0020.8s不可用(无结构)全文检索
OCR + 版面分析¥0.0112.3s86%批量入库
视觉模型端到端¥0.0856.7s91%关键字段

音频转写:分片和说话人分离是坑

第三个场景是客服通话录音转写,平均一通 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%。

参考