AI 生图不只是调 API,一条生产级生图链路的设计与工程取舍

0 阅读15分钟

本文基于 AI Mind 项目的真实实现整理。
GitHub:github.com/HWYD/ai-min…
对应代码版本:v0.4.12
线上体验:ai.hwyblog.cloud/instant-min…

AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。

如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。


main.png

一、一次生图 fetch 背后,藏着三个真问题

用户输入 /image 一只坐在窗台上的橘猫,阳光从左边照进来,几秒钟后,一张图片出现在屏幕上。

看起来很简单:把文字发给生图 API,把返回的图片展示出来。一次 fetch 的事。

但真动手做的时候,三个问题会依次冒出来,一个比一个难糊弄:

  1. 用户描述 ≠ 可执行的生图提示词。"一只坐在窗台上的橘猫"——场景是什么?风格是什么?画幅是横是方?生图模型需要这些信息,但用户没说。得有一个环节把自然语言补全成结构化需求,同时不能把系统默认值伪装成用户要求。
  2. LLM 生成的提示词可能偏离用户意图。让 LLM 把 ImageBrief 改写成生图提示词,它可能漏掉"阳光从左边照进来"这个关键约束,也可能自作主张把"橘猫"改成"橙色虎斑猫"。需要一个环节对照原始需求检查提示词,而且检查本身也得是可验证的——不能依赖 LLM 的一句"我觉得没问题"。
  3. Provider 返回的临时 URL 不可信。生图 API 返回一个 HTTPS URL 说"图片在这里"。这个 URL 指向哪?会不会重定向?内容真的是图片吗?体积会不会超?敢直接把 URL 给前端吗?

这三个问题,加上"链路本身不能失控"这道隐形防线,共同构成了四个必须做硬决策的边界点。本文以 AI Mind v0.4.12 的实现为线索,把它们逐一展开——重点不在"做了什么",而在"为什么这样做,不这样会出什么问题"。

p1.png


二、链路全景——三层架构与职责分离

v0.4.12 的生图链路分三层,每层有自己的硬边界。

第一层:ChatOrchestrator(聊天总调度器)

ChatOrchestrator 是 AI Mind 聊天系统的入口编排器。它的职责是识别 /image 命令,在进入普通聊天链路之前把请求分流。关键判断:只有首个非空白 token 为精确 /image 的消息才进入生图。/imagex、内嵌 /image、普通聊天中的"帮我画图"都不触发,继续走普通聊天。

第二层:ImageGenerationRunCoordinator(生图任务生命周期管理器)

Coordinator 是生图链路的实际执行者。它持有所有副作用依赖——数据库、网络、流式写入器——负责编排 Graph 执行、调用 Provider、管理流式输出和错误处理。领域决策委托给 Graph,副作用操作(持久化、网络调用、流式输出)由自己完成。

第三层:LangGraph StateGraph(纯领域决策图)

Graph 只持有可序列化的领域状态,不碰数据库、不碰网络、不碰流式输出。它的职责是:从用户描述中提取结构化 ImageBrief、生成执行提示词、检查提示词质量、决定是否需要修订、决定是否允许生成。

三层之间的数据流是单向的:

ChatOrchestrator → 识别 /image → 创建 StreamRun
  → ImageGenerationRunCoordinator → 编排 Graph 执行
    → StateGraph → 纯决策(brief / draft / inspect / revise / block)
    ← 返回 GraphState(领域决策结果)
  → Coordinator → 调用 Provider / 流式输出 / 持久化
→ 前端 → 流式消费 / 预览 / 下载

这样分层之后,Graph 可以独立测试——用模拟的 planning model 和 image provider 就能验证所有分支(pass / revise / block / 计数器上限),不需要启动数据库或网络。同时,Coordinator 中的原子操作(比如把 generationCount 从 0 改为 1 再调用 Provider)不会因为 Graph 节点的重试而重复执行——Graph 不持有副作用引用,节点重试不会触发重复的网络调用。

图片占位:三层架构关系图——ChatOrchestrator / ImageGenerationRunCoordinator / StateGraph 的职责边界和数据流向。


三、结构化提取——用 Zod Schema 让 LLM 输出可校验的数据

第一个边界点:用户输入的自然语言,怎么变成可被后续环节使用的结构化数据?

为什么需要 ImageBrief

用户说"一只坐在窗台上的橘猫,阳光从左边照进来"。这句话里有什么信息?

  • 主体:橘猫(用户明确说的)
  • 动作/位置:坐在窗台上(用户明确说的)
  • 光照:阳光从左边照进来(用户明确说的)
  • 场景:室内窗台(可推断,但用户没明确说)
  • 风格:未指定
  • 构图:未指定
  • 画幅:未指定

