feat: session-metrics実装

This commit is contained in:
2026-05-03 15:10:43 +09:00
parent 702ed79517
commit 70c4f1930e
15 changed files with 982 additions and 28 deletions
+73 -3
View File
@@ -30,6 +30,43 @@ use crate::llm_client::types::Item;
/// 実際の projection と一致する savings を返す必要がある。
pub type SavingsEstimator = Box<dyn Fn(&[Item], &[usize]) -> u64 + Send + Sync>;
/// Result of one prune evaluation pass, surfaced to the optional
/// [`PruneObserver`] for instrumentation.
///
/// Worker は LLM リクエストごとに 1 回 prune の評価をし、その結果を
/// (observer が登録されていれば)この値で通知する。fire/skip の判定
/// 結果と、判定材料になった候補数 / 推定 savings / 境界ターン位置を持つ。
#[derive(Debug, Clone)]
pub struct PruneEvaluation {
/// `prunable_indices` の長さ。`Skipped::NoCandidates` の時は 0。
pub candidate_count: usize,
/// 推定された savings (tokens)。`NoCandidates` の時は 0。
pub estimated_savings: u64,
/// `protected_turns` 境界に当たる turn-start アイテムの index。
/// turn 数が `protected_turns` 以下で境界が決まらない場合は `None`。
pub border_turn: Option<usize>,
/// 判定結果。
pub decision: PruneDecision,
}
/// Outcome of one prune evaluation. Each variant is one branch of the
/// "fire vs skip" decision tree the Worker walks before each LLM request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PruneDecision {
/// `prunable_indices` が空 → 何もしない。
SkippedNoCandidates,
/// 候補はあったが推定 savings が `min_savings` 未満 → 何もしない。
SkippedBelowMinSavings,
/// 候補があり savings >= min_savings → projection を適用した。
/// `pruned_count` は `project()` が実際に書き換えた item 数
/// (既に content=None だった候補は 0 計上)。
Fired { pruned_count: usize },
}
/// Optional observer invoked after each prune evaluation, regardless of
/// branch. Pod 等の上位層が install して metrics を発行する。
pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>;
/// Configuration for the Prune algorithm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PruneConfig {
@@ -100,12 +137,20 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
/// Returns an empty vector when there are too few turns or no prunable
/// candidates.
pub fn prunable_indices(items: &[Item], protected_turns: usize) -> Vec<usize> {
evaluate_candidates(items, protected_turns).0
}
/// Same as [`prunable_indices`] but also returns the index of the
/// `protected_turns` boundary (the turn-start item whose tail is
/// protected). `None` when too few turns exist for a boundary to be
/// defined.
pub fn evaluate_candidates(items: &[Item], protected_turns: usize) -> (Vec<usize>, Option<usize>) {
let turn_starts = find_turn_starts(items);
if turn_starts.len() <= protected_turns {
return Vec::new();
return (Vec::new(), None);
}
let boundary = turn_starts[turn_starts.len() - protected_turns];
items[..boundary]
let candidates = items[..boundary]
.iter()
.enumerate()
.filter_map(|(i, item)| match item {
@@ -114,7 +159,8 @@ pub fn prunable_indices(items: &[Item], protected_turns: usize) -> Vec<usize> {
} => Some(i),
_ => None,
})
.collect()
.collect();
(candidates, Some(boundary))
}
#[cfg(test)]
@@ -239,6 +285,30 @@ mod tests {
assert_eq!(project(&mut items, &candidates), 0);
}
#[test]
fn evaluate_candidates_returns_boundary_index() {
let big = "x".repeat(64);
let items = make_history(&[
("turn1", vec![("s1", Some(&big))]),
("turn2", vec![("s2", Some(&big))]),
("turn3", vec![("s3", Some("keep"))]),
("turn4", vec![("s4", Some("keep too"))]),
]);
let (candidates, border) = evaluate_candidates(&items, 2);
assert_eq!(candidates.len(), 2);
// protected_turns=2 → boundary は turn3 の user message 位置。
// turn1: u/a/c/r (4) + turn2: u/a/c/r (4) = index 8 (turn3 の user)。
assert_eq!(border, Some(8));
}
#[test]
fn evaluate_candidates_no_boundary_when_too_few_turns() {
let items = make_history(&[("only", vec![("s", Some("x"))])]);
let (candidates, border) = evaluate_candidates(&items, 2);
assert!(candidates.is_empty());
assert!(border.is_none());
}
#[test]
fn protected_turns_boundary_exact() {
// 3 turns with protected_turns=2: only turn 1 is a candidate.
+44 -3
View File
@@ -184,6 +184,9 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
/// by higher layers that own usage measurements. `None` disables
/// the prune projection.
savings_estimator: Option<crate::prune::SavingsEstimator>,
/// Optional observer fired once per prune evaluation (regardless of
/// whether projection actually fired). `None` disables instrumentation.
prune_observer: Option<crate::prune::PruneObserver>,
/// 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>,
@@ -384,6 +387,16 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.savings_estimator = estimator;
}
/// Install an observer notified after each prune evaluation pass.
///
/// Fires once per outgoing LLM request (the same point as the
/// `prune_config` / `savings_estimator` pair), regardless of whether
/// projection actually applied. Intended for upper layers that want
/// to instrument fire/skip rates without owning the prune logic.
pub fn set_prune_observer(&mut self, observer: Option<crate::prune::PruneObserver>) {
self.prune_observer = observer;
}
/// Mark an index into the current history as a stable, cacheable
/// prefix boundary. The value is included in each outgoing
/// [`Request`] via [`Request::cache_anchor`] — caching-aware
@@ -854,9 +867,16 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
// threshold. Worker does not own usage history itself; the
// estimator is injected by the layer that does.
if let (Some(config), Some(estimator)) = (&self.prune_config, &self.savings_estimator) {
let candidates =
crate::prune::prunable_indices(&request_context, config.protected_turns);
if !candidates.is_empty() {
let (candidates, border_turn) =
crate::prune::evaluate_candidates(&request_context, config.protected_turns);
let evaluation = if candidates.is_empty() {
crate::prune::PruneEvaluation {
candidate_count: 0,
estimated_savings: 0,
border_turn,
decision: crate::prune::PruneDecision::SkippedNoCandidates,
}
} else {
let savings = estimator(&request_context, &candidates);
if savings >= config.min_savings {
let pruned = crate::prune::project(&mut request_context, &candidates);
@@ -867,7 +887,25 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
"Projected old tool-result content out of request context"
);
}
crate::prune::PruneEvaluation {
candidate_count: candidates.len(),
estimated_savings: savings,
border_turn,
decision: crate::prune::PruneDecision::Fired {
pruned_count: pruned,
},
}
} else {
crate::prune::PruneEvaluation {
candidate_count: candidates.len(),
estimated_savings: savings,
border_turn,
decision: crate::prune::PruneDecision::SkippedBelowMinSavings,
}
}
};
if let Some(observer) = &self.prune_observer {
observer(&evaluation);
}
}
@@ -1077,6 +1115,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
tool_output_limits: None,
prune_config: None,
savings_estimator: None,
prune_observer: None,
cache_anchor: None,
cache_key: None,
_state: PhantomData,
@@ -1334,6 +1373,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
tool_output_limits: self.tool_output_limits,
prune_config: self.prune_config,
savings_estimator: self.savings_estimator,
prune_observer: self.prune_observer,
cache_anchor: self.cache_anchor,
cache_key: self.cache_key,
_state: PhantomData,
@@ -1414,6 +1454,7 @@ impl<C: LlmClient> Worker<C, Locked> {
tool_output_limits: self.tool_output_limits,
prune_config: self.prune_config,
savings_estimator: self.savings_estimator,
prune_observer: self.prune_observer,
cache_anchor: self.cache_anchor,
cache_key: self.cache_key,
_state: PhantomData,