PydanticAI実践チュートリアル — FastAPI感覚で型安全なAIエージェントを作る方法

PydanticAI実践チュートリアル — FastAPI感覚で型安全なAIエージェントを作る方法

PydanticAI 1.88.0を実際にインストールし、TestModel、output_type、@agent.tool、マルチプロバイダー切り替えを直接テストした結果です。result_type→output_type変更のような実際の罠とFunctionModelテスト戦略も含みます。

from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel

agent = Agent('test', system_prompt='Pythonの専門家')
result = agent.run_sync('f-stringと.format()どっちが速いですか?', model=TestModel())
print(result.output)  # → success (no tool calls)

このコードがAPIキーなしで動くのを初めて見たとき、少し驚いた。FastAPIを初めて使ったときのように — 構造があまりにも直感的で、むしろ疑いたくなった。PydanticAIはそういうライブラリだ。

正直、最初は「Instructorにラッパーかぶせただけじゃないの?」と思っていた。実際に使ってみて考えが変わった。FastAPIのように型システムを中心に設計されたフレームワークで、AIエージェントにその哲学をそのまま持ち込んだものだ。今日は直接インストールして動かした結果をまとめる。失敗したテストも含めて。

なぜPydanticAIなのか — 既存比較との違う視点

以前のPython AIエージェントライブラリ比較でPydanticAI・Instructor・Smolagentsを扱った。あのポストが「何を選ぶか」を扱うなら、今回のポストは「PydanticAIで実際にどう作るか」だ。実装方法が目的だ。

主な違いをまとめると:

ライブラリ核心的な役割エージェントループ型安全性
InstructorLLM出力のパースなし構造化出力のみ
PydanticAIエージェントフレームワーク完全サポート入出力+ツール全体
LangGraphオーケストレーショングラフベース弱い
CrewAIマルチエージェントチームロールベース弱い

特に2行目が実際に使うときの違いを生む。LLMがツールを呼び出し、その結果を受け取って処理する全ループで型が維持される。ランタイムエラーが開発中のIDEエラーとして前倒しで現れる。

インストールとPre-requisites

pip install pydantic-ai

今日時点(2026年4月29日)の最新バージョンは1.88.0だ。インストールは簡単。

python3 -m venv venv
source venv/bin/activate
pip install pydantic-ai

プロバイダー別の追加パッケージは使う時点でインストールすれば良い:

pip install pydantic-ai[anthropic]   # Claude使用時
pip install pydantic-ai[openai]       # OpenAI GPT使用時
pip install pydantic-ai[google]       # Gemini使用時

Requirements: Python 3.9以上、pydantic v2(v1は非サポート)。

最初のエージェント: TestModelで構造検証

エージェントを作るとき私が最初に使うパターンだ。実際のAPIを繋げる前にエージェントの構造が正しいか確認する。

from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel

agent = Agent(
    'test',
    system_prompt='あなたはPythonコードレビュアーです。簡潔に答えてください。',
)

result = agent.run_sync(
    'f-stringと.format()どっちが速いですか?',
    model=TestModel()  # APIキー不要
)

print(result.output)    # → "success (no tool calls)"
print(result.usage())   # → RunUsage(input_tokens=64, output_tokens=4, requests=1)

TestModelはAPIを呼び出さない。エージェントの構造、ツール設定、依存性注入が正しいか確認するためのテスト専用モデルだ。CIパイプラインでAPIコストなしにエージェントロジックを検証する時に活用する。

実際のClaudeを繋げるにはモデル文字列一つを変えるだけ:

import os
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-...'

# 開発中: TestModel
result = agent.run_sync('コードレビューして', model=TestModel())

# 本番: 実際のClaude
result = agent.run_sync('コードレビューして', model='anthropic:claude-sonnet-4-6')

エージェントのコードはそのままで、モデルだけ交換する。

構造化出力: output_typeでPydanticモデルを返す

ここからPydanticAIの核心だ。LLMが自由テキストではなくPydanticモデルインスタンスを返すよう強制できる。

重要 — v1.88.0 Breaking Change: result_typeパラメータがoutput_typeに変更された。古いドキュメントやチュートリアルをそのまま使うと次のエラーが出る:

pydantic_ai.exceptions.UserError: Unknown keyword arguments: `result_type`

