1. ChatGPT App におけるエラーと冪等性
クラシックな Web では、「ユーザーがボタンを押す → 1 つの HTTP リクエスト → 1 つのレスポンス」という発想が今も根強くあります。LLM の世界では、これはすでに当てはまりません。モデルはあなたのツールを複数回呼ぶこともあれば、ユーザーが Regenerate を押した後に応答を再生成することも、聞き返すことも、途中でネットワークエラーに遭遇することもあります。結果として、同じツールが非常によく似た引数で 2 回、3 回と呼ばれることは十分にあり得ます。
さらに、どんなエラーにも突然 2 種類の「受け手」が現れます。ひとつはモデル側で、何が悪かったのかを機械可読に理解できる説明が必要です。そうすれば引数を修正して再試行できます。もうひとつはユーザーの UI(ウィジェットやチャット本体)側で、人間向けのメッセージと次に取れる行動を提示する必要があります。「Error: 500 (see logs)」のような表示では不十分です。
もう 1 つ重要な点として、クラシックなアーキテクチャは「誰かが大量に『再生成』を押してリトライを増やす」ことを前提にしていません。ChatGPT ではこのシナリオが標準です。 さらに、プラットフォーム自身が一時的なネットワーク問題の際に再呼び出しを行う場合があります。したがって、このエコシステムでの冪等性は「あると良い」オプションではなく、特に「本当に何かを行う」ツール(注文作成、課金、メール送信など)においては基本要件です。
この講義は、ツール呼び出しの失敗がユーザーの気分もあなたの本番環境も壊さないようにするための話です。
インサイト
ChatGPT はあなたの関数に引数を「渡す」というより、スキーマに合う引数セットを推測します。JSON Schema や対話のコンテキストを見て統計的に値を当てにいきますが、しばしば外します。「型が違う」「必須フィールドを忘れた」「パラメータが矛盾」などは、tool-call の日常であって想定外ではありません。公開情報やテレメトリによれば、複雑なスキーマではこうした外しが最大で約 30% の呼び出しを占めることも珍しくありません。
モデルにとっては問題ではありません。あなたの返答を「引数が悪かった」というシグナルとして受け取り、少し入力を変えながら 2〜3 回連続で再試行します。あなたにとって重要なのは別の点です。すべてのツールは、ほぼ確実に似たようなパラメータで複数回呼ばれる前提で設計すべきということです。
このため冪等性が非常に重要です。ChatGPT は、どんなパラメータであなたの関数を呼べばよいかを繰り返し当てにいきます。1 回の呼び出しにつき 2〜3 回の試行は普通です。
2. ウィジェットの安全な設定: text/html+skybridge と _meta
サーバー寄りの話(エラー、リトライ、冪等性)に進む前に、Apps SDK に特有の UI セキュリティの話題を 1 つ片付けましょう。あなたのウィジェットがチャット内で「インターネットの怪しいページ」ではなく、安全にレンダリングされるようにするにはどうするか、です。
registerResource と MIME タイプ text/html+skybridge
ChatGPT から見たあなたのウィジェットは、ユーザーのブラウザではなく ChatGPT クライアントのサンドボックスに読み込まれる特別な HTML リソースです。プラットフォームがそれを単なる HTML ではなく「ウィジェット」だと理解するために、MIME タイプとして text/html+skybridge を使います。
MCP/サーバー側では、次のようにリソースを登録します(擬似 TS):
// MCP サーバーの設定のどこか
registerResource({
name: "giftgenius-widget",
path: "/widget",
mimeType: "text/html+skybridge", // 重要!
});
この mimeType は ChatGPT クライアントへのシグナルです。「これは単なる HTML ではなく、隔離環境で起動すべき埋め込みウィジェットのコンポーネントテンプレートである」と。もし通常の text/html を指定すると、プラットフォームは生の HTML を表示するか、そもそもレンダリングを拒否する可能性があります。
_meta とセキュリティ制御: CSP、ドメイン、枠線
次に、ツールやリソースのレスポンスに付随して渡されるメタデータ — _meta を使います。これにより、ウィジェットが読み込める外部リソース、見た目上の挙動、さらにはモデルがそれをどう説明するかまで制御できます。
典型的な構造の例:
const toolResult = {
content: "<!-- ウィジェットの HTML -->",
_meta: {
"openai/widgetCSP": "default-src 'self'; img-src https://cdn.example.com",
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetPrefersBorder": true,
"openai/widgetDescription": "GiftGenius はギフトのおすすめをカード形式で表示します。"
}
};
主要なフィールドを見ていきます。
- openai/widgetCSP はウィジェット用の Content Security Policy を指定します。これは ChatGPT 内のブラウザ向けの小さなファイアウォールのようなもので、どこからスクリプト・スタイル・画像・XHR 等を読み込めるかを明示します。プラットフォームはワイルドカード * なしの厳格なポリシーを想定しており、(チャット、あなたの API、CDN など)使用するドメインを明示する必要があります。
- openai/widgetDomain はウィジェットが動作するオリジンを指定します。通常は ChatGPT のドメインであり、それを自分のサイトにすり替えるのではなく、隔離環境でどのように見えるべきかを知らせます。
- openai/widgetPrefersBorder は純粋に見た目のフラグで、ウィジェットの周りに枠線を描くかどうかを表します。GiftGenius の場合は、チャットの通常メッセージとおすすめブロックを視覚的に区別するために枠線を残すのが自然でしょう。
- openai/widgetDescription はモデル向けのテキスト説明です。モデルが自力で説明を「ひねり出す」代わりに、この文言を使ってユーザーに現在開いたインターフェースを説明できます。これにより、モデルの奇妙または過剰なコメントのリスクを下げられます。
実務上の結論として、一度 mimeType と _meta を丁寧に設定すれば、不要な場所へアクセスせず、ユーザーとプラットフォームの両面で予測可能に振る舞う安全な隔離 UI を得られます。フロントエンド側の安全性はこれで整理できました。ウィジェットはサンドボックス内で動作し、あなたが許可した先にだけアクセスします。次はサーバー側にフォーカスし、エラーの種類、それらの記述方法、そしてツールを冪等にする方法を見ていきます。
インサイト: ウィジェットのキャッシュ
ChatGPT はアプリ登録時にウィジェットの HTML をキャッシュします。ChatGPT の HTML ウィジェットは「生きたフロントエンド」ではなく、ビルド済みの固定アーティファクトです。アプリを公開(Store や Dev Mode)する際、プラットフォームは HTML リソース(text/html+skybridge)を読み取り、その後は常にそのバージョンを使います。テキスト 1 行やカードの余白 1 つの変更であっても、事実上新しいリリースになります。
したがって、HTML 構造、スロット、data-* 属性、structuredContent → DOM コントラクトの修正は「軽い修正」ではなく、完全なフロントエンド移行です。今日 items[] のリストをレンダリングしていて、明日 results[] に切り替えるとしても、古いウィジェットはそれを知りません。従来の JSON を受け取り続け、正しく動作しなくなるでしょう。
3. ツール動作におけるエラーの種類
ここから本題に入りましょう。ツールにはどんな種類のエラーがあり、UX とバックエンドの観点でどう異なるのか。4 つのレイヤーで考えると整理しやすいです。
入力バリデーションエラー
最も基本的なレベルは、入力引数が契約(スキーマ)にまったく合っていない場合です。
学習用アプリ GiftGenius のツール suggest_gifts(興味と予算からのギフト提案)の例:
- 年齢が 0 未満または 120 を超える;
- 予算が負数;
- 必須フィールド relationship_type が欠落;
- budget_min が budget_max より>になっている。
スキーマに合わない素の JSON もここに含まれます。理想的には、Apps SDK と JSON Schema が「完全におかしな」呼び出しはコード到達前に弾いてくれますが、(budget_min/budget_max の関係のような)ビジネス上のバリデーションは自分で実装する必要があります。
ビジネスロジックエラー
入力自体は一見妥当でも、ドメインルール上、正常な結果を返せない場合です。
典型的なケース:
- 指定の興味・予算に合うギフトが 1 つも見つからない;
- ユーザーが当日の提案回数の上限を超えた;
- モデルが購入を求める商品がすでに販売終了。
これは「サーバーが壊れた」わけではありません。期待される通常の状況であり、500 Internal Server Error ではなく、ユーザーとモデルの双方にわかりやすい形で提示すべきものです。
外部インフラのエラー
ここからは「技術的な地獄」です。データベースが落ちる、外部 API がタイムアウトする、あなたのコードで未処理例外が飛ぶ、など。
例えば:
- ギフトカタログへのリクエストが 503 を返す、または応答しない;
- MongoDB が突然一時停止した;
- ギフトのフィルタリングコードでゼロ除算が発生。
UX の観点では、多くの場合「サービスは一時的に利用できません。しばらくしてからもう一度お試しください」と伝えるべきです。場合によっては隠れたリトライを試みても良いでしょう。ただし、黙ってフェードアウトしたり、生のスタックトレースを表示したりするのは避けるべきです。
プラットフォーム/ネットワークのエラー
最後に、あなたのコードの外で起き得るレイヤーがあります。tool-call が届かない、応答の途中で接続が切れる、ストリーミングが中断される。想像より頻繁に起こります。例えば無料トンネルを使っていると、ピーク時間帯に速度が落ち、ChatGPT の tool call がタイムアウトで失敗することがあります。
これを完全にコントロールすることはできませんが、再呼び出しや中断がシステムを混乱させないよう、ツールとウィジェットを設計することはできます。だからこそ、try/catch だけで済ませるのではなく、冪等性と丁寧なエラー処理が重要だと言っています。
4. モデルと UI の双方に向けたエラーの記述と返し方
発想の転換が必要です。あなたのエラーは console.error にログしたものだけではありません。ツールの契約の一部であり、モデルと UI の双方がそれに基づいて動くのです。
エラーの構造
単純な構造に従うと扱いやすいでしょう:
type ToolError = {
code: string; // "VALIDATION_ERROR", "NO_RESULTS", "UPSTREAM_TIMEOUT"
message: string; // 人間が読めるメッセージ、またはモデル向けの簡潔な説明
retryable: boolean; // 再試行する意味があるか
};
そしてツールの結果は判別共用体で包みます:
type SuggestGiftsResult =
| { ok: true; gifts: GiftCard[] }
| { ok: false; error: ToolError };
MCP プロトコル自体にも「これはエラー」という別フラグがありますが、アプリ内部でも自前のフォーマットに揃えておくと、UI とモデルの双方が同じように解釈できて便利です。
「うまく失敗する(fail gracefully)」戦略
不快な状況すべてを「硬い」エラーとして扱う必要はありません。エラーにせず、空の結果と説明を返す方が有益なことも多いです。
例えば、ギフトが見つからない場合は、ok: true、gifts: [](空配列)と、UI/モデル向けの noResultsReason を返すのが妥当です。"NO_RESULTS" をエラーとして返す代わりに、モデルは「この予算では見つかりませんでした。予算を上げるか、興味を絞りますか?」のように対話を継続できます。
一方で外部 API が完全に落ちているなら、ok: false、code: "UPSTREAM_UNAVAILABLE"、retryable: true のように返し、モデルが後で別パラメータで再試行できる余地を与えます。
第 3 章の 4 レイヤーを思い出しましょう。バリデーションエラーは通常 ok: false かつ retryable: false とします—同じ引数での再試行は無意味です。「何も見つからない」といったビジネス状況は、ok: true で空結果+説明にするのが一般的です。外部サービスのインフラ障害は、ok: false かつ retryable: true として返し、モデルが安全に再試行できるようにします。プラットフォーム/ネットワークのエラーはあなたのコードの前後で起こり得て、実務上は「ツールの再呼び出し」として現れることが多い—だからこそ、次に述べる冪等性が重要になるのです。
内部詳細をそのまま外に出さない
サーバーコードでは、error.toString() をそのまま返したくなる誘惑があります。LLM ツールではそれは得策ではありません。対話にノイズが混ざるうえ、(内部サービスの URL、スタックトレース、テーブル名など)機微情報が露出する恐れがあります。おすすめは、例外を捕捉して、コンパクトなエラーコードと丁寧なメッセージに変換することです。
最小限のラッパー例:
try {
const gifts = await loadGiftsFromCatalog(input);
return { ok: true, gifts };
} catch (err) {
console.error("suggest_gifts failed", err);
return {
ok: false,
error: {
code: "UPSTREAM_ERROR",
message: "Catalog service is unavailable",
retryable: true
}
};
}
モデルには整ったシグナルを、UI にはわかりやすい文言を、それでいて詳細はログにだけ残す、という形になります。
ウィジェットでのエラー表示
React ウィジェットの観点では、やることはシンプルです。ok を確認し、false なら親切なメッセージと、可能なら次の行動(続行方法)を提示します。
function GiftResults({ result }: { result: SuggestGiftsResult }) {
if (!result.ok) {
return (
<div>
<p>ギフトの提案に失敗しました: {result.error.message}</p>
{result.error.retryable && <p>パラメータを変更するか、もう一度お試しください。</p>}
</div>
);
}
if (result.gifts.length === 0) {
return <p>条件に合うギフトが見つかりませんでした。予算や興味を調整してみてください。</p>;
}
return <GiftCardsList gifts={result.gifts} />;
}
これは、「何かがおかしいです」よりも、正直で簡潔なメッセージの方が UX を大幅に改善する好例です。
すでに、いくつかのエラーは retryable: true と正直にマークして、ユーザーに「もう一度試す」ことを提案できると合意しました。システムにこうしたリトライ(UI 上の明示的なもの、プラットフォーム側の隠れたもの)が入ると、次の疑問が生じます。同じデータで同じツールが 2 回呼ばれたらどうなるのか? ここからが冪等性の話です。
5. 冪等性: 「同じ呼び出しがもう一度」への防御
いよいよ本題です。形式的な定義では、冪等性とは、同じ入力での再呼び出しが、システムの状態と結果を変えないという性質です。厳密には副作用の非重複と同一応答の両方を指しますが、ChatGPT Apps 実務でまず重要なのは前者、すなわち再呼び出しでデータが壊れたり新規のエンティティができたりしないことです。応答本文が多少違っても構いません。
ChatGPT Apps における冪等性は、リトライ、Regenerate、LLM の予測不能なロジックが引き起こすあれこれへの防御です。
冪等性が特に重要な場面
読み取り専用のツールは通常安全です。suggest_gifts を同じパラメータで何度呼んでも、返ってくるのは別のギフト候補のリストです。多少違っても、システムの状態は変わらず、副作用もありません。
問題は外部システムの状態を変更するツールです:
- 注文の作成(create_order);
- 決済の実行(charge_card、submit_payment);
- メールや通知の送信(send_email、send_sms);
- 副作用を伴うエンティティの作成(例: 予約)。
この種のツールがほぼ同じ引数で連続して 2 回呼ばれると、重複注文や二重課金など、経理泣かせの事態が起こり得ます。
idempotency_key パターン
クラシックなアプローチは、ツールに idempotency_key という追加パラメータ(オペレーションの文字列識別子)を設けることです。同じキーのリクエストが既に成功していれば、サーバーは再実行せずに保存済み結果を返します。
GiftGenius の仮想ツール create_checkout_session 用に拡張したスキーマ例:
const CreateCheckoutSchema = {
type: "object",
properties: {
giftId: {
type: "string",
description: "選択したギフトの ID"
},
idempotency_key: {
type: "string",
description: "重複防止のための一意なオペレーションキー"
}
},
required: ["giftId", "idempotency_key"]
} as const;
サーバー側のハンドラーは概ね次のようにします:
async function createCheckoutSession(input: CreateCheckoutInput) {
const existing = await db.checkoutSessions.findOne({ idempotencyKey: input.idempotency_key });
if (existing) {
return existing; // 以前の結果を返す
}
const session = await paymentProvider.createSession({ giftId: input.giftId });
await db.checkoutSessions.insert({ idempotencyKey: input.idempotency_key, session });
return session;
}
モデルが何らかの理由で同じ idempotency_key でツールを再度呼んだとしても、ユーザーは二重決済にはならず、同じチェックアウトが表示されるだけです。
prepare と commit の分離
特にセンシティブな操作(決済、不可逆変更)では二相のアプローチがよく使われます。準備用ツール(prepare_*)とコミット用ツール(commit_*)を分ける方法です。
例えば:
- prepare_order — 在庫確認や金額計算を行い、「注文のドラフト」を返す;
- commit_order — ドラフトの ID を受け取り、実際の注文を作成して決済を開始する。
この設計には複数の利点があります。第一に、prepare_order を完全に冪等にできます。同じパラメータでの再呼び出しは同じドラフトを返します。第二に、commit_order はユーザーの明示的な確認後にのみ呼べるようにでき、UX とセキュリティの両面で扱いやすくなります。
6. ツールの安全な設計
冪等性は必要条件ですが、それだけで十分ではありません。モデルに渡すツール群そのものの設計が非常に重要です。
最小権限の原則
考え方は単純です。各ツールはシナリオに必要なことだけを、過不足なく実行できるべきです。以下のような 1 つの関数 do_anything_with_user_account は避けるべきです:
- あらゆるものを読み取り・更新・削除できる;
- 気まぐれな operation 文字列と payload JSON を受け取る。
代わりに、明確に記述された個別のツールに分けましょう:
- get_user_profile;
- update_user_preferences;
- create_order;
- cancel_order.
GiftGenius でも同様です。suggest_gifts は候補の提示だけを行い、create_checkout_session は注文のキャンセルやユーザーのメール変更については知りません。
「読み取り」と「書き込み」ツールの分離
データを読み取るだけのツールと、何かを変更するツールを明確に分けるのは良いパターンです。ギフトカタログの検索(search_products、suggest_gifts)は、モデルが濫用しても基本的に安全です。一方、create_order や charge_payment は、より慎重な扱いが必要です。
こうしたツールの説明には、何をし、どの文脈で呼べるのかを明示しましょう。例:
{
"name": "create_checkout_session",
"description": "1 つのギフトに対する新しい支払いセッションを作成します。ユーザーが明示的に選択を確認した後にのみ呼び出してください。",
"parameters": { /* ... */ }
}
これは万能の防御ではありません(LLM はそれでも誤る可能性があります)。それでも、少なくともリスクについて明確なシグナルを与えられます。
Human-in-the-loop と確認
本当に「危険な」操作では、確認ステップを含むシナリオが有効です。例えばモデルは:
- まず購入準備用のツールを呼び、UI に適した形(ギフト名、価格、配送先など)でデータを返す。
- プラットフォームはユーザーに「購入を確定」ボタン付きのウィジェットを表示する。
- ボタンがクリックされて初めてコミット用ツールを呼び、実際の決済を行う。
これにより、たとえモデルが「こっそり」注文を通したくなっても、ユーザーの関与なしに進められなくなります。
説明やアノテーションでリスクの意味づけをする
プラットフォームのバージョンによっては、destructiveHint のような「不可逆操作になり得る」ことを示す特別なアノテーションが登場します。そうしたフィールドが無い/不安定な場合でも、description やパラメータ名の中にリスクの意味づけを組み込めます。
例えば、次のようにする代わりに:
{
"name": "delete_user_data",
"description": "ユーザーデータを削除します。"
}
こうします:
{
"name": "request_user_data_deletion",
"description": "サービスのポリシーに従い、ユーザーの個人データ削除をリクエスト済みとしてマークします。ユーザーが明示的に削除を要求した後にのみ使用してください。"
}
同時に、人間による確認を伴う UX も構築しましょう。
7. GiftGenius への小さな実践的改修
これまでの話を、ギフト提案アプリ GiftGenius に結び付けましょう。GiftGenius に create_checkout_session というツールを追加し、ユーザーがギフトの選定だけでなく、購入手続きへ進めるようにする想定です。
JSON Schema とセキュリティの観点で、次のことを行います。
第一に、idempotency_key を追加し、説明を丁寧に書きます:
const CreateCheckoutTool = {
name: "create_checkout_session",
description:
"選択された 1 つのギフトに対する支払いセッションを作成します。 " +
"ユーザーがこのギフトを購入したいと明示的に確認した後にのみ呼び出してください。",
parameters: {
type: "object",
properties: {
gift_id: {
type: "string",
description: "suggest_gifts の結果に含まれるギフトの識別子。"
},
idempotency_key: {
type: "string",
description: "一意なオペレーションキー。再呼び出し時は同じキーを使ってください。"
}
},
required: ["gift_id", "idempotency_key"]
}
} as const;
第二に、サーバー側では冪等なハンドラーを実装します:
async function handleCreateCheckout(input: CreateCheckoutInput) {
const existing = await db.checkout.findOne({ idempotencyKey: input.idempotency_key });
if (existing) {
return { ok: true, checkout: existing };
}
const checkout = await payments.createSession({ giftId: input.gift_id });
await db.checkout.insert({ idempotencyKey: input.idempotency_key, ...checkout });
return { ok: true, checkout };
}
第三に、エラーを考慮します:
try {
return await handleCreateCheckout(input);
} catch (err) {
console.error("create_checkout_session failed", err);
return {
ok: false,
error: {
code: "PAYMENT_PROVIDER_ERROR",
message: "支払いセッションを作成できませんでした。後でもう一度お試しください。",
retryable: true
}
};
}
ウィジェット側では、わかりやすいエラー状態を表示し、必要に応じて UI レベルの「再試行」ボタンを用意して、モデルとの新しい対話を開始できるようにします。
このように 1 つずつ整えていけば、学習用のかわいいデモから、理論上は本番リリース可能なものへと近づいていきます。
8. ツールのエラー処理と冪等性でよくあるミス
ミス 1: エラー = 単なる throw と 500。
あらゆる失敗でツールが例外を投げ、それが「何かがおかしいです」に化けるだけだと、モデルと UI は情報を得られません。モデルは同じ引数で再試行すべきか判断できず、ユーザーも次に何をすればいいかわかりません。エラーコード・短いメッセージ・retryable の有無を含む構造化エラーを返し、サーバー内部で詳細をログする方がずっと良いです。
ミス 2: エラー種別の区別がない。
バリデーション、ビジネス、インフラのエラーを一緒くたにするのは悪手です。結果として「見つからない」という状況が「DB が落ちた」と見分けがつかなくなります。これは UX を損ない、モデルの適切な反応も妨げます。例えば第 3 章のビジネスエラーとインフラエラーを混ぜると特に痛いです。
ミス 3: リトライ世界で非冪等な操作。
create_order を「必ず 1 回しか呼ばれない」前提で設計するのは、重複注文への近道です。特にユーザーが Regenerate をよく押す場合や、接続が途中で切れる場合に危険です。副作用のあるツールなら、ほぼ常に idempotency_key を追加し、結果を保存して、再呼び出しで新しいエンティティを作らないようにすべきです。
ミス 4: 1 つの万能モンスター・ツール。
開発者が action パラメータを持つスーパーな 1 ツールで、検索・作成・更新・削除を全部やらせようとすることがあります。LLM にとってこれは振る舞いがほぼ確実に予測不能になります。いつ何を呼ぶかの学習が難しくなり、誤りの影響も重くなります。可能な限り小さく明確に記述された読み取り専用ツールと、確認を伴う変更系ツールに分割するのが正解です。
ミス 5: 内部詳細の漏えい。
モデルや UI に生のスタックトレースや例外全文を投げるのは典型的な怠慢です。ユーザーに不親切なだけでなく、内部構造の露出にもつながり、モデルの改善にも役立ちません。例外は捕捉してコンパクトなコードと簡明なメッセージにマッピングし、詳細はログやモニタリングにだけ残しましょう。
ミス 6: エラーとウィジェット UX の不整合。
サーバー側が丁寧にエラーコードを返しているのに、UI のウィジェットが永久に spinner のまま、あるいは空白のまま、ということがよくあります。ユーザーは「何も起きていない」と感じ、モデルは tool-call が終わったと見なして対話を続けてしまいます。error と empty の状態を分け、人間にわかるメッセージと(パラメータ変更、後で再試行など)行動の提案を出す方がずっと良いです。
ミス 7: 最小権限の原則を無視。
冪等性と良いエラー処理を実装していても、execute_sql_anywhere のように何でもできるツールを記述してしまえば、リスクは依然として巨大です。LLM は文脈を間違えて呼ぶかもしれないし、誤ったパラメータで呼ぶかもしれません。各ツールは可能な限り狭く、明確な 1 つの行為だけを実行するべきです。特にお金や個人データが絡む場合はなおさらです。
GO TO FULL VERSION