短い依頼なら何度投げても通るのに、少し考え込む依頼だけが決まって途中で落ちる。しかも上流のログにはエラーが一行も残っていない。
自前の中継を一枚挟んだ構成で、この形の不調に出くわしたことはないでしょうか。私も、エッジで動く小さな中継を書いたあとで似た症状を踏みました。最初に疑ったのはモデル側の不安定さでしたが、結論から言えば見当違いで、止めていたのは自分が書いた数行のほうでした。
厄介なのは、この故障が「重い処理のときだけ」現れることです。動作確認は短いプロンプトで済ませてしまいがちなので、テストは全部通り、本番で長考が始まった日に初めて落ちます。
先に結論を書きます
切断の判定は、応答が失敗したかどうかではなく、一定時間バイトが一つも届かなかったかどうかで行われています。
Claude Code は、ANTHROPIC_BASE_URL や ANTHROPIC_AWS_BASE_URL 経由の接続について、ゲートウェイが中継したバイトを数えています。SSE の ping イベントもコメント行も等しく1バイトとして数え、既定では 300秒 のあいだ完全に無音だったストリームを打ち切ります。これは公式のゲートウェイプロトコル仕様に明記されている挙動です。
そして長い思考のあいだ、回線を流れている唯一のトラフィックが上流の keep-alive ping です。つまり中継がその ping を落とすか、溜め込んで後でまとめて流すかした瞬間に、クライアントから見た回線は「無音」になります。上流は正常に喋り続けているのに、です。
2026年8月12日の v2.1.229 では、ゲートウェイのストリーミング応答に SSE keepalive ping が追加され、Vertex や Bedrock を上流に置いた構成での idle timeout 切断が改善されました。同じリリースで、ping が確かに届いているにもかかわらずカスタム ANTHROPIC_BASE_URL 側で idle timeout が発火する不具合も直っています。上流の改善は入りました。残っているのは、自分が書いた中継のほうです。
番犬は「意味」ではなく「バイト」を見ています
ここが最初のつまずきどころでした。私は当初、クライアントが「ping イベントを受け取ったかどうか」を見ていると思い込んでいました。実際にはもっと素朴で、届いたバイト数しか見ていません。
この素朴さには両側に刃があります。ping という形をしていなくても、何かバイトが流れていれば時計は戻ります。逆に、意味のある ping を正しく送っていても、中継が握って離さなければ時計は進み続けます。
さらに、接続に使う環境変数によって監視の仕組みそのものが入れ替わります。ここを混同すると、対策を打った経路と実際に切れている経路がずれます。
| 接続に使う変数 | 監視の仕組み | 無音時の既定 |
ANTHROPIC_BASE_URL / ANTHROPIC_AWS_BASE_URL | バイト単位のウォッチドッグ(コメント行も計上) | 300秒で中断 |
ANTHROPIC_BEDROCK_BASE_URL | ウォッチドッグの対象外。CLAUDE_ENABLE_BYTE_WATCHDOG_BEDROCK で追加可能 | 5分のアイドルタイムアウト |
ANTHROPIC_VERTEX_BASE_URL / ANTHROPIC_FOUNDRY_BASE_URL | ウォッチドッグの対象外 | 5分のアイドルタイムアウト |
紛らわしいのは、ゲートウェイが Anthropic Messages 形式を喋っていても、クライアント側が ANTHROPIC_BEDROCK_BASE_URL で繋いでいればバイト単位の監視には包まれない、という点です。「形式が同じだから挙動も同じだろう」という推測は、ここでは通りません。切断の相談を受けたときに私が真っ先に聞くのは、モデル名でもリージョンでもなく、どの変数で繋いでいるかです。
中継の振る舞いを6通り並べてみます
推測で対策を書くと、効かなかったときに何が原因か分からなくなります。そこで、上流・中継・クライアントを分けた最小の実験台を組み、中継の実装だけを差し替えて挙動を並べました。
秒数はそのままでは検証に時間がかかりすぎるので、比率を保って縮めています。上流は6秒の長考、クライアントの無音上限は4秒。実運用の300秒に対して、およそ50分の1のスケールです。無音上限4秒に対して ping 間隔1.5秒は、およそ2.7倍の余白がある計算になります。同じ余白を本番のスケールに戻すと、ping 間隔が110秒より短ければ足りることになります。
上流は、最初に1フレーム送ったあと黙り込み、指定間隔でコメント行だけを流します。
// origin.mjs — 上流の模擬。最初の1フレームのあと長考に入り、ping だけを流す
import http from 'node:http';
const SILENCE = Number(process.env.SILENCE ?? 6000); // 長考の長さ
const PING = Number(process.env.PING_MS ?? 0); // 0 なら ping を出さない
http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write('event: message_start\ndata: {"type":"message_start"}\n\n');
// 長考区間。本文は出ず、コメント行だけが流れる
const iv = PING ? setInterval(() => res.write(': ping\n\n'), PING) : null;
setTimeout(() => {
if (iv) clearInterval(iv);
res.write('event: content_block_delta\ndata: {"text":"done"}\n\n');
res.write('event: message_stop\ndata: {}\n\n');
res.end();
}, SILENCE);
}).listen(4001);
クライアント側は、バイトが届くたびにタイマーを張り直すだけの番犬です。実装が素朴であることが、そのまま挙動の説明になります。
// client.mjs — バイトが1つでも届けばタイマーを戻す
const IDLE = Number(process.env.IDLE ?? 4000);
const t0 = Date.now();
const ctrl = new AbortController();
let timer = setTimeout(() => ctrl.abort(), IDLE);
let bytes = 0;
let wakeups = 0;
try {
const r = await fetch('http://127.0.0.1:4002/v1/messages', { signal: ctrl.signal });
const reader = r.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.length;
wakeups++;
clearTimeout(timer); // ← 内容は一切見ていない
timer = setTimeout(() => ctrl.abort(), IDLE);
}
clearTimeout(timer);
console.log(`OK elapsed=${Date.now() - t0}ms bytes=${bytes} wakeups=${wakeups}`);
} catch (e) {
clearTimeout(timer);
console.log(`ABORT elapsed=${Date.now() - t0}ms bytes=${bytes} wakeups=${wakeups}`);
}
中継は5つの振る舞いを切り替えられるようにしました。stream はそのまま流す、buffer は全部読んでから返す、strip は ping フレームごと落とす、strip-loose は ping の行だけ落として空行を残す、aggregate は5秒ごとにまとめて流す、です。
// proxy.mjs — 中継。MODE を差し替えて挙動を比較する
import http from 'node:http';
const MODE = process.env.MODE ?? 'stream';
const FLUSH = Number(process.env.FLUSH ?? 5000);
http.createServer(async (req, res) => {
const up = await fetch('http://127.0.0.1:4001/v1/messages');
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
// よくある一行。ここで全部溜まる
if (MODE === 'buffer') {
res.end(await up.text());
return;
}
const reader = up.body.getReader();
const dec = new TextDecoder();
let pending = '';
const iv = MODE === 'aggregate'
? setInterval(() => { if (pending) { res.write(pending); pending = ''; } }, FLUSH)
: null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
let chunk = dec.decode(value, { stream: true });
if (MODE === 'strip') chunk = chunk.replace(/^:[^\n]*\n\n/gm, ''); // フレームごと除去
if (MODE === 'strip-loose') chunk = chunk.split('\n').filter((l) => !l.startsWith(':')).join('\n');
if (!chunk.length) continue;
if (MODE === 'aggregate') pending += chunk;
else res.write(chunk);
}
if (iv) clearInterval(iv);
if (pending) res.write(pending);
res.end();
}).listen(4002);
同じ上流・同じクライアントで、中継だけを差し替えた結果です。
| 中継の振る舞い | 上流の ping | 結果 | 経過 | クライアントに届いたバイト |
| そのまま流す | なし | 中断 | 4.06秒 | 53 |
| そのまま流す | 1.5秒ごと | 完走 | 6.05秒 | 157 |
| 全部読んでから返す | 1.5秒ごと | 中断 | 4.00秒 | 0 |
| ping フレームごと除去 | 1.5秒ごと | 中断 | 4.06秒 | 53 |
| ping の行だけ除去(空行は残す) | 1.5秒ごと | 完走 | 6.05秒 | 136 |
| 5秒ごとにまとめて流す | 1.5秒ごと | 中断 | 4.00秒 | 0 |
予想と逆だったのは5行目です。ping を削っているのに完走しました。
理由は身も蓋もありません。行単位のフィルタは : ping という行を消しますが、フレームを区切る空行までは消さないので、改行文字が2バイトだけ流れ続けます。番犬はバイトしか見ていないので、これで満足してしまう。意味的には ping が消えているのに、監視は通る。
この結果は「ping を転送していれば安全」という理解が二重に誤りであることを示しています。ping を消しても生き延びる経路があり、ping を送っても死ぬ経路がある。見るべきは ping の有無ではなく、無送信の区間が生まれていないかどうかです。
そして中断した3ケースのうち2ケースは、クライアントに1バイトも届いていません。中継が全て握っていたためです。この状態の何が怖いかというと、上流のログにはエラーが残らず、中継のログにも例外が出ず、クライアント側にだけ「切れた」という事実が残ることです。三箇所のログを突き合わせても原因が見えない不具合の、典型的な作られ方だと感じました。
エッジで中継を書くときに踏む一行
私は個人開発で複数のサイトを Cloudflare Workers で運用していて、Worker にレスポンスを中継させる場面が日常的にあります。そこで身に沁みたのは、ストリームを壊す書き方があまりに自然に書けてしまうということでした。
// Before — 動くけれど、長考の日に落ちる
export default {
async fetch(request, env) {
const upstream = await fetch(env.UPSTREAM, request);
const body = await upstream.text(); // ← ここで全部待つ
return new Response(body, {
status: upstream.status,
headers: upstream.headers,
});
},
};
await upstream.text() は、ログを足したいときにも、ヘッダを見て分岐したいときにも、ごく自然に手が伸びる一行です。しかもレスポンスは最終的に正しく返るので、短いプロンプトでは何の問題も起きません。
// After — 本体には触れず、そのまま素通しする
export default {
async fetch(request, env) {
const upstream = await fetch(env.UPSTREAM, request);
// 本文を消費しない。ReadableStream をそのまま渡す
const headers = new Headers(upstream.headers);
headers.set('Cache-Control', 'no-cache, no-transform');
headers.set('X-Accel-Buffering', 'no'); // 前段のバッファリングを抑止する
headers.delete('Content-Length'); // ストリームに長さはない
return new Response(upstream.body, {
status: upstream.status,
headers,
});
},
};
no-transform と X-Accel-Buffering: no は、自分の Worker より前に立つ層への意思表示です。自分の中継を直しても、その手前の nginx が既定の proxy_buffering on で溜め込んでいれば同じことが起きます。nginx を自分で持っているなら、該当の location で proxy_buffering off; と proxy_cache off;、加えて proxy_read_timeout を長考より長く取ります。
私はこの順序で判断するようにしてから、迷わなくなりました。
- 本文を読む必要が本当にあるか。ほとんどの中継ではないので、まず
upstream.body の素通しに寄せる
- どうしても中身を見たいなら、
TransformStream で通過させながら覗く。溜めない
- 圧縮・整形・検査を挟む層があるなら、そこがフレーム単位で吐き出しているかを確かめる
3番目は見落としやすい層です。応答を圧縮する中間層は、効率のために小さな書き込みをまとめようとします。それはコンテンツ配信としては正しい判断で、ストリーミングにおいてだけ有害になります。
切れたあとの挙動まで壊さない
もう一つ、実験台では見えないけれど本番で効いてくる点があります。
Claude Code は上流の拒否に対して自動でリトライし、拒否された機能をその会話のあいだ無効化して回復する仕組みを持っています。そしてこのリトライ判定は、上流のエラー本文の文言に対するマッチで行われています。
つまり、良かれと思ってエラーを自前のエンベロープで包む中継は、ステータスコードを正しく保っていても回復経路を壊します。監視のためにエラー形式を統一したくなる気持ちは分かりますし、私も別の文脈では推奨する側です。ただこの経路に限っては、エラー応答は加工せずそのまま返すのが正解でした。
同じ理屈で、リクエストボディを検査目的で書き換える中継も危険です。ヘッダとボディフィールドは対で送られてくるので、片方だけを落とすと 400 になります。検査するなら、変更せずに読むだけにとどめます。
このあたりの「上流の言い分をそのまま届ける」という原則は、レート制限を扱うときの考え方とも重なります。ヘッダを読んで先回りする話はレート制限の余白をヘッダから計測して枯渇前に絞る運用メモにまとめてありますが、どちらも「中継が上流の情報を薄めない」という一点に帰着します。
本番に入れる前に確かめること
中継を書き換えたら、長考を待たずに検証できます。上の実験台は、SILENCE と IDLE を実運用の比率に合わせるだけで自分の構成に流用できます。
確認すべきは一つだけです。上流が黙っているあいだ、クライアント側にバイトが届き続けているか。 届いていれば安全で、届いていなければ、その区間の長さが上限を超えた日に落ちます。
今日できる一手を挙げるなら、自分の中継のコードを開いて await の後ろに .text() か .json() が付いていないかを探してみてください。付いていたら、そこが無音区間の発生源です。
私自身、この一行を見つけるまでに上流とモデルを何度も疑いました。同じ遠回りを省けたなら嬉しいです。お読みいただきありがとうございました。