LangGraphをLLMなしでちょっと触ってみよう #langgraph #langchain #ai #llm #python

この記事は1年以上前に投稿されました。情報が古い可能性がありますので、ご注意ください。
はじめに
LangGraphとは、LLM (Lagre Language Models; 大規模言語モデル)を使用した、ステートフルなエージェントやワークフローを作成するためのライブラリです。LLMを使用したアプリケーションを開発するためのフレームワークであるLangChainの兄弟といった感じです。
LangGraphを使うとゆくゆくはAIエージェントなどを容易に作れそうなので、まず取っ掛かりとしてLangGraphそのものがどういうものなのか、とExampleやTutorialsを開いてみます。すると最初からLLM関連コードが入っているので、LangGraphそのものを知りたいという向きには少ししんどいなと感じました。
そこでLLM関連コードをまったく書かず、LangGraphそのものの動きを見るためのコードを調べながら書いてみたので本稿でご紹介したいと思います。LangGraphの文法や仕様などは詳しく触れません。それらに関しては公式ドキュメントも併せてご覧ください。
また、次の記事を参考にさせていただきました。
「ノード」と「エッジ」
まず「グラフ」という概念において、点である「ノード」と線である「エッジ」という構成要素を押さえておく必要がありそうです。LangGraphにおいても、処理を担当する「ノード」と処理同士を接続する「エッジ」という、次のような図になりそうです。

2つのノードを接続する
- valueを- 1にする- node1
- valueを- 2にする- node2
と2つのノードがあり、
- 処理は node1からnode2に進む
というエッジで2つのノードを接続するとします。また、
- node1から始まる
- node2で終わる
という流れとします。ここで value の初期値を 0 として開始するLangGraphスクリプトを書いてみました。
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
# グラフ内で受け渡しされるステートフルなオブジェクトの宣言
class State(TypedDict):
    value: str
# value を 1 にする node1
def node1(state: State):
    return {"value": "1"}
# value を 2 にする node2
def node2(state: State):
    return {"value": "2"}
# ステートフルなグラフの初期化
workflow = StateGraph(State)
# グラフにノードを追加
workflow.add_node("node1", node1)
workflow.add_node("node2", node2)
# グラフにエッジを追加: 処理は node1 から node2 に進む
workflow.add_edge("node1", "node2")
# node1 から始まる
workflow.set_entry_point("node1")
# node2 で終わる
workflow.set_finish_point("node2")
# グラフをコンパイル
app = workflow.compile()
# グラフをアスキーアートで表示
app.get_graph().print_ascii()
# グラフを実行、引数は値の初期値
print(app.invoke({"value": "0"}))
このような形になります。 add_node と add_edge の字面が似ているので並んでいると目が滑るのですが…。コメントを入れたり、LangGraphの理解が進めば見分けがつくようになってくるかなと思います。また、LLM関係のコードを含めず、純粋にLangGraphのみのコードになっているので、まだ何をやっているか分かりやすいはずです。
これを実行すると次のようになります。
+-----------+
| __start__ |
+-----------+
      *
      *
      *
  +-------+
  | node1 |
  +-------+
      *
      *
      *
  +-------+
  | node2 |
  +-------+
      *
      *
      *
 +---------+
 | __end__ |
 +---------+
{'value': '2'}
value が 0 で開始し、 2 となって終わりました。途中の 1 となっているところが見えませんが、スクリプトの最終行を、
print(app.invoke({"value": "0"} ,debug=True))
とすることでデバッグ出力が有効となり、次のように途中経過もわかるようになります。
[-2:checkpoint] State at the end of step -2:
{}
[0:tasks] Starting step 0 with 1 task:
- __start__ -> {'value': '0'}
[0:writes] Finished step 0 with writes to 1 channel:
- value -> '0'
[-2:checkpoint] State at the end of step -2:
{'value': '0'}
[1:tasks] Starting step 1 with 1 task:
- node1 -> {'value': '0'}
[1:writes] Finished step 1 with writes to 1 channel:
- value -> '1'
[-2:checkpoint] State at the end of step -2:
{'value': '1'}
[2:tasks] Starting step 2 with 1 task:
- node2 -> {'value': '1'}
[2:writes] Finished step 2 with writes to 1 channel:
- value -> '2'
グラフの条件分岐
グラフに条件分岐を作ります。特に注目するところだけにコメントをつけていきます。
from typing import Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
import random
class State(TypedDict):
    value: str
