停不下来的智能体,坏的不是循环而是条件
一句话总结
智能体停不下来的事故,大多不是因为构成了循环,而是因为存在达不到结束循环的条件的输入。
为什么需要它
上线了一个把回答草稿改写到符合规则为止的智能体。头几天运行得很好。然后有一天,有一条一直结束不了、不停地转,日志里 review 和 revise 交替打印了几百行。
看代码,结束条件好端端地在那里。“分数达到 70 分以上就放行。”问题在于那一条的草稿永远达不到 70 分。扣分的原因有两个——违禁词和长度——而修订节点只清除了违禁词。因长度被扣掉的 40 分,转多少圈都原封不动。
循环没有错,错的是“修改就会变好”这个假设。
工作原理
在 LangGraph 中,分支用 add_conditional_edges 创建。有三个参数。
graph.add_conditional_edges("review", after_review,
{"ship": "ship", "revise": "revise", "escalate": "escalate"})
中间的函数返回的是分支名称,而不是下一个节点的名称。哪个分支通向哪个节点,由第三个参数(路径映射)决定。为什么要多这一层——是为了把判断的代码和接线分开。如果把节点名称写在函数里,每次改图的形状都得修改判断代码,而且负责绘图的一方(get_graph())也无从知道能通向哪里。
循环只是往回走的边。一行 graph.add_edge("revise", "review"),经过 revise 之后就会回到 review。没有特别的语法,这既是这个模型的好处,也是它可怕的地方。
递归上限数的是什么——亲自测得的数字
没有结束条件就会无限转下去吗?不会。LangGraph 在触及 recursion_limit 时会抛出 GraphRecursionError。消息是这样的。
Recursion limit of 25 reached without hitting a stop condition.
You can increase the limit by setting the `recursion_limit` config key.
计数的单位不是节点执行次数,而是超级步(superstep)。一个超级步里可以运行多个节点。在本实验镜像的 langgraph 0.2.60 中亲自测量,结果如下。
| 图 | 运行到结束所需的最小 recursion_limit |
|---|---|
| 串联 3 个节点 | 4 |
| 并排放置 3 个节点 | 2 |
| (默认值) | 25 |
串联时为 노드 수 + 1(韩文,意为“节点数 + 1”),是因为起始通道要占一个超级步;并排时无论节点数是多少都是 2,是因为全部在同一个超级步里运行。不了解这个差别,就会把“增加节点后撞上了上限”误诊——并排增加的节点并不消耗上限。
默认值 25 不是一个宽裕的数。如果修订循环每转一圈用掉两个超级步(review + revise),连十二圈都转不到。
触及上限与完成了任务是两回事
实际工作中常在这里出错。如果捕获 GraphRecursionError 只记成“失败”,以后就无法知道那一条为什么失败。因为两件完全不同的事被放进了同一格。
- 完成了:触及了结束条件。无论结果是
ship还是escalate,图都是按自己的判断结束的。 - 触及了上限:图失去了判断的机会。调高上限也许能结束,也许永远结束不了。
所以捕获到的异常要像 {"status": "limit"} 这样换一个名称记录。而且当触及上限的条目积累起来时,不是去调高上限,而是先看结束条件是否够得到。
设置两层上限
即使结束条件正常,上限也是必要的。实际工作中设置两层。
- 外层上限——
recursion_limit。这是平台保证的最后一道防线。触及它会抛异常,所以没有结果。 - 内层上限——在状态中统计
rounds,到达MAX_ROUNDS时转交给人的路径。触及它时有结果——结果就是“已转交给人”。
没有内层上限时,会由外层上限来拦,那时到目前为止做好的草稿也会一起消失。内层上限留下结果,外层上限丢弃结果。这个差别在生产环境中很大。
在现场相遇的样子
第一,靠调高上限来蒙混过关。把 recursion_limit 从 25 调到 200,那一条就过去了。然后下个月再调到 400。真正的问题是存在够不到结束条件的输入,这与上限无关。
第二,判断分散在多个地方。如果在节点里看一次是否结束,在条件函数里又看一次,总有一天两处会不一致。判断集中在条件函数一处,节点只干活。
第三,分支名称和节点名称写成一样的。这样方便,但会省略路径映射,结果绘图时就看不到有哪些分支。即使名称相同,也最好写上映射。
第四,把并排增加的节点归咎于上限。上面的表纠正了这种误解。并排的节点属于同一个超级步。
实际工作中真正重要的事
- 对每个输入都问一句结束条件是否够得到。先筛出够不到的输入,这种诊断比调高上限便宜。
- 设置内层上限,留下结果。只依赖外层上限的话,触及上限的瞬间,到目前为止做的事就消失了。
- 把触及上限和已完成用不同的名称记录。放进同一格,以后就分不开了。
- 递归上限数的是超级步。只有串联增加的节点才消耗上限。
下一项实验要做什么
一步步扩展 /root/work/agroute/route.py。先做出带路径映射的分支,接上把修订后的草稿再送去评分的循环。然后做一个故意去掉结束条件的版本,亲眼看到 GraphRecursionError,再亲手测量串联图和并排图的最小上限,确认什么是超级步。最后把触及上限的与已完成的记为不同的结果,并做出筛选“修了也不会变好的输入”的诊断。