AIREITER

Agentのツール説明が古くなる問題:宣言を唯一の正とする

最終更新日: 2026-07-31 07:35:05

Agentに「あるプラットフォームの公開投稿を取得する」ツールを追加したとします。2週間、本番で何の問題もなく動いていたある日、関数の limit のデフォルトを25から20へ変え、sort のenumにも新しい値を加えました。コードを修正し、テストはグリーン。そのままマージです。

ところが3日後、本番で時折エラーが出始めます。モデルが先週削除したenum値を使ってツールを呼び出し、実行時バリデーションに弾かれていました。スタックトレースが示すのはディスパッチ層です。30分ほどコードを追っても、そこには一行の間違いもない。本当の原因はまったく別の場所にあります。関数シグネチャは変えたのに、モデルが読むツール説明を更新していなかったのです。モデルは古いスキーマのまま呼び出しを生成するため、新しい実装と噛み合わなくなります。

これがツール説明のドリフトです。Agent開発で最もよく起き、しかも最も追跡しにくい不具合の一つです。難しい理由は明確で、エラーの発生場所と根本原因の場所が違うからです。エラーは実行層に出る一方、原因は誰も開こうと思わないJSONファイルに潜んでいます。必要なのは「同期を忘れない」ことではありません。ずれる対象となる二重管理を、構造から消すことです。

ドリフトが起きるのは、正解が2つあるから

分解してみると、ドリフトの根は二つの情報源を維持していることにあります。

一つは実際に動くコードです。関数シグネチャ、引数のバリデーション、デフォルト値、enum制約がここにあります。こちらは厳格です。間違えれば、はっきり失敗します。

もう一つは、モデルが読むツール説明です。name、description、parameters のJSON Schemaが該当します。こちらは曖昧です。間違っていても、その場では壊れません。モデルが不正な呼び出しを生成し、後段の実行層で初めて失敗します。

人間がこの二つを一致させ続ける限り、ドリフトは起きるかどうかではなく、いつ起きるかの問題です。コード側の引数だけを変えて説明を忘れる。説明だけを変えてコードを忘れる。両方変えても意味が揃っていない。どれも変更した瞬間には見つかりません。モデルが差分に触れる呼び出しを生成したときに初めて表面化し、その頃には2週間前に何を変えたか忘れています。解決策は一方向しかありません。二つのコピーを一つにすることです。

パーサー宣言をインターフェースの唯一の正にする

考え方を切り替えます。ツール説明用のJSONは、そもそも不要です。

関数のパーサー宣言とdocstringには、ツール説明に必要な情報がすでにすべて入っています。素の argparse 宣言なら、たとえば次のようになります。

subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
    "--sort",
    choices=("hot", "new", "top", "rising", "controversial"),
    default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)

help はコマンドの一行説明です。choices はenum制約、default はデフォルト値、type はパラメータ型を表します。位置引数は必須フィールドです。コマンドが何をするのか、どのパラメータを受け取るか、必須項目は何か、enumにどんな値があるか、デフォルトはいくつか。モデルがツールを呼ぶために必要な情報は、すべてここにあります。そしてこの宣言は、実行時のパースとバリデーションにもそのまま使われます。実行ロジックそのものなので、実装からずれようがありません。

つまり、二つ目のツール説明を書かないことです。正しい前提は、その文書は存在しないということです。あるのはコードだけで、ツール説明が必要になったときはコードから投影して作る。流れはコードから説明への一方向だけで、逆はありません。

宣言からツールカタログ全体を生成する

宣言がインターフェースだと受け入れるなら、ツール説明を手で書くべきではありません。すべてを生成する仕組みを用意します。

生成処理そのものは機械的です。各プラットフォームコンテキストを走査し、パーサーをimportして argparse のactionリストを読み込み、変更不能な三つの構造、Platform、Command、Parameter に落とし込みます。各 Parameter は名前、型、必須フラグ、enum値、デフォルト、ヘルプテキストを持ちます。これはコードだけから導出された、インターフェースのread modelです。

このread modelがあれば、出力形式はすべてその下流になります。describe --format json は、Agentのツール選択に渡せる完全な機械可読インターフェースを出力します。render_skill() は、人間にもモデルにも読める機能カタログを生成します。カタログ内のコマンド数も手入力の定数ではありません。sum(len(platform.commands)) をその場で計算します。現時点では22のプラットフォームコンテキストと241コマンドですが、カタログに手で入力されたものは一つもありません。

