跳到主要内容

多模态处理

多模态 AI 能够同时处理文本、图像、音频等多种类型的数据。本章介绍 Spring AI 中的多模态功能,包括图像理解和多模态对话。

什么是多模态?

多模态 AI 模型能够理解并生成多种类型的内容:

┌─────────────────────────────────────────────────────────────┐
│ 多模态 AI 能力 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 输入类型 输出类型 应用场景 │
│ ────────────────────────────────────────────── │
│ 文本 + 图像 ───> 文本 图像理解、问答 │
│ 文本 ───> 图像 文生图(AI绘画) │
│ 文本 + 图像 ───> 图像 图像编辑、风格转换 │
│ 音频 ───> 文本 语音转文字 │
│ 文本 ───> 音频 文字转语音 │
│ │
└─────────────────────────────────────────────────────────────┘

支持多模态的模型

模型提供商支持的模态
GPT-4o / GPT-4 VisionOpenAI文本、图像输入
Claude 3Anthropic文本、图像输入
Gemini Pro VisionGoogle文本、图像输入
LLaVAOllama (本地)文本、图像输入
Qwen-VL阿里云文本、图像输入

图像输入

基本图像理解

Spring AI 使用 Media 类来表示图像等媒体内容:

import org.springframework.ai.model.Media;
import org.springframework.util.MimeTypeUtils;

@Service
public class ImageAnalysisService {

@Autowired
private ChatClient chatClient;

/**
* 分析图片内容
*/
public String analyzeImage(String imageUrl, String question) {
var userMessage = new UserMessage(
question,
List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageUrl))
);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
}
}

使用 ChatClient 流式 API

@GetMapping("/analyze")
public String analyzeImage(
@RequestParam String imageUrl,
@RequestParam(defaultValue = "描述这张图片") String question) {

return chatClient.prompt()
.user(u -> u.text(question)
.media(MimeTypeUtils.IMAGE_JPEG, new URL(imageUrl)))
.call()
.content();
}

从本地上传图片

@RestController
@RequestMapping("/api/vision")
public class VisionController {

private final ChatClient chatClient;

public VisionController(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("你是一个专业的图像分析助手,请详细描述和分析图片内容。")
.build();
}

/**
* 上传并分析图片
*/
@PostMapping("/analyze")
public String analyzeUploadedImage(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "详细描述这张图片") String question) throws IOException {

// 获取图片 MIME 类型
String contentType = file.getContentType();
MimeType mimeType = MimeType.valueOf(contentType != null ? contentType : "image/jpeg");

// 创建用户消息
var userMessage = new UserMessage(
question,
List.of(new Media(mimeType, file.getBytes()))
);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
}
}

从文件系统读取图片

@Service
public class LocalImageService {

@Autowired
private ChatClient chatClient;

public String analyzeLocalImage(Path imagePath, String question) throws IOException {
byte[] imageData = Files.readAllBytes(imagePath);

// 根据文件扩展名确定 MIME 类型
String fileName = imagePath.getFileName().toString();
MimeType mimeType = determineMimeType(fileName);

var userMessage = new UserMessage(
question,
List.of(new Media(mimeType, imageData))
);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
}

private MimeType determineMimeType(String fileName) {
if (fileName.endsWith(".png")) return MimeTypeUtils.IMAGE_PNG;
if (fileName.endsWith(".gif")) return MimeTypeUtils.IMAGE_GIF;
if (fileName.endsWith(".webp")) return MimeType.valueOf("image/webp");
return MimeTypeUtils.IMAGE_JPEG; // 默认 JPEG
}
}

多图像输入

分析多张图片

@PostMapping("/compare")
public String compareImages(
@RequestParam("file1") MultipartFile file1,
@RequestParam("file2") MultipartFile file2,
@RequestParam(defaultValue = "比较这两张图片的异同") String question) throws IOException {

var userMessage = new UserMessage(
question,
List.of(
new Media(MimeTypeUtils.IMAGE_JPEG, file1.getBytes()),
new Media(MimeTypeUtils.IMAGE_JPEG, file2.getBytes())
)
);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
}

