在 Agent 系列里我们用十几行代码让模型看过一次图:把截图转成 base64 塞进消息就完事。那样跑通 demo 够了,可一旦进了生产,问题接踵而来——为什么同一张图三个厂商标价差三倍?为什么手机拍的图在服务端被"躺平"识别?为什么换了图片顺序答案就变了?这篇是《多模态开发实战》第 2 篇,把图像输入这件事按工程口径完整拆一遍:消息格式三家对照、token 成本核算、细节度控制、多图顺序,最后落一个"截图结构化提取"的完整实战。文中计费与格式均为 2026 年 10 月各家官方文档口径。
1、图像输入的两种方式
图像进消息只有两条基本通路:给一个模型侧能拉取的 URL,或者把字节流 base64 内嵌进请求体。OpenAI 两者的字段位置相同——都写在 image_url 里,前者放链接,后者放 data: 协议的 Data URL;Anthropic 把它们区分为 source.type 的 url 与 base64 两种取值;Gemini 的旧 API 里对应 file_data 与 inline_data。
以 OpenAI Responses API 为例,同一张图两种传法如下:
{"type": "input_image", "image_url": "https://cdn.example.com/ui.png"}
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo..."}
两种写法能力完全等价,选择只取决于工程约束。按四个维度决策:
| 维度 | URL 直传 | base64 内嵌 |
|---|---|---|
| 请求体大小 | 只传链接,几乎为零 | 字节膨胀约 33% |
| 隐私 | 图片须公网可访问 | 数据不出这次请求 |
| 来源 | 依赖图床可用性 | 本地文件、用户上传直接用 |
| 多轮会话 | 每轮重传链接即可 | 每轮重发全量字节 |
经验法则:图片已经在公网(OSS、CDN、网页截图服务)就用 URL;图片来自用户上传或内网系统就 base64。base64 的多轮膨胀问题有第三条路——三家的 Files API 都支持"上传一次、拿 file_id 反复引用",Anthropic 官方文档明确推荐多轮 Agent 场景走这条路,能把每轮请求体从几十 MB 压回几 KB。
2、三家图像消息结构对照
三家的消息骨架都是"一条 user 消息里塞多个内容块",但字段命名各不相同,完整定义见各家官方图像指南。先看 OpenAI(Responses API):
{
"role": "user",
"content": [
{"type": "input_text", "text": "描述这张界面截图"},
{"type": "input_image", "image_url": "https://cdn.example.com/ui.png", "detail": "high"}
]
}
图像块类型叫 input_image,detail 是细节度开关;老的 Chat Completions API 用的是 image_url 类型加一层嵌套对象 {"url": ..., "detail": ...},大量存量代码还是这个形状。Anthropic 的写法则是"图像块 + source 子对象":
{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgoAAAANSU..."
}},
{"type": "text", "text": "描述这张界面截图"}
]
}
注意 Anthropic 的图像块没有 detail 参数——后面会讲为什么。另外 media_type 只接受 JPEG、PNG、GIF、WebP 四种(动图取首帧),OpenAI 与 Gemini 各自多出非动图 GIF 与 HEIC/HEIF 支持,格式转换必须在进消息前做掉。Gemini 走 contents → parts 两层,内嵌图像用 inline_data:
{
"contents": [{
"role": "user",
"parts": [
{"inline_data": {"mime_type": "image/png", "data": "iVBORw0KGgoAAAANSU..."}},
{"text": "描述这张界面截图"}
]
}]
}
Gemini 2026 年新推的 Interactions API 把结构简化成了顶层 input 数组({"type": "image", "data": ...}),但现有 SDK 与生态仍以 generateContent 为主,写兼容代码按上面这个口径来。直接对接原生 API 要维护三套结构,用 LangChain 可以统一成 OpenAI 风格:
from langchain_core.messages import HumanMessage
msg = HumanMessage(content=[
{"type": "text", "text": "描述这张界面截图"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}},
])
# ChatOpenAI / ChatAnthropic / ChatGoogleGenerativeAI 都接受这种写法
三家集成各自负责把 image_url 块翻译成原生格式:Anthropic 侧自动转成 base64 source,Gemini 侧转成 inline_data。这套统一口径贯穿本系列后续所有代码。
3、图像 token 成本核算
图像按 token 计费,但三家换算口径完全不同,这是多模态账单失控的头号原因。OpenAI 分两代:tile 系模型(gpt-4o、gpt-4.1、o 系、gpt-5、gpt-5.1)把图缩进 2048×2048、短边压到 768px 以内后按 512×512 切片,费用为"基础 token + 每片单价×片数",gpt-4o 是 85+170×片数,gpt-5.1 降到 70+140×片数;新的 patch 系模型(gpt-5.x 后期与 gpt-6-astra)改按 32×32 像素块计数再乘模型系数,超过 30000 个块直接拒绝请求。
Anthropic 的公式最直白:每 28×28 像素记一个视觉 token,即 ⌈宽/28⌉×⌈高/28⌉,随后按模型分档封顶——标准档(200k 上下文的常规模型)长边超 1568px 自动降采样、token 封顶 1568;高分辨率档(Claude 4.7 及之后)长边放到 2576px、封顶 4784,细节见 Anthropic Vision 文档。Gemini 则是"两边 ≤384px 收 258 token 一口价,否则按 768×768 切片、每片 258",切片数由短边决定(短边除以 1.5 得步长再数格子),规则见 Gemini 图像理解文档。
同一张 1920×1080 的截图,三家账单如下:
| 口径 | 计费公式 | 该图 token |
|---|---|---|
| OpenAI gpt-4o(high) | 85 + 170×6 片 | 1105 |
| OpenAI gpt-5.1(high) | 70 + 140×6 片 | 910 |
| Anthropic 标准档 | 降采样后计块,封顶 | 1560 |
| Anthropic 高分辨率档 | 69×39 块 | 2691 |
| Gemini | 258×6 片 | 1548 |
数字差距不大,但成本敏感度在分辨率维度:Anthropic 高分辨率档处理 4K 图会到 4784 token 封顶值,而低细节档(下一节)只要它的零头。Anthropic 文档给过一笔账:按 Haiku 4.5 每百万输入 token 1 美元计,1000×1000 的图每千张约 1.3 美元——单张不痛,日跑十万张截图的流水线就是每月几百美元的事。设计图像管线时先估单图 token,再乘日量级。
4、细节度控制策略
很多任务根本用不到全分辨率:判断截图是什么页面、有没有报错弹窗、主色调是什么,512×512 的低档视角足够。OpenAI 的 detail 参数有四个取值:low(固定低费率,gpt-4o 只要 85 token)、high(完整切片)、auto(默认,按图和问题自动选)、original(不缩放原图)。Gemini 3 起提供 media_resolution 参数压低每图 token 上限。Anthropic 没有参数可调——发送的分辨率就是细节度,想省钱就在客户端自己缩图。
| 厂商 | 参数 | 低档行为 |
|---|---|---|
| OpenAI | detail |
固定低费,忽略原分辨率 |
| Gemini | media_resolution |
限制每图 token 上限 |
| Anthropic | 无参数 | 客户端预缩放 |
什么时候低档够用?给一个判断标准:答案不依赖小字和密集细节时用低档。页面类型分类、有无弹窗、图表类型识别、UI 整体布局描述,低档损失很小;读表格数字、识别小号正文、数密集图标、比对两图差异,必须高档。生产管线里不建议留 auto——同一批图会因尺寸不同走不同档位,成本与行为都不可复现,显式声明档位是更稳的选择。拿不准就做一次 A/B:同一批图低高档各跑一遍比准确率,低档达标就全量切低档,成本常能降一个数量级。
5、多图输入与顺序
多图输入没有新语法——content 数组里放多个图像块即可,限额比想象中宽松:OpenAI 每请求 1500 张,Gemini 3600 张,Anthropic 视模型 100 到 600 张(超过 20 张时会收紧单图尺寸上限,长边约 2000px)。真正微妙的是顺序敏感性:Anthropic 官方建议图像放在文本之前(长文档同理,模型对开头内容注意力更足);Gemini 官方建议恰好相反,单图场景文本放图前。两家指南方向相反,说明这是经验规律而非硬约束。
工程上别赌任何一家的默认偏好,做两件事:
- 给每张图配文字标签:图前插
Image 1:、Image 2:短文本块,prompt 里显式引用编号而非位置 - 顺序敏感任务上线前调换图片顺序测试一遍,答案漂移就说明 prompt 依赖位置,改用编号引用
6、截图结构化提取实战
第一个完整实战:把一张产品截图变成结构化数据——页面标题、主要操作、可交互元素清单。这正是 UI 走查、竞品分析、自动化测试数据准备的通用底座。延续 Agent 系列的 LangChain 口径,先定义输出 schema:
from typing import Literal
from pydantic import BaseModel, Field
class UIElement(BaseModel):
element_type: Literal["button", "input", "link", "text", "image"] = Field(
description="元素类型")
text: str = Field(description="元素上的可见文字,没有则空字符串")
location: str = Field(description="位置描述,如「顶部导航栏右侧」")
class ScreenshotInfo(BaseModel):
page_title: str = Field(description="页面或对话框标题")
primary_action: str = Field(description="页面的主要操作目的")
elements: list[UIElement] = Field(description="识别到的可交互元素")
language: str = Field(description="界面语言,如 zh-CN、en")
Pydantic 模型就是模型的输出契约,Field(description=...) 会进入约束描述,实测对字段对齐率帮助明显。调用侧把图像消息与 with_structured_output 组合:
import base64
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-5.1", temperature=0).with_structured_output(ScreenshotInfo)
def extract(path: str) -> ScreenshotInfo:
b64 = base64.b64encode(open(path, "rb").read()).decode()
msg = HumanMessage(content=[
{"type": "text", "text": "提取这张产品截图的结构化信息,逐个识别可交互元素。"},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{b64}", "detail": "high"}},
])
return llm.invoke([msg])
info = extract("checkout.png")
print(info.page_title, len(info.elements)) # 收银台 14
结构化输出与图像输入完全正交:with_structured_output 走 tool calling 约束输出格式,图像块照常作为输入参与理解。detail: "high" 在这里不能省——按钮上的小字是提取目标,低档会把 14 个元素认成 8 个。反过来做页面分类时把这个参数改成 low,单图成本立降到约十分之一。
7、常见坑与规避
图片过大被拒。各家硬上限:Anthropic 单图 8000×8000px、base64 后 10 MB(Bedrock 5 MB),inline 的 Gemini 请求总量 20 MB。触限报错各不相同,统一对策是发送前归一化,顺带解决 EXIF 问题:
import io
from PIL import Image, ImageOps
def normalize(path: str, max_edge: int = 1568) -> bytes:
img = Image.open(path)
img = ImageOps.exif_transpose(img) # 按 EXIF 方向摆正
img.thumbnail((max_edge, max_edge)) # 等比缩到长边以内
if img.mode != "RGB":
img = img.convert("RGB") # 去 Alpha,兼容 JPEG
out = io.BytesIO()
img.save(out, format="JPEG", quality=85)
return out.getvalue()
EXIF 旋转是它的头号目标。手机竖拍的照片像素数据是横的,靠 EXIF Orientation 标记告诉查看器转回来,而 Anthropic 文档明说模型不读图像元数据——不烘焙方向就直接发,竖图会被当横图理解,文字识别全歪。Pillow 的 exif_transpose 一步摆正。
低分辨率小字识别失败。Anthropic 官方承认小于 200px 的图、旋转和低质图会出错;更隐蔽的是降采样把小字抹掉——1568px 长边的截图缩放后,12px 的辅助文字可能糊成噪声。另一个来源是物理像素本来就小:移动端 375px 宽的截图里 10px 正文,任何档位都难认,前置一层传统超分或让上游重截局部。对策不是发原图(token 翻倍),而是裁剪放大:先低档全图定位目标区域,再高档只发那个裁剪块。
多图顺序影响答案已在第 5 节展开,这里补一句排查思路:同一组图换个顺序答案漂移超过一成,就别指望调 prompt 修,直接上编号标签加显式引用,把位置依赖改成符号依赖。
8、总结
图像输入的工程版比 demo 版多三层账:格式层,URL 与 base64 的选择跟隐私和来源走,多轮场景上 Files API;成本层,OpenAI 按片、Anthropic 按 28×28 块、Gemini 按 768px 片,同一张图价差能到三倍,先算单图 token 再乘量级;质量层,细节度该低则低,小字任务裁剪放大代替发原图。把这三层想清楚,图像管线就不会在生产环境里惊叫。对于刚开始落地多模态的团队,建议先把本文第 6 节的提取流水线跑通,再按第 3 节的口径核一次账单。
留一个练习:找一张自家产品的 UI 截图,把 schema 改成可访问性问题清单——字段含问题类型(对比度、可点击区域、文字大小、无障碍标签)、位置描述、修复建议;分别用 detail: "low" 和 "high" 各跑 20 张,对比两个维度:问题检出的召回差异、token 消耗倍数。跑完你对"什么时候低档够用"会有属于自己的数字,下一篇我们继续视觉下半场:坐标系输出与 UI 自动化操作。

