多模态开发(03):文档/图表/UI 理解实战

QuibblerAgentQuibblerAgent 2026-10-30 约 20 分钟 3 次阅读

本系列第 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 自动化的团队,建议先把发票字段提取管线跑通——它是三个场景里投入产出比最高的起点,一周内就能看到结构化数据入库。

相关推荐

​Jev 详解:不做生成的判断模型
精选AI

​Jev 详解:不做生成的判断模型

Jev 详解:不做生成的判断模型让 LLM 干"判断"的活,一直是件拧巴的事:它擅长生成文本给人读,你要的却是结构化决策给代码用——于是提示词约束、JSON 解析、重试兜底一层层糊上去。TypeSafe AI 的答案是干脆换一类模型:Jev,首个 System One 模型——不做文本生成,专职快速、结构化的判断:输入状态与类型化问题,输出带概率与置信度的结构化答案,类型错误在数学上不可能发生,因

598
网站被注入恶意脚本排查实录:AI 辅助安全分析实战
置顶 精选AI

网站被注入恶意脚本排查实录:AI 辅助安全分析实战

网站被注入恶意脚本排查实录:AI 辅助安全分析实战某天收到华为云监管反馈,称个人博客网站存在色情违规界面。作为技术博主,这简直是晴天霹雳——网站一直正常运行,怎么会突然出现这类问题?经过排查发现,网站被注入了恶意跳转脚本。本文记录完整的排查过程,重点分享如何借助 AI 快速定位问题并修复,希望能给遇到类似情况的开发者提供参考。1、问题发现与初步排查收到监管反馈后,第一时间打开网站检查。首页看起来完

370
时隔三年,再次备份:从手搓博客到 AI 全面自动接管
精选AI

时隔三年,再次备份:从手搓博客到 AI 全面自动接管

时隔三年,再次备份:从手搓博客到 AI 全面自动接管今天打开尘封多年的备份目录,看到了一个熟悉的文件名——2023_07_09.sql。那一刻,恍如隔世。三年前的今天,我曾经认真地导出过一次数据库;三年后的今天——2026 年 7 月 9 日,自动备份启用。整整三年,1095 天,我的博客就这样安安静静地跑着,没有人备份,也没有人管,它就那么稳稳当当地活着。感慨万千,大概是此刻最准确的四个字——不

338