多模态入门:图像理解与生成
本文是专栏《Spring AI 入门与实战》的第 7 篇,上一篇我们聊了《结构化输出:让 AI 稳定返回 Java 对象》,让模型输出变成可靠的 Java 数据。这一篇把"输入"从纯文本扩展到图片:怎么把一张发票图片直接丢给模型抽出结构化字段,以及怎么反过来让模型画图。
一、场景引入
财务同事提了个需求:每月几千张报销发票要人工录入,OCR 厂商按调用收费,识别出原始文本之后,"发票抬头、金额、税号"这些关键字段还得再写一堆规则去抠。团队想试试视觉大模型(VLM):一张图进去,结构化字段出来,一个接口把识别和抽取一起做完。这就是多模态输入的价值。顺带还有个反向需求:运营活动页要配图,希望用一句话生成海报底图。Spring AI 1.1 对这两件事都有直接支持——前者是 ChatModel 的多模态消息,后者是独立的 ImageModel 抽象。本篇把两件事都讲清楚。
二、核心讲解
2.1 多模态消息的构造:Media + MimeType
依赖和第 6 篇一样,还是spring-ai-starter-model-openai。但配置要注意:DeepSeek 的 chat 模型不支持图像输入,图像理解任务我默认用通义的 qwen-vl-max(OpenAI 兼容端点):
spring:ai:openai:api-key:${DASHSCOPE_API_KEY}base-url:https://dashscope.aliyuncs.com/compatible-mode/v1chat:options:model:qwen-vl-maxtemperature:0.1多模态消息的核心类是org.springframework.ai.content.Media,它承载两部分信息:MIME 类型(MimeTypeUtils.IMAGE_PNG、IMAGE_JPEG等)和数据来源。来源支持两种:本地 Resource(classpath、文件、内存字节)和远程 URI:
// 本地 classpath 图片MedialocalPng=newMedia(MimeTypeUtils.IMAGE_PNG,newClassPathResource("docs/invoice.png"));// 远程 URL 图片MediaremoteJpg=newMedia(MimeTypeUtils.IMAGE_JPEG,URI.create("https://example.com/receipt.jpg"));有了 Media,用UserMessage.builder()把文字和图片组装成一条用户消息:
UserMessagemessage=UserMessage.builder().text("请描述这张图片的内容。").media(localPng).build();2.2 实战:票据信息抽取,替代传统 OCR
这个场景是上一篇entity()的天然搭档:图片进、对象出。定义返回类型:
publicrecordInvoiceInfo(Stringtitle,// 发票抬头StringinvoiceNo,// 发票号码BigDecimalamount,// 价税合计StringissueDate,// 开票日期,yyyy-MM-ddStringsellerTaxId// 销方税号){}服务类:
@ServicepublicclassInvoiceOcrService{privatefinalChatClientchatClient;publicInvoiceOcrService(ChatModelchatModel){this.chatClient=ChatClient.builder(chatModel).build();}publicInvoiceInfoextract(Resourceimage){UserMessagemessage=UserMessage.builder().text(""" 请从这张发票图片中抽取字段:发票抬头、发票号码、\ 价税合计金额、开票日期、销方税号。 金额保留两位小数,日期用 yyyy-MM-dd 格式。 金额以票面大写人民币为准。看不清的字段填null,不要猜。""").media(newMedia(MimeTypeUtils.IMAGE_PNG,image)).build();returnchatClient.prompt(newPrompt(message)).call().entity(InvoiceInfo.class);}}一个最简的文件上传接口:
@RestControllerpublicclassInvoiceController{privatefinalInvoiceOcrServiceservice;publicInvoiceController(InvoiceOcrServiceservice){this.service=service;}@PostMapping("/invoice")publicInvoiceInfoupload(@RequestParam("file")MultipartFilefile)throwsIOException{returnservice.extract(newByteArrayResource(file.getBytes()));}}说清楚"替代传统 OCR"的思路与代价。思路:传统方案是两段式——先检测加识别出文本行,再用规则或小模型抽取字段;VLM 是端到端,图文一起理解,字段抽取直接在推理里完成,规则代码几乎清零。代价有四个:一是精度不到 100%,金额这类关键字段必须有校验手段(双通道比对或人工抽检);二是单价远高于传统 OCR,调用一次 VLM 的费用可能是 OCR 的几十倍;三是时延是秒级,批量回填任务要想清楚吞吐;四是幻觉,模型看不清时可能"编"一个合理值——所以提示词里"看不清填 null,不要猜"这句话是保命的,没有它,错误会以高置信度的样子流进财务系统。
2.3 图像生成:ImageModel
生成是另一个方向的抽象:ImageModel。spring-ai-starter-model-openai里自带 OpenAI 的实现(dall-e-3),国内模型可以用智谱的 starter(CogView 系列),API 形态一致。以 OpenAI 为例:
spring:ai:openai:api-key:${OPENAI_API_KEY}image:options:size:1024x1024注意image模块可以单独配 base-url 和 api-key(spring.ai.openai.image.base-url等),也就是说聊天走 DeepSeek、画图走 OpenAI 可以共存于同一个应用。
@ServicepublicclassPosterService{privatefinalImageModelimageModel;publicPosterService(ImageModelimageModel){this.imageModel=imageModel;}publicStringgenerate(Stringscene){ImageResponseresponse=imageModel.call(newImagePrompt("扁平插画风:"+scene+",暖色调,构图留白,适合做活动海报底图",OpenAiImageOptions.builder().withModel("dall-e-3").withWidth(1024).withHeight(1024).withResponseFormat("url")// 返回图片 URL;改 b64_json 可直接拿字节.build()));returnresponse.getResult().getOutput().getUrl();}}一个容易被忽略的细节:url格式返回的链接是有时效的(OpenAI 大约一小时后失效),生产上拿到链接要立刻下载并转存到自己的对象存储,别把临时 URL 直接写进数据库。
三、生产视角
图像 token 成本要估算。主流 VLM 按分辨率折算 token,比如 OpenAI 的 vision 系列大致按图片面积切 tile 计费,一张 2048×1536 的照片可能折算出几千 token,比整个文本 prompt 还贵。通义、智谱各有各的折算规则。上线前拿真实图片压测一次,把单图 token 数算出来,再乘 QPS,别等账单出来才看。
大图压缩是第一优化项。送到模型前把图片缩到性价比最优的档位:多数 VLM 在 768~2048px 之间表现稳定,票据抽取这种任务 1500px 长边完全够用,JPEG 质量 80 肉眼无损。Java 侧用 Thumbnailator 两行搞定:Thumbnails.of(file).size(1600, 1600).outputQuality(0.8).toFile(...)。压缩做在服务端入口,收益同时体现在成本、时延和成功率三个维度。
超限报错要处理。图片过大时厂商直接拒绝,典型报错如Invalid input image - content size too large,各家的字节上限不同(base64 编码还会再膨胀约三分之一)。上传接口要前置校验尺寸和大小,给用户可读的提示,而不是把 400 原样透传。
安全与合规。发票、身份证、银行卡属于敏感个人信息,上传到第三方云服务前要过合规评审:尽量走企业协议端点、明确数据不留存条款、日志里只记图片哈希不记原图,必要时考虑本地化部署的 VLM。
缓存与幂等。对同一张图(内容哈希相同)别重复调用,结果按哈希缓存;批量回填任务要支持断点重跑。
幻觉兜底。金额、税号这类强校验字段,抽取结果必须过格式校验(位数、校验位),失败转人工。多模态模型的幻觉比文本更隐蔽,因为它"看起来很确定"。
四、踩坑记录
坑一:给 DeepSeek 发图,直接被 400 拒绝。项目最初统一用 deepseek-chat,发图后报:
org.springframework.web.client.HttpClientErrorException$BadRequest: 400 Bad Request on POST request for "https://api.deepseek.com/chat/completions": {"error":{"message":"Invalid request: content type is not supported", "type":"invalid_request_error","param":null,"code":"invalid_request_error"}}原因很直接:DeepSeek 的 chat 模型不支持图像输入,消息里的图片部分整个被拒。解决方案是图像任务单独走一个指向通义 compatible-mode 端点的配置(model 用 qwen-vl-max)。换成视觉模型后又暴露一个软性问题:中文发票上"壹仟贰佰叁拾元整"的大写金额识别偶发出错,在提示词里加上"金额以票面大写人民币为准"之后,正确率明显上来。多模态的坑一半在框架,一半在提示词。
坑二:手机直拍原图触发大小限制。测试同事拿手机直拍原图上传,报:
org.springframework.web.client.HttpClientErrorException$BadRequest: 400 Bad Request: {"error":{"message":"Invalid input image - content size too large", "type":"invalid_request_error"}}原图 4032×3024、4.8MB,base64 之后膨胀到 6MB 以上,超出接口限制。解决:服务端入口统一压缩,长边 1600、质量 0.8,单图压到 500KB 以内。顺手统计了一下,压缩后单图 token 成本降了六成多——这个坑踩得值,它逼着我们把压缩逻辑做成了标配。
五、小结与练习
这一篇的要点:多模态输入等于UserMessage加Media(org.springframework.ai.content.Media),数据来源支持本地 Resource 和远程 URI;票据抽取场景和entity()是天作之合,但"看不清填 null"的提示词和业务校验缺一不可;图像生成走ImageModel抽象,OpenAI 和智谱都开箱即用,返回 URL 要及时转存;图像按分辨率计费,压缩是性价比最高的优化。
练习:找一张你手头的截图或单据照片,写一个接口返回record ImageReport(String description, List<String> textsInImage),输出图片描述和图里出现的全部文字。然后分别在原图和压缩后的图上调用,对比响应里的 token 消耗与识别结果差异,你会对"压缩到什么程度开始丢信息"有手感。