WorkForge 搭智能体训练环境,先把任务与奖励边界写清

文章导读
想用 WorkForge 搭训练环境,顺序上建议先把任务写成一张可查的三栏表,再动配置。原因很直接:任务定义、奖励函数、终止条件这三处任何一处写得含糊,训练都会以一种看起来“在跑”的方式跑偏——步数在涨、日志有输出,但智能体学到的和你以为的不是一回事。反过来,任务边界一旦能用状态、动作、终止三栏写清,环境配置就退化成填空题,出错也容易定位到具体一栏。下面用一个最小的单工位取件任务走一遍,命令和字段
📋 目录
  1. 一 把任务拆成状态、动作、终止三栏
  2. 二 奖励函数先写稀疏版再补 shaping
  3. 三 在配置文件里固定依赖与随机种子
  4. 四 跑完一个 episode 并读日志
  5. 五 统计失败 episode 的终止原因分布
A A

想用 WorkForge 搭训练环境,顺序上建议先把任务写成一张可查的三栏表,再动配置。原因很直接:任务定义、奖励函数、终止条件这三处任何一处写得含糊,训练都会以一种看起来“在跑”的方式跑偏——步数在涨、日志有输出,但智能体学到的和你以为的不是一回事。反过来,任务边界一旦能用状态、动作、终止三栏写清,环境配置就退化成填空题,出错也容易定位到具体一栏。下面用一个最小的单工位取件任务走一遍,命令和字段名可按本地实际安装的 CLI 与 SDK 替换。

WorkForge 里最省事的起点不是先调环境参数,而是把任务写成状态、动作、终止三栏并逐栏标注取值范围;奖励先只给成功一个信号,失败和超时都记 0 分且必须带上可区分的终止原因;随后固定依赖版本和随机种子,跑通一个 episode 再谈规模。判断是否跑通看三件事:步数落在上限内、reward 累加只在终止那步跳变、终止原因命中已知分类。终止原因出现 unknown,说明任务边界还没写完,不要急着加中间奖励。

把任务拆成状态、动作、终止三栏

三栏表的作用是把“任务是什么”变成可被配置读取、可被日志核对的东西。每一栏都必须写清取值范围,取值范围决定后面的校验逻辑怎么写。

r>
栏位写什么允许取值范围边界判据
状态智能体做决策时能看到的信息固定维度的离散枚举或归一化连续向量不包含未来信息、不包含动作执行结果
动作可执行动作集合有限离散动作编号,或带上下界的连续区间每个动作都有越界与非法调用的处理分支
终止episode 结束条件成功 / 失败 / 超时三类,互斥且有独立 reason每个 reason 都能在日志中被单独统计

以“把零件搬进料盒”为例,一个够用的填写示例是:状态写成 [agent_x(0-3), agent_y(0-3), carrying(0 或 1), part_x(0-3), part_y(0-3), step_count(0-50)];动作写成 0 上、1 下、2 左、3 右、4 拾取、5 放下,共 6 个离散值;终止写成搬进料盒且 carrying=0 记 success,step_count 达到 50 记 timeout,非法动作用连续次数触发失败记 fail。三栏写完先自查一遍:状态里出现的每个量,动作执行后是否会变;终止条件里每个分支,是否都能在不看代码的情况下从日志复现。

奖励函数先写稀疏版再补 shaping

稀疏奖励的价值在于它是一个干净的对照组。成功给分、其余不给,跑一批 episode 如果 success 始终为 0,至少能确定问题出在任务可达性或探索上,而不是奖励塑形把问题盖住了。稀疏版建议写成按终止原因分支的形式,并对未命中的原因做兜底报错,避免新增终止条件后静默返回 0 分。

WorkForge 搭智能体训练环境,先把任务与奖励边界写清
SUCCESS, FAIL, TIMEOUT = "success", "fail", "timeout"

