解开GOAP迷思:计划中动作能重复执行吗?
2025-04-25 15:16:15
解开 GOAP 回归搜索的迷思:计划中可以重复执行动作吗?
使用目标导向动作规划(Goal-Oriented Action Planning, GOAP)时,不少朋友可能会遇到和下面这位开发者类似的困惑,特别是看到经典的回归搜索示例时。
问题来了:GOAP 计划只包含动作类型吗?
咱们先看看 Jeff Orkin 博士那个经典的 GOAP 回归搜索例子。
Dr. Jeff Orkin's example of regressive search
按照图示和解释,规划器(Planner)从目标状态出发,反向搜索动作空间,找到一个能将角色从初始状态带到目标状态的动作序列。回归搜索的每一步,都是为了找一个动作,其“效果”能满足某个尚未满足的“目标条件”或者“下一动作的前提条件”。
例子里,回归搜索得到的结果是 目标(Goal) -> 攻击(Attack) -> 装弹(Load Weapon) -> 拔武器(Draw Weapon)
。执行的时候,顺序反过来,变成 拔武器 -> 装弹 -> 攻击 -> 达成目标
。
这里的困惑点是:这个计划看起来每个动作只出现了一次。假设我们的动作库里有 [攻击, 装弹, 拔武器, 使用药水, 撤退]
这几个动作,那么最终生成的计划,仅仅是达成目标的一个“路线图”,标明了需要哪些“类型”的动作,而不是一个完整、精确到每一次执行的动作列表吗?
如果实际情况需要多次攻击才能干掉敌人(比如,目标是“敌人死亡”),那回归搜索是怎么处理的?最终的执行序列会是 拔武器 -> 装弹 -> 攻击 -> 攻击 -> 攻击
这种情况吗?或者说,GOAP 的回归搜索根本就不会产生包含重复动作的计划?
这位朋友尝试查阅了原作者的论文,也问了 AI 助手,翻了学术搜索,但似乎都没找到能完全解开这个疑惑的答案。
为什么会产生这种困惑?剖析 GOAP 回归搜索的核心
这个困惑挺常见的,主要源于对 GOAP 计划生成过程和计划本身性质的理解可能存在一点偏差。
1. 回归搜索:从终点倒推
GOAP 的核心魅力在于它的回归搜索(Regressive Search)。它不像正向搜索那样,从当前状态一步步推演,尝试所有可能的动作序列;而是直接盯着“目标状态”,反过来问:“要达到这个目标,我刚才需要完成哪个动作?”
比如目标是 敌人已死亡(EnemyDead: true)
。回归搜索会去找动作库里哪个动作的“效果”包含 EnemyDead: true
。假设找到了 攻击(Attack)
动作。
接着,搜索器会看 攻击
动作的“前提条件”是什么。假设前提是 武器已拔出(WeaponDrawn: true)
和 武器已装弹(WeaponLoaded: true)
。现在,WeaponDrawn: true
和 WeaponLoaded: true
就成了新的、需要满足的“子目标”。
搜索器继续为这两个子目标寻找能满足它们的动作。它可能会找到 装弹(Load Weapon)
动作,其效果是 WeaponLoaded: true
;以及 拔武器(Draw Weapon)
动作,其效果是 WeaponDrawn: true
。
然后,再看 装弹
和 拔武器
各自的前提条件是什么... 如此循环往复,直到所有的前提条件都能在“世界初始状态”中得到满足。
2. 示例的简化
Orkin 博士的那个经典图例,主要目的是演示回归搜索如何通过匹配“效果”与“前提条件”来建立动作依赖关系链。为了清晰起见,它做了简化:
- 隐含假设: 它假设一次
攻击
动作就足以满足最终的目标条件(比如敌人已死亡
)。它没显式处理“需要攻击多少次”的问题。 - 关注点: 重点在于展示如何“解锁”
攻击
动作所需的前提条件 (WeaponLoaded
,WeaponDrawn
),并找到对应的动作 (Load Weapon
,Draw Weapon
)。
3. 计划的本质:策略而非固定脚本
GOAP 生成的计划,本质上是一个在当前世界状态 下,能够导向目标状态 的有效动作策略 。它告诉你需要执行哪些类型的动作,以及它们的依赖顺序,以满足目标条件。
这个计划是在规划那一刻生成的,基于当时的世界状态。但世界是动态变化的。
所以,那个例子里的 拔武器 -> 装弹 -> 攻击
序列,是规划器在那个特定时刻认为能达成目标的、最直接(或成本最低)的路径。它解决了动作间的依赖问题。
现在,回到关键问题:重复动作怎么办?
解决方案:让计划更灵活,处理重复动作
想让 GOAP 处理需要重复执行同一动作的情况,有几种常见且有效的方法:
方案一:状态驱动执行与重新规划 (State-Driven Execution and Replanning)
这是最常用也最符合 GOAP 哲学的方式。
-
原理与作用:
生成的计划 (拔武器 -> 装弹 -> 攻击
) 只是第一步的指导。AI 按照计划执行动作:- 执行
拔武器
。世界状态更新,现在WeaponDrawn
为true
。 - 执行
装弹
。世界状态更新,现在WeaponLoaded
为true
。 - 执行
攻击
。世界状态更新。敌人可能受伤了,但还没死。EnemyDead
仍然是false
。
此时,计划里的动作执行完了,但最初的目标状态 (
EnemyDead: true
) 还没达成。怎么办?很简单:重新规划(Replan) 。
AI 会基于当前最新的世界状态 (武器已拔、已装弹、敌人受伤但未死)和原始目标 (
EnemyDead: true
),再次运行 GOAP 规划器。由于现在
WeaponDrawn
和WeaponLoaded
都已经是true
,攻击
动作的前提条件直接满足。规划器会发现,再次执行攻击
是达成EnemyDead: true
的有效(甚至可能是唯一或成本最低的)动作。于是,新的计划可能就只有一个动作:攻击
。然后 AI 执行这个新的
攻击
动作。如果敌人还是没死,循环继续:检查目标 -> 目标未达成 -> 重新规划 -> 执行新计划... 直到EnemyDead
变成true
。这样一来,
攻击
动作就自然地被重复执行了,次数取决于每次攻击的效果和敌人的“血量”(状态)。 - 执行
-
操作步骤/伪代码:
current_state = get_current_world_state() goal_state = {'EnemyDead': True} plan = None while not is_goal_achieved(current_state, goal_state): # 重新规划或获取计划的第一步 if plan is None or len(plan) == 0: plan = find_goap_plan(current_state, goal_state) # 如果找不到计划,可能目标无法达成或卡住了 if plan is None or len(plan) == 0: print("无法找到计划,放弃或执行备用逻辑") break # 取出计划的下一步动作 action = plan.pop(0) # 或者 plan.pop_front() # 检查前提条件是否依然满足 (可选但健壮) if not are_preconditions_met(action, current_state): print(f"动作 '{action.name}' 前提条件不再满足,强制重新规划") plan = None # 清空旧计划,下次循环会重新规划 continue # 执行动作 print(f"执行动作: {action.name}") success = execute_action(action) # 更新世界状态 current_state = update_world_state_after_action(action, current_state, success) # 如果动作执行失败,也可能需要重新规划 if not success: print(f"动作 '{action.name}' 执行失败,考虑重新规划") plan = None # 清空旧计划 print("目标达成!") # --- 辅助函数 (示意) --- def find_goap_plan(current_state, goal_state): # 调用你的 GOAP 规划器实现... # 返回动作列表或 None pass def is_goal_achieved(current_state, goal_state): for key, value in goal_state.items(): if current_state.get(key) != value: return False return True def are_preconditions_met(action, current_state): # 检查 action 的 preconditions 是否在 current_state 中满足... pass def execute_action(action): # 实际执行游戏逻辑... # 返回成功或失败 pass def update_world_state_after_action(action, current_state, success): # 根据 action 的 effects 和执行结果更新 state... pass
-
进阶使用技巧:
- 智能重新规划触发: 不必每次动作执行完都检查目标和重新规划。可以设置触发条件,比如:只有当世界状态发生显著变化、当前计划执行完毕、某个动作执行失败,或者距离上次规划超过一定时间时,才进行重新规划。这样可以节省计算资源。
- 部分重新规划 (Plan Repair): 如果只是某个动作的前提条件失效,有时可以尝试只修复计划中受影响的部分,而不是完全重新生成。
-
安全建议:
- 防止无限循环: 确保有终止条件。例如,设置最大规划尝试次数,或者如果连续多次重新规划都得到相同的无效计划,则中断行为。
- 规划失败处理:
find_goap_plan
可能返回None
(找不到可行计划)。需要有备用逻辑,比如让 AI 执行默认行为(如待机、撤退)或上报错误。
方案二:在动作效果/状态中引入计数或资源
另一种方法是让状态和动作本身包含更多量化信息。
-
原理与作用:
与其用一个布尔值EnemyDead: true
作为目标,不如用一个数值化的状态,比如EnemyHP <= 0
。
相应地,攻击
动作的效果不再是简单地设置EnemyDead
为true
,而是修改状态值,例如EnemyHP -= 30
(每次攻击减少30点HP)。这样,GOAP 规划器在回归搜索时:
- 目标是
EnemyHP <= 0
。 - 找到
攻击
动作,效果是EnemyHP -= 30
。 - 规划器需要计算,从当前的
EnemyHP
(假设是100) 到达<= 0
,需要执行多少次这样的效果。 - 如果一次
攻击
(效果-= 30
) 不足以使EnemyHP <= 0
,那么这个目标条件仍然未被完全满足。规划器需要继续寻找能进一步降低EnemyHP
的动作(或者满足当前攻击
动作前提条件的动作)。 - 在合适的设定下(比如允许同一个动作被多次考虑,或者规划器能理解数值变化),它会发现需要重复执行
攻击
动作,直到EnemyHP
的预期值降到0或以下。
这时,生成的计划本身就可能包含多个
攻击
动作,或者规划器知道需要某种机制来重复它,直到状态满足。这取决于具体规划器的实现方式。有些规划器可能直接输出[拔武器, 装弹, 攻击, 攻击, 攻击, 攻击]
这样的序列。 - 目标是
-
代码示例 (概念性):
世界状态 (World State):
{ "PlayerArmed": false, "WeaponLoaded": false, "EnemyHP": 100 }
目标状态 (Goal State):
{ "EnemyHP <= 0": true // 或者用特殊标记表示这是一个数值比较目标 }
动作定义 (Action Definitions):
// 拔武器 Draw Weapon { "name": "DrawWeapon", "preconditions": { "PlayerArmed": false }, "effects": { "PlayerArmed": true } } // 装弹 Load Weapon { "name": "LoadWeapon", "preconditions": { "PlayerArmed": true, "WeaponLoaded": false }, "effects": { "WeaponLoaded": true } } // 攻击 Attack { "name": "Attack", "preconditions": { "PlayerArmed": true, "WeaponLoaded": true }, // 效果现在是修改数值 "effects": { "EnemyHP": "-=30" } // 用特殊语法表示减少30 }
规划过程(简化示意):
- 目标
EnemyHP <= 0
。需要效果能降低EnemyHP
。找到Attack
(效果-=30
)。 Attack
的前提是PlayerArmed: true
和WeaponLoaded: true
。PlayerArmed: true
需要DrawWeapon
(效果PlayerArmed: true
)。DrawWeapon
前提PlayerArmed: false
(初始状态满足)。WeaponLoaded: true
需要LoadWeapon
(效果WeaponLoaded: true
)。LoadWeapon
前提PlayerArmed: true
(由DrawWeapon
提供) 和WeaponLoaded: false
(初始状态满足)。- 此时,如果规划器计算出需要多次
Attack
才能达成EnemyHP <= 0
,它可能会构建出包含重复Attack
的计划,或者结合方案一的重新规划机制来实现重复。例如,它可能认识到执行一次Attack
后,EnemyHP
变为 70,仍不满足目标,需要再次触发能降低 HP 的动作,而Attack
仍然是最佳选择。
- 目标
-
进阶使用技巧:
- 动作成本 (Action Cost): 给每个动作赋予一个成本(比如时间、能量消耗)。GOAP 会尝试找到总成本最低的计划。多次
攻击
会累加成本。 - 规划器对数值的处理: 需要规划器能够理解和处理数值运算(加减、比较)。有些高级 GOAP 实现支持这一点。
- 动作成本 (Action Cost): 给每个动作赋予一个成本(比如时间、能量消耗)。GOAP 会尝试找到总成本最低的计划。多次
-
安全建议:
- 状态空间爆炸: 过多的数值状态和复杂计算可能让规划变得非常慢。谨慎设计状态表示。
- 浮点数精度: 如果使用浮点数(如 HP、距离),要注意比较时的精度问题。最好用整数或者设定容差范围。
方案三:宏动作或组合动作 (Macro Actions / Composite Actions)
这种方法是在动作层面进行抽象。
-
原理与作用:
你可以定义一个更高级别的“宏动作”,比如持续攻击直到目标死亡 (AttackUntilEnemyDead)
。
这个宏动作本身封装了重复攻击的逻辑。- 对 GOAP 规划器来说,
AttackUntilEnemyDead
就是一个普通动作。它有自己的前提条件(比如PlayerArmed: true
,WeaponLoaded: true
)和效果(EnemyDead: true
)。 - 规划器在生成计划时,可能直接选用这个宏动作。生成的计划可能是
[拔武器, 装弹, AttackUntilEnemyDead]
。 - 在执行阶段 ,当执行到
AttackUntilEnemyDead
这个动作时,其内部逻辑才被触发,开始循环执行底层的“单次攻击”动作,并实时检查敌人是否死亡,直到满足条件或无法继续攻击(比如没弹药了,需要重新装填,这又可能触发新的规划)。
- 对 GOAP 规划器来说,
-
代码示例 (概念性):
宏动作定义:
{ "name": "AttackUntilEnemyDead", "preconditions": { "PlayerArmed": true, "WeaponLoaded": true }, // 效果是最终达成的状态 "effects": { "EnemyDead": true }, // 执行逻辑不是 GOAP 规划器直接用的,而是执行器调用 "execution_logic": "function() { while(enemy.HP > 0 && CanAttack()) { PerformSingleAttack(); CheckAmmoAndReloadIfNeeded(); UpdateWorldState(); } }" }
规划结果:
[DrawWeapon, LoadWeapon, AttackUntilEnemyDead]
执行阶段:
- 执行
DrawWeapon
。 - 执行
LoadWeapon
。 - 执行
AttackUntilEnemyDead
:进入其内部循环,反复调用PerformSingleAttack()
直到enemy.HP <= 0
。
- 执行
-
进阶使用技巧:
- 分层 GOAP (Hierarchical GOAP): 类似于分层任务网络 (HTN),可以将规划问题分解到不同抽象层次。宏动作就是一种简单的分层。
- 参数化宏动作: 宏动作可以接受参数,比如
AttackTarget(enemy_id)
。
-
安全建议:
- 隐藏复杂度: 宏动作虽然简化了顶层规划,但把复杂性(如循环、状态检查、可能的内部失败处理)移到了执行逻辑内部,调试可能稍微麻烦点。
- 确保终止: 宏动作的内部逻辑必须有可靠的终止条件,避免无限循环。
总结:理解 GOAP 计划的本质
回过头看,Jeff Orkin 的例子并没有错,只是为了教学目的简化了。GOAP 生成的计划本身是解决“如何从状态 A 到达目标状态 B”的动作依赖链和策略。
至于动作重复:
- 重复不是 GOAP 的直接产物,而是执行策略的结果。 最自然的方式是通过状态驱动执行和重新规划 来实现。执行一步,检查状态,如果目标未达成,就基于新状态再规划。
- 可以通过更精细的状态/动作设计来间接影响规划。 使用数值状态 (如 HP)和相应修改这些数值的动作效果,让规划器需要考虑“量”的问题,可能引导出需要重复动作的计划(具体取决于规划器实现)。
- 可以用宏动作封装重复逻辑。 把重复行为打包成一个高级动作,简化上层规划,让执行层处理重复细节。
所以,GOAP 计划不只是“动作类型”的列表,它是达成目标的有效步骤序列。它是否包含显式的重复动作,或者重复动作如何发生,取决于你选择哪种方式来处理动态世界和需要累积效果才能达成的目标。最常用且灵活的是结合重新规划的机制。