Qwen3-VL 读表格实测:900px 全对,手机拍的返回空

一句话结论:M4 16GB 跑 qwen3-vl:8b,把 8 列 10 行截图表格转 JSON,一张 4 分钟,80 格全对;宽度给 900 像素就够;手机拍的必须先摆正,否则返回空串。

下面每个数字都是在本机同一套环境里跑出来的。测试表由我自己生成,内容和答案都留着,所以每个格子的对错可以逐项复核,不用信感觉。

装起来先跑通一次调用

模型 6.1GB,Ollama 0.40.14 直接拉,装完确认服务在跑。这些权重默认堆在 ~/.ollama/models,磁盘紧张的话搬目录的做法可以看 Ollama 模型占 C 盘怎么移。

ollama pull qwen3-vl:8b
ollama list | grep qwen3-vl
curl -s http://127.0.0.1:11434/api/version

读图走 /api/chat 的 images 字段,内容传 base64,跟 OpenAI 那套 image_url 不是一回事。最小可用调用长这样。

import base64, json, urllib.request

b64 = base64.b64encode(open("table.png", "rb").read()).decode()
payload = {
    "model": "qwen3-vl:8b",
    "stream": False,
    "messages": [{"role": "user", "content": "把表格转成 JSON 数组", "images": [b64]}],
    "options": {"temperature": 0},
}
req = urllib.request.Request(
    "http://127.0.0.1:11434/api/chat",
    data=json.dumps(payload).encode(),
    headers={"Content-Type": "application/json"},
)
resp = json.loads(urllib.request.urlopen(req, timeout=900).read())
print(resp["message"]["content"])

temperature 必须显式写成 0。这个模型的默认值是 1,options 里不写,同一张图两次跑出来的行顺序和数字格式都会漂,后面对数字的比对全部作废。

图片进来就被压成 1088 个视觉 token

我原本以为给更大的图能喂进更多细节,实测正好相反。同一个提示词,图片宽度 1600、1200、900、700、520 五档,视觉部分消耗的 token 一次都没变过。

服务端日志直接写了这个过程。

decoding image batch 1/3, n_tokens_batch = 512
decoding image batch 2/3, n_tokens_batch = 512
decoding image batch 3/3, n_tokens_batch = 64

三段加起来 1088。返回体里的 prompt_eval_count 在 1162 到 1166 之间浮动,减去提示词本身的七十多个 token,正好对上这个数。Ollama 这一版给 qwen3-vl 的视觉编码设了固定预算,原图多大都先重采样到 1088 个 token 的位置再送进模型。

由此有两个推论。4K 截图喂进去没有任何精度收益,只多占一次读盘和内存带宽。耗时也不会因为图变大而变长,因为视觉 token 恒定,真正拉长时间的是后面那段思考链,一跑就是三四千个 token。

顺带说一句上下文预算。1088 个视觉 token 只是入场费,num_ctx 给得太小,图和输出会互相挤,具体怎么分可以对照 Ollama 上下文长度实测 那篇的分配比例。

五档宽度实测:字高 6 像素是分界

测试表 8 列 10 行,正文 14 号字,原图 1968 像素宽。等比缩小时最小字号跟着缩,所以第二列写的是图内最小字高,这一列比宽度更能决定准确率。

图片宽度(像素) 图内最小字高(像素) 视觉 token 输出 token 耗时(秒) 80 格命中
1600 11.4 1088 5119 291.8 80 / 80
1200 8.5 1088 4512 243.8 80 / 80
900 6.4 1088 4048 230.7 80 / 80
700 5.0 1088 4886 284.3 78 / 80
520 3.7 1088 4610 260.6 38 / 80
1208 偏斜 JPEG55 8.6 1088 7026 400.1 0(空输出)

900 像素档字高 6.4 像素,80 个格子全对。700 像素档字高 5 像素,错两个:网线钳读成网线箱,型号 LP-360B 读成 LP-3600。520 像素档字高 3.7 像素,80 格里错 42 格,数量 7 读成 3、单价 1,786.50 读成 1,700.50 这类错法集中在小数位和千分位上。

1200 像素档单独跑过两轮,都全对,一次 172.3 秒一次 243.8 秒。差异来自思考链长短,与画质无关,这也是为什么耗时那一列看着不单调。

