フレームワークなしで作って学ぶAIエージェント 〜その1：会話履歴の実装〜

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

- LLM と AI エージェントの概要
- OpenAI API で LLM から回答を得る基本的な方法
- 会話履歴を考慮した対話を行うチャットボットを LangChain などのフレームワークを使わずに構築する方法 

# なぜフレームワークなしで作るのか？

2025年1月現在、AIエージェントは大きく注目されています。「2025年はAIエージェントの年」といったような記事も最近よく目にします[^1]。

そして、AIエージェントを構築するためのフレームワークも LangChain, LangGraph, LlamaIndex, AutoGen, CrewAI, Swarm, PydanticAI などなど数多く出てきています。

これらのフレームワークは簡単にエージェントを構築できて非常に便利ですが、一方でプロンプトの詳細や内部でどのようにLLMを呼び出しているかが把握しづらくなってしまいます。

そこで本記事では、上述のようなAIエージェント構築のためのフレームワークを一切使わず、LLM呼び出しのAPIのみを使用してAIエージェントを作り、AIエージェントに対する理解を深めることを試みます。

# LLM と AI エージェントの概要

まずは AI エージェントにも使用されている技術である大規模言語モデル (Large Language Model, LLM) について、概要を復習します。これらについて概要を把握している方はこの章は読み飛ばしてください。

### 大規模言語モデル (LLM) とは

大規模言語モデル (以下、LLM) とは、文字通り言語モデルを大規模にしたものなので、言語モデルについて理解しておく必要があります。

言語モデル (Language Model) とは、文章の並び方に確率を割り当てる確率モデルです[^2] 。詳細は割愛しますが、例えば N-gram 言語モデルなどは LLM 登場以前から広く使われていました。

LLM は N-gram 言語モデルのような従来の言語モデルよりも、計算量、学習させるデータ量、モデルのパラメータ数が大規模になっているモデルで、従来の言語モデルよりも次の単語を予測する性能が非常に高くなっています。また、近年性能の良いモデルの多くは Transformer といわれるアーキテクチャを採用していることも特徴です。

従来の言語モデルや Transformer などについてはわかりやすい資料がすでに数多く存在しているので、そちらをご参照ください。例えば、以下のスライドなどは大変わかりやすくまとめられています。

https://speakerdeck.com/chokkan/20230327_riken_llm

厳密な定義ではないですが、ざっくりと、大量のデータを使って巨大なモデルを学習したら、文書が途中まで与えられたときに次の単語をすごく予測できる状態になったものが、大規模言語モデルだと理解しておけばひとまずよいかと思います。

## AI エージェントとは

厳密な定義ではないですが、AI エージェントとは、問題が与えられたときに**自律的に目標・道筋を立て**、その問題の解決までのアクションをとっていくシステムのことです。

自律的に目標や道筋を立てるというところが、従来の決まった処理を決まった順序で行うワークフローとの差になっており、重要なところです。

LLM 以前の技術では AI エージェントを実装するのは容易ではなかったですが、プロンプトを用いて適切な質問を用意すれば高い精度で回答を返してくれる昨今の LLM を活用すれば AI エージェントの実装や実用化に現実味が帯びてきた、というのが今の状況かと思います。

AIエージェントの定義に関しては、以下の記事などを参考にしました。

https://aws.amazon.com/jp/what-is/ai-agents/

https://www.salesforce.com/jp/agentforce/what-are-ai-agents/

https://www.anthropic.com/research/building-effective-agents

# 実装編

それでは前置きはこのくらいにして、実装に移っていきます。

いきなりAIエージェントを実装するのではなく、本記事では会話履歴を参照して対話が行えるシンプルなチャットボットの実装を行います。冒頭で述べたように、LangChainなどのフレームワークを使わずにチャットボットがどのように実装できるかを見ていきます。

## 前提

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

LLM は OpenAI の `gpt-4o-mini` を使用し、そのためのライブラリには以下を使用しました。

