ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SKILLRL:用递归技能库与进化策略破解强化学习样本效率难题

SKILLRL:用递归技能库与进化策略破解强化学习样本效率难题

1. 项目概述

1.1 核心需求解析

干强化学习这行的朋友,应该都有过这种体验:训练一个智能体从零开始在复杂环境里摸爬滚打,跑了上百万步,结果它还在原地打转。尤其是在稀疏奖励、长周期任务这类场景下,智能体经常学了半天连个像样的策略都憋不出来。我在实际项目里遇到过不少次这种“训练半天,不如随机策略”的尴尬局面,后来慢慢意识到,问题往往出在智能体缺少可复用的结构性知识——它每次都是在平地上建楼,没有脚手架,也没有预制件。

我们这次聊的SKILLRL,核心思路就是给强化学习智能体装上一套“技能库”,并且让技能之间可以递归组合。直白点说,就是让智能体先把简单动作学成“技能”,再把技能组合成更高级的技能,层层递进,形成一棵技能树。这样到了新任务里,智能体不需要从头学起,而是直接调用、组合已有的技能模块,学习效率能提升一个量级。

这个项目面向的读者,主要是已经在做强化学习项目、但苦于样本效率太低或者多任务泛化能力太弱的同学。不管你是做游戏AI、机器人控制,还是搞决策智能体,这套“递归技能增强”的思路都能给你不少启发。

1.2 场景与影响范围

我最初接触这个方向,是在做一个多阶段机器人操作任务的时候。那个任务要求机械臂先抓取零件、再装配、最后检测,三个阶段连在一起,每一步之间的过渡特别讲究。用普通强化学习算法训了很久,智能体总是学会第一步就忘了第二步,学了第二步又把第一步的策略搞崩了。后来参考了层级强化学习和技能发现方向的一些做法,把“抓取”“移动”“插入”分别定义成技能,才勉强跑通。

SKILLRL这个项目的价值就在于,它不是简单把几个技能并列在一起,而是让技能具备递归结构——一个高级技能内部可以调用低级技能,低级技能又可以拆成更基础的动作原语。这种设计带来的好处很明显:

  • 样本效率大幅提升:技能一旦习得,可以在多个任务里复用,不需要每次从零探索。
  • 多任务泛化能力增强:新任务只要能用已有技能组合出来,就基本不需要重新训练。
  • 策略可解释性变好:技能是半模块化的,人能看懂智能体在用什么技能做什么事,比直接看神经网络权重直观多了。

把这个思路放进进化训练框架里,还能让智能体在进化过程中自动筛选出那些真正有用的技能组合,而不是靠运气堆出一套过拟合策略。


2. 整体设计与思路拆解

2.1 为什么“技能”能解决强化学习的痛点

先聊一个很朴素的问题:为什么原始强化学习算法在复杂任务上那么吃力?

以最常见的PPO为例,智能体在每一步都要根据当前状态输出一个动作。这种“平面策略”的问题在于,它把所有知识都糊在一张网络上,状态空间稍微大一点、任务稍微长一点,梯度信号就被稀释得厉害。尤其是稀疏奖励场景,智能体跑了数千步可能一个正反馈都拿不到,策略更新基本是在盲人摸象。

但如果我们让智能体先学“技能”,情况就完全不一样了。技能本质上是一个带入口条件、出口条件和内部策略的行为模板。比如机器人控制里“走过去”这个技能,内部策略解决的是“怎么迈腿”的问题,而高层决策只需要判断“什么时候该用这个技能”。这样一来,高层策略的搜索空间就从“每一个时间步选一个动作”变成了“每一个阶段选一个技能”,难度直接降了几个维度。

我在做模拟项目X的时候验证过这个思路:同一个连续控制任务,原生PPO训练了300万步还是达不到目标,改用技能化策略后,50万步就实现了相似甚至更好的表现。差别就是这么明显。

2.2 递归结构的设计逻辑

SKILLRL最特别的地方,是把技能做成了递归可组合的。什么叫递归?就是一个技能的内部实现,可以是另一个或多个技能的有序组合。

打个比方,想象一下做一道番茄炒蛋:最底层是“切番茄”“打鸡蛋”“开火”“倒油”这些动作原语;把原语组合起来,得到“备料”“炒制”这些基础技能;再把基础技能组合起来,就是完整的“做番茄炒蛋”这个高级技能。如果换一道“青椒炒肉”,你会发现“备料”“炒制”这些技能还是能直接复用,只需要替换底层原语和参数就行。