これにより、扱いやすい性質が得られます。プラットフォームを追加すれば、プラットフォームコンテキストを足すだけでカタログは自動的に全コマンドを取り込みます。パラメータを変えれば、パーサー宣言を直すだけで、カタログのenumとデフォルトも自動更新されます。「新コマンドを書いたのに登録を忘れた」「パラメータを変えたのにカタログが古い」といった問題は起きません。登録という手作業がそもそも存在しないからです。カタログは保守するものではなく、計算されるものです。

(維持するのではなく導出する、というこの発想は、言語間で実際にどこまで移行できているかを集合演算で判断する場面にも通じます。詳しくは言語横断マイグレーションの記事で扱っています。)

CIでドリフトをコミット時に止める

導出によって「新しいコマンドが自動でカタログに載る」問題は解決します。ただし、まだ穴が一つ残ります。誰かがパーサー宣言を編集し、生成処理を再実行せず、再生成されたカタログをコミットしないケースです。リポジトリ上のコピーが古くなり、裏口からドリフトが戻ってきます。

最後の関門はCIです。中核になるのは、たった一つのassertionです。

docs-check:
	$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
	  path = Path("skill/SKILL.md"); \
	  assert path.read_text(encoding="utf-8") == render_skill(), \
	  "skill/SKILL.md is out of sync with the code; run make docs"'

リポジトリにコミットされたカタログと、現在のコードから再生成したカタログをバイト単位で比較します。一文字でも違えばCIは失敗し、「カタログがコードと同期していない。make docs を実行せよ」と通知します。

この一行の価値は、ドリフトを見つけるタイミングを変えられることです。以前のドリフトは実行時の幽霊でした。2週間後に本番で壊れ、しかもスタックトレースは間違った場所を指します。今はコミット時の赤いXです。プルリクエストで止まり、カタログが古いと明示され、再生成すれば解決します。ドリフトは「最も追跡しにくいバグ」から、一つのコマンドで消せるコンパイルエラーへ変わります。これがinterface-as-codeの全体像です。宣言がソース、機能カタログがビルド成果物、CIが型チェックになります。ビルド成果物を手書きしたり、ソースと食い違う状態を許したりはしないはずです。ツール説明も同じ扱いにすべきです。

コードに固定するもの、モデルに任せるもの

導出とCIによって、インターフェース説明が正確であることは保証できます。ただし、その前に判断すべきことがあります。ある機能を固定コードとして書くべきか、その場でモデルにオーケストレーションさせるべきかです。この分担を誤れば、インターフェースが正確でも救われません。

機能は三層に分けて考えると整理しやすくなります。

低レベルのプリミティブは、一種類のデータを読み取るか、明確な一つの操作を行います。入力は安定し、出力は構造化され、単体でテストできます。この層は純粋なコードであり、推論を一切消費しません。241コマンドの大半はここに属します。

決定的なワークフローは、一つのプラットフォーム内で強い順序を持つプロセスです。状態を共有し、成功条件も明確です。たとえば creative-pipeline では、機会の探索、Top Ads、クリエイターマッチング、クリエイティブブリーフ、生成前チェックを順に実行します。手順と各ステップの依存関係は固定です。この層もコードに固定します。順序がすでに決まっているなら、毎回モデルに再計画させるのは遅く、不安定だからです。指定は一行で済みます。コマンドに set_defaults(_command_level="workflow") を設定するだけです。コードベース内でこの行があるのはこれだけであり、そのためカタログではワークフローとプリミティブが別レベルとして表示されます。

Agentオーケストレーションは、プラットフォーム横断の調査、状況に応じたトレードオフ、失敗後の経路変更を担う層です。次に何を問い合わせるべきかは直前の問い合わせ結果で決まるため、ここは事前に書き下せません。だからこそ、モデルに任せる層です。

判断基準はかなり明確です。安定したステージ状態、共有コンテキスト、生成による副作用が必要なら、コードに固定します。クエリ拡張、プラットフォーム横断の検証、失敗後の経路変更を伴うなら、モデルに任せます。どちらを誤ってもコストが出ます。リサーチ仮説をクライアントにハードコードするのは固定しすぎで、プラットフォームが変われば再びコード修正が必要です。逆に固定手順を毎回モデルに組み立てさせるのは固定が足りず、モデルの判断を一つ省いたつもりで、大量の不安定さを買うことになります。