def start_node(state: State):
    return {"value": "start"}
def east_node(state: State):
    return {"value": "east"}
def west_node(state: State):
    return {"value": "west"}
def end_node(state: State):
    return {"value": state["value"]}
workflow = StateGraph(State)
workflow.add_node("start_node", start_node)
workflow.add_node("east_node",  east_node)
workflow.add_node("west_node",  west_node)
workflow.add_node("end_node",   end_node)
workflow.set_entry_point("start_node")
# ランダムに east_node か west_node に分岐
def routing(state: State) -> Literal["east_node", "west_node"]:
    if random.randint(0,1) == 0:
       return "east_node"
    else:
       return "west_node"
# グラフに条件分岐エッジを追加
workflow.add_conditional_edges("start_node", routing)
workflow.add_edge("east_node", "end_node")
workflow.add_edge("west_node", "end_node")
workflow.set_finish_point("end_node")
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": "0"}))
start_node から開始し、ランダムに east_node か west_node を通り、 end_node で終了します。
            +-----------+
            | __start__ |
            +-----------+
                  *
                  *
                  *
            +------------+
            | start_node |
            +------------+
           ...         ...
          .               .
        ..                 ..
+-----------+           +-----------+
| east_node |           | west_node |
+-----------+           +-----------+
           ***         ***
              *       *
               **   **
            +----------+
            | end_node |
            +----------+
                  *
                  *
                  *
             +---------+
             | __end__ |
             +---------+
{'value': 'east'}
実行するたびに、最終結果が {'value': 'east'} か {'value': 'west'} になります。ランダムなので、同じ結果が続くこともあります。
値の追加
これまでは value という str 形式の値を上書きしていました。ここでは str 形式の値を要素として持つ list 形式として値を追加してみます。
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from operator import add
# グラフ内で受け渡しされるステートフルなオブジェクトの宣言
# list[str] には add (追加) を行う
class State(TypedDict):
    value: Annotated[list[str], add]
# value に「node1」を追加する node1
def node1(state: State):
    return {"value": ["node1"]}
# value に「node2」を追加する node2
def node2(state: State):
    return {"value": ["node2"]}
workflow = StateGraph(State)
workflow.add_node("node1", node1)
workflow.add_node("node2", node2)
workflow.add_edge("node1", "node2")
workflow.set_entry_point("node1")
workflow.set_finish_point("node2")
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": []}))
これを実行すると次のようになります。
+-----------+
| __start__ |
+-----------+
      *
      *
      *
  +-------+
  | node1 |
  +-------+
      *
      *
      *
  +-------+
  | node2 |
  +-------+
      *
      *
      *
 +---------+
 | __end__ |
 +---------+
{'value': ['node1', 'node2']}
初期値 [] で開始した value に要素が2つ追加され、 ['node1', 'node2'] で終了していることがわかります。
2つのノードを並列実行
先程は2つのノードを条件分岐、つまりどちらか1つだけを実行していましたが、今回は2つのノードを並列実行してみます。
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from operator import add
class State(TypedDict):
    value: Annotated[list[str], add]
def start_node(state: State):
    return {"value": ["start"]}
def east_node(state: State):
    return {"value": ["east"]}
def west_node(state: State):
    return {"value": ["west"]}
def end_node(state: State):
    return {"value": ["end"]}
workflow = StateGraph(State)
workflow.add_node("start_node", start_node)
workflow.add_node("east_node",  east_node)
workflow.add_node("west_node",  west_node)
workflow.add_node("end_node",   end_node)
workflow.set_entry_point("start_node")
# start_node から east_node と west_node で分岐
workflow.add_edge("start_node", "east_node")
workflow.add_edge("start_node", "west_node")
# east_node と west_node から end_node へ合流
workflow.add_edge(["east_node", "west_node"], "end_node")
workflow.set_finish_point("end_node")
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": []}))
条件分岐のときは add_conditional_edges が必要でしたが、並列実行する際は単純に add_edge で複数のエッジを引くだけでOKです。
            +-----------+
            | __start__ |
            +-----------+
                  *
                  *
                  *
            +------------+
            | start_node |
            +------------+
           ***         ***
          *               *
        **                 **
