逐日AI
第 3 周 · D19约 4 小时

多模态输入:粘贴截图、图片校验与照图改代码

让 Agent 看得见:把图片接进消息结构,处理粘贴与文件两种来源、体积与尺寸下限、以及不支持图片的模型该怎么降级,最后用一张界面截图驱动一次真实的代码改动。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能把图片接进已有的消息结构,且不破坏纯文本路径的兼容性
  2. 能实现图片的来源解析与合法性校验,并给出人话级的失败提示
  3. 能在模型不支持图片时给出可用的降级路径

前十八天 mca 一直只靠文字工作。今天给它开一只眼睛。做完之后回到页面顶部把三条目标勾掉。

小白版讲解

让他看图纸,不只是听描述

带新人改一个界面,你有两种交代方式。

第一种是描述:「工具栏左边那个蓝色的主按钮,文案改一下,要带上保存的意思。」这句话得反复确认——哪个是主按钮?工具栏是顶上那条还是侧边那条?改成什么才算「带上保存的意思」?

第二种是把图纸拍在他桌上:「照这张改。」

前十八天我们对 mca 一直用的是第一种。它对「这个界面长什么样」的全部认知,来自你打的那几行字。而很多任务的信息本来就在图里:一张报错截图、一张设计稿、一张测试框架吐出来的对比图。你描述一遍,信息已经丢了一半,而且丢的那一半正是你没注意到的那一半。

今天要做的事,一句话就是让那张图直接进到对话里。听起来像是一个「加个字段」的活,但它会牵出四个真问题:消息结构要怎么扩才不会把前十八天的纯文本路径搞坏;一张图在终端里到底怎么「粘」进来;哪些图该在发出去之前就拦下;以及最要命的一条——你怎么知道对面那个模型到底看不看得见图

最后这条不是一句反问。2026-09-07 我们用真实 key 在一个 OpenAI 兼容网关上实测:同一张纯红色的图发给四个模型,四个都正常返回、都没有报任何错误,其中一个把这张纯红图说成了蓝绿色。它不是拒绝,是编了一个答案。这一条实测结论会把今天后半篇的设计整个改写。

消息结构怎么扩:第一天就留好的那两种形态

翻回第一天那个冻结的协议文件,Message.content 的类型是这样写的:

TextText
ContentPart = { type: text, text }  或  { type: image, mediaType, base64 }
 
Message.content: string | ContentPart[]
                 纯文本用 string;多模态用数组(注释里写着「D19 才会用到数组形态」)

那行注释在仓库里躺了十八天。今天它兑现了:协议层今天一个字段都不用加。

这不是运气,是一条可以复用的判断:协议要为将来留形状,不为将来留字段。 第一天把 content 定成 string,今天就得改这个类型,而改它意味着审批门、截断、快照、压缩、会话日志全部要重新检查一遍;第一天急着把图片的字段定死(比如加一个 imageUrl?: string),今天又会发现形状不对——一条消息可能带好几张图,而且图文要能交替。

留形状的代价是从第一天起,每一处想把消息当字符串用的地方都得先过一遍 contentToText。十八天付了几十次这个小麻烦,今天一次性收回来——而且收回来的不只是「不用改类型」,下面还有两处白捡的好处。

真正要改的只有出口mca 内部一直用 ContentPart 这套协议,而网关认的是它自己的报文形状。2026-09-07 实测跑通的形状是这样的:文字段是 type: "text",图片段是 type: "image_url",里面套一个 image_url.url,值是一条 data URL。

src/vision/wire.ts
export function toWireContent(
  content: string | ContentPart[]
): string | Array<Record<string, unknown>> {
  if (typeof content === 'string') return content
  // 没有图片的数组仍旧拍平成字符串:见下面那段关于提示缓存的说明
  if (!content.some((part) => part.type === 'image')) return contentToText(content)
  return content.map((part) =>
    part.type === 'text'
      ? { type: 'text', text: part.text }
      : {
          type: 'image_url',
          image_url: { url: `data:${part.mediaType};base64,${part.base64}` },
        }
  )
}