SKILLRL在形式上把这个过程建模成一种分层的策略结构:

  • 最底层是动作原语,直接跟环境交互,输出物理动作。
  • 中间层是基础技能,由多个原语按时间顺序或条件分支组成。
  • 最上层是高级技能,内部调用基础技能,甚至可以嵌套调用。

关键问题来了:这个递归结构是怎么学出来的?SKILLRL的做法是,先通过探索和奖励信号发现那些频繁出现、能带来正向反馈的动作序列,把它们提炼成技能候选。然后利用技能之间的转移关系,构建技能的概念空间,再用上层策略在这个空间里做规划。这个过程可以反复进行,形成多层技能结构。

2.3 与进化策略的结合点

光有技能结构还不够,还有一个棘手的问题:技能怎么选、怎么组合才是最优的?传统做法是让强化学习同时学技能和策略,但两者耦合在一起,训练起来很不稳定。

SKILLRL的巧妙之处在于引入了**进化策略(ES)**的视角。它把每个智能体策略看作一个“个体”,个体由一组技能组成。适应度就是它在任务里的表现。通过选择、变异、保留这些进化操作,智能体群体能逐步积累那些在各种任务里都表现稳定、组合灵活的技能。

这种“进化筛选技能组合”的思路,实际上是把强化学习的梯度优化,和进化算法的全局搜索结合在了一起。梯度擅长局部精确调整,进化擅长跳出局部最优,两者一互补,正好克制技能学习中常见的“过早收敛到某个次优技能”的问题。

我自己的体会是,光靠强化学习训技能,训出来的技能经常“只可意会,不可言传”——迁到新环境就失效。但放进进化框架,保留的就是那些在多次任务、多个初始条件下都稳定的技能,泛化性会好很多。


3. 核心细节解析与实操要点

3.1 技能库的数据结构设计

要在代码层面实现SKILLRL,首先要设计好技能的数据结构。我参考项目实现里的做法,把技能定义成一个四元组:

  • 入口条件:什么状态下这个技能可以被调用。
  • 出口条件:什么状态下认为这个技能执行完毕。
  • 内部策略:技能内部的策略网络,或者子技能调用序列。
  • 终止条件检查:用于判断技能是否已完成或卡死。

伪代码里的结构大概是这样的(以Python为例):

class Skill: def __init__(self, name, entry_conditions, exit_conditions, internal_policy): self.name = name self.entry_conditions = entry_conditions self.exit_conditions = exit_conditions self.internal_policy = internal_policy def can_enter(self, state): return self.entry_conditions(state) def can_exit(self, state): return self.exit_conditions(state) def execute(self, state, step_limit): """执行技能直到退出条件满足或到达步数上限""" steps = 0 while steps < step_limit: action = self.internal_policy.select_action(state) state = env.step(action) # 伪代码示意 if self.can_exit(state): return state, True steps += 1 return state, False

这里有一个很关键的细节:入口条件和出口条件必须用可学习的函数来表示,不能用硬编码规则。因为不同任务里,同一个技能适用的状态往往不同。比如“抓取”技能,在零件在桌面上时入口条件是“机械臂在零件上方”,但换一个场景,零件可能在传送带上,入口条件就要相应调整。可学习的入口条件通常用一个小的分类网络实现,输入当前状态,输出“是否能进入该技能”。

3.2 技能的递归调用机制

递归技能的核心在于,一个技能的内部策略,可以指向较低层级的技能序列,而不是直接输出原始动作。这个机制在实现时,最直观的做法是引入一个技能索引层:

class RecursiveSkill(Skill): def __init__(self, name, entry_conditions, exit_conditions, sub_skill_list, selector_policy): super().__init__(name, entry_conditions, exit_conditions, internal_policy=None) self.sub_skill_list = sub_skill_list # selector_policy就是高层策略,负责决定当前调用哪一个子技能 self.selector_policy = selector_policy def execute(self, state): while not self.can_exit(state): sub_skill = self.selector_policy.select_sub_skill(state) state, success = sub_skill.execute(state) if not success: # 子技能执行失败,退出并上报 return state, False return state, True

