Closed15

LangChainのLangGraphを試す

kun432kun432

https://python.langchain.com/v0.1/docs/langgraph/

LangGraphは、LLMを使ってステートフルなマルチアクター・アプリケーションを構築するためのライブラリである。PregelApache BeamにインスパイアされたLangGraphは、通常のPython関数(またはJS)を使って、周期的な計算ステップにまたがる複数のチェーン(またはアクター)の調整とチェックポイントを行うことができる。パブリック・インターフェースはNetworkXからヒントを得ている。

主な用途は、LLMアプリケーションにサイクルと永続性を追加することだ。もしDAG(Directed Acyclic Graphs)を素早く作るだけなら、LangChain Expression Languageを使えばできる。

サイクルは、LLMをループで呼び出して、次にどんなアクションを取るか尋ねるようなエージェント的ビヘイビアには重要だ。

LCELとの違いはループできるステートマシンであるというところかな。

DAGとステートマシンの違いは以下。

https://zenn.dev/link/comments/69ad1acb002b30

kun432kun432

ということで、ColaboratoryでQuick start。ドキュメントとnotebookで被ってるけど、ざっと見た感じnotebookで進めつつ、ドキュメントで説明を読むが良さそう。

https://python.langchain.com/v0.1/docs/langgraph/#installation

https://langchain-ai.github.io/langgraph/how-tos/docs/quickstart/

パッケージインストール。LangGraphは独立したパッケージになっている。

!pip install -U langgraph
!pip install langchain_openai

APIキーをセット

import os
from google.colab import userdata

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

グラフを作成する。

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import END, MessageGraph

# モデルの定義
model = ChatOpenAI(temperature=0)

# グラフを初期化
graph = MessageGraph()

# モデルを呼び出すノード、"first_node"をグラフに追加
graph.add_node("first_node", model)

# "first_node"ノードと"END"ノード(終了ノード)をエッジでつなげる
graph.add_edge("first_node", END)

# グラフのエントリーポイント(開始点)を"first_node"に設定
graph.set_entry_point("first_node")

# グラフをコンパイルして実行可能にする
runnable = graph.compile()

これを実行する。

response = runnable.invoke(HumanMessage("1 たす 1は?"))
for r in response:
    print("{}: {}".format(r.type, r.content))

human: 1 たす 1は?
ai: 1たす1は2です。

なるほど、ノードはRunnable or 関数で、そこから作られたグラフもRunnableなのね。

可視化する。

from IPython.display import Image, display

display(Image(runnable.get_graph(xray=True).draw_mermaid_png()))

で、

なるほど、ノードはRunnable or 関数で、そこから作られたグラフもRunnableなのね。

ということで、関数で書くとこうなる。

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import END, MessageGraph

model = ChatOpenAI(temperature=0)

def call_first_node(messages: list):
    return model.invoke(messages)

graph = MessageGraph()

graph.add_node("first_node", call_first_node)
graph.add_edge("first_node", END)

graph.set_entry_point("first_node")

runnable = graph.compile()

response = runnable.invoke(HumanMessage("1 たす 1は?"))

結果は同じ。

ただし

ただ、runnableへの入力は現在のステート全体であることを念頭においてほしい。だからこれは失敗する:

# MessageGraphでこれは動作しない!
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

prompt = ChatPromptTemplate.from_messages([
   ("system", "あなたは {name} という名前の親切なアシスタントで、いつも大阪弁で話します。"),
   MessagesPlaceholder(variable_name="messages"),
])

chain = prompt | model

# ステートはメッセージのリストである必要があるが、chainは入力に辞書を期待している:
#
# { "name": some_string, "messages": [] }
#
# そのため、グラフは実行時に例外を投げる
graph.add_node("oracle", chain)

の部分はちょっとよくわからない。

MessagesPlaceholderを使わない≒会話履歴を考慮しないのであれば、以下のように実行すると実行できる。

from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_openai import ChatOpenAI
from langgraph.graph import END, MessageGraph


prompt = ChatPromptTemplate.from_messages([
    ("system", "あなたは {name} という名前の親切なアシスタントで、いつも大阪弁で話します。"),
])

chain = prompt | model

graph = MessageGraph()

graph.add_node("first_node", chain)

graph.set_entry_point("first_node")
graph.add_edge("first_node", END)

runnable = graph.compile()

response = runnable.invoke({"name": "花代", "role": "user", "content": "明日の天気を教えて"})
for r in response:
    print("{}: {}".format(r.type, r.content))

human: 明日の天気を教えて
ai: おおっ、花代ちゃん!明日の天気やで〜。明日は晴れるらしいで。最高気温は25度やさかい、ちょっと暑いかもしれへんな。日焼け対策は忘れずにしてや〜。

ただまあ最初の例を見る限りはmessagesをまるっと投げるようなので上記のようなイメージとはちょっと違うっぽい気はしてる。とりあえず

runnableへの入力は現在のステート全体である

というところを少し念頭に置いて、もう少し細かく見ていく中でどういうことかを考えることとする。

kun432kun432

条件分岐

グラフに条件分岐的なものを追加する。

from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode
from langgraph.graph import END, MessageGraph
from langchain_core.messages import BaseMessage
from typing import Literal

# ツールの定義
@tool
def multiply(first_number: int, second_number: int):
    """2つの数値で掛け算を行う。"""
    return first_number * second_number

# 分岐処理を行う関数
def router(state: list[BaseMessage]) -> Literal["multiply", "__end__"]:
    tool_calls = state[-1].additional_kwargs.get("tool_calls", [])
    if len(tool_calls):
        return "multiply"
    else:
        return END

# モデルの定義
model = ChatOpenAI(temperature=0)
# モデルにFunction Callingの関数定義を紐づけ
model_with_tools = model.bind_tools(tools=[multiply])

# グラフを初期化
graph = MessageGraph()

# モデル+Function Callingを呼び出すノード、"first_node"をグラフに追加
graph.add_node("first_node", model_with_tools)

# ツールを実行するノードにツールを紐づけ
tool_node = ToolNode([multiply])

# ツール実行ノード、"multiply"をグラフに追加
graph.add_node("multiply", tool_node)

# "multiply"ノードと"END"ノード(終了ノード)をエッジでつなげる
graph.add_edge("multiply", END)

# グラフのエントリーポイント(開始点)を"first_node"に設定
graph.set_entry_point("first_node")

# 分岐を行うエッジを"first_node"、分岐用関数に紐づけ
graph.add_conditional_edges("first_node", router)

# グラフをコンパイルして実行可能にする
runnable = graph.compile()

ここ最初ちょっとイメージ沸かなかったけど、Function Callingの動きを念頭に置いて考えれば理解できた。

  • bind_tools()のTool Callの定義を渡して、ツールを使うか判断させる
  • これによりmodelが返すのは・・・
    • ツールを使う: tool_callsで関数を返す
    • ツールを使わない: そのままメッセージを返す
  • 上記の結果をrouter()関数で判断するためにconditional_edgeを使う
    • ツールを使う: tool_nodeでツールを実行し結果を取得
    • ツールを使わない: そのままメッセージを返す

多分こういうことだと思う。

実際に動かしてみる。

response = runnable.invoke(HumanMessage("123 かける 456 はいくつ?"))

for r in response:
    print("{}: {}".format(r.type, r.content))

human: 123 かける 456 はいくつ?
ai:
tool: 56088

response = runnable.invoke(HumanMessage("明日の天気を教えて。"))

for r in response:
    print("{}: {}".format(r.type, r.content))

human: 明日の天気を教えて。
ai: 申し訳ありませんが、私は天気情報を提供することができません。他にお手伝いできることがあればお知らせください。

ちゃんと分岐している。

これを可視化するとこうなる。

from IPython.display import Image, display

display(Image(runnable.get_graph(xray=True).draw_mermaid_png()))

Function Callingの動きを前提に考えないと、ちょっとわかりにくい、というか直感的ではない感はあるかな。

kun432kun432

動きが見えず、複雑にも感じるかも。LangSmithでトレーシングしながら確認するほうが良いかもしれない。

kun432kun432

ループ

LangChainでエージェント周りはあまり触っていないのだけど、AgentExecutorをLangGraphでより低レベルに作ってみるという感じっぽい。Tavilyの検索をツールとして使う。

パッケージインストール。Tavilyを使うのでTavilyのPythonパッケージとLangChainのcommunityパッケージが必要になる。

!pip install -U langgraph langchain_openai tavily-python langchain_community

APIキーをセット。今回はLangSmithでトレーシングする。

import os
from google.colab import userdata

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
os.environ["TAVILY_API_KEY"] = userdata.get('TAVILY_API_KEY')
os.environ["LANGCHAIN_API_KEY"] = userdata.get('LANGCHAIN_API_KEY')
os.environ["LANGCHAIN_TRACING_V2"] = "true"

ツールの定義。

from langchain_community.tools.tavily_search import TavilySearchResults

tools = [TavilySearchResults(max_results=1)]

Toolノードでラップする。

from langgraph.prebuilt import ToolNode

tool_node = ToolNode(tools)

モデルの定義とともに、bind_tools()でFunction Callingで使うツールの定義をモデルにバインドする。

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, streaming=True)
model = model.bind_tools(tools)

StateGraphでエージェントのステートを定義する。上で使ったMessageGraphはこのStateGraphのステートオブジェクトを既に定義したラッパーのようなものらしい。

ステートマシンでは各ノードはこのステートを入力値として受け取って、各ノードの処理により更新する。以前試したLLM用ステートマシンフレームワーク「Burr」でもそうだったけども、基本的にステートの更新というのは、属性を新しく追加するか、特定の属性を上書きするかのどちらかになるのが一般的っぽい。

以下の例では、AgentStateクラスでステートを定義、ステートはメッセージのリストを保持し、各ノードはここにメッセージを追加するだけとなる。

from typing import TypedDict, Annotated

def add_messages(left: list, right: list):
    """追加する。上書きしない"""
    return left + right

class AgentState(TypedDict):
    # アノテーション内の`add_messages`関数は、
    # *どのように*更新をステートにマージするかを定義する
    messages: Annotated[list, add_messages]

次にノードを定義していく。ノードは必要なノードは2つ。

  1. 取るべき行動を決定するための"agent"の役割を担うノード。
  2. ツールを呼び出す関数ノード。agentノードが取るべき行動を決定したら、それを実行する。

2のツールを呼び出す関数ノードは上で既に設定済みのToolノードがそれ。なのでここでは1の"agent"ノードを定義する。取るべき行動はLLMに判断させる≒つまりモデルを呼び出してステートを更新するような関数になる。

# モデルを呼び出す関数を定義
def call_model(state: AgentState):
    messages = state['messages']
    response = model.invoke(messages)
    # 既存のリストに追加されるので、リストを返す
    return {"messages": [response]}

次にエッジの定義。以下のエッジが必要になる。

  1. "agent"ノードが呼び出された際に、以下の分岐を行う条件付きエッジ
    • "agent"が行動を取るべきと判断した場合にツールを呼び出す
    • "agent"が取るべき行動がないと判断した場合に、終了する(ユーザに応答を返す)
  2. ツール実行後に結果をステートに保存、次の取るべき判断をさせるために"agent"ノードに戻す通常エッジ

この1を行う関数を定義する。

from typing import Literal

# 続行するかどうかを決定する関数を定義する
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
    messages = state['messages']
    last_message = messages[-1]
    # LLMがツールを呼び出す場合、"tools "ノードにルーティングする。
    if last_message.tool_calls:
        return "tools"
    # そうでない場合は停止する(ユーザーに応答を返す)
    return "__end__"

そしてこれらを組み合わせてグラフを定義する。

from langgraph.graph import StateGraph, END

# グラフの初期化
workflow = StateGraph(AgentState)

# 循環させる2つのノードを定義
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)

# エントリーポイントを `agent` に設定する。
# これはこのノードが最初に呼び出されることを意味する。
workflow.set_entry_point("agent")

# ここで条件付きエッジを追加
workflow.add_conditional_edges(
    # まず、開始ノードを定義する。ここでは `agent` を使う。
    # つまり、これらは `agent` ノードが呼ばれた後のエッジである。
    "agent",
    # 次に、どのノードが次に呼ばれるかを決定する関数を渡す。
    should_continue,
)

