二哥项目资料

用户发了张图片,求指派怎么自动切到视觉模型?

正文 28,222 字 #12 原文
本文目录 · 39 节

星球:Java进阶之路&二哥编程星球 | 专栏:求职派 OpenClaw 架构 | 作者:沉默王二 | 发布:2026-07-28

用户发了张图片,求指派怎么自动切到视觉模型?

用户发了张图片,求指派怎么自动切到视觉模型?

Spring AI 对多模态的支持,API 层面其实很简单——构建一个 Media 对象,塞进 UserMessage,就完事了。

但到了生产环境,就有新的问题了。

用户发了一张图片过来,系统怎么知道该用视觉模型而不是文本模型?多个供应商都支持视觉能力,模型怎么选?不同用户想用不同的视觉模型,配置怎么隔离?

这篇内容就从 Spring AI 的多模态 API 讲起,再拆解求职派在它基础上做的三层设计——Media 构建、模型自动检测、用户偏好路由。读完你就能搞清楚,一个对接了微信、钉钉、飞书三个渠道的 Agent 系统,到底怎么处理用户发来的图片。技术派: https://paicoding.com/jobclaw-springai-multimodal

系好安全带,我们粗粗发~

01、多模态到底是什么?

先把概念理清楚。

多模态(Multimodal)指的是大模型能够同时处理多种类型的输入——文本、图片、音频、视频,然后生成自然语言的输出。

图片.png

举个例子:用户在微信上给求职派发了一张招聘海报的截图,然后问“这个岗位适合我吗?”。大模型需要同时理解图片里的岗位信息和用户的文字提问,才能给出有意义的回答。

这就是多模态的典型场景,输入不是纯文本,而是文本 + 图片的组合。

目前支持多模态输入的主流模型包括智谱的 GLM-5V 系列、通义千问的 qwen3.7 系列等。求职派实际对接的多模态供应商是智谱和千问,所以后面的例子以这两家为主。

在 Spring AI 之前,要对接不同供应商的多模态能力,需要分别处理各家的 API 差异。

有的用 base64 编码传图,有的用 URL 引用,参数格式也各不相同。Spring AI 把这些差异屏蔽掉了,提供了统一的 Media 抽象,一套代码就能对接所有支持多模态的供应商。

02、Spring AI 的多模态 API 怎么用?

Spring AI 把多模态的接入抽象成了三个步骤,构建 Media、组装 UserMessage、发起调用。

图片.png

第一步,构建 Media

Media 是 Spring AI 对媒体资源的统一封装,支持 builder 模式。

Java

Media media = Media.builder()
        .mimeType(MimeTypeUtils.IMAGE_PNG)
        .data(imageBytes)
        .build();

mimeType 指定媒体类型(PNG、JPEG、PDF 等), data 传入字节数组。如果图片来自本地文件,也可以用 FileSystemResource。

Java

new Media(mimeType, new FileSystemResource(path.toFile()));

两种构造方式的区别在于数据来源。

字节数组适合网络下载的图片,文件资源适合本地存储的图片。

builder 模式下还有两个可选字段, name 用于标识媒体资源(批量处理时方便日志追踪), id 用于唯一标识(多轮对话中引用特定图片时使用)。

第二步,组装 UserMessage

把 Media 和文本提示词一起塞进 UserMessage。

Java

UserMessage msg = UserMessage.builder()
        .text("识别这张图片中的食物,计算卡路里")
        .media(media)
        .build();

.media() 方法既接受单个 Media 对象,也接受 List。如果用户一次发了多张图片,传列表就行了。

第三步,发起调用

后面的流程和纯文本调用完全一样。

Java

Prompt prompt = new Prompt(msg);
String result = chatClient.prompt(prompt)
        .call().content();

如果需要结构化返回,把 .content() 换成 .entity(FoodDetail.class) 即可。

Spring AI 会自动把模型的输出解析成 Java 对象。对于泛型类型(比如返回一个列表),需要用 ParameterizedTypeReference。

Java

.call().entity(
    new ParameterizedTypeReference>() {});

图片.png

API 层面就这么多,三步走完就能让大模型“看懂”图片了。

03、求职派怎么处理用户发来的图片?

API 简单不代表生产落地简单。

求职派同时对接了微信、钉钉、飞书三个 IM 渠道,用户可能通过任何一个渠道发图片过来。不同渠道传图片的方式不一样——微信给的是字节流,文件系统里存的是本地路径。

业务 Agent 在处理消息的时候,并不知道图片是从哪个渠道来的,也不应该关心。

求职派的做法是在 LLM 调用层统一处理媒体构建。核心逻辑在 BizAgentLlmCaller 的 buildUserMessage() 方法里。

Java

for (var image : message.getMedias()) {
    Media media = createImageMedia(image);
    if (media != null) {
        mediaList.add(media);
    }
}

这段代码遍历消息中携带的所有媒体资源,逐个转换成 Spring AI 的 Media 对象。

(源码位置: core/.../agent/llm/BizAgentLlmCaller.java,第 169-210 行)

转换的逻辑分两种情况。

Java

if (image.getData() != null && image.getData().length > 0) {
    MimeType mimeType = MimeType.valueOf(image.getMimeType());
    return new Media(mimeType, new ByteArrayResource(image.getData()));
} else if (image.getFilePath() != null) {
    Path path = image.getFilePath();
    MimeType mimeType = MimeType.valueOf(image.getMimeType());
    return new Media(mimeType, new FileSystemResource(path.toFile()));
}

有字节数据的直接包装成 ByteArrayResource,有文件路径的包装成 FileSystemResource。不管图片从哪个渠道来,最终都变成了统一的 Media 对象。