如果直接让 LLM 把这句话改写成生图提示词,LLM 会把"未指定"的值填上它自己猜的内容——而且每次猜的可能不一样。更危险的是,它可能漏掉"阳光从左边照进来"这个关键约束。

所以需要一个中间产物:ImageBrief——一份结构化的事实记录,区分"用户明确要求的"和"系统合理默认的"。它是后续提示词生成和质量检查的唯一基准。

Schema 设计:边界数字和 strict 策略

// 解决的问题:约束 LLM 输出边界,防止无节制生成;区分"用户要求"和"系统默认"
export const imageBriefSchema = z
  .object({
    aspectRatio: z.enum(['square', 'landscape', 'portrait']),
    intent: z.string().trim().min(1).max(160),
    subjects: z.array(z.string().max(120)).min(1).max(8),
    mustInclude: z.array(z.string().max(160)).max(12),
    avoid: z.array(z.string().max(160)).max(12),
    assumptions: z.array(z.string().max(160)).max(8),
    scene: z.string().max(240).optional(),
    composition: z.string().max(240).optional(),
    style: z.string().max(240).optional(),
    lightingAndColor: z.string().max(240).optional(),
    visibleText: z.array(z.string().max(120)).max(8).optional(),
  })
  .strict()

每个边界数字背后是一个具体约束:intent ≤ 160 字符(生图意图应该是一句话)、subjects ≤ 8 项(一张图的主体不可能超过 8 个可辨识对象)、mustInclude ≤ 12 项(如果用户有超过 12 条"必须满足"的约束,这个需求本身就应该拆分)。

.strict() 是一个容易被忽略的细节。它拒绝 LLM 输出中任何不在 schema 里的字段。如果 LLM 幻觉出一个 moodcameraAngle 字段,它不会静默通过,而是触发解析失败。解析失败后,系统不做隐藏的 JSON 修复或重试,直接以 IMAGE_PROMPT_PLANNING_FAILED 终止。

assumptions:区分"用户要求"和"系统默认"

assumptions 字段的作用是:记录系统替用户做的默认假设,并标注"这不是用户要求的"。

用户没指定画幅,系统默认 square。这个默认值写入 assumptions["默认方形画幅"]。后续的提示词检查环节看到这个字段,就知道"画幅是系统默认的,不是用户要求的",不会因为画幅问题阻断生成。

如果 assumptions 不存在,系统默认值和用户要求混在一起,检查环节就分不清"这个要求没满足是因为用户没提还是 LLM 漏了"。这个字段承担的是整条链路中"事实归因"的职责。


四、提示词质量检查——用结构化判断替代"我觉得没问题"

第二个边界点:LLM 生成的生图提示词,怎么确认它忠实于 ImageBrief?

对照检查,不是自由评审

做法是再调用一次 LLM,但这次不是让它自由发挥,而是让它对照 ImageBrief 做结构化判断。这个判断的产物叫 PromptInspection:

// 解决的问题:对照 ImageBrief 做结构化判断,分类问题 + 判定严重程度
export const promptInspectionSchema = z
  .object({
    outcome: z.enum(['block', 'pass', 'revise']),
    issues: z.array(z.object({
      code: z.enum([
        'capability_boundary',    // 要求了不支持的能力(如编辑、扩图)
        'conflict',               // 提示词与 ImageBrief 冲突
        'missing_constraint',     // 遗漏了 ImageBrief 中的约束
        'missing_subject',        // 遗漏了 ImageBrief 中的主体
        'unsupported_assumption', // 做了 ImageBrief 中不允许的假设
      ]),
      severity: z.enum(['blocking', 'fixable', 'non_blocking']),
    })).max(8),
    revisionInstruction: z.string().max(500).optional(),
  })
  .strict()

这里有三件事值得说清楚。

第一,issue 分类覆盖了所有可能的问题维度,没有"其他"。missing_subjectmissing_constraint 是"遗漏",conflict 是"冲突",capability_boundary 是"越界",unsupported_assumption 是"幻觉"。如果让 LLM 自由分类,会产生不可预期的问题类型,下游路由没法做确定性处理。

第二,severity 三档直接决定路由。blocking → 不生成;fixable → 修订一次;non_blocking → 不处理,直接生成。没有"可能严重"或"视情况而定"的模糊空间。

第三,inspection 的指令是 "Return no reasoning"。不让 LLM 输出思维链,只输出结构化判断。思维链会暴露内部执行提示词——用户不应该看到这些——而且对下游决策没有价值。

路由决策:LLM 判断,代码拍板

inspection 的结果怎么用?

