Agent を分散システムとして捉える:信頼性の問題はモデルにはなかった

Agent の安定化に着手したばかりのチームが最初に行うのは、たいてい prompt 最適化とモデルの入れ替えです。temperature を少し下げ、指示により忠実に従うモデルへ切り替え、few-shot の例を数件足す。この手の処方箋は品質確かに改善しますが、信頼性にはほとんど効きません。Agent がクラッシュする原因の大半はモデルの賢さの不足ではなく、モデルの外側にある実装上の穴だからです。

1 回の Agent 実行を分解して見ると、こういうものです。モデルが予測できない制御フローを生成し、その制御フローのなかに外部システムへの実際の呼び出しが埋め込まれ、呼び出しが状態を変え、状態が次の制御フローを再び左右し、最後にはこの一連の過程の結果を人へ提示する。どの段も失敗し、しかも失敗の方式是互いに増幅し合います。見目は分散システムに似ていても、ほとんどの分散システムより遥かに故障に強いものを作るのは難しい。その理由は三つの構造的な違いにあるからです。

ひとつめ、制御フローには静的な上限がない。普通のサービスの retry 境界は呼び出しグラフで決まっており、何段ネストし、各段で何回呼ぶかはコードに書き込まれている。Agent のステップ数、各ステップでどのツールを呼び出すか、その引数が何かは、すべてモデルがその場で決めるもので、コンパイル時点には retry 図そのものが存在しません。存在しないグラフに timeout 予算や circuit breaker を張ることはできない。

ふたつめ、1 回の呼び出しは必ず多段ネストになる。ツール呼び出しの下にはオーケストレーション層、ゲートウェイ層、下流サービス、データベースと続き、timeout を各段に同じ固定値で設定すると、timeout 予算はネスト段数に対して掛け算のように膨らむ。20 段のチェーンに 3 秒の timeout を効かせると、最外層は 75 分も待ってからようやく諦めることになる。まさに、セッション全体の timeout が 1 分しかないようなものです。

みっつめ、推論と副作用が同じ軌跡に結合している。普通のサービスなら、推論が失敗したら推論をやり直し、書き込みが失敗したら書き込みをやり直す。2 本の経路はきれいに分離されている。Agent では、1 回の retry が推論をもう一度回す可能性があるだけでなく、すでに効いてしまった書き込みをもう一度発火させる可能性もある。これが idempotency 問題の根源であり、本記事後半で長い紙面を使う理由でもある。

したがって本稿の構成は、信頼性を三つのレイヤーに分解して語ります。制御層はループと状態を扱い、ループ停止検出、ステップ予算、circuit breaker に対応します。データ層はコンテキストと時間制限を扱い、timeout 予算の配分と継承に対応します。効果層は外部世界を担当し、idempotency、retry、補償トランザクション、レート制限に対応します。三つのレイヤーの障害は互いに伝染し、最終的には chaos test で三つをまとめて検証します。

Agent に固有の6種類の障害

機構を 1 つでも書き始める前に、まず治すべき病を並べておきます。以下 6 つは Agent の場面で頻度に高いもので、いずれも普通のマイクロサービスに対応するものがあるものの、Agent ではより極端に現れます。

1、タイムアウトの欠落:上限のない長いトランザクション

典型的な症状は、ツール呼び出しに timeout パラメータがなく、TCP 層の再送が尽きるまで待つことです。あるいは timeout を書いていても、オーケストレーション層からツールアダプタまでの途中の層で渡し忘れ、最終的にフレームワークのより緩いデフォルト値が使われてしまうことです。ユーザーから見えるのは、ボタンが 40 分回って、そのあと空のレスポンスが返ることだけです。

より厄介なのは、全体の timeoutだけを設定してアイドル timeoutを設定していない場合です。ストリーミング応答では、相手が接続を開いたあと 1 バイトも送ってこないので、サーバーはそのまま吊り続けます。あるいは agent が 5 秒ごとにハートビートを吐く一方で、ツールはとっくに死んでいるのに接続は生きている、という具合です。最初の 1 バイトの timeout とストリーム間隔の timeout は分けて設定しなければなりません。この項目は MCP をはじめ各種の Agent ツールプロトコルでも、いまでもしばしば抜け落ちています。

2、非冪等なリトライ:1 回のタイムアウトで 3 通のメール

もっともコストが高くつく種類です。ツールはすでに成功しているのに、応答が戻りの途中で失われ、クライアントが見えないままリトライする。そのたびにメール送信、料金決済、注文、リリースがそれぞれ 2 回ずつ行われます。多くのチームは本番投入前にテストダブルでツールを固定値返すものへ差し替え、成功率がとても良く見えたのですが、本番 1 周目には二重請求のクレームが届きます。

より厄介な変種がモデルレベルの自己修復です。ツールの例外テキストをそのままコンテキストへ押し戻すと、モデルが自分で判断してもう一度呼びます。この種の retry は、フレームワークに書いた retry ポリシーを完全に迂回します。ゲートウェイで retry 回数を見ることはできず、目に付くのはモデルの推論コストが静かに倍増することだけです。

3、部分失敗の置き場がない:3 つ成功、4 つ目がタイムアウト

Agent が 5 歩の操作を計画し、最初の 3 歩はデータベース書き込みに成功し、4 歩目でタイムアウトしました。ここでシステムにすぐ答えを出さなければならない問いがあります。すでに効いた 3 件のレコードを保持するのか、それとも巻き戻すのか。多くの実装は、何も起きなかったかのように振る舞い、例外を投げ返してユーザーのリトライを待ちます。その結果、ユーザーがリトライすると、すでに成功済みの最初の 3 歩がもう一度走ってしまいます。

4、ループが収束しない:考えているように見えるが、実際は原地で回っている

モデルの推論過程にはランダム性があり、同じ状態から千種にも異なる言い回しの無効な行動を生成することがあります。この種のループを「同じ文字列の繰り返しを検出」しても捉えられません。各ステップのテキストがすべて違うからです。検出すべきなのは意味上の無前進です。状態サマリが変わらず、目標に近づかず、言い回しだけが変わっている状態のこと。

5、扇状過負荷:1 回の計画で 36 回の呼び出し

モデルが「この四半期のすべてのチャネルのデータを全部取ってきて」という文面を見て、30 個のサブ呼び出しを計画したとします。オーケストレーション層が素直に並行実行すれば、同じ下流サービスが瞬時に 30 倍のトラフィックを受け取り、レート制限が作動し、ひどければ雪崩まで起きます。単一ツール単位のレート制限はここではまったく効きません。サブ呼び出しは、そのサービスの立場からすればそれぞれ別のリクエストだからです。

6、フォールバックが伝播しない:upstream はもう忙しいのに、downstream はまだ貪欲に引く

モデルゲートウェイが 429 や 503 を返したのに、オーケストレーション層がそれらを解析せず、一括してツールエラーとしてモデルへ渡します。モデルは別の言い方を試して、連続数回で quota を突き破ります。この種の障害の特徴はコストのほうがエラーより速く上がることであり、まず請求書に気づいてから、監視データで原因を探します。

timeout 予算:層ごとにパラメータを渡すのではなく、コンテキストとして配分する

タイムアウトについて最初に直すべき認識は、管理すべきなのは待ち時間ではなく締切時刻だということです。timeout は長さ、deadline は絶対時刻です。呼び出し連鎖が 3 層あり、各層がリトライする可能性があるとき、timeout を層ごとに渡していくと予算が制御を失います。どの層も、その上の層にどれくらい時間が残っているかを知らないからです。

この 2 つの違いは一言でまとめられます。timeout は「最大でどれくらい待つか」に答え、deadline は「最遅いつまでに終わっていなければならないか」に答えます。ネストした呼び出しのなかで正しく減衰できるのは、後者のほうだけです。

予算配分の 3 つの形

実際の現場で現れる配分形態は 3 つしかありません。まずこの違いを分けたうえで、継承の話に進みます。

直列チェーン:複数の呼び出しが順番に実行され、同じ実時間予算を共有します。配分は残り量に従ったローリング配分によって行うもので、冒頭で一度に切り分けるのではありません。前半が早く終われば、残った時間は後ろに残すべきだからです。

並列ファンアウト:N 個の分岐が同時に走るので、残り予算を分割しなければなりません。各分岐が自分のコピーを得るのではなく、分割である点に注意してください。そうしなければ並列分岐の総予算が「親予算×分岐数」になり、timeout の爆発で最もよくある原因になります。

入れ子 Agent:子 Agent は親の残り枠を継承し、通常はさらに比率を掛け、絶対上限を設けます。比率は 1 つの高コストな子タスクが予算を丸ごと飲み込むのを防ぎ、上限は長尾リクエストが階層を伝わるうちに拡大しつづけるのを防ぐためです。

予算とモデル推論の結合

タイムアウトの另一半はしばしば忘れられています。ツール呼び出しが失敗したあと、モデルはもう一度推論してどうするかを決めなければならず、この推論にも時間と token がかかります。予算がツールだけをカバーしていず、推論をカバーしていなければ、最も選択を迫られる瞬間に使える枠がなくなってしまいます。結果として、乱暴に打ち切られて「タイムアウトのため完了できません」とだけ出力されるか、枠のないままこっそり推論を 1 回通してしまい、実際の所要時間が想定を超えてしまいます。どちらもよい結果ではありません。前者はすでに完了したツールの作業を無駄にし、後者は予算そのものの意味を失わせます。したがって予算は少なくとも 3 区間をカバーすべきです。モデル推論 1 回、ツールの往復 1 回、そして失敗後の再判断における推論 1 回です。

ここから導かれる結論は、推論そのものにも上限を設けるべきだということです。残り予算が 1 回の推論の典型的な所要時間より短いなら、モデルに考えさせ続けるのは純粋な無駄です。このときより妥当なのは、非常に短い出力のフォールバック経路を通して、モデルが最小の token で取得済みの情報を回答としてまとめるか、いっそ決定論的なテンプレートに委ねることです。「予算が足りなければ最小出力へ落とす」という挙動は、長時間のタスクの終盤に出現する頻度が想像よりずっと高く、単なる保険としてではなく、独立した設計に値します。

もうひとつ、見落とされがちな使い方があります。予算のほうで計画自体を拘束することです。プランナーがアクションリストを生成する時点で残り枠を見せると、「予算が 3 歩分なら 3 歩しか計画しない」方針を採ります。30 歩を計画して途中で打ち切られるのではなく、そのほうです。後ろのやり方は、すでに効いてしまった未完了の副作用を大量に残します。前のやり方なら、最初からそれらは生じません。これは実行層で遮断するよりずっと綺麗ですが、その代わりとして予算情報をプロンプトやツール記述のなかに明示し、モデルが実際にそれを読み取ることを確認する必要がある、という代償がかかります。

子 Agent の予算継承

子 Agent や expert モデルを並列に組み込んだ途端、予算はプロセス間、さらにプロバイダをまたぐリソースになります。実行可能なルールは 3 つあります。第一に、子 Agent は親の残り枠に比率を掛けて継承し、絶対上限を設けます。親が子に渡した総枠は、親が回収できる必要があります。そうでなければ並列分岐の数をモデル自身が決めてしまい、総予算が制御を失います。第二に、子 Agent が消費した枠は書き戻さなければならず、呼び出しツリー全体を貫くトークン台帳か元帳で記録し、親と子の双方が「まだ使える」と思い込むことを防ぎます。第三に、子 Agent の不可逆な副作用は親の台帳へ戻す必要があります。そうしないと復旧ロジックは親側の記録しか見られず、子 Agent がすでに送ったメッセージを見失います。

三番目がもっとも見落とされがちです。子 Agent は通常、自分自身の実行コンテキストと永続化を持っており、親はクラッシュ復旧のときに通常は自分の状態しかロードしません。両者のあいだに共有台帳がなければ、復旧時に「親はやっていないと思い込み、子 Agent はやってしまった」という隙間ができ、最後は推測で埋めるしかありません。さらに厄介なのは、この種の問題は実行時には露見せず、復旧経路のほうでたまに失敗するだけで、原因の特定が極めて難しいことです。共有台帳のコストは高くはありません。ふつう、副作用を書くときに親子関係を付したレコードを 1 件書き足すだけです。

3 つの配分形態の対比

形態配分のしかたもっともよくある 2 つの誤り結果
直列チェーン残り量によるローリング配分。前半が早く終われば後半が余る冒頭で固定枠を一度に切り分ける前半が速ければ枠を無駄にし、前半が遅ければ途中で枠が尽きる
並列ファンアウト残り枠を重みに応じて分割各分岐が親予算のコピーを得る総予算が分岐数とともに膨らみ、全体が制限で止まる
入れ子 Agent残り量に比率を掛け、絶対上限を設けて消費を書き戻す継承するだけで記帳しない親と子の双方が枠があると思い込み、実際の消費は想定を大きく超える

この表で並列の行にある 2 つの誤りは、方向が逆なのに結果は同じです。一方は総予算を膨らませ、もう一方は枠が最初の試行で尽き、補償の段階に金が残りません。前者は増幅、後者は枯渇です。共通点は、走り切らない run と宙に浮いた副作用が残ることです。だからこそ並列分岐の枠配分は補償予算と一緒に考え、それぞれが互いに干渉しないと考えるべきではありません。

必ず固定しておくべき 3 つのルール

  1. 予算はリセットできない。リトライは同じ締切時刻を再利用し、リトライごとに新たな計時をしてはいけません。retry を「増幅器」から「復旧手段」に引き戻すための最初のゲートです。
  2. 予算が足りなければ発火させない。必ずタイムアウトする呼び出しを発火すると、接続を無駄に消費し、下流の枠を占有し、半端な副作用を残すかもしれません。呼び出しの前に検査して、タイムアウトを待つべきではありません。
  3. 残り枠はプロセスを越えて伝播させる。ツールはしばしば独立してデプロイされた HTTP サービスなので、残りのミリ秒数をリクエストヘッダに入れて渡し、子サービスが消費量を書き戻せるようにしなければなりません。そうでなければサービス境界をまたぐ那一層の予算は、純粋な推測にすぎません。

実装:予算をコンテキストオブジェクトとして扱う

# 预算不是参数,是上下文:一次 run 只有一个墙钟截止时刻,
# 所有下游调用从它继承剩余额度,而不是各自拿一份新的 timeout。

from __future__ import annotations

import time
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field

_budget: ContextVar['Budget | None'] = ContextVar('budget', default=None)


class BudgetExceeded(Exception):
    pass


@dataclass
class Budget:
    total: float                      # 本次 run 的墙钟预算(秒)
    started: float = field(default_factory=time.monotonic)
    name: str = 'root'

    @property
    def remaining(self) -> float:
        return max(0.0, self.total - (time.monotonic() - self.started))

    @property
    def expired(self) -> bool:
        return self.remaining <= 0.0

    def check(self, need: float = 0.0) -> None:
        # need 是这次调用预计要花的时长。剩余量大于 0 但不够跑完时
        # 就该放弃,而不是发出去再等它超时。
        if self.expired or self.remaining < need:
            raise BudgetExceeded(
                f'budget {self.name} exhausted: '
                f'need={need:.2f}s remain={self.remaining:.2f}s'
            )

    def child(self, name: str, weight: float = 1.0, cap: float | None = None) ->Budget':
        # 并行分支按权重瓜分「剩余」额度;不给每个分支一份完整副本,
        # 否则 N 路并行总预算就变成了 N 倍父预算。
        share = self.remaining * weight
        if cap is not None:
            share = min(share, cap)
        return Budget(total=share, name=f'{self.name}/{name}')


DEADLINE_HEADER = 'x-agent-deadline-ms'


def outbound_headers() -> dict:
    b = _budget.get()
    if b is None:
        raise RuntimeError('no budget in context: run must enter run_with_budget()')
    return {DEADLINE_HEADER: str(int(b.remaining * 1000))}


def adopt_downstream(headers: dict) ->Budget':
    # 工具在另一个进程里:从请求头恢复自己的预算,继续向上衰减
    left_ms = int(headers.get(DEADLINE_HEADER, '0'))
    return Budget(total=left_ms / 1000.0, name='downstream')


@contextmanager
def run_with_budget(total: float, name: str = 'root'):
    token = _budget.set(Budget(total=total, name=name))
    try:
        yield _budget.get()
    finally:
        _budget.reset(token)


def call_tool(fn, spec, *args, **kw):
    # spec 是工具声明的延迟画像(p50/p99),用来决定给它多少额度
    b = _budget.get()
    allowance = min(spec.p99 * 1.5, b.remaining)
    b.check(need=allowance)
    return fn(*args, deadline=time.monotonic() + allowance, **kw)

この実装で最も見落とされやすいのは check(need=...) の事前判定です。p99 に係数を掛け、1 回の呼び出しに対する枠の上限とすることには理由があります。p99 が平均値に近づきすぎると、長尾が頻繁に予算枯渇を引き起こします。緩すぎると、1 つの遅いツールが run の時間を丸ごと消費します。多くのチームの出発点は p99 の 1.5 倍から 2 倍で、その後は本番の分布を見ながら調整します。

もう 1 点、触れておく価値があります。ツールがレイテンシプロファイル(p50/p99)を宣言すること自体には、プロファイルが自前の観測に基づくヒストグラムである場合にだけ意味があります。勘で書いた定数を p99 とするのは、予算判断を一度も検証されていない推測の上に築くことに等しい。方法は地味ですが効果的です。各ツールについて直近 1 時間の分位レイテンシを記録し、プロファイルを定期的に更新し、サンプル数の閾値を下回るツールは予算配分に参加させず、保守的な固定値を使うようにします。

リトライの前提は冪等性である

この節を一言でまとめると、リトライは耐障害性の策略ではなく、すでに効いたかどうか分からない副作用に対する当て推量にすぎません。副作用が冪等なら、外したときの代償はゼロです。冪等でなければ、外した瞬間にデータ破損になります。したがってリトライ策略の正しい順序は、まずツールに等級を付けること、次にリトライできるものを選ぶこと、そして最後に backoff のアルゴリズムの話することです。

ツールをリトライ可能性で 3 つに分類する

分類の基準は「読み取りか書き込みか」ではなく、「繰り返しても意味が変わるか」です。この基準で 3 つに分けると、その結果は直感よりも明快になります。

分類判定基準代表的なツール自動リトライ
自然に冪等繰り返しても同じ結果を生むか、読み取りの意味しかない検索、クエリ、ファイル読み込み、ディレクトリ一覧、翻訳、要約許可。最大で数回までリトライ
自然な冪等キーを持つ書き込みサーバーが業務キーで重複を除去し、重複する書き込みはまとめられるUPSERT、メッセージ ID による登録、外部伝票番号による記帳許可。ただし冪等キーが実際にサーバーまで渡っていることが前提
不可逆副作用がすでに外から可視で、業務キーでは重複除去できないメール送信、メッセージ送信、課金、サブプロセス起動、リリース自動リトライ禁止。補償でしか救えない