劣化運転の判断をモデルに渡す6つのステージ状態

オーケストレーション層が判断するには、下位層から返る情報がモデルにとって読み取れる形でなければなりません。不透明な成功・失敗のbooleanだけでは不足です。success: false だけを渡されても、モデルは次の手を推測するしかありません。

そこでワークフローの各ステージはbooleanではなくステージ状態を返します。状態は completed、empty、ready、skipped、unavailable、blocked の六つです。重要なのは、進めなかった状態の違いです。

  • skipped は、たとえばある収集経路の上限を0にするなど、オペレーターが意図的にそのステップを無効化したことを意味します。エラーではなく、モデルは再試行すべきではありません。

  • unavailable は、インターフェースのエラーやセッション不足など、このステップが依存する何かが一時的に利用できない状態です。モデルはこのステップを飛ばして継続するか、新しいセッションを求めてから戻れます。

  • blocked は、リサーチ証拠が空、あるいは事前チェックに失敗したといった前提条件の未達を意味します。モデルは次のステップを強行すべきではありません。戻って証拠を補う必要があります。

先ほどのクリエイティブパイプラインでは、「プラットフォームの事前チェックがreadyか」と「リサーチ証拠がreadyか」を別々に判定し、最終的に ready = platform_ready and research_ready とします。どちらかが失敗すれば、生成ステージは何に阻まれているかを示す blockers リスト付きで blocked を返します。商用検索の結果がすべて空なら、生成ジョブは単に送信しません。

なぜこの設計がモデルに有利なのでしょうか。オーケストレーションモデルが seedance_generation: blocked と blockers: [research_evidence_empty] を読めば、送信を再試行するのではなく、証拠を取りに戻るべきだと分かります。organic_discovery: skipped なら、これはユーザーの意図であり障害ではないため、放置すべきだと判断できます。unavailable のステップなら、迂回して劣化運転できると分かります。意図的な無効化、一時的な利用不能、前提条件の未達を分けておけば、モデルは適切な劣化経路を選べます。三つをすべて false に潰すと、強いモデルであってもその場で空回りします。

レイヤーごとにモデルを使い分ける

ここまでのスタックでは、層ごとにモデルへ求めるものが大きく異なります。(4段階のリバースエンジニアリングの記事では、同じ4層の表をリバースエンジニアリングの文脈で示しました。ここではそれをAgentスタックに当てはめます。) レイヤー単位で割り当てれば、能力を無駄にしません。

Agentスタックでの作業

必要な能力

選定モデル

model id

ツール選択のため、241コマンド分の describe jsonをコンテキストに読み込む

長いコンテキストを使い、カタログ全体を一度に読める

Kimi K3

kimi-k3

オーケストレーション:ステージ状態とblockerを読み、劣化運転、迂回、継続を決める

強い推論力。状態に対して正しい判断を下せる

Claude Opus 5

claude-opus-5

docstringからモデル向けツール説明文を一括生成する

低コストで、数百件の呼び出しを高並行で実行できる

Claude Sonnet 5

claude-sonnet-5

ツール呼び出しエラーの原因分類:エラーと宣言を読み、ドリフトか上流変更かを判断する

中程度の推論力。特定フィールドに基づいて説明できる

GPT-5.6 Sol

gpt-5.6-sol

特に注目すべきはオーケストレーション層です。blocked と skipped を読み、次の行動を決める処理は、モデルの切り替えが結果に目に見えて表れる唯一のステップです。試されるのは、状態に対して正しい判断を下せるかどうかだからです。弱いモデルは skipped を失敗として再試行したり、blocked を見てもそのまま送信したりします。強い推論モデルは blockers を読み、正確に迂回します。これはフィンガープリンティングの記事で反証セクションが本当に自分の仮説に反論しているかを見極めるのと同じ種類の差です。候補を出すだけなら誰にでもできます。難しいのは判断です。

