AIエージェントの作り方:計画、ツール活用、そして実行へ

AIエージェントの作り方:計画、ツール活用、そして実行へ

AIエージェントの構築は、シンプルなアイデアから始まります。モデルにアクションを起こす能力、結果を観察する能力、そしてループさせる能力を与えることです。モデルは目標に対して推論し、次に何をすべきか決定し、ツールを呼び出し、結果を受け取り、タスクが完了するまでこれを継続します。この「読み取り→評価→アクション」のループは、単一ステップのツール呼び出しであれ、コードを書き、実行し、自身のエラーを修正するマルチステップシステムであれ、あなたが構築するあらゆるエージェントの中核となります。

このチュートリアルでは、エージェントを機能させる4つの構成要素、すなわち計画、ツール活用、コード実行、評価について説明します。この記事を読み終える頃には、実用的な概念モデルと、今日すぐに実行できる最小限の実装を身につけていることでしょう。

AIエージェントを定義するもの

AIエージェントが基本的なLLM呼び出しと異なる点は、決定的に一つだけです。それは、アクションを起こし、その結果を観察するという点です。

単純なチャット補完はテキストを生成します。エージェントはそのテキストを使って関数を呼び出し、APIに問い合わせ、コマンドを実行し、ファイルを読み取り、そしてその結果を次のステップの入力としてフィードバックします。このフィードバックループこそが、一回の応答では答えられないタスクに対してエージェントを有用にしているのです。

エージェントを定義する3つの能力があります。

  • ツール呼び出し — 名前と構造化された引数を使って定義済み関数を呼び出すこと
  • コンテキストの蓄積 — 会話が進むにつれてツールの結果を前方に引き継ぐこと
  • 目標指向のループ — 一回の応答だけでなく、停止条件が満たされるまで継続すること

システムがこれら3つ全てを備えていれば、あなたはエージェントを構築しています。コンテキストの蓄積やループが欠けている場合は、それはツールで拡張されたチャットボットです。

同じアーキテクチャが幅広いタスクを処理します。検索して合成するリサーチエージェント、コードを書いて実行するコーディングエージェント、データベースに問い合わせてレポートを生成するデータエージェントなどです。実装パターンは、どのケースでもほぼ同一です。

ステップ1:計画 — 推論レイヤー

計画レイヤーは、モデルが次に何をすべきかを決定する場所です。別個のプランナーモデルは必要ありません。テキストを生成するのと同じモデルが、各ステップで目標を推論し、構造化されたアクション(ツール呼び出し)を生成できます。

システムプロンプトがほとんどの役割を担います。優れた計画プロンプトはモデルに以下のことを伝えます。

  • どの目標に向かって作業しているか
  • どのツールが利用可能で、それぞれが何をするか
  • ツールを呼び出す際に使用する形式
  • いつ停止するか(終了条件)

NovitaのLLM API(OpenAI互換)を使用すると、以下のようになります。

import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.novita.ai/v3/openai",
    api_key=os.environ["NOVITA_API_KEY"],
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "run_code",
            "description": "Execute Python code and return stdout/stderr.",
            "parameters": {
                "type": "object",
                "properties": {
                    "code": {
                        "type": "string",
                        "description": "Python code to execute"
                    }
                },
                "required": ["code"]
            }
        }
    }
]

messages = [
    {
        "role": "system",
        "content": "You are a coding assistant. Use the run_code tool to execute Python and solve tasks. Stop when you have a final answer."
    },
    {
        "role": "user",
        "content": "Find all prime numbers under 50."
    }
]

