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

119 lines
5.6 KiB
Markdown

# 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.rs` の `ResponsesUsage`
`input_tokens_details.cached_tokens` を追加) で計測経路は復旧したが、
それでも 0 が観測される → **server 側でそもそもキャッシュが効いていない**
原因は codex-rs の実装で確定:
```rust
// 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 側
- `Request``cache_key: Option<String>` を追加 (`types.rs:442`
`cache_anchor` の隣)。doc コメントで「会話単位の安定キー。
prompt_cache_key として送られる (OpenAI Responses)。
prefix anchor を持たない provider は無視」を明記
- ビルダ `Request::cache_key(impl Into<String>)` を追加
- `OpenAIResponsesScheme::build_request``request.cache_key.clone()`
`ResponsesRequest::prompt_cache_key` にセット
- `ResponsesRequest``prompt_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
- `SessionId``SharedState::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_request`
`prompt_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 test``llm-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
- 状態: Approve
- レビュー詳細: [./responses-prompt-cache-key.review.md](./responses-prompt-cache-key.review.md)
- 日付: 2026-05-02