この表の実践的な価値は、「このツールをリトライしてよいか」という毎回議論する開放された問いを、ツールレジストリから直接読み出せる属性に変えた点にあります。分類はツールを登録する時点で宣言し、冪等キーの組み立て方も明示します。本番が動き出してから初めて「このツールはリトライできない」と気づくときには、たいていもう遅すぎます。

もう 1 つ、LLM 推論呼び出しは単独で触れておく価値があります。一見すると自然に冪等です。同じ入力なら同じ出力か? 違います。推論のリトライは外部副作用を生みませんが、お金を燃やし、本来は諦めるべき分岐を生き延びさせます。もっと重要なのは、推論とツールが同じループのなかに書かれているとき、推論リトライの回数がモデルの自己修正回数として数えられることです。その回数には上限がなければならず、そうでなければ次の節で扱うループの circuit breaker の話題に入ってしまいます。

冪等キーは意図に結びつける

冪等キーの組み立て方が、整套のしくみが成立するかを左右します。もっともよくある間違いは、キーを試行に結びつけることです。リトライのたびに新しい UUID を生成し、サーバーは新しいキーだと見て有無を問うことなくそのまま受け取ります。これは冪等性がまったくないのとまったく同じです。正しく結びつける対象は論理的な意図です。同じ意図に対するすべての試行、すべてのリトライ、すべてのプロセスをまたぐ再生は、同一のキーを共有します。

キーの構成には 4 つの要素を含めるのがよいです。テナント識別子、意図識別子(オーケストレーション層が計画段階で生成して永続化するもの)、ツール名、そして正規化した引数の要約です。正規化の工程はしばしば飛ばされます。するとモデルは最初には {'q': '天气'} を、リトライでは {'q': '天气 '} を渡し、鍵が異なってしまうため重複除去が失效します。正規化では、空白、大文字と小文字、キーの並び順、タイムスタンプのプレースホルダ、ランダム id といった、よくある差異を処理します。

忘れられがちな前提がもう 2 つあります。サーバーが本当にキーと結果の対応を保存しているのでなければならず、キャッシュをちらっと見るだけでは足りないということです。また、その保存の生存期間は最大のリトライ窓にプロセス再起動の復旧窓を足した長さを覆っていなければなりません。多くの実装は TTL を 1 時間に設定してしまうため、日をまたぐチェックポイントからの復旧再生が 1 度で重複除去を突き破ってしまいます。」

推論のリトライとツールのリトライは層を分けなければならない

この 2 種類の retry はしばしば同じ策略にまとめられ、その結果として予算もカウントも意味を失います。ツールのリトライが起動するのは明確な通信エラーかサーバーエラーで、再試行されるのは同じ呼び出しの実行そのものです。推論のリトライが起動するのは出力が書式や検証を満たさないときで、再試行されるのはモデルのサンプリング過程です。両者の予算・回数上限・観測指標は別々に集計すべきです。まとめてしまうと、下流の揺れも見えず、モデルの品質の揺らぎも見えなくなり、最後は「今日は少し不安定だ」と大雑把に言うしかありません。

もっと重要なのは、モデルが自発的に際限なく自己修復することを禁じることです。ツールの例外テキストをそのままコンテキストへ押し戻して、モデル自身にリトライを決めさせるのは、体験としては自然です。しかし代償は完全な制御を失うことです。上限も予算も backoff もなく、したがって組み合わせる circuit breaker も存在しません。現実的な妥協案として、各ツールに「モデルが自己修復できる回数」を宣言し、デフォルトを 1 回か 2 回にします。これを超えたら、そのツールを今回の run のなかで「利用不可」と印し付け、探索を続けさせる代わりに降格経路を強制的に通させます。

この自己修復回数と circuit breaker は重複ではなく、補完の関係にあります。circuit breaker が扱うのは「このツール全体が壊れているか」で、run をまたぐ統計を見ています。自己修復回数が扱うのは「今回のこの呼び出しをもう一度試す価値があるか」で、単一 run のなかのステップ数を見ています。混ぜると典型的な誤りが 2 つ生まれます。circuit breaker が緩すぎると自己修復回数が唯一の防衛線になり、モデルは壊れた同じツールを何度も叩きます。逆に circuit breaker がきつすぎると少しの揺れで開いてしまい、タスク自体はすぐ終わるのに、ユーザーは「現在利用できません」を繰り返し見ることになります。

サーバー側のレート制限シグナルを尊重する

下流が明確なレート制限やオーバーロードのシグナルを返してきたとき、リトライ戦略はサーバー側の指示に道を譲らなければなりません。いちばん基本的なのは、レスポンスに書かれた待ち時間をそのまま採用し、自分の backoff 式をそのまま被せないことです。サーバーが提案する値は実際のキュー長から得られたもので、ローカルの backoff はその事情を何も知りません。独自の backoff は、レスポンスにシグナルがまったく無いときのフォールバックとしてだけ使い、しかもそのときは攻めるのではなく保守側に回すべきです。「シグナルが無い」ということは、相手にすでにシグナルを生成する余裕が無いということであることが多いからです。

さらに一段上の話題が分散レート制限の協調です。複数の Agent インスタンスが同じ上流から同時に制限されたとき、各自が standalone で backoff しても同期したスパイクは生じます。退避の起点が、どのインスタンスでも同じ「上流が応答を返した時刻」になるからです。現実的な対策は 2 つあります。1 つは上流の制限状態を共有ストレージに短い間だけキャッシュし、全インスタンスで同じクールダウンウィンドウを共有させること。もう 1 つは裾の長い抖动を持つリトライ時刻の分布を導入し、リクエストをウィンドウの前半に集中させずに全体へ散らすことです。どちらも複雑な話ではありませんが、これがないとリトライ予算は単一インスタンスの総量を制約するだけで、集約時のピークは押さえられません。

モデル側から来るシグナルにも 1 つ注目すべきものがあります。推論リクエスト自体が返すオーバーロードエラーと、ツールが返すオーバーロードエラーでは、ユーザーにとっての意味がまったく違います。ツール側のオーバーロードには通常、降格経路(キャッシュを使う、データソースを替える、対象範囲を縮める)が残っています。モデル側のオーバーロードは、タスクそのものが成立しないことを示唆します。両方のエラーを「再試行可能」に分類すると、最悪の組合せが生じます。ツールがオーバーロードしている最中にモデルが大量リトライして、もともと持つはずだった推論クォータまで消費し尽くし、結局どこでも抜けられなくなります。

リトライの過程をユーザーから見せる

20 秒かけてようやく成功した呼び出しを、ユーザーは「固まった」と感じます。信頼性にはここに純粋に体験の次元で効いてくるところがあります。リトライに進捗が明示されていれば——何回目か、なぜリトライしているのか、あと何秒待てばよいのか——20 秒の体感上の損失は明確に縮みます。実装コストはきわめて低く、理由と推定時間をリトライイベントに入れておくだけで、インタラクションの設計を触る必要はありません。

逆に、リトライが確実に失敗に終わるなら、早く失敗して理由をはっきり伝えるほうが、リトライをすべて使い切るより親切です。判断基準は「残りの予算で、最低 1 回の成功に到達できるかどうか」です。到達できるならリトライし、届かないなら構造化された失敗をそのまま返します。このルールはリトライ予算とユーザーの待ち時間という 2 つの目標を揃えるもので、失敗したうえにさらに 30 秒待たせる、最悪の組合せを防ぎます。

バックオフとリトライ予算

指数バックオフに抖动を足すという組み合わせは常識ですが、そもそも嵐が発生するかどうかを決めるのはリトライ予算という考え方そのものです。リトライのリクエスト数は総リクエスト数の一定比率以内に収めなければならず、通常は 20 パーセント以内です。予算を超えたら fail fast して、呼び出し側に降格を任せます。加圧を続けるのではなく。

抖动は full jitter(フル・ジッター)を使うべきです。上限の近くで小さな揺らぎを加えるのではなく、[0, 上界] の区間上で一様サンプリングします。理由は単純で、指数バックオフに小さな揺らぎを足しても、大規模ではなお同期してしまうからです。すべてのクライアントのバックオフ曲線が同じ形になるからです。遅延を区間全体にランダムに散らして、はじめてスパイクを平らにできます。

実装:ステージ分け、リトライ予算、フル・ジッター

# 重试的前提是幂等:先给工具分级,再决定退避与预算。
# 幂等键固定在意图上,而不是尝试上 —— 这是整套机制成立的前提。

import hashlib
import json
import random
import threading
import time
from dataclasses import dataclass

READ, IDEM_WRITE, IRREVERSIBLE = 'read', 'idempotent_write', 'irreversible'


@dataclass(frozen=True)
class ToolSpec:
    name: str
    kind: str
    p50: float
    p99: float


def classify(exc: Exception) -> bool:
    # 5xx / 429 / 超时可重试;4xx 业务错与参数错重试一万次也是错
    code = getattr(exc, 'status_code', None)
    return code is None or code == 429 or code >= 500


class RetryBudget:
    '总请求数里只有一小部分允许是重试,超出就快速失败。'

    def __init__(self, ratio: float = 0.2):
        self.rate = ratio
        self.tokens, self.last = 10.0, time.monotonic()
        self.lock = threading.Lock()

    def allow(self) -> bool:
        with self.lock:
            now = time.monotonic()
            self.tokens = min(20.0, self.tokens + (now - self.last) * self.rate * 60)
            self.last = now
            if self.tokens < 1.0:
                return False
            self.tokens -= 1.0
            return True


def normalize(args: dict) -> dict:
    # 剥离空白与不稳定字段,否则同一意图会算出不同的键
    return {k: (v.strip() if isinstance(v, str) else v)
            for k, v in sorted(args.items())
            if not k.startswith('_') and k not in ('trace_id', 'request_id')}


def idempotency_key(spec: ToolSpec, args: dict, intent_id: str, tenant: str):
    if spec.kind == IRREVERSIBLE:
        return None                     # 不可逆:不重试,交给补偿事务
    payload = json.dumps(normalize(args), sort_keys=True, ensure_ascii=False, default=str)
    raw = f'{tenant}|{intent_id}|{spec.name}|{payload}'.encode('utf-8')
    return hashlib.sha256(raw).hexdigest()[:32]


def backoff_delay(attempt: int, rng: random.Random) -> float:
    # 全抖动:把尖峰摊到整个区间,而不是在上界附近小幅扰动
    return rng.uniform(0.0, min(8.0, 0.25 * (2 ** attempt)))


def call_with_retry(spec, args, intent_id, tenant, invoke, budget, deadline, rng=None):
    rng = rng or random.Random()
    key = idempotency_key(spec, args, intent_id, tenant)
    for attempt in range(4):
        try:
            return invoke(args, key=key)
        except Exception as exc:
            left = deadline - time.monotonic()
            if not classify(exc):                   # 4xx 业务错不重试
                raise
            # 先判次数上限,再判幂等键,让「不幂等绝不重试」成为短路规则
            if attempt >= 3 or (key is None and spec.kind != READ):
                raise
            if left <= spec.p50 or not budget.allow():
                raise                               # 预算不足 -> 快速失败
            time.sleep(min(backoff_delay(attempt, rng), max(0.0, left - spec.p50)))

コードの if attempt >= 3 or key is None and spec.kind != READ という判定の順序は、掘り下げる価値があります。回数の上限を先に、冪等キーを次にと並べるのは、「冪等でなければ絶対にリトライしない」というルールを、コード上の回避できない短絡として定着させるためです。複数の条件に散らばったオプショナルな項として書いてしまうと、半年後のリファクタリングで必ず 1 つ漏れます。

さらに、退避の sleep 時間が min によって残りの予算に収められている点にも注意してください。ここには直感に反する結論があります。退避時間も呼び出し時間も、同じように予算を消費するというものです。多くの実装は予算に p99 しか織り込んでおらず、退避を数えていません。その結果として、リトライチェーン 1 回が実際に占有するのは p99 の 3 倍に退避 2 回分で、予算はその場で溢れます。

忘れられがちなコストの次元がもう 1 つあります。プレフィックスキャッシュです。モデル呼び出しをリトライするとき、リトライ側のプロンプトのプレフィックスが前回と完全に同じなら、KV キャッシュがヒットして、リトライの限界コストは非常に低くなります。この性質は、請求書に現れる「リトライストーム」をそこまで深刻に見せないため、発見を遅らせてしまいます。したがって、コストのアラートは総量だけを見てはいけず、run あたりの平均推論回数を見なければなりません。

外部副作用と補償トランザクション

リトライでは、不可逆なツールの問題は解けません。送信済みのメールは戻ってこず、引き落とされた金額も戻りません。できるのは 2 つだけです。事前に不可逆な動作を減らすこと、そして事後に新しい動作で状態を許容範囲へ押し戻すことです。後者が補償トランザクションであり、利用できる手段はこれしかありません。

先に引き算をする:不可逆な動作の並べ替え

補償の仕組みを 1 つでも入れる前に、まず原価が最も低く効果が最も高いことをやります。不可逆な動作をできるだけオーケストレーションの最後に回すことです。あるタスクが確認メールを 3 通送り、データベースに 1 件レコードを書き込む必要があるのであれば、順序は「まず書き込む、検証する、メール本文を準備する、そして最後に送信する」となります。そうすれば、前のどの段階で失敗しても、補償すべきものは残りません。

この原則は、プランナーのプロンプト制約として直接埋め込め、オーケストレーションフレームワークの依存宣言にも落とし込めます。さらに踏み込むなら、ツールに可逆性の等級を付け、プランナーが順序を選ぶときに不可逆性の高い動作を依存グラフの最上位に回すようにします。特に警戒すべき反例が 1 つあります。「どうせ両方やるんだ」という理由で、モデルにメール送信と課金を並行させることです。並列に分岐した補償順序はほとんど解けないので、まず直列に並べ替える必要があります。

補償動作には強弱がある

補償を大雑把に「逆向きの操作」と理解すると、期待値が大きすぎます。補償の可靠度は呼びごとに大きく異なり、少なくとも 3 段階に分かれます。全部を同じものとして扱うと、補償が失敗したときにどう扱えばよいか分からなくなります。

等級特徴対処方針典型例
強い補償正確に元に戻せて、もともと冪等自動実行。失敗しても予算が尽きるまで安全にリトライできる未消費のタスクの取り消し、自分が作成した下書きの削除
弱い補償状態をほぼ一致に近づけることしかできない自動実行は 1 回だけ。失敗したら即座に人手へ回し、無限リトライはしない注文を人手確認待ちとしてマークする、訂正通知を 1 通送る
補償なし外部から撤回できず、代替動作もない主として実行前に回避。実行中にできるのはアラートと報告のみ課金が済み、対帳ウィンドウを過ぎている

分級したあとは、直接的に得られる点が 2 つあります。1 つは、自動補償の失敗処理にようやく根拠が生じたことです。強い補償は安心してリトライできます。繰り返しても代償がないからです。弱い補償は 1 回のリトライで十分です。繰り返すこと自体が 2 度目の被害になりうるからです。訂正通知を重複して送るくらいなら、補償しないほうが悪いのです。もう 1 つは、補償なしの動作を計画段階ですでに前もって回避できることです。実行時になって「どうにもならなくなる」と気づくのを待らずに済みます。

実務上の落とし穴がもう 1 つあります。補償動作の発効時間は、補償動作そのものよりも重要です。レコードを削除すれば即座に発効する一方、訂正メールを送っても相手が読むまで時間がかかります。一致性ウィンドウの中での意味はまったく違います。補償が足りているかを評価する基準は、システム内部で成功したかどうかではなく、外部に見える状態を許容範囲へ引き戻すまでどれくらいかかるかです。

補償はロールバックではない

データベースのロールバックは、状態をある時点へ復元することです。補償は新しい動作を前に実行して、システムを業務上許容できる近傍の状態へ到達させることです。両者はまったく違い、設計段階で切り分けておく必要があります。

差は 3 点に現れます。第 1 に、可視性です。ロールバックはデータベースの内部では見えませんが、補償動作は外部から見え、しかも補償自体が失敗することもあります。第 2 に、可逆性が対等とは限りません。返金は失敗したり、メッセージの取り消しはタイムアウトしたりするからです。第 3 に、時間ウィンドウです。補償が遅くなるほど代償は大きくなります。課金が済んで 10 分後に間違いに気づいても、返金フローはすでに対帳ウィンドウを過ぎていることがあります。

実践では、副作用ごとに 3 行のメタデータを明示してください。compensate が補償動作、compensatable_until が補償の有効期限、compensation_fail が補償失敗後の処置(アラート、人手への引き渡し、不一致の受容)です。この 3 行はオーケストレーションのロジックに書くよりも、ツールレジストリに書いたほうがはるかに信頼できます。

台帳は先に永続化してから実行する

補償の前提は、自分が何をしたかを知っていることです。台帳が Agent のコンテキストにだけ存在するなら、プロセスのクラッシュ、コンテキストの切り詰め、コンテキストの圧縮のいずれでも消えてしまいます。台帳は Agent の状態と同一トランザクションで永続化し、順序を守る必要があります。まず「これから X をする」と台帳に書き、次に X を実行し、終わったら状態を成功へ更新する、という順序です。

この「書前の意図」という順序が、復旧の一意性を保証します。クラッシュが台帳への書き込みの後、実行の前に起きた場合、復旧時には pending のレコードが 1 件見つかります。このとき X が実際に実行済みかどうかを判断する必要があり、判断できないなら実行済みと仮定して補償を発動させます。補償は 1 回多くても漏らしてはいけないからです。補償動作は冪等でなければ、この 1 回多い実行に耐えられません。

実装:Saga 台帳と逆順補償

# 副作用台账:正向动作与补偿动作都要在「执行前」落盘。
# 顺序固定为 记录意图 -> 执行 -> 标记结果,三步缺一不可。

import asyncio
import time


class CompensationFailed(Exception):
    pass


class Ledger:
    def __init__(self, store, run_id):
        self.store = store
        self.run_id = run_id

    async def declare(self, step, intent, compensate=None, ttl=None):
        rec = {
            'run_id': self.run_id,
            'step': step,
            'intent': intent,
            'compensate': compensate,          # 补偿动作的可调用标识
            'compensatable_until': ttl,        # 过了这个时刻补偿就无意义了
            'status': 'pending',               # pending / succeeded / failed /
                                             # compensated / uncompensatable / manual
            'result': None,
        }
        await self.store.insert_if_absent(rec)
        return rec

    async def mark(self, step, status, result=None):
        await self.store.update_where(
            {'run_id': self.run_id, 'step': step},
            {'status': status, 'result': result},
        )

    async def settled_but_uncompensated(self):
        rows = await self.store.find({'run_id': self.run_id, 'status': 'succeeded'})
        return [r for r in rows if r['compensate']]


