AIエージェントを解き明かす:純粋なPythonだけでゼロからエージェンティック・パイプラインを構築する

Dev.to / 2026/5/21

💬 オピニオンDeveloper Stack & InfrastructureIdeas & Deep AnalysisTools & Practical Usage

要点

  • この記事では、多くの「AIエージェント」チュートリアルが不思議に感じられるのは、人気フレームワークが基盤となる実行メカニズムを高レベルの抽象化で隠しているためだと主張しています。
  • 主要なエージェントフレームワークは、プロンプトのオーケストレーション、状態を持つメモリ、ツール実行、制御ループ、構造化された出力といった少数の中核プリミティブに依存していることを説明します。
  • 標準ライブラリのみ、ネイティブHTTPリクエスト、SDKやオーケストレーションフレームワークなしで、純粋なPythonからエージェンティック・パイプラインをゼロから作る方針を提示します。
  • 通常の単発のLLM対話(1つのプロンプトから1つの応答)と、think/decide/act/observeの継続的な実行ループに基づくエージェント的な振る舞いを対比しています。

ほとんどのAIデモは、簡単な質問をしてみるまで見栄えがします。たとえば:実際に裏側で何が起きているのでしょうか?

LangChain、CrewAI、Microsoft AutoGenのようなフレームワークは、数行のコードで「AIエージェント」をすぐに立ち上げられるようにしてくれます。ですが、抽象化には代償があります。多くの開発者は、裏でそれらを動かしている実行時アーキテクチャを十分に理解しないまま、フレームワークを使ってエージェントを構築できます。

本質的には、ほとんどのエージェントフレームワークは驚くほど単純なプリミティブに基づいて作られています:

  • プロンプトのオーケストレーション
  • 状態を持つメモリ
  • ツールの実行
  • 制御ループ
  • 構造化された出力

今週、私は「AIエージェントが実際には裏側でどう動いているのか」を理解したい友人と話していました。その会話の中で気づいたことがあります:ほとんどのチュートリアルは、AIエージェントを、実際よりもはるかに神秘的に感じさせている。

フレームワークは開発を高速化するのに優れていますが、同時に多くの中核メカニズムを抽象化の層の奥に隠してしまいます。ライブラリを読み込み、「エージェント」を初期化し、ツールをひもづけると、すべてが自律的で賢そうに見えてきます。しかし、その抽象化の下で、ほとんどのエージェントシステムは驚くほど少数の概念の集合に基づいて構築されています:

  • プロンプト
  • メモリ
  • ツールの実行
  • 構造化された出力
  • 制御ループ

そこで私は、エージェント型システムを最初に調べ始めたときに見つけたかった記事を書くことにしました。重たいフレームワークは使いません。オーケストレーション用のライブラリも使いません。隠れた実行時マジックもありません。純粋なPythonで、1つずつ手作業で組み上げる「コアの考え方」だけです。

この記事では、次のものだけを使って、抽象化を取り払い、最初から丸ごとプロダクションに近い発想のエージェント型パイプラインを構築します:

  • 純粋なPython
  • 標準ライブラリのみ
  • ネイティブなHTTPリクエスト
  • SDKは使わない
  • オーケストレーション用のフレームワークは使わない

最後には、現代のAIエージェントの背後にある中核メカニズムと、なぜほとんどのフレームワークが、要するに決定論的な実行ループの「層になった便利機能の抽象化」であるのかが理解できるようになります。

エージェント型パイプラインとは何ですか?

一般的なLLMとのやり取りは、通常は単発のトランザクションです:

ユーザープロンプト ──> モデルの応答

モデルは文脈を一度受け取り、静的な応答を生成します。ですが、エージェントはそれとは異なります。単一の応答を生成するのではなく、継続的な実行サイクルの中で振る舞います:

       ┌───────────────────────────────────────┐
       │                                       │
       ▼                                       │
[ THINK ] ───> (Decision) ───> [ ACT ] ───> [ OBSERVE ]
                               (Tool Call)   (Tool Result)

Think

モデルはユーザーの目的、利用可能なツール、これまでの観測、現在のメモリ状態を評価します。その後、次に何をするかを決めます。

Act

エージェントはアクションを実行します。これは、関数の呼び出し、データベースの問い合わせ、Web検索、ファイルの読み取り、あるいは最終回答の返却などでありえます。

