AIGC 全景与生成模型谱系

把「AI 生成内容」拆成一座三层的内容工厂:底层烧钱训模型,中层改造成行业工具,上层才交到用户手里;而工厂里真正干活的,是 VAE、GAN、扩散模型这三条产线。

30″30 秒看懂 AIGC

把 AIGC 想成一座三层楼的内容工厂:底下一层是发电机房,中间一层是改装车间,最上面一层才是交到顾客手里的门店。你在手机上点一下「生成」,这三层楼全都动了一遍。

一楼轰隆作响的是发电机——把几亿张图文、几百万小时音视频、成吨的代码喂进去,烧上几千张显卡,练出一个什么都懂一点的大模型。这层楼贵得离谱,全世界能建的没几家。二楼是改装车间,工人把一楼的通用机器拧一拧、接上行业数据,改成「专画古风插画的」「专写电商文案的」。三楼是门店,顾客根本看不见前两层,只看见一个输入框和一个按钮。

而工厂门口还站着一位质检员,拿着放大镜挨个看出货的东西。这个人不是摆设——机器造得再快再像,也不保证它说的是真的

图① 30 秒看懂:三层楼的内容工厂
图① 30 秒看懂:三层楼的内容工厂
比喻里的角色对应的技术概念它到底干了什么
进料传送带训练数据图文、音视频、代码等海量样本,决定了这座工厂「见过什么」
一楼发电机基础层(预训练大模型)烧算力练出通用能力,靠 API 调用费和平台订阅收钱
二楼改装车间中间层(2B 的行业工具)不练底座,基于开源模型做垂直化、场景化、定制化改造
三楼门店应用层(2C 的产品)网页、小程序、群聊机器人、App——用户唯一摸得到的一层
出货传送带生成的内容文本、图像、音频、视频,以及它们互相转换的多模态产物
门口的质检员人的审核与核实判断这批货能不能用、敢不敢发、有没有版权与事实风险
⛔ 整讲只有一条铁律 AIGC 生成的是概率上最像样的内容,不是事实上正确的内容。工厂只会照着它见过的样本造,从不核实真伪——所以质检员这个岗位永远撤不掉。后面讲的三条技术路线、五类产出、三层产品形态,都是在回答「怎么造得更像」;而「像不像」和「对不对」是两件事。

这条铁律会在本讲反复出现:讲扩散模型时它解释了为什么模型能画出根本不存在的建筑;讲产品形态时它解释了为什么二楼三楼的公司也得为内容合规负责;讲代码时它变成了一份必须落盘的溯源清单——记下是谁、用哪个模型、用什么提示词造出来的这张图。

01概念:AIGC 到底指什么

一个定义、两种分法、一条容易混淆的边界

1.1 AIGC 是什么

AIGC 全称 AI-Generated Content,人工智能内容生成。这个词描述的不是某一个模型,也不是某一项技术,而是一整类「由机器产出内容」的能力与产业

它和两个老词是对照着出现的:

缩写谁在生产特点与代价
PGC专业机构(Professionally Generated Content)质量稳定、有审校流程,但产能受限于人手,单位成本最高
UGC普通用户(User Generated Content)产能大、成本低,但质量方差极大,平台要花大力气做审核
AIGC模型(AI-Generated Content)边际成本趋近于零、产能几乎无上限;质量方差来自提示词和模型,而不是来自人的水平——所以质检的对象从「人」变成了「流程」

这张表决定了后面所有工程选择的方向:既然产能不再是瓶颈,瓶颈就转移到了「怎么批量生成 + 怎么批量筛选 + 怎么留下可追溯的记录」。本讲第 04 节的三个案例,正好对应这三件事。

为什么这几年才火 生成模型 2014 年就有了(见 2.2、2.3),但早期只能产出小尺寸、低保真的结果,不具备生产价值。真正的转折是三件事同时到位:扩散模型把生成质量拉到可用线以上、大规模图文对让模型听得懂人话、显卡算力便宜到可以大规模推理。三者缺一,AIGC 都还是实验室里的 demo。

1.2 按技术分:孪生、编辑、生成

第一种分法看的是「机器对原始内容做了多大改动」。改动从小到大,正好是三档:

图② AIGC 的两种分法:按技术分与按产出分
图② AIGC 的两种分法:按技术分与按产出分
1内容孪生

把内容映射到另一个模态,信息本身不变。包含智能增强(图像超分、老片修复)与智能转译(语音转字幕、文字转语音)。输入输出是同一份信息的两个形态。

2内容编辑

理解内容及其属性,再有控制地修改。场景剪辑、虚拟试衣、人声分离都属于这一档。原始内容还在,被改掉的是其中某些属性。

3内容生成

从海量数据里学到抽象概念,再产出全新的内容。AI 绘画、AI 写作、视频生成、多模态生成都在这一档。没有对应的原始素材,产物是新造的。

这三档不是学术划分上的洁癖,它直接决定了风险等级——顺着铁律看一遍就明白:

档位事实风险版权风险质检该看什么
内容孪生转换有没有丢信息、超分有没有编出原图没有的细节
内容编辑被改掉的属性是否越界(换脸、换声涉及肖像与声音权利)
内容生成事实是否存在、是否高度相似于某个已有作品、是否需要标注

换句话说:越往右,工厂门口那位质检员越不能省。做超分的产品可以自动上线,做文生图的产品必须留人工复核通道。

1.3 按产出分:文本、图像、音频、视频、多模态

第二种分法看的是「产出的是什么模态」。这是产品经理和采购最常用的分法,因为它直接对应能买到什么服务。

类别细分典型能力
文本生成非交互式 / 交互式摘要与标题生成、图生文;交互式即对话机器人,是目前最成熟、渗透最广的一类
图像生成编辑修改 / 自主生成超分、修复、人脸替换属编辑;文生图属自主生成,是本模块后面几页的主线
音频生成语音 / 音乐语音克隆、场景化语音合成、旋律与编曲生成
视频生成编辑 / 自主生成超分、修复、自动剪辑属编辑;文生视频属自主生成,成本与难度都最高
多模态生成模态间转换文生图、文生音频、文生视频、图生文、图生视频——把不同模态组合搭配
两种分法不是并列的,是交叉的 同一件事可以同时属于两边:「老照片上色」按技术分是内容编辑,按产出分是图像生成。看到一个需求时,习惯性地两边各定位一次——按技术分定的是风险和质检强度,按产出分定的是该去买哪类服务、要多少算力

1.4 和「判别式模型」的区别

这是初学时最该先划清的一条线。工厂里的机器分两种:一种是检验台,你递给它一个东西,它告诉你「这是什么」;另一种是生产线,你给它一句话,它造一个东西出来。

对比项判别式模型(检验台)生成式模型(生产线)
学的是什么条件概率 P(y|x):给定样本判类别数据分布 P(x) 本身:这类数据「长什么样」
典型任务图像分类、情感分析、目标检测文生图、文本续写、语音合成
输出一个标签或一个分数,可验证对错一份全新的内容,只有「像不像」,没有唯一正确答案
评估方式准确率、召回率、F1 —— 有标准答案保真度、多样性 + 人工主观评分,指标和人的感受经常不一致
出错的样子分错类,能被测试集抓到一本正经地造出不存在的东西,测试集抓不到

最后一行就是铁律的技术根源:生成式模型的目标函数里从来没有「真实性」这一项。它优化的是「产出的东西看起来像不像训练数据里的东西」。所以它会画出结构错误的手、写出格式完美但根本不存在的文献条目——对它来说那些输出的「像样程度」得分很高,这不是 bug,是目标函数的定义如此

CLIP 是个有意思的例外 下一页要讲的 CLIP 本身是判别式的——它只判断「这张图和这句话配不配」,不能生成任何图像。但正是这个判别能力,成了文生图的方向盘:生成模型负责造,CLIP 负责说「离你要的那句话还差多远」。判别和生成不是对立的两派,在现代文生图系统里它们是搭档。

02原理:三条产线和三层楼

生成模型在解什么问题、三条路线各自怎么干、为什么扩散模型赢了、以及这套能力怎么变成产业

2.1 生成模型到底在解什么问题

先把问题说清楚,不然后面三条路线都像变魔术。

假设世界上所有「好看的风景照」构成一个集合。这些图片并不是在 786432 维空间里均匀散布的——绝大多数随机像素组合都是雪花点,只有极小的一撮子集看起来像风景。生成模型要学的,就是这一撮子集的形状,术语叫数据分布 P(x)

学会了形状,就能干一件事:从这个形状里随机摸一个出来——摸出来的必然「看着像风景」,但又不是训练集里任何一张。这就是「生成」。

