深夜3時、AIエージェントを使った請求書処理バッチが止まりました。10時間かけて処理した2,400件のうち、1,800件は完了済み、600件は未処理。再実行すれば1,800件に二重請求がかかる。監視ダッシュボードの前で手が震えます。私自身、個人プロジェクトで似たような経験を何度もしてきました。
この問題を「処理前に idempotency key を入れれば解決」と片付けてしまうのは簡単です。ただ、本番で AI エージェントを走らせると、重複防止だけではどうにもならないケースが次々と出てきます。タイムアウトしたのは Claude API なのか下流のサービスなのか、どこまで進んだのか、途中で人間の承認を挟みたいがプロセスが落ちたら承認待ち状態はどこへ消えたのか——。
こうした「長く走るプロセスを、途中で何が起きても安全に続行する」問題に対して、ワークフローエンジンの Temporal.io は強力な解答を持っています。Durable Execution と呼ばれる仕組みで、ワークフローコード自体がクラッシュや再起動を乗り越えて「続きから」再開できるのです。そしてこの考え方は、Claude Agent SDK との相性が抜群に良いことが、実際に組み合わせてみるとわかります。
ここではClaude Agent SDK と Temporal を組み合わせて、本番品質の長期実行 AI ワークフローを構築する設計と実装を、動くコードとともに解説します。単純な Hello World ではなく、請求処理・承認フロー・バッチ処理の実例を通じて、補償トランザクション、Signal による外部入力、Continue-As-New によるメモリ管理まで踏み込みます。
なぜ AI エージェントに Durable Execution が必要なのか
多くの AI エージェント実装は、プロセスがずっと走っている前提で書かれています。トップレベルの async def run_agent() が完走するまでプロセスを維持し、ループの中で Claude API を叩き、ツールを呼び、次のアクションを決める。これは15分以内で終わるタスクなら問題ありません。
しかし、ワークフローが数時間〜数日におよぶと話が変わります。サーバーが再起動されたら?コンテナがOOMで落ちたら?人間の承認待ちでプロセスが4時間寝ていたら?各ステップのリトライポリシーはどこで管理する?エラーログからどこまで進んだかを復元できる?
私はこれらすべてを自前で書こうとして、何度も挫折しました。Redis にチェックポイントを書いて、PostgreSQL にジョブテーブルを作って、Python で独自のリトライデコレータを書いて——気づくと「ワークフローエンジンを自作している」状態になります。
Temporal はこの「長時間走るプロセスを、インフラの揺らぎから隔離する」問題を専用に解く基盤です。ワークフローのコード自体がイベントソーシングで永続化され、ワーカーが落ちても別のワーカーが状態を復元して続きから走れます。Claude API を叩く部分を Temporal の Activity として分離することで、リトライ・タイムアウト・Heartbeat・冪等性が宣言的に設定できるようになります。
「今のプロジェクトには大げさすぎる」と感じるかもしれません。実際、私も最初はそう思いました。ただ、一度 Temporal の上に AI ワークフローを載せてみると、エージェントのコードがシンプルになる方に気づきます。リトライロジックや状態永続化のコードが消え、ビジネスロジックだけが残るからです。
Temporal の基本概念を15分で理解する
Temporal を使うには、4つの概念だけ押さえれば十分です。
Workflow : 長期実行されるビジネスロジック本体。純粋な関数として書き、副作用を持ちません。Temporal はワークフローの実行履歴(Event History)を永続化し、プロセスが落ちても同じ入力から決定論的に再実行できます。Workflow のコードから直接 API を叩いたり乱数を使ったりしてはいけません。
Activity : 副作用を伴う処理。API 呼び出し、DB 書き込み、外部サービス連携はすべて Activity になります。Activity はリトライポリシー・タイムアウトを持ち、失敗しても Workflow からは「再実行された」ことが透過的に扱えます。Claude API の呼び出しはここに入れます。
Worker : Workflow と Activity を実行するプロセス。複数台で水平スケール可能。ワーカーが死んでも Temporal サーバーが別のワーカーに処理を振り分けます。
Signal / Query : 外部から Workflow に入力を送る(Signal)、状態を問い合わせる(Query)仕組み。「人間の承認」「外部Webhookからの通知」「進捗の可視化」をワークフローに組み込むのに使います。
Temporal サーバーは自前でも Temporal Cloud でも動かせます。開発中は Docker で起動するのが手軽です。
# Temporal のローカル環境を起動
# temporal CLI のインストール: https://docs.temporal.io/cli
temporal server start-dev --ui-port 8080
# 別ターミナルで Web UI を開く
open http://localhost:8080
Claude Agent SDK × Temporal の基本アーキテクチャ
組み合わせる際の原則はシンプルです。Workflow は Claude API を直接呼ばず、Activity に閉じ込める 。これを守るだけで、リトライ・永続化・監視がすべて Temporal に任せられます。
# 推奨される構造
# ┌──────────── Workflow ─────────────┐
# │ agent_loop_workflow(input) { │
# │ while not done: │
# │ response = await act.call_claude(messages) ← Activity
# │ if response.is_tool_use: │
# │ result = await act.run_tool(tool_name, args) ← Activity
# │ messages.append(result) │
# │ else: │
# │ done = True │
# │ return final_answer │
# │ } │
# └─────────────────────────────────────┘
この構造の何がありがたいかと言うと、act.call_claude() がタイムアウトやレート制限で失敗しても、Temporal が指数バックオフで自動的にリトライしてくれる点です。Workflow のコードには try/except や retry デコレータが一切出てきません。そして Worker が落ちた場合でも、再起動後に Event History から「どこまで進んだか」が復元されます。
プロジェクトのセットアップ
必要なパッケージは以下です。Python 3.11 以降を前提にします。
# 依存関係のインストール
pip install temporalio anthropic python-dotenv
# プロジェクト構造
# workflows/
# ├── __init__.py
# ├── agent_workflow.py # Workflow 定義
# ├── activities.py # Activity 定義(Claude API 呼び出し)
# ├── worker.py # Worker 起動スクリプト
# └── starter.py # Workflow 起動クライアント
Activity: Claude API 呼び出しの本番実装
Activity は Claude API の呼び出しを行う箇所です。ここで重要なのが冪等性 とHeartbeat の設計です。
# activities.py
import os
from anthropic import Anthropic, APIError, RateLimitError
from temporalio import activity
from dataclasses import dataclass
from typing import Any
client = Anthropic( api_key = os.environ[ "ANTHROPIC_API_KEY" ])
@dataclass
class ClaudeCallInput :
messages: list[dict[ str , Any]]
system: str
tools: list[dict[ str , Any]]
max_tokens: int = 4096
model: str = "claude-sonnet-4-5"
@dataclass
class ClaudeCallResult :
stop_reason: str
content: list[dict[ str , Any]]
usage_input_tokens: int
usage_output_tokens: int
@activity.defn
async def call_claude (inp: ClaudeCallInput) -> ClaudeCallResult:
"""
Claude API を叩く Activity。
タイムアウト・レート制限は Temporal 側でリトライされるので、
ここでは例外を握りつぶさずに上げるのが正解。
"""
# Heartbeat: 長時間の呼び出し中もワーカーが生きていることを通知
activity.heartbeat( "calling claude" )
try :
response = client.messages.create(
model = inp.model,
max_tokens = inp.max_tokens,
system = inp.system,
tools = inp.tools,
messages = inp.messages,
)
except RateLimitError as e:
# リトライしてほしい種類のエラーはそのまま上げる
# Temporal の RetryPolicy が指数バックオフで再試行する
raise
except APIError as e:
if e.status_code and e.status_code >= 500 :
# 5xx は一時的なので再試行対象
raise
# 4xx はリクエスト自体が間違っているので再試行しない
raise activity.ApplicationError(
f "Claude API client error: { e.status_code } { e } " ,
non_retryable = True ,
)
return ClaudeCallResult(
stop_reason = response.stop_reason,
content = [block.model_dump() for block in response.content],
usage_input_tokens = response.usage.input_tokens,
usage_output_tokens = response.usage.output_tokens,
)
Activity のコード内で重要なのは、再試行すべきエラーと再試行してはいけないエラーを明確に分ける ことです。non_retryable=True を付けないと Temporal は無限にリトライしようとします。4xx 系(認証エラー、リクエスト不正)はリトライしても無駄なので、明示的に停止させます。
ツール実行 Activity の冪等性設計
エージェントがツールを呼ぶ部分、特に DB への書き込みや外部 API のコールは冪等性が重要です。Activity は Temporal 的に少なくとも1回は確実に実行されますが、タイムアウト後の再試行で2回実行される可能性もあります。
# activities.py (続き)
import hashlib
from typing import Any
@dataclass
class ToolRunInput :
tool_name: str
tool_input: dict[ str , Any]
idempotency_key: str # Workflow 側で生成して渡す
@activity.defn
async def run_tool (inp: ToolRunInput) -> dict[ str , Any]:
"""
エージェントが呼ぶツールを実行する Activity。
副作用を伴う操作は idempotency_key で重複実行を防ぐ。
"""
if inp.tool_name == "send_invoice" :
return await _send_invoice_idempotent(inp.tool_input, inp.idempotency_key)
elif inp.tool_name == "fetch_customer" :
# 冪等な読み取り操作はそのまま実行
return await _fetch_customer(inp.tool_input)
else :
raise activity.ApplicationError(
f "Unknown tool: { inp.tool_name } " ,
non_retryable = True ,
)
async def _send_invoice_idempotent (args: dict , idem_key: str ) -> dict :
"""
請求書送付。Stripe API の Idempotency-Key を使って重複防止。
同じキーで2回呼ばれても、Stripe 側が最初のレスポンスを返す。
"""
# 実際は stripe.Invoice.create() に idempotency_key を渡す
return { "invoice_id" : f "inv_ { idem_key[: 16 ] } " , "status" : "sent" }
冪等性キーの生成は Workflow 側で行い、Activity に渡します。Workflow は決定論的なので、再実行されても同じキーが生成され、Activity の再試行でも同一キーになります。
Workflow: エージェントループの本体
Workflow では Claude との対話ループを組み立てます。ここがアーキテクチャの核心で、通常のエージェントコードと見た目はほぼ同じですが、すべての API 呼び出しが Activity 経由で行われる点が異なります。
# agent_workflow.py
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
# 型の循環インポートを防ぐ
with workflow.unsafe.imports_passed_through():
from .activities import (
call_claude, run_tool,
ClaudeCallInput, ClaudeCallResult, ToolRunInput,
)
@workflow.defn
class InvoiceProcessingWorkflow :
def __init__ (self) -> None :
self ._messages: list[ dict ] = []
self ._human_approval: bool | None = None
@workflow.run
async def run (self, customer_ids: list[ str ]) -> dict :
"""
顧客リストに対して請求書処理を行う長期ワークフロー。
数時間かかることを想定。人間の承認を途中で挟む。
"""
system = "あなたは請求書処理エージェントです。指示された顧客に請求書を発行してください。"
tools = [
{
"name" : "fetch_customer" ,
"description" : "顧客情報を取得" ,
"input_schema" : { "type" : "object" , "properties" : { "customer_id" : { "type" : "string" }}},
},
{
"name" : "send_invoice" ,
"description" : "請求書を送付" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"customer_id" : { "type" : "string" },
"amount" : { "type" : "number" },
},
},
},
]
self ._messages = [
{ "role" : "user" , "content" : f "顧客 { customer_ids } の請求書を発行してください。" }
]
# リトライポリシー: 指数バックオフで最大5回まで再試行
retry_policy = RetryPolicy(
initial_interval = timedelta( seconds = 1 ),
maximum_interval = timedelta( minutes = 5 ),
maximum_attempts = 5 ,
non_retryable_error_types = [ "ApplicationError" ],
)
results = []
# エージェントループ
for _ in range ( 50 ): # 最大反復回数の安全装置
claude_result = await workflow.execute_activity(
call_claude,
ClaudeCallInput(
messages = self ._messages,
system = system,
tools = tools,
),
start_to_close_timeout = timedelta( minutes = 2 ),
retry_policy = retry_policy,
)
self ._messages.append({ "role" : "assistant" , "content" : claude_result.content})
if claude_result.stop_reason == "end_turn" :
break
if claude_result.stop_reason == "tool_use" :
tool_results = []
for block in claude_result.content:
if block[ "type" ] != "tool_use" :
continue
# 冪等性キーは Workflow ID + ツール呼び出し情報から決定的に生成
idem_key = workflow.info().workflow_id + "_" + block[ "id" ]
# send_invoice の前に人間の承認を待つ
if block[ "name" ] == "send_invoice" :
await workflow.wait_condition(
lambda : self ._human_approval is not None ,
timeout = timedelta( hours = 24 ),
)
if not self ._human_approval:
tool_results.append({
"type" : "tool_result" ,
"tool_use_id" : block[ "id" ],
"content" : "承認されませんでした。処理をスキップします。" ,
})
self ._human_approval = None
continue
self ._human_approval = None # 次の承認のためリセット
result = await workflow.execute_activity(
run_tool,
ToolRunInput(
tool_name = block[ "name" ],
tool_input = block[ "input" ],
idempotency_key = idem_key,
),
start_to_close_timeout = timedelta( minutes = 1 ),
retry_policy = retry_policy,
)
tool_results.append({
"type" : "tool_result" ,
"tool_use_id" : block[ "id" ],
"content" : str (result),
})
results.append(result)
self ._messages.append({ "role" : "user" , "content" : tool_results})
return {
"customers_processed" : customer_ids,
"results" : results,
"total_messages" : len ( self ._messages),
}
@workflow.signal
def submit_approval (self, approved: bool ) -> None :
"""人間の承認を外部から Signal で受け取る"""
self ._human_approval = approved
@workflow.query
def get_progress (self) -> dict :
"""現在の進捗を外部から Query で取得"""
return {
"messages_count" : len ( self ._messages),
"waiting_for_approval" : self ._human_approval is None ,
}
このコードで起きている重要なことを3つ挙げます。
第一に、workflow.execute_activity の呼び出しは Temporal Event History に記録され、Worker が落ちても次のワーカーが履歴から状態を復元できます。Claude API へのリクエストとレスポンスが丸ごと永続化されるため、再実行でも同じ会話を継続できます。
第二に、workflow.wait_condition は Workflow を「停止」させますが、ワーカーのメモリは解放されます。Temporal サーバー側に「承認 Signal を待っている」状態が保存され、Signal が来たら該当のワーカーが Workflow を再開します。人間が4時間後に承認ボタンを押しても、その間プロセスが動きっぱなしになることはありません。
第三に、リトライポリシーが宣言的です。5xx エラーは自動で最大5回まで指数バックオフで再試行され、non_retryable_error_types に該当するエラーだけが Workflow に伝播します。エラーハンドリングのコードが Activity の中に閉じ込められ、Workflow 側は「素直なループ」として書けます。
補償トランザクション(Saga)パターンで部分失敗を扱う
本番で必ず遭遇するのが、ワークフローの途中でエラーが起きて、これまでに行った副作用を巻き戻したい場面です。Temporal では Saga パターン を明示的に書くことで、補償処理を安全に組み込めます。
# agent_workflow.py に追加
from contextlib import suppress
@workflow.defn
class RefundableInvoiceWorkflow :
@workflow.run
async def run (self, customer_id: str , amount: float ) -> dict :
compensations = [] # 巻き戻し処理を積むスタック
try :
# Step 1: 顧客の信用照会
credit_result = await workflow.execute_activity(
run_tool,
ToolRunInput(
tool_name = "check_credit" ,
tool_input = { "customer_id" : customer_id},
idempotency_key = f " { workflow.info().workflow_id } _credit" ,
),
start_to_close_timeout = timedelta( seconds = 30 ),
)
# Step 2: 在庫予約
reservation = await workflow.execute_activity(
run_tool,
ToolRunInput(
tool_name = "reserve_inventory" ,
tool_input = { "customer_id" : customer_id, "amount" : amount},
idempotency_key = f " { workflow.info().workflow_id } _reserve" ,
),
start_to_close_timeout = timedelta( seconds = 30 ),
)
compensations.append(( "release_inventory" , reservation))
# Step 3: 請求書発行(ここで失敗したら Step 2 を巻き戻す)
invoice = await workflow.execute_activity(
run_tool,
ToolRunInput(
tool_name = "send_invoice" ,
tool_input = { "customer_id" : customer_id, "amount" : amount},
idempotency_key = f " { workflow.info().workflow_id } _invoice" ,
),
start_to_close_timeout = timedelta( minutes = 1 ),
)
compensations.append(( "void_invoice" , invoice))
return { "status" : "success" , "invoice" : invoice}
except Exception as e:
# 逆順で補償処理を実行(Saga の原則)
for tool_name, state in reversed (compensations):
with suppress( Exception ):
await workflow.execute_activity(
run_tool,
ToolRunInput(
tool_name = tool_name,
tool_input = state,
idempotency_key = f " { workflow.info().workflow_id } _comp_ { tool_name } " ,
),
start_to_close_timeout = timedelta( seconds = 30 ),
)
raise
補償処理自体もリトライ可能な Activity として定義します。補償が失敗する可能性があるため、with suppress(Exception) で個別の失敗が全体の補償を止めないようにします。この部分は業務要件によって変わります——「補償が失敗したらアラートだけ出して続行する」か「補償の失敗は致命的として別ワークフローで対応する」かを明確に決めておく点が肝心です。
実装上の5つの落とし穴
ここからが本記事で最も価値のある部分です。私が実際に Claude Agent SDK と Temporal を組み合わせて本番投入する過程で遭遇した、非自明な落とし穴を共有します。
落とし穴1: Workflow 内で datetime.now() や random を使う
Workflow は決定論的でなければならないため、非決定的なコードを書くと Event History との整合性が崩れます。
# ❌ 間違い: 再実行時に別の値が出るため、Event History との整合性が崩れる
import datetime
timestamp = datetime.datetime.now()
# ✅ 正解: Temporal が提供する決定論的な API を使う
timestamp = workflow.now()
random_val = workflow.random().randint( 0 , 100 )
落とし穴2: Claude の応答サイズで Event History が爆発する
Claude が大きなレスポンスを返すと、それがそのまま Event History に保存されます。Event History はデフォルトで最大2MBまでしか保持できず、超えると Workflow が強制終了します。
# ❌ 間違い: 大きな文書をそのまま Activity から返す
@activity.defn
async def summarize_document (full_text: str ) -> str :
# full_text が 500KB の場合、次のターンで 500KB + 要約が Event History に入る
result = await call_claude( ... )
return full_text + " \n\n Summary: " + result
# ✅ 正解: 大きなペイロードは外部ストレージに保存し、参照だけを渡す
@activity.defn
async def summarize_document (doc_id: str ) -> str :
full_text = await s3_get(doc_id) # S3 から取得
summary = await call_claude( ... )
summary_id = await s3_put(summary) # 要約も S3 に
return summary_id # Event History には ID だけが残る
実運用では、Claude のレスポンスが 10KB を超えそうなら S3 や DynamoDB に逃がすのが鉄則です。私は最初この設計を軽視して、2日目にして Event History サイズ超過でワークフローが強制終了しました。
落とし穴3: Activity のタイムアウトを start_to_close_timeout だけで設定する
Temporal のタイムアウトには4種類あり、それぞれ意味が違います。
schedule_to_start_timeout: Activity がキューに入ってから Worker が拾うまでの時間
start_to_close_timeout: Worker が Activity を実行し始めてから完了するまでの時間
schedule_to_close_timeout: キューイングから完了まで全体の時間
heartbeat_timeout: Heartbeat 間隔の最大許容値
Claude API 呼び出しは実行時間が読めないので、start_to_close_timeout を長めに取り、heartbeat_timeout と組み合わせて「処理が進んでいるのかハングしているのか」を検知するのが実用的です。
# 本番で使っている設定例
await workflow.execute_activity(
call_claude,
inp,
start_to_close_timeout = timedelta( minutes = 10 ), # 極端な長文生成用に余裕
heartbeat_timeout = timedelta( seconds = 30 ), # 30秒 heartbeat 来なければタイムアウト
retry_policy = retry_policy,
)
落とし穴4: Continue-As-New を忘れて長大なワークフローが遅くなる
1つの Workflow 実行の Event History は時間とともに大きくなり、Worker が状態を復元するコストが増えていきます。目安として 50,000 イベントを超えたら Continue-As-New を検討します。
@workflow.defn
class LongRunningAgent :
@workflow.run
async def run (self, state: dict ) -> dict :
iteration = state.get( "iteration" , 0 )
while iteration < 1000000 :
# ...エージェント処理...
iteration += 1
# 1000反復ごとに Continue-As-New で状態を引き継いで新しい Workflow として再スタート
if iteration % 1000 == 0 :
workflow.continue_as_new({ "iteration" : iteration, "snapshot" : self ._state})
Continue-As-New は現在の Workflow を終了し、同じ Workflow ID で新しい実行を始めます。Event History がリセットされるため、メモリと永続化コストが定数時間に戻ります。
落とし穴5: Signal を受け取る前提のコードで、Signal が来ないと無限待ち
workflow.wait_condition は必ずタイムアウトを設定してください。人間の承認を無期限で待つと、運用チームが忘れた瞬間にワークフローがゾンビ化します。
# ❌ 間違い: Signal が来なければ永遠に止まる
await workflow.wait_condition( lambda : self ._human_approval is not None )
# ✅ 正解: タイムアウトを明示し、超過時のフォールバックを用意
try :
await workflow.wait_condition(
lambda : self ._human_approval is not None ,
timeout = timedelta( hours = 48 ),
)
except asyncio.TimeoutError:
# デフォルト承認、アラート、別ワークフローへのエスカレーション等
self ._human_approval = False
await workflow.execute_activity(
notify_timeout, inp, start_to_close_timeout = timedelta( seconds = 30 ),
)
Child Workflow でエージェントを階層化する
大規模な処理では、1つの親 Workflow が複数の子 Workflow を起動して並列実行するパターンが有用です。各子 Workflow が独自の Event History を持つため、親のサイズが肥大化しません。
@workflow.defn
class OrchestratorWorkflow :
@workflow.run
async def run (self, customer_batches: list[list[ str ]]) -> list[ dict ]:
"""
顧客バッチを並列に処理。各バッチが独立した Child Workflow。
"""
handles = []
for batch_idx, batch in enumerate (customer_batches):
handle = await workflow.start_child_workflow(
InvoiceProcessingWorkflow.run,
batch,
id = f " { workflow.info().workflow_id } _batch_ { batch_idx } " ,
retry_policy = RetryPolicy( maximum_attempts = 3 ),
)
handles.append(handle)
# すべての Child の完了を待つ
results = []
for handle in handles:
try :
result = await handle
results.append(result)
except Exception as e:
# 個別バッチの失敗は許容してログに残す
results.append({ "error" : str (e)})
return results
親は Child の起動と完了待ちだけを管理するので、100個のバッチを並列に走らせても親の Event History は軽量です。
本番運用でのモニタリングと可観測性
Temporal Web UI だけでは足りない場面があります。本番では以下のシグナルを外部モニタリングに連携します。
Workflow 開始・完了・失敗数 : Temporal のメトリクス を Prometheus で取得し、Grafana でダッシュボード化します。
Activity 失敗率とリトライ回数 : 特定の Activity だけリトライが頻発している場合、下流サービスの劣化を示唆します。
Claude API のコスト集計 : Activity の戻り値に usage_input_tokens / usage_output_tokens を含め、完了時に集計テーブルへ書き込みます。Workflow ID をコスト集計のキーにすると、「どのワークフロー実行がいくらかかったか」がトレースできます。
# activities.py にコスト集計 Activity を追加
@activity.defn
async def record_claude_cost (workflow_id: str , input_tokens: int , output_tokens: int ) -> None :
"""各 Claude 呼び出し後にコストを記録"""
# Sonnet 4.5 の料金例: $3/Mtok 入力, $15/Mtok 出力
cost_usd = (input_tokens * 3.0 + output_tokens * 15.0 ) / 1_000_000
await db_insert_cost(workflow_id, cost_usd, input_tokens, output_tokens)
可観測性について深く掘り下げたい方は、Claude API × OpenTelemetry でAIアプリケーションのオブザーバビリティを構築する完全ガイド も合わせて読むと、Activity レイヤーへの OpenTelemetry 組み込みが具体的にイメージできます。本番での設計パターン全般については Claude API プロダクション耐障害設計パターン もこの記事の内容を補完します。
テスト戦略 — Temporal TestEnvironment の活用
Temporal のテストは temporalio.testing.WorkflowEnvironment を使うと、実際のサーバーなしで Workflow のロジックをテストできます。
# test_invoice_workflow.py
import pytest
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from workflows.agent_workflow import InvoiceProcessingWorkflow
from workflows.activities import call_claude, run_tool
@pytest.mark.asyncio
async def test_invoice_workflow_with_mock_claude ():
async with await WorkflowEnvironment.start_time_skipping() as env:
# Activity をモックに差し替える
async def mock_call_claude (inp):
return ClaudeCallResult(
stop_reason = "end_turn" ,
content = [{ "type" : "text" , "text" : "処理完了" }],
usage_input_tokens = 100 ,
usage_output_tokens = 50 ,
)
async with Worker(
env.client,
task_queue = "test-queue" ,
workflows = [InvoiceProcessingWorkflow],
activities = [mock_call_claude, run_tool],
):
result = await env.client.execute_workflow(
InvoiceProcessingWorkflow.run,
[ "customer_1" ],
id = "test-workflow" ,
task_queue = "test-queue" ,
)
assert result[ "customers_processed" ] == [ "customer_1" ]
start_time_skipping() は時間をスキップできるため、タイムアウトテストや「24時間後の挙動」も瞬時に検証できます。Claude API の実呼び出しなしでワークフロー全体の挙動をテストできるのが大きな利点です。
テスト戦略全般を体系的に学びたい場合は Claude API アプリケーションのテスト戦略完全ガイド にユニット・統合・E2E の設計が詳しく書かれています。
Worker のデプロイとスケーリング
Worker は Kubernetes や ECS などのコンテナ基盤に置くのが一般的です。以下は Worker 起動スクリプトの例です。
# worker.py
import asyncio
import os
from temporalio.client import Client
from temporalio.worker import Worker
from workflows.agent_workflow import InvoiceProcessingWorkflow, RefundableInvoiceWorkflow
from workflows.activities import call_claude, run_tool, record_claude_cost
async def main ():
client = await Client.connect(
os.environ.get( "TEMPORAL_ADDRESS" , "localhost:7233" ),
namespace = os.environ.get( "TEMPORAL_NAMESPACE" , "default" ),
)
worker = Worker(
client,
task_queue = "claude-agent-queue" ,
workflows = [InvoiceProcessingWorkflow, RefundableInvoiceWorkflow],
activities = [call_claude, run_tool, record_claude_cost],
max_concurrent_activities = 20 , # 並列 Activity 数
max_concurrent_workflow_tasks = 50 , # 並列 Workflow タスク数
)
await worker.run()
if __name__ == "__main__" :
asyncio.run(main())
スケーリングの原則はシンプルです。Worker を水平スケールさせればよい 。同じ Task Queue に複数の Worker を接続すれば Temporal が自動的に分散します。Claude API のレート制限に応じて max_concurrent_activities を調整するのが実用的です。
実際に組み込んで感じた変化
この構成を本番に入れてから、私の運用が明確に変わった点が3つあります。
第一に、障害対応の時間が激減しました。以前は「どこまで進んだか」を調べるために SQL を叩いたりログを grep したりしていたのが、Temporal Web UI でワークフロー ID を入れれば全ステップが可視化されます。失敗した Activity をリトライボタンでやり直せるのも大きいです。
第二に、人間の承認フローを組み込むのがほぼゼロコストになりました。workflow.wait_condition と Signal だけで、数時間〜数日の承認待ちが安全に実装できます。Slack のボタンから Signal を送る程度の実装で、業務システムらしい見た目になります。
第三に、コストの追跡が楽になりました。Workflow ID ごとに Claude API の消費量が集計できるので、「この顧客の請求処理でいくらかかったか」がすぐ分かります。コスト最適化のアプローチは Claude API コスト最適化プロダクションガイド と組み合わせると、さらに削減余地が見えてきます。
さらに深掘りしたい方向けに
Temporal の設計思想は AWS Step Functions や Azure Durable Functions と類似しますが、セルフホスト可能で言語サポートが広く、ローカル開発が軽量な点で独自の強みがあります。本格的に学ぶなら以下の書籍が参考になります。
Worker と Temporal サーバー間の mTLS 通信を安全に構成する際に役立ちます。
今日から始められる一歩
この記事を読み終えたら、まず temporal server start-dev を走らせてローカル環境を立ち上げてください。次に、既存の Claude API プロジェクトから「もっとも長く走る処理」を1つ選び、それだけを Temporal Workflow に移植してみます。5時間かかる処理が、ワーカーを落としても続きから再開できる感覚を一度体験すると、戻れなくなります。
本番運用の判断軸としては、処理時間が30分を超える、もしくは人間の承認を含む、もしくは複数サービスの調整が必要 ——この3つのいずれかに当てはまるなら Temporal を検討する価値があります。30分未満のシンプルな処理なら、素の Claude Agent SDK のままで十分です。全部を Temporal に載せる必要はありません。適材適所で使い分けるのが、長く運用できる設計の鍵です。