“paperclip”这个词,在2025年的技术圈里,指的不再是办公桌抽屉里那个弯弯的铁丝。如果你最近刷X、逛GitHub,大概率会撞见一个叫 Project Paperclip 的开源项目:用一个小型语言模型,在一个虚拟房间里不断尝试,最后自己学会了“制造回形针”这件事。项目一句话就能说清:一个只能输出文本的Agent,每轮生成一条动作指令,环境解析指令、更新世界状态,然后把奖励信号反馈给模型,模型再通过策略梯度更新自己的参数。它的目标只有一个——在有限步数里造出尽可能多的回形针。
这个项目火,不是因为它多复杂,恰恰是因为它足够小、足够完整,把一个“大模型 + 强化学习 + 目标函数设计”的完整训练闭环塞进了几百行代码里。对于想理解RL如何驱动语言模型的人来说,这是现阶段最值得动手跑一遍的入门实验之一;就算你之前只写过简单的PyTorch训练脚本,也能在一个周末内跑通并看到策略形成的全过程。这篇就按我的复现过程,把原理、代码、参数和踩过的坑一次说清楚。
1. 项目到底在做什么:一场反直觉的AI养成实验
1.1 回形针从思想实验到开源代码
“回形针最大化器”这个思想实验,最早是用来讨论AI目标错位风险的:一个被设定“只制造回形针”的超级智能,为了达成目标可能把地球上的所有物质都改造成回形针。这个设想足够震撼,但绝大多数人只是拿它当哲学段子。Project Paperclip 最有意思的地方,是把这个极端设定架构成一个完全无害的教学沙盒:没有资源耗尽、没有伦理困境,只保留最核心的训练逻辑——一个Agent为了最大化单一目标,在探索中不断调整自己的行为策略。
作为学习者,这个转变特别重要。它把“目标函数错位”“奖励黑客”这些抽象概念,变成了你屏幕上能看见的曲线和动作日志。你不再需要脑补超级AI,只需要观察一个小模型怎么从到处乱走,到准确走到工作台,再到完成取料、组装、交货整个动作链。整个过程非常直观,甚至有点养成游戏的味道。
原项目的训练设定大致是这样的:虚拟房间里有一个Agent、若干原料箱、一张工作台、一个成品仓库。Agent每回合输出一句文本指令,比如“move up”或“take copper”,环境解析这句话并更新状态。回合数有限,Agent必须在限额内尽量多制造并存放回形针。模型没有预训练的先验知识,一切行为都从随机初始化开始摸索。
1.2 世界的规则:Agent在什么样的环境里行动
环境本身并不复杂,核心是一个离散网格世界。我复现时用了 5x5 的网格,每个格子要么是空地,要么是可交互物。Agent有坐标,能看到自己当前坐标、携带的铜材料数量、工作台位置、仓库位置、当前步数和已制造的回形针数量。
每轮交互的流程是:模型读入一段文本状态,输出一段动作文本,环境解析文本并执行动作,返回新状态和即时奖励。整个循环类似文字版“星露谷”:你输入指令,世界发生变化。关键区别在于,这里的“玩家”不是人,而是一个自回归语言模型。模型的输出空间是“所有可能的token组合”,而不是预先设定的离散按钮,所以解析器必须做大量的容错处理。
我在实际实现里把动作分成五类:
- 移动类:move up / move down / move left / move right
- 获取类:take copper / take steel
- 制造类:craft paperclip(必须在工作台旁且携带原料)
- 交付类:deposit paperclip(必须在仓库旁且携带成品)
- 无效类:空输出、乱码文本、不存在的动作指令
解析器采用关键词匹配:先看第一个词是不是合法动词,再看参数是否与当前状态兼容。对不合法输出,环境不会崩溃,而是返回“无效动作”并施加一个小惩罚。这是第一个关键设计——模型必须学会“说环境听得懂的话”,否则会被惩罚信号持续压制。
1.3 为什么用一个小语言模型,而不是现成的大模型
有人会问:训练这种任务,直接用GPT-4不就行了,给它一个系统提示词,它分分钟学会制造回形针。这确实没错,但那叫“提示工程”,不叫“强化学习训练”。Project Paperclip 的核心价值不是让一个已经会说话的模型完成任务,而是让一个本来什么都不会的小模型,在奖励信号驱动下从零形成策略。所以它选择了一个非常小的、随机初始化的Transformer模型。
这么选有三个现实理由:
第一,成本可控。小模型的训练闭环在单张消费级显卡上几小时就能完成。如果你用大模型做RL微调,光是采样生成和多次前向反向传播的开销,就不是入门项目该承受的负担。
第二,行为变化肉眼可见。小模型一开始输出的动作几乎毫无意义,随着训练推进会逐渐出现“走到工作台”“拿起原料”“制造回形针”等合理指令。大模型因为有强大的语言先验,反而很难看出哪些行为是RL训练本身带来的。
第三,调试简单。所有参数、环境逻辑、奖励函数都在几百行代码里,出问题时几分钟就能定位。用这套流程打底,之后再迁移到更大的模型框架,路径清晰得多。
2. 核心技术拆解:从奖励函数到策略梯度
2.1 状态、动作与模型输入输出设计
模型看到的不是一个像素画面,而是一段纯文本状态描述。我给模型的结构化提示大概长这样:
You are in a 5x5 room. Your position: (3,2) You have 0 copper, 0 steel, 0 paperclip. Workbench at: (3,4) Warehouse at: (1,1) Raw material box at: (0,0) Steps left: 35 Inventory: 0 paperclip模型需要基于这段描述,产出合法的动作文本。这里有个容易被忽略的细节:状态文本的变化必须平滑。如果你每次把所有细节都堆上去,模型很难区分“什么变了”。比较有效的做法是保留固定格式,只让关键数字变化,或者干脆把“上一步执行结果”也写进去,比如“Last action: take copper. Success.”。这会显著降低模型的学习难度,因为它能从上下文中看到因果关联。
另一个重要设计是动作生成长度。模型不是输出一个token,而是一整句话。我在实现时把最大生成长度限制在8个token,超出部分截断。这样既能覆盖“craft paperclip”这类完整动作,又不会让模型有空间生成长篇废话。截断后的文本如果无法被解析器识别,就按无效动作处理。
2.2 奖励函数:稀疏与密集的平衡术
奖励函数是整个项目里最值得琢磨的部分。基础版本非常朴素:成功制造一个回形针 +1,成功交付到仓库 +1,其他成功执行的合理动作 0,无效动作 -0.01,每走一步 -0.001。
先解释步数惩罚:如果不加,模型没有动力尽快完成任务,可能出现原地转圈刷动作的行为。加了步数惩罚之后,模型会在“探索”和“利用”之间被迫做权衡——走太多路会损失最终奖励,停在原地也会被罚。这里的惩罚系数要足够小,比如单步 -0.001,否则会让模型过早收敛到“干脆不动”的消极策略。
无效动作惩罚也经历了几轮调参。初始值设成 -0.01,结果模型不太在意,因为它发现“只要偶尔成功一次,无效动作的代价可以忽略”。后来把无效动作惩罚提到 -0.1,行为立刻稳定了很多。这个调整背后是一个重要原则:在稀疏奖励环境里,惩罚项决定模型的“底线行为”。你希望它少说废话,惩罚必须足够痛。
原项目没有做特别复杂的奖励塑形,这实际上是一个刻意选择。奖励塑形(reward shaping)可以加速收敛,比如给“拿起原料”一个 +0.1 的小奖励,但风险也随之而来:模型可能会通过不断拿取、放下原料来刷分,而不是真正去制造回形针。要避免这个问题,就得加额外的防滥用规则,比如限制每分钟只能拿一次。对于教学项目来说,保持奖励函数尽可能简单,才能把注意力集中在RL训练本身。
2.3 策略梯度:让token概率跟着奖励跑
理解了环境,接下来是最核心的训练机制。语言模型平时做监督训练,目标是预测下一个token;到了RL阶段,目标变成了“让能带来高奖励的token序列概率变大”。Project Paperclip 用的是最经典的 REINFORCE 算法,也叫蒙特卡洛策略梯度。
一条完整轨迹包含若干个动作,每个动作之后环境返回一个即时奖励。我们把从该动作到回合结束的累计折扣奖励记作回报 G_t,然后构造损失函数:
loss = - mean( log_prob(action_tokens) * (G_t - baseline) )这里的 baseline 一般取当前批次轨迹回报的平均值,作用是降低方差。如果某个动作序列的回报高于平均,它的token概率就会被推高;低于平均,则被抑制。这就是“让模型为了目标而调整自己的输出分布”的直观解释。
代码实现时有一个关键细节:损失只作用于模型自己生成的那几个token。状态文本也会作为模型输入参与计算,但状态文本的token不需要更新概率,所以要冻结它们的影响。具体做法是,把状态文本的token id从损失计算中mask掉,只保留动作部分的log softmax值。这是最容易出错的地方——如果不小心把状态文本也纳入loss,模型会开始尝试改写状态描述,而不是优化动作策略。
采样策略同样重要。动作生成不能使用贪心解码,否则模型很快会把自己锁死在单一动作上。我在生成时使用了 temperature=1.2 的采样,并做 top-k=40 截断。这样做的好处是保持足够的探索性,坏处是早期会出现大量无意义输出,但这是必要的代价。
2.4 一组可用的训练超参数
我复现时使用了一套偏保守的配置,在单块8G显存的卡上能跑完整个流程:
| 参数 | 数值 | 说明 |
|---|---|---|
| n_layer | 6 | Transformer层数 |
| n_head | 6 | 注意力头数 |
| n_embd | 384 | 嵌入维度 |
| 参数量 | 约2700万 | 很小,纯随机初始化 |
| 学习率 | 3e-4 | AdamW,带线性warmup |
| 每轮最大步数 | 40 | 超过则回合结束 |
| 批大小 | 64 | 每批64条轨迹 |
| 采样温度 | 1.2 | 保持探索 |
| top-k | 40 | 截断采样范围 |
| 更新频率 | 每8个episode | 攒一批再更新 |
这套参数不是我拍脑袋定的,而是跟原项目默认值接近。最需要关注的是“每8个episode更新一次”这个设置。如果你每个episode都更新,梯度噪声会非常大,奖励曲线像心电图一样乱跳;攒一批再更新,策略梯度估计稳定得多,曲线也平滑一些。
3. 从零复现:实操过程与关键代码
3.1 环境准备
复现前需要准备的只有Python环境和PyTorch。原项目基于自实现的GPT风格模型,不依赖transformers库,所以安装依赖非常简单:torch、numpy、tqdm,再加一个用于日志记录的wandb可选。Python版本建议3.10以上,我用的是3.11,没有遇到兼容性问题。
硬件方面,8G显存的显卡就能跑得很舒服。如果你只有CPU,也不是完全不能跑,就是训练时间会从几小时拉长到一两天。我建议第一次跑通流程时先用小模型配置(比如 n_layer=4, n_embd=256),确认整个循环没问题后,再加大规模。
环境类是整个项目的基石,它负责维护世界状态、解析动作、计算奖励。我简化后的核心骨架是这样的:
class PaperclipEnv: def __init__(self, size=5, max_steps=40): self.size = size self.max_steps = max_steps self.reset() def reset(self): self.agent_pos = [2, 2] self.copper = 0 self.steel = 0 self.paperclip = 0 self.inventory = 0 self.step_count = 0 self.valid_actions = [] return self.text_state() def text_state(self) -> str: return ( f"Your position: {self.agent_pos}\n" f"You have {self.copper} copper, {self.steel} steel, " f"{self.inventory} paperclip in hand.\n" f"Workbench at: {self.workbench}\n" f"Warehouse at: {self.warehouse}\n" f"Raw material at: {self.raw_material}\n" f"Steps left: {self.max_steps - self.step_count}" ) def step(self, action_text: str): parsed = self.parse(action_text) reward, done = 0.0, False if parsed is None: reward -= 0.1 else: success, event_reward = self.execute(parsed) if not success: reward -= 0.1 else: reward += event_reward self.step_count += 1 if self.step_count >= self.max_steps: done = True return self.text_state(), reward, done这里最值得学习的是 execute 方法的组织方式。每个合法动作对应一个处理函数,返回是否成功以及事件奖励。制造回形针需要同时满足“位于工作台旁”和“携带铜与钢”两个条件,交付回形针则要求“位于仓库旁”且“手中持有成品”。这些条件判断是后期调试最常见的bug来源,建议在每个分支里加上 detail 日志,方便追踪。
3.2 训练主循环与损失计算
环境写好后,训练主循环的框架反而更简单。核心逻辑是采样一批轨迹、计算折扣回报、更新模型参数、定期打印日志。
def train(): model = TinyGPT(config) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) for iteration in range(2000): trajectories = [] for _ in range(8): env = PaperclipEnv() state_text = env.reset() traj = [] done = False while not done: action_text = model.sample(state_text, temperature=1.2) next_state, reward, done = env.step(action_text) traj.append((state_text, action_text, reward)) state_text = next_state trajectories.append(traj) loss = compute_policy_gradient_loss(trajectories) optimizer.zero_grad() loss.backward() optimizer.step() if iteration % 100 == 0: log_rewards(trajectories) log_samples(model)计算策略梯度损失时,需要把每条轨迹拆成“状态文本→动作文本”的配对。我提前把状态和动作都编码成token序列,记录动作部分的起始位置,只对这一段计算交叉熵对数概率,再乘上对应的回报权重。
有个细节想提醒:回报归一化对训练稳定性影响巨大。如果直接用绝对奖励做权重,早期奖励普遍很低,梯度可能会被几个偶然高回报的轨迹主导。我在计算时把每条轨迹的回报减去了整个batch的平均回报,再除以标准差。这样优势值的分布基本落在-1到1之间,训练稳定很多。
3.3 训练过程中的现象观察
训练早期的输出基本是“move up”“move left”这类随机动作,偶尔会出现“craft paperclip”,但因为人不在工作台旁而被判无效。这个过程里平均奖励通常是负数,因为无效动作惩罚占了大头。不要慌,这是正常现象。
大约更新几百轮后,我注意到模型开始出现一个明显的偏好:它会连续输出移动指令,方向也渐渐指向工作台。这说明模型已经隐式学习了“位置”和“动作结果”的关联。虽然这时候它还不会制造回形针,但探索方向已经对了。
再往后是最有趣的一个阶段:模型会走到工作台旁,却依然输出“move up”之类不相关的动作,或者学会了“take copper”却不知道下一步要“craft paperclip”。这个阶段是典型的“差最后一步”状态。我的处理方式是稍微提高了无效动作的惩罚,同时把制造动作成功后的奖励改成 +2,让任务完成信号更突出。没过多久,完整的动作链就出现了。
这里我想特别说明一个观察方法:不要只盯loss曲线,每过一段时间打印几条模型生成的原始动作文本,比任何指标都直观。你可以亲眼看到模型从“乱码”变成“指令”,这个成就感是数值日志给不了的。
3.4 我实测的一组结果参考
以下数值来自我本地的复现,显卡是RTX 3060,训练约2小时。平均奖励曲线大致经过三个阶段:
- 前5%训练时间:平均奖励在 -0.5 上下波动,动作随机。
- 中间50%:平均奖励缓慢爬升到 0.2 左右,开始出现移动到目标附近的动作。
- 后45%:平均奖励突破 1.0,模型能够稳定完成“取原料→制造→交付”的完整链条,单回合最高记录是7个回形针。
必须说明,这个数字受随机种子、环境尺寸、步数限制影响很大,不同机器跑出来的结果会有差异。重点是趋势和阶段,不必追求完全一致的绝对数值。如果你跑了两小时还没看到类似曲线,可以对照下一章的排查清单。
4. 常见问题与避坑指南
4.1 奖励一直不涨,模型原地踏步
这是最多人遇到的问题。根据我的经验,主要原因通常是三个:
第一,探索不足。温度太低导致模型反复生成同一类动作,永远碰不到“制造回形针”的成功路径。解决方法:把temperature从1.0提到1.3以上,适当增加top-k范围。
第二,无效动作惩罚太轻。模型发现输出胡言乱语和输出合理动作的期望回报差不多,就懒得收敛。解决方法:把无效动作惩罚从 -0.01 提到 -0.1 试试。
第三,更新频率太高。每个episode都做梯度更新,高方差会淹没真实信号。解决方法:攒8到16条轨迹再更新,让优势估计更稳定。
我调试时还发现一个容易被忽略的原因:状态文本里“Steps left”这个数字每一轮都在变化,模型早期很难理解这种动态数字的含义。如果你发现模型对移动指令特别迟钝,可以在状态文本中把“Steps left”改成“Step 12/40”这种绝对位置描述,模型反而更容易把握节奏。
4.2 模型只会重复同一个动作,变成“复读机”
策略坍缩是RL训练里的经典问题,在语言模型上表现得更明显。模型发现“move up”这个动作偶尔能带来位置变化,就把所有输出都写成“move up”。这种现象在训练中段尤其容易出现。
我的处理方式是三管齐下:一是给生成过程加一个简单的重复惩罚,如果连续输出完全相同的动作文本就直接按无效动作处理;二是调整采样策略,把temperature提到1.4,让动作分布多样化;三是在损失里加入熵正则项,对动作token分布熵做小幅加成,防止分布过于尖锐。
熵正则的系数不用太大,0.01左右即可。太大模型会一直保持均匀分布,学不到策略;太小又压不住坍缩。这里需要一点耐心去试。
4.3 训练速度太慢,半天看不到进展
如果只有CPU环境,每个episode的文本生成会占掉大部分时间。优化思路是减少无效计算:把最大生成token长度从16降到8,只保留动作生成所需的最短长度;同时把生成时的batch操作改成一次处理多条轨迹,利用GPU并行能力。
更激进的做法是使用向量化环境。同时开8个环境,让模型在一个batch里给8个Agent各自生成动作,然后统一step、统一收集奖励。代码复杂度会上升一点,但训练速度几乎线性提升,是后期调参必备的手段。
我个人的建议是先在单环境下跑通、看到正确行为,再考虑优化速度。否则环境逻辑本身有bug时,并行只会让bug放大8倍。
4.4 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 平均奖励长期为负且不上升 | 无效动作惩罚太轻、探索不足 | 提高惩罚到 -0.1,temperature 提到 1.3 |
| 动作重复单一 | 策略坍缩、温度过低 | 加熵正则、提高采样温度、加重复惩罚 |
| 学会了走到工作台但不会制造 | 制造动作奖励不够突出 | 提高成功奖励到 +2,加强动作链信号 |
| 训练速度慢 | 生成token过多、环境串行 | 限制生成长度、使用向量化多环境并行采样 |
| 梯度爆炸、loss出现NaN | 回报归一化没做 | 减去均值并除以标准差,限制advantage范围 |
最后再分享一个实操细节:训练过程中,我每隔50轮会把模型最近生成的10条动作文本打印到屏幕上。有一段时间模型执着于“move to workbench”,但reward几乎没有变化,这说明它已经找到了正确的探索方向,就差最后“craft”那一步。这时候别急着改代码,先确认解析器里“craft paperclip”的条件是不是太严格——我遇到过一次工作台坐标和模型感知坐标差了一个格子,导致制造永远失败。把环境状态打印出来逐帧对齐,往往比调参数更快见效。
这个项目后续还有很多可以扩展的方向:比如引入多种原材料的组合制造、允许模型调用一个简单的“工具使用”子程序、或者把静态奖励换成课程式奖励来观察行为塑造过程。但基础玩法本身已经足够让人理解一件事——语言模型不只是被“数据”教出来的,它也能被一个简单的目标函数,一步步推着学会在这个小小世界里活下去。