Observe

システムはアクションの結果を取り込み、それをコンテキストウィンドウに戻します。目的が完了するまでサイクルを繰り返します。

役立つメンタルモデル

エージェントを、生産環境の問題をデバッグする開発者だと思ってください:

エラーのログを観測する
        │
        ▼
 仮説を立てる
        │
        ▼
 コマンドを実行する
        │
        ▼
 出力を確認する
        │
        ▼
    繰り返す

この反復的なフィードバックループこそが、エージェント型システムが動く仕組みそのものです。

プロジェクト構成

コードベースは、小さく要点を絞ったモジュールに整理します。

agentic-pipeline/
├── config.json       # 実行時設定
├── llm_client.py     # 低レベルのHTTPクライアント
├── memory.py         # コンテキスト/状態管理
├── agent.py          # エージェントのオーケストレーションエンジン
└── main.py           # 実行時の実行ループ

この分割は、プロダクションシステムでよく見られる構成の仕方と同じです。

Step 1 — 設定管理

実行時の変数をコード内に直接ハードコーディングするのは避けましょう。このデモでは、説明のためだけに config.json ファイルを作成します:

{
  "llm": {
    "provider": "openai",
    "model": "gpt-4o",
    "api_key": "sk-your-api-key",
    "temperature": 0.2,
    "max_tokens": 1024 
  }
}

⚠️ 注意:プロダクション環境では、資格情報(クレデンシャル)は静的な設定ファイルではなく、環境変数やシークレットマネージャから取得すべきです。

Step 2 — インフラストラクチャ層の構築

ほとんどのSDKは、LLMとのあらゆるやり取りが単なるHTTPリクエストに過ぎないという現実を隠してしまいます。抽象化の裏側では、処理は単純です:

ペイロードをシリアライズ ──> HTTPS POSTリクエストを送信 ──> JSON応答を受信 ──> 出力を解析

それを llm_client.py で手作業で実装してみましょう。

import json
import urllib.request
import urllib.error
from typing import Dict, Listclass LLMClient:
    def __init__(self, config: Dict):
        self.config = config["llm"]
        self.api_key = self.config["api_key"]

    def chat_completion(
        self,
        messages: List[Dict],
        temperature: float = None
    )-> str:
        payload = {
            "model": self.config["model"],
            "messages": messages,
            "temperature": temperature or self.config.get("temperature", 0.2),
            "max_tokens": self.config.get("max_tokens", 1024)
        }

        data = json.dumps(payload).encode("utf-8")

        req = urllib.request.Request(
            "[https://api.openai.com/v1/chat/completions](https://api.openai.com/v1/chat/completions)",
            data=data,
            method="POST"
        )

        req.add_header("Content-Type", "application/json")
        req.add_header("Authorization", f"Bearer {self.api_key}")

        try:
            with urllib.request.urlopen(req) as response:
                result = json.loads(response.read().decode())
                return result["choices"][0]["message"]["content"].strip()
        except urllib.error.HTTPError as e:
            error_body = e.read().decode()
            raise Exception(f"LLM API error: {e.code} - {error_body}")

ここでLLMClientが何をしているのかを理解するには、昔ながらの電信の通信係のようなものだと考えると分かりやすいです。この層には、推論・計画・ツールの実行といった概念がありません。さらに、メモリも管理しません。役目はただ、テキストの積み重ねをひとまとめにして、それをモデルへと通信線で送信し、生のレスポンスを返してくれることだけです。メッセージの中に書かれている単語を理解する必要なく、メッセージを行き来させることを確実に行います。

Step 3 — Managing Agent Memory

LLMはステートレスです。リクエストのたびに、履歴全体をすべて送信しない限り、過去のやり取りは記憶されません。実行ループが進むにつれて、コンテキストウィンドウは継続的に大きくなります。そこで、memory.pyに軽量なメモリマネージャーが必要になります。
返却形式: {"translated": "翻訳されたHTML"}

from typing import List, Dict