+-----------+           +-----------+
| east_node |           | west_node |
+-----------+           +-----------+
           ***         ***
              *       *
               **   **
            +----------+
            | end_node |
            +----------+
                  *
                  *
                  *
             +---------+
             | __end__ |
             +---------+
{'value': ['start', 'east', 'west', 'end']}
このように特別なコードを書かずとも、並列実行できています。
ノード数が異なる並列実行
ノードが2つのルートと、ノードが1つのルートに分岐し、並列実行します。
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from operator import add
class State(TypedDict):
    value: Annotated[list[str], add]
def start_node(state: State):
    return {"value": ["start"]}
def east1_node(state: State):
    return {"value": ["east1"]}
def east2_node(state: State):
    return {"value": ["east2"]}
def west_node(state: State):
    return {"value": ["west"]}
def end_node(state: State):
    return {"value": ["end"]}
workflow = StateGraph(State)
workflow.add_node("start_node", start_node)
workflow.add_node("east1_node",  east1_node)
workflow.add_node("east2_node",  east2_node)
workflow.add_node("west_node",  west_node)
workflow.add_node("end_node",   end_node)
workflow.set_entry_point("start_node")
# start_node から east1_node、east1_node から east2_node へ
workflow.add_edge("start_node", "east1_node")
workflow.add_edge("east1_node", "east2_node")
# start_node から west_node へ
workflow.add_edge("start_node", "west_node")
# east2_node と west_node から end_node へ合流
workflow.add_edge(["east2_node", "west_node"], "end_node")
workflow.set_finish_point("end_node")
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": []}))
この場合も特別なコードを書く必要はありません。
             +-----------+
             | __start__ |
             +-----------+
                   *
                   *
                   *
            +------------+
            | start_node |
            +------------+
            ***          **
           *               **
         **                  **
+------------+                 **
| east1_node |                  *
+------------+                  *
       *                        *
       *                        *
       *                        *
+------------+           +-----------+
| east2_node |           | west_node |
+------------+           +-----------+
            ***         ***
               *       *
                **   **
             +----------+
             | end_node |
             +----------+
                   *
                   *
                   *
              +---------+
              | __end__ |
              +---------+
{'value': ['start', 'east1', 'west', 'east2', 'end']}
アスキーアートでは east2_node と west_node が横に並んでいるので west_node が待たされているように見えますが、実際は east1_node と west_node が先に実行されています。
さらにノード数が異なる並列実行
- east1_node- east2_node
- west_node
- north1_node- north2_node- north3_node
の3ルートを並列実行してみます。
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from operator import add
class State(TypedDict):
    value: Annotated[list[str], add]
def start_node(state: State):
    return {"value": ["start"]}
def east1_node(state: State):
    return {"value": ["east1"]}
def east2_node(state: State):
    return {"value": ["east2"]}
def west_node(state: State):
    return {"value": ["west"]}
def north1_node(state: State):
    return {"value": ["north1"]}
def north2_node(state: State):
    return {"value": ["north2"]}
def north3_node(state: State):
    return {"value": ["north3"]}
def end_node(state: State):
    return {"value": ["end"]}
workflow = StateGraph(State)
workflow.add_node("start_node",  start_node)
workflow.add_node("east1_node",  east1_node)
workflow.add_node("east2_node",  east2_node)
workflow.add_node("west_node",   west_node)
workflow.add_node("north1_node", north1_node)
workflow.add_node("north2_node", north2_node)
workflow.add_node("north3_node", north3_node)
workflow.add_node("end_node",    end_node)
workflow.set_entry_point("start_node")
workflow.add_edge("start_node", "east1_node")
workflow.add_edge("east1_node", "east2_node")
workflow.add_edge("start_node", "west_node")
workflow.add_edge("start_node",  "north1_node")
workflow.add_edge("north1_node", "north2_node")
workflow.add_edge("north2_node", "north3_node")
workflow.add_edge(["east2_node", "west_node", "north3_node"], "end_node")
workflow.set_finish_point("end_node")
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": []}))
これを実行してみます。
                          +-----------+
                          | __start__ |
                          +-----------+
                                 *
                                 *
                                 *
                          +------------+
                          | start_node |
                       ***+------------+****
                   ****          *          ****
               ****              *              ****
            ***                  *                  ****