async def resume_and_compensate(ledger: Ledger, registry, actor):
    # 恢复入口:先读台账,再决定继续还是补偿。不要凭上下文猜。
    rows = await ledger.store.find({'run_id': ledger.run_id})
    rows.sort(key=lambda r: r['seq'], reverse=True)   # 逆序补偿,后做的先撤

    for rec in rows:
        if rec['status'] in ('compensated', 'failed', 'uncompensatable'):
            continue
        comp = registry.get(rec['compensate'])
        if comp is None:
            await ledger.mark(rec['step'], 'uncompensatable')
            continue
        if rec['compensatable_until'] and time.time() > rec['compensatable_until']:
            await ledger.mark(rec['step'], 'manual')   # 过期补偿只能转人工
            continue

        for attempt in range(3):
            try:
                await actor.call(comp, args=rec['result'])   # 补偿自身必须幂等
                await ledger.mark(rec['step'], 'compensated')
                break
            except Exception as exc:
                if attempt == 2:
                    await ledger.mark(rec['step'], 'manual')
                    raise CompensationFailed(rec['step']) from exc
                await asyncio.sleep(0.5 * (2 ** attempt))

復旧ロジックの rows.sort(key=lambda r: r['seq'], reverse=True) という 1 行は地味に見えますが、実プロジェクトではよく漏らされます。逆順補償は Saga の定義的要件だからです。メールを送ってから課金する場合、取消時はまず返金してから通知を取り消さなければなりません。通知文面が「課金済み」という事実を前提に書かれているからです。順序を逆にすると、「返金はしたのにユーザーには通知が届いていない」という、説明のしにくい状態が生まれます。

'pending' 状態の扱いにも注意してください。上のコードでは pending のレコードは補償の対象から除外されています。結果が得られていない状態では、補償すべきかどうかを判断できないからです。より厳密にやるなら pending を「実行済みの可能性あり」とみなして、照会系のツールで実際の状態を突き合わせたうえで判断することです。こうした分岐を明示的に書いておくのは、下流の暗黙の処理へ任せるよりずっと信頼できます。

台帳をどこに置くかについて、务实的な選択肢が 1 つあります。オーケストレーションフレームワークがすでに永続チェックポイントを提供しているなら(各ステップの状態を外部ストレージへ書き込み、任意のチェックポイントから復旧できる機能)、台帳はチェックポイント自身のフィールドの 1 つに退化させ、専用のテーブルを新設する必要はありません。ただし、そのチェックポイントが 2 つの条件を満たすことを確認してください。書き込みのタイミングが各副作用の直前であること、復旧の粒度が単一ステップであることです。多くのフレームワークのチェックポイント粒度は run の中の 1 ステップでしかなく、これでは不十分です。

照合:補償の下支えとなる最後の層

補償はイベント駆動です。失敗があってはじめて補償が動きます。つまり扱えるのは「観測された」失敗だけです。台帳への書き込み自体を亡くした場合や、プロセスが 2 つの重要なステップのあいだでクラッシュして状態が永久に pending のままになった場合、補償は永久に起動しません。この種の「静かな失敗」は、あらゆる仕組みのなかで最も防ぎにくい類型です。ログを一切残さないからです。

対策は定期的な照合です。台帳を周期的にスキャンし、異常な状態にあるレコードをすべて洗い出して、サーバー側の実際の状態と突き合わせます。重点的に拾い出すべきものが 4 類型あります。状態が長時間 pending のまま停留していて、すでにタイムアウトしているもの(クラッシュの遺残の可能性)、状態が成功なのに有効期限を過ぎた後も補償の結論が出ていないもの(補償が漏れている)、補償が失敗と記録されているのに一度もリトライされていないもの(人手への引き渡しが抜けている)、そしてサーバー側の状態と台帳のレコードが矛盾しているもの(1 回の書き込みが成功にも失敗にも記録されていないことを示す)です。

照合のコストの大半は「サーバー側の実際の状態を照会する」という工程にあります。したがって記録の種類ごとにコストを分ける必要があります。冪等な書き込みは冪等キーから直接照会できるため、コストは低くなります。不可逆な動作は相手のシステムへ照合作業を依頼する必要があり、コストは高めです。よって照合の頻度は記録の種類ごとに分けて構いません。冪等なものは高頻度に回し、不可逆なものは日次で回します。

異常を検出した後の処置も分級します。自動で直せるもの(たとえばサーバー側がそもそも実行していないなら、台帳を失敗とマークしてクォータを解放する)は自動で直し、判断を要するもの(サーバー側で実行されたが結果が不明)は人手へ回します。その際、判断に必要な情報をひとまとめにパッケージ化し、前に述べた人手への引き渡しと同じ形式を使ってください。

最後に 1 点だけ強調します。照合は補償の代わりにはならず、下支えにすぎないということです。照合が異常を検出するまでの遅延は通常、分から時間の単位です。一方、補償の価値はほとんどすべて適時性に由来します。ユーザーにとって「10 分後に取り消された」と「その場で取り消された」の受容度はまったく違います。ゆえに照合の位置づけは最後の安全網であり、恒久的な不整合を最終的に残さないことを保証するものです。正常系で誤りを直す手段ではありません。

ループの停滞と circuit breaker

Agent のループが収束しないことには、大きく 3 つの異なる形態があります。検出手段を 1 つに決めてしまうと、そのうち 1 種類しかカバーできません。3 つは根因が異なるので、分けて扱う価値があります。

形態 1:真の重複

同じアクションが同じ引数の組で繰り返し実行されるケースです。検出はいちばん簡単で、アクションの fingerprint をハッシュして個数を数えるだけで足ります。根因はしばしば、ツールがモデルの処理しきれない結果を返している場合です。空のリストやエラーコードのように、そのままでは解釈できない返答を受けると、モデルは同じ経路を延々選んでしまう。

形態 2:進捗がない

アクションも言い回しも変わっているのに、状態サマリが変化していないというケースです。モデルは原地でぐるぐる回っています。同じページを 3 通りの言い方で見比べ、毎回まったく同じ結果を得て、毎回「情報が足りない」と判定し、そしてまた調べ直します。もっともコストのかかる形態です。毎ステップが実際にツールを呼び、実際に推論トークンを消費しているにもかかわらず、検出はもっとも難しい。

検出方法は状態サマリをハッシュすることです。数ステップ連続でサマリがまったく変わらないなら、外部の世界は変化していない可能性が高く、モデルが何度アクションを入れ替えても新しい情報は得られません。そのまま止めて、ユーザーに「今の情報では前に進めない」と報告します。ここでいう状態サマリは、モデルの真の進捗の根拠——読み終えたファイルの hash、調べたツールの戻り値の要約、TODO リストの内容——を覆っている必要があります。対話を構成するテキストの hash だけでは不十分です。

形態 3:予算切れでも収束しない

ループ自体は変化しているのに、最後までタスクが完了しないケースです。ハードな予算で止めるしかありません。重要な判断は予算が上限に達したときの挙動です。黙って切り詰めて、それでいて完成して見える答えを返すのは、ユーザーを騙すことになってしまいます。必ず明示的に失敗させ、ここまで完了した部分と未完了の部分を両方とも引き渡す必要があります。

ステップ予算をグローバル定数にしてはいけない

最大ステップ数をグローバルな定数として書くのは、本質的に「タスクの難度をモデル化していない」ことを認めることにほかなりません。現実的なのは、タスクの種別に応じて予算を変化させることです。「明確な指示を 1 文だけ投げる単発クエリ」なら 2 ステップで足りるかもしれません。3 つのシステムを突き合わせる照合系のタスクなら 20 ステップは要るかもしれません。オープンな調査系のタスクなら 100 ステップを越えても収束を保証できません。いまタスク種別の手がかりがないというなら、せめて予算を適応的にはできます。タスク種別ごとに過去の収束ステップ数の分布を集計し、ある分位点を取りそれにマージンを足したものを、その種別のタスクの予算にするのです。

適応的な予算のリスクは、外れ値に引っ張られやすいことです。あるタスクが上限に達したときの対処が「単に予算を引き上げる」だけだと、「難しいほど予算が増える、予算が増えるほど収束しない」という循環が生まれます。堅実な方法は分位点を中央値から高めの分位点の範囲に取り、単一タスクの予算には絶対的な天井を設け、そして到達率を長期指標として監視し続けることです。到達率が上がり続けるなら、タスクそのものが難しくなったのではなく予算系の設計に問題があるということです。後者なら通常は成功率が同時に上がりますが、前者はそうではありません。

run をまたぐヘルスメモリ

単一の run 内の circuit breaker は、いま走っているその 1 本の軌跡しか守ってくれません。本当によく効くのは、ツールのヘルスを run の外側に記録して、新しい run が立ち上がった最初から「このツールは直前に 8 回連続で失敗した」ことを知っている状態にすることです。このメモリは通常、共有キャッシュにスライドウィンドウのカウンタを付けたもので、コストはきわめて安いのに、体験の改善は直接的です。新しいタスクが壁にぶつかってから回り道する必要がなくなり、無駄な探り呼び出しも丸ごと省けるからです。

ヘルスメモリはさらに、計画にも参加すべきです。ツールの説明文に動的に「最近は不安定」という一文を添えて、プランナーが代替経路があるときは安定したツールを優先するようにします。これは運用の視点を推論の視点へ注入することに相当します。実行層で硬い停止をかけるよりずっと穏やかで、Agent の働き方にもよく合っているからです。モデル自体は、情報さえ十分なら正しい取捨選択ができるのが得意で、問題なのは「すべてのツールが同じだけ信頼である」と暗黙に仮定していることに尽きます。

circuit breaker の状態はユーザーに見せる説明を持たせる

circuit breaker が発動したとき、最終的にユーザーへ返す説明が「タスク失敗」であってはいけません。2 つの場合に分けて扱う必要があります。タスクを劣化した形で完了できるなら、「一部の情報はリアルタイム検索由来で、残りはキャッシュ由来です。リアルタイム検索が一時的に利用できないため」と明示的に伝えます。そのツールに依存しなければタスクが完了しない場合は、「リアルタイム検索サービスは現在利用できません。少し時間をおいて再試行してください。完了済みの部分が重複実行されることはありません」と説明します。とくに後者は重要で、circuit breaker の状態と冪等性の保証を同時に伝えることになり、ユーザーが再試行をためらう理由を大きく減らせます。信頼性メカニズムのユーザー教育まで同時に片付いてしまうわけです。

circuit breaker:ツールに上限を付ける

ループ検出が守るのは単一の run の内部であり、circuit breaker が守るのはrun をまたぐ同じ依存です。どちらも必要で、どちらも欠けません。1 つの run のなかで 3 つのツールを入れ替えてループを回しても circuit breaker では止まりません。一方、下流のあるサービス全体が劣化したとき、run ごとにそれぞれ再試行すると、故障を N 倍に増幅してしまいます。

標準的な 3 状態の circuit breaker はツールにそのまま適用できます。closed 状態で失敗率の閾値を集計して open にし、open 状態では高速に fail して冷却期間を待ち、half_open では少量のプローブを流します。Agent の場面では 2 点の修正が要ります。1 つは、失敗カウントに同一ツールへの連続失敗を別枠で数える必要があること。両方とも壊れているツールを交互に呼ぶと、失敗率が薄まってしまうからです。もう 1 つは、open 状態のエラーフィードバックが「このツールは一時的に利用できません。リトライしないで」と、モデルに構造化された形で届かなければならないこと。さもないとモデルが自分で何度も試行して、いま開いた circuit breaker を押し戻してしまいます。

実装:fingerprint、重複検出、3 重の予算

# 死循环不是异常,是「有进展地重复」。三种症状分别用三种检测。

import hashlib
import json
import time


class StepBudgetExhausted(Exception):
    pass


class LoopDetected(Exception):
    pass


class NoProgress(Exception):
    pass


class LoopGuard:
    def __init__(self, max_steps=40, max_repeat=3, window=4):
        self.max_steps = max_steps
        self.max_repeat = max_repeat
        self.window = window
        self.steps = 0
        self.fingerprints: list[str] = []
        self.state_log: list[str] = []

    @staticmethod
    def _digest(value) -> str:
        body = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str)
        return hashlib.sha256(body.encode('utf-8')).hexdigest()[:16]

    def _action_fp(self, tool: str, args: dict, state_digest: str) -> str:
        return self._digest({'t': tool, 'a': args, 's': state_digest})

    def observe(self, tool: str, args: dict, state_digest: str):
        self.steps += 1
        if self.steps > self.max_steps:
            raise StepBudgetExhausted(
                f'step budget {self.max_steps} exhausted at {tool}'
            )

        fp = self._action_fp(tool, args, state_digest)
        self.fingerprints.append(fp)
        self.state_log.append(state_digest)

        # 形态一:同一动作在同一个状态下反复出现
        repeats = self.fingerprints.count(fp)
        if repeats > self.max_repeat:
            raise LoopDetected(f'tool={tool} repeated {repeats}x on same state')

        # 形态二:动作在变,但外部状态摘要连续不变 -> 无进展
        recent = self.state_log[-self.window:]
        if len(recent) == self.window and len(set(recent)) == 1:
            raise NoProgress(f'state digest unchanged for {self.window} steps')


class ToolBreaker:
    '三态熔断:closed / open / half_open。反馈给模型时必须结构化,
     否则模型会自己反复试探,把刚打开的熔断又顶回关闭态。'

    def __init__(self, fail_threshold=5, cooldown=30.0, half_open_probes=2):
        self.fail_threshold = fail_threshold
        self.cooldown = cooldown
        self.half_open_probes = half_open_probes
        self.state = 'closed'
        self.fails = 0
        self.opened_at = 0.0
        self.probes = 0

    def allow(self) -> bool:
        if self.state == 'closed':
            return True
        if self.state == 'open':
            if time.monotonic() - self.opened_at > self.cooldown:
                self.state, self.probes = 'half_open', 0
                return True
            return False
        if self.state == 'half_open':
            if self.probes < self.half_open_probes:
                self.probes += 1
                return True
            return False

    def on_success(self):
        self.state, self.fails = 'closed', 0

    def on_failure(self):
        self.fails += 1
        if self.state == 'half_open' or self.fails >= self.fail_threshold:
            self.state = 'open'
            self.opened_at = time.monotonic()

「進捗なし」の検出で最も重要な設計の選択は、状態サマリのカバレッジ範囲です。対話履歴だけをハッシュすると、モデルの冗長な言い換えまで進展として数えられてしまい、決して発火しません。ツールの戻り値だけをハッシュすると、推論のレイヤーでモデルが進展を得た部分を拾いこぼします。適しているのは 3 つをサマリに含めることです。獲得済みの外部の事実の集合、TODO リストの変化、そして試行済みのアクションの集合。この 3 つがすべて変化して初めて、真の進展となります。

3 重の予算のなかで見落とされがちな次元がもう 1 つあります。コスト予算です。ステップ数の上限では、1 ステップだけ異常に費用がかかる場合を止められません。1 ステップごとに 10 万 token を消費する run が 20 ステップ進んでも、ステップ数は超えていなければ、お金はもう尽いています。token 数とドル建てのコストを、ステップ数と並ぶハードな予算として扱い、wall clock の予算と同じように上限に達した時点で明示的に失敗させること。それが 2026 年の Agent の本番化の前提条件です。

circuit breaker が開いたあとの扱いについて、現実的なヒントが 1 つあります。黙って失敗させ、モデルにひたすら再試行させるのではなく、計画側の入力として渡すというものです。モデルに対して「ツール X は一時的に利用できません」と明示した構造化エラーを返し、劣化経路を選ぶか、ユーザーに説明するかを選べるように指示します。そうすれば circuit breaker は故障の増幅を止めつつ、Agent の自律的な劣化処理能力を保てます。

モデル側の障害:タイムアウトではないが、同様に致命的

ここまでの各節で扱ってきたのは「ツールとインフラが失敗する」問題です。しかし Agent には、完全にモデル側で起きる別の種類の障害があります。タイムアウトもせず、エラーも出さず、ただ間違えただけです。この種の障害は chaos test がなくても再現でき、それでいてネットワークのジッタより発見しにくいことが多いのです。システムから見ると,一切正常に動いているように見えるからです。

出力が途中で切れ、構造が崩れる

もっともよくあるのは、構造化された出力が生成の途中で切れてしまうことです。原因は最大 token 長に触れたからかもしれませんし、ストリーミング接続が途切れたからかもしれません。表面上看は JSON の解析失敗として現れますが,実際には半製品が失敗として扱われているだけです。ここで重要なのは、解析失敗と生成失敗を切り分けることです。前者はモデルが考えはまとめてあるものの言葉が尽きた可能性を示します。後者は、モデルがそもそも使える出力を出していないことを示します。正しい対処は生成を続きまで続けるか、出力量を減らすことであり、最初の時点からやり直すことではありません。

実践でもっとも効果的なのは、プロンプトの中で出力量を明示的に抑え、解析に失敗したときのフォールバック解析経路を用意しておくことです。切れた JSON から、すでに完成しているフィールドを救出するようにします。美的とは限りませんが、実トラフィックにおける効果は、高価で結果の読めないリトライ 1 回よりずっと大きくなります。

ツールの幻覚:存在しないものを呼び出す

モデルは、ツールレジストリにまったく登録されていないツール名を呼び出したり、実在するツールに存在しない引数を渡したりします。この種の障害は、ツール名と実際の関数名がずれているときに特に多く発生します。説明文に書いてある名前と実際に登録されている名前が 1 文字違うだけで、モデルは説明文のほうを呼んでしまうのです。

防御は実行前に済ませなければなりません。例外の中でごまかすのではいけません。検証は 3 層です。ツール名がレジストリに存在するか、必須引数が揃っているか、引数の型と値の範囲が合法か。この 3 層はすべてオーケストレーション層で行ってください。下流サービスが 400 を返して、モデルに自己修正させる方式に頼ってはいけません。それは高価な往復 1 回で、しかもモデルがそのままもう一度送ってしまう確率はかなり高い。さらに徹底した方法として、ツール名の単一情報源をプロンプトとレジストリのあいだに設けるがあります。レジストリのドキュメントを手書きではなく生成し、不一致の物理的な発生源そのものをなくします。

誤った事実の蓄積

Agent に固有の、しかももっとも隠れた障害です。ツールがエラーになったあと、モデルがそのエラーメッセージを事実として推論に書き留め、それを土台として planning を続けます。たとえばクエリが「権限なし」を返したのに、モデルは「データは存在しない」と解釈し、実際のところ偏った結論を出します。また、書き込みがタイムアウトしただけで、モデルは書き込みが成功したと仮定して後続のステップへ進むのに、実際には何も起きていない、などといったことです。

対策は「事実」と「未決」をはっきりと分けることです。ツールが返す構造のなかに、今回の呼び出しの状態を明示する項目が必ずある必要があります。モデルが推論するとき、成功と明示された戻り値だけを事実として扱い、未決や失敗の戻り値は必ず処理待ち項目として扱わなければなりません。この制約をプロンプトに書くだけだと、遵守率はそれほど高くありません。失敗した呼び出しを構造化されたプレースホルダの説明に差し替えるように、フレームワークの側で次のコンテキストを組み立てるほうが確実です。生のエラーテキストをそのまま通すのではなく、です。生のエラーテキストがモデルに与える情報量は多すぎますし、その曖昧さこそが幻覚の原料になるからです。

