多模态输入处理

本页给出图像输入两种方式(base64 vs URL)的代码和适用场景对比、音频 API 调用格式(含格式转换前置步骤)、PDF/CSV 转文本处理代码、GPT-4V 图像 token 估算表(不同分辨率的具体数值),以及跨模态引用的 prompt 写法。

GPT-4V 图像 token 估算表(不同分辨率的具体 token 数):

# GPT-4V / GPT-4o 图像 token 估算(detail="auto" 模式)
# 来源:OpenAI 官方文档(2024)
#
# 分辨率                  tiles 数    token 数    说明
# 512×512 以内(低精度)     1           85        detail="low",固定 85 token
# 512×512                  1          255        detail="high",1 tile
# 1024×1024                4          765        2×2 tiles
# 1024×2048                8         1445        2×4 tiles
# 2048×2048               16         2833        4×4 tiles(接近上限)
# 最大(任意尺寸)        ≤16        ≤2833        超出则自动缩放
#
# 计算公式(detail="high"):
#   tiles_x = ceil(width / 512)
#   tiles_y = ceil(height / 512)
#   tiles = min(tiles_x * tiles_y, 16)
#   tokens = 85 + 170 * tiles
#
# 示例:1280×720 图像
#   tiles_x = ceil(1280/512) = 3, tiles_y = ceil(720/512) = 2
#   tiles = 6, tokens = 85 + 170*6 = 1105 tokens

def estimate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
    if detail == "low":
        return 85
    # detail="high" 或 "auto"(auto 时按 high 计算)
    tiles_x = -(-width // 512)   # ceil division
    tiles_y = -(-height // 512)
    tiles = min(tiles_x * tiles_y, 16)
    return 85 + 170 * tiles

print(estimate_image_tokens(512, 512))    # 255
print(estimate_image_tokens(1024, 1024))  # 765
print(estimate_image_tokens(1280, 720))   # 1105

摄取流水线

从上传到模型可用内容,按阶段拆分责任:先挡明显无效输入,再做安全与元数据治理,最后才进入识别与结构化。

[ 上传 / URL 拉取 ]
              │
              ▼
        [ MIME · 魔数 · 大小上限 ]
              │
         ┌────┴────────────┐
         ▼                 ▼
   [ 安全扫描 ]      [ 元数据清理 ]
   (隐写/宏/沙箱)     (EXIF/敏感字段)
         │                 │
         └────────┬────────┘
                  ▼
         [ 转码 / 裁剪 / 归一化 ]
                  │
         ┌────────┴────────┐
         ▼                 ▼
   [ OCR / 视觉块 ]   [ ASR / 分段 ]
         │                 │
         └────────┬────────┘
                  ▼
    [ 统一消息 schema · token 估算 ]
                  │
                  ▼
            [ 模型 / Agent ]

图像输入:base64 vs URL 两种方式

import base64, httpx
from openai import OpenAI
from pathlib import Path

client = OpenAI()

# === 方式 1:base64 编码 ===
# 适用场景:本地文件、内网图片、需要确保图片可用性、图片较小(<4MB)
# 优点:无需公网 URL,图片生命周期可控
# 缺点:增大请求体体积(约 1.37x),大图会超出请求大小限制

def image_to_base64(image_path: str) -> str:
    """将本地图片转为 base64 data URL。"""
    path = Path(image_path)
    # 校验魔数而非仅依赖扩展名
    with open(path, "rb") as f:
        header = f.read(12)
    mime = detect_mime_from_magic(header)  # 见下方实现
    if mime not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
        raise ValueError(f"不支持的图片格式: {mime}")

    data = path.read_bytes()
    b64 = base64.standard_b64encode(data).decode()
    return f"data:{mime};base64,{b64}"

def call_vision_base64(image_path: str, question: str) -> str:
    b64_url = image_to_base64(image_path)
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": question},
                {"type": "image_url", "image_url": {"url": b64_url, "detail": "high"}},
            ]
        }],
        max_tokens=1000,
    )
    return resp.choices[0].message.content