这段里唯一需要解释的是第二个判断:纯文本消息为什么不统一成数组。两种写法对面都认,统一成数组看起来更整齐。但整齐在这里是要付钱的——提示缓存是按前缀逐字命中的,把每一条历史消息的字节形状都改一遍,等于让缓存在改版当天全部落空。为一个用不上的统一性去动缓存前缀,不划算。所以规矩是:只有真的带图那一条消息用数组形态,其余一律保持原样。

两种来源:终端里其实没有「粘贴一张图」

「粘贴截图」这个说法在终端里是不成立的。mca 是一个 readline 程序,到达它手里的永远是文本;剪贴板里那张位图根本没有路径能进到这个进程。所以「粘贴」在一个终端 Agent 里实际上只有两条路,我们两条都收:

  1. 粘路径@work/shots/ui.png —— 直接复用第八天那套 @ 引用语法,一个新符号都不加。
  2. 粘 data URL:截图工具和浏览器都能直接给出 data:image/png;base64,... 这种串。

第一条路有个坑必须堵。@ 在第八天的语义是「把这个文件的文本贴进上下文」,一个 .png 走那条路,注入的会是一坨乱码——而且它不会报错,你只会看到模型答非所问。所以图片这一层必须排在引用展开之前:先把指向图片的那几条引用摘走,再在原文里把它们转义掉(\@),交给第八天的解析器时它们已经是普通文字了。转义是第八天就有的机制,本来就是为了表达「这个 @ 别当引用」——新功能能复用老机制的逃生口,就别新造一个符号。

第二条路的坑是体积。一张 125 KB 的截图,base64 之后是十七万字符。这么长一段东西绝不能留在用户那句话的正文里:终端会刷屏、会话日志会爆炸、后面压缩上下文时摘要请求还会把它再发一遍。所以摘出来之后,原地换成一个 [截图 1] 的占位符。模型看到的是一句带占位符的话加一张真图,读起来正好对得上;你翻历史时看到的也是人能读的东西。

实验里跑一下就看得见:一条 170538 字符的输入,正文只剩 16 字符「看这个 [截图 1] 帮我改文案」。

校验先行:格式看字节、尺寸有下限

图片进来之后,发出去之前,要过三道闸。

第一道是格式,判据是字节不是扩展名。 截图工具存成 .jpg 实际是 PNG,是很常见的一件事;而 mediaType 是要写进报文的,写错了对面要么回一个看不懂的错误码,要么把它当坏数据默默丢掉。文件头那几个魔数字节是文件自己带的,它才是事实。PNG 的签名是固定八字节,JPEG 以 FF D8 FF 开头,GIF 是 GIF87aGIF89a

顺带一提,认得出来但不收比「不认识」强:GIF 能被识别但不在支持清单里,提示会说「这是 image/gif,本工具只收 PNG 与 JPEG」,而不是含糊的「看不懂」。

第二道是尺寸,而且真的存在一条下限。 2026-09-07 实测:一张 4x4 的 PNG 被模型判为无效图片,同一批里 64x64 的图完全正常。所以下限不是我们多心,是真的会被拒。把它挡在本地,读者拿到的是一句人话;发出去再被拒,拿到的是一条网关错误码。

读尺寸这件事 PNG 很省心——IHDR 必须是第一个块,宽高的偏移是固定的 16 与 20。JPEG 就麻烦得多,它没有固定偏移,必须沿着 marker 一段段走到 SOFn 那一段才有宽高。这里藏着一个特别值得记的坑。

src/vision/probe.ts
function jpegSize(buf: Buffer): { width: number; height: number } | null {
  let i = 2
  while (i + 3 < buf.length) {
    if (buf[i] !== 0xff) { i += 1; continue }
    const marker = buf[i + 1] as number
    // D8 D9 与 D0-D7 不带长度字段,遇到就只能往前挪两个字节
    if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue }
    const length = buf.readUInt16BE(i + 2)
    // C4 C8 CC 的编号落在 SOF 区间里却不是 SOF,漏了这条判断不会报错,只会读出一个假尺寸
    const isSof = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
    if (isSof) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }
    i += 2 + length
  }
  return null
}

