LLM SDK

ストリーミング応答を読む ── AIが書いた「動くけど止まらない」チャット実装

生成AIにチャット呼び出しを書かせると、たいていストリーミングのタイムアウトと後始末が抜けた形で出てきます。手元では動くのに本番で固まる実装を、どこを見れば公開前に気づけるかで読みます。timeout=5 秒の設定が 60.1 秒走ってしまう様子を実測で並べました。

  • LLM SDK
  • client.chat.completions.create
  • stream=True
  • stream_options
  • timeout
  • close()

ここを見れば気づける

  • timeout はチャンクの間隔にしか効かず、ストリーム全体の長さを止めない(実測: 5秒設定で60.1秒完走)
  • 途中で切れたストリームを素朴に最初からやり直すと、実用ぶんの1.5倍近くを払う
  • 読み捨てたストリームを close していないと、サーバ側は喋り続ける

証拠の出し方:同じ入力を stream=True / stream=False に通し、実測トークン数・所要時間・エラーの有無を並べる。タイムアウトの効き方は、わざと途中で黙る SSE サーバを立てて確定させる

氷河期世代のクラウドエンジニア、terralien です。ITエンジニアとしてデビューしたのはほんの数年前なので、コードを書くより「出てきたコードを読む」側にいる時間のほうが長いんですよね。

生成AIにチャット呼び出しを書かせると、だいたい10行くらいの綺麗なコードが返ってきます。動かすと、ちゃんと文字が流れてくる。ここで手を止めてマージしてしまうと、しばらくしてから「たまにリクエストが返ってこない」という報告が来ます。

この記事は、そのコードをマージする前に3分で読むための記事です。書けるようになる話ではありません。

この記事の数字はどこで測ったか

推測値は1つも書いていません。全部このMacの上で走らせた実測です。手元でも再現できるように、環境を先に出しておきます。

項目値
マシンMacBook Pro(Apple M5 Pro / メモリ 64GB / macOS 26.6.1)
推論サーバOllama 0.32.14(http://localhost:11434)
モデルgemma4:latest(8.0B・Q4_K_M)
クライアントPython 3.11.2 / openai 3.6.0 / httpx 0.28.1
計測日2026-08-29

Ollama は OpenAI 互換のエンドポイントを持っているので、openai パッケージの base_url を差し替えるだけで同じコードが通ります。課金なしで、本物の SDK の挙動をそのまま測れるのがありがたいところです。以下のコードはモデル名と base_url を変えれば、そのまま本番の API にも向きます。

用途 ── ストリーミングが解決するのは「速さ」ではなく「待たされ方」

まず、何のための道具かを取り違えないことです。同じ入力(47都道府県の列挙・出力312トークン)を、stream=True と stream=False で通した結果がこれです。

最初の1文字まで全部揃うまで出力トークン
stream=True0.48 秒5.96 秒312
stream=False―(5.65秒まで何も無い)5.65 秒312

全部揃う時刻は、むしろストリーミングのほうが 0.3 秒遅いんです。速くなっていません。変わったのは、読者が白い画面を見つめる時間が 5.65 秒から 0.48 秒になったこと、それだけです。

宅配便を「1個口でまとめて明日」から「小分けにして今日から順次」に変えたようなものです。荷物が早く着くわけではなく、玄関で待つ時間の体感が変わる。だからバッチ処理にストリーミングを付ける意味はありません。誰も待っていない画面に小分けで届けても、置き配が増えるだけです。

ここを外していると、そもそも監査するまでもありません。AIが書いたコードに stream=True が付いていて、それを受けるのが cron のバッチだったら、複雑さだけ増えて得るものがゼロです。

代表要素 ── 覚えるのは3つ

要素何を返すか既定値の罠
client.chat.completions.create(..., stream=True)チャンクのイテレータ(Stream オブジェクト)。for で回すと delta.content が少しずつ来る返り値はレスポンスではなく、開きっぱなしの HTTP 接続。使い終わったら閉じる責任がこちら側に来る
timeout=(クライアント引数)httpx のタイムアウト設定になるストリームでは接続・書き込み・チャンクとチャンクの間隔にしか効かない。総時間の上限ではない(後述)
stream_options={"include_usage": True}最後に usage(消費トークン)だけを載せたチャンクが追加で来る付けないとストリーミングではトークン数が一切分からない。付けると choices が空のチャンクが混ざるので、chunk.choices[0] を無条件で見ているコードが IndexError で落ちる

3つめは地味ですが、課金額を後から検証できるかどうかがここで決まります。付け忘れたまま本番に出ると、請求書と手元のログを突き合わせる手段がなくなります。

AIが書く例 ── 実際に書かせたもの

伝聞にしたくないので、手元の Qwen3.8:27b に「OpenAI の Python SDK でストリーミング応答を端末に流す関数を書いて」と頼みました。返ってきたのがこれです(一字も直していません)。

from openai import OpenAI

def stream_chat_response(prompt: str) -> None:
    client = OpenAI()
    stream = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        stream=True
    )
    for chunk in stream:
        if chunk.choices[0].delta.content:
            print(chunk.choices[0].delta.content, end="", flush=True)
    print()