# ここで、`tools`から`agent`に通常のエッジを追加する。
# つまり、`tools`が呼ばれた後、次に`agent`ノードが呼ばれる。
workflow.add_edge("tools", 'agent')

# 最後にそれをコンパイルする!
# これでLangChain Runnableにコンパイルされる、
# つまり、他のrunnableと同じように使うことができる。
app = workflow.compile()

フローを可視化するとこうなる。

from IPython.display import Image, display

display(Image(app.get_graph(xray=True).draw_mermaid_png()))

自分なりにもう少し細かく書くと多分こんな感じ。

実行してみる。ステートの中身をある程度わかりやすくしてみた。

from langchain_core.messages import HumanMessage
import json

inputs = {"messages": [HumanMessage(content="神戸の現在の天気・気温・湿度を教えて。")]}
response = app.invoke(inputs)

for m in response["messages"]:
    if m.type == "tool":
        tool_result = json.loads(m.content)
        print("{}: {}".format(m.type.upper(),tool_result))
    else:
        if m.content == '':
            print("{}: {}".format(m.type.upper(),m.additional_kwargs["tool_calls"]))
        else:
            print("{}: {}".format(m.type.upper(),m.content))

HUMAN: 神戸の現在の天気・気温・湿度を教えて。
AI: [{'index': 0, 'id': 'call_yfpLW130lyeAjYT7Clix4j34', 'function': {'arguments': '{"query": "神戸の現在の天気"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}, {'index': 1, 'id': 'call_EnaGIleVmftvOuZCcyEsc0VD', 'function': {'arguments': '{"query": "神戸の現在の気温"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}, {'index': 2, 'id': 'call_7WdFuUyXgksravSmv9fMOkor', 'function': {'arguments': '{"query": "神戸の現在の湿度"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]
TOOL: [{'url': 'https://tenki.jp/forecast/6/31/6310/28100/', 'content': '神戸市の今日明日の天気、気温、降水確率に加え、台風情報、警報注意報、観測ランキング、紫外線指数等を掲載。気象予報士が日々更新する ...'}]
TOOL: [{'url': 'https://tenki.jp/live/6/31/47770.html', 'content': '神戸の現在の天気(気象観測所の観測結果)を見ることができます。最小3時間毎の天気や雲の観測のほか、10分ごとに更新される今の気温、風向 ...'}]
TOOL: [{'url': 'https://tenki.jp/live/6/31/47770.html', 'content': '神戸の現在の天気(気象観測所の観測結果)を見ることができます。最小3時間毎の天気や雲の観測のほか、10分ごとに更新される今の気温、風向 ...'}]
AI: 現在の神戸の天気や気温、湿度については以下の情報があります:

- **天気**: [こちらのリンク](https://tenki.jp/forecast/6/31/6310/28100/)から詳細な天気情報を確認できます。
- **気温**: [こちらのリンク](https://tenki.jp/live/6/31/47770.html)から最新の気温情報を確認できます。
- **湿度**: [こちらのリンク](https://tenki.jp/live/6/31/47770.html)から最新の湿度情報を確認できます。

各リンクをクリックして詳細情報をご確認ください。
kun432kun432

ストリーミング

ストリーミングは.stream()メソッドを使うだけ。上の続き。

inputs = {"messages": [HumanMessage(content="神戸の現在の天気・気温・湿度を教えて。")]}
for output in app.stream(inputs, stream_mode="updates"):
    # stream() はノード名をキーとする辞書を出力する。
    for key, value in output.items():
        print(f"Output from node '{key}':")
        print("---")
        print(value)
    print("\n---\n")
Output from node 'agent':
---
{'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'index': 0, 'id': 'call_83bUKI616sb4q7dYbK88U6yu', 'function': {'arguments': '{"query": "神戸の現在の天気"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}, {'index': 1, 'id': 'call_qXGQzxydbcd1u1ZIqCLlMBjB', 'function': {'arguments': '{"query": "神戸の現在の気温"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}, {'index': 2, 'id': 'call_iLL16uKfRu0QeddGeISX09RS', 'function': {'arguments': '{"query": "神戸の現在の湿度"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, response_metadata={'finish_reason': 'tool_calls'}, id='run-d9fbacd3-13b6-4468-abbc-0bbe7e712b6a-0', tool_calls=[{'name': 'tavily_search_results_json', 'args': {'query': '神戸の現在の天気'}, 'id': 'call_83bUKI616sb4q7dYbK88U6yu'}, {'name': 'tavily_search_results_json', 'args': {'query': '神戸の現在の気温'}, 'id': 'call_qXGQzxydbcd1u1ZIqCLlMBjB'}, {'name': 'tavily_search_results_json', 'args': {'query': '神戸の現在の湿度'}, 'id': 'call_iLL16uKfRu0QeddGeISX09RS'}])]}

---

Output from node 'tools':
---
{'messages': [ToolMessage(content='[{"url": "https://tenki.jp/forecast/6/31/6310/28100/1hour.html", "content": "\\u4eca\\u65e5 2024\\u5e7402\\u670806\\u65e5(\\u706b)[\\u53cb\\u5f15]\\n\\u66c7\\u308a\\n\\u66c7\\u308a\\n\\u6674\\u308c\\n\\u66c7\\u308a\\n\\u66c7\\u308a\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u66c7\\u308a\\n\\u66c7\\u308a\\n\\u66c7\\u308a\\n\\u66c7\\u308a\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u5317\\u897f\\n\\u5317\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u5317\\u897f\\n\\u5317\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5175\\u5eab\\u770c\\u96e8\\u96f2\\u30ec\\u30fc\\u30c0\\u30fc\\u73fe\\u5728\\u306e\\u96e8\\u96f2\\u3092\\u898b\\u308b\\n\\u3053\\u308c\\u304b\\u3089\\u306e\\u4e88\\u5831\\u3092\\u898b\\u308b\\n\\u660e\\u65e5 2024\\u5e7402\\u670807\\u65e5(\\u6c34)[\\u5148\\u8ca0]\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u897f\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u897f\\n\\u5317\\u5317\\u897f\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5175\\u5eab\\u770c\\u96e8\\u96f2\\u30ec\\u30fc\\u30c0\\u30fc\\u73fe\\u5728\\u306e\\u96e8\\u96f2\\u3092\\u898b\\u308b\\n\\u3053\\u308c\\u304b\\u3089\\u306e\\u4e88\\u5831\\u3092\\u898b\\u308b\\n\\u660e\\u5f8c\\u65e5 2024\\u5e7402\\u670808\\u65e5(\\u6728)[\\u4ecf\\u6ec5]\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u66c7\\u308a\\n\\u66c7\\u308a\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u6674\\u308c\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\u5317\\u897f\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\n\\u5317\\u5317\\u6771\\n\\u5317\\u5317\\u6771\\n\\u5317\\u5317\\u6771\\n\\u5317\\u5317\\u6771\\n\\u5317\\u5317\\u6771\\n\\u5317\\u5317\\u6771\\n\\u5175\\u5eab\\u770c\\u96e8\\u96f2\\u30ec\\u30fc\\u30c0\\u30fc\\u73fe\\u5728\\u306e\\u96e8\\u96f2\\u3092\\u898b\\u308b\\n\\u3053\\u308c\\u304b\\u3089\\u306e\\u4e88\\u5831\\u3092\\u898b\\u308b\\n\\u30b9\\u30ae\\u82b1\\u7c89\\u4e88\\u6e2c\\n\\u5175\\u5eab\\u770c\\u306e\\u82b1\\u7c89\\u98db\\u6563\\u5206\\u5e03\\u4e88\\u6e2c\\n02\\u670806\\u65e5\\n\\u5c11\\u306a\\u3044\\n48\\u6642\\u9593\\u5f8c\\u307e\\u3067\\u306e\\u4e88\\u6e2c\\u3092\\u898b\\u308b\\n10\\u65e5\\u9593\\u5929\\u6c17\\n02\\u670809\\u65e5\\n(\\u91d1)\\n02\\u670810\\u65e5\\n(\\u571f)\\n02\\u670811\\u65e5\\n(\\u65e5)\\n02\\u670812\\u65e5\\n(\\u6708)\\n02\\u670813\\u65e5\\n(\\u706b)\\n02\\u670814\\u65e5\\n(\\u6c34)\\n02\\u670815\\u65e5\\n(\\u6728)\\n02\\u670816\\u65e5\\n(\\u91d1)\\n\\u66c7\\u306e\\u3061\\u6674\\n\\u6674\\n\\u6674\\u6642\\u3005\\u66c7\\n\\u6674\\u6642\\u3005\\u96e8\\n\\u6674\\n\\u6674\\u6642\\u3005\\u66c7\\n\\u66c7\\u6642\\u3005\\u96e8\\n\\u66c7\\u6642\\u3005\\u96e8\\n11\\n3\\n12\\n3\\n11\\n4\\n10\\n4\\n11\\n3\\n14\\n6\\n16\\n9\\n15\\n9\\n20%\\n10%\\n40%\\n50%\\n10%\\n40%\\n50%\\n50%\\n\\u6c17\\u8c61\\u4e88\\u5831\\u58eb\\u306e\\u30dd\\u30a4\\u30f3\\u30c8\\u89e3\\u8aac(\\u65e5\\u76f4\\u4e88\\u5831\\u58eb)\\n\\u6771\\u4eac23\\u533a\\u306b\\u5927\\u96ea\\u8b66\\u5831\\u767a\\u8868\\n\\u5927\\u96ea\\u5f53\\u65e5\\u3088\\u308a\\u6551\\u6025\\u642c\\u9001\\u8005\\u304c\\u6025\\u5897\\u3057\\u305f\\u4e8b\\u4f8b\\u3082\\u3000\\u5927\\u96ea\\u306e\\u7fcc\\u65e5\\u306b\\u6c17\\u3092\\u4ed8\\u3051\\u308b\\u30dd\\u30a4\\u30f3\\u30c8\\n\\u6771\\u4eac\\u90fd\\u5fc3\\u30671\\u30bb\\u30f3\\u30c1\\u306e\\u7a4d\\u96ea\\u3000\\u96ea\\u306e\\u30d4\\u30fc\\u30af\\u306f\\u3053\\u308c\\u304b\\u3089\\u4eca\\u591c\\u3000\\u30b9\\u30ea\\u30c3\\u30d7\\u4e8b\\u6545\\u3084\\u8ee2\\u5012\\u306b\\u8b66\\u6212\\u3092\\n\\u95a2\\u6771\\u7532\\u4fe1\\u3000\\u660e\\u65e56\\u65e5\\u306e\\u671d\\u306b\\u304b\\u3051\\u3066\\u96ea\\u304c\\u964d\\u308a\\u7d9a\\u304f\\u3000\\u6771\\u4eac23\\u533a\\u3082\\u8b66\\u5831\\u7d1a\\u306e\\u5927\\u96ea\\u306e\\u304a\\u305d\\u308c\\n\\u3053\\u3061\\u3089\\u3082\\u304a\\u3059\\u3059\\u3081\\n\\u5357\\u90e8(\\u795e\\u6238)\\u5404\\u5730\\u306e\\u5929\\u6c17\\n\\u5929\\u6c17\\u30ac\\u30a4\\u30c9\\n\\u795e\\u6238\\u306e\\u89b3\\u6e2c\\u30e9\\u30f3\\u30ad\\u30f3\\u30b0\\n\\u203b\\u795e\\u6238\\u5e02\\u306b\\u6700\\u3082\\u8fd1\\u3044\\u89b3\\u6e2c\\u5730\\u70b9\\uff08\\u30a2\\u30e1\\u30c0\\u30b9\\uff09\\u3092\\u8868\\u793a\\u3057\\u3066\\u3044\\u307e\\u3059\\u3002\\n\\u6ce8\\u76ee\\u306e\\u60c5\\u5831\\n30\\u65e5\\u9593\\u7121\\u6599\\u30c8\\u30e9\\u30a4\\u30a2\\u30eb\\u5b9f\\u65bd\\u4e2d\\n\\u767b\\u5c71\\u5929\\u6c17\\u304c\\u30d1\\u30ef\\u30fcUP\\uff01\\u6700\\u65b0\\u306e\\u5c71\\u306e\\u72b6\\u614b\\u304c\\u3059\\u3050\\u306b\\u308f\\u304b\\u308b\\uff01\\n\\u4eba\\u6c17\\u306e\\u65e5\\u76f4\\u4e88\\u5831\\u58eb\\u3092\\u914d\\u4fe1\\ntenki.jp\\u306e\\u516c\\u5f0fTwitter\\u3092\\u30c1\\u30a7\\u30c3\\u30af!\\u5929\\u6c17\\u3001\\u964d\\u6c34\\u78ba\\u7387\\u3001\\u6700\\u9ad8\\u6700\\u4f4e\\u6c17\\u6e29\\u3092\\u914d\\u4fe1\\n\\u5929\\u6c17\\u4e88\\u5831\\n\\u89b3\\u6e2c\\n\\u9632\\u707d\\u60c5\\u5831\\n\\u5929\\u6c17\\u56f3\\n\\u6307\\u6570\\u60c5\\u5831\\n\\u30ec\\u30b8\\u30e3\\u30fc\\u5929\\u6c17\\n\\u5b63\\u7bc0\\u7279\\u96c6\\n\\u5929\\u6c17\\u30cb\\u30e5\\u30fc\\u30b9\\ntenki.jp+more\\n\\u5168\\u56fd\\u306e\\u30b3\\u30f3\\u30c6\\u30f3\\u30c4\\ntenki.jp\\u30c8\\u30c3\\u30d7\\n\\u5929\\u6c17\\u4e88\\u5831\\n\\u89b3\\u6e2c\\n\\u9632\\u707d\\u60c5\\u5831\\n\\u5929\\u6c17\\u56f3\\n\\u6307\\u6570\\u60c5\\u5831\\n\\u30ec\\u30b8\\u30e3\\u30fc\\u5929\\u6c17\\n\\u5b63\\u7bc0\\u7279\\u96c6\\n\\u5929\\u6c17\\u30cb\\u30e5\\u30fc\\u30b9 tenki.jp\\n\\u795e\\u6238\\u5e02\\u306e\\u5929\\u6c1706\\u65e500:00\\u767a\\u8868\\n\\u96f7\\u30ec\\u30fc\\u30c0\\u30fc\\u3067\\u843d\\u96f7\\u60c5\\u5831\\u3092\\u898b\\u308b\\n\\u4eca\\u65e506\\u65e5(\\u706b)\\n\\u660e\\u65e507\\u65e5(\\u6c34)\\n\\u660e\\u5f8c\\u65e508\\u65e5(\\u6728)\\n"}]', name='tavily_search_results_json', tool_call_id='call_83bUKI616sb4q7dYbK88U6yu'), ToolMessage(content='[{"url": "https://tenki.jp/live/6/31/47770.html", "content": "\\u795e\\u6238\\u306e\\u73fe\\u5728\\u306e\\u5929\\u6c17(\\u6c17\\u8c61\\u89b3\\u6e2c\\u6240\\u306e\\u89b3\\u6e2c\\u7d50\\u679c)\\u3092\\u898b\\u308b\\u3053\\u3068\\u304c\\u3067\\u304d\\u307e\\u3059\\u3002\\u6700\\u5c0f3\\u6642\\u9593\\u6bce\\u306e\\u5929\\u6c17\\u3084\\u96f2\\u306e\\u89b3\\u6e2c\\u306e\\u307b\\u304b\\u300110\\u5206\\u3054\\u3068\\u306b\\u66f4\\u65b0\\u3055\\u308c\\u308b\\u4eca\\u306e\\u6c17\\u6e29\\u3001\\u98a8\\u5411 ..."}]', name='tavily_search_results_json', tool_call_id='call_qXGQzxydbcd1u1ZIqCLlMBjB'), ToolMessage(content='[{"url": "https://tenki.or.jp/forecast/6/31/6310/28100/3hours.html", "content": "\\u795e\\u6238\\u5e02\\u306e3\\u6642\\u9593\\u3054\\u3068\\u306e\\u5929\\u6c17\\u3001\\u6c17\\u6e29\\u3001\\u964d\\u6c34\\u91cf\\u306a\\u3069\\u306b\\u52a0\\u3048\\u3001\\u53f0\\u98a8\\u60c5\\u5831\\u3001\\u8b66\\u5831\\u6ce8\\u610f\\u5831\\u3092\\u63b2\\u8f09\\u30023\\u65e5\\u5148\\u307e\\u3067\\u308f\\u304b\\u308b\\u304b\\u3089\\u304a\\u51fa\\u304b\\u3051\\u8a08\\u753b\\u306b\\u5f79\\u7acb\\u3061\\u307e\\u3059\\u3002\\u6c17\\u8c61\\u4e88\\u5831\\u58eb\\u304c\\u65e5\\u3005\\u66f4\\u65b0\\u3059\\u308b\\u300c\\u65e5\\u76f4\\u4e88\\u5831\\u58eb\\u300d\\u3084\\u5b63\\u7bc0\\u3092\\u697d\\u3057\\u3080\\u30b3\\u30e9\\u30e0\\u300ctenki.jp\\u30b5\\u30d7\\u30ea\\u300d\\u306a\\u3069\\u3082\\u30c1\\u30a7\\u30c3\\u30af\\u3067\\u304d\\u307e\\u3059\\u3002"}]', name='tavily_search_results_json', tool_call_id='call_iLL16uKfRu0QeddGeISX09RS')]}

---

Output from node 'agent':
---
{'messages': [AIMessage(content='現在の神戸の天気は「晴れ」で、気温や湿度については以下の情報があります:\n\n- 気温:[こちら](https://tenki.jp/live/6/31/47770.html)のリンクから最新の情報をご確認ください。\n- 湿度:[こちら](https://tenki.or.jp/forecast/6/31/6310/28100/3hours.html)のリンクから詳細な情報をご覧いただけます。\n\n天気や気温、湿度の詳細情報を確認するには、各リンクをご覧ください。', response_metadata={'finish_reason': 'stop'}, id='run-9df58ab4-efd7-45ce-9ee0-a6b924060f09-0')]}

---

この場合はノードごとの出力単位でストリーミングされる。

"Streaming LLM Tokens"のところはドキュメントどおりの動きにならなかったので一旦スキップ。

kun432kun432

ここまでの所感

グラフやワークフロー的に書くものはこれまでに以下のようなものを軽く触ってきた。

https://zenn.dev/kun432/scraps/17b82ca0f7ad2e

https://zenn.dev/kun432/scraps/854a976153b481

https://zenn.dev/kun432/scraps/ea449be468c7e5

これらに比べるとLangGraphはややわかりにくく自分は感じたけども、これはエージェントなのである程度複雑になるのはしょうがないと思うし、慣れの問題もある。

ただ、改めて、上記のような他の選択肢でもエージェントを書いてみて比較してみたいなという気はした。個人的にはBurrの書きっぷりはとてもわかりやすいような気がするし、LCELやQuery Pipelineをエージェントモジュールでラップするだけで十分なケースもある気がする。

とりあえず別のドキュメントにチュートリアルがあるようなので、これを引き続き進めてみる。

イントロダクション
https://langchain-ai.github.io/langgraph/tutorials/introduction/

いろいろなチュートリアル
https://langchain-ai.github.io/langgraph/tutorials/

まずはイントロダクションから。

kun432kun432

チュートリアルのイントロダクション。多少上のところとかぶるけど一通りやる。

https://langchain-ai.github.io/langgraph/tutorials/introduction/

このチュートリアルではLangGraphでチャットボットを作る。チャットボットの機能は以下。

  • ウェブを検索して一般的な質問に答える
  • 通話中の会話状態を維持する
  • 複雑なクエリをレビューのために人間にルーティングする
  • カスタムステートを使用して動作を制御する
  • 会話を巻き戻し、代替経路を探索する

セットアップ。今回はAnthropicを使う。

!pip install -U langgraph langchain_anthropic

APIキーをセット。LangSmithでのトレーシングも有効化。

import os
from google.colab import userdata

os.environ["ANTHROPIC_API_KEY"] = userdata.get('ANTHROPIC_API_KEY')
os.environ["LANGCHAIN_API_KEY"] = userdata.get('LANGCHAIN_API_KEY')
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_PROJECT"] = "LangGraph Tutorial"

Part1: チャットボットの基本部分を作る

まずはチャットボットの基本部分を作る。

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic

# ステートの定義
class State(TypedDict):
    # メッセージは "list "型。
    # アノテーションの`add_messages`関数は、このステートのキーがどのように更新されるべきかを定義。
    # (この場合、メッセージを上書きするのではなく、リストに追加する)。
    messages: Annotated[list, add_messages]


# モデルの定義
llm = ChatAnthropic(model="claude-3-haiku-20240307")


# "chatbot"ノードの関数
def chatbot(state: State):
    return {"messages": [llm.invoke(state["messages"])]}


# グラフの初期化
graph_builder = StateGraph(State)

# 最初の引数は一意なノード名になる
# 第2引数はそのノードが使用されるたびに呼び出される関数またはオブジェクトになる
graph_builder.add_node("chatbot", chatbot)

# エントリーポイント(開始点)の設定
graph_builder.set_entry_point("chatbot")

# フィニッシュポイント(終了点)の設定
# ノードが一度実行されたら処理が終わる
graph_builder.set_finish_point("chatbot")

# グラフのコンパイル
graph = graph_builder.compile()

可視化

from IPython.display import Image, display

display(Image(graph.get_graph().draw_mermaid_png()))

実行してみる。

while True:
    user_input = input("User: ")
    if user_input.lower() in ["quit", "exit", "q"]:
        print("Goodbye!")
        break
    for event in graph.stream({"messages": ("user", user_input)}):
        for value in event.values():
            print("Assistant:", value["messages"][-1].content)

User: 2024年の日本ダービーの結果について教えて
Assistant: 申し訳ありません。2024年の日本ダービーの結果は私にも分かりませんでした。
競馬の結果は毎年変わるため、2024年のレースの詳細については現時点では正確な情報を持ち合わせていません。
実際に開催された時にメディアなどで発表される情報をお待ちください。競馬ファンの方にとって注目のレースですので、2024年の結果が楽しみですね。
User: では2023年については?
Assistant: 2023年については、以下のようなことが予想されます:

  • 経済面では、世界的な金融緊縮政策の影響などから景気減速が懸念されています。インフレ抑制が課題となっています。

  • 地政学的にはウクライナ情勢の行方が重要です。ロシアとウクライナの対立が長期化すれば、エネルギー価格高騰や食料価格高騰など世界経済への悪影響が懸念されます。

  • 気候変動問題では、温暖化に伴う災害リスクの増大が課題となっています。再生可能エネルギーの導入加速などが期待されます。

  • テクノロジー面では、AIやデジタル化の進展が各産業に変革をもたらすと考えられます。ただし、プライバシーや倫理面での懸念も指摘されています。

  • 社会的には人口減少や少子高齢化への対応が重要な課題となっています。人材不足への対策が各国で求められます。

このように、政治経済、社会、環境など様々な面で2023年は注目点の多い年になると見られます。状況の推移を注意深く見守る必要があるでしょう。
User: quit
Goodbye!

新しい情報持っていないということと、会話の履歴が全くつながっていないのがわかる。

Part2: チャットボットにツールを使わせる

ということで、まずはこれにツールを追加してWeb検索できるようにする。Tavilyを使う。

パッケージのインストールとAPIキーの設定

!pip install -U tavily-python langchain_community
import os
from google.colab import userdata

os.environ["TAVILY_API_KEY"] = userdata.get('TAVILY_API_KEY')

ツールを定義して、単体で実行してみる。

from langchain_community.tools.tavily_search import TavilySearchResults

tool = TavilySearchResults(max_results=2)
tools = [tool]
tool.invoke("2024年のダービーの結果について教えて。")

[{'url': 'https://www.jra.go.jp/datafile/seiseki/g1/derby/result/derby2024.html',
'content': '2024年 重賞レース一覧 >. 2024年 日本ダービー. 2024年 重賞レース一覧日本ダービー. 第91回 日本ダービー. 2024年5月26日(日曜) 2回東京12日. 発走時刻: 15時40分. 天候晴.'},
{'url': 'https://race.netkeiba.com/special/index.html?id=0059',
'content': 'No.1競馬サイト「netkeiba」が日本ダービー(G1).2024年5月26日東京の競馬予想・結果・速報・日程・オッズ・出馬表・出走予定馬・払戻・注目馬・見どころ・調教・映像・有力馬の競馬最新情報をお届け!'}]

これをグラフに組み込む。

import json
from typing import Literal
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.messages import ToolMessage


class State(TypedDict):
    messages: Annotated[list, add_messages]


# ツールの定義
tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
# モデルにツール定義を紐づける
llm_with_tools = llm.bind_tools(tools)


def chatbot(state: State):
    # ツール定義と紐づいたモデルオブジェクトに差し替え
    return {"messages": [llm_with_tools.invoke(state["messages"])]}


# ToolNodeオブジェクトを使えば以下は定義済みだが、あえて実装
class BasicToolNode:
    """最後のAIMessageで要求されたツールを実行するノード。"""
    
    def __init__(self, tools: list) -> None:
        self.tools_by_name = {tool.name: tool for tool in tools}

    def __call__(self, inputs: dict):
        if messages := inputs.get("messages", []):
            message = messages[-1]
        else:
            raise ValueError("No message found in input")
        outputs = []
        for tool_call in message.tool_calls:
            tool_result = self.tools_by_name[tool_call["name"]].invoke(
                tool_call["args"]
            )
            outputs.append(
                ToolMessage(
                    content=json.dumps(tool_result),
                    name=tool_call["name"],
                    tool_call_id=tool_call["id"],
                )
            )
        return {"messages": outputs}


# Toolノードを定義
tool_node = BasicToolNode(tools=[tool])

graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)

# Toolノードをグラフに追加
graph_builder.add_node("tools", tool_node)

# ツール実行すべきかを判断して分岐する関数
# ツールの使用を要求すれば"tools"を返し、直接応答しても問題なければ"__end__"を返す
# というtools_condition関数を使えば、以下は定義済みだが、あえて実装
def route_tools(
    state: State,
) -> Literal["tools", "__end__"]:
    """
    最後のメッセージにtool callsがあれば、ToolNode にルーティング、
    そうでなければ、最後にルーティングする。
    """
    if isinstance(state, list):
        ai_message = state[-1]
    elif messages := state.get("messages", []):
        ai_message = messages[-1]
    else:
        raise ValueError(f"No messages found in input state to tool_edge: {state}")
    if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
        return "tools"
    return "__end__"
    
# conditional_edgeで条件ルーティング。これがメインのエージェントループを定義する
graph_builder.add_conditional_edges(
    "chatbot",
    route_tools,
    # 以下の辞書を使用すると、条件の出力を特定のノードとして解釈するようにグラフに指示することができる。
    # デフォルトは関数名を指すが、"tools"以外の名前のノードを使用したい場合は、辞書の値を他の名前に更新することができる
    # 例: "tools": "my_tools"
    {"tools": "tools", "__end__": "__end__"},
)

# ツール実行後はchatbotノードに常に戻す
graph_builder.add_edge("tools", "chatbot")

graph_builder.set_entry_point("chatbot")
# __end__により以下は不要
#graph_builder.set_finish_point("chatbot")

graph = graph_builder.compile()

可視化

from IPython.display import Image, display

display(Image(graph.get_graph().draw_mermaid_png()))

実行。ちょっと日本語で回答してくれないケースがあったので、少しだけプロンプトで指示を追加している。

User: 2024年の日本ダービーの結果について教えて。日本語で。
Assistant: [{'id': 'toolu_01TnJX2DvywN66M9G8dfVpbf', 'input': {'query': '2024年日本ダービー結果'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Assistant: [{"url": "https://www.keibanomiryoku.com/article/derby-2024-results.html", "content": "\u65e5\u672c\u30c0\u30fc\u30d3\u30fc2024\u306e\u7d50\u679c\u30fb\u52d5\u753b\u3092\u307e\u3068\u3081\u305f\u8a18\u4e8b\u3067\u3059\u30022024\u5e74\u306e\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u306e\u7740\u9806\u306f1\u7740\uff1a\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb\u30012\u7740\uff1a\u30b8\u30e3\u30b9\u30c6\u30a3\u30f3\u30df\u30e9\u30ce\u30013\u7740\uff1a\u30b7\u30f3\u30a8\u30f3\u30da\u30e9\u30fc\u3068\u306a\u308a\u307e\u3057\u305f\u3002\u30ec\u30fc\u30b9\u306e\u8a73\u3057\u3044\u7d50\u679c\u3001\u52d5\u753b\u306a\u3069\u3092\u3054\u89a7\u304f\u3060\u3055\u3044\u3002"}, {"url": "https://race.netkeiba.com/race/result.html?race_id=202405021211&mode=result", "content": "2024\u5e745\u670826\u65e5 \u6771\u4eac11r \u65e5\u672c\u30c0\u30fc\u30d3\u30fc(g1)\u306e\u7d50\u679c\u30fb\u6255\u623b\u3067\u3059\u3002jra\u958b\u50ac\u30ec\u30fc\u30b9\u306e\u51fa\u99ac\u8868\u3084\u6700\u65b0\u30aa\u30c3\u30ba\u3001\u30ec\u30fc\u30b9\u7d50\u679c\u901f\u5831\u3001\u6255\u623b\u60c5\u5831\u3092\u306f\u3058\u3081\u3001\u7af6\u99ac\u4e88\u60f3\u3084\u30c7\u30fc\u30bf\u5206\u6790\u306a\u3069\u4e88\u60f3\u306b\u5f79\u7acb\u3064\u60c5\u5831\u3082\u6e80\u8f09\u3067\u3059\u3002"}]
Assistant: 2024年の日本ダービーの結果は以下の通りでした:

1着: ダノンデサイル
2着: ジャスティンミラノ
3着: シンエンペラー

詳しい競走結果や動画などは、上記のリンクで確認できます。
日本最高峰のクラシックレースである日本ダービーの結果は毎年大きな話題となります。優勝馬ダノンデサイルの活躍にも注目が集まっています。
User: じゃあ2023年はどうだったっけ?
Assistant: [{'text': 'はい、', 'type': 'text'}, {'id': 'toolu_01D2KjZNKGQfe3YULXFwuNR1', 'input': {'query': '2023年'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Assistant: [{"url": "https://ja.wikipedia.org/wiki/2023\u5e74", "content": "\u8a73\u7d30\u306f\u300c \u97d3\u56fd\u8c6a\u96e8 (2023\u5e74) \uff08 \u671d\u9bae\u8a9e\u7248 \uff09 \u300d\u3092\u53c2\u7167. 7\u670826\u65e5 - \u30cb\u30b8\u30a7\u30fc\u30eb \u3067\u8ecd\u306b\u3088\u308b \u30af\u30fc\u30c7\u30bf\u30fc \u304c\u767a\u751f\u3057\u3001 \u30e2\u30cf\u30e1\u30c9\u30fb\u30d0\u30ba\u30e0 \u5927\u7d71\u9818\u304c\u5927\u7d71\u9818\u8b66\u8b77\u968a\u306b\u62d8\u675f\u3055\u308c\u308b [72] \u3002. \u8a73\u7d30\u306f\u300c 2023\u5e74\u30cb\u30b8\u30a7\u30fc\u30eb\u30af\u30fc\u30c7\u30bf\u30fc \u300d\u3092\u53c2\u7167. 7\u670820\u65e5 - \u30cb\u30e5\u30fc\u30b8\u30fc\u30e9\u30f3\u30c9\u3068 ..."}, {"url": "https://www.timeanddate.com/calendar/?year=2023&country=41", "content": "China 2023 - Calendar with holidays. Yearly calendar showing months for the year 2023. Calendars - online and print friendly - for any year and month"}]
Assistant: 2023年は重要な出来事が多数ありました。ロシアによるウクライナ侵攻の続行、モハメド・バズム大統領の拘束、北朝鮮のミサイル発射など、世界的に大きな影響を及ぼす出来事が続発しました。一方で経済面では金融引き締めと高インフレが続く中、中国の景気回復や欧州の緩和的な動きもありました。また気候変動への取り組みも進展しつつあります。全体として、2023年は大変複雑で混沌とした1年だったと言えます。
User: quit
Goodbye!

モデルの知識にないタイムリーな質問に対しても答えれるようになった。

上のコードのコメントにあるように、あえて実際に実装している箇所は予め用意されている関数やオブジェクトを使えばもっとシンプルに書ける。

import json
from typing import Literal
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.prebuilt import ToolNode, tools_condition


class State(TypedDict):
    messages: Annotated[list, add_messages]


tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
llm_with_tools = llm.bind_tools(tools)


def chatbot(state: State):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}


# ToolNodeを使ってToolノードを定義
tool_node = ToolNode(tools=[tool])

graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)

graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,     # tools_conditionで自動的に分岐判断
)

graph_builder.add_edge("tools", "chatbot")

graph_builder.set_entry_point("chatbot")

graph = graph_builder.compile()

直接実装していたBasicToolNoderoute_toolsを、ToolNodetools_conditionを使って置き換えている。またこれらの関数およびモジュールは並列実行ができるというメリットもある模様。

Part3: チャットボットに記憶をもたせる

次に会話のコンテキストを維持できるように、記憶する処理を持たせる。LangGraphではpersistent checkpointingを使う。

  • グラフのコンパイル時にcheckpointer(メモリーオブジェクトのようなもの?)を付与する
  • グラフの呼び出し時にthread_idを付与する。

これにより、

  • 各ノード実行後にステートが保存される
  • 各ノード実行時に同一のスレッドのステートが読み出される。

ことになる。

import json
from typing import Literal
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.sqlite import SqliteSaver


class State(TypedDict):
    messages: Annotated[list, add_messages]


tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
llm_with_tools = llm.bind_tools(tools)


def chatbot(state: State):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}


tool_node = ToolNode(tools=[tool])

graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)

graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,
)

graph_builder.add_edge("tools", "chatbot")

graph_builder.set_entry_point("chatbot")

# checkpointerを定義
memory = SqliteSaver.from_conn_string(":memory:")

# checkpointerを付与してグラフをコンパイル
graph = graph_builder.compile(checkpointer=memory)

修正したのは最後の箇所だけ。めちゃめちゃシンプルにできる。

そして、呼び出し時にthread_idを付与する。

# スレッドIDを定義したconfigurableを定義
config = {"configurable": {"thread_id": "1"}}

while True:
    user_input = input("User: ")
    if user_input.lower() in ["quit", "exit", "q"]:
        print("Goodbye!")
        break
    # configurableは、`stream()`/`invoke()`の第2引数に指定する!
    for event in graph.stream({"messages": ("user", user_input)}, config):
        for value in event.values():
            print("Assistant:", value["messages"][-1].content)

User: 2024年の日本ダービーの結果について教えて。日本語で。
Assistant: [{'id': 'toolu_01YLXEbSAdbhdMtAvavugKVt', 'input': {'query': '2024年の日本ダービーの結果'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Assistant: [{"url": "https://www.keibanomiryoku.com/article/derby-2024-results.html", "content": "\u65e5\u672c\u30c0\u30fc\u30d3\u30fc2024\u306e\u7d50\u679c\u30fb\u52d5\u753b\u3092\u307e\u3068\u3081\u305f\u8a18\u4e8b\u3067\u3059\u30022024\u5e74\u306e\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u306e\u7740\u9806\u306f1\u7740\uff1a\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb\u30012\u7740\uff1a\u30b8\u30e3\u30b9\u30c6\u30a3\u30f3\u30df\u30e9\u30ce\u30013\u7740\uff1a\u30b7\u30f3\u30a8\u30f3\u30da\u30e9\u30fc\u3068\u306a\u308a\u307e\u3057\u305f\u3002\u30ec\u30fc\u30b9\u306e\u8a73\u3057\u3044\u7d50\u679c\u3001\u52d5\u753b\u306a\u3069\u3092\u3054\u89a7\u304f\u3060\u3055\u3044\u3002"}, {"url": "https://race.netkeiba.com/race/result.html?race_id=202405021211&mode=result", "content": "2024\u5e745\u670826\u65e5 \u6771\u4eac11r \u65e5\u672c\u30c0\u30fc\u30d3\u30fc(g1)\u306e\u7d50\u679c\u30fb\u6255\u623b\u3067\u3059\u3002jra\u958b\u50ac\u30ec\u30fc\u30b9\u306e\u51fa\u99ac\u8868\u3084\u6700\u65b0\u30aa\u30c3\u30ba\u3001\u30ec\u30fc\u30b9\u7d50\u679c\u901f\u5831\u3001\u6255\u623b\u60c5\u5831\u3092\u306f\u3058\u3081\u3001\u7af6\u99ac\u4e88\u60f3\u3084\u30c7\u30fc\u30bf\u5206\u6790\u306a\u3069\u4e88\u60f3\u306b\u5f79\u7acb\u3064\u60c5\u5831\u3082\u6e80\u8f09\u3067\u3059\u3002"}]
Assistant: 2024年の日本ダービーの結果は以下の通りです:

1着: ダノンデサイル
2着: ジャスティンミラノ
3着: シンエンペラー

レースの詳しい結果や動画などを確認できます。
User: じゃあ2023年は?
Assistant: [{'id': 'toolu_01FmGBW2aTm9HEueprUwLwVi', 'input': {'query': '2023年の日本ダービーの結果'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Assistant: [{"url": "https://race.netkeiba.com/race/result.html?race_id=202305021211", "content": "2023\u5e745\u670828\u65e5 \u6771\u4eac11R \u65e5\u672c\u30c0\u30fc\u30d3\u30fc(G1)\u306e\u7d50\u679c\u30fb\u6255\u623b\u3067\u3059\u3002JRA\u958b\u50ac\u30ec\u30fc\u30b9\u306e\u51fa\u99ac\u8868\u3084\u6700\u65b0\u30aa\u30c3\u30ba\u3001\u30ec\u30fc\u30b9\u7d50\u679c\u901f\u5831\u3001\u6255\u623b\u60c5\u5831\u3092\u306f\u3058\u3081\u3001\u7af6\u99ac\u4e88\u60f3\u3084\u30c7\u30fc\u30bf\u5206\u6790\u306a\u3069\u4e88\u60f3\u306b\u5f79\u7acb\u3064\u60c5\u5831\u3082\u6e80\u8f09\u3067\u3059\u3002"}, {"url": "https://www.jra.go.jp/datafile/seiseki/g1/derby/result/derby2023.html", "content": "\u30b9\u30bf\u30fc\u30c8\u304b\u3089\u9053\u4e2d\u3001\u305d\u3057\u3066\u76f4\u7dda\u306e\u653b\u9632\u306b\u81f3\u308b\u307e\u3067\u5b8c\u74a7\u306a\u8d70\u308a\u3092\u62ab\u9732\u3057\u3066\u3001\u30bf\u30b9\u30c6\u30a3\u30a8\u30fc\u30e9\u306f\u7690\u6708\u8cde2\u7740\u60dc\u6557\u306e\u96ea\u8fb1\u3092\u679c\u305f\u3059\u3068\u3068\u3082\u306b\u3001\u7b2c90\u4ee3\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u99ac\u306e\u79f0\u53f7\u3092\u624b\u306b\u5165\u308c\u305f\u306e\u3067\u3042\u308b\u3002. \uff08\u8c37\u5ddd \u5584\u4e45\uff09. 2023\u5e745\u670828\u65e5\uff08\u65e5\u66dc\uff09 2\u56de\u6771\u4eac12\u65e5. \u767a\u8d70\u6642\u523b\uff1a 15\u664240\u5206 ..."}]
Assistant: 2023年の日本ダービーの結果は以下の通りです:

1着: タスティエラ
2着: 雪辱
3着: オーソデガーラ

レースの詳しい結果や動画などを確認できます。
User: quit
Goodbye!

ちょっとハルシネーションしてるのだけど、会話がつながっているのがわかる。

そして同じスレッドIDならば、別セッションで実行してもちゃんと記憶されている。

config = {"configurable": {"thread_id": "1"}}

while True:
    user_input = input("User: ")
    if user_input.lower() in ["quit", "exit", "q"]:
        print("Goodbye!")
        break
    for event in graph.stream({"messages": ("user", user_input)}, config):
        for value in event.values():
            print("Assistant:", value["messages"][-1].content)

User: 私がさっき聞いたのは何についてでしたっけ?
Assistant: 申し訳ありません。前に聞かれたのは2024年の日本ダービーの結果についてでした。

私がお伝えした内容は以下の通りです:

2024年の日本ダービーの結果:
1着:ダノンデサイル
2着:ジャスティンミラノ
3着:シンエンペラー

その後、2023年の結果についても説明しました。
User: quit
Goodbye!

違うスレッドIDで聞くとこちらは会話履歴が保存されていない。

# スレッドIDを変更
config = {"configurable": {"thread_id": "2"}}

while True:
    user_input = input("User: ")
    if user_input.lower() in ["quit", "exit", "q"]:
        print("Goodbye!")
        break
    for event in graph.stream({"messages": ("user", user_input)}, config):
        for value in event.values():
            print("Assistant:", value["messages"][-1].content)

User: 私がさっき聞いたのは何についてでしたっけ?
Assistant: 申し訳ありませんが、私はまだ前の会話の履歴を確認できていないため、あなたがさっき聞いたことについて正確には覚えていません。前の質問や会話の内容を教えていただけますでしょうか。私はこの情報をもとに適切に回答させていただきます。
User: quit
Goodbye!

kun432kun432

Part4: Human-in-the-loop

エージェントループに人間が介入するHuman-in-the-loopに対応している。Human-in-the-loopの実装方法は複数あるようだけども、interrupt_beforeを使う方法。

import json
from typing import Literal
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.sqlite import SqliteSaver


class State(TypedDict):
    messages: Annotated[list, add_messages]


tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
llm_with_tools = llm.bind_tools(tools)


def chatbot(state: State):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}


tool_node = ToolNode(tools=[tool])

graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)

graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,
)

graph_builder.add_edge("tools", "chatbot")

graph_builder.set_entry_point("chatbot")

memory = SqliteSaver.from_conn_string(":memory:")

graph = graph_builder.compile(
    checkpointer=memory,
    # interrupt_beforeをここで定義
    # 注: interrupt_afterを使って、アクションの「前」で中断することもできる。
    interrupt_before=["tools"],
)

これで実行してみるとこうなる。

user_input = "2024年の日本ダービーの結果について教えて。日本語で。"
config = {"configurable": {"thread_id": "1"}}
events = graph.stream(
    {"messages": [("user", user_input)]}, config, stream_mode="values"
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================ Human Message =================================

2024年の日本ダービーの結果について教えて。日本語で。
================================== Ai Message ==================================

[{'id': 'toolu_01BsDwizSG4ELJyWQwMS8NHi', 'input': {'query': '2024年 日本ダービー'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Tool Calls:
tavily_search_results_json (toolu_01BsDwizSG4ELJyWQwMS8NHi)
Call ID: toolu_01BsDwizSG4ELJyWQwMS8NHi
Args:
query: 2024年 日本ダービー

ツールの引数を生成したところで処理が止まる。ステートの状態を見てみる。

snapshot = graph.get_state(config)
snapshot.next

('tools',)

ちょっとドキュメントの記載とは違うのだけども、次に処理されるのはtoolになっているのがわかる。

最後のメッセージを見てみる。

existing_message = snapshot.values["messages"][-1]
existing_message.tool_calls

[{'name': 'tavily_search_results_json',
'args': {'query': '2024年 日本ダービー'},
'id': 'toolu_01BsDwizSG4ELJyWQwMS8NHi'}]

tool_callの結果が入っている。

.stream() / .invoke()に入力ではなく、Noneを渡すと中断箇所から再開される。

# `None`は現在の状態に何も新しいものを追加せず、中断されていなかったかのように再開させる。
events = graph.stream(None, config, stream_mode="values")
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================= Tool Message =================================
Name: tavily_search_results_json

[{"url": "https://race.netkeiba.com/special/index.html?id=0059", "content": "No.1\u7af6\u99ac\u30b5\u30a4\u30c8\u300cnetkeiba\u300d\u304c\u65e5\u672c\u30c0\u30fc\u30d3\u30fc(G1).2024\u5e745\u670826\u65e5\u6771\u4eac\u306e\u7af6\u99ac\u4e88\u60f3\u30fb\u7d50\u679c\u30fb\u901f\u5831\u30fb\u65e5\u7a0b\u30fb\u30aa\u30c3\u30ba\u30fb\u51fa\u99ac\u8868\u30fb\u51fa\u8d70\u4e88\u5b9a\u99ac\u30fb\u6255\u623b\u30fb\u6ce8\u76ee\u99ac\u30fb\u898b\u3069\u3053\u308d\u30fb\u8abf\u6559\u30fb\u6620\u50cf\u30fb\u6709\u529b\u99ac\u306e\u7af6\u99ac\u6700\u65b0\u60c5\u5831\u3092\u304a\u5c4a\u3051!"}, {"url": "https://www3.nhk.or.jp/news/html/20240526/k10014461201000.html", "content": "2024\u5e745\u670826\u65e5 16\u664247\u5206 \u7af6\u99ac. \u7af6\u99ac\u306e3\u6b73\u99ac\u65e5\u672c\u4e00\u3092\u6c7a\u3081\u308bG1\u30ec\u30fc\u30b9\u300c\u7b2c91\u56de\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u300d\u304c\u6771\u4eac\u7af6\u99ac\u5834\u3067\u884c\u308f\u308c\u30019\u756a\u4eba\u6c17\u306e\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb\u304c\u512a\u52dd\u3057 ..."}]
================================== Ai Message ==================================

2024年の日本ダービーの結果は以下のようになりました:

  • 2024年5月26日に東京競馬場で第91回日本ダービーが行われた。
  • 9番人気のダノンデサイルが優勝した。
  • 詳細な競争結果やオッズ、出馬表、映像など、ダービーの最新情報が報道されている。

つまり、2024年の日本ダービーでは、人気薄の馬が優勝するという驚きの結果となったということですね。競馬ファンにとっては注目の出来事だったと思います。

ここで何かしらUI的に判断を行うフローを入れると承認プロセスみたいなものが作れるのかもしれない。具体例が思いつかないけども。

Part 5: ステートの手動更新

ということでこのプロセスを実装する。

まず先ほどのコード。

import json
from typing import Literal
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.sqlite import SqliteSaver


class State(TypedDict):
    messages: Annotated[list, add_messages]


tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
llm_with_tools = llm.bind_tools(tools)


def chatbot(state: State):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}


tool_node = ToolNode(tools=[tool])

graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)

graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,
)

graph_builder.add_edge("tools", "chatbot")

graph_builder.set_entry_point("chatbot")

memory = SqliteSaver.from_conn_string(":memory:")

graph = graph_builder.compile(
    checkpointer=memory,
    interrupt_before=["tools"],
)

user_input = "2024年の日本ダービーの結果について教えて。日本語で。"
config = {"configurable": {"thread_id": "1"}}

events = graph.stream(
    {"messages": [("user", user_input)]}, config, stream_mode="values"
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

先程と同じくツール実行前で止まる。

================================ Human Message >=================================

2024年の日本ダービーの結果について教えて。日本語で。
================================== Ai Message ==================================

[{'id': 'toolu_01Jw4U5a2UVPJ4pg3KTDsnRv', 'input': {'query': '2024年日本ダービー 結果'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Tool Calls:
tavily_search_results_json (toolu_01Jw4U5a2UVPJ4pg3KTDsnRv)
Call ID: toolu_01Jw4U5a2UVPJ4pg3KTDsnRv
Args:
query: 2024年日本ダービー 結果y: 2024年 日本ダービー 結果

この部分を、仮に検索する必要がないと仮定して、正しい回答をそのまま直接提供する。

from langchain_core.messages import AIMessage, ToolMessage

answer = """\
2024年の日本ダービーの結果は以下のようになりました:

1着: 3枠 5番 ダノンデサイル(横山典) 9番人気  タイム:2:24.3
2着: 7枠 15番 ジャスティンミラノ(戸崎) 1番人気 2馬身差
3着: 7枠 13番 シンエンペラー(坂井)7番人気 1馬身1/4差

勝馬のダノンデサイルは前走皐月賞で直前での出走取消となったためか9番人気でしたが、
低評価の鬱憤を晴らすかのような強い勝ち方でした。
皐月賞の勝馬ジャスティンミラノは1番人気でしたが、残念ながら2着で二冠制覇とはなりませんでした。
"""

new_messages = [
    # LLM APIは、ツール呼び出しにマッチするToolMessageを期待している。ここではそれを満たす。
    ToolMessage(content=answer, tool_call_id=existing_message.tool_calls[0]["id"]),
    # # そして、その返答を入力することで、直接「LLMの口に言葉を入れる」。
    AIMessage(content=answer),
]

new_messages[-1].pretty_print()
graph.update_state(
    # どのステートを更新するか?ここではconfigが持っているスレッドIDのことだと思う。
    config,
    # 提供する更新された値。`State`のメッセージは "追加のみ"であり、既存のステートに追加される。
    # 既存のメッセージを更新する方法については、次のセクションで説明する!
    {"messages": new_messages},
)

print("\n\nLast 2 messages;")
print(graph.get_state(config).values["messages"][-2:])

================================== Ai Message ==================================

2024年の日本ダービーの結果は以下のようになりました:

1着: 3枠 5番 ダノンデサイル(横山典) 9番人気 タイム:2:24.3
2着: 7枠 15番 ジャスティンミラノ(戸崎) 1番人気 2馬身差
3着: 7枠 13番 シンエンペラー(坂井)7番人気 1馬身1/4差

勝馬のダノンデサイルは前走皐月賞で直前での出走取消となったためか9番人気でしたが、
低評価の鬱憤を晴らすかのような強い勝ち方でした。
皐月賞の勝馬ジャスティンミラノは1番人気でしたが、残念ながら2着で二冠制覇とはなりませんでした。

Last 2 messages;
[
ToolMessage(content='2024年の日本ダービーの結果は以下のようになりました:\n\n1着: 3枠 5番 ダノンデサイル(横山典) 9番人気 タイム:2:24.3\n2着: 7枠 15番 ジャスティンミラノ(戸崎) 1番人気 2馬身差\n3着: 7枠 13番 シンエンペラー(坂井)7番人気 1馬身1/4差\n\n勝馬のダノンデサイルは前走皐月賞で直前での出走取消となったためか9番人気でしたが、\n低評価の鬱憤を晴らすかのような強い勝ち方でした。\n皐月賞の勝馬ジャスティンミラノは1番人気でしたが、残念ながら2着で二冠制覇とはなりませんでした。\n', id='f636af20-6fe9-4ef0-8ff2-48530e3ad7a8', tool_call_id='toolu_01BsDwizSG4ELJyWQwMS8NHi'),
AIMessage(content='2024年の日本ダービーの結果は以下のようになりました:\n\n1着: 3枠 5番 ダノンデサイル(横山典) 9番人気 タイム:2:24.3\n2着: 7枠 15番 ジャスティンミラノ(戸崎) 1番人気 2馬身差\n3着: 7枠 13番 シンエンペラー(坂井)7番人気 1馬身1/4差\n\n勝馬のダノンデサイルは前走皐月賞で直前での出走取消となったためか9番人気でしたが、\n低評価の鬱憤を晴らすかのような強い勝ち方でした。\n皐月賞の勝馬ジャスティンミラノは1番人気でしたが、残念ながら2着で二冠制覇とはなりませんでした。\n', id='479b4688-faa0-4be7-8d8c-d290de1e6ffe')
]

ツール呼び出しのメッセージがそのまま最終回答になっているが、トレースを見てみるとupdate_stateがコールされている

・・・というのは最終回答を置き換えたのではなく、あくまでもツール呼び出しの結果を返した、ということを言いたいのかな?確かにステートにはメッセージはツールの結果とモデルのレスポンスのそれぞれが「追加」されているわけだし。

追加する際にどのノードとして追加するかを指定することもできる

graph.update_state(
    config,
    {"messages": [AIMessage(content="私は競馬のプロです!")]},
    # この関数をどのノードとして動作させるか。
    # このノードがちょうど実行されたかのように、自動的に処理を続ける。
    as_node="chatbot",
)

ステートを見てみる。

snapshot = graph.get_state(config)
print(snapshot.values["messages"][-3:])
print(snapshot.next)

[
ToolMessage(content='2024年 日本ダービーの結果は以下のようになりました:\n\n1着: 3枠 5番 ダノンデサイル(横山典) 9番人気 タイム:2:24.3\n2着: 7枠 15番 ジャスティンミラノ(戸崎) 1番人気 2馬身差\n3着: 7枠 13番 シンエンペラー(坂井)7番人気 1馬身1/4差\n\n勝馬のダノンデサイルは前走皐月賞で直前での出走取消となったためか9番人気でしたが、\n低評価の鬱憤を晴らすかのような強い勝ち方でした。\n皐月賞の勝馬ジャスティンミラノは1番人気でしたが、残念ながら2着で二冠制覇とはなりませんでした。\n', id='fdb3fb14-fbd7-4569-b8cd-9d05f8ae1e83', tool_call_id='toolu_01BsDwizSG4ELJyWQwMS8NHi'),
AIMessage(content='2024年 日本ダービーの結果は以下のようになりました:\n\n1着: 3枠 5番 ダノンデサイル(横山典) 9番人気 タイム:2:24.3\n2着: 7枠 15番 ジャスティンミラノ(戸崎) 1番人気 2馬身差\n3着: 7枠 13番 シンエンペラー(坂井)7番人気 1馬身1/4差\n\n勝馬のダノンデサイルは前走皐月賞で直前での出走取消となったためか9番人気でしたが、\n低評価の鬱憤を晴らすかのような強い勝ち方でした。\n皐月賞の勝馬ジャスティンミラノは1番人気でしたが、残念ながら2着で二冠制覇とはなりませんでした。\n', id='c7dde9a7-a3c1-4386-8a2d-5726653ed4ca'),
AIMessage(content='私は競馬のプロです!', id='ed37c9c2-24df-4c58-ad42-dd8e6af38a96')
]

ここではchatbotノードとして追加をしており、chatbotノードはconditional_edgeでtool_callsが含まれているかを判断して分岐する。追加したメッセージにはtool_callsが含まれていないので、最後の.nextでは何も含まれていない≒終了している、ということらしい。

既存のメッセージを書き換えることもできる。

user_input = "2024年のオークスの結果を教えて下さい。"
config = {"configurable": {"thread_id": "2"}}  # 別のスレッドIDを使う。
events = graph.stream(
    {"messages": [("user", user_input)]}, config, stream_mode="values"
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================ Human Message =================================

2024年のオークスの結果を教えて下さい。
================================== Ai Message ==================================

[{'id': 'toolu_01WiFzykHJWanXVoCc2x6Sho', 'input': {'query': '2024年オークス競走の結果'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Tool Calls:
tavily_search_results_json (toolu_01WiFzykHJWanXVoCc2x6Sho)
Call ID: toolu_01WiFzykHJWanXVoCc2x6Sho
Args:
query: 2024年オークス競走の結果

ツール呼び出しの前で停止されて、引数が出力されている。

既存のメッセージを書き換えるには、メッセージのIDが必要になる。上にもあるけどステートのメッセージは基本的に追加しかできないが、IDが指定された場合には書き換えることができるみたい。

from langchain_core.messages import AIMessage

snapshot = graph.get_state(config)
existing_message = snapshot.values["messages"][-1]
print("Original")
print("Message ID", existing_message.id)
print(existing_message.tool_calls[0])
new_tool_call = existing_message.tool_calls[0].copy()
new_tool_call["args"]["query"] = "2024年 優駿牝馬 オークス 結果 払い戻し"
new_message = AIMessage(
    content=existing_message.content,
    tool_calls=[new_tool_call],
    # 重要! このIDは、LangGraphがこのメッセージをAPPENDするのではなく、REPLACEすることを知るためのものになる。
    id=existing_message.id,
)

print("Updated")
print(new_message.tool_calls[0])
print("Message ID", new_message.id)
graph.update_state(config, {"messages": [new_message]})

print("\n\nTool calls")
graph.get_state(config).values["messages"][-1].tool_calls

Original
Message ID run-1e6840ac-75a2-48a4-8864-166cfa7fdf09-0
{'name': 'tavily_search_results_json', 'args': {'query': '2024年オークス競走の結果'}, 'id': 'toolu_01WiFzykHJWanXVoCc2x6Sho'}
Updated
{'name': 'tavily_search_results_json', 'args': {'query': '2024年 優駿牝馬 オークス 結果 払い戻し'}, 'id': 'toolu_01WiFzykHJWanXVoCc2x6Sho'}
Message ID run-1e6840ac-75a2-48a4-8864-166cfa7fdf09-0

Tool calls
[{'name': 'tavily_search_results_json',
'args': {'query': '2024年 優駿牝馬 オークス 結果 払い戻し'},
'id': 'toolu_01WiFzykHJWanXVoCc2x6Sho'}]

中身が書き換わっっているのがわかる。

では再開させてみる。

events = graph.stream(None, config, stream_mode="values")
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================= Tool Message =================================
Name: tavily_search_results_json

[{"url": "https://race.netkeiba.com/special/index.html?id=0058", "content": "\u30aa\u30fc\u30af\u30b9 \u904e\u53bb\u306e\u30ec\u30fc\u30b9\u7d50\u679c. No.1\u7af6\u99ac\u30b5\u30a4\u30c8\u300cnetkeiba\u300d\u304c\u30aa\u30fc\u30af\u30b9 (G1).2024\u5e745\u670819\u65e5\u6771\u4eac\u306e\u7af6\u99ac\u4e88\u60f3\u30fb\u7d50\u679c\u30fb\u901f\u5831\u30fb\u65e5\u7a0b\u30fb\u30aa\u30c3\u30ba\u30fb\u51fa\u99ac\u8868\u30fb\u51fa\u8d70\u4e88\u5b9a\u99ac\u30fb\u6255\u623b\u30fb\u6ce8\u76ee\u99ac\u30fb\u898b\u3069\u3053\u308d\u30fb\u8abf\u6559\u30fb\u6620\u50cf\u30fb\u6709\u529b\u99ac\u306e\u7af6\u99ac\u6700\u65b0\u60c5\u5831\u3092\u304a\u5c4a\u3051!."}, {"url": "https://jra-van.jp/fun/tokusyu/g1/oaks/result.html", "content": "2024\u5e74 \u30ec\u30fc\u30b9\u7d50\u679c >> \u6210\u7e3e\u8868\u30fb\u6255\u623b\u91d1\u306f\u3053\u3061\u3089. \u30c1\u30a7\u30eb\u30f4\u30a3\u30cb\u30a2\u3001\u5916\u304b\u3089\u5dee\u3057\u5207\u308a\u30aa\u30fc\u30af\u30b9\u3092\u5236\u3059. \u7b2c85\u56de\u30aa\u30fc\u30af\u30b9\u306fc.\u30eb\u30e1\u30fc\u30eb\u9a0e\u624b\u9a0e\u4e57\u306e2\u756a\u4eba\u6c17\u30c1\u30a7\u30eb\u30f4\u30a3\u30cb\u30a2\u304c\u4e2d\u56e3\u3067\u8ffd\u8d70\u3059\u308b\u3068\u3001\u76f4\u7dda\u306f\u5916\u304b\u3089\u672b\u811a\u3092\u4f38\u3070\u3057\u3066\u9bae\u3084\u304b\u306b\u5dee\u3057\u5207\u3063\u305f\u3002\u52dd\u3061\u6642\u8a08\u306f2\u520624\u79d20\uff08\u826f\uff09\u3002"}]
================================== Ai Message ==================================

レースの結果によると、2024年の第85回日本オークスはチェルヴィニア(騎手 C.ルメール)が優勝しました。2着は人気のチェルヴィニアに差し切られました。勝ち時計は2分24秒0でした。

回答内容としてはあまり書き換えた意味が反映されていないのだけど、トレースを見ると、あたかもFunction Callingが返しているクエリの内容が置き換わっているのがわかる。

メモリも当然書き換わっている。

events = graph.stream(
    {
        "messages": (
            "user",
            "これまでに聞いた内容を要約して。",
        )
    },
    config,
    stream_mode="values",
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================ Human Message =================================

これまでに聞いた内容を要約して。
================================== Ai Message ==================================

はい、2024年第85回日本オークスの結果を要約すると以下の通りです。

  • 優勝は人気馬のチェルヴィニア(騎手 C.ルメール)
  • 2着に人気馬も差し切られる形で入線
  • 勝ち時計は2分24秒0

つまり、人気馬のチェルヴィニアが優勝し、2着もチェルヴィニアに差されるという順序で、人気どおりの結果となったオークス競走だったことがわかります。

ちょっとこのセクション、わかるんだけどもっと具体的なユースケースじゃないと自分にはピンと個なくて難しかった。。。

kun432kun432

Part 6: ステートのカスタマイズ

これまでのステートは単にメッセージのリストになっていたが、別のフィールドを追加してそれによって判断させる、ということもできる。PPart4・5と絡めて、人間の判断を要求するかどうかのフラグをステートに設定してみる。

from typing import Annotated, Union

from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.messages import BaseMessage
from typing_extensions import TypedDict

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.pydantic_v1 import BaseModel


class State(TypedDict):
    messages: Annotated[list, add_messages]
    # 人間の判断を必要とするかのフラグ
    ask_human: bool


class RequestAssistance(BaseModel):
    """
    専門家に会話をエスカレーションする。あなたが直接サポートできない場合、またはユーザーがあなたの権限以上のサポートを必要とする場合に使用する。
    この機能を使用するには、専門家が適切なガイダンスを提供できるように、ユーザーの「リクエスト」をリレーする。
    """
    request: str

tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
# llmをツール定義、pydanticモデル、jsonスキーマにバインドすることが可能
llm_with_tools = llm.bind_tools(tools + [RequestAssistance])


def chatbot(state: State):
    response = llm_with_tools.invoke(state["messages"])
    ask_human = False
    if (
        response.tool_calls
        and response.tool_calls[0]["name"] == RequestAssistance.__name__
    ):
        ask_human = True
    return {"messages": [response], "ask_human": ask_human}


graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", ToolNode(tools=[tool]))

from langchain_core.messages import AIMessage, ToolMessage


def create_response(response: str, ai_message: AIMessage):
    return ToolMessage(
        content=response,
        tool_call_id=ai_message.tool_calls[0]["id"],
    )


def human_node(state: State):
    new_messages = []
    if not isinstance(state["messages"][-1], ToolMessage):
    # 通常、ユーザーは割り込み中に状態を更新している。
    # もしユーザーが更新しないことを選択した場合, LLMを続行させるためにプレースホルダのToolMessageを含める.
        new_messages.append(
            create_response("No response from human.", state["messages"][-1])
        )
    return {
        # メッセージを追加したら
        "messages": new_messages,
        # フラグをOFFにする
        "ask_human": False,
    }


graph_builder.add_node("human", human_node)

def select_next_node(state: State):
    # 人間の判断が必要な場合はhumanノードに流す
    if state["ask_human"]:
        return "human"
    # それ以外の場合は既存のルーティングを行う
    return tools_condition(state)


graph_builder.add_conditional_edges(
    "chatbot",
    select_next_node,
    {"human": "human", "tools": "tools", "__end__": "__end__"},
)

# 残りは同じ
graph_builder.add_edge("tools", "chatbot")
graph_builder.add_edge("human", "chatbot")
graph_builder.set_entry_point("chatbot")
memory = SqliteSaver.from_conn_string(":memory:")
graph = graph_builder.compile(
    checkpointer=memory,
    # 'human'の前に割り込みを行う
    interrupt_before=["human"],
)

可視化

from IPython.display import Image, display

try:
    display(Image(graph.get_graph().draw_mermaid_png()))
except:
    pass

お、エージェントらしい感じがしてきた。

では実行してみる。

user_input = "2024年の日本ダービーのレース内容について専門家の解析内容を聞きたい。"
config = {"configurable": {"thread_id": "1"}}

events = graph.stream(
    {"messages": [("user", user_input)]}, config, stream_mode="values"
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

途中で止まる。

================================ Human Message >=================================

2024年の日本ダービーのレース内容について専門家の解析内容を聞きたい。
================================== Ai Message ==================================

[{'id': 'toolu_01QrLeMReoWbDTxcmEie8c7W', 'input': {'request': '2024年の日本ダービーのレース内容について専門家の解析'}, 'name': 'RequestAssistance', 'type': 'tool_use'}]
Tool Calls:
RequestAssistance (toolu_01QrLeMReoWbDTxcmEie8c7W)
Call ID: toolu_01QrLeMReoWbDTxcmEie8c7W
Args:
request: 2024年の日本ダービーのレース内容について専門家の解析

グラフの状態を確認。

snapshot = graph.get_state(config)
snapshot.next

('human',)

人間の判断を行わせるhumanノードの手前で止まっているのがわかる。

では人間の判断を入れる。

ai_message = snapshot.values["messages"][-1]
human_response = """\
この日の東京競馬場芝コースの状態は、

- Cコース替わりでタイムが出やすい超高速馬場だった
- Cコース替わりで内枠が荒れていない状態だった
- スローペースで速い上がりタイムを持つ先行馬が残りやすいラップだった

ため、内で3番手にポジションを取りロスがなかったダノンデサイルが1着となりました。
2着のジャスティンミラノも先行しましたが、やや外目かつ4コーナーでやや窮屈な場所に入ってしまい、
伸びては来たものの、前を行くダノンデサイルには届かないという結果でした。

馬場の状態やペースなど、内枠かつ先行した馬が有利だったと解析します。
"""

tool_message = create_response(human_response, ai_message)
graph.update_state(config, {"messages": [tool_message]})

{'configurable': {'thread_id': '1',
'thread_ts': '1ef1cf1e-57e0-6947-8002-cbef421427e6'}}

ステートの状態はこうなっている。

graph.get_state(config).values["messages"]

[
HumanMessage(content='2024年の日本ダービーのレース内容について専門家の解析内容を聞きたい。', id='fe115c00-9996-450e-991c-317d45fa7362'),
AIMessage(content=[{'id': 'toolu_01QrLeMReoWbDTxcmEie8c7W', 'input': {'request': '2024年の日本ダービーのレース内容について専門家の解析'}, 'name': 'RequestAssistance', 'type': 'tool_use'}], response_metadata={'id': 'msg_013UyTQLQ6rPVhMiDRPRjjgd', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 984, 'output_tokens': 75}}, id='run-a4ae9bb1-5cda-434d-8074-8a37f1478fd0-0', tool_calls=[{'name': 'RequestAssistance', 'args': {'request': '2024年の日本ダービーのレース内容について専門家の解析'}, 'id': 'toolu_01QrLeMReoWbDTxcmEie8c7W'}]),
ToolMessage(content='この日の東京競馬場芝コースの状態は、\n\n- Cコース替わりでタイムが出やすい超高速馬場だった\n- Cコース替わりで内枠が荒れていない状態だった\n- スローペースで速い上がりタイムを持つ先行馬が残りやすいラップだった\n\nため、内で3番手にポジションを取りロスがなかったダノンデサイルが1着となりました。\n2着のジャスティンミラノも先行しましたが、やや外目かつ4コーナーでやや窮屈な場所に入ってしまい、\n伸びては来たものの、前を行くダノンデサイルには届かないという結果でした。\n\n馬場の状態やペースなど、内枠かつ先行した馬が有利だったと解析します。\n', id='0381c5e3-1cea-449a-ad3a-7248080fd2de', tool_call_id='toolu_01QrLeMReoWbDTxcmEie8c7W')
]

では再開。

events = graph.stream(None, config, stream_mode="values")
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================= Tool Message =================================

この日の東京競馬場芝コースの状態は、

  • Cコース替わりでタイムが出やすい超高速馬場だった
  • Cコース替わりで内枠が荒れていない状態だった
  • スローペースで速い上がりタイムを持つ先行馬が残りやすいラップだった

ため、内で3番手にポジションを取りロスがなかったダノンデサイルが1着となりました。
2着のジャスティンミラノも先行しましたが、やや外目かつ4コーナーでやや窮屈な場所に入ってしまい、
伸びては来たものの、前を行くダノンデサイルには届かないという結果でした。

馬場の状態やペースなど、内枠かつ先行した馬が有利だったと解析します。

================================== Ai Message ==================================

専門家の解析によると、2024年の日本ダービーは、タイムが速く内枠の馬が有利な高速馬場だったことから、
内で3番手のポジションをキープしてロスの少なかったダノンデサイルが1着となり、2着のジャスティンミラノも先行馬として健闘しましたが、
ダノンデサイルには及ばなかったという内容だと理解しました。
馬場条件とペース配分が勝敗を左右した大変興味深いレースだったようです。

なるほど、Human-in-the-loopをどうステートで管理するか、というサンプルとしてはかなりわかりやすくなった。トレースを見るとask_humanの動きも見える。

ただ、コードはそこそこ複雑ではあるなぁ。

kun432kun432

Part 7: タイムトラベル

各ノードの処理結果はステートで管理され、会話履歴としても管理されている。ただ過去のある地点に戻って別の出力を生成したいという場合もある。そこでいわゆる「巻き戻し」的なことをやってみる。

前回のコード。何も変更はない。

from typing import Annotated, Union

from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.messages import BaseMessage
from typing_extensions import TypedDict

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition


class State(TypedDict):
    messages: Annotated[list, add_messages]
    # 人間の判断を必要とするかのフラグ
    ask_human: bool

from langchain_core.pydantic_v1 import BaseModel


class RequestAssistance(BaseModel):
    """
    専門家に会話をエスカレーションする。あなたが直接サポートできない場合、またはユーザーがあなたの権限以上のサポートを必要とする場合に使用する。
    この機能を使用するには、専門家が適切なガイダンスを提供できるように、ユーザーの「リクエスト」をリレーする。
    """
    request: str

tool = TavilySearchResults(max_results=2)
tools = [tool]

llm = ChatAnthropic(model="claude-3-haiku-20240307")
# llmをツール定義、pydanticモデル、jsonスキーマにバインドすることが可能
llm_with_tools = llm.bind_tools(tools + [RequestAssistance])


def chatbot(state: State):
    response = llm_with_tools.invoke(state["messages"])
    ask_human = False
    if (
        response.tool_calls
        and response.tool_calls[0]["name"] == RequestAssistance.__name__
    ):
        ask_human = True
    return {"messages": [response], "ask_human": ask_human}


graph_builder = StateGraph(State)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", ToolNode(tools=[tool]))

from langchain_core.messages import AIMessage, ToolMessage


def create_response(response: str, ai_message: AIMessage):
    return ToolMessage(
        content=response,
        tool_call_id=ai_message.tool_calls[0]["id"],
    )


def human_node(state: State):
    new_messages = []
    if not isinstance(state["messages"][-1], ToolMessage):
    # 通常、ユーザーは割り込み中に状態を更新している。
    # もしユーザーが更新しないことを選択した場合, LLMを続行させるためにプレースホルダのToolMessageを含める.
        new_messages.append(
            create_response("No response from human.", state["messages"][-1])
        )
    return {
        # メッセージを追加したら
        "messages": new_messages,
        # フラグをOFFにする
        "ask_human": False,
    }


graph_builder.add_node("human", human_node)

def select_next_node(state: State):
    # 人間の判断が必要な場合はhumanノードに流す
    if state["ask_human"]:
        return "human"
    # それ以外の場合は既存のルーティングを行う
    return tools_condition(state)


graph_builder.add_conditional_edges(
    "chatbot",
    select_next_node,
    {"human": "human", "tools": "tools", "__end__": "__end__"},
)

# 残りは同じ
graph_builder.add_edge("tools", "chatbot")
graph_builder.add_edge("human", "chatbot")
graph_builder.set_entry_point("chatbot")
memory = SqliteSaver.from_conn_string(":memory:")
graph = graph_builder.compile(
    checkpointer=memory,
    # 'human'の前に割り込みを行う
    interrupt_before=["human"],
)

では少し実行してみる。

config = {"configurable": {"thread_id": "1"}}
events = graph.stream(
    {
        "messages": [
            ("user", "2024年の日本ダービーのレース結果について教えて。")
        ]
    },
    config,
    stream_mode="values",
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================ Human Message =================================

2024年の日本ダービーのレース結果について教えて。
================================== Ai Message ==================================

[{'id': 'toolu_017zjPdYubduNe4SUrzy4EFR', 'input': {'query': '2024年 日本ダービー 結果'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Tool Calls:
tavily_search_results_json (toolu_017zjPdYubduNe4SUrzy4EFR)
Call ID: toolu_017zjPdYubduNe4SUrzy4EFR
Args:
query: 2024年 日本ダービー 結果
================================= Tool Message =================================
Name: tavily_search_results_json

[{"url": "https://www.jra.go.jp/datafile/seiseki/g1/derby/result/derby2024.html", "content": "\u305d\u3053\u304b\u3089\u7acb\u3066\u76f4\u3057\u3066\u306e\u6234\u51a0\u306b\u300c\u81ea\u5206\u306e\u6c7a\u65ad\u3068\u3001\u53a9\u820e\u30b9\u30bf\u30c3\u30d5\u306e\u99ac\u306e\u4f5c\u308a\u65b9\u306b\u9593\u9055\u3044\u304c\u306a\u304b\u3063\u305f\u3002\u771f\u646f\u306b\u5411\u304d\u5408\u3048\u3070\u99ac\u306f\u5fdc\u3048\u3066\u304f\u308c\u308b\u300d\u3068\u3001\u3053\u308c\u304c\u65e5\u672c\u30c0\u30fc\u30d3\u30fc3\u52dd\u76ee\u3001\u65e5\u672c\u30c0\u30fc\u30d3\u30fc&jra\u30fbg\u2160\u6700\u5e74\u9577\u52dd\u5229\u8a18\u9332\u3092\u66f4\u65b0\u3057\u305f\u6a2a\u5c71\u9a0e\u624b\u306f\u80f8\u3092\u306a\u3067\u304a\u308d\u3059\u3002"}, {"url": "https://www3.nhk.or.jp/news/html/20240526/k10014461201000.html", "content": "2024\u5e745\u670826\u65e5 16\u664247\u5206 \u7af6\u99ac. \u7af6\u99ac\u306e3\u6b73\u99ac\u65e5\u672c\u4e00\u3092\u6c7a\u3081\u308bg1\u30ec\u30fc\u30b9\u300c\u7b2c91\u56de\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u300d\u304c\u6771\u4eac\u7af6\u99ac\u5834\u3067\u884c\u308f\u308c\u30019\u756a\u4eba\u6c17\u306e\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb\u304c\u512a\u52dd\u3057 ..."}]
================================== Ai Message ==================================

検索結果によると、2024年の日本ダービーはJRA公式サイトでの結果発表によると、横山騎手が騎乗したダノンデサイールが優勝したようです。NHKのニュース記事でも、同じダノンデサイールの優勝が報じられています。
結果の詳細としては、横山騎手が「自分の決断と、鞍上スタッフの馬の作り方に間違いがなかった」と述べており、日本ダービー3勝目、JRA・G1通算最年長勝利記録を更新したことが伝えられています。

もう少し続けてみる。

events = graph.stream(
    {
        "messages": [
            ("user", "なるほど、ダノンデサイルは何番人気でしたか?")
        ]
    },
    config,
    stream_mode="values",
)
for event in events:
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================ Human Message =================================

なるほど、ダノンデサイルは何番人気でしたか?
================================== Ai Message ==================================

[{'id': 'toolu_017oUpVJ627CnjTMq3QsGZzc', 'input': {'query': '2024年 日本ダービー ダノンデサイール 人気'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]
Tool Calls:
tavily_search_results_json (toolu_017oUpVJ627CnjTMq3QsGZzc)
Call ID: toolu_017oUpVJ627CnjTMq3QsGZzc
Args:
query: 2024年 日本ダービー ダノンデサイール 人気
================================= Tool Message =================================
Name: tavily_search_results_json

[{"url": "https://www3.nhk.or.jp/news/html/20240526/k10014461201000.html", "content": "2024\u5e745\u670826\u65e5 16\u664247\u5206 \u7af6\u99ac \u7af6\u99ac\u306e3\u6b73\u99ac\u65e5\u672c\u4e00\u3092\u6c7a\u3081\u308bG1\u30ec\u30fc\u30b9\u300c\u7b2c91\u56de\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u300d\u304c\u6771\u4eac\u7af6\u99ac\u5834\u3067\u884c\u308f\u308c\u30019\u756a\u4eba\u6c17\u306e\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb\u304c\u512a\u52dd\u3057 ..."}, {"url": "https://dir.netkeiba.com/keibamatome/detail.html?no=4141", "content": "\u7b2c91\u56de\u65e5\u672c\u30c0\u30fc\u30d3\u30fc(3\u6b73\u30fb\u7261\u725d\u30fbGI\u30fb\u829d2400m)\u306f\u3001\u9053\u4e2d\u306f\u597d\u4f4d\u3067\u9032\u3081\u3001\u76f4\u7dda\u3067\u6700\u5185\u304b\u3089\u629c\u3051\u51fa\u3057\u305f\u6a2a\u5c71\u5178\u5f18\u9a0e\u624b\u9a0e\u4e57\u306e9\u756a\u4eba\u6c17\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb(\u72613\u3001\u6817\u6771\u30fb\u5b89\u7530\u7fd4\u4f0d\u53a9\u820e)\u304c\u3001\u540c\u3058\u304f\u597d\u4f4d\u304b\u3089\u811a\u3092\u4f38\u3070\u3057\u305f1\u756a\u4eba\u6c17\u30b8\u30e3\u30b9\u30c6\u30a3\u30f3\u30df\u30e9\u30ce(\u72613\u3001\u6817\u6771\u30fb\u53cb\u9053\u5eb7\u592b\u53a9\u820e)\u306b2\u99ac\u8eab\u5dee\u3092\u3064\u3051\u512a\u52dd\u3057\u305f\u3002\u52dd\u3061\u30bf\u30a4\u30e0\u306f2 ..."}]
================================== Ai Message ==================================

検索結果によると、2024年の日本ダービーでは、ダノンデサイールが9番人気で優勝しました。
NHKニュースの記事によると、「9番人気のダノンデサイールが優勝した」と報じられています。
また、競馬まとめのサイトの情報でも、ダノンデサイールが9番人気から勝利したことが確認できます。

ちゃんと検索もできているし、会話も続いているのがわかる。

.get_state_history()を使うと、ステートの履歴で何が行ったかを確認することができる。ここではステートの履歴の中から6番目のステートをリプレイ対象として取得している。

to_replay = None
for state in graph.get_state_history(config):
    print("Num Messages: ", len(state.values["messages"]), "Next: ", state.next)
    print("-" * 80)
    if len(state.values["messages"]) == 6:
        # ステート内のチャット・メッセージの数に基づいて、特定の状態をやや恣意的に選んでいる。
        to_replay = state

Num Messages: 8 Next: ()
--------------------------------------------------------------------------------
Num Messages: 7 Next: ('chatbot',)
--------------------------------------------------------------------------------
Num Messages: 6 Next: ('tools',)
--------------------------------------------------------------------------------
Num Messages: 5 Next: ('chatbot',)
--------------------------------------------------------------------------------
Num Messages: 4 Next: ('start',)
--------------------------------------------------------------------------------
Num Messages: 4 Next: ()
--------------------------------------------------------------------------------
Num Messages: 3 Next: ('chatbot',)
--------------------------------------------------------------------------------
Num Messages: 2 Next: ('tools',)
--------------------------------------------------------------------------------
Num Messages: 1 Next: ('chatbot',)
--------------------------------------------------------------------------------
Num Messages: 0 Next: ('start',)
--------------------------------------------------------------------------------

該当のステートの状態を確認

print(to_replay.next)
print(to_replay.config)

('tools',)
{'configurable': {'thread_id': '1', 'thread_ts': '1ef1cf56-81f8-6f1f-8006-99e7524d5111'}}

2回目のツール実行(勝馬の人気を検索する箇所)の前のステートになっている。thread_tsが履歴中の特定のステートを一意に取得するためのIDとなる。

これをconfigurableとしてグラフに渡すことで、その時点のステートから処理を再度再開することができる。

# `to_replay.config`の`thread_ts`は、チェックポインタに永続化した状態に対応している
for event in graph.stream(None, to_replay.config, stream_mode="values"):
    if "messages" in event:
        event["messages"][-1].pretty_print()

================================= Tool Message =================================
Name: tavily_search_results_json

[{"url": "https://dir.netkeiba.com/keibamatome/detail.html?no=4141", "content": "\u7b2c91\u56de\u65e5\u672c\u30c0\u30fc\u30d3\u30fc(3\u6b73\u30fb\u7261\u725d\u30fbGI\u30fb\u829d2400m)\u306f\u3001\u9053\u4e2d\u306f\u597d\u4f4d\u3067\u9032\u3081\u3001\u76f4\u7dda\u3067\u6700\u5185\u304b\u3089\u629c\u3051\u51fa\u3057\u305f\u6a2a\u5c71\u5178\u5f18\u9a0e\u624b\u9a0e\u4e57\u306e9\u756a\u4eba\u6c17\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb(\u72613\u3001\u6817\u6771\u30fb\u5b89\u7530\u7fd4\u4f0d\u53a9\u820e)\u304c\u3001\u540c\u3058\u304f\u597d\u4f4d\u304b\u3089\u811a\u3092\u4f38\u3070\u3057\u305f1\u756a\u4eba\u6c17\u30b8\u30e3\u30b9\u30c6\u30a3\u30f3\u30df\u30e9\u30ce(\u72613\u3001\u6817\u6771\u30fb\u53cb\u9053\u5eb7\u592b\u53a9\u820e)\u306b2\u99ac\u8eab\u5dee\u3092\u3064\u3051\u512a\u52dd\u3057\u305f\u3002\u52dd\u3061\u30bf\u30a4\u30e0\u306f2 ..."}, {"url": "https://www.ronspo.com/articles/2024/2024052701/", "content": "\u7af6\u99ac\u306e\u796d\u5178\u300c\u7b2c91\u56de\u65e5\u672c\u30c0\u30fc\u30d3\u30fc\u300d\uff08\u6771\u4eac\u829d2400\u30e1\u30fc\u30c8\u30eb\u3001g1\uff09\u306f26\u65e5\u306b\u884c\u308f\u308c\u30019\u756a\u4eba\u6c17\u306e"\u4f0f\u5175"\u30c0\u30ce\u30f3\u30c7\u30b5\u30a4\u30eb\uff08\u72613\u3001\u5b89\u7530\uff09\u304c\u76f4\u7dda\u30a4\u30f3\u304b\u3089\u9bae\u3084\u304b\u306b\u629c\u3051\u51fa\u3057\u30012\u520624\u79d23\u306e\u30bf\u30a4\u30e0\u3067\u756a\u72c2\u308f\u305b\u3092\u6f14\u3058\u305f\u3002"}]
================================== Ai Message ==================================

検索結果によると、2024年の日本ダービーでは、優勝したダノンデサイールが9番人気だったことがわかります。
記事によれば、ダノンデサイールは好位から直線で抜け出し、2着のジャスティンミラノに2馬身差をつけて優勝したそうです。
9番人気からの優勝は大波乱となったようで、横山騎手の手応えの良さが印象的だったようです。

kun432kun432

まとめ

とりあえずざっと流したけど、やはりLCELに比較してもLangGraphは難しい。。。。ただ、難しいなと思いつつもHuman-in-the-loopのところなんかはとても興味深い。LLMアプリだとLLMに全部やらせようとしてプロンプト頑張るって発想になりがちな気がしてて、適宜人の手を介するってのは現実的なアプローチと思える。

とりあえず他にも多数チュートリアルがあるので、実際のコードサンプルをいろいろ動かしてみて慣れたい。

https://langchain-ai.github.io/langgraph/tutorials/

あと、別のステートマシンフレームワーク「Burr」でもLangChainとのインテグレーション例があったので、そっちも実行してみて、書きっぷりと言うか書き心地を比較してみたくなった。

Burrについてはこちら
https://zenn.dev/kun432/scraps/ea449be468c7e5

Burrを使ってLangChainと組み合わせたマルチエージェントのサンプル
https://github.com/DAGWorks-Inc/burr/tree/main/examples/multi-agent-collaboration/lcel

このスクラップは5ヶ月前にクローズされました