WorkForge 里智能体总学不会——是任务太难还是奖励给错了?

文章导读
训练了很多轮但智能体表现不涨,通常不是「任务太难」和「奖励给错」二选一,而是这两种原因在日志里长得不一样:任务难到探索不到时,成功率长期贴着零、动作分布几乎不变;奖励缺少梯度时,智能体能拿到分,但某一个小分项吃掉了绝大部分回报,策略很快停在同一个局部行为上。建议先别急着降难度,也别急着重写奖励,先跑两条对照实验——固定种子看训练曲线形态,再用随机策略跑一次基线——再决定改哪一头。
📋 目录
  1. A 看训练曲线区分没探索到与奖励没梯度
  2. B 用随机策略跑一次基线
  3. C 拆解奖励各分项的量级
  4. D 逐项调整后再跑对照实验
  5. E 在提高任务难度前先固定评测口径
A A

训练了很多轮但智能体表现不涨,通常不是「任务太难」和「奖励给错」二选一,而是这两种原因在日志里长得不一样:任务难到探索不到时,成功率长期贴着零、动作分布几乎不变;奖励缺少梯度时,智能体能拿到分,但某一个小分项吃掉了绝大部分回报,策略很快停在同一个局部行为上。建议先别急着降难度,也别急着重写奖励,先跑两条对照实验——固定种子看训练曲线形态,再用随机策略跑一次基线——再决定改哪一头。

先定性再动手:成功率恒为零且动作熵不降,先怀疑探索不到和任务可解性;成功率非零但回报不进位、某个奖励分项量级压倒其余,先怀疑奖励没梯度。用随机策略基线确认任务本身可解,再拆奖励分项量级,每次只改一个变量并固定评测口径。具体阈值要结合自己的环境和历史日志判断,不要照搬外部数值。

看训练曲线区分没探索到与奖励没梯度

两类形态有比较明显的差别,先看清楚再改配置,能省掉很多无效重跑。

  • 探索不到:success_rate 从训练开始到结束基本贴近零;episode_return 的方差很小,说明每次回合结果都差不多失败;动作熵不降或者集中到少数几个动作;失败原因日志里反复出现同一类错误,比如超时、无效动作、在第一步就被环境拒绝。
  • 奖励没梯度:success_rate 有波动但没有台阶式上升;return 有数值,可是波动来源单一;奖励各分项里有一项恒为常数或量级远大于其他项,策略只要维持某个固定行为就能拿分;模型输出逐渐模板化,换种子结果依然雷同。

判断依赖日志字段,训练前就把下面这些字段落到每轮记录里,缺了后面很难回看:

# 每轮汇总一条记录,字段固定,方便后面画曲线和对比
record = {
    "step": step,
    "success_rate": ok_count / total_episodes,
    "mean_return": mean(returns),
    "return_std": std(returns),
    "mean_episode_len": mean(lengths),
    "action_entropy": entropy(action_counter),
    "invalid_action_rate": invalid / total_actions,
    "top_fail_reason": most_common(fail_reasons, 3),
    "reward_parts_mean": {k: mean(v) for k, v in reward_parts.items()},
}
writer.write(json.dumps(record, ensure_ascii=False))

把这些字段按 step 画出来,两条曲线形态基本能直接对号入座。如果 success_rate 和 action_entropy 都不动,先去做随机策略基线;如果 success_rate 有值而某个 reward_parts_mean 长期不变,先去做分项拆解。

用随机策略跑一次基线

随机策略基线要回答的是「这个任务在当前动作空间和回合预算下,有没有可能拿到非零分」。如果连随机采样都拿不到任何正分、也拿不到任何中间进度分,先检查环境 reset/step 和奖励计算是否接通,而不是马上去调难度。

# 通用接入骨架:把训练用的 rollout 换成策略函数即可,接口按自己环境替换
import random

def random_policy(obs):
    return random.choice(env.action_space)  # 有动作掩码时在此处过滤

