本文是「Spring Boot + AI 全栈后端」系列第 10 篇。前面 9 篇把"能不能对话、成本、结构化、实时数据、私有知识、Agent、MCP、流式"都铺开了,这一篇处理一个过去只能靠人眼干的活:让模型真正"看图"------商品图上架前的合规审核、报销发票的字段抽取。示例基于 Spring AI 2.0 / Boot 4.1。
Spring Boot 用多模态做商品图审核 / 发票识别

一、这两个活,过去全靠人眼硬扛
V哥先说两个真实到不能再真实的场景。
场景 A:电商后台商品图审核。 运营每天上架几百个 SKU,每个商品主图都要过一遍平台规范:有没有第三方水印、有没有外部二维码、有没有违规文字、尺寸够不够大。审核员一张张看、一张张打勾,下午三点以后眼睛就花了,标准是"上午严、下午松",漏放一张带二维码的图,平台直接扣分。
场景 B:报销发票识别。 财务收到一堆纸质/电子发票,要把发票代码、号码、金额、开票日期、销售方、购买方一个个敲进报销系统。一张开错,整单退回来重走流程。量大、重复、纯体力。
这两个活的共同点是:输入是一张图,产出是结构化结论。过去要么人工看,要么上 OCR 专项模型再写一堆规则后处理。V哥的看法是------多模态大模型把"看图 + 理解 + 按字段输出"这三步合成一步,Spring Boot 接入的成本比很多人想的低得多。
二、多模态不是"图存盘再人工看",而是"图进消息体"
很多人对多模态的理解停在有偏差的地方:以为是把图片 URL 传给模型、模型自己下载看。在 Spring AI 里,正确的姿势是把图片作为 Media 挂到 UserMessage 上 ,和文字指令一起送进 ChatClient。图没挂上,模型就是在瞎猜------这是后面要重点验的点。
Spring AI 2.0 的 API 非常直白:
java
Media media = Media.builder()
.mimeType(MimeTypeUtils.IMAGE_PNG)
.data(image) // image 是 org.springframework.core.io.Resource
.build();
String answer = 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
V哥把发票识别做成一个独立 Service,入参是一张 Resource,出参是结构化 Invoice:
java
@Service
public class InvoiceExtractService {
private final ChatClient chatClient;
public InvoiceExtractService(ChatModel chatModel) {
this.chatClient = ChatClient.create(chatModel);
}
public Invoice extract(Resource image) {
if (image == null) throw new AiServiceException("发票图片不能为空");
Media media = Media.builder()
.mimeType(MimeTypeUtils.IMAGE_PNG)
.data(image)
.build();
return chatClient.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,这是"图真正进入模型"的入口:
java
@PostMapping(value = "/invoice", consumes = MediaType.APPLICATION_JSON_VALUE)
public Invoice invoice(@Valid @RequestBody InvoiceExtractRequest request) {
Resource image = new ByteArrayResource(Base64.getDecoder().decode(request.imageBase64()));
return invoiceService.extract(image);
}
用 base64 而不是 MultipartFile,好处是接口在 JSON 调用、消息队列、内部服务之间都能传,不绑死 HTTP 表单。

四、实战二:商品图审核,机器粗筛、人只复核红区
审核比识别多一层"判断"。V哥让模型按类目给出合规结论 + 理由清单,前端只把不合规的推给人工:
java
@Service
public class ProductReviewService {
private final ChatClient chatClient;
public ProductReviewService(ChatModel chatModel) {
this.chatClient = ChatClient.create(chatModel);
}
public ReviewResult review(Resource image, String category) {
if (image == null) throw new AiServiceException("商品图片不能为空");
if (category == null || category.isBlank())
throw new AiServiceException("商品类目不能为空");
Media media = Media.builder()
.mimeType(MimeTypeUtils.IMAGE_PNG)
.data(image)
.build();
String prompt = String.format(
"这是一张%s类商品的主图,请按电商规范审核:是否含违规文字、第三方水印、"
+ "外部二维码、低俗或侵权内容,主图尺寸是否过小。"
+ "仅以 JSON 返回:{\"compliant\":true/false,\"reasons\":[\"不合规点\"]}。",
category);
return chatClient.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:
java
public class MultimodalStubChatModel implements ChatModel {
@Override
public ChatResponse call(Prompt prompt) {
UserMessage um = prompt.getInstructions().stream()
.filter(m -> m instanceof UserMessage)
.map(m -> (UserMessage) m)
.reduce((a, b) -> b).orElse(null);
this.lastUserMessage = um; // 记住这次请求带了什么
String text = um == null ? "" : um.getText();
String reply = text.contains("发票") ? INVOICE_JSON : REVIEW_JSON;
return new ChatResponse(List.of(new Generation(new AssistantMessage(reply))));
}
public int getMediaCount() { // 断言:图真的挂在消息上了
return lastUserMessage == null || lastUserMessage.getMedia() == null
? 0 : lastUserMessage.getMedia().size();
}
}
测试里要验两件大事:① 图确实被挂到了 Media(没挂就是事故);② 模型吐的 JSON 能被 entity() 解析成 POJO。一个用例长这样:
java
Invoice invoice = 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哥聊一个更硬的话题:接口写完了,怎么真正扛住生产流量------限流、降级、可观测,本地能跑和上线不挂之间,差的就是这几层。