この差は、実際にテストできます。

  1. 自分のワークフローから、stages と blockers を含む実際の戻り値を一つ取ります。あるいは blockers: [research_evidence_empty] を持つ blocked のレスポンスを作ります。

  2. そのレスポンス、機能カタログである describe json、そして次のアクションを決める指示を、claude-opus-5 と gpt-5.6-sol に別々に渡します。

  3. 見るべき点は一つです。モデルが提案する次のアクションは、blocked(証拠を取りに戻る)、skipped(ユーザーの意図なので放置する)、unavailable(セッションを取得するか迂回する)を正しく区別しているでしょうか。それとも skipped を失敗扱いして再試行するでしょうか。

  4. 正しい劣化経路を選べた割合が、選定基準になります。それが、実際の障害でAgentが空回りするか、自力で迂回できるかを決めます。

本当の障壁はモデル切り替えのコスト

この4モデルは3つのベンダーにまたがっており、特にfunction callingでは切り替えコストが重くなります。OpenAIの tools / tool_calls とAnthropicの tool_use / tool_result は別形式です。オーケストレーション層により適した判断力のモデルを入れようとすると、ツールディスパッチとエラー解析の経路全体を書き換えなければなりません。ステージ状態を読み違えることが多いモデルでも、多くの人がオーケストレーション層を一モデルに固定してしまう本当の理由はここにあります。

AIReiter はその層を取り除きます。一つのキー、一つのOpenAI互換インターフェースの背後に4モデルがあり、切り替えはリクエストボディの model フィールドを変えるだけです。

# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
  }'

# Generate tool-description text in bulk: change the model field, leave the rest
#   "model": "claude-sonnet-5"
# Error attribution:
#   "model": "gpt-5.6-sol"

ネイティブのfunction callingでは tools 配列を追加するだけです。OpenAIのツールプロトコルはこのインターフェースをそのまま通るため、モデルの切り替えは依然として一フィールドの変更で済みます。すでにOpenAI SDKを使っているなら、base_url を https://aireiter.com/api/v1 に向けるだけで、ほかは何も変える必要がありません。Anthropic SDKでは、同じキーで POST /api/v1/messages を呼び出します。

価格面では、Claudeモデルはリスト価格から30%オフ、GPTモデルは半額で、Kimi K3も同じキーで利用できます。このスタックでは、割引が効くべき場所に効きます。オーケストレーション層が一歩進むたびに推論層への呼び出しが増えるため、ここがAgent全体で最も頻度が高く、最も高価な層になります。そしてClaudeの割引はそこに直接かかります。241件のdocstringからツール説明を一括生成する高並行のSonnet処理も割引対象です。この二つがコストの大半を占めます。エラー原因分類のGPT-5.6呼び出しは、はるかに少数です。

  • APIキーを取得する

  • 登録せずに試す:同じ blocked レスポンスを両モデルに渡して数回手で試し、オーケストレーション層へ組み込む前に、どちらが正しく劣化運転できるかを確認できます。

まとめ

ツール説明のドリフトは、「同期を忘れない」では解決しません。それは構造上の欠陥を個人の注意力の問題に押し込めているだけです。本当の解決は、二つの情報源という構造をなくすことです。パーサー宣言とdocstringを唯一のソースにし、機能カタログはそこから導出されるビルド成果物にする。そして一つのCI assertionを型チェックとして置く。これでドリフトは実行時の幽霊から、コミット時の赤いXに変わります。

ただし、導出が保証するのは説明の正確さだけです。レイヤー分けが正しいかどうかまでは保証しません。どの機能をコードに固定し、どの機能をモデルにオーケストレーションさせるか。そして「再試行するか、劣化運転するか」をモデルが読めるようにする六つのステージ状態。この二つが、Agentが自律的に動けるかを決めます。このスタックでモデルが担う具体的な役割は二つです。オーケストレーション層でトレードオフを判断することと、ツール呼び出しの失敗時に原因を分類することです。機能を固定すべきか、どの劣化経路を取るべきかは、モデルではなく、設計したステージ状態と書いたCIによって決まります。

これは、集合ベースのマイグレーション照合や、統一レスポンスModelを作らない理由で述べた考え方と同じです。AIは一工程にかかる時間を圧縮し、最終的な判定はハードコードした制約の中に残ります。全体が滑らかに動くようになった後に残る摩擦はモデル切り替えだけです。それはインフラの問題であり、一つの統一インターフェースが解決します。