Agent 中图片如何处理:从请求适配、资产溢出存储到上下文压缩的完整架构实战

Agent 中图片如何处理:从请求适配、资产溢出存储到上下文压缩的完整架构实战

在传统的 LLM 编码智能体(如早期的纯文本 ReAct 框架)中,Agent 处理的媒介几乎 100% 都是纯文本(源码、终端输出、Git Diff)。但随着 Agent 深入到 Web 浏览、UI 自动化测试、数据图表生成、桌面运维 等真实场景,图片(视觉多模态) 成了不可或缺的核心能力。

然而,将图片接入大模型 Harness(智能体运行宿主框架)远不止调用一次多模态 API 那么简单。图片数据拥有极其特殊的物理属性:极高的 Base64 文本体积(一张 1080P 图约 2.6MB 字符)vs 相对较小的视觉语义 Token(约 1200 Tokens)。

这种矛盾会贯穿图片的整个生命周期。一套成熟的多模态 Agent 框架,必须在三个关键阶段分别建立严密的防御关卡:

  1. 第一关(请求与交互层):如何图文混排?工具返回截图如何防止切碎?如何绕过 OpenAI 的协议限制?
  2. 第二关(持久化与存储层):如何避免数兆的 Base64 撑爆本地会话文件?
  3. 第三关(上下文与压缩层):长会话触达上下文预算时,图片如何估算、裁剪并兼顾大模型 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
2
ContentBlock = TextContent | ImageContent
MessageContent = str | list[ContentBlock]

1.1 厂商中立的抽象定义

Harness 内部必须定义与具体模型解耦的标准图片块(ImageContent):

1
2
3
4
5
6
7
class 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):

  1. role: "tool" 仅返回工具执行结果的纯文本说明;
  2. 紧接着自动注入一条合成的 role: "user" 消息:
    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,..."}}
    ]
    }
    这样既遵循了 API 规范,又确保模型在下一轮 ReAct 推理时能够立即读取到刚捕获的图像。

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
2
3
4
5
6
7
8
9
10
┌─────────────────┐     ┌─────────────────────┐     ┌─────────────────────┐
│ 1. 工具函数执行 │ ──► │ 2. 截断防护安全过滤 │ ──► │ 3. 事件与展示解耦 │
│ 返回复合图文块 │ │ 保护 Base64 不被腰斩 │ │ 文本上屏 / 图片入历史 │
└─────────────────┘ └─────────────────────┘ └──────────┬──────────┘
│
▼
┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ 最终模型消费 │ ◄── │ 5. Provider 协议抹平 │ ◄── │ 4. 资产溢出落盘 │
│ 下一轮 ReAct 看到│ │ OpenAI合成 / Claude直传│ │ 存文件,瘦身 JSONL │
└─────────────────┘ └─────────────────────┘ └─────────────────────┘

工序一:工具输出必须采用“复合块(Composite Blocks)”

工具绝不能只返回裸二进制图片,而必须同时输出文本说明(Text Note)+ 图片块(Image Block):

1
2
3
4
5
6
7
return [
{
"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"}
]

这段文本说明承担着四重职能:

  1. 坐标补偿映射:告诉模型缩放比例,便于精准计算点击坐标;
  2. 终端防刷屏:为 CLI/TUI 提供精简状态文本,避免几百万 Base64 字符刷屏卡死;
  3. 压缩摘要线索:历史被压缩时 Base64 会被剔除,靠这段说明让摘要模型知道当时读过什么图;
  4. 视觉证据:配合附带的图片块提供实际视觉输入。