可执行的口径就一句:截图时保证最小字号不低于 6 像素,宽度落在 900 到 1200 之间,给到 1600 属于白费。

手机拍的那张返回空字符串

同一张图旋转 1.6 度、按 JPEG 质量 55 重存一遍,模拟随手拍。请求返回 HTTP 200,done 为 true,content 是空字符串,一个字都没有。

原因藏在思考链里。模型读到第二行税额时开始反复怀疑自己。

第二行税额是3,38.52?不对,原表格里第二行税额是3,38.52?不,用户给的表格里第二行税额是3,38.52?

这句话循环了十几遍,把 8192 的上下文彻底吃满。收包时 prompt_eval_count 1166 加 eval_count 7026 正好等于 8192,done_reason 是 length,整个过程 400.1 秒。

我试过把 num_ctx 抬到 16384,看给更多空间能不能让它自己转出来。同一张图重跑,等了 900 秒客户端先超时,服务端还在往下生成,循环根本没停。抬上下文买不到正确率,只是让它在错的路上走得更久。

这张图宽度 1208 像素、字高 8.6 像素,按上面的表本该全对。画质并不差,问题出在 1.6 度倾斜让相邻列的横向位置整体偏移,模型在核对税额时反复对不上,最后转进了循环。同一张图不倾斜时是 80 格全对,所以这不是能力上限,是输入质量的问题。

判断方法只有一条:done_reason 为 length 且 content 为空,就是上下文被思考链吃光。这种情况没有报错码,HTTP 永远 200,只能靠自己读这两个字段。

十六行的表能读,但开始错字

换一张 16 行 6 列的采购表,12 号字,1576 像素宽,246.2 秒跑完。输出 17 个对象含合计行,97 个格子对 96 个,合计金额 119506.20 完全对上。

错的只有一处:万兆网卡读成万光网卡。这一档的视觉 token 是 1047,比 10 行表略少,原因是长宽比不同导致重采样后的补丁数有出入。所以行数不是瓶颈,相邻字的区分度才是。兆和光挨在一起,笔画密度接近,压到 12 号字就开始混。

两种调用方式,一种给你空结果

同一个问题、同一张 900 像素的图,走两条路结果完全不同。

调用方式 返回情况 耗时(秒) 要注意
/api/chat 带 images 10 行全部返回,80 格全对 230.7 图片传 base64,字段名 images
/v1/chat/completions 带 data URI content 为空,只产出思考链 118.4 max_tokens 要算上思考,至少 6000

OpenAI 兼容端点最容易踩。把 max_tokens 设成 2000,思考链吃掉 2000 个 token,正文一格没出,completion_tokens 恰好等于 2000,用时 118.4 秒。这条路上 max_tokens 要把思考那部分算进去,至少要给到 6000 才够一份 10 行表的输出。

命令行这条路不适合批量。ollama run 的帮助里只有提示词位置参数,没有独立的图片选项,图要跟着提示词一起给。要跑几十张还是走接口,能拿到 done_reason 这类判断字段,命令行只回一段文本。

提示词照抄这一段

我全程用的是同一段提示词,三个约束缺一个就出问题。

把图片里的表格逐行转成 JSON 数组输出。每行一个对象,键固定为
no,name,spec,qty,price,amount,rate,tax。数值只保留阿拉伯数字,去掉千分位逗号,
保留两位小数。不要解释,只输出 JSON 数组。

键名固定这一条是为了让下游能直接取值,模型很爱自己发明中文键名,每张图换个写法,解析脚本就得跟着改。去掉千分位这一条是为了避开逗号与 JSON 分隔符冲突,1,786.50 原样吐出来会变成两个字段。只输出 JSON 这一条是压掉解释性文字,否则正文前后会挂上几句总结,正则清理很麻烦。

实测这段提示词在 900 像素档给出 1704 个字符的干净数组,json.loads 直接能解析,10 个对象一个不差。

踩坑:思考模式关不掉

这个模型的 Modelfile 里写着 RENDERER qwen3-vl-thinking 和 PARSER qwen3-vl-thinking,思考由渲染层决定,三个常规开关全部无效。

