造物手记 VOL.06
SAFELIGHT ON / 暗房作业中 VOL.06 · 2026.10

FIELD NOTES一个 AI 生图工作台的解剖报告

等一张图
显影从渠道体检到队列预算——一条不许跳步的流水线

本篇提要

生图工具都在比谁的模型多。可真用起来,先坏掉的往往不是模型:是那条把「一次点按」翻译成「一次排队、一次降级、一次落盘、一次记账」的流水线。这篇拆一个自己写的生图工作台:它怎么查渠道、怎么排队、怎么在失败时换一条路,以及它凭什么说某句话是作者写的、某句话是它猜的。

48 条已配渠道 30 个 API 路由 56 个测试用例 4 档降级
暗房里的木桌:一盏琥珀色安全灯,一台老式胶片放大机把光柱投在显影盘上方,桌上散落着几张刚显影完成的黑白照片,背景里一台屏幕显示着四格图片网格
SAFELIGHT / 12 ft-candleF8 · 1/125 · ISO 400
FIG. 00 显影不是一瞬间,是一段可以被掐断的过程OIL / DIGITAL / 2026
48条本机配置的模型渠道
4张本机真正生成的作品
30个API 路由(不含静态页)
55/56测试通过(1 条挂着)

2026 年 10 月,我为了给这篇文章配五张插图,同时把三个生成请求发给了一个聚合渠道。两个当场被退回:429 · User concurrency limit reached。四个小时以后,改成一条一条地发,五张图全部出完,单张 75 到 306 秒不等。这条经验听起来像废话,但它正是这个工作台第一版就要解决的问题——生图不是一次请求,是一条会堵、会断、会被人半路掐掉的流水线。

这篇文章解剖的是「画境」,一个自己写的生图工作台。它把渠道管理、提示词润色、参考图与局部重绘、批量矩阵、图库、视频工坊放在同一间屋子里。源码里最长的两个文件是 1,868 行的服务端和 1,128 行的设置页——不优雅,但每一行都对应一次真实的失败。

我刻意保留了它的狼狈:本机配了 48 条渠道,真正生成过的作品只有 4 张;渠道列表长到必须折叠成一行;能力识别靠模型名里的关键词猜;元数据读取要区分「作者写的」和「AI 猜的」。这些不是缺陷清单,这是一个私人工具在真实网络里活下来的形状。

阅读说明:全文约一万二千字,七章。第 1 章给整条流水线的全貌;第 2、3 章是最脏的两块——渠道体检和队列预算;第 4、5 章讲一次生成怎么落盘、失败怎么换路;第 6 章是元数据账本,关于「谁写了这句话」;第 7 章讲怎么证明它没偷偷撒谎。四枚小样全部是确定性模型,参数与判定规则写在正文里,可以自己拖。

PLATE LIST / 工序表07 CHAPTERS · 04 DEMOS

SCENE01

一行提示词背后

用户在创作页按下「生成」,到图库里出现一张能翻页的缩略图,中间隔着十六个动作。模型只负责其中一个。

所有生图产品的演示都长一个样:输入一句话,等一下,一张图从模糊变清晰。这个等的过程被打包成一个进度条,于是没人知道里面发生了什么。可一旦你自己维护这套东西,进度条就会开始骗人——它涨到 90% 停了三十秒,你不知道是在排队、在等上游、在下载,还是上游其实早就返回了、只是你的磁盘写不动。

「画境」的第一版只有一条通路:前端把提示词和一个渠道 ID 发到 POST /api/generations,服务端调一次 /v1/images/generations,把 base64 解出来存成文件,返回。不到两百行。它能用,但只在你恰好有一个健康渠道、一次只点一张、网络不抖的时候能用。

今天这个版本有 30 条 API 路由、11,329 行前后端 JS。多出来的部分几乎全是「不顺利的时候怎么办」。把一次点按摊开,是这样十六个动作:

阶段动作落在哪
入参校验提示词非空;解析这次用哪个渠道、哪个模型、什么尺寸server.js
入参参考图已先一步落盘到 data/references/,这里只取文件名store
记账按 n 拆成 N 条记录,逐条 status:'queued',写 queuedAtstore
排队入队,用 queuedGenerationIds 去重,setImmediate 触发下一次排空drainGenerationQueue
排队检查并发槽:runningGenerations < generationConcurrency 才放行server.js
排队改 running,写 startedAt 与 queueDurationMsstore
调用取渠道、取模型、把负面提示词拼进正文provider-service
调用有参考图走 /images/edits(multipart),没有走 /images/generationsfetchWithTimeout
调用超时、代理、认证头,全部由渠道级配置决定http-transport
解码b64_json 直接解码;url 则二次下载并验 mimeprovider-service
降级无图字段 → 尝试 chat 型生图;上游挂了 → 换一个渠道再试一次generateImage
落盘文件名用 crypto.randomUUID(),先写临时文件再 renameatomicWrite
落盘记录改 completed,写入 media[]、耗时、来源渠道store
收尾预热 640px WebP 缩略图;sharp 不可用就静默降级thumbnail-service
读取前端轮询列表,默认只拿缩略图,点开才取原图gallery
读取搜索、收藏、复制提示词、同款生成、打包下载zip-service
点击 HTTP 状态机 网络 磁盘 读取 创作页 prompt / size / n POST /generations 一条记录 = 一个任务 队列 · 并发槽 queued → running 默认 2,上限 8 渠道 /v1/images b64 或 url 超时 180s · 代理可选 atomicWrite tmp → rename db.json 原子快照 图库 640px 缩略 失败发生在任意一段,都会往回走一层: 渠道 500 → 换一条渠道 渠道 404 / 空 data → 换 chat 型接口 写盘失败 → 任务标 failed,不落半张图 而模型自己,只占「渠道」这一格。 同一套骨架被复用到四处: 文生图 / 参考图编辑(/images/edits) / 局部重绘(带蒙版) / 批量矩阵(N 条记录 × 各自排队) 视频工坊是另一条异步链:提交拿 remoteTaskId → 轮询 → 下载成片,最长等 15 分钟。
FIG. 01一次生成的五段式骨架。模型在第三段;剩下的四段,才是这个项目一万多行代码的去处。

