多模态图文理解:LangChain4j 视觉理解实战(DashScope 通义千问 qwen-vl)

多模态图文理解: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

TextContentImageContent 组合成一条多模态消息的姿势:

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 → 直接用

也就是说:方法参数只要类型是 ContentImageContent 实现了 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.ymlvision.model-name 一行切换,无需改代码。


六、踩坑记录(全部实测)

坑1:没开 isMultimodalModel → 图片被当文本忽略

现象 :模型名设成 qwen-vl-max,代码里 ImageContent.from(url) 也传了,但模型回答"我看不到图片"或直接把 URL 当字符串念出来。

根因QwenChatModel 默认走文本 Generation 路径,ImageContent 不会被序列化成多模态消息体。必须 isMultimodalModel(true) 切换到 MultiModalConversation 路径。

验证方式javap 反编译 QwenChatModel$QwenChatModelBuilder,有 isMultimodalModel(Boolean) 方法;DashScopeChatModelPropertiesisMultimodalModel 字段;自动配置字节码确认会读取它。

解法 :手动构造视觉模型时调 .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"}'

九、五步总结

  1. 多模态消息 = UserMessage.from(List<Content>) ,把 TextContent + ImageContent 塞进同一条消息。
  2. DashScope 视觉模型必开 isMultimodalModel(true),否则图片被当文本忽略------这是本篇第一坑,反编译确认。
  3. 多模型别注册成 ChatModel bean ,用 holder + AiServices.builder() 手动装配,避开按类型注入歧义。
  4. 声明式 AiService 能吃 ImageContent 参数DefaultAiServices.prepareUserMessage 字节码验证),调用方只管"问 + 图"。
  5. DetailLevel 按场景分级AUTO 做默认,OCR 上 HIGH,别无脑 ULTRA_HIGH 烧 token。

版本声明 :本文所有 API 签名均经 javap 反编译 langchain4j-core-1.17.2.jarlangchain4j-1.17.2.jarlangchain4j-community-dashscope-1.17.2-beta27.jarlangchain4j-community-dashscope-spring-boot-starter-1.17.2-beta27.jar 核实。

相关推荐
KKKlucifer1 小时前
异构融合与大规模割接——某电信运营商融合4A平台建设实践
大数据·网络·人工智能·安全
GIR7201 小时前
重构全球关节腔内注射物行业竞争格局:市场占有率、销量排名及主要竞争对手分析
大数据·人工智能
宋哥转AI1 小时前
深入理解 AI Agent · 多 Agent 编排 #01:多 Agent 编排的四种核心模式
人工智能·agent·ai编程
阿图灵1 小时前
OpenCV 阈值与模糊:全局/自适应阈值、Canny 边缘检测与三种模糊
图像处理·人工智能·python·opencv·计算机视觉·边缘检测
故七月1 小时前
让AI“看见”好服务——生成式引擎优化(GEO)的底层逻辑与产业实践
大数据·人工智能·机器学习
用户5274675614211 小时前
Agent 之间别互抄聊天记录:把交接做成可验证的 Artifact Envelope
人工智能
刘立军1 小时前
插件化与扩展点:引导 AI 模块化插拔开发,功能解耦便于迭代
人工智能·后端·架构
腾视科技-AIoT1 小时前
让安全驾驶有“AI”相伴|腾视科技DMS视频监控一体机,守护每一次出行
大数据·人工智能·科技·安全·行车记录仪·ainas·腾视科技
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<二十一>:灰度直方图清晰化图像
c++·人工智能·opencv·计算机视觉