
シンプルな生成 AI チャットボットを Amazon Bedrock AgentCore で作る(1)Runtime に Strands エージェントをのせる
本シリーズの記事一覧:
- Runtime に Strands エージェントをのせる(本記事)
- FastAPI BFF を ECS Fargate にのせて Runtime を Server-Sent Events(SSE)で中継する
- SPA を S3 + CloudFront で配信してチャットをブラウザで動かす
- Cognito で認証を足してベースラインを完成させる
- AgentCore Memory で会話を永続化する
- AgentCore Observability でエージェントを観測する
こんにちは、コンサルティング部のシモンです。
皆さんは生成 AI のチャットボットを、自分の AWS アカウントの中に一から組み立ててみたいと思ったことはありますか?私はあります。
このシリーズでは、Amazon Bedrock AgentCore を使ってブラウザから使える生成 AI チャットボットを AWS 上に構築する手順を共有します。まず動く最小構成を作り、そこから AgentCore の機能を足して育てていきます。
第 1 回である本記事では、シリーズ全体の構成を紹介したうえで、エージェント本体を Amazon Bedrock AgentCore Runtime にデプロイして、CLI から応答が返ってくるところまでを作ります。
Amazon Bedrock AgentCore とは
AgentCore は、AI エージェントを本番環境で動かすためのマネージドサービス群です。Runtime(エージェントのホスティング)、Memory(会話や知識の永続化)、Gateway(既存 API のツール化)、Identity(エージェントの認証・認可)、Observability(トレースとメトリクス)などが含まれ、必要なものだけを個別に採用できます。エージェントのフレームワークは問わないため、Strands Agents でも LangGraph でも CrewAI でも同じ Runtime に載せられます。
本シリーズでは、この中から必要なものだけを順に採用します。まずは Runtime でエージェントをサーバーレスに動かし、その後 Memory で会話を永続化し、Observability で中の動きを追います。
このシリーズで作るもの
シリーズを通して、次の構成のチャットボットを作ります。