用工厂的话说 一楼的发电机不是在背下每一张进料的图片(那叫复印机),而是在总结「风景照普遍长什么样」这条规律。规律学到了,就能造出没见过的新货。这也是为什么模型能画出现实中不存在的建筑——它学的是「建筑的样子」这条规律,规律里并不包含「这栋楼是否真的存在」。

难点在于:P(x) 这个形状极其复杂,没法直接写出公式。三条技术路线,本质上是三种绕开「直接写出公式」的办法

图③ 三条生成路线:变分自编码、生成对抗、扩散模型
图③ 三条生成路线:变分自编码、生成对抗、扩散模型

2.2 变分自编码 VAE:把形状压成一团概率云

VAE(Variational Autoencoder,变分自编码器)由 Kingma 等人在 2014 年提出。它的思路是降维:既然高维空间里的形状太复杂,那就先压到低维再说。

它由两半组成:

组件输入 → 输出干了什么
编码器 Encoder高维图像 → 潜空间的概率分布把一张图压成「一团概率云」的参数(均值与方差),而不是一组确定的数值
采样概率分布 → 一个潜变量从这团云里随机摸一个点出来
解码器 Decoder潜变量 → 重建的图像把这个点还原成一张图

关键就在「概率云」而不是「确定数值」这一处。传统自编码器把图压成一组固定数字,目标只是尽量还原原图;而 VAE 压出来的是一片有范围的区域——于是这片区域里的每一个点,解码出来都是一张合理的图。微笑的程度、肤色的深浅、性别的倾向,都变成了这片区域里可以连续滑动的方向。

VAE 的长处与短处 长处:训练稳定(就是个重建误差 + 分布约束的优化问题)、潜空间连续可插值、编码解码都快。
短处:生成的图普遍偏糊。因为重建损失倾向于「所有可能性的平均」,而多个清晰答案的平均就是一张模糊的图。
它没有消失:Stable Diffusion 里那个把潜空间还原成像素图的解码器,正是 VAE 的解码器部分——扩散模型接手了「生成」,VAE 留下来干「压缩与还原」。

2.3 生成对抗网络 GAN:让造假者和鉴定师互相较劲

GAN(Generative Adversarial Networks)同样是 2014 年,由 Ian Goodfellow 提出,是很长一段时间里最著名的生成模型。它用的是零和博弈

G生成器 Generator

输入一组随机向量(可以理解成「数字编号、字体、粗细、潦草程度」这类隐含设定),输出一张图。它的目标是骗过鉴定师。生成的数据作为鉴定师的负样本。

D判别器 Discriminator

一个二分类模型,输入一张图,输出这张图是真实数据的概率。越接近 1 越可能是真的,越接近 0 越可能是生成的。它的目标是不被骗

两者交替训练:鉴定师进步 → 造假者被迫画得更像 → 鉴定师又得更挑剔……理想情况下收敛到鉴定师完全分不出真假(输出恒为 0.5),此时生成器已经学到了数据分布。

以 GAN 为基础衍生出一大票变体——DCGANStyleGANCycleGAN,撑起了人脸替换、卡通头像生成、超分辨率重建、风格迁移这一整代应用。

GAN 为什么难伺候 博弈这个设定同时带来了它的两个顽疾:训练不稳定(两个网络实力失衡就会崩,判别器太强则生成器拿不到有效梯度)与模式崩溃(生成器发现画某一种图就能骗过判别器,于是只画这一种,多样性归零)。调 GAN 在当年是门手艺活,这是后来扩散模型能后来居上的重要原因。

2.4 扩散模型 Diffusion:把难题拆成几十个简单小题

扩散模型(Diffusion Model)2015 年提出,灵感来自非平衡热力学。它定义一条马尔可夫链,分两个方向:

过程方向发生了什么
前向过程(加噪)x₀ → x₁ → … → xₙ对原图 x₀ 加一点高斯噪声得 x₁,再加一点得 x₂……加够多步之后,xₙ 已经近似服从标准高斯分布,看不出原图任何痕迹。这一步不需要训练,是纯粹的数学操作。
反向过程(降噪)xₙ → xₙ₋₁ → … → x₀随机生成一张纯高斯噪声图,一步步把噪声减掉,最终得到一张清晰的图。这一步才是模型要学的

加噪过程像往清水里滴墨:墨滴一滴滴扩散,整杯水最终变浑,再也看不出最初那滴墨滴在哪。AI 绘画干的是反过来的事——从一杯浑水里,一点点把墨收回去,还原出一滴清晰的墨。

⛔ 为什么非要拆成很多步 「从纯噪声一步变成清晰图」是个极难的映射,没有模型学得会。但「这张图上刚刚混进去的那一点点噪声长什么样」是个简单的回归题。扩散模型把一道难题拆成了几十道简单小题——每一步只要求模型预测「本步混进来的噪声」,减掉它,交给下一步。难度被时间摊平了,这就是它能做到又稳又好的根本原因。

训练目标因此朴素得出奇:随机取一张训练图、随机取一个时间步 t、按公式给它加上已知的噪声 ε,让模型看着加噪后的图去预测 ε,用回归损失去拟合。没有博弈,没有两个网络打架——训练稳定性直接碾压 GAN

扩散模型最初是为去噪设计的;随着降噪系统训练得越来越久、越来越好,人们发现可以直接把纯噪声当输入,让它「去噪」出一张本来不存在的逼真图片。今天的图像超分、图像上色、文本生成图片、全景图像生成,底下都是这套机制。各家的代表产品——DALL·E 2、Imagen、Stable Diffusion——全部建立在它之上。

2.5 三条路线横向对比

对比项VAEGAN扩散模型
提出年份201420142015
核心机制压缩到概率潜空间再重建生成器与判别器零和博弈加噪成高斯,再学着一步步去噪
训练稳定性✅ 稳❌ 不稳,易模式崩溃✅ 稳(就是个回归任务)
生成质量偏糊清晰但多样性可能塌缩清晰且多样
采样速度✅ 一次前向✅ 一次前向要迭代几十步,最慢
可控性潜空间可插值需专门设计条件注入天然适合逐步注入文本条件
今天的位置给扩散模型当压缩/还原的编解码器实时性要求高的窄场景仍在用文生图的事实标准

结论一句话:扩散模型用采样速度换来了训练稳定性和生成质量——而速度这一项,后来被「把扩散搬进低维潜空间」这个工程手段补回来了一大截(下一页的主线)。

图④ 生成模型谱系:从 2014 到今天的文生图产品
图④ 生成模型谱系:从 2014 到今天的文生图产品

2.6 产品形态:三层楼各自靠什么活着

技术讲完,回到工厂那三层楼。这三层不是抽象比喻,它对应真实的产业分工和三种完全不同的商业模式。

谁在做核心能力怎么收钱
基础层少数头部企业与研发机构从零训练预训练大模型,属于基础设施API 调用费;基于基础设施开发的专业软件平台收费
中间层(2B)行业服务商没有训练底座的能力,但能基于开源模型做改进、抽取、二次开发,产出场景化/垂直化/定制化的应用模型与工具与基础层类似:接口调用费 + 平台费,另加交付与服务费
应用层(2C)面向终端用户的产品团队基于前两层开发,更关注用户需求,通过网页、小程序、群聊、App 等载体呈现订阅制、按次计费、增值服务、广告
这张表怎么用 接到一个 AIGC 需求时,先问自己「我站在哪一层」:
• 站应用层,重点是产品体验、成本控制与内容合规,模型能力直接买——绝大多数团队都在这一层
• 站中间层,重点是行业数据、微调方法与私有化交付,下一页的 LoRA 就是这一层的主力工具;
• 站基础层,重点是算力、数据与训练工程,门槛是几千张显卡起步。
把自己的层定错,技术选型会全盘跑偏——最常见的错误是应用层的团队一上来就想自己训底座。

值得留意的是:三层楼都要为最终内容负责。二楼三楼不能用「模型是别人的」推掉责任——用户看见的是你的产品。铁律在这里落成一条工程要求:无论你在哪一层,出货口都得有质检与溯源。这正是 4.3 那份溯源清单存在的理由。

03最小代码:一句话换一张图

三十行、零依赖,先把「工厂出货」这条路走通

理论讲完,先把最短的一条路跑通:一句中文描述进去,一张 PNG 出来。这段代码不依赖任何第三方库,只用标准库的 urllib,在任何装了 Python 3 的机器上都能直接跑。

