実装
ストリーミングUXの優先順位 — DeepSeek APIで「信頼」をどう設計するか
リード
ストリーミング実装で最初に決めるべきは「速さ」ではなく「何を壊してはいけないか」です。 本記事では、正確性 → 完了の可視性 → 体感速度 → 描画の滑らかさ、という優先順位で信頼を設計する方法を示します。
前提
- 想定読者: DeepSeek APIをストリーミングで呼び、UIに逐次表示する実装者
- 必要な環境: Python 3.10+ / httpx、TypeScript(ブラウザ or Node)
- 前提知識: SSE(
text/event-stream)の基礎、非同期HTTPクライアントの基本
DeepSeek APIのパラメータ名、終端マーカー(
[DONE])の有無、usage取得用オプション、冪等キーの対応可否は変更され得ます。実装前に公式ドキュメントで要確認してください。以下のコードは一般的なOpenAI互換SSEを前提にした骨格です。
本文
なぜストリーミングで「信頼」が問題になるのか
非ストリーミングは「全か無か」です。失敗すれば何も表示されず、ユーザーは再試行するだけです。 一方ストリーミングは、途中まで正しく見えた文章が最後に欠けることがあります。このときユーザーは「途中までは正しい」と受け取っているため、欠落に気づかず信用するか、気づいて全体を疑うかのどちらかになります。後者のコストが非常に高いのがストリーミングの性質です。
したがって優先順位は次の順になります。
- 正確性 — 欠落・重複・順序の破壊をしない
- 完了の可視性 — どこまでが確定した出力かを常に説明できる
- 体感速度 — 最初のトークン(TTFT)と途切れない流れ
- 描画の滑らかさ — 最後。ここを最初に最適化すると、上の3つが壊れたときに何も説明できない
優先順位1: 受信を壊さない — 一括読み込みをしない、順序と重複を排除する
まずアンチパターンから。res.body.to_s のようにレスポンス全体を文字列化すると、ストリーミングの意味が消えます。エラー本文の確認など非ストリーミング用途に限定してください。
逐次受信の最小形は次のとおりです。
import json
import httpx
def stream_chat(prompt: str, on_delta, on_done, api_key: str):
with httpx.stream(
"POST",
"https://api.deepseek.com/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": "deepseek-chat",
"messages": [{"role": "user", "content": prompt}],
"stream": True,
},
# read は「チャンク間の最大待ち時間」になる。全体の締切とは別に持つ
timeout=httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0),
) as res:
res.raise_for_status()
for line in res.iter_lines():
if not line or not line.startswith("data:"):
continue
payload = line[len("data:"):].strip()
if payload == "[DONE]":
on_done()
return
chunk = json.loads(payload)
delta = chunk["choices"][0].get("delta", {}).get("content")
if delta:
on_delta(delta)
順序と重複については、受け取った断片に連番を付けて保持します。UI側で「直前の番号より大きくない断片は捨てる」と決めておくと、再接続や二重購読が起きても表示が壊れません。ツール呼び出しやreasoningを含む実装では、断片がどのブロックに属するかを index で識別し、ブロック終端で組み上がった完全なブロックを受け取る設計にすると、消費側で再組み立てする必要がなくなります。
type Fragment = { seq: number; text: string };
class OrderedBuffer {
private lastSeq = -1;
private parts: string[] = [];
push(f: Fragment): boolean {
// 順序が巻き戻った断片は破棄する(重複排除)
if (f.seq <= this.lastSeq) return false;
this.lastSeq = f.seq;
this.parts.push(f.text);
return true;
}
text(): string {
return this.parts.join("");
}
}
なぜそうするか: 表示の正しさは、後から取り返せない唯一の性質です。速度は後から改善できますが、壊れた文章を信じたユーザーの認識は戻せません。
優先順位2: 「確定」と「生成中」を状態として分ける
UX上の事故はほぼここで起きます。生成中のテキストを確定扱いにして、コピー・保存・送信・要約を許してしまうケースです。
状態を明示してください。
type StreamState =
| { status: "idle" }
| { status: "streaming"; text: string; startedAt: number }
| { status: "done"; text: string; finishReason: string | null }
| { status: "aborted"; text: string; reason: "user" | "timeout" | "error" };
streamingの間は、コピーや保存に「生成中」の注記を付ける、あるいは無効化するdoneの条件を1つに決める。終端マーカー、finish_reason、接続終了のどれを正とするかを先に決め、来なかった場合のフォールバックも決めるabortedでは部分テキストを捨てずに保持し、「不完全である」と明示する。黙って消すとユーザーは同じ操作を繰り返し、課金だけが増えます
なぜそうするか: ユーザーが知りたいのは「これは完成した回答か」です。装飾やタイピングアニメーションより、この1ビットの情報のほうが価値が高い。
優先順位3: 体感速度は「最初の1文字」と「途切れなさ」に投資する
体感速度に効くのは、平均スループットではなく TTFT と 空白時間の有無 です。
- プロキシや圧縮、サーバ側のバッファリングで最初のチャンクが滞留していないか確認する
- クライアント側でチャンクを溜めてから一括描画すると、TTFTが犠牲になる。描画間引きは16ms程度のフレーム単位に留める
- チャンク間隔を計測し、閾値を超えたらUIに「待機中」を出す。無反応より、遅いと分かるほうがよい
優先順位4: 中断・失敗・再試行を「説明できる」ようにする
ストリーミングの再試行は、非ストリーミングより危険です。同じプロンプトを送り直せば再度課金され、部分出力を捨てるなら生成コストも無駄になります。
- クライアント側で送信ボタンを
streaming中は無効化する(連打による多重リクエストを防ぐ) - サーバ側でリクエストIDを発行し、課金ログと突合できるようにする。冪等キーがAPI側でサポートされているかは公式ドキュメントで要確認
- リトライ対象は接続失敗・タイムアウトなど出力が始まる前に限定する。出力開始後の失敗は自動再試行せず、部分結果を保持してユーザーに再生成を選ばせる
- ユーザーによるキャンセルは、上流の接続も閉じる。閉じないと生成が続き課金される可能性がある
# 失敗の分類を先に決める。これがないとリトライが課金爆発になる
RETRYABLE_BEFORE_FIRST_TOKEN = {"connect_error", "read_timeout_before_first_chunk"}
def should_retry(error_kind: str, received_any_token: bool) -> bool:
if received_any_token:
return False # 途中からの再開はできない。部分結果を見せて再生成させる
return error_kind in RETRYABLE_BEFORE_FIRST_TOKEN
なぜそうするか: 信頼は「失敗しないこと」ではなく「失敗したときに何が起きたか説明できること」で決まります。自動リトライは一見親切ですが、途中まで表示した文章が突然別の文章に置き換わるのは、最も不信を招く挙動です。
計測: 信頼は数字でしか維持できない
最低限、次の4つを記録してください。UIの改善はここからしか始まりません。
| 指標 | 意味 | 使いどころ |
|---|---|---|
| TTFT | リクエストから最初のトークンまで | 体感速度の改善 |
| チャンク間隔の最大値 | 最大の空白時間 | 待機中UIの閾値設計 |
| 完了率 | done に到達した割合 |
中断・失敗の実態把握 |
| 中断理由の内訳 | user / timeout / error | リトライ設計の妥当性検証 |
つまずきポイント
| 症状 | 対処 |
|---|---|
| 最初の文字が数秒出ない | プロキシ・圧縮・サーバ側バッファリングを疑う。res.body.to_s 相当の一括読み込みをしていないか確認 |
| 途中で文章が切れる | read timeoutをチャンク間待ちとして設定。出力開始後は自動リトライせず部分結果を保持して再生成させる |
| 同じ文が二重に表示される | 断片に連番を付け、直前の番号以下の断片を破棄する |
| 日本語が文字化けする | バイト列を自前で分割デコードせず、行単位でデコードする。マルチバイト境界を跨ぐ分割を避ける |
| 完了したのにusageが取れない | usage取得用オプションの有無・名称を公式ドキュメントで確認 |
| リトライで課金が倍になる | リトライ可否を「最初のトークン前のみ」に限定。リクエストIDで課金ログと突合 |
| 生成中にコピーされて不完全な内容が拡散 | streaming 状態を明示し、確定操作を done まで保留する |
まとめ
- ストリーミングのUXは速さではなく、壊さないことから設計する
- 順序と重複を排除し、確定と生成中を状態として分離する
- 出力開始後の失敗は自動リトライしない。部分結果を保持して説明する
- 体感速度はTTFTと空白時間に投資し、描画の滑らかさは最後に回す
- TTFT・空白時間・完了率・中断理由を計測して初めて改善できる
チェックリスト
- レスポンスを一括文字列化している箇所がない
- 断片の連番と重複排除を実装した
- 「確定」と「生成中」を状態として分けた
- 完了条件とフォールバックを1つに決めた
- 出力開始後の自動リトライを無効化した
- キャンセル時に上流接続も閉じている
- リクエストIDと課金ログを突合できる
- TTFT・チャンク間隔・完了率・中断理由を記録している