Spring AI 多模态开发指南:图片理解与语音合成
本文深入讲解如何使用 Spring AI 实现多模态 AI 应用,涵盖图片理解(Vision)、语音合成(TTS)、语音识别(STT)、视频理解等能力的集成、性能优化、异常处理与生产级最佳实践。
一、多模态 AI 概述
1.1 多模态模型能力矩阵
| 能力 | 输入模态 | 输出模态 | 典型模型 | 应用场景 |
|---|---|---|---|---|
| 图片理解 | 文本 + 图片 | 文本 | Qwen-VL-Max, GPT-4o, Claude-3.5 | 图片描述、OCR、图表分析 |
| 文本生成图片 | 文本 | 图片 | Stable Diffusion, DALL-E 3 | 文生图、设计稿生成 |
| 语音合成 | 文本 | 音频 | CosyVoice, OpenAI TTS, Azure TTS | 语音助手、有声读物 |
| 语音识别 | 音频 | 文本 | Whisper, FunASR, Paraformer | 语音输入、会议记录 |
| 视频理解 | 文本 + 视频帧 | 文本 | Qwen-VL-Max (视频), GPT-4o | 视频摘要、内容审核 |
| 图片编辑 | 文本 + 图片 | 图片 | DALL-E 2/3, Stable Diffusion XL | 图片修复、风格迁移 |
1.2 Spring AI 多模态模块架构
Spring AI 通过 ChatClient 的统一 API 支持多模态输入,同时为不同模态提供专门的抽象接口:
text
┌─────────────────────────────────────────────────────────────────┐
│ 应用层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ ChatClient │ │SpeechModel │ │TranscriptionModel│ │
│ │ (统一入口) │ │ (TTS) │ │ (STT) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ Spring AI Core │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Media 模型: 表示多模态内容 (MimeType + 数据源) │ │
│ │ Prompt 增强: 支持 Media 列表 │ │
│ │ OutputConvert: 结构化输出 (POJO/Map/List) │ │
│ └────────────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ 提供商适配层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │DashScope │ │OpenAI │ │Azure │ │Anthropic │ │
│ │(通义) │ │(GPT-4o) │ │(Vision) │ │(Claude) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、图片理解(Vision)- 扩展
2.1 完整的 Vision 配置(支持多提供商)
java
@Configuration
public class VisionConfig {
@Value("${spring.ai.dashscope.api-key}")
private String dashScopeApiKey;
@Value("${spring.ai.openai.api-key:}")
private String openAiApiKey;
/**
* 创建视觉模型实例(支持多提供商)
*/
@Bean
public ChatModel visionModel(@Value("${multimodal.vision.provider:dashscope}") String provider) {
return switch (provider) {
case "dashscope" -> createDashScopeVisionModel();
case "openai" -> createOpenAiVisionModel();
default -> throw new IllegalArgumentException("Unsupported vision provider: " + provider);
};
}
private ChatModel createDashScopeVisionModel() {
var api = new DashScopeApi(dashScopeApiKey);
return new DashScopeChatModel(api, DashScopeChatOptions.builder()
.withModel("qwen-vl-max")
.withTopP(0.8)
.withTemperature(0.7)
.build());
}
private ChatModel createOpenAiVisionModel() {
var api = new OpenAiApi(openAiApiKey);
return new OpenAiChatModel(api, OpenAiChatOptions.builder()
.withModel("gpt-4o")
.withMaxTokens(4096)
.build());
}
@Bean
public ChatClient visionChatClient(ChatModel visionModel) {
return ChatClient.builder(visionModel).build();
}
}
2.2 高级图片处理:自动压缩与格式转换
java
package com.example.ai.vision;
import net.coobird.thumbnailator.Thumbnails;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 图片预处理工具:压缩、格式转换、尺寸调整
*/
@Component
public class ImagePreprocessor {
// 目标配置
private static final int MAX_WIDTH = 1024;
private static final int MAX_HEIGHT = 1024;
private static final double COMPRESSION_QUALITY = 0.85; // JPEG 质量
private static final long MAX_FILE_SIZE_BYTES = 4 * 1024 * 1024; // 4MB
/**
* 预处理图片:自动压缩到合理尺寸和质量
*/
public ProcessedImage preprocess(MultipartFile file) throws IOException {
byte[] originalBytes = file.getBytes();
MediaType mediaType = MediaType.parseMediaType(file.getContentType());
// 如果图片过大,进行压缩
if (originalBytes.length > MAX_FILE_SIZE_BYTES || isTooLargeDimensions(originalBytes)) {
byte[] compressed = compressImage(originalBytes, mediaType);
return new ProcessedImage(compressed, mediaType, true);
}
return new ProcessedImage(originalBytes, mediaType, false);
}
/**
* 检查图片尺寸是否过大
*/
private boolean isTooLargeDimensions(byte[] imageData) throws IOException {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageData));
if (image == null) return false;
return image.getWidth() > MAX_WIDTH || image.getHeight() > MAX_HEIGHT;
}
/**
* 压缩图片(使用 Thumbnailator)
*/
public byte[] compressImage(byte[] imageData, MediaType mediaType) throws IOException {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageData));
if (original == null) {
return imageData; // 无法读取,原样返回
}
// 计算目标尺寸(等比缩放)
int targetWidth = original.getWidth();
int targetHeight = original.getHeight();
if (targetWidth > MAX_WIDTH || targetHeight > MAX_HEIGHT) {
double ratio = Math.min((double) MAX_WIDTH / targetWidth, (double) MAX_HEIGHT / targetHeight);
targetWidth = (int) (targetWidth * ratio);
targetHeight = (int) (targetHeight * ratio);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String format = mediaType.getSubtype();
if (format.equalsIgnoreCase("png")) {
// PNG 无损压缩
Thumbnails.of(original)
.size(targetWidth, targetHeight)
.outputFormat("png")
.toOutputStream(baos);
} else {
// JPEG/WEBP 有损压缩
Thumbnails.of(original)
.size(targetWidth, targetHeight)
.outputFormat("jpeg")
.outputQuality(COMPRESSION_QUALITY)
.toOutputStream(baos);
}
return baos.toByteArray();
}
/**
* 处理后的图片结果
*/
public record ProcessedImage(byte[] data, MediaType mediaType, boolean compressed) {}
}
2.3 增强的 VisionService(带缓存和降级)
java
package com.example.ai.vision;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Media;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.time.Duration;
import java.util.UUID;
@Service
public class EnhancedVisionService {
private final ChatClient visionClient;
private final ImagePreprocessor preprocessor;
private final Cache<String, String> analysisCache;
public EnhancedVisionService(ChatClient visionClient, ImagePreprocessor preprocessor) {
this.visionClient = visionClient;
this.preprocessor = preprocessor;
// 设置缓存:最多1000条,过期时间1小时
this.analysisCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofHours(1))
.build();
}
/**
* 带缓存的图片分析
*/
public String analyzeImageWithCache(byte[] imageData, String question) {
// 生成缓存 key(基于图片哈希 + 问题)
String imageHash = DigestUtils.md5DigestAsHex(imageData);
String cacheKey = imageHash + ":" + question;
return analysisCache.get(cacheKey, key -> {
try {
return analyzeImageInternal(imageData, question);
} catch (Exception e) {
// 记录降级日志
return "图片分析失败,请稍后重试。错误:" + e.getMessage();
}
});
}
/**
* 内部执行分析(带重试和降级)
*/
private String analyzeImageInternal(byte[] imageData, String question) {
// 预处理
ImagePreprocessor.ProcessedImage processed = preprocessor.process(imageData);
Resource imageResource = new ByteArrayResource(processed.data()) {
@Override
public String getFilename() {
return "image." + processed.mediaType().getSubtype();
}
};
String finalQuestion = question != null ? question : "请详细描述这张图片的内容";
try {
return visionClient.prompt()
.user(userSpec -> userSpec
.text(finalQuestion)
.media(processed.mediaType(), imageResource)
)
.call()
.content();
} catch (Exception e) {
// 如果图片分析失败,尝试降级:仅文本提示
return "无法分析图片:图片格式可能不受支持或内容过于复杂。";
}
}
/**
* 批量图片分析(并行处理)
*/
public Map<String, String> batchAnalyze(List<byte[]> images, String question) {
return images.parallelStream()
.collect(Collectors.toMap(
img -> DigestUtils.md5DigestAsHex(img),
img -> analyzeImageWithCache(img, question),
(a, b) -> a // 去重
));
}
}
2.4 视频理解(基于多帧图片)
java
package com.example.ai.vision;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* 视频理解服务(抽取关键帧 + 多图分析)
*/
@Service
public class VideoUnderstandingService {
private final ChatClient visionClient;
public VideoUnderstandingService(ChatClient visionClient) {
this.visionClient = visionClient;
}
/**
* 分析视频内容(从视频文件中抽取帧)
* 注:实际可使用 FFmpeg 或 OpenCV 抽取帧
*/
public String analyzeVideo(File videoFile, String question) throws Exception {
// 1. 抽取关键帧(示例:使用 FFmpeg)
List<byte[]> frames = extractFrames(videoFile, 5); // 抽取5帧
// 2. 构建多模态请求
var userSpec = visionClient.prompt().user(u -> u.text(question));
for (int i = 0; i < frames.size(); i++) {
byte[] frame = frames.get(i);
Resource frameResource = new ByteArrayResource(frame) {
@Override
public String getFilename() {
return "frame_" + i + ".jpg";
}
};
userSpec.media(MediaType.IMAGE_JPEG, frameResource);
}
// 3. 调用模型分析
return userSpec.call().content();
}
/**
* 抽取视频帧(简化实现,实际需用 FFmpeg)
*/
private List<byte[]> extractFrames(File videoFile, int frameCount) {
// 实际实现:调用 FFmpeg 或使用 Xuggler/JavaCV
// 此处返回示例数据
List<byte[]> frames = new ArrayList<>();
// 省略实现...
return frames;
}
/**
* 流式视频分析(逐帧处理)
*/
public String analyzeVideoStreaming(File videoFile, String question) {
// 使用 FFmpeg 管道读取帧,边读边分析
// 适用于长视频,降低延迟
return "视频分析结果...";
}
}
三、语音合成(TTS)
3.1 多提供商 TTS 抽象
java
package com.example.ai.tts;
import org.springframework.ai.audio.tts.SpeechModel;
import org.springframework.ai.audio.tts.SpeechPrompt;
import org.springframework.ai.audio.tts.SpeechResponse;
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Configuration
public class TTSConfig {
@Value("${spring.ai.openai.api-key}")
private String openAiApiKey;
@Value("${spring.ai.dashscope.api-key}")
private String dashScopeApiKey;
@Bean
public SpeechModel ttsModel() {
// 根据配置选择 TTS 提供商
String provider = System.getProperty("tts.provider", "openai");
return switch (provider) {
case "openai" -> createOpenAiTTS();
case "dashscope" -> createDashScopeTTS();
default -> createOpenAiTTS();
};
}
private SpeechModel createOpenAiTTS() {
OpenAiAudioApi api = new OpenAiAudioApi(openAiApiKey);
return new OpenAiAudioSpeechModel(api);
}
private SpeechModel createDashScopeTTS() {
// DashScope TTS 集成
return new DashScopeAudioSpeechModel(new DashScopeApi(dashScopeApiKey));
}
}
3.2 增强的 TTSService(支持 SSML、流式输出、多音色)
java
package com.example.ai.tts;
import org.springframework.ai.audio.tts.SpeechModel;
import org.springframework.ai.audio.tts.SpeechPrompt;
import org.springframework.ai.audio.tts.SpeechResponse;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
@Service
public class EnhancedTTSService {
private final SpeechModel speechModel;
private final Map<String, String> voiceMap = new HashMap<>();
public EnhancedTTSService(SpeechModel speechModel) {
this.speechModel = speechModel;
// 初始化语音映射(中文语音映射到英文)
voiceMap.put("zh-CN-XiaoxiaoNeural", "nova");
voiceMap.put("zh-CN-YunyangNeural", "onyx");
voiceMap.put("zh-CN-YunyeNeural", "shimmer");
}
/**
* 标准 TTS(支持 SSML 标签增强语音)
*/
public byte[] synthesize(String text, String voice, Double speed) {
// 支持 SSML 增强:用 <speak> 标签修饰
String ssmlText = wrapWithSSML(text, speed);
var options = OpenAiAudioSpeechOptions.builder()
.withVoice(OpenAiAudioApi.TTSRequest.Voice.valueOf(voice.toUpperCase()))
.withModel("tts-1")
.withResponseFormat(OpenAiAudioApi.TTSRequest.AudioResponseFormat.MP3)
.withSpeed(speed != null ? speed : 1.0)
.build();
SpeechPrompt prompt = new SpeechPrompt(ssmlText, options);
SpeechResponse response = speechModel.call(prompt);
return response.getResult().getOutput();
}
/**
* 多语音合成(分段合成不同音色)
*/
public byte[] synthesizeWithMultiVoice(String text, Map<String, String> segmentVoices) {
// 例如:"欢迎使用我们的服务" 用女声,"请注意安全" 用男声
// 需要按句子分割并分别合成
StringBuilder merged = new StringBuilder();
// 实现略...
return merged.toString().getBytes();
}
/**
* 流式 TTS(边生成边输出音频片段)
*/
public Flux<byte[]> synthesizeStreaming(String text, String voice, Consumer<byte[]> onChunk) {
// 模拟流式输出
return Flux.create(sink -> {
// 实际调用 TTS 的流式 API
byte[] fullAudio = synthesize(text, voice, 1.0);
// 将音频分块发送
int chunkSize = 4096;
for (int i = 0; i < fullAudio.length; i += chunkSize) {
int end = Math.min(i + chunkSize, fullAudio.length);
byte[] chunk = Arrays.copyOfRange(fullAudio, i, end);
sink.next(chunk);
if (onChunk != null) {
onChunk.accept(chunk);
}
}
sink.complete();
});
}
private String wrapWithSSML(String text, Double speed) {
double rate = speed != null ? speed : 1.0;
return String.format(
"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\">" +
"<prosody rate=\"%.2f\">%s</prosody>" +
"</speak>",
rate, text
);
}
/**
* 获取支持的语音列表(动态)
*/
public List<VoiceInfo> getVoices() {
return List.of(
new VoiceInfo("alloy", "中性", "en-US"),
new VoiceInfo("echo", "男声", "en-US"),
new VoiceInfo("fable", "男声", "en-GB"),
new VoiceInfo("onyx", "男声", "en-US"),
new VoiceInfo("nova", "女声", "en-US"),
new VoiceInfo("shimmer", "女声", "en-US")
);
}
public record VoiceInfo(String id, String style, String language) {}
}
3.3 TTS Controller 增强(支持缓存、CORS、流式)
java
@RestController
@RequestMapping("/api/tts")
public class TTSExtendedController {
private final EnhancedTTSService ttsService;
private final Cache<String, byte[]> audioCache;
public TTSExtendedController(EnhancedTTSService ttsService) {
this.ttsService = ttsService;
this.audioCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofHours(24))
.build();
}
/**
* 生成并缓存音频
*/
@PostMapping("/synthesize")
public ResponseEntity<byte[]> synthesize(@RequestBody TTSRequest request) {
String cacheKey = request.text() + ":" + request.voice() + ":" + request.speed();
byte[] audio = audioCache.get(cacheKey, key ->
ttsService.synthesize(request.text(), request.voice(), request.speed())
);
return ResponseEntity.ok()
.header("Cache-Control", "public, max-age=86400")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(audio);
}
/**
* 流式 TTS(适用于长文本,边生成边返回)
*/
@GetMapping(value = "/stream", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public Flux<byte[]> streamTTS(@RequestParam String text,
@RequestParam(defaultValue = "alloy") String voice) {
return ttsService.synthesizeStreaming(text, voice, null);
}
/**
* 获取语音列表
*/
@GetMapping("/voices")
public ResponseEntity<List<VoiceInfo>> getVoices() {
return ResponseEntity.ok(ttsService.getVoices());
}
public record TTSRequest(String text, String voice, Double speed) {}
}
四、语音识别(STT)
4.1 增强的 STT 服务(支持多语言、实时转写)
java
package com.example.ai.stt;
import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt;
import org.springframework.ai.audio.transcription.AudioTranscriptionResponse;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class EnhancedSTTService {
private final OpenAiAudioTranscriptionModel transcriptionModel;
private final Map<String, String> languageMap = new ConcurrentHashMap<>();
public EnhancedSTTService(@Value("${spring.ai.openai.api-key}") String apiKey) {
OpenAiAudioApi api = new OpenAiAudioApi(apiKey);
this.transcriptionModel = new OpenAiAudioTranscriptionModel(api);
// 初始化语言映射
languageMap.put("zh", "zh");
languageMap.put("zh-CN", "zh");
languageMap.put("en", "en");
languageMap.put("en-US", "en");
}
/**
* 语音转文本(自动检测语言)
*/
public String transcribe(byte[] audioData, String mimeType) {
Resource audioResource = new ByteArrayResource(audioData) {
@Override
public String getFilename() {
return "audio." + (mimeType.contains("webm") ? "webm" : "mp3");
}
};
var options = OpenAiAudioTranscriptionOptions.builder()
.withModel("whisper-1")
.withResponseFormat(OpenAiAudioApi.TranscriptResponseFormat.JSON)
.withTemperature(0.0)
.build();
AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(audioResource, options);
AudioTranscriptionResponse response = transcriptionModel.call(prompt);
return response.getResult().getOutput();
}
/**
* 指定语言的转写
*/
public String transcribeWithLanguage(byte[] audioData, Locale locale) {
String lang = languageMap.getOrDefault(locale.getLanguage(), "en");
var options = OpenAiAudioTranscriptionOptions.builder()
.withModel("whisper-1")
.withLanguage(lang)
.withResponseFormat(OpenAiAudioApi.TranscriptResponseFormat.JSON)
.build();
// ...
return "转写结果";
}
/**
* 实时流式语音识别(使用 WebSocket 或 SSE)
* 简化实现:分段处理
*/
public String transcribeStreaming(Flux<byte[]> audioChunks) {
StringBuilder fullText = new StringBuilder();
// 这里需要实现流式分段识别
return fullText.toString();
}
}
4.2 STT Controller
java
@RestController
@RequestMapping("/api/stt")
public class STTController {
private final EnhancedSTTService sttService;
@PostMapping(value = "/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<?> transcribe(@RequestParam("file") MultipartFile file,
@RequestParam(value = "language", required = false) String language) throws Exception {
byte[] audioData = file.getBytes();
String result;
if (language != null && !language.isBlank()) {
result = sttService.transcribeWithLanguage(audioData, Locale.forLanguageTag(language));
} else {
result = sttService.transcribe(audioData, file.getContentType());
}
return ResponseEntity.ok(Map.of("success", true, "text", result));
}
}
五、多模态应用场景
5.1 智能文档审核(图片+文本)
java
@Service
public class DocumentAuditService {
private final ChatClient visionClient;
private final EnhancedVisionService visionService;
/**
* 审核包含图片的文档
*/
public AuditResult auditDocument(String documentText, List<Resource> images) {
// 1. 分析每个图片
List<String> imageAnalyses = images.stream()
.map(img -> visionService.analyzeLocalImage(img, "请详细描述这张图片内容"))
.collect(Collectors.toList());
// 2. 综合所有信息进行审核
String auditPrompt = String.format("""
请审核以下文档和图片描述,检查:
1. 文字内容是否合规(不包含敏感词)
2. 图片内容是否合适(无违规内容)
3. 图文是否一致
文档文本:%s
图片描述:
%s
请给出通过/不通过结论,并说明原因。
""", documentText, String.join("\n", imageAnalyses));
String result = chatClient.prompt(auditPrompt).call().content();
return parseAuditResult(result);
}
}
5.2 语音对话助手(TTS + STT + RAG)
java
@Service
public class VoiceAssistantService {
private final EnhancedSTTService sttService;
private final EnhancedTTSService ttsService;
private final ChatClient chatClient;
private final VectorStore vectorStore;
/**
* 语音对话全流程
*/
public byte[] handleVoiceQuery(byte[] audioData) {
// 1. 语音转文本
String query = sttService.transcribe(audioData, "audio/mp3");
// 2. RAG 检索
List<Document> docs = vectorStore.similaritySearch(query);
String context = docs.stream().map(Document::getContent).collect(Collectors.joining("\n"));
// 3. LLM 生成回答
String answer = chatClient.prompt()
.system("基于以下参考资料回答问题:" + context)
.user(query)
.call()
.content();
// 4. 文本转语音
return ttsService.synthesize(answer, "nova", 1.0);
}
}
5.3 多语言图文翻译
java
@Service
public class MultilingualTranslateService {
private final ChatClient visionClient;
/**
* 图片文字翻译(OCR + 翻译)
*/
public String translateImageText(Resource image, String targetLanguage) {
// 先 OCR 提取文字
String extractedText = visionClient.prompt()
.user(u -> u.text("请识别图片中的所有文字,并以纯文本形式输出(保留原始排版)。")
.media("image/*", image))
.call()
.content();
// 再翻译
return chatClient.prompt()
.system("你将文本翻译成目标语言:" + targetLanguage)
.user(extractedText)
.call()
.content();
}
}
六、性能优化与成本控制
6.1 图片 Token 优化策略
不同模型的图片 Token 计费差异很大,合理控制图片尺寸可以节省成本。
java
@Component
public class VisionTokenOptimizer {
/**
* 估算图片 Token 数(gpt-4o 模型)
*/
public int estimateTokens(byte[] imageData) throws IOException {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageData));
if (image == null) return 0;
int width = image.getWidth();
int height = image.getHeight();
// gpt-4o 的计费规则:
// 低分辨率: 85 tokens (512px 以下)
// 高分辨率: 170 + (宽/512) * (高/512) * 85 tokens
int lowResToken = 85;
int highResToken = 170;
if (width <= 512 && height <= 512) {
return lowResToken;
} else {
int tilesX = (int) Math.ceil((double) width / 512);
int tilesY = (int) Math.ceil((double) height / 512);
return highResToken + (tilesX * tilesY) * lowResToken;
}
}
/**
* 根据 Token 预算自动调整图片尺寸
*/
public byte[] optimizeForBudget(byte[] imageData, int maxTokens) throws IOException {
int currentTokens = estimateTokens(imageData);
if (currentTokens <= maxTokens) {
return imageData;
}
// 压缩比例
double ratio = Math.sqrt((double) maxTokens / currentTokens);
int newWidth = (int) (ImageIO.read(new ByteArrayInputStream(imageData)).getWidth() * ratio);
int newHeight = (int) (ImageIO.read(new ByteArrayInputStream(imageData)).getHeight() * ratio);
// 调用压缩方法...
return compressImage(imageData, newWidth, newHeight);
}
}
6.2 TTS 缓存策略
java
@Service
public class TTSCacheService {
private final Cache<String, byte[]> ttsCache;
private final EnhancedTTSService ttsService;
public TTSCacheService(EnhancedTTSService ttsService) {
this.ttsService = ttsService;
this.ttsCache = Caffeine.newBuilder()
.maximumSize(2000)
.expireAfterWrite(Duration.ofDays(30))
.recordStats()
.build();
}
public byte[] getOrSynthesize(String text, String voice, Double speed) {
String key = text + ":" + voice + ":" + speed;
return ttsCache.get(key, k -> ttsService.synthesize(text, voice, speed));
}
public CacheStats getStats() {
return ttsCache.stats();
}
}
6.3 并发控制
java
@Component
public class MultimodalRateLimiter {
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
public boolean tryAcquire(String tenantId, String modality) {
String key = tenantId + ":" + modality;
RateLimiter limiter = limiters.computeIfAbsent(key, k ->
RateLimiter.create(10.0) // 每秒10次
);
return limiter.tryAcquire();
}
}
七、异常处理与监控
7.1 统一异常处理
java
@RestControllerAdvice
public class MultimodalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(MultimodalExceptionHandler.class);
@ExceptionHandler(VisionException.class)
public ResponseEntity<?> handleVisionException(VisionException e) {
log.error("视觉分析失败", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "图片分析失败", "detail", e.getMessage()));
}
@ExceptionHandler(TTSException.class)
public ResponseEntity<?> handleTTSException(TTSException e) {
log.error("语音合成失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "语音生成失败", "detail", e.getMessage()));
}
@ExceptionHandler(ImageTooLargeException.class)
public ResponseEntity<?> handleImageTooLarge(ImageTooLargeException e) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("error", "图片过大", "maxSize", e.getMaxSize()));
}
}
7.2 监控指标
java
@Component
public class MultimodalMetrics {
private final MeterRegistry registry;
public MultimodalMetrics(MeterRegistry registry) {
this.registry = registry;
}
public void recordVisionCall(boolean success, long durationMs, String model) {
registry.timer("vision.latency", "model", model)
.record(Duration.ofMillis(durationMs));
registry.counter("vision.calls", "status", success ? "success" : "failure")
.increment();
}
public void recordTTSGeneration(int tokens, String voice) {
registry.counter("tts.generations", "voice", voice).increment();
registry.summary("tts.tokens", "voice", voice).record(tokens);
}
public void recordSTTProcessing(long audioLengthMs) {
registry.timer("stt.duration").record(Duration.ofMillis(audioLengthMs));
}
}
八、测试策略
8.1 单元测试(Mock)
java
@ExtendWith(MockitoExtension.class)
class VisionServiceTest {
@Mock
private ChatClient chatClient;
@Mock
private ChatClient.PromptSpec promptSpec;
@Mock
private ChatClient.PromptUserSpec userSpec;
@Mock
private ChatClient.CallResponseSpec callSpec;
@InjectMocks
private EnhancedVisionService visionService;
@Test
void testAnalyzeImage() {
when(chatClient.prompt()).thenReturn(promptSpec);
when(promptSpec.user(any())).thenReturn(promptSpec);
when(promptSpec.call()).thenReturn(callSpec);
when(callSpec.content()).thenReturn("图片描述");
byte[] imageData = new byte[]{1, 2, 3};
String result = visionService.analyzeImageWithCache(imageData, "描述");
assertEquals("图片描述", result);
}
}
8.2 集成测试(真实 API)
java
@SpringBootTest
@ActiveProfiles("test")
class MultimodalIntegrationTest {
@Autowired
private EnhancedVisionService visionService;
@Test
@Disabled("需要真实 API Key")
void testRealImageAnalysis() throws IOException {
Resource image = new ClassPathResource("test-image.jpg");
byte[] data = image.getInputStream().readAllBytes();
String result = visionService.analyzeImageWithCache(data, "这是什么?");
assertNotNull(result);
assertFalse(result.isBlank());
System.out.println("分析结果: " + result);
}
}
九、安全与合规
9.1 图片内容安全检测
java
@Service
public class ContentSafetyService {
private final ChatClient visionClient;
/**
* 检测图片是否包含敏感内容
*/
public boolean isSafe(Resource image) {
String result = visionClient.prompt()
.user(u -> u.text("请判断这张图片是否包含色情、暴力、仇恨言论等敏感内容,只回答'安全'或'不安全'。")
.media("image/*", image))
.call()
.content();
return "安全".equals(result.trim());
}
}
9.2 音频数据隐私处理
java
@Component
public class AudioPrivacyFilter {
/**
* 去除音频中的敏感信息(如姓名、身份证号)
*/
public byte[] redactAudio(byte[] audioData, List<String> sensitivePhrases) {
// 先转文字,再替换敏感词,最后再合成(过于复杂)
// 或者使用语音识别 + 音频编辑
// 简化:返回原音频,但在日志中标记
log.info("处理音频数据,敏感词列表:{}", sensitivePhrases);
return audioData;
}
}
十、部署与运维
10.1 Docker 镜像优化
dockerfile
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/*.jar app.jar
# 安装 FFmpeg 用于视频/音频处理
RUN apt-get update && apt-get install -y ffmpeg && apt-get clean
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
10.2 配置外部化
yaml
multimodal:
vision:
provider: dashscope
model: qwen-vl-max
max-image-size: 4MB
compression-quality: 0.85
cache-ttl: 1h
tts:
provider: openai
model: tts-1
default-voice: alloy
cache-ttl: 24h
stt:
provider: openai
model: whisper-1
language: auto
十一、总结
通过本文,你掌握了 Spring AI 多模态开发的完整知识体系:
| 模块 | 核心内容 |
|---|---|
| 图片理解 | 多提供商配置、自动压缩、缓存、批量处理、视频帧抽取 |
| TTS | 多音色、SSML 增强、流式合成、缓存、多语音合成 |
| STT | 多语言、流式识别、实时转写 |
| 应用场景 | 智能客服、文档审核、语音助手、图文翻译、无障碍描述 |
| 性能优化 | Token 估算、图片压缩、TTS 缓存、并发控制 |
| 异常处理 | 统一异常、监控指标、降级策略 |
| 测试 | 单元测试、集成测试、Mock |
| 安全 | 内容安全检测、隐私过滤 |
| 部署 | Docker 镜像优化、配置外部化 |
推荐模型选型:
| 场景 | 推荐模型 | 提供商 |
|---|---|---|
| 中文图片理解 | qwen-vl-max | 阿里云 |
| 英文图片理解 | gpt-4o / claude-3.5-sonnet | OpenAI/AWS |
| 中文语音合成 | CosyVoice (longwan) | 阿里云 |
| 英文语音合成 | OpenAI TTS (nova/alloy) | OpenAI |
| 多语言语音识别 | whisper-1 / FunASR | OpenAI/阿里 |
最佳实践清单:
- 图片压缩:上传前压缩到 1024px 以内,降低成本。
- 缓存策略:对高频查询(相同图片+问题)使用缓存。
- 降级方案:当视觉模型不可用时,回退到纯文本分析。
- 并发限制:为每个租户设置 TTS/STT 请求频率限制。
- 监控告警:记录每次调用的耗时、成功率、Token 消耗。
- 内容审核:在调用视觉模型前增加安全检查。
- 数据隐私:对音频/图片中的个人敏感信息进行脱敏处理。
- 版本管理:支持不同模型版本切换,便于灰度发布。
- 异步处理:长耗时任务(如视频分析)使用异步队列。
- 成本优化:定期分析各租户用量,调整配额和模型选择。
参考资源: