
シンプルな生成 AI チャットボットを Amazon Bedrock AgentCore で作る(2)FastAPI BFF を ECS Fargate にのせて Runtime を SSE で中継する
本シリーズの記事一覧:
- Runtime に Strands エージェントをのせる
- FastAPI BFF を ECS Fargate にのせて Runtime を Server-Sent Events(SSE)で中継する(本記事)
- SPA を S3 + CloudFront で配信してチャットをブラウザで動かす
- Cognito で認証を足してベースラインを完成させる
- AgentCore Memory で会話を永続化する
- AgentCore Observability でエージェントを観測する
こんにちは、コンサルティング部のシモンです。
このシリーズでは、Amazon Bedrock AgentCore を使ってブラウザから使える生成 AI チャットボットを AWS 上に構築しています。まず動く最小構成を作り、そこから AgentCore の機能を足して育てていく流れです。
第 1 回では、Strands で書いたエージェントを AgentCore Runtime にデプロイし、AWS CLI から応答が返るところまでを作りました。ただし今の状態で呼び出せるのは、AWS の認証情報を持っている人だけです。ブラウザからは触れません。
第 2 回である本記事では、ブラウザとエージェントの間に立つ BFF(Backend for Frontend)を FastAPI で書き、ECS Fargate にのせます。Runtime のストリーミング応答を SSE のまま中継し、ALB 経由で curl から会話できるところまでを作ります。SSE がどういう仕組みかは第 1 回で説明しています。
サンプルコードのリポジトリ
本記事の時点のコードは entry-2 タグで参照できます。
BFF は何を担うのか
今回作るのは、下図の BFF の部分です。

