Spring AI 多模态:给大模型一双眼睛,图片它也能看懂

0 阅读14分钟

Spring AI 多模态:给大模型一双眼睛,图片它也能看懂

作者:鱼宵 | Spring AI 实战精通营 · 第 6 篇

前几天有个读者扔来一张报错截图问我:"能不能让我的 AI 接口直接看这张图、告诉我错在哪?"他照着前几课的写法,把工程配好、接口写好,图片也传上去了——结果模型回了一句"我不支持图片输入"。他懵了:都是大模型,为什么 ChatGPT、通义能看图,他配的 DeepSeek 就不行?

这篇文章把"多模态"讲透:什么叫模态、为什么有的模型长了"眼睛"、Spring AI 里怎么把图片塞进发给模型的消息。代码全在仓库 lesson-06/ 目录,clone 下来照着跑——跑之前先说好:图片识别部分我如实标了"待实测",为什么待实测、拿到 Key 怎么复测,第四节全交代,不拿编的输出糊弄你。

一、核心原理:为什么有的模型能看图,有的不能

先解释一个词:**模态(Modality)**就是信息的"形态"。文字是一种模态,图片是另一种,音频视频又是别的。多模态模型 = 能同时吃两种以上形态输入的模型;只能吃文字的叫纯文本模型。

1. 盲人 vs 视力正常:能力是训练出来的

视觉模型就是在普通大模型基础上加了一个"图片编码器",把图片转成模型能理解的表示,再和文字一起推理。deepseek-chat 像个只会读信的盲人,你把照片寄给他他摸不着;qwen-vl-plus、GPT-4o 像视力正常的人,信里夹的照片他也能看。

为什么 DeepSeek 不能看图?——它是纯文本模型,训练时就没见过图片数据,你硬塞图片它直接报错。选型第一步就是看模型的"输入模态"支不支持 image,不是调 API 碰运气。

2. 信封夹照片:Media + UserMessage

普通消息只有文字;多模态消息 = 文字指令 + 图片列表。Spring AI 里靠两个类:

  • Media(媒体):一段图片数据,内容可以是 base64 串,也可以是一个图片 URL。
  • UserMessage(用户消息):文字 text() + 挂一个 media 列表。

类比寄信:UserMessage 是信封,text 是信纸上的字,Media 是你额外夹进去的照片。照片有两种寄法:

喂法怎么传请求体前提
base64 数据图片编码成 data:image/png;base64,(内容) 随请求发大,图片越大越占带宽不依赖外网
公网 URL只传一个图片链接小图片 URL 公网可达,模型自己下载

3. 能力矩阵:点菜前先看菜单

能不能看图,是写在模型能力清单上的硬指标:

模型文本对话图片输入备注
deepseek-chat(本课默认)✅❌纯文本,便宜
qwen-plus✅❌通义纯文本
qwen-vl-plus(切换目标)✅✅通义视觉模型
GPT-4o / GPT-4o-mini✅✅OpenAI 视觉模型

4. 看图为什么比说话贵

视觉模型不是按张收费,是把图片切成小块(patch)折算成 token:图片越大、分辨率越高,切的块越多,折算 token 越多。类比打印店按面积收费——小一寸照片和大海报打印费不一样。教学场景图片压到 1080p 以内,别把 4K 原图直接丢给视觉模型。

5. 一个"错误写法"教学现场

拿默认 deepseek-chat 去调图片接口,会收到模型 400 报错("该模型不支持图片输入")。这不是你代码写错了,是模型能力不够——这正是上面能力矩阵的现场。很多新手被这个报错卡半天,以为是 multipart 传错了。

二、动手:跑起来

环境:Windows + JDK 17 + Maven 3.9+。会 @RestController、懂 multipart 上传就行。

第 1 步:30 秒检查环境。

java -version                                # 期望 True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')            # 期望 True
Get-NetTCPConnection -LocalPort 8098 -State Listen -ErrorAction SilentlyContinue   # 无输出=空闲

第 2 步:编译 + 启动。

cd spring-ai-journey\lesson-06
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"   # Maven 必须跑在 JDK 17 上
mvn clean install -DskipTests         # BUILD SUCCESS
mvn spring-boot:run                    # 看到 Tomcat started on port 8098

第 3 步:先测纯文本接口,证明工程是通的。

$q = [uri]::EscapeDataString('用一句话解释什么是多模态')
Invoke-RestMethod "http://localhost:8098/chat?msg=$q"

第 4 步:传一张图试试(默认 DeepSeek 下会报错,这是预期)。

