usage永続化のdoc修正

This commit is contained in:
Keisuke Hirata 2026-04-13 07:13:49 +09:00
parent 101679dbb8
commit d5e2c3819d
4 changed files with 15 additions and 258 deletions

View File

@ -7,4 +7,3 @@
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- [ ] UI用トークン情報の記録run stats の永続化、session-store 後)

View File

@ -54,17 +54,27 @@ pub struct PingEvent {
}
/// 使用量イベント
///
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
///
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
/// 料金会計用。占有量からは差し引かない。
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// 入力トークン数
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
pub input_tokens: Option<u64>,
/// 出力トークン数
/// このリクエストで生成された出力トークン数
pub output_tokens: Option<u64>,
/// 合計トークン数
/// `input_tokens + output_tokens`
pub total_tokens: Option<u64>,
/// キャッシュ読み込みトークン数
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
pub cache_read_input_tokens: Option<u64>,
/// キャッシュ作成トークン数
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分割増料金、Anthropic
pub cache_creation_input_tokens: Option<u64>,
}

View File

@ -1,202 +0,0 @@
# 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) — 上記経由で依存

View File

@ -1,50 +0,0 @@
# usage-history レビュー
## 要件の充足
チケットが定義した要件は全て達成されている:
- **LogEntry::LlmUsage**: session-store のハッシュチェーンに乗る variant として追加。
`history_len` / `input_total_tokens` / `cache_read_tokens` / `cache_write_tokens` / `output_tokens` の5フィールド
- **RestoredState.usage_history**: `collect_state` の replay で `Vec<UsageRecord>` に時系列順で積まれる。history の構築には影響しない
- **save_usage**: `append_entry` 経由でハッシュチェーンに接続
- **既存ログ互換**: `LlmUsage` entry が無い既存ログを読んでも `usage_history` が空になるだけで壊れない
- **1リクエスト = 1 entry**: Timeline の `pending_usage` + `flush_usage()` で複数 Usage event を集約し、handler には1度だけ発火
## アーキテクチャ
レイヤー分担が明確で、各層の責務が逸脱していない:
| レイヤー | 責務 |
|---------|------|
| scheme (anthropic) | raw → 占有量への正規化。`input_tokens + cache_read + cache_creation` |
| Timeline | 1リクエスト内の複数 Usage event をフィールド単位 latest-non-None でマージ。`flush_usage()` で1度だけ発火 |
| Worker | ストリーム完了・エラー・キャンセルの全パスで `flush_usage()` を呼ぶ |
| UsageTracker (pod) | `note_request(history_len)``record_usage(event)` のペアリング。drain で Pod に渡す |
| Pod::persist_turn | drain した records を `save_usage` で session-store に書き出し |
## 指摘と対処
### 1. UsageEvent の doc comment対処済み
`UsageEvent.input_tokens` が「占有量(プロンプト全長、キャッシュ込み)」を意味することが
struct と各フィールドの doc comment に明記された。scheme 層での正規化規約も記載済み。
### 2. save_usage の引数が多い(非ブロッカー、未対処)
8引数。`UsageRecord` を直接受け取れば `drain()` の結果をそのまま渡せてシグネチャがきれいになるが、
他の `save_*` 関数がフラットな引数を取るパターンと一貫しているため、統一性の観点では現状でも妥当。
将来フィールドが増えた時点でまとめて `UsageRecord` 受け取りに変えればよい。
## テスト
- `replay_llm_usage_appends_to_usage_history`: 複数 LlmUsage entry の replay で usage_history が正しく積まれ、history.len() に影響しない
- `replay_without_llm_usage_keeps_usage_history_empty`: 既存ログ互換
- `llm_usage_entry_round_trip_via_json`: serde 往復
- `test_convert_usage_includes_cache_in_input_total`: Anthropic の占有量正規化
- `test_usage_aggregation_and_flush`: Timeline の集約 + flush
- UsageTracker: ペアリング、drain、未ペアの drop、複数リクエスト
## 判定
承認。