引数の幻覚と事前検証

引数が欠けているのよりもっと隠れているのは、それなりに筋が通っているのに実際には存在しない引数の値です。モデルに注文 ID を埋めさせると、コンテキストから書式が似ているものを写してくるか、ある規則で勝手に捏造してくるかもしれません。この種の誤りは下流ではまず判断がつかず、「該当なし」として返されるのが落ちです。するとモデルがもう一度試し、もう一度捏造し、遅くとも収束しないループに陥ります。

有効な防御は、検証を判断できる場所まで前倒しすることです。引数はツールが返した集合のなかから取るものでなければならず、モデルが自由に生成するものではいけません。検証可能なパターン(固定の書式や長さなど)を満たさなければならず、存在確認を 1 回通らなければなりません。事前検査が失敗したとき、返すのはエラーではなく構造化された質問要求にして、モデルに再生成ではなく補完を促します。金額や時刻のようなフィールドは、寧ろ明示的なセレクターを使ってユーザーかシステムに供給させたほうがよく、モデルに手書きさせないでください。

タイムアウトの後:やり直すのではなく、チェックポイントから続ける

タイムアウトは「今回は失敗した」と処理されがちな、もっとも扱いが粗い signal ですが、本当の意味は結果がわからないということです。呼び出しは届いていなかったかもしれません。途中で実行されていたかもしれません。すでに完了していて、応答だけが戻ってこなかったかもしれません。この不確実性が、タイムアウトの正しい扱い方を明確な業務エラーとまったく違うものにしています。

タイムアウトは「結果不明」の一種である

結果不明に対する対処は 3 つあり、3 種類のツールに対応します。読み取り専用のツールなら、リトライが最適解です。重複実行のコストがないからです。冪等な書き込みツールなら、まず冪等キーで前回の結果を照会し、取れたらそれを使い、取れなければリトライします。この手順は直接リトライするより堅牢です。不要な重複実行を 1 回避けられるからです。不可逆なツールでは、必ず状態を確認してから判断し、必要なら補償フローに入ります。盲目的なリトライは絶対にしてはいけません。

「タイムアウト」と「明確な失敗」を同じエラー種別に入れるのは、多くの実装に潜む、もっとも深い設計ミスです。見た目は分類コード 1 行にすぎません。実際の被害は、不可逆なツールに対して,本来やってはならないリトライを行うことです。しかもテスト環境では気づけません。テスト環境は通常タイムアウトしないからです。

継続の 3 つの粒度

1 回の run がタイムアウトしたあと、継続のしかたは 3 つの粒度から選べ、コストと効果の差が非常に大きいです。

もっとも軽いのは現在のステップからやり直す方式です。完了済みのステップは冪等キーで飛ばし、タイムアウトしたステップからリトライします。実装はもっとも単純で、対象となる拠点のほとんどはこれで足ります。2 つめはチェックポイントから run 全体を再開する方式です。コンテキスト、完了済みのアクション、残りの予算をすべて永続化し、復旧ときに再度ロードします。非常に長いタスクではこれしか選択肢がありません,計画し直すのは高価であると同時に結論まで変えてしまうからです。

3 つめは劣化して届ける方式です。完了を追わず、完了済みの部分をまとめて結果としてユーザーに渡し、残りを明示します。失敗として扱われがちですが、長時間タスクが予算切れになったとき、もっとも妥当な結論です。対話型のプロダクトにとって、欠けた箇所を注記した答えを早く見せる価値は、まだ回っている進捗インジケータよりずっと大きくなります。

継続したことはユーザーに伝える

継続が発生したなら、返り値のなかに明確な継続マークを入れるべきです。どのステップから再開したか、それ以前に何を飛ばしたか、副作用が再実行された可能性があるか。ユーザーが「3 番目のステップから復旧し、前の 4 ステップは重複しません」と見るのと、初回とまったく同じ戻り値を見るのでは信頼感がまるで違います。後者ではユーザーはリトライをためらって,于是問題が宙に残ってしまいます。

もう 1 つ見落としやすい点があります。継続するときは 新しい意図識別子ではなく、元の意図識別子を引き継ぐということです。継続のたびに新しい意図識別子を生成すると、冪等キーが変わり、すでに成功していたステップがもう一度走ってしまいます。継続という仕組みの意味が完全に裏返ってしまいます。この制約は、呼び出し側が引数を正しく渡すことに期待して枠組の側で強制すべきものです。

リソース型の障害:子プロセス、ハンドル、リーク

Agent には普通のサービスに加えて、もう 系統的な障害モードがあります。それがリソースリークです。タイムアウトでも、リトライでも、冪等性の問題でもありませんが、それでもシステムを徐々に使用不能にします、症状は「負荷が高すぎる」と誤診されがちです。

Agent に特有のリーク要因

要因はいちばん多く出るのは 3 つです。1 つ目はツール呼び出しが残した子プロセスです。コマンド実行系のツールが長時間のタスクを起動し、タイムアウトして呼び出しは戻ったのに、プロセスは動き続けています。ツールを並行実行していると、数周のループのあいだに大量のゾンビプロセスが溜まり、マシンの CPU とメモリを食い潰します。2 つ目はファイルハンドルと一時ファイルです。ツールが開いたファイル、ダウンロードの中間成果物、ブラウザのセッションは、すべて例外経路でも閉じなければなりません。正常系だけを処理する実装では、失敗率が上がったときに同時にリークします。

3 つ目はセッションと接続です。ツール呼び出しのたびに新しい接続を作り、再利用もプールへの返却もしない実装では、高並行のなかで接続数が単調に増え続け、ファイルディスクリプタを上限まで使い切ります。4 つ目の、もっとも見落とされがちなのはコンテキスト内のリソース参照です。大きなファイルや HTML の丸ごとをコンテキストへ押し込むと、数ステップでコンテキストがパンクし、続く切り詰めによってモデルの挙動まで一緒に劣化します。

lease と回収

リークに対する汎用的な対策はleaseです。あらゆるリソースは生成時に lease を登録し、lease には有効期限を持たせます。コレクタが定期的に走査して期限切れの lease を回収します。呼び出し終了時のクリーンアップに頼るよりよほど堅牢です。異常終了に対しても timeout に対しても効くからです。

lease の登録はすべてのリソース種別を網羅しなければなりません。子プロセス、一時ファイル、開いたハンドル、そして貸し出した下流のクォータです。とくにクォータの項は強調しておきたい点です。Agent の場面でのクォータはしばしば run 単位で割り当てられているため、run がクラッシュして正常終了しなければクォータがリークします。長く見れば、そのテナント全体の枠を少しずつ漏らすことに等しくなります。この種の障害の特徴は「あるテナントの使用量が理由なく伸びているのに、呼び出しが見えない」ことで、切り分けが非常に困難です。

ツール呼び出しにリソースガードを付ける

ガードはアダプタ層に置くのが規則として比較的固定です。子プロセスは必ずプロセスグループに入れ、timeout 時は主プロセスだけでなくグループ全体を殺します。一時ファイルは lease 表に登録してクリーンアップ経路を用意し、各ツール呼び出しには独立したメモリ量とファイルサイズの上限を設けます。戻り値がしきい値を超えたら自動的に切り詰め、明確な説明を添えます。完全な内容をそのままモデルへ通すのではなく、意図した内容を明示的に指定できるようにしておきます。

このうち「戻り値の切り詰め」は、それ自体で 1 つの段落として触れておきます。信頼性の問題であると同時に、品質の問題でもあるからです。10 万行のログを返すツールは、コンテキストを押し広げ、推論を遅くし、無関係な情報のなかに 1 行だけ有用なものを探させにします。堅牢なガードは、まず構造化された重要フィールド(エラーコード、状態、対象フィールド)を優先して残し、長い本文はプレースホルダの説明に畳み込み、「詳細は折りたたまれている。必要になったときに必要に応じて照会して」とモデルに伝えます。

コストも予算の一種である

コストを別に引き出しているのは、それがふつう性能最適化の副産物とみなされがちですが、実際には信頼性の硬い制約のひとつだからです。予算を暴走して消費した Agent は、毎ステップが成功していても最終的には失敗です。予算を焼き、配額を占有し、同じテナントの他のユーザーを待たせるからです。

リトライの限界益は逓減する

最初のリトライの期待収益は通常かなり大きいです。瞬間的なジッタを処理しているだけなので、成功率が高いからです。2 回目と 3 回目は明確に逓減します。2 回ともリトライを通ったリクエストは、じつはジッタではなく本物の障害だからです。しかし多くの実装のバックオフ戦略は、まさにこの点で間違っています。エラー種別でリトライするか否かを決めるのではなく、これまでに何回リトライしたかで決める。正しいやり方は、リトライ回数そのものを判断要素に含め、そして「リトライのコスト」を明示的に予算に算入することです。そうすれば、最初のリトライがすでに予算の大半を消費していた場合、2 回目のリトライは自動的に却下されます。

より根本的な判断はこうです。ある呼び出しは、リトライが成功する確率にその価値とを掛けたものが、それが消費する予算を上回るときに限りリトライに値する。1 回 10 円程度の処理を 5 回リトライするのは割に合いませんが、10 万円の処理なら価値があります。この判断は単純な期待値関数として実装できます。まったく正確である必要はなく、桁が合っていれば明らかな無駄は避けられます。

コストを目標に書き込む

実践でもっとも効果的なのは、タスク種別ごとにコスト予算を設け、予算が一定の比率まで消費された時点で挙動を変えることです。予算を半分使った時点でも収束していなければ、プランナーに残りのステップを簡略化するよう促します。8 割まで使ったら探索をやめて、仕上げだけを行います。予算切れのときの劣化経路はあらかじめ設計しておく必要があります。その瞬間が訪れたとき、複雑な判断をする時間はもう残っていないからです。

関連する指標はもう 1 つ、1 回の呼び出しコストではなく成功 1 件あたりのコストです。こちらはリトライ、無駄なループ、失敗した試行のコストをすべて含むので、実際の経済性をより正しく反映します。あるチームでは 1 回あたりの呼び出しコストはきわめて低く見えるのに、請求書を見ると 1 タスク完了あたりのコストが想定の 5 倍だった、ということが起こります。差額はリトライと無進展のループのなかに隠れています。

人が引き継ぐべきとき

自動化としてどれだけ完成した仕組みにも終わりがあり、明確なエスカレーション経路が要ります。「人に任せる」ことを失敗ではなく最終防御と位置づけるには、自動化のなかで「これ以上は試行しない」という条件を明示しておく必要があります。

エスカレーション 3 段階のトリガー条件

第 1 段階は不可逆かつ補償できない場合です。ROLLBACK できない形で外部に効いてしまった、あるいは補償の有効期限を過ぎている。この種ののケースは自動フローを止めて人手に移すべきで、探りを入れたリトライや補償の試行は一切やってはいけません。自動化したまま進める唯一の結末は損害の拡大です。

第 2 段階は繰り返しの失敗です。同じ目標に対して複数の異なる戦略が連続して失敗した、あるいは circuit breaker が何度も開いた。この種のの問題の根本原因はたいてい枠組の外にあります。設定ミス、権限不足、下流のインターフェースの非互換 などです。自動化では解けないので、続けて試しても予算を消費するだけです。判断材料は「合計の失敗回数」ではなく「異なる戦略の失敗回数」でなければなりません。そうしないと、壊れた戦略 1 つが際限なくリトライされます。

第 3 段階は不確実性が耐えられる範囲を超えた場合です。補償は成功しうるものの業務上受け入れられない、あるいは続けることで法的・コンプライアンス上のリスクが生じる。この段階の判断には業務知識が必要なので、システムは判断に必要な情報をすべて整理して人に渡せるようにしなければならない。「失敗しました」と 1 文だけ投げるのでは足りません。

引き継ぎパッケージに含めるべきもの

引き継ぎの質が、人接手のコストを決めます。合格パッケージとして最低限必要なのは次のものです。何が起きたかの完全なタイムライン(各アクション、引数の要約、結果)、現在の確定事項と未確定事項(どれが効いていることが確認済みで、どれの状態が未知か)、試行済みと未試行の戦略、そして 進行中のアクションごとの帳簿(まだ動いている可能性があるか、先にキャンセルすべきか)。

このうち「まだ動いている可能性があるか」がもっとも落とされがちです。timeout したものの実行中の可能性がある子プロセスや非同期タスクが、引継ぎパッケージに記されていなければ、人が引き継いだあとに二重実行されてしまいます。正しい做法は、進行中のリソースの lease 情報も一緒に渡して、引き継ぐ人がまず何を確認すべきかを知るようにすることです。

信頼性の回帰検証

ここまでの仕組みは、その後のリファクタリングでこっそり壊されがちです。そしてその破壊は通常、機能テストでは露見しません。したがって信頼性そのものも回帰検証を必要とします。

障害シナリオ集合はバージョン管理する

做法は、chaos test のシナリオ集合をコードとして保守することです。各シナリオに番号、説明、期待される不変条件の結果を持たせ、リリースとともにバージョンを進めます。この方法の価値は、あるリリースによって circuit breaker が発火しなくなり、あるいは補償が実行されなくなったとき、リグレッションテストがすぐ検出することです。障害対応まで待たずに済みます。

シナリオ集合は、ここまでに触れてきたすべての注入レイヤーをカバーすべきで、意図的に組み合わせたシナリオを含めるべきです。「ツールは成功したが失敗を返し、同時に下流がレート制限し、同時にプロセスがクラッシュする」ような 3 つが同時に起きる組み合わせこそ、仕組みを本当の意味で試す瞬間です。個別に検証すれば通るが、組み合わせると失敗するというケースは、実務上ごく普通に発生します。

劣化シグナルの読み取り

信頼性メトリクスは劣化します。劣化そのものを正しく読む必要があります。もっとも誤読しやすいのは、あるメトリクスが突然大きく改善したときです。たとえば circuit breaker の発動率がゼロになる場合、それは通常システムが健全になったからではなく、閾値が誰かに一時的に上げられたか、ヘルスメモリの期限切れで集計基準が変わったからです。信頼性メトリクスの変更はコードの変更と同じレビューを通さなければなりません。そうしないとシステムに対する基本的な感覚を失います。

もう 1 つの読み取りの原則は絶対値ではなく比率を見るということです。circuit breaker の発動回数が 10 回から 100 回へ増えるのは、トラフィックが倍になっただけかもしれません。しかしリトライ増幅係数が 1.2 から 1.5 へ上がったなら、それは本物の劣化です。前に挙げた比率型メトリクスが重要なのは、トラフィック変化に鈍感で、絶対値がまだ動いていない段階で先にシグナルを出せるからです。

部分失敗:続けるのか、巻き戻すのか

判断の段階に入ると、核心の問いはひとつだけです。すでに生效した副作用を残すのか、取り消すのか。答えは三つの軸の組み合わせで決まります——可逆性、idempotency、そして失敗が retry 可能かどうか。これらを交差させてみると、判断の空間は実はかなり狭いままです。

生效済みステップの性質失敗地点が retry 可能失敗地点が retry 不可
読み取りのみ、副作用なし続行。通常はユーザー対応不要縮退:手元の情報で回答し、欠落した部分を明示する
idempotent な書き込み続行:先行ステップの再実行は key で畳み込まれるrollback か partial success を立て、ユーザーに判断を委ねる
compensation を持つ書き込み続行。compensation はまだ発動させないcompensation:逆順で実行して結果を報告する
不可逆かつ compensation なし可能な限り回避。発生したら即座に alert人間の手に handed off。自動で retry は絶対にしない

デフォルト方針は保守寄りに

この表は読むと複雑ですが、コードに落とし込むなら分岐は数本あれば足ります。実用的なデフォルトルールはひとつ、読み取り専用の接頭辞までは自由に続けてよいが、書き込み経路に入ったら以降の失敗を黙って進ませてはならず、必ず明示的に partial success の状態へ落とす、というものです。理由は単純で、読み取りの失敗は世界を変えません。けれど書き込み経路で失敗したのに続けると、Agent はすでに誤った状態の上で動き続け、単発の失敗を連鎖的なエラーへ拡大してしまうからです。

「partial success」という状態はユーザーから見え、かつ具体的に見せる必要があります。ユーザーへ返す内容には少なくとも三つのブロックが必要です。完了した操作の一覧、失敗した操作とその理由、そしてこの状態が回復可能かどうか。「3 ステップ完了、4 つ目が timeout なのでそのまま retry してよい(前の 3 ステップは重複しません)」と伝えられるのは、漠然とした失敗メッセージよりはるかに有用であり、何も起きていないように見せるよりずっと誠実です。

partial success を正直に報告する

partial success には固定の報告構造が必要です。モデルに自由に書かせてはいけません。少なくとも四つのブロックを備えます。完了した操作とその主要な結果。これでユーザーはシステムが実際に何をしたかを知れます。未完了の操作とその原因。これでユーザーは受け止められるかを判断できます。現在の状態が回復可能かどうか、つまり副作用を重複させずにそのまま retry できるかどうか。そして推奨する対処です。四つ目が欠けると、ユーザーがいちばん自然に選ぶのは retry であり、その retry のセマンティクスは完全に前と同じでなければ、前功尽棄します。

この構造には見落とされがちな役割がもうひとつあります。partial success を「異常状態」から下流が識別できる正常状態へ変えるのです。戻り値に明確な status フィールドと、完了した操作の機械可読な一覧があれば、呼び出し側は自前の画面で正しい反応を返せます。自然言語から「何が起きたか」を推測する必要がなくなるからです。既存の業務フローに Agent を組み込む現場ではこれが特に重要です。下流のシステムは「操作の大半は完了しました」という言葉が具体的に何を意味するか、判断する手段を持っていないからです。

逆に、明確に避けるべき実装がひとつあります。partial success を成功にすり替えることです。戻り値に「操作の大半は完了しました」と書き足しても、status フィールドは成功のままにする。呼び出し側はその値を見て後段のフローを進める——既知の不整合を、より遠い場所まで先送りして爆発させることになります。しかもその時点で原因の特定はずっと難しくなります。信頼性設計において、誠実な失敗と回復可能という status タグの組み合わせは、一見成功した偽の姿より価値が遥かに高い。問題になるのは見映えではなく、error が「扱える形態」を失ってしまうことにあります。

checkpoint が復旧の粒度を決める

rollback できるかどうかは、そのステップまで正確にたどれるかどうかで決まります。そのために checkpoint の粒度を副作用の粒度に合わせておく必要があります。1 つの run が 20 ステップあり 5 番目で失敗して rollback する、となったとき、checkpoint が 10 番目にしか残っていなければ頭からやり直すしかない。そして非 idempotent な副作用がある状態で頭からやり直すのは破滅です。