$img = "C:\temp\test.png"   # 换成你本机任意一张图片路径
Invoke-RestMethod -Uri "http://localhost:8098/describe" -Method Post -Form @{
    image  = Get-Item $img
    prompt = "请描述这张图片"
}

三、关键代码:三个接口,图片是怎么"塞"进去的

第一段:application.yml——放开上传限制,温度调低。

server:
  port: 8098                       # 端口按课程分配表:lesson-06 = 8098
spring:
  servlet:
    multipart:
      max-file-size: 10MB           # 本课要收图片,放开上限(默认才 1MB)
      max-request-size: 10MB
  ai:
    openai:
      base-url: ${LLM_BASE_URL:https://api.deepseek.com}   # 默认 DeepSeek
      api-key: ${DEEPSEEK_API_KEY}                          # Key 只从环境变量读
      chat:
        options:
          model: ${LLM_MODEL:deepseek-chat}                 # 默认纯文本模型
          max-tokens: 500
          temperature: 0.2                                 # 图片描述要客观,温度调低别让模型"创作"

multipart 段是本课特有的——不收图片的课不用配,不配的话传张截图就被 Spring 前置拦掉,请求根本到不了模型。

第二段:ImageChatController.java——本课主角,完整可跑。

package com.springai.lesson06;

import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.http.MediaType;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.Base64;
import java.util.List;
import java.util.Locale;

/**
 * 多模态聊天控制器:本课的 HTTP 入口。
 * 什么是"多模态"?—— 模型不只吃文字,还能吃图片等别的"模态"。
 * 把图片塞进消息靠两个类:
 *   Media(媒体):一段图片数据(MIME 类型 + 内容),内容可以是 base64 串或图片 URL;
 *   UserMessage:文字 text() + 挂 media 列表,一起发给模型。
 */
@RestController
public class ImageChatController {

    /** ChatModel:Spring AI 的底层聊天门面,starter 自动装配 */
    private final ChatModel chatModel;

    public ImageChatController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    /** 纯文本问答:GET /chat?msg=你好,默认 DeepSeek 即可实测 */
    @GetMapping("/chat")
    public String chat(@RequestParam("msg") String msg) {
        return chatModel.call(msg);   // 单轮文本快捷方法
    }

    /**
     * 图片上传识别:POST /describe(multipart/form-data)。
     * 流程:图片字节 → base64 → 拼成 data URL → 包成 Media → 挂进 UserMessage → 发给视觉模型。
     */
    @PostMapping(value = "/describe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String describe(
            @RequestPart("image") MultipartFile image,
            // @RequestPart 没有 defaultValue 属性(Spring MVC 小坑),默认值在方法体内判空兜底
            @RequestPart(value = "prompt", required = false) String prompt) throws IOException {

        // 调用方没传 prompt 时,给个默认指令
        if (!StringUtils.hasText(prompt)) {
            prompt = "请详细描述这张图片里画了什么。";
        }

        // 1) 推断图片 MIME:优先客户端声明,拿不到就按文件名后缀猜
        String contentType = StringUtils.hasText(image.getContentType())
                ? image.getContentType()
                : guessMimeTypeByFilename(image.getOriginalFilename());
        MimeType mimeType = MimeTypeUtils.parseMimeType(contentType);

        // 2) 图片字节 → base64:HTTP JSON 请求体不能塞二进制,要编码成纯文本
        //    OpenAI 兼容协议规定图片写成 data URL:data:image/png;base64,(图片数据)
        String base64 = Base64.getEncoder().encodeToString(image.getBytes());
        String dataUrl = "data:" + mimeType + ";base64," + base64;

        // 3) 组装 Media:告诉模型"这是一张什么类型的图,内容在这个 data URL 里"
        Media media = Media.builder()
                .mimeType(mimeType)
                .data(dataUrl)
                .build();

        // 4) 组装用户消息:文字指令 + 挂图片(media 是列表,一次可发多张)
        UserMessage userMessage = UserMessage.builder()
                .text(prompt)
                .media(List.of(media))
                .build();

        // 5) 调用模型,取回答文本
        ChatResponse response = chatModel.call(new Prompt(userMessage));
        return response.getResult().getOutput().getText();
    }

    /**
     * 图片 URL 识别:GET /describe-url?url=图片公网地址。
     * 图片不走 base64,直接给链接,视觉模型自己下载——请求体小,但要求 URL 公网可达。
     */
    @GetMapping("/describe-url")
    public String describeUrl(
            @RequestParam("url") String url,
            @RequestParam(value = "prompt", required = false,
                    defaultValue = "请详细描述这张图片的内容,并提取图中出现的所有文字(OCR)。") String prompt) {

        Media media = Media.builder()
                .mimeType(MimeTypeUtils.IMAGE_PNG)
                .data(url)          // 直接传图片 URL 字符串
                .build();

        UserMessage userMessage = UserMessage.builder()
                .text(prompt)
                .media(List.of(media))
                .build();

        ChatResponse response = chatModel.call(new Prompt(userMessage));
        return response.getResult().getOutput().getText();
    }

    /** 按文件名后缀猜 MIME(multipart 没带 Content-Type 时兜底) */
    private String guessMimeTypeByFilename(String filename) {
        if (!StringUtils.hasText(filename)) {
            return MediaType.IMAGE_PNG_VALUE;
        }
        String lower = filename.toLowerCase(Locale.ROOT);
        if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
            return MediaType.IMAGE_JPEG_VALUE;
        }
        if (lower.endsWith(".gif")) {
            return MediaType.IMAGE_GIF_VALUE;
        }
        return MediaType.IMAGE_PNG_VALUE;
    }
}

