一行提示词背后
用户在创作页按下「生成」,到图库里出现一张能翻页的缩略图,中间隔着十六个动作。模型只负责其中一个。
所有生图产品的演示都长一个样:输入一句话,等一下,一张图从模糊变清晰。这个等的过程被打包成一个进度条,于是没人知道里面发生了什么。可一旦你自己维护这套东西,进度条就会开始骗人——它涨到 90% 停了三十秒,你不知道是在排队、在等上游、在下载,还是上游其实早就返回了、只是你的磁盘写不动。
「画境」的第一版只有一条通路:前端把提示词和一个渠道 ID 发到 POST /api/generations,服务端调一次 /v1/images/generations,把 base64 解出来存成文件,返回。不到两百行。它能用,但只在你恰好有一个健康渠道、一次只点一张、网络不抖的时候能用。
今天这个版本有 30 条 API 路由、11,329 行前后端 JS。多出来的部分几乎全是「不顺利的时候怎么办」。把一次点按摊开,是这样十六个动作:
| 阶段 | 动作 | 落在哪 |
|---|---|---|
| 入参 | 校验提示词非空;解析这次用哪个渠道、哪个模型、什么尺寸 | server.js |
| 入参 | 参考图已先一步落盘到 data/references/,这里只取文件名 | store |
| 记账 | 按 n 拆成 N 条记录,逐条 status:'queued',写 queuedAt | store |
| 排队 | 入队,用 queuedGenerationIds 去重,setImmediate 触发下一次排空 | drainGenerationQueue |
| 排队 | 检查并发槽:runningGenerations < generationConcurrency 才放行 | server.js |
| 排队 | 改 running,写 startedAt 与 queueDurationMs | store |
| 调用 | 取渠道、取模型、把负面提示词拼进正文 | provider-service |
| 调用 | 有参考图走 /images/edits(multipart),没有走 /images/generations | fetchWithTimeout |
| 调用 | 超时、代理、认证头,全部由渠道级配置决定 | http-transport |
| 解码 | b64_json 直接解码;url 则二次下载并验 mime | provider-service |
| 降级 | 无图字段 → 尝试 chat 型生图;上游挂了 → 换一个渠道再试一次 | generateImage |
| 落盘 | 文件名用 crypto.randomUUID(),先写临时文件再 rename | atomicWrite |
| 落盘 | 记录改 completed,写入 media[]、耗时、来源渠道 | store |
| 收尾 | 预热 640px WebP 缩略图;sharp 不可用就静默降级 | thumbnail-service |
| 读取 | 前端轮询列表,默认只拿缩略图,点开才取原图 | gallery |
| 读取 | 搜索、收藏、复制提示词、同款生成、打包下载 | zip-service |
为什么不是一个接口搞定
因为每一段都会独立失败,而失败需要不同的反应。把它们塞进一个函数,你就只能统一抛一个「生成失败」,然后永远不知道是谁的错:是渠道挂了、是 key 过期、是模型名拼错、是上游返回了 SVG、还是磁盘满了。
拆开之后,错误就有了名字。这个项目里定义了几十个错误码,全部带 HTTP 状态:PROVIDER_NOT_FOUND 是 404、API_KEY_REQUIRED 是 400、PROVIDER_AUTH_FAILED 是 401、IMAGE_MIME_INVALID 是 502、TASK_NOT_CANCELLABLE 是 409。前端不需要理解上游的方言,只看这几个码就能给出人话提示。
状态的三种时态
一条生成记录只有四个状态:queued、running、completed、failed;加上用户的意志,多一个 cancelled。看起来简单,但「取消」在这套流水线里不是一个布尔量,而是三个不同时刻的三件不同的事:
- 排队时取消:直接出队,任务从未花过上游一分钱。
- 运行中取消:落
cancelled,但 HTTP 请求不会中断——它继续跑完,回来后结果被丢弃,不入库。上游的钱已经花了。 - 结果已回但未落盘时取消:同样丢弃,因为检查发生在写盘之前。
三种时态的差别,用户看不到。他们看到的只是「点了取消,图没了」。但账本上必须留下差别,因为第一种是零成本,后两种是已付费。这套系统的所有诚实,都建立在这种「用户看不见、但账上记着」的区分上。
渠道是会撒谎的接口
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 的同步等待兜底。同步等待在这里是可耻但正确的:它跑在一个显式的、用户点了按钮才触发的动作里,阻塞两秒比返回一个残缺的渠道列表要好。
// 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,再加上兼容后缀反推根路径,逐个试。
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做营销,体检就会把它列进生图列表,直到第一次真调用才露馅。