图片.png

这个设计的好处是业务 Agent 完全不需要关心媒体资源的来源。它拿到的永远是一个包含了文本和媒体的 UserMessage,剩下的事情交给 LLM 调用层处理。

04、模型怎么知道该用视觉模型?

这是多模态落地最关键的一环。

用户发纯文本消息时,系统应该用文本模型(比如 GLM-4.7-Flash);用户发了图片时,系统应该自动切换到视觉模型(比如 GLM-4V-Flash)。如果每个业务 Agent 都要自己判断、自己切模型,代码会非常冗余。

求职派的解法是在 LLM 调用器的 getClient() 方法里做自动检测。

Java

ModelConfig.ModelType model = ModelConfig.ModelType.TEXT;
if (prompt.getUserMessages().stream()
        .anyMatch(m -> !CollectionUtils.isEmpty(m.getMedia()))) {
    model = ModelConfig.ModelType.VISION;
}
var chatModel = (ChatModel) modelProviders.getModel(
        user.jobClawUserId(), model);

(源码位置: core/.../agent/llm/SimpleLlmCaller.java,第 82-87 行)

逻辑简单,扫一遍 Prompt 里的所有用户消息,只要有任何一条消息携带了 Media,就把模型类型从 TEXT 切换到 VISION。

图片.png

这意味着业务 Agent 在构建 Prompt 的时候完全不需要指定模型类型。消息里有图片,系统自动用视觉模型;没有图片,自动用文本模型。模型切换这件事,对业务层是透明的。

这个设计避免了一个很常见的问题——开发者忘记切模型。

如果模型切换由业务 Agent 手动控制,早晚有人会忘记处理图片场景,导致纯文本模型收到了图片输入,直接报错。自动检测机制从根本上消除了这类低级错误。

05、多供应商怎么适配?

模型类型确定了之后,还有一个问题。不同供应商对视觉模型的支持方式不一样,系统怎么适配?

求职派定义了 7 种模型类型,覆盖了目前主流的 AI 能力。

  • TEXT — 纯文本对话

  • VISION — 视觉理解(图片识别)

  • IMAGE — 图像生成

  • VIDEO — 视频处理

  • EMBEDDING — 向量嵌入

  • ASR — 语音识别

  • TTS — 语音合成

(源码位置: core/.../providers/ModelConfig.java,第 80-110 行)

每个供应商模块实现 ModelProvider 接口,通过 model() 方法处理不同的模型类型。以智谱供应商为例。

Java

return switch (info.getType()) {
    case TEXT -> buildChatModel(info);
    case VISION -> buildChatModel(info);
    case IMAGE -> buildImageModel(info);
    case EMBEDDING -> buildEmbeddingModel(info);
    default -> throw new IllegalArgumentException(...);
};

(源码位置: providers/zhipu/.../ZhiPuModelProvider.java,第 66-73 行)

这里有个细节,智谱的 TEXT 和 VISION 走的是同一个 buildChatModel() 方法,因为智谱的视觉模型和文本模型使用同一套 API 协议,区别只在于模型名称( GLM-4.7-Flash vs GLM-4V-Flash)。

图片.png

新增一个供应商的时候,只需要实现对应的模型类型支持就行了,不需要修改任何业务代码。

这就是 ModelProvider 接口作为扩展点的价值——供应商之间的 API 差异被封装在各自的模块内部,对外暴露的是统一的 Model 接口。

06、用户偏好怎么隔离?

最后一层设计是用户级别的模型偏好。

求职派是一个多用户系统,不同用户可能配置了不同的模型供应商和 API Key。

用户 A 用智谱的 glm-4.6v-flash,用户 B 用通义千问的 qwen-omni-turbo,系统需要按用户维度隔离模型选择。

这个能力由 ModelProviders.getModel() 方法实现。

Java

var preferModel = switch (modelType) {
    case TEXT -> preference.getModels().getText();
    case VISION -> preference.getModels().getVision();
    case IMAGE -> preference.getModels().getImage();
    // ...
};

(源码位置: core/.../providers/ModelProviders.java,第 103-111 行)

每个用户的偏好配置中都有一个 vision 字段,格式是 provider#modelName(例如 zhipu#GLM-4V-Flash)。系统先查用户自己的配置,查不到就回退到全局默认配置。

图片.png

整条流程串起来就是这样。

用户发了一张图片 → LLM 调用器检测到 Media,模型类型设为 VISION → 查用户偏好找到视觉模型配置 zhipu#GLM-4V-Flash → 解析出供应商和模型名 → 智谱供应商构建对应的 ChatModel 实例 → 发起调用。

业务 Agent 全程只管一件事,把用户的消息和图片组装成 Prompt,然后调用 call() 方法。模型选择、供应商路由、偏好降级,全部在底层自动完成。

07、图片识别实战

理解了底层机制,我们来看一个完整的实战例子。

接收一张食物图片的 URL,调用视觉模型识别图片内容,返回食物名称和卡路里估算。

先定义结构化输出的数据模型。

Java

public record FoodDetail(
    @JsonPropertyDescription("整张图片的描述")
    String desc,
    @JsonPropertyDescription("总的卡路里")
    Double totalCalorie,
    @JsonPropertyDescription("图片中的食材列表")
    List itemList) {}

@JsonPropertyDescription 注解会被 Spring AI 自动提取,生成 JSON Schema 传给大模型,引导模型按指定格式返回。

然后写一个接口,完成从下载图片到调用模型的全流程。

Java

