Hugging Face官方博客详细教程:用TRL和OpenEnv将编程模型微调出水彩画风格,涵盖强化学习训练流程与环境配置。
8 月 23 日,Surya Narreddi 发布了一段语言模型绘制水彩画的视频,美得令人惊叹。该模型通过 p5.brush(一个"为 p5.js 添加自然绘画工具"的库)编写 JavaScript 来作画。视频迅速走红,截至撰写本文时已获得超过 150 万次播放。
视频附带了一篇博客文章,解释了训练过程——那是项目早期的一个较窄阶段,只画特写花卉而非视频中的完整构图,可惜目前尚未开放产物。他的网站称完整的技报即将发布,请务必关注他。最初的想法来自他,源于艺术与设计那一面,而他的技能远在我之上。我的尝试则从工程角度出发,用公开的方式复现这个配方,每一步都对外发布。
注:关于项目的背景故事,由 Surya 本人亲口讲述,请观看他的论文视频。
在本文中,我尝试用 TRL 和 OpenEnv 来复现他的想法。参考池数据集、RL 环境、训练脚本以及训练好的模型,全部开放。
整个流水线在 Hugging Face 上端到端运行:
一旦两个 Space 就绪,配方便是一条命令。克隆环境与评分模型,设置两个环境变量来控制奖励配比,然后启动:
hf jobs uv run train/watercolour_grpo.py --flavor h200 --timeout 48h --secrets HF_TOKEN -- \
--env-url https://<you>-watercolour-env.hf.space \
--model Qwen/Qwen3.5-35B-A3B --lora --all-linear --bf16 --gradient-checkpointing \
--subject 'a peach hibiscus' --references 4 \
--top-p 0.95 --top-k 20 \
--lr 5e-5 --lr-scheduler constant_with_warmup --warmup-steps 5 \
--scale-rewards none \
--steps 110 --n-episodes 240 --num-generations 8 \
--per-device-batch-size 1 --gradient-accumulation-steps 8 \
--max-completion-length 8192 \
--run-tag my-run --out <you>/watercolour-grpo --push-to-hub
本文的其余部分是实现过程的完整故事,每一个组件都在仓库里。
我严格按照原博客的步骤来进行,只有在不得不改的情况下才做改动。我自己的每一个新想法都先记到一张清单上,而不是直接塞进实验,这张清单最终成为末尾的"下一步尝试",与已发布产物的完整列表并列。如果你已经读过他的文章,框架和奖励设计会让你觉得眼熟。新材料是开源实现、手工评级的池,以及三种奖励配比下的训练与对比——从"你需要构建的 RL 环境"这一节开始。
三次运行,三种奖励配比,并行演化。每帧展示的是一个 step 中间的画作。现在还不需要区分它们,文章会解释哪个跑对应哪种配置。
这些画作看起来松散、不完美、手工感十足,而此刻的图像模型产出的却是完美的(统计意义上平均的)画面。我认为这种对比是视频走红的很大一部分原因。它让我想起生成式 AI 艺术的早期岁月,那时的主题是探索媒介本身。DeepDream(2015 年)是一个调试工具,被人们转化为艺术;Edmond de Belamy(2018 年)这样的作品来自艺术家对 GAN 能做什么的探索;而 Mario Klingemann 那几年一直在用神经网络制作梦幻肖像。
这个项目更接近那些早期时光。在论文中,Surya 描述了通往这里的路径。他一开始是给文生图模型写 prompt,但 prompt 是唯一能拉的杠杆,更详细的描述只能在一定程度上买到更多控制权。直接训练模型本身则走得更远。想法的另一半是媒介。模型写出一个约 150 行的 JavaScript 程序来绘制图像。这个模型输出的是代码。你可以阅读它、编辑它、再次运行它,每一笔背后的决策都是可见的。而风格来自于一个约束——模型只被允许使用该库的十个方法。详见下文。
在同一时期,Anna Ridler 拍摄了数千张郁金香的照片,为每一张手工打标签,将数据集本身作为艺术品展出,后来又在其上训练了一个模型。我是通过 AI 智能体在构建这个项目时带回的参考资料发现她的作品的,我喜爱她的作品,因为这个项目用非常相似的方式运作——手工策划一组图像,然后用它们做训练。
近期大多数语言模型的 RL 工作使用可以验证的奖励。例如有标准答案的数学题、能通过测试的代码,或者非对即错的评分器,而且运行成本很低。这个项目更接近更早期的那类例外——RLHF,即模型从人类偏好中学习奖励模型。
这里的奖励是美学偏好。没有标准答案。项目的真正问题是:能否在品味上做 RL。
奖励的定义——正如他的博客中所定义、也正如我构建的 RL 环境所实现的那样:
HPSv3 是一个开放的 7B 偏好模型。给它一张图像和一段文字描述,它返回一个分数,表示人类对该图像的偏好程度。它在一个大规模的人类图像对选择数据集上训练,因此它的分数是许多人品味的平均值。成对裁判是 Qwen3-VL-30B-A3B-Instruct,一个通用视觉模型,通过 HF Inference Providers 调用。成对裁判将候选画作与从池中随机选取的四幅参考图并排展示,依据一份书面描述来指导权重(洇染、半透明水洗、柔和边缘),每组比较在两种呈现顺序下各做一次,它的分数是候选画作赢得比较的占比。裁判的唯一标准是池,因此它的分数代表我的品味,编码在那套评级里。
奖励函数中的四个项中有两个是模型。两者都是某个人品味的代理。
这些是 Narreddi 收敛到的权重。池在这里定义了品味。这将工作从调超参数转变为构建那个决定什么是美的集合。
我用这个奖励训练了三次运行。它们只在两个模型裁判之间的权重分配上有所不同:
我先用 hps-only 来验证流水线是否能够学习。一旦奖励在上升、指标保持健康,就没有理由让它继续跑下去,于是转而启动了另外两个更长的运行。长运行要回答的问题是:你能把 HPSv3 的多少能力交给成对裁判?裁判承担的权重越大,奖励就越代表我的品味而不是所有人的品味,攀爬应该越困难。顺带一提,如果你推得够远,或者你的风格与平均值差距太大,模型可能完全停止。
幸运的是,它没有停止,而且两个有,成对裁判参与的运行都学会了。手工评级的池至少在指标和最终画作显示的范围内,能够引导策略。数据在下面。
免责声明。如果我们用前沿模型,它已经能根据 prompt 生成绘制水彩的 JavaScript 代码。那是起点。这里的工作是为一个更小的模型做这件事,并与一个人的个人艺术偏好结合起来。
你需要构建的 RL 环境
环境封装了模型与奖励之间的所有东西,包括模型用于绘画的 JavaScript 库、约束它的系统提示、渲染每张草图的无头 Chromium,以及拒绝作弊的门控。
这个库做的事比看起来多。p5.brush,由 @acamposuribe 开发,模拟一种媒介而非绘制形状:颜料会洇出填充区域的边缘、纸张有纹理、笔触有重量、流场会拖拽笔触。当模型调用 brush.fillBleed(0.25) 时,它在决定墨水洇开多远。
注。p5.brush 的作者在这件事之前很久就在尝试教机器绘画了。2022 年他做了一个生成艺术系列,将一部关于教 p5.js 像孩子一样画画的日记藏在了作品里:"它几乎不会用蜡笔 [...] 它听不懂简单指令。我今天就到这里,太气人了。"这个系列本该有三件作品,他只做了两件。当 Surya 的视频走红后,他转发了它,共享了那个日记,并说这件作品是第三件,自己来了。
p5.brush 暴露了 47 个方法。提示词允许其中 10 个:scaleBrushes、noStroke、fill、noFill、fillBleed、fillTexture、beginShape、vertex、endShape 和 circle。其余三十七个——线条、阴影线、自定义画笔——会破坏水彩画的效果。有了这 10 个方法,模型只能绘制填充形状,而库会给每一个形状添加渗透效果。
一次 rollout 中 draw() 的部分内容及其渲染结果。注释是模型自己写的。Reward 0.864,129 行,第 22 步。每幅画的完整源代码都在 rollouts 数据集中。

