pruneで用いるトークン計算の改善

This commit is contained in:
2026-04-14 00:15:09 +09:00
parent 2edc2dc245
commit 5a995cf099
4 changed files with 131 additions and 84 deletions
+13 -77
View File
@@ -1,12 +1,18 @@
//! Conditional Prune algorithm for context window management.
//! Prune — context projection for old tool-result content.
//!
//! Removes `content` from old [`Item::ToolResult`] entries, leaving only
//! their `summary`. This reclaims tokens while preserving the "what
//! happened" trail.
//! LLM 送信時のコンテキストから古い [`Item::ToolResult`] の `content` を
//! 省略して、コンテキスト窓のトークンを回収する。`summary` は残すので
//! 「何が起きたか」の痕跡は保たれる。
//!
//! このモジュールは pure な「候補抽出」と「適用」だけを提供する。
//! `min_savings` 判定や savings 推定はこの crate には置かず、上位層
//! `pod::prune_hook` など)が usage 履歴ベースのトークン会計と組み合わせて行う
//! # 設計方針
//!
//! Prune は **コンテキスト射影** であり、history の変換ではない
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
//! 射影の適用は上位層(`pod::prune_hook` 等)が LLM に送る一時コンテキスト
//! に対してだけ行う。Worker の永続履歴は決して変更されない。
//!
//! `min_savings` 判定や savings 推定もこの crate には置かず、上位層が
//! usage 履歴ベースのトークン会計と組み合わせて行う。
use serde::{Deserialize, Serialize};
@@ -45,13 +51,6 @@ impl Default for PruneConfig {
}
}
/// Result of [`apply_prune`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PruneResult {
/// Number of items whose `content` was set to `None`.
pub pruned_count: usize,
}
/// Find indices where each "turn" begins.
///
/// A turn starts at every user message. Returns the indices of those
@@ -88,24 +87,6 @@ pub fn prunable_indices(items: &[Item], protected_turns: usize) -> Vec<usize> {
.collect()
}
/// Set `content = None` on each item at `indices`. Returns the number
/// of items that were actually modified (already-pruned items are
/// counted as 0).
pub fn apply_prune(items: &mut [Item], indices: &[usize]) -> PruneResult {
let mut count = 0;
for &i in indices {
if let Item::ToolResult { content, .. } = &mut items[i] {
if content.is_some() {
*content = None;
count += 1;
}
}
}
PruneResult {
pruned_count: count,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -158,51 +139,6 @@ mod tests {
}
}
#[test]
fn apply_drops_content_only() {
let big = "x".repeat(64);
let mut items = make_history(&[
("turn1", vec![("s1", Some(&big))]),
("turn2", vec![("s2", Some(&big))]),
("turn3", vec![("s3", Some("keep me"))]),
("turn4", vec![("s4", Some("keep me too"))]),
]);
let candidates = prunable_indices(&items, 2);
let result = apply_prune(&mut items, &candidates);
assert_eq!(result.pruned_count, 2);
for item in &items {
if let Item::ToolResult {
summary, content, ..
} = item
{
if summary == "s1" || summary == "s2" {
assert!(content.is_none(), "old content should be pruned");
} else {
assert!(content.is_some(), "protected content should remain");
}
}
}
}
#[test]
fn apply_is_idempotent() {
let big = "x".repeat(64);
let mut items = make_history(&[
("turn1", vec![("s1", Some(&big))]),
("turn2", vec![]),
("turn3", vec![]),
("turn4", vec![]),
]);
let first_indices = prunable_indices(&items, 2);
assert_eq!(apply_prune(&mut items, &first_indices).pruned_count, 1);
// 2 周目: 候補は (まだ) いるかもしれないが、すでに content=None なので
// apply_prune は 0 件と数える。
let second_indices = prunable_indices(&items, 2);
assert!(second_indices.is_empty());
}
#[test]
fn already_pruned_items_excluded_from_candidates() {
let items = make_history(&[