为什么不是一个接口搞定

因为每一段都会独立失败,而失败需要不同的反应。把它们塞进一个函数,你就只能统一抛一个「生成失败」,然后永远不知道是谁的错:是渠道挂了、是 key 过期、是模型名拼错、是上游返回了 SVG、还是磁盘满了。

拆开之后,错误就有了名字。这个项目里定义了几十个错误码,全部带 HTTP 状态:PROVIDER_NOT_FOUND 是 404、API_KEY_REQUIRED 是 400、PROVIDER_AUTH_FAILED 是 401、IMAGE_MIME_INVALID 是 502、TASK_NOT_CANCELLABLE 是 409。前端不需要理解上游的方言,只看这几个码就能给出人话提示。

一个副作用:错误码变成了降级逻辑的开关。第 5 章会讲,哪些码值得换渠道重试、哪些必须立刻停手,全靠这张码表决定。先有名字,才谈得上选择。

状态的三种时态

一条生成记录只有四个状态:queued、running、completed、failed;加上用户的意志,多一个 cancelled。看起来简单,但「取消」在这套流水线里不是一个布尔量,而是三个不同时刻的三件不同的事:

  • 排队时取消:直接出队,任务从未花过上游一分钱。
  • 运行中取消:落 cancelled,但 HTTP 请求不会中断——它继续跑完,回来后结果被丢弃,不入库。上游的钱已经花了。
  • 结果已回但未落盘时取消:同样丢弃,因为检查发生在写盘之前。

三种时态的差别,用户看不到。他们看到的只是「点了取消,图没了」。但账本上必须留下差别,因为第一种是零成本,后两种是已付费。这套系统的所有诚实,都建立在这种「用户看不见、但账上记着」的区分上。

生图工具最容易骗人的地方,是它把「排队」「等待」「失败重试」全画成同一个转圈。而工程的起点,是让这圈转得有名有姓。
SCENE02

渠道是会撒谎的接口

48 条渠道,没有一条是我在这个工作台里手工敲进去的。而它们对自己能干什么的描述,全都靠一个字符串在猜。

先看一个数字:这台机器上的 db.json 里有 48 个渠道,而真正生成过的作品只有 4 张。比例荒唐得像个笑话,但它精确地描述了一个私人工具的真实处境——渠道是收集来的,作品是稀缺的。

这些渠道来自一次扫描。功能叫「站点接入」,实现是三个只读探针:CC Switch 的 SQLite 库、~/.claude/settings.json、~/.codex/config.toml + auth.json。它们把我在别的 agent 工具里配过的 base URL、key 和模型线索全捞出来,去重、脱敏、列成一个可勾选的清单。

读别人的库,先学会不弄坏它

CC Switch 在运行时会不停地写那个库。直接开它,轻则读到半份数据,重则和它抢锁。所以扫描器的第一步是把 .db、-wal、-shm 三个文件一起复制到临时目录,再打开副本——WAL 不能少,少了就是一份少了最近提交的快照。

然后是重试。复制出来的快照偶尔仍然不一致,代码用三次尝试、400ms 和 800ms 的同步等待兜底。同步等待在这里是可耻但正确的:它跑在一个显式的、用户点了按钮才触发的动作里,阻塞两秒比返回一个残缺的渠道列表要好。

src/import-scan.jsreadCcSwitchProviders
// CC Switch 运行中会频繁写库,复制出的快照偶发不一致:重试几次通常可恢复
for (let attempt = 0; attempt < 3; attempt += 1) {
  try {
    readCcSwitchProvidersOnce(dbPath, list, seen);
    return;
  } catch {
    if (attempt === 2) return;
    const wait = 400 * (attempt + 1);
    const until = Date.now() + wait;
    while (Date.now() < until) { /* 同步等待 */ }
  }
}

去重用的不是渠道名,是 sha1(小写 baseUrl + '|' + apiKey) 的前 16 位。同名不同 key 是两条渠道,同 key 不同名是一条——因为名字是人起的,而「同一个地址配同一把钥匙」才是物理意义上的同一个入口。

但 key 不进前端。接口返回前会被 publicProvider() 剥掉 apiKey、proxyUrl、cookie 三个字段,换成三个布尔量和一个掩码:前 3 位 + 四个圆点 + 后 4 位,短于 8 位直接全遮。这条规则的动机很朴素——列表会被截图,会出现在录屏里,会被人发到群里问「这个站有人用过吗」。

体检:一次 15 秒的拷问

渠道配好之后,第一件要做的事是问它「你到底有哪些模型」。这动作叫体检,接口是 POST /api/providers/discover-all。它把所有启用的渠道塞进一个队列,四个 worker 并发处理,每个渠道发一次 GET {base}/models,15 秒超时。

难点在于地址。有的站是 https://x.com,模型在 /v1/models;有的直接给你 https://x.com/v1;有的把生图和对话拆成两条完全不同的路径;还有一些中转站,模型列表挂在 /api/claudecode、/api/anthropic、/coding 这类非标准前缀下。代码里为此维护了一张候选表:先按 URL 的形态推出 /v1/models,再加上兼容后缀反推根路径,逐个试。

src/provider-service.jsCOMPAT_SUFFIXES 与失败策略
const COMPAT_SUFFIXES = [
  '/api/claudecode', '/api/anthropic', '/apps/anthropic', '/api/coding',
  '/claudecode', '/anthropic', '/step_plan', '/coding', '/claude'
];

// 404 / 405 说明这条路没有路由,换下一条;401 / 403 说明钥匙不对,换端点也没用
if ([404, 405].includes(response.status)) {
  lastError = error;
  continue;
}
throw error;

这个区分是整个项目里我最满意的一处小设计:把「找不到路」和「进不去门」当成两件不同的事。找不到路就继续试,进不去门就立刻停。很多自动探测写得又慢又吵,就是因为把两者混为一谈,拿着过期的 key 把八个候选端点挨个撞一遍。