他的博客文章为我节省了大量时间,避免了把时间浪费在提示词迭代上。冗长的 API 文档会让模型发明不存在的方法,而他的 200 次 GEPA 迭代收敛到了一个严格的白名单,且没有任何文档说明。我看到了同样的失败,于是手动编写了白名单。我对那个配方唯一增加的一句话是:每片花瓣画两到三遍,先画一层大致的,再在里面画一层更小、更不透明的。这一小改动让我的输出丰富多了色彩。
注:如果你是第一次听说 GEPA,它是一个自动提示词优化器。语言模型用平实的语言反思当前提示词的失败之处,然后提出更好的版本,循环往复。
门控是最后一块拼图。草图必须能够编译、使用库而非直接调用 p5、在画布上放上真正的颜料、且不能试图欺骗评分器——例如在画布上写文字。
池由 178 幅画组成,分为两个等级,基于我个人的偏好:love 和 okay。它们实际上都是由模型生成的。四个开源模型通过推理提供商调用,写出 p5.brush 草图,每一个都基于一张来自 iNaturalist 的真实、开放许可的木槿照片。一位视觉模型对每个草图给出书面反馈,经过三轮迭代精炼。然后我一次一幅地对每个最终渲染进行评分,178 幅通过了筛选。
这里我选择了四个不同的模型族来测试它们不同的风格。这四个是开放模型中在快速可靠性检查中每次都能生成有效草图的,另外两个候选者因未能通过检查而被淘汰。如果你想生成自己的池,可能会选择其他的。
两个等级的实际样子。对评分持不同意见是合理的,某人的判断现在就是奖励函数。