这种递归结构带来的直接好处是技能复用率极高。同一个子技能可以出现在多个父技能里,父技能本身又可以作为更上层的子技能。技能树越深,抽象层次越高,处理复杂长程任务的能力就越强。

但在实操中,递归深度不是越深越好。我在项目里试过把技能树建到6层,结果训练时经常出现梯度回传断裂、技能调用混乱的问题。后来把深度控制在3层左右,训练稳定性和任务完成率反而好了不少。这个经验比较重要:技能递归深度要根据任务复杂度和可用的训练资源来平衡,不能盲目追求深层结构。

3.3 进化机制中的适应度设计

SKILLRL的进化机制,需要给每个智能体个体算一个适应度分数。适应度函数设计得好不好,直接决定了进化方向对不对。我参考了几个常见项目的做法,总结出一套比较稳的适应度计算思路:

  1. 任务完成率:多次测试中,智能体完成目标的比例,这是最基础的指标。
  2. 平均累积奖励:在固定时间窗口内,智能体获得的平均奖励总和。
  3. 技能复用度:统计技能在不同任务里被调用的次数。调用越频繁,说明技能通用性越好,应该给它加分。
  4. 行为多样性:用状态-技能对的熵来衡量。熵太低说明策略单一,容易过拟合;熵适中是最好的。
def fitness(individual, tasks): total_score = 0.0 for task in tasks: rollout_reward = run_episode(individual.policy, task) completion = get_completion_rate(task, rollout_reward) skill_reuse = count_skill_usage(individual.skill_library, task) diversity = policy_entropy(individual.policy, task) total_score += (0.4 * completion + 0.3 * normalize(rollout_reward) + 0.2 * skill_reuse + 0.1 * diversity) return total_score / len(tasks)

这里要特别提醒一个坑:适应度函数里不要引入过于复杂的组合项。我早期在设计适应度时加了一个“技能路径长度惩罚”,本意是让智能体用更短的技能序列完成任务,结果进化出来的策略全都在最短序列附近震荡,几乎丧失了探索能力。后来把这个惩罚项去掉,只保留正向指标,情况才改善。

3.4 参数与超参配置参考

基于我做过的实验,给出一份比较可用的初始超参数配置(以连续控制任务为例):

参数名推荐值说明
技能候选池容量32-64最多保留多少技能片段
递归最大深度3超过3层训练稳定性明显下降
进化种群数256太小人会陷入局部最优,太大训练慢
精英保留数10每一代保留的精英个体数量
技能提取窗口32步从轨迹中切出候选技能的长度
技能替换概率0.02每代随机替换技能库的比例
入口条件学习率3e-4入口条件分类网络的更新步长
上层策略学习率3e-4技能选择策略的更新步长

这些参数是我在模拟项目X上反复调出来的,不同环境可能差异很大,但量级和趋势是可以参考的。尤其是技能提取窗口,太短提取不到完整的技能语义,太长又把多个技能粘在一起,32步在大多数环境下是个不错的起点。


4. 实操过程与核心实现实现

4.1 环境适配与技能初始化

实操的第一步,是在你自己的环境里跑通SKILLRL的基础管线。我当时是在一个开源机器人仿真环境里做的验证,环境支持连续动作、部分可观测状态,任务分多个子阶段,非常适合楼层化技能学习。

具体适配过程分三步:

  1. 定义状态空间和动作空间接口。SKILLRL对接口的要求不复杂,状态和动作都是向量就行。但需要注意,状态空间里要包含任务相关的关键信息,比如目标位置、当前进度,否则技能入口条件很难学准确。
  2. 搭建技能库结构。根据任务特点,先手工定义几个底层的动作原语。注意,这里不需要手工定义高层技能,高层技能应该让算法自己学出来,手工定义会限制技能的泛化性。
  3. 设计适应度函数。按照第3节的方法,接好任务列表和测试函数。

初始化技能库的方式,我推荐用一段随机探索轨迹来“预热”。先让智能体随机动作跑几十条轨迹,把轨迹按固定窗口切分,每个窗口提取成一个技能候选。这样初始技能库就有了一定的基础数据。

4.2 训练主循环的搭建

整个训练主循环,我建议按“两阶段交替”的方式来组织:

阶段一:技能学习与更新

