如今的大模型不再局限于文本对话——GPT‑4o、通义千问视觉版(Qwen‑VL)、Gemini 都支持图片输入。把一张图丢给模型,它能理解图中的内容并回答相关问题。
这一节,我们就在 Spring AI 中实现多模态输入,让应用真正“看懂”世界。

一、多模态的应用场景

先说说这个能力能用来做什么,不然写完代码大家不知道往哪用:

场景 说明
发票/收据识别 上传发票图片,自动提取金额、日期、商家信息
商品图片分析 用户上传商品图,自动识别品牌、型号、成色
图表数据提取 上传报表截图,提取其中的数据
UI/截图分析 分析页面截图,提取信息或定位界面问题
文档 OCR + 理解 不只是识别文字,还能理解文档的语义
质检辅助 上传产品图片,判断是否有缺陷

有了这些场景的认知,我们再来看代码实现。

二、基础用法:传图片 URL

最简单的方式是传入一个可公开访问的图片 URL


下面是一个完整的 VisionController,提供三个核心接口:

  • 单张图片 URL 分析

  • 上传图片文件分析

  • 多张图片对比

package com.studying.controller.ptoto;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;

@RestController
@RequestMapping("/api/vision")
public class VisionController {

    private final ChatClient chatClient;

    public VisionController(DashScopeChatModel dashScopeChatModel) {
        this.chatClient = ChatClient.builder(dashScopeChatModel).build();
    }

    // 视觉模型通用配置
    private static final DashScopeChatOptions VL_OPTIONS = DashScopeChatOptions.builder()
            .withModel("qwen-vl-max")      // 必须用视觉模型,qwen-max 不支持图片
            .withMultiModel(true)          // 必须设置,否则请求会打到文本端点
            .build();

    // 1. 传图片 URL 分析
    @GetMapping("/analyze-url")
    public String analyzeImageUrl(
            @RequestParam String imageUrl,
            @RequestParam(defaultValue = "请描述这张图片的内容") String question) {

        Media media = Media.builder()
                .mimeType(MimeTypeUtils.IMAGE_JPEG)
                .data(URI.create(imageUrl))
                .build();

        UserMessage message = UserMessage.builder()
                .text(question)
                .media(media)
                .build();

        return chatClient.prompt()
                .messages(message)
                .options(VL_OPTIONS)
                .call()
                .content();
    }

    // 2. 上传图片文件分析
    @PostMapping("/analyze-upload")
    public String analyzeUploadedImage(
            @RequestParam("image") MultipartFile imageFile,
            @RequestParam(defaultValue = "请描述这张图片的内容") String question) throws Exception {

        MimeType mimeType = MimeType.valueOf(
                imageFile.getContentType() != null ? imageFile.getContentType() : "image/jpeg");

        Media media = Media.builder()
                .mimeType(mimeType)
                .data(imageFile.getResource())
                .build();

        UserMessage message = UserMessage.builder()
                .text(question)
                .media(media)
                .build();

        return chatClient.prompt()
                .messages(message)
                .options(VL_OPTIONS)
                .call()
                .content();
    }

    // 3. 多张图片对比
    @PostMapping("/compare-images")
    public String compareImages(
            @RequestParam("images") List<MultipartFile> images,
            @RequestParam String question) throws Exception {

        List<Media> mediaList = new ArrayList<>();
        for (MultipartFile image : images) {
            MimeType mimeType = MimeType.valueOf(
                    image.getContentType() != null ? image.getContentType() : "image/jpeg");
            mediaList.add(Media.builder()
                    .mimeType(mimeType)
                    .data(image.getResource())
                    .build());
        }

        UserMessage message = UserMessage.builder()
                .text(question)
                .media(mediaList)
                .build();

        return chatClient.prompt()
                .messages(message)
                .options(VL_OPTIONS)
                .call()
                .content();
    }
}

如果文件超出限制可配置:(Spring Boot 默认文件上传大小限制为 1MB(max-file-size),请求总大小限制为 10MB(max-request-size))

spring:
  servlet:
    multipart:
      max-file-size: 100MB        # 单个文件最大大小
      max-request-size: 100MB     # 请求总大小

测试命令

# 传 URL
curl "http://localhost:8080/api/vision/analyze-url?imageUrl=https://example.com/photo.jpg&question=图片里有什么"

# 上传图片文件
curl -X POST "http://localhost:8080/api/vision/analyze-upload" \
  -F "image=@/path/to/photo.jpg" \
  -F "question=描述这张图片"

# 多张图片对比
curl -X POST "http://localhost:8080/api/vision/compare-images" \
  -F "images=@before.jpg" \
  -F "images=@after.jpg" \
  -F "question=对比这两张图片的差异"

三、实战:发票识别(结构化输出)

财务报销场景中,我们需要从发票图片里提取出结构化的字段,而不是自由文本。
Spring AI 支持将模型的输出直接映射为 Java record,非常方便。

package com.studying.controller.ptoto;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/invoice")
public class InvoiceController {

    private final ChatClient chatClient;

    public InvoiceController(DashScopeChatModel dashScopeChatModel) {
        this.chatClient = ChatClient.builder(dashScopeChatModel)
                .defaultSystem("""
                        你是一个发票信息提取助手。
                        精确提取发票上的信息,不要猜测,看不清楚的字段返回 null。
                        金额统一用数字表示,不要带"元"或"¥"符号。
                        """)
                .build();
    }

