「動かしっぱなしの AI バッチが、ある日突然真夜中に止まっていた」— Claude API を本番に投入してしばらく経つと、ほぼ全員がこの壁にぶつかります。私自身、4 サイト分のブログを Claude API で自動生成・更新する仕組みを運用していますが、最初の数ヶ月は「サーバーレス関数のタイムアウト」「Anthropic 側の一時的な 529」「ツール呼び出し中の API ダウン」といった事故で何度も冷や汗をかきました。
その経験から得た結論は、プロダクションの AI ワークフローには『途中から再開できる仕組み』が必要だ ということです。リトライを try/catch で書く、ジョブテーブルを自前で設計する、タイムアウトをキューで分割する——どれも一度はやりましたが、コードがすぐに爆発します。
ここではTypeScript ファーストの耐障害ワークフローエンジン Inngest を使って、Claude API を呼ぶ AI ワークフローを本番品質に引き上げる設計と実装を、動くコードとともに解説します。Next.js や Cloudflare Workers にそのまま乗るので、別サーバーや別インフラを増やす必要がありません。
なお Python 主体の重厚なワークフローエンジンが好みであれば、Claude Agent SDK × Temporal.io で耐障害性のある長期実行AIワークフローを構築する をご覧ください。本記事はそれよりも軽量で、TypeScript の Web 開発者が「いつものスタックのまま」始められる構成に振り切っています。
なぜ「ただの Promise ループ」では本番に出せないのか
Claude API でエージェントを書くとき、最初は誰もが次のような形にたどり着きます。
// よくある最初の実装(本番に出すと壊れる)
async function runAgent ( userInput : string ) {
const messages = [{ role: "user" , content: userInput }];
while ( true ) {
const res = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
tools,
messages,
});
messages. push ({ role: "assistant" , content: res.content });
if (res.stop_reason === "end_turn" ) return res;
if (res.stop_reason === "tool_use" ) {
const toolResult = await runTool (res); // ← ここで外部APIを叩く
messages. push ({ role: "user" , content: toolResult });
}
}
}
テストの一発実行なら問題なく動きます。ところがこのコードを本番に出すと、次のような症状が必ず出てきます。
タイムアウト : Vercel の Serverless Function は最大 5 分、Cloudflare Workers は CPU 時間 30 秒。3 ターン以上の Tool Use で簡単に超えます。
二重実行による二重課金 : タイムアウト後にユーザーがリトライしたら、Stripe の請求 API を 2 回叩きました——という事故が起きます。
どこまで進んだか分からない : ログを掘り返しても、messages 配列のどの時点で落ちたかが特定できません。
人間承認を挟みづらい : 「危険な操作だけ承認してから実行したい」となった瞬間、コードが破綻します。
これらは個別に直せる問題ではなく、「長く走る処理を、途中でクラッシュしても安全に再開できる 」という設計が必要です。私は最初、Redis でチェックポイントを書いて、PostgreSQL にジョブテーブルを作って、自前で再開ロジックを書きました。が、そのうち「これはワークフローエンジンを自作している」と気づきました。
Inngest を 5 分で理解する
Inngest は TypeScript/JavaScript ファーストの耐障害ワークフローエンジンです。Temporal と同じ「Durable Execution」の考え方を持ちながら、開発体験は Next.js の API ルート一つで完結する軽さを持ちます。
押さえるべき概念は 4 つだけです。
Function : イベントを受けて走る関数。inngest.createFunction() で定義します。
Step : Function の中で「ここまで進んだら結果をキャッシュしてほしい」と Inngest に伝える単位。step.run() で囲んだコードは、再実行時に既に成功していればスキップされます。
Event : Function を起動するトリガー。inngest.send({ name, data }) で発火します。
Sleep / WaitForEvent : 関数が「待つ」ためのプリミティブ。step.sleep() や step.waitForEvent() を使うと、関数自体は終了し、後で Inngest が起こしてくれます。
ここでの肝は Step の境界が再開ポイントになる ことです。step.run() の中身が成功すると結果が永続化され、関数全体が落ちても次の実行ではその step はスキップされます。これが「途中から続行できる」を実現します。
プロジェクトのセットアップ
Next.js 16 (App Router) を例にします。Cloudflare Workers / OpenNext でも同じコードがそのまま動きます。
# 依存関係(Node 20+ 推奨)
npm install inngest @anthropic-ai/sdk
npm install -D @types/node
# 環境変数(.env.local)
ANTHROPIC_API_KEY = YOUR_ANTHROPIC_API_KEY
INNGEST_EVENT_KEY = YOUR_INNGEST_EVENT_KEY
INNGEST_SIGNING_KEY = YOUR_INNGEST_SIGNING_KEY
Inngest クライアントと Next.js 側のハンドラを 1 ファイルずつ用意します。
// src/inngest/client.ts
import { Inngest } from "inngest" ;
export const inngest = new Inngest ({
id: "claude-lab-agent" ,
// 本番では eventKey を環境変数から自動読込
});
// src/app/api/inngest/route.ts
import { serve } from "inngest/next" ;
import { inngest } from "@/inngest/client" ;
import { runResearchAgent } from "@/inngest/functions/research-agent" ;
export const { GET , POST , PUT } = serve ({
client: inngest,
functions: [runResearchAgent],
});
ローカルで Inngest Dev Server を立ち上げると、/api/inngest を自動検出して関数の実行履歴・リトライ・再実行が UI で見られます。
npx inngest-cli dev
# ブラウザで http://localhost:8288 を開く
動くコードで作る「耐障害な Claude エージェント」
ここから本題です。Tool Use を含む Claude エージェントを Inngest の Function として実装し、リトライ・冪等性・人間承認・ファンアウトをすべて組み込んでいきます。
Step 1: Tool 定義と Claude 呼び出しを step.run に閉じ込める
最初のポイントは、Claude API 呼び出しを必ず step.run() で囲む ことです。これだけで、関数が途中で落ちても再実行時に Claude を二度叩かずに済みます。
// src/inngest/functions/research-agent.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { inngest } from "../client" ;
const anthropic = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const tools : Anthropic . Tool [] = [
{
name: "web_search" ,
description: "公開情報を検索して上位3件のスニペットを返します。" ,
input_schema: {
type: "object" ,
properties: { query: { type: "string" } },
required: [ "query" ],
},
},
{
name: "send_summary_email" ,
description: "完成した要約を指定アドレスに送信します。" ,
input_schema: {
type: "object" ,
properties: {
to: { type: "string" },
subject: { type: "string" },
body: { type: "string" },
},
required: [ "to" , "subject" , "body" ],
},
},
];
export const runResearchAgent = inngest. createFunction (
{
id: "run-research-agent" ,
// ループが暴走しないよう同時実行数を絞る
concurrency: { limit: 5 },
// Anthropic 側の一時障害に備えた既定リトライ
retries: 4 ,
},
{ event: "research/agent.requested" },
async ({ event , step , logger }) => {
const { topic , requestedBy , jobId } = event.data as {
topic : string ;
requestedBy : string ;
jobId : string ;
};
let messages : Anthropic . MessageParam [] = [
{ role: "user" , content: `次のトピックを調査して要約してください: ${ topic }` },
];
// 最大 6 ターンで打ち切る(無限ループ防止)
for ( let turn = 0 ; turn < 6 ; turn ++ ) {
// ✅ Claude 呼び出しは step.run で囲む = 結果が永続化される
const response = await step. run ( `claude-turn-${ turn }` , async () => {
const res = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
tools,
messages,
});
return res;
});
messages. push ({ role: "assistant" , content: response.content });
if (response.stop_reason === "end_turn" ) {
logger. info ( "agent finished" , { turn, jobId });
return { ok: true , finalMessages: messages };
}
if (response.stop_reason === "tool_use" ) {
const toolUses = response.content. filter (
( b ) : b is Anthropic . ToolUseBlock => b.type === "tool_use" ,
);
const toolResults : Anthropic . ToolResultBlockParam [] = [];
for ( const tu of toolUses) {
// ツール実行も step に分けると、片方だけ失敗しても再開できる
const result = await step. run ( `tool-${ turn }-${ tu . id }` , async () => {
return runTool (tu.name, tu.input, { jobId, requestedBy });
});
toolResults. push ({
type: "tool_result" ,
tool_use_id: tu.id,
content: JSON . stringify (result),
});
}
messages. push ({ role: "user" , content: toolResults });
}
}
throw new Error ( `Agent did not converge in 6 turns (jobId=${ jobId })` );
},
);
step.run() の第 1 引数は その関数実行内でユニークな ID にします。ループの中で使う場合は claude-turn-${turn} のようにターン番号を必ず含めてください。同じ ID を使い回すと、Inngest は「2 回目の呼び出しは 1 回目のキャッシュ結果を返せばよい」と判断して Claude を呼ばなくなります。
Step 2: ツール側に冪等性キーを持たせる
runTool() の中身は副作用の塊です。メール送信・Stripe 課金・DB 書き込みなど、再実行で困る処理が並びます。Inngest は step が成功すれば再実行をスキップしてくれますが、ステップ内部の処理がさらに複数の外部 API を叩くケース では二重実行のリスクが残ります。
そこで、Tool 側にも冪等性キーを必ず持たせます。tool_use_id をそのままキーに使うのが最もシンプルで安全です。
// src/inngest/functions/tools.ts
import { Resend } from "resend" ;
import { db } from "@/db" ;
const resend = new Resend (process.env. RESEND_API_KEY ! );
export async function runTool (
name : string ,
input : unknown ,
ctx : { jobId : string ; requestedBy : string },
) : Promise < unknown > {
if (name === "web_search" ) {
return webSearch ((input as { query : string }).query);
}
if (name === "send_summary_email" ) {
const args = input as { to : string ; subject : string ; body : string };
// ✅ 冪等性キー: jobId + tool_use_id 相当
const idempotencyKey = `${ ctx . jobId }:email:${ hash ( args ) }` ;
// DB に「この key で既に送ったか」を先に記録(UPSERT)
const inserted = await db.idempotency. upsert ({
where: { key: idempotencyKey },
create: { key: idempotencyKey, status: "pending" },
update: {},
});
if (inserted.status === "completed" ) {
return { skipped: true , reason: "already sent" };
}
const result = await resend.emails. send ({
from: "agent@example.com" ,
to: args.to,
subject: args.subject,
text: args.body,
});
await db.idempotency. update ({
where: { key: idempotencyKey },
data: { status: "completed" , externalId: result.id },
});
return { sent: true , id: result.id };
}
throw new Error ( `unknown tool: ${ name }` );
}
async function webSearch ( query : string ) {
// 検索 API 呼び出し(省略)
return [{ title: "..." , snippet: "..." , url: "..." }];
}
function hash ( obj : unknown ) {
// 簡易: JSON 文字列のハッシュ。実運用は crypto.subtle で SHA-256
return Buffer. from ( JSON . stringify (obj)). toString ( "base64" ). slice ( 0 , 24 );
}
ここでの設計判断は 「Inngest の step だけに冪等性を任せない」 です。Inngest は step の前後の境界では再実行を防げますが、step が落ちる前に外部 API を叩き、その後でレコードを書く前にプロセスが死んだ場合、step は失敗扱いで再実行され、外部 API は 2 回叩かれます。実運用での経験上、データベース側に冪等性キーを持つ二重防衛がほぼ必須です。
Step 3: 失敗を「リトライすべき」と「諦めるべき」に分類する
Claude API の失敗は大きく 3 種類あります。
一時的(リトライ推奨) : 429(レート制限)、529(過負荷)、5xx、タイムアウト。
入力ミス(リトライ無意味) : 400(バリデーション)、401(鍵が無効)、404、invalid_request_error。
コンテンツポリシー違反 : モデルが拒否、stop_reason === "refusal"。
すべて 4 回リトライしていると、入力ミスで Anthropic を 4 回叩いた挙げ句、ユーザーには 1 分後にエラーが返ります。これはコストと体験の両方を悪化させます。Inngest には NonRetriableError という型があり、これを投げると即座にリトライを止められます。
import { NonRetriableError } from "inngest" ;
const response = await step. run ( `claude-turn-${ turn }` , async () => {
try {
return await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
tools,
messages,
});
} catch (e) {
if (e instanceof Anthropic . APIError ) {
// 4xx 系(429 を除く)は即座に止める
if (e.status && e.status >= 400 && e.status < 500 && e.status !== 429 ) {
throw new NonRetriableError (
`claude rejected request: ${ e . status } ${ e . message }` ,
{ cause: e },
);
}
}
throw e; // それ以外は Inngest にリトライさせる
}
});
リトライ間隔は Inngest 側で制御します。本記事のコードでは関数定義に retries: 4 を指定しましたが、サーバー過負荷系のエラーには指数バックオフが効きやすいので、Inngest 側のデフォルト挙動で十分です。なお Anthropic 固有の 429/529 の扱いについては、Claude API のレート制限と 429/503/timeout エラーを根治する で詳しく解説しています。
Step 4: 人間承認を挟む(waitForEvent パターン)
「メール送信前に人間が承認したい」「金額の大きい決済はチェックを挟みたい」といった要件は、Web アプリの中だけで実装しようとすると複雑になります。Inngest の step.waitForEvent() を使うと、関数を一旦休止させ、外部からのイベントが届くまで待機できます。
// 高額決済前に人間承認を待つ例
if (toolName === "charge_customer" && (input as { amount : number }).amount > 50000 ) {
// 承認リクエストを Slack や DB に書き出す処理
await step. run ( "request-approval" , async () => {
return notifyApprovers ({
jobId,
amount: (input as { amount : number }).amount,
requestedBy,
});
});
// 最大 24 時間、承認イベントを待つ
const approval = await step. waitForEvent ( "wait-for-approval" , {
event: "agent/approval.received" ,
timeout: "24h" ,
if: `event.data.jobId == "${ jobId }"` , // 同じ jobId のイベントだけ拾う
});
if ( ! approval) {
throw new NonRetriableError ( "Approval timed out after 24h" );
}
if (approval.data.decision !== "approved" ) {
return { skipped: true , reason: "rejected by human" };
}
// 以降、本当の課金処理を実行
}
承認 UI 側からは、ボタンが押されたときに inngest.send({ name: "agent/approval.received", data: { jobId, decision: "approved", reviewer: "user-123" } }) を呼ぶだけです。Inngest 側では関数のメモリ常駐は不要で、24 時間後にイベントが来れば「続きから」起動します。サーバーレス関数のタイムアウトと完全に切り離せるのが最大の利点です。
Step 5: 大量バッチをファンアウト・ファンインで捌く
「100 件のドキュメントを並列に Claude で要約してから、最後に統合レポートを作る」のようなパターンは、AI ワークフローの典型です。Inngest では step.invoke() で子関数を呼び、複数の子関数を Promise.all() で並列起動できます。
export const summarizeBatch = inngest. createFunction (
{ id: "summarize-batch" , concurrency: { limit: 20 } },
{ event: "batch/summary.requested" },
async ({ event , step }) => {
const { docIds } = event.data as { docIds : string [] };
// ✅ ファンアウト: 各ドキュメントごとに子関数を起動
const summaries = await Promise . all (
docIds. map (( id ) =>
step. invoke ( `summarize-${ id }` , {
function: summarizeOne,
data: { docId: id },
}),
),
);
// ✅ ファンイン: すべて揃ったら統合レポートを作る
const report = await step. run ( "compose-report" , async () => {
const res = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 4096 ,
messages: [
{
role: "user" ,
content: `以下の要約から統合レポートを作成してください: \n\n ${ summaries
. map (( s ) => `- ${ s . title }: ${ s . summary }` )
. join ( " \n " ) }` ,
},
],
});
return res.content[ 0 ].type === "text" ? res.content[ 0 ].text : "" ;
});
return { docCount: docIds. length , report };
},
);
const summarizeOne = inngest. createFunction (
{ id: "summarize-one" , concurrency: { limit: 10 } },
{ event: "internal/summarize.one" },
async ({ event , step }) => {
const { docId } = event.data as { docId : string };
const doc = await step. run ( "fetch-doc" , () => db.doc. findUniqueOrThrow ({ where: { id: docId } }));
const summary = await step. run ( "call-claude" , async () => {
const res = await anthropic.messages. create ({
model: "claude-haiku-4-5" ,
max_tokens: 512 ,
messages: [{ role: "user" , content: `要約してください: \n\n ${ doc . body }` }],
});
return res.content[ 0 ].type === "text" ? res.content[ 0 ].text : "" ;
});
return { id: docId, title: doc.title, summary };
},
);
concurrency.limit を子関数側で 10、親側で 20 に絞っていますが、これは Anthropic のレート制限と下流のデータベース負荷を両方考えた値です。本番では実際のレート上限に合わせて調整してください。経験上、Haiku 中心のバッチなら同時 20、Sonnet 中心なら 5〜10 が安全圏でした。
観測できなければ運用できない
耐障害性を組んでも、ダッシュボードで何が起きているかが見えなければ運用できません。Inngest はクラウド版・OSS 版どちらもイベント単位の実行履歴を時系列で確認できますが、ビジネスメトリクスは別途用意したほうが安心です。
私の運用では次の 3 つを必ず記録しています。
入力トークンと出力トークン : response.usage を step の戻り値に含めて、後で集計できるようにしておきます。
ツール呼び出しの回数と種類 : どのツールが頻繁に呼ばれるかが、プロンプト改善のヒントになります。
Claude のリトライ回数 : 関数の attempt をログに含めると、Anthropic 側の障害トレンドが見えます。
const response = await step. run ( `claude-turn-${ turn }` , async ({ attempt }) => {
const res = await anthropic.messages. create ({ /* ... */ });
logger. info ( "claude.usage" , {
jobId,
turn,
attempt,
inputTokens: res.usage.input_tokens,
outputTokens: res.usage.output_tokens,
cacheCreation: res.usage.cache_creation_input_tokens,
cacheRead: res.usage.cache_read_input_tokens,
});
return res;
});
ログは Vercel の Log Drains 経由で BigQuery や Honeycomb に流すと、月次のコスト分析が一気に楽になります。プロンプトキャッシュを併用しているなら、cache_read_input_tokens の比率を必ず追ってください。50% を超えてくると Claude の月額コストが体感で半減します。詳しくは Claude API のプロンプトキャッシュで月額コストを半減する にまとめてあります。
本番運用で詰まりがちな落とし穴
ここからは、私が実際に踏み抜いた落とし穴と、その後に確立した対処法です。
落とし穴 1: Step の中で乱数や日付を使ってしまう
// ❌ 危険: 再実行時に異なる値になる
const requestId = await step. run ( "save" , async () => {
const id = crypto. randomUUID (); // ← 再実行ごとに変わる
await db.requests. create ({ data: { id, ... } });
return id;
});
step.run() の中身は再実行時に再評価される可能性があります。ID 生成や日付は step に入る前 に確定させるか、Inngest の step.run() の戻り値だけを参照してください。
// ✅ 正解
const requestId = crypto. randomUUID (); // step の外で確定
await step. run ( "save" , async () => {
await db.requests. create ({ data: { id: requestId, ... } });
});
落とし穴 2: Tool 結果をそのまま messages に積んでサイズが爆発する
ツール呼び出しが大きな JSON を返すと、次のターンの Claude API がコンテキスト超過で 400 を返します。私は web_search の結果を毎回フルに積んで、5 ターン目で詰まりました。対処は単純で、Tool 結果に対しても要約圧縮を 1 ステップ挟みます。
const compressed = await step. run ( `compress-${ tu . id }` , async () => {
if ( JSON . stringify (rawResult). length < 4000 ) return rawResult;
const res = await anthropic.messages. create ({
model: "claude-haiku-4-5" ,
max_tokens: 512 ,
messages: [
{
role: "user" ,
content: `以下の検索結果から、トピック「${ topic }」に関係する情報のみ抽出してください: \n\n ${ JSON . stringify ( rawResult ) }` ,
},
],
});
return res.content[ 0 ].type === "text" ? res.content[ 0 ].text : "" ;
});
Haiku を使う点が肝です。Sonnet で要約させるとコスト構造が逆転して、本記事のキャッシュ最適化の意味が薄れます。
落とし穴 3: 関数 ID を変えてデプロイすると過去の実行が宙に浮く
createFunction({ id: "old-id" }, ...) の id を変更すると、Inngest は別関数として扱います。デプロイ前から走っていた実行は旧 ID のままで進行し、新コードに該当しないため復旧できません。関数 ID は安易にリネームしない が原則です。どうしても必要なら、旧 ID も並行デプロイして 24 時間以上残すか、Inngest UI で旧実行を手動で完了させてください。
落とし穴 4: Cloudflare Workers での 50ms CPU 制限
Cloudflare Workers の有料プラン(Bundled / Unbound)では CPU 時間に制限があり、Claude API 応答後の同期的なパース処理が長すぎると落ちます。Inngest の step ごとに await を挟んで I/O を発生させるか、長い処理は別 step に分けるのが効きます。私のケースでは JSON.parse を 1MB 超の文字列に対して同期的に走らせて 10ms 超え、計算量を別 step に逃して解消しました。
落とし穴 5: 並列度を絞らずに Anthropic から 429 連発
Promise.all() で 100 件を並列起動すると、Tier 1 のアカウントなら即座に 429 が返ってきます。concurrency.limit を関数定義側で必ず設定してください。Tier 2 でも Sonnet で 20、Haiku で 50 程度が安全圏でした。並列処理の詳細は Claude API の並列リクエストを Python asyncio で安全に走らせる も参考になります。
設計判断: Temporal ではなく Inngest を選ぶ理由
ここまで読んで、「Temporal でも同じことができそうだが?」と思われた方もいるはずです。私の現場での選び分けは次の通りです。
Inngest を選ぶ場合 : Next.js / SvelteKit / Hono など Web フレームワーク中心、TypeScript ファースト、サーバーレスで完結したい、外部インフラを増やしたくない、ワークフローの寿命が数分〜数時間。
Temporal を選ぶ場合 : Python と TypeScript が混在、ワークフローが数日〜数週間走る、強い決定論的実行が必要、社内に専任 SRE がいます。
Inngest はランタイムにエージェントを常駐させない設計のため、Vercel や Cloudflare Workers にそのまま乗ります。一方で、Workflow コードの完全な決定論的再生 (同じイベント履歴から bit 単位で同じ実行を再現する)は Temporal の方が厳密です。AI ワークフローの場合、出力に多少の揺らぎがあるのは前提なので、Inngest の「step 単位での冪等性」で十分なケースが多いと感じています。
私の個人プロジェクトは前者にぴったり当てはまるため、Inngest に倒しました。月額 0 円から始められて、トラフィックが増えても本人は何もしなくていい設計です。
ここから何を試すか
長くなりましたが、最後に 明日からの一歩 を 1 つだけ提案します。既に Claude API を叩いている既存コードがあるなら、その中で 一番怖いツール呼び出し を 1 つ選び、それだけを Inngest の step.run() に閉じ込めてみてください。Stripe の課金、メール送信、外部サービスへの POST——どれでも構いません。
「全体を書き直す」と思うと挫折します。1 つの危険な処理だけ Durable にする だけで、夜間バッチが落ちて冷や汗をかく回数は確実に減ります。私自身、最初の導入はそのレベルから始めて、半年かけて全エージェントを Inngest に寄せました。Claude を本番で使うなら、ワークフローエンジンは「いずれ必要になる」ではなく「最初から必要だった」と感じる日がきっと来ます。