体检的产物是一个 discovery 对象:体检时间、模型总数、生图模型列表(最多留 40 个)、视频模型列表(20 个)、全部模型 ID(120 个)、错误信息、以及一个可选的余额数字。余额靠两次额外请求:/dashboard/billing/subscription 拿硬额度,/dashboard/billing/usage 拿已用,两个 8 秒超时,任一失败就默默不显示。不是所有站都有这两个接口,绝大多数没有。

能力是猜的,而且猜得很粗

这是这一章必须说清楚的地方。模型能不能生图,系统并不知道。它只知道模型名里有没有这些词:

能力命中的关键词(节选)
生图image dall flux stable-diffusion sdxl imagen ideogram kolors qwen-image seedream
视频sora video kling seedance hailuo veo wanx pika runway ltx mochi
视觉vision vl gpt-4o gemini qwen-vl llava
文本gpt claude deepseek llama chat……以及所有什么都没命中的模型

这四条规则有两个必然的误判,而且在这个项目的日常里都以真实案例出现过:

  • 真的能生图、但名字不含关键词的模型会被判成文本。最典型的就是那些把路由交给上游的站,模型名直接叫 auto。按规则它一条都不命中,于是被归进「文本」——它明明是最强的那条生图入口,却永远进不了自动降级的候选池。
  • 名字含关键词、但其实只做对话的模型会被判成生图。反过来同样成立:有人在对话模型名里塞了 image 做营销,体检就会把它列进生图列表,直到第一次真调用才露馅。
所以列表必须可折叠。真实使用里,48 条渠道意味着 76 行、每行带一串模型标签。这个页面最初是整段平铺的,翻三屏都翻不到底;后来改成整段可折叠,只剩一行头,开合状态记在本地。这不是审美问题,是「你会不会真的去用它」的问题。
黑暗空间里悬着几十只发光灯泡,由细线连成网络;多数亮着暖光,少数灰暗熄灭,一颗青绿色光点正沿线路移动
FIG. 02渠道体检的实质:挨个敲门,记下哪些灯亮着。亮着的未必能生图,灭着的也未必真死——只是这十五秒里没应答。

把这件事摊开

下面这枚小样用源码里那四张关键词表做判定,你可以让主力渠道「挂掉」,看巡检会挑谁接班;也可以把某个渠道标成「已锁定」,看它在失败面前只能干等。特别注意那个叫 auto 的渠道——它有生图能力,但一条关键词都不命中。

DEMO 01 渠道体检台 点一行设为主力,再让它挂掉

能力标签由模型名关键词推断:生图 / 视频 / 视觉 / 文本(兜底)。真实系统还会用一次 /models 探活确认,但确认不了能力。

巡检判定
主力—
主力生图模型0
可接班渠道0
巡检间隔10 min
判定依据:候选 = 除主力外、启用中、有 baseUrl、discovery.imageModels.length > 0 的渠道,按生图模型数量降序取第一个;若 roles.image.locked 为真则只记一条事件,不切。
SCENE03

并发是一笔预算

对一个按「每用户并发」限流的渠道,客户端并发不是提速,是自伤。这句话我用一个下午验证了两遍。

第一节里那个 429 · User concurrency limit reached,值得单独讲一段。我当时要五张插图,很自然地并行发了三个——毕竟在自己的机器上,并发是免费的。结果是两个立刻被拒,一个跑了 306 秒才回来。把剩下两张改成一条一条发,全部成功,单张 75 到 83 秒。

这不是巧合。很多聚合渠道按「同一账户同时在途的请求数」限流,常见上限就是 1 到 2。你的并发一旦越过它,多出来的请求不是排队,是直接被拒。于是「并发提速」变成了「并发把成功率打到三分之一,再靠重试把时间补回来」。

排队不是慢。排队是把「一定会被拒的并发」换成「一定会被服务的顺序」。在限流的世界里,唯一的加速是少发请求。

三层闸门

服务端里有三个独立的并发参数,各自服务于不同规模的意图:

闸门默认范围它想保护谁
全局生图21 – 8整机同时打给上游的请求总数
单批批量22 – 6一次批量矩阵内部的同时在途数
视频11 – 4最贵的那条链:最长等 15 分钟

生图默认 2,不是拍脑袋。一方面,2 正好能吃掉「一次生成 4 张、后台慢慢出」的常见节奏;另一方面,2 是小渠道常见限流阈值附近最保守又不至于完全串行的值。视频默认 1,因为一条视频任务可能占着上游十几分钟,并发两条只会让两条都变慢,还会把配额烧光。

批量那层最值得说。代码里有一行注释:「一次批量就是用户明确的并发意图,由本批 concurrency 决定同时在途的任务数」。意思是,批量不复用全局节奏,它有自己的节拍——用户一次排了 12 个变体,他就是在说「我要它们一起出」。为了避免批量把整机打满,排空函数对批任务做了个小动作:每轮只启动一个批内任务,然后立刻退出本轮循环。

src/server.jsdrainGenerationQueue(节选)
while (generationQueue.length) {
  const next = generationQueue[0];
  if (next.batch) {
    const inFlight = batchInFlight.get(next.batch.batchId) || 0;
    if (inFlight >= next.batch.concurrency) break;
  } else if (runningGenerations >= generationConcurrency) {
    break;
  }
  const { id, batch } = generationQueue.shift();
  queuedGenerationIds.delete(id);
  runningGenerations += 1;
  processGeneration(id).finally(() => { /* 释放槽位 → 放行同批下一条 → 再次排空 */ });
  if (batch) break;   // 批任务一轮只放一个
}

这行 if (batch) break 看着像偷懒,实际是让批量的节拍由 batchInFlight 计数决定,而不是被全局循环一口气抽干。批内某个任务完成时,finally 里会把同批还排着的下一条重新入队——于是批量的推进变成「完成一个,放一个」,节奏稳定,也不会因为一次点了 12 张就把整台机器塞死。

时间去哪了

每条记录都记三个时间:queuedAt、startedAt、completedAt,外加一个 queueDurationMs。这不是为了做漂亮的统计图,是为了回答一个用户一定会问的问题:「为什么我点了之后等了两分钟才出图?」

