夜間に走らせている記事生成の処理を、あるときエージェント3本に分けました。トピック選定、本文執筆、品質チェック。役割を分ければ見通しが良くなるはずでした。
分けた翌週、私は自分の書いたグルーコードの前で手が止まりました。個人開発では繋ぎ目もすべて自分の担当で、誰かに引き渡す先はありません。3本はそれぞれ別のライブラリで書かれていて、繋ぎ目ごとに JSON の形を合わせ、タイムアウトを決め、失敗時の再送を書いている。エージェント本体より、繋ぎ目のほうが長くなっていたのです。
Google が公開した Agent2Agent (A2A) プロトコル は、この繋ぎ目そのものを標準化する試みです。異なるフレームワーク、異なるプロバイダーで作られたエージェントが、統一された HTTP ベースの仕様で会話する。相手の内部実装を知らないまま協調できる。
ここでは A2A の仕様を押さえたうえで、Gemini API・ADK と組み合わせた実装を FastAPI で組み立て、Cloud Run に載せるところまでを追います。後半には、実際に回して初めて見えてきた運用の勘所を置きました。
想定する読者:
- Gemini API を使ったエージェント開発の経験がある方
- 複数エージェントの協調システムを構築したい方
- A2A プロトコルを本番環境に適用したい方
事前知識として、Gemini API Function Calling:ツール統合と実践的な活用法 と ADKに頼らない Gemini API カスタムエージェントループ設計ガイド に目を通しておくと、この記事の設計判断が追いやすくなります。
A2A プロトコルの仕様と核心概念
プロトコルの基本構造
A2A プロトコルは、HTTP/JSON-RPC 2.0 をベースにした軽量な仕様です。各エージェントは「A2A サーバー」として動作し、標準化されたエンドポイントを公開します。
A2A の主要コンポーネントは以下の 4 つです。
① エージェントカード(Agent Card): エージェントの能力・エンドポイント・認証方式を記述した JSON メタデータです。/.well-known/agent.json で公開され、クライアントはここを起点にエージェントを「発見」します。
② タスク(Task): クライアントがエージェントに依頼する作業単位です。一意の ID を持ち、submitted → working → completed / failed というライフサイクルを管理します。
③ アーティファクト(Artifact): タスクの成果物です。テキスト・JSON・ファイルなど複数の MIME タイプをサポートし、ストリーミング中は逐次追記される形式をとります。
④ メッセージ(Message): タスク実行中のやり取りです。ユーザーとエージェント間のマルチターン対話や、エージェント間の中間連絡に使われます。
通信フロー
クライアント A2A サーバー(エージェント)
| |
|--- GET /.well-known/agent.json ----> | ① エージェントカード取得
|<-- {"name": "...", "skills": [...]} -|
| |
|--- POST /tasks/send ---------------> | ② タスク送信
| {"id": "task-123", |
| "message": {"role": "user", ...}}|
|<-- {"status": {"state": "submitted"}}|
| |
|--- GET /tasks/get?id=task-123 -----> | ③ ステータス確認(ポーリング)
|<-- {"status": {"state": "completed"},|
| "artifacts": [...]} |
| |
| または SSE でリアルタイム受信 |
|--- POST /tasks/sendSubscribe ------> |
|<== data: {"type": "artifact", ...} --|(ストリーミング)
|<== data: {"type": "status", ...} ---|
エージェントカードの設計
エージェントカードは、エージェントを発見するための「名刺」であり、他エージェントが何を依頼できるかを判断する根拠になります。設計の質が協調システム全体の使いやすさを左右します。
{
"name": "データ分析エージェント",
"description": "CSV・JSON データを統計分析し、ビジネス洞察と可視化を提供します",
"url": "https://data-agent.example.run.app",
"version": "1.2.0",
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"authentication": {
"schemes": ["Bearer"]
},
"skills": [
{
"id": "analyze-csv",
"name": "CSV 統計分析",
"description": "CSV ファイルを受け取り、平均・分散・相関係数などの統計量と主要な洞察を返します",
"inputModes": ["text", "file"],
"outputModes": ["text", "data"]
},
{
"id": "generate-chart",
"name": "データビジュアライゼーション",
"description": "数値データから最適なグラフ(棒・折れ線・散布図)を自動選択し生成します",
"inputModes": ["data"],
"outputModes": ["file"]
}
]
}
設計のポイント:
description はエージェントの専門性を 1 文で明確に述べる
skills[].description は何を入力すると何が返ってくるかを具体的に書く
inputModes / outputModes は対応形式を正確に列挙する
環境準備と A2A サーバーの基本実装
必要なパッケージ
# Google ADK(A2A サーバー機能を含む)
pip install google-adk
# Gemini API クライアント
pip install google-generativeai
# Web サーバーフレームワーク
pip install fastapi uvicorn httpx pyjwt
最小構成の A2A サーバー
# a2a_server.py
import uuid
import asyncio
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
import google.generativeai as genai
# Gemini API 初期化
# ⚠️ API キーは必ず環境変数から取得すること
import os
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
model = genai.GenerativeModel("gemini-2.5-pro")
app = FastAPI(title="Gemini A2A Agent", version="1.0.0")
# タスクストア(本番では Redis または Firestore を推奨)
tasks: dict[str, dict] = {}
# =====================
# エージェントカード
# =====================
AGENT_CARD = {
"name": "Gemini 汎用分析エージェント",
"description": "Gemini 2.5 Pro を活用したテキスト分析・要約・構造化データ抽出エージェント",
"url": "https://your-agent.run.app",
"version": "1.0.0",
"capabilities": {
"streaming": True,
"pushNotifications": False
},
"authentication": {"schemes": ["Bearer"]},
"skills": [
{
"id": "analyze-text",
"name": "テキスト分析",
"description": "テキストを分析し、要約・洞察・構造化 JSON を返します",
"inputModes": ["text"],
"outputModes": ["text", "data"]
}
]
}
# =====================
# エンドポイント定義
# =====================
@app.get("/.well-known/agent.json")
async def get_agent_card():
"""エージェントカードを公開する(A2A 必須エンドポイント)"""
return AGENT_CARD
class TaskRequest(BaseModel):
id: str
message: dict
sessionId: Optional[str] = None
@app.post("/tasks/send")
async def send_task(req: TaskRequest, background_tasks: BackgroundTasks):
"""タスクを非同期で受け付け、処理を開始する"""
task_id = req.id
tasks[task_id] = {
"id": task_id,
"status": {"state": "submitted", "timestamp": datetime.utcnow().isoformat()},
"artifacts": [],
"history": [req.message]
}
background_tasks.add_task(process_with_gemini, task_id, req.message)
return tasks[task_id]
@app.get("/tasks/get")
async def get_task(id: str):
"""タスクの現在状態を取得する"""
if id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
return tasks[id]
async def process_with_gemini(task_id: str, message: dict):
"""Gemini API でタスクを処理する内部関数"""
try:
tasks[task_id]["status"]["state"] = "working"
# メッセージからテキスト部分を結合
user_text = " ".join(
part.get("text", "")
for part in message.get("parts", [])
if part.get("type") == "text"
)
# Gemini API 呼び出し(ブロッキングなので asyncio.to_thread で実行)
response = await asyncio.to_thread(model.generate_content, user_text)
tasks[task_id]["status"] = {
"state": "completed",
"timestamp": datetime.utcnow().isoformat()
}
tasks[task_id]["artifacts"] = [{
"name": "analysis_result",
"parts": [{"type": "text", "text": response.text}]
}]
except Exception as e:
tasks[task_id]["status"] = {
"state": "failed",
"message": {"code": -1, "message": str(e)},
"timestamp": datetime.utcnow().isoformat()
}
print(f"❌ タスク {task_id} 失敗: {e}")
期待する動作確認:
# サーバー起動
uvicorn a2a_server:app --reload
# エージェントカード確認
curl http://localhost:8000/.well-known/agent.json
# タスク送信
curl -X POST http://localhost:8000/tasks/send \
-H "Content-Type: application/json" \
-d '{"id":"task-001","message":{"role":"user","parts":[{"type":"text","text":"AI エージェントとは何かを 3 行で説明してください"}]}}'
# 結果取得
curl "http://localhost:8000/tasks/get?id=task-001"
# → {"status":{"state":"completed"},"artifacts":[{"parts":[{"type":"text","text":"..."}]}]}
A2A クライアント:エージェント発見から結果取得まで
# a2a_client.py
import httpx
import asyncio
import uuid
from typing import Optional
class A2AClient:
"""A2A プロトコル準拠のクライアントライブラリ"""
def __init__(self, agent_url: str, bearer_token: Optional[str] = None):
self.agent_url = agent_url.rstrip("/")
self.headers = {}
if bearer_token:
self.headers["Authorization"] = f"Bearer {bearer_token}"
async def get_agent_card(self) -> dict:
"""エージェントの能力を確認する"""
async with httpx.AsyncClient() as client:
res = await client.get(
f"{self.agent_url}/.well-known/agent.json",
headers=self.headers
)
res.raise_for_status()
return res.json()
async def send_task_and_wait(
self,
message_text: str,
poll_interval: float = 2.0,
max_wait: int = 300
) -> dict:
"""タスクを送信し、完了まで待機してから結果を返す"""
task_id = str(uuid.uuid4())
payload = {
"id": task_id,
"message": {
"role": "user",
"parts": [{"type": "text", "text": message_text}]
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
# タスク送信
await client.post(
f"{self.agent_url}/tasks/send",
json=payload,
headers=self.headers
)
# ポーリングで完了を待つ
elapsed = 0
while elapsed < max_wait:
await asyncio.sleep(poll_interval)
elapsed += poll_interval
status_res = await client.get(
f"{self.agent_url}/tasks/get",
params={"id": task_id},
headers=self.headers
)
task = status_res.json()
state = task["status"]["state"]
if state == "completed":
return task
elif state == "failed":
error = task["status"].get("message", {})
raise RuntimeError(f"タスク失敗 (code={error.get('code')}): {error.get('message')}")
raise TimeoutError(f"タスクが {max_wait} 秒以内に完了しませんでした")
@staticmethod
def extract_text(task: dict) -> str:
"""アーティファクトからテキストを取得するユーティリティ"""
for artifact in task.get("artifacts", []):
for part in artifact.get("parts", []):
if part.get("type") == "text":
return part["text"]
return ""
Google ADK との統合:専門エージェントのオーケストレーション
ADK の Agent と LlmAgent を A2A でラップし、他システムから呼び出せるようにします。
# adk_a2a_wrapper.py
from google.adk.agents import LlmAgent, Agent
from google.adk.tools import google_search, code_execution
from google.adk.runners import Runner
# --- 専門エージェント定義 ---
research_agent = LlmAgent(
name="research_specialist",
model="gemini-2.5-pro",
description="Google 検索で最新情報を収集・整理する専門エージェント",
instruction="""
あなたは情報収集の専門家です。
ユーザーの質問に対し、Google 検索で最新の正確な情報を収集し、
出典 URL を明示したうえで 500 文字以内にまとめてください。
情報が古い可能性がある場合はその旨を明記してください。
""",
tools=[google_search]
)
code_agent = LlmAgent(
name="code_executor",
model="gemini-2.5-pro",
description="Python コードを生成・実行し、計算や数値処理を担当するエージェント",
instruction="""
あなたはデータサイエンスとプログラミングの専門家です。
要求された計算や処理を Python で実装し、実際に実行して数値結果を返してください。
コードはシンプルに保ち、各処理にコメントを付けてください。
結果は数値・表・グラフの URL などで具体的に示してください。
""",
tools=[code_execution]
)
# --- オーケストレーター定義 ---
orchestrator = Agent(
name="research_orchestrator",
model="gemini-2.5-pro",
description="調査・コード実行を組み合わせた高度な分析を行うオーケストレーター",
instruction="""
あなたはマルチエージェントシステムのオーケストレーターです。
ユーザーのタスクを分析し、以下のサブエージェントを適切に活用してください:
- research_specialist: 情報収集・最新動向の調査
- code_executor: 数値計算・データ処理・コード生成
複数のエージェントの結果を統合し、一貫性のある包括的な回答を返してください。
各エージェントの担当箇所を明示すると、読者の理解が深まります。
""",
sub_agents=[research_agent, code_agent]
)
async def run_orchestrated_task(user_request: str) -> str:
"""ADK オーケストレーターを実行し、結果を返す"""
runner = Runner(agent=orchestrator)
# Runner の実行(ADK バージョンにより API が異なる場合があります)
result = await runner.run_async(user_request)
return str(result)
マルチエージェント協調の 3 パターン
パターン 1:パイプライン型(Sequential Processing)
タスクを直列に複数エージェントへ渡すパターンです。前段の出力が後段の入力になります。
# pipeline.py
async def research_then_report_pipeline(topic: str) -> str:
"""調査 → 分析 → レポート生成の 3 段パイプライン"""
search_client = A2AClient("https://search-agent.run.app")
analysis_client = A2AClient("https://analysis-agent.run.app")
report_client = A2AClient("https://report-agent.run.app")
# Step 1: 情報収集(検索エージェント)
print("📡 Step 1: 情報収集中...")
raw_task = await search_client.send_task_and_wait(
f"「{topic}」について最新動向を詳しく調べてください"
)
raw_data = search_client.extract_text(raw_task)
# Step 2: データ分析(分析エージェント)
print("🔍 Step 2: 分析中...")
analysis_task = await analysis_client.send_task_and_wait(
f"以下の情報を分析し、重要なポイントを 5 つ抽出してください:\n\n{raw_data}"
)
insights = analysis_client.extract_text(analysis_task)
# Step 3: レポート生成(レポートエージェント)
print("📝 Step 3: レポート生成中...")
report_task = await report_client.send_task_and_wait(
f"次の洞察をもとに、1,000 文字のエグゼクティブサマリーを作成してください:\n\n{insights}"
)
return report_client.extract_text(report_task)
パターン 2:並列ファンアウト型(Parallel Fan-out)
複数エージェントを同時実行するパターンです。asyncio.gather で並列化し、処理時間を大幅に短縮できます。
# fanout.py
async def parallel_competitive_analysis(company: str) -> dict:
"""市場・技術・リスクを並列分析する"""
# 3 エージェントの send_task を同時実行(直列比で約 3 倍速)
market_task, tech_task, risk_task = await asyncio.gather(
A2AClient("https://market-agent.run.app").send_task_and_wait(
f"{company} の市場シェアと競合状況を分析してください"
),
A2AClient("https://tech-agent.run.app").send_task_and_wait(
f"{company} の技術スタックと技術的強みを評価してください"
),
A2AClient("https://risk-agent.run.app").send_task_and_wait(
f"{company} のリスク要因と課題を列挙してください"
),
return_exceptions=True # 一部失敗でも残りを継続
)
def safe_extract(client_url: str, task_or_exc) -> str:
if isinstance(task_or_exc, Exception):
return f"分析エラー: {str(task_or_exc)}"
return A2AClient(client_url).extract_text(task_or_exc)
return {
"market": safe_extract("https://market-agent.run.app", market_task),
"technology": safe_extract("https://tech-agent.run.app", tech_task),
"risk": safe_extract("https://risk-agent.run.app", risk_task)
}
パターン 3:動的ルーティング型(Dynamic Routing)
Gemini Flash でリクエストを分類し、最適なエージェントへ転送するパターンです。
# dynamic_router.py
import json
router_model = genai.GenerativeModel("gemini-2.5-flash") # 低コスト・高速なモデルで分類
AGENT_REGISTRY = {
"legal": {
"url": "https://legal-agent.run.app",
"description": "契約書レビュー・法的文書の分析・コンプライアンスチェック"
},
"data": {
"url": "https://data-agent.run.app",
"description": "データ分析・統計計算・グラフ生成・予測モデル"
},
"content": {
"url": "https://content-agent.run.app",
"description": "記事執筆・コピーライティング・SEO 最適化"
}
}
async def smart_route(user_request: str) -> str:
"""リクエストを分類し、最適なエージェントに転送する"""
routing_prompt = f"""
ユーザーのリクエストに最適なエージェントを 1 つ選んでください。
エージェント一覧:
{json.dumps({k: v['description'] for k, v in AGENT_REGISTRY.items()}, ensure_ascii=False)}
リクエスト: {user_request}
次の JSON 形式のみで回答(説明不要):
{{"agent": "agent_id", "reason": "選択理由(15文字以内)"}}
"""
routing_res = router_model.generate_content(
routing_prompt,
generation_config={"response_mime_type": "application/json"}
)
routing = json.loads(routing_res.text)
agent_id = routing["agent"]
if agent_id not in AGENT_REGISTRY:
# フォールバック: 最初のエージェントを使用
agent_id = list(AGENT_REGISTRY.keys())[0]
print(f"🎯 ルーティング → {agent_id}(理由: {routing['reason']})")
client = A2AClient(AGENT_REGISTRY[agent_id]["url"])
task = await client.send_task_and_wait(user_request)
return client.extract_text(task)
ストリーミング実装:SSE でリアルタイム進捗を配信
長時間かかるタスクでは、Server-Sent Events (SSE) を使ってリアルタイムに結果を配信します。
# streaming_server.py
from fastapi.responses import StreamingResponse
import json
@app.post("/tasks/sendSubscribe")
async def send_subscribe(req: TaskRequest):
"""Gemini のストリーミング出力を SSE でクライアントに中継する"""
user_text = " ".join(
part.get("text", "")
for part in req.message.get("parts", [])
if part.get("type") == "text"
)
async def event_stream():
task_id = req.id
# working イベント配信
yield f"data: {json.dumps({'type': 'status', 'id': task_id, 'status': {'state': 'working'}})}\n\n"
# Gemini ストリーミング生成
stream = model.generate_content(user_text, stream=True)
for chunk in stream:
if chunk.text:
event = {
"type": "artifact",
"id": task_id,
"artifact": {
"name": "response",
"parts": [{"type": "text", "text": chunk.text}],
"append": True
}
}
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
await asyncio.sleep(0) # イベントループに制御を返す
# completed イベント配信
yield f"data: {json.dumps({'type': 'status', 'id': task_id, 'status': {'state': 'completed'}})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
セキュリティ設計:JWT 認証とレート制限
本番環境では、エージェント間通信にも適切な認証が必要です。
# auth.py
import jwt
import time
import os
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
JWT_SECRET = os.environ["JWT_SECRET"]
security = HTTPBearer()
def create_agent_token(agent_id: str, ttl_sec: int = 3600) -> str:
"""A2A 通信用 JWT トークンを生成する"""
return jwt.encode(
{"agent_id": agent_id, "iat": int(time.time()), "exp": int(time.time()) + ttl_sec},
JWT_SECRET,
algorithm="HS256"
)
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""Bearer トークンを検証し、エージェント情報を返す"""
try:
payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=["HS256"])
if payload.get("exp", 0) < time.time():
raise HTTPException(status_code=401, detail="トークンの有効期限切れ")
return payload
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="無効なトークン")
# レート制限(メモリベース、本番では Redis を推奨)
from collections import defaultdict
request_log: dict[str, list[float]] = defaultdict(list)
RATE_LIMIT_RPM = 60 # 1 分あたりの最大リクエスト数
async def rate_limit(agent: dict = Depends(verify_token)) -> dict:
"""エージェント ID ごとにレート制限を適用する"""
agent_id = agent["agent_id"]
now = time.time()
request_log[agent_id] = [t for t in request_log[agent_id] if now - t < 60]
if len(request_log[agent_id]) >= RATE_LIMIT_RPM:
raise HTTPException(status_code=429, detail="レート制限超過。1 分後に再試行してください。")
request_log[agent_id].append(now)
return agent
本番デプロイ:Cloud Run への最適化
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# セキュリティ: 非 root ユーザーで実行
RUN useradd -m -u 1000 agentuser && chown -R agentuser:agentuser /app
USER agentuser
EXPOSE 8080
# 4 ワーカーで並列処理(本番推奨)
CMD ["uvicorn", "a2a_server:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "4", "--timeout-keep-alive", "120"]
Cloud Run デプロイコマンド
PROJECT_ID="your-gcp-project-id"
REGION="asia-northeast1"
SERVICE="gemini-a2a-agent"
# ビルド & プッシュ
gcloud builds submit \
--tag "asia-northeast1-docker.pkg.dev/${PROJECT_ID}/agents/${SERVICE}:latest"
# デプロイ(シークレットは Secret Manager から注入)
gcloud run deploy ${SERVICE} \
--image "asia-northeast1-docker.pkg.dev/${PROJECT_ID}/agents/${SERVICE}:latest" \
--region ${REGION} \
--allow-unauthenticated \
--memory 2Gi --cpu 2 \
--min-instances 1 --max-instances 20 \
--set-secrets "GEMINI_API_KEY=gemini-api-key:latest,JWT_SECRET=a2a-jwt-secret:latest"
# デプロイ URL 確認
gcloud run services describe ${SERVICE} --region ${REGION} --format "value(status.url)"
# → https://gemini-a2a-agent-xxxxxxxxxx-an.a.run.app
Cloud Monitoring でのカスタムメトリクス記録
# monitoring.py
from google.cloud import monitoring_v3
import time
monitoring_client = monitoring_v3.MetricServiceClient()
PROJECT_NAME = "projects/your-gcp-project-id"
def record_task_latency(agent_name: str, duration_sec: float, success: bool):
"""タスク処理時間をカスタムメトリクスとして記録する"""
series = monitoring_v3.TimeSeries()
series.metric.type = "custom.googleapis.com/a2a/task_latency_seconds"
series.metric.labels["agent"] = agent_name
series.metric.labels["status"] = "success" if success else "failure"
now = time.time()
point = monitoring_v3.Point({
"interval": {"end_time": {"seconds": int(now)}},
"value": {"double_value": duration_sec}
})
series.points = [point]
monitoring_client.create_time_series(name=PROJECT_NAME, time_series=[series])
3つの協調パターンを同じタスクで測り分ける
パターンの説明だけでは、実際にどれを選ぶべきかは決まりません。私自身、最初はなんとなくパイプラインを選び、後からファンアウトに組み替えて後悔したことがあります。そこで同一のタスクを3パターンすべてで流し、手元で測り直しました。
測定条件は次の通りです。エージェントは検索・要約・整形の3本。すべて gemini-2.5-flash を使用し、Cloud Run の同一リージョン(asia-northeast1)に min-instances=1 で常駐。入力は約 1,200 トークンの技術記事1本。各パターン 50 回ずつ実行し、中央値を採りました。
| パターン |
エンドツーエンド レイテンシ(中央値) |
1タスクあたり総トークン |
失敗時の影響範囲 |
| パイプライン(逐次) |
約 8.4 秒 |
約 5,900 |
1本落ちると全体が止まる |
| ファンアウト(並列) |
約 3.1 秒 |
約 7,400 |
1本落ちても部分結果が残る |
| 動的ルーティング |
約 5.2 秒 |
約 4,100 |
ルーター自体が単一障害点 |
数字を並べてみて、腑に落ちたことがあります。
ファンアウトはレイテンシが約 2.7 倍速い代わりに、トークンを約 25% 多く消費します。各エージェントが同じ入力を独立に受け取るため、入力トークンが重複するからです。逆に動的ルーティングは、必要なエージェントだけを呼ぶのでトークンが最も少ない。その代わりルーターの判断に1往復かかり、ルーターが誤ると下流が丸ごと無駄になります。
私はこう使い分けています。ユーザーが待っている処理はファンアウト、夜間バッチはパイプライン、入力の種類が読めない処理は動的ルーティング。待ち時間の価値がゼロの夜間バッチで、トークンを 25% 余分に払う理由はありません。
なお、この差はエージェント数が増えるほど広がります。5本に増やしたとき、パイプラインは約 14 秒まで伸びましたが、ファンアウトは約 3.6 秒に留まりました。逐次は本数に比例し、並列は最も遅い1本に律速される — 当たり前のことですが、実測してようやく設計の判断材料になります。
エージェントカードのキャッシュが招いたバージョン不整合
A2A で最初に躓いたのは、プロトコルの中身ではなく、その周りでした。
/.well-known/agent.json を配信するエージェントカードは、クライアントから見れば単なる静的 JSON です。だから素直にキャッシュされます。私は要約エージェントの skills に新しい入力形式を追加してデプロイしたのですが、呼び出し側は古いカードを掴んだまま、古い形式で投げ続けました。エラーにならないのが厄介でした。カードの型に合致してしまうため、静かに古い経路を通り続けるのです。
原因に辿り着くまで半日かかりました。以来、エージェントカードには必ずバージョンと ETag を付けています。
# agent_card.py
import hashlib
import json
from fastapi import APIRouter, Request, Response
router = APIRouter()
AGENT_CARD = {
"name": "summarizer-agent",
"description": "技術記事を指定文字数に要約するエージェント",
"url": "https://summarizer-agent.example.run.app",
"version": "2.1.0", # skills を変更したら必ず上げる
"capabilities": {"streaming": True, "pushNotifications": False},
"skills": [
{
"id": "summarize",
"name": "summarize",
"description": "本文を要約する",
"inputModes": ["text/plain", "application/json"],
"outputModes": ["application/json"],
}
],
}
_CARD_BYTES = json.dumps(AGENT_CARD, ensure_ascii=False, sort_keys=True).encode()
_CARD_ETAG = '"' + hashlib.sha256(_CARD_BYTES).hexdigest()[:16] + '"'
@router.get("/.well-known/agent.json")
async def agent_card(request: Request) -> Response:
# 変更がなければ 304 を返し、変更時は確実に再取得させる
if request.headers.get("if-none-match") == _CARD_ETAG:
return Response(status_code=304, headers={"ETag": _CARD_ETAG})
return Response(
content=_CARD_BYTES,
media_type="application/json",
headers={
"ETag": _CARD_ETAG,
# 5分は許容し、それ以上は握らせない
"Cache-Control": "public, max-age=300, must-revalidate",
},
)
must-revalidate を付けているのは、期限切れのカードを黙って使わせないためです。ETag はカード本体のハッシュから生成しているので、skills を1文字でも変えれば自動的に変わります。バージョンを上げ忘れても、ETag だけは嘘をつきません。
クライアント側では、掴んだカードのバージョンを必ずログに残します。
# client_version_check.py
import logging
logger = logging.getLogger(__name__)
MIN_SUPPORTED = {"summarizer-agent": "2.0.0"}
def _parse(v: str) -> tuple[int, ...]:
return tuple(int(x) for x in v.split("."))
def assert_card_compatible(card: dict) -> None:
name = card.get("name", "unknown")
version = card.get("version", "0.0.0")
logger.info("resolved agent card name=%s version=%s", name, version)
required = MIN_SUPPORTED.get(name)
if required and _parse(version) < _parse(required):
raise RuntimeError(
f"{name} のカードが古すぎます: {version} < {required}. "
"キャッシュを疑ってください"
)
たった十数行ですが、これがあれば「静かに古い経路を通る」状態は起動時に落ちます。エラーで落ちてくれるほうが、半日を溶かすよりずっとありがたい。
タスクの再送とべき等性 — A2A が決めていない部分を自分で埋める
A2A はタスクのライフサイクルを定義していますが、再送のセマンティクスまでは決めていません。ここは実装者の領分です。
tasks/send がタイムアウトしたとき、クライアントには2つの可能性が残ります。リクエストが届かなかったのか、届いて処理されたが応答が返らなかったのか。区別がつかないまま素直に再送すると、Gemini API を二重に叩き、課金も二重になります。
私の夜間パイプラインでは、これで一晩に同じ記事を2本生成したことがあります。朝、ログを見て背筋が冷えました。
対策はべき等キーです。クライアントが生成した ID をサーバー側で記録し、同じ ID なら処理済みの結果を返します。
# idempotent_task.py
import asyncio
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from fastapi import APIRouter, Header, HTTPException
router = APIRouter()
@dataclass
class _Entry:
status: str # "running" | "done"
result: Any = None
created_at: float = field(default_factory=time.time)
# 本番では Firestore / Redis などの共有ストアに置く
# (Cloud Run は複数インスタンスに分散するため、プロセス内 dict では守れない)
_STORE: dict[str, _Entry] = {}
_TTL_SEC = 24 * 60 * 60
_LOCK = asyncio.Lock()
async def run_idempotent(
key: str, work: Callable[[], Awaitable[Any]]
) -> tuple[Any, bool]:
"""(結果, 再利用したか) を返す。"""
async with _LOCK:
entry = _STORE.get(key)
if entry and time.time() - entry.created_at > _TTL_SEC:
entry = None
_STORE.pop(key, None)
if entry is None:
_STORE[key] = _Entry(status="running")
elif entry.status == "done":
return entry.result, True
else:
# 先行リクエストが処理中。409 で呼び出し側に待たせる
raise HTTPException(status_code=409, detail="task in progress")
try:
result = await work()
except Exception:
async with _LOCK:
_STORE.pop(key, None) # 失敗は残さず、再送を許す
raise
async with _LOCK:
_STORE[key] = _Entry(status="done", result=result)
return result, False
@router.post("/tasks/send")
async def send_task(
payload: dict,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
if not idempotency_key:
raise HTTPException(status_code=400, detail="Idempotency-Key ヘッダが必要です")
async def _work() -> dict:
# ここで実際の Gemini 呼び出しを行う
return await handle_task(payload)
result, reused = await run_idempotent(idempotency_key, _work)
return {"result": result, "reused": reused}
失敗時にエントリを消しているのが要点です。残したままだと、一度失敗したキーが永久に「処理済み」として扱われ、再送しても何も起きなくなります。成功だけを記憶する。これは A2A に限らず、再送を許すあらゆる API に共通する作法だと私は考えています。
そして 409 を返す設計にした理由も添えておきます。処理中に同じキーが来たとき、待たせるか弾くかは選択です。私は弾く側を選びました。エージェントの処理は数秒から数十秒かかるため、HTTP 接続を保持して待たせるより、クライアントに指数バックオフで再取得させるほうが素直に回りました。リトライ設計の具体については Gemini API 503 エラー(Service Unavailable)の原因と解決策 にまとめています。
Managed Agents が来た今、自前で A2A を建てる意味はあるか
2026 年 7 月時点で、Gemini API の Managed Agents が公開プレビューに入りました。Google がホストする隔離 Linux サンドボックス上で、状態を保持する自律エージェントを構築・デプロイできます。汎用モデルとして antigravity-preview-05-2026 が提供され、計画・推論・コード実行・ファイル操作・ウェブ閲覧をサンドボックス内で完結させられます。
これを知ったとき、正直なところ「自前で A2A サーバーを建てた時間は何だったのか」と一瞬思いました。ですが、両者を並べてみると役割は綺麗に分かれます。
| 観点 |
自前 A2A(本記事の構成) |
Managed Agents |
| 実行環境 |
自分の Cloud Run。ランタイムを自由に選べる |
Google ホストの隔離サンドボックス |
| 他フレームワークとの相互接続 |
プロトコルが標準なので可能 |
Gemini エコシステム内が前提 |
| 状態の保持 |
自分で設計・実装する |
マネージドで保持される |
| 運用の手間 |
認証・監視・スケールを自分で持つ |
大部分を委譲できる |
| 向く場面 |
既存資産や他社エージェントを繋ぐ |
ゼロから自律処理を立ち上げる |
A2A が解くのは「繋ぐ」問題で、Managed Agents が解くのは「建てる」問題です。ここを混同すると選択を誤ります。
すでに LangChain のエージェントや社内の独自実装が動いていて、それらを協調させたいなら A2A です。Managed Agents はサンドボックスの中で完結する設計なので、外部の異種エージェントを対等に呼び合う用途には向きません。
逆に、これから自律処理をひとつ立ち上げるだけなら、自前で FastAPI サーバーを書き、JWT を実装し、Cloud Run のスケール設定を詰める必要はないでしょう。Managed Agents のサンドボックスに置いたほうが、確実に早く着きます。サンドボックスからの成果物の持ち出しについては Managed Agents の隔離サンドボックスから成果物を安全に持ち出す が参考になります。
私の現在の構成は折衷です。既存の3本は A2A のまま残し、新しく足す単発の処理は Managed Agents に載せる。片方に寄せる必要はありません。A2A は HTTP の上の約束事に過ぎないので、後から Managed Agents 側に A2A の口を被せることもできます。
公式ドキュメントに書かれていない、運用で効いた3つのこと
最後に、仕様書には現れないが実運用で効いた点を書き留めておきます。
1. エージェントカードの url は環境変数から組み立てる
カードに本番 URL をハードコードすると、ステージング環境が本番のエージェントを呼びます。私はこれを一度やりました。ステージングのテストが本番のトークンを消費していたのです。os.environ["SERVICE_URL"] から組み立て、起動時に空なら落とすようにしています。
2. tasks/get のポーリング間隔は固定にしない
初期実装では 1 秒固定でポーリングしていました。エージェントの処理が 30 秒かかると、30 回の無駄なリクエストが飛びます。1 秒から始めて上限 5 秒まで 1.5 倍ずつ伸ばす形に変えたところ、同じ処理でのポーリング回数が 30 回から 11 回に減りました。SSE ストリーミングが使える相手なら、そもそもポーリングしないのが最善です。
3. 監視は「タスク完了率」ではなく「状態遷移の滞留時間」を見る
完了率だけを見ていると、working のまま止まったタスクを見逃します。私は submitted → working と working → completed それぞれの経過時間を Cloud Monitoring のカスタム指標に出し、後者の 95 パーセンタイルが平常時の 3 倍を超えたらアラートを飛ばしています。どのエージェントが詰まっているかが一目で分かるようになりました。エージェントの動作監視をより体系的に組みたい場合は Google ADK Callbacks & Guardrails 実装ガイド が踏み込んだ内容になっています。
次に試すこと
A2A の価値は、繋ぎ目のコードを自分で書かなくてよくなることに尽きます。エージェントカードで能力を宣言し、タスクのライフサイクルに従って会話する。それだけの約束事が、フレームワークの壁を越えさせます。
もし今すでに動いているエージェントが2本以上あるなら、まずは片方だけを A2A サーバーとして公開してみてください。全部を作り直す必要はありません。既存のエージェントの前に FastAPI を1枚置き、/.well-known/agent.json と /tasks/send の2つだけを生やす。ここまでなら 1 時間ほどで届きます。
その1本が A2A で呼べるようになったとき、呼び出し側から消えるグルーコードの量に驚くはずです。私はそこで、この繋ぎ目に付き合ってきた時間の意味がようやく分かりました。
繋ぎ目に費やす時間が少しでも減れば、この記事を書いた甲斐があります。最後までお付き合いくださり、ありがとうございました。