😶

LangGraph マルチエージェントのチュートリアルを動かしてみる

2024/11/13に公開

エージェントツールの開発がLangChain からLangGraph に移行するので、LangGraph の理解も重要になっている。
単にLangGraph を使いたいだけなら、LangGraph のチュートリアルを動かしてみると良い。が、LangGraph のメリットはやはり、複雑なエージェント間の制御を可能にすることだと思う。

今回はPython 版のMultiagent Workflow のチュートリアルのうち、multi-agent-collaborationを動かしてみたので、その備忘録。これを理解していれば残りもまあ問題ないと思われる。(JavaScript 版は触ったことないが...)

チュートリアルの解説

Multi-agent network

「divide-and-conquer」は「分割統治法」。大学の授業とかで耳にしてたら以下イメージしやすいかも。

Create Agents

MessagesPlaceholder

プロンプトの一部(コンテクストや質問文など)はRAG でよく変数化するが、プロンプトごと変数化するのがMessagesPlaceholder
今回の場合、変数messages の中身が全てLLM に入力されることになる。

参考) LangChain API Reference:MessagesPlaceholder

prompt.partial()

prompt の引数の一部を設定してしまう処理。以下のコードを読んだらイメージしやすいかも。
(※ 内部のコード見るとfunctools.partial は使って無さそうだったので、あくまでイメージまで)

from functools import partial

def echo(a: str, b: str):
    print(f"{a}, {b}!")

echo2 = partial(echo, a="Hello")
echo2(b="World")  #Hello, World!

llm.bind_tools()

LLM にツール群を与えて、どのツールを使い & どんな引数を入力すべきかを答えさせる(→ エージェント化する)ことが出来る。
実際にそのツールを実行するためには、 そのための処理が別途必要 なのに注意。

参考) LangChain 公式ドキュメント:tool calling

Define tools

デコレータ@tool により、定義した関数をツールに出来る。LLM がツールの詳細を把握するため、description やannotaion が必要。

from langchain_core.tools import tool
from typing import Annotated

@tool
def double(a: Annotated[int, "2倍したい整数"] ):
    """入力された整数を2倍した値を返します。"""
    return 2 * a

print(double.name)  #double
print(double.description)  #入力された整数を2倍した値を返します。
print(double.args)  #{'a': {'description': '2倍したい整数', 'title': 'A', 'type': 'integer'}}

Create graph

Define State

LangGraph の肝は「状態, State」であると思う。本記事では以下、「State」 はLangGraph 一般における"状態"を指すものとする。

AgentStateTypedDict 型を継承しているので、{"messages" : ..., "sender" : ...} のような辞書と考えれば良い。

class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], operator.add]
    sender: str

ただしState の annotation には特別な意味がある のに注意。

  • AgentState.messagesBaseMessage のシーケンス型)は、operator.add がアノテーションされているため、State 更新時、 新規メッセージがシーケンスの末尾に追加される 挙動になる。
  • AgentState.sender にはアノテーションが無いため、State の更新時に置換される挙動になる。

Define Agent Nodes

LangGraph において、頂点(ノード)はState のみを引数とし、新たなState を返す関数である。ノードに到達したとき、その関数の処理が行われる。
agent_node 関数はstateagentname の3つの引数が必要だが、実際にノードとして設定する際はagentnamefunctools.partial で設定してしまうので問題ない)

正確には、ノードはRunnableLike であれば良い。すなわちエージェントでも、LangGraph そのものでも良い。
hierarchical_agent_teams でやってるのがそれ。

agent_node()

agent_node 関数の処理は以下の通り。

  1. agentstate をinvoke した結果を得る。

    1. で得られた結果に対して、出力結果がToolMessage 型ならpass 。そうでない場合、type を削除、name を書き換えてAIMessage 型を再作成する。
from langchain_core.messages import HumanMessage, AIMessage

result = HumanMessage(content='', name='test', type='human')
print(AIMessage(**result.dict(exclude={'name', 'type'})))  #content='' additional_kwargs={} response_metadata={}

※ 今回はresearch_nodechart_node しか作成しない都合上、1. の結果はAIMessage 型以外有り得ない。
そのためToolMessage 型の分岐・type の削除処理は不要だが、汎用性を考慮してこのような分岐を設定していると思われる。

  1. "messages"=[2. の結果]、 "sender"=エージェント名 とした辞書を返す。

State のannotation から、返された辞書の"messages" の中身がAgentState.messages の末尾に追加されることに注意。AgentState.sender は返された辞書の"sender" の中身に置き換わる。

Define Tool Node

llm.bind_tools() だとツールを選ぶだけなので、そのツールを実行するためのノードを作成している。ToolNode 便利

Define Edge Logic

LangGraph の複雑な分岐処理を可能にするのがRouter である。
Router はState を引数として文字列を返す関数で、後述するadd_conditional_edges と合わせて分岐が決定する。

今回のrouter は以下のような処理になる。

  1. statemessages シーケンスのうち、最後尾(=直近のノードで行われた処理の結果)を見る。
  2. それにtool_calls が含まれる場合、LLM がツールを使うべきと判断したわけなので、ツールを実行するノードの名前"call_tool" を返す。
  3. それの出力に"FINAL ANSWER" が含まれる場合、LLM が全ての処理を終了したと判断したわけなので、処理終了ノードを返す。
  4. どちらでもない場合、処理続行として、"continue" を返す。

Define the Graph

いよいよグラフを定義する。

add_node()

これはグラフにノード(ノード名と関数)を追加するだけ。

add_edge()

グラフに辺(エッジ)を追加する。
有向グラフであるのに注意。add_edge(A, B) だとA → B のようなエッジになる。

add_conditional_edges()

