本系列第 2 篇解决了"怎么把图送进模型":base64 还是 URL、分辨率怎么选、token 怎么算账。但会发图不等于会干活——真正值钱的问题是:扫描件 PDF 怎么变成结构化数据,柱状图里的数字能不能信,一张 UI 截图能不能直接变成可执行的点击坐标。本篇把视觉能力落到三个生产场景:文档、图表、UI 截图,每个场景按"难点 → 方案 → 代码 → 边界"走一遍,最后把"看图"封装成 Agent 工具。全文按"文档 → 图表 → UI → Agent 化 → 边界与技巧"组织,也是多模态系列与 Agent 系列知识的第一次合体。
1、文档理解与字段提取
企业文档的一大半是扫描件和图片型 PDF:pdftotext 跑完输出为空,传统 OCR 又在表格线、盖章、手写批注面前频频断版。难点不在识别字符,而在理解版面——哪个数字是金额、哪段是卖方、表格里哪一列是税额,这些信息有一半写在布局里。视觉模型的方案很直接:把每一页渲染成高分辨率图像,让模型像人一样"看"整页,直接输出结构化字段。
1.1、视觉抽取与文本解析
先说清楚与 RAG 的关系:视觉抽取不是文本解析的升级版,而是解析层里的一件重武器。RAG 的召回上限由解析质量决定,管线选型只看一条——文本层是否干净。
| 文档形态 | 判定方法 | 推荐管线 |
|---|---|---|
| 数字原生 PDF | pdftotext 输出完整可读 | 文本解析 + LLM 抽取 |
| 扫描件 PDF | 文本层为空或乱码 | 整页渲染成图 + 视觉模型 |
| 图文混排报告 | 关键结论只存在于图表中 | 视觉模型整页直读 |
执行顺序永远是先便宜后贵:文本管线处理一页的成本只是视觉管线的零头,一张千像素整页图折算的 token 通常是同页文本的好几倍。视觉只在文本层缺失、或版面本身承载语义时出手。
1.2、发票字段提取实战
第一步把 PDF 页变成模型可用的 data URL,用 PyMuPDF 渲染,代码如下:
# render_pdf.py —— PDF 页渲染为 data URL
import base64, io
import fitz # PyMuPDF
from PIL import Image
def page_to_data_url(pdf_path: str, page_no: int = 0, dpi: int = 200):
doc = fitz.open(pdf_path)
pix = doc[page_no].get_pixmap(dpi=dpi) # 200 DPI 够读小字
img = Image.open(io.BytesIO(pix.tobytes("png")))
if img.width > 1600: # 控边长,防 token 爆炸
img = img.resize((1600, round(img.height * 1600 / img.width)))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
A4 单据的 DPI 经验区间是 150~200:低于 150 小字开始糊,高于 250 清晰度收益递减而 token 线性上涨;边长压进 1600px 是成本闸门。第二步发图提取字段,走 OpenAI 兼容接口,任意视觉网关通用:
# extract_invoice.py —— 发票字段结构化提取
import json, os
from openai import OpenAI
client = OpenAI() # 网关地址走环境变量
FIELDS = {"开票日期": "YYYY-MM-DD,图中未见填 null",
"价税合计": "数字,保留两位小数",
"卖方名称": "全称",
"明细": [{"品名": "", "金额": 0.0}]}
def extract(data_url: str) -> dict:
r = client.chat.completions.create(
model=os.environ["VL_MODEL"], # 如 qwen-vl-max、glm-4.5v
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text":
"提取发票字段,严格输出 JSON,schema:\n"
+ json.dumps(FIELDS, ensure_ascii=False)
+ "\n只填图中可见信息,禁止推测;金额去掉货币符号与千分位。"},
]}],
temperature=0)
return json.loads(r.choices[0].message.content)
三个工程细节决定成败。temperature=0 锁死输出;提示词里"禁止推测、未见填 null"是防幻觉的关键句——模型宁可编一个日期也不愿留空的倾向,要靠显式禁令压住;金额的货币符号与千分位预处理写成指令,省掉一堆后处理。生产环境建议把裸提示换成接口的结构化输出(JSON schema 约束),字段名漂移的问题一并解决。
2、图表理解与趋势问答
图表是"版面即信息"的极端形态:数据被画成了几何形状,文本解析彻底无能为力。这个场景先用起来再优化,但必须先划清能力边界。
视觉模型是感知器,不是测量仪。
模型对趋势、对比、异常的判断接近人的水平——"三季度环比大涨""蓝色系持续压过橙色"这类问题可以放心问。精确读数是另一回事:柱状图没有数据标签时,估读误差一成是常态;折线交叉点、拐点的读数更不可靠。
| 问题类型 | 模型表现 | 使用建议 |
|---|---|---|
| 趋势、对比、异常检测 | 强 | 直接问 |
| 无标签柱状图读数 | 弱,误差约一成 | 要求输出"约值" |
| 交叉点与拐点精确值 | 很弱 | 回原生数据源 |
实战把"读数"和"问答"拆成两步输出,逼模型先交估读数据、再下结论:
# chart_qa.py —— 图表数据提取与趋势问答
def ask_chart(data_url: str, question: str) -> str:
prompt = f"""分两步作答。第一步输出 JSON:
{{"series": {{"系列名": ["逐期估读值,带'约'前缀"]}}}}
第二步回答问题:{question}
规则:读数均为估读,结论只做定性比较;
图中无数值标签时,禁止输出精确到个位的数字。"""
r = client.chat.completions.create(
model=os.environ["VL_MODEL"],
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt}]}],
temperature=0)
return r.choices[0].message.content
"带约前缀"与"禁止精确到个位"两句,是把模型的模糊性显式化——下游拿到数据就知道这是估读,不会把"约 12 万"当财务口径入账。真正要精确数字的场合,正确路径是找生成图表的原始数据;视觉只负责没有数据源时的应急读取与摘要生成。
3、UI 理解与元素定位
第三个场景离 Agent 最近:给一张 UI 截图,模型返回"提交按钮在 (450, 100)"这样的坐标。这正是 Computer Use 的地基——Anthropic 的 computer-use 示例里,模型反复调用截图工具,靠 {"type": "click", "coordinate": [450, 100]} 这类动作操作界面;开源侧 Qwen3-VL 也把 GUI grounding 列为基础能力。Agent 系列第 26 篇的 Computer Use 实战,地基就在本节。
3.1、坐标与分辨率坑
元素定位的头号坑是分辨率换算:模型返回的坐标基于它看到的那张图,不是你的屏幕。发图前做过缩放,坐标就要按同一比例还原。工程上先固定策略——统一缩放到 1280 宽再发图,所有坐标乘回缩放比。其次是小元素:16px 的图标在整页截图里只占几个像素,模型看不见就永远点不中。
注意:坐标类任务先把"截图尺寸 → 发图 → 换算"的流水线固定下来,再谈准确率,否则调提示词全是无用功。
3.2、无障碍审查 Agent
单发一张截图问"有什么问题",只能得到泛泛回答。把截图做成工具、让模型多轮调用,审查质量立刻上一个台阶:模型先看整页,再对可疑区域放大复查。先定义工具与截图函数:
# a11y_agent.py —— UI 截图无障碍审查:工具定义
import json
TOOLS = [{"type": "function", "function": {
"name": "screenshot",
"description": "截取页面;region=[x,y,w,h] 时只截局部放大",
"parameters": {"type": "object", "properties": {
"region": {"type": "array"}}}}}]
def screenshot(region=None):
img = capture_page() # 换成 Playwright 实现
if region:
img = img.crop(tuple(region)) # 裁剪聚焦
return to_data_url(img)
工具的关键在 region 参数:它给了模型"主动放大"的能力,审查提示词里"可疑处用 region 放大复查"才有落点。主循环把审查任务跑成多轮工具调用:
# a11y_agent.py —— UI 截图无障碍审查:多轮主循环
def audit(page_url: str) -> str:
msgs = [{"role": "user", "content":
f"审查 {page_url} 的无障碍问题:先整页截图,按对比度、字号、"
"触控目标、图文标签四类检查;可疑处用 region 放大复查;"
"最后输出问题清单与修复建议。"}]
while True:
r = client.chat.completions.create(
model=os.environ["VL_MODEL"], messages=msgs,
tools=TOOLS, temperature=0)
msg = r.choices[0].message
if not msg.tool_calls:
return msg.content # 无工具调用即最终清单
msgs.append(msg)
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
msgs.append({"role": "tool", "tool_call_id": tc.id,
"content": "已截图,见下一条消息"})
msgs.append({"role": "user", "content": [
{"type": "image_url",
"image_url": {"url": screenshot(**args)}},
{"type": "text", "text": "截图结果如上"}]})
两处细节值得注意。多数 OpenAI 兼容网关的 tool 消息只收文本,所以截图以 user 消息回传、tool_call_id 写进文本对齐——这是当前生态的现实妥协;"先整页、再放大"的两段式策略是审查类任务的通用骨架,粗看圈范围、细看定结论,成本和精度都优于一上来发 4K 原图。
4、视觉工具与 Agent 循环
把上一节的模式一般化:screenshot 只是工具清单里的一项,"看图"变成 Agent 的一个标准动作——图像在多轮消息里反复进出,模型自主决定何时看、看哪里,这就是视觉 Agent 循环。循环骨架与 3.2 完全相同,多模态带来的新问题只有一个:图像历史会滚雪球。每轮请求携带全部历史消息,历史截图每轮重复计费,三轮 1600px 整页截图就能吃掉半个上下文窗口。
解法是图像历史治理——旧轮次的截图替换为文字占位,只保留最近几张:
# vision_loop.py —— 视觉循环的图像历史治理
def prune_images(messages: list, keep_last: int = 2) -> list:
"""旧轮次截图替换为占位文本,只保留最近 keep_last 张"""
idx = [i for i, m in enumerate(messages)
if isinstance(m.get("content"), list)
and any(c.get("type") == "image_url" for c in m["content"])]
for i in idx[:-keep_last]:
messages[i]["content"] = [c for c in messages[i]["content"]
if c.get("type") != "image_url"] + [
{"type": "text", "text": "(历史截图已省略)"}]
return messages
每轮发起前调用一次,再配合提示词缓存,长循环的视觉成本能压回可接受区间。Agent 系列的循环与工具协议是骨架,视觉是感知器官,二者拼起来才是"会自己看图的 Agent"。
5、场景边界与反场景
视觉是重武器,清单思维比热情更重要。以下场景明确别用视觉:
| 场景 | 为什么别用 | 替代方案 |
|---|---|---|
| 纯文本 PDF、Word | 文本解析零丢失,成本低约一个量级 | pdftotext、python-docx |
| 证件号与金额的精确抄录 | 视觉模型逐字符抄写会错 | PaddleOCR + 校验位 |
| 固定版面的批量表格 | 每页都付一遍图像 token | Camelot、模板化解析 |
| 长文档全文检索 | 图像进不了倒排索引 | 先文本化再向量化 |
判断原则一句话:版面即信息时才用视觉。文本层缺失、布局承载语义(表格、图表、UI)的场景掏重武器,其余交给文本管线,又准又便宜。混合文档按页路由:正文页走文本、扫描附图页走视觉,逐页判定。
6、读图质量技巧
同一张图,发法不同准确率能差出一截。三个经过反复验证的技巧。
裁剪聚焦:整页发过去,目标小字占的有效像素太少,模型看不清就猜。先裁出目标区域再发——发票只发表格区,长报告只发目标段落,token 减半、精度反升。3.2 节的 region 参数就是运行时裁剪的接口。
引导标注:在图上画框编号,让模型用编号引用元素,即 Set-of-Mark(SoM)提示法,微软 SoM 项目有完整实现。核心逻辑十几行:
# annotate.py —— 画编号框(Set-of-Mark 简化版)
from PIL import Image, ImageDraw
def annotate(img: Image.Image, boxes: list) -> Image.Image:
draw = ImageDraw.Draw(img)
for i, (x, y, w, h) in enumerate(boxes):
draw.rectangle([x, y, x + w, y + h], outline="red", width=3)
draw.text((x + 6, y + 4), str(i), fill="red")
return img
画完框配一句提示"用编号引用元素",定位类任务的指代歧义基本消除;UI 检测场景还有现成的 OmniParser 可以直接生成标注框。
分步提问:先总后细。第一问只要图面结构概览,第二问再钻细节,比一次抛出复合问题稳定得多——第 2 节"先提取数据、再回答问题"就是这个套路的两步版。
7、总结
回顾全文:文档场景先判文本层,扫描件整页渲染 150~200 DPI 起步,防幻觉靠"未见填 null"的显式禁令;图表场景牢记感知器不是测量仪,估读必须显式化;UI 场景先固定坐标换算流水线再谈准确率,多轮截图审查远强于单次问答;Agent 化的关键是图像历史治理;反场景清单常备,版面即信息才上视觉。
留一个练习:做一个"周报图表解读 Agent"。输入一页周报截图(内含两三张图表),Agent 先整页概览、定位图表区域,再逐图裁剪放大、两步式提取数据,最后汇总成一页解读文字。裁剪聚焦、分步提问、图像治理三个技巧全部能用上,做完它,本篇的知识点就都过手了一遍。
对于正在做文档智能化或 UI 自动化的团队,建议先把发票字段提取管线跑通——它是三个场景里投入产出比最高的起点,一周内就能看到结构化数据入库。

