yoi/tickets/usage-history.md

203 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Usage 履歴の永続化
## 背景
LLM レスポンスから得られる Usage は **プロバイダがリクエスト送信時の history prefix
に対して実測したトークン数**であり、手元にある最も正確なトークン情報。
現状は `CompactState::last_input_tokens` に最新の input_tokens だけを `AtomicU64`
で上書き保存しており、過去履歴も prefix との対応関係も失われている。
これを session-store の append-only ログに積めば、ハッシュチェーンに乗った
tamper-evident な実測値の時系列が得られ、履歴上の任意位置の占有量を逆算できる。
## 動機
- ターンより細かい粒度で「履歴のここまでが N トークン」と言いたい
長く自走するエージェントは1ターンが長いため、ターン境界では粗すぎる
- セッションを restore した直後でも、過去の実測値が手元にある状態にしたい
- Compact / Prune の判断、UI のトークン表示、コスト集計、デバッグ用の
時系列分析、すべての基盤になる
## データモデル
求めているのは **「history のある位置における占有量スナップショット」** であって
LLM 呼び出しの input/output 統計ではない。ただし占有量を実測するための測定値が
そのまま 1 リクエスト = 1 entry の形に乗るので、測定値を素直に保存する。
### `LogEntry::LlmUsage` 追加
```rust
// crates/session-store/src/session_log.rs
LlmUsage {
ts: u64,
/// 送信時の history.len()。以下の測定値はこの prefix に対するもの
history_len: usize,
/// history[..history_len] をプロバイダが実測した占有量(プロンプト全長)。
/// このリクエストで新たに追加したトークン数ではなく、折り返しを想定した prefix 全体。
/// 各プロバイダの正規化:
/// - Anthropic: input_tokens + cache_read + cache_creation
/// - OpenAI: prompt_tokens
/// - Gemini: promptTokenCount
/// - Ollama: prompt_eval_count
input_total_tokens: u64,
/// 上記のうちキャッシュから読み出された分。料金会計用
/// - Anthropic: cache_read_input_tokens
/// - OpenAI: prompt_tokens_details.cached_tokens
/// - Gemini: cachedContentTokenCount
/// - Ollama: 0
cache_read_tokens: u64,
/// 上記のうちこのリクエストでキャッシュに書かれた分。Anthropic のみ非ゼロ
cache_write_tokens: u64,
/// このリクエストで生成された出力トークン数
output_tokens: u64,
}
```
- ハッシュチェーンに乗る (`HashedEntry` の通常 variant として)
- `collect_state` の replay で `RestoredState.usage_history` に積まれる
- 1 リクエスト = 1 entry。Anthropic は message_start と message_delta の2回 Usage
を出すが、llm-worker 側で集約して **完了時の最終値だけ** を pod に渡す
### `RestoredState` 拡張
```rust
pub struct RestoredState {
// ... 既存
pub usage_history: Vec<UsageRecord>,
}
#[derive(Debug, Clone)]
pub struct UsageRecord {
pub history_len: usize,
pub input_total_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub output_tokens: u64,
}
```
`collect_state` の replay で `LlmUsage` entry を見たら `usage_history` に push。
他の variant のように `history` を変化させることはない(独立した時系列)。
### `save_usage` 関数
```rust
// crates/session-store/src/session.rs
pub async fn save_usage(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
history_len: usize,
input_total_tokens: u64,
cache_read_tokens: u64,
cache_write_tokens: u64,
output_tokens: u64,
) -> Result<(), StoreError>;
```
## タイミングと結線
**append のタイミングはリクエスト完了時に 1 回**。送信時には storage に触らない。
### llm-worker 側
- 各プロバイダの scheme で 1 リクエスト内の複数 Usage eventAnthropic の
message_start + message_deltaを集約し、**完了時の最終値だけを 1 つの
`UsageEvent` として外に発火する**。pod 側では暫定値を見ない
- 占有量への正規化Anthropic: `input_tokens + cache_read + cache_creation`)は
各 scheme の `convert_usage` で行い、`UsageEvent.input_tokens` には正規化済みの
占有量プロンプト全長が入る。consumer 側pod / UsageTracker
`event.input_tokens` をそのまま使う
- 動機: 正規化ロジックを scheme に閉じ込めることで、consumer が provider 差異を
意識する必要がなくなる
- `cache_read_input_tokens` / `cache_creation_input_tokens` は内訳として
別フィールドに保持。料金会計用
### pod 側
- LLM リクエスト送信**直前**に `history.len()` を捕捉して stash
`Arc<Mutex<Option<usize>>>` などで on_usage callback と共有)
- `on_usage` callback で stash された `history_len` と Usage の最終値を組にして
`save_usage` を呼ぶ
- 既存の `CompactState::update_input_tokens` 経路はそのまま残してよい
(閾値判定は最新値だけで足りる)。`save_usage` はそれと並列に呼ぶ
結線箇所はおそらく `crates/pod/` 内の既存 `compact_state.rs``on_usage`
callback と同じ場所。
## 任意位置のトークン数を割り出せること
このデータがあれば、後段token-counter`history[..M]` の占有量を逆算できる:
`UsageRecord` から得られるデータ点:
1. **送信時点**: `(history_len, input_total_tokens)` — 完全な実測
2. **応答完了時点**: `(history_len + k, input_total_tokens + output_tokens)`
k は応答で追加された item 数。次の `LlmUsage` の history_len と log 上の
AssistantItems entry から復元できる
`history[..M]` のトークン数を求める手順:
- 上記のデータ点を時系列に並べる
- M がデータ点と一致 → 実測値そのまま
- 2つのデータ点の間 → 区間内 items のバイト数で按分
- 最後のデータ点より新しい → 最後の rate で外挿
途中で user input や hook injected items が入った分は、次の LlmUsage 時点で
再測定されてキャリブレートされるので誤差は永久に蓄積しない。
詳細アルゴリズムと API は [token-counter.md](token-counter.md) で扱う。
## 設計ポイント
- **最細粒度はリクエスト単位**: provider API がそれ以上細かく返さない。
ターンより遥かに細かいのでこれで十分
- **append-only + ハッシュチェーン**: 改ざん検知や fork 検出に乗る
- **collect_state の replay コストはほぼゼロ**: usage_history に push するだけで
history の構築には影響しない
- **既存ログとの互換性**: 古いログには `LlmUsage` entry が存在しないだけ。
`RestoredState.usage_history` が空になる以外の挙動変化は無い
- **占有量とコストの両立**: input_total と cache 内訳を別フィールドに持つので、
Compact/Prune 用の占有量も将来のコスト集計も同じ entry から取れる
## 実装対象
- `crates/llm-worker/`
- 各 provider scheme で 1 リクエスト内の複数 Usage event を集約し、
完了時に 1 度だけ最終値を発火する仕組み
- Anthropic の `cache_read_input_tokens` / `cache_creation_input_tokens`
`UsageEvent` 経由で正しく外に出せていることの確認(既に出している)
- `crates/session-store/src/session_log.rs`
- `LogEntry::LlmUsage` variant 追加
- `UsageRecord` 型追加
- `RestoredState.usage_history: Vec<UsageRecord>` 追加
- `collect_state``LlmUsage` を replay
- `crates/session-store/src/session.rs`
- `save_usage` 関数追加
- `lib.rs` から re-export
- `crates/pod/`
- LLM リクエスト送信直前に `history.len()` を stash する仕組み
`Arc<Mutex<Option<usize>>>` を on_usage callback と共有)
- `on_usage` callback から `save_usage` 呼び出し
- provider 別の占有量正規化Anthropic は cache_read + cache_creation を
足す)をどこに置くか実装時判断
- テスト
- `LlmUsage` を含むログの round-trip
- 複数 entry を replay して `usage_history` が時系列順に積まれる
- 既存ログ(`LlmUsage` 無し)を読んでも壊れない
- Anthropic の cache hit ありレスポンスで input_total が正しく計算される
## レビュー状態
Reviewed — [usage-history.review.md](usage-history.review.md)
## 依存
- なし(前提チケット)
## ブロックする後続
- [token-counter.md](token-counter.md) — この履歴を消費して計算する側
- [compact-improvements.md](compact-improvements.md) — 上記経由で依存