答案分三种,而三者的改法完全不同:

  • 队列时间长:并发太低,或者前面有一个慢任务卡着。改并发或改渠道。
  • 运行时间长:上游慢。换渠道,或者调低该模型的超时。今天那个 306 秒就是这一类。
  • 两者都短但总感觉慢:前端轮询间隔的问题。列表默认靠 TanStack Query 轮询,间隔和缓存策略决定了「已经好了但你还看着旧状态」的那几秒。
一次真实的账:五张图、单条串行、渠道限并发 1。耗时分别是 306 / 74 / 82 / 83 / 75 秒,合计 620 秒,约 10 分 20 秒。如果那天三条并发都被接受,理论总时长会接近最慢那张的 306 秒——但事实是两条被 429 拒掉,重发之后总时长反而更长。在限流面前,「理论并发收益」是个负数。

取消的账

第 1 章说过取消有三种时态。在队列这里,它还有一个更反直觉的性质:取消不会提前释放并发槽。

因为取消的实现是「打个标记」——cancelledGenerationIds.add(id),然后把记录改成 cancelled。真正的 HTTP 请求还在飞,processGeneration 的 Promise 还没 resolve,槽位就还占着。等上游把图吐回来,代码检查标记,丢弃结果,finally 才释放槽位。

为什么不直接 abort?因为对生图接口 abort 并不能让上游停止计费,反而可能让某些中转报出更难看的错误;而且 abort 之后你连「它到底出没出图」都不知道了。留着请求跑完、只丢结果,是更诚实、也更省事的选择。

下面这枚小样把这套调度摊开。拖任务数和并发,看总时长怎么变;点任意一根条可以「取消」,然后注意一件事——总时长一格都不会变。

DEMO 02 队列预算台 点条子可以取消,看总时长变不变
总时长0s
峰值在途0
末位等待0s
被取消0

横轴是时间;每行一个任务。琥珀 = 立刻开始,青 = 排过队,斜纹 = 已取消但结果丢弃。

排空策略队首优先
槽位释放Promise finally
取消打标记,不 abort
耗时模型±40% 抖动
立刻开始 排过队 已取消

耗时抖动用固定种子生成,同一组参数每次结果一致——这是小样,不是压测。

读法:勾上「限 1 并发」后把并发拖到 6,你会看到总时长不动——多出来的并发只会在真实渠道那里变成 429,而这里直接不给你发。取消也一样:标记变了,机器时间没变。
SCENE04

一张图的完整旅程

上游把图给你,只是把负片交到你手上。定影、冲洗、编号、入库——后面的活才决定它能不能被找到。

本机上唯一一张真正生成成功的图,是一张 4 行 6 列的聊天表情包总图。提示词第一句写得很清楚:「生成一张 3840×2160、16:9 的单张完整表情包」。请求里 size 传的是 auto——让上游按模型默认值决定。回来的文件是 1448×1086。

这个差距没有被隐藏。记录里存了真实的宽高和 sha256,所以任何人打开这条记录,都会立刻看到「你要的和你要到的」是两件事。这是这一章想说的事:落盘不是把字节写进磁盘,是把一次不可复现的调用变成一份可以被复查的证据。

上游会以两种方式交图

OpenAI 兼容的生图接口有两种返回形态,这个项目两种都得接:

  • b64_json:图片直接内联在 JSON 里。优点是省一次下载;缺点是响应体可能巨大,而且解码前没有任何机会检查它是什么。
  • url:返回一个临时地址,需要客户端自己下载。优点是响应轻;缺点是那个地址可能在几秒后失效,也可能指向一个你完全不该信任的域名。

两条路都设了硬上限,而且上限不是随手写的:base64 字符串超过 45MB 拒绝,解码后的 buffer 超过 30MB 拒绝。两个数字不同,是因为 base64 有约 4/3 的编码膨胀——一个 30MB 的图,字符串大约 40MB。先卡字符串是为了在解码前就挡住内存炸弹,再卡 buffer 是为了挡住「字符串合法但内容超限」的边界情况。

URL 那条路的检查更密。协议只允许 http/https;mime 必须以 image/ 开头;content-length 超过 30MB 直接拒;而且下载是流式的——一边读一边累加,超过 30MB 立刻中断,不等读完。

为什么偏偏要拒绝 SVG

这一行是整章里最不起眼、也最要紧的一行:

src/provider-service.jsdownloadRemoteImage
const mime = (response.headers.get('content-type') || 'image/png').split(';')[0];
if (!mime.startsWith('image/') || mime === 'image/svg+xml') {
  throw apiError('IMAGE_MIME_INVALID', '上游返回的不是安全位图', 502);
}

SVG 是 XML,XML 可以带 <script>。而这张图接下来会被保存到 data/generated/,再通过同源的 /media/ 路径发回浏览器。如果允许 SVG 进来,一个恶意上游就能借你的域名执行脚本——一个「生图工具」变成了存储型 XSS 的跳板。同源是这里的关键:不是「SVG 危险」,是「我们把它放回了自己的域名下」。

一个例外:本地演示适配器(adapter:'demo')生成的占位图就是 SVG,由服务端自己拼字符串产出,不经过这条外部 mime 检查。区别在于它是自己写的,不是别人给的。「同源」在信任自己产出时是优点,在信任上游时是弱点。

落盘:先写临时文件,再改名

图片文件名是 crypto.randomUUID() 加扩展名。不用标题、不用时间戳、不用提示词——因为那些都可能包含非法字符、超长、或者重名。UUID 的唯一要求是「不重复」,而它做到了。

写入用的是一套两段式:

src/server.jsatomicWrite
async function atomicWrite(file, buffer) {
  const temp = file + '.' + process.pid + '.tmp';
  await fs.promises.writeFile(temp, buffer);
  await fs.promises.rename(temp, file);
}

临时文件名带 pid,是为了两个进程(或两次启动)同时写同一个目标时不互相踩。rename 在同一文件系统内是原子操作,所以对读取方来说,f5b15a20….png 要么不存在,要么是完整的——永远不会出现「文件存在但只有一半」的中间态。这对一个会被浏览器直接 <img> 拉取的目录来说,是底线。

