Spring AI 多模态开发指南:图片理解与语音合成

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/阿里

最佳实践清单

  1. 图片压缩:上传前压缩到 1024px 以内,降低成本。
  2. 缓存策略:对高频查询(相同图片+问题)使用缓存。
  3. 降级方案:当视觉模型不可用时,回退到纯文本分析。
  4. 并发限制:为每个租户设置 TTS/STT 请求频率限制。
  5. 监控告警:记录每次调用的耗时、成功率、Token 消耗。
  6. 内容审核:在调用视觉模型前增加安全检查。
  7. 数据隐私:对音频/图片中的个人敏感信息进行脱敏处理。
  8. 版本管理:支持不同模型版本切换,便于灰度发布。
  9. 异步处理:长耗时任务(如视频分析)使用异步队列。
  10. 成本优化:定期分析各租户用量,调整配额和模型选择。

参考资源:

相关推荐
YOLO视觉与编程40 分钟前
YOLO / Labelme目标分割数据集增强扩充软件v1.0.0适用于YOLO全版本
人工智能·深度学习·yolo·计算机视觉
en.en..43 分钟前
Linux mmap 内存映射深度解析:基于帧缓冲 /dev/fb0
java·服务器·前端
洋不写bug1 小时前
链表补充练习,双链表的模拟实现
java·数据结构·链表·双链表·底层实现
angered1 小时前
「AI 应用 / AI Agent」行业日报 · 2026-09-04
人工智能·ai编程
深海鱼肝油ya1 小时前
向量数据库Elasticsearch(一)
人工智能·elasticsearch·向量数据库·es数据库增加索引·es数据库相似度查询·knn查询
residual_fan1 小时前
持续对比强化学习(Continual Contrastive Reinforcement Learning)论文分享
人工智能·算法·数据挖掘·数据分析
后台模板学习1 小时前
用 IM 即时聊天项目一次讲清消息去重算法的踩坑与解决方案
java·数据库·spring
深维AI随笔1 小时前
《构建之法》| 第六章敏捷流程:敏捷不是“快“,而是“响应变化“,AI时代怎么敏捷
人工智能·敏捷流程·构建之法
秋名RG1 小时前
2026/6/15 系统故障复盘与整改方案
java·架构