- AgentCore Runtime: エージェントのコンテナをサーバーレスにホストします
- Strands Agents: エージェント本体を書くための OSS フレームワークです
- BFF(FastAPI on ECS Fargate): ブラウザに AWS の認証情報を置けないため、Runtime の呼び出しを代行するアプリケーションです(第 2 回)
- Amazon S3 + CloudFront: 静的な SPA を配信します(第 3 回)
- Amazon Cognito: ユーザー認証を担います。SPA が Authorization Code Flow + PKCE でトークンを取得し、BFF がそれを検証します(第 4 回)
- AgentCore Memory: 会話履歴を保持し、セッションをまたいで文脈を引き継ぎます(第 5 回)
- AgentCore Observability: エージェントの実行トレースを CloudWatch に送ります(第 6 回)
要件・前提条件(シリーズ全体)
以下はシリーズ全体で使うものです。第 1 回で必要なのは AWS CLI、Docker、Python です。
- AWS アカウントと、本シリーズのリソースを作成・削除できる権限
- リージョンは us-east-1
- モデルは Kimi K2.5(モデル ID
moonshotai.kimi-k2.5)を使いますが、任意のモデルで問題ありません - ローカルの開発環境(バージョンは目安)
- AWS CLI v2
- Docker(
docker buildとdocker pushが使えること。Apple Silicon 以外のマシンでは arm64 のクロスビルド環境も必要) - Python 3.12 以上
- Node.js 20 以上と npm(SPA、第 3 回で使用)
- Git
サンプルコードのリポジトリ
本シリーズのコードは次のリポジトリにあります。本記事の時点のコードは entry-1 タグで参照できます。
Strands で最小のチャットエージェントを書く
前掲のリポジトリの agent/app.py に AI エージェントの実装をしていきましょう。
AI エージェントは、モデルが自分で次の行動を決めながら、ユーザーの依頼を達成するまで処理を進めるソフトウェアです。モデルは推論で答えられるなら答え、外部の情報や操作が必要と判断したらツールを呼びます。呼び出し結果はモデルに戻され、それを元にモデルは再び次の行動を決めます。答えが揃うまでこれを繰り返す流れをエージェントループと呼びます。
Strands Agents は AWS が公開している OSS のエージェントフレームワークで、このループの実装を提供します。開発者が用意するのはモデル、システムプロンプト、ツールです。
最小のエージェントはこれだけです。
from strands import Agent
from strands.models import BedrockModel
model = BedrockModel(model_id="moonshotai.kimi-k2.5", region_name="us-east-1")
agent = Agent(model=model, system_prompt="You are a helpful assistant. Answer concisely.")
print(agent("Hello!"))
Agent がエージェントループを持ち、BedrockModel がモデル呼び出しを担います。今回は Amazon Bedrock でホストするため strands.models は BedrockModel としています。
Strands 自体は特定のモデルホストに紐づいていません。Amazon Bedrock のほか Anthropic、OpenAI、Ollama、LiteLLM 経由の各社モデルなどのプロバイダーが用意されており、Agent に渡すモデルオブジェクトを差し替えればホストを変えられます。
ツールを 1 つ足す
エージェントらしい挙動をデモとして実現するために、ツールを 1 つ用意します。本記事では簡単のために外部 API を呼ばず、固定文字列を返すモックにします。
from strands import tool
@tool
def get_weather(location: str) -> str:
"""Get the current weather for a location.
Args:
location: The city or place to look up.
"""
# Mock implementation for the sample: always sunny.
return f"{location} is sunny"
@tool を付けた関数がそのままツールになります。あとは Agent に登録します。
agent = Agent(
model=model,
system_prompt="You are a helpful assistant. Answer concisely.",
tools=[get_weather],
)
モデルはツールをどう選ぶか
@tool を付けた時点で、Strands は関数から次の 3 つを取り出してツールの定義を組み立てます。
- 名前: 関数名(
get_weather) - 説明: docstring(
Get the current weather for a location.) - 引数のスキーマ: 型ヒントと
Args:の記述(locationは文字列)
Agent に登録されたツールの定義は、ユーザーの入力と共に毎回モデルへ送られます。つまり、モデルがツールを選ぶ判断材料は関数名と docstring と引数の型です。
モデルは入力とツール定義を突き合わせて、自力で答えるか、ツールを呼ぶかを決めます。ツールを呼ぶと決めた場合、モデルは「get_weather を location="Tokyo" で呼びたい」という要求を返します。
ツールの実行は Strands が行います。Python 関数を呼び、戻り値をツールの実行結果としてモデルに返し、モデルが次の行動を決める、というエージェントループに戻ります。
したがって docstring はコメントではなく、モデル向けのインターフェース仕様です。ここが曖昧だと、呼ばれるべき場面で呼ばれないことがあります。
今回の実装例では、「東京の天気は?」と聞くと、モデルが get_weather を選び、Strands が Tokyo is sunny を受け取ってモデルに渡し、モデルがそれを日本語の文章に整えて返す、という流れです。ツール呼び出しが実際に起きたかどうかは、第 6 回の Observability でトレースとして確認します。
AgentCore Runtime 向けにラップする
AgentCore Runtime は、ECR に置いたコンテナイメージを指定して作成するリソースです。呼び出しを受けるとそのイメージからコンテナを起動し、HTTP でリクエストを渡して応答を返します。Runtime は決められた形の HTTP サーバーとして振る舞ってさえいれば動きます。
その約束事は次の 4 つです。
0.0.0.0:8080で HTTP を待ち受けるGET /pingでヘルスチェックに応答するPOST /invocationsで呼び出しを受け付ける- イメージのアーキテクチャは linux/arm64
このうち上の 3 つは bedrock-agentcore SDK の BedrockAgentCoreApp が引き受けます。開発者はエージェントの処理を、@app.entrypoint を付けた関数に実装します。
from bedrock_agentcore.runtime import BedrockAgentCoreApp
# /ping と /invocations を備えた HTTP アプリケーション
app = BedrockAgentCoreApp()
@app.entrypoint # この関数が /invocations の実装になる
async def invoke(payload):
"""Stream the agent's answer to the prompt in the request payload."""
user_message = payload.get("prompt")
if not user_message:
yield {"error": "Missing 'prompt' in request payload."}
return
async for event in agent.stream_async(user_message):
if "data" in event:
yield event["data"]
if __name__ == "__main__":
app.run() # 0.0.0.0:8080 にバインドしてリクエストを受け始める。
応答をストリーミングで返す
エントリポイントを async def の非同期ジェネレータにすると、yield した値が順次クライアントへ流れます。AgentCore Runtime はこれを SSE として返します。
SSE は、サーバーがレスポンスを開いたまま、データができた順に少しずつ送り続ける HTTP の仕組みです。普通の HTTP レスポンスは本文がすべてそろってから 1 回で返りますが、SSE ではヘッダーを先に返して本文を細切れに流し続け、送るものがなくなった時点で接続を閉じます。
ストリーミングにしておく利点は 2 つあります。
- 最初の応答が早く返る: モデルが最初のテキスト断片を生成した時点で表示を始められるため、推論がすべて終わるのを待つ場合に比べて、回答の最初の文字が出るまでの時間が短くなります
- 無通信の時間が短くなる: 何十秒も応答がない接続は、途中の経路やクライアントにタイムアウトと判断されることがあります。テキストが流れている間はその判断をされにくくなります(ツール実行中はテキストが流れないため、そこは無通信になります)
async for event in agent.stream_async(user_message):
# Strands emits many event types; forward only the text deltas.
if "data" in event:
yield event["data"]
agent.stream_async() はエージェントループの進行に伴って多くの種類のイベントを発行します。生成されたテキストの断片、ツール呼び出しの開始と終了、ループの区切りなどです。ここで必要なのは画面に出す文字だけなので、テキスト断片が入る data キーを持つイベントだけを転送しています。
これでエージェントのファイルが完成しました!全体は前掲のリポジトリの agent/app.py にあります。
このストリーミングをブラウザまで届ける中継は第 2 回、モデルが書き始めた瞬間から文字が出る SPA の実装は第 3 回で扱います。
arm64 のイメージをビルドする
agent/Dockerfile を用意します。ポート 8080 を公開し、ログが CloudWatch Logs へ遅れずに流れるよう PYTHONUNBUFFERED=1 を設定しています。
ビルドします。
docker build --platform linux/arm64 --tag agentcore-chatbot-sample-agent:latest agent
--platform linux/arm64 を付けているのは、AgentCore Runtime が arm64 のイメージだけを受け付けるためです。Apple Silicon のマシンであれば arm64 はネイティブなのでそのままビルドできます。Intel マシンの場合はクロスビルドの準備が必要です。
CloudFormation でリソースを作る
作るリソースは 3 つです。イメージを置く ECR リポジトリ、Runtime の実行ロール、そして Runtime 本体です。
Runtime はイメージの URI を指定して作るため、作成の時点で ECR にイメージが存在している必要があります。リポジトリを作り、イメージを push し、それから Runtime を作るという順序になりますが、push は CloudFormation のデプロイの途中に挟めません。そのためテンプレートを 2 つに分け、リポジトリと Runtime を別々のスタックで作ります。
ECR リポジトリ
infra/ecr.yaml はリポジトリを 1 つ作るだけのテンプレートです。デプロイします。
aws cloudformation deploy \
--region us-east-1 \
--stack-name agentcore-chatbot-sample-ecr \
--template-file infra/ecr.yaml \
--parameter-overrides RepositoryName=agentcore-chatbot-sample-agent
リポジトリができたので、先ほどビルドしたイメージを push します。
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
REGISTRY="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com"
IMAGE_URI="${REGISTRY}/agentcore-chatbot-sample-agent:latest"
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin "${REGISTRY}"
docker tag agentcore-chatbot-sample-agent:latest "${IMAGE_URI}"
docker push "${IMAGE_URI}"
この IMAGE_URI を次の Runtime スタックに渡します。