这两个等级在奖励中做了真实的工作。当成对评判器抽取四个参考时,一半来自 love,一半来自 okay,所以策略总是面对一些它有时能击败的对手,无论击败哪个等级的收益都是一样的。这是我少数几个有意的改动之一:原版只与顶级比较,我把较简单的等级保留在抽取中,这样早期较弱的策略仍然能得到信号。
里面没有任何人工绘制的画,这是一个真正的局限。p5.brush 是一个小众的库,其中存在可访问代码的人类作品只有少量作品,远不够一个训练语料库所需的量,正如他的博客也指出的那样。
这里有一个有趣的想法,正如我之前已经讨论过的,模型将学习模仿池中包含的内容。如果我们将环境指向一个不同的数据集,奖励将自动改变,无需触碰任何一行代码。对于我生成并公开分享的数据集,我还包含了源草图。
如果仔细看评判器,两者回答的是不同的问题。HPSv3 决定它是否是一朵花,而成对评判器决定它是否以我选择的风格被很好地绘制。
在任何东西起作用之前,有一段很长的平坦奖励曲线期。如果你尝试过复现一篇研究论文/博客但没有开放的产物,你大概能理解。每次运行都测试了我认为合理的关于问题所在的理论。一次运行需要大量时间,所以我在分析上一次运行结果的同时排下一次运行。和往常一样,从能工作的更简单的东西开始,然后在此基础上构建,是答案。一个简单的控制任务,没有浏览器和评判器,是第一个能够学习的尝试,原因是我的学习率太低了。
三次奖励实验,三条平坦的线。我换了池、移除了渲染器噪声、关闭了成对评判器,但都没有让曲线移动。产生移动的那次运行改变了训练器配置,所以差异在训练器,而不是奖励组合。