```plaintext
openai==1.59.8
```

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

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

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

## クエリに対する返答を取得する方法

まずは一番の基本である、ユーザーからのクエリに対してどのように返答を返すかを確認します。

Python の `openai` ライブラリを使用し、 `openai.OpenAI.chat.completion.create` を呼び出すことでクエリに対する返答を取得できます。例えば、以下のようなコードになります。

```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)
# ChatCompletionMessage(content='蛇口についた水垢を楽に落とす方法は...（以下省略)', refusal=None, role='assistant', audio=None, function_call=None, tool_calls=None)
```

`openai.OpenAI.chat.completion.create` の引数は2つで、1つ目の `model` は使用するモデルを指定しており、今回は安い `gpt-4o-mini` を指定しています。2つ目の引数 `content` はLLMに入力するメッセージです。

メッセージにはロール (`role`) という概念があります。ロールは文字通りメッセージの役割を表し、そのメッセージを誰が作成したものかを明確にするもので `system`, `user`, `assistant`, `tool` などの値をとります。

上記のコードでは、1つ目のメッセージは `system` ロールとして「あなたは優秀なアシスタントです。」としています。これはいわゆるシステムプロンプトというもので、LLMにどのようなふるまいをすべきかの指示を与えるために使われます。

```python
{"role": "system", "content": "あなたは優秀なアシスタントです。"}
```

2つ目のメッセージは `user` ロールで、こちらがユーザーの入力を想定したものです。上記の例では「蛇口についた水垢を楽に落とすにはどうすればよいですか？」といった質問にしています。

```python
{
    "role": "user",
    "content": "蛇口についた水垢を楽に落とすにはどうすればよいですか？",
}
```

この質問に対してLLMからの返答は `completion.choices[0].message` に格納されます。中身を見てみると `assistant` ロールで「蛇口についた水垢を楽に落とす方法は...」から始まる長い説明でした。この `assitant` ロールは LLM が生成したメッセージであることを意味します。

```python
print(completion.choices[0].message)
# ChatCompletionMessage(content='蛇口についた水垢を楽に落とす方法は...（以下省略)', refusal=None, role='assistant', audio=None, function_call=None, tool_calls=None)
```

この API に関する詳細は下記の公式ドキュメントを参照してください。

https://platform.openai.com/docs/api-reference/chat/create

## 会話履歴（コンテキスト）を保持した対話の実現方法

上記の例では LLM に対して1往復（ユーザーがメッセージを送り、LLM が回答を返す）のやり取りを行いました。ChatGPT 等のサービスのように、複数回（マルチターン）のやり取りはどのようにして実現するのでしょうか？

答えは、OpenAI をはじめとした LLM の API は基本的にステートレスなので、**過去の会話履歴を毎回最初から LLM に入力**してあげる必要があります。

しりとりを例にして説明します。先ほどの例と同様に「しりとりをしましょう」と 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)
# いいですね！しりとりを始めましょう。私が「りんご」と言います。それでは、あなたの番です！
```

LLM は「りんご」と言ってきました。これにユーザーが「ゴリラ」と回答するには、以下のようなメッセージを LLM に送る必要があります。

```python
import openai

client = openai.OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "あなたは優秀なアシスタントです。"},
        {
            "role": "user",
            "content": "しりとりをしましょう",
        },
        {
            "role": "assistant",
            "content": "いいですね！しりとりを始めましょう。私が「りんご」と言います。それでは、あなたの番です！",
        },
        {
            "role": "user",
            "content": "ゴリラ",
        },
    ],
)

print(completion.choices[0].message.content)
# 「ゴリラ」ですね！では、「ラーメン」と言います。あなたの番です！
```

しりとりが終わってしまった・・・！[^3]というのはありますが、このように今までの一連の会話履歴を入力すると、LLM はそれらの流れを踏まえた上での回答を返してくれます。

このように、それまでの会話履歴を保存しておき、毎回最初から LLM に入力するような仕組みを用意すれば、チャットボットが実装できそうです。

### チャットボットの実装

それでは、これまで見てきた仕組みを `SimpleChatbot` という名前のクラスで実装してみます。

```python:simple_chatbot.py
import openai
from openai.types.chat.chat_completion_message_param import (
    ChatCompletionMessageParam,

)
client = openai.OpenAI()


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

