Agent 中图片如何处理:从请求适配、资产溢出存储到上下文压缩的完整架构实战
Agent 中图片如何处理:从请求适配、资产溢出存储到上下文压缩的完整架构实战
在传统的 LLM 编码智能体(如早期的纯文本 ReAct 框架)中,Agent 处理的媒介几乎 100% 都是纯文本(源码、终端输出、Git Diff)。但随着 Agent 深入到 Web 浏览、UI 自动化测试、数据图表生成、桌面运维 等真实场景,图片(视觉多模态) 成了不可或缺的核心能力。
然而,将图片接入大模型 Harness(智能体运行宿主框架)远不止调用一次多模态 API 那么简单。图片数据拥有极其特殊的物理属性:极高的 Base64 文本体积(一张 1080P 图约 2.6MB 字符)vs 相对较小的视觉语义 Token(约 1200 Tokens)。
这种矛盾会贯穿图片的整个生命周期。一套成熟的多模态 Agent 框架,必须在三个关键阶段分别建立严密的防御关卡:
- 第一关(请求与交互层):如何图文混排?工具返回截图如何防止切碎?如何绕过 OpenAI 的协议限制?
- 第二关(持久化与存储层):如何避免数兆的 Base64 撑爆本地会话文件?
- 第三关(上下文与压缩层):长会话触达上下文预算时,图片如何估算、裁剪并兼顾大模型 KV-Cache 前缀缓存?
本文基于官方 Pi(@earendil-works/pi-coding-agent)及我们实现的 my-pi-agent 架构,全面拆解这三道关卡的工程实现与设计哲学。
第一篇:输入与请求层(Wire Protocol & Tool Execution)
1. 内部消息模型:联合类型与图文交错保序
在纯文本接口中,一条消息的定义非常简单:1
2
3
4{
"role": "user",
"content": "帮我看看这个函数有什么 bug"
}
引入图片后,Harness 的底层消息模型必须演进为联合类型(Union Type):
1 | ContentBlock = TextContent | ImageContent |
1.1 厂商中立的抽象定义
Harness 内部必须定义与具体模型解耦的标准图片块(ImageContent):1
2
3
4
5
6
7class ImageContent(TypedDict):
type: Required[Literal["image"]]
mime_type: Required[Literal["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/bmp"]]
data: NotRequired[str] # Base64 编码的图像数据(发送前按需水合)
asset_path: NotRequired[str] # 本地资产持久化路径(如 images/<sha256>.png)
width: NotRequired[int] # 图像宽度(像素)
height: NotRequired[int] # 图像高度(像素)
1.2 图文交错的绝对保序(Interleaved Order Preservation)
在多模态交互中,图文出现的相对顺序至关重要。
例如人类给出的指令通常是:
“先看旧版界面:[截图1],再看新版界面:[截图2],请告诉我导航栏有什么改动?”
大模型依靠自注意力机制(Self-Attention)与位置编码(Positional Embeddings)在序列空间中建立语义联系:1
[Text: "先看旧版界面:"] ──► [Vision Tokens: 截图1] ──► [Text: "再看新版界面:"] ──► [Vision Tokens: 截图2] ──► [Text: "有什么改动?"]
如果 Harness 粗暴地把文字集中在前面、图片集中在末尾,模型就无法判断哪句话对应哪张图。因此,content 列表中的块顺序必须与输入顺序完全一致(Source Order)。
1.3 图文混排的网络载荷(Wire Payload)映射
针对上述 5 个图文交错块,各大厂商 Provider 转换器在网络传输时的映射方式如下:
OpenAI (GPT-4o):将图片包装为
image_url,通过 Data URL(data:<mime>;base64,<data>)内联传输:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15{
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "先看旧版界面:\n"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw..."}},
{"type": "text", "text": "\n再看新版界面:\n"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw..."}},
{"type": "text", "text": "\n请告诉我导航栏有什么改动?"}
]
}
]
}Anthropic Claude (3.5 / 3.7):原生支持类型化的
source对象:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15{
"model": "claude-3-7-sonnet-20250219",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "先看旧版界面:\n"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw..."}},
{"type": "text", "text": "\n再看新版界面:\n"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw..."}},
{"type": "text", "text": "\n请告诉我导航栏有什么改动?"}
]
}
]
}
2. 各大厂商(Provider)的请求协议差异与陷阱
| 维度 | OpenAI (GPT-4o) | Anthropic (Claude 3.7) | Google Gemini |
|---|---|---|---|
| 包装方式 | image_url.url (Data URI) |
source (Typed Base64) |
inlineData (Direct Base64) |
| 单图体积上限 | 20MB (官方限制) | 5MB (超出直接报错 400) | 20MB (内联限制) |
| Tool 结果图片 | ❌ 不支持(content 只能是 string) |
✅ 原生支持 (tool_result 内混排) |
Gemini 3+ 支持 / 旧版需兼容 |
| Assistant 附图 | ❌ 不允许 | ❌ 严禁出现 | ❌ 不允许 |
⚠️ OpenAI 的协议陷阱:role: "tool" 无法接收图片
在 OpenAI Chat Completions 规范中,工具返回消息(role: "tool")的 content 强制要求为纯字符串。如果直接塞入图片块,API 会立即返回 HTTP 400 Bad Request。
对此,Harness 必须引入合成 User 轮次机制(Synthetic User Turn):
role: "tool"仅返回工具执行结果的纯文本说明;- 紧接着自动注入一条合成的
role: "user"消息:这样既遵循了 API 规范,又确保模型在下一轮 ReAct 推理时能够立即读取到刚捕获的图像。1
2
3
4
5
6
7{
"role": "user",
"content": [
{"type": "text", "text": "Attached image(s) from tool result:"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
]
}
3. 工具执行(Tool Result)返回图片时的完整处理流水线
很多人直觉上认为“工具返回图片主要是文生图模型(如 DALL-E、Midjourney)的事情”。事实恰恰相反。
在自主 Agent 中,生图工具产出的图片通常直接存盘展示给用户,大模型自身无需反刍;而 90% 以上的工具返回图片场景,是 Agent 的“眼睛”——即视觉反馈与自我检验闭环(Visual Feedback Loop):
- 场景 1:前端与 UI 自动化调试(
agent_browser截取网页渲染,定位 CSS 错位); - 场景 2:代码工程本地资源检查(
read读取本地 PNG 图标、架构流程图); - 场景 3:数据科学绘图自验闭环(Python 脚本生成
scatter.png后,Agent 自主read回看确认坐标轴与标签无重叠); - 场景 4:复杂文档与图纸解析(PDF 解析器光栅化图表页面返回给视觉模型)。
为了支持这些场景,工具输出到模型消费必须经过五道工序:
1 | ┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ |
工序一:工具输出必须采用“复合块(Composite Blocks)”
工具绝不能只返回裸二进制图片,而必须同时输出文本说明(Text Note)+ 图片块(Image Block):1
2
3
4
5
6
7return [
{
"type": "text",
"text": "Read image file [image/png]\n[Image: original 3840x2160, displayed at 2000x1125. Multiply coordinates by 1.92]"
},
{"type": "image", "data": "<base64...>", "mime_type": "image/png"}
]
这段文本说明承担着四重职能:
- 坐标补偿映射:告诉模型缩放比例,便于精准计算点击坐标;
- 终端防刷屏:为 CLI/TUI 提供精简状态文本,避免几百万 Base64 字符刷屏卡死;
- 压缩摘要线索:历史被压缩时 Base64 会被剔除,靠这段说明让摘要模型知道当时读过什么图;
- 视觉证据:配合附带的图片块提供实际视觉输入。
工序二:截断防护的类型分流(防止切碎 Base64)
Agent 框架在工具层通常配置有 50KB / 2000 行 的输出截断保护。
- 致命陷阱:若对包含 Base64 的输出直接做字符串截断,Base64 会在某一字符处被腰斩。残缺的编码会导致模型 API 直接报 HTTP 400 Invalid Base64 崩溃。
- 解决方案:在
ToolResult.serialize()中实现类型分流:若输出为包含图片块的多模态列表,完整保留结构体,坚决跳过文本截断。
工序三:事件广播与消息实体解耦
广播给前端与 TUI 的 ToolExecutionEnd.result 仅包含纯文本说明;而进入底层 ReAct 消息历史的 tool_msg.content 则完整保留多模态数据。
工序四:资产溢出存储落盘
图片二进制写入本地资产目录 <session_dir>/images/<sha256>.png,会话日志只记录轻量引用(详见第二篇)。
工序五:面向大模型的分发适配
针对 Claude 原生放入 type: "tool_result";针对 OpenAI 自动拆分为纯文本 Tool 消息与合成 User 附图消息。
第二篇:持久化与存储层(Session & Asset Spill)
4. 核心痛点:Base64 直存会话文件的三重灾难
Agent 的会话记录通常以追加式 JSONL(.jsonl)保存:
- 纯文本会话中,几十轮交互的
.jsonl通常仅有 30KB ~ 100KB; - 一旦引入图片,一张图的 Base64 就有 2.6MB ~ 4MB。15 轮带图交互会让文件体积膨胀至 50MB ~ 80MB。
直接写入 Base64 会带来严重后果:
- 原子保存磁盘 IO 卡顿:每次追加消息执行
fsync时,几十兆文件频繁同步,产生明显卡顿; - 反序列化与内存 GC 压力:加载会话时,
json.loads()反序列化巨型单行字符串会瞬时占用数百兆内存,引发 GC 停顿; - 编辑器与 Git 瘫痪:文本编辑器打开几十兆的
.jsonl容易卡死,Git 追踪也失去意义。
5. 三大存储方案横向对比
| 对比维度 | 方案一:全量内联 (Pi 做法) | 方案二:资产溢出 (本地进阶做法) | 方案三:云端对象存储 (S3 做法) |
|---|---|---|---|
| 持久化形态 | .jsonl 单行内嵌巨大 Base64 |
本地二进制文件 + 轻量路径引用 | S3 Object Key + 数据库记录 |
| 会话文件体积 | 极重(数 MB 至数十 MB) | 极轻(始终保持 1KB ~ 3KB) | 极轻(仅存元数据 ID) |
| IO 与反序列化 | 较差,大文件读写 GC 严重 | 极高,日志轻量追加,按需读图 | 极高,应用层零大对象压力 |
| 单文件自包含性 | 最强(单个文件拷走即可跑) | 较好(会话目录打包即可迁移) | 依赖云端鉴权与网络连通性 |
| 断链丢失风险 | 零风险(数据已嵌入) | 极低(会话目录同生共死) | 需管理文件生命周期与鉴权 |
| 工程实现难度 | 最简单 | 适中(需维护资产目录与水合层) | 较高(需接入云存储生命周期) |
6. 资产溢出存储(Asset Spill)的运转流水线
为了兼顾性能与本地运行能力,我们采用资产溢出存储方案:大体积二进制与轻量文本日志解耦,日志存引用,内存按需水合。
1 | 第 1 步:读处理(第一关拦截) |
第三篇:上下文管理与压缩层(Context Compaction & KV-Cache)
7. 多轮对话中的网络开销与 KV-Cache 命中机制
在多轮交互中,历史图片的数据流转存在两个关键事实:
7.1 未压缩前:每次请求必须将历史图片全量上传
大模型云端 API 本质上是无状态(Stateless)的。在上下文未触达压缩阈值前,底层水合机制会在每次请求时读取图片并生成完整 Base64 发往云端。
7.2 图片能命中 KV-Cache 吗?
能够命中,且收益极高,但非常脆弱。
缓存排布与收益:
- 现代模型(Claude 3.7、GPT-4o)采用 Prompt Caching。在显存序列中,图片折算为视觉 Tokens(1080P 图约 1200~1600 Tokens);
- 只要前缀保持完全一致,后续轮次中图片的视觉 Tokens 无需重新经过视觉编码器计算;
- 成本与延迟红利:以 Anthropic 为例,缓存读取费用仅为常规输入的 10%(享受 90% 巨额折扣),首字延迟(TTFT)大幅下降。
两大工程现实:
- 服务端命中 Cache $\neq$ 客户端不用传网络字节:缓存命中发生在云端 GPU 显存中,普通的无状态 API 依然要求客户端每次将 2.6MB 的 Base64 上传到云端网关。它节省了模型算力和账单费用,但不节省上行带宽;
- 字节级前缀脆弱性:若历史消息变动一个字符,或工具重新读取时生成的 Base64 出现微小偏差,整条前缀缓存直接击穿(Cache Miss),后续所有文字和上千个图片 Token 必须重新全额计费。
8. 长会话触达水位线压缩(Compaction)时的图片处理
当多轮交互累积使得上下文逼近大模型上限时,框架会基于 80% 水位线门控 触发离散 Epoch 块级压缩。
1 | ┌────────────────────────────────────────────────────────┐ |
8.1 第一步:Token 启发式估算(1200 Token 权重)
- 痛点:若按字符数估算(4字符=1Token),2.6MB 的 Base64 会被误算为 65 万 Token 导致频繁误触发压缩;若算作 0 Token,真实消耗又会撑爆窗口。
- 解法:对标 Pi,硬编码单图等效字符
IMAGE_EQUIVALENT_CHARS = 4800(即固定 1200 Tokens)。计算消息规模时,显式排除data字符串,按图片块数量乘以 4800 累加,落在各大模型视觉 Token 的统计期望中心。
8.2 第二步:切分点回溯与活跃尾部(retainedTail)保护
- 系统从最新消息向前倒序扫描(
_find_cut),累加文本与图片的等效字符; - 累积达到
keep_recent_tokens(约 20,000 Token)处切分,对齐到用户轮次边界; - 核心原则:
retainedTail内的所有近期图片,100% 完好无损保留其多模态 Block 与 Base64,确保 Agent 正在执行的工作视力不中断。
8.3 第三步:摘要模型序列化(彻底剥离 Base64)
在调用小模型对旧历史进行总结时,序列化函数会过滤并剔除所有图片块:
1 | def _serialize_messages(messages: list[Message]) -> str: |
8.4 深度洞察一:为什么摘要调用无法复用主会话的 KV-Cache?
- System Prompt 分叉:主会话是 Coding Agent 提示词,摘要调用是 Summarization 提示词。最长公共前缀在第 1 个 Token 就中断,主会话显存里的 KV-Cache 0% 命中,全部按全额原价计费;
- 消息形态重构:多轮结构体被序列化为一段纯文本长字符串塞入模板;
- 模型异构:主会话由高规格模型驱动,摘要通常分发给更敏捷便宜的小模型(如 Haiku / GPT-4o-mini),物理显卡不同,无法共享 Cache;
- 本质是“内存垃圾回收税(GC Tax)”:这次调用不产出业务代码、无 Cache 优惠,必须追求极限低成本。Pi 通过剥离 Base64(省数千视觉 Token)、剔除 Thinking 思考块、工具输出二次腰斩(限 2000 字符),确保以极小代价完成总结。
8.5 深度洞察二:摘要模型里到底有没有对图片进行描述?
有描述,但完全依靠围绕图片发生的文字对话提炼,绝不调用视觉模型去写“看图小作文”。
- 视觉模型写出的泛化看图描述在工程任务中充斥着噪声;
- 摘要小模型依靠工具元数据(
Read image file [image/png], 1920x1080)和人类与助手的业务对话(“定位出 z-index 冲突并修改 CSS”),足以在摘要中总结出业务结论:- 分析了用户提供的 1920x1080 界面截图,确认并修复了 z-index 遮挡登录按钮的问题; - 在软件工程中,图片的价值在于看图后得出的结论与落实的代码修改。结论沉淀在摘要中,旧图片的像素便可以功成身退。
8.6 第四步:上下文重组与前缀稳固
压缩后,向主模型发送的视图结构为:1
[System Prompt] + [Compaction Summary 纯文本摘要] + [Epoch 截断边界] + [含最新图片的 retainedTail]
旧图 Base64 彻底蒸发,释放大量空间;同时静态摘要与系统提示词构成新的不可变前缀,开启下一个高命中率的 KV-Cache 周期。
8.7 进阶演进:图片墓碑机制(Tombstoning with Re-read)
若超长任务后续需要回溯久远截图,可在压缩时留下墓碑标签:[Image Tombstone: file="dashboard_4k.png", 1920x1080, sha256="..."] (Base64 dropped. Use read("dashboard_4k.png") to re-inspect)。
Agent 明确知晓过去存在此图,必要时可主动调用工具重新读取,兼顾预算与灵活性。
结语:三关合一的工业级架构全景图
1 | 1. 【入口层 · 第一关】 |
通过这套流水线,Agent 既获得了视觉双眼与自修复闭环能力,又彻底规避了API 400 崩溃、本地文件 IO 卡顿、上下文迅速爆仓与算力浪费的隐患。