class AgentMemory:
    def __init__(self, max_messages: int = 20):
        self. messages: List[Dict] = []
        self. max_messages = max_messages

    def add(self, role: str, content: str):
        self. messages. append({
            "role": role,
            "content": content
        })

        if len(self. messages) > self. max_messages:
            # システムプロンプトを保持する
            system_prompt = self. messages[0]

            # 会話ウィンドウをスライドさせる
            active_history = self. messages[1:]
            self. messages = (
                [system_prompt] + 
                active_history[-(self. max_messages - 1):]
            )

    def get_messages(self) -> List[Dict]:
        return self. messages. copy()

    def clear(self):
        self. messages. clear()

LLMクライアントが私たちの電信オペレーターだとしたら、このメモリマネージャーは探偵の手帳のようにイメージできます。エージェントがタスクを調査するにつれて、あらゆる些細な詳細が書き留められます。元のユーザーリクエスト、内部での推論、ツールの選択、そしてその過程で見つかった手がかりです。手帳は無限のページを持てないので、探偵は最終的に、調査の中心となる文脈を前面に保ちながら、古い詳細をアーカイブしなければなりません。このスライディングウィンドウのロジックが、文脈を扱いやすく保つ方法そのものです。

ステップ 4 — エージェントエンジンの構築

ここにオーケストレーション(制御)ロジックが置かれます。エージェントは、利用可能なツールを理解し、いつそれらを使うべきかを判断し、構造化された出力を解析し、関数を実行し、観測結果をメモリに戻し入れる必要があります。ではagent.pyを書いていきましょう:

from llm_client import LLMClient
from memory import AgentMemory
from typing import Dict, Callable
import json

class Agent:
    def __init__(self, system_prompt: str, config_path: str = "config.json"):
        with open(config_path) as f:
            self. config = json.load(f)
        self. llm = LLMClient(self. config)
        self. memory = AgentMemory()
        self. system_prompt = system_prompt
        self. tools: Dict[str, dict] = {}

        self. memory.add("system", system_prompt)
def register_tool(self, name: str, func: Callable, description: str): self.tools[name] = { "func": func, "description": description } def _get_tool_descriptions(self) -> str: if not self.tools: return "利用可能なツールはありません。" return " ".join([ f"- {name}: {info['description']}" for name, info in self.tools.items() ]) def think(self, user_input: str) -> str: self.memory.add("user", user_input) messages = self.memory.get_messages() tool_info = self._get_tool_descriptions() if self.tools: messages = messages.copy() enhanced_content = ( f"{user_input} " f"利用可能なツール: " f"{tool_info} " f"ツールが必要な場合は、次の形式でJSONのみを返してください: " f'{{"tool":"tool_name","args":{{}}}} ' f"タスクが完了している場合は、自然な形で返し、' FINAL ANSWER' を含めてください。" ) messages[-1]["content"] = enhanced_content response = self.llm.chat_completion(messages) self.memory.add("assistant", response) return responsedef act(self, response: str): if "{" in response and "}" in response: try: start = response.find("{") end = response.rfind("}") + 1 tool_json = json.loads(response[start:end]) tool_name = tool_json.get("tool") args = tool_json.get("args", {}) if tool_name in self.tools: result = self.tools[tool_name]<a href="**args">"func"</a> self.memory.add( "system", f"観測結果('{tool_name}' から): {result}" ) return result except Exception as e: error_msg = f"ツールの実行に失敗しました: {str(e)}" self.memory.add("system", error_msg) return error_msg return None

この構造的なハンドオフは、現代のAIエージェントで最も誤解されやすい部分の1つを浮き彫りにします。それは次の点です: モデルは、あなたのPython関数を直接実行しません。

代わりに、プロンプト内にローカルコードの内容を平文の説明として提示することになります。モデルがこれらの説明を読み取り、助けが必要だと判断した場合、単にテキスト出力を整形して、ツール名とパラメータを指定する生のJSONブロックとして返します。ホストアプリケーションはそのJSONを受け取り、読み取り、ローカルでネイティブのPythonコードを実行し、その結果をテキスト履歴に戻します。LLM自身は完全に隔離されたままです。ローカルアプリケーションが実際の実行環境になります。

Step 5 — The Runtime Control Loop

ランタイムループがなければ、エージェントは複数ステップの推論を実行できません。ホストアプリケーションは、実行を継続的に前に進める必要があります。main.pyを見てみましょう:

from agent import Agent
import time