直接遭遇した。inspect.signature(Agent.__init__)で確認したらoutput_typeが正しいパラメータ名だった。

from pydantic import BaseModel, Field
from pydantic_ai import Agent

class CodeReview(BaseModel):
    severity: str = Field(description="'low', 'medium', 'high'のいずれか")
    issues: list[str] = Field(description="発見された問題のリスト")
    suggestions: list[str] = Field(description="改善提案のリスト")
    score: int = Field(ge=0, le=100, description="コード品質スコア(0-100)")

review_agent = Agent(
    'anthropic:claude-sonnet-4-6',
    output_type=CodeReview,  # ← v1.88.0: result_typeではない
    system_prompt='Pythonコードをレビューして構造化フィードバックを提供してください。',
)

result = review_agent.run_sync('''
def get_user(id):
    db = connect()
    return db.query(f"SELECT * FROM users WHERE id={id}")
''')

print(type(result.output))       # → <class '__main__.CodeReview'>
print(result.output.severity)    # → 'high'
print(result.output.score)       # → 25
print(result.output.issues[0])   # → 'SQLインジェクション脆弱性'

返り値がdictやstrではなくPydanticモデルインスタンスだ。result.output.を入力するとIDEがseverityissuesなどをオートコンプリートしてくれる。

自動リトライメカニズムも重要だ:

review_agent = Agent(
    'anthropic:claude-sonnet-4-6',
    output_type=CodeReview,
    retries=3,       # 出力バリデーション失敗時に最大3回リトライ
    output_retries=2
)

3回全て失敗するとUnexpectedModelBehavior例外が発生する。

@agent.toolと依存性注入 — FastAPIのDepends()と同じパターン

from pydantic_ai import Agent, RunContext

class AppDeps:
    def __init__(self, db_url: str, user_id: int):
        self.db_url = db_url
        self.user_id = user_id

agent = Agent(
    'anthropic:claude-sonnet-4-6',
    deps_type=AppDeps,
    system_prompt='タスク管理エージェントです。',
)

# 非同期ツール
@agent.tool
async def get_pending_tasks(ctx: RunContext[AppDeps], limit: int = 5) -> list[dict]:
    """未完了タスクのリストを取得します"""
    return [
        {"id": f"task_{i}", "title": f"タスク {i}", "priority": "high"}
        for i in range(limit)
    ]

# 同期ツール
@agent.tool
def calculate_priority_score(
    ctx: RunContext[AppDeps],
    urgency: int,
    importance: int
) -> float:
    """タスクの優先度スコアを計算します"""
    return urgency * 0.6 + importance * 0.4

deps = AppDeps(db_url="postgresql://localhost/taskdb", user_id=42)
result = agent.run_sync("緊急タスクで最優先のものを選んで", deps=deps)

@agent.toolは関数シグネチャとdocstringを読んでLLMに渡すJSON Schemaを自動生成する。

メッセージフローは4段階で追跡できる:

1. ModelRequest  → system_prompt + user_prompt
2. ModelResponse → ToolCallPart(tool_name='get_pending_tasks', ...)
3. ModelRequest  → ToolReturnPart(content=[{...}])
4. ModelResponse → 最終応答

PydanticAI実行ログ — サンドボックステスト結果

TestModel vs FunctionModel — テスト戦略

サンドボックスでテストしていてTestModelの重要な制限を発見した。

TestModelはstrフィールドに'a'intフィールドに0のような最小値を返す。厳格なカスタムvalidatorがあると失敗する:

from pydantic_ai.exceptions import UnexpectedModelBehavior

class UserProfile(BaseModel):
    email: str

    @field_validator('email')
    @classmethod
    def valid_email(cls, v):
        if '@' not in v:  # TestModelが'a'を返すので常に失敗
            raise ValueError('@が必要')
        return v

agent = Agent('test', output_type=UserProfile, retries=3)
try:
    result = agent.run_sync('...', model=TestModel())
except UnexpectedModelBehavior as e:
    print(f"例外: {e}")
    # → Exceeded maximum retries (3) for output validation

FunctionModelを使えば解決する:

from pydantic_ai.models.function import FunctionModel
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
from pydantic_ai.settings import ModelSettings
import json