    record InvoiceInfo(
        @JsonPropertyDescription("发票号码")
        String invoiceNumber,

        @JsonPropertyDescription("开票日期,格式 yyyy-MM-dd")
        String invoiceDate,

        @JsonPropertyDescription("销售方名称(卖家)")
        String sellerName,

        @JsonPropertyDescription("销售方税号")
        String sellerTaxId,

        @JsonPropertyDescription("购买方名称(买家)")
        String buyerName,

        @JsonPropertyDescription("购买方税号")
        String buyerTaxId,

        @JsonPropertyDescription("不含税金额,纯数字")
        Double amountExcludingTax,

        @JsonPropertyDescription("税额,纯数字")
        Double taxAmount,

        @JsonPropertyDescription("价税合计(含税总金额),纯数字")
        Double totalAmount,

        @JsonPropertyDescription("货物或服务名称")
        String items
    ) {}

    @PostMapping("/extract")
    public InvoiceInfo extractInvoice(@RequestParam("file") MultipartFile file) throws Exception {

        MimeType mimeType = MimeType.valueOf(
                file.getContentType() != null ? file.getContentType() : "image/jpeg");

        Media media = Media.builder()
                .mimeType(mimeType)
                .data(file.getResource())
                .build();

        UserMessage message = UserMessage.builder()
                .text("请提取这张发票上的所有信息")
                .media(media)
                .build();

        return chatClient.prompt()
                .messages(message)
                .options(DashScopeChatOptions.builder()
                        .withModel("qwen-vl-max")
                        .withMultiModel(true)
                        .build())
                .call()
                .entity(InvoiceInfo.class);
    }
}

测试

curl -X POST "http://localhost:8080/api/invoice/extract" -F "file=@invoice.jpg"

返回的 JSON 会直接映射为 InvoiceInfo 对象,前端可以直接使用。

四、实战:商品图片分析

电商场景中,用户上传二手商品图片,我们需要自动分析成色、特征、瑕疵并给出建议定价

package com.studying.controller.ptoto;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;

@RestController
@RequestMapping("/api/product")
public class ProductAnalysisController {

    private final ChatClient chatClient;

    public ProductAnalysisController(DashScopeChatModel dashScopeChatModel) {
        this.chatClient = ChatClient.builder(dashScopeChatModel)
                .defaultSystem("你是一个二手商品鉴定专家,擅长评估商品价值和状态。定价参考市场行情,客观准确。")
                .build();
    }

    record ProductAnalysis(
        @JsonPropertyDescription("商品类别,如:手机/笔记本/衣服")
        String category,

        @JsonPropertyDescription("品牌(如果能识别的话)")
        String brand,

        @JsonPropertyDescription("商品状态:全新/9成新/7-8成新/5-6成新/需维修")
        String condition,

        @JsonPropertyDescription("识别到的主要特征,最多5条")
        List<String> features,

        @JsonPropertyDescription("明显的瑕疵描述,没有则为空列表")
        List<String> defects,

        @JsonPropertyDescription("建议的二手定价区间,格式:最低价-最高价,单位元")
        String suggestedPriceRange,

        @JsonPropertyDescription("商品描述,适合用于二手交易平台的文案,100字以内")
        String description
    ) {}

    @PostMapping("/analyze")
    public ProductAnalysis analyzeProduct(@RequestParam("image") MultipartFile imageFile) throws Exception {

        MimeType mimeType = MimeType.valueOf(
                imageFile.getContentType() != null ? imageFile.getContentType() : "image/jpeg");

        Media media = Media.builder()
                .mimeType(mimeType)
                .data(imageFile.getResource())
                .build();

        UserMessage message = UserMessage.builder()
                .text("请分析这个二手商品的状况,并给出合理的定价建议")
                .media(media)
                .build();

        return chatClient.prompt()
                .messages(message)
                .options(DashScopeChatOptions.builder()
                        .withModel("qwen-vl-max")
                        .withMultiModel(true)
                        .build())
                .call()
                .entity(ProductAnalysis.class);
    }
}

测试

curl -X POST "http://localhost:8080/api/product/analyze" -F "image=@product.jpg"

五、通义千问视觉版的配置要点

使用 DashScope 视觉模型时,有两个必填项,少一个就会报错:

配置项 作用
model 必须为 qwen-vl-max 或 qwen-vl-plus,普通 qwen-max 不支持图片
multi-model 必须设为 true,否则请求会发送到文本端点,导致 url error

5.1在 application.yml 中全局配置(项目只用视觉模型时)

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-vl-max
          multi-model: true   # 缺少这行会报错

5.2在代码中动态指定(同一项目混用文本和图片请求时)

DashScopeChatOptions.builder()
        .withModel("qwen-vl-max")
        .withMultiModel(true)
        .build()

六、图片大小与格式限制

不同模型对图片的限制不同,开发时最好在接口层提前校验:

模型 支持格式 大小限制
GPT‑4o JPEG, PNG, GIF, WEBP 单张 20 MB
Qwen‑VL JPEG, PNG 单张 10 MB(URL 更小)

我们可以写一个通用的校验方法,在 Controller 中复用:

private void validateImage(MultipartFile file) {
    // 大小校验
    if (file.getSize() > 10 * 1024 * 1024) {
        throw new IllegalArgumentException("图片大小不能超过 10MB");
    }
    // 格式校验
    String contentType = file.getContentType();
    if (contentType == null || !List.of("image/jpeg", "image/png", "image/webp")
            .contains(contentType)) {
        throw new IllegalArgumentException("只支持 JPEG、PNG、WEBP 格式");
    }
}

在每个多模态接口(如 extractInvoiceanalyzeProduct)中,调用该方法即可。

七、小结

Spring AI 的多模态支持让我们能轻松地将视觉能力集成到业务中。通过 ChatClient + UserMessage + Media 的组合,无论是单图分析、多图对比,还是结构化输出,代码都非常简洁。而通义千问视觉版(Qwen‑VL)在国内部署的稳定性和合规性上更有优势,非常适合企业级应用。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