グラフエンジニアリングとは何か・一本道のチェーンでは足りない場面の設計
ループエンジニアリングの記事では、エージェントに「もう一周」させる外側ループの設計を見てきました。ここではさらに、その1周1周の中身が単純な一本道では済まなくなったときの設計、グラフエンジニアリングを扱います。LangChainの記事で見た「チェーン」(複数の処理ステップをひとつなぎにしたもの)があれば、複雑な処理も十分組み立てられるだろうと思うかもしれませんが、チェーンには一度組んだ処理の流れを後から作り変えにくいという制約があります。
一本道の「チェーン」では、分岐・ループ・並列実行を表現しきれない
ステップを順番につなげるチェーンの発想があれば、どんなに複雑な処理も十分に組み立てられるだろうと思うかもしれませんが、実際の業務ワークフローには「条件によって次に進む処理が変わる分岐」「基準を満たすまで繰り返すループ」「複数の処理を同時に走らせる並列実行」が必要になる場面が数多くあります。これらを一本道のチェーンだけで表現しようとすると、条件分岐のたびに別々のチェーンを用意して手動でつなぎ替える羽目になり、すぐに破綻します。グラフエンジニアリングは、処理の単位を「ノード」、ノード同士のつながりを「エッジ」として表現し、エッジに条件を持たせることで、分岐・ループ・並列実行を1つの構造の中で扱えるようにする設計手法です。
# チェーン(一本道)とグラフ(分岐あり)の違いのイメージ
チェーン: 調査 → 執筆 → 完了
グラフ:
調査 → 執筆 → 校正
├─ 基準を満たす → 完了
└─ 基準を満たさない → 調査に戻る(ループ)
# 「校正」ノードの結果によって次に進む先が変わる
# = エッジに条件(条件付きエッジ)を持たせている
チェーンは、決まった順路をそのまま歩くだけの一本道の観光ルートに近いものです。分岐が無い前提なら迷いようがなく分かりやすい反面、「この展示が微妙だったら別のルートに変える」といった融通は利きません。グラフは、分かれ道・行き止まり・時には元の地点に戻る周回路まである攻略マップのようなものです。「ここでこの条件を満たしたらこっちの道」という分岐そのものを地図の上に描けるため、一本道では表現できない複雑な攻略順序を組み立てられます。
ノード間で受け渡される「状態(State)」というオブジェクト
各ノードはそれぞれ独立した処理をこなすだけで、他のノードが何をしたかは気にしなくてよいのだろうと思うかもしれませんが、実際にはグラフ全体で共有される「状態(State)」という1つのオブジェクトがあり、各ノードはこの状態を読み取り、自分の処理結果を書き加えてから次のノードに渡していきます。バトンリレーのバトンのように、状態オブジェクトが次々と手渡されていくイメージです。
# 状態(State)が各ノードを経由して更新されていくイメージ
状態 = { 質問: "...", 調査結果: [], 原稿: "", 校正結果: null }
ノード「調査」 → 状態.調査結果 に検索結果を追加
ノード「執筆」 → 状態.調査結果 を読み、状態.原稿 を書き込む
ノード「校正」 → 状態.原稿 を読み、状態.校正結果 に合否を書き込む
# 各ノードは状態の一部を読み、一部を更新するだけで、
# 状態オブジェクト自体はグラフ全体を通じて1つ
工場の生産ラインで、1枚の工程送り状が各工程を回っていく様子に近いイメージです。組み立て担当は送り状の「部品リスト」欄を見て自分の作業を終えたら「組み立て完了」欄に印を付け、検品担当はその印を見てから自分の検品結果を書き加えます。誰か1人がすべての工程の情報を覚えておく必要はなく、送り状という1つのモノに情報が蓄積されていく点が、グラフにおける状態オブジェクト(バトンリレーのバトン)の役割と同じです。
よくあるはまりポイント:並列に分岐したノードが同じ状態のキーを取り合って衝突する
グラフの形にしておけば、複数のノードを並列に走らせて処理を高速化するのも簡単だろうと思うかもしれませんが、ここには見落としやすい落とし穴があります。複数のノードが同時に実行され、しかも同じ状態のキーに書き込もうとすると、「どちらの更新を採用すべきか」が定まらず衝突が起きます。LangGraphでは、状態の更新ルールを何も指定しなければ「後から書き込んだ値で上書きする」という既定の挙動になっており、これは1つのノードしかそのキーに書き込まない前提では問題ありませんが、並列に分岐した複数のノードが同じキーに書き込む場合はこの前提が崩れ、INVALID_CONCURRENT_GRAPH_UPDATEというエラーになります。対策として、そのキーに「reducer(集約関数)」を明示的に指定しておく必要があります。リストに追記していくoperator.addや、チャット履歴をID単位で重複なく統合するadd_messagesなどが代表的な例です。
- ノード / エッジ
- グラフを構成する要素。ノードは処理の単位、エッジはノード同士のつながり。エッジには条件を持たせられる(条件付きエッジ)
- 状態(State)
- グラフ全体で共有され、各ノードが読み書きしながら次へ渡していくオブジェクト。バトンリレーのバトンに相当する
- reducer(集約関数)
- 複数のノードが同じ状態キーに書き込むときの統合方法を決める関数。指定しないと後勝ちで上書きされ、並列分岐では衝突エラーの原因になる
並列に走らせるノードを設計する段階で、それぞれのノードがどの状態キーに書き込むかを一覧にしてみてください。2つ以上のノードが同じキーに書き込む可能性があるなら、後から衝突エラーに気づいて直すのではなく、設計の最初の時点でそのキーにreducerを指定しておくと、後々のデバッグの手間を減らせます。
分岐・状態・並列実行という視点が掴めたら、他の記事ともあわせて読んでみると理解が深まります。(シリーズ一覧・プロンプトエンジニアリング・コンテキストエンジニアリング・ループエンジニアリング)