@RequestMapping(path = "recognitionAndOutput")
public FoodDetail recognitionAndOutput(
        @RequestParam(name = "imgUrl") String imgUrl,
        @RequestParam(name = "msg") String msg) {
    byte[] imgs = HttpUtil.downloadBytes(imgUrl);
    Media media = Media.builder()
            .mimeType(MimeTypeUtils.IMAGE_PNG)
            .data(imgs).build();
    Message userMsg = UserMessage.builder()
            .text(msg).media(media).build();
    Prompt prompt = new Prompt(userMsg);
    return chatClient.prompt(prompt)
            .call().entity(FoodDetail.class);
}

图片.png

从代码可以看出,多模态调用和普通的文本调用几乎没有区别。唯一的差异就是在构建 UserMessage 的时候多了一个 .media(media) 调用。

不过有一点需要注意,调用之前要确认使用的模型确实支持多模态输入。如果用一个纯文本模型去处理图片,调用会直接报错。在求职派的架构下,这个问题由模型自动检测机制解决了——只要消息里有 Media,系统会自动切换到视觉模型,不需要开发者操心。

08、求职派的岗位采集是怎么用多模态的?

除了用户侧的图片识别,求职派的管理后台也重度依赖了多模态能力。

岗位数据采集场景下,运营人员经常需要从招聘截图中批量提取岗位信息。这个功能由岗位采集代理实现:

Java

Media media = Media.builder()
        .mimeType(type)
        .data(bytes)
        .name("图片-" + type.getSubtype() + "-" + rid)
        .build();
UserMessage msg = UserMessage.builder()
        .media(media)
        .text("提取图片中的表格信息,按照指定要求返回")
        .build();

(源码位置: app/.../gather/service/GatherAiAgent.java,第 119-127 行)

这里有个细节, Media.builder() 除了 mimeType 和 data,还设置了一个 name 字段。在批量处理多张图片的场景下, name 帮助在日志和调试时区分不同的图片。

图片.png

更有意思的是大图片分批处理的设计。

当一张图片内容太多(比如一整页的招聘表格),单次调用可能无法提取完所有信息。采集代理会自动发起多轮对话,第一轮携带图片和提示词,后续轮次只发“继续返回”,直到模型返回完整的结果。

这个设计利用了 ChatMemory 的上下文保持能力。模型在第一轮已经“看过”了图片,后续轮次不需要重复发送图片,只需要告诉模型继续输出就行了。

ending

【Spring AI 的多模态 API 就三步,构建 Media、组装 UserMessage、发起调用。但从“能用”到“好用”,中间隔着模型自动检测、供应商适配、用户偏好路由这三层设计。】

求职派把这三层全部沉到了 LLM 调用层,业务 Agent 只管组装消息,不管模型怎么选。

这大概就是分层设计该有的样子——用的时候感觉不到它的存在。

如何把求职派写到简历上?

项目名称:求职派(JobClaw)—— 多 Agent 智能求职系统

项目简介:基于 Spring AI + LangGraph4J 的多 Agent 求职系统,对接微信/钉钉/飞书三个 IM 渠道,通过意图识别将用户消息路由到身份采集、岗位检索、岗位推荐等业务 Agent,支持多模态输入处理和多供应商模型切换。

技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J、JPA/Hibernate、React 19 / Next.js 15

核心职责:

  • 基于 Spring AI 的 Media API 实现多模态输入处理,支持图片、文件等多种媒体类型的统一接入,业务层无需关心媒体来源和格式差异

  • 设计并实现 LLM 调用层的模型自动检测机制,通过扫描 Prompt 中的 Media 内容自动切换文本/视觉模型,消除业务 Agent 的模型选择负担

  • 实现多供应商(智谱、通义千问)视觉模型的统一适配层,新增供应商只需实现模型类型映射,无需修改业务代码

  • 设计用户级别的模型偏好隔离方案,通过 provider#modelName 格式支持按用户维度配置不同的视觉模型,并实现全局默认降级策略

  • 基于多模态能力实现岗位数据智能采集,支持从招聘截图中批量提取结构化岗位信息,通过多轮对话机制处理大图片的分页识别

Spring AI 对多模态的支持,API 层面其实很简单——构建一个 Media 对象,塞进 UserMessage,就完事了。

但到了生产环境,就有新的问题了。

用户发了一张图片过来,系统怎么知道该用视觉模型而不是文本模型?多个供应商都支持视觉能力,模型怎么选?不同用户想用不同的视觉模型,配置怎么隔离?

这篇内容就从 Spring AI 的多模态 API 讲起,再拆解求职派在它基础上做的三层设计——Media 构建、模型自动检测、用户偏好路由。读完你就能搞清楚,一个对接了微信、钉钉、飞书三个渠道的 Agent 系统,到底怎么处理用户发来的图片。技术派: https://paicoding.com/jobclaw-springai-multimodal

系好安全带,我们粗粗发~

01、多模态到底是什么?

先把概念理清楚。

多模态(Multimodal)指的是大模型能够同时处理多种类型的输入——文本、图片、音频、视频,然后生成自然语言的输出。

图片.png

举个例子:用户在微信上给求职派发了一张招聘海报的截图,然后问“这个岗位适合我吗?”。大模型需要同时理解图片里的岗位信息和用户的文字提问,才能给出有意义的回答。

这就是多模态的典型场景,输入不是纯文本,而是文本 + 图片的组合。

目前支持多模态输入的主流模型包括智谱的 GLM-5V 系列、通义千问的 qwen3.7 系列等。求职派实际对接的多模态供应商是智谱和千问,所以后面的例子以这两家为主。

在 Spring AI 之前,要对接不同供应商的多模态能力,需要分别处理各家的 API 差异。