另一个让我花费时间发现的改动是正确调整 LoRA 参数。通常的 target_modules 列表假设一个密集模型,而 Qwen/Qwen3.5-35B-A3B 是一个混合专家模型,其大多数投影命名不同,所以适配器训练了四十层中的十层。我通过将其改为 all-linear 解决了这个问题,这会触达每一个线性层。这个架构中被路由的专家是融合张量,即使 all-linear 也让其保持冻结,但其他所有都得到了适配器,这就足够学习了。
修复涉及 TRL 的 GRPOTrainer 中的四个改动:
这四个改动解锁了第一次成功运行(hps-only),奖励明显在改善。
使用该配置后,三次运行都学会了。两个 judge 运行都设置为 200 步后在 110 步停止,奖励仍在缓慢攀升。每步需要十五到十八分钟,组合之间的比较已经稳定,所以两个我都停止了以节省算力。每个运行的前三分之一和最后三分之一的平均组奖励:

三条曲线与我个人审美权重的一致程度相符。评判器权重越大,起始值越低,攀登过程越嘈杂。judge-led 在最初三十步几乎平坦,然后才开始移动。这与调试解决的思路相同。把问题缩小到有东西能学习,然后一次一个地把困难的部分加回来。
成对评判器项本身在使用了它的两次运行中攀升了。随着训练推进,模型在对抗池的对比较中赢得更多,这是 hps-only 无法声称的。没有任何组的奖励在任何运行中崩溃到相同值,这是 GRPO 的失败模式,会杀死梯度。好奇的话,per-metric 曲线(HPSv3、paint coverage、entropy)都在仓库的 CSV 文件中。
完整的启动命令、硬件配置和两个将这个变成另外两个运行的环境变量都在配方中。
在每次运行中,模型首先学会的是停止产生糟糕的画——那些总奖励低于 0.3 的近乎空白画布和无形色块。在 hps-only 中,组均值四分之三的提升来自糟糕的画变得罕见。在 judge 运行中崩溃更为陡峭:低于 0.3 的 rollout 从 judge-led 的三分之一中的 99 下降到 16,hps-led 中从 37 下降到 4。

这就是为什么明显的可视化——每步的最佳画作——在 hps-only 中几乎看不到差异的原因。它在整个运行中移动了 +0.034,而中位数移动了 +0.155。学习在分布的中间部分可见。
成对评判器改变的是顶部。在 hps-only 中,画变得更可靠了,但没有变得更好。好的那些的质量对组均值的贡献只有 +0.03,而一旦 HPSv3 看到了围绕一个中心和一根茎的花瓣,它就不再要求更多颜料。有了评判器,故事的另一半就出现了。更好意味着更接近池,也就是更接近我评为"好"或我更喜欢的东西。在 judge-led 中这增加了 +0.12,在 hps-led 中增加了 +0.16,每步的最佳也上升了,而且两次运行的颜料覆盖率都翻倍了(0.11 到 0.23,以及 0.13 到 0.30),而 hps-only 几乎没动。有了要击败的参考,一幅好画仍然可以变得更好,模型开始因使用更多颜料而获得奖励。
还有另一个发现。模型忽略了一个明确的指令,而它这样做是对的。系统提示词要求十五到三十个填充形状。如果我们看真实的均值,它在 7 到 9 之间,而 n_shapes 在任何运行中与奖励几乎不相关(+0.000、−0.14、+0.07)。策略没有因为遵守那句话而获得奖励,所以它不遵守它。
hps-only 路线也有一个天花板。如果每个 rollout 都与其好的那些匹配,那个运行的均值会停留在 0.771。更多步数是否会打破它是一个开放的问题。
这些画还展示了一些表格中没有的东西。在每一次训练运行中,它们的风格都很相似。随着训练的推进,每组内的奖励值越来越接近,开篇视频中的中间画作看起来像是同一朵花的不同拍片。这就是 GRPO 在只用一个主体的池子中所做的事情。池子决定什么算多样性,就像它决定什么算质量一样。如果奖励只针对匹配某一朵花,那么模型就学着画那一朵花。要有更多样化的输出,就需要更多样化的池子,而构建这样的池子需要更多的策展工作。Surya 较新的作品就是一个例子。Alex Yango 的动物画用了相同的配方,只是池子里的选择不同。这就是审美奖励和数学评分器之间最大的区别。在数字背后是一份非常人类化的工作——决定什么应该进入奖励集。Jason Liu 关于品味的文章用一句话说出了通用版本。AI 将瓶颈从「制作」转移到了「发现」。
Surya 在博客结尾展示了他的一些最爱。下面我没有选自己的,而是放了一面墙,展示奖励评分最高的 178 幅画——与参考池数量相同,不分排名顺序。打开它,选出你自己的。
两次评分运行中奖励最偏爱的 178 幅画,打乱顺序。现在选出你的最爱。

