最初にこの記事の前提を一つだけ共有させてください。私自身が、Claude Agent SDK で組み立てた6ステップのワークフローのうち、5ステップ目で外部APIがタイムアウトし、1〜4ステップで起こった副作用(決済の保留、メール下書き、CRM更新、Slack通知)の戻し方が分からずに止まっていた、という小さな事故を本番でやっています。当時の私は、SDKのループに気を取られていて「失敗したらどう戻すか」を後回しにしていました。
この記事は、その失敗から学んだ設計の話です。Agent SDK の公式ドキュメントには「冪等にしましょう」「リトライを設計しましょう」と書いてあるのですが、複数ステップにまたがる副作用をまとめて巻き戻す方法、いわゆる サーガパターン に踏み込んだ実装例はほとんど見つかりません。今日は、実際に運用してきた構成と、本番で詰まったポイントを順を追って共有します。
なぜ Agent SDK でサーガパターンが必要なのか
Agent SDK は、ツール呼び出しと推論をループで繰り返すフレームワークです。1つのループの中で、エージェントは以下のような副作用を順番に発生させていきます。
- 決済の予約(Stripe
payment_intentの作成) - 在庫の引当(自社DBへの更新)
- 配送業者への出荷予約API呼び出し
- 顧客向けメールの送信
- 監査ログの記録
ここで重要なのは、これらの操作はそれぞれが独立した外部システムへの副作用 であり、データベーストランザクションのように ROLLBACK でまとめて戻せないことです。3番目の出荷予約APIが 504 を返した瞬間に、1番目の決済予約と2番目の在庫引当だけが現実世界に残ってしまいます。
この「分散副作用の不整合」を、ローカルACIDの代わりに「補償トランザクションの連鎖」で扱う設計が、サーガパターンです。Agent SDK で組むワークフローは、外部API・自社DB・LLM呼び出しが入り混じるため、サーガパターンとの相性は本来とても良いのですが、SDK が標準ではこの構造を提供してくれません。だから自分で組み立てる必要があります。
Choreography と Orchestration、どちらを選ぶか
サーガパターンには大きく2つの実装スタイルがあります。
- Choreography(コレオグラフィ): 各サービスがイベントを購読し、自律的に次のステップを決めて動く
- Orchestration(オーケストレーション): 中央のコーディネータがステップの順序を管理する
私は Agent SDK でサーガを組むときは、ほぼ常に Orchestration を選んでいます。理由は3つあります。1つ目は、Agent SDK 自体がもともと中央の推論ループを持っているので、そこにサーガコーディネータを置くのが自然だから。2つ目は、ステップごとに「エージェントの判断が必要な分岐」が入りやすく、イベント駆動だと判断ロジックの局所性が壊れやすいから。3つ目は、ロールバック時の順序保証が Orchestration の方が直感的だからです。
ただし、ステップ数が10を超えてくると Orchestration のコーディネータが太りやすくなります。その場合は、サブサーガに分割して各サブサーガを別のエージェントに任せる構成(マルチエージェント設計)の方が筋が良くなります。マルチエージェントの実装方針については Claude API でのマルチエージェント本番運用パターン で詳しく書いています。
最小構成のサーガコーディネータ
まず、ステップ単体を表す型を定義します。実行する関数 execute と、失敗時に呼ばれる compensate のペアです。
// saga/types.ts
export interface SagaStep<TInput, TResult> {
name: string;
execute: (input: TInput, ctx: SagaContext) => Promise<TResult>;
compensate: (input: TInput, executeResult: TResult | null, ctx: SagaContext) => Promise<void>;
// 補償の冪等性キーの基底(後述)
idempotencyKeyPrefix: string;
}
export interface SagaContext {
sagaId: string; // 1サーガ実行ごとに発行するID(UUIDv7推奨)
attempt: number; // 何回目の試行か
store: SagaStateStore; // 状態の永続化先
}compensate の引数に executeResult: TResult | null を渡しているのが地味ですが重要なポイントです。実行が始まる前に失敗したのか(null)、実行は成功したけれど後続が失敗したのか(実行結果あり)で、補償の挙動を変えなければならないからです。
次に、コーディネータ本体です。
// saga/coordinator.ts
import { SagaStep, SagaContext } from "./types";
interface RunOptions {
sagaId: string;
store: SagaStateStore;
}
export async function runSaga<I>(
steps: SagaStep<I, unknown>[],
initialInput: I,
opts: RunOptions
): Promise<{ ok: boolean; failedAt?: string }> {
const ctx: SagaContext = { sagaId: opts.sagaId, attempt: 1, store: opts.store };
const executed: { step: SagaStep<I, unknown>; result: unknown }[] = [];
for (const step of steps) {
try {
// 既に成功していれば再実行しない(再開のための冪等性)
const cached = await ctx.store.getStepResult(ctx.sagaId, step.name);
if (cached?.status === "succeeded") {
executed.push({ step, result: cached.result });
continue;
}
await ctx.store.markStepRunning(ctx.sagaId, step.name);
const result = await step.execute(initialInput, ctx);
await ctx.store.markStepSucceeded(ctx.sagaId, step.name, result);
executed.push({ step, result });
} catch (err) {
await ctx.store.markStepFailed(ctx.sagaId, step.name, err);
// 既に成功したステップを「逆順」で補償していく
for (const done of executed.reverse()) {
try {
await done.step.compensate(initialInput, done.result, ctx);
} catch (compErr) {
// 補償の失敗は最も致命的なので、ログに残し人手介入を促す
await ctx.store.markCompensationFailed(ctx.sagaId, done.step.name, compErr);
}
}
return { ok: false, failedAt: step.name };
}
}
return { ok: true };
}期待する出力は、すべてのステップが成功すれば { ok: true }、途中で失敗した場合は { ok: false, failedAt: "step-name" } が返り、それまでに完了していたステップが逆順で補償されます。
ここで一番大切なのは「補償は逆順で」「補償の失敗は別カテゴリで記録する」の2点です。逆順にしないと、たとえば在庫を戻す前に決済をキャンセルしてしまい、在庫だけが残るような不整合が発生します。
Agent SDK のツール呼び出しとサーガを橋渡しする
ここからが Agent SDK 固有の話です。Agent SDK では、エージェントが LLM の判断結果を元に tool_use イベントを発火させます。このツール実装の中で、上述の runSaga を呼び出すラッパーを作るのが、私の経験上もっとも安定した構成でした。
// tools/checkout-saga-tool.ts
import { Tool } from "@anthropic-ai/claude-agent-sdk";
import { runSaga } from "../saga/coordinator";
import { reservePayment, capturePayment, cancelPayment } from "./payment";
import { allocateInventory, releaseInventory } from "./inventory";
import { scheduleShipping, cancelShipping } from "./shipping";
export const checkoutSagaTool: Tool = {
name: "execute_checkout",
description: "顧客の購入を、決済・在庫・配送の3つの副作用を整合的に処理する",
input_schema: {
type: "object",
properties: {
orderId: { type: "string" },
customerId: { type: "string" },
amountJpy: { type: "number" },
},
required: ["orderId", "customerId", "amountJpy"],
},
async run(input, sdkCtx) {
const sagaId = `chk_${input.orderId}`; // orderId に紐づけて再開可能にする
const result = await runSaga(
[
{
name: "reserve-payment",
idempotencyKeyPrefix: "pay-reserve",
execute: (i, c) => reservePayment(i.amountJpy, c),
compensate: (_i, r) => (r ? cancelPayment(r as { paymentIntentId: string }) : Promise.resolve()),
},
{
name: "allocate-inventory",
idempotencyKeyPrefix: "inv-alloc",
execute: (i) => allocateInventory(i.orderId),
compensate: (i) => releaseInventory(i.orderId),
},
{
name: "schedule-shipping",
idempotencyKeyPrefix: "ship-schedule",
execute: (i) => scheduleShipping(i.orderId),
compensate: (i) => cancelShipping(i.orderId),
},
],
input,
{ sagaId, store: sdkCtx.runtime.kv }
);
if (!result.ok) {
// LLM に「失敗したので別の選択肢を考えてもらう」フィードバックを返す
return {
status: "compensated",
failedAt: result.failedAt,
message: "途中まで実行した副作用は補償済みです。ユーザーに別案を提示してください。",
};
}
return { status: "ok" };
},
};このラッパーを通すことで、エージェントの推論ループは「ツールが成功したか・失敗したか・補償されたか」だけを認識すればよくなります。LLM 側のプロンプトに「補償されたケースでは別案を提示してください」と書いておくと、ユーザー体験まで一貫した形で組み上がります。
冪等性キーの設計 — リトライ時に二重補償を防ぐ
サーガで一番苦労するのが、補償の二重実行です。たとえばネットワーク瞬断で cancelPayment の応答が返らなかった時、サーガを再開すると同じ補償が再度走ります。Stripe のような外部APIは冪等性キーをサポートしているので問題は表面化しないことが多いのですが、自社DB相手の補償(在庫の解放など)は明示的に冪等にしないと、在庫が二重に解放されて結果として在庫マイナスになる事故が起きます。
私は次の規約で統一しています。
// saga/idempotency.ts
export function compensateKey(sagaId: string, stepName: string, prefix: string): string {
// 例: "pay-reserve:chk_abc123:reserve-payment"
return `${prefix}:${sagaId}:${stepName}`;
}
// 自社DB側
async function releaseInventory(orderId: string): Promise<void> {
const key = compensateKey(`chk_${orderId}`, "allocate-inventory", "inv-alloc");
// すでにこのキーで補償済みなら何もしない
const seen = await db.query("SELECT 1 FROM compensations WHERE key = $1", [key]);
if (seen.rowCount > 0) return;
await db.transaction(async (trx) => {
await trx.query("UPDATE inventory SET reserved = reserved - 1 WHERE order_id = $1", [orderId]);
await trx.query("INSERT INTO compensations(key, applied_at) VALUES ($1, now())", [key]);
});
}期待する出力は、同じ orderId に対して releaseInventory を何回呼んでも、在庫の reserved カウントは1しか減らない状態です。compensations テーブルが冪等性の唯一の根拠になるので、ここは絶対にステップと同じトランザクションで書き込むこと。別トランザクションにすると「在庫は戻したけれど冪等キーは記録されていない」という最悪の状態が生まれます。
冪等性の周辺は、別記事の Claude Agent SDK の冪等性設計パターン でより詳しく書いていますので、補完的に読んでいただけたらと思います。
状態の永続化 — Redis か Durable Objects か RDB か
サーガコーディネータが動いているプロセスが落ちたとき、サーガは「失敗」ではなく「中断」しているだけです。再起動時に途中から再開できなければ、長時間ジョブを安全に運用できません。これが SagaStateStore の役割です。
私が採用してきた選択肢を、特性ごとに整理します。
- Redis: TTL を効かせやすく、書き込みが速い。ただし永続性が弱いので、最終的なジャーナルは別の永続ストレージに二重書きするのが安全。1日以内に終わる短命サーガに向く
- Cloudflare Durable Objects: サーガIDごとにオブジェクトを割り当て、強整合のシングルライタが得られる。マルチリージョンで動かすときに最も悩みが少ない選択肢。コストはかかるが、運用負荷を考えると安い
- PostgreSQL + advisory lock: 既にRDBがある組織で導入しやすい。ジャーナルテーブルとインデックスを丁寧に設計すれば、何ヶ月も寝かせるサーガにも耐える。長命ワークフロー向け
参考までに、Durable Objects を使った Agent ワークフローの構成は Claude Agent SDK と Temporal による永続的 AI ワークフロー でも触れていますので、似た要件の方は併読していただくと選定が早く済むと思います。
よくある間違い・落とし穴
ここからは、私が実際にやらかしたか、レビューで指摘した落とし穴を列挙します。
1. ネットワークタイムアウトを「失敗」と即断する
タイムアウトの実体は「結果が分からない」状態です。reservePayment がタイムアウトしたとき、サーバ側で予約は成立しているかもしれません。ここで素朴に「失敗扱い」にしてリトライすると、決済の二重予約が起きます。タイムアウトに対する補償は「成功とも失敗とも判断できない」前提で設計し、外部システム側に冪等性キーを必ず投げ込みます。
2. 補償が失敗した時の扱いを決めていない
補償が失敗するということは、外部システムとの整合が取れない状態が発生したという意味です。私は compensation_failed という状態を別カテゴリで持ち、Slack へ即時通知+手動介入用のダッシュボードに表示しています。ここを自動リトライだけで解決しようとすると、いつまでも整合が取れないケースに気づけません。
3. ステップ数を減らすために1ステップに副作用を詰め込む 「決済予約と在庫引当を同じステップでやってしまえば楽じゃないか」と考えたくなりますが、その瞬間にサーガパターンの利点が消えます。1ステップ=1副作用の原則を守ること。テストの書きやすさ・観測性・補償のしやすさが、桁で変わります。
4. サーガコーディネータの上にエージェントの推論を直接置く これは私が最初にやって苦しんだ構成です。LLM が判断する箇所と、決定論的に進める箇所を混ぜると、補償の対象が動的に変わって正しさを保証できなくなります。LLM はサーガの「外側」、すなわちサーガを呼ぶか・呼ばないかの判断と、結果を解釈する役だけに限定するのが安全です。
5. リトライの上限を設けない 無限リトライは、外部システムが落ちている間にサーガコーディネータが内部リソースを消費し続けます。私は「3回連続で同じステップが失敗したら、人間に通知する」という上限を必ず設けます。AIの自律性は素晴らしいですが、外部システムの障害には自律性を発揮させないほうが安全です。
観測性 — サーガを見えるようにする
本番で運用するサーガには、最低限以下のテレメトリが要ります。
- サーガIDとステップ名でフィルタできるトレース: OpenTelemetry の Span を
runSagaの中で発行し、各ステップは子Spanにする - 補償の発生回数のメトリクス: 補償が増えてきたら設計の見直しサインなので、ダッシュボードで常時可視化する
- 補償失敗のアラート: PagerDuty なり Slack なり、人が即座に気付ける場所に流す
- 長時間滞留しているサーガのリスト: たとえば1時間以上
runningのままのサーガを毎分集計しておく
ここを後回しにすると、いざ問題が起きたときに「どのサーガがどこで止まっているのか」を Grep で探すことになります。私は最初それで深夜2時に1時間溶かしました。最初から計装に投資しておくと、本番運用に入った後の睡眠時間が違います。
トランザクショナル・アウトボックスを使って、サーガのイベントを別系統に流す構成については Claude Agent SDK と Transactional Outbox の実装ガイド でも書いています。観測性とサーガを綺麗に分離したい方は参考になるかと思います。
設計の選び方 — 最小ステップから始める
ここまでの内容を踏まえると、最初から完全なサーガコーディネータを作るのは過剰になりがちです。私のおすすめは次の段階的導入です。
最初は2〜3ステップの最小サーガから始めて、補償ロジックと冪等性キーの規約だけを固めます。次に状態の永続化を Redis から始めて、長命ジョブが必要になった段階で Durable Objects か RDB に移行します。観測性の計装はステップ数が増える前に済ませておきます。サブサーガに分割するのは、ステップ数が10を超えてからで遅くないです。
サーガパターンは華やかさのない設計領域ですが、Agent SDK で「自律的にいろいろな副作用を起こすエージェント」を本番投入する時、ここを真面目にやっているかどうかで、深夜のアラート回数が桁で変わります。私自身、宮大工だった祖父たちが「手を動かすことが一つの信心」と語っていたのを思い出すと、こういう地味な設計の積み上げこそが、ユーザーから見えない部分の信頼を作っているのだと感じています。
次のアクション
まずは、いま運用している(あるいは設計中の)Agent SDK のワークフローのなかで、2つ以上の外部副作用を順番に起こしているツール を1つ選んでみてください。そのツールに、本記事の runSaga を当てはめて、各ステップの compensate を1行ずつ書き出すだけで、抜けていた補償ロジックが2つは見つかると思います。最初の小さな1個を補償対応にするところから始めれば、設計の感覚は数日でつかめるはずです。
実装の同じ課題に取り組んでいる方の参考になれば幸いです。お読みいただきありがとうございました。