这段代码的主线就五步:图片字节转 base64 → 拼成 data URL → 包成 Media → 挂进 UserMessage → 调模型。.media(List.of(media)) 是个列表——一次发多图就是塞多个 Media,这是后面做"多图对比"的扩展点。

第三段:Lesson06Application.java——标准启动类。

package com.springai.lesson06;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Spring AI 实战精通营 · 第 6 课:多模态(图片理解)。
 * 启动后访问:
 *   GET  http://localhost:8098/chat?msg=你好            —— 纯文本问答(DeepSeek 实测可用)
 *   POST http://localhost:8098/describe                 —— 上传图片,让模型看图说话/OCR
 *   GET  http://localhost:8098/describe-url?url=图片URL  —— 用图片公网 URL 让模型描述
 */
@SpringBootApplication
public class Lesson06Application {

    public static void main(String[] args) {
        SpringApplication.run(Lesson06Application.class, args);
    }
}

四、实测输出:哪些是真跑过的,哪些是预期

这一节我得老实交代。2026-10-05 本机实测:纯文本接口 /chat 通过;图片识别当前标"待实测"——探测下来 QWEN_API_KEY(qwen-vl-plus)和 ZHIPU_API_KEY 都返回 401 身份验证失败,默认 deepseek-chat 又是纯文本模型,所以"看图说话"的真实输出这篇先不贴,贴了就是编。

先贴真跑过的——文本接口 /chat(DeepSeek,HTTP 200):

$q = [uri]::EscapeDataString('用一句话解释什么是多模态')
Invoke-RestMethod "http://localhost:8098/chat?msg=$q"
HTTP 200
多模态是指AI系统能够同时理解和处理文本、图像、音频、视频等多种类型的信息,
就像人用眼睛看、耳朵听、嘴巴说一样,综合多种感官来感知世界。

这个输出证明工程本身是通的。再看图片接口会发生什么——下面都是条件句,不是实测输出:

  • 用默认 deepseek-chat 调 POST /describe,预期会收到模型 400 类报错,类似"该模型不支持图片输入"。这是教学预期:证明你走到了模型的能力边界,不是代码写错。
  • 拿到一个有效的视觉模型 Key 后,按第六节"切换三件套"切到 qwen-vl-plus,再传同一张图,预期会返回一段对图片内容的描述("看图说话");传一张带文字的截图、prompt 改成"只提取图中文字",预期会输出 OCR 文本。

复测方法就三步,代码一行不改:

$env:LLM_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"   # 通义 OpenAI 兼容端点
$env:LLM_MODEL="qwen-vl-plus"                                           # 换成视觉模型
$env:DEEPSEEK_API_KEY="<你的有效通义 Key>"                              # 复用同名占位,不动 yml
# 重启 mvn spring-boot:run 后,按第二节第 4 步上传图片

看出来了吗?切视觉模型只改三个配置(base-url + api-key + model),业务代码零改动——这就是 Spring AI starter 抽象的价值。