response = client.chat.completions.create(
    model="meta-llama/llama-3.1-8b-instruct",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

モデルは、プレーンテキストの応答ではなく、関数名と引数を含むJSONオブジェクトであるツール呼び出しを返します。あなたのコードはこれを受け取り、関数を実行し、その結果を messages に追加してループバックします。

ステップ2:ツール活用 — モデルを世界に繋ぐ

ツールこそがエージェントが実際に行うことです。ツールがなければ、モデルはテキストを生成することしかできません。ツールがあれば、ファイルを読み取り、APIを呼び出し、コードを実行し、データベースを検索し、あるいはあなたが定義した任意の関数をトリガーできます。

ツールの定義

各ツールは、関数を記述するJSONスキーマです。モデルはこのスキーマを読み取って、呼び出しを正しくフォーマットします。いくつかの実用的なルールを以下に示します。

  • 説明は短く正確に保つ。 モデルは毎ステップでこれを読み取ります。冗長な説明はトークンを浪費します。
  • パラメータ名は、関数が実際に受け入れるものと一致させる。 スキーマと実装の間の不一致は、黙ったままの障害を引き起こします。
  • 構造化された出力を返す。 ツールが生のHTMLやフォーマットされていない散文を返すと、モデルはそれを解析するためにトークンを浪費します。明確なラベルが付いたクリーンな文字列かJSONを返してください。

ツール呼び出しループ

モデルがツール呼び出しを行った後、あなたのコードは以下の処理を行います。

  1. 応答からツール名と引数をパースする
  2. 一致するPython関数を検索する
  3. パースした引数でそれを呼び出す
  4. role: "tool" として結果を messages に追加する
  5. 別の補完リクエストを行う

ループは、モデルがツール呼び出しのないプレーンメッセージを返すまで(終了条件)継続されます。

import json

def run_agent_loop(client, model, messages, tools, tool_fns, max_steps=10):
    for _ in range(max_steps):
        response = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=tools,
            tool_choice="auto",
        )
        choice = response.choices[0]

        # Done — no tool call
        if choice.finish_reason == "stop":
            return choice.message.content

        # Execute the tool call
        tool_call = choice.message.tool_calls[0]
        fn_name = tool_call.function.name
        fn_args = json.loads(tool_call.function.arguments)

        result = tool_fns[fn_name](**fn_args)

        # Append both the model's tool call and the result
        messages.append(choice.message)
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": str(result),
        })

    return "Max steps reached without a final answer."

これが完全なエージェントループです。max_steps ガードは暴走ループを防ぎます。モデルが同じツールを繰り返し呼び出すスタックに陥った場合、ループは無限に実行されることなく終了します。それ以外の全て — マルチツールオーケストレーション、メモリ、ストリーミング — は、このパターンの上に構築されます。

一般的なツールパターン

ファイルアクセス: エージェントの作業ディレクトリ内のファイルを読み書きします。ファイルの内容を文字列として返します。

ウェブ検索: 検索結果を {title, url, snippet} オブジェクトのリストとして返します。スニペットは短く保ちます。

コード実行: 隔離された環境でコードを実行し、stdout/stderrを返します(次のセクションで説明)。

構造化データ検索: データベースやAPIに問い合わせ、レコードをJSONとして返します。要約はモデルが担当します。

各ツールは一つのことを行い、同じ入力に対して決定論的な出力を返すべきです。一つのツールにあまりに多くのロジックを詰め込もうとするエージェントは、デバッグや評価が難しくなります。

ステップ3:コード実行 — コードを安全に実行する

エージェントがコードを生成して実行する場合、隔離された環境が必要です。モデルが生成したコードをあなたのマシン上で直接実行することはセキュリティリスクです。モデルは破壊的なシェルコマンド、無限ループ、あるいはアクセスすべきでない機密ファイルを読み取るコードを生成する可能性があります。

標準的なアプローチは サンドボックス です。これは、エージェントがホストシステムに影響を与えることなく自由にコードを実行できる、短命で隔離されたLinux環境です。NovitaのAgent Sandboxはまさにこのユースケースのために設計されています。クラウドベースの実行環境で、200ms未満で起動し、エージェントが生成した任意のコードを実行し、セッション終了時に環境を破棄します。

以下は、計画セクションの run_code ツールをサンドボックスを使って実装する方法です。

from novita_sandbox.code_interpreter import Sandbox

sandbox = Sandbox.create()

def run_code(code: str) -> str:
    result = sandbox.run_code(code)
    output = ""
    if result.logs.stdout:
        output += "\n".join(result.logs.stdout)
    if result.logs.stderr:
        output += "\nSTDERR: " + "\n".join(result.logs.stderr)
    return output or "(no output)"

エージェントが run_code を呼び出すと、この関数は隔離環境でコードを実行し、stdout/stderrを文字列として返します。エージェントは結果を見て、次に何をするかを決定します。バグを修正するのか、出力を続けるのか、タスク完了を宣言するのか。

このアーキテクチャは、推論レイヤーと実行レイヤーをきれいに分離します。LLMは計画を立ててツールを呼び出し、サンドボックスはコンテナ外に何もさらすことなくコードを実行します。

