止まらないエージェントは、ループではなく条件が悪い
一言でいうと
エージェントが止まらない事故は、たいていループを作ったからではなく、ループを終える条件に届かない入力があるために起きます。
なぜ必要なのか
回答の下書きを、ルールに合うまで書き直すエージェントを立ち上げました。数日はうまく動きました。ところがある日、1件が終わらずに回り続け、ログにはreviewとreviseが交互に数百行出力されていました。
コードを見ると、終える条件はちゃんとあります。「スコアが70点以上なら出す」です。問題は、その1件の下書きが70点に届き得ないことでした。スコアが下がった理由は2つあり(禁止語と長さ)、直すノードは禁止語しか取り除きませんでした。長さが原因で引かれた40点は、何回回してもそのままです。
ループに間違いはありませんでした。間違っていたのは、「直せばよくなる」という仮定でした。
どう動くのか
LangGraphでは、ブランチをadd_conditional_edgesで作ります。引数は3つです。
graph.add_conditional_edges("review", after_review,
{"ship": "ship", "revise": "revise", "escalate": "escalate"})
真ん中の関数は、次のノード名ではなくブランチ名を返します。どのブランチがどのノードへ行くかは、3番目の引数(パスマップ)が決めます。この1枚がなぜあるのか。判断するコードと配線を切り離すためです。ノード名を関数の中に書いておくと、グラフの形を変えるたびに判断のコードを直す必要があり、描画する側(get_graph())も、どこへ行けるのかがわかりません。
ループは、ただの後ろへ戻るエッジです。graph.add_edge("revise", "review")の1行で、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)です。1つのスーパーステップで動けるノードは複数あります。このラボのイメージのlanggraph 0.2.60で実測すると、次のとおりです。
| グラフ | 最後まで動かすのに必要な最小のrecursion_limit |
|---|---|
| ノード3つを一列につないだもの | 4 |
| ノード3つを並べたもの | 2 |
| (デフォルト値) | 25 |
一列につないだほうが노드 수 + 1(プレースホルダーはノード数です)なのは、開始のチャネルが1スーパーステップを使うからで、並べたほうがノード数と無関係に2なのは、同じスーパーステップですべてが動くからです。この違いを知らないと、「ノードを増やしたら上限に引っかかった」を、的外れに診断します。並べて増やしたものは、上限を使いません。
デフォルト値の25は、十分な数ではありません。直すループが1周にスーパーステップ2つ(review + revise)を使うなら、12周もできません。
上限に達したことと、仕事を終えたことは違う
ここで、実務がよく間違えます。GraphRecursionErrorを捕まえて「失敗」とだけ書いておくと、あとで、その1件がなぜ失敗したのかがわかりません。2つがまったく違う出来事なのに、同じ欄に入ってしまうからです。
- 終えた: 終える条件に届きました。結果が
shipでもescalateでも、グラフは自分の判断で終わりました。 - 上限に達した: グラフが判断する機会を失いました。上限を上げれば終わるかもしれませんし、永遠に終わらないかもしれません。
そのため、捕まえた例外は、{"status": "limit"}のように別の名前で残します。そして、上限に達した件がたまったら、上限を上げるのではなく、終える条件に届くことができるかから見ます。
二重の上限を置く
終える条件が正常なときも、上限は必要です。実務では、二重に置きます。
- 外側の上限:
recursion_limit。プラットフォームが保証する最後の防衛線です。ここに達すると例外なので、結果がありません。 - 内側の上限: 状態で
roundsを数え、MAX_ROUNDSで人に渡す経路。ここに達すると結果があります。「人に渡した」という結果です。
内側の上限がないと、外側の上限が代わりにかかり、そのときは、それまでに作った下書きも一緒に消えます。内側の上限は結果を残し、外側の上限は結果を捨てます。その違いが、運用では大きいのです。
現場での姿
1つ目: 上限を上げてやり過ごす。recursion_limitを25から200に上げれば、その1件は通ります。そして、来月に400に上げます。本当の問題は、終える条件に届かない入力があることで、それは上限と無関係です。
2つ目: 判断があちこちに散らばる。終えるかどうかを、ノードの中でも条件関数でも見ていると、2か所が食い違う日が来ます。判断は条件関数の1か所に集め、ノードは仕事だけをします。
3つ目: ブランチ名とノード名を同じにする。楽ですが、パスマップを省略するようになり、そうするとグラフを描くときに、どのブランチがあるのかが見えません。名前が同じでも、マップは書くほうがよいです。
4つ目: 並べて増やしたノードを、上限のせいにする。上の表が、その誤解を正します。並んだノードは、同じスーパーステップです。
実務で本当に大切なこと
- 終える条件に届くことができるかを、入力ごとに尋ねます。届かない入力を先に見分ける診断のほうが、上限よりコストが低いです。
- 内側の上限を置いて、結果を残します。外側の上限だけに頼ると、上限に達した瞬間に、それまでにしたことが消えます。
- 上限に達したことと終えたことを、別の名前で記録します。同じ欄に入れると、あとで分けられません。
- 再帰の上限が数えるのは、スーパーステップです。直列に増やしたものだけが、上限を使います。
次のラボですること
/root/work/agroute/route.pyを、1ステップずつ育てます。まず、パスマップを備えたブランチを作り、直した下書きをもう一度採点に送るループをつなぎます。次に、わざと終える条件を外したものを作ってGraphRecursionErrorを自分の目で見て、ノードを一列につないだグラフと並べたグラフの最小の上限を実測して、スーパーステップが何かを確認します。最後に、上限に達したものと終えたものを別の結果として残し、直しても改善しない入力を見分ける診断を作ります。