← 返回导航站首页

AI Agent 多模态输入实战指南:图片、PDF、音频的预处理管线与成本悬崖

把图片、PDF、音频丢给模型,看着很省事,账单和解法都比想象中细。这篇文章全部数字来自本机实跑(Windows / RapidOCR CPU / PyMuPDF / faster-whisper small int8),复现脚本和原始 JSON 都在仓库 tools/multimodal/。

先给结论,四条:

  1. 多模态输入的成本由「面积 / 时长」决定,准确率由「有效字高 / 信噪 / 评测口径」决定——这是两条不同的曲线。 渲染 dpi 从 72 提到 300,视觉 token 涨 7.9 倍,字符错误率一动不动。
  2. 能本地转成文本的,就别把原文件丢给模型。 同一份 PDF:文本层提取 0.0112 秒 / 0 token / 0 错误;渲染成图再 OCR 是 2.62 秒 / 3485 token。
  3. 静音是最贵的输入。 60 秒没人说话的音频,会照单计费(1461 → 4461 token),本地转写从 2.5 秒拖到 52.65 秒,而且同一文件连续三次结果都不同。
  4. 同一份完全正确的识别结果,CER 可以是 0% 也可以是 25.64%。 差别不在模型,在你有没有冻结评测口径。

一、先算账:三类模态怎么计费

模态 计费量 本机实测基准
图片(视觉输入) 瓦片数:85 + 170 × ceil(w/512) × ceil(h/512) A4 200dpi 整页 1654×2339 = 20 瓦片 = 3485 token
PDF(当图送) 同上,按渲染 dpi 换算 72dpi→765 / 200dpi→3485 / 300dpi→6035 token
音频(转写) 按秒,各家用不同系数(本文按 50 token/秒估算) 29.21 秒语音 ≈ 1461 token
文本 按字符 同一页内容转成文本 ≈ 391 token

同一页合同:直接送图 3485 token,本地 OCR 成文本再送 391 token——差 8.9 倍。差价的来源不是模型强不强,是有没有在本地多做一步。

仓库里 tools/multimodal/mm_budget.py 就是干这个的,纯标准库、不调模型、不联网:

python tools/multimodal/mm_budget.py pdf --pages 20 --extracted-chars 0
{
  "pages": 20, "extracted_chars": 0, "chars_per_page": 0.0,
  "silent_failure": true,
  "why_empty": "提取为空且不报错——扫描件就是这个形态;下游如果只看「调用成功」会把 0 字当成功",
  "route": "ocr", "render_dpi": 200, "render_size": [1652, 2338],
  "vision_tokens_per_page_if_sent_raw": 3485,
  "why": "文本层不健康,必须走 OCR;OCR 出来的文本再送模型,成本按文本算而不是按图算"
}

按字高给缩放建议:

python tools/multimodal/mm_budget.py image --width 1654 --height 2339 --glyph-px 14
# → suggest_scale 0.714,建议尺寸 1181×1670,2125 token,省 39.0%

关键点:传「最小字高」,不是正文字高。 传正文字高 24px 会得到 0.417 的激进方案(省 78%),但实测悬崖就落在 0.5x 与 0.35x 之间。


二、图片:分辨率与准确率是两条曲线

测试页:PIL 渲染 A4(200dpi 基准 1654×2339),正文 24px,最小的校验行 14px,ground truth 归一化后 391 字符。按比例缩放后逐个 OCR:

缩放 尺寸 PNG 视觉 token OCR 耗时 字符错误率
1.0(原始 200dpi) 1654×2339 170.4 KB 3485 2.81 s 0.26%
0.75x 1240×1754 122.3 KB 2125 2.01 s 0.26%
0.5x 827×1169 73.7 KB 1105 1.88 s 0.51%
0.35x 578×818 43.9 KB 765 1.74 s 11.51%
0.25x 413×584 29.6 KB 425 1.76 s 25.32%
0.25x 再放大 4 倍 1652×2336 281.0 KB 3485 2.21 s 35.29%