サンドボックスが処理するもの

  • シェルコマンド( bash , git , python , node
  • サンドボックス内のファイルシステム操作
  • コンテナ内からのネットワークリクエスト
  • 非同期結果取得を伴う長時間実行バックグラウンドタスク
  • マルチセッションワークフローのための一時停止と再開

許可されないこと: ホストシステム、他のサンドボックス、または明示的に渡していない認証情報へのアクセス。この閉じ込めこそが、モデルに任意のコードを書かせて実行させても安全な理由です。

ステップ4:評価 — エージェントが正しいかどうかを知る

評価は、エージェント開発において最もスキップされがちなステップです。出力を生成するエージェントと、正しい出力を生成するエージェントは同じではありません。

評価の3つのレベル

1. 決定論的チェック

既知の正解があるタスク — 日付の解析、値の計算、フィールドの抽出 — については、エージェントの出力に対してユニットテストを書きます。これらは実行コストが低く、リグレッションを素早くキャッチします。

2. 実行ベースのチェック

コーディングエージェントにとって、最も信頼性の高いシグナルは、コードが実際に実行され、正しい結果を生成するかどうかです。エージェントの出力をサンドボックスで実行し、stdoutを期待値と比較します。これはコードテキストを比較するよりも強力です。なぜなら、文法ではなく振る舞いをテストするからです。

def evaluate_code_task(agent_code: str, expected_stdout: str) -> bool:
    result = run_code(agent_code)
    return result.strip() == expected_stdout.strip()

3. LLMによる判定

オープンエンドなタスク — 要約を書く、研究質問に答える — については、2番目のLLM呼び出しを使用して、ルーブリックに照らして出力を評価します。ルーブリックは具体的にします。「回答に動作するコード例が含まれているか?」は「回答は良いか?」よりも優れています。曖昧なルーブリックは一貫性のないスコアを生み出します。

障害モードの追跡

エージェントは繰り返し発生するいくつかのパターンで失敗します。

障害 原因 修正
ツール呼び出しループ モデルが同じツールを呼び続ける max_steps を設定し、中間ステップをログに記録する
引数の不一致 スキーマが実装と一致しない ツールを呼び出す前に引数を検証する
コンテキストオーバーフロー セッションがモデルのコンテキスト制限を超えて成長する 古いツール結果を要約または切り捨てる
幻覚のツール名 モデルが存在しないツールをでっち上げる tool_choice: "auto" で厳格なツールリストを使用する

評価は最後の別のフェーズではなく、開発の一部です。エージェントの機能を拡張するたびに、新しい振る舞いのリグレッションを捕捉できるテストを少なくとも1つは書いてください。

エージェント推論のためのモデル選択

ほとんどのチュートリアルは、エージェント推論にクローズドソースモデルをデフォルトとしています。これはプロトタイピングには有効ですが、プロダクション環境では、エージェントがタスクごとに20回のLLM呼び出しを行う可能性もあるため、モデルのコストとレート制限が現実的な制約となります。

NovitaのLLM APIを通じてホストされるオープンソースモデルは、エージェントが実際に必要とするタスク、すなわち関数呼び出し、構造化出力、マルチステップ推論において、クローズドソースのパフォーマンスにますます匹敵しつつあります。Llama 3.3 70BDeepSeek-V3 といったモデルは、コーディングと指示追従においてGPT-4クラスのレベルでベンチマークされながら、わずかなコストで動作します。高スループットのエージェントワークロードでは、この差はすぐに大きくなります。

Novita APIは完全にOpenAI互換であるため、クローズドソースモデルからオープンソースモデルへの切り替えは1行の変更で済みます。

# Before
model = "gpt-4o"

# After — same API contract, lower cost
model = "meta-llama/llama-3.3-70b-instruct"

レイテンシーに敏感なエージェントループでは、meta-llama/llama-3.1-8b-instruct のような小規模モデルの方が応答が速く、ステップあたりのコストも低くなります。これは、各ユーザータスクに多くのシーケンシャル呼び出しが含まれる場合に重要です。実用的なパターンとしては、ルーティングや分類のステップにはより高速で小規模なモデルを使用し、より多くの容量を必要とする複雑な推論ステップには大規模モデルを使用します。

Novitaのモデルライブラリには、特定のエージェントサブタスクに適した強力なオプションも含まれています。画像ベースのツール結果のためのビジョンモデル、検索のための埋め込みモデル、コーディングエージェントのためのコード特化モデルなど、全て同じAPIエンドポイントからアクセス可能です。

統合 — 最小限の動作するエージェント

以下は、数学の問題を受け取り、それを解くためのPythonコードを生成し、サンドボックスで実行し、検証済みの結果を返す、完全なエージェントです。

import os
import json
from openai import OpenAI
from novita_sandbox.code_interpreter import Sandbox

client = OpenAI(
    base_url="https://api.novita.ai/v3/openai",
    api_key=os.environ["NOVITA_API_KEY"],
)
sandbox = Sandbox.create()

def run_code(code: str) -> str:
    result = sandbox.run_code(code)
    stdout = "\n".join(result.logs.stdout)
    stderr = "\n".join(result.logs.stderr)
    return stdout or stderr or "(no output)"

tool_fns = {"run_code": run_code}

tools = [
    {
        "type": "function",
        "function": {
            "name": "run_code",
            "description": "Execute Python code. Returns stdout or stderr.",
            "parameters": {
                "type": "object",
                "properties": {
                    "code": {"type": "string"}
                },
                "required": ["code"]
            }
        }
    }
]

messages = [
    {
        "role": "system",
        "content": "Solve the task by writing and running Python code. Use run_code to execute it. Stop when you have a verified answer."
    },
    {
        "role": "user",
        "content": "What is the sum of all even Fibonacci numbers under 1,000,000?"
    }
]

for _ in range(10):
    response = client.chat.completions.create(
        model="meta-llama/llama-3.1-8b-instruct",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
    choice = response.choices[0]

    if choice.finish_reason == "stop":
        print(choice.message.content)
        break

    tool_call = choice.message.tool_calls[0]
    fn_args = json.loads(tool_call.function.arguments)
    result = tool_fns[tool_call.function.name](**fn_args)

    messages.append(choice.message)
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,
        "content": result,
    })