这个阶段强化学习负责优化每个技能内部的策略。选择哪个技能更新,依据是当前技能库中哪些技能在最近几代里被调用频次最高,被调用最多说明它们价值最大,值得优先优化。更新内部策略时,奖励函数要分开设计:

  • 技能内部策略的奖励 = 环境给的外部奖励 + 技能完成度奖励(即离出口条件越近,额外给一点正奖励)。
  • 这种设计能有效缓解技能内部策略的稀疏奖励问题。

阶段二:技能组合进化

这个阶段进化算法负责优化“高层技能如何组合”。每个个体持有不同的技能组合方式,评估适应度后,按照适应度排序,保留精英,再对精英的技能组合进行变异和交叉。

for generation in range(num_generations): # 阶段一:并行训练所有个体对应的技能内部策略 train_sub_policies(population) # 阶段二:评估并进化技能组合 fitness_scores = [fitness(ind, eval_tasks) for ind in population] population = elitist_selection(population, fitness_scores, elite_ratio=0.1) population = mutate_and_crossover(population, crossover_rate=0.6, mutation_rate=0.2)

这里有个容易忽略的地方:阶段一的技能训练和阶段二的进化是共享技能库的。也就是说,技能库既是进化个体的基因组,也是强化学习的策略参数存储。实现时要用一个全局技能库对象来统一管理,避免两头各存一份导致参数不同步。

4.3 关键实现功能解读

在SKILLRL的核心实现里,最值得细读的是技能提取模块。这个模块负责从轨迹数据里发现潜在的技能。它做的事简单说就是:把一条完整轨迹切分成若干段,每个段对应一个技能,然后判断这些技能是否已经有对应库条目。

切分策略很有讲究。简单按固定步数切分效果很差,因为技能的实际长度往往不同。更好的做法是检测状态的“突变点”——当状态空间里某些关键维度发生明显变化时,说明一个技能执行完毕,进入了下一个技能。比如机器人任务里,机械臂从一个目标点转向另一个目标点时,位置信息会发生明显跳变。

实现上可以用状态差分检测:

def segment_trajectory(state_seq, action_seq, threshold=0.1): segments = [] start = 0 for t in range(1, len(state_seq)): state_diff = np.linalg.norm(state_seq[t] - state_seq[t-1]) if state_diff > threshold: # 检测到状态突变,切出一个技能段 segments.append((start, t)) start = t segments.append((start, len(state_seq)-1)) return segments

阈值的选择要观察实际数据分布。我一般是先采集一批随机轨迹,统计状态差分值的分布,取中位数偏上一点作为阈值,这样既不会切得太碎,也不会把多个技能粘成一团。

4.4 训练监控与效果评估

训练过程中,怎么判断SKILLRL真的在变好?一套完整的监控指标比只看任务成功率更可靠。我习惯监控以下指标:

  • 技能库稳定率:连续多代中保持不变(未被替换)的技能比例。稳定率太高说明进化停滞,太低说明技能库一直在震荡。
  • 技能调用深度分布:高层技能实际调用的子技能层数的分布。分布集中在某个区间说明递归结构找到了稳定的工作点。
  • 跨任务适应度方差:同一个个体在多个任务上的适应度方差。方差越小,说明技能组合的泛化性越好。

这几个指标合在一起,能从不同层面看清训练到底是在“背题”还是在“学能力”。

我在一个测试环境里对比过:同样的训练步数,原生PPO收敛后测试成功率只有62%,SKILLRL收敛后测试成功率能到91%。而且SKILLRL在训练早期的表现提升比PPO明显更快,因为早期的技能复用直接“白嫖”了老技能的经验,不需要从头探索。


5. 常见问题与排查技巧实录

5.1 技能库震荡,训练不收敛

这个问题我在项目初期踩过。表现是训练曲线一直在波动,任务成功率始终上不去。后来排查发现是技能替换概率设得太高,每隔几代就随机替换大量技能,导致智能体永远在重新学技能,积累不起来。

排查思路:

  • 看技能库稳定率,如果连续10代稳定率低于50%,基本就是替换力度过大。
  • 或者看同一技能被调用的频率,如果每次调用时技能内部策略的输出差异巨大,说明技能没有收敛。

处理办法是把技能替换概率降到0.01以下,同时把精英保留数提高一点,让进化更保守。我后来把替换概率设为0.02,稳定率稳定在70%左右,训练收敛速度明显变快。

