社内勉強会で実践！TDDとLangGraphを使ったAIエージェント開発入門

はじめまして。
京セラコミュニケーションシステム株式会社　
技術開発センター　ICT技術開発部 AIシステム開発課　 東京AIシステムの中山です。
この記事では、LangGraphを用いてAIエージェントを構築する方法を初心者向けに解説します。どうぞよろしくお願いいたします！

## 記事の要約
本記事では、LangGraphを用いてAIエージェントを構築する方法を、初心者向けに分かりやすく解説します。社内勉強会で実施したハンズオンの内容を基に、Pythonを使って「現在日時を回答できるAIエージェント」を作成するプロセスをステップバイステップで紹介します。

LangGraphは、AIエージェントのワークフローを視覚的に構築できる強力なツールです。この記事を通じて、LangGraphの基本的な使い方や、AIエージェントにツールを統合する方法を学ぶことができます。特に、通常のLLM（大規模言語モデル）では実現が難しい「現在日時の取得」を可能にするためのツールの活用方法に焦点を当てています。

また、開発手法としてTDD（[テスト駆動開発](https://amzn.asia/d/0XbtgWr)）を採用しています。TDDは、まずテストコードを書き、そのテストを通すための実装を行う開発手法です。この手法により、コードの品質を高め、バグを早期に発見することができます。この記事では、TDDの基本的な流れや実践方法についても触れています。

Pythonの基本的な文法を理解している方であれば、この記事を読むことで、AIエージェントの構築に必要な基礎知識を身につけることができ、実際に自分のプロジェクトに応用することができるはずです。

## 想定読者
   - 基本的なPythonの文法を理解している方
   - AIエージェントの構築に興味がある方
   - 新人エンジニアやAI初心者向けの内容です

## 実施環境
   - OS: Windows 11
   - Python: 3.11（3.9以上で動作可能）
   - 必要なライブラリは`requirements.txt`に記載しています
## ゴール
   - LangGraphを用いて、現在日時を回答できるAIエージェントを構築する
   - AIエージェントの基本的な構造と動作を理解する

## 環境のセットアップをします
**requirements.txt**というPythonプロジェクトで使用される外部ライブラリやパッケージの依存関係を記述するためのテキストファイルを保存します。
（以後、分かりやすいようにすべて同一のディレクトリで作業しています）

```python:requirements.txt
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環境を作成するために仮想環境を使用します。これにより、異なるプロジェクト間で依存関係が競合することを防ぎます。
```python
py -3.11 -m venv .venv 
```

仮想環境をアクティベートすることで、その環境内でインストールされたライブラリやパッケージを使用できるようになります。これにより、プロジェクトに必要な特定のバージョンのライブラリを確実に使用できます。

```
>.venv\Scripts\activate
```

`requirements.txt` に記載されたすべてのライブラリを一括でインストールします。これにより、プロジェクトに必要なすべての依存関係が自動的に解決され、環境が整います。
```
>pip install -r requirements.txt
```

## TDDの準備もします

TDDでは以下のルールどおり、最初にテストコードを書きます。
一般的な手順のとおりに進めたいと思います。

### TDDのルール
1. コードを書く前に、**失敗**する自動テストコードを**必ず**書く
2. 重複を除去する

### TDDの手順
1. RED：動作しない、コンパイルも通らないテストを一つ書く
2. GREEN：そのテストを迅速に動作させる。ひどいコードを書いてもいい
3. リファクタリング：テストを通すために発生した重複をすべて除去する

Kent Beck氏になるべく忠実にテストコードから書きます。
今回、現在日時を回答するAIエージェントを作成したいので、著作でもあるように以下のリストを作成しました。
REDはテストの失敗、GREENはテストの成功を表しています。
***
- [ ] 現在日時を回答するテストをする  
	- [ ] RED  
	- [ ] GREEN  
	- [ ] リファクタリング  
***

現在日時を質問し、回答を確認するテストケースを**agent_test.py**に作成して保存します。

```python: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**と記載があります。ここが失敗したということです。
これは進んでいることになります。

```python
> 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 ================================================================
```
状況を更新します。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [ ] GREEN  
	- [ ] リファクタリング  
***

## テストを成功させるクラスをつくる
すぐ実装できそうにないので、単純に現在日時を戻すメソッドを**agent.py**に実装します。
```python:agent.py
from datetime import datetime


class Agent:
    def response(self, content):
        now = datetime.now().strftime("%Y-%m-%d")
        return now

```
importだけ変えて、Agentを読めるようにします。
```python:agent_test.py
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になりました）

```python
> 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 ================================================================

```
状況を更新します。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [x] GREEN  
	- [ ] リファクタリング  
***


## 重複のリファクタリングを考える

現在日時がテストコードとプロダクトコードで重複しているので、リファクタリングが必要です。
しかし、すぐにどうやればいいか分かりません。
AIエージェントをつくるための**LangGraph**のコンポーネントのテストを追加します。

## LangGraphのコンポーネント

公式ドキュメントにはこのあたりに書いてます。
[コンポーネントの説明](https://langchain-ai.github.io/langgraph/concepts/low_level/#graphs)

説明のための私の解釈をここに記載します。

### Graphs
エージェントのワークフローのことのようです。
私としては双六と捉えるといいと思います。

### States
状態。アプリケーションの現状のスナップショット
私としては双六のコマ

### Nodes
エージェントの関数。
私としては双六のマス

### Edges
現在の状態に基づいて次に実行するノードを決定する関数。
私としてはマスの経路

## Graphのテストコードを作成
まずは、単純なLLMでも可能なGraphの作成を目指します。
Graphを作成し、「はい」と答えてもらうテストです。

```python:graph_test.py
def test_graph_start():
    graph = GraphFactory.create()
    result = graph.start(content="はいと回答してください")
    assert "はい" in result

```
状況はこうなっています。

***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [x] 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 =========================================================== 

```

以下のようになりました。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [x] GREEN  
	- [ ] リファクタリング  
		- [ ] ベタ書きしている  
- [ ] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [ ] GREEN  
	- [ ] リファクタリング
***

## graphをつくり、GREENにします。

テストがとおるように「はい」を返すクラスをつくります。
```python:graph.py
class Graph:
    def start(self, content):
        return "はい"
```

そのインスタンスを作成する**GraphFactory**もつくります。
```python:graph_factory.py
from graph import Graph


class GraphFactory:
    @staticmethod
    def create():
        return Graph()
```

テストコードのimportを変更しました。

```python:graph_test.py
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 ================================================================ 

```

状況はこうなりました。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [x] GREEN  
	- [ ] リファクタリング  
		- [ ] ベタ書きしている  
- [ ] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [x] GREEN  
	- [ ] リファクタリング
***

## 重複をなくすリファクタリングする。
「はい」という文字列を返すだけのgraphを修正します。
factoryでLangGraphのgraphをつくるように変更しました。
graph_builderにNodeとEdgeを追加してコンパイルします。
```python:graph_factory.py
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](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.state.StateGraph)はリンクに記載があります。双六です。双六にいろいろ足していきます。


### State
状態を管理するstateを作成して、StateGraphの引数にしています。
状態はmessageのやり取りを保持するようなデータ構造をしています。
[add_messages](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages)はmessageのマージをしやすくしている関数になっています。
```python:state.py
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](https://python.langchain.com/api_reference/openai/chat_models/langchain_openai.chat_models.azure.AzureChatOpenAI.html#langchain_openai.chat_models.azure.AzureChatOpenAI)を使いました。

```python:llm.py
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で今回は設定します。
（以下は実際の値ではありません。別途用意する必要があります）

```:.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以外の外部サービスに情報を送信したくない場合もあると思います。追加しなくても問題ありません)


