Spring AI Alibaba 多模态模型踩坑记录
一、 defaultOptions 作用解析
1. 核心概念defaultOptions 用于在 ChatClient 构建阶段配置全局默认的大模型调用参数。
2. 主要作用
- 统一配置:避免在每次调用
.prompt()时重复编写相同的控制参数,提高代码复用性。 - 核心参数:
Model:指定调用的具体模型(如qwen-max、qwen-vl-max)。Temperature:控制生成的随机性与创造力(值越低越严谨,越高越发散)。MaxTokens:限制单次输出的最大 Token 数量。
3. 覆盖机制
支持局部覆盖。在具体发起请求时,可通过 client.prompt().options(...) 传入新的参数,以覆盖全局 defaultOptions 中的同名配置。
二、 多模态参数 .withMultiModel(true) 原理
在使用视觉等多模态模型(如 qwen-vl-max)时,必须在 DashScopeChatOptions 中开启 .withMultiModel(true)。
原因涉及底层 API 协议的差异:
1. 请求报文结构(JSON Payload)差异
- 纯文本模式(默认):底层序列化时,
content字段仅支持单一字符串格式。如果传入包含媒体资源(Media)的请求,媒体数据会被忽略或丢弃。 - 多模态模式(开启后):转换器切换协议,将
content字段构建为包含文本和媒体 URL 的对象数组(Array),符合阿里云多模态 API 的标准入参格式。
2. 流式响应(Streaming)解析差异
- 多模态模型与纯文本模型在返回 SSE(Server-Sent Events)数据流时,报文结构不一致。
- 如果不开启该参数,Spring AI 底层反序列化器会默认使用纯文本规则解析多模态响应包,导致数据格式不匹配,进而引发流式输出解析异常或静默失败。
三、 多模态消息构建与动态调用策略
1. 客户端动态切换
在实际业务中,接口通常需要兼容“纯文本”和“图文混合”两种对话形式。应根据用户请求中是否包含媒体附件,动态选择对应的 ChatClient 实例:
- 包含附件:使用预先配置了
.withMultiModel(true)的多模态客户端(如qwen-vl-max)。 - 无附件:使用基础文本客户端(如
qwen-max)。
2. UserMessage 的条件构建原则(核心踩坑点)
在组装发给大模型的消息体(UserMessage)时,必须严格通过 if-else 区分纯文本与多模态场景,不可混用构建逻辑:
-
多模态场景(有附件):
需将文件流或 URL 转换为List<Media>,并显式调用.media()方法附加到消息中。userMessage = UserMessage.builder() .text(message) .media(memoryResources) // 传入 List<Media> .build(); -
纯文本场景(无附件):
仅传入文本内容。绝对不要调用.media()方法(即使传入null或空集合)。如果强行调用,底层 Converter 可能会将其误判为格式错误的多模态请求,从而引发校验失败或 JSON 序列化异常。userMessage = UserMessage.builder() .text(message) // 严禁在此处调用 .media(null) 或 .media(emptyList) .build();
3. 消息参数注入
无论底层走哪种逻辑构建的 UserMessage 对象,最终都统一通过 .prompt().messages(userMessage) 注入到 ChatClient 中。此方式解耦了请求的构建与执行,保证了后续 Advisors(如上下文记忆)和流式(.stream())处理代码的通用性。
参考代码示例:
/**
* 生成流式对话响应
* @param message 用户消息
* @param files 附件列表
* @param fileUrls 附件URL列表
* @param sessionId 会话ID
* @param userId 用户ID
* @return Flux<ServerSentEvent<String>> 流式响应
*/
public Flux<ServerSentEvent<String>> generateStreamResponse(String message, List<MultipartFile> files, List<String> fileUrls, Long sessionId, Long userId) {
boolean hasFiles = files != null && !files.isEmpty();
ChatClient clientToUse = hasFiles ? multiModalChatClient : dashScopeChatClient;
log.info("使用模型: {}, 是否包含附件: {}", hasFiles ? "多模态" : "基础文本", hasFiles);
// 如果有附件,使用多模态模型
List<Media> memoryResources = hasFiles ? convertMultipartFilesToMedia(files) : null;
// 根据是否有附件,分别构建 UserMessage
UserMessage userMessage;
if (hasFiles && memoryResources != null && !memoryResources.isEmpty()) {
// 多模态情况:同时传入文字和媒体文件
userMessage = UserMessage.builder()
.text(message)
.media(memoryResources)
.build();
} else {
// 纯文本情况:只传入文字,绝对不要调用 .media() 方法
userMessage = UserMessage.builder()
.text(message)
.build();
}
// --- 持久化记录:直接存前端传来的 fileUrls ---
String urlsString = (fileUrls != null) ? String.join(",", fileUrls) : "";
// 保存用户消息,同时记录附件URL
chatMessageService.saveMessage(sessionId, userId, 1, message, urlsString);
// 创建会话标识
String chatId = String.valueOf(sessionId);
// 为当前会话创建流式状态标识
AtomicBoolean isStreaming = streamingStates.computeIfAbsent(chatId, k -> new AtomicBoolean(true));
isStreaming.set(true);
// 重置消息保存标识(开始新的流式对话)
AtomicBoolean messageSaved = messageSavedFlags.computeIfAbsent(chatId, k -> new AtomicBoolean(false));
messageSaved.set(false);
// 用于累积AI回复内容
StringBuilder aiResponse = new StringBuilder();
// 生成流式对话
return clientToUse.prompt()
.messages(userMessage)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.stream()
.content()
.takeWhile(data -> isStreaming.get())
.map(content -> {
aiResponse.append(content);
return ServerSentEvent.<String>builder()
.data(content)
.build();
})
.concatWith(Flux.just(ServerSentEvent.<String>builder()
.data("\u0003")
.build()))
.doOnComplete(() -> handleStreamComplete(sessionId, userId, message, aiResponse.toString(), chatId))
.doOnCancel(() -> handleStreamCancel(sessionId, userId, aiResponse.toString(), chatId))
.doOnError(error -> handleStreamError(sessionId, userId, chatId, error))
.onErrorResume(error -> handleStreamErrorResponse(userId, sessionId, error));
}
更多推荐




所有评论(0)