有的用 base64 编码传图,有的用 URL 引用,参数格式也各不相同。Spring AI 把这些差异屏蔽掉了,提供了统一的 Media 抽象,一套代码就能对接所有支持多模态的供应商。

02、Spring AI 的多模态 API 怎么用?

Spring AI 把多模态的接入抽象成了三个步骤,构建 Media、组装 UserMessage、发起调用。

图片.png

第一步,构建 Media

Media 是 Spring AI 对媒体资源的统一封装,支持 builder 模式。

Java

Media media = Media.builder()
        .mimeType(MimeTypeUtils.IMAGE_PNG)
        .data(imageBytes)
        .build();

mimeType 指定媒体类型(PNG、JPEG、PDF 等), data 传入字节数组。如果图片来自本地文件,也可以用 FileSystemResource。

Java

new Media(mimeType, new FileSystemResource(path.toFile()));

两种构造方式的区别在于数据来源。

字节数组适合网络下载的图片,文件资源适合本地存储的图片。

builder 模式下还有两个可选字段, name 用于标识媒体资源(批量处理时方便日志追踪), id 用于唯一标识(多轮对话中引用特定图片时使用)。

第二步,组装 UserMessage

把 Media 和文本提示词一起塞进 UserMessage。

Java

UserMessage msg = UserMessage.builder()
        .text("识别这张图片中的食物,计算卡路里")
        .media(media)
        .build();

.media() 方法既接受单个 Media 对象,也接受 List。如果用户一次发了多张图片,传列表就行了。

第三步,发起调用

后面的流程和纯文本调用完全一样。

Java

Prompt prompt = new Prompt(msg);
String result = chatClient.prompt(prompt)
        .call().content();

如果需要结构化返回,把 .content() 换成 .entity(FoodDetail.class) 即可。

Spring AI 会自动把模型的输出解析成 Java 对象。对于泛型类型(比如返回一个列表),需要用 ParameterizedTypeReference。

Java

.call().entity(
    new ParameterizedTypeReference>() {});

图片.png

API 层面就这么多,三步走完就能让大模型“看懂”图片了。

03、求职派怎么处理用户发来的图片?

API 简单不代表生产落地简单。

求职派同时对接了微信、钉钉、飞书三个 IM 渠道,用户可能通过任何一个渠道发图片过来。不同渠道传图片的方式不一样——微信给的是字节流,文件系统里存的是本地路径。

业务 Agent 在处理消息的时候,并不知道图片是从哪个渠道来的,也不应该关心。

求职派的做法是在 LLM 调用层统一处理媒体构建。核心逻辑在 BizAgentLlmCaller 的 buildUserMessage() 方法里。

Java

for (var image : message.getMedias()) {
    Media media = createImageMedia(image);
    if (media != null) {
        mediaList.add(media);
    }
}

这段代码遍历消息中携带的所有媒体资源,逐个转换成 Spring AI 的 Media 对象。

(源码位置: core/.../agent/llm/BizAgentLlmCaller.java,第 169-210 行)

转换的逻辑分两种情况。

Java

if (image.getData() != null && image.getData().length > 0) {
    MimeType mimeType = MimeType.valueOf(image.getMimeType());
    return new Media(mimeType, new ByteArrayResource(image.getData()));
} else if (image.getFilePath() != null) {
    Path path = image.getFilePath();
    MimeType mimeType = MimeType.valueOf(image.getMimeType());
    return new Media(mimeType, new FileSystemResource(path.toFile()));
}

有字节数据的直接包装成 ByteArrayResource,有文件路径的包装成 FileSystemResource。不管图片从哪个渠道来,最终都变成了统一的 Media 对象。

图片.png

这个设计的好处是业务 Agent 完全不需要关心媒体资源的来源。它拿到的永远是一个包含了文本和媒体的 UserMessage,剩下的事情交给 LLM 调用层处理。

04、模型怎么知道该用视觉模型?

这是多模态落地最关键的一环。

用户发纯文本消息时,系统应该用文本模型(比如 GLM-4.7-Flash);用户发了图片时,系统应该自动切换到视觉模型(比如 GLM-4V-Flash)。如果每个业务 Agent 都要自己判断、自己切模型,代码会非常冗余。

求职派的解法是在 LLM 调用器的 getClient() 方法里做自动检测。

Java

ModelConfig.ModelType model = ModelConfig.ModelType.TEXT;
if (prompt.getUserMessages().stream()
        .anyMatch(m -> !CollectionUtils.isEmpty(m.getMedia()))) {
    model = ModelConfig.ModelType.VISION;
}
var chatModel = (ChatModel) modelProviders.getModel(
        user.jobClawUserId(), model);

(源码位置: core/.../agent/llm/SimpleLlmCaller.java,第 82-87 行)

逻辑简单,扫一遍 Prompt 里的所有用户消息,只要有任何一条消息携带了 Media,就把模型类型从 TEXT 切换到 VISION。

图片.png

这意味着业务 Agent 在构建 Prompt 的时候完全不需要指定模型类型。消息里有图片,系统自动用视觉模型;没有图片,自动用文本模型。模型切换这件事,对业务层是透明的。

这个设计避免了一个很常见的问题——开发者忘记切模型。

如果模型切换由业务 Agent 手动控制,早晚有人会忘记处理图片场景,导致纯文本模型收到了图片输入,直接报错。自动检测机制从根本上消除了这类低级错误。

05、多供应商怎么适配?

模型类型确定了之后,还有一个问题。不同供应商对视觉模型的支持方式不一样,系统怎么适配?

