本記事では、Amazon Bedrock AgentCore Runtime 上のチャットボットで「長い応答が必ず途中で切れる」という問題にハマり、最終的に原因が レスポンスペイロードの 100MB 上限だった、という話を共有します。最初は「ストリーミングの時間制限かな?」と思い込んでかなり遠回りしたので、同じところで悩む方の参考になれば幸いです。
以前、GitHub 公式 MCP サーバを AgentCore Runtime に載せた記事を書きましたが、今回はそれを使った「GitHub の Issue を要約するチャットボット」を作っている中で踏んだ問題です。
TL;DR
- 長い応答のとき、ブラウザが
network error/ERR_HTTP2_PROTOCOL_ERROR 200 (OK)で落ちる。約60〜90秒で切れるので、最初は 「ストリーミングの時間制限」だと誤解した。 - いろいろ原因を疑って調べたが、どれもハズレ。**決め手は「受信バイト数が毎回 100 MiB の直前で一致」**したこと。時間はバラつくのに、サイズは一定だった。
- 真因は、公式ドキュメントに明記された 「Maximum payload size: 100 MB(request/response、調整不可)」。約60秒は「100MB が溜まるまでの時間」だっただけ。
- なぜそんなに膨らむのか? → テンプレート標準のストリーミングが、エージェントの「全イベント」を無加工で中継しており、1トークンごとの増分に会話状態(履歴・ツール結果の生JSON・全ツール定義)を毎回丸ごと同梱していたため。
- 対策は (1) 許可リスト方式(クライアントが使うフィールドだけ送る) で 100MB → 数百KB に激減。さらに (2) ツール結果のソース削減 でモデル入力を軽くし、LLM 利用料金の削減にも寄与。
前提: リージョンは
ap-northeast-1、AWSアカウントは伏せ字(123456789012)で記載します。フロントは React、エージェントは Python(Strands)、IaC は AWS CDK(TypeScript) という構成で、AWS Labs の FAST テンプレートをベースにしています。GitHub へのアクセスは公式 GitHub MCP サーバ経由(読み取り専用)です。
症状
「今月更新した Issue を全件要約して、所定のフォーマットを更新して」のような、GitHub を何度も叩いて長文を生成するプロンプトを投げると、応答の途中で必ず接続が切れます。
- ブラウザ(DevTools):
net::ERR_HTTP2_PROTOCOL_ERROR 200 (OK) - サーバから
curlで直叩きしても再現:- HTTP/2:
HTTP/2 stream was not closed cleanly: INTERNAL_ERROR - HTTP/1.1:
transfer closed with outstanding read data remaining
- HTTP/2:
いずれも HTTP 200 で正常に始まったあと、送出中に切られる(アプリ側のエラー応答は無し)。そして毎回 だいたい60〜90秒で切れます。この「時間っぽさ」に、まんまと引っ張られました。
原因調査:いろいろ疑ったが、どれもハズレ
最初は「AgentCore Runtime のストリーミング時間制限では?」と考えました。ところが公式ドキュメントの Quotas を見ると 「Streaming maximum duration: 60 mins」 とあり、観測している60〜90秒とはまるで桁が違う。矛盾していて混乱しました。
そこから、考えられる原因を一通り疑って調査しました。ネットワーク経路(社内プロキシ)・API Gateway などの中間コンポーネント・アプリのコード・HTTP プロトコル(HTTP/2 と HTTP/1.1)などを確認しましたが、どれもハズレでした(詳細な調査内容は割愛)。
転機は、切れたときの受信バイト数を見比べたことです。curl の -w "%{size_download}" で測ると、こうなっていました。
| 実行 | プロトコル | 受信バイト数 | 経過時間 |
|---|---|---|---|
| 1 | HTTP/2 | 104,856,998 | 63.6s |
| 2 | HTTP/2 | 104,850,935 | 56.5s |
| 3 | HTTP/1.1 | 104,855,941 | 61.2s |
時間(56〜63秒)はバラつくのに、受信バイト数は3回ともほぼ同じ。しかも、その値は 100 MiB = 104,857,600 バイトの、ほんの数KB手前。ここで「これは時間じゃない。サイズだ」と気づきました。
真因:レスポンスペイロードの100MB上限
公式 Quotas の AgentCore Runtime → Invocation limits を見ると、はっきり書いてありました。
| Limit | Value | Adjustable | Notes |
|---|---|---|---|
| Maximum payload size | 100 MB | No | Maximum size for request/response payloads |
| Streaming chunk size | 10 MB | No | 1チャンクの最大 |
| Streaming maximum duration | 60 mins | No | (時間。今回は無関係) |
ポイントは 「request だけでなく response にも 100MB 上限が適用される(調整不可)」こと。レスポンスのストリームが累積で約100MBに達した時点で、接続が(クリーンな終端ではなく)強制的に切られていたわけです。「約60秒で切れる」は、100MBが溜まるのにそれだけかかっていただけでした。時間制限という思い込みが、完全にミスリードでした。
なぜ、たかが要約で100MBも流れるのか
ここが一番の驚きでした。最終的な要約文はせいぜい数KBなのに、ストリーム全体は約100MB。中身を解析して分かったのは、次の構造です。
そもそも AgentCore Runtime がブラウザに流しているのは、最終結果だけではなく「エージェントの実行イベントの全ストリーム」 です。
[ブラウザ]
▲ ストリーミング(SSE)で「全イベント」を受信
│
[AgentCore Runtime / Strands エージェント]
│ ① ツールを呼ぶ
▼ ② 公式 GitHub MCP が「生JSON全文」を返す
[公式 GitHub MCP] → GitHub
③ モデルが生JSONを読んで要約を生成
そして、テンプレート標準の実装は、Strands が吐くストリーミングイベントを 無加工でそのまま中継していました。問題は、その1イベントの中身です。実データを見ると、1トークン分の増分(=1デルタ)のイベントに、会話状態がまるごと同梱されていました。
1イベント(約320KB)の中身:
data: "ます。" ← 新しく増えた分は わずか数バイト
messages: [...全履歴...] ← ★ツール結果の生JSON(数万字)を含む会話履歴
tool_config: [...全ツール定義...]
system_prompt: "..."
(他、トレース系メタデータ)
つまり、モデルが要約を1トークンずつ生成するたびに、「履歴+生JSON+全ツール定義」約320KBを毎回再送していたのです。
イベント1: 新規"今"(数B) + 会話状態まるごと(約320KB)
イベント2: 新規"月"(数B) + 会話状態まるごと(約320KB) ← また再送
…×数百デルタ ≒ 100MB超
たとえると「本に一文書き足すたびに、本を一冊まるごと送り直している」ような状態です。
さらに拍車をかけていたのが 公式 GitHub MCP が返す生JSONの冗長さです。GitHub の Issue オブジェクトは、要約には不要な *_url 系(labels_url, comments_url, events_url, …)や id/node_id/reactions などのフィールドが大量にあり、1件で数KB〜になります。これがそのまま会話履歴に積まれ、上記の「毎デルタ再送」で増幅されていました。MCP がバグっているわけではなく、REST の生JSONをそのまま返すので、LLM に食わせる用途には過剰、という話です。
対策
(1) 許可リスト方式:クライアントが使うフィールドだけ送る
フロント側のパーサが実際に使っているのは、data(テキスト)・current_tool_use・message・result などごく一部のキーだけで、messages(複数形の全履歴)・tool_config・system_prompt は一切使っていません。なので、**使うキーだけを通す「許可リスト」**にして、残りは送らないようにしました。
# フロントが実際に使うキーだけを通す(許可リスト)
_CLIENT_EVENT_KEYS = (
"data", "delta", "current_tool_use", "message", "result",
"init_event_loop", "start_event_loop", "start",
)
def to_client_event(event: dict) -> dict:
return {k: event[k] for k in _CLIENT_EVENT_KEYS if k in event}
# エントリポイントのストリーミングループ
async for event in agent.stream_async(prompt):
# before: 全フィールドを無加工で送っていた(約320KB/イベント)
# after : 必要なキーだけ(数十バイト/イベント)
yield to_client_event(dict(event))
これだけで、各デルタイベントが約320KB → 数十バイトになり、同じ処理のレスポンスが約100MB → 数百KBに激減。当然、途中で切れることもなくなりました。
(2) ツール結果のソース削減:モデル入力を軽くしてLLM料金を下げる
(1) はあくまで「ブラウザに送るデータ(ワイヤー)」を削っただけで、モデルが読み込むデータ(コンテキスト)は生JSONのままです。モデルは毎ターン、肥大した会話履歴(=生JSON)を読み直すので、入力トークンが膨らみ、LLM の利用料金に効いてきます。
そこで、Strands の ツール実行後フック(AfterToolCallEvent) を使い、GitHub のツール結果を要約に必要な項目だけに整形してからモデルの文脈に渡すようにしました。
from strands.hooks import AfterToolCallEvent, HookProvider, HookRegistry
class GithubResultTrimmer(HookProvider):
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(AfterToolCallEvent, self._on_after_tool)
def _on_after_tool(self, event: AfterToolCallEvent) -> None:
# event.result(ツール結果)のテキストを、
# number / title / state / updated_at / labels / body... など
# 要約に必要なフィールドだけに絞る(*_url や id 等は捨てる)
...
# Agent 生成時に登録
agent = Agent(..., hooks=[GithubResultTrimmer()])
実測では、最大だったツール結果が 生 約63,000字 → 約23,000字に縮小。入力トークンが減るぶん、コスト面で効きます。
注意: これは「安く・軽くする」施策であって、「速くする」施策ではありません。応答にかかる時間は、ツール呼び出しの回数やネットワーク往復・生成時間が支配的で、入力サイズを削っても体感速度はあまり変わりませんでした。期待していたぶん、ここは正直拍子抜けでした。
まとめ・学び
-
「時間制限」に見えて、実は「サイズ制限」だった。 切れるタイミングの“秒数”ではなく、受信バイト数の一致を見たことが切り分けの決め手になりました。
curl -w "%{size_download}"や DevTools の受信サイズは、こういうとき本当に効きます。 - AgentCore Runtime のストリーミングは、「最終結果」ではなく「エージェントの全イベント」を流している。だからこそ、何をクライアントに送るかは自分で絞る必要があります。
- マネージドなテンプレート(FAST)+公式 MCP をそのまま使うと、ツール結果が大きいワークロードでは普通に起こり得ます。同じ構成の方はご注意を。
- 公式ドキュメントの Quotas は、ちゃんと読むと答えが書いてある(自戒)。
同じところでハマっている方の参考になれば幸いです。
付録:AgentCore Runtime の主なリミット
| 項目 | 値 | 調整 |
|---|---|---|
| Maximum payload size(request/response) | 100 MB | 不可 |
| Streaming chunk size | 10 MB | 不可 |
| Streaming maximum duration | 60 min | 不可 |
| Request timeout(同期) | 15 min | 不可 |
| Idle session timeout | 15 min | 可 |
| Max session duration | 8 hours | 可 |