
Pydantic AIとLangGraphで同じエージェントを書き比べてみる
はじめに
データ事業本部のkobayashiです。
先日AWS Lambda durable functionsとPydantic AIの統合について記事を書きました。
このときPydantic AIを初めて触ったのですが、これまでLiteLLM × LangGraphでエージェントを組んできた身としては「同じことをやっているはずなのに書き方がかなり違う」という印象だけが残り、フレームワークとしての理解が浅いままでした。
そこで今回は、過去に書いたLangGraphの記事とまったく同じ題材をPydantic AIで書き直し、どこが対応していてどこが違うのかを確認してみます。
比較の元にするのは以下の2記事です。
Pydantic AIとは
Pydantic AIは、データ検証ライブラリPydanticの開発元が作っているPythonのAIエージェントフレームワークです。FastAPIのような開発体験をLLMアプリケーション開発に持ち込むことを掲げていて、エージェントの出力型・ツールの引数・実行時の依存を型で宣言する点が特徴です。出力とツールの引数はPydanticで検証され、依存(実行ごとに変わる値)は型付きのままツールへ受け渡せます。
ほかの主な特徴としては以下になります。
- プロバイダー非依存:
"bedrock:モデルID"のような文字列でOpenAI・Anthropic・Bedrock・Google などを切り替える - capabilityによる拡張: 耐障害性や記憶などの追加機能を
capabilities=[...]で差し込む
LangGraphが「グラフでフローを組む」フレームワークなのに対し、Pydantic AIは「1つのエージェントの入出力を型で固める」フレームワークかと思います。この立ち位置の違いが、以降で見ていく書き方の違いに現れます。
では早速試してみます。
環境
今回使用した環境は以下の通りです。
Python 3.13.15
pydantic-ai-slim 2.51.0
pydantic 2.13.5
boto3 1.43.105
anthropic 1.9.0
openai 3.22.0
インストールは使うプロバイダーをextraで指定します。今回はBedrockを主に使い、後半でAnthropicとOpenAIにも切り替えるので3つ入れています。
$ uv pip install "pydantic-ai-slim[bedrock,anthropic,openai]"
また2.43.0からは、最初の実行時に起動バナーが標準エラー出力に表示されます。本記事の実行結果は環境変数PYDANTIC_AI_NO_BANNER=1でバナーを抑止して取得しています。
なおpydantic-ai(slimでない方)にはOpenAI・Anthropic・Googleなどのプロバイダーと周辺ツールが既定で入りますが、Bedrockは含まれずextraの追加が必要です。必要なものだけ入れたい場合はpydantic-ai-slimにextraを付けます。
モデルはAmazon BedrockのClaude Haiku 4.5(jp.anthropic.claude-haiku-4-5-20251001-v1:0)を使いました。
比較1: 基本的なエージェントとツール
まず過去記事で書いたLangGraphのReActエージェントです。天気を返すツールと計算するツールを持たせ、「東京と大阪の天気を調べて、気温の差を計算してください」と聞いています。
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_litellm import ChatLiteLLM
@tool
def get_weather(city: str) -> str:
"""指定された都市の現在の天気を取得します。"""
weather_data = {
"東京": "晴れ、気温25°C、湿度60%",
"大阪": "曇り、気温23°C、湿度70%",
"札幌": "雪、気温-2°C、湿度80%",
"福岡": "晴れ、気温22°C、湿度55%",
}
return weather_data.get(city, f"{city}の天気データは見つかりませんでした")
# calculate ツールの定義は Pydantic AI 版と同じ内容のため省略
llm = ChatLiteLLM(model="anthropic/claude-sonnet-4-6")
agent = create_agent(llm, tools=[get_weather, calculate])
result = agent.invoke(
{"messages": [{"role": "user", "content": "東京と大阪の天気を調べて、気温の差を計算してください"}]}
)
for msg in result["messages"]:
print(f"[{msg.type}] {msg.content}")
これをPydantic AIで書くと次のようになります。
from pydantic_ai import Agent
from pydantic_ai.usage import UsageLimits
agent = Agent(
"bedrock:jp.anthropic.claude-haiku-4-5-20251001-v1:0",
instructions="ユーザーの質問にツールを使って答えてください。",
)
@agent.tool_plain
def get_weather(city: str) -> str:
"""指定された都市の現在の天気を取得します。"""
weather_data = {
"東京": "晴れ、気温25°C、湿度60%",
"大阪": "曇り、気温23°C、湿度70%",
"札幌": "雪、気温-2°C、湿度80%",
"福岡": "晴れ、気温22°C、湿度55%",
}
return weather_data.get(city, f"{city}の天気データは見つかりませんでした")
@agent.tool_plain
def calculate(expression: str) -> str:
"""数式を計算します。四則演算に対応しています。"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "エラー: 許可されていない文字が含まれています"
try:
result = eval(expression) # noqa: S307
return str(result)
except Exception as e:
return f"計算エラー: {e}"
result = agent.run_sync(
"東京と大阪の天気を調べて、気温の差を計算してください",
usage_limits=UsageLimits(request_limit=6),
)
for msg in result.all_messages():
for part in msg.parts:
kind = part.part_kind
if kind == "user-prompt":
print(f"[user] {part.content}")
elif kind == "text":
print(f"[text] {part.content}")
elif kind == "tool-call":
print(f"[tool-call] {part.tool_name}({part.args})")
elif kind == "tool-return":
print(f"[tool-return] {part.content}")
print()
print(f"出力 : {result.output}")
print(f"モデル呼び出し回数: {result.usage.requests} / ツール呼び出し回数: {result.usage.tool_calls}")
実行結果です。
$ python basic_agent.py
[user] 東京と大阪の天気を調べて、気温の差を計算してください
[text] 東京と大阪の天気を調べて、気温の差を計算いたします。
[tool-call] get_weather({'city': '東京'})
[tool-call] get_weather({'city': '大阪'})
[tool-return] 晴れ、気温25°C、湿度60%
[tool-return] 曇り、気温23°C、湿度70%
[text] それでは気温の差を計算します。
[tool-call] calculate({'expression': '25 - 23'})
[tool-return] 2
[text] 結果をまとめます:
**天気情報:**
- **東京**:晴れ、気温 25°C、湿度 60%
- **大阪**:曇り、気温 23°C、湿度 70%
**気温の差:** 2°C
(東京が大阪より2°C暖かいです)
出力 : 結果をまとめます:
(以下同じ内容のため省略)
モデル呼び出し回数: 3 / ツール呼び出し回数: 3
ツールを2回呼んでから計算ツールを呼び、最終回答を作るという動きは過去記事のLangGraph版と同じです。書き方の対応は次のようになります。
| 観点 | LangGraph | Pydantic AI |
|---|---|---|
| モデルの指定 | create_agent(model="gpt-5.5", ...)と文字列でも、ChatLiteLLM(...)のインスタンスでも渡せる |
Agent("bedrock:モデルID")と文字列でも、モデルオブジェクトでも渡せる |
| ツールの定義 | @toolで関数を定義し、tools=[...]でエージェントに渡す |
@agent.tool_plainでエージェントに直接登録 |
| エージェントの構築 | create_agent(llm, tools=[...]) |
Agent(...)のコンストラクタ |
| 実行 | agent.invoke({"messages": [...]}) |
agent.run_sync("プロンプト") |
| 最終回答の取り出し | result["messages"][-1].content |
result.output |
| 途中経過 | result["messages"]をたどる |
result.all_messages()をpart単位でたどる |
書き味で一番違うと感じたのは入出力です。LangGraphはグラフのStateを更新していくモデルなので、入力も出力もmessagesという辞書のキーを経由します。Pydantic AIは1回の実行を関数呼び出しのように扱うので、プロンプトを文字列で渡してresult.outputで受け取ります。
ツールの登録方法も違います。LangGraphの@toolは「ツールというオブジェクト」を作るだけで、どのエージェントに渡すかは別途決めます。Pydantic AIの@agent.tool_plainは特定のエージェントに直接紐付きます。ただしPydantic AIもAgent(tools=[roll_dice, get_player_name])のようにコンストラクタでリスト渡しができますし、複数のエージェントで使い回すならFunctionToolsetにまとめてtoolsets=[...]で渡せます。デコレータは書き方の選択肢の1つです。
なおUsageLimits(request_limit=6)はモデル呼び出し回数の上限です。ツールを呼びながらループするエージェントでは、想定外に回り続けたときの保険として付けておくと安心かと思います。
比較2: 構造化出力
次に構造化出力です。過去記事ではLiteLLMとLangChainで3パターン紹介しました。ここでは自然文からタスクを抽出するTaskExtractionスキーマを使ったパターンと比較します。
過去記事ではLiteLLMのcompletion()にresponse_formatとしてスキーマを渡し、返ってきたJSON文字列をmodel_validate_json()でパースしていました。
response = completion(
model="openai/gpt-5.4",
messages=[
{"role": "system", "content": "ユーザー入力からタスク情報を抽出してください。"},
{"role": "user", "content": user_input},
],
response_format=TaskExtraction,
)
raw = response.choices[0].message.content
task = TaskExtraction.model_validate_json(raw)
LangChainのインターフェースを使う場合はllm.with_structured_output(スキーマ)で構造化専用のオブジェクトを作る形になり、こちらはPydanticオブジェクトが直接返ります。
Pydantic AIではエージェントのoutput_typeにスキーマを渡します。
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.usage import UsageLimits
class TaskExtraction(BaseModel):
"""ユーザーの自然文からタスクを抽出する。"""
title: str = Field(..., description="タスクの短いタイトル")
priority: str = Field(..., description="high / medium / low のいずれか")
due_date: str | None = Field(None, description="期限。YYYY-MM-DD 形式、なければnull")
tags: list[str] = Field(default_factory=list, description="関連するタグ")
agent = Agent(
"bedrock:jp.anthropic.claude-haiku-4-5-20251001-v1:0",
output_type=TaskExtraction,
instructions="ユーザー入力からタスク情報を抽出してください。今日は2026-09-13です。",
)
user_input = "明日までに取引先に提案書を送る、優先度高め。タグは営業と提案書"
result = agent.run_sync(user_input, usage_limits=UsageLimits(request_limit=4))
task = result.output
print("=== 構造化されたタスク ===")
print(f" 型 : {type(task).__name__}")
print(f" title : {task.title}")
print(f" priority : {task.priority}")
print(f" due_date : {task.due_date}")
print(f" tags : {task.tags}")
print(f"モデル呼び出し回数: {result.usage.requests}")
実行結果です。
$ python structured_output.py
=== 構造化されたタスク ===
型 : TaskExtraction
title : 取引先に提案書を送る
priority : high
due_date : 2026-09-14
tags : ['営業', '提案書']
モデル呼び出し回数: 1
result.outputがTaskExtractionのインスタンスとして返ってくるので、過去記事のresponse_format版で必要だったmodel_validate_json()の呼び出しは不要です。検証が省かれるわけではなく、Pydantic AIが内部で出力を検証し、失敗した場合はモデルにリトライさせます。
過去記事で書いた3パターンはいずれも「モデルに構造化出力の設定を付ける」形で、ツールを使うエージェントとは別に組んでいました。Pydantic AIでは構造化出力が「エージェントの出力型」なので、output_typeを指定したままツールも持たせられます。
ただしこれもPydantic AI固有ではありません。LangChainのcreate_agentにもresponse_formatがあり、ツールと構造化出力を同時に指定してresult["structured_response"]で受け取れます。違いは受け取り方で、LangChainは最終状態の辞書のキーとして返すのに対し、Pydantic AIはresult.outputそのものが指定した型になります。
型注釈の観点でも違いがあります。Agent(output_type=TaskExtraction)と書くと、そのエージェントはAgent[object, TaskExtraction]として型が付き、result.outputがエディタ上でTaskExtractionとして補完されます。
比較3: 依存注入
3つ目は依存注入です。実行ごとに変わる値(ユーザーIDや契約プランなど)を、LLMに埋めさせる引数とは混ぜずにツールへ渡したい、というケースです。過去記事のLangGraphシリーズでは、この手の値はStateに載せて扱っていました。
Pydantic AIにはdeps_typeという専用の仕組みがあります。
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from pydantic_ai.usage import UsageLimits
@dataclass
class UserContext:
"""1 リクエストぶんの実行コンテキスト。"""
user_id: str
home_city: str
plan: str
agent = Agent(
"bedrock:jp.anthropic.claude-haiku-4-5-20251001-v1:0",
deps_type=UserContext,
instructions="ユーザーの居住地を踏まえて、ツールで調べた情報から簡潔に答えてください。",
)
@agent.instructions
def add_user_info(ctx: RunContext[UserContext]) -> str:
"""システムプロンプトを実行時の依存から組み立てる。"""
return f"問い合わせ元のユーザーは {ctx.deps.user_id}、居住地は {ctx.deps.home_city}、契約プランは {ctx.deps.plan} です。"
@agent.tool
def get_weather(ctx: RunContext[UserContext], city: str | None = None) -> str:
"""都市の天気を取得します。city を省略するとユーザーの居住地を使います。"""
target = city or ctx.deps.home_city
weather_data = {
"東京": "晴れ、気温25°C、湿度60%",
"大阪": "曇り、気温23°C、湿度70%",
"札幌": "雪、気温-2°C、湿度80%",
"福岡": "晴れ、気温22°C、湿度55%",
}
return f"{target}: {weather_data.get(target, 'データなし')}"
@agent.tool
def get_alert_quota(ctx: RunContext[UserContext]) -> str:
"""契約プランに応じた通知の上限を返します。"""
quota = {"free": 3, "pro": 50}.get(ctx.deps.plan, 0)
return f"プラン {ctx.deps.plan} の通知上限は 1 日 {quota} 件です。"
for deps in [
UserContext(user_id="U001", home_city="札幌", plan="free"),
UserContext(user_id="U002", home_city="福岡", plan="pro"),
]:
result = agent.run_sync(
"今日の天気と、私が受け取れる通知の上限を教えてください",
deps=deps,
usage_limits=UsageLimits(request_limit=4),
)
print(f"--- {deps.user_id} ({deps.home_city} / {deps.plan}) ---")
print(result.output)
print(f"モデル呼び出し回数: {result.usage.requests} / ツール呼び出し回数: {result.usage.tool_calls}")
print()
ポイントは以下の点です。
Agent(deps_type=UserContext)で依存の型を宣言する- ツールは
@agent.tool_plainではなく@agent.toolで定義し、第1引数にRunContext[UserContext]を受け取る ctx.depsで実行時に渡した値を参照する。この引数はモデルには見えないので、LLMが埋めるべき引数と混ざらない@agent.instructionsを付けた関数でシステムプロンプトも実行時に組み立てられる- 実行時は
agent.run_sync(..., deps=UserContext(...))で渡す
エージェント定義は1つのまま、渡す依存を変えて2回実行した結果です。
$ python deps_injection.py
--- U001 (札幌 / free) ---
ご確認させます:
**📍 札幌の天気**
- 雪
- 気温:-2°C
- 湿度:80%
**📬 通知の上限**
- free プランでは **1日3件** までの通知をお受け取りいただけます
お出かけの際は暖かくしてお過ごしくださいね。
モデル呼び出し回数: 2 / ツール呼び出し回数: 2
--- U002 (福岡 / pro) ---
お答えします。
**今日の福岡の天気:**
晴れ、気温22°C、湿度55%
**通知の上限:**
pro プランでは、1日50件の通知を受け取ることができます。
モデル呼び出し回数: 2 / ツール呼び出し回数: 2
ユーザーは都市名もプラン名も伝えていませんが、依存として渡した居住地とプランに応じて別々の答えが返っています。
なお、この仕組みはPydantic AI固有ではありません。LangChainのcreate_agentにもcontext_schemaがあり、ツールはToolRuntime[UserContext]を引数に取ってruntime.contextから同じように参照できます。この引数もモデルからは見えません。過去記事でStateに載せていたのは執筆時点での書き方の選択で、LangGraphにこの用途の仕組みが無いわけではありませんでした。
両者の差は、依存がどこに現れるかです。LangChainではcreate_agent(context_schema=UserContext)とツール側のToolRuntime[UserContext]にそれぞれ書くのに対し、Pydantic AIではdeps_typeがエージェント自身の型パラメータ(Agent[UserContext, str])になります。エージェントの型を見れば必要な依存が分かる点は、型を軸にしたフレームワークらしいと感じました。
比較4: プロバイダーの切り替え
最後に、過去記事でLiteLLMを使っていた理由そのものであるプロバイダーの切り替えです。過去記事ではChatLiteLLM(model="anthropic/claude-sonnet-4-6")のモデル名を変えるだけで切り替えられる点を紹介しました。
Pydantic AIではrun_sync()のmodel引数にモデル文字列を渡すと、エージェント定義をそのままに実行時のモデルだけを差し替えられます。
from pydantic_ai import Agent
from pydantic_ai.usage import UsageLimits
agent = Agent(instructions="ユーザーの質問にツールを使って答えてください。")
@agent.tool_plain
def get_weather(city: str) -> str:
"""指定された都市の現在の天気を取得します。"""
weather_data = {
"東京": "晴れ、気温25°C、湿度60%",
"大阪": "曇り、気温23°C、湿度70%",
}
return weather_data.get(city, f"{city}の天気データは見つかりませんでした")
@agent.tool_plain
def calculate(expression: str) -> str:
"""数式を計算します。四則演算に対応しています。"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "エラー: 許可されていない文字が含まれています"
try:
return str(eval(expression)) # noqa: S307
except Exception as e:
return f"計算エラー: {e}"
MODELS = [
"bedrock:jp.anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic:claude-haiku-4-5-20251001",
"openai:gpt-6-luna",
]
question = "東京と大阪の気温差は何度ですか"
for model in MODELS:
result = agent.run_sync(question, model=model, usage_limits=UsageLimits(request_limit=6))
answer = result.output.replace("\n", " ")[:60]
print(f"{model}")
print(f" 出力: {answer}")
print(f" モデル呼び出し {result.usage.requests} 回 / ツール呼び出し {result.usage.tool_calls} 回")
Agent()にモデルを渡さず、実行時に指定しています。実行結果です。
$ python model_switch.py
bedrock:jp.anthropic.claude-haiku-4-5-20251001-v1:0
出力: **結果:** - **東京**:晴れ、気温25°C、湿度60% - **大阪**:曇り、気温23°C、湿度70%
モデル呼び出し 3 回 / ツール呼び出し 3 回
anthropic:claude-haiku-4-5-20251001
出力: 現在の気温は以下の通りです: - **東京**:25°C(晴れ、湿度60%) - **大阪**:23°C(曇り、湿度7
モデル呼び出し 2 回 / ツール呼び出し 2 回
openai:gpt-6-luna
出力: 東京は25°C、大阪は23°Cなので、気温差は**2°C**です。
モデル呼び出し 2 回 / ツール呼び出し 2 回
Bedrock経由のClaude、Anthropic APIのClaude、OpenAIのGPTの3つが、同じエージェント定義・同じツール定義のまま動きました。認証情報はプロバイダーごとの標準的な環境変数(ANTHROPIC_API_KEY、OPENAI_API_KEY、Bedrockはboto3の資格情報)をそのまま読んでくれるので、コード側の分岐は不要でした。
ここは過去記事でLiteLLMに任せていた役割と重なります。プロバイダーの抽象化だけが目的なら、Pydantic AIを使う場合にLiteLLMを挟む必要はなさそうです。一方でLiteLLMにはフォールバック、コスト追跡、Routerによる負荷分散といった機能もあるので、そちらが必要なら引き続き併用するのが良いかと思います。
使い分けの整理
ここまで書き比べた印象をまとめると、両者は競合というより向いている場面が違うかと思います。
LangGraphが向いている場面
- 分岐・ループ・並列を明示的に組みたい。Plan-and-ExecuteやSupervisor型のマルチエージェントなど、フロー自体が設計対象になるケース
- Checkpointerによる永続化とHuman-in-the-Loopを、フレームワーク内で完結させたいケース
Pydantic AIが向いている場面
- 1つのエージェントの入出力と依存を型で固めたい。
Agent[UserContext, TaskExtraction]という宣言に情報を集約したいケース - ツール呼び出しのループはフレームワークに任せ、フロー自体は自前のPythonコードで書きたいケース
- 既存のPydanticモデルが多く、それをそのままエージェントの出力型や依存の型として使いたいケース
なお永続化については、Pydantic AIのドキュメントは目的ごとに仕組みを分けて説明しています。会話の保存はメッセージ履歴をシリアライズして自前のストレージに置く形で、途中で落ちた実行の再開はTemporalやDBOSなどのdurable executionをcapabilityとして付ける形です(AWS Lambda durable functionsとの統合は別パッケージのpydantic-ai-harness側にあります)。pydantic-ai-harnessにはファイルやSQLiteなどにステップ単位で保存するStep Persistenceもあります。checkpointerという1つの仕組みで会話の保存と再開をまとめて扱うLangGraphとは、永続化層の置き方の考え方が異なります。
まとめ
LangGraphで書いてきたエージェントを、同じ題材のままPydantic AIで書き直してみました。
モデルの文字列指定、ツールと構造化出力の併用、実行時コンテキストの注入はLangChainのcreate_agentにも用意されていて(LangChain側は公式ドキュメントでの確認です)、差が出たのは機能の有無ではなく書き方でした。単一のエージェントを型で固めたいならPydantic AI、複数ステップのフローを組みたいならLangGraphと、用途で選ぶと良さそうです。
最後まで読んで頂いてありがとうございました。