```python:graph.py
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()を実行するようにしました。

```python:graph_test.py
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の構成になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1125664/5ee11343-7d54-b394-c5da-6de8bec95ed9.png)


graphがつくれたので現在日時のリファクタリングに着手します。

***

- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [x] GREEN  
	- [ ] リファクタリング  
		- [ ] ベタ書きしている  
- [x] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [x] GREEN  
	- [x] リファクタリング
***

## ベタ書きをgraphに変える

**agent.py**をベタ書きをgraphを使うように変更し、テストコードを実行します。

```python:agent.py
from graph_factory import GraphFactory


class Agent:
    def response(self, content):
        graph = GraphFactory.create()
        return graph.start(content=content)

```

環境変数を読み込むように、変えます。


```python:agent_test.py
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では「申し訳ありませんが、リアルタイムの日時を取得することはできません。～」となり、現在日時は回答できませんでした。（わかっていましたが）


```python
> 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 =========================================================== 

```

現在時刻を取得するためのツールが必要です。テストを追加して以下のようにします。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [ ] GREEN  
	- [ ] リファクタリング  
		- [x] ベタ書きしている  
- [x] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [x] GREEN  
	- [x] リファクタリング
- [ ] 現在時刻を取得できるツールが動く
	- [ ] RED
	- [ ] GREEN
	- [ ] リファクタリング
***

## ツールのテストを追加
[ツール](https://python.langchain.com/docs/how_to/custom_tools/)はtoolの型になっていればinvokeで動くので、このように現在日時を取得できるか確認するテストコードをつくります。
```python:get_now_test.py
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 ========================================================= 

