※アルカナスタッフ、ケビンが英語で書かいた記事をAIで日本語訳しております。
チャットボットを作るのは簡単です。しかし、複雑な問題を実際に考え抜くAIエージェントを構築するとなると、話は別です。
ホテルを検索し、インテリジェントな推奨を行う旅行アシスタントの実験を始めたとき、シンプルな「プロンプト → LLM → レスポンス」フローでは不十分だとすぐに気づきました。もっと洗練されたものが必要でした。つまり、推論し、行動を起こし、最適な答えに到達するまで反復できるオーケストレーション層です。
これは、LangGraphを使った実験を通じてそのシステムを構築し、その過程で発見したアーキテクチャパターンの物語です。
ReActパターン:AIに思考と行動を教える
私のシステムの中核にあるのはReActパターン(推論 + 行動)です。LLMにすべてを一度に解決することを期待するのではなく、シンプルなループを与えます:
- 推論する: ユーザーのクエリを分析し、次に何をすべきか決定する
- 行動する: ツール(データベースクエリ、計算など)を実行する
- 観察する: 結果をレビューする
- 繰り返す: 答えるのに十分な情報が得られるまで続ける
自分で旅行を計画する方法を考えてみてください。パリのすべてのホテルを魔法のように知っているわけではありません。検索し、オプションをレビューし、地域で絞り込み、それから推奨を行います。LLMも同じことをします。
2ノードグラフ
私のオーケストレーショングラフにはわずか2つのノードがあります:
┌─────────────┐
│ Chatbot │ ← LLMの推論と意思決定
└──────┬──────┘
│
├─→ ツール呼び出しあり? → はい ─┐
│ │
└─→ いいえ → 終了 │
│
┌──────────────┐ │
│ Tools │ ←────────────────────┘
└──────┬───────┘
│
└─→ 常にChatbotに戻る┌─────────────┐
│ Chatbot │ ← LLMの推論と意思決定
└──────┬──────┘
│
├─→ ツール呼び出しあり? → はい ─┐
│ │
└─→ いいえ → 終了 │
│
┌──────────────┐ │
│ Tools │ ←────────────────────┘
└──────┬───────┘
│
└─→ 常にChatbotに戻るChatbotノード: LLMがツールを呼び出すか最終回答を提供するかを決定します。
Toolsノード: データベースクエリを実行し、結果を処理し、観察を返します。
条件付きルーティング: LLMの決定に基づいて、さらに情報を収集するためにループバックするか、最終回答で終了します。
このシンプルさが強力です。LLMが自身の反復を制御します。ハードコードされた制限も、硬直したフローもありません。答えるのに十分な情報があるかどうかをLLM自身が決定します。
後でさらに多くのツールを持つノードを追加できます。ここからさらに強力になっていきます。
これが複雑なクエリに対して機能する理由
次のクエリを考えてみましょう:「次の週末、200ドル以下のパリのホテルを見つけて。」
単一のLLM呼び出しでは、ホテル名と価格を幻覚してしまいます。しかしReActを使えば:
- 推論: 「特定の日付と価格フィルタでパリのホテルを検索する必要がある」
- 行動: フィルタ(場所、日付、価格)を使ってデータベースをクエリ
- 観察: 「条件に一致する15のホテルが見つかった」
- 推論: 「評価と設備に基づいてトップ3を推奨しよう」
- 回答: 実際のデータを使った厳選された推奨を返す
このパターンは任意の複雑なクエリにスケールします。なぜなら、LLMは複数のツールを呼び出し、アプローチを改善し、必要に応じて反復できるからです。
コアアーキテクチャ:状態、LLM、ツール
3層状態管理
状態はステートフルエージェントの基盤です。私は3層設計を使用しています:
class AgentState(TypedDict):
messages: List[BaseMessage] # 会話履歴
tool_context: dict # 隠れたランタイムデータ
metadata: dict # エンリッチメント用のツール結果class AgentState(TypedDict):
messages: List[BaseMessage] # 会話履歴
tool_context: dict # 隠れたランタイムデータ
metadata: dict # エンリッチメント用のツール結果なぜ3層なのか?
- Messages: LLMが見る会話履歴。これにはユーザークエリ、LLM応答、ツール呼び出し、ツール結果が含まれます。エージェントの「作業メモリ」です。
- Tool Context: ツールに注入するがLLMからは隠すランタイムデータ。ユーザー設定(通貨、場所)、セッションメタデータ、APIレート制限などを考えてください。これにより、必要なコンテキストを提供しながらツールスキーマをクリーンに保ちます。
- Metadata: ツール実行から蓄積された結果(ホテルID、画像、座標)。LLMはこの大量のデータを見る必要はありません。代わりに後処理のエンリッチメントに使用します。
重要な洞察: LLMが推論するもの(メッセージ)と、システムが実行に使用するもの(コンテキスト)およびプレゼンテーション(メタデータ)を分離します。
デュアルモードLLM:1つのモデル、2つの仕事
ここに私が発見した微妙だが重要なパターンがあります:同じLLMを2つの異なるモードで使用することです。
モード1:ツール呼び出し
会話の初期段階では、LLMは何をするかを決定する必要があります。ツールスキーマがバインドされた通常のLLMを使用します:
llm_with_tools = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)LLMはsearch_hotels_toolやget_hotel_detailsのようなツール呼び出しで応答できます。
モード2:構造化出力
データ(データベースからのホテル)を取得したら、構造化された推奨が必要です。構造化出力モードに切り替えます:
structured_llm = ChatOpenAI(model="gpt-4o-mini").with_structured_output(ChatResponse)これでLLMは有効なJSONを返す必要があります:{ text: "...", hotels: [...] }。これにより、フロントエンドが解析可能なデータを取得できることが保証されます。
動的切り替え:
状態メタデータをチェックします。ホテル結果があれば、構造化出力を使用します。そうでなければ、ツール呼び出しを使用します。これにより、初期段階では柔軟性が得られ、最後には検証が行われます。
なぜ両方を同時に使用しないのか? OpenAIのAPIはツール呼び出しと構造化出力の同時使用をサポートしていません。このデュアルモードパターンが私たちの回避策です。
スマートツール実行
ツール実行は単に「関数を実行する」だけではありません。3つのインテリジェンス層を追加しました:
1. イントロスペクションによるコンテキスト注入
実行時にツール関数のシグネチャを検査します:
sig = inspect.signature(tool_function)
if "tool_context" in sig.parameters:
tool_args["tool_context"] = tool_contextsig = inspect.signature(tool_function)
if "tool_context" in sig.parameters:
tool_args["tool_context"] = tool_contextコンテキストが必要なツール(ユーザーの優先通貨など)は自動的に取得します。不要なツールはシンプルなままです。定型文もツールスキーマの汚染もありません。
2. レート制限
ツールごとの呼び出し制限を強制します:
TOOL_CALL_LIMITS = {
"search_hotels_tool": 1, # ターンごとに1回の検索のみ
}TOOL_CALL_LIMITS = {
"search_hotels_tool": 1, # ターンごとに1回の検索のみ
}これがないと、LLMは1ターンで複数の都市を検索し、データベースクエリを消費してしまう可能性があります。レート制限によりコストを抑え、焦点を絞ったクエリを奨励します。
3. データベースクエリパターン
エージェントフローで外部APIを直接呼び出す代わりに、自分のデータベースをクエリします。これにより次のことが得られます:
- 検索クエリ: 場所ベースのフィルタリング、日付範囲の可用性、価格/評価のしきい値
- 集計クエリ: 地域、価格帯、設備でグループ化
- 詳細クエリ: 選択されたプロパティの完全なホテル情報を取得
外部APIよりデータベースの利点:
- レート制限なし: 自分のデータを制御
- より速い応答時間: サードパーティサービスへのネットワークレイテンシなし
- データ所有権: インデックスを最適化し、積極的にキャッシュし、必要な形にデータを整形できる
データベースを最新の状態に保つために、ホテルAPIを定期的に呼び出して可用性と価格を更新するバックグラウンドワーカーを構築しました。このようにして、エージェントは高速なローカルクエリを取得しながら、データは最新の状態を保ちます。ワーカーは独立して実行され、ユーザーリクエストをブロックすることなくAPIレート制限と再試行を処理します。
条件付きルーティング:ループの仕組み
ReActの魔法はルーティングロジックにあります:
def tools_condition(state):
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools" # LLMがツールを呼び出したい
return "END" # LLMが最終回答を持っているdef tools_condition(state):
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools" # LLMがツールを呼び出したい
return "END" # LLMが最終回答を持っているこれだけです。LLMが応答にツール呼び出しを含んでいれば、toolsノードにルーティングします。そうでなければ、完了です。
自然な終了: LLMが十分な情報があるときに決定します。「最大5回の反復」といったハードコードはありません。LLMが満足するまでループが続きます。
安全ネット: ツール固有のレート制限により無限ループを防ぎます。LLMが同じツールを10回呼び出そうとすると、上限に達します。
トークンバイトークンストリーミング:リアルタイム応答
ユーザーは完全な回答を待つのではなく、生成されるにつれて応答を見ることを期待します。ストリーミングは現代のAIアプリの基本要件です。
課題:ツールを介したストリーミング
シンプルなLLM呼び出しの場合、ストリーミングは簡単です。しかし、ツールを使用したオーケストレーションシステムでは、トリッキーです:
- LLMがトークンを生成... その後ツールを呼び出すことを決定
- ツール実行に2〜3秒かかる(データベースクエリ)
- LLMが結果を処理し、さらにトークンを生成
それを通してどのようにストリーミングを維持しますか?
私のソリューション:バッファリングと再開
LangGraphのastream_events APIを使用して、複数のレベルでストリーミングします:
async for event in graph.astream_events(input_state, config):
if event["event"] == "on_chat_model_stream":
# LLMトークンをユーザーにストリーミング
yield {"type": "token", "content": event["data"]["chunk"]}
elif event["event"] == "on_tool_start":
# 「ホテルを検索中...」メッセージを表示
yield {"type": "status", "content": "Searching hotels..."}
elif event["event"] == "on_tool_end":
# ツール完了後にトークンストリーミングを再開
yield {"type": "status", "content": "Generating recommendations..."}async for event in graph.astream_events(input_state, config):
if event["event"] == "on_chat_model_stream":
# LLMトークンをユーザーにストリーミング
yield {"type": "token", "content": event["data"]["chunk"]}
elif event["event"] == "on_tool_start":
# 「ホテルを検索中...」メッセージを表示
yield {"type": "status", "content": "Searching hotels..."}
elif event["event"] == "on_tool_end":
# ツール完了後にトークンストリーミングを再開
yield {"type": "status", "content": "Generating recommendations..."}パターン:
- 生成されるにつれてLLMからトークンをストリーミング
- ツールが実行されるとき、トークンストリーミングを一時停止し、ステータスメッセージを表示(「検索中...」、「15のホテルが見つかりました...」)
- LLMが続行するときにトークンストリーミングを再開
ユーザーエクスペリエンス: ユーザーはすべてのステップで進捗を見ます。バックグラウンドで複雑な複数ステップの作業を行っていても、応答はリアルタイムに感じられます。
状態の一貫性: ストリーミングは状態管理に影響しません。ユーザーにストリーミングしているかどうかに関係なく、各ノードの後にチェックポイントを作成します。
主要な設計パターン
実験を通じて、強調する価値のあるいくつかのパターンを発見しました。
パターン1:推論と表示の分離
LLMはホテルをランク付けするのは得意ですが、ホテルIDや座標を記憶するのは得意ではありません。そこで責任を分割しました:
LLMの仕事: 名前でトップ3のホテルを選択し、なぜそれらが適しているかを説明する。
後処理の仕事: ホテル名をIDに一致させ、画像/座標を取得し、応答をエンリッチする。
# LLMが返すもの:
{
"text": "中心部に位置するHotel Parisをお勧めします...",
"hotels": [
{"name": "Hotel Paris", "rating": "4.5", "price": "$250"}
]
}
# 後処理が追加するもの:
{
"id": "hotel_abc123",
"thumbnail": "https://...",
"latitude": 48.8566,
"longitude": 2.3522
}# LLMが返すもの:
{
"text": "中心部に位置するHotel Parisをお勧めします...",
"hotels": [
{"name": "Hotel Paris", "rating": "4.5", "price": "$250"}
]
}
# 後処理が追加するもの:
{
"id": "hotel_abc123",
"thumbnail": "https://...",
"latitude": 48.8566,
"longitude": 2.3522
}これが重要な理由:
- LLMは簿記ではなく品質に集中
- 再トレーニングすることなくメタデータの保存/取得方法を変更できる
- よりクリーンなプロンプト(LLMはID、URL、座標を見ない)
パターン2:選択的コンテキスト注入
完全なコンテキストはコストがかかり、気を散らすことを発見しました。すべてのメタデータをターンごとにLLMに送り返す代わりに、選択的に注入します:
# 送信しないもの:
- 完全なホテルリスト(すでにツール結果にある)
- 画像URL(数百文字)
- 座標(推論には役立たない)
# 送信するもの:
- 検索サマリー(「15のホテルが見つかりました、すべて空室あり」)
- 最近のツール使用(「パリのホテルを検索したばかり」)# 送信しないもの:
- 完全なホテルリスト(すでにツール結果にある)
- 画像URL(数百文字)
- 座標(推論には役立たない)
# 送信するもの:
- 検索サマリー(「15のホテルが見つかりました、すべて空室あり」)
- 最近のツール使用(「パリのホテルを検索したばかり」)影響: プロンプトトークンが70%削減。より速い応答、より低いコスト、より良い焦点。
LLMはすでにツール結果でホテルリストを見ました。それを繰り返すのは無駄です。サマリーで十分です。
パターン3:状態としてのメタデータ
ツールは2つのものを返します:LLM用の回答と、システム用のメタデータです。
ToolResponse(
answer="パリの15のホテルです...", # LLM用
metadata={
"hotel_ids": {...},
"images": {...},
"coordinates": {...}
} # 後処理用
)ToolResponse(
answer="パリの15のホテルです...", # LLM用
metadata={
"hotel_ids": {...},
"images": {...},
"coordinates": {...}
} # 後処理用
)これによりデータフローと推論フローが分離されます。LLMはIDやURLを操作する必要はありません。ホテルについて推論するだけです。システムは状態にメタデータを蓄積し、後でエンリッチメントに使用します。
これが機能する理由: 異なる消費者は異なるデータを必要とします。LLMはテキスト説明が必要です。フロントエンドはIDと画像を持つ構造化データが必要です。これらの関心事を分離することで、両方をクリーンに保ちます。
パターン4:永続化のためのチェックポインティング
会話は複数のターンにまたがります。PostgreSQLベースのチェックポインティングを使用して状態を永続化します:
checkpointer = AsyncPostgresSaver(conn=db_pool)
graph = builder.compile(checkpointer=checkpointer)checkpointer = AsyncPostgresSaver(conn=db_pool)
graph = builder.compile(checkpointer=checkpointer)すべてのノード実行後、状態はデータベースに保存されます。ユーザーが明日戻ってきても、完全な会話履歴がそこにあります。
セッション管理: 各ユーザーには複数のセッション(異なる旅行)があります。セッションIDはチェックポインティングの「スレッドID」になります。分離は自動的です。
競合状態: 複数のワーカーがある本番環境では、同時リクエストが同じセッションを変更する可能性があります。更新前にメッセージコンテンツを比較することでこれを検出します。コンテンツが変更されている場合、更新をスキップして警告をログに記録します。
なぜPostgreSQL? すでにユーザーデータに使用しています。同じデータベースに状態を保持することで、運用が簡素化され、クエリが簡単になります(例:「ユーザーXのすべてのセッションを表示」)。
このアーキテクチャを使用すべき場合
このオーケストレーションパターンは次の場合に複雑さに見合う価値があります:
- 複数ステップの推論が必要: シンプルなQ&Aにはオーケストレーションは不要です。しかし、タスクが検索、フィルタリング、比較、推奨を含む場合、それが必要です。
- ツール/アクションが不可欠: エージェントが単にチャットするだけなら、シンプルなLLM呼び出しに固執してください。データベースのクエリ、APIの呼び出し、または計算の実行が必要な場合、オーケストレーションが役立ちます。
- 状態が重要: 会話が複数のターンにまたがり、コンテキストが時間とともに構築される場合、適切な状態管理が必要です。
- 可観測性が必要: オーケストレーション層は、ログ記録、監視、デバッグのためのフックを提供します。ブラックボックスのLLM呼び出しにはありません。
避けるべき場合: シンプルなユースケースの場合、これはオーバーキルです。FAQボットや単一ターンの分類器を構築している場合は、この複雑さを追加しないでください。
このアーキテクチャは、事前設計ではなく実験を通じて現れました。各パターンは、構築中に直面した特定の問題を解決しました。デュアルモードLLMは、幻覚データへのフラストレーションから生まれました。選択的コンテキストは、トークンコストが爆発するのを見て生まれました。バックグラウンドワーカーは、速度を犠牲にすることなく新鮮なデータが必要だと気づいたことから生まれました。
同様のものを構築している場合、これらのパターンが時間を節約できることを願っています。LangGraphはオーケストレーションを扱いやすくしますが、悪魔は細部に宿ります。