本文是「Spring Boot + AI 全栈后端」系列第 10 篇。前面 9 篇把"能不能对话、成本、结构化、实时数据、私有知识、Agent、MCP、流式"都铺开了,这一篇处理一个过去只能靠人眼干的活:让模型真正"看图"——商品图上架前的合规审核、报销发票的字段抽取。示例基于 Spring AI 2.0 / Boot 4.1。
Spring Boot 用多模态做商品图审核 / 发票识别
一、这两个活,过去全靠人眼硬扛
先说两个真实到不能再真实的场景。
场景 A:电商后台商品图审核。运营每天上架几百个 SKU,每个商品主图都要过一遍平台规范:有没有第三方水印、有没有外部二维码、有没有违规文字、尺寸够不够大。审核员一张张看、一张张打勾,下午三点以后眼睛就花了,标准是"上午严、下午松",漏放一张带二维码的图,平台直接扣分。
场景 B:报销发票识别。财务收到一堆纸质/电子发票,要把发票代码、号码、金额、开票日期、销售方、购买方一个个敲进报销系统。一张开错,整单退回来重走流程。量大、重复、纯体力。
这两个活的共同点是:输入是一张图,产出是结构化结论。过去要么人工看,要么上 OCR 专项模型再写一堆规则后处理。V哥的看法是——多模态大模型把"看图 + 理解 + 按字段输出"这三步合成一步,Spring Boot 接入的成本比很多人想的低得多。
二、多模态不是"图存盘再人工看",而是"图进消息体"
很多人对多模态的理解停在有偏差的地方:以为是把图片 URL 传给模型、模型自己下载看。在 Spring AI 里,正确的姿势是把图片作为Media挂到UserMessage上,和文字指令一起送进ChatClient。图没挂上,模型就是在瞎猜——这是后面要重点验的点。
Spring AI 2.0 的 API 非常直白:
Mediamedia=Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(image)// image 是 org.springframework.core.io.Resource.build();Stringanswer=chatClient.prompt().user(u->u.text("这是一张增值税发票,请提取字段并以 JSON 返回").media(media)).call().content();关键就一句:.user(u -> u.text(prompt).media(media))。Media的data()收的是Resource,所以前端传来的 base64 要先解码成ByteArrayResource;mimeType决定模型怎么解析,图片用image/png或image/jpeg。
三、实战一:发票识别,把图直接抽成 POJO
把发票识别做成一个独立 Service,入参是一张Resource,出参是结构化Invoice:
@ServicepublicclassInvoiceExtractService{privatefinalChatClientchatClient;publicInvoiceExtractService(ChatModelchatModel){this.chatClient=ChatClient.create(chatModel);}publicInvoiceextract(Resourceimage){if(image==null)thrownewAiServiceException("发票图片不能为空");Mediamedia=Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(image).build();returnchatClient.prompt().user(u->u.text(INVOICE_PROMPT).media(media)).call().entity(Invoice.class);// 直接落 POJO,复用第 04 篇的结构化输出}}这里Invoice是个 record,字段上带@JsonPropertyDescription,既是给模型写 JSON Schema 的说明,也帮 Jackson 在entity()反序列化时对键名。金额、日期先留字符串——模型吐的格式不一定规整,先原样接住,落地前再转BigDecimal/LocalDate,别在模型这层就强转,一转就崩。
接口层负责把 base64 解码成 Resource,这是"图真正进入模型"的入口:
@PostMapping(value="/invoice",consumes=MediaType.APPLICATION_JSON_VALUE)publicInvoiceinvoice(@Valid@RequestBodyInvoiceExtractRequestrequest){Resourceimage=newByteArrayResource(Base64.getDecoder().decode(request.imageBase64()));returninvoiceService.extract(image);}用 base64 而不是MultipartFile,好处是接口在 JSON 调用、消息队列、内部服务之间都能传,不绑死 HTTP 表单。
四、实战二:商品图审核,机器粗筛、人只复核红区
审核比识别多一层"判断"。让模型按类目给出合规结论 + 理由清单,前端只把不合规的推给人工:
@ServicepublicclassProductReviewService{privatefinalChatClientchatClient;publicProductReviewService(ChatModelchatModel){this.chatClient=ChatClient.create(chatModel);}publicReviewResultreview(Resourceimage,Stringcategory){if(image==null)thrownewAiServiceException("商品图片不能为空");if(category==null||category.isBlank())thrownewAiServiceException("商品类目不能为空");Mediamedia=Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(image).build();Stringprompt=String.format("这是一张%s类商品的主图,请按电商规范审核:是否含违规文字、第三方水印、"+"外部二维码、低俗或侵权内容,主图尺寸是否过小。"+"仅以 JSON 返回:{\"compliant\":true/false,\"reasons\":[\"不合规点\"]}。",category);returnchatClient.prompt().user(u->u.text(prompt).media(media)).call().entity(ReviewResult.class);}}注意我把category拼进了 prompt——食品、3C、服饰的审核尺度不一样,告诉模型类目,它的判断才贴边。返回ReviewResult(布尔compliant+reasons列表),合规的秒过,不合规的带原因进人工复核队列。人工从"看全部"变成"只看红区",这就是多模态在这个场景里真正省下的成本。
五、离线怎么验证:桩把"图+文"一起收下
V哥写文章前,这段代码是先在code/verify工程跑绿的。难点在离线——不能真连多模态模型。做法是写一个桩ChatModel,它把UserMessage上的Media记下来,再按指令吐发票 JSON 或审核 JSON:
publicclassMultimodalStubChatModelimplementsChatModel{@OverridepublicChatResponsecall(Promptprompt){UserMessageum=prompt.getInstructions().stream().filter(m->minstanceofUserMessage).map(m->(UserMessage)m).reduce((a,b)->b).orElse(null);this.lastUserMessage=um;// 记住这次请求带了什么Stringtext=um==null?"":um.getText();Stringreply=text.contains("发票")?INVOICE_JSON:REVIEW_JSON;returnnewChatResponse(List.of(newGeneration(newAssistantMessage(reply))));}publicintgetMediaCount(){// 断言:图真的挂在消息上了returnlastUserMessage==null||lastUserMessage.getMedia()==null?0:lastUserMessage.getMedia().size();}}测试里要验两件大事:① 图确实被挂到了Media(没挂就是事故);② 模型吐的 JSON 能被entity()解析成 POJO。一个用例长这样:
Invoiceinvoice=invoiceService.extract(image);assertThat(invoice.code()).isEqualTo("011002300311");assertThat(invoice.amount()).isEqualTo("1999.00");assertThat(stub.getMediaCount()).isEqualTo(1);// 图挂上了assertThat(stub.getMedia().get(0).getMimeType().toString()).isEqualTo("image/png");六、三个最容易踩的坑
坑一:图没挂上 Media,模型在瞎猜。最常见的新手写法——把图片路径写进text()当字符串,而不是用.media()。模型收不到二进制,只能编。V哥的桩测试就是专门堵这个的:getMediaCount()==1不过,文章不发。
坑二:base64 解码位置错了。解码必须在后端做,前端传的是字符串。别在 Controller 里把 base64 当 Resource 直接塞——ByteArrayResource才认字节数组。解码失败(非法 base64)要尽早抛 400,别传到模型层才炸。
坑三:多模态贵且慢,别对每张图都调。商品图审核建议先过一道本地规则(尺寸、格式、哈希去重),重复图直接复用上次结论;发票识别同理,同一张发票别重复调。多模态的 token 里图片占比极高,乱调成本会爆。
七、落地要点(V哥的清单)
- 图进消息体:用
.media(Media),别把路径塞进文本。 - 先接住再清洗:金额/日期留字符串,落地转类型。
- 结构化复用:
entity(POJO.class)直接落对象,和第 04 篇一个套路。 - 机器粗筛人复核:审核类场景把不合规推人工,别指望模型 100% 拍板。
- 加本地预处理:去重、格式校验前置,别让每张图都烧多模态额度。
- 离线先跑绿:用桩把"图+文"一起收下,断言 Media 数量与解析结果,再写进文章。
多模态把"看"这件事第一次变成了可编排、可结构化、可离线条的普通接口。下一篇 V哥聊一个更硬的话题:接口写完了,怎么真正扛住生产流量——限流、降级、可观测,本地能跑和上线不挂之间,差的就是这几层。