把这件事摊开
下面这枚小样用源码里那四张关键词表做判定,你可以让主力渠道「挂掉」,看巡检会挑谁接班;也可以把某个渠道标成「已锁定」,看它在失败面前只能干等。特别注意那个叫 auto 的渠道——它有生图能力,但一条关键词都不命中。
能力标签由模型名关键词推断:生图 / 视频 / 视觉 / 文本(兜底)。真实系统还会用一次 /models 探活确认,但确认不了能力。
巡检判定
discovery.imageModels.length > 0 的渠道,按生图模型数量降序取第一个;若 roles.image.locked 为真则只记一条事件,不切。并发是一笔预算
对一个按「每用户并发」限流的渠道,客户端并发不是提速,是自伤。这句话我用一个下午验证了两遍。
第一节里那个 429 · User concurrency limit reached,值得单独讲一段。我当时要五张插图,很自然地并行发了三个——毕竟在自己的机器上,并发是免费的。结果是两个立刻被拒,一个跑了 306 秒才回来。把剩下两张改成一条一条发,全部成功,单张 75 到 83 秒。
这不是巧合。很多聚合渠道按「同一账户同时在途的请求数」限流,常见上限就是 1 到 2。你的并发一旦越过它,多出来的请求不是排队,是直接被拒。于是「并发提速」变成了「并发把成功率打到三分之一,再靠重试把时间补回来」。
三层闸门
服务端里有三个独立的并发参数,各自服务于不同规模的意图:
| 闸门 | 默认 | 范围 | 它想保护谁 |
|---|---|---|---|
| 全局生图 | 2 | 1 – 8 | 整机同时打给上游的请求总数 |
| 单批批量 | 2 | 2 – 6 | 一次批量矩阵内部的同时在途数 |
| 视频 | 1 | 1 – 4 | 最贵的那条链:最长等 15 分钟 |
生图默认 2,不是拍脑袋。一方面,2 正好能吃掉「一次生成 4 张、后台慢慢出」的常见节奏;另一方面,2 是小渠道常见限流阈值附近最保守又不至于完全串行的值。视频默认 1,因为一条视频任务可能占着上游十几分钟,并发两条只会让两条都变慢,还会把配额烧光。
批量那层最值得说。代码里有一行注释:「一次批量就是用户明确的并发意图,由本批 concurrency 决定同时在途的任务数」。意思是,批量不复用全局节奏,它有自己的节拍——用户一次排了 12 个变体,他就是在说「我要它们一起出」。为了避免批量把整机打满,排空函数对批任务做了个小动作:每轮只启动一个批内任务,然后立刻退出本轮循环。
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 章说过取消有三种时态。在队列这里,它还有一个更反直觉的性质:取消不会提前释放并发槽。
因为取消的实现是「打个标记」——cancelledGenerationIds.add(id),然后把记录改成 cancelled。真正的 HTTP 请求还在飞,processGeneration 的 Promise 还没 resolve,槽位就还占着。等上游把图吐回来,代码检查标记,丢弃结果,finally 才释放槽位。
为什么不直接 abort?因为对生图接口 abort 并不能让上游停止计费,反而可能让某些中转报出更难看的错误;而且 abort 之后你连「它到底出没出图」都不知道了。留着请求跑完、只丢结果,是更诚实、也更省事的选择。
下面这枚小样把这套调度摊开。拖任务数和并发,看总时长怎么变;点任意一根条可以「取消」,然后注意一件事——总时长一格都不会变。
横轴是时间;每行一个任务。琥珀 = 立刻开始,青 = 排过队,斜纹 = 已取消但结果丢弃。
耗时抖动用固定种子生成,同一组参数每次结果一致——这是小样,不是压测。
一张图的完整旅程
上游把图给你,只是把负片交到你手上。定影、冲洗、编号、入库——后面的活才决定它能不能被找到。
本机上唯一一张真正生成成功的图,是一张 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
这一行是整章里最不起眼、也最要紧的一行:
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 的唯一要求是「不重复」,而它做到了。
写入用的是一套两段式:
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(() => {})——这样某一次写失败不会毒化整条链,后面的写入还能继续。
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。