求职派定义了 7 种模型类型,覆盖了目前主流的 AI 能力。

  • TEXT — 纯文本对话

  • VISION — 视觉理解(图片识别)

  • IMAGE — 图像生成

  • VIDEO — 视频处理

  • EMBEDDING — 向量嵌入

  • ASR — 语音识别

  • TTS — 语音合成

(源码位置: core/.../providers/ModelConfig.java,第 80-110 行)

每个供应商模块实现 ModelProvider 接口,通过 model() 方法处理不同的模型类型。以智谱供应商为例。

Java

return switch (info.getType()) {
    case TEXT -> buildChatModel(info);
    case VISION -> buildChatModel(info);
    case IMAGE -> buildImageModel(info);
    case EMBEDDING -> buildEmbeddingModel(info);
    default -> throw new IllegalArgumentException(...);
};

(源码位置: providers/zhipu/.../ZhiPuModelProvider.java,第 66-73 行)

这里有个细节,智谱的 TEXT 和 VISION 走的是同一个 buildChatModel() 方法,因为智谱的视觉模型和文本模型使用同一套 API 协议,区别只在于模型名称( GLM-4.7-Flash vs GLM-4V-Flash)。

图片.png

新增一个供应商的时候,只需要实现对应的模型类型支持就行了,不需要修改任何业务代码。

这就是 ModelProvider 接口作为扩展点的价值——供应商之间的 API 差异被封装在各自的模块内部,对外暴露的是统一的 Model 接口。

06、用户偏好怎么隔离?

最后一层设计是用户级别的模型偏好。

求职派是一个多用户系统,不同用户可能配置了不同的模型供应商和 API Key。

用户 A 用智谱的 glm-4.6v-flash,用户 B 用通义千问的 qwen-omni-turbo,系统需要按用户维度隔离模型选择。

这个能力由 ModelProviders.getModel() 方法实现。

Java

var preferModel = switch (modelType) {
    case TEXT -> preference.getModels().getText();
    case VISION -> preference.getModels().getVision();
    case IMAGE -> preference.getModels().getImage();
    // ...
};

(源码位置: core/.../providers/ModelProviders.java,第 103-111 行)

每个用户的偏好配置中都有一个 vision 字段,格式是 provider#modelName(例如 zhipu#GLM-4V-Flash)。系统先查用户自己的配置,查不到就回退到全局默认配置。

图片.png

整条流程串起来就是这样。

用户发了一张图片 → LLM 调用器检测到 Media,模型类型设为 VISION → 查用户偏好找到视觉模型配置 zhipu#GLM-4V-Flash → 解析出供应商和模型名 → 智谱供应商构建对应的 ChatModel 实例 → 发起调用。

业务 Agent 全程只管一件事,把用户的消息和图片组装成 Prompt,然后调用 call() 方法。模型选择、供应商路由、偏好降级,全部在底层自动完成。

07、图片识别实战

理解了底层机制,我们来看一个完整的实战例子。

接收一张食物图片的 URL,调用视觉模型识别图片内容,返回食物名称和卡路里估算。

先定义结构化输出的数据模型。

Java

public record FoodDetail(
    @JsonPropertyDescription("整张图片的描述")
    String desc,
    @JsonPropertyDescription("总的卡路里")
    Double totalCalorie,
    @JsonPropertyDescription("图片中的食材列表")
    List itemList) {}

@JsonPropertyDescription 注解会被 Spring AI 自动提取,生成 JSON Schema 传给大模型,引导模型按指定格式返回。

然后写一个接口,完成从下载图片到调用模型的全流程。

Java

@RequestMapping(path = "recognitionAndOutput")
public FoodDetail recognitionAndOutput(
        @RequestParam(name = "imgUrl") String imgUrl,
        @RequestParam(name = "msg") String msg) {
    byte[] imgs = HttpUtil.downloadBytes(imgUrl);
    Media media = Media.builder()
            .mimeType(MimeTypeUtils.IMAGE_PNG)
            .data(imgs).build();
    Message userMsg = UserMessage.builder()
            .text(msg).media(media).build();
    Prompt prompt = new Prompt(userMsg);
    return chatClient.prompt(prompt)
            .call().entity(FoodDetail.class);
}

图片.png

从代码可以看出,多模态调用和普通的文本调用几乎没有区别。唯一的差异就是在构建 UserMessage 的时候多了一个 .media(media) 调用。

不过有一点需要注意,调用之前要确认使用的模型确实支持多模态输入。如果用一个纯文本模型去处理图片,调用会直接报错。在求职派的架构下,这个问题由模型自动检测机制解决了——只要消息里有 Media,系统会自动切换到视觉模型,不需要开发者操心。

08、求职派的岗位采集是怎么用多模态的?

除了用户侧的图片识别,求职派的管理后台也重度依赖了多模态能力。

岗位数据采集场景下,运营人员经常需要从招聘截图中批量提取岗位信息。这个功能由岗位采集代理实现:

Java

Media media = Media.builder()
        .mimeType(type)
        .data(bytes)
        .name("图片-" + type.getSubtype() + "-" + rid)
        .build();
UserMessage msg = UserMessage.builder()
        .media(media)
        .text("提取图片中的表格信息,按照指定要求返回")
        .build();

(源码位置: app/.../gather/service/GatherAiAgent.java,第 119-127 行)

这里有个细节, Media.builder() 除了 mimeType 和 data,还设置了一个 name 字段。在批量处理多张图片的场景下, name 帮助在日志和调试时区分不同的图片。

图片.png

更有意思的是大图片分批处理的设计。

