はじめまして。
京セラコミュニケーションシステム株式会社
技術開発センター ICT技術開発部 AIシステム開発課 東京AIシステムの中山です。
この記事では、LangGraphを用いてAIエージェントを構築する方法を初心者向けに解説します。どうぞよろしくお願いいたします!
記事の要約
本記事では、LangGraphを用いてAIエージェントを構築する方法を、初心者向けに分かりやすく解説します。社内勉強会で実施したハンズオンの内容を基に、Pythonを使って「現在日時を回答できるAIエージェント」を作成するプロセスをステップバイステップで紹介します。
LangGraphは、AIエージェントのワークフローを視覚的に構築できる強力なツールです。この記事を通じて、LangGraphの基本的な使い方や、AIエージェントにツールを統合する方法を学ぶことができます。特に、通常のLLM(大規模言語モデル)では実現が難しい「現在日時の取得」を可能にするためのツールの活用方法に焦点を当てています。
また、開発手法としてTDD(テスト駆動開発)を採用しています。TDDは、まずテストコードを書き、そのテストを通すための実装を行う開発手法です。この手法により、コードの品質を高め、バグを早期に発見することができます。この記事では、TDDの基本的な流れや実践方法についても触れています。
Pythonの基本的な文法を理解している方であれば、この記事を読むことで、AIエージェントの構築に必要な基礎知識を身につけることができ、実際に自分のプロジェクトに応用することができるはずです。
想定読者
- 基本的なPythonの文法を理解している方
- AIエージェントの構築に興味がある方
- 新人エンジニアやAI初心者向けの内容です
実施環境
- OS: Windows 11
- Python: 3.11(3.9以上で動作可能)
- 必要なライブラリは
requirements.txt
に記載しています
ゴール
- LangGraphを用いて、現在日時を回答できるAIエージェントを構築する
- AIエージェントの基本的な構造と動作を理解する
環境のセットアップをします
requirements.txtというPythonプロジェクトで使用される外部ライブラリやパッケージの依存関係を記述するためのテキストファイルを保存します。
(以後、分かりやすいようにすべて同一のディレクトリで作業しています)
pytest==8.3.4
langgraph==0.2.6
langchain-openai==0.1.25
python-dotenv==1.0.1
-
pytest==8.3.4:
テスト駆動開発 (TDD) を行うためのテストフレームワークです。コードの動作を確認するために使用します。特に、テストケースを自動化して実行することで、コードの品質を保つことができます。 -
langgraph==0.2.6:
AIエージェントのワークフローを視覚的に構築するためのライブラリです。LangGraphを使用することで、エージェントの状態遷移やノードの構成を簡単に管理できます。 -
langchain-openai==0.1.25:
OpenAIのAPIを利用して、言語モデルを操作するためのライブラリです。AIエージェントが自然言語を理解し、応答を生成するために必要です。 -
python-dotenv==1.0.1:
環境変数を管理するためのライブラリです。.env
ファイルからAPIキーやエンドポイントなどの機密情報を読み込むことで、コード内に直接書かずに済みます。
プロジェクトごとに独立したPython環境を作成するために仮想環境を使用します。これにより、異なるプロジェクト間で依存関係が競合することを防ぎます。
py -3.11 -m venv .venv
仮想環境をアクティベートすることで、その環境内でインストールされたライブラリやパッケージを使用できるようになります。これにより、プロジェクトに必要な特定のバージョンのライブラリを確実に使用できます。
>.venv\Scripts\activate
requirements.txt
に記載されたすべてのライブラリを一括でインストールします。これにより、プロジェクトに必要なすべての依存関係が自動的に解決され、環境が整います。
>pip install -r requirements.txt
TDDの準備もします
TDDでは以下のルールどおり、最初にテストコードを書きます。
一般的な手順のとおりに進めたいと思います。
TDDのルール
- コードを書く前に、失敗する自動テストコードを必ず書く
- 重複を除去する
TDDの手順
- RED:動作しない、コンパイルも通らないテストを一つ書く
- GREEN:そのテストを迅速に動作させる。ひどいコードを書いてもいい
- リファクタリング:テストを通すために発生した重複をすべて除去する
Kent Beck氏になるべく忠実にテストコードから書きます。
今回、現在日時を回答するAIエージェントを作成したいので、著作でもあるように以下のリストを作成しました。
REDはテストの失敗、GREENはテストの成功を表しています。
-
現在日時を回答するテストをする
- RED
- GREEN
- リファクタリング
現在日時を質問し、回答を確認するテストケースをagent_test.pyに作成して保存します。
from datetime import datetime
def test_agent_response():
agent = Agent()
result = agent.response(
content="現在日時はなんですか。YYYY-MM-DDで返してください。"
)
now = datetime.now().strftime("%Y-%m-%d")
assert now in result
Agentなど作っていないのでテストが失敗
TDDの教えるところにしたがい、テストを失敗させました。(REDということです)
agent_test.pyの右にFと記載があります。ここが失敗したということです。
これは進んでいることになります。
> pytest
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 1 item
agent_test.py F [100%]
==================================================================== FAILURES =====================================================================
_______________________________________________________________ test_agent_response _______________________________________________________________
def test_agent_response():
> agent = Agent()
E NameError: name 'Agent' is not defined
agent_test.py:5: NameError
============================================================= short test summary info =============================================================
FAILED agent_test.py::test_agent_response - NameError: name 'Agent' is not defined
================================================================ 1 failed in 0.06s ================================================================
状況を更新します。
-
現在日時を回答するテストをする
- RED
- GREEN
- リファクタリング
テストを成功させるクラスをつくる
すぐ実装できそうにないので、単純に現在日時を戻すメソッドをagent.pyに実装します。
from datetime import datetime
class Agent:
def response(self, content):
now = datetime.now().strftime("%Y-%m-%d")
return now
importだけ変えて、Agentを読めるようにします。
from datetime import datetime
from agent import Agent
def test_agent_response():
agent = Agent()
result = agent.response(
content="現在日時はなんですか。YYYY-MM-DDで返してください。"
)
now = datetime.now().strftime("%Y-%m-%d")
assert now in result
実行します。テストが成功しました。(GREENになりました)
> pytest
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 1 item
agent_test.py . [100%]
================================================================ 1 passed in 0.01s ================================================================
状況を更新します。
-
現在日時を回答するテストをする
- RED
- GREEN
- リファクタリング
重複のリファクタリングを考える
現在日時がテストコードとプロダクトコードで重複しているので、リファクタリングが必要です。
しかし、すぐにどうやればいいか分かりません。
AIエージェントをつくるためのLangGraphのコンポーネントのテストを追加します。
LangGraphのコンポーネント
公式ドキュメントにはこのあたりに書いてます。
コンポーネントの説明
説明のための私の解釈をここに記載します。
Graphs
エージェントのワークフローのことのようです。
私としては双六と捉えるといいと思います。
States
状態。アプリケーションの現状のスナップショット
私としては双六のコマ
Nodes
エージェントの関数。
私としては双六のマス
Edges
現在の状態に基づいて次に実行するノードを決定する関数。
私としてはマスの経路
Graphのテストコードを作成
まずは、単純なLLMでも可能なGraphの作成を目指します。
Graphを作成し、「はい」と答えてもらうテストです。
def test_graph_start():
graph = GraphFactory.create()
result = graph.start(content="はいと回答してください")
assert "はい" in result
状況はこうなっています。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
Graph関係のクラスがないためテストは失敗します。(RED)
GraphFactoryがないので失敗します。
進んでいます。
> pytest
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 2 items
agent_test.py . [ 50%]
graph_test.py F [100%]
==================================================================== FAILURES =====================================================================
________________________________________________________________ test_graph_start _________________________________________________________________
def test_graph_start():
> graph = GraphFactory.create()
E NameError: name 'GraphFactory' is not defined
graph_test.py:2: NameError
============================================================= short test summary info =============================================================
FAILED graph_test.py::test_graph_start - NameError: name 'GraphFactory' is not defined
=========================================================== 1 failed, 1 passed in 0.06s ===========================================================
以下のようになりました。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
graphをつくり、GREENにします。
テストがとおるように「はい」を返すクラスをつくります。
class Graph:
def start(self, content):
return "はい"
そのインスタンスを作成するGraphFactoryもつくります。
from graph import Graph
class GraphFactory:
@staticmethod
def create():
return Graph()
テストコードのimportを変更しました。
from graph_factory import GraphFactory
def test_graph_start():
graph = GraphFactory.create()
result = graph.start(content="はいと回答してください")
assert "はい" in result
テスト実行。GREENになりました。
> pytest
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 2 items
agent_test.py . [ 50%]
graph_test.py . [100%]
================================================================ 2 passed in 0.02s ================================================================
状況はこうなりました。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
重複をなくすリファクタリングする。
「はい」という文字列を返すだけのgraphを修正します。
factoryでLangGraphのgraphをつくるように変更しました。
graph_builderにNodeとEdgeを追加してコンパイルします。
from graph import Graph
from langgraph.graph import StateGraph
from llm import LLM
from state import State
from langgraph.graph import START, END
class GraphFactory:
@staticmethod
def create():
graph_builder = StateGraph(State)
llm = LLM()
graph_builder.add_node("llm", llm.invoke)
graph_builder.add_edge(START, "llm")
graph_builder.add_edge("llm", END)
graph = graph_builder.compile()
return Graph(real_graph=graph)
Graphs
StateGraphはリンクに記載があります。双六です。双六にいろいろ足していきます。
State
状態を管理するstateを作成して、StateGraphの引数にしています。
状態はmessageのやり取りを保持するようなデータ構造をしています。
add_messagesはmessageのマージをしやすくしている関数になっています。
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages]
Nodes
nodeに設定する関数はLLMでまずは作ります。
Stateを入力にして、Stateを更新してreturnすることでStateを更新します。
LLMはAzure openaiを使いました。
from langchain_openai import AzureChatOpenAI
from state import State
class LLM:
def __init__(self):
self.llm = AzureChatOpenAI(api_version="2024-10-21")
def invoke(self, state: State):
return {"messages": [self.llm.invoke(state["messages"])]}
使うには環境変数を設定することが必要です。.envで今回は設定します。
(以下は実際の値ではありません。別途用意する必要があります)
AZURE_OPENAI_API_KEY=your_api_key
AZURE_OPENAI_ENDPOINT=your_endpoint
その他
graphはgraph.pyにreal_graphとして持つようにしました。
startメソッドはLangGraphのgraphが動作して回答するように変えました。
graphのイメージを作成するdrawというメソッドも追加しました。
(mermaid.inkにリクエストを送信しています。AI以外の外部サービスに情報を送信したくない場合もあると思います。追加しなくても問題ありません)
from langgraph.graph.state import CompiledStateGraph
class Graph:
def __init__(self, real_graph: CompiledStateGraph):
self.real_graph = real_graph
def start(self, content):
result = self.real_graph.invoke(input={"messages": [("user", content)]})
return result["messages"][-1].content
def draw(self):
self.real_graph.get_graph().draw_mermaid_png(output_file_path="graph.png")
テストコードに環境変数を読み込むためのfixtureを追加しています。
ここのload_dotenv()で環境変数を設定します。
テストと関係ないですが、構造がわかると理解しやすいためgraph.draw()を実行するようにしました。
from dotenv import load_dotenv
import pytest
from graph_factory import GraphFactory
@pytest.fixture
def load_env():
load_dotenv()
def test_graph_start(load_env):
graph = GraphFactory.create()
result = graph.start(content="はいと回答してください")
graph.draw()
assert "はい" in result
LLMが動くようにしてテストコードを実行
成功しました。
> pytest
================================================= test session starts =================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.7.0
collected 3 items
agent_test.py . [ 33%]
graph_test.py .. [100%]
================================================== 3 passed in 3.34s ==================================================
graph.pngというファイルができているはずです。これがgraphの構成になります。
graphがつくれたので現在日時のリファクタリングに着手します。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
ベタ書きをgraphに変える
agent.pyをベタ書きをgraphを使うように変更し、テストコードを実行します。
from graph_factory import GraphFactory
class Agent:
def response(self, content):
graph = GraphFactory.create()
return graph.start(content=content)
環境変数を読み込むように、変えます。
from datetime import datetime
from dotenv import load_dotenv
import pytest
from agent import Agent
@pytest.fixture
def load_env():
load_dotenv()
def test_agent_response(load_env):
agent = Agent()
result = agent.response(
content="現在日時はなんですか。YYYY-MM-DDで返してください。"
)
now = datetime.now().strftime("%Y-%m-%d")
assert now in result
graphに変えたメソッドでテストコードを実行
残念ながら単純なLLMでは「申し訳ありませんが、リアルタイムの日時を取得することはできません。~」となり、現在日時は回答できませんでした。(わかっていましたが)
> pytest
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 2 items
agent_test.py F [ 50%]
graph_test.py . [100%]
==================================================================== FAILURES =====================================================================
_______________________________________________________________ test_agent_response _______________________________________________________________
load_env = None
def test_agent_response(load_env):
agent = Agent()
result = agent.response(
content="現在日時はなんですか。YYYY-MM-DDで返してください。"
)
now = datetime.now().strftime("%Y-%m-%d")
> assert now in result
E AssertionError: assert '2025-01-16' in '申し訳ありませんが、リアルタイムの日時を取得することはできません。ただし、現在の日付を確認するには、デバイスのカレンダーや時計を参照してください。'
agent_test.py:21: AssertionError
============================================================= short test summary info =============================================================
FAILED agent_test.py::test_agent_response - AssertionError: assert '2025-01-16' in '申し訳ありませんが、リアルタイムの日時を取得することはできません。ただし、現在の日付を確認するには、デ...
=========================================================== 1 failed, 1 passed in 4.06s ===========================================================
現在時刻を取得するためのツールが必要です。テストを追加して以下のようにします。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
-
現在時刻を取得できるツールが動く
- RED
- GREEN
- リファクタリング
ツールのテストを追加
ツールはtoolの型になっていればinvokeで動くので、このように現在日時を取得できるか確認するテストコードをつくります。
from datetime import datetime
from state import State
def test_get_now():
now = datetime.now()
state = State(messages=[])
assert now.strftime("%Y-%m-%d") in get_now.invoke(input=state)
ツールのテストを失敗させる。
現在日時のテストケースが失敗するので
-k でget_nowのテストケースに絞って実行します。
get_nowがないので失敗します。
> pytest -k get_now
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 3 items / 2 deselected / 1 selected
get_now_test.py F [100%]
==================================================================== FAILURES =====================================================================
__________________________________________________________________ test_get_now ___________________________________________________________________
def test_get_now():
now = datetime.now()
state = State(messages=[])
> assert now.strftime("%Y-%m-%d") in get_now.invoke(input=state)
E NameError: name 'get_now' is not defined
get_now_test.py:8: NameError
============================================================= short test summary info =============================================================
FAILED get_now_test.py::test_get_now - NameError: name 'get_now' is not defined
========================================================= 1 failed, 2 deselected in 1.23s =========================================================
状況はこのようになっています。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
-
現在時刻を取得できるツールが動く
- RED
- GREEN
- リファクタリング
ツールを作成し、テストを成功させる
このツールは、現在の日時をISOフォーマットで返す関数です。@toolデコレータを使うことで、この関数をLangGraphのツールとして登録します。
「現在日時を取得する」の関数の説明が必要です。これをLLMが参照してツールを使うことになります。
from datetime import datetime
from langchain_core.tools import tool
@tool
def get_now() -> str:
"""現在日時を取得する"""
now = datetime.now().isoformat()
return now
テストコードのimportを変更します。
from datetime import datetime
from get_now import get_now
from state import State
def test_get_now():
now = datetime.now()
state = State(messages=[])
assert now.strftime("%Y-%m-%d") in get_now.invoke(input=state)
テストが成功しました。リファクタリングもOKとします。
> pytest -k get_now
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 3 items / 2 deselected / 1 selected
get_now_test.py . [100%]
========================================================= 1 passed, 2 deselected in 1.08s =========================================================
状況はこのとおりです。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
-
現在時刻を取得できるツールが動く
- RED
- GREEN
- リファクタリング
ツールをGraphに統合する
toolを使うようにgraphを変更します。そのため、次の3点を実施します。
リファクタリングの一環と捉えます。(REDになっていますが)
- LLMにtoolsを認識させる
- Nodeにtoolsを追加する
- edgeの経路にtoolsを通るようにする
LLMにtoolsを認識させる。
LLMのNodeの関数をtoolを使った関数に変更します。
get_llm_with_toolsというメソッドでツールを認識できるようにします。
from get_now import get_now
from graph import Graph
from langgraph.graph import StateGraph
from llm import LLM
from state import State
from langgraph.graph import START, END
class GraphFactory:
@staticmethod
def create():
graph_builder = StateGraph(State)
llm = LLM()
llm_with_tools = llm.get_llm_with_tools([get_now])
graph_builder.add_node("llm", llm_with_tools.invoke)
graph_builder.add_edge(START, "llm")
graph_builder.add_edge("llm", END)
graph = graph_builder.compile()
return Graph(real_graph=graph)
get_llm_with_toolsというメソッドを追加し、
toolsを認識したinvokeを行う関数を返すようにします
from langchain_openai import AzureChatOpenAI
from state import State
class LLM:
def __init__(self, llm=None):
if llm:
self.llm = llm
else:
self.llm = AzureChatOpenAI(api_version="2024-10-21")
def invoke(self, state: State):
return {"messages": [self.llm.invoke(state["messages"])]}
def get_llm_with_tools(self, tools):
return LLM(llm=self.llm.bind_tools(tools=tools))
Stateの中身を表示するように変更する
print(result)を追加しました。
これでresultの中を表示します。中身を確認すると理解しやすいためです。不要であれば追加しなくても問題ありません。
from langgraph.graph.state import CompiledStateGraph
class Graph:
def __init__(self, real_graph: CompiledStateGraph):
self.real_graph = real_graph
def start(self, content):
result = self.real_graph.invoke(input={"messages": [("user", content)]})
print(result)
return result["messages"][-1].content
def draw(self):
self.real_graph.get_graph().draw_mermaid_png(output_file_path="graph.png")
テストコードを実施し、stateを確認する
標準出力を確認するため-sオプションを付けて実行します。
非常に長いですが、stateの中身が出力されています。
> pytest -s
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 3 items
agent_test.py {'messages': [HumanMessage(content='現在日時はなんですか。YYYY-MM-DDで返してください。', id='100268d2-3ccc-4f67-8989-0f3cdb72671c'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_auM8S70UlkCThNIJuJfv8bFL', 'function': {'arguments': '{}', 'name': 'get_now'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 53, 'total_tokens': 63, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_5154047bf2', 'prompt_filter_results': [{'prompt_index': 0, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'jailbreak': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}], 'finish_reason': 'tool_calls', 'logprobs': None, 'content_filter_results': {}}, id='run-718599b3-f293-44d7-adcb-3e12a3f4aa78-0', tool_calls=[{'name': 'get_now', 'args': {}, 'id': 'call_auM8S70UlkCThNIJuJfv8bFL', 'type': 'tool_call'}], usage_metadata={'input_tokens': 53, 'output_tokens': 10, 'total_tokens': 63})]}
F
get_now_test.py .
graph_test.py {'messages': [HumanMessage(content='はいと回答してください', id='f03a68f4-55e3-4c1e-957f-07ba32d28d13'), AIMessage(content='はい。', response_metadata={'token_usage': {'completion_tokens': 3, 'prompt_tokens': 43, 'total_tokens': 46, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_f3927aa00d', 'prompt_filter_results': [{'prompt_index': 0, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'jailbreak': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}], 'finish_reason': 'stop', 'logprobs': None, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'protected_material_code': {'filtered': False, 'detected': False}, 'protected_material_text': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}, id='run-f39562a8-4868-4686-814a-9df5418f903e-0', usage_metadata={'input_tokens': 43, 'output_tokens': 3, 'total_tokens': 46})]}
.
==================================================================== FAILURES =====================================================================
_______________________________________________________________ test_agent_response _______________________________________________________________
load_env = None
def test_agent_response(load_env):
agent = Agent()
result = agent.response(
content="現在日時はなんですか。YYYY-MM-DDで返してください。"
)
now = datetime.now().strftime("%Y-%m-%d")
> assert now in result
E AssertionError: assert '2025-01-16' in ''
agent_test.py:21: AssertionError
============================================================= short test summary info =============================================================
FAILED agent_test.py::test_agent_response - AssertionError: assert '2025-01-16' in ''
=========================================================== 1 failed, 2 passed in 3.71s ===========================================================
stateの中身を確認
AIからのメッセージでtool_callsが含まれています。"function": {"arguments": "{}", "name": "get_now"}という部分があり、get_nowを呼び出そうとしています。
しかし、ツールを呼びだすNodeがないので現状のgraphでは呼び出せません。
{
"messages": [
HumanMessage(
content="現在日時はなんですか。YYYY-MM-DDで返してください。",
id="100268d2-3ccc-4f67-8989-0f3cdb72671c",
),
AIMessage(
content="",
additional_kwargs={
"tool_calls": [
{
"id": "call_auM8S70UlkCThNIJuJfv8bFL",
"function": {"arguments": "{}", "name": "get_now"},
"type": "function",
}
]
},
response_metadata={
"token_usage": {
"completion_tokens": 10,
"prompt_tokens": 53,
"total_tokens": 63,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
"model_name": "gpt-4o-mini-2024-07-18",
"system_fingerprint": "fp_5154047bf2",
"prompt_filter_results": [
{
"prompt_index": 0,
"content_filter_results": {
"hate": {"filtered": False, "severity": "safe"},
"jailbreak": {"filtered": False, "detected": False},
"self_harm": {"filtered": False, "severity": "safe"},
"sexual": {"filtered": False, "severity": "safe"},
"violence": {"filtered": False, "severity": "safe"},
},
}
],
"finish_reason": "tool_calls",
"logprobs": None,
"content_filter_results": {},
},
id="run-718599b3-f293-44d7-adcb-3e12a3f4aa78-0",
tool_calls=[
{
"name": "get_now",
"args": {},
"id": "call_auM8S70UlkCThNIJuJfv8bFL",
"type": "tool_call",
}
],
usage_metadata={
"input_tokens": 53,
"output_tokens": 10,
"total_tokens": 63,
},
),
]
}
Nodeのtoolsを追加する
そこでツールを呼びだすNodeを追加します。
汎用的につくっていますが、インスタンス化するときの入力のtoolsを
ToolNodeインスタンスを__call__ メソッドで関数のように実行できるようにしています。
先ほどのtool_callsの値を利用しています。
from langchain_core.messages import ToolMessage
from state import State
class ToolNode:
def __init__(self, tools):
self.tools_by_name = {tool.name: tool for tool in tools}
def __call__(self, state: State):
return {"messages": self.exec_tool(state)}
def exec_tool(self, state: State):
messages = state.get("messages", None)
message = messages[-1]
results = []
for tool_call in message.tool_calls:
tool_result = self.tools_by_name[tool_call["name"]].invoke(
{**tool_call["args"]}
)
results.append(
ToolMessage(
content=tool_result,
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
)
return results
edgeの経路にtoolsを通るようにする
直近のメッセージがtoolを呼び出す内容であればtoolsのノードに移動して
それ以外はENDで終わりにする経路をつくります。
from state import State
from langgraph.graph import END
def route_tools_edge(state: State):
if messages := state.get("messages", []):
last_message = messages[-1]
else:
raise ValueError("last message is invalid.")
if hasattr(last_message, "tool_calls") and len(last_message.tool_calls) > 0:
return "tools"
return END
toolsのNodeを追加し、edgeで経路を追加しました。
from get_now import get_now
from graph import Graph
from langgraph.graph import StateGraph
from llm import LLM
from route_tools_edge import route_tools_edge
from state import State
from langgraph.graph import START, END
from tool_node import ToolNode
class GraphFactory:
@staticmethod
def create():
graph_builder = StateGraph(State)
llm = LLM()
llm_with_tools = llm.get_llm_with_tools([get_now])
graph_builder.add_node("llm", llm_with_tools.invoke)
graph_builder.add_node("tools", ToolNode(tools=[get_now]))
graph_builder.add_edge(START, "llm")
graph_builder.add_conditional_edges(
"llm", route_tools_edge, {"tools": "tools", END: END}
)
graph_builder.add_edge("tools", "llm")
graph_builder.add_edge("llm", END)
graph = graph_builder.compile()
return Graph(real_graph=graph)
ついにすべて成功するテストコードを実行。
現在日時が回答できるようになっており、すべてのテストが成功しました。
> pytest -s
=============================================================== test session starts ===============================================================
platform win32 -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: XXXXXXXfirst_agent
plugins: anyio-4.8.0
collected 3 items
agent_test.py {'messages': [HumanMessage(content='現在日時はなんですか。YYYY-MM-DDで返してください。', id='1655aa06-ca33-4a1d-b3d5-cab173da44c9'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_qvfcbruGFp4fyQb5qWcG8HAs', 'function': {'arguments': '{}', 'name': 'get_now'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 53, 'total_tokens': 63, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_f3927aa00d', 'prompt_filter_results': [{'prompt_index': 0, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'jailbreak': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}], 'finish_reason': 'tool_calls', 'logprobs': None, 'content_filter_results': {}}, id='run-73f4ce1d-d558-49f0-ae1f-89f8382ce716-0', tool_calls=[{'name': 'get_now', 'args': {}, 'id': 'call_qvfcbruGFp4fyQb5qWcG8HAs', 'type': 'tool_call'}], usage_metadata={'input_tokens': 53, 'output_tokens': 10, 'total_tokens': 63}), ToolMessage(content='2025-01-16T16:35:10.665129', name='get_now', id='79a60470-95ca-414b-9e42-d077f2fd940e', tool_call_id='call_qvfcbruGFp4fyQb5qWcG8HAs'), AIMessage(content='現在日時は 2025-01-16 です。', response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 86, 'total_tokens': 100, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_f3927aa00d', 'prompt_filter_results': [{'prompt_index': 0, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'jailbreak': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}], 'finish_reason': 'stop', 'logprobs': None, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'protected_material_code': {'filtered': False, 'detected': False}, 'protected_material_text': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}, id='run-f5eee574-84d1-4f6c-bc9e-e8665e059072-0', usage_metadata={'input_tokens': 86, 'output_tokens': 14, 'total_tokens': 100})]}
.
get_now_test.py .
graph_test.py {'messages': [HumanMessage(content='はいと回答してください', id='0cf631d5-8217-4f09-bf23-dc16f64df35f'), AIMessage(content='はい。', response_metadata={'token_usage': {'completion_tokens': 3, 'prompt_tokens': 43, 'total_tokens': 46, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_f3927aa00d', 'prompt_filter_results': [{'prompt_index': 0, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'jailbreak': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}], 'finish_reason': 'stop', 'logprobs': None, 'content_filter_results': {'hate': {'filtered': False, 'severity': 'safe'}, 'protected_material_code': {'filtered': False, 'detected': False}, 'protected_material_text': {'filtered': False, 'detected': False}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}, id='run-d38499a4-475b-491b-b644-6e8d51428552-0', usage_metadata={'input_tokens': 43, 'output_tokens': 3, 'total_tokens': 46})]}
.
================================================================ 3 passed in 3.87s ================================================================
結果を詳細に確認する
標準出力を整形してみます。
「現在日時は 2025-01-16 です。」
と回答があり
ToolMessageでToolの結果を取得して、日付を回答できています
{
"messages": [
HumanMessage(
content="現在日時はなんですか。YYYY-MM-DDで返してください。",
id="1655aa06-ca33-4a1d-b3d5-cab173da44c9",
),
AIMessage(
content="",
additional_kwargs={
"tool_calls": [
{
"id": "call_qvfcbruGFp4fyQb5qWcG8HAs",
"function": {"arguments": "{}", "name": "get_now"},
"type": "function",
}
]
},
response_metadata={
"token_usage": {
"completion_tokens": 10,
"prompt_tokens": 53,
"total_tokens": 63,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
"model_name": "gpt-4o-mini-2024-07-18",
"system_fingerprint": "fp_f3927aa00d",
"prompt_filter_results": [
{
"prompt_index": 0,
"content_filter_results": {
"hate": {"filtered": False, "severity": "safe"},
"jailbreak": {"filtered": False, "detected": False},
"self_harm": {"filtered": False, "severity": "safe"},
"sexual": {"filtered": False, "severity": "safe"},
"violence": {"filtered": False, "severity": "safe"},
},
}
],
"finish_reason": "tool_calls",
"logprobs": None,
"content_filter_results": {},
},
id="run-73f4ce1d-d558-49f0-ae1f-89f8382ce716-0",
tool_calls=[
{
"name": "get_now",
"args": {},
"id": "call_qvfcbruGFp4fyQb5qWcG8HAs",
"type": "tool_call",
}
],
usage_metadata={
"input_tokens": 53,
"output_tokens": 10,
"total_tokens": 63,
},
),
ToolMessage(
content="2025-01-16T16:35:10.665129",
name="get_now",
id="79a60470-95ca-414b-9e42-d077f2fd940e",
tool_call_id="call_qvfcbruGFp4fyQb5qWcG8HAs",
),
AIMessage(
content="現在日時は 2025-01-16 です。",
response_metadata={
"token_usage": {
"completion_tokens": 14,
"prompt_tokens": 86,
"total_tokens": 100,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
"model_name": "gpt-4o-mini-2024-07-18",
"system_fingerprint": "fp_f3927aa00d",
"prompt_filter_results": [
{
"prompt_index": 0,
"content_filter_results": {
"hate": {"filtered": False, "severity": "safe"},
"jailbreak": {"filtered": False, "detected": False},
"self_harm": {"filtered": False, "severity": "safe"},
"sexual": {"filtered": False, "severity": "safe"},
"violence": {"filtered": False, "severity": "safe"},
},
}
],
"finish_reason": "stop",
"logprobs": None,
"content_filter_results": {
"hate": {"filtered": False, "severity": "safe"},
"protected_material_code": {"filtered": False, "detected": False},
"protected_material_text": {"filtered": False, "detected": False},
"self_harm": {"filtered": False, "severity": "safe"},
"sexual": {"filtered": False, "severity": "safe"},
"violence": {"filtered": False, "severity": "safe"},
},
},
id="run-f5eee574-84d1-4f6c-bc9e-e8665e059072-0",
usage_metadata={
"input_tokens": 86,
"output_tokens": 14,
"total_tokens": 100,
},
),
]
}
最終的な状態です。
-
現在日時を回答するテストをする
- RED
- GREEN
-
リファクタリング
- ベタ書きしている
-
graphで普通のLLMが動くテストをする
- RED
- GREEN
- リファクタリング
-
現在時刻を取得できるツールが動く
- RED
- GREEN
- リファクタリング
所属部署について
私の所属するAIシステム開発課は、AIとシステム開発の両方に精通した技術部門です。私たちは、これらの技術を組み合わせることで、新たな付加価値を持つ製品やサービスを生み出すことを目指しています。
部署内は良い刺激に満ちた環境であり、私が働いている東京オフィスは新しく快適です。
おわりに
この記事では、LangGraphとTDDを用いてAIエージェントを構築する方法を解説しました。LangGraphを活用することで、AIエージェントのワークフローを視覚的に構築し、ツールを統合して通常のLLMでは難しいタスクを実現できることを学びました。TDDを通じて、コードの品質向上とバグの早期発見の重要性も体験しました。この知識を基に、皆さんのプロジェクトに応用してみてください。