フレームワークなしで作って学ぶAIエージェント 〜その２：単純なエージェントの実装〜

# この記事を読むとわかること

LangChain などのフレームワークなしで、以下の方法がわかります。

- OpenAI API でツール呼び出しを実装する方法
- 検索エンジンを任意の回数利用してユーザーの質問に答えてくれるシンプルなエージェントを実装する方法

# はじめに

[前回の記事](https://qiita.com/a01dm/items/3639ba61adae98ffc713) では、LLM や AI エージェントについての概要と、会話履歴を踏まえて対話できるチャットボットの実装方法を紹介しました。

チャットボットは LangChain などのフレームワークを使用せず、OpenAI API のみを活用して実装を行いました。会話履歴の保持には、ユーザーとアシスタント (LLM) の書いたメッセージを全て保存して毎回 LLM のリクエストに含めることで実現しました。

https://qiita.com/a01dm/items/3639ba61adae98ffc713

今回の記事では、前回の実装を発展させて、フレームワークなしでツール呼び出しを実装する方法についてみていきます。

# そもそもツールとは？

LLM はテキスト（または最近だと画像や音声、動画など）を介して対話を行うことができますが、世の中のタスクにはそれだけでは完結しないものも多いです。

例えば、あくまで一例ですが、以下のようなタスクを行いたい場合は、純粋な LLM だけでは行うことができません。

- LLM の学習データに含まれていない知識を活用した対話
  - 昨日のニュースのハイライトを教えてもらう[^1]
  - 社内の機密情報を元にした質疑応答を行う[^2]
- アクションを起こす
  - メールを送信する
  - Slack にメッセージを投稿する
  - ブラウザの操作を行い予約サイトで予約をとる
- データ分析を行う
  - テーブルデータに対してクエリを実行する
  - テーブルデータの集計・可視化を行う
  - 機械学習モデルを実行して予測を行う

このような LLM だけでは完了することのできないタスクを実施するために必要になってくるのが「ツール」という概念です。

ツールは実態としてはプログラミング言語の関数です。LLM はユーザーからの質問に加えて使用可能なツール一覧を入力として受け取り、回答を直接生成するかツールを実行した上で何らかの情報を取得したりアクションを起こしたりするかを選択することができます。

OpenAI の場合は [Function calling](https://platform.openai.com/docs/guides/function-calling) という仕組みを活用すると、このツールの機能を実現することができます。次節以降、具体的にどのようなコードでツール呼び出しを行えるかを確認していきます。

# 検証環境について

前回同様、本記事に記載されているソースコードは Python 3.12.0 環境で検証を行いました。

LLM は OpenAI の `gpt-4o-mini` を使用し、Python のライブラリは以下を使用しています。

```plaintext
duckduckgo-search==7.2.1
openai==1.59.8
```

本記事で示すサンプルコードは、すべて環境変数に `OPENAI_API_KEY` が正しく設定されている前提で記載します。

また、実際に使用したソースコード全体は以下の GitHub レポジトリで公開しています。

https://github.com/kyohei3/ai-agent-without-framework

# OpenAI API でのツールの呼び出し方法

## ツール呼び出しの簡単な例

まずは前回の復習です。Python の `openai` ライブラリを使用し、`chat.completions.create()` を呼び出すことで LLM からの回答を取得していました。

```python
import openai

client = openai.OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "あなたは優秀なアシスタントです。"},
        {
            "role": "user",
            "content": "しりとりをしましょう",
        },
    ],
)

print(completion.choices[0].message.content)
# いいですね！しりとりを始めましょう。私が「りんご」と言います。それでは、あなたの番です！
```

ユーザーの入力は以下のように `user` というロールを持つ辞書 (dict) として渡していました。

```python
{
    "role": "user",
    "content": "しりとりをしましょう",
}
```

ツールの呼び出し機能を使用する場合は、この例と同様に `messages` にユーザーのメッセージを渡すのに加えて、ツール呼び出しに関する情報も渡していきます。例えば、現在のドル円の為替レートを取得する API を呼び出して活用できるツールを渡す場合は、以下のようなコードになります。

```python
from pprint import pprint

import openai

client = openai.OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "あなたは優秀なアシスタントです。"},
        {
            "role": "user",
            "content": "現在のドル円の為替レートを教えてください。",
        },
    ],
    # [1] LLM が使用するツール（関数）を定義
    tools=[
        {
            "type": "function",
            "function": {
                # [2] 関数名と説明の定義
                "name": "get_currency_rate",
                "description": "Get the current rate for a currency pair.",
                # [3] 関数の引数の定義
                "parameters": {
                    "type": "object",
                    "properties": {
                        # "currency_pair" という文字列型の引数を定義。
                        # USD/JPY や EUR/USD などの通貨ペアを指定する。
                        "currency_pair": {
                            "type": "string",
                            "description": "The currency pair to get the rate for. For example, 'EUR/USD'.",
                        },
                    },
                    # 必須の引数があればここで指定
                    "required": ["currency_pair"],
                },
            },
        }
    ],
)

pprint(completion.choices[0].message.model_dump())

```

"為替レートを取得する" というツールを、`tools` というパラメータにて LLM に渡しています。渡す値の詳細は [API Reference](https://platform.openai.com/docs/api-reference/chat/create) を見ていただくとして、概要は以下の通りです。

1. `tools` に LLM が呼び出すことのできるツール（関数）の一覧を渡している部分
1. 関数名や関数の説明も記載して渡す部分
    - LLM がツールを呼び出すかどうかは、それまでの会話内容とここで定義されるツールの説明等を踏まえて決定されます
    - 今回は `get_currency_rate` という為替レートを取得するツール（関数）を定義しています
1. 関数に渡す引数の定義
    - 関数が引数を必要とする場合は、その型や説明を定義します
    - 今回の場合は通貨ペア ( `USD/JPY` など) を受け取るための `curency_pair` という引数を定義しています

上記のコードを実行すると以下のような出力になります。

```python
{'audio': None,
 'content': None,
 'function_call': None,
 'refusal': None,
 'role': 'assistant',
 'tool_calls': [{'function': {'arguments': '{"currency_pair":"USD/JPY"}',
                              'name': 'get_currency_rate'},
                 'id': 'call_Km9tnayyJ62sO2rkR68XiZat',
                 'type': 'function'}]}
```

テキストで返信が返ってくるときは `content` に返信が含まれていましたが、今回は `content` の中身は `None` となっています。代わりに `tool_calls` に値が埋まっている様子が確認できます。この `tool_calls` が、LLM がツールを呼び出すと判断したときに返される値です。

`tool_calls` の中身で大事なところは以下の通りです。

- 要素数が1のリストが返ってきている（つまり、LLM は1つだけツールを呼び出そうとしている）
- そのツールは関数で、関数名は `get_currency_rate` である
- `currency_pair` に`USD/JPY` という値を渡そうとしている

以上がツール呼び出しの概要となります。

## ツールが呼び出された際の処理は自分で実装する必要がある

これまで見てきた通り、ツール呼び出しといっても、LLM が実際に為替レートの API に対して直接リクエストを投げてくれるわけではありません。

LLM がツールを呼び出すと判断した際には、LLM の判断に従って実際にツールの実行 (API へのリクエスト送信やコードの実行など) を行う部分は我々エンジニアが実装する必要があります。

そして我々が書いたコードでツールを実行した結果を LLM に返して、LLM にもう一度判断を仰ぎます。

例えば、為替レートを取得する API を呼び出して、ドル円のレートが `156.1` と帰ってきたとしましょう。その結果を LLM に入力するには以下のようなコードを書きます。

```python
from pprint import pprint

import openai

client = openai.OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "あなたは優秀なアシスタントです。"},
        # 元のユーザーメッセージ
        {
            "role": "user",
            "content": "現在のドル円の為替レートを教えてください。",
        },
        # LLM のツールを呼び出すという判断
        {
            "role": "assistant",
            "tool_calls": [
                {
                    "function": {
                        "arguments": '{"currency_pair":"USD/JPY"}',
                        "name": "get_currency_rate",
                    },
                    "id": "call_Km9tnayyJ62sO2rkR68XiZat",
                    "type": "function",
                }
            ],
        },
        # 指示に従ってツールを実行した結果を渡す
        {
            "role": "tool",
            "tool_call_id": "call_Km9tnayyJ62sO2rkR68XiZat",
            "content": "156.1円",
        },
    ],
    # LLM に与えるツールは先ほどと同じ（省略）
    tools=[...],
)

pprint(completion.choices[0].message.model_dump())
# {'audio': None,
#  'content': '現在のドル円の為替レートは156.1円です。',
#  'function_call': None,
#  'refusal': None,
#  'role': 'assistant',
#  'tool_calls': None}
```

前回みたように、LLM には過去の会話履歴をすべて渡す必要がありました。今回は会話ではないですが、LLM が `get_currency_rate` を呼び出すと判断した際のメッセージと、それに対するツールの実行結果を会話履歴として LLM に渡しています。

すると LLM はツールの実行結果を正しく解釈し、最終的に「現在のドル円の為替レートは156.1円です。」という回答を生成してくれました。

<details>
<summary>[参考] tools の省略なしのサンプルコード</summary>

```python
from pprint import pprint

import openai

client = openai.OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "あなたは優秀なアシスタントです。"},
        {
            "role": "user",
            "content": "現在のドル円の為替レートを教えてください。",
        },
        {
            "role": "assistant",
            "tool_calls": [
                {
                    "function": {
                        "arguments": '{"currency_pair":"USD/JPY"}',
                        "name": "get_currency_rate",
                    },
                    "id": "call_Km9tnayyJ62sO2rkR68XiZat",
                    "type": "function",
                }
            ],
        },
        {
            "role": "tool",
            "tool_call_id": "call_Km9tnayyJ62sO2rkR68XiZat",
            "content": "156.1円",
        },
    ],
    # [1] LLM が使用するツール（関数）を定義
    tools=[
        {
            "type": "function",
            "function": {
                # [2] 関数名と説明の定義
                "name": "get_currency_rate",
                "description": "Get the current rate for a currency pair.",
                # [3] 関数の引数の定義
                "parameters": {
                    "type": "object",
                    "properties": {
                        # "currency_pair" という文字列型の引数を定義。
                        # USD/JPY や EUR/USD などの通貨ペアを指定する。
                        "currency_pair": {
                            "type": "string",
                            "description": "The currency pair to get the rate for. For example, 'EUR/USD'.",
                        },
                    },
                    # 必須の引数があればここで指定
                    "required": ["currency_pair"],
                },
            },
        }
    ],
)

pprint(completion.choices[0].message.model_dump())

```

</details>


# シンプルなエージェントの実装

ここまででツール呼び出しを行う方法は理解できました。次はツール呼び出しを活用して、単純なエージェントを実装してみます。

## 検索エンジンを使って調べ物を手伝ってくれるエージェント (RAG)

今回実装するのは、検索エンジンを用いて調べ物を手伝ってくれるエージェントです。LLM の知識のみでは回答できない最近のニュースや出来事について、検索エンジンを駆使して情報を取得し、その情報を踏まえて回答を生成してくれます。

このような、検索を活用して取得した情報を使った回答生成は RAG (Retrieval Augmented Generation) として広く知られています[^3]。企業内の機密情報等も踏まえた回答が作成できるなどのメリットから、2024年ごろからビジネス界隈でも RAG という言葉を耳にしたことがある方もいらっしゃるのではないでしょうか。

## 前回の実装の復習

前回は以下のような SimpleChatbot という LLM と対話するためのクラスを実装しました。今回はこの SimpleChatbot の実装を発展させ、検索エンジンをツールとして駆使してユーザーの質問に回答してくれるシンプルなエージェントを実装してみます。

SimpleChatbot の実装については前回の記事を参照していただくか、以下に再掲するコードを参考にしてください。

<details><summary>[参考] 前回の SimpleChatbot の実装</summary>

```python:simple_chatbot.py
class SimpleChatbot:
    """会話履歴を保持してマルチターンの対話を行うチャットボット"""

    def __init__(self, system_prompt: str) -> None:
        self._client = openai.OpenAI()
        self._system_message: ChatCompletionMessageParam = {
            "role": "system",
            "content": system_prompt,
        }
        self._message_history: list[ChatCompletionMessageParam] = []

    def _get_response(self, user_query: str) -> str | None:
        """ユーザーからメッセージを受け取り、LLM により回答を生成する関数"""

        # ユーザの入力を message_history に追加
        user_message: ChatCompletionMessageParam = {
            "role": "user",
            "content": user_query,
        }
        self._message_history.append(user_message)

        # LLM を呼び出して回答を得る
        completion = self._client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[self._system_message, *self._message_history],
        )

        # LLM からの回答を message_history に追加
        assistant_response = completion.choices[0].message
        if assistant_response.content:
            self._message_history.append(
                {"role": "assistant", "content": assistant_response.content}
            )

        return completion.choices[0].message.content

    def run(self) -> None:
        """チャットボットとの対話を開始"""
        while True:
            try:
                # ユーザーからの入力を待ち、LLM に問い合わせて回答を得る
                user_query = input("ユーザ: ")
                response = self._get_response(user_query)
                print(f"アシスタント: {response}")
            except KeyboardInterrupt:
                # Ctrl-C で終了
                break
```

</details>

## エージェントの状態遷移

今回実装するエージェントでは、以下のように内部状態を遷移させます。

```mermaid
graph LR
  Start([Start])-->|ユーザーメッセージ入力| A{LLM_CALL: 回答に必要な情報が揃っているか？}
  A--> |No: ツール呼び出し| B[TOOL_RUN: ツールの実行]
  B-->A
  A--> |Yes: 回答生成| End([End])
```

まずユーザーからの入力を受け取ると Start から始まります。Start 直後にユーザーからの入力を受け取りそれを LLM に渡します。図でも分岐しているように、LLM のレスポンスによってその後の振る舞いが分岐します。

ユーザーの質問が検索エンジンを必要としないものや、すでに検索エンジンから回答に必要な情報な得られている状態であれば、エージェントは Yes のパスをたどって回答の生成を行います。そして回答を生成しユーザに提示して動作を完了します。

ユーザーの質問に回答するために情報が足りない場合は、Noのパスをたどってツール呼び出しを行います。その後、ツールを実際に実行し、実行結果が揃った状態でもう一度LLMを呼び出します。

1回の検索で十分な情報が見つけられなかった場合は、ここで2回、3回と検索エンジンが呼ばれることになります。

この実装では、最初からコードで決められた道筋をだとって回答を生成するのではなく、LLM がツールの呼び出しが必要か、何回呼び出す必要があるのかを自律的に判断したうえで回答を生成しています。これは[前回の記事の「AI エージェントとは」](https://qiita.com/a01dm/items/3639ba61adae98ffc713#ai-%E3%82%A8%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%B3%E3%83%88%E3%81%A8%E3%81%AF) で紹介した定義と合致しており、今回の実装は単純なエージェントといえるのではないかと思います。

## エージェントの実装

それではいよいよエージェント部分を実装していきます。前回記事の `SimpleChatbot` の実装方針を踏襲しつつ、 `SimpleAgent` という新しいクラスを定義します。

### 状態遷移部分の実装

今回の実装では状態遷移の現在地を `State` という Enum を使って管理します。

`State` では状態遷移図に登場した4つの状態をそのまま定義します。

```python
import enum

@enum.unique
class State(enum.Enum):
    """エージェントの状態の一覧"""

    START = enum.auto()
    """ターンの開始"""

    LLM_CALL = enum.auto()
    """ LLM による回答生成またはツールの呼び出し"""

    TOOL_RUN = enum.auto()
    """ツールの実行"""

    END = enum.auto()
    """ターンの終了"""
```

### エージェントの状態の初期化

では `SimpleAgent` 本体の実装に取り掛かります。

システムプロンプトを受け取り保持する部分と、会話履歴を管理する`self._message_history` を定義している部分は前回と同様です。

新しく追加されているのは `self._state` という変数で、これは先ほど定義した `State` の値をとりエージェントの現在の状態を示すのに使用されます。

```python
import openai
from openai.types.chat.chat_completion_message_param import (
    ChatCompletionMessageParam,
)

class SimpleAgent:
    """必要に応じて検索エンジンを活用して回答してくれるAIエージェント"""

    def __init__(self, system_prompt: str) -> None:
        self._client = openai.OpenAI()
        self._system_message: ChatCompletionMessageParam = {
            "role": "system",
            "content": system_prompt,
        }
        self._message_history: list[ChatCompletionMessageParam] = []

        # エージェントの内部状態を管理する変数
        self._state: State = State.START
```

### LLM の呼び出し部分

以前の実装では LLM からの返答はテキスト形式の返答のみを想定していました。

今回はテキストで回答が生成されるパターンに加え、ツール呼び出しがレスポンスとして返ってくる可能性があります。

そこで、まずはツール呼び出しの回答を保持しておくための簡単なクラスを定義します。

```python
import pydantic

class ToolCall(pydantic.BaseModel):
    """LLM によるツール呼び出しの情報を保持するクラス"""

    id: str
    type: str
    function_name: str
    arguments: dict[str, Any]
```

メンバ変数として、先ほどツール呼び出しのレスポンスを確認した際に含まれていた要素である ID や関数名、引数などが含まれています。

LLM 呼び出しを行い返答を得る関数 `_get_response` は以下の方で定義します。

`def _get_response(self, user_query: str) -> str | list[ToolCall] | None`

テキストで回答が生成された場合は `str` 型の値を、ツール呼び出しが行われた場合は `list[ToolCall]` 型の値を返すことで LLM の返答の種別を区別できるようにします。

```python
class SimpleAgent:
    """必要に応じて検索エンジンを活用して回答してくれるAIエージェント"""

    ...

    def _get_response(self, user_query: str | None) -> str | list[ToolCall] | None:
        """ユーザーからメッセージを受け取り、LLM により回答を生成する関数

        Args:
            user_query (str | None): ユーザーからの入力

        Returns:
            str | list[ToolCall] | None: LLM による回答。回答がテキストの場合は str、
                ツール呼び出しの場合は ToolCall のリスト形式の値を返す。
        """

        # ユーザの入力を message_history に追加
        if user_query:
            user_message: ChatCompletionMessageParam = {
                "role": "user",
                "content": user_query,
            }
            self._message_history.append(user_message)

        # LLM を呼び出して回答を得る
        completion = self._client.chat.completions.create(
            model="gpt-4o",
            messages=[self._system_message, *self._message_history],
            tools=[
                {
                    "type": "function",
                    "function": {
                        "name": "search",
                        "description": (
                            "ウェブを検索し情報を取得するツール。"
                            "最近のニュースや出来事を参照する場合にはこのツールを使ってください。"
                        ),
                        "parameters": {
                            "type": "object",
                            "properties": {
                                "query": {
                                    "type": "string",
                                    "description": "検索クエリ。検索クエリにはユーザーが使用している言語と同じ言語を用いてください。",
                                },
                            },
                            "required": ["query"],
                        },
                    },
                }
            ],
        )

        # LLM からの回答を message_history に追加
        message_to_add: ChatCompletionAssistantMessageParam = {"role": "assistant"}
        assistant_response = completion.choices[0].message
        if assistant_response.content:
            message_to_add["content"] = assistant_response.content
        if assistant_response.tool_calls:
            message_to_add["tool_calls"] = [
                {
                    "id": t.id,
                    "type": t.type,
                    "function": {
                        "name": t.function.name,
                        "arguments": t.function.arguments,
                    },
                }
                for t in assistant_response.tool_calls
            ]
        self._message_history.append(message_to_add)

        # ツール呼び出しがあれば、呼び出し情報をToolCall のリストに変換して返す
        if assistant_response.tool_calls:
            result: list[ToolCall] = []
            for tool_call in assistant_response.tool_calls:
                result.append(
                    ToolCall(
                        id=tool_call.id,
                        type=tool_call.type,
                        function_name=tool_call.function.name,
                        arguments=json.loads(tool_call.function.arguments),
                    )
                )
            return result

        # ツール呼び出しがなければ、レスポンスのテキストをそのまま返す
        return completion.choices[0].message.content or ""
```

### ツール（検索エンジン）の実行部分の実装

次はツールの実行部分です。今回は検索エンジンのみをツールとして持たせるので、それ以外のツールが選択された場合は例外を投げます。

検索は [DuckDuckGo](https://duckduckgo.com/) という検索エンジンを使用します。この検索エンジンは登録等が不要な API を提供しており、非常に便利です。

使い方も簡単で、基本的には検索キーワードを指定して関数を呼び出すだけで、検索上位にヒットしたページのタイトル、スニペット、URLが取得できます。

例えば、`AIエージェント フレームワーク` と検索して上位2件を取得したい場合は下記のようなコードになります。

```python
from pprint import pprint

import duckduckgo_search

with duckduckgo_search.DDGS() as ddgs:
    results = ddgs.text(
        keywords="AIエージェント フレームワーク",
        region="jp-jp",
        max_results=2,
    )
    pprint(results)
```

そして実行結果は以下の通りです。 `body`, `href`, `title` をキーにもつ辞書のリストが返ってきます。

```python
[{'body': 'こんにちは、みなさん!今回は、AI開発の未来を支える5つのマルチエージェントAIフレームワークをご紹介します!これらのフレームワークを使えば、複雑なタスクを簡単に自動化し、独自のAIエージェントを作成することができます。これらのフレームワークは、特に企業の自動化 '
          '...',
  'href': 'https://note.com/life_to_ai/n/n2240787f4d79',
  'title': '【マルチエージェントaiフレームワーク ベスト5選】 ｜-d-'},
 {'body': 'The Importance of AI Agent Frameworks. AI agent frameworks play a '
          'crucial role in advancing the field of artificial intelligence for '
          'several reasons: Accelerated Development: By providing pre-built '
          'components and best practices, these frameworks significantly '
          'reduce the time and effort required to create sophisticated AI '
          'agents.',
  'href': 'https://www.analyticsvidhya.com/blog/2024/07/ai-agent-frameworks/',
  'title': 'Top 5 Frameworks for Building AI Agents in 2025'}]
```

この API を活用すると、検索を実行して結果を取得する部分は以下のように簡単に記述できます。

```python
class SimpleAgent:
    """必要に応じて検索エンジンを活用して回答してくれるAIエージェント"""

    ...

    def _run_tool(self, tool_call: ToolCall) -> str:
        """LLM の出力に従ってツールを実行する関数

        Args:
            tool_call (ToolCall): LLM が指定した呼び出すツールや引数などの情報

        Returns:
            str: ツールの実行結果
        """
        if tool_call.function_name != "search":
            raise ValueError(f"Unsupported tool: {tool_call.function_name}")

        if "query" not in tool_call.arguments:
            raise ValueError("query argument is required for search tool.")

        # ツールの実行状況をユーザーに通知
        print(f"ツール呼び出し: {tool_call.function_name}({tool_call.arguments})")

        with duckduckgo_search.DDGS() as ddgs:
            results = ddgs.text(
                keywords=tool_call.arguments["query"],
                region="jp-jp",
            )

            # 検索結果を文字列に変換し、会話履歴に追加する
            tool_response = "\n\n".join(
                f"Title: {d['title']}\nURL: {d['href']}\nBody: {d['body']}"
                for d in results
            )
            self._message_history.append(
                {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": tool_response,
                }
            )
            return tool_response
```

上記の通り実装は単純で、検索結果を取得したら、それを文字列に成形したのち、検索結果を `tool` ロールのメッセージとして会話履歴に追加しています。

### 状態遷移部分の実装

さて、これまでの実装で LLM の呼び出しとツールを実行という今回のエージェントに必要なパーツが揃いました。いよいよ本丸の状態遷移部分を実装していきます。

前回の単純な会話を行う `SimpleChatbot` の際には、以下のような流れでメインループを回していました。

- ユーザーからの入力を受け取る
- LLM からの出力（回答）を画面に表示する


今回は状態を遷移させる必要があるので、1ループの中で回答生成まで行うのではなく、1つの状態に対応する処理のみを行います。つまり、ループ1回で回答生成の状態遷移1回分に相当する以下のような処理を行います。

- 現在の状態に応じた処理を行う
    - `START` であれば入力を受け取る
    - `LLM_CALL` であれば LLM を呼び出す
    - `TOOL_RUN` であればツールを実行する
    - `END` であれば結果を画面に表示する
- 次の状態に遷移する

方針が見えたところで、実際にコードを書いていきます。
ループの中で、`self._state` の値に応じて `match` で処理を分岐させ、各処理の最後には必ず次の遷移先の状態を定義するようにしています。

```python
class SimpleAgent:
    """必要に応じて検索エンジンを活用して回答してくれるAIエージェント"""

    ...
    
    def run(self) -> None:
        """チャットボットとの対話を開始"""
        user_query: str | None = None
        while True:
            try:
                match self._state:
                    case State.START:
                        # START 状態の場合はユーザーからの入力を受け取り、LLM_CALL 状態に遷移
                        user_query = input("ユーザ: ")
                        self._state = State.LLM_CALL

                    case State.LLM_CALL:
                        # LLM_CALL 状態では LLM のレスポンスに応じて遷移先の状態が変わる
                        response = self._get_response(user_query)
                        user_query = None

                        if isinstance(response, str):
                            # テキストの場合は END へ遷移
                            self._state = State.END

                        elif isinstance(response, list):
                            # ツール呼び出しの場合は TOOL_RUN へ遷移
                            self._state = State.TOOL_RUN

                    case State.TOOL_RUN:
                        if not isinstance(response, list):
                            raise ValueError("response must be a list of ToolCall.")

                        # ツール呼び出しを実行・実行結果を履歴に保存したのち、再度 LLM_CALL 状態に遷移
                        for tool_call in response:
                            self._run_tool(tool_call)
                        self._state = State.LLM_CALL

                    case State.END:
                        if not isinstance(response, str):
                            raise ValueError("response must be a string.")

                        # LLM の回答表示して会話1往復が終了
                        # START 状態に戻り、ユーザーからの次の入力を待つ
                        print(f"アシスタント: {response}")
                        self._state = State.START

            except KeyboardInterrupt:
                # Ctrl-C で終了
                break
```

最初の状態遷移図には描いてないですが、`END` に到達したら次の会話のターンが始まるため再度 `START` 状態に遷移する実装になっています。

以上でエージェントのコード部分は実装完了です！

### システムプロンプトの定義

次にシステムプロンプトを定義していきます。まず最初に今回使用したシステムプロンプトの全文を紹介します。

```plaintext
あなたはユーザーの疑問に対して真摯に回答するアシスタントです。
ユーザーの質問に対してあなたのもつ事前知識で回答できる場合は、そのまま回答を生成してください。
直近のニュースや最近の出来事など、事前知識にない質問に対しては検索エンジンを活用して回答を生成してください。

回答の生成に検索結果を利用した場合は、必ず回答のその箇所に脚注を利用して参考にしたURL明示してください。
脚注は、本文中に [^1] のように数字を使って記述し、文末に以下のように実際のURLを記載してください。

[^1]: https:/...

また、現在の日付は2025-01-26です。
```

以下にポイントを2点紹介します。

#### ツール呼び出しを行う条件を指定

LLM に検索エンジンを利用する必要があるか、それとも現在揃っている状態だけで回答を生成できるか判断させるためのプロンプトがこちらです。どのような場合に検索エンジンを利用してほしいかを指示に含めています。

```plaintext
ユーザーの質問に対してあなたのもつ事前知識で回答できる場合は、そのまま回答を生成してください。
直近のニュースや最近の出来事など、事前知識にない質問に対しては検索エンジンを活用して回答を生成してください。
```

### 出典の明記

以下の文言で、出典を明記するように指示しています。

```plaintext
回答の生成に検索結果を利用した場合は、必ず回答のその箇所に脚注を利用して参考にしたURL明示してください。
脚注は、本文中に [^1] のように数字を使って記述し、文末に以下のように実際のURLを記載してください。

[^1]: https:/...
```

出典にはマークダウンの脚注の機能[^4]を利用して参照元のURLを明記するように指示しています。

### 動作例

以上にて、今回のエージェントのコードとプロンプトの実装が完了しました。先週トランプ大統領の就任とともに発表されたスターゲートプロジェクトについて、早速聞いてみましょう。冒頭で説明した通り、LLM は直近の出来事などについては知識を持っていないため、検索エンジンのツールなしでは答えることができないトピックです。

```markdown
ユーザ: 先週トランプ政権が発表したスターゲートプロジェクトの概要は？
ツール呼び出し: search({'query': 'トランプ政権 スターゲートプロジェクト 概要 2025年'})
アシスタント: トランプ政権が2025年1月21日に発表した「スターゲートプロジェクト」は、人工知能（AI）インフラを整備するための巨額な投資計画です。このプロジェクトは、ソフトバンクグループ、オープンAI、オラクルの3社による共同事業で、今後4年間で米国に5000億ドル（約78兆円）を投資することが予定されています。

この取り組みの目的は、AI関連のインフラを強化し、アメリカの経済を活性化させることです。トランプ大統領は、このプロジェクトが自国のテクノロジー分野において重要な役割を果たすと期待を寄せており、具体的にはデータセンターの建設などを通じて、段階的に投資を進める意向を示しています[^1][^2][^3]。

[^1]: https://www.ioi-life.co.jp/finance/7881/  
[^2]: https://www.newsweekjapan.jp/stories/world/2025/01/533749.php  
[^3]: https://www.fnn.jp/articles/-/817931
```

途中で「トランプ政権 スターゲートプロジェクト 概要 2025年」というクエリで検索したうえで、最終的に3つのページを参照して回答を生成してくれました。お見事ですね！

スターゲートプロジェクトの例では検索は1回しか行われませんでしたが、検索エンジンを複数回駆使する必要のある問いにも回答することができます。以下はその一例です。

```markdown
ユーザ:  昨年度ナショナルリーグでMVPをとった選手の妻の名前を教えてください。
ツール呼び出し: search({'query': '2024年 ナショナルリーグ MVP'})
ツール呼び出し: search({'query': '大谷翔平 妻 名前'})
アシスタント: 2024年のナショナルリーグのMVPは大谷翔平選手です。彼の妻の名前は田中真美子さんです[^1][^2]。

[^1]: https://conponx2.com/otani-mamiko-wife/
[^2]: https://shoutaimuzu.com/shoheiohtani-tumapurofiru/
```

1回目の検索で2024年のMVPの選手を検索しその選手とは大谷翔平であることを特定し、2回目の検索で大谷翔平の妻は誰かを検索エンジンで調べています。そして回答もばっちり正解しています。お見事ですね！

### 全体のソースコード

動く状態のソースコード全体は以下の通りです。

`OPENAI_API_KEY` という環境変数に OpenAI API の有効なキーが設定されている必要があることに注意してください。

<details><summary>simple_agent.py</summary>

```python:simple_agent.py
import enum
import json
from typing import Any

import duckduckgo_search
import openai
import pydantic
from openai.types.chat.chat_completion_message_param import (
    ChatCompletionAssistantMessageParam,
    ChatCompletionMessageParam,
)

_SYSTEM_PROMPT = """あなたはユーザーの疑問に対して真摯に回答するアシスタントです。
ユーザーの質問に対してあなたのもつ事前知識で回答できる場合は、そのまま回答を生成してください。
直近のニュースや最近の出来事など、事前知識にない質問に対しては検索エンジンを活用して回答を生成してください。

回答の生成に検索結果を利用した場合は、必ず回答のその箇所に脚注を利用して参考にしたURL明示してください。
脚注は、本文中に [^1] のように数字を使って記述し、文末に以下のように実際のURLを記載してください。

[^1]: https:/...

また、現在の日付は2025-01-26です。
"""


@enum.unique
class State(enum.Enum):
    """エージェントの状態の一覧"""

    START = enum.auto()
    """ターンの開始"""

    LLM_CALL = enum.auto()
    """ LLM による回答生成またはツールの呼び出し"""

    TOOL_RUN = enum.auto()
    """ツールの実行"""

    END = enum.auto()
    """ターンの終了"""


class ToolCall(pydantic.BaseModel):
    """LLM によるツール呼び出しの情報を保持するクラス"""

    id: str
    type: str
    function_name: str
    arguments: dict[str, Any]


class SimpleAgent:
    """必要に応じて検索エンジンを活用して回答してくれるAIエージェント"""

    def __init__(self, system_prompt: str) -> None:
        self._client = openai.OpenAI()
        self._system_message: ChatCompletionMessageParam = {
            "role": "system",
            "content": system_prompt,
        }
        self._message_history: list[ChatCompletionMessageParam] = []

        # エージェントの内部状態を管理する変数
        self._state: State = State.START

    def _get_response(self, user_query: str | None) -> str | list[ToolCall] | None:
        """ユーザーからメッセージを受け取り、LLM により回答を生成する関数

        Args:
            user_query (str | None): ユーザーからの入力

        Returns:
            str | list[ToolCall] | None: LLM による回答。回答がテキストの場合は str、
                ツール呼び出しの場合は ToolCall のリスト形式の値を返す。
        """

        # ユーザの入力を message_history に追加
        if user_query:
            user_message: ChatCompletionMessageParam = {
                "role": "user",
                "content": user_query,
            }
            self._message_history.append(user_message)

        # LLM を呼び出して回答を得る
        completion = self._client.chat.completions.create(
            model="gpt-4o",
            messages=[self._system_message, *self._message_history],
            tools=[
                {
                    "type": "function",
                    "function": {
                        "name": "search",
                        "description": (
                            "ウェブを検索し情報を取得するツール。"
                            "最近のニュースや出来事を参照する場合にはこのツールを使ってください。"
                        ),
                        "parameters": {
                            "type": "object",
                            "properties": {
                                "query": {
                                    "type": "string",
                                    "description": "検索クエリ。検索クエリにはユーザーが使用している言語と同じ言語を用いてください。",
                                },
                            },
                            "required": ["query"],
                        },
                    },
                }
            ],
        )

        # LLM からの回答を message_history に追加
        message_to_add: ChatCompletionAssistantMessageParam = {"role": "assistant"}
        assistant_response = completion.choices[0].message
        if assistant_response.content:
            message_to_add["content"] = assistant_response.content
        if assistant_response.tool_calls:
            message_to_add["tool_calls"] = [
                {
                    "id": t.id,
                    "type": t.type,
                    "function": {
                        "name": t.function.name,
                        "arguments": t.function.arguments,
                    },
                }
                for t in assistant_response.tool_calls
            ]
        self._message_history.append(message_to_add)

        # ツール呼び出しがあれば、呼び出し情報をToolCall のリストに変換して返す
        if assistant_response.tool_calls:
            result: list[ToolCall] = []
            for tool_call in assistant_response.tool_calls:
                result.append(
                    ToolCall(
                        id=tool_call.id,
                        type=tool_call.type,
                        function_name=tool_call.function.name,
                        arguments=json.loads(tool_call.function.arguments),
                    )
                )
            return result

        # ツール呼び出しがなければ、レスポンスのテキストをそのまま返す
        return completion.choices[0].message.content or ""

    def _run_tool(self, tool_call: ToolCall) -> str:
        """LLM の出力に従ってツールを実行する関数

        Args:
            tool_call (ToolCall): LLM が指定した呼び出すツールや引数などの情報

        Returns:
            str: ツールの実行結果
        """
        if tool_call.function_name != "search":
            raise ValueError(f"Unsupported tool: {tool_call.function_name}")

        if "query" not in tool_call.arguments:
            raise ValueError("query argument is required for search tool.")

        # ツールの実行状況をユーザーに通知
        print(f"ツール呼び出し: {tool_call.function_name}({tool_call.arguments})")

        with duckduckgo_search.DDGS() as ddgs:
            results = ddgs.text(
                keywords=tool_call.arguments["query"],
                region="jp-jp",
            )

            # 検索結果を文字列に変換し、会話履歴に追加する
            tool_response = "\n\n".join(
                f"Title: {d['title']}\nURL: {d['href']}\nBody: {d['body']}"
                for d in results
            )
            self._message_history.append(
                {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": tool_response,
                }
            )
            return tool_response

    def run(self) -> None:
        """チャットボットとの対話を開始"""
        user_query: str | None = None
        while True:
            try:
                match self._state:
                    case State.START:
                        # START 状態の場合はユーザーからの入力を受け取り、LLM_CALL 状態に遷移
                        user_query = input("ユーザ: ")
                        self._state = State.LLM_CALL

                    case State.LLM_CALL:
                        # LLM_CALL 状態では LLM のレスポンスに応じて遷移先の状態が変わる
                        response = self._get_response(user_query)
                        user_query = None

                        if isinstance(response, str):
                            # テキストの場合は END へ遷移
                            self._state = State.END

                        elif isinstance(response, list):
                            # ツール呼び出しの場合は TOOL_RUN へ遷移
                            self._state = State.TOOL_RUN

                    case State.TOOL_RUN:
                        if not isinstance(response, list):
                            raise ValueError("response must be a list of ToolCall.")

                        # ツール呼び出しを実行・実行結果を履歴に保存したのち、再度 LLM_CALL 状態に遷移
                        for tool_call in response:
                            self._run_tool(tool_call)
                        self._state = State.LLM_CALL

                    case State.END:
                        if not isinstance(response, str):
                            raise ValueError("response must be a string.")

                        # LLM の回答表示して会話1往復が終了
                        # START 状態に戻り、ユーザーからの次の入力を待つ
                        print(f"アシスタント: {response}")
                        self._state = State.START

            except KeyboardInterrupt:
                # Ctrl-C で終了
                break


def main() -> None:
    agent = SimpleAgent(system_prompt=_SYSTEM_PROMPT)
    agent.run()


if __name__ == "__main__":
    main()
```

</details>

### サンプルコードについての補足

今回のサンプルコードは、あくまでエージェントの動作を簡単に示すことを目的としており、実用レベルに至るまでには多くの点が不足しています。以下はその一例です。もしこのコードを参考にしていただく際には、この点ご留意お願いします。

- エラーハンドリングが十分でない
- 会話履歴が長くなったり検索結果が多い場合にトークン数がLLMの入力上限を超える可能性がある
- ツール呼び出しはリクエストで指示してない形式にて返ってくることもあるが、その前提で LLM のレスポンスを検証する必要がある

# まとめ

今回の記事では、OpenAI API を活用したツールの呼び出し方法と、ツール呼び出しを活用したシンプルなエージェントの実装方法を見ていきました。

自律的に計画を立てて行動するエージェントと聞くと、最初は途方もなく実装が難しく思えますが、1つずつひも解いてみると実はそこまで複雑なことはしなくてよいことが理解してもらえたのではないかと思います。

今年数多く発表されるであろうAIエージェントのプロダクトやサービスを見たときに、その裏側で行われていることを少しでも想像・理解するためのきっかけになれば幸いです。

次回は Reflection などを導入したより複雑なエージェントを実装してみようと思います。

[^1]: LLM の学習データの収集は、当然ながら LLM 利用時よりも過去の時点で行われています。今日や昨日といった直近のデータは通常 LLM の学習データには含まれていないので、直近の知識を必要とする時事ネタなどは LLM は回答することができません。
[^2]: LLM の学習はインターネットに公開されているデータ等を用いて行われることが多いです。企業が機密情報として内部に保持している文書は当然 LLM の学習時には参照することができないので、各企業固有の情報を用いた対話は行うことができません。
[^3]: 世の中には文書をベクトル化して類似文書を探したり、Graph を構築して関連文書を探したりなど様々な RAG の手法が存在していますが、今回は検索エンジンを利用するというシンプルな手法を用います。
[^4]: この `[^4]` などで補足を表示する機能のことです