グラフの分岐を定義する。引数は、(ノード名、router、辞書) のような形式になる。

  1. そのノードに到達し、ノードの関数を実行してState が更新される。
  2. router にState を入力した結果を得る。
  3. 辞書において、2. の結果をキーとしたときのvalue が、次のノードになる。

ノードcall_tool の遷移先は、必ず その直前のノードになる ことに注意。State の"sender" の使い道はここ。

workflow.add_conditional_edges(
    "call_tool",
    lambda x: x["sender"],
    {
        "Researcher": "Researcher",
        "chart_generator": "chart_generator",
    },
)

実行例

理解は難しいと思うので、チュートリアルを実際に実行した結果を貼っておく。

ただしチュートリアルどおりだと動作しない(イギリスのGDP の正確なデータが入手できずにResearcher が途中で投げてしまい、chart_generator の手にも負えないため、エージェント間の盥回しが無限ループする)ことがあったので、適当に質問文を変えています。あと3期面白いし

また途中経過がよく分からないので、pretty_print() 使って確認するのがおすすめ。

for event in graph.stream(
    {
        "messages": [
            HumanMessage(
                content="<グラフへの入力>"
            )
        ],
    },
    # Maximum number of steps to take in the gra
    {"recursion_limit": 150},
    stream_mode="values"
):
    if "messages" in event:
        event["messages"][-1].pretty_print()

グラフへの入力

Liella! のメンバー人数を縦軸、2020年,2021年,2022年,2023年を横軸としたmatplotlib グラフを出力してください。ただし、グラフのタイトルと軸は英語で記述すること。

Researcher(1回目)

call_tool(tavily_tool) へ遷移。Args はLiella! メンバー人数 2020 2021 2022 2023

call_tool(1回目):tavily_tool

[{"url": "https://www.lovelive-anime.jp/yuigaoka/cast/", "content": "Liella!(リエラ)キャスト紹介 | ラブライブ!スーパースター!! Liella!リエラ 音楽CDのリリース、アニメーション、書籍、スマートフォン向けアプリなど、オールメディアで展開をおこなうプロジェクト・ラブライブ!シリーズ。その第4作となる「ラブライブ!スーパースター!!」から生まれた11人組のスクールアイドルグループです。...(以下略)}]

→ 直前のノードResearcher に戻る。

Researcher(2回目)

Liella! のメンバー人数について以下の情報が得られました:

- 2020年: 5人
- 2021年: 5人(2021年4月にデビュー)
- 2022年: 9人(2期生4人を追加)
- 2023年: 11人(3期生2人を追加)

これらの情報を基に、matplotlibを使ってグラフを生成します。

call_tool(tavily_tool) へ遷移。Args はmatplotlib グラフ作成方法

call_tool(2回目):tavily_tool

[{"url": "https://www.yutaka-note.com/entry/matplotlib_pyplot", "content": "mat-plotlib.pyplotモジュールを使用してグラフを作成する方法をまとめています。 pyplotでは手軽に手早く簡単にグラフ作成することが可能です! グラフを作成、 グラフのスタイル変更、 タイトルや軸の設定など基本事項について徹底解説しています!"}, {"url": "https://www.tech-teacher.jp/blog/python-library19-pyplot/", "content": "Matplotlib でグラフを作成するには主に『pyplotインターフェース』を用いる方法、『オブジェクト指向インターフェース』を用いる方法の2つがあります。. 本記事では『pyplotインターフェース』の使い方と関数について解説します。. もう一つのオブジェクト"}]

→ 直前のノードResearcher に戻る。

Researcher(3回目)

2回目のtavily_tool では有益な情報が得られなかったが、LLM の前提知識としてコードを書いてしまったらしい。

Matplotlib の pyplot モジュールを使用して、Liella! のメンバー人数の推移を示すグラフを作成します。以下はそのコード例です。

'''python
import matplotlib.pyplot as plt

# データ
years = [2020, 2021, 2022, 2023]
members = [5, 5, 9, 11]

# グラフの作成
plt.figure(figsize=(8, 6))
plt.plot(years, members, marker='o')

# タイトルとラベルの設定
plt.title('Number of Liella! Members Over Years')
plt.xlabel('Year')
plt.ylabel('Number of Members')

# グリッドの表示
plt.grid(True)

# グラフの表示
plt.show()
'''

このコードを実行すると、2020年から2023年にかけてのLiella! のメンバー人数の変化を示す折れ線グラフが表示されます。

tool_calls"FINAL ANSWER" も出力されなかったため、chart_generator へ遷移。

chart_generator(1回目)

call_tool(python_repl) へ遷移。Args は上記のコードの中身

call_tool(3回目):python_repl

Successfully executed:
'''python
import matplotlib.pyplot as plt

# Data
years = [2020, 2021, 2022, 2023]
members = [5, 5, 9, 11]

# Create the plot
plt.figure(figsize=(8, 6))
plt.plot(years, members, marker='o')

# Set title and labels
plt.title('Number of Liella! Members Over Years')
plt.xlabel('Year')
plt.ylabel('Number of Members')

# Show grid
plt.grid(True)

# Display the plot
plt.show()
'''
Stdout: 

If you have completed all tasks, respond with FINAL ANSWER.

→ 直前のノードchart_generator に戻る。

chart_generator(2回目)

Here is the matplotlib graph showing the number of Liella! members from 2020 to 2023:

- **Title**: Number of Liella! Members Over Years
- **X-axis**: Year (2020, 2021, 2022, 2023)
- **Y-axis**: Number of Members

The graph clearly illustrates the increase in the number of Liella! members over these years.

FINAL ANSWER

"FINAL ANSWER" が出力されたため、終了ノードEND へ遷移。

Discussion