具体的な手順はこうです。外部副作用が永続化された直後に checkpoint を書き、その中に idempotency key、呼び出し引数、結果の要約、対応する compensation の識別子を記録します。再実行するときは checkpoint を境界として完了済みステップをスキップし、idempotency key によって再実行の安全性を担保します。この組み合わせが揃って初めて「回復可能な実行」が完成し、いまの主要な orchestration framework が補い続けている方向もここです。

ツールをまたぐ並行性とレート制限

Agent の並行性には、通常のサービスにはない性質があります。fan-out の規模をモデルが決めてしまい、先验的な上界がない。コードには「一度に最大 8 並列」とハードコードできますが、モデルが 36 個のサブ呼び出しを組み立てたら、制限はツールの入口ではなく出口で効かせなければなりません。

制限の階層:グローバルが単一ツールに勝つ

ツールごとに制限をかけるのは Agent の場ではほぼ効きません。同じ下流サービスに 30 個の別々のツール名からアクセスする可能性があるため、ツールごとのトークンバケットを 5 つずつ許可すれば、実効的な並行数は 150 になります。正しいやり方はトークンバケットを下流リソースの識別子に紐付けることであり、ツール名に紐付けることではありません。複数のツールが同じリソースへマップされるなら、バケットを共有します。

そのためには「どのツールがどのリソースグループに属するか」の宣言が要ります。たとえば「検索ツール」と「ベクトル検索ツール」はどちらも検索クラスタへ、「書き込み系ツール」と「読み取り系ツール」はどちらもプライマリ DB へマップします。ツール名が増えても、ツール名だけを宣言する制限設定は必ずいずれ迂回されます。

適応的な並行数は固定の上限に勝る

上限を高くしすぎると下流が悪化したときにシステムを押しつぶし、低くしすぎると下流が健全なときにスループットを捨てる。

適応的な並行数はend-to-end レイテンシを並行上限のフィードバック信号として使う。レイテンシが上がり始めたならキューはすでに堆積しつつあり、ここでさらに呼び出しを受け入れたら悪化するだけです。レイテンシが安定していれば余裕があるので、少しずつ広げていける。これは queueing theory から生まれた古典的な手法で、Envoy の adaptive concurrency filter や service mesh 内の類似実装もこの考え方です。

Agent の場での具体的な利点はさらに明確です。1 回の run には形も性格も異なる呼び出しが多数含まれうるため、固定上限では遅いツールを止めるか速いツールを通すかのどちらかに偏ります。適応的にすれば、遅いツールでは自動的に縮み、速いツールでは自動的に緩みます。

制限はモデル側の quota と連動させる

見落とされがちな結合がひとつあります。ツール側の制限とモデル側の quota 消費は、同じ鎖の上に並ぶふたつのバルブです。もし一度の fan-out が推論コストと下流への圧力を同時に拡大するなら、片方だけを押さえれば圧力はもう片方へ移ります。たとえばツールの並行数を 8 から 4 に落としても、計画ロジックが変わらなければモデル側の token 消費はほぼ減りません。むしろ一歩ごとに待つ時間が長くなり、鎖全体が待ちのためにむしろ遅くなりうるのです。

より根本的な対策は源头で fan-out を減らすことです。「30 件を 1 件ずつ調べる」を「まとめて 1 回で調べる」に、「1 件ずつ変更する」を「batch で投入する」に変える。この手の変更は構造的な効果で、制限の仕組みに一切依存せずそのまま効きます。それに対して並行上限を調整するのは、同じ総量をより長い時間に均すだけ。ユーザー体験は悪化しても仕事の総量は変わりません。制限のパラメータに手を入れる前に、先に「並行上限で本来は batch interface で解くべき問題を隠していないか」を確認してください。

連動する次元がもうひとつあります。コスト予算です。残りのコストが現在の計画をまかなえないなら、正しい行動は待つことではなく作業範囲を狭めることです。「すべてのレコードを書き出す」というタスクに対して予算が 3 分の 1 しかなければ、直近 30 日分に絞るか、三回に分けてユーザーに別々に起動させるかにすべきです。待つ方針は multi-tenant 環境では fairness の問題にすぐ化けます。予算の多いタスクがキューを埋め、予算の少ないタスクは永久に後ろへ回される。後者はむしろ緊急なインタラクティブなリクエストだったりするからです。

制限と縮退の間には、見落とされがちな順序の問題もあります。先に縮退中であることをユーザーに伝えてから、縮退を実行する。多くの実装は縮退の発動時に黙ってリアルタイムの問い合わせをキャッシュに差し替え、ユーザーは古いデータを最新だと信じて受け取ります。縮退の注記を戻り値に入れるのは partial success の報告と同じ仕組みです。この 2 つは別々に書かず、同じ構造を共有すべきです。

fan-out の収束と write conflict

制限に加えて、計画段階で fan-out の規模を絞る必要があります。手法は三層あります。プロンプト層で一度の返却で返す操作数を制限する。オーケストレーション層で並列度にハード上限を置き、超過分を待機させる。実行層で同一リソースに対する複数ステップを一つの batch tool に統合する。最初の二層は戦略、最後の一層は工学です。最も効果が高いが、コストも最も高い。

write conflict は別の、直交した問題です。同一リソースへの書き込みは直列化しなければならない。さもなくば古典的な lost update が起きます。Agent ではこの問題は起きやすい。推論パターン「先に照会してから変更する」がそのままアクション列に埋め込まれているからです。モデルは在庫を照会し、価格を変更し、ついでに在庫をさっき照会した古い値で書き戻す。対策は 3 種類あります。optimistic concurrency(バージョン付きの書き込みで、衝突したら retry して読み直す)、pessimistic lock、そして single-writer モード(すべての書き込みを同じキューへ入れ、1 つの consumer が実行する)。財務や在庫のような場面では、single writer に optimistic なバージョン番号を組み合わせるのが最も手間のない選択です。

実装:リソース単位のトークンバケットと適応的並行制御

# 并发控制挂在「下游资源」上而不是「工具名」上。
# 同一个集群被多个工具访问时,共享一个桶才拦得住扇出。

import asyncio
import time
from collections import defaultdict


class TokenBucket:
    def __init__(self, rate: float, burst: float):
        self.rate = rate
        self.burst = burst
        self.tokens = burst
        self.ts = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self, n: int = 1) -> None:
        while True:
            async with self._lock:
                now = time.monotonic()
                self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate)
                self.ts = now
                if self.tokens >= n:
                    self.tokens -= n
                    return
                deficit = n - self.tokens
                wait = deficit / self.rate
            await asyncio.sleep(wait)          # 队内等待,而不是直接拒绝


class Governor:
    def __init__(self, resource_rates):
        self.buckets = {r: TokenBucket(rate, burst=rate)
                        for r, rate in resource_rates.items()}
        self.concurrent = defaultdict(int)
        self.limit = 8
        self.min_limit, self.max_limit = 1, 64
        self.ewma_latency = 0.05
        self._sem = asyncio.Semaphore(self.limit)

    async def run(self, resource: str, tool_fn, *args, **kw):
        await self.buckets[resource].acquire()
        async with self._sem:
            self.concurrent[resource] += 1
            t0 = time.monotonic()
            try:
                return await tool_fn(*args, **kw)
            finally:
                self.concurrent[resource] -= 1
                rtt = time.monotonic() - t0
                # 自适应:延迟抬升就收缩并发,平稳就缓慢放开
                self.ewma_latency = 0.9 * self.ewma_latency + 0.1 * rtt
                if self.ewma_latency > self.limit * 0.25 and self.limit > self.min_limit:
                    self.limit = max(self.min_limit, int(self.limit * 0.9))
                    self._sem = asyncio.Semaphore(self.limit)
                elif self.limit < self.max_limit:
                    self.limit += 1
                    self._sem = asyncio.Semaphore(self.limit)

上の Governor にある適応ロジックには 1 か所、説明のための簡略化があります。セマフォ全体を置き換えると、実行中のタスクが一瞬だけ制御を失います。本番の実装では解放時に新しい上限に従って許可を追加するかを決めるべきで、セマフォを作り直すのではありません。簡潔さを保ったのはフィードバックロジックそのものを強調したくかったからで、レイテンシは堆積圧力の観測値であり、唯一の調整材料でもあります。

multi-tenant の場ではさらにfairnessを設計する必要があります。二十分走った長いタスクが大量の並行許可を握ると、他のテナントが飢餓します。現実的なのはテナントごとにバケットを割いてさらに輪番スケジューリングを重ねるか、単一 run の総許可数に上限を置いてユーザー優先度で配分することです。この層は multi-tenant SaaS 形態の Agent プラットフォームではほぼ必須で、内部ツールの場面なら先に作らなくても構いません。

混沌テスト:これらの機構が本当に効いていることを示す

ここまでの五節で扱ったすべての機構には、共通の弱点があります。正常系の上ではまったく見えないというのです。idempotency key を正しく書いてもフィードバックは何も返りませんし、compensation logic が正しく走ってもログが一行増えるだけです。障害を注入しなければ、retry budget が本当に止まるのか、circuit breaker が本当に開くのか、compensation がすでに入った書き込みをすべてカバーできたのかは分かりません。

したがって Agent の場における混沌テストは豪華な飾りではなく、これらの機構を検証する唯一の方法です。重要な認識はひとつ、混沌テストが表明するのは不変条件であって、成功率ではない。「リクエストが成功した」は意味のある assertion ではありません。大量の障害を注入している状況では成功率が本来就高くあるべきではないからです。意味があるのは「どの idempotency key からも副作用は高々一回しか発生しない」のほうです。

注入点の四つの階層

外側から内側へ並べると、注入点は四つの層に分かれ、それぞれの層が異なるものを検証します。

ネットワーク層:ローカル proxy や middleware を使って timeout、connection reset、応答の切り詰め、5xx を注入します。この層は現実状態に最も近く、timeout budget と retry logic の両方を同時に検証できます。

ツールアダプタ層:ツール呼び出しの前後に instrumentation を入れ、「実行は成功したのに失敗が返る」という最も処理しづらい状況を作り出します。この層は専ら idempotency key を検証します。別の方法ではこれを測れません。

モデルゲートウェイ層:rate limit、コンテキスト長超過、出力の切り詰め、構造が不完全な JSON を注入します。出力の切り詰めはパース層の脆い前提を次々に引きずり出すので、専用のケースとしてカバーする価値があります。

オーケストレーション層とストレージ層:プロセスの crash、ストレージの一時的な利用不可、checkpoint の書き込み失敗を注入します。この層は台帳の write-ahead 順序と復旧 logic を検証し、compensation mechanism の受け入れゲートになります。

書き残さなければならない不変条件

不変条件は混沌テストの中核資産であり、実行可能なチェックリストとして一元管理することを推奨します。次の項目で前述のすべての機構をカバーできます。

  • どの idempotency key からも外部副作用は高々一回しか生じない。
  • ステップ数、wall clock、token の三重 budget はいずれも突破されておらず、超過時は暗黙の切り詰めではなく明示的な失敗になっている。
  • ステータスが成功である副作用はすべて、最終的に「compensated」「uncompensatable」「manual」のいずれかに着地し、宙に浮いた記録は残っていない。
  • 未処理の例外が run の境界から漏れていない。すべての失敗が構造化されたエラーに変換されている。
  • circuit breaker が開いた後は、同じ run のなかでそのツールの呼び出しが起きない。
  • compensation の逆順性。compensation の実行順序が正向の操作と厳密に逆になっている。
  • 並行のピーク値が設定上限を超えず、特定のテナントが飢餓することもない。

実装:不変条件チェッカー

# 混沌测试断言的是不变量,不是「没报错」。
# 这几条就是超时/重试/补偿三层机制是否真的生效的判据。

from collections import defaultdict


class InvariantViolation(AssertionError):
    pass


def check_invariants(txn) -> None:
    # 1) 幂等:同一个键最多产生一次外部副作用
    by_key = defaultdict(int)
    for e in txn.effects:
        if e.idempotency_key:
            by_key[e.idempotency_key] += 1
    dup = {k: n for k, n in by_key.items() if n > 1}
    if dup:
        raise InvariantViolation(f'幂等失效: {dup}')

    # 2) 三重预算都没被击穿,且是显式失败
    if txn.steps > txn.limits.steps:
        raise InvariantViolation('步数超限')
    if txn.wall_clock > txn.limits.wall_clock + 0.5:
        raise InvariantViolation('墙钟超限')
    if txn.tokens > txn.limits.tokens:
        raise InvariantViolation('token 超限')
    if txn.limit_breached and not txn.degraded_explicitly:
        raise InvariantViolation('预算触顶但未显式降级')

    # 3) 没有悬空副作用:成功的写入都必须有处置结论
    succeeded = {e.step for e in txn.effects if e.status == 'succeeded'}
    settled = {e.step for e in txn.effects
               if e.status in ('compensated', 'uncompensatable', 'manual')}
    if succeeded - settled:
        raise InvariantViolation(f'悬空副作用: {sorted(succeeded - settled)}')

    # 4) 补偿逆序
    order = [e.seq for e in txn.effects if e.status == 'compensated']
    if order != sorted(order, reverse=True):
        raise InvariantViolation(f'补偿顺序错误: {order}')

    # 5) 没有未处理异常逃逸
    for e in txn.events:
        if e.kind == 'exception' and not e.handled:
            raise InvariantViolation(f'未处理异常逃逸: {e.name}')

    # 6) 熔断打开后同 run 内不再有该工具的调用
    for name, opened_at in txn.breaker_opens:
        after = [c for c in txn.calls if c.tool == name and c.t > opened_at]
        if after:
            raise InvariantViolation(f'熔断后仍调用 {name} 共 {len(after)} 次')

    # 7) 并发峰值不超上限
    if txn.peak_concurrency > txn.limits.max_concurrency:
        raise InvariantViolation('并发超限')

flaky にしない CI での混沌テスト

混沌テストがもっとも頻出する失敗形態は、ランダムに落ちる継続的 integration になることです。混沌テストを一切行わないより悪い。その代償は、チームの感覚が「アラートは見なくてもよい」方向へ染みつってしまうことです。対策は三つ、いずれも具体的に定めます。

第一に、故障シナリオを固定し、乱数シードを固定しない。ランダムにすべきなのは故障パターンの選択だけで、具体的なパラメータ(何ミリ秒で timeout とするか、何回連続で失敗させるか、何番目のステップで注入するか)はシナリオ番号で決める。そうすれば、どの失敗も同じ番号で正確に再現できます。ランダム性をパラメータ層からシナリオ層へ移せば、広い被覆と再現性を同時に手に入ります。シードだけを固定すると組み合わせの爆発を隠してしまいます。シナリオだけを固定すると、リストに載っていない組み合わせには届きません。

第二に、成功を表明するのでなく不変条件を表明する。障害を注入したあとの成否は不安定そのものです。それを判定基準に据えたら必ずちらつきます。不変条件は常に成り立ちます。何を注入しても、1 つの idempotency key から副作用が高々一回しか生じないという条件は破られないからです。安定した判定基準になります。これは前節の不変条件チェックリストと同じ話で、混沌テストの価値はほぼすべてそこに集中しています。

第三に、不変条件違反と通常の失敗を別の状態に分ける。不変条件違反は mechanism 自体に欠陥があることを意味するので、いますぐ merge を止めるべきです。通常の失敗は記録して統計に入れ、mechanism の生効率を観察するために使います。両者を同じ赤信号に混ぜてしまうと、チームの合理的な反応は再実行か無視のどちらかで、どちらも混沌テストを純粋なノイズ源へ退化させます。

混沌環境でのデータ分離

テスト環境で「実行は成功したのに失敗が返る」類の障害を注入するとき、対象サービスの副作用が本物だと、テストが本物のデータを作ります。軽いケースでは開発用 DB が汚れ、重いケースでは本物の通知と本物の決済が走ります。したがって注入点は 2 つの条件のいずれかを満たさなければなりません。障害が server 側の状態を変えず、クライアントが観測する結果だけを変える。或者は対象が挙動的に等価な代替サービスを指していることです。

最もきれいな実装は、ツールアダプタと本物のサービスの間に programmable な proxy を挟み、注入をすべてこの層で発生させることです。同時に別の 2 つの問題も解決します。注入点と業務コードが分離され、業務のリファクタで壊れません。proxy 層が完全な呼び出しトレースを記録でき、後続のトレース再生の素材になります。これは混沌テストを継続的 integration に組み込むための前提でもあります。この分離がなければ、コミットごとに回すことはできません。

もう 1 種類、抜けやすいシナリオもカバーすべきです。長期障害下での状態収束。短時間の障害が試すのは迅速な失敗と circuit breaker ですが、長期障害が試すのは復旧パスです。障害が 10 分続いた後もシステムの状態が一致しているか、復旧のあとで宙に浮いた副作用がないか、compensation task が溜まり、合理的な時間内に処理しきれなくなっていないか,こういうテストは継続的 integration では回せず、定期的な drill が必要です。頻度は月次でもよく、drill の報告書自体がチームの足並みを揃える資料としてとても優れています。

実行戦略:proxy 層から段階的に深く

注入の順序は「制御可能なものから制御不可能なものへ」という原則に従います。まずネットワーク層で決定的な注入(固定 timeout、固定失敗)を行い、単一ロジックの検証を目的とします。次にランダムな組み合わせ故障を注入し、複数機構が重なったときの挙動を検証します。最後に長時間続く障害を注入し、circuit breaker と縮退のパスの安定性を検証します。

トレース再生は効率を上げる鍵です。本番で採集した実際のトレースをマスキングして入力とし、注入点では乱数シードを固定します。すると CI が回すたびに同じ一組の故障シナリオになり、失敗を正確に再現できます。逆に、ランダム性は故障パターンの選択に残すべきで、各故障の具体的なパラメータに与えるべきではありません。そうしないと、同じ bug が今日は出て明日は消え、誰も特定できません。

飛ばされがちな工程がもう 1 つあります。mechanism の生効率の統計。テストの成否だけを数えるのではなく、retry budget が作動した比率、circuit breaker が開いた比率、compensation が実行された比率も数えます。これらの比率が長期的にゼロなら、機構はまったく発動していない、つまりテストは安全の偽物です。そしてこの比率が異常に高いなら、閾値の調整が要ります。この指標は単純な pass/fail より、機構の実際の状態をそのまま反映します。

可観測性:Agent の run に SLO を定義する

すべての信頼性運用の仕事はは、最終的に観測可能な数値に落とし込まれます。そうでなければ改善が効いたかどうかを判断できません。Agent の観測には特殊な難しさがあります。1 回の run は数分にまたがり、複数の外部呼び出しを含む複合過程である。これを 1 つのリクエストとして監視すると、有用な情報はほとんどすべて失われます。

一等市民にすべきイベント