元数据走的是另一套原子性:整个 db.json 被序列化成快照,写进临时文件,再 rename。所有写入串成一条 Promise 链,链头上挂一个 .catch(() => {})——这样某一次写失败不会毒化整条链,后面的写入还能继续。

src/store.js#persist
async #persist() {
  const snapshot = JSON.stringify(this.db, null, 2);
  this.writeQueue = this.writeQueue.catch(() => {}).then(async () => {
    const temp = `${config.dbFile}.${process.pid}.tmp`;
    await fs.promises.writeFile(temp, snapshot, 'utf8');
    await fs.promises.rename(temp, config.dbFile);
  });
  return this.writeQueue;
}

读的时候也不能客气。启动时会校验根节点是对象、五个集合字段是数组、settings 是对象;任何一条不满足,就不启动,并抛出一个明确说「原文件已保留,请修复或从备份恢复」的错误。宁可开不了机,也不要带着一份被误解的数据跑起来——那会让后面每一条记录都变成嫌疑犯。

缩略图:一个可以失败的优化

图库列表不加载原图。每张图在落盘后会预热一张 640px 宽的 WebP,质量 72,effort 3;启动时回填历史图片,请求时按需补齐。列表接口只返回缩略图路径,点开大图才取原图。

但 sharp 是可选依赖。它在某些平台缺预编译包,加载会失败。这套代码的处理是:import('sharp').then(m => m.default).catch(() => null),拿不到就返回空字符串,所有接口静默降级为原图。同理,缩略图生成过程中的任何异常都被吞掉,返回空串。

这不是「错误处理写得马虎」,是一个刻意的分级:缩略图是性能优化,不是功能。它失败时,用户应该看到慢一点的图库,而不是一个报错页面。区分「可以失败的优化」和「不可以失败的功能」,比给每个 catch 都加日志更重要。

还有一个小细节:同一张图的缩略图生成请求会被 inFlight 这张 Map 合并。列表一次请求 20 条、其中 5 条都缺缩略图时,sharp 只会被调用 5 次,不是 20 次。并发去重在这里比任何缓存都值钱,因为生成只需要几十毫秒,重复生成却会同时占满 CPU。

暗房显影盘特写:一张相纸浸在显影液里,左半已经浮出城市与河流的影像,右半仍是空白,液面有琥珀色安全灯的反光
FIG. 03显影的中间态是危险的:一半有影、一半空白。落盘的原子性就是为了永远不把这种半成品交出去。

把「你要的」和「你要到的」记在一起

回到那张表情包。记录里同时躺着两组数字:请求的 size: "auto",和实际的 width: 1448, height: 1086。前者是意图,后者是事实。这个项目没有试图让两者一致——它只是把两者都存了下来,然后让 UI 在需要的时候显示事实。

GENERATION626a361b
PROMPT生成一张 3840×2160、16:9 的单张完整表情包总图:4行×6列共24格……
SIZE 请求auto
实际像素1448 × 1086
文件f5b15a20….png · 1,816,647 B
SHA-2563ecb5f1b…c48bb5
耗时排队 + 生成,分两段记

这张记录不是装饰。它是这个工具敢不敢说「我生成过」的唯一凭据:文件在不在、多大、什么哈希、多少像素、花了多久、走的哪条渠道。缺任何一项,「生成成功」都只是一句自述。

SCENE05

打不过就换一条路

降级不是「失败了再试一次」。降级是一张表格:哪种错值得换路,哪种错换了也白换。

这一章是全篇最像代码的一章,因为它讲的就是一张 if 表。这张表决定了:当主力渠道吐出一个非 200,系统是原地不动、换接口格式、还是换一条渠道。错的表不会报错,只会静默地把任务拖死。

先看两张表。第一张是「不换渠道」的白名单,命中就立刻抛:

src/provider-service.jsgenerateImage · 不降级名单
// 渲染失败/参数错误不降级;网络与上游错误时自动切到下一个含生图模型的渠道重试一次
if (!failover || [
  'PROVIDER_REQUIRED', 'MODEL_REQUIRED', 'PROVIDER_NOT_CONFIGURED',
  'IMAGE_TOO_LARGE', 'IMAGE_RESPONSE_UNSUPPORTED', 'IMAGE_MIME_INVALID',
  'IMAGE_URL_INVALID', 'PROVIDER_AUTH_FAILED'
].includes(error?.code)) throw error;

理由是分类:前三个是「你根本没配好」,换渠道也没用;中间四个是「拿到的东西不能用」,换渠道可能有救、但更可能只是把同一个问题再演一遍;最后一个是「钥匙不对」,换门也没用。

第二张表是「先试 chat 型生图」的条件:

src/provider-service.jsgenerateImageOnce · chat 降级
if (!response.ok) {
  const upstream = await upstreamError(response, 'IMAGE_GENERATION_FAILED', '图片生成失败');
  // 鉴权错误换端点也没用,直接抛;其余错误(404/500 端点无路由、渠道缺失等)降级到 chat 型生图
  if ([401, 402, 403].includes(response.status)) throw upstream;
  if (!mask) {
    try {
      const chatResult = await generateImageViaChat({ /* … */ });
      if (chatResult) return chatResult;
    } catch {
      // 降级失败时以原始 /images 错误为准,避免掩盖真实原因
    }
  }
  throw upstream;
}

这段代码值得逐句读。注意三件事:

  1. 有蒙版就不试 chat。局部重绘传的是一个 PNG 蒙版,chat 接口没有地方放它。与其发一个没有蒙版的请求得到一张完全不对的图,不如老实失败。
  2. chat 失败时抛的是原始错误。不是 chat 的错误。因为用户关心的是「为什么我的 /images 调用没成」,而不是「备用方案也没成」——后者只是信息,前者才是原因。
  3. 这段注释和实际行为有一处偏差。注释说鉴权错误「直接抛」,但抛出去的是一个 IMAGE_GENERATION_FAILED,它并不在上一张白名单里。所以 401 依然会被外层接住、换一个渠道再试。真正不做的是 chat 降级。读代码时,注释描述意图,错误码决定行为;两者不一致时,以错误码为准。