```
状況はこのようになっています。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [ ] GREEN  
	- [ ] リファクタリング  
		- [x] ベタ書きしている  
- [x] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [x] GREEN  
	- [x] リファクタリング
- [ ] 現在時刻を取得できるツールが動く
	- [x] RED
	- [ ] GREEN
	- [ ] リファクタリング
***

## ツールを作成し、テストを成功させる
このツールは、現在の日時をISOフォーマットで返す関数です。[@tool](https://python.langchain.com/api_reference/core/tools/langchain_core.tools.convert.tool.html#langchain_core.tools.convert.tool)デコレータを使うことで、この関数をLangGraphのツールとして登録します。
「現在日時を取得する」の関数の説明が必要です。これをLLMが参照してツールを使うことになります。



```python:get_now.py
from datetime import datetime
from langchain_core.tools import tool

@tool
def get_now() -> str:
    """現在日時を取得する"""
    now = datetime.now().isoformat()
    return now

```

テストコードのimportを変更します。
```python:get_now_test.py
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とします。
```python
> 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 ========================================================= 
```
状況はこのとおりです。
***
- [ ] 現在日時を回答するテストをする  
	- [x] RED  
	- [ ] GREEN  
	- [ ] リファクタリング  
		- [x] ベタ書きしている  
- [x] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [x] GREEN  
	- [x] リファクタリング
- [x] 現在時刻を取得できるツールが動く
	- [x] RED
	- [x] GREEN
	- [x] リファクタリング
***


## ツールをGraphに統合する

toolを使うようにgraphを変更します。そのため、次の3点を実施します。
リファクタリングの一環と捉えます。(REDになっていますが）

4. LLMにtoolsを認識させる
5. Nodeにtoolsを追加する
6. edgeの経路にtoolsを通るようにする

## LLMにtoolsを認識させる。

LLMのNodeの関数をtoolを使った関数に変更します。
get_llm_with_toolsというメソッドでツールを認識できるようにします。

```python:graph.py
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を行う関数を返すようにします

```python:llm.py
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の中を表示します。中身を確認すると理解しやすいためです。不要であれば追加しなくても問題ありません。

```python:graph.py
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**の値を利用しています。

```python:tool_node.py
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で終わりにする経路をつくります。

```python:route_tools_edge.py
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で経路を追加しました。

```python:graph_factory.py
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,
            },
        ),
    ]
}
```

最終的な状態です。
***
- [x] 現在日時を回答するテストをする  
	- [x] RED  
	- [x] GREEN  
	- [x] リファクタリング  
		- [x] ベタ書きしている  
- [x] graphで普通のLLMが動くテストをする  
	- [x] RED  
	- [x] GREEN  
	- [x] リファクタリング
- [x] 現在時刻を取得できるツールが動く
	- [x] RED
	- [x] GREEN
	- [x] リファクタリング
***
graphの構成はこのようになりました。
![image 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1125664/45410a02-4892-3cc0-b799-f3da4cdd482d.png)
以上です。

## 所属部署について
私の所属するAIシステム開発課は、AIとシステム開発の両方に精通した技術部門です。私たちは、これらの技術を組み合わせることで、新たな付加価値を持つ製品やサービスを生み出すことを目指しています。
部署内は良い刺激に満ちた環境であり、私が働いている[東京オフィス](https://www.instagram.com/kccs_recruit/p/C5pzy9dSZH9/?img_index=1)は新しく快適です。


## おわりに
この記事では、LangGraphとTDDを用いてAIエージェントを構築する方法を解説しました。LangGraphを活用することで、AIエージェントのワークフローを視覚的に構築し、ツールを統合して通常のLLMでは難しいタスクを実現できることを学びました。TDDを通じて、コードの品質向上とバグの早期発見の重要性も体験しました。この知識を基に、皆さんのプロジェクトに応用してみてください。