
1. 一句话结论
"图片归一化"不是单个函数,而是一条"图片进入会话历史之前"的处理管线。它完成三件事:
- 把 provider 不接受的格式转成可内联的格式;
- 把过大的图片压到模型/请求限制以内;
- 把转换和缩放信息以文本 hint 的形式告诉模型,避免坐标和尺寸误判。
关键设计是:归一化只做一次 。图片在进入历史前被处理,之后所有请求复用同一份字节,避免每次请求重新编码破坏 prompt cache。模型元数据里的 resize 字段也明确写着 "Cache-safe resize profile applied before a new image enters conversation history."(providers/model-schema.ts:67-72)。
2. 为什么需要这条管线
2.1 Provider 的图片限制
不同 provider 对图片有不同的限制:
- 单张图片的最大编码体积;
- 最大宽高;
- 单条消息/单次请求的图片数量;
- 支持的 MIME 类型。
如果一张图片超出限制,很多 provider 会拒绝整个请求,而不是只拒绝那一张图片。对于长会话,这意味着一次超限可能让整个对话无法继续。因此在图片进入历史前就把它压到安全范围,比在请求失败后补救更可靠。
2.2 不支持的格式
read 工具、剪贴板、截图工具、MCP 工具可能返回 BMP、TIFF 等格式。部分 provider 只接受 PNG/JPEG/GIF/WebP。归一化负责把不支持的格式转成 PNG。
2.3 坐标映射
模型看到的是缩放后的图片,但工具返回的坐标(例如截图点击位置)通常基于原图。如果不告诉模型缩放比例,模型会把显示坐标当成原图坐标,导致点击/裁剪位置错误。
2.4 Prompt cache 稳定
如果每次请求都根据当前模型重新编码图片,图片字节会变化,prompt cache 的前缀就会失效。归一化在图片进入历史时完成一次,后续请求复用同一份字节,cache 才能稳定命中。
3. 总览
用户 prompt ──> _normalizePromptImages ─┐
工具结果 ────> normalizeToolResultImages ├─> processImage
read 工具 ───> processImage ────────────┤
CLI @file ───> processImage ────────────┘
│
▼
┌─────────────────────────────────┐
│ processImage │
│ 1. MIME 归一化 │
│ 2. 不支持格式 → PNG │
│ 3. 按需缩放 │
│ 4. 生成 hints │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 进入会话历史 / 工具结果 │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 请求层兜底 │
│ - blockImages │
│ - 非视觉模型降级 │
│ - 错误工具结果图片拆分 │
└─────────────────────────────────┘
4. 四条入口
| 入口 | 调用点 | 处理对象 |
|---|---|---|
| 用户 prompt 附件 | AgentSession._normalizePromptImages(agent-session.ts:2027) |
prompt(..., { images }) |
| 工具结果图片 | AgentSession._afterToolCall → normalizeToolResultImages(agent-session.ts:685,tool-result-images.ts:24) |
扩展、MCP、截图工具返回的图片 |
read 工具 |
read.ts:136 |
读取到的图片文件 |
CLI @file |
file-processor.ts:54 |
命令行附件 |
四条入口最终都调用 processImage(image-process.ts:72)。
5. processImage 的核心流程
processImage(bytes, mimeType, options)(image-process.ts:72-119)按顺序执行:
5.1 MIME 归一化
normalizeSupportedImageMimeType(image-process.ts:33-47)只接受:
image/pngimage/jpegimage/jpg(统一成image/jpeg)image/gifimage/webp
它会去掉 ; charset=... 之类的参数。其他格式返回 null,进入转换分支。
5.2 不支持的格式转 PNG
normalizeImage(:49-65)对不支持的 MIME 调用 convertImageBytesToPng:
- 用 Photon 解码;
- 应用 EXIF 方向;
- 重新编码成 PNG。
失败时 processImage 返回:
[Image omitted: could not be converted to a supported inline image format.]
5.3 按需缩放
autoResizeImages 默认 true。开启时调用 resizeImage;返回 null 时给出:
[Image omitted: could not be resized below the inline image size limit.]
5.4 生成 hints
成功时生成两类 hint:
- 转换提示:
[Image converted from image/bmp to image/png.] - 尺寸提示:只有实际缩放才生成。
尺寸提示格式:
[Image: original 2400x4800, displayed at 1000x2000.
Multiply coordinates by 2.40 to map to original image.]
5.5 autoResizeImages: false 的语义
autoResizeImages: false 只跳过缩放,不会跳过格式转换。BMP 仍然会被转成 PNG。
测试 converts BMP files to PNG attachments when auto-resize is disabled(test/image-process.test.ts)覆盖了这一点。
另一个边界:当格式受支持且关闭缩放时,processImage 不会解码校验,直接把原字节转成 base64 返回。损坏的 PNG 可能原样进入历史,直到 provider 拒绝。
6. 缩放算法与默认限制
6.1 执行环境
缩放由 resizeImage(image-resize.ts:84-109)完成:
- 优先在 worker thread 中运行 Photon(Rust/WASM),避免阻塞 TUI 事件循环;
- worker 加载失败时回退到进程内
resizeImageInProcess; - Bun 编译产物会先尝试字符串路径的 worker,以使用内嵌 worker。
6.2 默认参数
默认参数(image-resize-core.ts:4-9, 31-39):
| 参数 | 默认值 | 说明 |
|---|---|---|
maxWidth |
2000 | 最大宽度 |
maxHeight |
2000 | 最大高度 |
maxBytes |
4.5 MB | base64 编码后的字节数,不是原始二进制大小 |
jpegQuality |
80 | JPEG 初始质量 |
maxBytes 的语义容易误解。4.5 MB base64 大约对应 3.375 MB 原始字节,是给 Anthropic 5 MB 限制留的余量(image-resize-core.ts:31-32 注释)。
6.3 算法
resizeImageInProcess(:69-174)的算法:
- 用 Photon 解码,并应用 EXIF 方向;
- 如果宽高都在限制内,且 base64 大小小于
maxBytes,直接返回原始字节,wasResized: false; - 否则按宽高比缩放到
maxWidth/maxHeight; - 生成编码候选:
- PNG;
- JPEG,按
[jpegQuality, 85, 70, 55, 40]去重后的顺序; - 返回第一个
encodedSize < maxBytes的候选;
- 如果都超限,按 0.75 比例逐步缩小,直到 1x1;
- 仍然超限则返回
null。
6.4 尺寸 hint
formatDimensionNote(image-resize.ts:111-122)在 wasResized 为 true 时生成:
[Image: original ${originalWidth}x${originalHeight}, displayed at ${width}x${height}.
Multiply coordinates by ${scale.toFixed(2)} to map to original image.]
其中 scale = originalWidth / width。
用途:模型看到的是缩放后的图片,但工具返回的坐标通常基于原图。告诉模型缩放比例,它才能把坐标映射回原图。
7. 模型相关的 resize profile
7.1 模型元数据
缩放参数不是写死的。模型可以通过 inputLimits.images.resize 覆盖(providers/model-schema.ts:51-79):
ts
inputLimits?: {
images?: {
resize?: {
maxWidth?: number;
maxHeight?: number;
maxBytes?: number; // base64 payload
jpegQuality?: number;
};
maxPerMessage?: number;
maxPerRequest?: number;
};
};
models.json 里的覆盖会与内置模型元数据合并,嵌套的 resize 也是逐字段合并(provider-composer.ts:144-162)。
7.2 _limitsModel()
AgentSession 通过 _limitsModel()(agent-session.ts:641-643)选择 profile:
ts
private _limitsModel(): Model<any> | undefined {
return this.routedModel?.model ?? this.model;
}
- 普通模型:当前模型;
- 虚拟模型:最近一次成功响应所使用的物理模型。
7.3 只消费 resize
目前这条管线只消费 resize。maxPerMessage / maxPerRequest 是模型元数据,但 AgentSession 没有在这里强制数量限制。
8. 用户 prompt 图片
8.1 调用位置
_prepareRun(agent-session.ts:1890-1941)在 before_agent_start 之后调用 _normalizePromptImages:
ts
const normalized = await this._normalizePromptImages(trigger.images);
const text =
normalized.hints.length > 0
? `${trigger.prompt}\n\n${normalized.hints.join("\n")}`
: trigger.prompt;
messages.push({
role: "user",
content: [{ type: "text", text }, ...normalized.images],
timestamp: Date.now(),
});
8.2 逐张处理
_normalizePromptImages(:2027-2047):
ts
const processed = await processImage(
Buffer.from(image.data, "base64"),
image.mimeType,
{
autoResizeImages: this.settingsManager.getImageAutoResize(),
resizeOptions: this._limitsModel()?.inputLimits?.images?.resize,
},
);
行为:
- 成功:加入
images,hints 收集起来; - 失败:不加入图片,只把失败消息作为 hint 加入文本;
- 所有 hint 最后拼到用户 prompt 文本后面。
8.3 为什么在 before_agent_start 之后
扩展可以在这个钩子里切换模型,而 resize profile 取决于模型。先让扩展选模型,再按该模型的限制归一化(:1912-1914 注释)。
8.4 持久化时机
归一化发生在 agent.prompt(messages) 之前,因此写入 SessionManager 的历史里已经是处理后的字节。
9. 工具结果图片
9.1 调用位置
工具结果图片和 prompt 图片的路径不同。_afterToolCall(agent-session.ts:685-725)先跑扩展的 tool_result 钩子,再归一化:
ts
const content = hookResult?.content ?? result.content ?? [];
const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize;
const normalizedContent = await normalizeToolResultImages(content, {
autoResizeImages: this.settingsManager.getImageAutoResize(),
resizeOptions,
});
顺序很重要:先扩展钩子,后归一化 。这样扩展注入或替换的图片也会被处理(:706 注释)。
9.2 normalizeToolResultImages 的规则
normalizeToolResultImages(tool-result-images.ts:24-67):
- 没有 image block:直接返回原数组(保持引用不变);
- 对每个 image block 调
processImage; - 处理失败:保留原 block,不删除工具输出;
- 处理成功且没有变化:保留原 block;
- 处理成功且有变化:替换 image block,并在它后面插入一个 text block 放 hints;
- 如果没有任何变化,返回原数组引用,调用方可以跳过重写。
9.3 为什么失败时保留原图
注释写得很明确:工具已经产出了这张图,失败可能只是图像后端不可用;静默删除工具输出比保留原图更糟。
代价是:如果原图确实超出 provider 限制,后续请求仍可能被拒绝。
9.4 _afterToolCall 的返回
ts
{
content: normalizedContent,
details: hookResult?.details,
structuredContent: hookResult ? hookResult.structuredContent : result.structuredContent,
isError: hookResult?.isError ?? isError,
usage: hookResult?.usage,
}
如果没有钩子且内容没变化,返回 undefined,不产生额外覆盖。
10. read 工具与 CLI 附件
10.1 read 工具
read.ts:133-152:
ts
const processed = await processImage(buffer, mimeType, {
autoResizeImages,
resizeOptions: ctx?.model?.inputLimits?.images?.resize ?? fallbackResizeOptions,
});
- 成功:返回一个 text note(包含 MIME 和 hints)加一个 image block;
- 失败:只返回 text note,内容包含
Image omitted...; - 如果当前模型不支持图片输入,还会追加:
[Current model does not support images. The image will be omitted from this request.]
这里有一个细节:autoResizeImages 是在创建工具定义时从设置读取的(agent-session.ts:3641, 3651-3654),而 resizeOptions 是每次调用时从 ctx.model 动态读取的。
所以:
- 切换模型会立即影响
read的 resize profile; - 关闭
images.autoResize后,read仍会按旧值缩放,直到reload重建工具定义; - 开启
images.autoResize时,随后的_afterToolCall归一化会按新值补做缩放; - prompt 和工具结果路径本身是每次调用实时读取设置。
10.2 CLI @file
file-processor.ts:49-69:
- 检测 MIME;
processImage(content, mimeType, { autoResizeImages });- 失败:把失败消息写进
<file>文本; - 成功:加入 image 附件,并写一条带 hints 的文本引用;
- 可以传
autoResizeImages: false,把缩放推迟到 prompt dispatch。
11. 请求层的三道兜底
归一化之后,请求真正发出去之前还有三层处理,容易和"归一化"混淆。
11.1 blockImages 设置
convertToLlmWithBlockImages(sdk.ts:296-330)在 convertToLlm 之后动态检查 settingsManager.getBlockImages()。如果开启,会把 user 和 toolResult 消息里的 image block 替换成:
Image reading is disabled.
并去掉连续重复的占位文本。它不影响历史,只影响当前请求的投影。
11.2 非视觉模型降级
transformMessages 里的 downgradeUnsupportedImages(packages/ai/src/api/transform-messages.ts:62-84)会检查 model.input.includes("image")。如果模型不支持图片:
- user 图片 →
(image omitted: model does not support images) - toolResult 图片 →
(tool image omitted: model does not support images)
这也是请求层行为,不修改历史。
11.3 错误工具结果的图片拆分
对于不接受错误工具结果里带图片的 API,splitErrorToolResultImages(transform-messages.ts:31-40)会把图片从错误结果中拆出来,用占位文本代替,然后把图片放到同一 user 消息里所有工具结果之后。注释点名了 Anthropic 和 Bedrock 上的 Claude。
12. 失败语义汇总
| 场景 | 行为 |
|---|---|
| prompt 图片转换失败 | 图片丢弃,失败消息作为文本 hint 拼进 prompt |
| prompt 图片缩放失败 | 同上 |
| 工具结果图片转换失败 | 保留原图 block,不删除工具输出 |
| 工具结果图片缩放失败 | 保留原图 block |
read 图片处理失败 |
返回纯文本 note,不含图片 |
CLI @file 图片处理失败 |
文本中记录 Image omitted...,不加入附件 |
autoResizeImages: false |
跳过缩放,但不跳过不支持格式到 PNG 的转换 |
| Photon 不可用 | 转换/缩放返回 null,按上面的失败语义处理 |
13. 边界与设计取舍
-
归一化不等于缩放
即使关闭
autoResizeImages,BMP 等格式仍会被转成 PNG。归一化包含格式、尺寸、hints 三部分。 -
maxBytes是 base64 长度不是原始文件大小。默认 4.5 MB 是编码后大小。
-
归一化只发生一次
图片进入历史时处理,之后所有请求复用同一份字节。这是为了 prompt cache 稳定。切换模型不会重新归一化历史里的旧图片。
-
虚拟模型首次请求的 profile 可能不完整
_limitsModel()依赖routedModel,而虚拟模型的路由发生在prepareRequest,晚于 prompt 图片归一化。首次请求时可能拿不到物理模型,只能使用虚拟模型自身的 limits 或默认值。后续工具结果图片通常能拿到已路由的物理模型。 -
工具结果失败时保留原图是有意为之
这不是漏处理,而是"不要静默删除工具输出"的取舍。真正超限的图片仍可能被 provider 拒绝。
-
read的autoResizeImages是构建时快照prompt 和工具结果路径实时读取设置;
read工具在_buildRuntime时读取。关闭设置后,read仍按旧值缩放直到 reload;开启设置时,随后的_afterToolCall归一化会按新值补做缩放。 -
数量限制没有在这条管线里强制
inputLimits.images.maxPerMessage/maxPerRequest是模型元数据,但AgentSession只消费resize。 -
图片是顺序处理的
_normalizePromptImages和normalizeToolResultImages都用for ... await逐张处理,不会并行;每次resizeImage还可能启动一个 worker。多图场景的延迟会累加。 -
关闭缩放时不校验受支持格式
支持格式且
autoResizeImages: false时,processImage直接返回原字节的 base64,不解码校验。损坏的 PNG 可能原样进入历史。 -
hints 是普通文本
转换和尺寸提示作为 text block 或 prompt 文本的一部分发送,不是隐藏元数据。模型可以选择忽略它们。
14. 完整 trace
14.1 Prompt 图片
假设用户粘贴一张 4000x3000 的 PNG,默认 profile:
prompt(text, { images: [4000x3000 PNG] })
→ before_agent_start(扩展可能切模型)
→ _normalizePromptImages
→ processImage
→ MIME: image/png,直接通过
→ autoResize: true
→ resizeImage
→ 4000x3000 超过 2000x2000
→ 按比例缩到 2000x1500
→ PNG 候选 base64 小于 4.5MB
→ wasResized: true
→ hints:
[Image: original 4000x3000, displayed at 2000x1500.
Multiply coordinates by 2.00 to map to original image.]
→ user message:
content: [
{ type: "text", text: "原始 prompt\n\n[Image: ...]" },
{ type: "image", data: "...", mimeType: "image/png" }
]
→ agent.prompt
→ message_end 持久化归一化后的图片
之后每次请求都复用这份 2000x1500 的图片,不会再次缩放。
14.2 工具结果图片
假设截图工具返回 2400x4800 PNG:
tool.execute() → content: [text, 2400x4800 PNG]
→ extension tool_result hook(可能替换 content)
→ _afterToolCall
→ normalizeToolResultImages
→ processImage → 缩到 1000x2000
→ hints 生成
→ 返回 [image(1000x2000), text(hint)]
→ tool result message 持久化
14.3 不支持格式
BMP 1x1,autoResizeImages: false:
processImage
→ MIME image/bmp 不在白名单
→ convertImageBytesToPng
→ PNG bytes
→ autoResize false:跳过缩放
→ hints: [Image converted from image/bmp to image/png.]
→ 返回 PNG base64
14.4 请求层兜底
blockImages: true:即使历史里有图片,请求里也会变成Image reading is disabled.- 非视觉模型:请求里变成
(image omitted: model does not support images)。 - 错误工具结果:图片可能被拆到工具结果之后发送。
15. 测试覆盖
| 测试文件 | 覆盖内容 |
|---|---|
test/image-process.test.ts |
GIF 签名、BMP 检测、关闭缩放时仍转 PNG |
test/tool-result-images.test.ts |
无图/已合规/超限/关闭缩放/BMP/损坏图片/文本顺序 |
test/image-resize-callers.test.ts |
read 工具与 CLI 附件的模型 profile、失败降级、延迟缩放 |
test/suite/agent-session-tool-result-images.test.ts |
AgentSession 把设置和当前模型 profile 传给工具结果归一化 |
test/block-images.test.ts |
blockImages 请求层过滤 |
16. 代码索引
| 位置 | 内容 |
|---|---|
image-process.ts:72 |
processImage |
image-process.ts:33 |
MIME 白名单 |
image-process.ts:49 |
normalizeImage |
image-process.ts:67 |
conversionHint |
image-resize.ts:84 |
resizeImage(worker/进程内) |
image-resize.ts:111 |
formatDimensionNote |
image-resize-core.ts:4 |
ImageResizeOptions |
image-resize-core.ts:31 |
DEFAULT_MAX_BYTES |
image-resize-core.ts:69 |
resizeImageInProcess |
image-convert.ts:23 |
convertImageBytesToPng |
photon.ts:116 |
loadPhoton |
tool-result-images.ts:24 |
normalizeToolResultImages |
agent-session.ts:641 |
_limitsModel |
agent-session.ts:685 |
_afterToolCall |
agent-session.ts:2027 |
_normalizePromptImages |
agent-session.ts:1890 |
_prepareRun |
agent-session.ts:3641 |
_buildRuntime 读取 autoResizeImages |
read.ts:133 |
read 工具的图片处理 |
file-processor.ts:49 |
CLI @file 图片处理 |
sdk.ts:296 |
blockImages 请求层过滤 |
transform-messages.ts:31 |
错误工具结果图片拆分 |
transform-messages.ts:62 |
非视觉模型图片降级 |
providers/model-schema.ts:51 |
inputLimits.images.resize 定义 |
provider-composer.ts:144 |
models.json 覆盖合并 |
settings-defaults.ts:41 |
images.autoResize / images.blockImages 默认值 |