// 解决的问题:LLM 提供判断(outcome),代码决定路由
export function routeAfterPromptInspection(state: ImageGenerationGraphState) {
  if (state.output?.status === 'failed' || state.output?.status === 'blocked')
    return 'finishBlocked'

  // 只有 outcome='revise' 且还没修订过,才允许修订一次
  if (state.prompt.inspection?.outcome === 'revise'
      && state.execution.promptRevisionCount === 0)
    return 'revisePrompt'

  // outcome='block' → 阻断;否则通过
  return state.prompt.inspection?.outcome === 'block'
    ? 'finishBlocked' : 'finishReady'
}

LLM 说"revise"但 promptRevisionCount 已经是 1,代码不会给第二次机会。LLM 说"block"但路由条件不满足,也不会阻断。LLM 的权限是"提供判断",不是"做决定"——决定权在代码里。


五、有界决策——用硬计数器替代"再试一次"

第三个边界点:如果一次修订后还不够好,能不能再修订一次?

为什么不能给第二次机会

给 LLM 越多的修订机会,越可能得到更好的提示词。但每一次修订都是一次 LLM 调用,消耗 token 和时间。更棘手的是实际生图调用的成本——如果修订循环中不小心触发了多次生图,成本会直接翻倍。

v0.4.12 的答案是用三道硬编码上限把这条路封死:

// 解决的问题:硬编码限制,不依赖 LLM "自律"
export const imageGenerationGraphLimits = {
  maxImageGenerations: 1,      // 最多调用一次生图 API(真金白银)
  maxPlanningModelCalls: 5,    // 最多 5 次 LLM 规划调用
  maxPromptRevisions: 1,       // 最多一次提示词修订
} as const
  • maxPromptRevisions = 1:第一次 inspection 发现可修复问题 → 修订一次 → 第二次 inspection → 只能生成或阻断。没有"修订后还不行,再修订一次"。
  • maxImageGenerations = 1:不管提示词质量如何,只生成一次。结果不理想需要重新发起 /image
  • maxPlanningModelCalls = 5:正常路径 3 次(brief + draft + inspect),修订路径最多 5 次(brief + draft + inspect + revise + reinspect)。超出直接终止,不做隐藏修复。

每个节点执行前,守卫函数检查当前计数:

export function canRevisePrompt(state: ImageGenerationGraphState) {
  return state.execution.promptRevisionCount
    < imageGenerationGraphLimits.maxPromptRevisions
    && state.output === undefined
}

LLM 根本不知道上限的存在——它只负责输出结构化判断,代码决定是否允许下一步。三条硬限制合在一起就是一句话:宁可少生成一次,也不多生成一次


六、安全代理——交付不信任的外部资源

第四个边界点:生图 API 返回了一个 URL,说图片在这里。这个 URL 能信任吗?

为什么不能把 Provider URL 直接给前端

Seedream(豆包 Seedream 文生图模型,本版固定使用的图像模型)返回的图片 URL 是一个临时签名 URL,指向火山引擎的对象存储。直接给前端有三个问题:

  1. 临时签名 URL 暴露了 Provider 的存储域名和签名参数。签名过期前,拿到 URL 的人都能访问。
  2. 前端直接访问无法做所有权校验。用户 A 猜到用户 B 的 runId,就能通过 URL 访问 B 的图片。
  3. 无法在服务端做超时控制、体积限制、MIME 校验和重定向拦截。

v0.4.12 的做法是:Provider URL 只保存在服务端数据库,前端拿到的只是一个同源内容路径/api/chat/runs/{runId}/image),由服务端代理读取后返回。

六层安全校验链

代理读取不是简单的 fetch + pipe。它是一层一层校验的。

第一层:URL 结构校验

// 解决的问题:Provider 返回的 URL 不可信,逐字段校验防 SSRF
const parsed = new URL(url)
return parsed.protocol === 'https:' &&
  !parsed.username && !parsed.password && !parsed.port && !parsed.hash &&
  isIP(parsed.hostname) === 0 &&
  seedreamImageProviderConfig.resultHosts.includes(parsed.hostname)
  ? url : undefined

拒绝:非 HTTPS、包含用户名密码、包含端口号、包含 hash fragment、hostname 是 IP 地址(防 SSRF 内网探测)、hostname 不在预设 allowlist 中。

第二层:所有权校验

服务端从当前请求的 session 派生出 ownerSessionHash,与数据库中的 StreamRun.ownerSessionHash 比对。不匹配直接返回 403。

第三层:状态校验

必须同时满足:StreamRun status 为 completed、ImageGenerationRun providerResultStatus 为 ready、未过期、未取消。已取消的 run 即使 Provider 已经生成了图片,内容路由也永久拒绝。