图片序列分析

@PostMapping("/analyze-sequence")
public String analyzeSequence(
@RequestParam("files") List<MultipartFile> files,
@RequestParam String question) throws IOException {

// 将多张图片作为序列输入
List<Media> mediaList = files.stream()
.map(file -> {
try {
return new Media(
MimeType.valueOf(file.getContentType()),
file.getBytes()
);
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.toList();

var userMessage = new UserMessage(question, mediaList);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
}

实际应用示例

1. OCR 文字识别

@Service
public class OcrService {

private final ChatClient ocrClient;

public OcrService(ChatClient.Builder builder) {
this.ocrClient = builder
.defaultSystem("""
你是一个OCR识别专家。
请准确识别图片中的所有文字内容。
保持原有的格式和排版。
如果文字模糊或有识别不确定的地方,请标注。
""")
.build();
}

/**
* 识别图片中的文字
*/
public OcrResult recognizeText(byte[] imageData, MimeType mimeType) {
var userMessage = new UserMessage(
"请识别这张图片中的所有文字内容",
List.of(new Media(mimeType, imageData))
);

String text = ocrClient.prompt()
.messages(userMessage)
.call()
.content();

return new OcrResult(text, calculateConfidence(text));
}

private double calculateConfidence(String text) {
// 简单的置信度计算
return text.length() > 0 ? 0.85 : 0.0;
}

record OcrResult(String text, double confidence) {}
}

2. 图片内容提取

@Service
public class ContentExtractionService {

private final ChatClient extractionClient;

public ContentExtractionService(ChatClient.Builder builder) {
this.extractionClient = builder
.defaultSystem("""
你是一个内容提取专家。
请从图片中提取结构化信息,以JSON格式返回。
""")
.build();
}

/**
* 提取图片中的结构化信息
*/
public <T> T extractInfo(byte[] imageData, MimeType mimeType,
String extractionPrompt, Class<T> type) {
var userMessage = new UserMessage(
extractionPrompt,
List.of(new Media(mimeType, imageData))
);

return extractionClient.prompt()
.messages(userMessage)
.call()
.entity(type);
}

/**
* 提取发票信息
*/
public InvoiceInfo extractInvoice(byte[] imageData) {
return extractInfo(imageData, MimeTypeUtils.IMAGE_JPEG, """
请从这张发票图片中提取以下信息:
- 发票号码
- 开票日期
- 公司名称
- 总金额
- 税额
""", InvoiceInfo.class);
}

/**
* 提取名片信息
*/
public BusinessCardInfo extractBusinessCard(byte[] imageData) {
return extractInfo(imageData, MimeTypeUtils.IMAGE_JPEG, """
请从这张名片中提取以下信息:
- 姓名
- 公司
- 职位
- 电话
- 邮箱
- 地址
""", BusinessCardInfo.class);
}

record InvoiceInfo(
String invoiceNumber,
String date,
String companyName,
double totalAmount,
double taxAmount
) {}

record BusinessCardInfo(
String name,
String company,
String title,
String phone,
String email,
String address
) {}
}

3. 图片分类

@Service
public class ImageClassificationService {

private final ChatClient classificationClient;

public ImageClassificationService(ChatClient.Builder builder) {
this.classificationClient = builder
.defaultSystem("""
你是一个图像分类专家。
请根据图片内容进行分类。
返回JSON格式的分类结果。
""")
.build();
}

/**
* 对图片进行分类
*/
public ClassificationResult classify(byte[] imageData,
MimeType mimeType,
List<String> categories) {
String prompt = String.format("""
请将这张图片分类到以下类别之一:%s

请返回JSON格式:
{
"category": "分类结果",
"confidence": 0.95,
"reason": "分类理由"
}
""", String.join(", ", categories));

var userMessage = new UserMessage(
prompt,
List.of(new Media(mimeType, imageData))
);

return classificationClient.prompt()
.messages(userMessage)
.call()
.entity(ClassificationResult.class);
}

/**
* 检测图片内容类型
*/
public ContentDetection detectContent(byte[] imageData, MimeType mimeType) {
var userMessage = new UserMessage("""
请检测这张图片的内容类型,返回JSON格式:
{
"type": "照片/插图/图表/截图/文档",
"subject": "主要内容描述",
"hasText": true/false,
"hasPeople": true/false,
"colors": ["主要颜色"]
}
""",
List.of(new Media(mimeType, imageData))
);

return classificationClient.prompt()
.messages(userMessage)
.call()
.entity(ContentDetection.class);
}

record ClassificationResult(
String category,
double confidence,
String reason
) {}

record ContentDetection(
String type,
String subject,
boolean hasText,
boolean hasPeople,
List<String> colors
) {}
}

4. 图片问答系统

@RestController
@RequestMapping("/api/image-qa")
public class ImageQAController {

private final ChatClient qaClient;

public ImageQAController(ChatClient.Builder builder) {
this.qaClient = builder
.defaultSystem("""
你是一个图片问答助手。
请根据图片内容准确回答用户的问题。
如果问题超出图片内容范围,请诚实说明。
""")
.build();
}

@PostMapping("/ask")
public Answer askAboutImage(
@RequestParam("file") MultipartFile file,
@RequestParam String question) throws IOException {

var userMessage = new UserMessage(
question,
List.of(new Media(
MimeType.valueOf(file.getContentType()),
file.getBytes()
))
);

ChatResponse response = qaClient.prompt()
.messages(userMessage)
.call()
.chatResponse();

return new Answer(
response.getResult().getOutput().getContent(),
response.getMetadata().getUsage().getTotalTokens()
);
}

@PostMapping("/batch-ask")
public List<Answer> batchAsk(
@RequestParam("file") MultipartFile file,
@RequestBody List<String> questions) throws IOException {

byte[] imageData = file.getBytes();
MimeType mimeType = MimeType.valueOf(file.getContentType());

return questions.stream()
.map(question -> {
var userMessage = new UserMessage(
question,
List.of(new Media(mimeType, imageData))
);

String answer = qaClient.prompt()
.messages(userMessage)
.call()
.content();

return new Answer(answer, 0);
})
.toList();
}

record Answer(String content, int tokens) {}
}

5. 图片描述生成

@Service
public class ImageDescriptionService {

private final ChatClient descriptionClient;

public ImageDescriptionService(ChatClient.Builder builder) {
this.descriptionClient = builder
.defaultSystem("你是一个专业的图像描述专家,请用清晰的语言描述图片内容。")
.build();
}

/**
* 生成简短描述
*/
public String generateBriefDescription(byte[] imageData, MimeType mimeType) {
var userMessage = new UserMessage(
"请用一句话描述这张图片的主要内容",
List.of(new Media(mimeType, imageData))
);

return descriptionClient.prompt()
.messages(userMessage)
.call()
.content();
}

/**
* 生成详细描述
*/
public ImageDescription generateDetailedDescription(byte[] imageData,
MimeType mimeType,
String language) {
String prompt = String.format("""
请用%s详细描述这张图片,包括:
1. 主要内容和主体
2. 场景和背景
3. 颜色和构图
4. 整体氛围和情感

请以JSON格式返回。
""", language);

var userMessage = new UserMessage(
prompt,
List.of(new Media(mimeType, imageData))
);

return descriptionClient.prompt()
.messages(userMessage)
.call()
.entity(ImageDescription.class);
}

/**
* 生成图片标签
*/
public List<String> generateTags(byte[] imageData, MimeType mimeType) {
var userMessage = new UserMessage(
"请为这张图片生成5-10个标签,返回JSON数组格式",
List.of(new Media(mimeType, imageData))
);

return descriptionClient.prompt()
.messages(userMessage)
.call()
.entity(new ParameterizedTypeReference<List<String>>() {});
}

record ImageDescription(
String mainContent,
String scene,
String composition,
String atmosphere
) {}
}

模型配置

OpenAI GPT-4 Vision

spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o # 支持视觉的模型
max-tokens: 1000

Ollama 本地多模态模型

spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: llava # 或 bakllava, moondream

拉取模型:

# 拉取 LLaVA 模型
ollama pull llava

# 拉取 Moondream(轻量级)
ollama pull moondream

Anthropic Claude

spring:
ai:
anthropic:
api-key: ${ANTHROPIC_API_KEY}
chat:
options:
model: claude-3-5-sonnet-20241022

图像大小和格式限制

大小限制

模型最大图片大小建议大小
GPT-4 Vision20MB< 4MB
Claude 35MB< 2MB
LLaVA (本地)取决于内存< 10MB

压缩图片

@Service
public class ImageCompressionService {

/**
* 压缩图片到指定大小
*/
public byte[] compressImage(byte[] imageData, long maxSizeKB) throws IOException {
ByteArrayInputStream bis = new ByteArrayInputStream(imageData);
BufferedImage image = ImageIO.read(bis);

float quality = 0.9f;
byte[] result = imageData;

while (result.length > maxSizeKB * 1024 && quality > 0.1f) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();

ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality);

writer.setOutput(ImageIO.createImageOutputStream(bos));
writer.write(null, new IIOImage(image, null, null), param);
writer.dispose();

result = bos.toByteArray();
quality -= 0.1f;
}

return result;
}
}

最佳实践

1. 优化图片大小

@RestController
public class OptimizedVisionController {

@Autowired
private ImageCompressionService compressionService;

@PostMapping("/analyze-optimized")
public String analyzeOptimized(
@RequestParam("file") MultipartFile file,
@RequestParam String question) throws IOException {

// 压缩图片到 500KB 以下
byte[] compressed = compressionService.compressImage(
file.getBytes(),
500
);

var userMessage = new UserMessage(
question,
List.of(new Media(MimeTypeUtils.IMAGE_JPEG, compressed))
);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
}
}

2. 缓存图片分析结果

@Service
public class CachedImageAnalysisService {

@Autowired
private ChatClient chatClient;

private final Cache<String, String> analysisCache =
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterAccess(Duration.ofHours(24))
.build();

public String analyzeImage(String imageHash, byte[] imageData, String question) {
String cacheKey = imageHash + ":" + question.hashCode();

return analysisCache.get(cacheKey, key -> {
var userMessage = new UserMessage(
question,
List.of(new Media(MimeTypeUtils.IMAGE_JPEG, imageData))
);

return chatClient.prompt()
.messages(userMessage)
.call()
.content();
});
}
}

3. 批量处理图片

@Service
public class BatchImageService {

@Autowired
private ChatClient chatClient;

@Async
public CompletableFuture<List<AnalysisResult>> analyzeBatch(
List<ImageData> images,
String commonQuestion) {

List<AnalysisResult> results = images.stream()
.map(img -> {
var userMessage = new UserMessage(
commonQuestion,
List.of(new Media(img.mimeType(), img.data()))
);

String result = chatClient.prompt()
.messages(userMessage)
.call()
.content();

return new AnalysisResult(img.id(), result);
})
.toList();

return CompletableFuture.completedFuture(results);
}

record ImageData(String id, byte[] data, MimeType mimeType) {}
record AnalysisResult(String imageId, String analysis) {}
}

小结

本章我们学习了:

  1. 多模态概念:AI 同时处理多种数据类型
  2. 图像输入:URL、上传、本地文件等方式
  3. 多图像处理:比较、序列分析
  4. 实际应用:OCR、信息提取、分类、问答
  5. 模型配置:OpenAI、Ollama、Claude 等
  6. 最佳实践:压缩、缓存、批量处理

练习

  1. 实现一个图片内容审核系统
  2. 创建一个发票信息提取服务
  3. 实现一个图片搜索功能(基于内容描述)

下一步