1. なぜ ChatGPT App ではエラーが「非常事態」ではなく通常運転なのか
前回の講義では、タスクをステップに分割し、ChatGPT App で多段のワークフローを構築する方法を話しました。ここに現実の前提を加えます: エラー、タイムアウト、そしてユーザーによる中断です。
クラシックな Web ではしばしば「ハッピーパス」を中心にロジックが組まれ、エラーはまれで緊急のものとして扱われがちです(赤い 500 ページ等)。しかし ChatGPT App の状況は異なります。LLM、外部 API、MCP、ウィジェットという分散システムで動作し、さらにユーザーはいつでもタブを閉じるかもしれません。エラーや中断は日常です。
状況を難しくする特徴がいくつかあります:
- 第一に、LLM は非決定的です。同じプロンプトでも少し違う判断をすることがあります。別のツールを呼んだり、パラメータを変えたり、そもそも「聞き返す」のが良いと判断したりします。
- 第二に、ネットワークとインフラの制約があります。ChatGPT の tool‑call にはタイムアウト(通常は数十秒)があり、Next.js/Vercel のバックエンドにもタイムアウトがあります。外部 API が遅ければ、途中で切れてしまうことがあります。
- 第三に、UX の要因があります。ユーザーが気を取られてチャットを閉じ、翌日戻ってくるかもしれません。あなたはその間ずっとデータベースのトランザクションを開きっぱなしにはできません。
ここから導かれる本講義の主張は次のとおりです。
フォールトトレラントなワークフロー = あらゆるステップが落ちうると最初から見なし、その場合に何が起こるかを明示的に定義するシナリオ。
エラーはユーザーにメッセージを見せるだけでなく、モデルへのシグナルでもあります。モデルは戦略を変え、ロールバックを提案し、別のツールを試し、あるいはシナリオを丁寧に終わらせることができます。
2. ワークフローのエラー地図: どんな種類があるか
障害を適切に処理するには、まずそれらを見分けられるようになる必要があります。ChatGPT Apps ベースの LLM アプリでは、典型的にいくつかのクラスのエラーが発生します。
技術的エラー。 分散システムの古典です。ネットワークのタイムアウト、あなたの API や外部 API からの 5xx、MCP サーバのダウン、ツールハンドラのバグなど。例えば GiftGenius で MCP ツール search_products がカタログにアクセスし、503 Service Unavailable を返される、といったケース。これは自動リトライの候補です。
論理(モデル)エラー。 モデルの拒否(ポリシーに反すると判断)、ハルシネーション、ツール応答の壊れた JSON などが含まれます。モデルがツール呼び出し用の不正な引数を生成し、あなたの JSON バリデーションで弾かれた、といったもの。多くの場合、これは入力データのエラーであって、インフラの問題ではありません。
ビジネスエラー。 意味の問題です。商品が在庫切れ、ユーザーの予算が選択したフィルタに対して少なすぎる、クーポンが無効、予約が期限切れなど。GiftGenius では「500 件の候補のうち、指定の制約に合致するものが一つもない」といった状況。ここではリトライはあまり効きません。パラメータを変えるか、制約が非現実的であることをユーザーに説明すべきです。
UX による中断。 ユーザー自身がシナリオを切ります。ChatGPT を閉じる、ウィジェットで「戻る」を押す、操作をキャンセルする、前のステップの回答を変更する、など。これも異常ではなく通常のフローと見なすべきです。この場合に状態を復元・ロールバックできることが重要で、後ほど詳しく扱います。
論理と技術の境界にある厄介なケースが、エージェントの無限ループです。モデルがエラーを受け「うーん、もう一度やってみよう」と考え、またエラーになり、それがコンテキストや予算が尽きるまで続くような事態。こうした挙動から守ることは、エラー設計の重要な一部です。
3. 基本戦略: retry、fail‑fast、rollback、ユーザーの関与
あらゆるエラーは分岐点と見なせます。ステップを再試行するか、ロールバックするか、ユーザーを巻き込むか。そして重要なのは、これらの戦略は組み合わせられるということです。
技術的・一時的な障害(ネットワークの瞬断、API が 503 を返す等)には、制限付きリトライ+バックオフが妥当です。 論理・ビジネスエラー(「バリデータが予算を受け付けない」「在庫がない」等)では、同じ入力で繰り返しても無意味なので、fail‑fast し、ユーザーに入力やパラメータの変更を求めます。
すでに外部の状態を変更する操作(注文作成、予約など)がある場合は、rollback が必要です。UI/コンテキスト上の「一つ戻る」といった論理的なもの、あるいは実際の補償(注文の取消、返金)です。
ユーザーの参加が必須のケースもあります。例えば決済が「発行体により拒否」の場合、それを自動で直すことはできません。モデルは何が起きたかを丁寧に説明し、別のカードで試す、金額を下げる、購入をやめる、といった選択肢を提示すべきです。
堅牢なワークフローのためには、各ステップごとに「どのタイプのエラーが起こりうるか」と「それぞれに対して何をするか」を明示しておくとよいです。自動リトライ、ロールバック、ユーザーへの問い合わせ、あるいはログだけ残して分岐を終了、など。
4. リトライ(retry)とバックオフ: いつ・どうやるか
開発者の自然な反応は「まあ、もう一度試してみよう」です。発想は正しいのですが、細部に悪魔がいます。
どのエラーをリトライすべきか
統合の実務での良い経験則はこうです。ネットワークエラーや 5xx は待って再試行してよい。一方で 4xx は基本的にリトライしない。
つまり、503、504、あるいは外部 API から応答が来ない場合は、短い遅延を置いて再試行する意味があります。逆にサーバが 400 Bad Request や 422 Unprocessable Entity を返した場合、通常はデータ側の問題であり、同じパラメータで再試行しても変わりません。
TypeScript の簡単なユーティリティ callWithRetry
MCP やバックエンド層で使える小さなユーティリティを書いてみましょう。
type RetryOptions = {
maxRetries: number;
baseDelayMs: number;
};
async function callWithRetry<T>(
fn: () => Promise<T>,
{ maxRetries, baseDelayMs }: RetryOptions
): Promise<T> {
let attempt = 0;
// 無限ループは不要
while (true) {
try {
return await fn();
} catch (err: any) {
attempt++;
const status = err?.status ?? err?.response?.status;
// 4xx はリトライしない
const isClientError = typeof status === "number" && status >= 400 && status < 500;
if (attempt > maxRetries || isClientError) {
throw err;
}
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 10_000);
// スタンピードを避けるため少しジッターを入れて待つ
const jitter = Math.random() * 200;
await new Promise((r) => setTimeout(r, delay + jitter));
}
}
}
この関数は次のことを行います。
- fn の呼び出しを指定回数までリトライします。
- 同時リトライでの「スタンピード」を避けるため、小さなジッター付きの指数バックオフを使います。
- 4xx ではリトライを止めます。
例えば商品カタログや社内のレコメンド API にアクセスする MCP ツール内部で使うのに向いています。
リトライはどこで行うべきか
ありがちな誤りは、制御できない層まで含めて全リクエストを片っ端から再試行しようとすることです。ChatGPT エコシステムでは、リトライを行える場所がいくつかあります。
- 自前のバックエンド/MCP の内部(上の callWithRetry のように);
- バックグラウンドワーカー/キューの内部(今後のモジュールでジョブキューや DLQ を詳述します);
- 場合によってはウィジェット側(副作用のない「リスト更新」程度の軽いリクエスト)。
ロジックの二重化は避けましょう。ジョブワーカーがすでにバックオフ付きで 3 回リトライしているなら、さらにウィジェット側で 5 回も積む意味はありません。そして決して while(true) { try ... } のような実装をしないでください——自家 DDOS の元です。
5. ステップのべき等性: 二重実行からの防御
リトライは第二の問題を生みます。すなわち、同じ操作を二重に実行してしまわない方法です。LLM の世界では特に切実です。モデルがうっかり同じツールを複数回呼んだり、タイムアウト後に ChatGPT が tool‑call を再送したり、ユーザーが「Regenerate」を押し、その後に UI やエージェントが独自に呼び出しを追加したりします。
べき等性の考えは単純です。同じ入力での再実行が追加の副作用を生まないなら、そのステップはべき等です。プロダクトフィードの取得——OK、レコメンドの再計算——OK。しかし同じデータでの二重課金や二重注文の作成——NG です。
ChatGPT App における Idempotency key
古典的なパターンです。副作用のある論理ステップごとに idempotency_key(通常 UUID)を生成し、モデル経由で MCP ツールへ渡し、ツール側で「キー → 結果」の対応を保持します。同じキーで 2 回目の呼び出しが来たら、操作を再実行せず、保存済みの結果を返すだけにします。
私たちの GiftGenius には create_order というステップがあります。ユーザーが「支払う」を押し、モデルがツールを呼び、決済は成功したが、どこかで応答が失われたとします。モデルやプラットフォームが呼び出しを再試行すると、べき等性がなければ注文の重複や二重課金が起こります。
TypeScript のシンプルなべき等ツールの例
idempotency キー付きの MCP ツール create_order の極簡単なハンドラを作りましょう。簡単化のため in‑memory の Map を使います。実運用では DB やキャッシュを用います。
type CreateOrderInput = {
userId: string;
items: Array<{ sku: string; qty: number }>;
idempotencyKey: string;
};
type CreateOrderResult = { orderId: string; status: "created" };
const idempotencyStore = new Map<
string,
{ paramsHash: string; result: CreateOrderResult }
>();
export async function createOrderTool(input: CreateOrderInput): Promise<CreateOrderResult> {
const { idempotencyKey, ...rest } = input;
const paramsHash = JSON.stringify(rest);
const existing = idempotencyStore.get(idempotencyKey);
if (existing) {
// すでにキーがある場合、パラメータ一致を確認
if (existing.paramsHash !== paramsHash) {
throw new Error("Idempotency key reuse with different params");
}
return existing.result;
}
// ここで実際に注文作成と決済を行う
const result: CreateOrderResult = {
orderId: "order_" + Math.random().toString(36).slice(2),
status: "created",
};
idempotencyStore.set(idempotencyKey, { paramsHash, result });
return result;
}
ここで私たちは次を行っています。
- 入力に idempotencyKey を必須とする;
- キーと一緒にパラメータのハッシュ(ここでは簡単のため JSON.stringify)を保持する;
- 同一キーでも別データでの再呼び出しはエラーとみなす;
- 同一キーかつ同一データでの再呼び出しは以前の結果を返す。
実プロジェクトでは次のようにするとよいでしょう。
- キーを TTL 付きで DB に保存する(無限に膨らまないように);
- idempotency_key をログに残し、MCP メッセージの _meta にも入れて、Inspector やダッシュボードで追いやすくする。
6. ステップのロールバックと Saga パターン
べき等性は重複を防ぎますが、もう一つのケース——シナリオの途中のステップが失敗したらどうするか——は解決しません。
E‑commerce では古典的な問題です。すでに注文を作成し倉庫に在庫を確保したが、決済段階で失敗した。単に「忘れる」わけにはいきません。前の状態を戻す必要があります。
論理的ロールバック vs 技術的ロールバック
ChatGPT のワークフローには 2 つのロールバックの層があります。
論理的ロールバックは、シナリオの前のステップへ戻り、コンテキストを調整することです。例えば「支払い」ステップでエラーが発生し、「支払い方法の選択」ステップ、あるいは「ギフトの選択」まで戻すと決めたとします。その場合重要なのは:
- バックエンドの WorkflowContext(現在のステップ、選択パラメータ)を更新する;
- ツール呼び出し/ToolOutput を通じて、ステップ変更をモデルに伝え、古い分岐を「忘れ」させて以降の挙動を合わせる;
- ウィジェットの UI を更新し、ステップやボタンが新しい状態に一致するようにする。
技術的ロールバックはビジネスレベルの補償です。作成済みエンティティの取消や外部副作用の補償です。例えば注文取消、在庫の解除、返金の開始。これが Saga パターンです。「危険な」各ステップに対して、事前に補償アクションを用意します。
GiftGenius 向けの forward/compensate 図
簡易な GiftGenius のチェックアウトについて、以下のシーケンスを描けます。
flowchart TD A[ステップ 1: create_order] --> B[ステップ 2: reserve_items] B --> C[ステップ 3: charge_card] C -->|成功| D[ステータス: 完了] C -->|エラー| E[補償: cancel_reservation] E --> F[補償: cancel_order] F --> G[ステータス: 失敗 + ユーザーへのメッセージ]
外部の状態を変える各アクション(注文作成、予約、決済)に対応する補償アクション(注文取消、予約解除、返金)を用意します。対称でない場合や 1:1 で対応できない場合もありますが、原則はこの通りです。
補償を伴う最小コード例
これらのステップを実行する小さなコード断片を見てみましょう。
async function completeCheckout(ctx: { userId: string }) {
const order = await createOrderInDb(ctx.userId);
try {
await reserveItems(order.id);
await chargeCard(order.id);
return { orderId: order.id, status: "paid" as const };
} catch (err) {
// 補償アクション
await safeCancelReservation(order.id);
await safeCancelOrder(order.id);
throw err;
}
}
ここでは:
- createOrderInDb、reserveItems、chargeCard は forward ステップ;
- safeCancelReservation と safeCancelOrder は補償ステップで、これ自体がべき等であるべきです(すでに取消済みでも害はありません)。
注意点として、エラー時にそれを隠さずに投げ直している点です。モデル(ToolOutput 経由)は理解しやすいエラー情報を受け取り、ユーザーに人間向けの説明をし、次のステップを提案できるようにします。
7. ステップのロールバックと状態同期: 同期ズレを起こさないために
見落としがちな「エラー」の一種が、UI・バックエンド・モデル間の状態の不整合です。
典型的なシナリオ:
- ユーザーがステップ 1 → 2 → 3 と進む。
- ステップ 3 で問題が起き、ユーザーがウィジェットで「戻る」を押す。
- ウィジェットはローカルステートを 2 に戻す。
- しかしモデルは「3 まで行って支払いを試した」ことを覚えている。次のメッセージでも支払いの話を続けるが、ユーザーはギフト選択画面を見ている。
これを避けるには、明示的なロールバックイベントを導入すると有効です。ウィジェットが MCP/モデルにイベントを送ります——ツール呼び出しか、ToolOutput として。
例えば現在のステップと状態を記録するシンプルなツール user_navigated_to_step を用意できます。
type NavigateInput = {
workflowId: string;
stepId: string;
};
export async function userNavigatedToStep(input: NavigateInput) {
await workflowRepo.setCurrentStep(input.workflowId, input.stepId);
return {
message: `User moved to step ${input.stepId}`,
};
}
ウィジェットは「戻る」時にこのツールを呼びます。モデルはツール呼び出し履歴でその結果を見て、以後は新しいステップを前提に対話を続けます。
UI 側ではおおむね次のようなハンドラになります。
async function handleBackClick() {
const { workflowId, prevStepId } = widgetState;
await window.openai.tools.call("user_navigated_to_step", {
workflowId,
stepId: prevStepId,
});
setWidgetState((s) => ({ ...s, currentStepId: prevStepId }));
}
重要な点は、現在のステップについての単一の情報源はバックエンド/エージェントであり、モデルはツールを通じてそれを見る、という設計にすることです。そうすれば、後でセッションを復元する際も正しくコンテキストを同期できます。
8. エラーの UX: ユーザーが見るもの、モデルが見るもの
リトライ、ロールバック、べき等性、状態同期を技術的にこなせるようになりました。残るは、ユーザーにもモデルにも自然に見える形にすることです。
どれだけリトライとロールバックが完璧でも、エラー UX が「古い Java サーブレット時代」——赤字のテキスト、スタックトレース、「Unexpected error」——のままでは台無しです。
ChatGPT App でのエラーメッセージには、2 つの受け手があります。
- ユーザー: 何が起きたか、次に何ができるかを理解できる必要があります。
- モデル: リトライするか、パラメータを変えるか、代替案を出すか、終了するかを判断できるだけの構造化情報が必要です。
良い実践としては:
- MCP/ツール層で、コード・タイプ・retryable フラグ・短い技術的説明を持つ構造化エラーを返す;
- モデルには(たとえば result.structuredContent で)その構造を渡し、長大なスタックトレースは渡さない;
- UI ではユーザーに短く人間向けのメッセージを見せる。
ツールが返すエラー構造の最小例:
type ToolError = {
code: string; // 例: "PAYMENT_TIMEOUT"
message: string; // 簡潔な技術的説明
retryable: boolean; // 再試行すべきか
};
throw {
isError: true,
error: <ToolError>{
code: "PAYMENT_TIMEOUT",
message: "Payment provider did not respond in time",
retryable: true,
},
};
モデルは retryable: true を見て、別のツールを試すか、ユーザーに再試行を提案できます。
ウィジェット側では、これらのコードをユーザー向けテキストにマッピングするだけです。
function ErrorBanner({ code }: { code: string }) {
const text =
code === "PAYMENT_TIMEOUT"
? "決済サービスの応答がタイムアウトしました。1分後にもう一度お試しください。"
: "問題が発生しました。もう一度お試しください。";
return <div className="error-banner">{text}</div>;
}
もう一つ大事な点として、ユーザーにスタックトレースやトークン、シークレットを見せないでください。見栄えも悪く危険です。技術的情報はあなた側でログし、ユーザーには短く安全なメッセージを出しましょう。
Insight
ChatGPT のような LLM システムでは、不正なツール呼び出しは例外ではなくむしろ常態です。モデルはしばしば、検証を通らない引数を生成します。型の取り違え、欠落フィールド、不正値、壊れた構造など。これは従来のエンジニアリング的な意味での「バグ」というより、確率的モデルの本質の一部であり、それに合わせてエラーインターフェース全体を設計する必要があります。
キーとなる考え方はこれです。エラーメッセージは「壊れた」ことの合図ではなく、次の試行を直すための指示です。そして主な受け手はモデル自身です。メッセージが構造化され、正確な指針を含んでいれば、モデルは自動的にパラメータを修正して正しく再試行できます。これはまさに Tool‑Reflection の技法が依拠する原理です。正しいフィードバックは、人手を介さずにエージェントの次の行動を改善します。
エラーフォーマットに関しては次を推奨します。
- メッセージは、検証に通らなかった具体的なフィールドを指摘する(「Invalid parameters」レベルの一般論は避ける);
- モデルが選べるよう、期待フォーマットや許容値を明示する;
- メッセージは簡潔・形式的・構造的にする。error_type、field、expected、allowed_values のような項目がモデルに非常に効く;
- 可能であれば最小の正しい入力例を示す。モデルの復元精度が上がることが多い。
理想的なモデル向けエラーフィードバックは 2 つの事実を含みます。何が問題だったのか、そしてどう直せばよいかです。
9. ワークフローのエラーロギングとメトリクス
エラー UX が丁寧でも、実際に何が壊れているかを理解するには、ユーザーメッセージだけでは不十分です。構造化ログとステップ別メトリクスが必要です。
各ワークフローステップをログする際の最小有用セット:
- user_id、少なくとも session_id;
- workflow_id と step_id;
- ステップのステータス(success、failed、retry、rolled_back);
- error_code(あれば);
- 外部呼び出しに関係するなら idempotency_key と correlation_id。
MCP やエージェントには _meta フィールドがあります。そこに idempotency_key と correlation_id を入れておくと、ログや Inspector で見やすく便利です。
Node.js/TypeScript による最小のロギング例(console でも、winston/pino でも可):
function logStepFailure(params: {
userId?: string;
workflowId: string;
stepId: string;
errorCode: string;
idempotencyKey?: string;
}) {
console.error(
JSON.stringify({
level: "error",
event: "workflow_step_failed",
...params,
timestamp: new Date().toISOString(),
})
);
}
このようなログは簡単にパースでき、ダッシュボード化や集計がしやすくなります。
- ステップ間のコンバージョン;
- 最頻のエラータイプ;
- リトライで終わったステップと最終的失敗で終わったステップの比率、など。
すべてのエラーを本番アラートにする必要はありません。重要なのは——MCP のダウン、系統的タイムアウト、特定ステップでの大量失敗など——監視に上げるべきです。一方で「非常に厳しいフィルタに合うギフトが見つからない」はビジネスイベントであり、インシデントではありません。
10. GiftGenius を発展させる: 強靭なチェックアウト・ステップ
ここまでの要素——リトライ、べき等性、Saga、状態同期、エラー UX、ロギング——を、学習用アプリ GiftGenius の一つのステップ(チェックアウト)でまとめてみましょう。
すでにあるもの
ここまでで私たちはすでに:
- 多段ワークフロー(情報収集 → アイデア提示 → ギフト選択 → チェックアウト)を持っている;
- ツールのゲーティングを設定している。チェックアウトでは commerce ツール群(create_order、get_payment_methods など)のみ有効;
- WorkflowContext を持ち、選択したギフト、予算、userId、現在ステップを保持している。
この講義で追加するもの
チェックアウトのステップに以下を導入します。
- ツール create_order の idempotency_key;
- 決済プロバイダの一時的エラー時の retry;
- 補償(部分的に成功した操作に対して);
- 正しいエラー UX(ウィジェット側)。
「支払う」クリック時にウィジェットで idempotency キーを生成します。
import { v4 as uuid } from "uuid";
async function handlePayClick() {
const idempotencyKey = uuid();
setWidgetState((s) => ({ ...s, idempotencyKey }));
await window.openai.tools.call("create_order", {
userId: widgetState.userId,
items: [/* ... */],
idempotencyKey,
});
}
ツール create_order 側は先ほどのべき等ハンドラです。キーと結果を保存し、再試行でも新しい注文を作りません。
決済 API とのやり取りは callWithRetry でラップし、ネットワークのグリッチで数回試せるようにします。そしてモデルが再試行を提案できるよう、エラーに retryable: true フラグを忘れず付けます。
注文作成と課金が成功した後に何かが壊れた(例えば外部の webhook が時間内に来ない)場合は、correlation_id と workflow_id を付けてログし、次を行います。
- バックグラウンドでリトライ(キューとイベントは今後のモジュールで扱います);
- またはステップを明示的に failed とし、補償を実行してユーザーに状況を説明します。
11. フォールトトレラントなワークフロー設計のありがちな誤り
誤りその1: 「飛ぶまで全てリトライする」。
すべてのステップを勝利するまで自動リトライ——これは地獄への近道です。ネットワークや 5xx エラーはバックオフと試行回数制限付きで再試行して構いません。しかし 4xx、ビジネスエラー、モデルの論理的失敗はデータで直すか、ユーザーに説明すべきです。さもないと不安定な挙動、奇妙な請求、ノイズだらけのログを招きます。
誤りその2: お金や注文に関わるところにべき等性がない。
create_order や charge_card のようなツールがべき等でないと、タイムアウト、Regenerate、エージェントのバグ等での再呼び出しが重複を生みます。LLM シナリオではリトライがクラシックな REST フロントエンドより頻発するため、idempotency_key は「あると良い」ではなく、決済やその他のクリティカルなステップで必須です。
誤りその3: 補償アクション(Saga)がない。
注文を作成し、在庫を確保したのに、決済で落ちて「問題が発生しました」とだけ表示。結果としてシステムに半端な注文や予約、金銭の「尻尾」がぶら下がることに。外部の状態を変える各ステップについて、次のステップが失敗したら何をするか(取消、返金、expired マーク等)を考えておきましょう。
誤りその4: エージェントに無限リトライを許す。
試行回数を制限せず(例えばヘルパーの maxRetries やエージェントロジックの max_iterations)、「retryable: false」とすべき場所でそうしないと、モデルは「もう一度…もう一度…」とループします。これはトークンと時間と神経を消耗します。
誤りその5: ロールバック時に UI とモデル間の状態不整合。
「戻る」ボタンを UI にだけ実装し、バックエンドとモデルのステップ同期を忘れがちです。その結果、ユーザーはステップ 2 を見ているのに、モデルはステップ 3 に居座り奇妙な提案を続けます。解決策は user_navigated_to_step のような明示的イベントと、遷移ごとの WorkflowContext 更新です。
誤りその6: ユーザーに技術的メッセージ、開発者にログがない。
ユーザーには「Error: ECONNRESET at TcpSocket.onEnd…」が出るのに、あなたはどの workflow_id のどの step_id が壊れたのか情報ゼロ。賢いやり方は、ユーザーには短くわかりやすい文と次の行動提案、開発者には workflow_id、step_id、error_code、idempotency_key、correlation_id を含む構造化ログです。
誤りその7: アラート戦略がない。
「非常に狭いフィルタでギフトが見つからない」まで含めて何でもアラートにするか、逆に MCP の本当のダウンすらアラートにしないか。クリティカルなシステム障害(サービスダウン、大量タイムアウト、webhook 喪失)と、想定内のビジネスイベントを分けましょう。前者は監視・オンコールへ、後者は分析でカウントするだけに。
GO TO FULL VERSION