【AI全栈12-10】Spring Boot 用多模态做商品图审核 / 发票识别

本文是「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哥的清单)

  1. 图进消息体 :用 .media(Media),别把路径塞进文本。
  2. 先接住再清洗:金额/日期留字符串,落地转类型。
  3. 结构化复用 :entity(POJO.class) 直接落对象,和第 04 篇一个套路。
  4. 机器粗筛人复核:审核类场景把不合规推人工,别指望模型 100% 拍板。
  5. 加本地预处理:去重、格式校验前置,别让每张图都烧多模态额度。
  6. 离线先跑绿:用桩把"图+文"一起收下,断言 Media 数量与解析结果,再写进文章。

多模态把"看"这件事第一次变成了可编排、可结构化、可离线条的普通接口。下一篇 V哥聊一个更硬的话题:接口写完了,怎么真正扛住生产流量------限流、降级、可观测,本地能跑和上线不挂之间,差的就是这几层。

相关推荐
bruce541101 小时前
给 UniRAG RAG 配一个前端,为什么最后选了 Lobe UI
后端
imDwAaY1 小时前
用户态与内核态到底怎么切换?从系统调用到上下文切换
后端·操作系统
imDwAaY1 小时前
进程、线程、协程有什么区别?从资源分配到调度,一篇理清
后端·操作系统
轮子大叔1 小时前
Java8和Java17常见区别
java
威哥爱编程1 小时前
【AI全栈12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-08】Spring Boot 用 MCP 统一接入内部系统:让 AI 接一次,全公司复用
java·spring boot·后端
deviant-ART1 小时前
Java 并发编程:深入理解 synchronized 的使用与原理
java·开发语言·后端
斯内普吖1 小时前
(开源)校园充电宝租借管理系统实战指南 基于 Java + SSM + Vue + MySQL
java·vue.js·mysql·开源
爱喝热水的呀哈喽1 小时前
hm探查。
java·前端·人工智能