+-------------+                  *                      ***
| north1_node |                  *                        *
+-------------+                  *                        *
        *                        *                        *
        *                        *                        *
        *                        *                        *
+-------------+                  *                 +------------+
| north2_node |                  *                 | east1_node |
+-------------+                  *                 +------------+
        *                        *                        *
        *                        *                        *
        *                        *                        *
+-------------+           +-----------+            +------------+
| north3_node |           | west_node |            | east2_node |
+-------------+****       +-----------+         ***+------------+
                   ****          *          ****
                       ****      *      ****
                           ***   *   ***
                           +----------+
                           | end_node |
                           +----------+
                                 *
                                 *
                                 *
                           +---------+
                           | __end__ |
                           +---------+
{'value': ['start', 'east1', 'west', 'north1', 'east2', 'north2', 'north3', 'end']}
このような結果になりました。アスキーアートではズレてしまっていますが、 east1_node west_node north1_node がまず実行され、次に east2_node north2_node が実行、最後に north3_node が実行されています。並列実行とは言いますが結果を見てみると、グラフに追加した順番でノードが実行されていることに注目してください。
ループ実行
ここまでは明示的に複数のノードを追加していましたが、ループを用いて複数のノードを追加します。
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from operator import add
import random
from langgraph.constants import Send
class State(TypedDict):
    value: Annotated[list[str], add]
    num: int
def start_node(state: State):
    return
def proc_node(state: State):
    return {"value": [state["num"]]}
# 3回ループ
def continue_to_proc(state: State):
    return [Send("proc_node", {"num": i}) for i in range(3)]
def end_node(state: State):
    return
workflow = StateGraph(State)
workflow.add_node("start_node", start_node)
workflow.add_node("proc_node",  proc_node)
workflow.add_node("end_node",   end_node)
workflow.set_entry_point("start_node")
workflow.add_conditional_edges("start_node", continue_to_proc, ["proc_node"])
workflow.add_edge("proc_node", "end_node")
workflow.set_finish_point("end_node")
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": []}))
次のようになります。
+-----------+
| __start__ |
+-----------+
       *
       *
       *
+------------+
| start_node |
+------------+
       .
       .
       .
+-----------+
| proc_node |
+-----------+
       *
       *
       *
 +----------+
 | end_node |
 +----------+
       *
       *
       *
  +---------+
  | __end__ |
  +---------+
{'value': [0, 1, 2]}
アスキーアートからはわかりませんが proc_node が3回実行されています。
開始と終了を略記する
START と END を使うと、開始ノードと終了ノードの明示的な用意や set_entry_point と set_finish_point の指定が不要になります。
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from operator import add
import random
from langgraph.constants import Send
class State(TypedDict):
    value: Annotated[list[str], add]
    num: int
def proc_node(state: State):
    return {"value": [state["num"]]}
def continue_to_proc(state: State):
    return [Send("proc_node", {"num": i}) for i in range(3)]
workflow = StateGraph(State)
workflow.add_node("proc_node",  proc_node)
# START から始める
workflow.add_conditional_edges(START, continue_to_proc, ["proc_node"])
# END で終わる
workflow.add_edge("proc_node", END)
app = workflow.compile()
app.get_graph().print_ascii()
print(app.invoke({"value": []}))
すっきり書けました。
+-----------+
| __start__ |
+-----------+
      .
      .
      .
