第1678篇:多模态Agent——让Agent能看图、听音频、读文档
第1678篇:多模态Agent——让Agent能看图、听音频、读文档
之前我一直以为多模态是个"炫技"功能,用来做演示很漂亮,实际工程价值有限。直到我们接了一个需求:用户上传一张产品图片,Agent自动识别产品型号、检测瑕疵、生成入库报告。纯文本的Agent完全做不了这个,必须要多模态。
这件事让我认真研究了多模态Agent的工程实现。今天分享一下我的思考和实践。
多模态Agent的能力边界
先说清楚多模态Agent能做什么,不能做什么,避免过度期待。
当前主流多模态LLM(GPT-4V、Claude 3、Gemini Pro Vision)的能力:
能做到的:
- 图片内容理解:描述图片、识别物体、读取文字(OCR)
- 图表数据提取:从图表里读出数值、趋势
- 文档理解:PDF、Word里的文字和图表
- 视频分析(某些模型):逐帧分析、理解时序内容
有局限的:
- 精确测量:无法从图片里精确测量尺寸(误差较大)
- 专业领域图像:X光片、卫星图、电路板,如果没有专门微调,准确率有限
- 实时音频:大多数多模态LLM不能直接处理音频流,需要先转录
做不到的:
- 修改图片(需要专门的图像生成模型)
- 真正"理解"视频动作(更多是逐帧描述,而不是动作理解)
架构设计:多模态工具的设计原则
多模态Agent的核心挑战是:不同类型的媒体需要不同的处理Pipeline,但对上层Agent来说应该是统一的接口。
图像理解工具的实现
最常用的场景,让Agent能看图:
@Service
public class ImageAnalysisTool {
private final OpenAIClient openAIClient;
private final ImageProcessor imageProcessor;
/**
* 通用图像分析:灵活描述,适合各种图像理解任务
*/
@AgentTool(
name = "analyze_image",
description = "分析图片内容,可以描述物体、读取文字、理解图表等。" +
"输入图片URL或Base64编码,返回分析结果。",
categories = {"vision"},
costLevel = "MEDIUM"
)
public ImageAnalysisResult analyzeImage(
@Param(description = "图片URL或base64编码字符串") String imageInput,
@Param(description = "分析任务的具体说明,如'识别图中的产品型号'") String instruction,
@Param(description = "输出详细程度:brief/detailed", required = false) String detail) {
// 统一转换为base64
String base64Image = imageProcessor.toBase64(imageInput);
String mimeType = imageProcessor.detectMimeType(imageInput);
// 构建多模态消息
List<MessageContent> contents = List.of(
MessageContent.text(instruction),
MessageContent.imageBase64(base64Image, mimeType)
);
String detailLevel = "detailed".equals(detail) ? "high" : "low";
ChatRequest request = ChatRequest.builder()
.model("gpt-4o")
.messages(List.of(
Message.system("你是一个图像分析专家,请根据用户的指令准确分析图片内容。"),
Message.user(contents)
))
.maxTokens("high".equals(detailLevel) ? 2000 : 500)
.build();
String response = openAIClient.chat(request);
return ImageAnalysisResult.builder()
.analysis(response)
.imageUrl(imageInput)
.build();
}
/**
* 专用OCR工具:专门提取图片中的文字
*/
@AgentTool(
name = "extract_text_from_image",
description = "从图片中提取所有文字内容(OCR),返回结构化的文字列表",
categories = {"vision", "ocr"}
)
public OcrResult extractText(
@Param(description = "图片URL或base64编码") String imageInput,
@Param(description = "文字类型:mixed/handwriting/printed", required = false)
String textType) {
String base64Image = imageProcessor.toBase64(imageInput);
String ocrPrompt = """
请提取这张图片中的所有文字内容。
要求:
1. 按照图片中的布局顺序输出文字
2. 保留原始格式(如表格用|分隔,段落用换行区分)
3. 如果文字不清晰,用[?]标注
4. 不要添加任何解释,只输出文字内容
""";
if ("handwriting".equals(textType)) {
ocrPrompt += "\n注意:这是手写文字,请尽力识别。";
}
String extractedText = callVisionModel(base64Image, ocrPrompt);
return OcrResult.builder()
.text(extractedText)
.imageSource(imageInput)
.confidence(estimateConfidence(extractedText))
.build();
}
/**
* 产品质检工具:检测图片中的产品缺陷
*/
@AgentTool(
name = "detect_product_defects",
description = "检测产品图片中的外观缺陷,返回缺陷列表和严重程度评估",
categories = {"vision", "quality-control"}
)
public DefectDetectionResult detectDefects(
@Param(description = "产品图片URL") String imageUrl,
@Param(description = "产品类型,如'电子元器件'、'布料'") String productType,
@Param(description = "关注的缺陷类型列表", required = false) List<String> defectTypes) {
String base64Image = imageProcessor.toBase64(imageUrl);
String defectTypeDesc = defectTypes != null && !defectTypes.isEmpty()
? "重点检查以下缺陷类型:" + String.join("、", defectTypes)
: "检查所有可见的外观缺陷";
String detectionPrompt = String.format("""
请对这张%s的图片进行质量检测。
%s
请以JSON格式返回检测结果:
{
"overall_quality": "合格/不合格/需复检",
"defects": [
{
"type": "缺陷类型",
"severity": "轻微/中等/严重",
"location": "缺陷位置描述",
"description": "详细描述"
}
],
"confidence": 0.85,
"recommendation": "处理建议"
}
如果没有发现缺陷,返回空的defects数组。
""", productType, defectTypeDesc);
String response = callVisionModel(base64Image, detectionPrompt);
// 解析JSON响应
DefectDetectionResult result = parseDefectResult(response);
result.setImageUrl(imageUrl);
return result;
}
}音频处理:语音转文字
很多Agent场景需要处理语音输入,比如语音客服、会议记录:
@Service
public class AudioProcessingTool {
private final OpenAIClient openAIClient;
private final AudioFileProcessor audioProcessor;
/**
* 语音转文字(Whisper)
*/
@AgentTool(
name = "transcribe_audio",
description = "将音频文件转录为文字,支持多种语言和方言",
categories = {"audio"},
costLevel = "MEDIUM"
)
public TranscriptionResult transcribeAudio(
@Param(description = "音频文件路径或URL") String audioInput,
@Param(description = "音频语言,如zh/en/ja,不确定可以不填", required = false)
String language,
@Param(description = "是否输出时间戳", required = false) Boolean withTimestamps) {
// 加载音频文件
File audioFile = audioProcessor.loadAudio(audioInput);
// 检查文件大小(Whisper API限制25MB)
if (audioFile.length() > 25 * 1024 * 1024) {
// 自动切分长音频
List<File> segments = audioProcessor.splitAudio(audioFile, Duration.ofMinutes(10));
return transcribeSegments(segments, language, withTimestamps);
}
// 调用Whisper API
TranscriptionRequest request = TranscriptionRequest.builder()
.file(audioFile)
.model("whisper-1")
.language(language)
.responseFormat(Boolean.TRUE.equals(withTimestamps) ? "verbose_json" : "json")
.build();
WhisperResponse whisperResult = openAIClient.transcribe(request);
return TranscriptionResult.builder()
.text(whisperResult.getText())
.language(whisperResult.getLanguage())
.segments(whisperResult.getSegments())
.duration(whisperResult.getDuration())
.build();
}
/**
* 分段转录长音频
*/
private TranscriptionResult transcribeSegments(List<File> segments,
String language,
Boolean withTimestamps) {
StringBuilder fullText = new StringBuilder();
List<TranscriptionSegment> allSegments = new ArrayList<>();
double totalOffset = 0;
for (File segment : segments) {
TranscriptionRequest request = TranscriptionRequest.builder()
.file(segment)
.model("whisper-1")
.language(language)
.responseFormat("verbose_json")
.build();
WhisperResponse segResult = openAIClient.transcribe(request);
fullText.append(segResult.getText()).append(" ");
// 调整时间戳偏移
for (TranscriptionSegment seg : segResult.getSegments()) {
seg.setStart(seg.getStart() + totalOffset);
seg.setEnd(seg.getEnd() + totalOffset);
allSegments.add(seg);
}
totalOffset += segResult.getDuration();
}
return TranscriptionResult.builder()
.text(fullText.toString().trim())
.segments(Boolean.TRUE.equals(withTimestamps) ? allSegments : null)
.build();
}
/**
* 会议记录分析:转录 + 提取关键信息
*/
@AgentTool(
name = "analyze_meeting_audio",
description = "分析会议音频,提取会议摘要、决策事项、待办任务",
categories = {"audio", "meeting"},
costLevel = "HIGH"
)
public MeetingAnalysisResult analyzeMeeting(
@Param(description = "会议录音文件路径或URL") String audioInput,
@Param(description = "会议主题,帮助提高摘要准确性", required = false)
String meetingTopic) {
// 先转录
TranscriptionResult transcript = transcribeAudio(audioInput, "zh", true);
// 再用LLM分析
String analysisPrompt = String.format("""
以下是一段会议录音的转录文字%s:
%s
请提取以下信息,以JSON格式返回:
{
"summary": "会议摘要(200字以内)",
"participants": ["参与者1(如果可以识别)"],
"decisions": [
{"item": "决策内容", "owner": "负责人(如提到)"}
],
"action_items": [
{"task": "任务描述", "owner": "负责人", "deadline": "截止时间(如提到)"}
],
"key_topics": ["关键话题1", "关键话题2"]
}
""",
meetingTopic != null ? "(会议主题:" + meetingTopic + ")" : "",
transcript.getText()
);
String analysisResult = llmClient.complete(analysisPrompt, "gpt-4o");
MeetingAnalysis analysis = JSON.parseObject(analysisResult, MeetingAnalysis.class);
return MeetingAnalysisResult.builder()
.transcript(transcript.getText())
.analysis(analysis)
.audioSource(audioInput)
.build();
}
}PDF和文档处理
PDF是企业场景里最常见的文档格式,Agent需要能"读懂"PDF:
@Service
public class DocumentProcessingTool {
private final PDFExtractor pdfExtractor;
private final LLMClient llmClient;
/**
* 智能文档理解:不只是提取文字,还能理解结构和语义
*/
@AgentTool(
name = "analyze_document",
description = "分析PDF/Word/Excel文档,提取文字内容、表格数据,理解文档结构",
categories = {"document"},
costLevel = "MEDIUM"
)
public DocumentAnalysisResult analyzeDocument(
@Param(description = "文档路径或URL") String documentInput,
@Param(description = "分析任务描述,如'提取合同的核心条款'") String task,
@Param(description = "文档的页码范围,如'1-5',不填则分析全部", required = false)
String pageRange) {
// 提取文档内容
DocumentContent content = extractDocumentContent(documentInput, pageRange);
if (content.isEmpty()) {
throw new DocumentProcessingException("文档内容为空或无法解析");
}
// 根据文档大小决定处理策略
if (content.getTokenEstimate() <= 8000) {
// 小文档:直接全文分析
return analyzeFullDocument(content, task);
} else {
// 大文档:分块处理 + MapReduce
return analyzeChunkedDocument(content, task);
}
}
private DocumentContent extractDocumentContent(String input, String pageRange) {
String fileType = detectFileType(input);
return switch (fileType) {
case "pdf" -> pdfExtractor.extract(input, parsePageRange(pageRange));
case "docx" -> wordExtractor.extract(input);
case "xlsx" -> excelExtractor.extract(input);
case "pptx" -> pptExtractor.extract(input);
default -> throw new UnsupportedDocumentException("不支持的文档类型: " + fileType);
};
}
/**
* 对大文档做Map-Reduce分析
*/
private DocumentAnalysisResult analyzeChunkedDocument(DocumentContent content,
String task) {
List<DocumentChunk> chunks = content.splitIntoChunks(4000); // 每块4000 tokens
log.info("文档分块处理: {} 块", chunks.size());
// Map阶段:每块独立分析
List<String> chunkResults = chunks.parallelStream()
.map(chunk -> analyzeChunk(chunk, task))
.collect(Collectors.toList());
// Reduce阶段:汇总各块结果
String reducePrompt = String.format("""
以下是一份长文档的各部分分析结果,请整合成完整的分析报告:
分析任务:%s
各部分分析结果:
%s
请合并和精炼以上分析,去除重复内容,保留关键信息,输出最终报告。
""",
task,
IntStream.range(0, chunkResults.size())
.mapToObj(i -> "第" + (i+1) + "部分:\n" + chunkResults.get(i))
.collect(Collectors.joining("\n\n"))
);
String finalAnalysis = llmClient.complete(reducePrompt, "gpt-4o");
return DocumentAnalysisResult.builder()
.analysis(finalAnalysis)
.chunkCount(chunks.size())
.documentSource(content.getSource())
.build();
}
/**
* 表格数据提取:专门针对PDF中的表格
*/
@AgentTool(
name = "extract_table_from_document",
description = "从PDF文档中提取表格数据,转换为结构化格式(JSON/CSV)",
categories = {"document", "data-extraction"}
)
public TableExtractionResult extractTable(
@Param(description = "文档路径或URL") String documentInput,
@Param(description = "表格所在页码", required = false) Integer pageNumber,
@Param(description = "输出格式:json/csv", required = false) String outputFormat) {
// 提取PDF页面为图片(更准确地处理复杂表格)
List<BufferedImage> pages = pdfRenderer.renderPages(
documentInput,
pageNumber != null ? List.of(pageNumber) : null
);
List<TableData> allTables = new ArrayList<>();
for (BufferedImage page : pages) {
String base64Page = imageProcessor.toBase64(page);
String tableExtractionPrompt = """
请识别图片中的所有表格,并以JSON格式输出表格数据:
{
"tables": [
{
"title": "表格标题(如果有)",
"headers": ["列1", "列2", "列3"],
"rows": [
["数据1", "数据2", "数据3"],
["数据4", "数据5", "数据6"]
]
}
]
}
注意:
- 保持原始数据,不要添加或删除内容
- 合并单元格用原始值填充
- 如果没有表格,返回空数组
""";
String response = callVisionModel(base64Page, tableExtractionPrompt);
List<TableData> pageTables = parseTableResponse(response);
allTables.addAll(pageTables);
}
// 根据输出格式转换
String formattedOutput;
if ("csv".equals(outputFormat)) {
formattedOutput = tablesToCsv(allTables);
} else {
formattedOutput = JSON.toJSONString(allTables, SerializerFeature.PrettyFormat);
}
return TableExtractionResult.builder()
.tables(allTables)
.formattedOutput(formattedOutput)
.tableCount(allTables.size())
.build();
}
}多模态上下文的构建
让Agent在一次对话中同时处理多种媒体,需要统一的上下文构建:
@Service
public class MultimodalContextBuilder {
/**
* 构建包含多种媒体的对话上下文
*/
public List<Message> buildMultimodalContext(MultimodalInput input,
List<Message> history) {
List<Message> messages = new ArrayList<>(history);
// 构建当前用户消息(可能包含多种媒体)
List<MessageContent> currentContents = new ArrayList<>();
// 1. 文本部分(用户的指令)
if (input.getText() != null) {
currentContents.add(MessageContent.text(input.getText()));
}
// 2. 图片
for (ImageAttachment image : input.getImages()) {
String base64 = imageProcessor.toBase64(image.getSource());
currentContents.add(MessageContent.imageBase64(base64, image.getMimeType()));
// 如果图片有标注说明,加上
if (image.getCaption() != null) {
currentContents.add(MessageContent.text(
"[图片说明: " + image.getCaption() + "]"
));
}
}
// 3. 音频(先转录再加入)
for (AudioAttachment audio : input.getAudios()) {
TranscriptionResult transcript = audioTool.transcribeAudio(
audio.getSource(), audio.getLanguage(), false
);
currentContents.add(MessageContent.text(
"[音频转录内容: " + transcript.getText() + "]"
));
}
// 4. 文档(提取关键内容后加入)
for (DocumentAttachment doc : input.getDocuments()) {
DocumentContent content = documentTool.extractContent(doc.getSource());
// 文档内容太长时,先做摘要
String docContent;
if (content.getTokenEstimate() > 4000) {
docContent = documentTool.summarize(content, 1000);
docContent = "[文档摘要(原文太长已压缩): " + docContent + "]";
} else {
docContent = "[文档内容: " + content.getText() + "]";
}
currentContents.add(MessageContent.text(docContent));
}
messages.add(Message.user(currentContents));
return messages;
}
}多模态输入的预处理和优化
直接把高清图片传给LLM又慢又贵,需要预处理:
@Service
public class ImageProcessor {
/**
* 图片预处理:压缩、转换格式、适配LLM要求
*/
public String toBase64(String imageInput) {
BufferedImage image = loadImage(imageInput);
// 尺寸限制:GPT-4V最大支持4096x4096,但超过2048x2048就很贵
if (image.getWidth() > 2048 || image.getHeight() > 2048) {
image = resize(image, 2048, 2048);
}
// 格式转换:统一转JPEG(比PNG小)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
ImageIO.write(image, "jpeg", baos);
} catch (IOException e) {
throw new ImageProcessingException("图片转换失败", e);
}
// 质量控制:超过5MB则进一步压缩
byte[] imageBytes = baos.toByteArray();
if (imageBytes.length > 5 * 1024 * 1024) {
imageBytes = compressJpeg(image, 0.7f);
}
return Base64.getEncoder().encodeToString(imageBytes);
}
/**
* 自动检测是图片URL还是Base64
*/
private BufferedImage loadImage(String input) {
if (input.startsWith("data:image")) {
// Base64 Data URL
String base64 = input.substring(input.indexOf(",") + 1);
byte[] bytes = Base64.getDecoder().decode(base64);
return ImageIO.read(new ByteArrayInputStream(bytes));
} else if (input.startsWith("http://") || input.startsWith("https://")) {
// URL
return ImageIO.read(new URL(input));
} else {
// 文件路径
return ImageIO.read(new File(input));
}
}
/**
* 智能图片分割:对于超大图片(如海报、地图),分割后分析
*/
public List<BufferedImage> splitForAnalysis(BufferedImage image, int maxTiles) {
// 如果图片宽高比不超过2:1,不需要分割
double aspectRatio = (double) image.getWidth() / image.getHeight();
if (aspectRatio < 2 && aspectRatio > 0.5) {
return List.of(image);
}
// 分割为多个tiles
List<BufferedImage> tiles = new ArrayList<>();
int tileWidth = Math.min(image.getWidth(), 1024);
int tileHeight = Math.min(image.getHeight(), 1024);
for (int y = 0; y < image.getHeight() && tiles.size() < maxTiles; y += tileHeight) {
for (int x = 0; x < image.getWidth() && tiles.size() < maxTiles; x += tileWidth) {
int width = Math.min(tileWidth, image.getWidth() - x);
int height = Math.min(tileHeight, image.getHeight() - y);
BufferedImage tile = image.getSubimage(x, y, width, height);
tiles.add(tile);
}
}
return tiles;
}
}完整的多模态Agent工作流
@Service
public class MultimodalAgentService {
public AgentResult processRequest(MultimodalAgentRequest request) {
// 预处理所有输入媒体
MultimodalInput processedInput = preprocessInput(request.getInput());
// 构建多模态上下文
List<Message> context = contextBuilder.buildMultimodalContext(
processedInput,
memoryService.getWorkingMemory(request.getSessionId())
);
// ReAct循环
for (int step = 0; step < 10; step++) {
// 调用多模态LLM
LLMResponse response = llmClient.chat(
ChatRequest.builder()
.model("gpt-4o") // 必须用支持视觉的模型
.messages(context)
.tools(toolRegistry.getAvailableTools())
.build()
);
context.add(Message.assistant(response.getContent()));
if (response.getToolCalls() == null || response.getToolCalls().isEmpty()) {
// 没有工具调用,任务完成
return AgentResult.success(response.getContent());
}
// 执行工具调用
for (ToolCall toolCall : response.getToolCalls()) {
ToolResult result = toolRouter.invoke(
toolCall.getName(), toolCall.getArguments(), null
);
context.add(Message.tool(toolCall.getId(), result.toString()));
}
}
return AgentResult.failed("超过最大步骤数");
}
private MultimodalInput preprocessInput(RawMultimodalInput raw) {
MultimodalInput processed = new MultimodalInput();
processed.setText(raw.getText());
// 处理图片
for (String imageUrl : raw.getImageUrls()) {
ImageAttachment attachment = new ImageAttachment();
attachment.setSource(imageUrl);
attachment.setMimeType(imageProcessor.detectMimeType(imageUrl));
// 检查图片安全性(NSFW检测等)
if (imageSafetyChecker.isSafe(imageUrl)) {
processed.addImage(attachment);
}
}
// 处理音频:先检查时长,超过限制则拒绝
for (String audioUrl : raw.getAudioUrls()) {
Duration duration = audioProcessor.getDuration(audioUrl);
if (duration.toMinutes() > 60) {
throw new InputTooLargeException("音频文件超过60分钟限制");
}
processed.addAudio(new AudioAttachment(audioUrl));
}
return processed;
}
}实际踩过的坑
坑1:图片Token费用比想象高很多。
GPT-4V对高分辨率图片会拆分成多个tiles计算token,一张2048x2048的图片可能消耗1000+token。批量处理产品图片时成本飙升,后来改成先检测图片是否需要高清分析(比如识别文字需要高清,识别物体可以低清),显著降低了成本。
坑2:多语言OCR的漏识别。
英文OCR准确率很高,但中文(尤其是手写)经常漏识别或识别错误。对于关键信息(如合同金额、身份证号),一定要让Agent二次确认,或者在提取后用规则验证(正则匹配金额格式等)。
坑3:PDF里的表格提取准确率不稳定。
纯文字的PDF可以直接提取文字,但扫描版PDF里的表格,必须走图片分析路线。对于包含复杂表格的PDF,我们发现先用pdfplumber提取,再用Vision API验证和补全,准确率比单一方案高很多。
坑4:音频转录的断句问题。
Whisper转录对长音频的断句不太好,有时候一句话跨两个段落,或者两句话连在一起。对于需要精确断句的场景(比如字幕生成),转录后还需要用LLM做二次断句优化。
多模态Agent的工程门槛比纯文本Agent高不少,主要难度在预处理、成本控制和质量评估。但对于图文混合的企业场景,这个投入是值得的——很多原本需要人工处理的图片和文档任务,可以完全自动化。