C4C8CC 这三个编号落在 C0CF 这个 SOF 区间里,但它们不是 SOF(C4 是霍夫曼表)。漏掉这条判断的后果非常典型:它不会报错,只会把霍夫曼表里的字节当成宽高读出来,给你一个离谱但形状完全正常的尺寸。实验的自检里专门拼了一段「先霍夫曼表后 SOF」的最小 JPEG 来钉住这一条。

第三道是体积与张数。 这几条线全是我们自己定的本地策略,实验里取的是单张 3 MB、单轮 4 张 / 6 MB。必须说清楚的是:它们不是任何一家网关的上限。 各家对图片大小、边长、张数的限制都不一样而且会变,代码里写死一个别家的数字,等于埋一个会在某天悄悄过期的假事实——真实上限一律以你所用网关的文档为准。/vision 这条命令把这句话原样打了出来。

图片很贵:把它变成一个数

「图片很贵」如果只是一句话,没人会当真。所以每张图进来时都要打一行账。

实验里打两个数:原始字节数base64 之后的字符数。第二个才是真正要走网络的量——base64 一律胀到约四比三,一张 125 KB 的截图在报文里是十七万字符。

刻意不打的是花费金额。一是模型侧怎么给图片计费各家算法不同,多数不按 base64 长度算,换算成钱就是在编;二是成本是明天的题目,那一天用 usage 回传的真实数字算,今天把「有多大」说清楚就够了。

顺着这行账,两个白捡的好处该收了。

第一个在会话日志。 第七天定的规矩是消息一条条追加进 work/sessions/<id>.jsonl。要是不管,那十七万字符的 base64 会原样躺进一行里,三五轮之后这个文件就没法读、没法 diff、--resume 还要先把它整个读回内存。所以写盘之前过一道投影:图片段换成一行 [截图 1:image/png,base64 170516 字符,正文未入日志]。代价要一起说清——恢复出来的会话里没有那张图了。这是有意的取舍:截图是一次性输入,它的结论已经变成了后面的对话与真实的代码改动;真要再看一眼,原图还在磁盘上,重新 @ 一次就是了。

第二个在压缩,而且它是白捡的。 第十二天压缩上下文时会把一段对话转录成文本再发给模型写摘要。如果图片被原样转录进去,压缩这一次请求会把所有截图再发一遍——压缩本来是为了省,结果成了最贵的一次请求。而实验里这条根本不用写代码:转录用的是第一天就放在协议层的 contentToText,它把图片段渲染成 [图片 image/png] 这个标记。第一天把渲染规则写在协议层,第十九天在一个完全没想到的地方兑现了。

不支持图片的模型会编一个答案

现在回到开头那条实测结论。

2026-09-07,一张纯红色的 PNG 发给四个模型。四个都正常返回、没有任何错误,其中一个说它是蓝绿色。

这条结论直接判死了三种常见的能力探测写法:

写法为什么不行
发一张图,没报错就算支持会把编答案的模型判成支持——这正是实测撞见的那一种
看模型 id 里有没有 vision 字样模型 id 的形状随网关变,第一天就讲过它不能当常量用
维护一张自己的支持清单清单会过期,而过期时没有任何东西会报错

能用的只剩一种:发一张答案已知的图,核对它答得对不对。

「答案已知」这四个字是关键,而它之所以能成立,是因为那张图是我们自己生成的——实验里手写了一个最小 PNG 编码器(CRC32 加 node:zlib,八十来行),探针图的颜色由我们指定,所以正确答案是我们已知的事实,不是另一个需要相信的东西。这就是可验证的判据:判据必须能被独立核对,否则它只是换了个地方的猜测。

src/vision/capability.ts
export async function probeVision(provider: ChatProvider, pick = randomColor()) {
  const png = solidPng(128, 128, pick.rgb) // 128 不是随口取的:探针自己也要过得了 64 的下限闸
  let answer = ''
  for await (const delta of provider.stream({ messages: [probeMessage(png)] })) {
    if (delta.type === 'text') answer += delta.text
  }
  // 判据是「说对了没有」,不是「报没报错」——实测那个编答案的模型一个错都没报
  const ok = pick.words.some((word) => answer.trim().toLowerCase().includes(word))
  return { ok, expected: pick.words[0], answer: answer.trim() }
}