# === 方式 2:URL 直接传入 ===
# 适用场景:公网可访问的图片、图片较大(>4MB)、CDN 上的媒体资源
# 优点:请求体小,模型直接拉取
# 缺点:需要图片公网可访问;URL 失效会导致调用失败;模型 IP 需能访问源站

def call_vision_url(image_url: str, question: str) -> str:
    # 先 HEAD 检查 URL 可访问性(避免模型调用时才发现 404)
    r = httpx.head(image_url, timeout=5, follow_redirects=True)
    if r.status_code != 200:
        raise ValueError(f"图片 URL 不可访问: {r.status_code} {image_url}")

    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": question},
                {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}},
            ]
        }],
        max_tokens=1000,
    )
    return resp.choices[0].message.content


# === 多图顺序编号(跨模态引用)===
def call_vision_multi_image(images: list[dict], question: str) -> str:
    """
    images: [{"url": "...", "caption": "图1:系统架构图"}, ...]
    在 question 中可以直接引用"图1左上角"、"图2中的红框区域"等。
    """
    content = [{"type": "text", "text": question + "\n\n图片说明:\n" +
                "\n".join(f"- {img['caption']}" for img in images)}]
    for img in images:
        content.append({
            "type": "image_url",
            "image_url": {"url": img["url"], "detail": "high"}
        })
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": content}],
        max_tokens=2000,
    )
    return resp.choices[0].message.content

# 使用示例:跨模态引用
result = call_vision_multi_image(
    images=[
        {"url": "https://cdn.example.com/arch.png", "caption": "图1:微服务架构图"},
        {"url": "https://cdn.example.com/error.png", "caption": "图2:错误日志截图"},
    ],
    question="图1左上角的 API Gateway 与图2中的 504 错误有什么关联?"
)


def detect_mime_from_magic(header: bytes) -> str:
    """根据文件魔数检测 MIME 类型(比扩展名更可靠)。"""
    if header[:2] == b"\xff\xd8":
        return "image/jpeg"
    if header[:8] == b"\x89PNG\r\n\x1a\n":
        return "image/png"
    if header[:6] in (b"GIF87a", b"GIF89a"):
        return "image/gif"
    if header[:4] == b"RIFF" and header[8:12] == b"WEBP":
        return "image/webp"
    return "application/octet-stream"

音频输入:格式转换 + API 调用

import subprocess, tempfile, os
from pathlib import Path
from openai import OpenAI

client = OpenAI()

# 步骤 1:格式转换前置处理(使用 ffmpeg)
# Whisper API 接受:flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm
# 最大文件大小:25MB
# 建议格式:mp3(压缩好)或 wav(无损,精度高)