def web_search(query: str) -> str:
    print(f"インデックスを検索中: '{query}')
    time.sleep(1)
    if "agentic ai" in query.lower():
        return (
            "見つかりました: 現代のエージェンティックなシステムは、硬いチェーンから"
            "軽量な制御ループとモジュール化されたツールへ移行しています。"
        )
    return (
        "見つかりました: ゼロからエージェントを構築すると、フレームワークによって"
        "しばしば隠されている実装の詳細が明らかになります。"
    )if __name__ == "__main__":
    system_prompt = (
        "あなたは自律的なオペレーションアシスタントです。 "
        "手順を追って考えてください。 "
        "必要に応じてツールを使用してください。 "
        "タスクが完全に完了したら、「FINAL ANSWER」というフレーズを含めてください。"
    )

    agent = Agent(system_prompt)
    agent.register_tool(
        name="search",
        func=web_search,
        description="インデックスデータベースを照会します。入力スキーマ: {'query': str}"
    )

    task = "エージェント型AIにおけるトレンドを調査し、ゼロから構築することに価値がある理由を説明してください。"
    print(f" Objective: {task}")

    max_steps = 5
    for step in range(max_steps):
        print(f"
[Cycle {step + 1}]")
        prompt = task if step == 0 else "これまでの観察を分析し、続けてください。"

        response = agent.think(prompt)
        print(f"
 Agent:
{response}")

        tool_output = agent.act(response)
        if tool_output:
            print(f"
 Observation:
{tool_output}")

        if "final answer" in response.lower():
            print("
✅ 目的の達成。")
            break

実行時の挙動をトレースする

以下は、2つの別々のサイクルにまたがる実行中に内部で何が起きているかを見たものです:

サイクル 1

  • Think: モデルはタスク、ツールの説明、初期のシステムメモリ状態を受け取ります。現在のトレンドに関する直接的な情報が欠けていることに気づきます。
  • Act: モデルが構造化されたJSONを出力します:

    {
      "tool": "search",
      "args": {
        "query": "エージェント型AIにおける最新トレンド"
      }
    }
    

plaintext
    実行ランタイムはこのブロックを解析し、ローカルのPython web_search 関数を実行します。
*   **Observe:** ツールの出力がメモリに追加されます。これにより、モデルは推論を続けるための追加の文脈を得ます。

### サイクル 2
モデルは、元の目的、これまでの観察、ツール出力を確認します。完全な応答を統合し、次を出力します:

```text
FINAL ANSWER

制御ループはこの完了キーワードを検出し、正常に終了します。

実際に構築したもの

すべての抽象化の下で、あなたは完全に動作するパイプラインを実装しました:

  • 状態を持つメモリ
  • ツール登録
  • 構造化されたツール呼び出し
  • 実行時オーケストレーション
  • 複数ステップの実行
  • 文脈管理
  • 決定的な制御フロー

それが、現代のほぼすべてのエージェント・フレームワークの土台です。

本番環境での考慮事項

この実装は意図的に最小限です。実際の本番システムでは通常、次を追加します:

領域 運用メカニクス
レジリエンス & トラッキング リトライ方針、トークン会計、観測性 & トレース
データ & 実行管理 並列ツール実行、サンドボックス化されたランタイム、レート制限
アーキテクチャのスケーリング 分散オーケストレーション、長期メモリの永続化レイヤ
セキュリティ & 安全性 ガードレールとバリデーション、人間による承認のチェックポイント

これらの運用上の懸念が十分に大きくなってきて初めて、フレームワークが価値を持つようになります。しかし、まずコアとなるループを理解することは、AIシステムの設計方法を変えます。

返却形式: {"translated": "翻訳されたHTML"}

最終的な所感

AIエージェントは、高度な抽象化の背後に隠れていると魔法のように見えることがあります。しかし、その層を取り除くと、ほとんどのシステムは少数の決定的なビルディングブロックに縮約されます。つまり、プロンプト、メモリ、ツール、パース(解析)、そしてループです。

これらのプリミティブを理解すると、フレームワークを無闇に組み合わせるよりも、はるかに高度なアーキテクチャ上の制御を手に入れられます。スタックに別の依存関係を導入する前に、次の問いを立てる価値があります。

「ここには実際にフレームワークが必要なのか、それとも、よく設計された制御ループが必要なだけなのか?」

この質問に自信を持って答えられるなら、今日それらを使っている多くの開発者よりも、エージェント型システムについて理解していることになります。