AWS Lambda durable functionsがPydantic AIと統合されたので試してみる
はじめに
データ事業本部のkobayashiです。
以前AWS Lambda durable functionsのparallel()とmap()について記事を書きました。
これらの記事ではAWS Durable Execution SDK for Pythonのcontext.step()で処理を一つずつdurable stepとして書きましたが、LLMエージェントをLambda上で動かす場合、モデル呼び出しとツール呼び出しの一つ一つを自分でstepに包むのは手間がかかります。
2026年9月10日にAWS Lambda durable functionsと Pydantic AI の統合が発表されました。Pydantic AIのエージェントにcapabilityを付けてハンドラを専用のデコレータで包むと、モデル呼び出しとツール呼び出しがそれぞれdurable stepとしてチェックポイントされ、タイムアウトなどで中断しても完了済みのステップをやり直さずに再開できるという内容です。今回はこの統合を実際にデプロイして、チェックポイントの記録とLambdaのタイムアウトをまたいだ再開を確認してみたのでその内容をまとめます。
- AWS Lambda durable functions integrates with Pydantic AI - AWS What's New
- Pydantic AI - AWS Durable Execution SDK Integration - AWS Documentation
- AWS Lambda Durability - Pydantic AI
Pydantic AIとdurable functionsの統合とは
Pydantic AIの公式capabilityライブラリであるpydantic-ai-harnessにAWSLambdaDurabilityというcapabilityが追加されました。これをエージェントに付け、Lambdaのハンドラをdurable_agent_handlerで包むと、エージェントの実行中のI/OがDurableContext.step()相当のdurable stepとして記録されます。
ドキュメントに挙げられている主な特徴としては以下になります。
- モデル呼び出しとツール呼び出しが自動でdurable stepになる
- 完了済みのステップはチェックポイントから復元される
- ステップ名はエージェント名とツールセットIDから決まる
- ツール単位で設定を上書きできる
ドキュメントに書かれている制約のうち、実装前に知っておいた方が良いものを挙げておきます。
| 制約 | 内容 |
|---|---|
エージェントにnameが必須 |
ステップ名の接頭辞になるため。AWSLambdaDurability(name=...)でも指定でき、どちらも無いとAgent(...)の時点でエラーになる |
| ツール呼び出しは直列実行になる | ステップの同一性が到達順で決まるため、durableハンドラ内では並列ツール呼び出しが無効になる |
| ステップはat-least-once | チェックポイントはステップ完了後に保存されるため、副作用の直後に中断するとツールが再実行される。副作用を冪等にするか、重複を許容できないツールにはstep_semantics=AT_MOST_ONCE_PER_RETRYに加えてretry_strategy=RetryPresets.none()も設定する(前者だけでは次の試行でツール本体が再実行される) |
| 既定でステップは最大6試行される | 5〜60秒の指数バックオフで再試行される。Pydantic AIやプロバイダクライアント側のリトライと重なるので、どちらかを無効にする |
| 実行の形を変えると実行中の処理が壊れる | ツールの追加削除や、ステップ名の接尾辞が変わるモデル変更はチェックポイントの対応付けを崩すため、新しいバージョンとして公開し、実行中の処理は古いバージョンで完了させる |
durable_agent_handlerを通らない実行は非durable |
agent.run_sync()を直接呼ぶと警告なしに通常実行になる |
実際に動かしてみると、このうち「ツール呼び出しは直列実行」は後述の実行履歴にそのまま現れ、「ステップはat-least-once」もタイムアウトからの再開の結果と矛盾しないものでした。表の上では細かい注意書きに見えますが、ツールの設計に直接効いてくるので、実装前に確認しておきたいのはこの2つかと思います。
では早速試してみます。
試してみる
環境
今回使用した環境は以下の通りです。
Lambda ランタイム: python3.13 (arm64)
pydantic-ai-harness 0.36.0
pydantic-ai-slim 2.51.0
aws-durable-execution-sdk-python 2.0.1
AWS SAM CLI 1.166.2
AWS CLI 2.37.7
リージョン: ap-northeast-1
モデル: apac.amazon.nova-lite-v1:0 (Amazon Bedrock)
エージェントの実装
旅行アシスタントのエージェントを実装します。天気と為替レートを返すツールを2つ持たせ、どちらも外部APIの代わりのモックにしています。get_weatherは環境変数WEATHER_API_DELAYの秒数だけ待つようにしており、後半でLambdaのタイムアウトを起こすために使います。
import os
import time
from typing import Any
from aws_durable_execution_sdk_python import DurableContext, durable_execution
from pydantic_ai import Agent
from pydantic_ai.usage import UsageLimits
from pydantic_ai_harness.aws_lambda import AWSLambdaDurability, durable_agent_handler
MODEL_ID = os.environ.get("MODEL_ID", "apac.amazon.nova-lite-v1:0")
agent = Agent(
f"bedrock:{MODEL_ID}",
name="travel",
instructions=(
"あなたは旅行アシスタントです。get_weather と get_exchange_rate で取得した情報を使って、"
"指定された都市への旅行アドバイスを日本語で3文以内で答えてください。"
"通貨コードは ISO 4217(例: THB)で指定してください。思考過程は出力しないでください。"
),
capabilities=[AWSLambdaDurability()],
)
@agent.tool_plain
def get_weather(city: str) -> str:
"""指定した都市の今日の天気を返す(外部 API の代わりのモック)。"""
delay = float(os.environ.get("WEATHER_API_DELAY", "0"))
time.sleep(delay) # 外部 API の応答待ちを模擬する
return f"{city} の今日の天気は晴れ、最高気温は 28 度です。"
@agent.tool_plain
def get_exchange_rate(currency: str) -> str:
"""指定した通貨コード(ISO 4217)の対円レートを返す(外部 API の代わりのモック)。"""
rates = {"USD": 150.2, "EUR": 162.8, "THB": 4.3}
code = currency.strip().upper()
rate = rates.get(code)
if rate is None:
return f"{code} のレートは取得できませんでした。"
return f"1 {code} = {rate} 円です。"
@durable_execution
@durable_agent_handler
async def handler(event: dict[str, Any], context: DurableContext) -> dict[str, Any]:
result = await agent.run(
str(event["prompt"]),
usage_limits=UsageLimits(request_limit=6),
)
return {
"output": result.output,
"requests": result.usage.requests,
"tool_calls": result.usage.tool_calls,
}
ポイントは以下の点です。
Agent(...)にname="travel"とcapabilities=[AWSLambdaDurability()]を渡す。これと次のdurable_agent_handlerの組み合わせで、モデル呼び出しとツール呼び出しがdurable stepになります- ハンドラは
async defで書き、@durable_executionを一番外側、@durable_agent_handlerをその内側に付ける。順序を逆にするとハンドラ定義時にエラーになります - ツールは通常の
@agent.tool_plainのままで、durable functions向けの記述は不要です UsageLimits(request_limit=6)はモデル呼び出し回数の上限で、統合とは関係ありませんがエージェントが想定外にループしたときの保険として入れています
依存パッケージは次の3つです。pydantic-ai-harnessのaws-lambdaエクストラにAWS Durable Execution SDK for Pythonが含まれ、Bedrockを使うのでpydantic-ai-slimのbedrockエクストラを追加しています。SDKのメジャーバージョンが変わると実行中の処理が壊れることがあるため、ドキュメントではSDKのバージョンを固定するよう案内されています。そこでSDKも含めてバージョンを固定しています。
pydantic-ai-harness[aws-lambda]==0.36.0
pydantic-ai-slim[bedrock]==2.51.0
aws-durable-execution-sdk-python==2.0.1
SAMテンプレート
デプロイにはAWS SAMを使います。durable functionsにするには関数にDurableConfigを設定します。AWS::Serverless::FunctionでDurableConfigを書けるのはAWS SAM CLI 1.150.0(2025年12月)以降なので、それより古いSAM CLIを使っている場合は更新してください。
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: AWS Lambda durable functions x Pydantic AI sample
Parameters:
ModelId:
Type: String
Default: apac.amazon.nova-lite-v1:0
FunctionTimeout:
Type: Number
Default: 60
WeatherApiDelay:
Type: String
Default: "0"
Resources:
DurableAgentFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: durable-pydantic-ai-agent
CodeUri: src/
Handler: handler.handler
Runtime: python3.13
Architectures:
- arm64
MemorySize: 1024
Timeout: !Ref FunctionTimeout
# DurableConfig を書くと、SAM が生成する実行ロールに AWSLambdaBasicDurableExecutionRolePolicy が付く
DurableConfig:
ExecutionTimeout: 900
RetentionPeriodInDays: 7
Policies:
- Statement:
- Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
Resource: "*"
Environment:
Variables:
MODEL_ID: !Ref ModelId
WEATHER_API_DELAY: !Ref WeatherApiDelay
Outputs:
FunctionName:
Value: !Ref DurableAgentFunction
FunctionArn:
Value: !GetAtt DurableAgentFunction.Arn
durable functionsは関数自身がLambda APIのCheckpointDurableExecutionとGetDurableExecutionStateを呼ぶので、実行ロールにその許可が要ります。SAMに実行ロールを生成させる場合、DurableConfigを書くとこれらの許可をまとめたAWS管理ポリシーAWSLambdaBasicDurableExecutionRolePolicyが自動で付くので、テンプレートのPoliciesにはBedrockの許可だけを書いています。実行ロールを自前で定義する場合は、このポリシーを自分で付ける必要があります。
DurableConfigのExecutionTimeoutは、durable executionが開始から完了までにかけてよい時間の上限です。同期呼び出しは実行全体の完了を待って結果を返すため、ドキュメントでは同期呼び出しの上限は15分とされています。ExecutionTimeoutが15分を超える関数は、非同期(--invocation-type Event)で呼び出すことになります。今回は結果をその場で見たいので、同期呼び出しができる900秒にしています。
デプロイして実行する
ビルドとデプロイはSAMの通常の手順です。
$ sam build
$ sam deploy --stack-name durable-pydantic-ai --region ap-northeast-1 \
--resolve-s3 --capabilities CAPABILITY_IAM --no-confirm-changeset
durable functionsの呼び出しには$LATESTやバージョン番号などの修飾子が必要なので、関数名に:$LATESTを付けて呼び出します。実行中のdurable executionは開始時のバージョンに固定されるため、ドキュメントでは発行済みバージョンを呼び出すことが推奨されています。一方$LATESTを呼んだ場合はバージョンが固定されず、実行中のコードや設定の変更がその後の再実行に反映されます。今回は後半のタイムアウトの検証で、実行中に設定を変えて再実行に反映させるので$LATESTを使っています。
$ aws lambda invoke --function-name 'durable-pydantic-ai-agent:$LATEST' \
--region ap-northeast-1 --cli-binary-format raw-in-base64-out \
--payload '{"prompt": "来週バンコクに行きます。天気とタイの通貨レートを調べて助言してください。"}' \
response.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST",
"DurableExecutionArn": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:durable-pydantic-ai-agent:$LATEST/durable-execution/892c83be-0f87-41f2-815d-a837400db650/10d026e5-95bf-3e4c-a6e3-3cddf200f369"
}
レスポンスにはDurableExecutionArnが含まれます。response.jsonの中身は次の通りで、モデル呼び出し2回、ツール呼び出し2回で回答が返ってきています。
{
"output": "来週のバンコクの天気は晴れで、最高気温は28度です。1タイバーツは4.3円に換算されます。",
"requests": 2,
"tool_calls": 2
}
実行履歴でdurable stepを確認する
この実行がどのようにチェックポイントされたかはget-durable-execution-historyで確認できます。
$ aws lambda get-durable-execution-history --region ap-northeast-1 \
--durable-execution-arn 'arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:durable-pydantic-ai-agent:$LATEST/durable-execution/892c83be-0f87-41f2-815d-a837400db650/10d026e5-95bf-3e4c-a6e3-3cddf200f369'
返ってくるJSONのEventsからEventTypeとNameを抜き出すと次のようになります。
| EventId | 時刻 | EventType | Name |
|---|---|---|---|
| 1 | 07:23:48.483 | ExecutionStarted | 892c83be-... |
| 2 | 07:23:52.676 | StepStarted | travel__model.request |
| 3 | 07:23:53.333 | StepSucceeded | travel__model.request |
| 4 | 07:23:53.391 | StepStarted | travel__function_toolset__<agent>.call_tool:get_weather |
| 5 | 07:23:53.391 | StepSucceeded | travel__function_toolset__<agent>.call_tool:get_weather |
| 6 | 07:23:53.461 | StepStarted | travel__function_toolset__<agent>.call_tool:get_exchange_rate |
| 7 | 07:23:53.461 | StepSucceeded | travel__function_toolset__<agent>.call_tool:get_exchange_rate |
| 8 | 07:23:53.622 | StepStarted | travel__model.request |
| 9 | 07:23:54.071 | StepSucceeded | travel__model.request |
| 10 | 07:23:54.202 | InvocationCompleted | |
| 11 | 07:23:54.202 | ExecutionSucceeded | 892c83be-... |
context.step()を一つも書いていないのに、モデル呼び出し(travel__model.request)とツール呼び出し(travel__function_toolset__<agent>.call_tool:get_weatherなど)がそれぞれdurable stepとして記録されていることがわかります。ステップ名のtravelはエージェントのname、<agent>はエージェントに直接登録したツールが属するツールセットのIDです。
1回目のモデル呼び出しで2つのツールが同時に要求されていますが、ツール呼び出しのステップはget_weather、get_exchange_rateの順に直列で記録されています。これがドキュメントにある「durableハンドラ内ではツール呼び出しが直列になる」挙動です。
ステップの結果ペイロードは、--include-execution-dataを付けたときだけ確認できます。travel__model.requestのチェックポイントにはモデルの応答(parts以下)がそのまま保存されていました。再開時はこの保存済み応答が返されるので、Bedrockへのリクエストは繰り返されない仕組みです。実際に繰り返されないことは、次のタイムアウトの検証で確認します。
$ aws lambda get-durable-execution-history --region ap-northeast-1 \
--durable-execution-arn '(省略)' --include-execution-data \
--query 'Events[?Name==`travel__model.request` && EventType==`StepSucceeded`] | [0].StepSucceededDetails.Result.Payload' --output text | head -c 300
{"t":"m","v":{"parts":{"t":"l","v":[{"t":"m","v":{"content":{"t":"s","v":"<thinking>\u6765\u9031\u30d0\u30f3\u30b3\u30af\u3078\u306e\u65c5\u884c\u306b\u5929\u6c17\u3068\u30bf\u30a4\u306e\u901a\u8ca8\u30ec\u30fc\u30c8\u306e\u60c5\u5831\u304c\u5fc5\u8981\u3067\u3059\u3002get_weather\u3068get_exchange_
日本語部分はエスケープされていますが、デコードすると<thinking>来週バンコクへの旅行に天気とタイの通貨レートの情報が必要です。get_weatherとget_exchange_...というモデルの応答本文です。
Lambdaのタイムアウトをまたいで再開させてみる
次に、durable functionsの本領である「中断しても完了済みのステップをやり直さない」ことを確認します。シナリオは「天気APIの応答が遅くてLambdaの実行時間を超えてしまい、その後APIが復旧する」というものです。
関数のタイムアウトを20秒、get_weatherの待ち時間を30秒にしてデプロイします。
$ sam deploy --stack-name durable-pydantic-ai --region ap-northeast-1 \
--resolve-s3 --capabilities CAPABILITY_IAM --no-confirm-changeset \
--parameter-overrides FunctionTimeout=20 WeatherApiDelay=30
この状態で先程と同じプロンプトで同期呼び出しを行い、呼び出しの約28秒後に環境変数WEATHER_API_DELAYを0に更新して「APIの復旧」を再現します。
$ aws lambda invoke --function-name 'durable-pydantic-ai-agent:$LATEST' \
--region ap-northeast-1 --cli-binary-format raw-in-base64-out \
--payload '{"prompt": "来週バンコクに行きます。天気とタイの通貨レートを調べて助言してください。"}' \
response.json &
$ sleep 28
$ aws lambda update-function-configuration --function-name durable-pydantic-ai-agent \
--region ap-northeast-1 \
--environment "Variables={MODEL_ID=apac.amazon.nova-lite-v1:0,WEATHER_API_DELAY=0}"
同期呼び出しは51秒後に正常に返ってきました。モデル呼び出しの回数は2回のままです。
{
"output": "バンコクの天気は晴れで最高気温が28度です。1 THBは約4.3円です。",
"requests": 2,
"tool_calls": 2
}
この実行の履歴を先程と同じように抜き出すと次のようになります。
| EventId | 時刻 | EventType | Name | 備考 |
|---|---|---|---|---|
| 1 | 07:24:53.549 | ExecutionStarted | fbce5d7f-... | |
| 2 | 07:24:58.293 | StepStarted | travel__model.request | 1回目の呼び出し |
| 3 | 07:24:58.851 | StepSucceeded | travel__model.request | チェックポイント保存 |
| 4 | 07:24:59.017 | StepStarted | travel__function_toolset__<agent>.call_tool:get_weather | 30秒待ちに入る |
| 5 | 07:25:17.211 | InvocationCompleted | 1回目がタイムアウト | |
| 6 | 07:25:38.280 | InvocationCompleted | 2回目もタイムアウト | |
| 7 | 07:25:43.221 | StepSucceeded | travel__function_toolset__<agent>.call_tool:get_weather | 3回目で完了 |
| 8 | 07:25:43.268 | StepStarted | travel__function_toolset__<agent>.call_tool:get_exchange_rate | |
| 9 | 07:25:43.268 | StepSucceeded | travel__function_toolset__<agent>.call_tool:get_exchange_rate | |
| 10 | 07:25:43.447 | StepStarted | travel__model.request | |
| 11 | 07:25:43.845 | StepSucceeded | travel__model.request | |
| 12 | 07:25:43.977 | InvocationCompleted | 3回目の呼び出し | |
| 13 | 07:25:43.977 | ExecutionSucceeded | fbce5d7f-... |
InvocationCompletedが3回あり、最初の2回には次のエラーが記録されています。エラーの中身も--include-execution-dataを付けたときだけ確認でき、付けない場合は"Truncated": trueだけが返ります。
"Error": {
"Payload": {
"ErrorMessage": "RequestId: 6e30ff2f-5c59-4201-9999-21320f9e794f Error: Task timed out after 20.00 seconds",
"ErrorType": "Sandbox.Timedout"
},
"Truncated": false
}
InvocationCompletedのStartTimestampを見ると、2回目の呼び出しは07:25:18に始まっていました。一方、環境変数を更新したあとget-function-configurationで確認すると、LastUpdateStatusがSuccessfulになったのは07:25:23でした。2回目は更新の反映前に始まったため、待ち時間30秒のままタイムアウトしています。
注目したいのは、2回目以降の呼び出しではtravel__model.requestのStepStartedが記録されていない点です。1回目の呼び出しでチェックポイントされたモデル応答がそのまま使われ、Bedrockへのリクエストは繰り返されていません。一方、get_weatherのStepStartedは1回しか記録されておらず、2回目の呼び出しで実行し直されたかどうかは履歴からは直接確認できません。ただ、2回目の呼び出しも20秒でタイムアウトし、待ち時間を0秒に戻した3回目の呼び出しでStepSucceededが記録されていることから、チェックポイント前だったget_weatherは再開時に再実行されたと考えられます。これはドキュメントにある「ステップはat-least-once」の挙動と整合していて、副作用のあるツールを冪等に作る必要がある理由でもあります。
ここまでの流れを図にすると次のようになります。番号は上の表のEventIdです。
★がチェックポイントです。保存したチェックポイントは呼び出しのたびにLambdaへ渡され、完了済みのステップはSDKが手元の状態から保存済みの結果を返します。Bedrockへのリクエストが2回で済んでいるのはこのためです。
タイムアウト後の再実行はこちらで何もしなくても自動で行われ、同期呼び出し側は完了まで待って最終結果を受け取れました。呼び出し元から見るとLambdaが3回動いたことは意識しなくてよい形になっています。
後片付け
検証が終わったらスタックを削除します。
$ sam delete --stack-name durable-pydantic-ai --region ap-northeast-1 --no-prompts
まとめ
AWS Lambda durable functionsとPydantic AIの統合を試してみました。エージェントにAWSLambdaDurabilityを付けてハンドラをdurable_agent_handlerで包むだけで、モデル呼び出しとツール呼び出しがdurable stepとして記録され、Lambdaのタイムアウトをまたいでもモデル呼び出しをやり直さずに再開できることが確認できました。
parallel()やmap()の記事では処理を自分でcontext.step()に切り分けていましたが、エージェントの場合はモデル呼び出しとツール呼び出しという自然な単位で自動的に切り分けてくれるので、LLMエージェントの耐障害性を上げる手段として使いやすいと感じました。ツール呼び出しが直列になることとat-least-onceの制約は設計時に意識する必要がありますが、長いツール呼び出しを含むエージェントをLambdaで動かす場面で活用していきたいと思います。
最後まで読んで頂いてありがとうございました。








