運用
DeepSeek APIのコストを抑える設計 — キャッシュヒット率と実行タイミングの作り込み
リード
DeepSeek APIのコストは、モデルを変える前に「プロンプトの形」と「実行する時刻」で決まります。 本記事では、自動で有効になるプレフィックスキャッシュを壊さない設計、ヒット率の計測、そして遅延可能な処理を寄せるバッチ的手法を、Pythonコードとともに整理します。
前提
- 想定読者: DeepSeek APIを本番または検証で継続利用しているエンジニア
- 必要な環境: Python 3.10+ /
openaiSDK(base_urlを DeepSeek に向ける構成) - 前提知識: Chat Completions の基本、
messages配列の構造
価格・割引キャンペーン・モデル名は変動が激しいため、本記事では具体的な単価を断定しません。最終的な数値は必ず公式の「模型 & 価格」「使用硬盘缓存」ページで確認してください。
本文
結論: 最適化の順序
- キャッシュヒット率を上げる(プロンプト設計の問題。レイテンシも改善する)
- オフピークに寄せる(スケジューリングの問題。遅延可能な処理のみ)
- リクエストを束ねる(呼び出し回数の削減。設計コストはかかる)
理由は単純で、1 はオンラインの対話リクエストにも効き、2 と衝突しないからです。逆に 2 だけをやっても、ユーザーの昼間の質問を夜に回すわけにはいきません。
なぜ「プレフィックス一致」がすべてなのか
DeepSeek のコンテキストハードディスクキャッシュは、コード変更なしで自動的に動作します。ただし公式ドキュメントが明示しているとおり、条件は厳しいものです。
- トークン0番目から一致している必要がある。途中からの一致はヒットしない
- 一致した部分だけが安い単価で課金され、残りは通常単価
- キャッシュには粒度があるため、数トークン単位の節約に意味はない
つまり「同じ内容を毎回送っているか」ではなく、**「毎回同じ内容が先頭から並んでいるか」**が問われます。 実務でヒット率を落とす最大の原因は、システムプロンプトの先頭に日時・UUID・ユーザー名・取得順が不定な検索結果を置いてしまうことです。
設計1: プロンプトを「不変 → 可変」で並べる
messages 全体を、上から順に安定度が下がるように積みます。
| 位置 | 置くもの | 安定度 |
|---|---|---|
| 先頭 | システムプロンプト、役割定義、出力フォーマット指示 | 完全固定 |
| 次 | ツール定義 / 関数スキーマ | 完全固定 |
| 次 | Few-shot 例 | 固定(順序も固定) |
| 次 | 参照文書(RAG) | 準固定(順序を固定すれば有効) |
| 末尾 | ユーザーの質問、日時、セッション固有情報 | 毎回可変 |
アンチパターンは次のような形です。
# NG: 先頭に可変値。以降すべてのキャッシュが無効化される
messages = [
{"role": "system", "content": f"現在時刻: {now}\nあなたは有能なアシスタントです。\n{system_prompt}"},
...
]
# OK: 固定部分を先頭に、可変値は末尾の user メッセージへ
messages = [
{"role": "system", "content": system_prompt},
{"role": "system", "content": tool_spec},
*few_shot_messages,
{"role": "user", "content": f"[現在時刻: {now}]\n{docs}\n\n## 質問\n{question}"},
]
「時刻をモデルに渡したい」ケースは多いですが、それは先頭に置く必要はありません。末尾に落とすだけで、その手前までのキャッシュが生き残ります。
設計2: マルチターンは「追記のみ」にする
多輪対話は、前のターンまでの内容がそのまま前置きになるため、構造的にはキャッシュと相性が良い形式です。壊れるのは決まって次の操作をしたときです。
- 過去のメッセージを要約して置き換える(会話圧縮)
- 過去の発言を編集・削除する
messagesを毎回ソートし直す、dictの順序が不定なまま組み立てる
会話圧縮は有効な手段ですが、キャッシュを捨てる操作だと理解しておく必要があります。圧縮は「ターン数が増えて入力が膨らんだとき」に限定し、毎ターンは行わないのが定石です。
# 追記のみ。既存要素には触らない
history.append({"role": "assistant", "content": resp.choices[0].message.content})
history.append({"role": "user", "content": next_question})
設計3: RAGの取得順を固定する
RAGは「取得結果が毎回わずかに違う」ため、キャッシュが効きにくい領域です。効かせるコツは、スコア順ではなく安定したキーで並べることです。
def build_context(chunks: list[dict]) -> str:
# 類似度スコア順は毎回ブレるので、doc_id 昇順に正規化する
ordered = sorted(chunks, key=lambda c: (c["source"], c["chunk_id"]))
return "\n\n".join(
f'<doc source="{c["source"]}" id="{c["chunk_id"]}">\n{c["text"]}\n</doc>'
for c in ordered
)
同じ文書集合に対する繰り返し質問(「この仕様書の◯◯は?」を何度も聞くタイプ)では、これだけでヒット率が大きく変わります。 逆に、ユーザーごとに異なる文書を1回ずつ読ませるだけのユースケースでは、キャッシュはほぼ期待できません。「同じ長い前置きを何度も使うか」で投資判断するのが正しい順序です。
計測する: usage の2フィールド
キャッシュは自動で効くので、効いているかを測らないと改善できません。レスポンスの usage にヒット/ミスの内訳が返ります。
import logging
log = logging.getLogger("deepseek.cost")
def log_usage(resp, label: str) -> float:
u = resp.usage
# SDK のバージョンによっては独自フィールドが属性に出ないため、raw から拾う
raw = u.model_dump() if hasattr(u, "model_dump") else dict(u)
hit = raw.get("prompt_cache_hit_tokens", 0) or 0
miss = raw.get("prompt_cache_miss_tokens", 0) or 0
total = hit + miss
rate = hit / total if total else 0.0
log.info(
"llm_usage label=%s prompt_tokens=%d hit=%d miss=%d hit_rate=%.3f completion=%d",
label, total, hit, miss, rate, raw.get("completion_tokens", 0),
)
return rate
見るべきは単価ではなく ヒット率 です。ラベル(機能名・プロンプト種別)ごとに集計し、
- ヒット率が極端に低い=先頭に可変値が混ざっている
- ヒット率は高いが入力トークンが増え続ける=履歴を圧縮していない
の2つをアラート条件にすると、実務では回ります。
バッチ = 「時間をずらす」+「束ねる」
「バッチ」という言葉は2つの別物を指すので分けて考えます。
(a) オフピーク割引(時間をずらす) DeepSeek は過去に時間帯別の割引を提供していましたが、現在の適用有無・時間帯・割引率は公式で確認が必要です。 使えるなら、評価バッチ、埋め込み前処理、夜間の一括要約など「遅延可能な処理」だけを寄せます。オンラインの対話は対象外です。
(b) リクエストを束ねる(呼び出し回数を減らす) OpenAI の Batch API に相当する専用エンドポイントが DeepSeek にあるかは、公式ドキュメントで要確認です(筆者の知る範囲では一般的な Chat Completions 経由での利用が基本)。 専用APIの有無に関わらず効くのは、独立した小タスクを1プロンプトにまとめる方法です。
# 10件を1リクエストに。件数はモデルの出力品質が落ちない範囲で調整する
items = [{"id": i, "text": t} for i, t in enumerate(targets)]
prompt = (
"以下の各テキストを20字以内で要約し、JSON配列で返してください。\n"
"キーは id と summary のみ。\n\n"
+ json.dumps(items, ensure_ascii=False)
)
束ねると出力トークンの総量はほぼ変わらない点に注意してください。削減できるのはリクエストあたりの固定費(オーバーヘッド)と、プロンプトを共有できる分の入力トークンです。凝集度の低いタスクを混ぜると品質が落ちるので、同一フォーマットの反復タスクに限定するのが安全です。
価格は必ず公式で確認する
キャッシュヒット時の単価、モデルごとの単価、オフピーク割引の有無は、この種の記事で最も陳腐化が早い部分です。ブログの二次情報には、モデル名や単価が実在の公式発表と一致しないものも混ざっています。 設計を固める前に、公式の「模型 & 価格」と「使用硬盘缓存」の2ページだけは直接確認してください。本記事の設計原則は単価が変わっても有効ですが、損益分岐の計算は最新単価でやり直す必要があります。
つまずきポイント
| 症状 | 対処 |
|---|---|
| ヒット率がほぼ0 | システムプロンプト先頭の日時・UUID・ユーザー名を末尾へ移動 |
| 途中まで同じなのにヒットしない | 先頭からの一致のみ有効。共通部分は必ず先頭に寄せる |
| RAGでヒット率が安定しない | 取得チャンクを source/chunk_id 昇順に正規化 |
| 会話が長引くとコストが増え続ける | 圧縮はキャッシュ破棄を伴う。ターン数閾値で限定実行 |
usage にヒット数が出ない |
SDKが独自フィールドを属性展開しない。model_dump() か生JSONから取得 |
| 束ねたら要約品質が落ちた | 混在タスクをやめ、同一フォーマットの反復タスクのみ束ねる |
まとめ
- コスト最適化の第一手はモデル変更ではなく、プロンプトを「不変→可変」に並べ替えること
- キャッシュは自動で効くが、トークン0番目からの一致という制約を設計で守る必要がある
- 効果測定は単価ではなく
prompt_cache_hit_tokensベースのヒット率をラベル別に集計する - バッチは「オフピークに寄せる」と「束ねる」の2種類。前者は遅延可能な処理のみ、後者は同一フォーマットの反復タスクのみ
- 単価・割引・モデル名は変動する。設計は原則で固め、数字は公式で都度確認
チェックリスト
- システムプロンプト・ツール定義・Few-shot が
messagesの先頭に固定順で並んでいる - 日時・セッションID・ユーザー名などの可変値が末尾に寄っている
- マルチターンが追記のみで、過去メッセージを書き換えていない
- RAGの参照チャンクを安定キーでソートしている
-
prompt_cache_hit_tokens/prompt_cache_miss_tokensをログに出している - ヒット率を機能ラベル別に集計し、閾値を決めている
- オフピーク割引の現在の適用条件を公式で確認した
- リクエスト束ねの可否を、品質劣化の許容範囲とセットで判断した