把图片、PDF、音频丢给模型,看着很省事,账单和解法都比想象中细。这篇文章全部数字来自本机实跑(Windows / RapidOCR CPU / PyMuPDF / faster-whisper small int8),复现脚本和原始 JSON 都在仓库 tools/multimodal/。
先给结论,四条:
| 模态 | 计费量 | 本机实测基准 |
|---|---|---|
| 图片(视觉输入) | 瓦片数: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 小字或双栏密排,同一组曲线会整体左移。
| 路线 | 耗时 | 提取字符 | 视觉 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",而是:发送前必须先切掉无语音段,且同一输入连续两次转写必须逐字一致,否则这条管线不能进评测。
这一节是全文最容易被忽略、代价最大的一块。
例一(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 不是模型的属性,是「模型 + 口径」的联合属性。 报告任何识别指标前,先把口径钉死并写进脚本:
norm() 函数,不能一个用严格、一个用宽松反过来,口径冻结之后,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 分钟,避免幻觉级联 |
mm_budget.py image)。get_text() 为空必须抛错,不能返回空串当成功。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