feat: session-metrics実装
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user