エージェントはモデルではなく状態機械だ
一言でいうと
エージェントはモデルではなく状態機械です。難しいのは、モデルを呼び出す場所ではなく、いつ止まるか、何を持ち歩くか、失敗したらどこへ行くかを決める場所です。
なぜ必要なのか: デモと製品が分かれる場所
エージェントのデモは、たいていうまく動きます。質問を1つ入れると、ツールを呼び出して答えが出ます。ところが、製品になった途端、3つのことが一度に爆発します。
- 止まらない。ツールが望む答えを返せないと、延々とリトライします。トークンの請求書が、その事実を教えてくれます。
- 知らないことをでっち上げる。照会が何も見つけられずに戻ってきたのに、答えを作り出します。
- なぜそうしたのかわからない。同じ質問に異なる答えが出るのに、どの経路を通ったかの記録がありません。
3つともモデルの問題ではありません。グラフの問題です。そのため、このコースはモデルを呼び出さず、グラフだけを扱います。モデルをつなぐのは、ノード1つを差し替える作業で、その場所は難しくありません。
状態は上書きされるのか、積み上がるのか
LangGraphでは、ノードは変更する部分だけを入れたディクショナリを返します。それをどうまとめるかは、状態の型が決めます。
class State(TypedDict):
question: str
steps: Annotated[list, operator.add] # 쌓인다
tries: int # 덮어쓴다
Annotated[list, operator.add]がないと、最後のノードが返した値だけが残ります。すると「たどってきた道」が消えて、あとで何があったのかを尋ねられません。何を積み上げ、何を上書きするかは、設計上の決定です。
グラフに移すと何がテスト可能になるのか
エージェントを1つのループのかたまりとして書くと、何が間違っていたのかを尋ねる場所がありません。グラフに分ける本当の理由は、各ノードを別々にテストできるようになることです。
ノードは純粋関数に近く作ります。状態を受け取って、状態の一部を返す形なら、そのノードだけを切り離して、入力を与えて結果を見られます。モデルの呼び出しはノードの中に置きますが、注入できるようにして、テストでは決まった答えを返すモックに差し替えます。
def plan(state: State, llm=None) -> dict:
llm = llm or default_llm
out = llm.invoke(state["messages"])
return {"plan": parse_plan(out), "step": state["step"] + 1}
分岐はノードではなく条件関数に置きます。次にどこへ行くかを決める関数を別に置けば、その関数だけで「こういう状態ではツールを呼び出し、こういう状態では終わる」を表にしてテストできます。モデルを呼び出さなくても、流れ全体を確認できるようになります。
状態は、何が積み上がり、何が上書きされるかを明示します。会話の履歴は積み上がり、現在の計画は上書きされます。これを型に書いておけば、ノードを増やすときに混乱しません。積み上げるべきものを上書きすると文脈が消え、上書きすべきものを積み上げると、プロンプトが膨れ上がります。
途中の状態を保存すれば、再開と人の介入が一緒についてきます。ノードごとに状態を保存しておけば、途中で止まって人に尋ね、その答えで続きから進められます。承認が必要な作業(メール送信、決済、削除)は、この場所で区切ります。
観測はノード単位で残します。どのノードで何秒かかり、トークンをどれだけ使ったかが残らないと、遅い場所と高い場所がわかりません。全体の所要時間だけを測ると、改善する場所を見つけられません。
現場では
面接で「エージェントを作ったことはありますか」と尋ねるとき、実際に聞きたいのは、フレームワークの名前ではなく、この3つです。終わる条件を何にしたか、ツールが失敗したらどこへ送るか、何を残しておいたか。
そして、これはエージェントだけの話ではありません。リトライの上限、失敗経路、観測の記録は、バックエンドでいつも行ってきた判断です。LangGraphは、その判断をグラフで書かせてくれるだけです。