做出选择时,你看了很多画,只留下几幅,而这正是构建这个项目奖励的整个工作。每一次运行中的每一幅画,及其草图和奖励,都在 rollouts 数据集中,可以在这个画廊里浏览。
最后一步各次运行的中间画作,顺序与开篇视频相同。相同的基础模型,相同的池子,三种奖励配比,三种风格。

既然奖励部分基于我的品味,作为一个观众,最后以我的评判来收尾也算公平。在我看来,judge-led 是最终最多样化、最具艺术趣味的运行。hps-led 画出了令人信服的水彩,但它最好的那些画有一种柔和的湿画法外观,几乎算得上一种独立的风格。hps-only 收敛得最厉害,大部分画作都固定在相同的颜色上。你可以在画廊里自己判断,那里收录了每一次运行的每一幅画,可以按步骤和奖励排序。
这个项目主要是基础设施。一次运行需要一个 trainer、两个 Spaces、一个 inference router 和一个 websocket 才能连续几小时保持健康,每一个静默失败的组件都会变成别处的错误数字。一半的工作是在检查你读到的数字是否与实际发生的事相符。
基础设施的故障被计入了奖励为 0 的情况。超时的渲染或没有响应的评分器与糟糕的画作得分相同,在组内都是 0.0。在我的所有运行中,这约占 rollouts 的 1.5%,在最差的一次运行中达到了 5.2%。这会在模型身上注入噪声,所以这些路径现在会返回 None,rollout 被排除在组之外。
我还在 OpenEnv 中发现了一个 bug,并把修复提交到了上游。客户端维护一个持久化的 websocket,而远端关闭的 socket 仍被缓存,所以之后所有的调用都失败了,即使环境实际上是健康的。我花了两个半完成的运行才发现这个问题。修复已提交到上游,使用它启动的运行此后一直干净运行。
judge-led 运行连续两步的最佳四幅画。step 12 的画是否看起来差了半分,由你来判断。

一个步骤的奖励取决于它抽取了哪些参考。成对评分器每步抽取四个参考,所以每一步面对的是不同的对手组合,有些抽签就是更难。GRPO 本身是相当安全的,因为优势是在组内计算的,一个难的抽签是整体一起移动的。我读到的曲线并不安全,有些看起来像糟糕步骤的东西实际上只是因为抽签难。上图就是一个例子。Step 12 比 step 11 低半分,主要是因为它抽到了运行中最难的参考,而画作本身看起来很接近。
四舍五入的数字,仅针对完成的运行。
一步是八个 rollouts,需要 15 到 18 分钟,其中 70% 到 80% 是渲染。单次渲染需要 69 到 96 秒,而截止时间是 90 秒。部分原因是预期的——Space 没有 GPU,所以 Chromium 用软件渲染 WEBGL 画布,而 p5.brush 的晕染和纹理是繁重的像素工作。即便如此,我预期它会更快,但我还没有找到完整的原因。
评分器的成本可能比使用它的训练还贵:HPSv3 必须在整个运行期间保持在线,所以当运行结束时,暂停 Space,或者设置它的睡眠计时器。
四个付费服务必须同时保持健康。只有 Hub 和 trackio 的生命周期超过运行本身。

一切都在 HF Jobs 上运行,环境是 Docker Space,指标在 trackio 中。
这个项目的规则是用所有资源开放来复现配方,而不是改进它,所以一路上积累了一份未尝试的想法清单。