为什么需要一个 chat 降级

因为有一类中转站,只把生图模型挂在 /chat/completions 上。/v1/images/generations 要么 404,要么返回 200 但 data 是空的。这类站的前端就是把图当消息发出来的,所以图片藏在消息里——可能是 message.images[].image_url.url,可能是 Markdown 里的 ![](data:image/png;base64,…),可能是一段裸的 https://…png。

解析器四种都认,按顺序试:结构化字段 → Markdown 图片 → 内联 data URI → 裸 URL。取到之后仍然走同一套 mime 与大小检查。降级的代价是丢参数:seed 和 guidance_scale 没法经 chat 传递,尺寸只能作为一句话附加在提示词后面(「图片输出尺寸约 1024×1024」)。这是有损降级,代码里也写明了「尽力而为」。

一张表,七种结局

把两张表合起来,就得到一个有点残酷的矩阵。假设每次都备了另一条健康渠道:

上游返回试 chat?抛出的码换渠道?用户看到
401 / 402 / 403否IMAGE_GENERATION_FAILED会可能被备用渠道救回
404 / 405是(无蒙版)IMAGE_GENERATION_FAILED会可能先被 chat 救、再被渠道救
429 / 500是(无蒙版)IMAGE_GENERATION_FAILED会同上
200 但无图是(无蒙版)IMAGE_RESPONSE_UNSUPPORTED不会直接失败,白名单拦住
200,图 > 30MB—IMAGE_TOO_LARGE不会直接失败,尺寸是硬约束

最后一行是这张表里唯一一个反直觉的洞:「返回 200 但没有图片」不会触发换渠道。因为它被判成 IMAGE_RESPONSE_UNSUPPORTED——「你选的这个模型不支持这种返回」,属于配置问题,不属于网络问题。可现实中它更常见的原因是「这条渠道今天就是不给你图」。这个判定的代价是:那条渠道会一直占着主力位置,而每一次生成都失败在同一行代码上。

降级的价值不在「多试一次」,而在把错误分对类。分错的后果不是崩,是稳定地错——每一次都失败得很干净,让你以为这就是世界的极限。

十分钟一次的巡检

上面那条路是「请求内的降级」。系统还有一条「请求外的」:定时巡检。

每 10 分钟(第一次在启动后 30 秒),服务端会把 roles 里登记过的每个角色拿出来,向对应渠道发一次 GET {base}/models,8 秒超时。如果生图主力检查失败,且没有被锁定,就从历史体检结果里挑「生图模型最多」的健康渠道接管,模型取它的第一个生图模型,写回 roles,并把这条切换记进事件列表(最多留 20 条)。

这套逻辑保守得近乎笨拙:只看 /models 通不通,只看模型数量最多,不看延迟、不看成功率、不看它到底能不能出图。但它有一个巨大的好处——无状态、可解释、失败时不会做出更坏的猜测。在私人工具里,「可解释的笨办法」永远优于「不可解释的聪明办法」。

它抓不到什么:今天我撞的那个 429,巡检一辈子都发现不了。/models 返回 200,清单一切正常,只是「你同时发三个我就不干活」。这类「活着但拒绝服务」的渠道,只能靠请求内的 429 处理,或者靠人去把并发调成 1。巡检是探活,不是探服务质量。
深色木桌上摆着一台老式收音机,调谐指针停在刻度上,背景虚化处有夜景城市与一台发着青光的电脑屏幕
FIG. 04巡检像调台:你只能确认这个频率有声音,不能确认它今天播的是不是你想听的节目。

自己走一遍这张表

下面的小样把两条降级链摊开:内层的 chat 降级、外层的换渠道。切换返回码、开关蒙版与备用渠道,看每一步的判决。特别建议看一眼「200 但无图」那一格——那是这张表里唯一的死路。

DEMO 03 降级链 换一个返回码,看这条路走不走得通
依据:generateImageOnce 内层判 401/402/403 跳过 chat;!mask 才试 chat;外层 generateImage 用错误码白名单决定是否换渠道。备用渠道取「生图模型数最多」的那一条,只重试一次。
SCENE06

谁写了这句话

一张图旁边的那段提示词,可能是作者写的,可能是它的工具写的,也可能是你的视觉模型看了一眼之后猜的。这三件事不能共用一个字段。

图库里最常见的一个动作是「复制提示词」。这个动作隐含一个假设:提示词是作者写的。可现实里它有三个来源——生成工具写进 PNG 的参数块、作者手填的说明、以及模型看图反推出来的猜测。三者的可信度差着一个数量级,混在一起,就是这套系统开始撒谎的地方。

PNG 里藏着一个时代

PNG 允许携带若干文本块:tEXt(不压缩)、zTXt(zlib 压缩)、iTXt(国际化,可指定语言与翻译)。Stable Diffusion 生态把这套机制用成了一种约定俗成的元数据格式,于是今天一张 AI 图里可能同时躺着三套互不兼容的方言。

读取器是手写的,不依赖任何图像库:从 PNG 签名开始,逐块读长度与类型,遇到 tEXt 取 key\0value,遇到 zTXt 用 zlib 解压。两个上限:单个 chunk 超过 16MB 停止,所有文本累计超过 2MB 停止。解压还带了 maxOutputLength——这是防 zip bomb 的,一个 1KB 的压缩块可以膨胀成 1GB,必须在解压时就掐死,而不是解完再检查。

src/image-metadata.jsreadPngText(节选)
if (type === 'zTXt') {
  const zero = data.indexOf(0);
  if (zero > 0 && data[zero + 1] === 0) {
    const expanded = zlib.inflateSync(data.subarray(zero + 2), {
      maxOutputLength: maxTextBytes - textBytes
    });
    textBytes += expanded.length;
    if (textBytes > maxTextBytes) break;
    result[data.toString('latin1', 0, zero)] = expanded.toString('utf8');
  }
}

三种方言,一个出口