工序二:截断防护的类型分流(防止切碎 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 会带来严重后果:

  1. 原子保存磁盘 IO 卡顿:每次追加消息执行 fsync 时,几十兆文件频繁同步,产生明显卡顿;
  2. 反序列化与内存 GC 压力:加载会话时,json.loads() 反序列化巨型单行字符串会瞬时占用数百兆内存,引发 GC 停顿;
  3. 编辑器与 Git 瘫痪:文本编辑器打开几十兆的 .jsonl 容易卡死,Git 追踪也失去意义。

5. 三大存储方案横向对比

对比维度 方案一:全量内联 (Pi 做法) 方案二:资产溢出 (本地进阶做法) 方案三:云端对象存储 (S3 做法)
持久化形态 .jsonl 单行内嵌巨大 Base64 本地二进制文件 + 轻量路径引用 S3 Object Key + 数据库记录
会话文件体积 极重(数 MB 至数十 MB) 极轻(始终保持 1KB ~ 3KB) 极轻(仅存元数据 ID)
IO 与反序列化 较差,大文件读写 GC 严重 极高,日志轻量追加,按需读图 极高,应用层零大对象压力
单文件自包含性 最强(单个文件拷走即可跑) 较好(会话目录打包即可迁移) 依赖云端鉴权与网络连通性
断链丢失风险 零风险(数据已嵌入) 极低(会话目录同生共死) 需管理文件生命周期与鉴权
工程实现难度 最简单 适中(需维护资产目录与水合层) 较高(需接入云存储生命周期)

6. 资产溢出存储(Asset Spill)的运转流水线

为了兼顾性能与本地运行能力,我们采用资产溢出存储方案:大体积二进制与轻量文本日志解耦,日志存引用,内存按需水合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
第 1 步:读处理(第一关拦截)
图片经 Pillow 检查尺寸与体积(2000px 硬顶),生成坐标映射提示,内存中得到安全 Base64。
│
▼
第 2 步:写 Session(第二关拦截:绝不把 Base64 写入 JSONL!)
├── 1. 解码二进制,计算 SHA-256,存入独立文件:images/<sha256>.png(内容寻址,天然全局去重)
└── 2. 会话文件(.jsonl)仅写入轻量结构(约 200 字节):
{"type": "image", "asset_path": "images/<sha256>.png", "width": 2000...}
★ 效果:无论产生多少张图片,session.jsonl 始终维持在 1KB ~ 3KB 左右。
│
▼
第 3 步:传给大模型前(发送前透明水合)
在通过 HTTP 向大模型发起请求前:
底层透明读取 images/<sha256>.png 还原为 Base64,组装进 HTTP 请求,全量发给大模型。

第三篇:上下文管理与压缩层(Context Compaction & KV-Cache)

7. 多轮对话中的网络开销与 KV-Cache 命中机制

在多轮交互中,历史图片的数据流转存在两个关键事实:

7.1 未压缩前:每次请求必须将历史图片全量上传

大模型云端 API 本质上是无状态(Stateless)的。在上下文未触达压缩阈值前,底层水合机制会在每次请求时读取图片并生成完整 Base64 发往云端。

7.2 图片能命中 KV-Cache 吗?

能够命中,且收益极高,但非常脆弱。

  1. 缓存排布与收益:

    • 现代模型(Claude 3.7、GPT-4o)采用 Prompt Caching。在显存序列中,图片折算为视觉 Tokens(1080P 图约 1200~1600 Tokens);
    • 只要前缀保持完全一致,后续轮次中图片的视觉 Tokens 无需重新经过视觉编码器计算;
    • 成本与延迟红利:以 Anthropic 为例,缓存读取费用仅为常规输入的 10%(享受 90% 巨额折扣),首字延迟(TTFT)大幅下降。
  2. 两大工程现实:

    • 服务端命中 Cache $\neq$ 客户端不用传网络字节:缓存命中发生在云端 GPU 显存中,普通的无状态 API 依然要求客户端每次将 2.6MB 的 Base64 上传到云端网关。它节省了模型算力和账单费用,但不节省上行带宽;
    • 字节级前缀脆弱性:若历史消息变动一个字符,或工具重新读取时生成的 Base64 出现微小偏差,整条前缀缓存直接击穿(Cache Miss),后续所有文字和上千个图片 Token 必须重新全额计费。

8. 长会话触达水位线压缩(Compaction)时的图片处理

当多轮交互累积使得上下文逼近大模型上限时,框架会基于 80% 水位线门控 触发离散 Epoch 块级压缩。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌────────────────────────────────────────────────────────┐
│ 第 1 步:Token 启发式估算 (1200 Token 常量隔离 Base64) │
│ 排除 Base64 字符串干扰,单图固定按 4800 字符 (1200 Token) 计入 │
└───────────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 第 2 步:切分点回溯与活跃尾部 (retainedTail) 保护 │
│ 倒序扫描保留最近约 20,000 Token,活跃尾部的最新图片 100% 完整保留 │
└───────────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 第 3 步:摘要模型序列化 —— 彻底剥离 Base64 │
│ 旧历史发给摘要小模型时,过滤所有图片,仅传文本元数据 (尺寸/说明) │
└───────────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 第 4 步:上下文投影重构 (Context Projection) │
│ 结构: [System] + [纯文本摘要] + [含最新图片的活跃尾部] │
│ 旧图 Base64 蒸发,上下文大幅释放,重构稳定 KV-Cache 前缀 │
└────────────────────────────────────────────────────────┘

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
2
3
4
5
6
7
8
9
10
11
12
13
14
def _serialize_messages(messages: list[Message]) -> str:
lines = []
for m in messages:
if isinstance(m.content, str):
content = m.content
else:
# 过滤图片块,仅保留文本内容与轻量标记
content = "".join(
part.get("text", "") if part.get("type") == "text" else "[image]"
for part in m.content
)
...
lines.append(f"{m.role}: {content}")
return "\n".join(lines)

8.4 深度洞察一:为什么摘要调用无法复用主会话的 KV-Cache?

  1. System Prompt 分叉:主会话是 Coding Agent 提示词,摘要调用是 Summarization 提示词。最长公共前缀在第 1 个 Token 就中断,主会话显存里的 KV-Cache 0% 命中,全部按全额原价计费;
  2. 消息形态重构:多轮结构体被序列化为一段纯文本长字符串塞入模板;
  3. 模型异构:主会话由高规格模型驱动,摘要通常分发给更敏捷便宜的小模型(如 Haiku / GPT-4o-mini),物理显卡不同,无法共享 Cache;
  4. 本质是“内存垃圾回收税(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 【入口层 · 第一关】
工具读取 / 用户发图 ──► Pillow 检查 (4.5MB / 2000px 硬顶)
──► 0.75 阶梯降采样 ──► 输出 [TextNote(坐标补偿), ImageBlock(合规Base64)]
──► 截断分流:ToolResult 绝不切碎 Base64,事件流发射纯文本防终端卡死

2. 【存储层 · 第二关】
写入会话 ──► 资产溢出拦截:提取 Base64 算 SHA-256 ──► 写入 images/<hash>.png
──► 会话 JSONL 只存 200 字节轻量 asset_path 引用(JSONL 永久保持 1.2KB!)
──► 发送请求前:按需透明水合,Claude 原生直传,OpenAI 合成 User 附图

3. 【上下文层 · 第三关】
多轮对话累积 ──► 1200 Token 常量精确监控 80% 水位线
──► 超阈值触发 Epoch 压缩:_find_cut 回溯保留 20,000 Token 活跃尾部
──► 摘要生成:彻底剥离 Base64,小模型仅看文本元数据,低成本飞速总结
──► 重组发送视图:旧图蒸发释放巨额空间,活跃尾部图文完好,KV-Cache 前缀稳固重构!

通过这套流水线,Agent 既获得了视觉双眼与自修复闭环能力,又彻底规避了API 400 崩溃、本地文件 IO 卡顿、上下文迅速爆仓与算力浪费的隐患。