コメントは付けましたが、ポイントは以下の2点かと思います。

1. `self._message_history` という変数を用意し、この変数にこれまでの会話履歴をすべて保存するようにする
1. `self._get_response()` 内では、ユーザーからメッセージを受け取ったり、LLM が返答を生成するたびに `self._message_history` に追加して会話履歴を常に最新に保つ

### 実行例

上記の `SimpleChatbot` を例えば以下のような形で呼び出すと、チャットボットを実行することができます。

```python
chatbot = SimpleChatbot("あなたは優秀なアシスタントです。")
chatbot.run()
```

先ほどのしりとりを再現するようなユーザーメッセージを送ると、以下のような結果になります。「ユーザー：」に続く部分が私が入力した文字で、「アシスタント：」に続く文字が LLM が生成した回答です。

```plaintext
ユーザ: しりとりしよう
アシスタント: いいですよ！私から始めますね。「りんご」です。あなたの番です！
ユーザ: ゴリラ
アシスタント: 「ゴリラ」の「ラ」ですね。次は「ラーメン」で！あなたの番です！
ユーザ: ^C
```

確かに、先ほどと同じような会話の流れになってしりとりが無残にも終了している様子が確認できます。

省略のない完全なソースコードは以下の場所にて確認できます。

https://github.com/kyohei3/ai-agent-without-framework/blob/main/simple_chatbot.py

### 補足：会話履歴が長くなったら場合の対応

LLM には無限の長さのテキストを入力できるわけではありません。LLM では通常トークンと呼ばれる単位[^4] に分割してテキストを処理していますが、どの LLM にも入出力できるトークン数の上限が存在します。

例えば [GPT-4o mini](https://platform.openai.com/docs/models#gpt-4o-mini) の場合は、2025/01/20 現在で 128,000 トークンが入力の上限となっています。この上限は非常に大きいですが、会話が長くなり上限を超えそうになったら対応する必要があります。


この際にどのように入力のトークン数を減らすかという問題がありますが、ここで LangChain などのライブラリの実装が参考になります。直近Nトークンのみを入力する、直近Nターンの会話のみを入力する、過去のやり取りを要約してトークン数を減らす、など様々なアプローチがあります。

詳細は以下のページ等を参考にしてください。

https://python.langchain.com/docs/how_to/trim_messages/

https://langchain-ai.github.io/langgraph/concepts/memory/

# まとめ

今回は導入偏として、以下の内容を紹介しました。

- LLM や AI エージェントの概要
- OpenAI API を呼び出してユーザークエリに対する返答を得る方法
- 過去の会話履歴を保持したチャットボットを構築する方法

今までブラックボックスだったチャットボットの裏側が、具体的に理解できるようになったのではないでしょうか。

次回は LLM を活用したツールの呼び出しと、ツール呼び出しを活用した簡単なエージェントの実装をどのように行うかを紹介します。

次回記事：

https://qiita.com/a01dm/items/cf933b3ce307a21112c1

[^1]: 無数にありますが、例えば https://cloud.google.com/blog/ja/products/gcp/2025-new-year-message など。
[^2]: 定義は https://www.nri.com/jp/knowledge/glossary/llm.html より引用
[^3]: このしりとりの例もそうですが、軽量で低コストの `gpt-4o-mini` を使っているので、このように返答はあまり賢くないことが良くあります。ちなみに、私の手元では何度か試しても頑なにラーメンと返してくるので笑ってしまいました。
[^4]: トークンになじみのない方は、正確ではないですが人間が普段使う「単語」を想像してもらうと良いです