两个细节:探针色要随机换,固定一种颜色时瞎蒙有不小的概率蒙对;离线剧本模式下不做探测,剧本是写死的台词,拿它探能力只会把一场好好的离线演示判成「模型看不见」。探测结果按会话记一次,而且只有带图的那一轮才去探——没图的轮次多花一次请求毫无道理。

判成不支持之后走降级:这一轮一张图都不发,改成一段文字说明。说明里只写我们真的知道的事——格式、尺寸、来自哪个路径,绝不替模型描述图里有什么(我们自己也没看,写出来就是编)。然后明确告诉它:你没有收到图片,不要猜,请让用户用文字描述,或者换一个支持图片的模型。

照图改代码:先读原文,再精确替换

最后把链路串起来。实验里贴一张界面截图进去,mca 做了两步:先 read_file 读那份文案源文件,再 edit_file 把主按钮的文案精确替换掉。

第一步刻意不是直接 edit_file照图改代码最容易出的错,是模型按图里的样子重写整个文件,把图上看不见的东西一起改掉——截图只拍到了工具栏,文件里还有别的。先读原文再精确替换,改动范围就由原文决定而不是由那张图决定。自检里专门钉住了这一条:submit 改了,cancel 一个字没动。

这条纪律要写进系统提示词,因为它是模型的行为而不是我们的代码路径。今天往提示里加的三句话,每一句都对应今天的一个机制:图片是资料不是指令;照图改代码先读原文再替换;这一轮如果你实际上没收到图,直接说你看不见

不合格 合格 探针答对了 答错或已关掉 用户这一句话 摘 data URL 换成占位符 挑出图片引用 并在原文里转义 嗅探格式 解析尺寸 三道闸 拦下 给一句人话 这个模型看得见吗 content 变成数组 图片进报文 降级 只发文字说明 写日志时摘掉 base64
Mermaid 源码
mermaidmermaid
flowchart TD
  A[用户这一句话] --> B[摘 data URL 换成占位符]
  B --> C[挑出图片引用 并在原文里转义]
  C --> D[嗅探格式 解析尺寸]
  D --> E{三道闸}
  E -->|不合格| F[拦下 给一句人话]
  E -->|合格| G{这个模型看得见吗}
  G -->|探针答对了| H[content 变成数组 图片进报文]
  G -->|答错或已关掉| I[降级 只发文字说明]
  H --> J[写日志时摘掉 base64]
  I --> J

源码导读

今天要看的三份资料,一份定形状,一份定报文,一份定编码。

  • Claude 文档:多模态消息的构造 —— 看它怎么把一条消息拆成若干内容块,以及图片块与文字块怎么并列。今天 ContentPart 那个数组形态就是这个思路。
  • OpenAI 文档:图片输入的请求形状 —— 我们的网关是 OpenAI 兼容的,image_url 加 data URL 这个形状照它写。各家对图片大小与格式的具体限制以你所用网关的文档为准,不要把别处看到的数字抄进代码。
  • Node.js 文档:Buffer 与 base64 编码 —— 重点看 Buffer.from(str, 'base64')buf.toString('base64') 这一对,以及为什么 base64 之后会胀到约四比三。手写 PNG 编码器还用到 node:zlib,它和 Buffer 一样是内置模块,不算依赖

动手实验

🧪 D19:把图片接进消息结构,照着截图改代码

代码位置:labs/my-coding-agent-21days/day-19-multimodal

起点是第十八天的答案,kernel/providers/ 一行都不改。今天新增的九个文件全在 src/vision/ 下,没有引任何图片库——格式嗅探、尺寸解析、base64 编码、连演示图片的生成全部自己写,仓库里一个二进制文件都没有。

  1. 把第十八天的答案复制过来,新建 src/vision/,先写那个最小 PNG 编码器:有了它才有演示素材,也才有答案已知的探针图。
  2. probe.ts 的魔数嗅探与尺寸解析(TODO(1) 是 JPEG 那一段,记得跳过 C4 / C8 / CC)。
  3. validate.ts 的三道闸(TODO(2) 是边长下限),每条提示都要有「多大、合格线、下一步」三件事。
  4. attach.ts:摘 data URL、挑出图片引用并在原文里转义掉(TODO(3)),组装 ContentPart 数组并打账。
  5. wire.ts 的两个出口(TODO(4) 是线协议映射):发给网关的报文形状,与写进日志时摘掉 base64 的投影。
  6. capability.ts 的探测与降级(TODO(5) 是核对答案那一步),然后跑 MOCK=1 SELFTEST=1 pnpm start,十五项要全绿。

