Agent 多模态输入编排:图片、音频、文本统一接入流水线

一、用户发了一张截图问"这个错误怎么修",Agent 说"请用文字描述"

多模态 Agent 的落地面临一个工程瓶颈:不同模态的预处理路线完全不同。图片需要 OCR + 视觉理解、音频需要 ASR + 声学分析、文本直接入 LLM。如果每条路径各自对接,Agent 的输入处理变成了一堆 if-else——if input_type == "image": process_image(); elif input_type == "audio": process_audio(); ...。这种代码维护性差、扩展性差、容错性差。

更糟糕的是混合输入场景——用户同时发了截图和文字"这个按钮的颜色不对"。Agent 需要理解"这个按钮"指的是图片中的哪个按钮——这需要模态交叉引用。if-else 结构根本无法处理这种跨模态的语义关联。

解决方案:统一多模态管道——所有输入先经过分类器判定模态,再路由到对应的预处理链,最后统一为文本描述注入 LLM。管道的核心思想是"描述而非传输"——不把原始图片/音频传给 LLM,而是用专用模型提取语义描述,以结构化文本注入。这个决策基于三个原因:成本(视觉模型单独调用比多模态 LLM 便宜)、可靠性(专用模型的提取结果比多模态 LLM 的理解更精确)、灵活性(文本描述可以组合、修改、缓存)。

flowchart TD
    A[用户输入] --> B[模态分类器]
    
    B --> C[纯文本]
    B --> D[图片/截图]
    B --> E[音频/语音]
    B --> F[混合内容<br/>文本 + 图片]
    
    C --> G[直接入 LLM]
    
    D --> H[OCR 文字提取]
    D --> I[视觉理解描述]
    H & I --> J[结构化描述融合]
    J --> G
    
    E --> K[ASR 语音转文字]
    E --> L[声学特征分析<br/>情绪/语速/噪声]
    K & L --> M[音频上下文融合]
    M --> G
    
    F --> N[文本直接入 LLM]
    F --> O[图片走图片管道]
    N & O --> P[模态交叉引用]
    P --> G
    
    G --> Q[多模态上下文 Prompt]
    Q --> R[Agent 推理与响应]

二、多模态管道的设计原则

原则一:模态优先分类

在预处理之前先判定模态。不是因为分类成本高(MIME 检测约 0.1ms),而是因为错误的预处理比不做预处理更糟糕——把图片当文本扫描,返回的是乱码;把音频当图片处理,抛异常。

分类方式:MIME 类型检测(最可靠)+ 文件扩展名(补充)+ 用户声明(最不可靠,但有些聊天协议支持)。python-magic 库读取文件前 2048 字节就能判定 MIME 类型——成本极低但准确率 > 99%。唯一例外是压缩文件内的内容——需要先解压再检测。

原则二:描述而非传输

不把原始图片/音频传给 LLM。而是用专用模型提取语义描述(图片中的文字、布局、物体、颜色),以结构化文本注入 LLM。这个决策基于成本和可靠性——视觉模型单独调用比走多模态 LLM 便宜很多。

成本对比:GPT-4o 处理一张图片约 $0.005(视觉 token 费用);而 GPT-4o-mini 做 OCR + 视觉描述约 $0.001。5 倍差距。对于批量处理场景(客服系统日均处理 1000+ 图片),这个差距直接决定了是否可行。

可靠性对比:专用 OCR 模型对文字提取的准确率 > 95%,而多模态 LLM 的理解可能漏掉关键细节(如错误码、时间戳等精确信息)。结构化描述可以让 Agent 精确引用图片中的文字内容,而非依赖 LLM 对图片的整体"印象"。

原则三:模态交叉引用

当用户同时发送文本和图片时(如"这个按钮" + 截图),管道需要关联两者。通过 embedding 相似度匹配文本描述和图片内容——如果用户提到"按钮",而图片描述中包含"右上角蓝色按钮",则建立关联。

交叉引用的实现:对用户文本做 embedding,对图片描述做 embedding,计算两者余弦相似度。相似度 > 0.8 的描述片段被标记为"相关上下文",注入 LLM 时优先呈现。