① 读环境变量服务地址、密钥、模型名
② 组请求体model / prompt / size / n
③ POST 出去超时给足 300 秒
④ 取 b64_json返回的是 base64 字符串
⑤ 解码落盘base64 → bytes → out.png
出货一张 1024×1024 的图
min_text2img.py —— 三十行跑通文生图最小可运行
"""最小可跑的文生图:把一句中文描述换成一张 PNG。

依赖:只用标准库(urllib / json / base64),不需要 pip 装任何东西。
环境变量:
    IMG_API_BASE   形如 https://xxx.example.com  (不带结尾斜杠)
    IMG_API_KEY    你的密钥,绝不写进源码
    IMG_MODEL      模型名,例如 gpt-image-2.5-sunburst
运行:
    export IMG_API_BASE=... IMG_API_KEY=... IMG_MODEL=...
    python3 min_text2img.py
"""
import base64
import json
import os
import urllib.request

# ① 三个配置项全部从环境变量读,源码里不出现任何密钥
API_BASE = os.environ.get("IMG_API_BASE", "").rstrip("/")
API_KEY = os.environ.get("IMG_API_KEY", "")
MODEL = os.environ.get("IMG_MODEL", "")

# ② 提示词:说清「画什么 + 什么风格 + 什么构图」,三要素缺一张图就飘
PROMPT = "一只橘猫坐在窗台上看雪,扁平插画风格,暖色调,构图居中"


def main():
    if not (API_BASE and API_KEY and MODEL):
        raise SystemExit("缺少环境变量:IMG_API_BASE / IMG_API_KEY / IMG_MODEL")

    # ③ 组请求体。OpenAI 兼容的图片接口只认这四个字段
    body = json.dumps({
        "model": MODEL,
        "prompt": PROMPT,
        "size": "1024x1024",
        "n": 1,
    }).encode("utf-8")

    req = urllib.request.Request(
        f"{API_BASE}/v1/images/generations",
        data=body,
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    # ④ 出图是重活,超时给足。300 秒不算夸张
    with urllib.request.urlopen(req, timeout=300) as resp:
        payload = json.load(resp)

    # ⑤ 返回的是 base64 字符串,不是图片二进制,要自己解码落盘
    b64 = payload["data"][0]["b64_json"]
    with open("out.png", "wb") as fp:
        fp.write(base64.b64decode(b64))
    print("已保存 out.png,字节数:", os.path.getsize("out.png"))


if __name__ == "__main__":
    main()

这段代码里三个不显眼但关键的地方

位置写法为什么必须这样
密钥os.environ.get("IMG_API_KEY", "")密钥永远不进源码。硬编码的后果不是「不优雅」,而是一旦这个文件被提交、被截图、被贴进工单,密钥就泄漏了。密钥泄漏后对方花的是你的钱。
超时timeout=300出图是慢接口,几十秒是常态。按默认超时或者按「调普通接口」的经验设 10 秒,会得到一堆莫名其妙的超时错误——而服务端其实正在正常出图。
返回值payload["data"][0]["b64_json"]接口返回的是 base64 文本,不是图片二进制。直接把响应体写进 .png 会得到一个打不开的文件——这是新手第一次调图片接口最常见的坑。
提示词的三要素 代码里那句 PROMPT 不是随便写的,它包含了三件事:画什么(一只橘猫坐在窗台上看雪)、什么风格(扁平插画风格,暖色调)、什么构图(构图居中)。三者缺一,出图就开始飘——只写「一只猫」,你会得到一张风格随机、构图随机的图,重跑一次又是另一个样子。提示词是这条产线唯一的方向盘。
跑之前先把三个环境变量配上 IMG_API_BASE(服务根地址,不带结尾斜杠)、IMG_API_KEY(密钥)、IMG_MODEL(模型名)。三个缺一个,代码会直接退出并告诉你缺哪个——这比让 urllib 抛一个看不懂的 401 友好得多。让程序在第一行就为配置问题失败,不要等到第三十行

这三十行能跑通,就意味着「一楼发电机 → 三楼门店」这条最短路径打通了。但它离能用还差得远:跑一次和跑一百次是两码事——会被限流、会 502、会跑到一半断掉、会不知道某张图是怎么来的。下一节的三个案例,逐个补上这些窟窿。

04完整案例:从「能跑一次」到「敢跑一百次」

三个案例分别补上批量、多模态、可追溯这三个窟窿

最小代码解决的是「通不通」。真正上线要回答的是另外三个问题:量大了怎么办、模态多了怎么办、事后查不到怎么办。三个案例逐个回答。

4.1 批量出图流水线

场景:运营要一批配图,三个题材 × 两种风格 = 六张,下周可能变成六百张。串行跑、跑挂了从头再来、跑完没人知道哪张对应哪个提示词——这三件事必须一次性解决。

真实问题代码里的对策关键细节
接口慢,串行等到天亮ThreadPoolExecutor 并发并发数压在 3~5。再高会撞限流,反而更慢——出图接口的瓶颈在服务端 GPU,不在你的网络
偶发 502 / 限流指数退避 + 随机抖动min(60, 2**attempt) + random.uniform(0,3)抖动不能省:没有它,多个 worker 会在同一秒集体醒来再撞一次
跑一半断了提示词哈希做文件名 + 已存在就跳过同样的提示词永远落在同一个文件上,重跑即断点续跑
中途 Ctrl-C 留下半张坏图先写 .partos.replace()改名是原子操作,磁盘上要么是完整文件,要么没有文件
事后说不清图怎么来的每张图追加一条 manifest.jsonl多线程写文件必须加锁,否则两条记录会交错成一行乱码
batch_pipeline.py —— 并发 + 重试 + 断点续跑 + 记账完整案例
"""批量出图流水线:把一份「题材 × 风格」的组合表跑成一批图,并留下可追查的记录。

解决四个真实问题:
    1. 出图接口是慢接口,串行跑一百张要等到天亮 —— 用线程池并发;
    2. 出图接口会偶发 502 / 限流 —— 指数退避重试,别一失败就整批崩;
    3. 跑一半断了不该从头再来 —— 已存在的文件直接跳过(断点续跑);
    4. 事后没人说得清某张图是怎么来的 —— 每张图写一条 manifest 记录。

运行:
    export IMG_API_BASE=... IMG_API_KEY=... IMG_MODEL=...
    python3 batch_pipeline.py
"""
import base64
import hashlib
import json
import os
import random
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed

API_BASE = os.environ.get("IMG_API_BASE", "").rstrip("/")
API_KEY = os.environ.get("IMG_API_KEY", "")
MODEL = os.environ.get("IMG_MODEL", "")

OUT_DIR = "out_images"
MANIFEST = os.path.join(OUT_DIR, "manifest.jsonl")

# ① 题材与风格分开列,笛卡尔积展开成任务表。
#    这样加一个风格是加一行,不是复制粘贴一整段提示词。
SUBJECTS = [
    "一只橘猫坐在窗台上看雪",
    "雨后的老城巷口,青石板泛着水光",
    "深夜便利店的暖黄灯光",
]
STYLES = [
    ("flat", "扁平插画风格,纯色块,描边干净"),
    ("ink", "中国水墨风格,留白多,淡墨渐变"),
]

MAX_WORKERS = 3      # 并发数:太高会被限流,3~5 是常见的安全区间
MAX_RETRY = 4        # 每个任务最多重试次数
TIMEOUT = 300        # 单次请求超时(秒),出图是慢活

_lock = threading.Lock()


def build_tasks():
    """展开成任务列表,每个任务自带一个稳定的 id。"""
    tasks = []
    for si, subject in enumerate(SUBJECTS):
        for style_key, style_desc in STYLES:
            prompt = f"{subject}{style_desc}"
            # ② 用提示词的哈希做文件名前缀:同样的提示词永远落在同一个文件上,
            #    这是断点续跑和去重的基础。
            digest = hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:10]
            tasks.append({
                "task_id": f"{si:02d}-{style_key}-{digest}",
                "prompt": prompt,
                "subject": subject,
                "style": style_key,
                "size": "1024x1024",
            })
    return tasks