三条可直接落地的结论:

再看 PDF 渲染 dpi 这一组(同一页,同一 OCR):

渲染 dpi 尺寸 视觉 token OCR 耗时 字符错误率
72 595×842 765 1.69 s 0.00%
100 827×1170 1105 1.81 s 0.00%
150 1240×1755 2125 2.56 s 0.26%
200 1653×2339 3485 2.62 s 0.00%
300 2480×3509 6035 1.91 s 0.00%

token 涨 7.9 倍,错误率在 0-0.26% 之间随机抖动。 文字内容上不存在"更高 dpi 更准"的收益区间——只有"低于某条线就崩"的悬崖。所以文档类的正确问法不是"我该用多少 dpi",而是"我的最小字高还剩几个像素"。这组 72dpi 之所以还是 0%,是因为这一页最小字号是 9.5pt,72dpi 下仍有约 10px;换成 6pt 小字或双栏密排,同一组曲线会整体左移。


三、PDF:先看有没有文本层

路线 耗时 提取字符 视觉 token 字符错误率
文本层 get_text() 0.0112 s 391 0 0.00%
渲染 200dpi → OCR 2.62 s 391 3485 0.00%
扫描件 get_text() 0.0083 s 0 0 100%
扫描件 渲染 200dpi → OCR 0.98 s 391 3485 0.00%

文本层路线比 OCR 快 234 倍,且不花一个 token、不给错误率留空间。所以 PDF 处理顺序永远是:先试文本层,别一上来就渲染。

第三、四行是这条管线里最危险的地方:扫描件的 get_text() 会返回空字符串,不抛异常、不报错、0.0083 秒"完成"。 一个只看"调用成功/失败"的下游会把 0 字当成功——错误率 100% 而日志全绿。这就是典型的假成功(false green):判据是"函数返回了",而不是"产出了字符"。

硬门禁写成两个断言,别写成一个:

text = page.get_text()
# 1) 空文本层必须被识别出来,不能当成"这页本来就没字"
if len(text.strip()) == 0:
    raise EmptyTextLayer(page.number)   # 转 OCR 兜底,不是当成功
# 2) 健康度:每页字符数低于阈值 = 文本层不健康(常见于只嵌了页眉页码的扫描件)
if len(text.strip()) / page_count < 50:
    route = "ocr"

两条都要,因为第二种形态更隐蔽:扫描件里插了一行标题文本,get_text() 返回 12 个字符,非空但不是内容。


四、字高悬崖:单行测试与它诚实的局限

把同一行中英数字混排文字(25 个归一化字符)按不同像素字高直接渲染后 OCR:

字高 4px 5px 6px 7px 8px 10px 14px 20px 28px
识别出字符 0 25 24 25 25 25 25 25 25
字符错误率 100% 24% 12% 0% 4% 0% 0% 0% 0%

读法要克制:≥10px 稳定 0%,≤6px 崩到 12%-24%,7-8px 是抖动区。7px 的 0% 和 8px 的 4% 不构成单调曲线——样本只有 25 个字符,一个字符的差异就是 4 个百分点。所以正确的表述是"低于约 10px 不再可依赖",而不是"7px 可以,8px 不行"。要下更细的结论得把样本量做到几百行,这轮没做。

另一组更有意思——同一有效字号,两条路径:

路径 等效字高 字符错误率
直接渲染 7px 7px 0%
28px 渲染后缩到 0.25 7px 32%
56px 渲染后缩到 0.125 7px 0%
64px 渲染后缩到 0.125 8px 0%
28px → 等效 5px(LANCZOS / BICUBIC / NEAREST) 5px 100% / 100% / 100%