+-----------+
| proc_node |
+-----------+
      *
      *
      *
 +---------+
 | __end__ |
 +---------+
{'value': [0, 1, 2]}
グラフの描画もすっきりしており、結果も変わりません。
ワークフローのモックアップ
突然複雑なことをやってみます。
- 日本語で質問文を受け取る。
- 日本語の質問文を英訳・要約する。
- 英語の質問文でウェブ検索する。
- 検索でヒットしたURLを取得・日本語訳・要約する。
- 結果を出力する。
というモックアップを作ります。あくまでモックアップなので実際に翻訳や要約などはしません。LangGraphで処理の流れをどう表現するのか、という観点に絞っています。
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from operator import add
from langgraph.constants import Send
class State(TypedDict):
    question: str
    urls: list[str]
    answers: Annotated[list[dict], add]
class IterState(TypedDict):
    url: str
def translate_summerize_question(state: State):
    # XXX: 英訳・要約処理
    translate_summerize_q = "What is Docker?"
    return {"question": translate_summerize_q}
def search_web(state: State):
    # XXX: 検索処理
    urls = [
        "https://docs.docker.com/get-started/docker-overview/",
        "https://aws.amazon.com/docker/",
        "https://www.ibm.com/topics/docker",
        "https://www.techtarget.com/searchitoperations/definition/Docker",
        "https://www.geeksforgeeks.org/introduction-to-docker/",
    ]
    return {"urls": urls}
def scrape_translate_summerize_web(state: IterState):
    url = state["url"]
    # XXX: ウェブ取得・翻訳・要約処理
    content = url.replace("https://","").replace("/","_")
    return {"answers": [{url: content}]}
def continue_to_scrape(state: State):
    return [Send("scrape_translate_summerize_web", {"url": s}) for s in state["urls"]]
workflow = StateGraph(State)
workflow.add_node("translate_summerize_question", translate_summerize_question)
workflow.add_node("search_web", search_web)
workflow.add_node("scrape_translate_summerize_web", scrape_translate_summerize_web)
workflow.add_edge(START, "translate_summerize_question")
workflow.add_edge("translate_summerize_question", "search_web")
workflow.add_conditional_edges("search_web", continue_to_scrape, ["scrape_translate_summerize_web"])
workflow.add_edge("scrape_translate_summerize_web", END)
app = workflow.compile()
app.get_graph().print_ascii()
stats = app.invoke({"question": "Dockerとは何ですか?"})
print()
print("question: " + stats["question"])
print()
for a in stats["answers"]:
    print(a)
次のような結果になります。
          +-----------+
          | __start__ |
          +-----------+
                 *
                 *
                 *
 +------------------------------+
 | translate_summerize_question |
 +------------------------------+
                 *
                 *
                 *
          +------------+
          | search_web |
          +------------+
                 .
                 .
                 .
+--------------------------------+
| scrape_translate_summerize_web |
+--------------------------------+
                 *
                 *
                 *
            +---------+
            | __end__ |
            +---------+
question: What is Docker?
{'https://docs.docker.com/get-started/docker-overview/': 'docs.docker.com_get-started_docker-overview_'}
{'https://aws.amazon.com/docker/': 'aws.amazon.com_docker_'}
{'https://www.ibm.com/topics/docker': 'www.ibm.com_topics_docker'}
{'https://www.techtarget.com/searchitoperations/definition/Docker': 'www.techtarget.com_searchitoperations_definition_Docker'}
{'https://www.geeksforgeeks.org/introduction-to-docker/': 'www.geeksforgeeks.org_introduction-to-docker_'}
ワークフローをLangGraphで表現することができました。あとはこれをもとに、実際にLLMやウェブ取得処理などを組み込んでいくことになりますが、本稿の趣旨から外れるので一旦ここまでとしておきます。
まとめ
本稿ではLangGraphそのものの動きを見るために、LLM関連コードをまったく書かずにワークフローグラフを表現することのみに焦点を絞ってコードを書いてみました。LangGraphに集中することができたので理解が進み、この後でLLM関連コードを追加したり、さらにLangGraphの機能を使ったりする上での土台になったかと思います。多分LLM関連コードが含まれたLangGraphのコードを見ても目が滑らなくなったはずです。
今後は引き続き、LLM関連コードを含めたものや、さらに複雑なワークフローの作成に挑戦していこうと思います。続報をお待ちください。