def compute_reward(info):
    reason = info.get("reason")
    if reason == SUCCESS:
        return 1.0
    if reason in (FAIL, TIMEOUT):
        return 0.0
    # 兜底分支:未登记的终止原因不允许静默给分
    raise ValueError(f"unhandled termination reason: {reason!r}")

def check_success(state):
    return state["carrying"] == 0 and \
           (state["part_x"], state["part_y"]) == tuple(BOX_POS)

什么时候才加中间奖励:先看日志,如果终止原因分布里 timeout 占大头、而且失败 episode 中智能体已经能稳定完成取件这类中间动作,说明卡点在最后一步而不是探索起步,这时补 shaping 才有意义。加的时候建议以配置开关的形式保留稀疏版,能和塑形版做同种子对照;塑形项要能被单独关掉,否则后面出现行为异常时无法判断是谁引入的。

在配置文件里固定依赖与随机种子

两次运行不可比,后面所有对照实验都没有意义。种子建议从配置文件传入、由 runner 统一注入到随机数生成器和环境重置逻辑中,并在日志首行打印实际生效的种子值,避免配置文件写了但没被读取。

WorkForge 搭智能体训练环境,先把任务与奖励边界写清
task: take_and_place_v0
seed: 20240101
env:
  grid_size: 4
  max_steps: 50
  box_pos: [3, 3]
reward:
  type: sparse      # sparse | sparse_plus_shaping
  shaping: false
runner:
  episodes: 100
  log_dir: runs/take_and_place/seed-20240101

依赖固定用锁文件或显式版本号,把环境包、训练框架、日志序列化库一并写进去;容器镜像打 tag 而不是 latest。验证方式很朴素:同一份配置连跑两次,逐 episode 对比 reward 累加值和终止原因序列,一致说明随机源已被收口;对不上就先排查并行环境数、多线程和数据加载顺序,这些通常比算法本身更容易引入不可复现。

跑完一个 episode 并读日志

先只跑一个 episode,确认闭环能走通再谈批量。下面命令里的 CLI 名和参数以本地安装为准,替换成实际的运行入口。

workforge run `--config` configs/take_and_place.yaml `--episodes` 1 `--log-level` info

日志里优先看四个字段:step 是否单调递增且不超过 max_steps;reward 是否只在终止那一步发生变化;cumulative_reward 与逐步 reward 相加是否对得上;reason 是否落在 success / fail / timeout 之内。如果 reason 为空或出现未登记值,说明终止判定没有覆盖这条路径,回到第一栏补齐,而不是先去改奖励。一个 episode 正常跑完的表现通常是:步数停在 max_steps 以内或刚好触到上限,最后一行的 terminated 为 true 且带明确 reason。

WorkForge 搭智能体训练环境,先把任务与奖励边界写清

统计失败 episode 的终止原因分布

“跑不通”是个太笼统的说法,把它拆成可定位的几类才好处理。建议的分类口径是:success、fail_invalid_action(动作越界或非法调用)、fail_rule_violation(违反任务规则,例如同时搬运两个零件)、timeout(步数耗尽)、unknown(未登记原因)。unknown 的计数不为 0,本身就是任务边界未写完的信号。

import json, collections, pathlib

path = pathlib.Path("runs/take_and_place/seed-20240101/episodes.jsonl")
counter = collections.Counter()
for line in path.read_text().splitlines():
    ep = json.loads(line)
    counter[ep.get("reason") or "unknown"] += 1

for reason, n in counter.most_common():
    print(f"{reason}\t{n}")

print("unhandled:", counter.get("unknown", 0))

统计结果只用来决定下一步动作:unknown 多就先补终止分支,timeout 多先看 max_steps 是否过紧或探索是否不足,fail_invalid_action 多则回到动作栏检查越界处理。这些判断依赖的是自己日志里的事实,不需要外部数据支撑;等终止原因分布稳定在两三类主因上,再回头调整奖励塑形或扩大训练规模,改动才容易被验证。