agentprof 的 「花得值不值」信号全来自 agentprof_core::analyzer 模块:它吃 Episodes + SessionMeta + &[ParseWarning] 三组输入,吐 AnalysisReport。每个 rollup(turn_summary / tool_rank / hook_rank)都是独立可测的纯函数,cache 段和 MCP waste 则是 AnalysisReport 上的派生方法(按需算,不入 pipeline),保证 analyzer 本身没有 I/O、没有时间副作用、可以 snapshot test。
- Event = raw input(adapter 从 jsonl 解析出的原始记录)。
- Episode = staged data(一层规整化:tool call 配对、turn 划分、hook 聚合)。
- AnalysisReport = business view(per-turn / per-tool / per-hook 的 rollup,渲染器直接消费)。
analyzer 模块就是那一层 SQL SELECT ... GROUP BY ... — 输入是 Episodes 这张「staged 表」,输出是若干张 rollup 表(turn_summary / tool_rank / hook_rank)。CacheMetrics 和 WasteReport 是 view-of-view(在 report 上再聚合),不进 transform 层。
3 类 rollup 对比(recon 真实公式)
| Rollup | 关键公式 / 算法 | 输出字段(节选) |
|---|---|---|
turn_summary(episodes) | 按 turn 分组:iterate episodes.turns,每 turn 收集 tool/hook/skill 调用计数 + 累计 duration + assistant 最后一条 message 的 output_tokens | Vec<TurnSummaryRow>:turn_id, started_at, duration: Option<Duration>, status: TurnStatus, model, mode, output_tokens: Option<u32>, tool_call_count, hook_call_count, skill_call_count |
tool_rank(episodes) | 按 tool name 分组:iterate episodes.tools,统计 success/failure/orphan 计数 + 累计 total_duration + 收集 sorted durations 算 percentile_nearest_rank(50.0) / (95.0);最后按 total_duration 降序 | Vec<ToolRankRow>:name, source: ToolSource(Builtin/Mcp/Skill/User/Unknown), call_count, success_count, failure_count, orphan_count, total_duration, p50_duration, p95_duration, max_duration, is_user_blocking |
CacheMetrics::from_raw(creation, read, input)(M2.5 派生,非 pipeline) | honest = 100 × read / (read + creation)naive = 100 × read / (read + input)saved_net = round(read × 0.9 − creation × 0.25) | Option<CacheMetrics>(None 当 creation == 0 && read == 0):creation, read, input, hit_rate_naive_pct, hit_rate_honest_pct, saved_gross: u64, saved_net: i64(可负) |
⚠️ Recon 校正:analyze() 本体只算 turn_summary/tool_rank/hook_rank 这 3 个 rollup,model_metrics 和 loaded_mcp_tools 是从 Episodes 直接 clone() 进 report;cache_metrics 和 WasteReport 是 report 上的派生方法,渲染时按需算。
👇 三张卡片:① analyze() 流水线真实顺序(recon 后修正)· ② Cache 段双 hit rate(ADR-0023 的「honest vs naive」)· ③ MCP waste 的 compute_waste + aggregate_waste 4 层精度。
1 analyze() 流水线(recon 真实顺序) 点击展开
crates/agentprof-core/src/analyzer/mod.rs:415)pub fn analyze(
episodes: &Episodes,
meta: &SessionMeta,
parse_warnings: &[ParseWarning],
) -> AnalysisReport {
let report = AnalysisReport {
meta: meta.clone(),
turn_summary: turn_summary(episodes),
tool_rank: tool_rank(episodes),
hook_rank: hook_rank(episodes),
warnings: episodes.warnings.clone(),
parse_warnings: parse_warnings.to_vec(),
model_metrics: episodes.model_metrics.clone(),
loaded_mcp_tools: episodes.loaded_mcp_tools.clone(),
};
// tracing::debug! 记录 tool/hook count 后 return
report
}
字段构造顺序就是流水线顺序:3 个独立 rollup 函数 + 4 个字段透传(meta / warnings / parse_warnings / model_metrics / loaded_mcp_tools)。没有 cache_metrics 或 waste 字段 — 这俩是 AnalysisReport 上的方法。
3 个 rollup 之间互不依赖(pure functions of Episodes),理论上可并行;当前实现单线程顺序执行 —— 因为单 session 通常 < 10 MB,并行开销大于收益。
turn_summary / tool_rank / hook_rank 这 3 个 pub fn 都 export 出去当然可以(且确实 export 了 — 便于单元测试),但大多数 caller(cli / serve / storage)需要的是一份完整 AnalysisReport 而非单个 rollup。提供 analyze() 这个 single entry 让调用方少写 5 行 boilerplate,也方便后续加 tracing::instrument 统一观测(name = "analyzer.analyze")。analyze() 是纯函数 + 无 I/O:输入是 borrowed refs,输出 owned AnalysisReport,不读文件、不写 SQLite、不发 OTLP — 全部 side effect 在 caller 那侧。这让它在 doctest / unit test / snapshot test 三个层级都 trivial,也是 ADR-0004 「lenient parsing + pure analyzer」分层决策的体现。2 Cache 段双 hit rate(ADR-0023) 点击展开
// crates/agentprof-core/src/analyzer/cache.rs:86
pub fn from_raw(creation: u64, read: u64, input: u64) -> Option<Self> {
if creation == 0 && read == 0 { return None; }
let naive_denom = read.saturating_add(input);
let honest_denom = read.saturating_add(creation);
let hit_rate_naive_pct = 100.0 * (read as f64) / (naive_denom as f64);
let hit_rate_honest_pct = 100.0 * (read as f64) / (honest_denom as f64);
let saved_gross = (read as f64 * CACHE_READ_DISCOUNT).round() as u64;
let saved_net = (read as f64 * CACHE_READ_DISCOUNT
- creation as f64 * CACHE_WRITE_PREMIUM).round() as i64;
Some(Self { creation, read, input,
hit_rate_naive_pct, hit_rate_honest_pct,
saved_gross, saved_net })
}
- honest_pct = read / (read + creation) — 「我尝试 cache 的 token 里,有几成被 reuse 了」。暴露 over-caching:高 creation + 低 read = cache 策略在烧钱。
- naive_pct = read / (read + input) — 「我的 prompt 里有几成走了 cache」。直观但不惩罚 over-caching — creation 多了 naive 也好看(因为分母不变)。
- 两者都报:让用户自己判断 cache 策略是否健康。
// crates/agentprof-core/src/analyzer/cache.rs:19,24
pub const CACHE_READ_DISCOUNT: f64 = 0.9; // cache_read = 10% of input price → 节省 90%
pub const CACHE_WRITE_PREMIUM: f64 = 0.25; // cache_creation = 125% of input → 多花 25%
saved_gross = round(read × 0.9) // 毛节省(input-token equivalent)
saved_net = round(read × 0.9 − creation × 0.25) // 净节省,可负
常数对应 Claude Sonnet 4.x(2026-06 价格表):cache read 价格是 input 的 10%(discount 0.9),cache write 价格是 input 的 125%(premium 0.25)。saved_net 类型是 i64 而非 u64 — 可以为负,表示 cache 策略反而花了更多钱(creation 远大于 read 时)。
agentprof aggregate --by tool 和 --by mcp-server 视图故意省略 cache 列。原因(ADR-0023 D-3 条决策):cache_creation / cache_read 是 prompt-level(API request 维度)token 计数,而 tool / MCP server 是 turn-level 维度 —— per-tool cache attribution 在语义上 undefined(一个 prompt 可能触发 5 个 tool call,cache token 怎么分?)。强行均摊会误导用户,所以这两个 aggregate 视图刻意留空 cache 段;--by model 和 --by day 视图保留 cache 列,因为这两个维度的归并是 well-defined 的。① 只报 saved_net(dollar / token 节省)— 简洁,但用户无法判断 cache 策略本身的健康度。同样的 net 节省,可能来自高效 cache,也可能来自天量 prompt。
② 只报 cache_creation / cache_read 原始值,不算 rate — 信息无损,但要用户脑补算除法。
当前「两个 rate + 一个净值 + 一个毛值」组合,是「教育成本」与「决策支持」的平衡点。
3 MCP waste — compute_waste + aggregate_waste 与 4 层精度 点击展开
// crates/agentprof-core/src/analyzer/waste.rs:402
pub fn compute_waste(report: &AnalysisReport, ctx: &WasteComputeContext) -> WasteReport;
// crates/agentprof-core/src/analyzer/waste.rs:648
pub fn aggregate_waste(per_session: &[(SessionRef, WasteReport)]) -> AggregateWasteReport;
compute_waste(report, ctx)— 单 session:对report.loaded_mcp_tools里每个 tool name,看report.tool_rank里是否真的被调用过,没调用的就是 wasted context;token 估算精度由ctx决定。aggregate_waste(per_session)— 跨 session:把多份WasteReport按 tool / mcp-server 维度合并,输出AggregateWasteReport,给aggregate --by mcp-server渲染用。
WasteComputeContext 的 4 层精度(recon: 3 个 builder + 1 默认)// crates/agentprof-core/src/analyzer/waste.rs:124
pub struct WasteComputeContext<'a> { /* fields ... */ }
impl<'a> WasteComputeContext<'a> {
pub fn new(wire: &'a BTreeSet<String>) -> Self; // 层 1:默认(启发式估算)
pub const fn with_tokenizer(mut self, kind: TokenizerKind) -> Self; // 层 2
pub const fn with_config(mut self, cfg: &'a BTreeMap<String, Vec<String>>) -> Self; // 层 3
pub fn with_sidecar(mut self, sidecar: &'a dyn SidecarLookup) -> Self; // 层 4(最准)
}
| 层 | 配置 | token 估算来源 | 精度 |
|---|---|---|---|
| 1 | 仅 new(wire) | heuristic:每 tool ≈ 100 tokens 默认估值 | ⭐ 粗糙 |
| 2 | + .with_tokenizer(TokenizerKind::O200kBase) | 用 tiktoken-rs 真算 schema 字符数 | ⭐⭐ 中等 |
| 3 | + .with_config(mcp.json) | 读 MCP server 配置,按 declared tool list 算 | ⭐⭐⭐ 较准 |
| 4 | + .with_sidecar(--tool-descriptions) | 读用户提供的真实 schema JSON sidecar,每 schema 单独 tokenize | ⭐⭐⭐⭐ 最准 |
builder 是可叠加的:WasteComputeContext::new(&wire).with_tokenizer(...).with_sidecar(...) 同时启用层 2 + 层 4。当多源信息可用时取最准的;缺什么 fall back 上一层。
infer_tokenizer / build_bpe// crates/agentprof-core/src/analyzer/waste.rs:297
pub fn infer_tokenizer(model: Option<&str>) -> TokenizerKind; // 按 dominant model 推断
// crates/agentprof-core/src/analyzer/waste.rs:267
pub fn build_bpe(kind: TokenizerKind) -> Option<CoreBPE>; // 构造 tiktoken-rs BPE
cli / serve 在调 compute_waste 前用 report.dominant_model() 推断 tokenizer kind(gpt-5 → O200kBase / claude → cl100k_base 近似),自动选最合适的 BPE — 用户不需要手动指定。
compute_waste 算的是「加载了但没被调用」的 schema token —— agentprof 的 ROI 卖点核心。--tool-descriptions sidecar.json 升精度,零配置也能用。下一步
本课讲清了 analyze() pipeline 的真实顺序、CacheMetrics 的双 hit-rate 决策(ADR-0023),以及 MCP waste 的 4 层精度模式。下一课「Tokenizer 与 token 计数」深入 agentprof-core::tokenizer 与 tiktoken-rs 集成的细节 — 为什么 cache 段需要 3 列 token、Claude 用什么 encoding、跨 model 比较为什么要慎重。
📂 相关源码:
agentprof-core/analyzer/mod.rs
analyze
📂 相关源码:
agentprof-core/analyzer/cache.rs
CacheMetrics
📂 相关源码:
agentprof-core/analyzer/waste.rs
compute_waste