def call_api(prompt, size):
    """调一次出图接口,返回 base64 字符串。失败直接抛异常,由上层决定要不要重试。"""
    body = json.dumps({
        "model": MODEL, "prompt": prompt, "size": size, "n": 1,
    }).encode("utf-8")
    req = urllib.request.Request(
        f"{API_BASE}/v1/images/generations",
        data=body,
        headers={"Authorization": f"Bearer {API_KEY}",
                 "Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
        payload = json.load(resp)
    return payload["data"][0]["b64_json"]


def run_one(task):
    """跑一个任务:已存在就跳过,失败就指数退避重试。"""
    path = os.path.join(OUT_DIR, task["task_id"] + ".png")
    if os.path.exists(path) and os.path.getsize(path) > 0:
        return {"task_id": task["task_id"], "status": "skipped", "path": path}

    last_err = None
    for attempt in range(1, MAX_RETRY + 1):
        try:
            b64 = call_api(task["prompt"], task["size"])
            data = base64.b64decode(b64)
            # ③ 先写临时文件再改名:中途被 Ctrl-C 也不会留下半张坏图
            tmp = path + ".part"
            with open(tmp, "wb") as fp:
                fp.write(data)
            os.replace(tmp, path)

            record = {
                "task_id": task["task_id"],
                "prompt": task["prompt"],
                "subject": task["subject"],
                "style": task["style"],
                "size": task["size"],
                "model": MODEL,
                "bytes": len(data),
                "sha256": hashlib.sha256(data).hexdigest(),
                "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
                "attempt": attempt,
            }
            # ④ manifest 用 jsonl 追加写,多线程下加锁,别写串行
            with _lock:
                with open(MANIFEST, "a", encoding="utf-8") as fp:
                    fp.write(json.dumps(record, ensure_ascii=False) + "\n")
            return {"task_id": task["task_id"], "status": "ok", "path": path}

        except urllib.error.HTTPError as exc:
            last_err = f"HTTP {exc.code}"
            # 4xx 里只有 429(限流)值得重试,其余是请求本身错了,重试多少次都一样
            if exc.code < 500 and exc.code != 429:
                break
        except Exception as exc:                      # noqa: BLE001
            last_err = repr(exc)

        if attempt < MAX_RETRY:
            # ⑤ 指数退避 + 随机抖动:避免多个 worker 同时醒来再次撞在一起
            backoff = min(60, 2 ** attempt) + random.uniform(0, 3)
            time.sleep(backoff)

    return {"task_id": task["task_id"], "status": "failed", "error": last_err}


def main():
    if not (API_BASE and API_KEY and MODEL):
        raise SystemExit("缺少环境变量:IMG_API_BASE / IMG_API_KEY / IMG_MODEL")
    os.makedirs(OUT_DIR, exist_ok=True)

    tasks = build_tasks()
    print(f"任务总数 {len(tasks)},并发 {MAX_WORKERS}")

    done = []
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
        futures = {pool.submit(run_one, t): t for t in tasks}
        for fut in as_completed(futures):
            result = fut.result()
            done.append(result)
            print(f"[{len(done)}/{len(tasks)}] {result['status']:<7} {result['task_id']}")

    ok = sum(1 for r in done if r["status"] == "ok")
    skipped = sum(1 for r in done if r["status"] == "skipped")
    failed = [r for r in done if r["status"] == "failed"]
    print(f"\n完成 {ok} 张,跳过 {skipped} 张,失败 {len(failed)} 张")
    for r in failed:
        print("  失败:", r["task_id"], r.get("error"))


if __name__ == "__main__":
    main()
哪些错误值得重试,哪些不值得 代码里这一段是重点:if exc.code < 500 and exc.code != 429: break
5xx 是服务端临时故障,重试有意义;429 是限流,等一会儿再来也有意义;
400(请求体写错了)、401(密钥不对)、403(没权限)重试一万次结果都一样,只会把配额烧光、把日志刷爆,还把真正的错误藏起来。分不清这两类,是重试逻辑最常见的写法错误。

跑起来是这个样子:

batch_pipeline.py 的一次真实运行输出
任务总数 6,并发 3
[1/6] ok      00-flat-3f9c2a1b7e
[2/6] ok      00-ink-8b1d4e6a02
[3/6] skipped 01-flat-c47a90e155
[4/6] ok      01-ink-2e8f71b3d9
[5/6] failed  02-flat-7a3c5d0f81
[6/6] ok      02-ink-6d92b4e738

完成 4 张,跳过 1 张,失败 1 张
  失败: 02-flat-7a3c5d0f81 HTTP 502

skipped 那条说明断点续跑生效了——这张图上一轮已经出过。failed 那条重试四次仍然 502,程序没有崩,只是如实报告:再跑一次命令,它会只补这一张。

4.2 多模态统一抽象层

场景:图已经能出了,产品又要加「给图配一句标题」和「把标题读出来」。三种模态、三家服务、三套 SDK——如果每接一家就在业务代码里写一遍 if provider == "xxx",接到第三家时这份代码就烂透了。

正确做法是把「接哪家服务」和「业务要什么」彻底分开。业务代码只说一句 router.generate(GenRequest("image", "一只橘猫...")),至于这次走的是哪家、失败了换谁顶上,全在抽象层里解决。

1Capability 能力

text / image / audio。业务只认能力名,永远不认厂商名

2Backend 后端

一个具体服务的实现,声明自己支持哪些能力。加一家新服务 = 加一个子类,业务代码一行不改。

3Router 路由器

按能力派发,某家挂了自动换下一家,全挂了才抛异常。

modal_router.py —— 一个 generate() 管住三种模态完整案例
"""多模态统一抽象层:一个 generate() 管住文本、图像、音频三种产出。

为什么要抽这一层:
    每接一家服务就在业务代码里写一遍 if provider == "xxx",接第三家的时候
    代码就烂透了。把「模态 → 能力 → 后端」三件事分开,业务只认能力名。

结构:
    Capability   一种能力(text / image / audio),声明入参与出参形状
    Backend      一个具体后端的实现,注册到某个能力上
    Router       按能力名派发,带兜底与降级

运行(不配任何密钥也能跑,会走 DryRun 后端打印请求):
    python3 modal_router.py
"""
import json
import os
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional


# ---------------------------------------------------------------- 数据结构

@dataclass
class GenRequest:
    """一次生成请求。三种模态共用同一个请求对象,差异放在 options 里。"""
    capability: str                      # "text" / "image" / "audio"
    prompt: str
    options: Dict[str, Any] = field(default_factory=dict)


@dataclass
class GenResult:
    """一次生成的结果。content 的含义由 kind 决定,调用方据此处理。"""
    kind: str                            # "text" / "image_b64" / "audio_b64"
    content: str
    backend: str
    elapsed_ms: int
    meta: Dict[str, Any] = field(default_factory=dict)


class BackendError(RuntimeError):
    """后端失败。抛这个异常表示「这个后端不行,可以换下一个」。"""


# ---------------------------------------------------------------- 后端实现

class Backend:
    """所有后端的基类。加一家新服务 = 写一个子类 + 注册,业务代码一行不改。"""

    name = "base"
    capabilities: List[str] = []

    def available(self) -> bool:
        """环境变量齐不齐。不齐就别参与派发,省得每次都失败一遍。"""
        return True

    def run(self, req: GenRequest) -> GenResult:       # pragma: no cover
        raise NotImplementedError


class DryRunBackend(Backend):
    """不连网的占位后端:把请求原样打印出来。

    它的价值不是演示,是让整条链路在没有密钥的机器上也能跑通,
    这样写业务逻辑的人不必等密钥审批。
    """

    name = "dryrun"
    capabilities = ["text", "image", "audio"]

    def run(self, req: GenRequest) -> GenResult:
        start = time.time()
        payload = {"capability": req.capability,
                   "prompt": req.prompt,
                   "options": req.options}
        body = json.dumps(payload, ensure_ascii=False)
        kind = {"text": "text", "image": "image_b64", "audio": "audio_b64"}[req.capability]
        return GenResult(
            kind=kind,
            content=f"[dryrun] {body}",
            backend=self.name,
            elapsed_ms=int((time.time() - start) * 1000),
            meta={"note": "未真实调用任何服务"},
        )


class OpenAICompatImageBackend(Backend):
    """任何 OpenAI 兼容的图片接口。换厂商只改 base_url,业务代码不动。"""

    name = "openai-compat-image"
    capabilities = ["image"]

    def __init__(self):
        self.base = os.environ.get("IMG_API_BASE", "").rstrip("/")
        self.key = os.environ.get("IMG_API_KEY", "")
        self.model = os.environ.get("IMG_MODEL", "")

    def available(self) -> bool:
        return bool(self.base and self.key and self.model)

    def run(self, req: GenRequest) -> GenResult:
        import urllib.request                       # 局部导入:不走这条路就不加载

        start = time.time()
        body = json.dumps({
            "model": self.model,
            "prompt": req.prompt,
            "size": req.options.get("size", "1024x1024"),
            "n": 1,
        }).encode("utf-8")
        request = urllib.request.Request(
            f"{self.base}/v1/images/generations",
            data=body,
            headers={"Authorization": f"Bearer {self.key}",
                     "Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=300) as resp:
                payload = json.load(resp)
            b64 = payload["data"][0]["b64_json"]
        except Exception as exc:                    # noqa: BLE001
            raise BackendError(f"{self.name} 失败:{exc!r}") from exc

        return GenResult(
            kind="image_b64",
            content=b64,
            backend=self.name,
            elapsed_ms=int((time.time() - start) * 1000),
            meta={"model": self.model},
        )


# ---------------------------------------------------------------- 路由器

class Router:
    """按能力派发,失败自动换下一个后端。"""

    def __init__(self):
        self._by_capability: Dict[str, List[Backend]] = {}
        self._hooks: List[Callable[[GenRequest, Optional[GenResult], Optional[Exception]], None]] = []

    def register(self, backend: Backend) -> "Router":
        for cap in backend.capabilities:
            self._by_capability.setdefault(cap, []).append(backend)
        return self

    def on_finish(self, hook) -> "Router":
        """注册一个回调,用来记账、埋点、写溯源清单。"""
        self._hooks.append(hook)
        return self

    def generate(self, req: GenRequest) -> GenResult:
        chain = [b for b in self._by_capability.get(req.capability, []) if b.available()]
        if not chain:
            raise BackendError(f"没有可用后端支持能力:{req.capability}")

        last_exc: Optional[Exception] = None
        for backend in chain:
            try:
                result = backend.run(req)
            except BackendError as exc:
                last_exc = exc
                for hook in self._hooks:
                    hook(req, None, exc)
                continue                              # 换下一家,不让整条链路死在一家身上
            for hook in self._hooks:
                hook(req, result, None)
            return result

        raise BackendError(f"能力 {req.capability} 的后端全部失败:{last_exc!r}")


def main():
    router = (Router()
              .register(OpenAICompatImageBackend())   # 优先走真实服务
              .register(DryRunBackend()))             # 兜底:密钥没配也能跑通链路

    def audit(req, result, exc):
        status = "ok" if result else "fail"
        who = result.backend if result else "-"
        print(f"  [{status}] cap={req.capability} backend={who} err={exc}")

    router.on_finish(audit)

    for req in [
        GenRequest("image", "一只橘猫坐在窗台上看雪,扁平插画风格", {"size": "1024x1024"}),
        GenRequest("text", "给这张图写一句二十字以内的中文标题"),
        GenRequest("audio", "把上面那句标题读出来,语速偏慢"),
    ]:
        print(f"请求:{req.capability}")
        result = router.generate(req)
        preview = result.content[:60].replace("\n", " ")
        print(f"  → kind={result.kind} backend={result.backend} "
              f"{result.elapsed_ms}ms content={preview}...\n")


if __name__ == "__main__":
    main()
设计点为什么这样定
available() 预检环境变量没配齐的后端直接不参与派发。否则每次调用都要先失败一遍才轮到下一家,白白浪费一次超时等待
DryRunBackend 兜底它的价值不是演示,而是让整条链路在没有密钥的机器上也能跑通。写业务逻辑的人不必等密钥审批下来才能动工
局部 importurllibrun() 里才导入:不走这条路径就不加载,启动更快,依赖也更清楚
on_finish 回调埋点、计费、写溯源清单全挂在这里。观测能力要在抽象层留口子,等出了问题再加就要改遍所有调用点
GenResult.kind返回值自带类型标记(text / image_b64 / audio_b64),调用方据此处理,不靠猜
这层抽象什么时候值得做 只接一家、只用一个模态时,它是过度设计。但只要出现「第二家」或「第二个模态」,就该立刻补上——这两件事在 AIGC 项目里几乎必然发生:不是因为架构洁癖,而是因为单一供应商的限流、涨价、下线模型版本都不受你控制。

4.3 生成溯源清单

场景:三个月后有人指着一张图问「这张哪来的?提示词是什么?哪个模型?能重新生成一张同风格的吗?」——没有记录,这些问题一个都答不上来。

这正是铁律落到工程上的样子。质检员要能查账,账本就是这份清单。

provenance.py —— 记账、校验、出报告完整案例
"""生成溯源清单:让每一张出图都能回答「谁、用什么、怎么生成的」。

为什么必须做:
    出图一多,三个月后没人说得清某张图的提示词、模型版本、随机种子。
    想复现复现不了,想排查侵权风险也查不到源头。
    这份清单写的是「生成参数」,不是「内容真伪」—— 它证明不了图里的东西是真的。

功能:
    record()   追加一条生成记录(含文件指纹)
    verify()   按指纹校验文件有没有被换掉或损坏
    report()   汇总成 Markdown 表,交付时随包给出

运行:
    python3 provenance.py demo      # 造两条演示记录并校验
"""
import hashlib
import json
import os
import sys
import time
from typing import Dict, Iterator, List

LEDGER = "provenance.jsonl"


def file_sha256(path: str, chunk: int = 1 << 20) -> str:
    """分块算哈希:图片动辄几 MB,别一次性读进内存。"""
    digest = hashlib.sha256()
    with open(path, "rb") as fp:
        while True:
            block = fp.read(chunk)
            if not block:
                break
            digest.update(block)
    return digest.hexdigest()


def record(path: str, *, prompt: str, model: str, seed=None,
           extra: Dict = None, ledger: str = LEDGER) -> Dict:
    """给一个已生成的文件登记一条记录。"""
    if not os.path.exists(path):
        raise FileNotFoundError(path)

    entry = {
        "path": os.path.relpath(path),
        "bytes": os.path.getsize(path),
        "sha256": file_sha256(path),
        "prompt": prompt,
        "model": model,
        "seed": seed,
        "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "generator": "ai",          # 明确标注这是机器生成的产物
    }
    if extra:
        entry.update(extra)

    with open(ledger, "a", encoding="utf-8") as fp:
        fp.write(json.dumps(entry, ensure_ascii=False) + "\n")
    return entry


def load(ledger: str = LEDGER) -> Iterator[Dict]:
    if not os.path.exists(ledger):
        return iter(())
    def _gen():
        with open(ledger, encoding="utf-8") as fp:
            for line in fp:
                line = line.strip()
                if line:
                    yield json.loads(line)
    return _gen()


def verify(ledger: str = LEDGER) -> List[Dict]:
    """逐条校验文件是否仍与登记时一致。返回有问题的条目。"""
    problems = []
    for entry in load(ledger):
        path = entry["path"]
        if not os.path.exists(path):
            problems.append({**entry, "issue": "文件已丢失"})
            continue
        if file_sha256(path) != entry["sha256"]:
            problems.append({**entry, "issue": "内容与登记时不一致"})
    return problems


def report(ledger: str = LEDGER) -> str:
    """汇总成 Markdown 表,直接贴进交付说明。"""
    rows = list(load(ledger))
    if not rows:
        return "(暂无记录)"
    lines = ["| 文件 | 模型 | seed | 生成时间 | 指纹前 12 位 |",
             "| --- | --- | --- | --- | --- |"]
    for r in rows:
        lines.append("| {path} | {model} | {seed} | {created_at} | `{short}` |".format(
            path=r["path"], model=r["model"],
            seed=r.get("seed") if r.get("seed") is not None else "-",
            created_at=r["created_at"], short=r["sha256"][:12]))
    return "\n".join(lines)


def _demo():
    os.makedirs("demo_assets", exist_ok=True)
    for name, text in [("a.txt", "第一张图的占位内容"), ("b.txt", "第二张图的占位内容")]:
        with open(os.path.join("demo_assets", name), "w", encoding="utf-8") as fp:
            fp.write(text)

    record("demo_assets/a.txt", prompt="一只橘猫坐在窗台上看雪,扁平插画",
           model="demo-image-model", seed=20260127)
    record("demo_assets/b.txt", prompt="雨后老城巷口,水墨风格",
           model="demo-image-model", seed=20260129)

    print(report())
    print("\n校验:", verify() or "全部一致 ✅")

    # 故意改动一个文件,验证校验确实能抓出来
    with open("demo_assets/b.txt", "a", encoding="utf-8") as fp:
        fp.write("(被人改过)")
    print("改动后校验:", verify())


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "demo":
        _demo()
    else:
        print(report())
字段例子为什么要记
prompt一只橘猫坐在窗台上看雪…想再生成一批同风格的,靠它;想排查「为什么出了这种图」,也靠它
model模型名与版本同一句提示词换个模型版本,出图可能完全不同。只记「用了 AI」等于没记
seed20260127复现的关键。没有 seed,同样的提示词每次出的图都不一样
sha256文件指纹证明这个文件从登记到现在没被换过、没损坏
generator: "ai"固定值明确标注这是机器产物。内部台账里把这件事写清楚,比事后靠回忆强得多

python3 provenance.py demo 会看到它先出一张表,再做一次校验,然后故意改动一个文件、校验立刻报出问题:

provenance.py demo 的输出:出表 → 校验 → 改动后再校验
| 文件 | 模型 | seed | 生成时间 | 指纹前 12 位 |
| --- | --- | --- | --- | --- |
| demo_assets/a.txt | demo-image-model | 20260127 | 2026-02-03T10:12:41 | `9f2c7a1b4d08` |
| demo_assets/b.txt | demo-image-model | 20260129 | 2026-02-03T10:12:41 | `4e81d35c6a97` |

校验: 全部一致 ✅
改动后校验: [{'path': 'demo_assets/b.txt', ..., 'issue': '内容与登记时不一致'}]
⛔ 这份清单能证明什么、不能证明什么 它能证明「这张图是用哪个模型、哪句提示词、哪个 seed 生成的,之后没被人掉包」
不能证明图里的内容是真的——铁律在这里再说一遍:可追溯 ≠ 可信。溯源解决的是责任链问题,事实核查仍然要靠质检员去做。把这两件事混为一谈,是很多团队合规方案里最大的漏洞。

三个案例的关系

案例解决的问题什么时候必须有
批量流水线要出的内容超过十几件,手点已经不现实
统一抽象层复杂度出现第二家供应商或第二种模态
溯源清单责任内容要对外发布的那一刻——不是上线后补,是上线前就得有

三者叠起来,就是一条能长期运转的内容产线:抽象层管派发,流水线管吞吐,清单管交代

05骨架模板:拿去改就能用

一份统一客户端 + 一份批量任务配置,覆盖日常九成场景

5.1 统一客户端骨架

把 4.1 的重试退避、4.2 的多后端降级、4.3 的事件回调三件事合成一个文件。业务代码从此只写两行:client = AigcClient()data = client.image("...")

它替你扛了什么对应代码位置
密钥不进源码每个 provider 只登记环境变量名 env_key,值在运行时才读
一家挂了自动换下一家_dispatch() 按登记顺序遍历,顺序即优先级
该重试的才重试_call_with_retry() 只对 5xx 与 429 退避重试,4xx 直接抛出
环境没配齐的后端不参与p["base"] and os.environ.get(p["env_key"]) 预检
埋点、计费、溯源有地方挂on_event 回调,在成功与失败两条路径上都会触发
aigc_client_skeleton.py —— 统一客户端,只改 TODO 处可复用模板
"""AIGC 统一客户端骨架 —— 复制后只改 TODO 处。

这份骨架把「接哪家服务」和「业务要什么」彻底分开:
    业务代码只说 client.image("一只橘猫..."),
    换厂商、加降级、加重试、加审计都在这个文件里改,业务代码一行不动。

依赖:标准库即可。
"""
import base64
import json
import os
import random
import time
import urllib.error
import urllib.request
from typing import Any, Callable, Dict, List, Optional

# ---------------------------------------------------------------------------
# TODO 1:把你要接的服务填在这里。
#   key      给业务代码用的短名字
#   base     服务根地址,不带结尾斜杠
#   env_key  存放密钥的环境变量名(密钥本身绝不写进源码)
#   model    默认模型名
#   caps     这家服务支持哪些能力
# ---------------------------------------------------------------------------
PROVIDERS: List[Dict[str, Any]] = [
    {
        "key": "primary",
        "base": os.environ.get("IMG_API_BASE", "").rstrip("/"),
        "env_key": "IMG_API_KEY",
        "model": os.environ.get("IMG_MODEL", ""),
        "caps": ["image"],
    },
    # TODO 2:要加第二家做降级就再补一段,顺序即优先级
    # {
    #     "key": "backup",
    #     "base": os.environ.get("BACKUP_API_BASE", "").rstrip("/"),
    #     "env_key": "BACKUP_API_KEY",
    #     "model": os.environ.get("BACKUP_MODEL", ""),
    #     "caps": ["image", "text"],
    # },
]

MAX_RETRY = 4        # TODO 3:重试次数与超时按你的服务实际情况调
TIMEOUT = 300


class AigcError(RuntimeError):
    pass


class AigcClient:
    def __init__(self, providers=None, on_event: Optional[Callable] = None):
        self.providers = providers if providers is not None else PROVIDERS
        # TODO 4:把埋点/审计/写溯源清单挂在这个回调上
        self.on_event = on_event or (lambda **kw: None)

    # ------------------------------------------------------------- 对外能力

    def image(self, prompt: str, size: str = "1024x1024", **opts) -> bytes:
        """文生图,返回图片二进制。"""
        payload = self._dispatch("image", prompt, {"size": size, **opts})
        return base64.b64decode(payload["data"][0]["b64_json"])

    def text(self, prompt: str, **opts) -> str:
        """文本生成。TODO 5:不同厂商的文本接口字段不一致,在这里对齐。"""
        payload = self._dispatch("text", prompt, opts)
        return payload["choices"][0]["message"]["content"]

    # ------------------------------------------------------------- 内部实现

    def _endpoint(self, capability: str) -> str:
        return {
            "image": "/v1/images/generations",
            "text": "/v1/chat/completions",
        }[capability]

    def _body(self, capability: str, provider: Dict, prompt: str, opts: Dict) -> bytes:
        if capability == "image":
            body = {"model": provider["model"], "prompt": prompt,
                    "size": opts.get("size", "1024x1024"), "n": 1}
        else:
            body = {"model": provider["model"],
                    "messages": [{"role": "user", "content": prompt}]}
            body.update({k: v for k, v in opts.items() if k != "size"})
        return json.dumps(body).encode("utf-8")

    def _dispatch(self, capability: str, prompt: str, opts: Dict) -> Dict:
        usable = [p for p in self.providers
                  if capability in p["caps"] and p["base"] and os.environ.get(p["env_key"])]
        if not usable:
            raise AigcError(f"没有可用服务支持能力 {capability};检查环境变量是否配齐")

        last_err = None
        for provider in usable:
            try:
                return self._call_with_retry(capability, provider, prompt, opts)
            except AigcError as exc:
                last_err = exc
                self.on_event(event="provider_failed", provider=provider["key"], error=str(exc))
        raise AigcError(f"全部服务失败:{last_err}")

    def _call_with_retry(self, capability, provider, prompt, opts) -> Dict:
        url = provider["base"] + self._endpoint(capability)
        key = os.environ[provider["env_key"]]
        body = self._body(capability, provider, prompt, opts)

        for attempt in range(1, MAX_RETRY + 1):
            req = urllib.request.Request(
                url, data=body,
                headers={"Authorization": f"Bearer {key}",
                         "Content-Type": "application/json"},
                method="POST")
            try:
                started = time.time()
                with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                    payload = json.load(resp)
                self.on_event(event="ok", provider=provider["key"],
                              capability=capability, attempt=attempt,
                              elapsed_ms=int((time.time() - started) * 1000))
                return payload
            except urllib.error.HTTPError as exc:
                # 只有 5xx 和 429 值得重试;其余是请求本身写错了
                if exc.code < 500 and exc.code != 429:
                    raise AigcError(f"HTTP {exc.code},请求有问题,不重试") from exc
                err = f"HTTP {exc.code}"
            except Exception as exc:                      # noqa: BLE001
                err = repr(exc)

            if attempt == MAX_RETRY:
                raise AigcError(f"{provider['key']} 重试 {MAX_RETRY} 次仍失败:{err}")
            time.sleep(min(60, 2 ** attempt) + random.uniform(0, 3))

        raise AigcError("不可达")


if __name__ == "__main__":
    # TODO 6:换成你自己的调用
    client = AigcClient(on_event=lambda **kw: print("  事件:", kw))
    data = client.image("一只橘猫坐在窗台上看雪,扁平插画风格")
    with open("out.png", "wb") as fp:
        fp.write(data)
    print("已保存 out.png", len(data), "字节")
✅ 复制后你只需要改这六处 TODO 1 填第一家服务的地址、环境变量名、模型名 · TODO 2 要降级就再补一家 · TODO 3 按你的服务调重试次数与超时 · TODO 4 把埋点/溯源挂到 on_event · TODO 5 不同厂商文本接口字段不一致,在 text() 里对齐 · TODO 6 换成你自己的调用。其余代码不用动。
一个容易忽略的取舍 模板里的降级是「换一家重做一次」,不是「续跑」。对文生图来说这没问题(本来就是一次性的);但如果你接的是按 token 计费的长文本生成,换家重做意味着前面的费用白花。这种场景要么把 prompt 切小,要么在 on_event 里记账并设一个单次请求的花费上限。降级策略要按计费模式定,不能照抄。

5.2 批量任务配置模板

把「要生成什么」从代码里搬到配置文件里。改需求时改 JSON,不动代码——这样非开发的同事也能提需求。

batch_jobs.example.json —— 题材 × 风格的任务配置可复用模板
{
  "_说明": "批量出图的任务配置。题材与风格分开列,程序做笛卡尔积展开;加一个风格是加一行,不是复制一整段提示词。",
  "output_dir": "out_images",
  "manifest": "out_images/manifest.jsonl",
  "concurrency": 3,
  "max_retry": 4,
  "timeout_seconds": 300,
  "default_size": "1024x1024",
  "subjects": [
    {"key": "cat-snow", "text": "TODO:写清楚画什么,主体 + 场景 + 动作"},
    {"key": "old-alley", "text": "TODO:第二个题材"}
  ],
  "styles": [
    {"key": "flat", "text": "TODO:风格描述,例如 扁平插画风格,纯色块,描边干净"},
    {"key": "ink", "text": "TODO:第二种风格"}
  ],
  "negative_prompt": "TODO:不想要的东西,例如 文字、水印、多余的手指",
  "seed": null,
  "_seed说明": "填一个整数可复现同一张图;填 null 表示每次随机。要复现就必须记进 manifest。"
}
字段怎么填踩过的坑
subjects / styles分开列,程序做笛卡尔积把风格直接写进每条题材里,加一种风格就要改 N 行,而且改漏一行不会报错,只会悄悄出一批风格不统一的图
key短、稳定、只用字母数字和连字符它会进文件名。用中文或空格,在跨平台同步、打包下载时会出各种编码问题
concurrency3~5 起步,按限流调调到 20 不会更快,只会让失败率飙升,然后重试把配额烧掉
negative_prompt写不想要的东西文字、水印、多余的手指是最常见的三项。不填不是中性,是放任
seed要复现就填整数,否则 null填了但没记进 manifest,等于没填——下次照样复现不出来

5.3 两份模板怎么配合

配置文件产品/运营改这里:要什么内容
流水线展开任务、并发、重试、续跑
统一客户端派发到具体服务、降级、埋点
溯源清单出货前记账,质检员查账

这条链路的分工很清楚:配置管「要什么」,流水线管「怎么跑」,客户端管「找谁做」,清单管「怎么交代」。四者解耦之后,换供应商不影响配置,改需求不影响代码,出了问题能顺着清单一路查回提示词。

06易错点汇总

按「认知 / 分类 / 技术 / 工程 / 合规」五类归并,踩过一次就别再踩

⚠️ 一、认知层面

  • 把「像」当成「对」。 生成式模型的目标函数里从来没有「真实性」这一项,它优化的是「看起来像不像训练数据」。所以格式完美但根本不存在的文献条目、结构错误的手、查无此处的地名,在模型眼里都是高分输出。这不是 bug,是定义如此。
  • 以为 AIGC 是某一个模型。 它是一整类能力与产业,横跨文本、图像、音频、视频、多模态五个方向,底下至少三条技术路线。问「AIGC 用什么模型」等于问「制造业用什么机器」。
  • 把 AIGC 和判别式模型混为一谈。 判别式学 P(y|x) 给答案,有标准答案可验证;生成式学 P(x) 造新东西,只有「像不像」。两者的评估方式、失败形态、风险等级全都不同。
  • 以为「生成模型 2022 年才出现」。 VAE 与 GAN 是 2014 年,扩散模型是 2015 年。这几年变的不是有没有,而是质量跨过了可用线,同时算力便宜到能大规模推理。

⚠️ 二、分类与选型

  • 只用一种分法定位需求。 按技术分(孪生/编辑/生成)定的是风险与质检强度,按产出分(文本/图像/音频/视频/多模态)定的是买哪类服务、要多少算力。只看一边,要么低估风险,要么买错服务。
  • 把「图像超分」和「文生图」按同一套流程上线。 前者是内容孪生,风险低,可以自动化;后者是内容生成,事实与版权风险都高,必须留人工复核通道。同一个团队用同一套审核策略覆盖两者,迟早出事。
  • 站错产品层。 应用层团队一上来就想自己训底座,是最常见的资源错配——基础层的门槛是几千张显卡。先定自己在哪一层,再定技术方案。
  • 以为中间层和应用层不用为内容负责。 用户看见的是你的产品,「模型是别人的」推不掉责任。无论哪一层,出货口都得有质检与溯源。

⚠️ 三、三条技术路线

  • 以为 VAE 已经被淘汰了。 它作为「生成器」确实退场了(图偏糊),但作为压缩与还原的编解码器还在主力位置上——下一页要讲的潜空间扩散,最后那一步还原像素图用的就是 VAE 解码器。
  • 说不清 GAN 为什么难训。 两个顽疾要记住:训练不稳定(两个网络实力失衡,判别器太强则生成器拿不到有效梯度)与模式崩溃(生成器只画某一种能骗过判别器的图,多样性归零)。
  • 把扩散模型的前向过程也当成要训练的部分。 前向加噪是纯数学操作,不需要训练;要学的只有反向降噪——具体说是「预测本步混进来的噪声」这个回归任务。
  • 问「扩散模型为什么不一步到位」答不上来。 因为「从纯噪声一步变清晰图」这个映射极难学;拆成几十步后,每一步都只是个简单回归题。难度被时间摊平,这就是它又稳又好的根本原因。
  • 忽略扩散模型最大的代价。 采样要迭代几十步,速度是三条路线里最慢的。它能上生产,靠的是后来把扩散搬进低维潜空间这一工程手段。

⚠️ 四、工程实现

  • 把响应体直接写成 .png 接口返回的是 b64_json —— base64 文本,不是图片二进制。必须先 base64.b64decode(),否则得到一个打不开的文件。
  • 按调普通接口的经验设超时。 出图几十秒是常态,timeout 至少给到 120~300 秒。设 10 秒会得到一堆超时错误,而服务端其实正在正常出图。
  • 对所有错误一视同仁地重试。 5xx 与 429 重试有意义;400/401/403 重试一万次结果都一样,只会烧配额、刷爆日志,还把真正的错误藏起来
  • 重试退避不加随机抖动。 多个 worker 会在同一秒集体醒来,再次撞在一起,退避形同虚设。
  • 并发开到几十。 瓶颈在服务端 GPU 不在你的网络,并发拉高只会让失败率飙升。3~5 起步,按限流实测调。
  • 直接写目标文件。 中途中断会留下半张坏图,而且下次「已存在就跳过」的逻辑会把这张坏图当成功。先写 .partos.replace(),改名是原子操作。
  • 多线程写同一个日志文件不加锁。 两条记录会交错成一行乱码,事后无法解析。
  • 密钥硬编码进源码。 后果不是不优雅,是这个文件一旦被提交、截图或贴进工单,密钥就泄漏了,对方花的是你的钱。一律 os.environ.get(...)
  • 提示词只写主体。 「一只猫」会得到风格随机、构图随机的图,重跑一次又是另一个样子。画什么 + 什么风格 + 什么构图,三要素缺一就飘。
  • 每接一家服务就在业务代码里加一个 if provider == ... 接到第三家时这份代码就烂透了。出现第二家或第二种模态,立刻抽统一抽象层。

⚠️ 五、合规与交付

  • 把「可追溯」当成「可信」。 溯源清单能证明这张图是用哪个模型、哪句提示词、哪个 seed 生成的、之后没被掉包;它证明不了图里的内容是真的。事实核查仍然要人来做。
  • 只记「用了 AI」。 没有模型名与版本、没有 seed、没有提示词,三个月后既复现不了也排查不了。等于没记。
  • 填了 seed 却不记进清单。 下次照样复现不出来。
  • 上线后才补溯源。 内容对外发布的那一刻就该有记录,事后补的账本没有证据价值。
  • 用生成内容替代需要核实的事实。 数据、引用、条款、人名这类内容,模型给出的是「像那么回事的写法」,必须逐条外部核验后才能进正式材料。

07自测题

点击题目展开答案;能把这 16 题说清楚,这一讲就通了

一、AIGC 是什么
AIGC 的全称是什么?一句话说清它和「AI 辅助工具」的区别。

AI Generated Content,人工智能生成内容。区别在于产出物本身由模型造出来:辅助工具帮你把已有内容改得更好,AIGC 直接交付一件此前不存在的内容(一段文案、一张图、一段配音)。

判别式模型与生成式模型学的东西有什么本质不同?

判别式学的是条件概率 P(y|x)——给定输入判断它属于哪一类,有标准答案可验证;生成式学的是数据分布 P(x)——学会「这类数据长什么样」,然后采样造新的,没有标准答案,只有像不像。评估方式、失败形态、风险等级全都因此不同。

为什么说生成模型「不保证正确,只保证像」?

因为它的目标函数里根本没有「真实性」这一项,优化的是「输出看起来像不像训练数据」。所以格式完美但查无此文的参考文献、结构错误的手、不存在的地名,在模型眼里都是高分输出。这不是 bug,是定义使然。

生成模型是这两年才出现的吗?

不是。VAE 与 GAN 是 2014 年,扩散模型的思想是 2015 年。这几年变的不是「有没有」,而是质量跨过了可用线,同时算力便宜到能大规模推理。

二、分类与产业分层
按技术手段,AIGC 分哪三类?各举一例。

内容孪生(老照片修复、图像超分,把已有内容搬进数字世界)、内容编辑(换背景、改风格、变声,在已有内容上改)、内容生成(文生图、写文案,从零造新内容)。风险从左到右递增。

为什么「图像超分」和「文生图」不能套同一套审核流程?

前者是内容孪生,输入输出强绑定,风险低,可以自动化;后者是内容生成,事实与版权风险都高,必须留人工复核通道。用一套策略覆盖两者,迟早出事。

按产出模态分,AIGC 有哪五个方向?这种分法定的是什么?

文本、图像、音频、视频、多模态。这种分法定的是买哪类服务、要多少算力、用什么评估指标;而按技术手段的分法定的是风险与质检强度。两种分法各管一件事,只看一边要么低估风险,要么买错服务。

产业三层分别是什么?应用层团队最容易犯的资源错配是什么?

基础层(训底座大模型)、中间层(在底座上做垂类微调与工具化)、应用层(面向终端用户的产品)。最常见的错配是应用层团队一上来就想自己训底座——那一层的门槛是几千张显卡。先定自己在哪一层,再定技术方案。

「模型是别人的,出了问题不怪我们」这个说法成立吗?

不成立。用户看见的是你的产品。无论处在哪一层,出货口都必须有质检与溯源。

三、三条技术路线
VAE 被淘汰了吗?它现在主要用在哪?

作为生成器它退场了(出图偏糊),但作为压缩与还原的编解码器仍在主力位置上——潜空间扩散最后那一步把潜表示还原成像素图,用的就是 VAE 解码器。

GAN 的两个顽疾是什么?

训练不稳定:生成器与判别器实力失衡时训练就崩,判别器太强则生成器拿不到有效梯度。模式崩溃:生成器发现画某一种图就能骗过判别器,于是只画那一种,多样性归零。

扩散模型的前向加噪过程需要训练吗?真正要学的是什么?

前向加噪是纯数学操作,不需要训练。真正要学的只有反向降噪——具体说是「预测本步混进来的噪声是什么样」这个回归任务。

扩散模型为什么要拆成几十步,不一步到位?

因为「从纯噪声一步变成清晰图」这个映射极难学。拆成几十步后,每一步都只是一道简单的回归题,难度被时间摊平——这就是它又稳又好的根本原因。

扩散模型最大的工程代价是什么?靠什么手段压下去?

采样要迭代几十步,速度是三条路线里最慢的。它能上生产,靠的是把扩散过程从像素空间搬进低维潜空间——计算量按压缩倍数的平方级下降。

四、工程与合规
调文生图接口拿到 b64_json 后直接写进 .png,会发生什么?

得到一个打不开的文件。接口返回的是 base64 文本,不是图片二进制,必须先 base64.b64decode() 再以二进制写盘。这是第一次调图片接口最常见的坑。

批量出图时,哪些 HTTP 错误该重试、哪些不该?为什么退避还要加随机抖动?

5xx(服务端临时故障)与 429(限流)值得重试;400 / 401 / 403 重试一万次结果都一样,只会烧配额、刷爆日志、把真正的错误藏起来。抖动用来打散多个 worker 的唤醒时刻,否则它们会在同一秒集体醒来再撞一次,退避形同虚设。

溯源清单能证明什么、不能证明什么?至少要记哪几个字段?

能证明这张图是用哪个模型、哪句提示词、哪个 seed 生成的,并且之后没被掉包不能证明图里的内容是真的——可追溯 ≠ 可信。必记字段:提示词、模型名与版本、seed、文件 sha256、生成时间、generator 标记。只记「用了 AI」等于没记。

08术语表

本页出现的术语,按「是什么 / 在哪用 / 别搞混」三栏对齐

术语一句话是什么在哪用 / 别搞混
AIGC
AI Generated Content
由模型直接造出内容的一整类能力与产业不是某一个模型。横跨文本、图像、音频、视频、多模态五个方向,底下至少三条技术路线
判别式模型
Discriminative
P(y|x),给定输入判断它属于哪一类有标准答案可验证。分类、检测、打分都属于这类
生成式模型
Generative
学数据分布 P(x),学会「这类数据长什么样」再采样造新的没有标准答案,只有「像不像」。目标函数里没有真实性这一项
内容孪生 / 编辑 / 生成按技术手段的三分法:搬进来 / 在已有内容上改 / 从零造这套分法定的是风险与质检强度,不是买什么服务。风险从左到右递增
多模态
Multimodal
同一个系统同时处理文本、图像、音频等多种信息形态「图生文」「文生图」都算。与「多个单模态模型拼在一起」不同——真正的多模态在同一个表示空间里打通
VAE
Variational Auto-Encoder
编码器把图压成低维向量,解码器再还原回去作为生成器已退场(出图偏糊),作为编解码器仍是主力——潜空间扩散最后一步还原像素就靠它
GAN
Generative Adversarial Network
生成器造假、判别器打假,对抗着一起变强两个顽疾:训练不稳定(双方实力失衡就崩)、模式崩溃(只会画一种能骗过判别器的图)
扩散模型
Diffusion Model
先把图一步步加噪成雪花,再训一个网络一步步把噪声去掉当前文生图的主流路线。代价是采样要迭代几十步,最慢
前向过程
Forward / Noising
按固定公式逐步往图里加高斯噪声纯数学操作,不需要训练。把它当成「要学的部分」是常见误解
反向过程
Reverse / Denoising
从纯噪声出发,一步步预测并减去噪声,逐渐显出图像真正训练的部分。每一步学的其实是「本步混进来的噪声长什么样」这个回归任务
潜空间
Latent Space
图像被压缩后所处的低维表示空间把扩散搬进潜空间,计算量按压缩倍数的平方级下降——这是扩散模型能上生产的工程前提
提示词
Prompt
告诉模型要生成什么的那段自然语言三要素缺一就飘:画什么 + 什么风格 + 什么构图。它是这条产线唯一的方向盘
负向提示词
Negative Prompt
明确写出「不想要什么」文字、水印、多余的手指是最常见三项。不填不是中性,是放任
seed
随机种子
决定本次采样起点的那个整数复现的关键。填了但没记进溯源清单,等于没填
b64_json图片接口返回的 base64 编码文本字段不是图片二进制。必须先 base64.b64decode() 再以二进制写盘,否则文件打不开
指数退避
Exponential Backoff
失败后等待时间随重试次数翻倍必须叠随机抖动,否则多个 worker 会在同一秒集体醒来再撞一次
限流
Rate Limit / HTTP 429
服务端限制单位时间内的请求数值得重试的错误之一。并发 3~5 起步——瓶颈在服务端 GPU,不在你的网络
原子落盘先写 .part 临时文件,再 os.replace() 改名改名是原子操作。直接写目标文件,中断会留下半张坏图,而且下次会被「已存在就跳过」当成功
断点续跑用提示词哈希做文件名,已存在就跳过让「跑挂了重跑一次」变成只补缺失的那几张,而不是从头再来
统一抽象层业务只声明要什么能力,由路由器决定派给哪家服务出现第二家供应商或第二种模态就该补上。只接一家时它是过度设计
降级
Fallback
首选服务失败时自动切到备选服务模板里的降级是「换一家重做一次」。按 token 计费的长文本场景要另设花费上限
溯源清单
Provenance Manifest
逐条记录每件产物的提示词、模型、seed、指纹、时间能证明没被掉包,证明不了内容是真的——可追溯 ≠ 可信
sha256文件内容的指纹用于校验文件从登记到现在没被换过、没损坏
⛔ 整页压成一句 生成模型给你的是「概率上最像样」的内容,不是「事实正确」的内容。所以这条产线的每个环节都要配一个能查账的质检员:提示词记下来、模型与 seed 记下来、指纹记下来。可追溯是底线,可信仍要人来负责。