BFF は次の 3 つを担います。本記事で作るのは 1 と 2、および 3 のうちヘルスチェックの受け口までです。
- Runtime を呼ぶ。
InvokeAgentRuntimeの呼び出しには SigV4 署名が要り、その認証情報をブラウザに置くわけにはいかないため、BFF が IAM の認証情報を持ってサーバー側で API を叩く - 応答を中継する。Runtime から返るストリームを、ブラウザがそのまま読める形に組み立て直して流す
- 推論以外の処理を引き受ける。ヘルスチェックの受け口のようなエンドポイントに加えて、認証やセッションの扱いといった、推論の前後に必要なロジックの置き場所になる
推論以外のロジックはエージェントに置けない
呼び出し側から叩ける Runtime のエンドポイントは /invocations だけで、その先はエージェントの処理です。アプリケーションに必要なそれ以外の処理には置き場所がありません。
- ALB のヘルスチェックの受け先
- 第 4 回で足すアクセストークンの検証
- 第 5 回で足す会話履歴の読み出し
- 第 4 回で足すセッション ID とユーザーの対応付け
Runtime を ALB の後ろに置いて、足りない API を別のターゲットで補う、という手も使えません。ALB のターゲットグループが取れるターゲットタイプは 3 種類(instance、ip、lambda)で、InvokeAgentRuntime に ARN を渡して呼ぶ Runtime はどれにも当てはまらないためです。
このうち権限にかかわる判断をブラウザ側に持たせることはできません。どのユーザーがどのセッションを触れるのかといった判断は、利用者が書き換えられる場所には置けないからです。
結果として、ブラウザと Runtime の間にサーバー側の層が必要になります。それが BFF です。
本記事の段階では、BFF は受け取った session_id が誰のものかを確かめずに Runtime へ渡します。第 1 回の最後で BFF の役割として挙げたセッション ID とユーザーの対応付けは、認証と不可分なので第 4 回でまとめて扱います。
FastAPI で SSE のチャットエンドポイントを書く
BFF の実装は bff/app.py の 1 ファイルです。公開するエンドポイントは 2 つだけにします。
GET /health: ALB のヘルスチェックの受け先POST /api/chat: チャット本体
ChatRequest でボディを受けてセッション ID を決める
POST /api/chat の定義です。このエンドポイントは、第 3 回で作る SPA がユーザーの入力を送る先になります。本記事の時点では curl から叩いて確認します。
class ChatRequest(BaseModel):
prompt: str
session_id: str | None = None
@app.post("/api/chat")
def chat(request: ChatRequest):
session_id = request.session_id or str(uuid.uuid4())
引数の型に ChatRequest を指定すると、FastAPI がリクエストボディの JSON をこのモデルに当てはめて渡します。プロンプトは必須、セッション ID は省略可です。
セッション ID が省略された場合は BFF が uuid4 を生成します。生成した値は応答の X-Session-Id ヘッダーで返すため、クライアントが次のターンで同じ値を送れば、Runtime 側の同じセッションに届きます。それが会話の続きになる仕組みは、動作確認のあとで説明します。
Runtime を呼ぶ
第 1 回で AWS CLI から叩いた invoke-agent-runtime を、boto3 から呼びます。
クライアントはモジュールの読み込み時に 1 つだけ作ります。
agent_core = boto3.client("bedrock-agentcore", region_name=AWS_REGION)
呼び出しは chat の中です。ここから先のコードはすべてこの関数の中にあります。
response = agent_core.invoke_agent_runtime(
agentRuntimeArn=AGENT_RUNTIME_ARN,
runtimeSessionId=session_id,
payload=json.dumps({"prompt": request.prompt}).encode(),
)
payload はバイト列です。第 1 回のエージェントが payload.get("prompt") で読んでいたのが、この JSON にあたります。セッション ID を渡す引数は runtimeSessionId です。以降の地の文ではセッション ID と呼びます。
contentType で分岐して応答を返す
invoke_agent_runtime の応答は contentType で形が変わるため、そこで分岐します。
content_type = response.get("contentType", "")
headers = {"X-Session-Id": session_id}
if "text/event-stream" in content_type:
headers["Cache-Control"] = "no-cache"
return StreamingResponse(
relay_sse(response["response"]),
media_type="text/event-stream",
headers=headers,
)
text/event-stream で返ってくるのは、第 1 回でエージェントのエントリポイントを非同期ジェネレータとして書いたためです。モデルが書いたテキストの断片を yield すると、AgentCore Runtime がそれを SSE のイベントに変換し、1 件ずつ返します。次のコードは第 1 回の agent/app.py のもので、ここでの app は BFF ではなくエージェント側のアプリケーションです。
@app.entrypoint
async def invoke(payload):
user_message = payload.get("prompt")
...
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"]
応答は次のようにリレーされていきます。
モデル → エージェント(yield)→ Runtime(SSE)→ BFF(SSE)→ クライアント
FastAPI では StreamingResponse にジェネレータを渡すと、そのジェネレータが yield した文字列が、yield されたそばから応答ボディへ書き出されます。応答全体がそろうのを待ちません。
ここで渡しているジェネレータが relay_sse です。Runtime から届いたストリームを読みながら、BFF 側で yield していきます。invoke の yield がエージェントから Runtime への出力だったのに対して、relay_sse の yield は BFF からクライアントへの出力です。
Cache-Control: no-cache は、間に入る経路にこの応答をキャッシュさせないための指定です。第 3 回で前段に CloudFront を置くため、そこで効いてきます。
Runtime 側がエラーを返したときなどは application/json で返るため、そちらも受けます。
if "application/json" in content_type:
# The body arrives either as a stream or as a sequence of byte chunks.
body = b"".join(response["response"])
return JSONResponse(json.loads(body), headers=headers)
relay_sse は 1 バイトずつ読む
def relay_sse(body):
for line in body.iter_lines(chunk_size=1):
decoded = line.decode("utf-8")
if decoded.startswith("data: "):
yield decoded + "\n\n"
relay_sse が受け取る body は、先ほど relay_sse(response["response"]) として渡した値、つまり Runtime から届くストリーム本体です。これを 1 行ずつ読み、data: で始まる行だけをクライアントへ流します。
iter_lines(chunk_size) は内部で read(chunk_size) を呼び、指定バイト数が集まるか、ストリームが閉じるまで返りません。デフォルトは 1024 で、SSE の 1 イベントは数十バイトのため、既定のままだと 1024 バイト分のイベントがたまるまで 1 行も読み出されません。応答全体が 1024 バイトに満たなければ、ストリームが閉じてからまとめて届きます。エラーは出ず、応答の内容も正しいため気づきにくい問題です。ここでは 1 行そろった時点で流したいので chunk_size=1 としています。
1 バイトずつの読み出しは転送効率を落としますが、モデルが文字を生成する速度はこの転送速度よりずっと遅いため問題になりません。
この構成ならではの CloudFormation の設定
VPC を infra/network.yaml、ALB と ECS を infra/bff.yaml に分けています。ECS や ALB 自体の説明は割愛します。
- タスクロールには
bedrock-agentcore:InvokeAgentRuntimeを、Runtime の ARN と${AgentRuntimeArn}/runtime-endpoint/*の両方に対して許可しています。invoke が実際に解決するエンドポイントは Runtime のサブリソースのため、ARN 本体だけでは権限不足になります - タスク定義で
AWS_REGIONを環境変数として設定しています。ECS は Lambda と異なりこの変数を自動では渡さないため、設定を省くと BFF は既定のリージョンにフォールバックします。あわせて、スタックパラメータのAgentRuntimeArnをAGENT_RUNTIME_ARNとして渡しています - ALB のアイドルタイムアウトを 300 秒に上げています。デフォルトは 60 秒で、エージェントがツールを実行している間はストリームが無音になるためです
- ターゲットグループの
deregistration_delayを 30 秒に下げています。デフォルトは 300 秒で、デプロイやテストのたびに 5 分待たされるのを避けるためです
AgentRuntimeArn はスタックパラメータとして受け取ります。そのため BFF スタックと Runtime スタックは依存関係を持たず、Runtime 側を単独で削除できます。
デプロイ
デプロイは 4 ステップです。ECS サービスは push 済みのイメージを必要とし、BFF スタックはネットワークスタックの出力を参照するため、この順序は入れ替えられません。
まず infra/ecr.yaml を再デプロイし、第 1 回で作った ECR スタックに BFF 用のリポジトリを追加します。entry-2 タグのこのテンプレートにはリポジトリが 2 つ定義されています。この更新で追加されるのは BFF 用のリポジトリだけで、第 1 回で作ったリポジトリと push 済みのイメージはそのまま残ります。
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 \
BffRepositoryName=agentcore-chatbot-sample-bff
リポジトリができたので、BFF イメージをビルドして push します。第 1 回のエージェントと同じ流れですが、イメージ URI は BFF 用のリポジトリのものを使います。タスク定義で arm64 を指定しているため、イメージも arm64 でビルドします。
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
REGISTRY="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com"
BFF_IMAGE_URI="${REGISTRY}/agentcore-chatbot-sample-bff:latest"
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin "${REGISTRY}"
docker build --platform linux/arm64 --tag agentcore-chatbot-sample-bff:latest bff
docker tag agentcore-chatbot-sample-bff:latest "${BFF_IMAGE_URI}"
docker push "${BFF_IMAGE_URI}"
次に infra/network.yaml をデプロイします。VPC、パブリック/プライベートサブネット、NAT ゲートウェイができます。
aws cloudformation deploy \
--region us-east-1 \
--stack-name agentcore-chatbot-sample-network \
--template-file infra/network.yaml
ネットワークができたので、第 1 回の Runtime スタックから ARN を取り出し、infra/bff.yaml をデプロイします。ALB、ECS クラスター、Fargate サービス、タスクロールができます。
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)"
aws cloudformation deploy \
--region us-east-1 \
--stack-name agentcore-chatbot-sample-bff \
--template-file infra/bff.yaml \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
ImageUri="${BFF_IMAGE_URI}" \
AgentRuntimeArn="${RUNTIME_ARN}"
ALB の DNS 名を、動作確認で使うために取り出しておきます。
ALB_DNS="$(aws cloudformation describe-stacks \
--region us-east-1 \
--stack-name agentcore-chatbot-sample-bff \
--query "Stacks[0].Outputs[?OutputKey=='AlbDnsName'].OutputValue" \
--output text)"
ここまでの手順は scripts/bff-deploy.sh にまとめてあります。第 1 回と同じく、1 コマンドずつ実行したのは各ステップで何が起きているかを追うためです。
ALB 経由で応答が逐次届くことを確認する
curl --no-buffer で ALB にリクエストを送ります。--no-buffer を付けないと、応答がまとめて表示されて逐次届いている様子が見えません。
各行が届いた時刻を見えるようにするため、リクエスト開始からの経過秒数を付ける簡単なフィルタを挟みます。
curl --no-buffer -N -X POST "http://${ALB_DNS}/api/chat" \
-H "Content-Type: application/json" \
-d '{"prompt": "生成 AI のストリーミング応答について、200 字程度で説明してください。"}' \
| python3 -c "
import sys, time
start = time.time()
for line in sys.stdin:
line = line.rstrip('\n')
if line:
print(f'[t+{time.time()-start:5.2f}s] {line}')
"

応答が 1 回で返るなら、すべての行に同じ時刻が付くはずです。実際には 0.95 秒、0.96 秒、1.15 秒、1.37 秒、1.55 秒、1.65 秒と、そのつど異なる時刻が付いて届きました。エージェントが書き終わるのを待たず、生成された分から順にクライアントへ届いていることが確認できました。
同じセッション ID で会話が続くことを確認する
session_id を同じ値にして 2 回呼び、前の発言を覚えているかを見ます。
curl --no-buffer -N -X POST "http://${ALB_DNS}/api/chat" \
-H "Content-Type: application/json" \
-d '{"prompt": "My name is Simon.", "session_id": "'"${SESSION_ID}"'"}'
curl --no-buffer -N -X POST "http://${ALB_DNS}/api/chat" \
-H "Content-Type: application/json" \
-d '{"prompt": "What is my name?", "session_id": "'"${SESSION_ID}"'"}'

2 回目の応答は「Your name is Simon.」でした。1 回目のやり取りを覚えたうえで答えています。
第 1 回で見たとおり、同じセッション ID は同じコンテナに届きます。BFF は受け取ったセッション ID を渡すだけで履歴を持ちません。会話の文脈は、Strands の Agent オブジェクトがコンテナの中で保持しているメッセージ履歴です。そのコンテナが停止してセッションが Stopped になれば、この文脈も失われます。セッションをまたいで会話を残す仕組みは第 5 回の AgentCore Memory で足します。
リソースを削除する
今回作った ALB、NAT ゲートウェイ、Fargate タスクは、削除するまで時間単位で費用が積み上がります。検証が終わったら削除します。
削除は scripts/bff-teardown.sh にまとめてあります。BFF スタック、ネットワークスタックの順に削除します。ネットワークスタックを先に消そうとすると、BFF の ECS タスクが持つ ENI がサブネットを掴んだままで失敗するためです。
第 1 回の Runtime と ECR は残したままにしています。Runtime はセッションが停止すれば課金が止まり、ECR はイメージのストレージ課金だけで額が小さいためです。
まとめ
BFF が Runtime のストリーミング応答を SSE のまま中継し、ALB 経由で HTTP からエージェントと会話できるようになりました。まだブラウザ向けの画面はありません。
次回は SPA を S3 と CloudFront で配信し、この BFF をブラウザから呼んで、ストリーミングされた文字が画面に流れていく様子を作ります。
最後までお読みいただきありがとうございました。