綺麗です。型ヒントまで付いています。そして動きます。手元で叩けば、ちゃんと文字が流れてきます。

この12行に、これから見る3つの穴が全部入っています。しかも chunk.choices[0] を無条件で見ているので、さっきの include_usage を足した瞬間に IndexError で落ちるおまけ付きです。

面白いことに、まったく同じ頼み方をもう一度したら、そこだけ if chunk.choices and ... とガードが付いたコードが返ってきました。他の3つの穴はどちらの回も開いたままです。つまりこの防御が入るかどうかは運で、生成のたびに変わります。「前に生成したときは正しかった」は、次のコードの品質を1ミリも保証しません。読むしかないのはこのためです。

監査① ── timeout はストリームの総時間を止めません

ここが最大の穴です。AIが書いたコードに timeout を足せば直る、と思っていると外します。

同じクライアント(timeout=5.0)で、同じ入力を stream の有無だけ変えて投げました。

経過時間結果
stream=False5.00 秒APITimeoutError(狙いどおり止まる)
stream=True5.71 秒エラーなし。311チャンク完走

同じ 5 秒設定で、片方は止まり、片方は止まりません。もっと長い生成(1200トークン)にすると差は開いて、22.3 秒=設定値の 4.5 倍を走り切ってエラーゼロでした。

なぜかを確かめるために、わざと途中で黙る SSE サーバを立てて、同じクライアントを向けました。

# ① 1チャンク送ったあと60秒だまるサーバ  → timeout は効くのか?
# ② 3秒おきにチャンクを送り続けるサーバ  → 合計60秒。timeout は効くのか?
c = OpenAI(base_url=f"http://127.0.0.1:{port}/v1", api_key="x",
           timeout=5.0, max_retries=0)
s = c.chat.completions.create(model="fake", messages=[...], stream=True)
for ch in s:
    ...
サーバの振る舞いtimeout経過時間結果
1チャンク送って60秒だまる5.0 秒5.4 秒ReadTimeout
3秒おきに20チャンク送る5.0 秒60.1 秒エラーなし・完走

答えが出ました。timeout はチャンクとチャンクの「間隔」に効くのであって、ストリーム全体の長さには一切効きません。間隔が5秒を超えれば止まる。超えなければ、何分でも走ります。設定値の12倍まで見届けました。

これは SDK のバグではなく、HTTP の読み取りタイムアウトの素直な仕様です(httpx のドキュメントが read を「チャンク受信の間隔」と定義しています)。だから「タイムアウトを設定したので大丈夫です」というレビューコメントは、ストリーミングに対しては何も保証していません。

止めたいなら、時計は自分で持つしかありません。

import time
DEADLINE = 30.0

t0 = time.monotonic()
with client.chat.completions.create(..., stream=True) as stream:   # ← with で閉じる
    for chunk in stream:
        if time.monotonic() - t0 > DEADLINE:
            break                       # with を抜けるので close される
        if chunk.choices and chunk.choices[0].delta.content:
            print(chunk.choices[0].delta.content, end="", flush=True)

chunk.choices and を足してあるのは、監査②の計測でこちらが実際に踏んだからです。

監査② ── 素朴なリトライは、実用ぶんの1.5倍近くを払わせます

