動かなかったのは、設計の先だった
1本目で「なぜキューが要るか」を、 2本目で「なぜ request / reply / cancel に分けるか」を書きました。 設計としては、これで筋が通っています。ローカル環境では、実際に完璧に動きました。
問題は、本番に載せた瞬間に始まりました。Apache が TLS を終端し、その奥の WebSocket サーバーへプロキシし、 さらに RabbitMQ を挟んで Consumer が LLM を回す——この長い経路のあちこちで、 設計図には出てこない罠が待っていました。
LLM推論中に、接続が切れる
犯人は Apache のリバースプロキシでした。TLS終端を Apache が担い、その奥の WebSocket サーバーへプロキシしています。 その Apache 側のタイムアウトが、LLMの生成時間より短かったのです。
短い質問は数秒で返るので問題になりません。しかし長い生成では、トークンが流れ続けていても、 Apache から見た「1往復」が長くなりすぎて、待ちきれずに接続を切ってしまう。下の経路図で、まさにこの瞬間を再現します。
対処 — タイムアウトを延ばし、心拍を送る
やることは2つ。Apache 側の待ち時間を延ばし、あわせて WebSocket 側から定期的に「生きている」を伝えます。
ProxyTimeout 300 KeepAliveTimeout 300
serve(handler, host, port, ping_interval=10)
「生きている」を、どう伝え続けるか
なぜ心拍(ping)が要るのか。長い生成の間には、データが流れない沈黙の時間が生まれます。 LLM が次のトークンを考えている間、経路上には何も流れません。
経路の途中にいる機器やプロキシは、この沈黙を見て「もう死んだ接続だ」と判断し、勝手に切ってしまう。 これはアイドル(暇な状態)とは違います。働いているのに黙っている状態です。
ping_interval=10 で10秒ごとに心拍を送り、沈黙を作らない。
生成中の無言を、切断の理由にさせないための延命策です。
動いていたコードが、動かない
古い記事のスニペットや、以前は動いていたコードが、最新のライブラリでそのまま動くとは限りません。
websockets の v16 では、いくつかのAPIが変わっていて、実際に踏みました(要点のみ)。
① ws.closed / ws.open が無くなった
接続の生死を属性で見られなくなりました。送信を try/except で包み、例外が出たら切れていると判断します。
- if ws.open: - await ws.send(msg) + try: + await ws.send(msg) + except Exception: + pass # 既に切断されている
② connection.reject() が無くなった
接続の拒否は、Response オブジェクトを返す形になりました。
- connection.reject(403, "Forbidden") + return Response(403, "Forbidden", + Headers([("Content-Length", "0")]))
③ オリジン検査は process_request で
許可しないオリジンには Response(403) を、許可するなら None を返します。
def process_request(connection, request): if origin not in ALLOWED: return Response(403, "Forbidden", ...) return None # 許可
「いま何件待っているか」を正しく取る
1本目のキュー可視化を、実運用でもやりたくなりました。「いま何件待っているか」をユーザーに表示したい。
ところが、キュー宣言時に返る件数(message_count)は、パージ直後などに古い値のまま返る場面がありました。
正確な件数は、キュー本体ではなく RabbitMQ の Management HTTP API に問い合わせて取ります。
GET http://localhost:15672/api/queues/%2F/rag_request # → JSON の messages フィールドが現在の件数
asyncio の、細かい罠
これは原因と対処が一対一で対応する、リファレンス的な一覧です。async の文脈でよく踏みます。
await 文脈でそのまま呼んだawait asyncio.to_thread(fn, ...) で別スレッドに逃がす。to_thread() に、async def の関数を渡してしまったawait fn(...) で呼ぶ。to_thread は同期関数専用。asyncio.get_event_loop() が RuntimeErrorasyncio.get_running_loop() を使う。expiration は文字列を受けない。→ timedelta で渡す。どれも一度踏めば忘れませんが、初見では原因が見えにくい。だからこそ、こうして書き残す価値があります。
罠は、境界に棲む
3本を通して見えたのは、実運用の問題がコンポーネントの内部ではなく継ぎ目に集中するという事実でした。 切れる(Apache↔WS)・黙る(生成中の沈黙)・古い(件数のステイル)・ずれる(ライブラリ世代差)。 どれも、単体では正しく動くものどうしのあいだで起きています。
- なぜWebアプリにメッセージキューが必要か
重い・同時・非力の3条件と prefetch_count=1 - 3キュー設計 — request / reply / cancel
役割の違う流れは、別の箱に分ける - 実運用の落とし穴(この記事)
切れる・黙る・古い・ずれる — 境界の罠
「なぜ要るか」→「どう分けるか」→「本番で何にハマるか」。この3段を越えて、 いま sasagawa.tokyo の AIコンシェルジュが、このキュー基盤の上で動いています。
この記事のまとめ
- 長い生成で切れるのは Apache のタイムアウト。
ProxyTimeout 300とping_interval=10で延命する。 - 生成中の沈黙は「死」ではない。ping で「生きている」を伝え続ける。
- ライブラリ世代差(websockets v16)は実際に踏む。バージョン固定+変更履歴。
- 正確な待ち件数はキューではなく Management API に聞く。
- asyncio は to_thread の対象・get_running_loop・timedelta が定番の罠。