当一张图片内容太多(比如一整页的招聘表格),单次调用可能无法提取完所有信息。采集代理会自动发起多轮对话,第一轮携带图片和提示词,后续轮次只发“继续返回”,直到模型返回完整的结果。

这个设计利用了 ChatMemory 的上下文保持能力。模型在第一轮已经“看过”了图片,后续轮次不需要重复发送图片,只需要告诉模型继续输出就行了。

ending

【Spring AI 的多模态 API 就三步,构建 Media、组装 UserMessage、发起调用。但从“能用”到“好用”,中间隔着模型自动检测、供应商适配、用户偏好路由这三层设计。】

求职派把这三层全部沉到了 LLM 调用层,业务 Agent 只管组装消息,不管模型怎么选。

这大概就是分层设计该有的样子——用的时候感觉不到它的存在。

如何把求职派写到简历上?

项目名称:求职派(JobClaw)—— 多 Agent 智能求职系统

项目简介:基于 Spring AI + LangGraph4J 的多 Agent 求职系统,对接微信/钉钉/飞书三个 IM 渠道,通过意图识别将用户消息路由到身份采集、岗位检索、岗位推荐等业务 Agent,支持多模态输入处理和多供应商模型切换。

技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J、JPA/Hibernate、React 19 / Next.js 15

核心职责:

  • 基于 Spring AI 的 Media API 实现多模态输入处理,支持图片、文件等多种媒体类型的统一接入,业务层无需关心媒体来源和格式差异

  • 设计并实现 LLM 调用层的模型自动检测机制,通过扫描 Prompt 中的 Media 内容自动切换文本/视觉模型,消除业务 Agent 的模型选择负担

  • 实现多供应商(智谱、通义千问)视觉模型的统一适配层,新增供应商只需实现模型类型映射,无需修改业务代码

  • 设计用户级别的模型偏好隔离方案,通过 provider#modelName 格式支持按用户维度配置不同的视觉模型,并实现全局默认降级策略

  • 基于多模态能力实现岗位数据智能采集,支持从招聘截图中批量提取结构化岗位信息,通过多轮对话机制处理大图片的分页识别

Spring AI 对多模态的支持,API 层面其实很简单——构建一个 Media 对象,塞进 UserMessage,就完事了。

但到了生产环境,就有新的问题了。

用户发了一张图片过来,系统怎么知道该用视觉模型而不是文本模型?多个供应商都支持视觉能力,模型怎么选?不同用户想用不同的视觉模型,配置怎么隔离?

这篇内容就从 Spring AI 的多模态 API 讲起,再拆解求职派在它基础上做的三层设计——Media 构建、模型自动检测、用户偏好路由。读完你就能搞清楚,一个对接了微信、钉钉、飞书三个渠道的 Agent 系统,到底怎么处理用户发来的图片。技术派: https://paicoding.com/jobclaw-springai-multimodal

系好安全带,我们粗粗发~

01、多模态到底是什么?

先把概念理清楚。

多模态(Multimodal)指的是大模型能够同时处理多种类型的输入——文本、图片、音频、视频,然后生成自然语言的输出。

图片.png

举个例子:用户在微信上给求职派发了一张招聘海报的截图,然后问“这个岗位适合我吗?”。大模型需要同时理解图片里的岗位信息和用户的文字提问,才能给出有意义的回答。

这就是多模态的典型场景,输入不是纯文本,而是文本 + 图片的组合。

目前支持多模态输入的主流模型包括智谱的 GLM-5V 系列、通义千问的 qwen3.7 系列等。求职派实际对接的多模态供应商是智谱和千问,所以后面的例子以这两家为主。

在 Spring AI 之前,要对接不同供应商的多模态能力,需要分别处理各家的 API 差异。

有的用 base64 编码传图,有的用 URL 引用,参数格式也各不相同。Spring AI 把这些差异屏蔽掉了,提供了统一的 Media 抽象,一套代码就能对接所有支持多模态的供应商。

02、Spring AI 的多模态 API 怎么用?

Spring AI 把多模态的接入抽象成了三个步骤,构建 Media、组装 UserMessage、发起调用。

图片.png

第一步,构建 Media

Media 是 Spring AI 对媒体资源的统一封装,支持 builder 模式。

Java

Media media = Media.builder()
        .mimeType(MimeTypeUtils.IMAGE_PNG)
        .data(imageBytes)
        .build();

mimeType 指定媒体类型(PNG、JPEG、PDF 等), data 传入字节数组。如果图片来自本地文件,也可以用 FileSystemResource。

Java

new Media(mimeType, new FileSystemResource(path.toFile()));

两种构造方式的区别在于数据来源。

字节数组适合网络下载的图片,文件资源适合本地存储的图片。

builder 模式下还有两个可选字段, name 用于标识媒体资源(批量处理时方便日志追踪), id 用于唯一标识(多轮对话中引用特定图片时使用)。

第二步,组装 UserMessage

把 Media 和文本提示词一起塞进 UserMessage。

Java

UserMessage msg = UserMessage.builder()
        .text("识别这张图片中的食物,计算卡路里")
        .media(media)
        .build();

.media() 方法既接受单个 Media 对象,也接受 List。如果用户一次发了多张图片,传列表就行了。

第三步,发起调用

后面的流程和纯文本调用完全一样。

Java

Prompt prompt = new Prompt(msg);
String result = chatClient.prompt(prompt)
        .call().content();

如果需要结构化返回,把 .content() 换成 .entity(FoodDetail.class) 即可。

Spring AI 会自动把模型的输出解析成 Java 对象。对于泛型类型(比如返回一个列表),需要用 ParameterizedTypeReference。

Java

.call().entity(
    new ParameterizedTypeReference>() {});