各 run のイベントストリームの中では、次の種類のイベントを必ず構造化して記録し、ログ本文だけに放置してはいけません。計画から生成されたアクション一覧、各ツール呼び出しの入力要約と idempotency key、各リトライの理由と回数、各 circuit breaker の状態遷移、各 compensation の実行と結果、そして各ステップの budget 消費です。どれか 1 種類が欠けていれば、事後レビューはログを読んで推測するしかなくなります。

idempotency key は必ずイベントに埋め込んでください。実用的な調査手順はこうです。ユーザーから申告された異常の受付番号を持ち、そのキーを含む呼び出しをすべて引き当てます。2 件以上出てきたなら、問題は idempotency の側にあるのであって、業務ロジックではありません。この検索は、キーを記録していなければ成立しません。

平均成功率からテールの指標へ

Agent の場で追うべき指標は平均成功率ではなく、テールに関わる比率の集まりです。

  • リトライ増幅係数:ツール呼び出し総数と、ユーザーが発起したタスク数の比。平均コストより早くリトライストームを露出させます。
  • circuit breaker 発動率:ツールごとにグループ分けします。発動し続けるツールは継続的な障害があり、発動ゼロのツールは閾値が緩すぎる可能性があります。
  • compensation 成功率:補償成功数と補償試行数の比、および補償失敗が人手 handover に渡る割合。
  • no-progress ステップの割合:no-progress 検出で止めたステップが全体の算力消費に占める割合。これはモデルの能力不足を見つける先行指標です。
  • idempotency key の衝突数:同じキーが重複排除で畳み込まれた回数。急増は上流が二重送信していることを意味します。
  • budget 枯渇率:wall clock かステップ数が上限に達した割合。budget 設定と実需の適合度を反映します。

これらの指標をテナント別、ツール種別別、タスク種別別に切り出すと、情報はさらに富みます。全体の no-progress 割合が数パーセントでも、特定のタスク種別では 30% に達するなら、そのタスク種別はプラン方式を変えないと。つまり閾値を調整する話ではない、ということです。

計測とイベント:これがないと、前述の機構はすべて検証できない

ここまでの各節で挙げた機構には、共通の下支えがあります。正常系では目に見える効果が生じない、ということです。idempotency key が効いているか、circuit breaker が本当に開いたか、compensation がすべての書き込みをカバーしたか。これらはイベントストリームでしか答えられません。したがってイベント契約は可観測性の付随品ではなく、信頼性機構そのものの一部です。

これで足りるイベント一覧

長い指標の列挙ではなく、機構を 1 本につなぐイベント群が必要です。以下が最小集合で、どれか 1 種類が欠けると調査に穴が空きます。

  • run_started と run_finished:1 回の実行の開始と終了、総 budget、総所要時間、総コスト、最終状態。
  • plan_created:プランナーが生成したアクション一覧のスナップショットと、意図の識別子。これは後続のすべての idempotency key のアンカーになります。
  • budget_allocated:予算配分ごとに、根拠と残量つき。これがないと「なぜここで枠が尽きたのか」を説明できません。
  • tool_call_started と tool_call_finished:呼び出しのペア。idempotency key、deadline 残量、試行回数、結果の分類つき。
  • retry_scheduled:リトライの理由、残りの budget、サーバー指示由来かどうか。この項目は「こちらがリトライを決めた」場合と「サーバーが待つように指示した」場合を区別します。
  • breaker_state_changed:breaker の状態遷移と、発動の根拠。「breaker が効かなかったのはなぜ」が調べられる問いになります。
  • compensation_started と compensation_finished:台帳記録の識別子と結果つき。宙に浮いた副作用を照合する唯一の根拠です。
  • budget_exhausted と partial_success_returned:budget 枯渇と partial success の明示マーカー。劣化のシグナルを集計するために使います。

各イベントに必須のフィールド

フィールド設計には原則がひとつあります。事後に同じ試行のすべてのイベントを紐づけられること。このため少なくとも run 識別子、意図識別子、ステップ番号、試行識別子、idempotency key の 5 つの貫通フィールドが必要です。意図識別子がなければ、再実行で idempotency key を再利用できません。試行識別子がなければ、リトライ回数の集計が二重に数えられます。idempotency key がなければ、ここまで何度強調した「重複副作用の逆引き」がまったくできません。

次に決定の根拠をイベントと一緒に保存する。budget 配分イベントには配分ルールを、breaker イベントには失敗回数を、backoff イベントには上限と実際の jitter 値をつけます。結果を記録しても根拠を記録しないと、事後は推測するしかなく、推測が一度外れると誤った因果関係に辿り着いて、触るべきでないパラメータを触ってしまいます。

イベントストリームはレビューの主要材料

完全なイベントストリームがあれば、1 回の本番障害のレビューは「ログを読んで推測する」から「タイムラインを引く」に変わります。この差は障害レビューではっきり現れます。イベントがあれば、どのステップで budget が尽きたか、どのリトライが最後の枠を消費したか、breaker がなぜその瞬間に開いたかが分かります。イベントがなければ、孤立したエラーログの山があり、因果はタイムスタンプから推測するしかありません。

本番障害の切り分けパス

機構が整ったあと、本当にそれを試すのは出事当日に迅速に切り分けられるかどうかです。以下は順番に実行する切り分けパスで、どのステップも前述のいずれかの節の機構に対応します。

まず triage、次に深掘り

最初の問いはいつでも範囲です。すべてのタスクが失敗したのか、一部だけか、あるいは遅く高いだけか。この切り分けで探索空間は 1 桁縮みます。全面失敗なら依存と設定を先に疑います。gateway、認証、quota あたりです。一部失敗なら特定のツールか特定のパラメータの組み合わせを先に疑います。失敗しないが遅いなら budget 配分を。遅く不高ならリトライとループを先に疑います。

2 つ目の問いは時刻の整合性です。障害はいつ始まり、直近のリリース、設定変更、下流変更と一致するか。合っていなければコードを追いかけず、依存側の変更履歴を見てください。

頻度の高い 3 種の障害に対する切り分け手順

重複した副作用:ユーザーから申告された異常の受付番号や対象識別子を持ち、関連する idempotency key を含む呼び出しをすべて引き当てます。成功記録が 2 件以上あれば問題は idempotency の側です。1 件だけでユーザーが 2 回見たというなら、問題は Agent ではなく下流の通知機構です。2 つで対処の方向がまったく違うので、この切り分けは手を動かす前に済ませる必要があります。

全面的な timeout:ツールごとに budget 枯渇イベントを集計し、すべてのツールが同時に枯渇したのか(全体的な budget 設定かモデル推論の劣化を指す)、特定のツールだけ枯渇したのか(そのツールのレイテンシ profile の歪みや下流の劣化を指す)を見ます。同時に deadline の伝播鎖も確認します。同じ run のなかの異なるステップで timeout 残量が噛み合わないなら、途中で budget がリセットされています。

compensation が走らなかったり失敗した場合:台帳を直接見て、ステータスが成功しているのに compensation の結論がない記録を探します。この一批が宙に浮いた副作用で、その個数が今回の事故の実被害範囲です。この数字はレビューに明記するなら、「一部のタスクが影響を受けた」の曖昧な言い回しで済ませてはいけません。

機構の故障と機構が発動していないことを区別する

最後のステップは機構自体に問題があるかを判断することです。breaker が開かなかった理由は 3 つあります。失敗率が確かに閾値に達していない(障害ではない)、閾値の設定に問題がある(機構の故障)、あるいは breaker の状態が他の箇所の health memory に上書きされた(機構の競合)。区別する方法は同じ時間窓内の統計を見ることです。失敗率は高いのに breaker が開いていないなら設定の問題です。breaker が開いたのにタスクが失敗し続けているなら、breaker のフィードバックがモデルに正しく伝わっていません。

この区別は重要です。2 つの問題の対処が逆になるからです。設定問題はパラメータを調整し、フィードバック問題はエラー契約を変えます。後者を前者と誤認すると一度も問題のない閾値を何度も触り続けてしまいます。

アラームはどの指標に張るか

信頼性のアラームで最も起こりやすい失敗は、設定しすぎて最終的に全部がノイズになって無視されることです。効くのは「明確に人が介入すべき」場合だけにアラームを張り、あとはダッシュボードに任せるという方針です。

必ずアラームを張るべき 4 つの指標

compensation が失敗して人間へ escalation:人間に escalate される 1 回 1 回が、ユーザーから見える不整合がまだ 1 つ処理しきれていないことを表します。この種のイベント数は限りなくゼロに近いべきで、出てきたら必ず誰かが追う必要があります。

breaker が長時間開きっぱなし:ここで見ているのは「長時間」であって「開いた」ではありません。1 回だけ開くのは正常な保護動作ですが、開きっぱなしなら下流が本当に壊れています。継続対応には明確な担当者を置きます。閾値としては「連続して開いている状態が数分を超えたら」と書けます。

idempotency key の衝突が急増:重複排除で折り畳まれた回数の急増は、上流が二重 submit しているか、ある呼び出し経路で意図しない重複が出たことを意味します。この変化はユーザーの申告より先に現れるのがふつうです。

budget の天井到達率が急増:budget 設定と実際の需要の乖離が急に広がったことを表します。急増はふつう上流が遅くなったか planning が複雑になったことが原因で、先行指標として使えます。

アラームを張るべきでないもの

単発 timeout 率の短期的な揺らぎ、retry 回数の散発的な増加、breaker の瞬間的な開き。この 3 つは単独でアラームを張るべきではありません。システムの正常な呼吸であり、アラームが増えすぎると人は赤い light に鈍感になります。まともな扱い方は baseline と逸脱の閾値を設定し、逸脱が一定時間続いたときだけアラームに上げることです。

もう 1 つ強調しておきたいのはディメンション別の切り出しです。全体の no-progress 割合が数パーセントでも問題がないとは限らず、タスク種別で切り出すと、ある種別だけ 30% に達していることがあります。そちらが本当に対応すべき問題で、しかも reliability mechanism ではなく planning の品質を指しています。すべての signal を 1 つの総数に押し込むのは、信頼性ダッシュボードでもっとも多い歪みの出どころです。

指標が動いたあとの読み方

指標そのものより重要なのは読み取りの動作です。同じ指標の上昇が、mechanism が正しく動いていることを意味する場合と、systems が本当に悪化している場合があります。この 2 つは対処がまったく逆です。以下はもっとも混ざりやすい組み合わせです。

retry 増幅係数の跳ね上がり、まず単一のツールに集中していないかを見ます。単一ツールに集中していれば、その依存のジッタで、Agent の論理とは無関係です。全ツールに均等に散らばっているなら、budget の設定が厳しすぎて 1 回の呼び出しにつき 1 回しか試せない可能性が高くなります。2 つの対処はそれぞれ retry budget を増やすのと 1 回あたりの budget を上げることで、方向がまったく逆です。

breaker の発動率の上昇、「保護が実際に効いている」のか「閾値を間違えている」のかを区別します。判定方法は breaker が開いている間のタスク成功率を見ること。開いたあとの成功率が高ければ breaker は効いています。開いたあとも大量の失敗が続くなら、breaker は正しい位置に切れていない。直すべきは閾値ではなく、モデルに返す内容です。

compensation 成功率の低下、まず compensation が弱い補償で多いのか、それとも有効時間窓を超えているのかを見ます。「期限切れの compensation」が出始めたなら、それは設計の問題です。取り消せない action をかなり前に置いている/business flow 自体が長すぎる実行時間を許している のどちらかです。この場合は設計を変える必要があります。パラメータを変えても効きません。

no-progress 割合の上昇、この指標は「モデルが馬鹿になった」と誤読されがちです。実際にはまず 2 つの技術的な原因を排除すべきです。状態摘要の設計が真の進捗根据をカバーしているか、そして tool の返す内容が貧弱すぎてモデルが進捗を判断できないかどうか。多くの「モデルが馬鹿になった」という感覚の実体は、tool の返却品質が下がったことでした。

idempotency key の衝突急増、もっともよくある本当の原因は上流が同じ要求を二重 submit していることです。たとえばユーザーが画面で素早く 2 回クリックしたとか。このような重複は折り畳めるので、衝突数の上昇自体悪いことではありません。本当に警戒すべきなのは折り畳みの失效です。同じ business intent に対して別々の 2 つの key が出力されたなら、key レシピに不安定な field が混ざった可能性があり、重複排除が静かに機能しなくなっています。

budget の天井到達率の上昇、まず 1 歩が遅くなったのか、歩数が増えたのかを切り分けます。1 歩が遅いなら依存側の劣化やレイテンシ profile の歪みを、歩数が増えたなら planning の品質低下や no-progress ループの増加を指します。この 2 つの修正箇所はまったく異なり、前者は tool 側、後者は planning と breaker 側にあります。

宙に浮いた副作用の件数は、その中で最も重い指標です。ユーザーから見える不整合に直接対応しているからです。値が非ゼロなら常に明確な処分の結論が要ります。それが「今は受け入れて watch list に入れる」だったとしても。長い期間ダッシュボードに吊るしたままで処理しないのは、集計しないことより悪い。チームのその数字への鈍化を養成してしまうからです。

これらの読み取りの背後にあるのは同一の原則です。まず「mechanism が正しく動いているのか、systems が本当に悪化しているのか」を尋ね、そのあとで action を決める。この順序は逆にできません。「mechanism が正しく動いている」を「systems が悪化した」と誤認した場合、もっとも典型的な結末は,本来正しい閾値をいじり続けて,西部大結末として本当にシステムを壊してしまうことです。

ダッシュボードをどう階層化するか

可観測性を増やしすぎると問題を起こします。すべての指標を 1 枚の dashboard に置くと、結果としてどの 1 枚も見切られなくなり、どれだけ diligent に cursory に見ても全体は把握できません。効くのは、データの出所ではなく答える問いで階層化することです。

リアルタイム層は「今が正常か」に答えます。人が介入する必要のある少数の項目だけを載せます。compensation 失敗の人間 escalation、breaker の長時間開口、idempotency 衝突の急増、budget の天井到達率。この層の設計目標は、人が 30 秒で「いますぐ対応するか」を判断できるようにすることです。それ以外は一切載せません。

トレンド層は「最近少しずつ悪化しているか」に答えます。比率系の指標をここに置きます。retry 増幅係数、no-progress 割合、平均開閉回数、compensation 成功率、宙に浮いた副作用の件数。この層は即時応答を必要としませんが、週次の trends を見る必要があります。多くの劣化は緩やかなものだからです。

キャパシティ層は「あと多久持つか」に答えます。コスト budget の消費、quota の使用量、並列のピーク、レート制限のヒット回数をここに置きます。この層は capacity planning と budget パラメータの調整に使います。通常はテナントやリソースグループで切り出します。

切り出すディメンションもあらかじめ決めておく必要があります。最低 3 つです。テナント別はテナント特有の問題の特定に、ツールやリソースグループ別は依存側の問題の特定に、タスク種別別は planning の品質問題の特定に使います。最後のディメンションがいちばん見落とされ、いちばん価値が高い。全体の平均だけると、覆い隠された重大な局所的な問題を引き出すことができるからです。

移行パス:素のまますぎる状態から機構をそろえる

いまの Agent にこれらの機構がまったく無いなら、改造の難所は技術ではなく順序にあります。6 つの側面を一度に手を入れると、チームは最初の月に意欲を失います。まともな道筋は、まず出血を止め、次に制約を足し、そのあと復旧、最後に検証という順です。

まず出血を止める:最も取り消せないツールから

最初の 1 歩は timeout ではなく、取り消せないツールから入ります。それらに人間確認の gate を挟むか、いっそ実行せずに案内だけ返す形に変えます。効果が早く、リスクが小さく、しかももっとも高くつく穴をその場で塞げます。重複課金や誤送信の損失は、タスク失敗の損失とは比較にならないからです。

並行して、コストがほぼゼロの作業を 1 つだけ入れます。すべてのツール呼び出しに、統一のイベントログを付けるのです。フィールドは前の必須リストの通りです。機構が 1 つも無い場合でも、この 1 つがあるだけで以降のすべての改造を観測できます。

次に制約:budget と retry の gate

2 歩目で全体の budget オブジェクトと tool の段階分けを入れます。この 1 歩は純粋に制約の話で、業務の semantics は変えません。よって業務側の了承は不要です。ただしユーザーの体感は変わります。task がより早く失敗するからです。事前にこれを説明し、明示的な失敗を十分な形に整えて、不安定として受け取られないようにしてください。

同時に idempotency key を入れます。この 1 歩は tool ごとに 1 つずつ入れる。一度に全部入れるのではありません。key のレシピと server 側の組み合わせは tool ごとに確認が要るからです。一度に全部入れたときの典型的な結果は、server 側で dedup が効かないのに client はもう retry しなくなり、いままで隠れていた重複問題が出始めて、改造が障害を持ち込んだように見えるというものです。

そのあと復旧:台帳、compensation、そして再開

3 歩目が台帳と compensation です。この 1 歩は永続化ストレージと復旧のロジックに関わり、改動の範囲は広い。ただしその前提条件、つまり tool の段階分け、idempotency key、イベントログは前の歩でそろっています。リスクは抑えられる範囲に収まります。

まずは単一のリソースグループにしか影響しない場面から始めます。復旧パスが正しいことを確かめてから広げます。そして「副作用の直後にクラッシュする」場面だけは、専用のテストを用意してください。台帳の設計でもっとも間違えやすく、常備のテストではもっとも捉えにくい瞬間だからです。

二重で走らせる期間を少しとる

改造のあいだ、新旧の機構をしばらく並行させるのは価値があります。新しい機構は判断を返しますが、結果はまず記録するだけで効かせません。対比に使うためです。この時期に 1 つ重要な問いに答えられます。新しい機構が、もともと成功していた task を何人分くらい落としたか。この数字が大きすぎれば閾値がきつすぎます。ゼロに近い、あるいは負(「新しい機構が task を救った」という意味)であれば閾値は妥当です。

二重運用期間のコストは推論と実装のほうにあり、運転コストのほうではありません。長く掛ける必要はなく、典型的な task ごとに数回ずつ回れば十分です。二重運用を終わらせる前に、対比結果を 1 ページにまとめて結論として残します。パラメータを決める根拠であり、チームに説明する根拠でもあります。

改造が効いたことをどう示すか

信頼性の改造でもっとも難しいのは、効果を証明しにくいことです。改善されるのは、何も起きなかった事象だからです。実現可能な証明の方法は 3 種類の evidence に分けられます。

1 つ目はmechanism の生効率です。breaker が発動した回数、compensation が実行された回数、retry が budget で止められた回数です。ゼロから立ち上がるこの曲線は、mechanism が本当に動いていることを直接示します。コードの中に置かれているだけじゃないということです。

2 つ目は不変条件違反の回数です。混沌テストで不変条件が破られた回数です。これは改造とともに単調に減るのが望ましい。成功率そのものよりも多くのことを語ってくれます。成功率は業務の変動を受けすぎるからです。

