Multimodal Input: Pasting Screenshots, Image Validation, and Fixing Code From a Screenshot
Give the agent eyes: wire images into the message structure, handle both paste and file sources, size and dimension floors, and how to degrade gracefully for a model that doesn't support images, then drive a real code change from one screenshot of a UI.
Today's Goals
- Wire images into the existing message structure without breaking the plain-text path's compatibility
- Implement image source parsing and validity checks, giving human-readable failure messages
- Give a usable degradation path when the model doesn't support images
For eighteen days mca has worked from words alone. Today we open one eye. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Let them look at the drawing, not just hear the description
There are two ways to hand a new hire a UI change.
The first is to describe it: "the blue primary button on the left of the toolbar — reword it so it says something about saving." That takes three rounds of clarification. Which one is the primary button? Is the toolbar across the top or down the side? What wording counts as "something about saving"?
The second is to put the drawing on their desk: "make it match this."
For eighteen days we have used the first way. Everything mca knows about a screen came from the few lines you typed. Yet for many tasks the information already exists as a picture: an error screenshot, a design mockup, a visual-diff image from a test framework. Describe it and half the information is gone — and the half you lost is exactly the half you did not notice.
Today's job is to let that picture into the conversation. It sounds like adding a field. It is not. It pulls four real problems in behind it: how to extend the message structure without breaking the plain-text path eighteen days are built on; what "pasting an image" can even mean inside a terminal; which images should be stopped before they are sent; and the nastiest one — how do you know whether the model on the other end can see the picture at all.
That last one is not a rhetorical question. Measured on 2026-09-07, with a real key against an OpenAI-compatible gateway: one pure-red image went to four models, and all four returned normally with no error of any kind. One of them described that pure-red image as blue-green. It did not refuse; it made an answer up. That single measurement rewrites the whole second half of today.
Extending the message structure: the two shapes day one left
Open the frozen protocol file from day one. The type of Message.content reads like this:
ContentPart = a text part carrying text
| an image part carrying mediaType and base64
Message.content: a plain string, or an array of ContentPart
strings for text; arrays for multimodal
(the comment says "the array shape is only used on D19")That comment sat in the repository for eighteen days. Today it pays out: the protocol layer needs zero new fields.
That is not luck, it is a reusable rule: a protocol should leave room for a future shape, not for a future field. Had day one typed content as a plain string, today we would have to change that type — which means re-auditing the approval gate, truncation, snapshots, compaction and the session log. Had day one instead nailed down an image field (an optional imageUrl string, say), the shape would still be wrong today: one message may carry several images, and text and images must be able to alternate.
The price of leaving room for a shape is that since day one, every place that wanted to treat a message as a string had to go through contentToText first. Eighteen days paid that small tax a few dozen times. Today it comes back at once — and not only as "no type change." Two more free wins show up further down.
What changes is the exit. Internally mca speaks ContentPart; the gateway speaks its own wire shape. The shape we got working on 2026-09-07 puts text parts under a text type and image parts under an image_url type whose nested url holds a data URL.
export function toWireContent(
content: string | ContentPart[]
): string | Array<Record<string, unknown>> {
if (typeof content === 'string') return content
// An array with no images still flattens to a string: see the note on prompt caching below
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}` },
}
)
}def to_wire_content(content: str | list[ContentPart]) -> str | list[dict]:
if isinstance(content, str):
return content
if not any(part.type == "image" for part in content):
return content_to_text(content)
return [
{"type": "text", "text": part.text}
if part.type == "text"
else {
"type": "image_url",
"image_url": {"url": f"data:{part.media_type};base64,{part.base64}"},
}
for part in content
]The only line needing explanation is the second check: why plain-text messages are not normalized into arrays too. Both shapes are accepted, and normalizing looks tidier. But tidiness costs money here — prompt caching hits on a byte-exact prefix, so rewriting the byte shape of every historical message makes every cache entry miss on the day you ship it. The rule: only the message that genuinely carries an image uses the array shape; everything else stays exactly as it was.
Two sources: a terminal has no "paste an image"
"Paste a screenshot" is not a thing that exists in a terminal. mca is a readline program; what reaches it is always text, and the bitmap on your clipboard has no route into this process at all. So inside a terminal Agent "pasting" really means one of two things, and we accept both:
- Paste a path: something like
@work/shots/ui.png— reusing day eight's reference syntax, with no new sigil invented. - Paste a data URL: screenshot tools and browsers hand you a
data:image/png;base64,string directly.
The first route has a trap. In day eight, @ means "paste this file's text into the context." Send a .png down that road and what gets injected is a wall of garbage — and it does not raise an error; you simply watch the model answer a question you did not ask. So the image layer must run before reference expansion: pull out the references pointing at images, then escape them in the original sentence (as \@) so day eight's parser sees ordinary words. Escaping is day eight's own mechanism, built to say "this @ is not a reference" — when a new feature can reuse an old mechanism's escape hatch, do not invent a new sigil.
The second route's trap is bulk. A 125 KB screenshot is about a hundred and seventy thousand characters once base64-encoded, and a string that long must not stay inside the user's sentence: the terminal floods, the session log balloons, and later compaction would send the whole thing again inside the summarization request. So it is lifted out and replaced with a short placeholder such as "screenshot 1". The model sees a sentence with a placeholder plus a real image, and the two line up; scrolling back through history, you see something a human can read.
The lab makes this visible: an input of 170538 characters leaves 16 characters of sentence behind.
Validate first: format from bytes, a dimension floor from measurement
Between arriving and being sent, an image passes three gates.
Gate one is format, and the evidence is bytes, not the extension. A screenshot tool saving PNG data under a .jpg name is an everyday occurrence, and mediaType goes into the wire payload — get it wrong and the other end either returns an error code you cannot interpret or silently discards the data. The magic bytes travel with the file itself; they are the fact. PNG's signature is a fixed eight bytes, JPEG opens with FF D8 FF, GIF with GIF87a or GIF89a.
Worth noting: recognized but not accepted beats "unrecognized." A GIF is identified but is not on the supported list, so the message says it is image/gif and that this tool takes PNG and JPEG only.
Gate two is dimensions, and a real floor exists. Measured on 2026-09-07: a 4x4 PNG was judged an invalid image by the model, while a 64x64 image in the same batch was fine. The floor is not us being fussy — small images really do get rejected. Catch it locally and the reader gets plain language; let it through and they get a gateway error code.
Reading dimensions is easy for PNG: IHDR must be the first chunk, so width and height sit at fixed offsets 16 and 20. JPEG has no fixed offset; you must walk marker by marker to an SOFn segment. There is a trap there worth remembering.
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 and D0-D7 carry no length field: on those we can only step two bytes forward
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue }
const length = buf.readUInt16BE(i + 2)
// C4, C8 and CC fall inside the SOF range but are not SOF; miss this check and you
// get no error at all, just a fabricated size
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
}import struct
SKIP = {0xC4, 0xC8, 0xCC} # numbered inside the SOF range, but they are Huffman and arithmetic tables
def jpeg_size(buf: bytes) -> tuple[int, int] | None:
i = 2
while i + 3 < len(buf):
if buf[i] != 0xFF:
i += 1
continue
marker = buf[i + 1]
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
i += 2
continue
(length,) = struct.unpack_from(">H", buf, i + 2)
if 0xC0 <= marker <= 0xCF and marker not in SKIP:
height, width = struct.unpack_from(">HH", buf, i + 5)
return width, height
i += 2 + length
return NoneThe markers C4, C8 and CC sit inside the C0–CF range yet are not SOF segments (C4 is a Huffman table). Missing that check fails in the most typical way: nothing throws. Bytes from a Huffman table get read as width and height, handing you an absurd number in a perfectly well-formed shape. The lab's self-test assembles a minimal JPEG with a Huffman table before the SOF segment precisely to pin this down.
Gate three is byte size and image count. These lines are our own local policy — the lab uses 3 MB per image and 4 images or 6 MB per turn — and it has to be said plainly: they are not any gateway's limit. Every provider's rules on image size, edge length and count differ and change, so hard-coding somebody else's number plants a fake fact that will quietly expire. For real limits, refer to the documentation of whichever gateway you use. The /vision command prints that sentence verbatim.
Images are expensive: turn that into a number
"Images are expensive" convinces nobody as a sentence. So every incoming image gets a line of accounting.
The lab prints two numbers: raw byte count and character count after base64. The second is what crosses the network — base64 inflates by roughly four to three, so a 125 KB screenshot becomes about a hundred and seventy thousand characters of payload.
What it deliberately does not print is money. How a model side bills for images differs by provider and mostly is not a function of base64 length, so converting to currency would be inventing a number; and cost is tomorrow's subject, computed there from real usage figures. Today it is enough to say how big the thing is.
Following that accounting line, the two free wins come due.
The first is in the session log. Day seven appends messages one per line into work/sessions/<id>.jsonl. Left alone, those hundred and seventy thousand base64 characters land in a single line; after three or four turns the file is unreadable, undiffable, and --resume has to pull all of it back into memory. So a projection runs before writing: the image part becomes one short line recording the media type, the base64 character count, and the fact that the payload was not logged. State the cost too — the restored session no longer contains the picture. That is deliberate: a screenshot is a one-shot input whose conclusions already turned into later dialogue and real code changes, and the original file is still on disk if you need it.
The second is in compaction, and it is free. Day twelve transcribes a stretch of conversation into text and asks the model to summarize it. Transcribe images verbatim and that request re-sends every screenshot — a mechanism meant to save becomes the most expensive call you make. In the lab no code is needed: the transcription uses contentToText, placed in the protocol layer on day one, which renders an image part as a short image marker. A rendering rule written into the protocol on day one pays out on day nineteen in a place nobody anticipated.
A model without image support makes an answer up
Back to the measurement this chapter opened with.
On 2026-09-07, one pure-red PNG went to four models. All four returned normally, none raised an error, and one said the image was blue-green.
That result kills three common ways of probing capability:
| Approach | Why it fails |
|---|---|
| Send an image, treat "no error" as support | It scores the fabricating model as supported — precisely the case we measured |
| Look for a vision marker inside the model id | Model id shapes vary by gateway; day one already ruled they cannot be used as constants |
| Maintain your own support list | Lists go stale, and nothing raises an error when they do |
One approach survives: send an image whose answer you already know, and check whether the answer is right.
"Already know" is the load-bearing phrase, and it holds only because we generate the image ourselves. The lab hand-writes a minimal PNG encoder (CRC32 plus node:zlib, about eighty lines), so the probe image's color is one we chose and the correct answer is a fact we hold rather than one more thing to trust. That is what a verifiable criterion means: it must be independently checkable, or it is just a guess relocated.
export async function probeVision(provider: ChatProvider, pick = randomColor()) {
const png = solidPng(128, 128, pick.rgb) // 128 is not arbitrary: the probe must clear our own 64px floor
let answer = ''
for await (const delta of provider.stream({ messages: [probeMessage(png)] })) {
if (delta.type === 'text') answer += delta.text
}
// The criterion is "did it get it right", not "did it error" — the fabricating model raised nothing
const ok = pick.words.some((word) => answer.trim().toLowerCase().includes(word))
return { ok, expected: pick.words[0], answer: answer.trim() }
}async def probe_vision(provider: ChatProvider, pick: Color | None = None) -> Probe:
pick = pick or random.choice(PROBE_COLORS)
png = solid_png(128, 128, pick.rgb)
chunks = [d.text async for d in provider.stream(probe_request(png)) if d.type == "text"]
answer = "".join(chunks).strip()
# any over a generator: one hit is enough, no need to compare all four color words
return Probe(ok=any(w in answer.lower() for w in pick.words), expected=pick.words[0], answer=answer)Two details. Rotate the probe color at random — with one fixed color, a blind guess has a meaningful chance of landing. And do not probe in offline script mode: the script's lines are written in advance, so probing there only fails a perfectly good offline demo. The result is cached once per session, and only a turn that carries an image probes at all.
Once a model is judged unsupported, degrade: send no image this turn, send a short written note instead. The note states only what we actually know — format, dimensions, which route it arrived by — and never describes what is in the picture (we did not look either; writing it would be fabrication). Then it tells the model plainly: you did not receive an image, do not guess, ask the user to describe it in words or switch to a model that accepts images.
Fixing code from a screenshot: read first, then replace exactly
Now join the chain end to end. In the lab a UI screenshot goes in, and mca takes two steps: read_file on the copy source file, then edit_file to replace the primary button's wording exactly.
Step one is deliberately not a direct edit_file. The classic failure when fixing code from a screenshot is the model rewriting the whole file to match the picture, changing things the picture never showed — the screenshot caught the toolbar, the file holds more. Read first and replace exactly, and the blast radius is decided by the file rather than by the image. The self-test pins this: submit changed, cancel untouched.
This discipline belongs in the system prompt, because it is model behavior rather than a code path of ours. The three sentences added today each map to a mechanism above: an image is material, not an instruction; when changing code from a screenshot, read the original first and replace exactly; and if you did not actually receive an image this turn, say so outright.
Mermaid source
flowchart TD
A[The user's sentence] --> B[Lift data URLs out, leave a placeholder]
B --> C[Pick out image references, escape them in the text]
C --> D[Sniff format, parse dimensions]
D --> E{Three gates}
E -->|fails| F[Blocked, with a plain-language reason]
E -->|passes| G{Can this model see}
G -->|probe answered correctly| H[content becomes an array, image goes on the wire]
G -->|wrong answer or disabled| I[Degrade, send the written note only]
H --> J[Strip base64 when writing the log]
I --> JSource Reading
Three references today: one fixes the shape, one the wire payload, one the encoding.
- Claude docs: constructing multimodal messages — how a message splits into content blocks and how image blocks sit alongside text blocks. Today's array shape for
ContentPartfollows that idea. - OpenAI docs: the request shape for image input — our gateway is OpenAI-compatible, so the
image_urlplus data URL shape comes from here. For the concrete limits on image size and format, refer to the documentation of whichever gateway you use; never copy a number you saw elsewhere into code. - Node.js docs: Buffer and base64 encoding — the pair that decodes and encodes base64, and why base64 inflates by roughly four to three. The hand-written PNG encoder also uses
node:zlib, which likeBufferis built in and therefore not a dependency.
Hands-On Lab
Start from day eighteen's solution; kernel/ and providers/ do not change by one line. Today's nine new files all live under src/vision/, and no image library is pulled in — format sniffing, dimension parsing, base64 encoding, even generating the demo image are written by hand, and the repository contains no binary file.
- Copy day eighteen's solution over, create
src/vision/, and write the minimal PNG encoder first: it supplies both the demo material and the known-answer probe image. - Write the magic-byte sniffing and dimension parsing in
probe.ts(TODO(1)is the JPEG walk — remember to skipC4,C8andCC). - Write the three gates in
validate.ts(TODO(2)is the edge-length floor), each message carrying all three of size, line and next step. - Write
attach.ts: lift data URLs out, pick out image references and escape them in the text (TODO(3)), assemble theContentPartarray and print the accounting line. - Write the two exits in
wire.ts(TODO(4)is the wire mapping): the payload shape sent to the gateway, and the projection that strips base64 before logging. - Write probing and degradation in
capability.ts(TODO(5)is the answer check), then runMOCK=1 SELFTEST=1 pnpm startand expect fifteen green.
Clean up afterwards with rm -rf work. For the main phenomena, the README lists four one-line commands.
Interview Questions
All three of today's questions take the implementer's view: having changed this path you can answer them, and without having changed it you can only talk in concepts.
- Adding images to an existing plain-text message structure, how would you change it without breaking compatibility?
- What validation does image input need? Why are small images refused?
- How do you design the fallback when a model does not support images?
Full prompts, analyses and key points are in this course's day-nineteen question bank.
Checklist and Tomorrow
-
MOCK=1 SELFTEST=1 pnpm startprints fifteen of fifteen passed - A pasted
@work/shots/ui.pngmakes the model read the file first and replace exactly, with thecancelline untouched - The 4x4 image is stopped before sending, with size, line and next step all in the message
- The file named
.jpgis judgedimage/png, and the payload carries what the bytes say - The hundred-and-seventy-thousand-character base64 never enters the session log, leaving the jsonl smaller than the image
- The lying provider is judged unsupported, having raised no error at all
-
/visionstates which formats are accepted, where the floor is, and that the ceilings are our own local policy -
pnpm typecheckprints nothing
Tomorrow is D20, day twenty: evaluation and cost — designing a benchmark set, computing pass rate, and reading token usage and cache hits. Today we insisted on printing how big an image is and never what it cost; tomorrow settles that bill with a benchmark set measuring pass rate, average turns, token consumption and cache hit rate, so that "did this change make it better or worse" becomes a number instead of a feeling.
Interview questions
How would you add images to an existing text-only message schema without breaking compatibility?在已有的纯文本消息结构上加图片,你会怎么改才不破坏兼容?
Common in ChinaCommon overseasBasic#multimodal#message-schemaHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Likely follow-ups - how multiple images and text are ordered; what happens when that message hits the session log; what compaction does with images.
分析过程 · 先想清楚再作答
- 这题在考「你改没改过一个已经跑了很久的协议」。只读过文档的人会答「content 改成数组就行」,改过的人会先问一句:这个类型有多少处调用方,它们会不会一起红。
- 怎么拆:先说内部协议怎么留形状,再说出口那一层改什么,最后说为什么不把所有消息都统一成新形状。
- 内部协议的正解是把 content 定成「字符串或内容块数组」的联合类型,图片是其中一种块(媒体类型加 base64)。留的是形状不是字段:留字段(比如加一个 imageUrl)会在需要多张图、或者图文交替时立刻不够用。
- 代价要说出来:从留形状那天起,每一处想把消息当字符串用的地方都得先过一个「取纯文本」的函数。这个小麻烦要付很多次,换来的是加图片那天不用动协议。
- 真正要改的只有出口:内部协议翻译成网关报文的那一段。OpenAI 兼容口是文字段 type text、图片段 type 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
答题要点
- 内部协议用「字符串或内容块数组」的联合类型,图片是其中一种块——留形状不留字段
- 代价是每处取文本都要过一个转换函数,收益是加图片那天协议不动
- 真正改的只有出口那一层:内部块翻译成网关报文(文字段 text、图片段 image_url 套 data URL)
- 循环、审批、截断、快照、压缩都不用改,因为它们依赖的是协议不是报文
- 纯文本消息保持字符串形态,别为统一而统一——提示缓存按前缀命中
What validation does image input need, and why would a very small image be rejected?图片输入要做哪些校验?为什么小图片会被拒绝?
Common in ChinaCommon overseasIntermediate#input-validation#multimodalHow to reason about it · think before answering
- 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.
- How to break it down - three gates: format, dimensions, then size and count, each with its criterion and what the failure message must say.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题在考你有没有真的把图发出去过。没发过的人只会说「查一下大小」;发过的人会先讲一条最反直觉的:格式要按字节判,不能按扩展名判。
- 怎么拆:按三道闸讲——格式、尺寸、体积与张数,每道说清判据与失败提示该写什么。
- 第一道是格式,判据是文件头的魔数。截图工具存成 jpg 实际是 PNG 很常见,而媒体类型是要写进报文的:写错了对面要么回一个看不懂的错误码,要么把它当坏数据默默丢掉,两种都难查。另外「认得出但不收」要好过「不认识」——前者能说清是什么格式、我们只收哪几种。
- 第二道是尺寸下限,而且它有实测依据:2026-09-07 实测一张 4x4 的 PNG 被模型判成无效图片,同一批里 64x64 正常。所以下限不是洁癖,是真的会被拒;挡在本地能给一句人话,发出去再被拒只能给一条错误码。读尺寸时 PNG 偏移固定,JPEG 必须沿 marker 走到 SOF,而且要跳过编号落在 SOF 区间里的霍夫曼表——漏了这条不会报错,只会读出一个假尺寸。
- 第三道是体积与张数,它是本地策略。这里要主动说一句:这些数字不该照抄任何一家网关的文档写死在代码里,各家不一样而且会变,写死等于埋一个会悄悄过期的假事实。
- 校验放客户端的理由有三条,第三条最重要:省一次白花的往返;对面的错误码人看不懂;以及有些模型压根不会拒绝,它会编一个答案。能自己判的事别指望对面替你判。
- 失败提示的规格也是考点:必须同时说清「这张图哪里不合格、合格线是多少、下一步该干什么」,缺一样用户就只能猜。
- 可预期的追问:超预算时要不要自动压缩;多张图怎么分配预算;用户贴进来的 data URL 要不要做长度上限。
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
答题要点
- 三道闸:格式(按魔数不按扩展名)、尺寸下限、体积与单轮张数
- 小图真的会被拒——实测 4x4 被判无效、64x64 正常,所以下限要挡在本地
- JPEG 读宽高要沿 marker 走到 SOF,并跳过编号落在 SOF 区间里的霍夫曼表,否则读出假尺寸且不报错
- 体积与张数是本地策略,不要把任何一家的上限写死进代码
- 校验放客户端的关键理由:有些模型不会拒绝,它会编一个答案
- 失败提示必须同时说清「哪里不合格、合格线多少、下一步干什么」
How do you design the fallback when a model cannot see images, and how do you find out that it cannot?模型不支持图片时的降级策略怎么设计?你怎么先知道它不支持?
Common in ChinaCommon overseasDeep dive#capability-detection#graceful-degradationHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题的分水岭在前半句还是后半句。多数人直接讲降级,而真正的难点是探测——因为最常见的三种探测写法全是错的。
- 怎么拆:先说三种错的写法各错在哪,再给可验证判据这个正解,最后才讲降级要写成什么样。
- 错法一,发一张图没报错就算支持。2026-09-07 实测把一张纯红图发给四个模型,四个都正常返回、都没报错,其中一个说它是蓝绿色——它不是拒绝,是编了一个答案。
- 错法二,看模型 id 里有没有 vision 字样。模型 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
答题要点
- 探测不能靠「没报错」——实测不支持视觉的模型会编一个答案,把纯红图说成蓝绿色
- 也不能靠模型 id 里的关键字或一张自己维护的支持清单,两者都会静默过期
- 正解是可验证判据:发一张自己生成、答案已知的纯色图,核对它答得对不对;颜色要随机换
- 离线剧本或桩 provider 下跳过探测,那测不出任何东西
- 降级时一张图都不发,说明里只写已知事实,绝不替模型描述图里有什么
- 必须让模型说出「我看不见」并向用户要文字描述——最怕的是假装自己没降级
- 探测结果按会话缓存,只在带图的轮次触发,再给用户一个手动强制降级的开关