跑完别忘了清产物:rm -rf work。想看主现象,README 里给了四条一行就能跑的命令。

面试题

今天三道题都是实现者视角:改过这条链路就答得出,没改过就只能说概念。

  • 在已有的纯文本消息结构上加图片,你会怎么改才不破坏兼容?
  • 图片输入要做哪些校验?为什么小图片会被拒绝?
  • 模型不支持图片时的降级策略怎么设计?

检查清单与明日预告

  • MOCK=1 SELFTEST=1 pnpm start 打印 15/15 通过
  • 贴一张 @work/shots/ui.png,模型先读原文再精确替换,cancel 那一行没被动过
  • 4x4 那张在发出去之前就被拦下,提示里有「多大、合格线、下一步」三件事
  • 名字叫 .jpg 的那张被判成 image/png,报文里写的是字节说了算的那个
  • 十七万字符的 base64 没进会话日志,整个 jsonl 比那张图还小
  • 撒谎的那个 provider 被判为不支持,而它一个错都没报
  • /vision 能说清收什么格式、下限多少、上限是我们自己定的
  • pnpm typecheck 无输出

明天是 D20,第二十天:评估与成本——基准集怎么设计、通过率怎么算、token 与缓存命中怎么看。今天我们坚持只打印「这张图有多大」而不打印「它花了多少钱」,明天就来还这笔账:搭一套基准集,把通过率、平均轮数、token 消耗与缓存命中率都量出来,让「这次改动到底让它变好了还是变差了」变成一个能看的数字,而不是一句感觉。