3 つ目はテールの指標の改善です。P99 の所要時間、retry の増幅係数、成功 1 件あたりのコストです。これらの数字の変化は小さい。故に絶対値ではなく trends を見ることを強調したい。比較するときは task 種別で切り出して、構成の変化に惑わされないようにしてください。

失敗を文字列ではなく契約として表す

ここまでの各節の mechanism はすべて同じ前提に立っています。失敗の形は分類できるということです。tool が文字列を返すだけなら、オーケストレーション層は次を区別できません。「ネットワークのジッタなので retry してよい」「引数が間違っているので何回やっても同じ」「下流が rate limit しているので相手の指定した時間だけ待つ」「この操作は既に実行されたかもしれない、応答が飛んだだけ」。モデルが受け取る情報が少ないほど、その行動はばらつき、最終的には無差別な retry か降参のどちらかに退行します。

使えるエラー契約は少なくとも 5 種類の情報を持つべきです。エラーの種別で retry なのか降級なのかを決めること。retry 可能かどうか。待つ時間の目安。すでに効いているかどうかで compensation を発火させるかを決めること。そしてモデル向けの短い説明。このうち 4 番目を一番忘れがちです。これは transport 層のエラーから業務の意味のあるエラーへ渡る唯一の経路であり、compensation が正しく発火するかどうかの唯一の根拠だからです。connection reset した書き込み呼び出しが「確実に実行されていない」のか「既に実行されたかもしれない」のかの判断は、tool アダプタが操作種別から出すしかありません。外側がどう推測しても当てになりません。

契約には試行の識別子も含めるべきです。同じ意図の別の試行を、それで紐づけられるようにします。そうしないと retry budget、breaker のカウント、idempotency の dedup に共通の物差しがありません。この識別子がなければ、retry に関する指標はログを数えるしかなく、最後には「どうやらこの tool は 1 日になんとか何回か retry しています」という数字しか得られません。

契約のもう 1 つの面は、モデル向けのフィードバック形式です。人間向けのエラーとモデル向けのエラーは分けるべきです。人間には「下流のサービスが今使えません。少し時間をおいてから試してください」。モデルには「tool X は使えません。retry はしないでください。別の経路を選ぶか、ユーザーに伝えてください」。 後者を完全な一文の自然言語で書くと、モデルは「もう 1 回試せばいいかもしれない」と解釈してしまいがちです。明確な指示を含む構造化されたテキストにすれば、従います。この細部は小さく見えますが、実際には breaker と降級が本当に効くかどうかを左右します。breaker が開いたのにモデルが知らなかったら、開いていないのと同じです。

実装の側にもう 1 つの要件があります。エラーの種別は呼び出しチェーン全体で一貫していなければなりません。tool アダプタからオーケストレーション層、そしてモデルのフィードバックまで、途中で汎用 exception に劣化させてはいけません。実装は型付きのエラーオブジェクトと明示的な分類 enum を用意し、変換点を 2 箇所だけに置きます。変換点は、プロセス間シリアライゼーションをまたぐところと、言語の境界をまたぐところだけです。中間層に広い catch を書いて文字列を詰めるようなことはしないでください。それは丹精込めて積み上げた情報を、構造の最も脆い場所で全部捨てることになります。しかもこの情報の消失はすぐには気づけません。表面上は機能が動いているように見えるからです。

もっともよく見られる 8 つの反パターン

以下の項目は、どれも実際に起きているものです。しかも例外を投げないことが多く、静かに信頼性を少しずつ漏らしています。共通点は、個々のコードはそれぞれとして妥当に見えるのに、組み合わせると問題になる、ということです。

  • 各層に固定の timeout を配る。どの層にも 3 秒を設定すると、20 層のチェーンは 1 時間もかけてようやく諦めます。正しいやり方は、締切時刻を渡す形に変えて、各層が上流から残りを引き継ぐようにすることです。これがなかなか直らないのは、単層テストでは常に正しいからです。
  • 並行する分岐がそれぞれ親予算を使い切る。いちばん安全そうに見えますが、実際には全体の予算が分岐の数だけ膨らみます。20 本束ねれば 20 倍で、最後はどこかの上流の制限で全体が止まります。対処は重みで残りを分けて、絶対の上限も設けることです。
  • retry のたびに計時をやり直す。毎回ゼロから数えると、事実上無限の時間が与えられ、retry budget も意味を失います。対処は retry が同じ締切時刻を使い回すようにし、退避時間も予算から引くことです。
  • idempotency key を試行に紐づける。retry ごとに新しい UUID を作ると server 側で dedup できず、「3 回 retry」=「3 回実行」になります。対処は key を planning 段階で生成した意図の識別子に紐づけ、server が本当に key から結果への map を保持しているかを確認することです。
  • 完全な軌跡で「進捗なし」を判定する。モデルの冗長な言い換えが進捗と見なされ、判定が一生発動しません。対処は外部的事実の集合と todo リストだけを hash することです。モデルが何を言ったかは判断に使いません。
  • compensation をアプリケーションのコードにだけ書く。プロセスが落ちると何をしたか誰も分からなくなり、復旧時は推測するしかありません。対処は書き込み前に記録する台帳を用意し、compensation を手順から切り出して独立した復旧の段階にすることです。
  • tool 名だけでレート制限する。複数の tool 名が同じクラスを指す場合、tool ごとの制限は実質何もしません。並列数は tool の数だけ掛けられます。対処は下流のリソース単位でグループを切って制限し、各 tool がどのグループに属するかを宣言することです。
  • 部分的な成功を成功として返す。戻り値に「大部分の操作は完了しました」と書きつつ status は成功のままだと、呼び出し側はそれを信じて進めてしまい、不整合はもっと遠い場所で爆発します。対処は明示的な部分成功の状態と、回復可能であることの印を付けることです。

この 8 つをレビューで素早く確認するなら、判断基準は 3 つの質問に圧縮できます。この retry は安全か。副作用が冪等か。この失敗は説明できるか。ユーザーは何が起きているか知っているか。この帳尻には誰かいるか。失敗後も状態を取り戻せるか。3 つにすべて即答できるなら、上記 8 種類の問題は出にくい。1 箇所でも答えられないなら、反パターンが 1 つ埋まっている可能性が高いです。

timeout budget の決め方:勘ではなくデータに決めさせる

timeout の mechanism 全体がいちばん形式化しやすくなるのは、budget の数値の出どころです。「30 秒」と決めて全体をそれに合わせて回すと、統一して見えるものの根拠が無く、調整もできません。まともなやり方は、budget をレイテンシの分布から作り出すことです。

レイテンシの分布から出発する

どの tool にもレイテンシの profile があり、遅くとも p50、p95、p99 とサンプル数が入っておくべきです。この profile の用途は 2 つあります。budget の配分の根拠になること。そして、その tool が異常な状態にないかを判断すること。p99 が過去の基準の 2 倍を超えていたら、下流が劣化している可能性が高く、このとき基準に沿って配った budget では、すべての呼び出しが timeout になります。

ここで見落としがちな方法論上の問題があります。profile は tool と引数の規模ごとに分けて取らなければなりません。絞り込み条件の無い全件クエリと、正確に 1 件絞ったクエリではレイテンシが桁違いで、混ぜた p99 には参考価値がありません。引数の規模は、返ってくる行数の見積もりやリクエストのサイズといった代理指標で層分けします。粗くても、まとめて計算するよりましです。

分位数は勘ではないが、そのまま写すのも違う

よく言われる「1 回の呼び出しには p99 の 2 倍」という数字には、それなりの理由があります。長い裾に 1 倍分の余裕を残しつつ、budget が膨らみすぎて多段の task が終わらなくなることはないからです。ただしこれは出発点であって結論ではありません。本当の判断の根拠は下流が劣化したときに何をしてほしいかです。リソースを健全な経路に譲るために早く失敗させたいなら、p99 に寄せます。

下流が唯一のデータ源で代替が無いなら、余裕を十分に与え、retry も許可します。より実用的な調整の方法はbudget の天井到達率を見ることです。この比率が低すぎれば budget が広すぎて、run が無駄に伸びます。高すぎれば budget がきつすぎて、通常の task が最後まで走り切れません。目標を数パーセント程度に置いて週ごとに調整するのは、一発で決めるより可靠な方法です。

budget と成功率の pareto

もう 1 層深い問題は、budget と成功率のトレードオフです。budget を 10 秒から 60 秒に増やせば成功率は上がります。ただし、戻り幅はすぐに小さくなります。ある点を超えると、timeout はほぼすべて本当に遅い task から来るものであって、運の問題ではなくなります。一方、3 倍の待ち時間は実費です。この曲線には折り返し点があります。「高いほど良い」を追うより、その点を見つけるほうが価値があります。

折り返し点の判断基準は、しばしば技術指標ではなく製品の制約です。1 回の失敗に対してユーザーが 10 秒 待てるなら、budget は長めの側に寄せます。ユーザーが即時の応答を期待しているなら、3 分の進捗バーを眺めさせるよりも、早く失敗して降格の結果を返すほうがよいです。

tool の呼び出しを永続化できるタスクにする

ここまでの retry と compensation の話は、呼び出しが即物で、結果をその場でもらえることを前提にしています。しかし多くの現実の場面では、tool の呼び出しはもともと非同期に実行できる長いタスクです。学習の job を回す、一括で書類を処理する、何十分もかかる API を。这类の場面で同期的な timeout の考え方を続けると、待つか諦めるかの二択を迫られます。

キューが無料で得られる再配信

呼び出しをメッセージキューに入れると、同期の方式では得られない性質が手に入ります。投递自体が永続化されるということです。consumer が timeout して抜ける、マシンが再起動する、オーケストレーションのプロセスが落ちる。いずれでもタスクは失われず、再起動後に消費を続けます。これは信頼性の一部をアプリケーションのコードからインフラへ移すことに相当し、そのインフラ側の部分は通常より信頼できます。

ただし、キューを使いこなすにはその意味論を理解しておく必要があります。ほとんどのキューが保証するのは少なくとも 1 回の配信で、ちょうど 1 回ではありません。メッセージが重複して配信されることもありますし、consumer が処理を終えたあとに確認の前に落ちて再配信されることもあります。この意味論と idempotency は元から対になる組です。キューは「失わない」を保証し、idempotency は「重複しても大丈夫」を担当します。idempotency が欠けると、少なくとも 1 回という保証は「必ず重複する」に変わります。

可視性の timeout がこのモデルの timeout になる

キューのモデルでは timeout の意味が変わります。「結果がこなければ諦める」ではなく、「処理時間が可視性の timeout を超えたので、メッセージが再配信される」になります。この違いは重要です。再配信は自動で起きるため、アプリケーション側の判断は要りません。一方、処理のロジックは任意の時点で安全に割り切れる必要があります。

実践では、consumer に冪等な処理関数と明示的な完了マークを付けます。メッセージを確認するのは本当に処理が終わったときだけで、途中では可視性の timeout を定期的に延長します。そうすれば長いタスクは安全に中断できても、timeout のせいで何度も再投递されずに済みます。延長ロジックは業務処理から分離しなければなりません。そうでなければ、業務コード側の例外が両者を同時に壊してしまいます。

結果のポーリングより状態を問い合わせる

非同期タスクの結果の取り出しには、よくある誤りがあります。クライアントが結果を返す API を何度も叩くことです。よりしっかりした形は投入後にタスクのハンドルだけを返し、状態購読かコールバックを付けるものです。ポーリングの問題は、長い待ちを大量の短いリクエストに分解してしまうことです。budget を消費するうえ、「まだ走っている」と「固まっている」というまったく違う状態を区別できません。

推奨されるのは、状態の問い合わせが構造化された段階情報を返すようにすることです。待機中、実行中(何ステップ目、あとどれくらい)、完了、失敗(理由つき)。これはユーザーにとっても有用ですが、上位の層が「待つ必要がある」のか「早めに compensation すべき」のかを区別できるようになります。判断の根拠は時間ではなく、失敗の理由だからです。

tool 契約の進化:一度の改名で走っている run を壊さない

tool は生きています。引数は変わる、戻り値の構造も変わる、意味を調整される。その一方で、走っている task は数分から数十分続くことがあり、その間のどの 1 度の変更でも、本来は成功し得た task を途中で失敗させることがあります。この節で扱うのは契約変更の互換性の制約です。

走っている task と tool のバージョン

もっとも基本的な要件はバージョン付きの戻り値の読み取りです。新しいバージョンではフィールドを追加できますが、既存のフィールドを削除したり意味を変えたりしてはいけません。古い task はまだ古い構造で解析しているからです。もっとも危険なのは削除ではなく、意味を変えることです。status を文字列の enum から boolean に変えるとか、金額の単位を元から分に変えるとか。这类の変更は例外を投げません。古い task が静かに誤った計算をするだけで、直接クラッシュするより悪いことです。

実用的なやり方は、契約の変更にランクを付けることです。任意フィールドの追加は安全です。必須引数の追加は破壊的です。意味の変更には二重書き込みの過渡期が要ります。削除は、走っている古い task が無いことを確認してからにします。リスクの高い変更には、短命の契約バージョンを用意し、オーケストレーション層が task の開始時にバージョンを記録し、終了時に検証するようにします。不整合を黙った誤った結果ではなく、明示的な失敗として表せます。

契約テストを回帰として使う

tool の信頼性はその契約の質でもあります。良い契約テストは、次をカバーするはずです。構造が schema に合っていること。エラーの経路が想定した種別を返すこと。idempotency key が本当に server に受け入れられること。そして重複呼び出しでも結果が変わらないこと。このテストを tool の変更ごとに回すようにしておけば、どんな文書よりも退化を防げます。

契約テストでもっとも省略されがちなのがエラーの経路です。正常な戻り値をテストする癖はありますが、Agent の信頼性はまさにエラー情報の規格に依存しています。500 を返す tool と、構造化されたエラーを返す tool は、フレームワークから見るとまったく別のものです。前者は未知の障害としてしか扱えません。

段階的な公開と 2 バージョン併存

意味の変更を伴うとき、正しい進め方はまず並行してから切り替えることです。新旧 2 つの契約を同時に動かし、割合を少しずつ新バージョンへ振り、不変条件の観測指標で全面公開してよいかを判断します。この過程の前提は、両方を独立に観測できることです。つまり前述の idempotency key と呼び出しイベントにバージョンを付けておく必要があります。

多くのチームが全面公開のあとに問題を見つけますが、その時点で古い契約はすでに終了していて、戻せる窓は閉ざされています。2 バージョン併存の期間の追加コストは制御可能で、それによって得られるのは「いつでも戻せる」という選択肢そのものです。信頼性の mechanism そのものが変更されているシステムにとって、この選択肢の価値は高いです。

信頼性の mechanism そのものの変更管理

あまり議論されないリスクが 1 つあります。信頼性の mechanism 自体が障害の原因になるということです。breaker の閾値を誤る、budget を間違う、compensation の順序を間違える。これらの誤りが、mechanism が無いときよりも重い障害を生み、しかも調査も難しくなります。

閾値系の設定は殊に危険

breaker の閾値、retry の回数、budget の秒数といった数字は、調整のためのパラメータに見えますが、実際には安全境界です。これらが間違ってもすぐには例外が出ず、systems が特定の条件下で期待と逆の挙動をします。这类の設定は一旦有効になると原因の特定が極めて難しい。「breaker が開かなかった」と「breaker が開きすぎた」は、ログの上ではどちらも 1 回の状態遷移にしか見えないからです。

したがってこの種の設定は、データベースの分離レベルと同じように扱うべきです。取りうる値の範囲が明示的で、変更のレビューがあり、段階公開があり、1 クリックで戻せる。と同時に、重要な閾値には観測項目を付けておきます。breaker の発動率、平均の開閉回数、budget の天井到達率。どれか 1 つが予想の範囲を外れたらアラームを上げます。ユーザーからの申告を待たずに。

mechanism の変更には観測の期間が必要

breaker の戦略を変えたら、少なくとも 1 つの完全な業務サイクルは観察してから結論を出すべきです。よくある事故は、ある調整で breaker の発動率が短期間だけ上がった(閾値をきつくなったため)ことです。チームをそれで壊したと判断し、元に戻します。しかしこの過程の中で、本当の問題は別の障害に隠 —— 覆い隠されていた別の障害が、この過程で表面化したのです。最小限の観察期間を決めて、その期間中は 2 回目の調整をしない。迅速な対応を求めることより可靠です。

戻せる経路を残せること自体をテストする

「ある mechanism を 1 クリックで止める」という機能は、定期的に演習しておくべきです。多くのチームは、実際に戻す必要になったときに初めて、設定値を変えても効かないことに気づきます。あるいは戻すスクリプトが、すでに終了した service に依存していたことに。演習のコストはきわめて小さく、得られるのは、もっとも悪いときにすぐ押せるボタンがあることです。

multi-tenant 環境での公平性と優先順位

テナントが 1 つの場合、レート制限が解いているのは下流の保護です。テナントが複数の場合には、それに加えて互いの干渉も解かなければなりません。1 つの長い task が並行の許可を握り続けると、他のテナントが飢餓に陥ります。

階層的な quota

まともなやり方は階層化です。全体の上限が下流を守り、テナントごとの上限がテナント間の公平を守り、1 つの run の上限が 1 つの task の肥大化を防ぎます。この 3 つの層は貸し借り仕組みでつながっています。空いているテナントの quota は他のテナントが一時的に借りられ、貸した側は低優先度になります。そうすれば自分が必要になったときに優先的に取り戻せます。

インタラクティブなリクエストはバッチより優先

時間のかかるバッチの task と、数秒で終わるインタラクティブなリクエストを同じプールに混ぜると、レート制限をどう調整しても誰かは不満になります。正しい解は物理的な分離です。制限を分け、キューを分け、必要な場合は下流のリソースも分けます。共有プールの中での優先順位では体験は良くなっても保証にはならず、インタラクティブなリクエストでは、平均的な処理量よりも遅延の予測性のほうが重要です。

これは task の中止の設計にも影響します。低優先度の task が高優先度の task に押されたときは、中断ではなく降格すべきです。範囲を狭める、精度を下げる。半分まで削られたバッチの task は、殺されたものよりマシです。少なくとも、それなりのものが残るからです。

エンドツーエンドで 1 回通す:注文の調整という task

ここまでの mechanism をすべてつないで見ると、抽象的だったものが具体的に変わります。想定する場面はこうです。Agent が「注文 A の配送日時を明日の午前中に変更し、买家に通知して」という指示を受け取ります。注文は社内システムにあり、通知はメールで行われ、両者は別のチームのシステムに属しています。

planning の段階

