AIボイスエージェントをWebSocketからWebRTCに切り替える — 何が壊れて、何を学んだか
数週間前、Darryl Rugglesのブログ記事と、Strands BidiAgentおよびAmazon Nova Sonic v2で構築した双方向の音声エージェントの付随リポジトリに出会いました。彼の取り組みは非常によく整理されていて、私のノートPC上で10分ほどで動く音声アシスタントを起動できました。このエージェントは、あなたの声を聞き、レシピの知識ベースを検索し、調理タイマーを設定し、栄養データを調べ、単位を変換します。すべて自然な会話を通じて行われます。
Darrylのバージョンでは、ブラウザとエージェントの間のトランスポートとしてWebSocketを使っています。うまく動きますが、さらに踏み込みたくなりました。トランスポートをWebRTCに切り替え、さらに全体をBedrock AgentCore Runtimeにデプロイします。この投稿では、その道のりで「何が変わって、何が壊れて、途中で何を学んだか」を扱います。
しかし先に、短いデモ!
完全なソースコードはGitHubで公開されています。このリポジトリはTerraformでエンドツーエンド管理されていますが、Terraformを周辺インフラには使い続け、エージェントのデプロイにはCLI呼び出しを行うようにDarrrylのMakefileのアプローチを好む場合でも、その方法は引き続き利用できます。
なぜ音声エージェントにWebRTCなのか
WebSocketのバージョンは動くのに、なぜ変えるのか?いくつかの理由が私をWebRTCへ押し進めました。
まずレイテンシです。WebSocketはTCPの上で動くため、すべてのパケットは順番どおりに必ず到達します。チャットメッセージにはそれが素晴らしいのですが、リアルタイムの音声では、1つでもパケットが落ちると、TCPが再送するまでストリーム全体が停止してしまいます。WebRTC1は内部的にUDPを使います。パケットが失われても、ストリームは止まらずに流れ続けます。音声会話では、目に見える一時停止よりも、小さな途切れの方がはるかに許容しやすいのです。
次に、ブラウザ側でより多くの処理を担えることです。WebSocketでは、getUserMediaでマイク音声をキャプチャし、ScriptProcessorNodeで16kHzにダウンサンプルし、base64 PCMとしてエンコードして、JSONメッセージとして送信する必要がありました。再生側では、受信する音声ストリームを扱うために、リングバッファを備えたAudioWorkletが必要でした。WebRTCでは、RTCPeerConnectionを通じて、ブラウザが音声キャプチャ、エンコード(Opus)、再生をネイティブに処理します。その結果、フロントエンドのコードが大幅にシンプルになりました。
3つ目に、WebRTCはビデオに関して将来性があります。AIアバターは許容できる遅延で実現されつつあり、WebRTCは音声トラックと同じくらい自然にビデオトラックも扱えます。後からビデオストリームを追加するのは、既存のピア接続にトラックを追加するだけで済み、アーキテクチャの変更は不要です。
WebRTCのアーキテクチャを素早くおさらい
WebRTCには、根本的に異なる2つの使い方があり、音声エージェントを作る際にはその選択が重要になります。
ピアツーピア(P2P)
P2P WebRTCでは、2つのピアが互いに直接接続します。間にメディアサーバはありません。音声はブラウザからエージェントへ、そして返ってくる形でそのまま流れます。片方または両方のピアがNAT3の背後にいる場合(本番ではほぼ確実にそうです。クライアントはインターネットルータの背後にあり、エージェント側は社内ツールにアクセスするためにプライベートVPCにいる必要があるため)、TURN2リレーサーバが必要になることがあります。ただしTURNサーバはパケットを中身まで検査・処理せず、単に転送するだけです。
ルームベース(SFU)
ルームベースのアーキテクチャでは、メディアサーバ(SFU4 — Selective Forwarding Unit)が中央に配置されます。参加者は互いではなくサーバに接続します。サーバは各参加者から音声/ビデオトラックを受け取り、それらを選択的に他の参加者へ転送します。LiveKit、Amazon Chime SDK、Dailyは、SFUベースのプラットフォームの例です。
1:1の音声エージェントであれば、P2Pの方がシンプルで、メディアサーバの運用(または利用料の支払い)にかかるコストと複雑性を回避できます。私は、管理されたTURNリレーとしてAmazon Kinesis Video Streams(KVS)を使うP2Pを選びました。これはAgentCore上のWebRTCに関するドキュメント化されたアプローチです。
ルームベースの解決策も検討しましたが、各SFUプラットフォームには独自のSDKが必要です。標準のRTCPeerConnectionで単に接続するだけでは済みません。AWS自身のWebRTC提供であるAmazon Chime SDKは機能が豊富です(文字起こし、録画、分析など)し、LiveKitやDailyのような代替よりも大幅に安価です。ただし、まだサーバサイドでのエージェント〜ルーム通信のための整備された道筋(「舗装された」パス)を提供していません。これはぜひ見てみたい機能です。Chime SDKの残りの部分がかなり魅力的であるだけに、なおさらです。現時点では、KVS TURNを使ったP2Pが最もストレートな道でした。ルーム内WebRTCはぜひ検討しますが、その話は別の投稿にします。
WebRTCスタック:ブラウザとサーバ
ブラウザ側では、WebRTCは組み込みです。RTCPeerConnection APIは、すべてのモダンブラウザ(Chrome、Safari、Firefox、Edge)でネイティブに利用できます。ピア接続を作成し、getUserMediaでマイクトラックを追加すると、ブラウザが音声エンコード(Opus)、ICE候補の収集、DTLS暗号化を処理します。ライブラリは不要です。
サーバ側は話が変わります。WebRTCはブラウザ向けに設計されていて、Pythonバックエンド用ではありません。PythonでのサーバサイドWebRTCの定番ライブラリはaiortcです。これはasyncioベースのWebRTCおよびORTCの実装です。ピア接続、ICEネゴシエーション、メディアトラックを扱い、音声/ビデオフレーム処理にはPyAV(FFmpegのバインディング)を使用します。ブラウザWebRTCほど戦場で鍛えられてはいませんが、よく動いており、AWSのサンプルコードでも同じものが使われています。
アーキテクチャ:ローカル開発 vs デプロイ
ダリルの当初の設計から維持したかったことの1つは、クラウドインフラなしで開発用にすべてローカルで実行できることです。WebRTCへの移行では、この要件は保たれています。
ローカルモード
ローカルモードでは、エージェントはあなたのマシン上で動作します。ブラウザとエージェントは同じネットワーク(または同じマシン)にあるため、TURNリレーを必要とせずにWebRTCはピアツーピアで接続します。シグナリング — SDP5のオファー/アンサーや、ICE6候補の交換 — は、Vite開発サーバのプロキシを通じてローカルのFastAPIサーバへ送られます。
デプロイ済みモード
デプロイ済みモードでは、エージェントは Bedrock AgentCore Runtime 上で Docker コンテナ内に起動され、プライベートサブネットの elastic network interface(ENI)を介して VPC に接続されます。ブラウザはエージェントに直接到達できません。メディア通信はすべて KVS TURN リレーを経由します。シグナリングは AgentCore の /invocations HTTP エンドポイントを通じて行われ、@aws-sdk/client-bedrock-agentcore SDK 経由で SigV4 により認証されます。
以下の図は、AWSドキュメント からのもので、ネットワークの観点でどう動くかを示しています。シグナリングは AgentCore の HTTP エンドポイントを通り、メディア通信は VPC の NAT ゲートウェイを通って KVS TURN リレーへ流れます。
重要なのは、エージェントのコードがローカルモードとデプロイ済みモードの間でほぼ同一だという点です。BidiAgent、BidiNovaSonicModel、そして4つのツール(レシピ検索、タイマー、栄養情報の参照、単位変換)は完全に変更されていません。唯一の違いはトランスポート層です。ローカルモードでは aiortc が P2P 接続し、デプロイ済みモードでは KVS TURN を経由して接続します。エージェントは CONTAINER_ENV 環境変数から自分がどのモードにいるかを検出し、それに応じて ICE サーバーを設定します。
このような明確な分離が可能だったのは、Strands の BidiInput/BidiOutput プロトコルのおかげです。私は aiortc のオーディオトラックを、BidiAgent が期待するイベント形式へ橋渡しするための小さなアダプタクラスを2つ書きました。WebRTCBidiInput と WebRTCBidiOutput です。エージェントは、オーディオが WebSocket から来ているのか WebRTC のトラックから来ているのかを知る必要も関心もありません。
Bedrock AgentCore の WebRTC サポートが追加するもの
2026年3月20日、AWS は AgentCore Runtime に対する WebRTC サポート を 発表 しました。
自信は100%ではありませんし、必要なら訂正しますが、私の印象では、構成要素――VPC ネットワークモード、KVS TURN、/invocations HTTP エンドポイント――は、この発表の前からすでに存在していました。VPC ネットワークモードは、2025年10月の AgentCore の一般提供(GA)以降利用可能です。KVS TURN は、長年使われてきた Kinesis Video Streams の機能です。そして /invocations は、これまでも AgentCore ランタイムの標準的な HTTP エンドポイントでした。
3月20日のリリースが追加するものとして、私が把握している限りの話ですが、公式ドキュメント、動作するサンプルコード、そして WebRTC が AgentCore Runtime でサポートされるプロトコルであることを明示した点です。それ以前は、技術的には同じ部品を自分で組み立てることもできましたが、あなたが一人でやる必要がありました――ドキュメントもサンプルもなく、動き続ける保証もありません。
AgentCore が提供している価値は本当に大きいです。自動スケーリング付きのマネージドなコンテナホスティング、同時利用者間でのセッション分離、組み込みの可観測性(CloudWatch ログ、X-Ray トレース)、そして VPC 以外に管理すべきインフラがないことです。私は ECS をセットアップしたり、ロードバランサを設定したり、コンテナオーケストレーションを管理したりする必要がありませんでした。
とはいえ、そこにはかなりの量のカスタムコードが関わります。WebRTC シグナリング(SDP の交換、ICE candidate の管理)、aiortc のピア接続のライフサイクル、BidiAgent へのオーディオトラックのブリッジ、そして KVS TURN の資格情報の管理――これらはすべてアプリケーションコードで、私が書きました。AgentCore はそれをホストして実行しますが、抽象化して隠してはくれません。
課題と学び
WebSocket から WebRTC への移行は、最初はスムーズでした(ローカルモードは最初の試みで動きました!)。しかしその後はあまりスムーズではありませんでした。Bedrock AgentCore 上で動かそうとしたためです。そこでつまずいた内容をまとめます。
VPC の可用性ゾーン互換性
AgentCore Runtime は特定の可用性ゾーンのみをサポートします。us-east-1 では、use1-az4(us-east-1a)、use1-az1(us-east-1c)、use1-az2(us-east-1d)のみがサポートされます。私は最初、Terraform に最初の2つの AZ を自動で選ばせました。その結果 us-east-1a と us-east-1b になりました。ランタイムの更新は、判読しにくい UPDATE_FAILED のステータスで失敗しました。実際のエラーメッセージ――サポートされていない AZ に言及している部分――は、Terraform のエラーとして表に出ず、API レスポンスの failureReason フィールドの中に埋もれていました。最終的に、VPC モジュールでサポートされている AZ をハードコードすることにしました。
セッションアフィニティ
これには何時間も費やしました。WebRTC シグナリングは複数ステップのハンドシェイクで、ブラウザとエージェントは接続を確立するために複数のメッセージを交換します。エージェントは、2通目と3通目を処理するときに、最初のメッセージから接続状態を覚えておく必要があります。それらのメッセージが別々のサーバーインスタンスに届いてしまうと、エージェントは進行中のハンドシェイクを覚えておらず、接続は失敗します。
私は最初、セッション ID をクエリパラメータとして含めればルーティングのアフィニティが得られると考え、生の SigV4 署名付き HTTP POST リクエストを使いました。しかしそれはできませんでした。ICE candidate は、ピア接続を保持しているものとは別のコンテナインスタンスに着地していました(?)。
修正は、@aws-sdk/client-bedrock-agentcore SDK を InvokeAgentRuntimeCommand と runtimeSessionId パラメータとともに使うことでした。これが WebRTC セッションに対するすべてのリクエストが同じコンテナインスタンスに到達することを保証する唯一の確実な方法です。AWS のサンプルコードでもこのパターンを使っています。私は最初、それが目に入っていなかったのですが、WebRTC の部分に集中していたためです。
SDP candidate のフィルタリング
エージェントが VPC 内でピア接続を作成すると、aiortc は利用可能なすべてのネットワークインターフェースについて ICE candidate を生成します。169.254.0.2 のような VPC 内部 IP も含まれます。これらのホスト candidate は、ブラウザに送られる SDP の回答に含まれてしまいます。ブラウザはまじめにそれらに接続を試みますが失敗します(公開インターネットから到達できないため)。その後にようやくリレー candidate へフォールバックします。これにより接続時間が数秒追加されます。
解決策は単純です。SDP の回答を返す前に、リレー以外の candidate を取り除きます。デプロイ済みモードで動作しうるのは TURN リレー candidate だけなので、他のものを含める理由はありません。
TURN のみモード
SDPのフィルタリング問題と同様に、エージェントの aiortc インスタンスはデフォルトでリレー候補の前にホスト候補を試そうとします。ホスト候補は VPC 内部の IP を使いますが、ブラウザの観点からは決して機能しないため、この手順は時間の無駄になります。aiortc を TURN リレー候補のみを使用するように設定(turn_only=True)すると、実際に機能する候補へ直接スキップできます。
遅延 KVS 初期化
最初は、if IS_CONTAINER チェックでガードして、モジュールの import 時に kvs.init() を呼び出していました。ローカルではうまく動きましたが、AgentCore ではコンテナがクラッシュしました。シグナリングチャネルを見つけるか作成するための KVS API 呼び出しには AWS の認証情報が必要で、コンテナ起動中は IAM ロールの認証情報が利用可能になるまで短い遅延が発生することがあります。初期化を最初の実際のリクエスト(遅延 init)に移したところ、クラッシュが解消されました。
コールドスタート挙動
コンテナがしばらくアイドル状態になった後、最初の WebRTC 接続の試行が時々失敗します。シグナリングリクエストは成功します(AgentCore が 200 を返す)のに、ICE 接続は完了しません。これは、AgentCore が新しいコンテナインスタンスを起動していることに関連しているのではないかと考えています。最初の数回のリクエストは、十分にウォームアップされていないインスタンスで処理される可能性があります。エージェント側では、同一コンテナ内のすべてのリクエストが同じプロセス(したがって同じメモリ上のピア接続状態)を叩くように、uvicorn のコマンドで --workers 1 を明示的に設定しました。フロントエンド側では、リトライ機構を追加しました。ICE が「connected」状態に到達するのを待ち、それが 10 秒以内に達しない場合は、いったん破棄して新しいセッション ID で再試行します。これらにより、接続が確実になりました。
重要なコード
すべてのファイルを説明はしませんが、WebRTC の統合を成立させている要素をいくつか紹介します。
WebRTCBidiInput アダプタは aiortc のトラックから音声フレームを読み取り、16kHz にリサンプルし、それを BidiAgent が理解する bidi_audio_input イベントとして返します:
class WebRTCBidiInput:
def __init__(self, track):
self._track = track
async def __call__(self):
try:
frame = await self._track.recv()
except MediaStreamError:
raise StopAsyncIteration
resampled = _resampler.resample(frame)
pcm = b"".join(f.planes[0] for f in resampled)
return {
"type": "bidi_audio_input",
"audio": base64.b64encode(pcm).decode("utf-8"),
"sample_rate": 16000,
}
WebRTCBidiOutput アダプタは逆のことを行います。BidiAgent からイベントを受け取り、それを aiortc の出力トラックへ音声としてプッシュします:
class WebRTCBidiOutput:
def __init__(self, output_track):
self._output_track = output_track
async def __call__(self, event):
if event.get("type") == "bidi_audio_stream":
audio_bytes = base64.b64decode(event["audio"])
self._output_track.add_audio(audio_bytes)
elif event.get("type") == "bidi_interruption":
self._output_track.clear()
フロントエンドでは、useWebRTCSession フックがシグナリングに AgentCore SDK を使用します:
const invoke = async (action, data = {}) => {
const client = new BedrockAgentCoreClient({ region, credentials });
const resp = await client.send(new InvokeAgentRuntimeCommand({
agentRuntimeArn,
runtimeSessionId: sessionId, // セッションのアフィニティを保証します
contentType: 'application/json',
payload: new TextEncoder().encode(JSON.stringify({ action, data })),
}));
return JSON.parse(new TextDecoder().decode(
await resp.response.transformToByteArray()
));
};
完全なソースは repo にあります。ローカル専用のバージョンは feat/webrtc ブランチで、Terraform を使ってデプロイした完全版は feat/webrtc-agentcore ブランチです。
開発ツール
このプロジェクトは、Amazon の AI 開発アシスタントである Kiro CLI を使って構築しました。計画、コード生成、デバッグ、反復的なデプロイを処理してくれました。この記事で説明している WebRTC 設定に関する多数の試行錯誤も含まれています。コードを書き、デプロイし、ログを確認し、問題を修正するという往復作業は、AI ペアプログラミングのワークフローに自然に合っていました。
自分で試してみる
ローカルで実行するには:
git clone https://github.com/psantus/strands-bidir-nova.git
cd strands-bidir-nova
git checkout feat/webrtc
uv sync && make install-frontend
# ターミナル 1:
make serve
# ターミナル 2:
make serve-frontend
http://localhost:5173 を開き、マイクをクリックして話し始めてください。
AgentCore 上でデプロイしたバージョンについては、feat/webrtc-agentcore ブランチを確認し、README に従ってください。いくつかのレシピを持つ Bedrock Knowledge Base、Cognito のユーザープール、そしてコンテナイメージをビルドするための Docker が必要です。残りは単一の terraform apply が処理します。
まずは WebSocket バージョンから始めたい場合は、Darryl Ruggles による 元の投稿 が出発点です。
Paul Santus は TerraCloud の独立系クラウドコンサルタントです。AWS 上で AI を活用したアプリケーションを構築・デプロイするための支援を行っています。LinkedIn で彼とつながってください。
-
WebRTC(Web Real-Time Communication)— UDP ベースのトランスポートを使用して、ブラウザとデバイスの間でリアルタイムの音声・映像・データ通信を直接行うためのオープン標準。 ↩
-
TURN(NAT の周りのリレーを使ったトラバーサル)— 2 つのピアが直接接続できない場合に、メディアトラフィックを中継するリレーサーバです。両側が自分の音声を TURN サーバに送信し、TURN サーバがそれを相手側へ中継します。 ↩
-
NAT(Network Address Translation)— プライベート IP アドレスをパブリックなものへ対応付けるネットワーキングの仕組みです。ほとんどの家庭用ルータやクラウドの VPC は NAT を使用しており、これにより直接的な着信接続ができなくなります。 ↩
-
SFU(Selective Forwarding Unit)— 参加者から音声/映像トラックを受け取り、それらを混合やトランスコードなしで選択的に他の参加者へ転送するメディアサーバです。LiveKit、Chime SDK、Daily などで使用されます。 ↩
-
SDP(Session Description Protocol)— コーデック、トランスポートのアドレス、メディアの種類などを記述するテキスト形式の、マルチメディアセッションの説明です。WebRTC では、ピア同士が SDP の「オファー」と「アンサー」を交換して接続をネゴシエートします。 ↩
-
ICE(Interactive Connectivity Establishment)— 2 つのピア間で最適なネットワーク経路を見つけるためのプロトコルです。候補アドレス(ローカル、サーバ反射、リレー)を収集し、それらの間で接続性をテストします。 ↩





![[Boost]](/_next/image?url=https%3A%2F%2Fmedia2.dev.to%2Fdynamic%2Fimage%2Fwidth%3D800%252Cheight%3D%252Cfit%3Dscale-down%252Cgravity%3Dauto%252Cformat%3Dauto%2Fhttps%253A%252F%252Fdev-to-uploads.s3.amazonaws.com%252Fuploads%252Fuser%252Fprofile_image%252F3618325%252F470cf6d0-e54c-4ddf-8d83-e3db9f829f2b.jpg&w=3840&q=75)