夜通し走らせたエージェントの連携パイプラインが、朝には「完了タスク 0 件」で止まっていました。ログにエラーは一行もありません。オーケストレータは三つのタスクを送ったまま、いつまでも完了を待ち続けていました。受け手のエージェントは処理を始めた気配すらなく、タスクは working の状態で静かに固まっていたのです。
厄介だったのは、どのエージェントのどこで止まったのかが分からなかったことです。A2A(Agent-to-Agent)の失敗は、例外を投げて派手に落ちるより、状態が進まないまま黙り込む形で現れます。ここでは、その静かな詰まりを状態遷移の時系列として計測し、滞留したタスクを辺の単位で名指しする監視の実装を、個人開発でエージェントを回してきた実感とあわせて整理します。
詰まりは、なぜ静かに起きるのか
A2A のタスクは submitted → working → input-required → completed / failed / cancelled という状態機械で進みます。エラーで落ちるなら failed に遷移し、少なくとも「失敗した」という事実は残ります。ところが実運用で多いのは、failed にすら至らず、非終端状態のまま時間だけが過ぎるパターンです。
私が観測した「静かな詰まり」は、大きく三経路に分かれます。
経路 何が起きているか 送信側から見える症状
envelope 未達 trace_id 欠落や宛先誤りで、そもそも相手にメッセージが届いていない submitted のまま working にすら遷移しない
capability 発見漏れ 受け手の capabilities に skill が未登録で、discover が候補を返さない 送ったつもりが、相手が存在しない扱いになる
task_state 遷移漏れ 受け手が処理は終えたのに completed を明示せず応答した 処理は済んでいるのに working のまま待たされる
三つとも共通するのは、エラーではなく「遷移が起きない」ことで詰まる点です。だから例外ログをいくら眺めても手掛かりが出てきません。見るべきは「どの状態に、どれだけの時間とどまっているか」という時間軸のほうです。
状態遷移を一本の時系列として記録する
まず決めるのは、監視の単位です。個別のログ行ではなく、タスクごとの状態遷移を一本の時系列として持ちます。各遷移に trace_id・タスクID・エージェント・遷移前後の状態・時刻を残し、状態にとどまった時間(dwell time)を測れる形にします。
記録する項目 意味
task_id / trace_id どのタスクの一連の流れか
agent その遷移を起こしたエージェント
from_state → to_state 状態遷移の辺。詰まりを名指しする単位
dwell_seconds 直前の状態にとどまっていた秒数
肝は、詰まりを「タスク単位」ではなく「辺(from → to)単位」で捉えることです。同じ working 滞留でも、submitted → working が起きないのか、working → completed が起きないのかで、疑う相手がまったく変わります。前者は envelope か discover、後者は受け手の遷移漏れです。辺で見ると、原因の当たりが一段速くつきます。
監視つき A2A クライアントを実装する
以下は、送信と受信のそれぞれに遷移記録をはさみ、非終端状態に長くとどまったタスクを watchdog が名指しする最小構成です。記録は軽量なストア(ここではメモリ、本番では Redis 等)に落とします。
# 環境変数: A2A_STALL_FACTOR="3"
import asyncio
import time
import uuid
from dataclasses import dataclass, field
from collections import defaultdict
from antigravity import A2AClient, TaskRequest, Message
TERMINAL = { "completed" , "failed" , "cancelled" }
@dataclass
class Transition :
task_id: str
agent: str
from_state: str
to_state: str
at: float
dwell: float
@dataclass
class TraceStore :
events: list[Transition] = field( default_factory = list )
_last: dict = field( default_factory = dict ) # task_id -> (state, at)
def record (self, task_id: str , agent: str , to_state: str ) -> None :
now = time.monotonic()
prev_state, prev_at = self ._last.get(task_id, ( "<start>" , now))
self .events.append(Transition(
task_id = task_id, agent = agent,
from_state = prev_state, to_state = to_state,
at = now, dwell = now - prev_at))
self ._last[task_id] = (to_state, now)
def open_tasks (self) -> list[tuple[ str , str , str , float ]]:
"""非終端状態でとどまっているタスクを (task_id, agent, state, 経過秒) で返す"""
now = time.monotonic()
out = []
for task_id, (state, at) in self ._last.items():
if state not in TERMINAL :
agent = next ((e.agent for e in reversed ( self .events)
if e.task_id == task_id), "?" )
out.append((task_id, agent, state, now - at))
return out
def edge_p95 (self) -> dict[tuple[ str , str ], float ]:
"""辺ごとの dwell time の p95。滞留閾値の逆算に使う"""
buckets = defaultdict( list )
for e in self .events:
buckets[(e.from_state, e.to_state)].append(e.dwell)
p95 = {}
for edge, vals in buckets.items():
vals.sort()
p95[edge] = vals[ max ( 0 , int ( len (vals) * 0.95 ) - 1 )]
return p95
記録側を用意したら、送信と受信の要所で record を呼びます。受け手が completed を明示し忘れても、watchdog 側は「working にとどまり続けるタスク」として検知できるので、遷移漏れそのものを外から観測できます。
store = TraceStore()
class MonitoredClient ( A2AClient ):
async def send_task (self, target, task: TaskRequest):
store.record(task.id, self .my_agent_id, "submitted" )
resp = await super ().send_task(target, task)
store.record(task.id, target, "working" )
return resp
async def on_state (self, task_id: str , agent: str , state: str ):
store.record(task_id, agent, state)
async def watchdog (stall_factor: float = 3.0 , interval: float = 10.0 ):
"""滞留を辺の p95 × factor で判定し、名指しで警告する"""
while True :
await asyncio.sleep(interval)
p95 = store.edge_p95()
for task_id, agent, state, elapsed in store.open_tasks():
# その状態から出ていく辺の p95 を基準にする
budgets = [v for (f, _), v in p95.items() if f == state]
budget = max (budgets) * stall_factor if budgets else 60.0
if elapsed > budget:
print ( f "⚠️ STALL task= { task_id[: 8 ] } agent= { agent } "
f "state= { state } elapsed= { elapsed :.0f } s budget= { budget :.0f } s" )
open_tasks が非終端で滞留中のタスクを吐き、edge_p95 が辺ごとの標準的な所要時間を出します。watchdog はこの二つを突き合わせ、標準の何倍も長くとどまっているタスクを、エージェント名と滞留状態つきで名指しします。「三つのタスクが止まった」ではなく「summarizer が working → completed を p95 の 3 倍を超えて起こしていない」まで絞れると、調査の初速がまるで変わります。
滞留閾値を dwell time から逆算する
固定のタイムアウト秒を全タスクに当てるのは、LLM を内部で呼ぶエージェントには乱暴です。要約と画像解析では所要時間が桁で違います。そこで閾値は、辺ごとの dwell time の p95 を基準に、その定数倍で決めます。
状況 扱い
elapsed < p95 × 3 正常範囲。待つ
elapsed > p95 × 3(working → completed) 受け手の遷移漏れを疑う。completed 明示を確認
submitted のまま滞留 envelope 未達か discover 漏れ。trace_id と capabilities を確認
再送後も同じ辺で滞留 本番投入を止め、人手調査へ回す
私の手元では係数 3 を初期値にしています。エージェントの応答時間の分布は右に長く裾を引くため、平均でなく p95 を基準に取るのが安定します。分布そのものが動く前提で、係数は固定で信じ切らず、誤検知の頻度を見ながら調整するのが現実的です。
詰まりを減らす構成の選び方
計測で詰まりが見えるようになったら、次は詰まりにくい構成に寄せます。A2A では、エージェント A が B を呼び、B が C を呼び、C がまた A を呼ぶ循環を作ると、滞留の因果が追えなくなります。私は次の判断で選んでいます。
中規模までは DAG(オーケストレータが Workers を順に呼ぶ)を採り、追跡のしやすさを最優先する
受け手には completed / failed の明示的な遷移を必ず返させ、黙って応答する経路を残さない
エージェントごとに認証トークンを分け、滞留の巻き添え範囲と漏洩時の影響を限定する
DAG は中央がボトルネックになりやすい代わりに、本番障害時に「どこで止まっているか」が秒で分かります。私自身は、スケール性より観測性を優先して DAG を選ぶことがほとんどで、この判断を強く推奨します。可視化のしにくいコレオグラフィ型は、Event Bus を挟んで遷移ログを一箇所に集約できる目処が立ってから採るのが安全です。
繰り返し引っかかったところ
同じ轍を何度も踏んだので、書き残しておきます。
watchdog の判定間隔を短くしすぎると、正常に長いタスクを滞留と誤検知して警告が溢れます。要約系は数十秒かかるのが普通なので、p95 を基準にしない固定閾値だと、対処のたびに現場が疲弊します。分布から閾値を決める一手間を省かないことです。
trace_id を送信側だけで採番して受信側でリセットしてしまうと、辺の時系列が二本に割れて滞留が追えなくなります。低レベル API を直接叩く場合ほど、envelope の trace_id を一連の流れで貫くことに注意が要ります。
そして一番の教訓は、滞留の検知は「止まったこと」しか教えてくれないことです。なぜ止まったかは、名指しされたエージェントのログを最後に人が読む工程で決まります。計測は原因を指し示すための道具であって、原因そのものではありません。ここを取り違えると、ダッシュボードは立派なのに障害は減らない、という状態に陥ります。
次の一歩
お手元に二つ以上のエージェントが連携する構成があれば、まず状態遷移だけを記録するところから始めてみてください。エラーログには何も出ていないのに、open_tasks に working のタスクが名前で並ぶ瞬間に、時間軸で見る意味が腑に落ちると思います。
静かな詰まりは、放っておくと「動いているはず」という思い込みだけが積もっていきます。状態遷移を一本の時系列にして辺で見られるようにすれば、その思い込みを一つずつ剥がせます。私もまだ係数の調整を続けている途中ですが、みなさんのエージェント運用の助けになれば嬉しいです。お読みいただきありがとうございました。