图片.png

API 层面就这么多,三步走完就能让大模型“看懂”图片了。

03、求职派怎么处理用户发来的图片?

API 简单不代表生产落地简单。

求职派同时对接了微信、钉钉、飞书三个 IM 渠道,用户可能通过任何一个渠道发图片过来。不同渠道传图片的方式不一样——微信给的是字节流,文件系统里存的是本地路径。

业务 Agent 在处理消息的时候,并不知道图片是从哪个渠道来的,也不应该关心。

求职派的做法是在 LLM 调用层统一处理媒体构建。核心逻辑在 BizAgentLlmCaller 的 buildUserMessage() 方法里。

Java

for (var image : message.getMedias()) {
    Media media = createImageMedia(image);
    if (media != null) {
        mediaList.add(media);
    }
}

这段代码遍历消息中携带的所有媒体资源,逐个转换成 Spring AI 的 Media 对象。

(源码位置: core/.../agent/llm/BizAgentLlmCaller.java,第 169-210 行)

转换的逻辑分两种情况。

Java

if (image.getData() != null && image.getData().length > 0) {
    MimeType mimeType = MimeType.valueOf(image.getMimeType());
    return new Media(mimeType, new ByteArrayResource(image.getData()));
} else if (image.getFilePath() != null) {
    Path path = image.getFilePath();
    MimeType mimeType = MimeType.valueOf(image.getMimeType());
    return new Media(mimeType, new FileSystemResource(path.toFile()));
}

有字节数据的直接包装成 ByteArrayResource,有文件路径的包装成 FileSystemResource。不管图片从哪个渠道来,最终都变成了统一的 Media 对象。

图片.png

这个设计的好处是业务 Agent 完全不需要关心媒体资源的来源。它拿到的永远是一个包含了文本和媒体的 UserMessage,剩下的事情交给 LLM 调用层处理。

04、模型怎么知道该用视觉模型?

这是多模态落地最关键的一环。

用户发纯文本消息时,系统应该用文本模型(比如 GLM-4.7-Flash);用户发了图片时,系统应该自动切换到视觉模型(比如 GLM-4V-Flash)。如果每个业务 Agent 都要自己判断、自己切模型,代码会非常冗余。

求职派的解法是在 LLM 调用器的 getClient() 方法里做自动检测。

Java

ModelConfig.ModelType model = ModelConfig.ModelType.TEXT;
if (prompt.getUserMessages().stream()
        .anyMatch(m -> !CollectionUtils.isEmpty(m.getMedia()))) {
    model = ModelConfig.ModelType.VISION;
}
var chatModel = (ChatModel) modelProviders.getModel(
        user.jobClawUserId(), model);

(源码位置: core/.../agent/llm/SimpleLlmCaller.java,第 82-87 行)

逻辑简单,扫一遍 Prompt 里的所有用户消息,只要有任何一条消息携带了 Media,就把模型类型从 TEXT 切换到 VISION。

图片.png

这意味着业务 Agent 在构建 Prompt 的时候完全不需要指定模型类型。消息里有图片,系统自动用视觉模型;没有图片,自动用文本模型。模型切换这件事,对业务层是透明的。

这个设计避免了一个很常见的问题——开发者忘记切模型。

如果模型切换由业务 Agent 手动控制,早晚有人会忘记处理图片场景,导致纯文本模型收到了图片输入,直接报错。自动检测机制从根本上消除了这类低级错误。

05、多供应商怎么适配?

模型类型确定了之后,还有一个问题。不同供应商对视觉模型的支持方式不一样,系统怎么适配?

求职派定义了 7 种模型类型,覆盖了目前主流的 AI 能力。

  • TEXT — 纯文本对话

  • VISION — 视觉理解(图片识别)

  • IMAGE — 图像生成

  • VIDEO — 视频处理

  • EMBEDDING — 向量嵌入

  • ASR — 语音识别

  • TTS — 语音合成

(源码位置: core/.../providers/ModelConfig.java,第 80-110 行)

每个供应商模块实现 ModelProvider 接口,通过 model() 方法处理不同的模型类型。以智谱供应商为例。

Java

return switch (info.getType()) {
    case TEXT -> buildChatModel(info);
    case VISION -> buildChatModel(info);
    case IMAGE -> buildImageModel(info);
    case EMBEDDING -> buildEmbeddingModel(info);
    default -> throw new IllegalArgumentException(...);
};

(源码位置: providers/zhipu/.../ZhiPuModelProvider.java,第 66-73 行)

这里有个细节,智谱的 TEXT 和 VISION 走的是同一个 buildChatModel() 方法,因为智谱的视觉模型和文本模型使用同一套 API 协议,区别只在于模型名称( GLM-4.7-Flash vs GLM-4V-Flash)。

图片.png

新增一个供应商的时候,只需要实现对应的模型类型支持就行了,不需要修改任何业务代码。

这就是 ModelProvider 接口作为扩展点的价值——供应商之间的 API 差异被封装在各自的模块内部,对外暴露的是统一的 Model 接口。

06、用户偏好怎么隔离?

最后一层设计是用户级别的模型偏好。

求职派是一个多用户系统,不同用户可能配置了不同的模型供应商和 API Key。

用户 A 用智谱的 glm-4.6v-flash,用户 B 用通义千问的 qwen-omni-turbo,系统需要按用户维度隔离模型选择。

这个能力由 ModelProviders.getModel() 方法实现。

Java

var preferModel = switch (modelType) {
    case TEXT -> preference.getModels().getText();
    case VISION -> preference.getModels().getVision();
    case IMAGE -> preference.getModels().getImage();
    // ...
};

