update: codexのキャッシュ利用が出来てなかった問題
This commit is contained in:
parent
f1d8f42fd5
commit
c79c54ba9d
1
TODO.md
1
TODO.md
|
|
@ -5,6 +5,7 @@
|
|||
- [ ] Resume 時の Scope claim の改善 → [tickets/resume-scope-claim.md](tickets/resume-scope-claim.md)
|
||||
- [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
|
||||
- [ ] OpenAI Responses: sampling パラメータの取り扱い → [tickets/responses-sampling-params.md](tickets/responses-sampling-params.md)
|
||||
- [ ] OpenAI Responses: prompt_cache_key 送出 → [tickets/responses-prompt-cache-key.md](tickets/responses-prompt-cache-key.md)
|
||||
- [ ] llm-worker のエラー耐性
|
||||
- [ ] HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md)
|
||||
- [ ] ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
|
||||
|
|
|
|||
|
|
@ -210,6 +210,15 @@ struct ResponsesUsage {
|
|||
output_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
total_tokens: Option<u64>,
|
||||
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
|
||||
#[serde(default)]
|
||||
input_tokens_details: Option<InputTokensDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InputTokensDetails {
|
||||
#[serde(default)]
|
||||
cached_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -270,7 +279,10 @@ pub(crate) fn parse_sse(
|
|||
total_tokens: usage.total_tokens.or_else(|| {
|
||||
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0))
|
||||
}),
|
||||
cache_read_input_tokens: None,
|
||||
cache_read_input_tokens: usage
|
||||
.input_tokens_details
|
||||
.and_then(|d| d.cached_tokens),
|
||||
// Responses API は cache 書き込みを別計上しない(input_tokens に含まれる)
|
||||
cache_creation_input_tokens: None,
|
||||
}));
|
||||
}
|
||||
|
|
@ -554,9 +566,31 @@ mod tests {
|
|||
assert_eq!(u.input_tokens, Some(10));
|
||||
assert_eq!(u.output_tokens, Some(20));
|
||||
assert_eq!(u.total_tokens, Some(30));
|
||||
assert_eq!(u.cache_read_input_tokens, None);
|
||||
assert_eq!(u.cache_creation_input_tokens, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_extracts_cached_tokens_from_input_tokens_details() {
|
||||
let data = r#"{"response":{"usage":{
|
||||
"input_tokens":12345,
|
||||
"input_tokens_details":{"cached_tokens":11000},
|
||||
"output_tokens":50,
|
||||
"total_tokens":12395
|
||||
}}}"#;
|
||||
let (events, _) = run("response.completed", data);
|
||||
let Event::Usage(u) = &events[0] else {
|
||||
panic!("expected usage")
|
||||
};
|
||||
assert_eq!(u.input_tokens, Some(12345));
|
||||
assert_eq!(u.output_tokens, Some(50));
|
||||
assert_eq!(u.total_tokens, Some(12395));
|
||||
assert_eq!(u.cache_read_input_tokens, Some(11000));
|
||||
// OpenAI Responses は cache 書き込みを別計上しない
|
||||
assert_eq!(u.cache_creation_input_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_stream_start_delta_stop() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ pub(crate) struct ResponsesRequest {
|
|||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
|
||||
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SessionId`
|
||||
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
/// reasoning 制御。
|
||||
|
|
@ -220,6 +225,7 @@ impl OpenAIResponsesScheme {
|
|||
} else {
|
||||
None
|
||||
},
|
||||
prompt_cache_key: request.cache_key.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -531,6 +537,29 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_passed_through_when_set() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").cache_key("session-abc");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["prompt_cache_key"], "session-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_omitted_when_none() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(body.prompt_cache_key.is_none());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("prompt_cache_key").is_none(),
|
||||
"prompt_cache_key key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_without_properties_is_normalized() {
|
||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||
|
|
|
|||
|
|
@ -455,6 +455,14 @@ pub struct Request {
|
|||
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
||||
/// Providers without prompt caching ignore the field.
|
||||
pub cache_anchor: Option<usize>,
|
||||
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
|
||||
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
|
||||
/// ほぼヒットしないため、pod 側で `SessionId` を渡す運用を想定。
|
||||
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||
/// `prompt_cache_key` を持たない provider は無視する。
|
||||
pub cache_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
|
|
@ -534,6 +542,14 @@ impl Request {
|
|||
self.config.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the conversation cache key.
|
||||
///
|
||||
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
|
||||
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
|
||||
self.cache_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
|||
/// Index of the last stable cache prefix item, set by higher layers.
|
||||
/// Plumbed into [`Request::cache_anchor`] at request build time.
|
||||
cache_anchor: Option<usize>,
|
||||
/// Conversation-scoped cache key, set by higher layers. Plumbed into
|
||||
/// [`Request::cache_key`] at request build time. Pod 側では
|
||||
/// `SessionId` を渡す。
|
||||
cache_key: Option<String>,
|
||||
/// State marker
|
||||
_state: PhantomData<S>,
|
||||
}
|
||||
|
|
@ -392,6 +396,14 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
self.cache_anchor = anchor;
|
||||
}
|
||||
|
||||
/// Set the conversation-scoped cache key. Plumbed into each outgoing
|
||||
/// [`Request`] via [`Request::cache_key`] — caching-aware providers
|
||||
/// that scope cache by an explicit key (OpenAI Responses) read it as
|
||||
/// `prompt_cache_key`. Pass `None` to clear.
|
||||
pub fn set_cache_key(&mut self, key: Option<String>) {
|
||||
self.cache_key = key;
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the timeline (for additional handler registration)
|
||||
pub fn timeline_mut(&mut self) -> &mut Timeline {
|
||||
&mut self.timeline
|
||||
|
|
@ -585,6 +597,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
// if the prune projection trimmed items from the head — keep it
|
||||
// in range).
|
||||
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
|
||||
request.cache_key = self.cache_key.clone();
|
||||
|
||||
request
|
||||
}
|
||||
|
|
@ -1065,6 +1078,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
|||
prune_config: None,
|
||||
savings_estimator: None,
|
||||
cache_anchor: None,
|
||||
cache_key: None,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
|
|
@ -1321,6 +1335,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
|||
prune_config: self.prune_config,
|
||||
savings_estimator: self.savings_estimator,
|
||||
cache_anchor: self.cache_anchor,
|
||||
cache_key: self.cache_key,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
|
|
@ -1400,6 +1415,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
|||
prune_config: self.prune_config,
|
||||
savings_estimator: self.savings_estimator,
|
||||
cache_anchor: self.cache_anchor,
|
||||
cache_key: self.cache_key,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1195,6 +1195,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
.compact_system()
|
||||
.map_err(PodError::PromptCatalog)?;
|
||||
let mut summary_worker = Worker::new(summary_client).system_prompt(summary_system_prompt);
|
||||
summary_worker.set_cache_key(Some(self.session_id.to_string()));
|
||||
|
||||
// Cumulative input-token meter + interceptor. The meter is bumped
|
||||
// from the on_usage callback and read on every pre_llm_request.
|
||||
|
|
@ -1356,6 +1357,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
// can place a durable `cache_control` breakpoint there — our
|
||||
// compact layout guarantees history[0] is the summary.
|
||||
worker.set_cache_anchor(Some(0));
|
||||
// Re-key the OpenAI Responses prompt cache namespace to the new
|
||||
// session_id so post-compact turns share a key with extract /
|
||||
// consolidate workers running in the same session.
|
||||
worker.set_cache_key(Some(new_session_id.to_string()));
|
||||
self.usage_history
|
||||
.lock()
|
||||
.expect("usage_history poisoned")
|
||||
|
|
@ -1545,6 +1550,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
|
||||
let client = self.build_extractor_client(memory_cfg)?;
|
||||
let mut extract_worker = Worker::new(client).system_prompt(extract::EXTRACT_SYSTEM_PROMPT);
|
||||
extract_worker.set_cache_key(Some(self.session_id.to_string()));
|
||||
|
||||
// Cumulative input-token meter + interceptor (mirror of
|
||||
// CompactWorkerInterceptor). Aborts the extract worker if its
|
||||
|
|
@ -1741,6 +1747,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
};
|
||||
let mut worker =
|
||||
Worker::new(client).system_prompt(consolidate::CONSOLIDATION_SYSTEM_PROMPT);
|
||||
worker.set_cache_key(Some(self.session_id.to_string()));
|
||||
|
||||
let input_so_far = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
{
|
||||
|
|
@ -1905,6 +1912,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
|
||||
let mut worker = Worker::new(common.client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
worker.set_cache_key(Some(session_id.to_string()));
|
||||
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
|
|
@ -1965,6 +1973,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
|
||||
let mut worker = Worker::new(common.client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
worker.set_cache_key(Some(session_id.to_string()));
|
||||
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
|
|
@ -2052,6 +2061,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
// overwrite the pieces the session log is authoritative for.
|
||||
let mut worker = Worker::new(common.client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
worker.set_cache_key(Some(session_id.to_string()));
|
||||
if let Some(ref prompt) = state.system_prompt {
|
||||
worker.set_system_prompt(prompt);
|
||||
}
|
||||
|
|
|
|||
91
docs/research/openai_responses_prompt_cache_key.md
Normal file
91
docs/research/openai_responses_prompt_cache_key.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# OpenAI Responses API — `prompt_cache_key` Parameter
|
||||
|
||||
- **Source**: https://platform.openai.com/docs/api-reference/responses/create
|
||||
- **Retrieved**: 2026-05-02
|
||||
|
||||
---
|
||||
|
||||
## 1. パラメータ名
|
||||
|
||||
`prompt_cache_key` — Responses API のリクエスト body に載せる任意の文字列キー。
|
||||
プロンプトキャッシュのスコープを明示する。
|
||||
|
||||
## 2. 必須 / 任意
|
||||
|
||||
**任意 (optional)**。
|
||||
|
||||
公式 OpenAI Responses API では省略しても automatic prefix matching が
|
||||
走るためキャッシュは効く。一方 ChatGPT backend (codex-oauth /
|
||||
`https://chatgpt.com/backend-api/codex/responses`) では **明示キーが
|
||||
無いと事実上ヒットしない**(後述)。
|
||||
|
||||
## 3. ChatGPT backend (`codex-oauth`) でのキャッシュ挙動
|
||||
|
||||
実観測(`019de419-...` セッション、171 turn / 累計入力 22.2M token)で
|
||||
`cache_read_tokens` が全 turn 0 だった。最新セッション (`019de48f-...`,
|
||||
5 turn) でも 0。
|
||||
|
||||
原因:
|
||||
|
||||
1. ChatGPT backend のプロンプトキャッシュは org / project 単位で
|
||||
ハッシュ衝突する設計
|
||||
2. `prompt_cache_key` を送らないと、複数 conversation のリクエストが
|
||||
同じハッシュ空間に積み上がり、prefix が他 conversation で
|
||||
上書きされてヒット率が落ちる
|
||||
3. Codex CLI の実装はこれを認識しており、conversation_id を毎リクエスト
|
||||
送って自分専用の名前空間にキャッシュさせている:
|
||||
|
||||
```rust
|
||||
// codex-rs/core/src/client.rs:853
|
||||
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
|
||||
```
|
||||
|
||||
## 4. 公式 OpenAI API での挙動
|
||||
|
||||
公式エンドポイント (`https://api.openai.com/v1/responses`) では、
|
||||
明示キーが無くても automatic prefix matching が走る。明示キーを
|
||||
送ることで、複数 client / 複数 organization が同じ prefix を共有する
|
||||
シナリオ(マルチテナント等)で意図しないヒット混線を避ける用途
|
||||
で使う。少なくとも害は無いので両 backend で同じ値を送って良い。
|
||||
|
||||
## 5. insomnia での運用
|
||||
|
||||
- `Request::cache_key: Option<String>` を provider-agnostic な
|
||||
キャッシュヒントとして持つ。`cache_anchor` (Anthropic 用 prefix
|
||||
index) と並立する別概念。
|
||||
- `OpenAIResponsesScheme::build_request` で
|
||||
`request.cache_key.clone()` を `prompt_cache_key` に投影。
|
||||
`None` のときは body にキー自体が載らない
|
||||
(`#[serde(skip_serializing_if = "Option::is_none")]`)。
|
||||
- pod 側は LLM 呼び出し時に `SessionId.to_string()` を渡す。
|
||||
主 Run / compactor / extract / consolidate worker のすべてが
|
||||
同じ `session_id` を使うので、pod 内の派生 worker が prefix を
|
||||
共有しているところでヒットが期待できる。
|
||||
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は
|
||||
`Request::cache_key` を未参照のまま無視する。
|
||||
|
||||
## 6. Fork との関係
|
||||
|
||||
`session-store::fork` / `fork_at` はいずれも新 `SessionId` を発行する。
|
||||
新 fork = 新 cache_key とする(素直に `SessionId.to_string()` を渡す)。
|
||||
|
||||
fork 直後の cache 明示ヒットは失われるが、OpenAI Responses は
|
||||
automatic prefix matching も走るため完全に冷えるわけではない。
|
||||
fork 越しに親の cache_key を継承して明示ヒットも残す最適化は
|
||||
別チケット扱い。
|
||||
|
||||
## 7. Compaction との関係
|
||||
|
||||
compaction は session_id を入れ替える (`create_compacted_session`)。
|
||||
compact 直後に worker の `cache_key` も新 session_id で更新するため、
|
||||
post-compact turn は extract / consolidate worker と同じ namespace で
|
||||
動く。compact 自体は prefix を大幅に書き換えるので、明示キー継続の
|
||||
有無に関わらずヒット率は元から低い。
|
||||
|
||||
## 8. ドキュメント URL
|
||||
|
||||
- 公式 API リファレンス: https://platform.openai.com/docs/api-reference/responses/create
|
||||
- Codex CLI 実装 (conversation_id を prompt_cache_key に渡す):
|
||||
https://github.com/openai/codex/blob/main/codex-rs/core/src/client.rs
|
||||
- ChatGPT backend のサポートパラメータ (LiteLLM issue):
|
||||
https://github.com/BerriAI/litellm/issues/21193
|
||||
118
tickets/responses-prompt-cache-key.md
Normal file
118
tickets/responses-prompt-cache-key.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# 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
|
||||
63
tickets/responses-prompt-cache-key.review.md
Normal file
63
tickets/responses-prompt-cache-key.review.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Review: OpenAI Responses prompt_cache_key 送出によるキャッシュ有効化
|
||||
|
||||
## 前提・要件の確認
|
||||
|
||||
### llm-worker 側
|
||||
- `Request` に `cache_key: Option<String>` を追加し doc を整備:
|
||||
`crates/llm-worker/src/llm_client/types.rs:458-465`。
|
||||
`cache_anchor` の直下、要件通りの位置。doc にも「会話単位の安定キー」「`prompt_cache_key` として送られる」「持たない provider は無視」が明記されている。
|
||||
- `Request::cache_key(impl Into<String>)` builder:
|
||||
`crates/llm-worker/src/llm_client/types.rs:546-552`。要件通り。
|
||||
- `OpenAIResponsesScheme::build_request` で `request.cache_key.clone()` を `ResponsesRequest::prompt_cache_key` に投影:
|
||||
`crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs:228`。
|
||||
- `ResponsesRequest::prompt_cache_key: Option<String>` を `#[serde(skip_serializing_if = "Option::is_none")]` 付きで追加:
|
||||
同 `request.rs:51-57`。
|
||||
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は touch されておらず、`Request::cache_key` を未参照のまま無視している(grep 確認済)。要件通り。
|
||||
|
||||
### pod 側
|
||||
- 主 Worker の構築 3 経路すべてで `set_cache_key(Some(session_id.to_string()))` を実施:
|
||||
- `from_manifest`: `crates/pod/src/pod.rs:1915`
|
||||
- `from_manifest_spawned`: 同 `:1976`
|
||||
- `restore_from_manifest`: 同 `:2064`
|
||||
- compactor (`summary_worker`) と memory extract (`extract_worker`) も session_id でキー付け済み:
|
||||
`pod.rs:1198`, `pod.rs:1553`。要件で明示された 3 経路(主 Run / compactor / extract)すべてカバーされている。
|
||||
- `Worker::set_cache_key` の追加と、`build_request` 時の `request.cache_key = self.cache_key.clone()` 投影、`lock()`/`unlock()` 越しの引き継ぎ:
|
||||
`crates/llm-worker/src/worker.rs:193, 399-405, 600, 1338, 1418`。状態遷移で落ちないことが確認できる。
|
||||
|
||||
### docs
|
||||
- `docs/research/openai_responses_prompt_cache_key.md` を新規作成。
|
||||
「ChatGPT backend では prompt_cache_key 必須」「codex-rs の挙動」「insomnia での Fork / Compaction 方針」「公式 API での挙動」「URL」がカバーされている。`openai_responses_max_output_tokens.md` と同じ並び。要件通り。
|
||||
|
||||
### 完了条件
|
||||
- ユニットテスト 2 件:
|
||||
`prompt_cache_key_passed_through_when_set` / `prompt_cache_key_omitted_when_none`
|
||||
(`crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs:540-560`)。
|
||||
body にキーが乗る/省略される、両方を JSON 値で確認している。完了条件を満たす。
|
||||
- `cargo check --workspace` 通過確認済(手元再実行で確認)。
|
||||
- `cargo test -p llm-worker --lib` 121 件パス、`-p provider --lib` 41 件、`-p pod --lib` 133 件パスを確認。
|
||||
- 「pod の Run で codex-oauth + Responses を使ったとき、2 turn 目以降の `cache_read_tokens` が 0 でない」については実セッションログ要観測(実装上は events.rs のパース修正で `cache_read_input_tokens` を埋める経路が出来ており、`prompt_cache_key` も乗ることがテストで確認できているので、計測経路としては揃っている)。これはコード単体では検証できない要件で、実走行に委ねるのが妥当。
|
||||
|
||||
## アーキテクチャ・スコープ
|
||||
|
||||
- llm-worker は provider-agnostic な `cache_key` を持ち、scheme ごとの解釈は scheme 配下で完結。`cache_anchor` (Anthropic 用 prefix index) と並立して別概念として扱う方針が doc とコードの両方で明確。低レベル基盤を歪めず、`cache_anchor` の規約パターンに素直に乗っている。
|
||||
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) はフィールドを未参照のまま残しており、不要な実装拡散がない。
|
||||
- 範囲外項目への踏み込み確認:
|
||||
- **memory consolidate worker への適用** (`pod.rs:1750`): ticket の要件節で明示されているのは Run/compactor/extract の 3 件だが、同節末尾に「compactor / extract のように pod の中で派生する worker でも 同じ session_id を使う。これにより pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き、prefix が共有されるところでヒットが期待できる」というポリシーが明記されている。consolidate も pod 内派生 worker なので、明示列挙されていなくとも policy の対象に当然含まれる解釈で妥当。漏れて consolidate だけ別 namespace になる方が不自然。
|
||||
- **compact 後の re-key** (`pod.rs:1363`): compact 中に `self.session_id = new_session_id` (1333) で session_id 自体が入れ替わる。worker 側のキャッシュキーを古い session_id のまま放置すると、post-compact turn と extract/consolidate (これらは `self.session_id` = 新) で namespace が分裂する。範囲外の「compaction の cache_key 戦略」は「明示キーを別系統に切り替える等の最適化」を指しており、ここは「session_id 一本で動かす」という ticket 末尾の方針 (110行) を素直に維持しているだけ。むしろ re-key しない方がポリシー違反になる。
|
||||
- Worker の構築・状態遷移箇所すべてで `cache_key` をハンドリング (`Worker::new` 初期化、`lock`/`unlock` 引き継ぎ) しており、後段で見落としによる空キー問題が起きない。
|
||||
- `events.rs` の `input_tokens_details.cached_tokens` 取り込みは ticket 本文では「直近のパース修正で復旧した」前提として記述されているが、実際には未コミットだった分が今回まとめて入っている。これは本 ticket 完了の前提として必要な計測経路であり (= cache 効果が実環境で確認可能になる)、ticket の精神を満たすために必要。範囲外項目ではない。
|
||||
|
||||
## 指摘事項
|
||||
|
||||
### Blocking
|
||||
なし。
|
||||
|
||||
### Non-blocking / Follow-up
|
||||
- 完了条件の最後(codex-oauth で実走行して `cache_read_tokens` が 0 でないことを実セッションログで確認)は実装変更だけでは取り込めない。ticket クローズ前に 1 セッション流して `cache_read_input_tokens` がログに出ることを確認してほしい。
|
||||
- ticket 本文では `cache_anchor` の隣としていたが、`Request::cache_anchor` フィールドの doc コメントが英語、`cache_key` は日本語になっている。プロジェクト方針として混在は許容されているように見えるが、両者揃える価値はある。優先度は低い。
|
||||
|
||||
### Nits
|
||||
- `docs/research/openai_responses_prompt_cache_key.md:79-83`「Compaction との関係」セクションは ticket 範囲外項目を補足する形になっているが、現時点の実装方針 (post-compact で new session_id に re-key) と完全整合しているので有用。残してよい。
|
||||
|
||||
## 判断
|
||||
**Approve** — ticket の前提・要件・完了条件はコード上満たされており、ticket 範囲外と明記された fork 越し継承や compaction 戦略への踏み込みも無い。consolidate worker への展開と post-compact re-key は ticket の policy 文 (「pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き」「session_id 一本で動かす」) に沿った最小拡張で、コードベースを歪めていない。残るは実セッションでの `cache_read_tokens > 0` 観測のみで、これは実走確認に委ねるのが妥当。
|
||||
Loading…
Reference in New Issue
Block a user