「途中で切れたらリトライ」は正しいのですが、最初からやり直すリトライには値札が付いています。

312トークンの応答を 124 チャンク目(4割地点)で切って、素朴に最初から投げ直したときの請求を数えました。

請求される実際に使えた差
入力トークン82412回ぶん払う
出力トークン436312捨てた124トークンも払う
合計518353+46.7%

出力の途中で捨てたぶんも、入力を読み直したぶんも、当然ながら課金対象です。成功率が下がるほど単価が上がる構造になっていて、しかもこれはログに「失敗」としか残らないので、請求書を見るまで気づきません。

読むときに見るのは、except の中身です。

リトライの書き方判定
for _ in range(3): try: ... except: continue❌ 上限だけあって、間隔も打ち切りもない。46%増を3回まで買う
途中まで受け取った文字を捨てて再送△ 動くが高い。短い応答なら許容範囲
受信済みを assistant メッセージとして積み、「続きから」と頼む○ 出力の再課金は減る。ただし入力は伸びる
そもそも切れないよう総時間の上限を持ち、切れたらユーザーに返す◎ いちばん安い。リトライしない選択肢を検討したかが読みどころ

監査③ ── break で抜けたストリームは、まだ喋っています

3つめは、コードを読んだだけでは絶対に気づけないやつです。

長い生成を始めて、8チャンクだけ読んで break で抜けました。そのあと別の軽いリクエストを投げて、生成速度を測っています。

直前にしたこと別リクエストの生成速度
何もしていない(アイドル)50.0 トークン/秒
break で抜けただけ8.1 トークン/秒(6.2分の1)
break のあと .close() を呼んだ50.1 トークン/秒(元通り)

break で抜けても、サーバ側の生成は止まっていません。裏で最後まで喋り続けていて、そのぶんの計算資源を後続のリクエストから奪っています。閉じた瞬間に元に戻るので、原因はこれで確定です。

電話を切らずに受話器を机に置いた状態です。こちらは会話を終えたつもりでも、相手はまだ喋っているし、その回線は塞がったままです。しかもクラウドの API なら、喋り続けたぶんは課金対象になり得ます。

Python の場合、変数がスコープを抜けてガベージコレクトされれば結果的に閉じます。「結果的に」で運用していい話ではないので、with を使うか .close() を明示するか、どちらかにします。AIが書いたコードには、まず入っていません。

隣で読むもの

ストリーミングの外側にも、同じ「動くけど間違っている」が並んでいます。

記事ここと繋がるところ
temperature を下げても答えは合いませんmax_tokens で切られた出力も、例外を出さずに正常系で流れてきます
while True の中でAIが喋り続けます総時間の上限を持たない、という同じ穴がツール実行ループにも開きます

チートシート

見たもの疑うこと直し方
stream=True が付いている受け手は人間か? バッチなら不要stream=False にして分岐を消す
timeout= を設定して安心している総時間は止まらない(実測 12倍)time.monotonic() で自前の締切を持つ
for chunk in stream: が裸で置いてある抜けたあと閉じているかwith ... as stream: で囲む
chunk.choices[0] を無条件で見ているusage チャンクで IndexErrorif chunk.choices and ...
usage をどこにも記録していない課金を後から検証できないstream_options={"include_usage": True}
except で continue してリトライ1回あたり +46.7% の請求打ち切り条件を先に決める/続きから頼む
生成が長い(1000トークン超)切れる確率も待ち時間も上がる出力上限を絞る・分割する
読めるようになったか、ひとつだけ確認

Q. timeout=30 を設定したストリーミング呼び出しが、5分間返ってこないことはありますか?

あります。チャンクが30秒以内の間隔で届き続けている限り、timeout は一度も発火しません。実測では 5 秒設定のクライアントが 60.1 秒完走しました。総時間を止めたいなら、時計は自分で持つしかありません。

ここまで偉そうに書いてきましたが、白状すると、この記事の計測コードを書いているときに chunk.choices[0] で IndexError を出したのはこちらです。しかも直し方を調べようとして、AIに聞きました。同じ穴に落ちてから記事を書いているので、説得力があるのか無いのか、自分でもよく分かりません。

出典