(源码位置: core/.../providers/ModelProviders.java,第 103-111 行)

每个用户的偏好配置中都有一个 vision 字段,格式是 provider#modelName(例如 zhipu#GLM-4V-Flash)。系统先查用户自己的配置,查不到就回退到全局默认配置。

图片.png

整条流程串起来就是这样。

用户发了一张图片 → LLM 调用器检测到 Media,模型类型设为 VISION → 查用户偏好找到视觉模型配置 zhipu#GLM-4V-Flash → 解析出供应商和模型名 → 智谱供应商构建对应的 ChatModel 实例 → 发起调用。

业务 Agent 全程只管一件事,把用户的消息和图片组装成 Prompt,然后调用 call() 方法。模型选择、供应商路由、偏好降级,全部在底层自动完成。

07、图片识别实战

理解了底层机制,我们来看一个完整的实战例子。

接收一张食物图片的 URL,调用视觉模型识别图片内容,返回食物名称和卡路里估算。

先定义结构化输出的数据模型。

Java

public record FoodDetail(
    @JsonPropertyDescription("整张图片的描述")
    String desc,
    @JsonPropertyDescription("总的卡路里")
    Double totalCalorie,
    @JsonPropertyDescription("图片中的食材列表")
    List itemList) {}

@JsonPropertyDescription 注解会被 Spring AI 自动提取,生成 JSON Schema 传给大模型,引导模型按指定格式返回。

然后写一个接口,完成从下载图片到调用模型的全流程。

Java

@RequestMapping(path = "recognitionAndOutput")
public FoodDetail recognitionAndOutput(
        @RequestParam(name = "imgUrl") String imgUrl,
        @RequestParam(name = "msg") String msg) {
    byte[] imgs = HttpUtil.downloadBytes(imgUrl);
    Media media = Media.builder()
            .mimeType(MimeTypeUtils.IMAGE_PNG)
            .data(imgs).build();
    Message userMsg = UserMessage.builder()
            .text(msg).media(media).build();
    Prompt prompt = new Prompt(userMsg);
    return chatClient.prompt(prompt)
            .call().entity(FoodDetail.class);
}

图片.png

从代码可以看出,多模态调用和普通的文本调用几乎没有区别。唯一的差异就是在构建 UserMessage 的时候多了一个 .media(media) 调用。

不过有一点需要注意,调用之前要确认使用的模型确实支持多模态输入。如果用一个纯文本模型去处理图片,调用会直接报错。在求职派的架构下,这个问题由模型自动检测机制解决了——只要消息里有 Media,系统会自动切换到视觉模型,不需要开发者操心。

08、求职派的岗位采集是怎么用多模态的?

除了用户侧的图片识别,求职派的管理后台也重度依赖了多模态能力。

岗位数据采集场景下,运营人员经常需要从招聘截图中批量提取岗位信息。这个功能由岗位采集代理实现:

Java

Media media = Media.builder()
        .mimeType(type)
        .data(bytes)
        .name("图片-" + type.getSubtype() + "-" + rid)
        .build();
UserMessage msg = UserMessage.builder()
        .media(media)
        .text("提取图片中的表格信息,按照指定要求返回")
        .build();

(源码位置: app/.../gather/service/GatherAiAgent.java,第 119-127 行)

这里有个细节, Media.builder() 除了 mimeType 和 data,还设置了一个 name 字段。在批量处理多张图片的场景下, name 帮助在日志和调试时区分不同的图片。

图片.png

更有意思的是大图片分批处理的设计。

当一张图片内容太多(比如一整页的招聘表格),单次调用可能无法提取完所有信息。采集代理会自动发起多轮对话,第一轮携带图片和提示词,后续轮次只发“继续返回”,直到模型返回完整的结果。

这个设计利用了 ChatMemory 的上下文保持能力。模型在第一轮已经“看过”了图片,后续轮次不需要重复发送图片,只需要告诉模型继续输出就行了。

ending

【Spring AI 的多模态 API 就三步,构建 Media、组装 UserMessage、发起调用。但从“能用”到“好用”,中间隔着模型自动检测、供应商适配、用户偏好路由这三层设计。】

求职派把这三层全部沉到了 LLM 调用层,业务 Agent 只管组装消息,不管模型怎么选。

这大概就是分层设计该有的样子——用的时候感觉不到它的存在。

如何把求职派写到简历上?

项目名称:求职派(JobClaw)—— 多 Agent 智能求职系统

项目简介:基于 Spring AI + LangGraph4J 的多 Agent 求职系统,对接微信/钉钉/飞书三个 IM 渠道,通过意图识别将用户消息路由到身份采集、岗位检索、岗位推荐等业务 Agent,支持多模态输入处理和多供应商模型切换。

技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J、JPA/Hibernate、React 19 / Next.js 15

核心职责:

  • 基于 Spring AI 的 Media API 实现多模态输入处理,支持图片、文件等多种媒体类型的统一接入,业务层无需关心媒体来源和格式差异

  • 设计并实现 LLM 调用层的模型自动检测机制,通过扫描 Prompt 中的 Media 内容自动切换文本/视觉模型,消除业务 Agent 的模型选择负担

  • 实现多供应商(智谱、通义千问)视觉模型的统一适配层,新增供应商只需实现模型类型映射,无需修改业务代码

  • 设计用户级别的模型偏好隔离方案,通过 provider#modelName 格式支持按用户维度配置不同的视觉模型,并实现全局默认降级策略

  • 基于多模态能力实现岗位数据智能采集,支持从招聘截图中批量提取结构化岗位信息,通过多轮对话机制处理大图片的分页识别