面试题库

  • 在已有的纯文本消息结构上加图片,你会怎么改才不破坏兼容?How would you add images to an existing text-only message schema without breaking compatibility?
    国内高频海外高频基础#multimodal#message-schema

    分析过程 · 先想清楚再作答

    1. 这题在考「你改没改过一个已经跑了很久的协议」。只读过文档的人会答「content 改成数组就行」,改过的人会先问一句:这个类型有多少处调用方,它们会不会一起红。
    2. 怎么拆:先说内部协议怎么留形状,再说出口那一层改什么,最后说为什么不把所有消息都统一成新形状。
    3. 内部协议的正解是把 content 定成「字符串或内容块数组」的联合类型,图片是其中一种块(媒体类型加 base64)。留的是形状不是字段:留字段(比如加一个 imageUrl)会在需要多张图、或者图文交替时立刻不够用。
    4. 代价要说出来:从留形状那天起,每一处想把消息当字符串用的地方都得先过一个「取纯文本」的函数。这个小麻烦要付很多次,换来的是加图片那天不用动协议。
    5. 真正要改的只有出口:内部协议翻译成网关报文的那一段。OpenAI 兼容口是文字段 type text、图片段 type image_url 里套一条 data URL。循环、审批、截断、快照、压缩一行都不用改,因为它们碰的是内部协议不是报文。
    6. 最后一条是加分项:纯文本消息不要统一成数组。两种写法对面都认,但提示缓存按前缀逐字命中,改一遍历史消息的字节形状等于让缓存当天全部落空。只有真的带图那一条用数组。
    7. 可预期的追问:多张图和文字怎么排序;这条消息进会话日志时怎么处理;压缩摘要时图片怎么办。

    How to reason about it · think before answering

    1. This tests whether you have ever changed a protocol that has been running for a while. People who only read docs answer "make content an array"; people who have done it first ask how many call sites that type has and whether they all break at once.
    2. How to break it down - first how the internal protocol leaves room, then what actually changes at the outbound edge, then why not normalize every message into the new shape.
    3. The internal answer is a union - content is either a string or an array of content parts, and an image is one kind of part (media type plus base64). What you reserve is a shape, not a field: reserving a field such as imageUrl falls apart the moment you need several images, or text and images interleaved.
    4. State the cost too - from that day on, every site that wants the message as a string must go through a to-plain-text helper. You pay that small tax many times, and in exchange the day images arrive the protocol does not move.
    5. What really changes is the outbound edge - the function that translates the internal protocol into gateway wire format. On an OpenAI-compatible endpoint a text part is type text and an image part is type image_url wrapping a data URL. The loop, approval gate, truncation, snapshots and compaction all stay untouched, because they touch the protocol rather than the wire format.
    6. A bonus point - do not normalize plain-text messages into arrays. Both forms are accepted, but prompt caching matches the prefix byte for byte, so rewriting the shape of every historical message drops the cache entirely on release day. Use the array form only for the message that actually carries an image.
    7. Likely follow-ups - how multiple images and text are ordered; what happens when that message hits the session log; what compaction does with images.

    答题要点

    • 内部协议用「字符串或内容块数组」的联合类型,图片是其中一种块——留形状不留字段
    • 代价是每处取文本都要过一个转换函数,收益是加图片那天协议不动
    • 真正改的只有出口那一层:内部块翻译成网关报文(文字段 text、图片段 image_url 套 data URL)
    • 循环、审批、截断、快照、压缩都不用改,因为它们依赖的是协议不是报文
    • 纯文本消息保持字符串形态,别为统一而统一——提示缓存按前缀命中

    Key points

    • Make the internal content type a union of string and an array of content parts, with image as one part kind - reserve the shape, not a field
    • The cost is a to-text helper at every read site; the payoff is that the protocol does not move on the day images arrive
    • Only the outbound edge changes - parts translated into wire format (text parts, and image parts as image_url with a data URL)
    • Loop, approval, truncation, snapshots and compaction need no change because they depend on the protocol, not the wire format
    • Keep plain-text messages as strings; normalizing them for tidiness costs you the prompt cache
  • 图片输入要做哪些校验?为什么小图片会被拒绝?What validation does image input need, and why would a very small image be rejected?
    国内高频海外高频进阶#input-validation#multimodal

    分析过程 · 先想清楚再作答

    1. 这题在考你有没有真的把图发出去过。没发过的人只会说「查一下大小」;发过的人会先讲一条最反直觉的:格式要按字节判,不能按扩展名判。
    2. 怎么拆:按三道闸讲——格式、尺寸、体积与张数,每道说清判据与失败提示该写什么。
    3. 第一道是格式,判据是文件头的魔数。截图工具存成 jpg 实际是 PNG 很常见,而媒体类型是要写进报文的:写错了对面要么回一个看不懂的错误码,要么把它当坏数据默默丢掉,两种都难查。另外「认得出但不收」要好过「不认识」——前者能说清是什么格式、我们只收哪几种。
    4. 第二道是尺寸下限,而且它有实测依据:2026-09-07 实测一张 4x4 的 PNG 被模型判成无效图片,同一批里 64x64 正常。所以下限不是洁癖,是真的会被拒;挡在本地能给一句人话,发出去再被拒只能给一条错误码。读尺寸时 PNG 偏移固定,JPEG 必须沿 marker 走到 SOF,而且要跳过编号落在 SOF 区间里的霍夫曼表——漏了这条不会报错,只会读出一个假尺寸。
    5. 第三道是体积与张数,它是本地策略。这里要主动说一句:这些数字不该照抄任何一家网关的文档写死在代码里,各家不一样而且会变,写死等于埋一个会悄悄过期的假事实。
    6. 校验放客户端的理由有三条,第三条最重要:省一次白花的往返;对面的错误码人看不懂;以及有些模型压根不会拒绝,它会编一个答案。能自己判的事别指望对面替你判。
    7. 失败提示的规格也是考点:必须同时说清「这张图哪里不合格、合格线是多少、下一步该干什么」,缺一样用户就只能猜。
    8. 可预期的追问:超预算时要不要自动压缩;多张图怎么分配预算;用户贴进来的 data URL 要不要做长度上限。

    How to reason about it · think before answering

    1. This tests whether you have actually shipped images to a model. People who have not say "check the size"; people who have start from the least intuitive rule - decide format from the bytes, never from the extension.
    2. How to break it down - three gates: format, dimensions, then size and count, each with its criterion and what the failure message must say.
    3. Gate one is format, decided by the magic bytes. Screenshot tools saving a PNG under a .jpg name is common, and the media type goes into the request: get it wrong and the other side either returns an opaque error code or silently discards the data, both hard to debug. Also, recognized but unsupported beats unrecognised - the former can say what the format is and which ones you accept.
    4. Gate two is a minimum dimension, and there is measured evidence for it: on 2026-09-07 a 4x4 PNG was rejected as an unsupported image while a 64x64 image from the same run went through. So the floor is real, not fussiness; catching it locally yields a human sentence, while sending it yields an error code. Reading dimensions is easy for PNG at fixed offsets; JPEG requires walking markers to the SOF segment and skipping the Huffman table whose marker number falls inside the SOF range - miss that and nothing errors, you just read a fake size.
    5. Gate three is byte size and image count, and it is local policy. Say this out loud - do not hardcode a number copied from any one gateway's docs. Limits differ between vendors and change over time, so a hardcoded one is a false fact that expires silently.
    6. Three reasons to validate client side, the third being the important one - you save a wasted round trip; the other side's error codes are unreadable to humans; and some models do not reject at all, they invent an answer. Do not outsource a judgment you can make yourself.
    7. The shape of the failure message is also part of the answer - it must state what is wrong with this image, what the threshold is, and what to do next. Drop any one of those and the user is left guessing.
    8. Likely follow-ups - whether to auto-compress when over budget; how to split a budget across several images; whether pasted data URLs need a length cap.

    答题要点

    • 三道闸:格式(按魔数不按扩展名)、尺寸下限、体积与单轮张数
    • 小图真的会被拒——实测 4x4 被判无效、64x64 正常,所以下限要挡在本地
    • JPEG 读宽高要沿 marker 走到 SOF,并跳过编号落在 SOF 区间里的霍夫曼表,否则读出假尺寸且不报错
    • 体积与张数是本地策略,不要把任何一家的上限写死进代码
    • 校验放客户端的关键理由:有些模型不会拒绝,它会编一个答案
    • 失败提示必须同时说清「哪里不合格、合格线多少、下一步干什么」

    Key points

    • Three gates - format by magic bytes rather than extension, a minimum dimension, and byte size plus per-turn count
    • Small images really are rejected - a measured 4x4 failure against a working 64x64 - so enforce the floor locally
    • Reading JPEG dimensions means walking markers to SOF and skipping the Huffman table inside the SOF range, or you silently read a fake size
    • Size and count limits are local policy; never hardcode one vendor's published ceiling
    • The decisive reason to validate client side - some models do not reject bad input, they invent an answer
    • A failure message must state what is wrong, what the threshold is, and what to do next
  • 模型不支持图片时的降级策略怎么设计?你怎么先知道它不支持?How do you design the fallback when a model cannot see images, and how do you find out that it cannot?
    国内高频海外高频深入#capability-detection#graceful-degradation

    分析过程 · 先想清楚再作答

    1. 这题的分水岭在前半句还是后半句。多数人直接讲降级,而真正的难点是探测——因为最常见的三种探测写法全是错的。
    2. 怎么拆:先说三种错的写法各错在哪,再给可验证判据这个正解,最后才讲降级要写成什么样。
    3. 错法一,发一张图没报错就算支持。2026-09-07 实测把一张纯红图发给四个模型,四个都正常返回、都没报错,其中一个说它是蓝绿色——它不是拒绝,是编了一个答案。
    4. 错法二,看模型 id 里有没有 vision 字样。模型 id 的形状随网关变(同一个模型在不同网关下带不带厂商前缀都不一样),它本来就不该当常量用。
    5. 错法三,维护一张自己的支持清单。清单一定会过期,而过期时没有任何东西会报错,表现是某天开始悄悄编答案。
    6. 正解是可验证的判据:发一张答案已知的图,核对它答得对不对。之所以能成立是因为那张图由我们自己生成——纯色、颜色由我们指定,所以正确答案是已知事实而不是另一个要相信的东西。两个细节:颜色要随机换,否则瞎蒙有概率蒙对;离线或桩 provider 下别做探测,那测不出任何东西。
    7. 降级的规格有三条:一张图都不发;说明里只写我们真的知道的事(格式、尺寸、来源路径),绝不替模型描述图里有什么,那就是我们自己在编;以及明确要求它说出「我看不见」并向用户要文字描述。最怕的不是能力弱,是假装自己没降级——含糊的说明会让模型带着一个编出来的视觉印象继续改代码。
    8. 工程上还要补两笔:探测结果按会话缓存一次,而且只有带图的那一轮才去探,没图的轮次多花一次请求毫无道理;再给用户一个手动开关,明知模型看不见时直接关掉,省下那次探测与那份流量。
    9. 可预期的追问:探测失败要不要自动换模型;同一进程里换了模型怎么让缓存失效;探测这一次请求本身的成本怎么算。

    How to reason about it · think before answering

    1. The dividing line is whether you focus on the first half or the second. Most candidates jump to the fallback, but the hard part is detection, because the three most common detection strategies are all wrong.
    2. How to break it down - name why each of the three is wrong, give the verifiable-criterion answer, and only then describe what the fallback must say.
    3. Wrong approach one - send an image and treat the absence of an error as support. On 2026-09-07 a solid red image went to four models; all four returned normally with no error, and one of them called it blue-green. That is not a rejection, it is an invented answer.
    4. Wrong approach two - look for the word vision in the model id. Model id shape varies by gateway (the same model may or may not carry a vendor prefix), so it was never safe to treat as a constant.
    5. Wrong approach three - maintain your own support list. The list will go stale, and when it does nothing errors; the symptom is that one day it quietly starts inventing answers.
    6. The right answer is a verifiable criterion - send an image whose answer you already know and check the reply. It works because you generate the image yourself: a solid color you chose, so the correct answer is a known fact rather than another thing to trust. Two details - randomize the color, or a guessing model has a decent chance of being right; and skip detection against an offline or scripted provider, where it measures nothing.
    7. The fallback has three requirements - send no image at all; in the replacement text state only what you actually know (format, dimensions, source path) and never describe the picture on the model's behalf, which would be you inventing; and explicitly require it to say it cannot see and ask the user for a description. The real danger is not weak capability but a fallback that hides itself - a vague note lets the model carry an invented visual impression into the code it writes.
    8. Two engineering notes - cache the detection result per session and probe only on turns that actually carry an image, since spending a request on image-free turns makes no sense; and give the user a manual switch to force text mode when they already know the model is blind, saving both the probe and the payload.
    9. Likely follow-ups - whether a failed probe should auto-switch models; how to invalidate the cache when the model changes mid-process; how to account for the cost of the probe request itself.

    答题要点

    • 探测不能靠「没报错」——实测不支持视觉的模型会编一个答案,把纯红图说成蓝绿色
    • 也不能靠模型 id 里的关键字或一张自己维护的支持清单,两者都会静默过期
    • 正解是可验证判据:发一张自己生成、答案已知的纯色图,核对它答得对不对;颜色要随机换
    • 离线剧本或桩 provider 下跳过探测,那测不出任何东西
    • 降级时一张图都不发,说明里只写已知事实,绝不替模型描述图里有什么
    • 必须让模型说出「我看不见」并向用户要文字描述——最怕的是假装自己没降级
    • 探测结果按会话缓存,只在带图的轮次触发,再给用户一个手动强制降级的开关

    Key points

    • Do not detect by absence of error - a model without vision was measured inventing an answer, calling a solid red image blue-green
    • Do not rely on keywords in the model id or a hand-maintained support list; both expire silently
    • Use a verifiable criterion - send a self-generated solid-color image whose answer you know and check the reply, randomizing the color
    • Skip detection against offline scripts or stub providers, where it proves nothing
    • On fallback send no image, and in the replacement text state only known facts, never a description of the picture
    • Make the model say it cannot see and ask the user for words - the worst outcome is a fallback that hides itself
    • Cache the result per session, probe only on turns carrying an image, and give the user a manual force-text switch

评论