def mock_valid_response(messages: list[ModelMessage], settings: ModelSettings) -> ModelResponse:
    data = {"email": "test@example.com", "name": "テスト"}
    return ModelResponse(parts=[TextPart(content=json.dumps(data))])

agent = Agent(FunctionModel(mock_valid_response), output_type=UserProfile)
result = agent.run_sync("...")
assert result.output.email == "test@example.com"

テスト戦略の整理:

class TestMyAgent:
    def test_structure(self):
        """エージェント構造検証 — TestModel"""
        result = my_agent.run_sync("テスト", model=TestModel())
        assert result is not None

    def test_tool_called(self):
        """ツール呼び出し確認 — TestModel + call_tools"""
        result = my_agent.run_sync(
            "DBからデータを取得して",
            deps=test_deps,
            model=TestModel(call_tools=['query_database'])
        )
        assert 'query_database' in result.output

    def test_with_mock(self):
        """応答処理ロジック — FunctionModel"""
        def mock_fn(messages, settings):
            return ModelResponse(parts=[TextPart(content='{"email": "t@t.com"}')])

        result = my_agent.run_sync("...", model=FunctionModel(mock_fn))
        assert result.output.email == "t@t.com"

マルチプロバイダー切り替え

エージェントのコードを変えずにモデル文字列だけで別プロバイダーに切り替えられる:

review_agent = Agent(
    system_prompt='シニアPython開発者としてコードをレビューします。',
    output_type=CodeReview,
)

# 実行時にモデルを指定
result_claude = review_agent.run_sync(code, model='anthropic:claude-sonnet-4-6')
result_gpt    = review_agent.run_sync(code, model='openai:gpt-4o')
result_gemini = review_agent.run_sync(code, model='google-gla:gemini-2.5-flash')
result_local  = review_agent.run_sync(code, model='ollama:llama3.3')

コンテキストエンジニアリングの観点から見ると、system_promptoutput_typeスキーマがコンテキストの核心で、その上でモデルを交換可能にするのが良い設計だ。

実際に使ってみて — 率直な評価

良かった点:

  • 型安全性が実際に差を生む。output_typeスキーマを変更するとIDEが即座に関連エラーを捕捉する
  • @agent.toolの自動JSON Schema生成が便利だ。ツール仕様を手動で再作成する必要がない
  • TestModel + FunctionModelの組み合わせでAPIなしにエージェントロジックを完全に単体テストできる

残念な点:

  • v1.88.0までresult_type → output_typeのような非互換な変更が頻繁にある。ライブラリがまだ安定化段階にない。実際にinspect.signature(Agent.__init__)でパラメータを確認しなければならない状況が生じた
  • ストリーミング構造化出力はまだベータ版だ。LLMが部分的に応答を生成している間にPydanticモデルとしてパースするのは難しく、現在の実装が安定していない
  • Pydantic v2に強く結びついている。v1のレガシーコードベースならマイグレーションコストを考慮する必要がある

プロダクションAIエージェント設計原則と合わせて読むと、エージェントフレームワーク選択においてどんな基準が重要かがより明確になる。

次のステップ

TypeScriptスタックならVercel AI SDKでClaudeストリーミングエージェントを作る方法がPythonと似たアプローチを提供する。

PydanticAIを実際のプロダクションに適用するなら、推奨する順番:

  1. output_typeで返却スキーマを最初に定義する
  2. deps_typeでDB接続、HTTPクライアントを依存性として管理する
  3. @agent.toolで外部API連携を追加する
  4. TestModel → FunctionModel → 実際のモデルの順で段階的にテストする
  5. retries=3output_retries=2でリトライ戦略を設定する
  6. バージョンを固定する(pydantic-ai==1.88.0)。変更が頻繁なライブラリだ

PydanticAI GitHubリポジトリは急速に更新されている。公式ドキュメントよりCHANGELOGを先に読む習慣がプロダクティビティを向上させる。

他の言語で読む

この記事は役に立ちましたか?

より良いコンテンツを作成するための力になります。コーヒー一杯で応援してください。

著者について

jw

Kim Jangwook

AI/LLM専門フルスタック開発者

10年以上のWeb開発経験を活かし、AIエージェントシステム、LLMアプリケーション、自動化ソリューションを構築しています。Claude Code、MCP、RAGシステムの実践的な知見を共有します。

ブログリストへ