五、挑战题:改一改,看看会怎样

  1. ⭐ 故意撞能力墙:用默认 deepseek-chat 直接调 POST /describe 传一张图,观察模型返回什么报错。别慌——这不是你代码写错了,是 deepseek-chat 根本没有"眼睛"。把报错原文存下来,面试被问"纯文本模型调图片接口会怎样",你就有一手答案。
  2. ⭐⭐ 改 prompt 逼它结构化:把 /describe 的 prompt 改成"图里有没有猫?只回答有/没有",切到视觉模型后上传几张图,体会提示词怎么引导视觉模型输出结构化的答案。答案就在 ImageChatController 那个判空默认值那一行。
  3. ⭐⭐ 堵一下 multipart 上限:把 yml 里 max-file-size 从 10MB 改成 1MB,重启后传一张 2MB 的截图,观察是 Spring 直接抛 SizeLimitExceededException(请求还没到模型),还是模型那边报错——这能帮你分清"上传层"和"模型层"的边界。

六、生产环境进阶:三个加分项

1. 图片先压缩再上传。 视觉模型按 patch 折 token,4K 原图又慢又贵。教学/OCR 场景压到 1080p 以内,体积小了、钱省了,识别精度几乎不掉。

2. 切换视觉模型三件套。 改三处配置就换供应商:base-url、api-key、model。qwen-vl-plus = https://dashscope.aliyuncs.com/compatible-mode/v1;GPT-4o 走 OpenAI 官方端点。业务代码一行不动。

3. 用户上传图要过校验。 线上开放 /describe 接口前,mime、大小、数量都要校验——别让人传个 50MB 的图把你的模型账单打穿。

七、面试回答模板

面试官:什么是多模态?为什么 deepseek-chat 不能看图,qwen-vl-plus 可以?

一句话:多模态 = 模型能同时处理文字、图片等多种形态的输入;能不能看图写在模型的"输入模态"清单上,是训练时决定的。展开:视觉模型在大模型基础上加了图片编码器,把图片转成模型能理解的表示;deepseek-chat 训练时没见过图片,硬传图就报 400。选型第一步就是查模型能力矩阵,看输入模态支不支持 image。(指向本课第一节 / lesson-06 的能力矩阵表)

追问:Spring AI 里怎么把一张图片塞进发给模型的消息?

一句话:把图片包成 Media,挂进 UserMessage 的 media 列表。展开:图片字节先 base64 编码成 data:image/png;base64,(内容) 的 data URL,Media.builder().mimeType(...).data(dataUrl).build(),再 UserMessage.builder().text(prompt).media(List.of(media)).build()。media 是列表,一次能发多张图。(指向本课第三节)

追问:base64 和图片 URL 两种喂法有什么区别?

base64 随请求一起发,不依赖外网但请求体大;公网 URL 只传链接、模型自己下载,请求体小但要求图片 URL 公网可达。内网图片走 base64,公网图走 URL。(指向本课第一节对比表)

追问:视觉模型的 token 怎么计价?为什么看图比说话贵?

它把图片切成 patch 小块折成 token,图越大块越多越贵;不像文字按字数算。生产上压分辨率、控制图片数量就是在控成本。(指向本课第一节)

八、总结表

坑现象解法
纯文本模型调图片接口模型 400 "image not supported"切 qwen-vl-plus 等视觉模型,不是代码错
multipart 默认上限 1MB传截图被 Spring 前置拦截yml 配 max-file-size: 10MB
图片不 base64请求体二进制乱码拼成 data:image/png;base64, data URL
@RequestPart 写 defaultValue编译不过默认值在方法体内 if (!hasText(prompt)) 兜底
视觉模型 Key 401图片接口鉴权失败换有效 Key,按三件套改三处配置重启
上传大图不压缩token 账单飙升教学/OCR 压到 1080p 以内

九、关于这个系列

本文是「Java 后端实战精通营」系列第 6 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-06/(Web 工程,内含 ImageChatController 三接口:文本问答 + multipart 上传识图 + 图片 URL 识别)

系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官

下一篇预告:《Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步》——多模态解决了"看图片",下一课解决"让模型读你自己的资料":把几段中文用本地 ONNX 模型变成一串数字坐标,然后你会看到神奇一幕——搜"我今天心情不好不想吃饭",命中的居然是"咽不下去"那句,俩话一个相同的字都没有。全程本机跑,零 API 成本。

跑完有任何报错,把终端输出发评论区,一起排查。


标签建议:SpringAI、多模态、视觉模型 摘要建议(≤256 字):同样是大模型,为什么 ChatGPT、通义能看图,DeepSeek 传图就报错?答案在"输入模态"——视觉模型训练时见过图片,纯文本模型没有。本文讲清多模态原理,用 Spring AI 的 Media + UserMessage 把图片以 base64 或 URL 两种方式塞进消息,完整拆解 multipart 上传识图接口,如实标注图片识别待实测项与复测三步,附能力矩阵、token 计价和面试回答模板,源码已开源 lesson-06 可 clone 直接跑。