多模态图文理解:LangChain4j 视觉理解实战(DashScope 通义千问 qwen-vl)
前几篇我们打通了基础对话、AI Service、RAG、Chat Memory、Agent 工具调用、流式 SSE。本篇把模型的"眼睛"接上------让通义千问看懂图片。
全部代码基于工程实测:LangChain4j 1.17.2 + Spring Boot 3.5.0 + DashScope(通义千问)。
一、为什么必须补上"视觉"
纯文本大模型回答得再好,也只能处理"已经被人用文字描述出来的世界"。但真实业务里,信息往往就躺在一张图里:
| 场景 | 输入 | 期望输出 |
|---|---|---|
| 电商客服 | 用户上传商品截图 | "这是 A 商品,库存 XX,价格 XX" |
| 内容审核 | UGC 图片 | 是否含违规元素 |
| 票据录入 | 发票/小票照片 | 结构化金额、日期字段 |
| 工单系统 | 现场设备照片 | 故障类型 + 处置建议 |
| 教辅 | 学生手写答题图 | 批改与点评 |
这些场景的共同点:图片本身就是"用户消息"的一部分 。LangChain4j 把它建模成 UserMessage 里的一种 Content------ImageContent。本篇就围绕这条主线展开。
二、核心概念:LangChain4j 的多模态消息模型
在 1.17.2 里,一条用户消息不再只是字符串,而是一个 Content 列表:
ini
UserMessage
└─ contents: List<Content>
├─ TextContent "这张图里有什么?"
├─ ImageContent [图片二进制/URL]
└─ ImageContent [第二张图] ← 多图
Content 是接口,1.17.2 内置五种实现(反编译 langchain4j-core 确认):
| Content 实现 | 包 | 用途 |
|---|---|---|
TextContent |
dev.langchain4j.data.message |
纯文本片段 |
ImageContent |
同上 | 图片(URL / Base64 / 文件) |
AudioContent |
同上 | 音频 |
VideoContent |
同上 | 视频 |
PdfFileContent |
同上 | PDF 文件 |
本篇聚焦 ImageContent。它的关键 API(javap 实测签名):
java
// dev.langchain4j.data.message.ImageContent (implements Content)
public static ImageContent from(String url); // 按 URL
public static ImageContent from(String base64Data, String mimeType); // 按 Base64
public static ImageContent from(Path path, String mimeType); // 按本地文件
public static ImageContent from(String url, DetailLevel level); // URL + 细节档位
public static ImageContent from(String base64Data, String mimeType, DetailLevel level);
public Image image();
public DetailLevel detailLevel();
DetailLevel 是一个枚举,控制模型看图"用多大力气":
java
public enum DetailLevel { LOW, MEDIUM, HIGH, ULTRA_HIGH, AUTO }
细节越高,识别越准、但 token 消耗越大。粗粒度分类用 LOW,OCR/细粒度用 HIGH。
把 TextContent 和 ImageContent 组合成一条多模态消息的姿势:
java
import dev.langchain4j.data.message.*;
List<Content> contents = new ArrayList<>();
contents.add(TextContent.from("这张图里有什么?")); // 文字提问
contents.add(ImageContent.from(imageUrl)); // 图片
UserMessage userMessage = UserMessage.from(contents); // ← 多模态 UserMessage
⚠️ 注意
UserMessage.from(Content...)是 varargs,不要 写成UserMessage.from(TextContent, ImageContent[])------数组会被当成单个参数导致编译失败。要么用from(List<Content>),要么逐个展开传 varargs。
三、DashScope 视觉模型的关键开关:isMultimodalModel
这是本篇最重要的一个发现,也是踩坑最多的地方。
DashScope starter 自动配置的 qwenChatModel bean,默认走的是文本对话 API 路径。即使你把 model-name 改成 qwen-vl-max,如果不打开一个隐藏开关,图片 ImageContent 会被当成普通文本忽略------模型根本"看不见"图。
这个开关藏在 QwenChatModelBuilder 里:
java
// 反编译 dev.langchain4j.community.model.dashscope.QwenChatModel$QwenChatModelBuilder
public QwenChatModelBuilder apiKey(String);
public QwenChatModelBuilder modelName(String);
public QwenChatModelBuilder isMultimodalModel(Boolean); // ← 关键开关
public QwenChatModelBuilder temperature(Float);
public QwenChatModelBuilder maxTokens(Integer);
public QwenChatModel build();
设了 isMultimodalModel(true) 之后,QwenChatModel 内部才会从 GenerationParam(文本路径)切换到 MultiModalConversationParam(多模态路径)------这一点在 QwenChatModel 类上的 setMultimodalConversationParamCustomizer(...) 方法名上也能印证。
好消息是,DashScope starter 的 DashScopeChatModelProperties 也暴露了这个配置项:
yaml
langchain4j:
community:
dashscope:
chat-model:
model-name: qwen-vl-max-latest
is-multimodal-model: true # ← 开关
字节码确认:DashScopeAutoConfiguration.qwenChatModel(...) 方法会读取 getIsMultimodalModel() 并传给 builder。所以方式一是直接在 yaml 里把文本模型改成视觉模型并打开开关。
但本工程不能这么做------原因见下一节。我们用的是方式二:单独构造一个视觉模型实例,不注册成 Spring bean。
通义千问视觉模型清单
反编译 QwenModelName 常量,当前可用的视觉模型:
| 常量 | 实际值 | 定位 |
|---|---|---|
QWEN_VL_MAX_LATEST |
qwen-vl-max-latest |
视觉旗舰,识别最准(本篇默认) |
QWEN_VL_PLUS |
qwen-vl-plus |
性价比,日常图文够用 |
QWEN3_VL_PLUS |
qwen3-vl-plus |
新一代视觉,速度更快 |
QWEN3_VL_FLASH |
qwen3-vl-flash |
极速版,适合高并发粗粒度 |
QWEN_VL_MAX |
qwen-vl-max |
稳定版旗舰 |
四、多模型共存陷阱:视觉模型为什么不能注册成 ChatModel bean
这是本篇最硬核的一坑,也是工程化落地必须想清楚的事。
直觉做法:定义一个视觉 @Bean QwenChatModel qwenVisionChatModel(...),用 @AiService(chatModel="qwenVisionChatModel") 引用。看起来干净。
但工程里已经有 8 处按类型注入 ChatModel:
java
ChatController private final ChatModel chatModel;
RagController private final ChatModel chatModel;
MemorySummarizer private final ChatModel chatModel;
DynamicAgentConfig private final ChatModel chatModel;
AgentConfig agentService(ChatModel chatModel, ...)
MallConfig (ChatModel chatModel, ...)
AdvancedMemoryConfig (ChatModel chatModel, ...)
ProductionConfig (ChatModel chatModel, ...)
一旦容器里出现第二个 ChatModel bean,这 8 处按类型注入全部 抛 NoUniqueBeanDefinitionException,应用直接起不来。
为什么不加
@Primary?视觉模型是qwen-vl-max,比文本模型贵、慢。把它设成@Primary,等于让所有纯文本对话都走视觉模型------成本和延迟都炸。为什么不把视觉 bean 设成"非主候选"?Spring 的按类型注入在多 bean 场景下只能靠
@Qualifier或字段名匹配救场,8 处全加@Qualifier改动面太大、风险高。
本篇的解法:holder 模式。 视觉模型实例只作为一个普通字段持有,不进 Spring 容器 ;AiService 通过 AiServices.builder() 手动装配------这和工程里 AgentConfig 装配 AgentService 的做法完全一致:
java
@Component
public class VisionModelProvider {
private ChatModel visionModel;
@PostConstruct
void init() {
this.visionModel = QwenChatModel.builder()
.apiKey(apiKey)
.modelName("qwen-vl-max-latest")
.isMultimodalModel(true) // 关键开关
.temperature(0.7f)
.maxTokens(2048)
.build();
}
public ChatModel get() { return visionModel; }
}
@Configuration
public class VisionConfig {
@Bean
public VisionAssistant visionAssistant(VisionModelProvider provider) {
return AiServices.builder(VisionAssistant.class)
.chatModel(provider.get()) // 手动装配,不依赖容器
.build();
}
}
这样:视觉模型不是 bean → 容器里依旧只有一个 ChatModel(文本)→ 8 处按类型注入纹丝不动 → 零侵入。应用启动实测通过,无任何 bean 歧义。
五、五种实战策略
策略一:低阶手动 ------ ChatModel.chat(ChatRequest)
对消息构造拥有完全控制权,能拿到 TokenUsage / FinishReason 等元数据:
java
@Service
public class VisionService {
private final ChatModel visionModel;
private final String modelName;
public VisionService(VisionModelProvider provider) {
this.visionModel = provider.get();
this.modelName = provider.modelName();
}
/** 按 URL 识别 */
public VisionResult describeByUrl(String imageUrl, String question,
ImageContent.DetailLevel level) {
ImageContent image = (level == null)
? ImageContent.from(imageUrl)
: ImageContent.from(imageUrl, level);
return ask("你是专业的图像分析助手。", question, List.of(image));
}
/** 按 Base64 识别(上传场景,图片不经过公网二次拉取) */
public VisionResult describeByBase64(String base64, String mimeType,
String question, ImageContent.DetailLevel level) {
ImageContent image = ImageContent.from(base64, mimeType, level);
return ask("你是专业的图像分析助手。", question, List.of(image));
}
/** 按本地文件识别(mimeType 必填:image/png、image/jpeg) */
public VisionResult describeByFile(Path file, String mimeType,
String question, ImageContent.DetailLevel level) {
ImageContent image = ImageContent.from(file, mimeType, level);
return ask("你是专业的图像分析助手。", question, List.of(image));
}
private VisionResult ask(String systemPrompt, String question, List<ImageContent> images) {
long start = System.currentTimeMillis();
// ★ 文本 + 图片组合成一条多模态 UserMessage
List<Content> contents = new ArrayList<>();
contents.add(TextContent.from(question));
contents.addAll(images);
UserMessage userMessage = UserMessage.from(contents);
ChatRequest request = ChatRequest.builder()
.messages(SystemMessage.from(systemPrompt), userMessage)
.build();
ChatResponse response = visionModel.chat(request);
long elapsed = System.currentTimeMillis() - start;
String text = response.aiMessage().text();
TokenUsage usage = response.tokenUsage();
// ... 打包 meta(modelName / imageCount / elapsedMs / tokens / finishReason)
return new VisionResult(text, meta);
}
public record VisionResult(String text, Map<String, Object> meta) {}
}
三种图片来源全覆盖,DetailLevel 可选。核心就一句:
java
ChatRequest.builder()
.messages(SystemMessage.from(...), UserMessage.from(TextContent, ImageContent))
.build();
visionModel.chat(request);
策略二:声明式 ------ VisionAssistant(@UserMessage + ImageContent 参数)
很多人以为 @AiService 声明式接口只能传 String。其实它能直接吃 ImageContent。原理在 DefaultAiServices.prepareUserMessage 字节码里:
java
53: instanceof class dev/langchain4j/data/message/Content // 参数是 Content → add 到 contents
81: isListOfContent → addAll // 参数是 List<Content> → 批量加入
138: checkcast class dev/langchain4j/data/message/UserMessage // 参数是 UserMessage → 直接用
也就是说:方法参数只要类型是 Content(ImageContent 实现了 Content),框架就会把它塞进 UserMessage 的 contents 列表,和 @UserMessage 的文本模板合成一条多模态消息。
所以这样的接口是合法且能跑的:
java
public interface VisionAssistant {
@SystemMessage("你是专业的图像分析助手。用中文给出准确、结构化的分析,"
+ "按「主体 / 场景 / 关键细节 / 适配用途」四段输出。")
String understand(@UserMessage String question, ImageContent image);
@SystemMessage("你是专业的图像分析助手。对比分析多张图片,用中文给出结构化结论。")
String understandMultiple(@UserMessage String question, ImageContent... images);
}
调用极简:
java
ImageContent image = ImageContent.from(imageUrl);
String answer = visionAssistant.understand("这张图里有什么?", image);
图片装配由框架自动完成,调用方只管"问 + 图"。多图用变参 ImageContent...,逐个被 instanceof Content 命中并 addAll。
策略三:多图对比
java
public VisionResult compareByUrl(List<String> imageUrls, String question,
ImageContent.DetailLevel level) {
List<ImageContent> images = new ArrayList<>();
for (String url : imageUrls) {
images.add(level == null ? ImageContent.from(url) : ImageContent.from(url, level));
}
return ask("你是专业的图像分析助手。", question, images);
}
把多张图片塞进同一条 UserMessage,模型天然具备跨图对比能力("左图偏暖、右图偏冷")。注意是同一条消息多图,而不是多轮------多轮会丢失图。
策略四:场景化 ------ 商城商品图智能分析
给个贴合电商的系统提示,同一套底层服务立刻变成"商品图识别引擎":
java
@GetMapping("/product")
public VisionService.VisionResult product(
@RequestParam String imageUrl,
@RequestParam(value = "question",
defaultValue = "识别这个商品:类目、材质、风格、适用人群,并给一句话上架文案") String question,
@RequestParam(value = "detailLevel", defaultValue = "HIGH") String detailLevel) {
return visionService.describeByUrl(imageUrl, question, parseLevel(detailLevel));
}
HIGH 档位保证材质纹理、吊牌文字都能识别。这个端点直接复用策略一的 describeByUrl,只换提示和档位------体现"系统提示即业务"的思路。
策略五:生产级细节
① token 计量 :ChatResponse.tokenUsage() 返回 inputTokenCount / outputTokenCount / totalTokenCount。视觉模型图片部分 token 消耗显著(尤其 ULTRA_HIGH),务必落库做成本核算。
② DetailLevel 取舍:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 商品粗分类、是否含人 | LOW |
省钱、快 |
| 客服看图答疑 | MEDIUM/AUTO |
平衡 |
| OCR、吊牌、票据 | HIGH |
要看清细节文字 |
| 医学/质检极细粒度 | ULTRA_HIGH |
精度优先,成本不敏感 |
③ Base64 vs URL:
- 公网可访问图 →
ImageContent.from(url),最省事。 - 用户上传 / 内网图 →
ImageContent.from(base64, mimeType),避免二次网络拉取,延迟更低。 - 服务端本地文件 →
ImageContent.from(path, mimeType),零网络。
④ 错误处理 :视觉模型调用要包进全局异常处理(本工程 GlobalExceptionHandler),把 ApiException 转成统一 ApiResponse,别把 DashScope 原始堆栈抛给前端。
⑤ 模型选型 :高并发巡检用 qwen3-vl-flash,旗舰识别用 qwen-vl-max-latest,日常图文用 qwen-vl-plus。可在 application.yml 的 vision.model-name 一行切换,无需改代码。
六、踩坑记录(全部实测)
坑1:没开 isMultimodalModel → 图片被当文本忽略
现象 :模型名设成 qwen-vl-max,代码里 ImageContent.from(url) 也传了,但模型回答"我看不到图片"或直接把 URL 当字符串念出来。
根因 :QwenChatModel 默认走文本 Generation 路径,ImageContent 不会被序列化成多模态消息体。必须 isMultimodalModel(true) 切换到 MultiModalConversation 路径。
验证方式 :javap 反编译 QwenChatModel$QwenChatModelBuilder,有 isMultimodalModel(Boolean) 方法;DashScopeChatModelProperties 有 isMultimodalModel 字段;自动配置字节码确认会读取它。
解法 :手动构造视觉模型时调 .isMultimodalModel(true);或 yaml 配 is-multimodal-model: true。
坑2:第二个 ChatModel bean → 8 处按类型注入全崩
现象 :定义 @Bean QwenChatModel qwenVisionChatModel(...) 后,应用启动报 NoUniqueBeanDefinitionException: No qualifying bean of type 'ChatModel'。
根因 :工程已有 8 处 ChatModel chatModel 按类型注入,容器多出一个 ChatModel bean 后全部歧义。自动配置的 qwenChatModel 是 @ConditionalOnProperty(非 @ConditionalOnMissingBean、非 @Primary),不会自动让位。
解法 :holder 模式------视觉模型不进容器,AiServices.builder().chatModel(provider.get()) 手动装配。零侵入,应用实测启动通过。
坑3:UserMessage.from(TextContent, ImageContent\[\]) 编译失败
现象:
css
对于 from(TextContent, ImageContent[]), 找不到合适的方法
根因 :UserMessage.from(Content...) 是 varargs。传 TextContent + ImageContent[] 时,Java 把 ImageContent[] 当成单个参数 (类型是"数组",不是 Content),与 varargs 不匹配。
解法 :用 UserMessage.from(List<Content>),把文本和图片放进同一个 List:
java
List<Content> contents = new ArrayList<>();
contents.add(TextContent.from(question));
contents.addAll(images);
UserMessage userMessage = UserMessage.from(contents);
坑4:视觉模型名写错 / 用了不存在的型号
现象 :modelName("qwen-vision") 或 modelName("qwen-vl") 调用报模型不存在。
根因 :通义千问视觉模型名有严格拼写,且区分代际。反编译 QwenModelName 常量拿到准确清单(见第三节表格)。qwen-vl-max-latest(带 -latest)是当前旗舰,qwen3-vl-* 是新一代。
解法 :照着常量表填,或直接用 QwenModelName.QWEN_VL_MAX_LATEST 常量。
坑5:DetailLevel 拉满导致 token 爆炸
现象 :全场景默认 ULTRA_HIGH,一张图烧掉几千 token,账单肉眼可见地涨。
根因 :DetailLevel 越高,图片被切成的 token tile 越多。ULTRA_HIGH 适合极细粒度,但不该做默认值。
解法 :按场景分级(见策略五表格),默认用 AUTO 让模型自定,OCR 类才上 HIGH。
七、API 速查表(1.17.2 实测签名)
ImageContent(dev.langchain4j.data.message)
| 方法 | 说明 |
|---|---|
from(String url) |
按 URL |
from(String base64Data, String mimeType) |
按 Base64 |
from(Path path, String mimeType) |
按本地文件 |
from(String url, DetailLevel level) |
URL + 细节档位 |
from(String base64Data, String mimeType, DetailLevel level) |
Base64 + 档位 |
image() |
取 Image 对象(url()/base64Data()/mimeType()) |
detailLevel() |
取档位 |
DetailLevel(ImageContent 内部枚举)
LOW / MEDIUM / HIGH / ULTRA_HIGH / AUTO
UserMessage(dev.langchain4j.data.message)
| 方法 | 说明 |
|---|---|
from(String text) |
纯文本消息 |
from(Content... contents) |
多模态消息(varargs,注意坑3) |
from(List<Content> contents) |
多模态消息(推荐,避免数组坑) |
from(String name, Content... contents) |
带 name 的多模态消息 |
ChatRequest(dev.langchain4j.model.chat.request)
java
ChatRequest.builder()
.messages(ChatMessage... messages) // SystemMessage + UserMessage
.parameters(ChatRequestParameters) // 可选:覆盖温度等
.build();
// ChatModel.chat(ChatRequest) → ChatResponse
QwenChatModelBuilder(dev.langchain4j.community.model.dashscope)
java
QwenChatModel.builder()
.apiKey(String)
.modelName(String) // qwen-vl-max-latest 等
.isMultimodalModel(Boolean) // ★ 视觉模型必填 true
.temperature(Float)
.maxTokens(Integer)
.build();
八、文件清单与测试接口
新增文件(vision 包)
| 文件 | 职责 |
|---|---|
vision/VisionModelProvider.java |
视觉模型持有者(非 bean,holder 模式) |
vision/VisionConfig.java |
AiServices.builder() 手动装配 VisionAssistant |
vision/VisionAssistant.java |
声明式多模态接口(@UserMessage + ImageContent 参数) |
vision/VisionService.java |
低阶手动多模态(三种图源 + DetailLevel + 多图 + token 计量) |
vision/VisionController.java |
5 个 REST 端点 |
application.yml 新增
yaml
vision:
model-name: qwen-vl-max-latest
测试接口
bash
# 按 URL 识别(低阶)
curl -X POST http://localhost:8080/api/vision/describe \
-H "Content-Type: application/json" \
-d '{"imageUrl":"https://你的图.jpg","question":"图里有什么","detailLevel":"HIGH"}'
# 声明式(只管问+图)
curl -X POST http://localhost:8080/api/vision/ask \
-H "Content-Type: application/json" \
-d '{"imageUrl":"https://你的图.jpg","question":"这张图的主体是什么"}'
# 多图对比
curl -X POST http://localhost:8080/api/vision/compare \
-H "Content-Type: application/json" \
-d '{"imageUrls":["https://a.com/1.jpg","https://a.com/2.jpg"],"question":"对比两张图","detailLevel":"MEDIUM"}'
# 商城商品图识别
curl "http://localhost:8080/api/vision/product?imageUrl=https://你的商品图.jpg"
# Base64 上传
curl -X POST http://localhost:8080/api/vision/describe-base64 \
-H "Content-Type: application/json" \
-d '{"base64":"iVBORw0KG...","mimeType":"image/png","question":"描述这张图","detailLevel":"AUTO"}'
九、五步总结
- 多模态消息 =
UserMessage.from(List<Content>),把TextContent+ImageContent塞进同一条消息。 - DashScope 视觉模型必开
isMultimodalModel(true),否则图片被当文本忽略------这是本篇第一坑,反编译确认。 - 多模型别注册成 ChatModel bean ,用 holder +
AiServices.builder()手动装配,避开按类型注入歧义。 - 声明式 AiService 能吃
ImageContent参数 (DefaultAiServices.prepareUserMessage字节码验证),调用方只管"问 + 图"。 - DetailLevel 按场景分级 ,
AUTO做默认,OCR 上HIGH,别无脑ULTRA_HIGH烧 token。
版本声明 :本文所有 API 签名均经
javap反编译langchain4j-core-1.17.2.jar、langchain4j-1.17.2.jar、langchain4j-community-dashscope-1.17.2-beta27.jar、langchain4j-community-dashscope-spring-boot-starter-1.17.2-beta27.jar核实。