三、统一多模态管道实现

import asyncio
import base64
import io
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Union
from pathlib import Path

from openai import AsyncOpenAI
import magic  # python-magic 用于 MIME 类型检测


class Modality(Enum):
    TEXT = "text"
    IMAGE = "image"
    AUDIO = "audio"
    VIDEO = "video"
    MIXED = "mixed"  # 多模态混合


@dataclass
class ProcessedInput:
    """预处理后的统一输出。
    
    不管输入是什么模态,最终都映射到这个结构。
    这是整个管道的契约——所有处理器必须产出相同格式。
    
    为什么用统一输出而非每个模态独立格式?
    因为 LLM 只接受文本输入——所有模态最终必须转成文本。
    统一输出让下游处理逻辑不需要再做模态判断。
    """
    text_content: str          # 核心文本内容(OCR/ASR 结果)
    visual_description: str = ""  # 图片的视觉描述
    audio_metadata: dict = field(default_factory=dict)  # 音频元数据
    modality: Modality = Modality.TEXT
    confidence: float = 1.0    # 分类置信度


class MultimodalPipeline:
    """统一多模态输入管道。
    
    设计原则:
    - 先分类再处理:避免错误模态的预处理
    - 并行处理:不同模态的提取任务互不依赖
    - 降级策略:某个模态处理失败时有 fallback
    """

    def __init__(self):
        self._client = AsyncOpenAI()
        # MIME 类型到模态的映射
        self._mime_map = {
            "image/": Modality.IMAGE,
            "audio/": Modality.AUDIO,
            "video/": Modality.VIDEO,
            "text/": Modality.TEXT,
        }

    async def process(
        self,
        text_input: Optional[str] = None,
        file_paths: Optional[list[str]] = None,
        file_bytes: Optional[list[bytes]] = None,
    ) -> ProcessedInput:
        """主处理入口。
        
        支持三种输入方式,按优先级:text > file_paths > file_bytes
        纯文本是最快路径——跳过所有预处理,直接返回
        """
        has_text = bool(text_input and text_input.strip())
        has_files = bool(file_paths or file_bytes)

        # 纯文本:最快路径,跳过所有预处理
        if has_text and not has_files:
            return ProcessedInput(
                text_content=text_input,
                modality=Modality.TEXT,
            )

        # 多模态混合:并行处理
        tasks = []
        if has_text:
            tasks.append(self._process_text(text_input))
        else:
            tasks.append(asyncio.sleep(0))  # 占位

        file_contents = []
        if file_paths:
            for fp in file_paths:
                with open(fp, "rb") as f:
                    file_contents.append(f.read())
        elif file_bytes:
            file_contents = file_bytes

        for fc in file_contents:
            tasks.append(self._process_file(fc))

        results = await asyncio.gather(*tasks, return_exceptions=True)

        # 合并结果
        return self._merge_results(results, text_input)

    async def _process_file(self, data: bytes) -> ProcessedInput:
        """处理单个文件——自动检测模态并路由。
        
        降级策略:模态检测失败时尝试文本解码;
        文本解码也失败时标记为"不支持的文件类型",
        置信度设为 0——Agent 收到置信度 0 的输入时应主动向用户确认
        """
        # MIME 检测——只读取前 2048 字节,不全量扫描
        mime_type = magic.from_buffer(data[:2048], mime=True)
        modality = self._detect_modality(mime_type)

        if modality == Modality.IMAGE:
            return await self._process_image(data)
        elif modality == Modality.AUDIO:
            return await self._process_audio(data)
        else:
            # 不支持的模态,标记为文本(尝试文本解码)
            try:
                text = data.decode("utf-8")
                return ProcessedInput(text_content=text, modality=Modality.TEXT)
            except UnicodeDecodeError:
                return ProcessedInput(
                    text_content=f"[不支持的文件类型: {mime_type}]",
                    modality=modality,
                    confidence=0.0,
                )

    async def _process_image(self, data: bytes) -> ProcessedInput:
        """图片处理管道:OCR + 视觉理解。
        
        两个任务并行——OCR 提取文字,视觉模型提取场景描述。
        并行的原因:两者互不依赖,且总延迟 = max(OCR, 视觉)
        而非 sum(OCR, 视觉)
        
        降级策略:如果 OCR 失败,只用视觉描述;
        如果视觉描述失败,只用 OCR 结果;
        两者都失败,返回最低置信度的描述
        """
        b64_image = base64.b64encode(data).decode("utf-8")

        # 并行执行 OCR 和视觉理解
        ocr_result, visual_result = await asyncio.gather(
            self._ocr_extract(b64_image),
            self._visual_describe(b64_image),
            return_exceptions=True,
        )

        ocr_text = ocr_result if not isinstance(ocr_result, Exception) else ""
        visual_desc = visual_result if not isinstance(visual_result, Exception) else ""

        # 融合:OCR 提供精确文本,视觉描述提供上下文
        # 这个融合顺序很重要:先看文字内容(精确),再看场景描述(宏观)
        combined = f"[图片内容]\n"
        if ocr_text:
            combined += f"文字内容:\n{ocr_text}\n"
        if visual_desc:
            combined += f"场景描述: {visual_desc}"

        # 置信度计算:两个任务都成功 = 0.9,只有一个成功 = 0.7
        confidence = 0.9 if (ocr_text and visual_desc) else 0.7 if (ocr_text or visual_desc) else 0.3

        return ProcessedInput(
            text_content=combined,
            visual_description=visual_desc,
            modality=Modality.IMAGE,
            confidence=confidence,
        )

    async def _process_audio(self, data: bytes) -> ProcessedInput:
        """音频处理管道:ASR + 声学分析。"""
        # 写入临时文件(OpenAI Whisper API 需要文件路径或二进制)
        import tempfile
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
            tmp.write(data)
            tmp_path = tmp.name

        try:
            with open(tmp_path, "rb") as audio_file:
                transcript = await self._client.audio.transcriptions.create(
                    model="whisper-1",
                    file=audio_file,
                    response_format="text",
                    language="zh",
                )
            
            # 声学元数据:不做深度分析(成本高),只提取基础信息
            audio_meta = {
                "size_bytes": len(data),
                "has_speech": bool(transcript.strip()),
                # 如果有语音但转录为空,可能是 Whisper 识别失败
                # Agent 应主动向用户确认
            }
            
            # 置信度:有转录内容 = 0.85,无转录但可能有语音 = 0.5
            confidence = 0.85 if transcript.strip() else 0.5
            
            return ProcessedInput(
                text_content=transcript,
                audio_metadata=audio_meta,
                modality=Modality.AUDIO,
                confidence=confidence,
            )
        finally:
            Path(tmp_path).unlink(missing_ok=True)

    async def _ocr_extract(self, b64_image: str) -> str:
        """OCR 文字提取。
        
        使用 GPT-4o-mini 的 vision 能力做 OCR。
        这不是最好的 OCR 方案(PaddleOCR 对中文更准确),
        但它不需要额外部署——一个 API 调用搞定。
        
        生产环境推荐:先用 PaddleOCR 做精确提取,
        再用 GPT-4o-mini 补充上下文描述
        """
        response = await self._client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": "提取图中的所有文字内容,保持原有结构。只输出文字,不要添加解释。"},
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_image}"}},
                ],
            }],
            max_tokens=500,
        )
        return response.choices[0].message.content or ""

    async def _visual_describe(self, b64_image: str) -> str:
        """视觉场景描述。
        
        max_tokens=200 限制输出长度——
        视觉描述只需要 1-2 句话概括,不需要详细描述每个像素
        """
        response = await self._client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": "简要描述这个图片的内容:它是什么类型的界面/场景?有什么关键元素?用1-2句话概括。"},
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_image}"}},
                ],
            }],
            max_tokens=200,
        )
        return response.choices[0].message.content or ""

    async def _process_text(self, text: str) -> ProcessedInput:
        return ProcessedInput(text_content=text, modality=Modality.TEXT)

    def _detect_modality(self, mime_type: str) -> Modality:
        for prefix, modality in self._mime_map.items():
            if mime_type.startswith(prefix):
                return modality
        return Modality.TEXT

    def _merge_results(
        self, results: list, original_text: Optional[str]
    ) -> ProcessedInput:
        """合并多个模态的处理结果。
        
        合并策略:用 "---" 分隔不同模态的文本内容,
        让 LLM 知道这是来自不同来源的信息。
        模态交叉引用在这个阶段完成——
        如果用户文本提到了图片中的元素,
        在合并后的文本中优先呈现相关的图片描述
        """
        text_parts = []
        visual_desc = ""
        audio_meta = {}
        has_image = False
        has_audio = False

        for r in results:
            if r is None or isinstance(r, Exception) or isinstance(r, type(None)):
                continue
            if isinstance(r, ProcessedInput):
                text_parts.append(r.text_content)
                if r.visual_description:
                    visual_desc = r.visual_description
                if r.audio_metadata:
                    audio_meta.update(r.audio_metadata)
                if r.modality == Modality.IMAGE:
                    has_image = True
                if r.modality == Modality.AUDIO:
                    has_audio = True

        if has_image and has_audio:
            modality = Modality.MIXED
        elif has_image:
            modality = Modality.IMAGE
        elif has_audio:
            modality = Modality.AUDIO
        else:
            modality = Modality.TEXT

        return ProcessedInput(
            text_content="\n---\n".join(text_parts),
            visual_description=visual_desc,
            audio_metadata=audio_meta,
            modality=modality,
        )