def run_episodes(policy, n_episodes, seed=0):
    random.seed(seed)
    rows = []
    for i in range(n_episodes):
        obs = env.reset(seed=seed + i)
        total, parts, steps, done = 0.0, {}, 0, False
        while not done and steps < MAX_STEPS:
            action = policy(obs)
            obs, reward, done, info = env.step(action)
            total += reward
            for k, v in info.get("reward_parts", {}).items():
                parts[k] = parts.get(k, 0.0) + v
            steps += 1
        rows.append({"episode": i, "return": total,
                     "success": info.get("success", False),
                     "steps": steps, "parts": parts})
    return rows

结果按固定格式落盘,不要只在终端看一眼:每行一个 episode,字段包含 return、success、steps、各奖励分项累计值,外加一个 seed 字段。跑三到五组种子,看随机策略的 success 是否出现过 true、return 的分布是全部相同还是有差异。全部相同往往意味着奖励在某个分支上被短路了。

拆解奖励各分项的量级

分项量级问题最常见的表现是:总分看着在涨,但涨的部分几乎全部来自一个容易拿的分项,真正关心的目标项贡献接近零。做法是在加权汇总前,把每个分项单独打印出来。

def compute_reward(info, weights):
    parts = {
        "goal": info.get("goal_score", 0.0),
        "progress": info.get("progress", 0.0),
        "format": info.get("format_ok", 0.0),
        "penalty": -info.get("invalid_actions", 0) * PENALTY_UNIT,
    }
    if LOG_REWARD_PARTS:
        print("parts:", {k: round(v, 4) for k, v in parts.items()})
    total = sum(parts[k] * weights[k] for k in parts)
    return total, parts

采样若干回合后,把分项均值填进下表。加权后占比一列是关键,它决定梯度主要被谁牵动。

WorkForge 里智能体总学不会——是任务太难还是奖励给错了?
分项原始均值权重加权后均值占总回报比例是否恒为常数
goal
progress
format
penalty

如果 format 或 progress 这类项占比过高且几乎每次都拿满,先把该项改成增量式或加上限,再观察 goal 项是否开始动。改的时候一次只动一项。

逐项调整后再跑对照实验

对照组设置建议用 A/B 两条:A 组保持现有配置不动,作为基准;B 组只改一个变量,比如把某个分项的权重下调一个档位,或者把常数项改成增量项。两组用同一批种子、同一训练预算、同一评测集,跑完对比 success_rate 和分项均值的变化方向,而不是只看总回报。

需要保持不变的条件清单:

  • 随机种子集合与任务采样顺序
  • 任务难度、任务数量与环境超时设置
  • 训练步数、回合预算、批大小、学习率等超参
  • 评测频率、评测集内容与成功判定函数版本
  • 日志字段与记录格式

如果两条曲线都平,说明这一次改动没起效,回到分项表继续找下一项,而不是一次性把权重全改一遍。同时改多项后即使指标变好,也无法判断是哪一项带来的。

在提高任务难度前先固定评测口径

难度和评分标准同时变动,后面所有历史曲线都会失去可比性。建议在提难度之前先把评测脚本的固定项写死:评测任务列表和种子、每个任务的最大步数、成功判定函数、评分聚合方式(平均回报还是通过率),以及评测过程中的随机性来源。

# 评测输出按配置指纹命名,历史结果直接留在同一个目录
result = {
    "eval_id": eval_config_hash,      # 评测集 + 判定函数版本的哈希
    "task_list": TASK_IDS,
    "max_steps": MAX_STEPS,
    "mean_return": mean(returns),
    "success_rate": ok / n,
    "per_task": {tid: {"return": r, "success": s} for tid, r, s in detail},
}
with open(f"eval/{eval_config_hash}_{run_tag}.jsonl", "a") as f:
    f.write(json.dumps(result, ensure_ascii=False) + "\n")

每次评测都写一条独立记录,按哈希和时间留档,不要覆盖上次结果。这样在提高任务难度后,能直接拿同一评测口径下的新旧记录做比较,判断到底是难度上去了还是评分标准变了。