deepseek.ac日本語で学ぶ、実践する

実装

ストリーミングUXの優先順位 — DeepSeek APIで「信頼」をどう設計するか

· 約9分

リード

ストリーミング実装で最初に決めるべきは「速さ」ではなく「何を壊してはいけないか」です。 本記事では、正確性 → 完了の可視性 → 体感速度 → 描画の滑らかさ、という優先順位で信頼を設計する方法を示します。

前提

DeepSeek APIのパラメータ名、終端マーカー([DONE])の有無、usage取得用オプション、冪等キーの対応可否は変更され得ます。実装前に公式ドキュメントで要確認してください。以下のコードは一般的なOpenAI互換SSEを前提にした骨格です。

本文

なぜストリーミングで「信頼」が問題になるのか

非ストリーミングは「全か無か」です。失敗すれば何も表示されず、ユーザーは再試行するだけです。 一方ストリーミングは、途中まで正しく見えた文章が最後に欠けることがあります。このときユーザーは「途中までは正しい」と受け取っているため、欠落に気づかず信用するか、気づいて全体を疑うかのどちらかになります。後者のコストが非常に高いのがストリーミングの性質です。

したがって優先順位は次の順になります。

  1. 正確性 — 欠落・重複・順序の破壊をしない
  2. 完了の可視性 — どこまでが確定した出力かを常に説明できる
  3. 体感速度 — 最初のトークン(TTFT)と途切れない流れ
  4. 描画の滑らかさ — 最後。ここを最初に最適化すると、上の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" };

なぜそうするか: ユーザーが知りたいのは「これは完成した回答か」です。装飾やタイピングアニメーションより、この1ビットの情報のほうが価値が高い。

優先順位3: 体感速度は「最初の1文字」と「途切れなさ」に投資する

体感速度に効くのは、平均スループットではなく TTFT と 空白時間の有無 です。

優先順位4: 中断・失敗・再試行を「説明できる」ようにする

ストリーミングの再試行は、非ストリーミングより危険です。同じプロンプトを送り直せば再度課金され、部分出力を捨てるなら生成コストも無駄になります。

# 失敗の分類を先に決める。これがないとリトライが課金爆発になる
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 まで保留する

まとめ

チェックリスト