
Strands Agentsでマルチエージェント(Graph)のイベントをストリーミング表示する
はじめに
Strands Agentsのマルチエージェントパターンの一つであるGraphではマルチエージェントのイベントをストリーミングできます。
本記事のソースコードは以下にありますので適宜参照してください。
動作概要
Strands AgentsのGraph Multi-Agent Patternの構成は以下の通りです。今回は観点ごとに並列でAgentが回答を作成し、その後回答を集約するシンプルなパターンです。
動作している様子は以下の通りです。
各観点のAgentが並列に文章生成を開始します。

最後にまとめ用のAgentが統合します。

すべての結果は以下のような形です。

構成
UIは表現力の高いReactで書きつつ、手軽にローカルデバッグとデプロイ[1]ができて、認証やWAFも対応可能な[2]構成にしました。Bedrock従量課金分を除けば費用感も安く抑えられると思います。ただ毎回オリジンからSPAを取得するのでバンドルサイズが重いとつらくなります。[3]

Lambdaソース(SPA及びServer)に修正が入っても、hotswapありで大体15秒程度でビルドとデプロイが完了します。
$ time bun run deploy:hotswap
$ turbo build --filter=!iac && bun --filter iac deploy:hotswap
╭──────────────────────────────────────────────────────────────────────────╮
│ │
│ Update available v2.7.6 ≫ v2.8.0 │
│ Changelog: https://github.com/vercel/turborepo/releases/tag/v2.8.0 │
│ Run "bunx @turbo/codemod@latest update" to update │
│ │
│ Follow @turborepo for updates: https://x.com/turborepo │
╰──────────────────────────────────────────────────────────────────────────╯
• turbo 2.7.6
• Packages in scope: agent, server, spa
• Running build in 3 packages
• Remote caching disabled, using shared worktree cache
spa:build: cache hit, replaying logs 5c70423ebfcc300a
spa:build: $ vite build
spa:build: vite v6.4.1 building for production...
spa:build: transforming...
spa:build: ✓ 277 modules transformed.
spa:build: rendering chunks...
spa:build: computing gzip size...
spa:build: ../server/dist/spa/index.html 0.33 kB │ gzip: 0.24 kB
spa:build: ../server/dist/spa/assets/index-DMGb393i.js 404.39 kB │ gzip: 126.93 kB
spa:build: ✓ built in 665ms
server:build: cache hit, replaying logs c06fc42816fe9c76
server:build: $ bun build ./src/index.ts --compile --target=bun-linux-arm64 --outfile ./dist/bootstrap
server:build: [113ms] bundle 247 modules
server:build: [5ms] compile ./dist/bootstrap bun-linux-aarch64-v1.3.8
Tasks: 2 successful, 2 total
Cached: 2 cached, 2 total
Time: 172ms >>> FULL TURBO
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
iac deploy:hotswap $ cdk deploy --hotswap -c stageName=dev d-acp-main --require-approval never
│ [47 lines elided]
│ opt-in/out. You can also view the telemetry we collect by
│ logging it to a local file, by adding
│ `--telemetry-file=my/local/file` to any `cdk` command.
│
│ Affected versions: cli: ^2.1100.0
│
│ More information at: https://github.com/aws/aws-cdk/issues/34892
│
│
│ If you don’t want to see a notice anymore, use "cdk acknowledge <id>". For example, "cdk acknowledge 34892".
└─ Done in 14.95 s
bun run deploy:hotswap 3.34s user 0.63s system 24% cpu 15.875 total
それぞれ詳しく構成を解説します。
Lambda
- Hono
/chatへのリクエストはAgentCore Runtimeへルーティング- 前項以外のリクエスト(*)は、SPAへルーティング
- デプロイするソースはSPAとサーバー(Hono)の両方をバンドル
- Bun自身のランタイムを埋め込んでシングルバイナリ化し、AL 2023 Runtimeへデプロイ
- Lambdaに依存せず、かつローカルで動作するように Lambda Web AdapterをLayerとして利用
- Lambda Web Adapterを利用しているので、ストリーム処理がローカルで動作し(
hono/aws-lambdaのstreamHandleや ランタイム組み込みのawslambda.HttpResponseStream()は ローカルで起動できないため)、Layerで追加しているのでコンテナビルドは必要なく高速にデプロイされます。手軽に検証したい場合に便利です。
- Lambda Web Adapterを利用しているので、ストリーム処理がローカルで動作し(
/chat から AgentCore Runtimeへデータを流す箇所は以下の通りです。
以下のようなコマンドでBunを使ってHonoのサーバーをBunランタイムをEmbedした形でシングルバイナリにコンパイルします。
SPA側(apps/spa/)のViteからサーバー側(apps/server/dist)へバンドルしたソースを出力します。できればSPAもembeddedFilesでEmbedしたかったのですが、別の機会に挑戦します😭
コンパイルされたシングルバイナリは、Lambda環境では同じディレクトリ階層にSPAがあるため参照して配信します。
作成された apps/server/dist をCDKでLambda Web AdapterをLayerに設定したPROVIDED_AL2023ランタイムのLambdaへデプロイします。Lambda Web Adapterを利用していますが、コンテナLambdaではないので早くデプロイできます。
Amazon API Gateway
Amazon API Gatewayで15分間ストリーミングすることが最近できるようになったため、REST APIで構築しています。
ただAmazon API GatewayのREST APIはカスタムドメインでない場合、basePathをつける必要があります。この制約のためアプリ側のSPAのルーティングを変える必要があります。差分としては以下でサンプルリポジトリにはすでに設定が入っています。
Amazon API GatewayのREST APIにおけるデフォルトbasePathのケア対応
diff --git a/apps/spa/src/components/Chat.tsx b/apps/spa/src/components/Chat.tsx
--- a/apps/spa/src/components/Chat.tsx
+++ b/apps/spa/src/components/Chat.tsx
@@ -56,7 +56,7 @@ export function Chat() {
try {
abortControllerRef.current = new AbortController();
- const response = await fetch('/api/chat', {
+ const response = await fetch('./api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diff --git a/apps/spa/src/main.tsx b/apps/spa/src/main.tsx
--- a/apps/spa/src/main.tsx
+++ b/apps/spa/src/main.tsx
@@ -4,7 +4,17 @@ import { createRoot } from 'react-dom/client';
import { routeTree } from './routes/__root';
-const router = createRouter({ routeTree });
+// API Gateway stage prefix detection (e.g., /prod/, /dev/)
+const getBasePath = () => {
+ const path = window.location.pathname;
+ const match = path.match(/^\/[^/]+\//);
+ return match ? match[0].slice(0, -1) : '';
+};
+
+const router = createRouter({
+ routeTree,
+ basepath: getBasePath(),
+});
declare module '@tanstack/react-router' {
interface Register {
diff --git a/apps/spa/vite.config.ts b/apps/spa/vite.config.ts
--- a/apps/spa/vite.config.ts
+++ b/apps/spa/vite.config.ts
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
+ base: './',
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, './src'),
Amazon API Gatewayのストリーミング設定部分は以下の箇所です。気づいたらL2でシンプルに書けるようになっていますね 😆
AgentCore Runtime(Strands Agents)
冒頭でも説明したように今回はストリーミング部分が肝なので簡易な構成にします。
ソースで該当する部分は以下の通りです。
ビルド&デプロイ
ソースコードには以下のような設定が入っています。
bun run deploy
動作
ストリーミングの様子は冒頭の動画をご確認いただくとして、ここではcurlで取得したレスポンスストリームの内容を確認します。レスポンスの構造を見ると、マルチエージェントシステムが並列でストリーミングしていることがわかります。
ストリーミングのイベントの種類は以下の通りです。
| イベント名 | 説明 | フィールド |
|---|---|---|
multiagent_node_start |
ノードが実行を開始したとき | type: "multiagent_node_start"node_id: ノードの一意識別子node_type: ノードの種類 ("agent", "swarm", "graph") |
multiagent_node_stream |
エージェント/マルチエージェントからのイベントをノードコンテキスト付きで転送 | type: "multiagent_node_stream"node_id: イベントを生成したノードの識別子event: 元のエージェントイベント(ネスト) |
multiagent_node_stop |
ノードが実行を完了したとき | type: "multiagent_node_stop"node_id: ノードの一意識別子node_result: 実行詳細、メトリクス、ステータスを含む完全なNodeResult |
multiagent_handoff |
エージェント間の制御移譲(Swarm)またはバッチ遷移(Graph)時 | type: "multiagent_handoff"from_node_ids: 実行を完了したノードIDのリストto_node_ids: 実行を開始するノードIDのリストmessage: オプションのハンドオフメッセージ(主にSwarmで使用) |
multiagent_result |
マルチエージェントの最終結果 | type: "multiagent_result"result: 最終的なGraphResultまたはSwarmResult |
こちらをAgentCore側で判定して、変換してLambda → Amazon API Gatewayへ返却します。
-
- 3つの専門ノードが並列起動
- ethics - 倫理的観点
- tech - 技術的観点
- social - 社会的観点
-
- 各ノードがストリーミングで回答を生成
- node_stream イベントでテキストが断片的に送られてくる
- Unicode エスケープ(\u30e4\u30f3 -> ヤン)はJSON形式の正常な表現
-
- 最後に writer ノードが統合
- 3つの視点を統合した最終回答を生成
-
- 完了
- complete イベントで Status.COMPLETED
$ curl -X POST "https://2kiom891rc.execute-api.ap-northeast-1.amazonaws.com/prod/api/chat" \
-H "Content-Type: application/json" \
-d '{"message": "ヤン・ルカンとデミス・ハサビスは、AGI実現へのアプローチの違いを説明してください", "sessionId": "550e8400-e29b-41d4-a716-446655440000"}'
# 1. 3つの専門ノードが並列起動
data: "{\"event\": \"node_start\", \"node_id\": \"ethics\"}"
data: "{\"event\": \"node_start\", \"node_id\": \"tech\"}"
data: "{\"event\": \"node_start\", \"node_id\": \"social\"}"
# 2. 各ノードが並列でストリーミング(テキストはUnicodeエスケープ形式)
data: "{\"event\": \"node_stream\", \"node_id\": \"ethics\", \"text\": \"\\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"tech\", \"text\": \"\\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"social\", \"text\": \"##\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"ethics\", \"text\": \"\\u30f3\\u3068\\u30c7\\u30df\\u30b9\\u30fb\\u30cf\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"tech\", \"text\": \"\\u30f3\\u3068\\u30c7\\u30df\\u30b9\\u30fb\\u30cf\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"social\", \"text\": \" \\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\"}"
# ... 各ノードのストリーミングが並列で継続(約1700行省略)...
# 3. 各ノードが完了(node_stopにはcontentフィールドで生成結果全体が含まれる)
data: "{\"event\": \"node_stop\", \"node_id\": \"tech\", \"content\": \"\\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\\u30f3\\u3068...(省略)\"}"
data: "{\"event\": \"node_stop\", \"node_id\": \"social\", \"content\": \"## \\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\\u30f3\\u3068...(省略)\"}"
data: "{\"event\": \"node_stop\", \"node_id\": \"ethics\", \"content\": \"\\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\\u30f3\\u3068...(省略)\"}"
# 4. writerノードが統合処理を開始
data: "{\"event\": \"node_start\", \"node_id\": \"writer\"}"
# 5. writerノードがストリーミング
data: "{\"event\": \"node_stream\", \"node_id\": \"writer\", \"text\": \"#\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"writer\", \"text\": \" \"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"writer\", \"text\": \"\\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\\u30f3\"}"
data: "{\"event\": \"node_stream\", \"node_id\": \"writer\", \"text\": \"\\u3068\\u30c7\\u30df\\u30b9\\u30fb\\u30cf\\u30b5\"}"
# ... writerノードのストリーミングが継続(約700行省略)...
# 6. writerノードが完了
data: "{\"event\": \"node_stop\", \"node_id\": \"writer\", \"content\": \"# \\u30e4\\u30f3\\u30fb\\u30eb\\u30ab\\u30f3\\u3068\\u30c7\\u30df\\u30b9...(省略)\"}"
# 7. 処理完了
data: "{\"event\": \"complete\", \"status\": \"Status.COMPLETED\"}"
さいごに
マルチエージェントで各エージェントの動きをストリーミングで閲覧できるのは、説明可能性や透明性、制御性の観点で良いアプローチといえます。またTTFT(Time to First Token)が短くなるため、UI/UXの観点でも有効です。今回のインフラ構成は非常に高速にデプロイできるため、Claude CodeとClaude in Chrome、AWS CLIを握らせてアプリ改善のAIフィードバックループを回すのにも良い構成と思います!
余談ですが、AIハッカソン的なものが社内で最近あり、Strandsのドキュメントを作業リポジトリに突っ込んでいたらClaude Codeがこの機能を見つけて勝手に実装していたと知ったといういい話があります。








