【AI全栈后端12-10】Spring Boot 用多模态做商品图审核 / 发票识别
本文是「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 非常直白MediamediaMedia.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(image)// image 是 org.springframework.core.io.Resource.build();StringanswerchatClient.prompt().user(u-u.text(这是一张增值税发票请提取字段并以 JSON 返回).media(media)).call().content();关键就一句.user(u - u.text(prompt).media(media))。Media的data()收的是Resource所以前端传来的 base64 要先解码成ByteArrayResourcemimeType决定模型怎么解析图片用image/png或image/jpeg。三、实战一发票识别把图直接抽成 POJO把发票识别做成一个独立 Service入参是一张Resource出参是结构化InvoiceServicepublicclassInvoiceExtractService{privatefinalChatClientchatClient;publicInvoiceExtractService(ChatModelchatModel){this.chatClientChatClient.create(chatModel);}publicInvoiceextract(Resourceimage){if(imagenull)thrownewAiServiceException(发票图片不能为空);MediamediaMedia.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,consumesMediaType.APPLICATION_JSON_VALUE)publicInvoiceinvoice(ValidRequestBodyInvoiceExtractRequestrequest){ResourceimagenewByteArrayResource(Base64.getDecoder().decode(request.imageBase64()));returninvoiceService.extract(image);}用 base64 而不是MultipartFile好处是接口在 JSON 调用、消息队列、内部服务之间都能传不绑死 HTTP 表单。四、实战二商品图审核机器粗筛、人只复核红区审核比识别多一层判断。让模型按类目给出合规结论 理由清单前端只把不合规的推给人工ServicepublicclassProductReviewService{privatefinalChatClientchatClient;publicProductReviewService(ChatModelchatModel){this.chatClientChatClient.create(chatModel);}publicReviewResultreview(Resourceimage,Stringcategory){if(imagenull)thrownewAiServiceException(商品图片不能为空);if(categorynull||category.isBlank())thrownewAiServiceException(商品类目不能为空);MediamediaMedia.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(image).build();StringpromptString.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布尔compliantreasons列表合规的秒过不合规的带原因进人工复核队列。人工从看全部变成只看红区这就是多模态在这个场景里真正省下的成本。五、离线怎么验证桩把图文一起收下V哥写文章前这段代码是先在code/verify工程跑绿的。难点在离线——不能真连多模态模型。做法是写一个桩ChatModel它把UserMessage上的Media记下来再按指令吐发票 JSON 或审核 JSONpublicclassMultimodalStubChatModelimplementsChatModel{OverridepublicChatResponsecall(Promptprompt){UserMessageumprompt.getInstructions().stream().filter(m-minstanceofUserMessage).map(m-(UserMessage)m).reduce((a,b)-b).orElse(null);this.lastUserMessageum;// 记住这次请求带了什么Stringtextumnull?:um.getText();Stringreplytext.contains(发票)?INVOICE_JSON:REVIEW_JSON;returnnewChatResponse(List.of(newGeneration(newAssistantMessage(reply))));}publicintgetMediaCount(){// 断言图真的挂在消息上了returnlastUserMessagenull||lastUserMessage.getMedia()null?0:lastUserMessage.getMedia().size();}}测试里要验两件大事① 图确实被挂到了Media没挂就是事故② 模型吐的 JSON 能被entity()解析成 POJO。一个用例长这样InvoiceinvoiceinvoiceService.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哥聊一个更硬的话题接口写完了怎么真正扛住生产流量——限流、降级、可观测本地能跑和上线不挂之间差的就是这几层。