Runtime の実行ロールと Runtime 本体
infra/runtime.yaml に定義し、デプロイします。
aws cloudformation deploy \
--region us-east-1 \
--stack-name agentcore-chatbot-sample-runtime \
--template-file infra/runtime.yaml \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
RuntimeName=agentcore_chatbot_sample_agent \
ContainerUri="${IMAGE_URI}" \
ModelId=moonshotai.kimi-k2.5 \
RepositoryName=agentcore-chatbot-sample-agent
ロールは bedrock-agentcore.amazonaws.com を信頼し、コンテナの中のコードはこのロールの権限で動きます。必要な権限は次のとおりです。
- ECR: イメージの pull に必要な
ecr:BatchGetImageとecr:GetDownloadUrlForLayer(対象は今回のリポジトリのみ)、およびecr:GetAuthorizationTokenを付けています - CloudWatch Logs:
/aws/bedrock-agentcore/runtimes/配下のロググループとログストリームの作成と書き込みに限定しています bedrock:InvokeModelとbedrock:InvokeModelWithResponseStream: モデルの呼び出しです。リソースは 2 つ指定します。転送先の基盤モデルがarn:aws:bedrock:*::foundation-model/*、推論プロファイル自体がリージョン付きのarn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:*です。クロスリージョン推論プロファイルを指定すると複数リージョンの基盤モデルへルーティングされるため、両方が必要になります- X-Ray のトレース送信と、名前空間を
bedrock-agentcoreに限定したcloudwatch:PutMetricData: 第 6 回の AgentCore Observability で使います。そのときにロールを作り直さなくて済むよう、最初から入れておきます
エージェントのコードには認証情報を渡す処理がありませんでした。BedrockModel が boto3 と同じ順序で認証情報を解決するため、Runtime 上ではこの実行ロールがそのまま使われるため、処理を書く必要がありません。
エージェント側はモデル ID とリージョンを環境変数から受け取ります(os.environ.get("BEDROCK_MODEL_ID", "moonshotai.kimi-k2.5") と os.environ.get("AWS_REGION", "us-east-1"))。Runtime の EnvironmentVariables には、テンプレートのパラメータ ModelId の値をそのまま BEDROCK_MODEL_ID として渡しています。モデルを変えるときは ModelId を変えてスタックを更新するだけで済み、イメージのビルドと push は不要です。
これで Runtime ができました!

呼び出しに使う ARN はスタックの出力から取れます。
RUNTIME_ARN="$(aws cloudformation describe-stacks \
--region us-east-1 \
--stack-name agentcore-chatbot-sample-runtime \
--query "Stacks[0].Outputs[?OutputKey=='RuntimeArn'].OutputValue" \
--output text)"
CLI から呼び出してツール呼び出しまで確認する
CLI でエージェントを呼び出してみましょう。もちろん、コンソール上のエージェントのテスト画面からも実行できます。
aws bedrock-agentcore invoke-agent-runtime \
--region us-east-1 \
--agent-runtime-arn "${RUNTIME_ARN}" \
--runtime-session-id "$(openssl rand -hex 20)" \
--cli-binary-format raw-in-base64-out \
--content-type "application/json" \
--payload '{"prompt": "日本のような四季とは異なるシーズンを持つ国の例をいくつか教えてください。"}' \
/dev/stdout
実行すると、応答が /dev/stdout に出力されます。--payload は AWS CLI がデフォルトで base64 として扱うため、生の JSON をそのまま渡すには --cli-binary-format raw-in-base64-out が必要です。これを付けないと、生の JSON がそのまま base64 として解釈され、リクエストの送信に失敗します。

日本語のプロンプトに対して、応答が少しずつ流れてくることが確認できました!
次にツールを呼ばせてみます。
aws bedrock-agentcore invoke-agent-runtime \
--region us-east-1 \
--agent-runtime-arn "${RUNTIME_ARN}" \
--runtime-session-id "$(openssl rand -hex 20)" \
--cli-binary-format raw-in-base64-out \
--content-type "application/json" \
--payload '{"prompt": "What is the weather in Tokyo?"}' \
/dev/stdout

モックの get_weather が返す Tokyo is sunny を踏まえた応答になっていることが確認できました!ツールがない場合は、外部情報を取得できないため回答できない、というレスポンスが返ってきてしまいます。
思ったような応答が返らないときは、CloudWatch Logs の /aws/bedrock-agentcore/runtimes/ 配下に出ているエージェントの標準出力を確認してください。
デプロイと呼び出しをスクリプトにまとめる
ここまで 1 コマンドずつ実行してきたのは、各ステップで何が起きているかを追うためです。実際の作業では、これらをまとめたスクリプトをリポジトリの scripts/ に用意してあります。リージョンやスタック名、モデル ID といった値は scripts/config.sh に集約し、各スクリプトがそれを読み込む形です。
デプロイは 1 コマンドで済みます。ECR スタックの作成、イメージのビルドと push、Runtime スタックの作成を順に実行します。
./scripts/agent-deploy.sh
呼び出しも同様です。引数を渡すとそれがプロンプトになります。
./scripts/agent-invoke.sh
./scripts/agent-invoke.sh "What is the weather in Tokyo?"
作ったリソースを削除する
使い終わったら削除します。スクリプトが Runtime スタック、ECR スタックの順に削除します。
./scripts/agent-teardown.sh
ECR リポジトリは EmptyOnDelete: true を付けてあるため、イメージが残っていても削除できます。
今回作ったリソースは、置いておいても大きな費用にはなりません。ただし第 2 回以降は、BFF を動かす Fargate タスクと、その前段に置く ALB、コンテナの外部通信に使う NAT ゲートウェイなど、起動している間ずっと課金されるリソースが増えます。使わないときは適宜削除してください。
セッション単位でコンテナが確保され、停止する
ここまで、コンテナを渡しただけでエージェントが動きました。そのコンテナがいつ起動していつ消えるのかを整理します。起動から停止までの単位(セッション)になるのは、呼び出し時に渡した --runtime-session-id です。
AgentCore Runtime はセッションごとに専用の実行環境(microVM)を確保し、その中でコンテナを起動します。セッションは Active(処理中)、Idle(待機中)、Stopped(コンピュートが破棄済み)の 3 状態を取ります。以下ではこの 3 つの間の遷移を追います。
aws bedrock-agentcore invoke-agent-runtime \
--region us-east-1 \
--agent-runtime-arn "${RUNTIME_ARN}" \
--runtime-session-id "$(openssl rand -hex 20)" \
--content-type "application/json" \
--payload '{"prompt": "What is the weather in Tokyo?"}' \
/dev/stdout
1 回目の呼び出しで環境が作られる
新しいセッション ID で invoke-agent-runtime を呼ぶと、AgentCore Runtime がそのセッション専用の microVM を確保し、実行ロールの権限で ECR からイメージを pull して、コンテナを起動します。ここでセッションは Active になります。
確保される環境はセッションごとに独立しています。CPU、メモリ、ファイルシステムは分離されており、あるセッションのコンテナから別のセッションのデータは見えません。
起動したコンテナには GET /ping が飛びます。ここで Healthy が返って初めて、POST /invocations にリクエストが渡されます。/ping の実装を自分で書かずに済んでいたのは、SDK がこのヘルスチェックに応答しているからです。
そのため 1 回目の呼び出しは、2 回目以降より時間がかかります。
2 回目以降は同じ環境が使い回される
同じセッション ID で呼ぶと、同じコンテナに届きます。ファイルシステムもプロセスのメモリもそのまま残っているため、前回の呼び出しで作った状態を引き継げます。
リクエストを処理していない間、セッションは Idle になります。環境は確保されたままなので、次の呼び出しは起動待ちなしで始まります。
コンテナが停止する条件
次のいずれかでコンテナは停止し、セッションは Stopped になります。
- 無操作が続いた(デフォルト 15 分)
- セッション開始からの経過時間が上限に達した(デフォルト 8 時間)
StopRuntimeSessionAPI で明示的に停止した- ヘルスチェックで異常と判断された
停止すると microVM は破棄され、コンテナの中に持っていた状態は失われます。セッションをまたいで会話の文脈を残したい場合は、状態をセッションの外に置く必要があり、これが第 5 回で AgentCore Memory を使う理由です。
停止したセッション ID で呼び直すと新しい環境になる
セッションそのものは、Runtime を削除するまで有効です。停止済みのセッション ID で呼び直すと Active に戻り、新しい環境が確保されます。1 回目と同じく起動から始まるため、中に持っていたデータは戻りません。
環境の確保中や停止処理中に同じセッションへ別の呼び出しを投げると、HTTP 409 の RetryableConflictException が返ります。短いバックオフを入れて再試行する前提の挙動です。
待ち時間に CPU の課金は発生しない
課金は CPU とメモリの消費量に対して、セッションのライフタイム全体で秒単位に計算されます。CPU はモデルやツールの応答を待っている間は消費されないため、ほかにバックグラウンド処理が動いていなければ、待ち時間に CPU の課金は発生しません。エージェントは LLM とツールの往復で待ち時間が長くなりやすいので、ここが効きます。
一方でメモリは、セッションが確保されている間ずっと課金対象です。無操作の 15 分のあいだもメモリの課金は続きます。
セッション ID とユーザーの対応付けは呼び出す側の担当
どのユーザーがどのセッション ID を使うかの管理は AgentCore の担当ではありません。ユーザーあたりのセッション数の制限なども含め、呼び出す側が持ちます。第 2 回で BFF を作るときの役割の 1 つがこれです。
まとめ
Strands で書いたエージェントを AgentCore Runtime にデプロイし、CLI から応答が返るところまで確認しました。サーバーの用意もセッションごとの分離も Runtime 側が引き受けるため、渡すのはコンテナイメージ 1 つだけです。
ただし、今回の構成で呼べるのは AWS の認証情報を持っている人だけです。ブラウザに AWS の認証情報を置くわけにはいかないので、間に呼び出し役を挟む必要があります。
次回は FastAPI で BFF を作り、ECS Fargate にのせて、Runtime のストリーミング応答を SSE で中継するところまでを作ります。
最後までお読みいただきありがとうございました。












