氷河期世代のクラウドエンジニア、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=True | 0.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=False | 5.00 秒 | APITimeoutError(狙いどおり止まる) |
stream=True | 5.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割地点)で切って、素朴に最初から投げ直したときの請求を数えました。
| 請求される | 実際に使えた | 差 | |
|---|---|---|---|
| 入力トークン | 82 | 41 | 2回ぶん払う |
| 出力トークン | 436 | 312 | 捨てた124トークンも払う |
| 合計 | 518 | 353 | +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 チャンクで IndexError | if 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に聞きました。同じ穴に落ちてから記事を書いているので、説得力があるのか無いのか、自分でもよく分かりません。