请求体传 think 为 false,返回体里的 thinking 字段仍然是 5832 个字符。在 OpenAI 兼容端点传 enable_thinking 为 false,输出与不带这个字段的那一次逐字节相同,连思考链内容都一样,说明这个字段根本没被读。提示词开头加 /no_think 能把思考链压到 3896 个字符,省下约三成时间,关不掉。

改渲染器更险。写一个 Modelfile 把 RENDERER 换成不带 thinking 的名字,ollama create 一路显示 success,直到发起第一次请求才报 unknown renderer 并返回 HTTP 500。创建阶段不校验这个名字,这是它能骗过人的原因,跟装完能启动不等于能跑是一个套路。

批量跑的时候要接住空结果

批量场景最怕把空字符串当正常结果写进表。下面这段循环把三种失败挡住:空内容、上下文吃满、解析失败,各自记一条日志再进下一张。

import json, glob
bad = []
for path in sorted(glob.glob("shots/*.png")):
    d = ask(path)
    m = d.get("message", {})
    if d.get("done_reason") == "length" or not m.get("content"):
        bad.append((path, "empty_or_truncated", d.get("eval_count")))
        continue
    try:
        rows = json.loads(m["content"])
    except json.JSONDecodeError:
        bad.append((path, "parse_fail", len(m["content"])))
        continue
    save(path, rows)
print(bad)

这段逻辑能救回大量返工。按上面的比例,字高低于 5 像素的图基本都会走 empty_or_truncated 分支,先跑一遍把所有失败图筛出来重新截图,比逐张人眼核对快得多。

成本账:一百张表跑一个通宵

按 900 像素档 230.7 秒算,一张 3.8 分钟。一百张 6.4 小时,一千张 64 小时,中间模型要保持常驻,占 6.00GB 内存,16GB 的机器这期间开不了第二个 8B。

电费可以自己算。M4 满载整机按 50 瓦上限估,6.4 小时是 0.32 度电,按 0.55 元一度约 0.18 元,摊到每张不到两厘钱。真正贵的是等待,不是电。要压时间只有两条路,把图压到字高刚好 6 像素别多给,以及认下思考链那部分固定开销。

如果一天的量超过五十张,值得考虑把读表这步换成专用文档解析工具,代价是输出形态从 JSON 变成 Markdown,下游要多一层转换。这个取舍我在 MinerU 三档速度实测 里算过,一页数字版最快只要 2.4 秒,跟这里的 4 分钟不在一个量级。

从数组到 Excel 的十行代码

模型只出 JSON,落地成表格要自己接一层。字段全是字符串,直接写进单元格会变成文本格式,Excel 里求和会是 0。

import json
from openpyxl import Workbook

rows = json.load(open("out.json"))
wb = Workbook(); ws = wb.active
ws.append(["序号", "名称", "型号", "数量", "单价", "金额"])
for r in rows:
    if str(r["no"]) == "合计":
        continue
    ws.append([int(r["no"]), r["name"], r["spec"],
               int(r["qty"]), float(r["price"]), float(r["amount"])])
wb.save("table.xlsx")

int 和 float 那几处不能省。金额带两位小数,用 float 落盘后在 Excel 里显示成 21438 而不是 21438.00,需要给单元格设数字格式 #,##0.00。

常见问题

qwen3-vl:8b 跑起来占多少内存

模型加 4096 上下文一共 6.00GB,/api/ps 返回的 size 字段就是这个值,全部落在显存里。16GB 统一内存的机器够用,但同时再加载一个 8B 文本模型就会被换出,换出之后每张图的耗时能翻倍。真要精算显存,权重加 KV 的拆法见 8B 模型显存怎么算。

能不能让它直接输出 xlsx 文件

不能。模型只会吐 JSON 或 Markdown 文本,二进制文件要靠外面那层代码生成。上面那段 openpyxl 就是最短路径,别在提示词里要求它给文件。

为什么同一张图两次结果不一样

默认 temperature 是 1,不显式设成 0 就会漂。设成 0 之后数字主体稳定,思考链长度仍然浮动,所以同一张图耗时能差七十秒以上,但 80 格准确率不变。

全部文章归档与模型专题