sandbox.kill()

これは、約50行で完全なエージェントループをカバーしています。計画、ツール呼び出し、実行、コンテキストの蓄積、最大ステップガード、クリーンなサンドボックス破棄を含みます。エージェントはコードを書き、実行し、出力を見て、継続するか最終的な答えを出します。

これを拡張するには、tool_fnstools にさらにツールを追加するか、より難しいタスクのために大規模モデルに交換するか、サンドボックスの run_code を他の任意の関数 — ウェブ検索、データベースクエリ、API呼び出し — に置き換えます。ループ自体は変わりません。

FAQ

AIエージェントとチャットボットの違いは何ですか?

チャットボットはテキスト応答を生成します。エージェントはアクションを起こします。ツールの呼び出し、コードの実行、APIへの問い合わせなどを行い、その結果を使って目標に向かって推論を続けます。ツール呼び出しとフィードバックループの存在が、それをエージェントたらしめているものです。

AIエージェントを構築するためにLangChainのようなフレームワークは必要ですか?

いいえ。コアとなるエージェントループは、任意のOpenAI互換APIに対して直接実装するのに十分シンプルです。フレームワークは複雑なオーケストレーション(マルチエージェントシステム、永続メモリ、ストリーミング出力)に価値を追加しますが、デバッグを難しくする抽象化レイヤーも追加します。生のAPIから始め、フレームワークが提供するものを本当に必要とする場合にのみ、それを追加してください。

エージェントが危険なコードを実行するのを防ぐにはどうすればよいですか?

サンドボックス化された実行環境を使用します。エージェントのコードは、ホストシステムや他のプロセスにアクセスできない隔離されたコンテナ内で実行されます。サンドボックスはセッション終了後に破棄されるため、エージェントが行った変更はそのコンテナ内に限定されます。

オープンソースモデルは関数呼び出しを確実に処理できますか?

はい — ただしモデルサイズに依存します。Llama 3.3 70B、DeepSeek-V3、Qwen 2.5 72Bは全て、構造化されたツール呼び出しを適切に処理します。小規模モデル(7B–8B)は、シンプルで明確に定義されたツールスキーマに対しては機能しますが、曖昧な指示や複雑なマルチステップ推論チェーンでは苦戦する可能性があります。プロダクションエージェントでは、モデルを決定する前に、実際のタスク分布でテストしてください。

エージェントはタスクごとに何ステップ取るべきですか?

ハードキャップを設定します。ほとんどのタスクでは10〜20ステップが一般的です。中間ステップをログに記録して、エージェントが何をしたかを正確に確認し、障害をデバッグできるようにします。一貫してより多くのステップを必要とするタスクは、通常、ループ制限を延長するよりも、サブエージェントやサブタスクに分割する方が効果的です。

おすすめ記事