单看这几行,会得出"缩小倍率本身是独立变量"的印象(28→7 崩,56→7 却不崩),但同样受样本量限制,这轮不足以定论,我把它原样列出来而不是硬编成规则。可以确定的只有一条:同一最终像素尺寸,直接渲染和缩小来的,不是同一张图;测的时候必须用流水线上真实的那条路径,不能用"渲染成小字"去代替"大图缩小"。

顺带一个反直觉结果:28px 缩到等效 5px 时,LANCZOS、BICUBIC、NEAREST 三种插值全部 100% 失败。插值算法救不了已经跌到悬崖下的字。


五、音频:静音照单计费,还污染输出

用 TTS 合成一段已知文本的语音(29.21 秒,171.1 KB MP3,48.0 kbps),本地 small int8 模型转写:

用例 音频时长 墙钟耗时 实时倍率 RTF 估算 token
原始语音 · 直转 29.21 s 2.50 s 0.0855 1461
尾部拼 60 s 静音 · VAD 关 89.21 s 52.65 s 0.5902 4461
同一文件 · VAD 开 89.21 s 2.54 s 0.0285 4461

本地转写比实时快 11.7 倍(RTF 0.0855),这是"能本地就别上云"的底气。但加 60 秒静音后,token 估算从 1461 涨到 4461(+205%),墙钟从 2.5 秒涨到 52.65 秒。 静音不产生一个字符,却按秒付费、按秒耗时——除非你先做 VAD 或静音裁剪。

VAD 能把时间拿回来(52.65 s → 2.54 s,17 倍),但拿不回 token:如果模型收到的仍是 89.21 秒的音频,账单就还是 4461。 时间省在本地,钱省在发送之前,这两件事要分开做。

更严重的问题:带静音的音频,输出不可复现

同一份 89.21 秒文件、同一参数、同一进程,连续转写三次:

配置 第 1 次 第 2 次 第 3 次
VAD 关 11 段 / 179 字 / CER 113.21% 6 段 / 102 字 / CER 44.34% 12 段 / 179 字 / CER 113.21%
VAD 开 6 段 / 102 字 / CER 45.28% 6 段 / 102 字 / CER 45.28% 6 段 / 102 字 / CER 45.28%
墙钟(VAD 关 / 开) 45.14 s / 2.55 s 43.36 s / 2.45 s 40.29 s / 2.47 s

VAD 关的三次结果:11 段、6 段、12 段;179 字、102 字、179 字。同一份输入,同一个模型,三次三个答案。 原因不难推:静音段里解码器没东西可听,继续往下生成,配上 condition_on_previous_text 的上下文继承,重复文本会级联放大——多出来的 77 个字符基本都是把前半段重复念了一遍。

这一条的杀伤力不在音频本身:不可复现的输入会污染整条评测链。 你没法对比两个模型、没法做回归测试、没法判断"这次改的提示词是不是有效",因为基线自己每天都在飘。同一份输入跑两次结果不同,等于没有测量。

所以音频预处理的门禁不是"建议加 VAD",而是:发送前必须先切掉无语音段,且同一输入连续两次转写必须逐字一致,否则这条管线不能进评测。


六、评测口径:0% 和 25.64% 可以是同一份结果

这一节是全文最容易被忽略、代价最大的一块。

例一(PDF 文本层):get_text() 把整页文字一字不差地提取出来了。同一份输出:

评测口径 字符错误率
逐字比较(含标点、全半角、空格) 23.53%
归一化(去空白/标点,只留汉字字母数字) 0.00%

例二(语音转写):模型输出语义完全正确,7 处差异全是书写形式:

评测口径 字符错误率
逐字比较 25.64%
归一化后 20.75%
再去掉数字书写形式后 0.00%

那 7 处差异长这样:九月三十日 → 9月30日、SD2026 → SD-2026、三十八万六千元 → 386000元、四十五个日历日 → 45个日历日、十月十五日 → 10月15日、百分之六十五 → 65%。转写系统做了逆文本规整(把口语数字写成阿拉伯数字),源文本是汉字数字——两边都没错,是口径没对齐。