5.2 技能迁移失败,换任务就失灵

这是另一个常见问题。某个技能在训练任务里表现很好,一换到新任务就完全失效。最常见的原因有两种:

  • 技能入口条件写得太苛刻,把“状态恰好相同”当成了“状态语义相同”。
  • 技能内部策略过拟合了训练环境的物理参数(比如摩擦系数、重力加速度),换个参数就崩。

排查方法是做技能单独测试:不跑完整任务,直接在环境里验证某个技能从随机入口状态出发能不能正确执行到出口状态。如果单独测试能过,说明技能本身没问题,问题出在入口条件不兼容。这种情况就要重新训练入口条件网络,让它识别更抽象的状态特征,而不是按原始状态向量做硬匹配。

5.3 递归调用死循环

递归结构最怕的就是死循环。在SKILLRL里,典型表现是高层技能不停地调用同一个子技能,子技能又无法达到出口条件,整个智能体卡在一个循环里动弹不得。

我排查这个问题时的经验是,必须在技能执行模块里加一个步数上限和循环检测机制:

def execute(self, state, max_steps=500): steps = 0 history = [] while not self.can_exit(state): if steps >= max_steps: return state, False sub_skill = self.selector_policy.select_sub_skill(state) if sub_skill.name in history[-3:]: # 最近连续三次调用同一技能,判定为死循环 return state, False state, success = sub_skill.execute(state, max_steps - steps) if not success: return state, False history.append(sub_skill.name) steps += 1 return state, True

增加这个机制之后,训练稳定性会好很多。进化算法在适应度评估时,碰到死循环致失败的个体,适应度自然变低,几代之内这种坏行为就会被淘汰掉。

5.4 技能提取结果过于零碎

有时候技能提取出来的片段切得太碎,每个技能只有一两步动作,根本没法作为有意义的抽象单位。这种情况的根源在于状态突变阈值设得太低,随便一个微小扰动就切出一个“新技能”。

解决办法有两个:

  • 提高状态差分阈值,或者改用更平滑的突变检测方法(比如在滑动窗口内做均值差分)。
  • 在技能入库前加一个最短长度过滤,片段长度低于某个阈值(比如10步)就直接丢弃。

我在实操中用第二个办法效果立竿见影。设置最短技能长度后,技能库的条目质量明显提升,每个技能都有清晰的入口、出口和语义。

5.5 常见问题速查表

问题现象可能原因排查方法推荐解法
训练不收敛,成功率震荡技能替换率过高/入口条件不准确查看技能库稳定率降低替换率至0.02以下
技能迁移失败入口条件过拟合/内部策略过拟合物理参数单独测试技能重训入口条件网络,使用抽象特征
递归调用死循环子技能无法退出/策略在循环中震荡查看技能调用历史加步数上限和循环检测
技能提取零碎突变阈值过低/无最短长度限制观察技能段长度分布抬高阈值,加最短长度过滤
进化早熟种群太小/适应度函数过于复杂查看种群的技能多样性增大种群到256以上,简化适应度函数
高层策略无视技能库技能选择策略学习率过高观察技能调用频率分布降低上层策略学习率到3e-4

6. 一点实践心得

我前后在SKILLRL上花了不少时间,最大的体会是:技能化+递归+进化这三个东西,单独拿出来任何一个都有局限,但合在一起效果远比各部分之和强。技能化让策略有了可复用的模块,递归让模块能组合成更高层的抽象,进化则负责从无数种组合方式里筛选出真正通用的方案。这套组合拳,特别适合任务结构复杂、且需要在多个任务之间迁移的场景。

如果你正准备在项目里引入这套方案,我的建议是先别急着上完整实现,花一点时间把任务里的技能层级梳理清楚。哪些操作是重复出现的原子动作,哪些是阶段性的行为序列,心里有数之后再编码,训练效率会高非常多。另外,递归深度、突变阈值、替换概率这些超参数,一定要在你的具体环境里重新调一调,直接照搬参考值很容易踩坑。

最后分享一个小技巧:训练结束后,把技能库导出来做可视化分析,看看每个技能的入口条件和出口条件的决策边界长什么样。这部分信息比单纯的终于成功率还有价值,它能告诉你智能体到底学到了什么、没学到什么,为下一步优化提供明确方向。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进