2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめまして。
京セラコミュニケーションシステム株式会社 
技術開発センター 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プロジェクトで使用される外部ライブラリやパッケージの依存関係を記述するためのテキストファイルを保存します。
(以後、分かりやすいようにすべて同一のディレクトリで作業しています)

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

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に作成して保存します。

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に実装します。

agent.py
from datetime import datetime


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

importだけ変えて、Agentを読めるようにします。

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になりました)

> 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を作成し、「はい」と答えてもらうテストです。

graph_test.py
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にします。

テストがとおるように「はい」を返すクラスをつくります。

graph.py
class Graph:
    def start(self, content):
        return "はい"

そのインスタンスを作成するGraphFactoryもつくります。

graph_factory.py
from graph import Graph


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

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

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

状況はこうなりました。


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

重複をなくすリファクタリングする。

「はい」という文字列を返すだけのgraphを修正します。
factoryでLangGraphのgraphをつくるように変更しました。
graph_builderにNodeとEdgeを追加してコンパイルします。

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はリンクに記載があります。双六です。双六にいろいろ足していきます。

State

状態を管理するstateを作成して、StateGraphの引数にしています。
状態はmessageのやり取りを保持するようなデータ構造をしています。
add_messagesはmessageのマージをしやすくしている関数になっています。

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を使いました。

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

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()を実行するようにしました。

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

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


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

ベタ書きをgraphに変える

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

agent.py
from graph_factory import GraphFactory


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

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

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

> 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で動くので、このように現在日時を取得できるか確認するテストコードをつくります。

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

状況はこのようになっています。


  • 現在日時を回答するテストをする
    • RED
    • GREEN
    • リファクタリング
      • ベタ書きしている
  • graphで普通のLLMが動くテストをする
    • RED
    • GREEN
    • リファクタリング
  • 現在時刻を取得できるツールが動く
    • RED
    • GREEN
    • リファクタリング

ツールを作成し、テストを成功させる

このツールは、現在の日時をISOフォーマットで返す関数です。@toolデコレータを使うことで、この関数をLangGraphのツールとして登録します。
「現在日時を取得する」の関数の説明が必要です。これをLLMが参照してツールを使うことになります。

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を変更します。

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とします。

> 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になっていますが)

  1. LLMにtoolsを認識させる
  2. Nodeにtoolsを追加する
  3. edgeの経路にtoolsを通るようにする

LLMにtoolsを認識させる。

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

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を行う関数を返すようにします

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

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の値を利用しています。

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で終わりにする経路をつくります。

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で経路を追加しました。

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,
            },
        ),
    ]
}

最終的な状態です。


  • 現在日時を回答するテストをする
    • RED
    • GREEN
    • リファクタリング
      • ベタ書きしている
  • graphで普通のLLMが動くテストをする
    • RED
    • GREEN
    • リファクタリング
  • 現在時刻を取得できるツールが動く
    • RED
    • GREEN
    • リファクタリング

graphの構成はこのようになりました。
image 1.png
以上です。

所属部署について

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

おわりに

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?