第四层:HTTP 响应校验

fetch Provider URL 时 redirect: 'manual'——拒绝重定向。同时检查 Content-Length 声明,超过 20MB 直接拒绝。

第五层:流式读取 + 实时体积检查

// 解决的问题:Content-Length 头不可信,必须边读边检查实际字节数
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  byteLength += value.byteLength
  if (byteLength > maximumImageBytes)  // 20MB
    throw new Error('exceeds allowed size')
  chunks.push(value)
}

即使 Content-Length 声明在 20MB 以内,实际读取的字节数也可能超过。边读边检查,超过上限立即终止。同时设置 15 秒超时——Provider 存储响应过慢时不无限等待。

第六层:Magic Bytes 校验

// 解决的问题:MIME 头可伪造,magic bytes 是文件格式的"身份证"
function matchesImageMagicBytes(bytes: Uint8Array, mimeType: string): boolean {
  if (mimeType === 'image/jpeg')
    return bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF
  if (mimeType === 'image/png')
    return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E
      && bytes[3] === 0x47 && bytes[4] === 0x0D && bytes[5] === 0x0A
      && bytes[6] === 0x1A && bytes[7] === 0x0A
  // WebP: RIFF....WEBP
  return bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46
    && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45
    && bytes[10] === 0x42 && bytes[11] === 0x50
}

HTTP 响应头的 Content-Type 可以伪造。一个声称 image/png 的响应,实际内容可能是任意二进制。Magic bytes 是文件格式的"身份证"——JPEG 开头必须是 FF D8 FF,PNG 开头必须是 89 50 4E 47 0D 0A 1A 0A,WebP 开头必须是 RIFF....WEBP。前面五层校验都被绕过时,magic bytes 仍然能阻止非图片内容被当作图片返回。

前端拿到的是什么

六层校验全部通过后,服务端把图片字节返回给前端。前端 fetch 一次内容路径,生成一个 Blob,用 URL.createObjectURL 创建预览地址。同一个 Blob 用于 <img> 预览和下载按钮。

过期策略:min(provider expiry, ready + 10min)。Provider 给的过期时间 vs 系统自身的 10 分钟上限,取更早的那个。前端在结果区域明确提示"临时结果,请及时下载"。组件卸载时 URL.revokeObjectURL 释放 Blob。

刷新页面后,Blob 和 object URL 都不再存在。v0.4.12 不承诺刷新恢复——这是有意为之,不是功能缺失。


七、边界与取舍——当前没做什么、为什么

v0.4.12 明确不做的几件事,和它做了的事同样重要。

不支持图片编辑、局部重绘、扩图、去背景

这些不是"文生图加个参数"。它们各自是独立的能力,需要不同的模型或后处理管线。如果用户输入 /image 把这张图的背景去掉,系统不会静默降级成文生图——它会返回 IMAGE_CAPABILITY_UNSUPPORTED 并明确说明当前只支持文生图。

不支持一次生成多张图片

每次任务只生成一张图片。Seedream 本身支持组图参数 sequential_image_generation,但本版把它设为 disabled。原因不是技术限制,而是:在"最多一次生成"的硬限制下,多图会引入"哪张作为最终结果"的新决策问题,且成本不可控。


八、总结

沿着一条生图链路走下来,四个边界点上的核心决策可以收拢成一张表:

边界点核心决策不这样做的后果
结构化提取Zod schema + strict + fail-closedLLM 幻觉字段静默通过,下游收到不可信数据
质量检查ImageBrief 事实锚点 + inspection 结构化判断提示词偏离用户意图,生成结果与预期不符
有界决策硬计数器 + 条件边路由Agent 陷入无限修订循环,成本和延迟失控
安全代理六层校验链,从 URL 到 magic bytesProvider URL 泄露,SSRF 风险,恶意内容伪装成图片

AI 生图系统的工程复杂度不在"调哪个 API"——Seedream、DALL-E、Midjourney 的 API 格式大同小异。真正的复杂度在 API 调用前后的每一道防线:输入怎么变成可信的结构化数据、提示词怎么被检查、Agent 怎么被限制、外部资源怎么被安全交付

v0.4.12 的边界也很明确:单次文生图、固定模型、无对象存储、无 HITL、无 checkpoint。每个 Non-goal 都为后续版本留出了清晰的扩展方向。下一个版本如果要支持参考图、多图生成或 HITL,需要先回答的问题是:现有的硬限制和防线,哪些需要保留,哪些需要调整


项目地址

👉 GitHub:github.com/HWYD/ai-min…

👉 线上体验:ai.hwyblog.cloud/instant-min…

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。