把「你要的」和「你要到的」记在一起
回到那张表情包。记录里同时躺着两组数字:请求的 size: "auto",和实际的 width: 1448, height: 1086。前者是意图,后者是事实。这个项目没有试图让两者一致——它只是把两者都存了下来,然后让 UI 在需要的时候显示事实。
这张记录不是装饰。它是这个工具敢不敢说「我生成过」的唯一凭据:文件在不在、多大、什么哈希、多少像素、花了多久、走的哪条渠道。缺任何一项,「生成成功」都只是一句自述。
打不过就换一条路
降级不是「失败了再试一次」。降级是一张表格:哪种错值得换路,哪种错换了也白换。
这一章是全篇最像代码的一章,因为它讲的就是一张 if 表。这张表决定了:当主力渠道吐出一个非 200,系统是原地不动、换接口格式、还是换一条渠道。错的表不会报错,只会静默地把任务拖死。
先看两张表。第一张是「不换渠道」的白名单,命中就立刻抛:
// 渲染失败/参数错误不降级;网络与上游错误时自动切到下一个含生图模型的渠道重试一次 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 型生图」的条件:
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; }
这段代码值得逐句读。注意三件事:
- 有蒙版就不试 chat。局部重绘传的是一个 PNG 蒙版,chat 接口没有地方放它。与其发一个没有蒙版的请求得到一张完全不对的图,不如老实失败。
- chat 失败时抛的是原始错误。不是 chat 的错误。因为用户关心的是「为什么我的 /images 调用没成」,而不是「备用方案也没成」——后者只是信息,前者才是原因。
- 这段注释和实际行为有一处偏差。注释说鉴权错误「直接抛」,但抛出去的是一个
IMAGE_GENERATION_FAILED,它并不在上一张白名单里。所以 401 依然会被外层接住、换一个渠道再试。真正不做的是 chat 降级。读代码时,注释描述意图,错误码决定行为;两者不一致时,以错误码为准。
为什么需要一个 chat 降级
因为有一类中转站,只把生图模型挂在 /chat/completions 上。/v1/images/generations 要么 404,要么返回 200 但 data 是空的。这类站的前端就是把图当消息发出来的,所以图片藏在消息里——可能是 message.images[].image_url.url,可能是 Markdown 里的 ,可能是一段裸的 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 通不通,只看模型数量最多,不看延迟、不看成功率、不看它到底能不能出图。但它有一个巨大的好处——无状态、可解释、失败时不会做出更坏的猜测。在私人工具里,「可解释的笨办法」永远优于「不可解释的聪明办法」。
/models 返回 200,清单一切正常,只是「你同时发三个我就不干活」。这类「活着但拒绝服务」的渠道,只能靠请求内的 429 处理,或者靠人去把并发调成 1。巡检是探活,不是探服务质量。
自己走一遍这张表
下面的小样把两条降级链摊开:内层的 chat 降级、外层的换渠道。切换返回码、开关蒙版与备用渠道,看每一步的判决。特别建议看一眼「200 但无图」那一格——那是这张表里唯一的死路。
generateImageOnce 内层判 401/402/403 跳过 chat;!mask 才试 chat;外层 generateImage 用错误码白名单决定是否换渠道。备用渠道取「生图模型数最多」的那一条,只重试一次。谁写了这句话
一张图旁边的那段提示词,可能是作者写的,可能是它的工具写的,也可能是你的视觉模型看了一眼之后猜的。这三件事不能共用一个字段。
图库里最常见的一个动作是「复制提示词」。这个动作隐含一个假设:提示词是作者写的。可现实里它有三个来源——生成工具写进 PNG 的参数块、作者手填的说明、以及模型看图反推出来的猜测。三者的可信度差着一个数量级,混在一起,就是这套系统开始撒谎的地方。
PNG 里藏着一个时代
PNG 允许携带若干文本块:tEXt(不压缩)、zTXt(zlib 压缩)、iTXt(国际化,可指定语言与翻译)。Stable Diffusion 生态把这套机制用成了一种约定俗成的元数据格式,于是今天一张 AI 图里可能同时躺着三套互不兼容的方言。
读取器是手写的,不依赖任何图像库:从 PNG 签名开始,逐块读长度与类型,遇到 tEXt 取 key\0value,遇到 zTXt 用 zlib 解压。两个上限:单个 chunk 超过 16MB 停止,所有文本累计超过 2MB 停止。解压还带了 maxOutputLength——这是防 zip bomb 的,一个 1KB 的压缩块可以膨胀成 1GB,必须在解压时就掐死,而不是解完再检查。
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 的意思是「这段文字是从文件里读出来的,不是想出来的」,不是「这段文字一定生成了这张图」。
读不到的时候,说读不到
没有元数据的图占多数。微信、截图、二次压缩、格式转换,任何一步都能把文本块抹掉。这时候系统有两条路:
- 有视觉模型:把图发给它,要求返回 JSON(
prompt/negativePrompt/keywords/style),并明确要求「不要声称这是作者原始提示词」。结果标为exact: false, inferred: true,界面上写「AI 推测,不是作者原始提示词」。 - 没有视觉模型:
usable: false,附一段人话说明「当前服务无法调用视觉模型反推;请切换到真实 Provider,或上传带 A1111 / ComfyUI / InvokeAI 参数的 PNG」。不编,不空着,也不假装成功。
我更愿意把这两条路看成同一件事的两个档位:系统可以有不知道的东西,但不能有不知道却不说的地方。一个空的 prompt 字段和一个标着「推测」的 prompt 字段,对用户的含义完全不同;前者让人以为坏了,后者让人知道该信几分。
润色:一个必须返回 JSON 的模型
提示词润色走的是文本模型。system 提示里把要求写死:保留主体与意图,补充构图、环境、光线、镜头、材质和色彩;不要添加冲突主体;返回且只返回 JSON,字段为 polishedPrompt、negativePrompt、keywords;并且特别强调 polishedPrompt 必须是「一段完整、独立、可直接提交给图像生成接口的正向提示词」,不能只给标题或关键词摘要。
最后那句是为了治一个很常见的病:模型被要求「润色」时,很喜欢返回一句总结式的标题,或者一串关键词,看着漂亮,丢进生图接口就废了。
解析端做了三层容错:
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。界面上不隐瞒这个差别——你一眼就能看出这段「润色」是模型写的,还是模板拼的。

