从一次“死循环”调试说起
去年年底,我在调试一个家居清洁Agent时遇到了一个让人抓狂的问题。Agent被要求“把客厅打扫干净”,它先识别出“扫地”这个子任务,然后开始扫地。扫到一半,它发现茶几上有灰尘,于是生成了“擦茶几”的子目标。擦完茶几,它又注意到地板上有水渍,于是决定“拖地”。拖完地,它发现刚才扫地时遗漏了沙发底下的区域,于是又回去“补扫”。补扫过程中,它再次经过茶几,发现茶几上又落了一点灰——因为刚才擦茶几时扬起的灰尘——于是又生成了“再擦一次茶几”的子目标。
这个Agent在客厅里来回折腾了47分钟,最终因为电量耗尽而停止。它完成了“扫地→擦茶几→拖地→补扫→再擦茶几”这一系列动作,但客厅的窗户没关,窗帘没拉,垃圾桶没倒——这些原始任务中隐含的子目标一个都没触发。
这个案例让我意识到:规划能力不是简单地拆解任务,而是要在动态环境中保持目标一致性,同时具备优先级判断和资源约束意识。今天我们就来聊聊Agent规划能力的三个核心环节:任务分解、子目标生成与动态调整。
任务分解:别把“切蛋糕”做成“拆积木”
任务分解是规划的第一步,但很多实现都做错了。最常见的错误是把任务分解当成“拆积木”——把一个大任务机械地拆成若干小步骤,然后按顺序执行。这种思路在静态环境中勉强可用,但在真实场景中几乎必死。
正确的做法是“切蛋糕”——每一刀下去,切出来的子任务应该是可独立验证、可并行执行、且具备上下文关联的。
举个例子,假设Agent要完成“组织一场技术分享会”。错误的分解方式是:
1. 确定主题 2. 邀请讲师 3. 预定场地 4. 准备PPT 5. 发送通知 6. 现场执行这种线性分解的问题在于:如果讲师临时有事,整个流程就要回滚到步骤2;如果场地预定失败,步骤3之后的全部作废。更糟糕的是,步骤4“准备PPT”和步骤2“邀请讲师”之间存在强依赖——讲师没确定,PPT谁来做?
正确的分解应该基于依赖关系图:
根任务:组织技术分享会 ├── 并行组1:确定主题 + 确定时间窗口 │ └── 依赖:无 ├── 并行组2:邀请讲师 + 预定场地 │ └── 依赖:并行组1完成 ├── 并行组3:准备PPT + 发送通知 │ └── 依赖:并行组2完成(讲师确认、场地确认) └── 并行组4:现场执行 └── 依赖:并行组3完成这里的关键是:每个子任务都对应一个可验证的完成条件。比如“确定主题”的完成条件是“主题已确认并记录在案”,而不是“想了一个主题”。这种设计让Agent在后续动态调整时能准确判断哪些子任务已经完成、哪些需要重做。
代码实现时,我习惯用DAG(有向无环图)来建模任务分解。别用简单的列表或队列,那是在给自己挖坑。DAG天然支持并行、依赖追踪和局部重规划。
# 这里踩过坑:用列表存子任务,结果调整时改得想哭classTaskNode:def__init__(self,task_id,description,completion_criteria):self.id=task_id self.desc=description self.criteria=completion_criteria# 可验证的完成条件self.dependencies=[]# 前置依赖的task_id列表self.status='pending'# pending | running | completed | failedself.result=NoneclassTaskDAG:def__init__(self,root_task):self.nodes={}self.root=root_task# 别这样写:self.execution_order = [] # 线性顺序是陷阱defadd_node(self,node,dependencies=None):# 添加节点时自动校验依赖是否存在ifdependencies:fordep_idindependencies:ifdep_idnotinself.nodes:raiseValueError(f"依赖节点{dep_id}不存在,先注册再关联")self.nodes[node.id]=node node.dependencies=dependenciesor[]子目标生成:从“我要做什么”到“我该先做什么”
子目标生成不是简单的“把大任务拆小”,而是在约束条件下选择最优的下一步行动。这里有两个关键点:目标优先级和资源约束。
还是用清洁Agent的例子。当Agent同时面对“扫地”“擦茶几”“拖地”三个子目标时,它需要判断:
- 优先级:哪个子目标对最终结果影响最大?通常“扫地”优先级高于“擦茶几”,因为扫地会产生灰尘,先擦茶几会被二次污染。
- 资源约束:当前电量、时间、清洁剂余量是否支持执行某个子目标?如果电量只剩20%,应该优先执行高价值且低能耗的子目标。
- 依赖关系:拖地必须在扫地之后,否则会把灰尘拖得到处都是。
我见过一个很蠢的实现:用LLM直接生成子目标列表,然后按生成顺序执行。LLM生成的顺序往往基于语义相关性,而不是实际执行逻辑。比如LLM可能会把“关窗”排在“扫地”前面,因为“关窗”在语义上更接近“打扫”这个主题——但实际场景中,如果窗户开着,扫地会把灰尘吹到室外,反而更高效。
正确的做法是引入一个目标优先级评估器,它基于当前状态和约束条件对候选子目标进行排序。这个评估器可以是规则引擎,也可以是一个轻量级模型。
# 别这样写:直接用LLM返回的顺序# sub_goals = llm.generate("请列出打扫客厅的子目标")# for goal in sub_goals: execute(goal) # 这是自杀式写法classGoalPrioritizer:def__init__(self,constraints):self.constraints=constraints# 电量、时间、资源等defevaluate(self,candidate_goals,current_state):scored_goals=[]forgoalincandidate_goals:score=0# 这里踩过坑:忘记考虑依赖链的传递影响# 比如"擦茶几"的优先级应该受"扫地"状态影响ifgoal.depends_onandnotall(d.completedfordingoal.depends_on):score-=100# 依赖未满足,直接降权ifgoal.estimated_energy>self.constraints['battery']:score-=50# 资源不足,降权但不排除(万一其他目标更耗电)ifgoal.urgency=='high':score+=30# 紧急任务加分# 更多评估逻辑...scored_goals.append((goal,score))returnsorted(scored_goals,key=lambdax:x[1],reverse=True)动态调整:别让Agent变成“一根筋”
动态调整是规划能力中最难的部分,也是最容易出bug的地方。很多Agent在遇到意外时只有两种反应:要么死磕当前子目标直到失败,要么直接放弃整个任务。
正确的做法是分层调整:
- 微调:当前子目标执行受阻时,尝试调整执行方式。比如扫地时发现某个区域被家具挡住,可以尝试从另一个角度进入,而不是直接放弃“扫地”这个子目标。
- 重排序:当前子目标无法完成时,将其标记为“阻塞”,切换到下一个可执行的子目标。等条件满足后再回来处理。
- 重规划:当多个子目标连续失败,或者外部环境发生重大变化时,重新进行任务分解。
我见过一个做得不错的案例:一个物流仓库的调度Agent,在遇到传送带故障时,不是立即重新规划所有路径,而是先尝试绕行(微调),如果绕行导致拥堵,则调整其他AGV的优先级(重排序),只有当故障范围超过30%时,才触发全局重规划。这种分层策略让系统在90%的异常情况下都能快速恢复,只有10%的情况需要消耗大量算力做全局重规划。
实现动态调整时,有一个容易忽略的点:状态回溯。当Agent决定重规划时,它需要知道哪些子任务已经完成、哪些部分完成、哪些完全没做。如果状态管理做得不好,重规划后可能会重复执行已经完成的任务,或者遗漏关键步骤。
# 这里踩过坑:重规划时忘记保存已完成任务的上下文classDynamicPlanner:def__init__(self,task_dag):self.dag=task_dag self.execution_history=[]# 记录每一步的执行结果defhandle_failure(self,failed_node):# 先尝试微调ifself.can_retry(failed_node):returnself.retry(failed_node)# 微调失败,尝试重排序blocked_nodes=self.get_blocked_nodes(failed_node)available_nodes=self.get_available_nodes(exclude=blocked_nodes)ifavailable_nodes:returnself.switch_to(available_nodes[0])# 所有路径都阻塞,触发重规划# 别这样写:self.dag = rebuild_from_scratch()# 应该基于已完成的任务进行增量重规划completed=[nforninself.dag.nodes.values()ifn.status=='completed']partial=[nforninself.dag.nodes.values()ifn.status=='partial']new_dag=self.incremental_replan(completed,partial,failed_node)returnnew_dag个人经验性建议
别迷信LLM的规划能力。LLM擅长生成看起来合理的计划,但缺乏对物理世界约束的理解。我见过太多Agent被LLM生成的“完美计划”带进沟里。规划的核心是约束求解,不是文本生成。建议用LLM做创意发散(比如生成候选子目标),用规则引擎或优化算法做决策(比如排序和选择)。
给Agent一个“后悔药”机制。动态调整的本质是允许Agent犯错并纠正。但很多实现只设计了“前进”逻辑,没有“回退”逻辑。我习惯在每个子任务执行前保存一个检查点,这样即使后续步骤失败,也能回滚到安全状态。代价是存储开销,但相比Agent卡死或执行错误任务,这点代价完全可以接受。
规划粒度要动态调整。不要一开始就把任务分解到原子级别。比如“打扫客厅”可以先分解为“清洁地面”“清洁家具”“整理物品”三个大块,每个大块在执行时再进一步分解。这种“懒分解”策略能避免过度规划——很多子任务可能根本不需要执行,或者执行过程中环境已经变化。
关注“负反馈”信号。Agent在规划时往往只关注“要做什么”,忽略了“不要做什么”。比如清洁Agent应该知道“不要在拖地后立即扫地”“不要在擦玻璃时使用粗糙抹布”。这些负反馈规则可以大幅减少无效操作。我习惯在规划器中内置一个“禁忌列表”,每次生成子目标时先过滤掉禁忌项。
最后一条,也是最重要的:规划能力不是越强越好。一个能完美规划所有场景的Agent,往往因为计算开销过大而无法实时响应。在实际工程中,我倾向于让Agent具备“80%场景下的快速规划能力”,剩下的20%异常场景通过人工介入或降级策略处理。追求100%的规划能力,最终得到的往往是99%的延迟和1%的崩溃。
回到开头的清洁Agent案例,后来我给它加了一个简单的规则:每完成一个子目标,重新评估一次全局优先级。同时限制了最大重规划次数(3次),超过后强制进入“收尾模式”——只执行最高优先级的剩余任务,忽略所有新生成的子目标。这个改动让它的平均完成时间从47分钟降到了22分钟,而且再也没有出现过“死循环”问题。
规划能力不是让Agent变得“聪明”,而是让它变得“可靠”。在工程实践中,可靠比聪明重要一百倍。