def convert_audio_for_whisper(
    input_path: str,
    target_format: str = "mp3",
    sample_rate: int = 16000,
    channels: int = 1,  # 单声道,减小文件体积
) -> str:
    """将任意音频转换为 Whisper 兼容格式。需要系统安装 ffmpeg。"""
    output_path = tempfile.mktemp(suffix=f".{target_format}")
    cmd = [
        "ffmpeg", "-i", input_path,
        "-ar", str(sample_rate),    # 采样率 16kHz(语音识别标准)
        "-ac", str(channels),        # 声道数
        "-ab", "64k",                # 比特率(mp3)
        "-y",                        # 覆盖输出文件
        output_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg 转换失败: {result.stderr}")
    return output_path

# 步骤 2:检查文件大小(超 25MB 需分片)
MAX_WHISPER_SIZE = 25 * 1024 * 1024  # 25MB

def chunk_audio_if_needed(audio_path: str, chunk_duration_sec: int = 600) -> list[str]:
    """超大音频按固定时长分片(默认 10 分钟/片)。"""
    file_size = os.path.getsize(audio_path)
    if file_size <= MAX_WHISPER_SIZE:
        return [audio_path]

    chunks = []
    output_dir = tempfile.mkdtemp()
    cmd = [
        "ffmpeg", "-i", audio_path,
        "-f", "segment",
        "-segment_time", str(chunk_duration_sec),
        "-c", "copy",
        f"{output_dir}/chunk_%04d.mp3"
    ]
    subprocess.run(cmd, check=True, capture_output=True, timeout=300)
    chunks = sorted(Path(output_dir).glob("chunk_*.mp3"))
    return [str(c) for c in chunks]

# 步骤 3:调用 Whisper API
def transcribe_audio(audio_path: str, language: str = "zh") -> dict:
    """
    转录音频,返回带时间戳的文本(便于后续跨模态引用)。
    response_format="verbose_json" 包含每段的 start/end 时间戳。
    """
    # 先转换格式
    converted = convert_audio_for_whisper(audio_path)
    chunks = chunk_audio_if_needed(converted)

    segments = []
    offset = 0.0

    for chunk_path in chunks:
        with open(chunk_path, "rb") as f:
            resp = client.audio.transcriptions.create(
                model="whisper-1",
                file=f,
                language=language,
                response_format="verbose_json",  # 包含时间戳
                timestamp_granularities=["segment"],
            )
        # 调整分片内的时间戳偏移
        for seg in resp.segments:
            segments.append({
                "start": seg.start + offset,
                "end": seg.end + offset,
                "text": seg.text.strip(),
            })
        # 估算当前分片时长作为下一片偏移
        if chunks.index(chunk_path) < len(chunks) - 1:
            offset += 600.0  # 每片 600 秒

    return {
        "full_text": " ".join(s["text"] for s in segments),
        "segments": segments,  # 带时间戳,便于引用"第 2 分 15 秒处的内容"
        "language": language,
    }

# 步骤 4:跨模态引用——在 prompt 中引用音频片段
def ask_about_audio_segment(transcript: dict, question: str) -> str:
    """基于转录结果和时间戳构建 prompt,支持具体时间引用。"""
    # 格式化带时间戳的转录文本
    formatted = "\n".join(
        f"[{s['start']:.1f}s - {s['end']:.1f}s] {s['text']}"
        for s in transcript["segments"]
    )
    prompt = f"""以下是音频转录文本(带时间戳):

{formatted}

问题:{question}

回答时如需引用音频内容,使用"[X.Xs处]"格式标注时间点。"""
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=1000,
    )
    return resp.choices[0].message.content

结构化载荷:PDF/CSV 转文本处理流程

import csv, io
from pathlib import Path

# === PDF 转文本 ===
# pip install pymupdf(fitz)— 速度快,支持文本、图片和表格提取

def pdf_to_text(pdf_path: str, max_pages: int = 50) -> dict:
    """
    PDF 转文本,保留页码信息便于引用("第 3 页")。
    超过 max_pages 时截断并标注。
    """
    import fitz  # pymupdf

    doc = fitz.open(pdf_path)
    pages = []
    total_chars = 0
    MAX_CHARS = 100_000  # 约 50k token,超出截断

    for page_num in range(min(len(doc), max_pages)):
        page = doc[page_num]
        text = page.get_text("text").strip()
        if not text:
            continue  # 跳过空白页(可能是扫描图片页)
        pages.append({
            "page": page_num + 1,
            "text": text,
            "char_count": len(text),
        })
        total_chars += len(text)
        if total_chars > MAX_CHARS:
            pages[-1]["text"] = pages[-1]["text"][:MAX_CHARS - (total_chars - len(text))]
            pages[-1]["truncated"] = True
            break

    return {
        "pages": pages,
        "total_pages": len(doc),
        "extracted_pages": len(pages),
        "truncated": total_chars > MAX_CHARS,
        "full_text": "\n\n---\n\n".join(
            f"[第 {p['page']} 页]\n{p['text']}" for p in pages
        ),
    }