例三(同一个模型,输出字形都变了):带静音、VAD 开的那次,13 处差异里 11 处是书写形式(8 处繁简:设→設、请→請、计划→計劃、验→驗、后→後、项→項;4 处数字形式),只有 2 处是真正的同音错误(工期→供期、日历日→日利日)。CER 45.28%,实测真实错误率约 2/76 ≈ 2.6%。

三例指向同一条规则:CER 不是模型的属性,是「模型 + 口径」的联合属性。 报告任何识别指标前,先把口径钉死并写进脚本:

反过来,口径冻结之后,CER 才是一个可比的测量。本例中真正的识别错误只有 2 处,占 76 个非数字字符的 2.6%——这才是"这个模型行不行"该看的数字。


七、落地管线:按顺序问四个问题

输入进来
  ├─ 音频? → 先 VAD 裁掉静音 → 本地转文本 → 送文本(不回传原始音频)
  ├─ PDF?  → 文本层字符数/页 ≥ 50 ?
  │            ├─ 是 → get_text() 直接用(0 token)
  │            └─ 否 → 渲染到最小字高 ≥10px → OCR → 送文本
  ├─ 图片? → 有效字高 ≥10px ?
  │            ├─ 否 → 找原图/原 PDF;不要放大、不要送模型
  │            └─ 是 → 缩放到字高刚好 10px(上限 0.5x)→ 送图
  └─ 都是文本?→ 直接送,别绕路
输入 首选路线 兜底 硬门禁
有文本层 PDF get_text() 渲染 150-200dpi → OCR 每页字符数 ≥ 50,否则判为不健康
扫描件 PDF 渲染 200dpi → OCR 换更高 dpi 前先量最小字高 渲染后最小字高 ≥ 10px
截图/照片 缩到最小字高 10px(上限 0.5x) 分块截图,只送有字的区域 不放大已缩小的图
语音 VAD 裁静音 → 本地转文本 云端转写(先 VAD) 同一输入两次转写逐字一致
长音频 切块 + 每块记账 — 单块 ≤ 5 分钟,避免幻觉级联

八、上生产前的 12 项检查

  1. 送图前打印它的瓦片数与 token 估算(mm_budget.py image)。
  2. 缩放方案按最小字高反推,不按正文字高。
  3. 缩放系数设上限(本机实测 0.5x 是安全点,0.35x 已崩)。
  4. 禁掉"压缩后再放大"这条路径,它花满额 token 还更差。
  5. PDF 先试文本层,任何情况都不要把"渲染成图"当第一步。
  6. get_text() 为空必须抛错,不能返回空串当成功。
  7. 每页字符数低于阈值也要转 OCR(非空 ≠ 有内容)。
  8. 音频发送前 VAD 裁静音,token 与耗时分开记账。
  9. 音频管线加重复性断言:同输入连跑两次,输出必须一致。
  10. 评测口径写进脚本常量(数字形式/繁简/标点),不许每次手改。
  11. CER 同时报"归一化"和"真实语义错误"两个数,别只报一个。
  12. 记录每次预处理的实际参数(缩放比、dpi、VAD 开关),否则事后无法归因。

九、复现

git clone https://github.com/Felixwang007/awesome-content-tools
cd awesome-content-tools/tools/multimodal

python mm_budget.py selftest                  # 15 项断言,零依赖
pip install pillow numpy rapidocr-onnxruntime pymupdf faster-whisper edge-tts
python bench_multimodal.py all --json          # 复现本文全部表格
python bench_multimodal.py repeat --runs 3     # 复现「三次三个答案」

res_*.json 是本机实跑的原始输出,文章里的每一个数字都能对应上。字体路径、ffmpeg、whisper 模型目录都可用环境变量覆盖(见该目录 README)。


边界声明


📚 更多创作资源: https://afdian.com/a/felix007