三套格式的解析难度差很远:

  • A1111:纯文本,三段结构——正向提示词、Negative prompt: 之后是负向、最后一行 Steps: 28, Sampler: Euler a, CFG scale: 7, Seed: 12345, Size: 1024x1024。用两个正则定出边界,再把参数行按逗号切成键值对。这是最容易、也最常见的一种。
  • ComfyUI:prompt 字段是一整张节点图,JSON。要从中找出 KSampler 类节点,顺着它的 positive/negative 输入追溯上游,才能拿到那两段文本。它记录的不是「提示词」,是「这张图怎么被算出来的」。
  • InvokeAI:sd-metadata / invokeai_metadata 里是结构化 JSON,字段名与 A1111 不同,正负向和参数都在不同键下。

三个解析器最后都收敛到同一个出口:finalizePromptResult(),产出 exact: true, inferred: false, source: 'AUTOMATIC1111 PNG Info' | 'ComfyUI workflow' | 'InvokeAI metadata'。exact 的意思是「这段文字是从文件里读出来的,不是想出来的」,不是「这段文字一定生成了这张图」。

这个区别很重要。PNG 里的参数块是自述。一台生成工具写什么,文件里就是什么,没有任何机制核对它真的用那段提示词算出了这张图。所以「exact」只保证来源,不保证因果。

读不到的时候,说读不到

没有元数据的图占多数。微信、截图、二次压缩、格式转换,任何一步都能把文本块抹掉。这时候系统有两条路:

  1. 有视觉模型:把图发给它,要求返回 JSON(prompt / negativePrompt / keywords / style),并明确要求「不要声称这是作者原始提示词」。结果标为 exact: false, inferred: true,界面上写「AI 推测,不是作者原始提示词」。
  2. 没有视觉模型:usable: false,附一段人话说明「当前服务无法调用视觉模型反推;请切换到真实 Provider,或上传带 A1111 / ComfyUI / InvokeAI 参数的 PNG」。不编,不空着,也不假装成功。

我更愿意把这两条路看成同一件事的两个档位:系统可以有不知道的东西,但不能有不知道却不说的地方。一个空的 prompt 字段和一个标着「推测」的 prompt 字段,对用户的含义完全不同;前者让人以为坏了,后者让人知道该信几分。

润色:一个必须返回 JSON 的模型

提示词润色走的是文本模型。system 提示里把要求写死:保留主体与意图,补充构图、环境、光线、镜头、材质和色彩;不要添加冲突主体;返回且只返回 JSON,字段为 polishedPrompt、negativePrompt、keywords;并且特别强调 polishedPrompt 必须是「一段完整、独立、可直接提交给图像生成接口的正向提示词」,不能只给标题或关键词摘要。

最后那句是为了治一个很常见的病:模型被要求「润色」时,很喜欢返回一句总结式的标题,或者一串关键词,看着漂亮,丢进生图接口就废了。

解析端做了三层容错:

src/provider-service.jsparseJsonObject
function parseJsonObject(value) {
  const text = String(value || '').trim()
    .replace(/^\x60\x60\x60(?:json)?/i, '')   // 去掉开头的 ``` 或 ```json
    .replace(/\x60\x60\x60$/, '')                // 去掉结尾的 ```
    .trim();
  try { return JSON.parse(text); } catch {}
  const start = text.indexOf('{');
  const end = text.lastIndexOf('}');
  if (start >= 0 && end > start) {
    try { return JSON.parse(text.slice(start, end + 1)); } catch {}
  }
  return null;
}

第一层剥 Markdown 围栏,第二层从第一个 { 截到最后一个 }——很多模型会在 JSON 前后加一句「好的,以下是润色结果:」。第三层是字段名归一化:polishedPrompt、polished_prompt、positivePrompt、prompt、enhancedPrompt 都认,还会往 data/result/output/content 里再找一层。

全都失败呢?退回本地规则:把原提示词压掉多余空白,接上该风格的一串短语,再给一份固定负向词表。返回值里 source 从 remote-text 变成 local-rules 或 remote-text-fallback。界面上不隐瞒这个差别——你一眼就能看出这段「润色」是模型写的,还是模板拼的。

俯拍暗色桌面:左侧一叠散乱的老照片,右侧四张空白卡片整齐排成一列,一把镊子正夹着其中一张照片移向卡片
FIG. 05整理照片的动作,和给记录填来源的动作是同一件事:先分清哪些是自己拍的,哪些是别人给的。

自己翻一遍账本

下面这枚小样有两个模式。左边选「读图」,切五种来源,看 exact / inferred / usable 三个开关怎么翻;左边选「润色」,切四种模型返回形态,看解析器在哪一层接住它、接不住时退回哪里。

DEMO 04 元数据账本 谁写的,写在哪一栏

RECORD
依据:解析顺序为 A1111 → ComfyUI → InvokeAI → 通用描述字段;都失败才走视觉反推。润色解析为剥围栏 → 花括号截取 → 字段归一化 → 本地规则兜底。
SCENE07

怎么证明它没撒谎

56 个测试、30 条 API 路由、一份会拒绝启动的数据校验。以及一张写清楚了「证明不了什么」的清单。

生图工具的评价标准通常是「出图好不好看」。这个标准没法测,也不该由工具自己测。能测的是另一批东西:取消有没有真的取消、写盘会不会留半张图、错误码分得对不对、key 会不会漏到前端、元数据的来源标得准不准。

56 个测试,55 个是绿的

测试用 Node 自带的 node --test,16 个文件 56 个用例,零测试框架依赖。跑一遍 npm test,结果是 55 过、1 挂。先看分布,再说那一条红的。

文件它在防什么
http-transport.test.js代理选择、重定向跟随与跨源剥离敏感头、超时
image-metadata.test.js三套方言的解析、压缩块膨胀上限
store.test.js损坏的 db.json 必须拒绝启动、原子写不产生半份文件
task-cancel.test.js取消的三种时态、已结束任务不可取消(409)
batch.test.js批内并发计数、批完成后放行下一条
video-flow.test.js远端任务 ID 的多种字段、状态归一化、续接轮询
site-relay.test.jsSSE 帧解析、从事件里捞出图片地址
zip-service.test.js自写 ZIP 的 CRC32 与目录结构
provider-service.test.js端点候选推导、能力推断、错误分类
vision-image.test.js / clipboard-image.test.js图片压缩与剪贴板粘贴的边界