# === CSV 转文本(结构化摘要)===
def csv_to_text(
    csv_content: str,
    max_rows: int = 100,
    include_stats: bool = True,
) -> dict:
    """
    CSV 转文本,包含统计摘要和前 N 行数据。
    大 CSV 不直接全量传入,先生成摘要 + 样本行。
    """
    reader = csv.DictReader(io.StringIO(csv_content))
    rows = list(reader)
    headers = reader.fieldnames or []

    sample_rows = rows[:max_rows]
    truncated = len(rows) > max_rows

    # 构建数值列统计
    stats = {}
    if include_stats:
        for col in headers:
            values = []
            for row in rows:
                try:
                    values.append(float(row[col]))
                except (ValueError, TypeError):
                    pass
            if values:
                stats[col] = {
                    "min": min(values),
                    "max": max(values),
                    "avg": round(sum(values) / len(values), 2),
                    "count": len(values),
                }

    # 格式化为 markdown 表格(前 max_rows 行)
    header_row = " | ".join(headers)
    separator = " | ".join(["---"] * len(headers))
    data_rows = "\n".join(
        " | ".join(str(row.get(h, "")) for h in headers)
        for row in sample_rows
    )
    table_text = f"| {header_row} |\n| {separator} |\n" + "\n".join(
        f"| {' | '.join(str(row.get(h, '')) for h in headers)} |"
        for row in sample_rows
    )

    summary = f"CSV 文件:{len(rows)} 行 × {len(headers)} 列"
    if truncated:
        summary += f"(仅显示前 {max_rows} 行)"
    if stats:
        summary += "\n数值列统计:\n" + "\n".join(
            f"  {col}: min={s['min']}, max={s['max']}, avg={s['avg']}, count={s['count']}"
            for col, s in stats.items()
        )

    return {
        "summary": summary,
        "table": table_text,
        "headers": headers,
        "total_rows": len(rows),
        "stats": stats,
        "full_text": summary + "\n\n" + table_text,
    }

# 跨模态引用:prompt 中引用"图2左上角"的写法
CROSS_MODAL_PROMPT_TEMPLATE = """
图片信息:
{image_captions}

文档内容(摘要):
{doc_summary}

问题:{question}

回答规范:
- 引用图片区域时用「图N的[位置]」(如"图1左上角的流程图")
- 引用文档内容时用「文档第N页」或「表格第N行」
- 如果图片和文档内容相互印证,明确指出关联
"""

MIME 与体积估算

浏览器报告的 type 可能为空(依赖扩展名猜测不可靠);服务端应以魔数为准。下方在本地选一个文件,查看 MIME 提示、原始字节与 Base64 展开后的近似长度(用于粗算 JSON / data URL 体积)。

本地试算

  • MIME(File.type):
  • 原始大小:
  • Base64 字符数(约):
---
name: multimodal-input-pipeline
description: 设计多模态上传与预处理流程;输入:图片/音频/文档文件;产出:标准化内容 + token 估算 + 跨模态引用 prompt;禁止:直接传入未校验 MIME 或未转码的原始文件
version: "1.1.0"
triggers:
  - "处理.*图片|图像.*输入|vision.*input"
  - "音频.*转录|PDF.*解析|多模态.*上传"
steps:
  1. 用 detect_mime_from_magic() 检测 MIME,禁止仅凭扩展名判断类型
  2. 图片超过 20MB 或最大边长超过 4096px 时,用 Pillow 降采样再传入
  3. 图片传入方式选择:本地文件/内网 → base64;公网 CDN 图片 → URL
  4. base64 方式前检查文件大小:超 4MB 切换为 URL 方式
  5. 用 estimate_image_tokens(w, h) 预估 token 数,超 2000 token 时降 detail="low"
  6. 多图时用 image_captions 列表编号(图1/图2),question 中用 "图N位置" 引用
  7. 音频先用 ffmpeg 转为 mp3(16kHz, 1ch, 64kbps)
  8. 音频超 25MB 时用 chunk_audio_if_needed() 分成 10 分钟片段
  9. Whisper 调用用 response_format="verbose_json" 获取时间戳
  10. PDF 用 pymupdf 提取文本,保留页码;超 100k 字符时截断并标注
  11. CSV 用 csv_to_text():生成统计摘要 + 前 100 行表格,不全量传入
  12. EXIF 剥离:图片传入前用 Pillow 去除 GPS 和设备信息
  13. 跨模态引用 prompt 用 CROSS_MODAL_PROMPT_TEMPLATE 模板
constraints:
  - 禁止直接传入 SVG 或含宏的 PDF(沙箱解析后再处理)
  - 禁止在日志中记录 base64 图片数据(只记录文件 hash 和大小)
  - 音频转录结果不得包含未脱敏的 PII(转录后过扫描 PII 过滤器)

返回技能库 更多技能入口