planner は 2 つの action を出します。注文を照会し、配送日時を変更し、それから通知を送る。オーケストレーション層はここで 3 つのことをします。並び順の決定です。取り消せないメールを最後に置き、前の 2 つが成功ことに明示的に依存させます。budget の配分です。全体には 40 秒を渡し、注文の照会に残りの 3 割かつ 8 秒以内、配送日時の変更に 10 秒以内を割り当て、残りを通知と推論に回します。契約の検証です。2 つの tool 名がどちらもレジストリにあり、引数の形式が正しいことを確かめます。

同時に台帳も書き入れます。2 件の記録です。1 件は配送日時の変更に対応し(compensation の action「元の配送日時に戻す」を持ち)、もう 1 件はメール送信に対応します(compensation の action はなく、取り消せないと印を付けます)。このステップは、いかなる副作用を実行する前に済ませます。

実行と障害の注入

注文の照会は成功します。配送日時の変更の段階で、下流が timeout を返します。ここで起きることが、mechanism の価値をもっとも示す場面です。

オーケストレーション層が残りの budget を確認します。あと 22 秒あり、今回の試行に割り当てられたのは 10 秒なので、十分です。そこで idempotency key を使って 2 回目の試行を始め、2 秒後に成功します。同時に、compensation の budget はこの 2 回の試行で枯渇していません。budget の判定が常に計算される方式で、一度に切り分ける方式ではないからです。

続いてメールを送ります。メールサービスは今のところレート制限中で、再試行までの待ち時間を明示的に返します。システムは自前の backoff ではなく、server の指定した待ち時間を使い、待つ前に残りの budget を確認します。あと 4 秒しかないので、もう 1 回待つには足りません。そこで「配送日時の変更は成功しましたが、買い手の通知は失敗しました」と直接返します。この結果は不完全です。しかし誠実です。完了した部分は明示され、未完了の部分には理由が示され、そしてメールの記録は台帳の中で pending の状態で、あとから対帳して送り直せます。

もし compensation が失敗したら

もっと悪い場面も考えます。配送日時を変更したあとに後続のステップがすべて失敗し、評価の結果、取消すべきだと判断されたとします。ここで compensation が発火します。

システムは逆順で処理します。まず「配送情報がまもなく変更されます」という訂正のメールを送り(すでに送っていた場合)、その後で元の配送日時に戻します。戻す action には同じ idempotency key を使うので、重複実行しても安全です。戻す action が 3 回連続で失敗した場合、さらに retry はしません。代わりにその記録を人間への引き継ぎへ回し、引き継ぎ用の情報をひとまとめにして当直者へ渡します。台帳の記録、元の注文の情報、すでに試した compensation とその失敗理由、そしてまだ動いているかもしれない子 task のことです。

最終的な返し

ユーザーに返るのは、明示的な状態を持つ構造です。配送日時は変更された(新しい値を添える)。買い手の通知は送信されていない(理由と推奨を添える)。現在の状態は回復可能で、retry しても注文を二重に変更しない。「操作に失敗しました」という結果よりはるかに多くの情報を含んでおり、ユーザーが後から retry したいときの助けになります。

振り返ると、この task は最初から最後まで 7 つや 8 つの mechanism を起動させていました。budget の継承と段階的な配分、idempotency key の組み立て、server の backoff 指示の採用、budget 不足の判定、台帳の書き込み前記録、部分成功の報告、そして別の分岐で動いた breaker。それらの共通点はどれもモデルを判断させる必要がないということです。mechanism がモデルを制約し、モデルは制約の中で選びます。これが Agent を運用可能な状態へ押し上げる决定的な境界線です。

既存のフレームワークに乗せる:そのまま使えるものと自分で書くべきもの

ここまでで話した mechanism の一部は、すでにフレームワークやワークフローエンジンがカバーしています。作り直す必要はありません。ただしどんなフレームワークの場合でも自分で書くべき部分もあります。この線を引けていれば、無駄な工数をかなり減らせます。もっとも危険な誤解も防げます。フレームワークに checkpoint があれば信頼性がある、と思い込むことです。

フレームワークが通常すでに解いてくれるもの

もっと代表的なのは 3 つです。1 つめは状態の永続化と checkpointです。主要なグラフ型のオーケストレーションフレームワークは、各ステップの状態を外部ストレージに書き込み、任意の checkpoint から再開する機能を持っています。ワークフローエンジンの決定論的な再生も同じ種類の能力です。これらが解いているのは「クラッシュ後どこから続けるか」と「長い task を最初からやり直さないで済むか」という問いです。

2 つめは1 回の呼び出しに対する自動 retryです。回数や backoff 方針による繰り返し、可視性の timeout、heartbeat による生存検出を含みます。この層は、少なくとも 1 回配信されるという意味論をただで得ているようなもので、特に非同期 task では便利です。

3 つめは呼び出しチェーンのトレースです。主要な Agent フレームワークは tracing を統合しており、呼び出しの完全な時系列、各 tool 呼び出しの引数と戻り値、ネストした呼び出しの親子関係を返してくれます。これはイベント列の、もっともよいデータ源のひとつであり、手作業の計測をかなり省いてくれます。

フレームワークでは解けず、自分で書くべき部分

抜けている部分は 3 つの箇所に集中していて、この 3 つがまさに本文の最も中核的な内容です。

idempotency key の組み立てと伝播。フレームワークは「呼び出しが失敗したので retry できる」ことを知っていますが、この呼び出しがどの意図に属するか、idempotency key を誰が作るかは知りません。key を試行に紐づけるのか意図に紐づけるのか、これは業務の意味の決定であり、フレームワークからは推測できません。重複した副作用の事故のほとんどは、ここが原因です。

compensation のトランザクション。復旧実行が解いたのは「再実行」ですが、多くの副作用は再実行できず、取消しかありません。「どの action が取消可能か、取消の順序はどうするか、取消に失敗したらどうするか」は業務の知識であり、tool を提供する側が宣言すべきもので、フレームワークはその情報を持っていません。

プロセスをまたぐリソースの記述。budget、idempotency key、台帳の識別子は、tool が動く別のプロセスやサプライヤーまで届いている必要があります。这类の境界を越える伝播は通常、アプリケーション層での明示的な実装が要ります。ヘッダを 1 つ付ける、フィールドを 1 つ書く、あるいは tool アダプタの中でまとめて注入する、という形です。

tool プロトコル層はどこまで助けてくれるか

tool の呼び出しがプロセスをまたぐプロトコルとして標準化されるにつれて、要求の timeout とエラーコードには共通的表达ようになり、サービスをまたぐ実装コストの一部が下がりました。ただしプロトコルの意味論の層には、アプリケーション層が補わなければならない部分がまだ残っています。

  • エラーコードから retry 方針への対応付け。プロトコルは「server 内部のエラー」と教えてくれます。しかしこの操作がすでに効いているかもしれないかどうかは教えてくれません。後者が compensation に入るかどうかを決めますが、操作の種別から推測するしかありません。
  • timeout budget の配分。プロトコルは timeout の長さを伝えられますが、残りの予算を伝え、呼び出しと下位呼び出しの階層関係を区別するには、アプリケーション層が自分でやる必要があります。
  • idempotency key のフィールドの約束。プロトコルは引数を渡しますが、idempotency key を誰が組み立て、どのフィールドに置き、server がどの規則で重複を除くかは、双方が調整する契約です。

この 3 つの約束をはっきりさせて初めて、サービスをまたぐ信頼性が本当に成立します。どれか 1 つが欠けると、「プロトコル層は呼び出し失敗と表示したのに、アプリケーション層は retry すべきか分からない」という曖昧な領域が現れます。

1 つの判断基準

1 行で分けることができます。「業務の意味」に関することはすべて自分で書き、「実行の骨格」に関することはできるだけ既存のものをを使う。実行の骨格には状態の保存、retry のループ、呼び出しの時系列、task の投入が含まれます。業務の意味には、この操作を繰り返せるか、取消をどうするか、その正しさに誰が責任を持つかが含まれます。

この基準の実務上の価値は、とてもよくある過剰設計を止めることにあります。是他に労力を払って独自の分散 task 実行を組み直す一方で、実際にはやっていなかったのは「取り消せない操作ごとに、どう取消するか」を考えることでした。後者こそが本番の事故の主な原因です。

tool レジストリ:宣言を 1 か所に集める

本文の冒頭で触れた多くの mechanism は、結局のところ同じ 1 つのメタデータに依存しています。tool がどの種類に属するか、idempotency key をどう組み立てるか、compensation の action は何か、レイテンシの profile、リソースの種別、責任者。これらがコードや設定や文書に散らばっていると、どれか 1 つ欠けただけで対応する mechanism が静かに機能しなくなります。それらを 1 つのレジストリに集めることが、mechanism 全体を維持可能にする前提です。

1 件のレジストリ項目に何を書くべきか

最低でも 7 つは必要です。idempotency の種別(3 分類のいずれか。自動 retry を許すかどうかを決めます)。idempotency key のレシピ(key をどの構成要素から作るか、誰が生成するか、どのフィールドへ伝播するか)。compensation の action(あるか否か、および補償の強さの段階分け)。レイテンシの profile(p50/p95/p99 とサンプル数)。リソースの帰属(どの下流リソースグループを指すか。レート制限に使います)。副作用の等級(外から見える度合いで、追加の gate が必要かを決めます)。そして責任者(問題が起きたときに誰に連絡するか)。

このうち「idempotency key のレシピ」は、もっとも曖昧な書き方になりがちです。実装を直接導く書き方になっていなければなりません。key はテナントの識別子、意図の識別子、tool 名、正規化した引数の要約、この 4 つを連結して作ります。意図の識別子は planning の段階で生成して下へ伝播し、正規化で落とすべきフィールドは設定に並べておきます。「request ID で idempotency を取る」とだけ書くのは不合格です。request ID は retry のときに変わるからです。

レジストリはコードとして扱う

これは実行時の依存であって、文書ではありません。したがってバージョンがあり、レビューがあり、契約テストがあり、他のコードと同じリリースの流れを通るべきです。変更のたびに 1 つの問いに答える必要があります。走っている古い task はこの項目をどう読むのか。フィールドの追加は通常安全ですが、フィールドの意味を変える変更には往往して二重書き込みの過渡期が要ります。

もっと価値があるのはレジストリからプロンプト内の tool の文書を生成することです。前にも述べましたが、tool の幻覚の根源は説明とレジストリの不一致です。同じデータから両方を生成すれば、この不一致を物理的に消せます。投資は小さいのに効果はすぐ見える変更なので、信頼性の改造のいちばん前に置くことを勧めます。

レジストリでリソースガバナンスをする

統一されたリソース帰属の宣言があれば、多くのガバナンスの操作を自動化できます。レート制限はリソースグループごとに設定し、監視はリソースグループごとに集約し、容量評価はリソースグループごとの呼び出し数で概算し、障害演習はリソースグループごとに注入先を選びます。2 つ目の対応表を別に保守する必要がなくなり、構成がずれることも起きません。

最後に実用的な提案を 1 つ。レジストリに完全性のチェックを 1 つ入れ、起動時にすべての項目の必須フィールドがそろっているか、idempotency の種別と compensation の宣言が矛盾していないか(例えば取り消せないと宣言しているのに compensation の宣言がなく、しかも事前回避の印も付けていない、という場合)を確認します。这类のチェックはデプロイ時に一批の設定ミスを止めてくれます。そしてそれらの設定ミスは、リリース後には通常、最初の 1 件の事故が起きるまで気づかれません。

着手順:投資対効果の順に進める

最後に、実行できる進め方を示します。最初の 2 つの段階はコストが極端に低く、効果が大きいので、飛ばすべきではありません。

第 1 週:全体的な締切時刻と hard timeout。run の入口に唯一の budget オブジェクトを作り、すべての tool 呼び出しが自分で timeout を決めるのではなく、そこから残りを引き継ぐようにします。加えて各 tool に、最初の 1 byte を待つ timeout と、応答が来ない timeout の 2 つを足します。この 1 歩で「長時間応答の返らない」問題の大部分は直接消えます。

この段階の受け入れ基準ははっきりしています。どの tool 呼び出しでも、その締切時刻をイベント列から辿れること。そしてその時刻が run の入口から一路引き継がれていることです。実現できていないなら、中間のどれかの層が再初期化したということです。ついでに tool のレイテンシ profile の収集も始めてください。以降のすべての budget の決定がそれに依存します。1 週早く始めたら、1 週早く信頼できる分位数が手に入ります。

第 2 週:tool の段階分けと retry の gate。tool レジストリを 3 段階に分け、取り消せない tool を明示します。idempotency key を意図に紐づけます。取り消せない tool では自動 retry を禁止し、retry budget の割合も加えます。この 1 歩で「もともとずっと重複実行していた」tool がいくつ姿を見せます。これは効果の発見にあたります。

この段階の確認方法は idempotency key の衝突数を見ることです。段階分けが終わったあと、これまで重複要求が一度も折り畳まれなかった tool で、折り畳み件数が出始めるはずです。長くゼロのままなら、業務上の偶然で重複がなかったのではなく、key の組み立てに問題があるということです。あわせて取り消せない tool の呼び出しチェーンを抜き取り確認し、本当に自動 retry の経路が無いことを確認してください。

1 ヶ月目:台帳、compensation、そして 3 重の budget。副作用の台帳と書き込み前記録を導入し、外部に働きのある tool にはすべて compensation のメタデータを埋めます。ステップ数、壁時計時間、token の 3 重 budget と明示的な降格を加えます。取り消せない action はオーケストレーションの末尾に置きます。この 1 歩は改動の要点がもっとも大きいので、tool の種別ごとに 1 つずつ進めることを勧めます。一度に全量を入れるのではなく。

この 1 歩は tool の種別ごとに 1 つずつ進めることを勧めます。まず読み取り専用と冪等な書き込みの組み合わせから。ここのリスクはほぼゼロです。次に compensation の action を持つ書き込み。最後にとり消せない action ですが、ここまで来て初めて外部の世界が実際に変わります。順序を逆にすると、最初の変更で取り消せない誤りを起こす可能性があり、その時点のチームの compensation の経路はたいていまだ検証されていません。

1 四半期:混沌 engineering と SLO。不変条件のチェックリストを、CI の中で実行できる検査に変えます。テールの指標のダッシュボードを作ります。リソースグループごとのレート制限と適応的な並行制御を入れます。最後に multi-tenant の公平性を考えます。この段階の成果は「障害がないこと」ではなく、障害が起きたときの損失が制御でき、事後に位置が分かることです。

この段階になって初めて指標を作り始めるのは避けてください。前の 3 歩の計測は同時にやる必要があります。そうしないと最後の四半期に来て、最初の 3 歩に効果があったかどうかを判断できず、感覚だけで報告するはめになります。

境界と代償

最後に、この仕組みの適用境界をはっきりさせておく必要があります。信頼性の engineering 自体にはコストがかかるからです。

もしあなたの Agent が 1 往復の質問と応答だけで、読み取り専用の tool しかなく、外部への副作用もないなら、上記の 9 割の mechanism は過剰設計です。やる価値があるのは 2 つだけです。推論の呼び出しの timeout と、総ステップ数の上限です。逆に、Agent がメッセージを送り、お金を動かし、本番の設定を変えるなら、本文の 1 つ 1 つがあなたのリスク一覧に載っています。

信頼性と自律性のあいだには本当に緊張関係があるので,この点は素直に認めるべきです。breaker は単回の成功率を下げます。本来自分で回復できたはずの呼び出しを、早く失敗させてしまうからです。compensation は新しい失敗点を持ち込みます。保守的なデフォルト方針は、Agent に制御権を返す頻度を上げます。これらはすべて、ピーク時の体験をテールの下限と交換するものです。テールの先端で生死を分ける場面なら取引に合うですが、短命でリスクの低い社内 tool では未必合うでしょう。

進んでいる方向から見て、持続的に注目すべきことが 3 つあります。1 つ目は回復可能な実行がオーケストレーションフレームワークの標準機能になることで、checkpoint、台帳、compensation をアプリケーションのコードからフレームワークへ下げ、各チームの重複建設のコストを減らします。2 つ目はtool プロトコル層が期限とエラーの意味を標準化することで、締切時刻の伝播と構造化されたエラーが、言語やプロセスをまたぐ共通の約束になり、各フレームワークがそれぞれ自分の定義を持つのではなくなります。 3 つ目はbudget を静的な制約にすることで、planner が action を生成する時点から budget の制約を受け、実行の途中で breaker に止められるようにしません。

よく見落とされるコストがもう 1 つあります。mechanism 自身が持つ遅延です。budget の確認、idempotency key の計算、台帳への書き込み、breaker の状態照会はいずれも主要な経路上にあり、合計がゼロとは限りません。経験的な方法は、必要なときにだけ発動する形にすることです。読み取り専用の tool は台帳を飛ばし、速い経路を通る場合は一部の検証を飛ばし、breaker の状態は共有ストレージを毎回参照するのではなくローカルのキャッシュに一定周期で更新します。この方法のリスクは、速い経路と遅い経路で挙動が一致しないことです。したがって定期的に遅い経路を強制的に 1 回通すようにし、対照を取る必要があります。速い経路が問題を隠したまま通らないようにするためです。

もう 1 つのコストはプロセス上のものです。breaker が開いたら誰かが追従し、compensation が失敗したら誰かが処理し、閾値の変更には誰かがレビューする。いずれもon-call の体制と明確な役割分担が必要だということを意味します。プロセス上の保障がなければ、mechanism は「アラームは出たが誰も見ていない」状態に退化し、価値がなくなるだけでなく、継続的なノイズによってチームのアラーム全体への信頼を損なうことになります。mechanism を入れる前に、誰が応答するかを確かめることは、コードを書くより先に問題です。

同様に、はっきりさせておきたいのはやってはいけない場面です。判断基準は複雑さではなく、「1 回の失敗の代償」と「失敗の確率」の積です。1 回の失敗の代償が非常に低い場合、たとえば内部の読み取り専用のクエリ tool で、失敗してもユーザーがもう一度聞けば済むなら、mechanism 全体は負の収益になります。逆に、1 回の失敗が外から見える取り消せない操作を伴うとか、失敗が後続のすべてのステップの状態を汚すとかなら、mechanism の投資対効果ははっきりと正になります。

中間的な状態として別に記述すべきものもあります。低頻度で高代償の場面です。年に 1 回しか走らない決算の処理がその例です。失敗の確率は高くないものの、1 回の事故の代償はすべての日常 task の合計をはるかに超える可能性があります。这类の場面では、投資の方向は日常のトラフィックと逆にすべきです。重点は失敗率を下げることではなく、失敗した後に人が発見できること、安全に再生できること、完全にたどれることです。可観測性と復旧能力は、ここでは breaker やレート制限よりはるかに重要です。

最後の判断であり、この記事全体が残したい言葉がこれです。Agent の信頼性は、より賢いモデルからは来ません。モデルの外側にあり、失敗を必然として設計された mechanism からも来ます。そして、systems が、tool は timeout を越し、下流は crash し、モデルは堂々巡り、プロセスは死ぬ、という前提で動くとき、はじめて production に載せられる service のように見え始めます。