看名字就知道,没有一个在测「图好不好看」。它们在测的都是会静默出错的地方:一个漏掉的 await、一次没剥干净的重定向头、一个膨胀到 2GB 的解压块、一次「已经取消了但结果还是入库了」。

跑法就一条:npm run verify,先 eslint 再 node --test。两个都过,才算能交付。

那一条红的

2026 年 10 月 22 日在这台机器上跑了一遍,输出是这个样子的:

npm test2026-10-22
# tests 56
# pass 55
# fail 1

not ok 34 - demo API covers providers, polish, reference upload and generation
  error: Expected values to be strictly equal:
  3 !== 1

挂在 server.test.js——那条唯一会真的 spawn 一个服务进程、再用假上游走完「建渠道 → 取模型 → 润色 → 传参考图 → 生成 → 等完成 → 局部重绘 → 导出」全链路的端到端测试。一条计数断言期望 1,实际回来 3。

这篇里我没有修它。不是忘了,是它正好落在这一章的论点上:这套账本之所以可信,不在于它全绿,而在于它把没通过的那一项写在同一个输出里,而不是藏起来。一个把 56 报成「全部通过」、或者悄悄跳过这一条的项目,看起来更干净,但你就没法知道该信它哪一句话。

这也解释了正文里每个数字为什么都带日期。渠道数会变、生成记录会变、测试会由红转绿——写死一句「56 个测试通过」而不说哪一天,等于在补一份自己都复查不了的记录。

那些没法写成测试的,就写成文字

有些事测试测不了,只能用一份清单把它固定下来,让每次改动都被迫面对。这个项目的清单是这样的:

它能证明它证明不了
文件存在,sha256 一致,像素尺寸与记录相符这段提示词真的生成了这张图
这次调用走的是哪条渠道、哪个模型那条渠道明天还活着
排队用了多久、生成用了多久模型会照提示词执行——尺寸就是反例
元数据来自哪个解析器(A1111 / ComfyUI / InvokeAI)那个参数块描述的就是这张图
取消发生在排队、运行、还是落盘前上游为这次取消退过款
key 没有出现在任何接口响应里key 没有出现在你的截图里

右边这一列不是「以后要修的 bug」,是这个工具的能力边界。写下来是为了防止一件更坏的事:把「能证明」的那一列,当成「全部」。一个工具一旦相信自己的账本是完整的,它就会开始替不存在的证据说话。

最诚实的一次统计

回到开头那组数字。48 条渠道,4 条生成记录,其中 3 条失败。唯一的成功是一条表情包,提示词要 3840×2160,实际给了 1448×1086。

把这四个数字放在一起,得到的是一个不好看但准确的画像:这是一个渠道收集量远超产出量的私人工具,成功率 25%,而且它连自己最朴素的诉求(尺寸)都没能满足。

可它同时也做到了另一些事:那 3 次失败各自留下了错误码与阶段;成功的那 1 次留下了哈希、像素、耗时与渠道;今天我用它背后的同一类渠道生成了这篇文章的五张插图,被 429 拒了两次之后,它教会我的第一件事就是别并发。

「能证明」和「好看」是两套完全不同的标准。一个工具选前者,不是因为它更高级,是因为前者可以被复查。

还没解决的四个死角

这一节按惯例留给没做完的事。它们不是路线图,是四个我知道在哪、但还没动的地方:

  1. 单用户假设。没有登录、没有用户隔离、data/ 目录就是全部权限边界。局域网里别人能打开这个端口,就能看到你的全部图与全部渠道。
  2. 私网访问默认开着。ALLOW_PRIVATE_NETWORK 默认为真,因为这是本机工具、要连本机的 ComfyUI。一旦部署到公网,这个默认值就是一个 SSRF 面,必须改。
  3. JSON 库不是数据库。所有记录在一个 db.json 里,每次写入全量序列化。图到几百张、记录到几千条时会开始变慢,而且没有事务、没有并发控制,只有一条 Promise 链和原子 rename 撑着的「单进程正确」。
  4. 渠道能力永远靠猜。模型名关键词表是硬编码的,新模型出来就得手工补。想真正解决,只能让渠道自己声明能力——而这需要上游配合,不是一个客户端能决定的。

结语:把剪刀停在半空

写完这七章,我发现「画境」真正在做的不是生图。生图那一步只有百来行,调一个接口,解一个 base64。剩下的九千多行全部在处理「万一不是这样」:万一渠道挂了、万一返回的不是图、万一写到一半断电、万一这个提示词其实是猜的、万一用户在半路改了主意。

这些「万一」在演示里一个都看不到。演示总是顺利的:一条好渠道,一句好提示词,一次成功的生成。而一个要天天用的工具,长的是另一副样子——它的全部工程量,都长在那些用户看不见、但一定会遇到的岔路上。

准则一

把「来源」当成一等字段。元数据是读的、猜的、还是模板拼的,必须记在记录里,而不是记在开发者的脑子里。exact 与 inferred 之间那道线,是这套系统所有诚实的地基。

准则二

并发是预算,不是免费的勇气。一个按用户限流的渠道面前,多发请求等于降低成功率。默认并发 2、视频 1、批量自己一套节拍——这些数字不是为了保守好看,是拿 429 换来的。

准则三

给每个失败起一个名字,然后用名字决定动作。换渠道、换接口、还是立刻停手,全看错误码而不看心情。名字起得越准,降级表就越短,人也就越少在深夜猜「到底哪坏了」。

留下的问题。这一篇没有解决「怎么判断一张图好不好」——它甚至没打算碰。它只处理了另一件事:怎么保证一张图、连同它周边的一切说法,都能被复查。前者是审美,后者是工程;审美没有账本,工程必须有。

五张插图来自一个聚合渠道,用的是这篇要讲的那套接口。它们被生成的那天,那个渠道正按每用户 1 并发限流——所以这篇文章的第一个数据点,就是它自己的绊倒。