yoi/tickets/responses-prompt-cache-key.md

5.6 KiB

OpenAI Responses: prompt_cache_key 送出によるキャッシュ有効化

背景

codex-oauth 経由 (ChatGPT backend) の OpenAI Responses API において、 プロンプトキャッシュが事実上効いていない。実セッションログ (019de419-..., 171 turn / 累計入力 22.2M token) で cache_read_tokens が全 turn 0、最新セッション (019de48f-..., 5 turn) でも 0。

直近のパース修正 (events.rsResponsesUsageinput_tokens_details.cached_tokens を追加) で計測経路は復旧したが、 それでも 0 が観測される → server 側でそもそもキャッシュが効いていない

原因は codex-rs の実装で確定:

// codex-rs/core/src/client.rs:853
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());

ChatGPT backend では prompt_cache_key をリクエストに含めないと プロンプトキャッシュが期待通りに動かない (org/project ハッシュが 別 conversation と衝突しやすく、ヒット率が著しく落ちる)。Codex は conversation 単位の安定キーを毎リクエスト付けて、その名前空間内で prefix をキャッシュさせている。

insomnia 側の ResponsesRequest には prompt_cache_key フィールドが 存在せず、Request 構造体にも会話/セッション単位の安定キー概念が無い。 このため codex-oauth で長尺の Run を走らせると毎 turn 全 prefix を 従量課金している。

方針

Request に provider-agnostic な cache_key: Option<String> を 足し、OpenAIResponsesScheme がそれを prompt_cache_key として 送る。pod 側は LLM 呼び出し時に SessionId をキーとして渡す。 他 scheme (Anthropic / Gemini / OpenAI Chat / Ollama) はフィールドを 無視する。既存の cache_anchor (Anthropic 用 prefix anchor) と 同じ「キャッシュヒントを Request に載せ、効く provider だけ拾う」 規約に揃える。

Fork との関係

session-store::fork / fork_at はいずれも新 SessionId を発行する。 本チケットでは 新 fork = 新 cache_key とする (素直に SessionId.to_string() を渡す)。fork 直後の cache 明示ヒットは失われる が、OpenAI Responses は automatic prefix matching も走るため完全に 冷えるわけではない。fork 越しに親の cache_key を継承して明示ヒットも 残す最適化は別チケットで検討する (本ticketの範囲外)。

要件

llm-worker 側

  • Requestcache_key: Option<String> を追加 (types.rs:442cache_anchor の隣)。doc コメントで「会話単位の安定キー。 prompt_cache_key として送られる (OpenAI Responses)。 prefix anchor を持たない provider は無視」を明記
  • ビルダ Request::cache_key(impl Into<String>) を追加
  • OpenAIResponsesScheme::build_requestrequest.cache_key.clone()ResponsesRequest::prompt_cache_key にセット
  • ResponsesRequestprompt_cache_key: Option<String> を追加 (#[serde(skip_serializing_if = "Option::is_none")])
  • 他 scheme (anthropic, gemini, openai_chat) は touch しない (Request の新フィールドを未参照のまま残す)

pod 側

  • LLM クライアントに渡す Request を組み立てる箇所で cache_key(session_id.to_string()) を入れる。少なくとも以下:
    • 主 Run の LLM 呼び出し (pod.rs の Run / Worker 経路)
    • compactor worker
    • memory extract worker
  • SessionIdSharedState::session_id から取得できる (shared_state.rs:21)
  • compactor / extract のように pod の中で派生する worker でも 同じ session_id を使う。これにより pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き、prefix が共有される ところでヒットが期待できる

docs

  • docs/research/ 配下に openai_responses_prompt_cache_key.md (仮) を追加し、「ChatGPT backend では prompt_cache_key 必須」 「codex-rs の挙動」「insomnia での Fork 方針」を残す。 既存の openai_responses_max_output_tokens.md と並びで置く

完了条件

  • Request::cache_key("abc") で組んだリクエストが、 OpenAIResponsesScheme::build_requestprompt_cache_key: "abc" を含む body を生成する (unit test)
  • cache_key = None のときは body に prompt_cache_key キーが 載らない (skip_serializing_if) (unit test)
  • pod の Run で codex-oauth + Responses を使ったとき、2 turn 目 以降の cache_read_tokens が 0 でない (実セッションログで確認)
  • cargo check / cargo testllm-worker, provider, pod で通る

範囲外

  • Fork 越しのキャッシュ継承 (forked_from を辿って root の cache_key を継承する最適化)。別チケット
  • 公式 OpenAI Responses API (非 ChatGPT backend) での prompt_cache_key 必要性検証。少なくとも害は無いので両経路で 同じ値を送って良い
  • compaction で prefix が大きく書き換わる経路の cache_key 戦略 (compaction 後は prefix がほぼ別物なので、ヒット率を最大化する なら compaction 直後だけ別 key にする手もあるが、まずは単純に session_id 一本で動かす)
  • cache_anchor (Anthropic 用) と cache_key (Responses 用) の 統合。両者は別概念 (前者は prefix の境界 index、後者は 名前空間キー) なので並立させる

Review