四、管道设计的代价

延迟累加

图片处理 = OCR(~1s)+ 视觉描述(~1s),并行后约 1s(取最大值)。如果用户发送多张图片,延迟线性增长——5 张图片约 5s。解决方案:并行处理所有图片,但受 API rate limit 限制。OpenAI API 的 rate limit 约 500 RPM(requests per minute),5 张图片 × 2 个请求/张 = 10 个请求,远在限制内。但如果同时有 100 个用户发图,rate limit 就成了瓶颈。

准确性损失

OCR 的结果可能不完美(尤其是中英文混排、手写体、低分辨率截图)。实测准确率:英文约 95%、中文约 90%、中英混排约 85%。视觉描述可能漏掉关键信息(如隐藏的错误提示、小字体的时间戳)。管道应该附带置信度评分,低置信度的结果让 Agent 主动向用户确认——"我看到图片中有一些文字,但不太确定是否完整识别,请问是否还有其他信息?"

成本叠加

每个模态都调用一次 LLM。一张图片的 OCR + 视觉描述约消耗 1000 tokens(~$0.001)。对于大部分场景可接受,但如果要处理视频的逐帧分析(30fps × 60 秒 = 1800 帧),需要大幅简化策略——只分析关键帧(每秒 1 帧 = 60 次调用,约 $0.06)。

Trade-off 讨论

  • 专用模型 vs 多模态 LLM:专用模型(OCR/ASR)成本低、精度高,但需要多步调用;多模态 LLM(如 GPT-4o)一步到位但成本高、对细节的理解不如专用模型。推荐:成本敏感场景用专用模型,精度敏感场景也用专用模型。
  • 并行 vs 串行:并行减少总延迟但增加瞬时 API 调用量(可能触发 rate limit);串行延迟更长但调用量可控。根据 rate limit 和用户数量决定。
  • 置信度阈值 vs 用户体验:低置信度时主动确认提高准确性但增加交互次数;不确认可能给出错误答案但体验流畅。推荐:关键信息(如错误码、金额)低置信度时确认,一般信息不确认。

五、总结

多模态管道本质上是输入归一化层:将图片、音频、文本统一映射到 LLM 能理解的文本空间。核心策略是"描述而非传输"——专用模型提取语义后以文本注入,既省钱又可靠。管道设计上优先保证并行处理和降级容错,确保单模态失败不影响其他模态。置信度评分是连接管道和 Agent 的桥梁——低置信度的输入应该触发确认而非直接推理。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