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 抽象的价值。
五、挑战题:改一改,看看会怎样
- ⭐ 故意撞能力墙:用默认 deepseek-chat 直接调
POST /describe传一张图,观察模型返回什么报错。别慌——这不是你代码写错了,是 deepseek-chat 根本没有"眼睛"。把报错原文存下来,面试被问"纯文本模型调图片接口会怎样",你就有一手答案。 - ⭐⭐ 改 prompt 逼它结构化:把
/describe的 prompt 改成"图里有没有猫?只回答有/没有",切到视觉模型后上传几张图,体会提示词怎么引导视觉模型输出结构化的答案。答案就在ImageChatController那个判空默认值那一行。 - ⭐⭐ 堵一下 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 识别)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步》——多模态解决了"看图片",下一课解决"让模型读你自己的资料":把几段中文用本地 ONNX 模型变成一串数字坐标,然后你会看到神奇一幕——搜"我今天心情不好不想吃饭",命中的居然是"咽不下去"那句,俩话一个相同的字都没有。全程本机跑,零 API 成本。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:SpringAI、多模态、视觉模型 摘要建议(≤256 字):同样是大模型,为什么 ChatGPT、通义能看图,DeepSeek 传图就报错?答案在"输入模态"——视觉模型训练时见过图片,纯文本模型没有。本文讲清多模态原理,用 Spring AI 的 Media + UserMessage 把图片以 base64 或 URL 两种方式塞进消息,完整拆解 multipart 上传识图接口,如实标注图片识别待实测项与复测三步,附能力矩阵、token 计价和面试回答模板,源码已开源 lesson-06 可 clone 直接跑。