自己翻一遍账本
下面这枚小样有两个模式。左边选「读图」,切五种来源,看 exact / inferred / usable 三个开关怎么翻;左边选「润色」,切四种模型返回形态,看解析器在哪一层接住它、接不住时退回哪里。
怎么证明它没撒谎
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.js | SSE 帧解析、从事件里捞出图片地址 |
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 日在这台机器上跑了一遍,输出是这个样子的:
# 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 拒了两次之后,它教会我的第一件事就是别并发。
还没解决的四个死角
这一节按惯例留给没做完的事。它们不是路线图,是四个我知道在哪、但还没动的地方:
- 单用户假设。没有登录、没有用户隔离、
data/目录就是全部权限边界。局域网里别人能打开这个端口,就能看到你的全部图与全部渠道。 - 私网访问默认开着。
ALLOW_PRIVATE_NETWORK默认为真,因为这是本机工具、要连本机的 ComfyUI。一旦部署到公网,这个默认值就是一个 SSRF 面,必须改。 - JSON 库不是数据库。所有记录在一个
db.json里,每次写入全量序列化。图到几百张、记录到几千条时会开始变慢,而且没有事务、没有并发控制,只有一条 Promise 链和原子 rename 撑着的「单进程正确」。 - 渠道能力永远靠猜。模型名关键词表是硬编码的,新模型出来就得手工补。想真正解决,只能让渠道自己声明能力——而这需要上游配合,不是一个客户端能决定的。
结语:把剪刀停在半空
写完这七章,我发现「画境」真正在做的不是生图。生图那一步只有百来行,调一个接口,解一个 base64。剩下的九千多行全部在处理「万一不是这样」:万一渠道挂了、万一返回的不是图、万一写到一半断电、万一这个提示词其实是猜的、万一用户在半路改了主意。
这些「万一」在演示里一个都看不到。演示总是顺利的:一条好渠道,一句好提示词,一次成功的生成。而一个要天天用的工具,长的是另一副样子——它的全部工程量,都长在那些用户看不见、但一定会遇到的岔路上。
把「来源」当成一等字段。元数据是读的、猜的、还是模板拼的,必须记在记录里,而不是记在开发者的脑子里。exact 与 inferred 之间那道线,是这套系统所有诚实的地基。
并发是预算,不是免费的勇气。一个按用户限流的渠道面前,多发请求等于降低成功率。默认并发 2、视频 1、批量自己一套节拍——这些数字不是为了保守好看,是拿 429 换来的。
给每个失败起一个名字,然后用名字决定动作。换渠道、换接口、还是立刻停手,全看错误码而不看心情。名字起得越准,降级表就越短,人也就越少在深夜猜「到底哪坏了」。
留下的问题。这一篇没有解决「怎么判断一张图好不好」——它甚至没打算碰。它只处理了另一件事:怎么保证一张图、连同它周边的一切说法,都能被复查。前者是审美,后者是工程;审美没有账本,工程必须有。
五张插图来自一个聚合渠道,用的是这篇要讲的那套接口。它们被生成的那天,那个渠道正按每用户 1 并发限流——所以这篇文章的第一个数据点,就是它自己的绊倒。