图片归一化

1. 一句话结论

"图片归一化"不是单个函数,而是一条"图片进入会话历史之前"的处理管线。它完成三件事:

  1. 把 provider 不接受的格式转成可内联的格式;
  2. 把过大的图片压到模型/请求限制以内;
  3. 把转换和缩放信息以文本 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/png
  • image/jpeg
  • image/jpg(统一成 image/jpeg)
  • image/gif
  • image/webp

它会去掉 ; charset=... 之类的参数。其他格式返回 null,进入转换分支。

5.2 不支持的格式转 PNG

normalizeImage(:49-65)对不支持的 MIME 调用 convertImageBytesToPng:

  1. 用 Photon 解码;
  2. 应用 EXIF 方向;
  3. 重新编码成 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)的算法:

  1. 用 Photon 解码,并应用 EXIF 方向;
  2. 如果宽高都在限制内,且 base64 大小小于 maxBytes,直接返回原始字节,wasResized: false;
  3. 否则按宽高比缩放到 maxWidth / maxHeight;
  4. 生成编码候选:
    • PNG;
    • JPEG,按 [jpegQuality, 85, 70, 55, 40] 去重后的顺序;
    • 返回第一个 encodedSize < maxBytes 的候选;
  5. 如果都超限,按 0.75 比例逐步缩小,直到 1x1;
  6. 仍然超限则返回 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):

  1. 没有 image block:直接返回原数组(保持引用不变);
  2. 对每个 image block 调 processImage;
  3. 处理失败:保留原 block,不删除工具输出;
  4. 处理成功且没有变化:保留原 block;
  5. 处理成功且有变化:替换 image block,并在它后面插入一个 text block 放 hints;
  6. 如果没有任何变化,返回原数组引用,调用方可以跳过重写。

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. 边界与设计取舍

  1. 归一化不等于缩放

    即使关闭 autoResizeImages,BMP 等格式仍会被转成 PNG。归一化包含格式、尺寸、hints 三部分。

  2. maxBytes 是 base64 长度

    不是原始文件大小。默认 4.5 MB 是编码后大小。

  3. 归一化只发生一次

    图片进入历史时处理,之后所有请求复用同一份字节。这是为了 prompt cache 稳定。切换模型不会重新归一化历史里的旧图片。

  4. 虚拟模型首次请求的 profile 可能不完整

    _limitsModel() 依赖 routedModel,而虚拟模型的路由发生在 prepareRequest,晚于 prompt 图片归一化。首次请求时可能拿不到物理模型,只能使用虚拟模型自身的 limits 或默认值。后续工具结果图片通常能拿到已路由的物理模型。

  5. 工具结果失败时保留原图是有意为之

    这不是漏处理,而是"不要静默删除工具输出"的取舍。真正超限的图片仍可能被 provider 拒绝。

  6. read 的 autoResizeImages 是构建时快照

    prompt 和工具结果路径实时读取设置;read 工具在 _buildRuntime 时读取。关闭设置后,read 仍按旧值缩放直到 reload;开启设置时,随后的 _afterToolCall 归一化会按新值补做缩放。

  7. 数量限制没有在这条管线里强制

    inputLimits.images.maxPerMessage / maxPerRequest 是模型元数据,但 AgentSession 只消费 resize。

  8. 图片是顺序处理的

    _normalizePromptImages 和 normalizeToolResultImages 都用 for ... await 逐张处理,不会并行;每次 resizeImage 还可能启动一个 worker。多图场景的延迟会累加。

  9. 关闭缩放时不校验受支持格式

    支持格式且 autoResizeImages: false 时,processImage 直接返回原字节的 base64,不解码校验。损坏的 PNG 可能原样进入历史。

  10. 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 默认值
相关推荐
skywalk81631 小时前
FreeBSD系统在Deepseek-harness中配置使用其它几家的模型,比如hy3 hunyuan-2 glm5.3-flash等
人工智能·freebsd·deepseek·harness
墨心@3 小时前
Pi 项目分析
agent·coding agent
用户3134672143544 小时前
learn-claude-code笔记3: Permission — 执行前做权限判断
llm·agent
墨心@4 小时前
AgentSession 详细分析
agent·pi·coding agent
for_ever_love__4 小时前
检索工具化封装:把 Elasticsearch、SQL、Confluence 包装成 Agent 的统一工具
agent·工具·封装·调用·rag·检索·做成
guslegend4 小时前
注意力稀释:当 113 个工具让模型降智时,工程师该怎么做?
agent
梦醒江南4 小时前
Agent 卡在网页上时,为什么不能换个真实入口继续?
人工智能·agent
用户460381291944 小时前
MCP 给了 Agent 一堆“手”,Skill 才教它怎么干活:两者到底什么关系?
agent·mcp
for_ever_love__5 小时前
Agentic RAG 实战:让 Agent 自己决定怎么检索、检索几次
人工智能·后端·agent·控制·自适应·rag·检索策略