一. 实现的接口

/// Trait for LLM providers.
#[async_trait]
pub trait LlmProvider: Send + Sync {
    /// Get the model name.
    fn model_name(&self) -> &str;

    /// Get cost per token (input, output).
    fn cost_per_token(&self) -> (Decimal, Decimal);

    /// Complete a chat conversation.
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError>;

    /// Complete with tool use support.
    async fn complete_with_tools(
        &self,
        request: ToolCompletionRequest,
    ) -> Result<ToolCompletionResponse, LlmError>;

    /// List available models from the provider.
    /// Default implementation returns empty list.
    async fn list_models(&self) -> Result<Vec<String>, LlmError> {
        Ok(Vec::new())
    }

    /// Fetch metadata for the current model (context length, etc.).
    /// Default returns the model name with no size info.
    async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
        Ok(ModelMetadata {
            id: self.model_name().to_string(),
            context_length: None,
        })
    }

    /// Resolve which model should be reported for a given request.
    ///
    /// Providers that ignore per-request model overrides should override this
    /// and return `active_model_name()`.
    fn effective_model_name(&self, requested_model: Option<&str>) -> String {
        normalized_model_override(requested_model)
            .map(std::borrow::ToOwned::to_owned)
            .unwrap_or_else(|| self.active_model_name())
    }

    /// Get the currently active model name.
    ///
    /// May differ from `model_name()` if the model was switched at runtime
    /// via `set_model()`. Default returns `model_name()`.
    fn active_model_name(&self) -> String {
        self.model_name().to_string()
    }

    /// Switch the active model at runtime. Not all providers support this.
    fn set_model(&self, _model: &str) -> Result<(), LlmError> {
        Err(LlmError::RequestFailed {
            provider: "unknown".to_string(),
            reason: "Runtime model switching not supported by this provider".to_string(),
        })
    }

    /// Calculate cost for a completion.
    fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
        let (input_cost, output_cost) = self.cost_per_token();
        input_cost * Decimal::from(input_tokens) + output_cost * Decimal::from(output_tokens)
    }

    /// Cost multiplier for cache-creation tokens (Anthropic prompt caching).
    ///
    /// Returns `1.0` by default (no surcharge). Anthropic providers return
    /// `1.25` for 5-minute TTL or `2.0` for 1-hour TTL.
    fn cache_write_multiplier(&self) -> Decimal {
        Decimal::ONE
    }

    /// Discount divisor for cache-read tokens.
    ///
    /// Cached-read cost = `input_rate / cache_read_discount()`.
    /// Returns `1` by default (no discount). Anthropic returns `10` (90% off),
    /// OpenAI would return `2` (50% off).
    fn cache_read_discount(&self) -> Decimal {
        Decimal::ONE
    }
}

二. Rig装饰器,将第三方接入自己的trait

pub struct RigAdapter<M: CompletionModel> {
    model: M,
    model_name: String,
    input_cost: Decimal,
    output_cost: Decimal,
    /// Prompt cache retention policy (Anthropic only).
    /// When not `CacheRetention::None`, injects top-level `cache_control`
    /// via `additional_params` for Anthropic automatic caching. Also controls
    /// the cost multiplier for cache-creation tokens.
    cache_retention: CacheRetention,
    /// Parameter names that this provider does not support (e.g., `"temperature"`).
    /// These are stripped from requests before sending to avoid 400 errors.
    unsupported_params: HashSet<String>,
    /// Default additional parameters merged into every request.
    /// Used by providers that need extra top-level fields (e.g., Ollama `think: true`).
    default_additional_params: Option<serde_json::Value>,
    /// Optional model-discovery endpoint. When set, [`LlmProvider::list_models`]
    /// issues a `GET` instead of returning the empty default. rig-core's
    /// `CompletionModel` does not expose model discovery, so this is wired
    /// explicitly per protocol (OpenAI-compatible, Anthropic, Ollama).
    models_endpoint: Option<ModelsEndpoint>,
}

三. 主链和cheap链

RecordingLlm → 
SwappableLlmProvider → 
CachedProvider → 
CircuitBreakerProvider → 
FailoverProvider →  兜底层
SmartRoutingProvider → 智能路由层
RetryProvider → 重试层
裸 provider(Rig),实际发起请求的一层 (主和廉价)

1. RetryProvider

pub struct RetryProvider {
    inner: Arc<dyn LlmProvider>,内部包裹
    config: RetryConfig,//重试次数配置,来自config
}
//重试逻辑  
  async fn retry_loop<T, F, Fut>(&self, mut op: F, label: &str) -> Result<T, LlmError>
    where
        F: FnMut() -> Fut,
        Fut: Future<Output = Result<T, LlmError>>,
    {
        let mut last_error: Option<LlmError> = None;

        for attempt in 0..=self.config.max_retries {
            match op().await {//op是函数参数,调用层的裸provider请求llm
                Ok(resp) => return Ok(resp),
                Err(err) => {
                    //不匹配这些 请求失败、速率限制、无效响应等等直接失败
                    if !is_retryable(&err) || attempt == self.config.max_retries {
                        return Err(err);
                    }


                    /// Calculate exponential backoff delay with random jitter.
///
/// Base delay is 1 second, doubled each attempt, with +/-25% jitter.
/// - attempt 0: ~1s (0.75s - 1.25s)
/// - attempt 1: ~2s (1.5s - 2.5s)
/// - attempt 2: ~4s (3.0s - 5.0s)
                    let delay = match &err {//重试延迟
                        LlmError::RateLimited {
                            retry_after: Some(duration),
                            ..
                        }
                        | LlmError::BadGateway {
                            retry_after: Some(duration),
                            ..
                        } => *duration,
                        _ => retry_backoff_delay(attempt),
                    };

                    tracing::warn!(
                        provider = %self.inner.model_name(),
                        attempt = attempt + 1,
                        max_retries = self.config.max_retries,
                        delay_ms = delay.as_millis() as u64,
                        error = %err,
                        "Retrying after transient error{label}"
                    );

                    last_error = Some(err);
                    tokio::time::sleep(delay).await;//睡眠
                }
            }
        }

        Err(last_error.unwrap_or_else(|| LlmError::RequestFailed {//重试到达最大次数,返回报错
            provider: self.inner.model_name().to_string(),
            reason: "retry loop exited unexpectedly".to_string(),
        }))
    }
}

2. SmartRouter

        let cheap: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
            Arc::new(RetryProvider::new(cheap, retry_config.clone()))
        } else {
            cheap
        };廉价模型也先包重试

**********
pub struct SmartRoutingConfig {
    /// Enable cascade mode: retry with primary if cheap model response seems uncertain.
    pub cascade_enabled: bool,
    /// Custom domain keywords for the scorer (None uses defaults).
    pub domain_keywords: Option<Vec<String>>,domain_keywords 是给打分器自定义领域关键词用的,命中关键词会被认为「这事儿挺专业」,倾向走 primary;不传就用内置默认关键词列表。    
}
**********
/// Configuration for the complexity scorer.
#[derive(Debug, Clone, Default)]
pub struct ScorerConfig {
    /// Weights for each scoring dimension.
    pub weights: ScorerWeights,
    /// Custom domain-specific keywords (overrides defaults if provided).
    /// Each entry is a word or regex pattern fragment.
    pub domain_keywords: Option<Vec<String>>,
}
*****
/// Weights for each of the 13 scoring dimensions.
#[derive(Debug, Clone)]
pub struct ScorerWeights {
    pub reasoning_words: f32,
    pub token_estimate: f32,
    pub code_indicators: f32,
    pub multi_step: f32,
    pub domain_specific: f32,
    pub ambiguity: f32,
    pub creativity: f32,
    pub precision: f32,
    pub context_dependency: f32,
    pub tool_likelihood: f32,
    pub safety_sensitivity: f32,
    pub question_complexity: f32,
    pub sentence_complexity: f32,
}
这 13 个维度是给 prompt 打分的 13 个角度:内容上判"是不是推理 / 代码 / 多步 / 创意 / 精确 / 上下文相关 / 像要调工具 / 涉及安全敏感 / 命中领域词";形式上判"长度 / 句子复杂度 / 问句复杂度 / 含糊程   
  > 度"。每一维按规则打成 0–100,乘以默认权重(来自 ScorerWeights::default())加总,得到的总分决定走 cheap 还是 primary。


  // smart_routing.rs:600 附近
  let total: f32 = [
      ("reasoning_words", weights.reasoning_words),
      ("token_estimate", weights.token_estimate),
      ("code_indicators", weights.code_indicators),
      ("multi_step", weights.multi_step),
      ("domain_specific", weights.domain_specific),
      ("ambiguity", weights.ambiguity),
      ("creativity", weights.creativity),
      ("precision", weights.precision),
      ("context_dependency", weights.context_dependency),
      ("tool_likelihood", weights.tool_likelihood),
      ("safety_sensitivity", weights.safety_sensitivity),
      ("question_complexity", weights.question_complexity),
      ("sentence_complexity", weights.sentence_complexity),
  ]
  .iter()
  .map(|(name, w)| components[*name] as f32 * w)
  .sum();

  每个维度先按上面的规则算成 0–100 的分,再乘以自己的权重,加权求和 → 总分(也是 0–100 量级)。最后总分映射到 TaskComplexity:

  0~20  → Tier::Flash        (闲聊级,几乎一定走 cheap)
  20~40 → Tier::Standard     (标准任务)
  40~70 → Tier::Pro          (专业任务)
  70+   → Tier::Frontier     (前沿/高难度任务,几乎一定走 primary)

• /// 智能路由 provider:根据任务复杂度打分并路由到合适的模型。
  ///
  /// - `complete()` — 按 13 个维度评估复杂度,先检查 pattern 覆写,
  ///   再决定走 cheap 还是 primary。中等复杂度的任务走 cascade
  ///   (先试 cheap,结果不确定时升级到 primary)。
  /// - `complete_with_tools()` — 永远走 primary(工具调用需要可靠的结构化输出)。
pub struct SmartRoutingProvider {
    primary: Arc<dyn LlmProvider>,
    cheap: Arc<dyn LlmProvider>,
    config: SmartRoutingConfig,
    scorer_config: ScorerConfig,//打分配置
    /// Pre-compiled domain regex (built once at construction time).
    domain_regex: Regex,正则匹配
    stats: SmartRoutingStats,//总次数、主模型次数、便宜模型次数、级联次升主模型次数最终叠加到主模型。
}
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
        self.stats.total_requests.fetch_add(1, Ordering::Relaxed);

        let complexity = self.classify(&request);//复杂度判断

        match complexity {
            TaskComplexity::Simple => {
                tracing::trace!(
                    model = %self.cheap.model_name(),
                    "Smart routing: Simple task -> cheap model"
                );
                self.stats.cheap_requests.fetch_add(1, Ordering::Relaxed);
                self.cheap.complete(request).await
            }
            TaskComplexity::Complex => {
                tracing::trace!(
                    model = %self.primary.model_name(),
                    "Smart routing: Complex task -> primary model"
                );
                self.stats.primary_requests.fetch_add(1, Ordering::Relaxed);
                self.primary.complete(request).await
            }
            TaskComplexity::Moderate => {
                if self.config.cascade_enabled {
                    tracing::trace!(
                        model = %self.cheap.model_name(),
                        "Smart routing: Moderate task -> cheap model (cascade enabled)"
                    );
                    self.stats.cheap_requests.fetch_add(1, Ordering::Relaxed);

                    let response = self.cheap.complete(request.clone()).await?;

                    if Self::response_is_uncertain(&response) {//是否包含 im not sure等,包含重新请求主模型
                        tracing::info!(
                            cheap_model = %self.cheap.model_name(),
                            primary_model = %self.primary.model_name(),
                            "Smart routing: Escalating to primary (cheap model response uncertain)"
                        );
                        self.stats
                            .cascade_escalations
                            .fetch_add(1, Ordering::Relaxed);
                        self.stats.primary_requests.fetch_add(1, Ordering::Relaxed);
                        self.primary.complete(request).await
                    } else {
                        Ok(response)
                    }
                } else {
                    // Without cascade, moderate tasks go to cheap model
                    tracing::trace!(
                        model = %self.cheap.model_name(),
                        "Smart routing: Moderate task -> cheap model (cascade disabled)"
                    );
                    self.stats.cheap_requests.fetch_add(1, Ordering::Relaxed);
                    self.cheap.complete(request).await
                }
            }
        }
    }
    fn classify(&self, request: &CompletionRequest) -> TaskComplexity {
        let last_user_msg = request
            .messages
            .iter()
            .rev()
            .find(|m| m.role == Role::User)
            .map(|m| m.content.as_str())
            .unwrap_or("");

        // Normalize: trim whitespace so anchored regexes and token scoring are consistent.
        let last_user_msg = last_user_msg.trim();

        // Highest priority: explicit tier hints (e.g. "[tier:flash]")
        if let Some(caps) = RE_TIER_HINT.captures(last_user_msg) {用户或上级显示提醒按用户的来
            // SAFETY: RE_TIER_HINT has exactly one capture group; get(1) is guaranteed Some after match.
            let tier_str = caps.get(1).expect("capture group 1 exists").as_str(); // safety: RE_TIER_HINT has group 1
            let tier = match tier_str.to_lowercase().as_str() {
                "flash" => Tier::Flash,
                "standard" => Tier::Standard,
                "pro" => Tier::Pro,
                "frontier" => Tier::Frontier,
                other => {
                    tracing::error!(tier = %other, "Unexpected tier in hint despite regex constraint");
                    Tier::Standard
                }
            };
            let complexity = TaskComplexity::from(tier);
            tracing::trace!(
                %tier,
                ?complexity,
                "Smart routing: explicit tier hint"
            );
            return complexity;
        }

        // Fast-path: check pattern overrides
        for po in DEFAULT_OVERRIDES.iter() {//寒暄、日期、审计、漏洞审查,次优先级
            if po.regex.is_match(last_user_msg) {
                let complexity = TaskComplexity::from(po.tier);
                tracing::trace!(
                    tier = %po.tier,
                    ?complexity,
                    "Smart routing: pattern override matched"
                );
                return complexity;
            }
        }

        // Full 13-dimension scoring (uses pre-compiled domain regex)
        let breakdown = score_complexity_with_regex(//13维度打分
            last_user_msg,
            &self.scorer_config.weights,
            &self.domain_regex,
        );
        let complexity = TaskComplexity::from(breakdown.tier);
        tracing::trace!(
            score = breakdown.total,
            tier = %breakdown.tier,
            ?complexity,
            hints = ?breakdown.hints,
            "Smart routing: scored complexity"
        );
        complexity
    }
13维度打分
  ## 组 A:纯正则命中(命中数 × 50,封顶 100)

  这一组完全靠正则 RE_* 在 prompt 里数命中次数:

  score = min(count × 50, 100)

  也就是命中 0 次 = 0 分、1 次 = 50 分、2 次 = 100 分(封顶),3+ 次还是 100。


*************
  ## 组 B:基于 prompt 长度(结构特征)

  ### token_estimate

  // smart_routing.rs:521
  let char_count = prompt.len();
  let token_score = ((char_count as i32 - 20).max(0) as f32 / 5.0).min(100.0) as u32;

  公式:

  score = clamp((char_count - 20) / 5, 0, 100)

  也就是:

  - < 20 chars → 0 分
  - 20 chars → 0 分
  - 25 chars → 1 分(几乎 0)
  - 100 chars → 16 分
  - 200 chars → 36 分
  - 520 chars → 100 分(封顶)
  - > 520 chars → 100 分(封顶)                                                                                                                                                                         

  逻辑简单粗暴:字符数越长,越怀疑复杂。20 chars 以下不扣分(短消息不一定是简单任务,但权重低),超过 520 chars 满分(一定复杂)。

  > 注意这是字符数,不是 token 数。prompt.len() 是 byte length(如果是 ASCII 字符等价于字符数;中文等多字节字符会被高估)。  


*******************

clauses = 逗号数 + 分号数 × 2 + 从句连词数
  score = min(clauses × 12, 100)

  RE_CONJUNCTIONS:and / but / or / however / therefore / because / although / while / whereas / moreover / furthermore。

  分号权重是逗号的 2 倍,因为分号通常意味着真正的语义切分。and / or / but 也算一句之内。

  举例:

   Prompt                                     逗号    分号    连词    clauses     分                                                                                                                     
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━  ━━━━━━  ━━━━━━  ━━━━━━━━━  ━━━━━
   hi                                            0       0       0          0      0
  ─────────────────────────────────────────  ──────  ──────  ──────  ─────────  ─────
   first, then, finally                          2       0       0          2     24
  ─────────────────────────────────────────  ──────  ──────  ──────  ─────────  ─────
   a; b; c; d; e                                 0       4       0          8     96
  ─────────────────────────────────────────  ──────  ──────  ──────  ─────────  ─────
   a, and b, because c, while d, however e       4       0       4          8     96

***********************


  ## 一个细节:为什么大部分系数是 × 50

  因为 2 次命中就满分,对短 prompt + 多个信号词这种典型场景比较友好——只要消息里命中 2 个相关词,就打到 100 分。考虑:

  - 太敏感(× 10):5 次命中才满分,可能漏判
  - 太迟钝(× 100):1 次命中就满分,可能过激
  - × 50 是经验中间值

  ambiguity 用 × 25 是因为 RE_VAGUE 的关键词(it/this/that/something)太常出现,几乎所有 prompt 都会命中几次,需要更宽容。

  ## 一句话总结

  > 13 个维度里有 9 个用「正则命中数 × 系数」算分(系数大多 50,ambiguity 25),2 个走长度(token_estimate 按字符数线性),2 个走复合结构特征(question_complexity 看问号 + 开放式词,sentence_complexity
  > 看逗号/分号/连词)。每个 0-100,乘权重求和得总分。   

3. Failover

failure_threshold 是「连续失败多少次就进冷却」(默认 3),cooldown_duration 是「冷却期持续多久」(默认 300s)。冷却期内 provider 直接跳过、成功一次立刻解封;全部 provider 都冷却时会挑最早进入的那个
  > 硬试一次作为安全网。

        let fallback: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
            Arc::new(RetryProvider::new(fallback, retry_config.clone()))
        } else {//也包重试了

        let cooldown_config = CooldownConfig {
            cooldown_duration: std::time::Duration::from_secs(config.nearai.failover_cooldown_secs),
            failure_threshold: config.nearai.failover_cooldown_threshold,
        };
        Arc::new(FailoverProvider::with_cooldown(
            vec![llm, fallback],//((smart,cheap), fallback)
            cooldown_config,
        )?)
pub struct FailoverProvider {
    providers: Vec<Arc<dyn LlmProvider>>,候选 provider 列表(primary 在前)
    /// Index of the provider that last handled a request successfully.
    /// Used by `model_name()` and `cost_per_token()` so downstream cost
    /// tracking reflects the provider that actually served the request.
    last_used: AtomicUsize,「最近一次成功」= 跨请求全局视角
    /// Per-provider cooldown tracking (same length as `providers`).
    cooldowns: Vec<ProviderCooldown>,每个 provider 各自的失败计数 + 冷却时间戳
    /// Reference instant for computing elapsed nanos. Shared across all
    /// cooldown timestamps so they are comparable.
    epoch: Instant,构造时间锚,所有冷却时间戳共用
    /// Cooldown configuration.
    cooldown_config: CooldownConfig,冷却策略(threshold + duration)
    /// Request-scoped provider index keyed by Tokio task ID.
    ///
    /// This allows `effective_model_name()` to report the provider that handled
    /// the *current* request, even when other concurrent requests update
    /// `last_used`.
    provider_for_task: Mutex<HashMap<tokio::task::Id, usize>>, 「当前请求」实际用的 provider
}
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
        let (provider_idx, response) = self
            .try_providers(|provider| {
                let req = request.clone();
                async move { provider.complete(req).await }
            })
            .await?;
        self.bind_provider_to_current_task(provider_idx);
        Ok(response)
    }

            .try_providers(|provider| {
// Step 1: 划分可用的 / 冷却中的
/ Step 2: 日志
// Step 3: 安全网——所有 provider 都在冷却时挑最早进入的那个硬试
// Step 4: 依次尝试 available 里的 provider
/ Step 6: 所有 available 都失败了,返回最后一次错误

4. CircuitBreakerProvider

        let cb_config = CircuitBreakerConfig {
            failure_threshold: threshold,//熔断上限
            recovery_timeout: std::time::Duration::from_secs(config.circuit_breaker_recovery_secs),//恢复秒数
            ..CircuitBreakerConfig::default()
        };

对内则维护一个三态状态机 Closed → Open → HalfOpen → Closed,用 tokio::Mutex 串行化状态变更。
- Closed(默认):正常放行调用,并累计连续失败次数。
  - Open(熔断):任何调用在 check_allowed 阶段就被拒掉,直接返回 LlmError::RequestFailed,错误里带"连续失败 N 次、还需 X 秒恢复"。这段时间内不再碰后端。
  - HalfOpen(探测):过了 recovery_timeout(默认 30 秒)之后,放过一个探测调用进来:
      - 探测成功并累计到 half_open_successes_needed(默认 2 次)→ 回到 Closed。
      - 探测失败 → 立刻重新进入 Open,重新计时。

pub struct CircuitBreakerConfig {
    /// Consecutive transient failures before the circuit opens.
    pub failure_threshold: u32,
    /// How long the circuit stays open before allowing a probe.
    pub recovery_timeout: Duration,
    /// Successful probes needed in half-open to close the circuit.
    pub half_open_successes_needed: u32,
}
pub struct CircuitBreakerProvider {
    inner: Arc<dyn LlmProvider>,
    state: Mutex<BreakerState>,
    config: CircuitBreakerConfig,
}

5. CachedProvider

pub struct CachedProvider {
    inner: Arc<dyn LlmProvider>,
    /// `std::sync::Mutex` (not tokio) — never held across an `.await` point,
    /// so blocking acquisition is safe and keeps `set_model()` synchronous.
    cache: Mutex<HashMap<String, CacheEntry>>,
    config: ResponseCacheConfig, 1h和1k条
    /// Total `complete()` calls (hits + misses) for periodic stats logging.
    request_count: AtomicU64,
    /// Running total of cache hits, independent of entry lifecycle.
    /// Never decremented on eviction, so `hit_rate_pct` in stats doesn't
    /// drift down as entries expire or are LRU-evicted.
    total_hit_count: AtomicU64,
}
key需要的字段
• cache_key 实际用到的字段就 5 个,我按它们在哈希流里的顺序列一下,并标注是否可选。

   #      字段              类型                   是否可选    来源                                                                                                                                      
  ━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   1      模型名            &str                   否          inner.effective_model_name(request.model.as_deref())
  ─────  ────────────────  ─────────────────────  ──────────  ──────────────────────────────────────────────────────
   2      messages          Vec<ChatMessage>       否          request.messages → serde_json::to_string
  ─────  ────────────────  ─────────────────────  ──────────  ──────────────────────────────────────────────────────
   3      max_tokens        Option<u32>            是          request.max_tokens(小端 4 字节)
  ─────  ────────────────  ─────────────────────  ──────────  ──────────────────────────────────────────────────────
   4      temperature       Option<f32>            是          request.temperature(小端 4 字节)
  ─────  ────────────────  ─────────────────────  ──────────  ──────────────────────────────────────────────────────
   5      stop_sequences    Option<Vec<String>>    是          request.stop_sequences(逐条字符串 + \x00 分隔)

6. SwappableLlmProvider重建链(runtime)

pub struct SwappableLlmProvider {
    state: RwLock<ProviderSnapshot>,//读写锁守护
}
struct ProviderSnapshot {
    inner: Arc<dyn LlmProvider>,
    model_name: &'static str,
    active_model_name: Arc<str>,
    cost_per_token: (Decimal, Decimal),
    cache_write_multiplier: Decimal,
    cache_read_discount: Decimal,
}
    /// Replace the inner provider chain with a freshly rebuilt provider.
    /// Metadata is refreshed atomically in the same critical section.
    pub fn swap(&self, inner: Arc<dyn LlmProvider>) {
        let fresh = ProviderSnapshot::capture(inner);
        *write(&self.state) = fresh;
    }

    fn current(&self) -> Arc<dyn LlmProvider> {
        read(&self.state).inner.clone()
    }
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
        self.current().complete(request).await 所有请求内部的都先调current()获取锁
    }

    fn set_model(&self, model: &str) -> Result<(), LlmError> {
        // Hold the write lock across both the delegate call and the snapshot
        // refresh so a concurrent `swap()` cannot overwrite the just-updated
        // inner provider with a snapshot captured from an older one. Inner
        // `set_model` impls are synchronous (no `.await`), so holding a
        // std::sync lock across the call is safe.
        let mut guard = write(&self.state);
        guard.inner.set_model(model)?;
        let refreshed = ProviderSnapshot::capture(Arc::clone(&guard.inner));
        *guard = refreshed;
        Ok(())
    }

7. RecordingLlm

/// LLM provider decorator that records interactions into a trace file.
把所有流经的 LLM 请求/响应(含工具调用、HTTP 流量、内存快照)序列化进一个 JSON
  trace 文件,专门用于后续回放测试。
pub struct RecordingLlm {
    inner: Arc<dyn LlmProvider>,
    steps: Mutex<Vec<TraceStep>>,// 每一次“循环步骤”的轨迹累积器。每条 TraceStep 通常包含当时的 messages、模型响应、tool calls 等。RecordingLlm 在每次循环结束后 steps.push(TraceStep { ... }),回放时按顺序还原一整轮 agent 行为。
    prev_message_count: Mutex<usize>,// 上一次记录时 messages 的长度,用来在每一步里只输出 增量(新追加的 message),而不是每步都拷贝整个对话历史。这样 trace 文件更紧凑,也便于人眼看 diff。
    output_path: PathBuf,// trace JSON 的落盘路径。可在 RecordingLlm::new 显式传入,或由环境变量 IRONCLAW_TRACE_OUTPUT 控制,默认形如 trace_20260619T103000.json。
    model_name: String,// 写入 trace 顶部的模型名。它是 记录用的标签,并不真正改变 inner 的模型;默认是 "recorded-" + inner.model_name(),也可用 IRONCLAW_TRACE_MODEL_NAME 覆盖,方便区分不同回放用例。
    memory_snapshot: Mutex<Vec<MemorySnapshotEntry>>,工作区 / 长期记忆 (workspace memory) 的快照列表。运行时会调用 take_memory_snapshot() 把当前相关 memory 文档抓进来,写进 trace。回放时用它复现“同一时刻 agent 看到了哪些 memory”,避免重放时检索结果不一
    致。
    http_interceptor: Arc<RecordingHttpInterceptor>,HTTP 出站请求的拦截器。LLM provider 在调用 OpenAI / Anthropic 等外部 API 时,会通过 http_interceptor() 拿到它,把请求 / 响应 含凭证脱敏后的版本记下来,这样 trace 能完整回放外部依赖,又不会泄漏 API
    key。
}
pub struct TraceStep {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_hint: Option<RequestHint>,
    pub response: TraceResponse,
    /// Tool results that appeared in the message context since the previous step.
    /// During replay, the test harness can compare actual tool results against
    /// these to verify tool output hasn't changed (regression detection).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub expected_tool_results: Vec<ExpectedToolResult>,
}
******
pub struct RequestHint {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_user_message_contains: Option<String>,
当前步骤录制时,最近一条 user 消息里的一段稳定子串。
      - 录制时 (recording.rs 中 capture_new_messages) 会从最近一条 Role::User 消息里截取一个稳定前缀(会避开 UUID、时间戳、上一步的 mission id 等会变的部分),把它作为指纹。
      - 回放时 (tests/support/trace_llm.rs 的 TraceLlm) 的匹配策略是:若 trace 队头的步骤有该 hint,且当前请求的 user 消息里包含这个子串,就选它;否则向前扫描,找到第一条 hint 能匹配的步骤。这样在多线程交错
        执行(如 engine v2 mission 子线程和主线程并发调 LLM)时,各自能取到属于自己那一支的步骤。

      - 之所以是 “软校验” 而不是硬相等,是因为提示信息可能在两段运行之间略有变化;匹配失败只 hint_mismatches += 1 并打印警告,不会让测试直接挂掉。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_message_count: Option<usize>,
录制该步骤时,消息历史的长度下限。
      - 回放时被当作 完整性检查:如果当前 messages 数量比 min_message_count 还少,说明前置上下文丢失了,记一次 hint_mismatches 并打印 WARN,提醒这条响应可能与预期不匹配。
      - 它不参与挑选步骤,只做“事后软校验”,用于发现上下文被压缩/截断/丢失的情况。
}
### 录制时:从 user 消息里抽取稳定前缀

  录制器 (recording.rs) 在 stable_hint_prefix 里只截稳定部分,扔掉会变的部分。两条规则:

  规则 1:如果是工具结果 message,以第一个 : 结尾

  完整 user 消息(实际发给 LLM 时的样子,这里 [Tool ... returned: ...] 是经过 sanitize 的 user 消息形式):

  [Tool `mission_create` returned: {"mission_id": "8f3c-2026-06-19-aaaa", "status": "queued", "created_at": "2026-06-19T10:30:00Z"}]

  稳定前缀(只保留到第一个冒号):

  [Tool `mission_create` returned:

  这个值会被写进 last_user_message_contains。注意:

  - mission_id 这种 UUID、时间戳全被扔掉了 → 即使重新跑 mission_create 拿到新 ID,hint 仍然能匹配上,不会因为 ID 不同而失配。
  - : 故意保留 → 防止它和别的 [Tool xxx ... 前缀撞车,保持唯一性。

  规则 2:普通 user 消息 → 截到 80 字节(UTF-8 字符边界)

  完整 user 消息:

  请帮我把 README 里第 3 段的"安装步骤"翻译成英文,保持 markdown 格式,翻译完直接覆盖回原文件。

  稳定前缀(前 80 字节,在字符边界截断,大致就是):

  请帮我把 README 里第 3 段的"安装步骤"翻译成英文,保持 markdown 格式,翻译完直接覆盖回原文件。

  (中文一句话常常不到 80 字节就结束了,这里就是原样保留。)

  ### 回放时:TraceLlm 怎么用它匹配

  假设 trace 里有这样一步:

  {
    "request_hint": {
      "last_user_message_contains": "[Tool `mission_create` returned:",
      "min_message_count": 4
    },
    "response": { "type": "text", "content": "好的,任务已创建。" }
  }

  回放时,当前请求的最近一条 user 消息是:

  [Tool `mission_create` returned: {"mission_id": "NEW-UUID-HERE", "status": "queued"}]

  → 在这条消息里找子串 "[Tool \mission_create` returned:"→ **能找到** → 把队头这步响应"好的,任务已创建。"` 返回给上层。

  如果回放时 user 消息是别的工具结果,比如:

  [Tool `mission_fire` returned: {"ok": true}]

  → 找不到 "mission_create" 子串 → 走 hint scan 向前找,实在找不到就退回到队头那步,并 hint_mismatches += 1 打 WARN。

  ### 测试里的最小例子

  /tests/support_unit_tests.rs:2638 直接构造了一个 hint "hello",验证只要 user 消息里包含 "hello" 就能匹配上:

  RequestHint {
      last_user_message_contains: Some("hello".to_string()),
      min_message_count: Some(1),
  },

  请求是 "say hello please" → 包含 "hello" → 命中 → 返回 "matched",hint_mismatches() 为 0。

  把 "hello" 换成 "banana"、user 消息保持 "apple" → 匹配不上 → 但仍然返回响应(只是 hint_mismatches == 2,一条是 last_user_message_contains,一条是 min_message_count)。这就是“软校验”的含义 —— 匹配错也不阻
  断,只警告。

  ## 一句话总结

  last_user_message_contains 就是一段从 user 消息里挑出来的 “稳定指纹”(通常是 [Tool \xxx` returned:` 这种前半句,或者前 80 字节),用来在并发回放里把 trace 步骤正确地分发给对应的那一轮请求;它避开 UUID/时
  间戳这些运行间会变的部分,所以“同一逻辑点”的步骤,在两次运行里仍然能匹配上。





****
pub enum TraceResponse {
    Text {
        content: String,
        input_tokens: u32,
        output_tokens: u32,
    },
    ToolCalls {
        tool_calls: Vec<TraceToolCall>,
        input_tokens: u32,
        output_tokens: u32,
    },
    /// Marker for a user message that triggered subsequent LLM calls.
    /// Not an LLM response — replay providers must skip these.
    UserInput { content: String },
}
****
pub struct ExpectedToolResult {
    pub tool_call_id: String,
    pub name: String,
    /// The full tool result content as it appeared in the message context.
    pub content: String,
}
****
pub struct RecordingHttpInterceptor {
    exchanges: Mutex<Vec<HttpExchange>>,
}
*****
pub struct HttpExchange {
    pub request: HttpExchangeRequest,
    pub response: HttpExchangeResponse,
}
pub struct TraceFile {
    pub model_name: String,
    /// Workspace memory documents captured before the recording session.
    /// Replay should restore these before running the trace.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub memory_snapshot: Vec<MemorySnapshotEntry>,
    /// HTTP exchanges recorded during the session, in order.
    /// Replay should return these instead of making real HTTP requests.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub http_exchanges: Vec<HttpExchange>,
    pub steps: Vec<TraceStep>,
}

    pub async fn flush(&self) -> Result<(), std::io::Error> {写入文件
        let steps = self.steps.lock().await;
        let memory_snapshot = self.memory_snapshot.lock().await;
        let http_exchanges = self.http_interceptor.take_exchanges().await;

        let trace = TraceFile {
            model_name: self.model_name.clone(),
            memory_snapshot: memory_snapshot.clone(),
            http_exchanges,
            steps: steps.clone(),
        };
        let json = serde_json::to_string_pretty(&trace).map_err(std::io::Error::other)?;
        tokio::fs::write(&self.output_path, json).await?;
        tracing::info!(
            steps = steps.len(),
            memory_docs = memory_snapshot.len(),
            path = %self.output_path.display(),
            "Flushed LLM trace recording"
        );
        Ok(())
    }




    /// Extract new user messages, tool results, and build request hint.
    ///
    /// Returns `(hint, tool_results)` where tool_results are new `Role::Tool`
    /// messages since the last call — these become `expected_tool_results` on
    /// the next step for replay verification.
    async fn capture_new_messages(//录制
        &self,
        messages: &[ChatMessage],
    ) -> (Option<RequestHint>, Vec<ExpectedToolResult>) {
        let mut prev_count = self.prev_message_count.lock().await;
        let current_count = messages.len();
        // After context compaction, the message list may shrink below
        // prev_count.  Clamp to avoid an out-of-bounds slice.
        let start = (*prev_count).min(current_count);

        let new_messages = &messages[start..];

        // Emit UserInput steps for new user messages
        let new_user_messages: Vec<&ChatMessage> = new_messages
            .iter()
            .filter(|m| m.role == Role::User)
            .collect();

        if !new_user_messages.is_empty() {
            let mut steps = self.steps.lock().await;
            for msg in &new_user_messages {
                steps.push(TraceStep {
                    request_hint: None,
                    response: TraceResponse::UserInput {
                        content: msg.content.clone(),
                    },
                    expected_tool_results: Vec::new(),
                });
            }
        }

        // Capture new tool result messages for expected_tool_results
        let tool_results: Vec<ExpectedToolResult> = new_messages
            .iter()
            .filter(|m| m.role == Role::Tool)
            .map(|m| ExpectedToolResult {
                tool_call_id: m.tool_call_id.clone().unwrap_or_default(),
                name: m.name.clone().unwrap_or_default(),
                content: m.content.clone(),
            })
            .collect();

        *prev_count = current_count;

        // Build request hint from last user message.
        //
        // The hint is used by `TraceLlm` for replay matching: a step is a
        // candidate when the *current* request's last user message contains
        // the hint as a substring. So the hint must be (a) distinctive enough
        // to identify the step and (b) free of values that change between
        // runs (UUIDs, timestamps, mission IDs from prior tool calls).
        //
        // Strategy: truncate at the first JSON-content boundary so we keep
        // the stable prefix (e.g. "[Tool `mission_create` returned:") and
        // drop the volatile payload (mission UUIDs, etc.). The fallback is
        // a hard cap at 80 bytes on a UTF-8 char boundary.
        let hint = messages
            .iter()
            .rev()
            .find(|m| m.role == Role::User)
            .map(|msg| {
                let hint_text = stable_hint_prefix(&msg.content);
                RequestHint {
                    last_user_message_contains: Some(hint_text),
                    min_message_count: Some(current_count),
                }
            });

        (hint, tool_results)
    }
********
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {调用llm
        let (hint, tool_results) = self.capture_new_messages(&request.messages).await;
        let response = self.inner.complete(request).await?;

        self.steps.lock().await.push(TraceStep {
            request_hint: hint,
            response: TraceResponse::Text {
                content: response.content.clone(),
                input_tokens: response.input_tokens,
                output_tokens: response.output_tokens,
            },
            expected_tool_results: tool_results,
        });

        Ok(response)
    }
信息够了。下面是一个**完整的、可对照代码**的运行实例。我把录制和回放两边都拆开,跟着 test 里 `validates_request_hints` / `hint_mismatch_warns_but_continues` 的真实调用流程走。

---

# 完整运行示例

## 场景

agent 处理一条 user 输入 `"say hello please"`,调一次 LLM,期望回复 `"matched"`。我们把这次交互**录制下来**,然后**用 trace 回放**,观察:

- `last_user_message_contains` 是怎么填的、怎么匹配的;
- `min_message_count` 是怎么填的、怎么软校验的;
- 不匹配时为什么不挂掉、只 `hint_mismatches += 1`。

测试代码在 [tests/support_unit_tests.rs:2633](/tests/support_unit_tests.rs:2633) 和 [tests/support_unit_tests.rs:2662](/tests/support_unit_tests.rs:2662)。这里把测试展开成一步步走读。

---

## 一、录制阶段(实际跑 agent 时发生的事)

假设 `RecordingLlm` 包着一个真的 provider。某次 agent 循环里,`messages` 长这样(在调用 `complete_with_tools` **之前**):

```text
messages = [
    ChatMessage { role: User, content: "say hello please" },
]
```

`RecordingLlm.complete_with_tools` 内部会先调 `capture_new_messages(messages)`。

### 1. `capture_new_messages` 做什么

逐行跟着 [recording.rs:947](/crates/ironclaw_llm/src/recording.rs:947) 看:

```rust
let mut prev_count = self.prev_message_count.lock().await;
let current_count = messages.len();        // = 1
let start = (*prev_count).min(current_count);  // 起步是 0,min(0,1) = 0
let new_messages = &messages[start..];     // 整段都是新追加的
```

`new_user_messages` 过滤出 user 角色:

```rust
let new_user_messages: Vec<&ChatMessage> = new_messages
    .iter()
    .filter(|m| m.role == Role::User)
    .collect();
// = 1 条 user
```

`steps.push(TraceStep { response: UserInput { ... }, .. })` —— **塞一个标记**:

```rust
steps.push(TraceStep {
    request_hint: None,
    response: TraceResponse::UserInput { content: "say hello please".into() },
    expected_tool_results: Vec::new(),
});
```

`tool_results` 过滤出 tool 角色(本场景没有):

```rust
let tool_results: Vec<ExpectedToolResult> = new_messages
    .iter()
    .filter(|m| m.role == Role::Tool)
    .map(|m| ExpectedToolResult { ... })
    .collect();
// = []
```

更新游标:

```rust
*prev_count = current_count;  // 0 → 1
```

构造 hint(`rev()` + `find`):

```rust
let hint = messages
    .iter()
    .rev()                              // 反向:[user "say hello please"]
    .find(|m| m.role == Role::User)     // 命中第一条
    .map(|msg| {
        let hint_text = stable_hint_prefix(&msg.content);
        // stable_hint_prefix 对非 "[Tool " 开头的 user 消息,就是 80 字节上限
        // "say hello please" 本身很短,原样保留
        RequestHint {
            last_user_message_contains: Some("say hello please".into()),
            min_message_count: Some(current_count),  // = 1
        }
    });
// Some(RequestHint { last_user_message_contains: "say hello please", min_message_count: 1 })
```

返回 `(Some(hint), [])`。

### 2. 调真实 LLM + 记录 step

`RecordingLlm` 继续走(伪代码):

```rust
let response = self.inner.complete_with_tools(messages, tools).await?;
let (hint, tool_results) = capture_new_messages(messages).await;

let mut steps = self.steps.lock().await;
steps.push(TraceStep {
    request_hint: hint,                                 // Some(hint)
    response: TraceResponse::Text {
        content: response.content.clone(),              // "matched"
        input_tokens: response.input_tokens,            // 10
        output_tokens: response.output_tokens,          // 5
    },
    expected_tool_results: tool_results,                // []
});
```

### 3. 最终内存里的 `steps`

```text
steps = [
    // 标记,回放时跳过
    TraceStep {
        request_hint: None,
        response: UserInput { content: "say hello please" },
        expected_tool_results: [],
    },
    // 真正的 LLM 调用
    TraceStep {
        request_hint: Some(RequestHint {
            last_user_message_contains: "say hello please",
            min_message_count: 1,
        }),
        response: Text { content: "matched", input_tokens: 10, output_tokens: 5 },
        expected_tool_results: [],
    },
]
```

### 4. 落盘
shutdown时会刷盘
调 `flush()`(在 [recording.rs:919](/crates/ironclaw_llm/src/recording.rs:919)):

```rust
let steps = self.steps.lock().await;                // 上面这 2 条
let memory_snapshot = self.memory_snapshot.lock().await;
let http_exchanges = self.http_interceptor.take_exchanges().await;  // []

let trace = TraceFile {
    model_name: self.model_name.clone(),
    memory_snapshot: memory_snapshot.clone(),
    http_exchanges,
    steps: steps.clone(),
};
let json = serde_json::to_string_pretty(&trace).map_err(...)?;
tokio::fs::write(&self.output_path, json).await?;
```

落盘后,**内存里的 `steps` 仍然有 2 条**(没有 `clear`)。`memory_snapshot` 同理。只有 `http_interceptor.take_exchanges()` 会清空 HTTP 缓冲。

trace JSON 大致长这样(简化):

```json
{
  "model_name": "test-model",
  "memory_snapshot": [],
  "http_exchanges": [],
  "steps": [
    {
      "response": { "type": "user_input", "content": "say hello please" }
    },
    {
      "request_hint": {
        "last_user_message_contains": "say hello please",
        "min_message_count": 1
      },
      "response": { "type": "text", "content": "matched", "input_tokens": 10, "output_tokens": 5 }
    }
  ]
}
```

---

## 二、回放阶段(用同一个 trace 跑测试)

测试里直接构造 trace(不经过录制),代码是 [tests/support_unit_tests.rs:2633](/tests/support_unit_tests.rs:2633):

```rust
let trace = LlmTrace::single_turn(
    "test-model",
    "say hello please",
    vec![TraceStep {
        request_hint: Some(RequestHint {
            last_user_message_contains: Some("hello".to_string()),
            min_message_count: Some(1),
        }),
        response: TraceResponse::Text {
            content: "matched".to_string(),
            input_tokens: 10,
            output_tokens: 5,
        },
        expected_tool_results: Vec::new(),
    }],
);
let llm = TraceLlm::from_trace(trace);
```

`single_turn` 把这步包成 1 个 turn;`from_trace` 再把所有 turn 的 step 摊平进一个 `VecDeque`。本例只有 1 个 step(没有 `UserInput` 标记,因为是手写的)。

### 调用 `complete_with_tools`

```rust
let resp = llm
    .complete_with_tools(make_request("say hello please"))
    .await
    .unwrap();
```

`make_request` 构造:

```rust
ToolCompletionRequest::new(
    vec![ChatMessage::user("say hello please")],
    vec![],   // 无 tools
)
```

进入 [tests/support/trace_llm.rs:475](/tests/support/trace_llm.rs:475) 的 `next_step`:

```rust
let last_user_content: Option<String> = messages
    .iter().rev()
    .find(|m| matches!(m.role, Role::User))
    .map(|m| m.content.clone());
// = Some("say hello please")
```

走匹配策略(在 [tests/support/trace_llm.rs:506](/tests/support/trace_llm.rs:506)):

```text
steps = [TraceStep { hint.last_user_message_contains = "hello", min=1, response=Text("matched") }]

(1) Head fast path:
    step_matches(head, "say hello please")
        step_hint = "hello"
        last_user_content = "say hello please"
        "hello" 是不是 "say hello please" 的子串? YES
    → pop_front,选 head

(2)(3) 不需要走。
```

然后做 `min_message_count` 软校验([tests/support/trace_llm.rs:542](/tests/support/trace_llm.rs:542)):

```text
hint.min_message_count = 1
messages.len() = 1
1 < 1? NO
→ 不增加 hint_mismatches
```

模板替换:本例是 `Text` 响应,跳过 `ToolCalls` 分支。

返回 step,`TraceLlm` 把 `Text { content: "matched", ... }` 包装成 `LlmResponse` 返回。

### 测试断言

```rust
assert_eq!(resp.content.as_deref(), Some("matched"));   // ✅ 通过
assert_eq!(llm.hint_mismatches(), 0);                   // ✅ 通过
```

---

## 三、不匹配时:依然返回响应,只 WARN

测试 [tests/support_unit_tests.rs:2662](/tests/support_unit_tests.rs:2662):

```rust
let trace = LlmTrace::single_turn(
    "test-model",
    "apple",
    vec![TraceStep {
        request_hint: Some(RequestHint {
            last_user_message_contains: Some("banana".to_string()),
            min_message_count: Some(5),
        }),
        response: TraceResponse::Text {
            content: "still works".to_string(),
            input_tokens: 10,
            output_tokens: 5,
        },
        expected_tool_results: Vec::new(),
    }],
);
let llm = TraceLlm::from_trace(trace);

let resp = llm
    .complete_with_tools(make_request("apple"))   // 当前 user="apple"
    .await
    .unwrap();
```

走匹配:

```text
steps = [TraceStep { hint.last_user_message_contains = "banana", min=5, response=Text("still works") }]

(1) Head fast path:
    step_matches(head, "apple")
        step_hint = "banana"
        "banana" 是不是 "apple" 的子串? NO
    → 不选 head

(2) Hint scan:
    steps.len() = 1,扫描范围 (1..1) 为空
    → 找不到

(3) Legacy fallback:
    hint_mismatches += 1   ← ① "banana" 不在 "apple" 里
    eprintln!("[TraceLlm WARN] Request hint mismatch: expected last user message to contain \"banana\", got \"apple\"")
    pop_front → 选 head 这步

然后做 min_message_count 软校验:
    messages.len() = 1,min_message_count = 5
    1 < 5? YES
    hint_mismatches += 1   ← ② "expected >= 5 messages, got 1"
    eprintln!("[TraceLlm WARN] Request hint mismatch: expected >= 5 messages, got 1")

返回 step → 返回响应 "still works"
```

测试断言:

```rust
assert_eq!(resp.content.as_deref(), Some("still works"));   // ✅ 依然有响应
assert_eq!(llm.hint_mismatches(), 2);                       // ✅ 两次不匹配都记上
```

**这就是“软校验”的完整含义:**即使两个 hint 都不匹配,trace 仍然继续往下走、上层代码不会因为回放失败而崩溃,只是计数 + WARN。这种设计让 e2e 测试在 trace 略有漂移时仍然能跑,而不是“一个 hint 错就全挂”。

---

## 四、对应到 `RecordingLlm` 里

`RecordingLlm` 的录制端并不调用 `TraceLlm::next_step` —— 那个匹配逻辑只在**回放端**用。录制端只负责往 `steps` 里塞 step 并附上 hint;回放端 (`TraceLlm`) 才用 hint 决定“当前请求该用 trace 队列里的哪一步响应”。两端分工是:

| 阶段 | 谁 | 干什么 |
|---|---|---|
| 录制 | `RecordingLlm::complete_with_tools` | 调真实 LLM,把响应 + 这次调用前夕的 `RequestHint` 一起 push 进 `steps` |
| 录制 | `RecordingLlm::flush` | 把 `steps` / `memory_snapshot` / `http_exchanges` 克隆后写盘,**不清内存** |
| 回放 | `TraceLlm::from_trace` | 把 trace 摊平成 step 队列 |
| 回放 | `TraceLlm::next_step` | 按 head-match / hint-scan / legacy-fallback 挑步,跑完软校验,返回响应 |

---

## 五、把录制端和回放端拼起来的完整一帧

把上面所有片段拼起来,一次 agent 循环里的“录制 → 落盘 → 另一进程回放”长这样:

```text
[进程 A:真实运行]
  messages = [User("say hello please")]
  → capture_new_messages
    · push UserInput 标记
    · 构造 hint = { last_user_message_contains: "say hello please", min_message_count: 1 }
  → inner.complete_with_tools → "matched"
  → push 真实 step: Text("matched") + hint + expected_tool_results=[]
  → flush → 写 trace_20260619T103000.json
    (内存里 steps 仍然是 2 条,没清)

[进程 B:测试回放]
  TraceLlm::from_file("trace_...json")
    → step 队列 = [UserInput, Text("matched")]
  测试调用 complete_with_tools([User("say hello please")])
    → 跳过 UserInput
    → head-match: "say hello please" 含 "say hello please" ✅
    → 软校验: 1 ≥ 1 ✅
    → 返回 Text("matched")
  assert_eq!(resp.content.as_deref(), Some("matched"))   ✅
```

这就是 `RecordingLlm` + `TraceLlm` 完整的一轮运行。后面所有 agent 步骤都按这个套路循环往复 —— 每条 user 消息打一个 `UserInput` 标记、每次 LLM 调用打一个 `Text` 或 `ToolCalls` step、每个 tool 结果合并到下一次调用的 `expected_tool_results` 里,直到 `flush()` 把整张时间线落盘,测试下次再 `from_file` 拿出来重放。
你这个疑问很合理——既然 hint 已经在 step 上标识了"哪条 user message 触发的",那把 user message 的内容**再单独存一个 `UserInput` step** 是不是冗余?

答案是 **不冗余,但 hint 和 UserInput step 用途完全不同**。让我把两条信息流分开看。

## 两条独立的信息流

trace 文件其实在记录两个维度的东西:

| 维度                    | 承载字段                       | 服务对象                | 时机                                                        |
| ----------------------- | ------------------------------ | ----------------------- | ----------------------------------------------------------- |
| **谁是源头**            | `UserInput` step 序列          | 读 fixture 的人 + 调试  | 任何一次 `complete()` 调用前,发现有新 user message 就 push |
| **每次 LLM 调用干了啥** | `Text`/`ToolCalls` step + hint | replay 引擎(TraceLlm) | 每次 `complete()` 调用后 push 一个                          |

这两个维度**不等价**:

- `UserInput` step **只要有新 user message 就 push**,可能 0 个,可能 N 个
- `Text`/`ToolCalls` step **每次 LLM 调用都 push 一个**,但 hint 是从"最后一条 user message"截的稳定前缀

## `UserInput` step 的真正用途

### 1. 让 fixture 自描述(人类读 trace 时用)

看一个具体的 fixture(`tests/fixtures/llm_traces/engine_v2/multi_turn.json` 里有两个 hint `"TestUser"` 和 `"name"`)。如果 trace 文件里只有 `Text`/`ToolCalls` step,**没有 `UserInput` step**,一个读 fixture 的人怎么知道:

- 第 0 条 step 的 hint `"TestUser"` 来自哪条 user message?
- 那条 user message 是怎么演变成第 2 条 step 的 hint `"name"` 的?
- 中间有没有用户补充输入?

`UserInput` step 就是 **"在这次 LLM 调用之前,agent 看到的新输入是什么"** 的精确记录。它不是 hint 的副本——hint 只存了稳定前缀(往往被 `stable_hint_prefix` 截短过),`UserInput.content` 存的是**完整原文**。

### 2. 多 user message 情况下的精确边界

agent loop 不止会"用户发一条消息 → LLM 回复一次"。还可能是:

```
[user1] → LLM 调用 1 (UserInput step 1 + ToolCalls step 1)
[user2] → LLM 调用 2 (UserInput step 2 + Text step 2)
```

`UserInput` step 让你能精确区分"哪条 user message 触发了哪次 LLM 调用"。如果只有 hint,所有 hint 都从"messages 里最后一条 user message"取,那么 step 1 和 step 2 的 hint **都可能指向 user2**(如果 user2 出现在 user1 之前加进来的话)——这时候 hint 就不足以区分 step 之间的边界。

### 3. replay 时的对齐校验

replay 时(`TraceLlm`)会用 hint 匹配 step。但还有一个隐含假设:**当前 messages 列表里最后一条 user message 必须是 trace 里某个 `UserInput` step 的内容**。`UserInput` step 是这个假设的 ground truth。

如果只有 hint 没有 `UserInput` step,replay 引擎要自己从消息流里"猜"哪条 user message 对应哪个 step;有了 `UserInput` step,回放校验就是直接的字符串匹配。

### 4. expected_tool_results 必须挂在输出 step 上的副作用

`UserInput` step 的 `expected_tool_results: Vec::new()` 不是"我不知道填啥所以留空"——是**故意为零**。原因前面说过:

> `expected_tool_results` 是用来在 replay 时验证"这次 LLM 调用看到的 tool 结果是否和录制时一致"。`UserInput` step 没有 tool 结果可言——用户消息进来时不会同时附带 tool 结果。

`UserInput` step **定义**上就是"输入事件",不是"输出事件",所以它的 `expected_tool_results` 永远是空。这反过来证明了 `UserInput` step 是**独立的输入维度**,不是 hint 的副本。

## hint 不能取代 UserInput step 的三个根本原因

| 维度         | hint                                               | UserInput step                                                                 |
| ------------ | -------------------------------------------------- | ------------------------------------------------------------------------------ |
| **数据粒度** | 截断后的稳定前缀(去掉了 UUID/timestamp 等)       | 完整原文                                                                       |
| **时机**     | 每次 LLM 调用都有一个,从"最后一条 user message"取 | 只在新 user message 出现时 push,跨多次 LLM 调用同一 user message 只 push 一次 |
| **对象**     | 标识 step("我是被哪条输入触发的")                | 标识输入事件("我是一条新的用户输入")                                         |

**hint 标识 step(step 的指纹);UserInput step 标识输入事件(输入事件的完整记录)**。两者一前一后配合:先有 `UserInput` step 记录"发生了什么新输入",再有 `Text`/`ToolCalls` step 记录"LLM 在这次输入下做了什么"。

## 一个反直觉但关键的点

**hint 不一定从它"自己那次" LLM 调用之前的新 user message 取**。看那段 hint 生成代码:

```rust
let hint = messages.iter().rev()
    .find(|m| m.role == Role::User)
    .map(|msg| { ... RequestHint { last_user_message_contains: ... } });
```

它永远从 **`messages` 列表里最后一条 user message** 取,不管这一次 LLM 调用之前有没有新 user message。这意味着:

- 第 0 次 LLM 调用:messages 末尾 user = "list jobs, then cancel" → hint 取这个
- 第 5 次 LLM 调用(tool result 回灌后):messages 末尾 user **还是** "list jobs, then cancel"(因为没新 user message)→ hint 还是这个

**5 次 LLM 调用共用同一个 hint**,所以光靠 hint 你根本无法知道第 1、2、3、4 次调用之间发生了什么新输入。`UserInput` step 序列才是真正的"输入事件日志"。

## 类比

- hint = 邮件主题行(标识这一封属于哪个会话,但所有回复邮件共用同一主题)
- UserInput step = 邮件正文(每条独立信息一条记录)
- Text/ToolCalls step = 回复邮件正文(每封回复一条记录,挂在主题上)

如果邮件系统只存主题不存正文,你看到一串同主题的回复但完全不知道每封回复之前发生了什么。`UserInput` step 就是把"会话主题下的输入流"展开成可读的序列。

## 小结

- `UserInput` step 不是 hint 的副本——**hint 是 step 指纹(标识输出 step 属于哪个会话),UserInput step 是输入事件日志(记录哪些新输入发生过)**
- 没有 UserInput step,replay 引擎就只能靠 hint 字符串反查 step,但**hint 跨多次 LLM 调用不变**,无法区分同会话里的多次输入
- 调试 / 审计 trace 文件时,`UserInput` step 是**唯一**能告诉你"这次 LLM 调用之前发生了什么新输入"的字段
- `expected_tool_results: Vec::new()` 是**定义性的**,不是漏填——UserInput step 在语义上就属于"输入事件",跟"输出校验"是两个维度

如果你想进一步验证这个理解是对的,可以去看 `tests/support/trace_llm.rs` 里 `step_hint()` 的用法——它只读 hint 字段,从来不读 UserInput step 的 content 来做匹配。这两个字段从一开始就是设计成**独立使用**的。

四. 重载句柄reload_handle(不在链上)

pub struct LlmReloadHandle {
    primary: Arc<SwappableLlmProvider>,
    cheap: Option<Arc<SwappableLlmProvider>>,
    /// Serializes concurrent `reload()` calls so rapid setting toggles
    /// don't fire overlapping chain rebuilds (each rebuild can touch OAuth
    /// refresh and HTTP probes; letting them pile up wastes upstream quota
    /// and leaves the wrapper briefly pointing at a half-built chain).
    reload_lock: tokio::sync::Mutex<()>,
}
/// Rebuild the provider chain from `config` and atomically replace the
    /// inner providers of the primary (and cheap, if present) wrappers.
    ///
    /// Reloads are serialized so two concurrent callers cannot race.
    pub async fn reload(
        &self,
        config: &crate::LlmConfig,
        session: Arc<crate::SessionManager>,
    ) -> Result<(), LlmError> {
        let _guard = self.reload_lock.lock().await;

        let components = crate::build_provider_chain_components(config, session).await?;

        self.primary.swap(components.primary);

        if let Some(ref cheap_handle) = self.cheap {
            let new_cheap = components
                .cheap
                .unwrap_or_else(|| self.primary.clone() as Arc<dyn LlmProvider>);
            cheap_handle.swap(new_cheap);
        } else if components.cheap.is_some() {
            // Asymmetry: no cheap wrapper was allocated at startup, so a
            // newly-configured cheap model cannot be activated via hot-reload.
            // Surfacing this through tracing so ops don't think the swap
            // silently took effect.
            tracing::warn!(
                "llm hot-reload: cheap provider is now configured but was not at startup; \
                 it will only take effect after a full restart",
            );
        }

        Ok(())
    }原子化重建主和cheap

五. TraceLLM(回放)

1. 结构

pub struct LlmTrace {
    pub model_name: String,
    pub turns: Vec<TraceTurn>,
按 user 消息切分的轮次。每个 TraceTurn = { user_input, steps, expects }。

  - 这是回放的主驱动数据。TraceLlm::from_trace 把所有 turn 的 steps 摊平成一个 VecDeque 顺序消费(在 /tests/support/trace_llm.rs:430)。
  - 用 turn 而不是单一扁平 step 数组,是因为 user 输入天然是时间线的边界;一层 turn 让 “这一步是回应哪条 user 消息” 在 JSON 里一目了然。
  - JSON 向后兼容:老的扁平 "steps" 数组(没有 "turns")在反序列化时会被 LlmTrace 按 UserInput 边界自动切分成 turn,见 doc-comment。
    /// Workspace memory documents captured before the recording session.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub memory_snapshot: Vec<MemorySnapshotEntry>,
    /// HTTP exchanges recorded during the session, in order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub http_exchanges: Vec<HttpExchange>,
    /// Declarative expectations for the whole trace (optional).
    #[serde(default, skip_serializing_if = "TraceExpects::is_empty")]
    pub expects: TraceExpects,
    /// Raw steps before turn conversion (populated only for recorded traces).
    /// Used by `playable_steps()` for recorded-format inspection.
    #[serde(skip)]
    #[allow(dead_code)]
    pub steps: Vec<TraceStep>,
}
    /// Load a trace from a JSON file.
    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
        let contents = std::fs::read_to_string(path)?;
        let trace: Self = serde_json::from_str(&contents)?;
        Ok(trace)
    }
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
        // complete() is called when Reasoning has force_text=true (no tools
        // available). Skip any remaining ToolCalls steps in the trace and
        // return the next Text step, since in real usage the LLM would
        // produce text when no tools are offered.
        loop {
            let step = self.next_step(&request.messages, None)?;//从录制找到匹配的结果
            match step.response {
                TraceResponse::Text {
                    content,
                    input_tokens,
                    output_tokens,
                } => {
                    return Ok(CompletionResponse {
                        content,
                        input_tokens,
                        output_tokens,
                        finish_reason: FinishReason::Stop,
                        reasoning: None,
                        cache_read_input_tokens: 0,
                        cache_creation_input_tokens: 0,
                    });
                }
                TraceResponse::ToolCalls { .. } => {
                    // Skip tool_calls steps — complete() is called in
                    // force_text mode so the LLM can't use tools anyway.
                    continue;
                }
                TraceResponse::UserInput { .. } => {
                    return Err(LlmError::RequestFailed {
                        provider: self.model_name.clone(),
                        reason: "TraceLlm::complete() encountered a user_input step; \
                                 these should have been filtered out during construction"
                            .to_string(),
                    });
                }
            }
        }
    }

2. 理论

  • Trace 回放验证的不是 LLM,而是 agent 系统在改动后是否还按原有路径工作。

这是一个**架构层面**的问题,值得展开讲。IronClaw 这套 `RecordingLlm` + `TraceLlm` 不是普通的 “录个 log”,而是为了 **离线、确定、可回归测试**而设计的一整套 LLM 替身系统。

## 一、为什么需要这套机制

真实 LLM 调用有三个根本性难题,这套机制就是用来解决它们的:

### 1. 不确定性
- 真实模型每次返回的文本、tool call 参数、甚至**是否调用某个 tool**,都可能因为温度、版本、prompt 微小改动而不同。
- 断言 “agent 在这个 user 输入下返回 ‘Hello world’” 几乎注定不稳定 —— 哪怕只是升级模型版本,文本就变了。

### 2. 成本
- 一个 e2e 测试可能跑几十轮 LLM 调用(agent 循环 + tool 反馈循环),用真实模型 = 每次 `cargo test` 都消耗 token、花钱、慢。

### 3. 外部依赖
- 真实模型要联网、有 rate limit、有可用性问题,CI 网络抖动就会让测试 flaky。

`RecordingLlm` + `TraceLlm` 把 **真实模型从测试路径里彻底剔除**,换成 **录制好的固定响应**。

## 二、录制阶段解决什么

**目的**:把 “agent 在真实环境下跑一次” 的全过程 **冻结成一份 trace 文件**,作为后续回放的 “剧本”。

具体能力:

### a) 把 LLM 输出当成 fixture 固化
录制时调真实模型,把返回的 `content` / `tool_calls` / `input_tokens` / `output_tokens` **原样抄进 JSON**。之后所有回放都用这份 JSON,完全不依赖真实模型。

### b) 捕获非确定变量 → 模板化
录制时检测 tool call 参数里的 **跨步骤引用**(比如第二步 `mission_fire` 引用第一步 `mission_create` 产生的 `mission_id`),把字面值替换成模板 `{{call_id.field}}`。这样回放时即使 `mission_create` 产生新 ID,`mission_fire` 也能拿到对的 ID,跨运行对齐。

### c) 捕获上下文快照
- `memory_snapshot`:会话开始时 workspace memory 的样子 → 回放时哪怕真实 memory 被改了,也能复现 agent 当初看到的视野。
- `http_exchanges`:工具发的 HTTP 请求/响应(已脱敏)→ 回放时不再打外网,直接拿录制响应。

### d) 打指纹(用于并发回放)
每一步记 `request_hint.last_user_message_contains`(user 消息稳定前缀)和 `min_message_count`(消息总数下限),回放时用来挑步骤 + 软校验。

## 三、回放阶段解决什么

**目的**:让测试在 **完全离线、完全确定性** 的情况下重放 agent 行为,并且能精确断言。

### a) 离线、不耗 token、不依赖外网
CI 上跑测试不需要 OpenAI / Anthropic key,也不消耗配额。

### b) 确定性
trace 写定 → 响应就定 → agent 行为就定。多次运行字节级一致。

### c) 跨运行 ID 对齐
模板 `{{call_id.field}}` 在回放时被当前请求里 tool 结果的实际值替换,解决 “录制 ID ≠ 回放 ID” 的经典问题。

### d) 并发回放
mission 子线程 + 主线程交错执行时,hint-based matching 让各自挑到属于自己的步骤,不靠 “必须按录制顺序”,这是 engine v2 并发架构的必要支撑(在 [tests/support/trace_llm.rs:259](/tests/support/trace_llm.rs:259) doc-comment 里讲得很明确)。

### e) 软校验 + 异常退路
- `min_message_count` 不达标 → `hint_mismatches += 1` + WARN,但不阻断 —— 上下文丢失能被看到,但不会让整条测试链断掉。
- 完全没有 hint 匹配(规则 3 fallback) → 仍然返回队头响应,WARN,响应正常 —— 兼容老 trace,允许 trace 漂移。

### f) 多档断言能力
- **值断言**:`assert_eq!(resp.content.as_deref(), Some("matched"))` —— 直接比对回放值。
- **请求快照**:`captured_requests[i]` 看第 i 次调用发出去的完整 messages,验证 dispatcher 给模型发了什么。
- **Tool 表面快照**:`captured_tool_definitions[i]` 看第 i 次调用暴露了哪些 tool,验证 runtime policy filtering。
- **声明式期望**:`expects.response_contains` / `tools_used` / `tools_order` 等,跨多步骤聚合断言。

## 四、这套机制典型用在哪些场景

### 1. e2e / Tier B 测试
agent 跑真实场景(几十轮 LLM + tool 调用),期望最终结果符合 `expects`。录制一次,反复回放验证不回归。

### 2. Tool 覆盖测试
[tests/e2e_builtin_tool_coverage.rs](/tests/e2e_builtin_tool_coverage.rs) 用 trace 验证每个 builtin tool 在典型场景下被正确调用 —— 不靠真实模型,纯靠回放验证 agent 决策路径。

### 3. Runtime 行为回归
dispatcher 给模型发了哪些 tool 定义、worker 暴露了哪些 tool(`captured_tool_definitions`)—— 这种 “agent 内部行为” 不能靠最终文本验证,只能靠录制时记下的请求快照 + 回放时比对。

### 4. 并发子线程路径
engine v2 mission 线程和主线程交错 —— 没有 hint-based matching 的话,光靠线性回放根本对不齐。trace 让两支线程各自的 LLM 调用都能复现。

### 5. PR 评审 / Debug
trace JSON 是 **可读的、有结构的**(有 `UserInput` 标记、有 step 序列、有 hint),看 trace 就能知道 agent 当初为什么做这个决策,不用跑实际环境。

## 五、和传统 “mock LLM” 的本质区别

| | 传统 mock | TraceLlm |
|---|---|---|
| 响应来源 | 测试代码手写每个响应 | 录制时从真实模型抄,或手写 fixture |
| 状态 | 无状态 | 有完整 step 队列、hint、expectations |
| 并发 | 难做(谁拿哪个 mock?) | hint-based matching 自动分配 |
| 跨步骤对齐 | 手写脚本把 ID 接起来 | 模板 `{{key.field}}` 自动接 |
| 软校验 | 无 | `min_message_count` + `hint_mismatches` |
| 上下文快照 | 无 | memory_snapshot + http_exchanges |
| 录制成本 | 0(本来就手写) | 跑一次录制,但回放时无限复用 |

简单说:**传统 mock 是 “每个测试写一次假响应”,trace 回放是 “跑一次真场景,后续无限次离线复用”**。前者适合单元测试,后者适合 e2e / Tier B / 并发路径。

## 六、一句话总结

**录制** 把 “agent + 真实 LLM” 的一次运行变成 **冻结的、可读的 JSON**,**回放** 让测试在不依赖真实模型、不耗 token、不联网、完全确定的前提下,验证 agent 行为、tool 表面、并发路径、跨步骤 ID 对齐这些 “真实 LLM 测试根本做不稳” 的事。配合 hint 匹配 + 模板替换 + 软校验 + 声明式 `expects`,它把 LLM 调用从测试的 “不确定来源” 变成了 “可断言的 fixture”,这才是它的核心价值。

3. 真实

好的,这是我看到的真相,比我之前说的更精确。继续回答:

## 你说得对——我之前回答错了

刚刚在 `tests/fixtures/llm_traces/README.md:306-316` 看到官方文档明确写了 **"What gets mocked vs. what runs for real"**:

```
| Component           | Mocked?     | Notes                                    |
|---------------------|-------------|------------------------------------------|
| LLM responses       | Yes         | TraceLlm replays canned responses        |
| Tool execution      | **No**      | Real tools run: file I/O, memory, shell  |
| Outgoing HTTP       | **Depends** | Mocked if ReplayingHttpInterceptor wired |
| Memory/workspace    | **Depends** | Pre-seeded from memory_snapshot if any   |
| Safety layer        | **No**      | Sanitizer/validator/policy run           |
| Context accumulation| **No**      | Messages accumulate naturally            |
| Token counting      | Partial     | Synthetic counts from trace              |
```

**只有 LLM 这一层是 mocked 的,工具执行、safety、context accumulation 全都是真的**。我之前那个"工具是否走真实调用取决于测试 setup"的回答是错的——官方明确:**工具默认真跑**。

## 这才是录制回放的真实价值

```
                  ┌─────────────────────────────────────┐
                  │   TraceLlm (mocked, 只这一层)       │
                  │   → 返回 fixture 里的 tool_calls    │
                  └────────────────┬────────────────────┘
                                   ↓
                  ┌─────────────────────────────────────┐
                  │   ToolDispatcher (real, 真跑)       │
                  │   → file_read 真的读磁盘             │
                  │   → memory_write 真的写 libSQL        │
                  │   → shell 真的执行命令               │
                  │   → http 真的发请求(或被拦截器拦截) │
                  └────────────────┬────────────────────┘
                                   ↓
                  ┌─────────────────────────────────────┐
                  │   Safety layer (real, 真跑)          │
                  │   → Sanitizer, Validator, Policy,    │
                  │     Leak detector 全都过一遍          │
                  └────────────────┬────────────────────┘
                                   ↓
                  工具结果 (Role::Tool) 灌回 messages
                                   ↓
                  下次 llm.complete_with_tools()
                  → TraceLlm 又从 fixture 抽下一个 step
                  → hint 匹配 + 模板替换
                  → 整个真实链路再走一遍
```

**验证的不是 LLM 本身,而是 LLM 之外的所有系统行为**:
- 工具层真的能 dispatch 真的 tool call 吗?
- safety 层对真实的 tool output 真的能拦得住 prompt injection 吗?
- context accumulation 在多轮工具结果灌回后真的对吗?
- approval gate / auth gate 在真实 tools + 真实 messages 下真的工作吗?

## 所以你那个"`{{...}}` 模板替换"的真实意义

我之前说"让录制时的非确定性参数在 replay 时动态绑定"——**这个说法也对,但没点出本质**。

模板替换解决的不是"跨环境稳定"问题(那是 hint 的事),而是 **"让录制时和 replay 时的工具调用语义保持一致"**:

- 录制时:`mission_create` 真实调用 → 真的返回 UUID → LLM 用这个 UUID 调用 `mission_update`
- replay 时:`mission_create` **真的再调一次**(因为工具层是真的)→ 拿到**新**的 UUID → fixture 里 `mission_update` 的参数里 `{{mission_create.uuid}}` 必须被替换成**新** UUID → 否则工具 A 用旧 UUID 调工具 B 会失败

**模板替换是"replay 时工具层真的跑、每次跑都产生新数据"这件事的必要补丁**——它让 fixture 不必绑定到"那次录制时碰巧生成的 UUID",而能适应"每次 replay 真实执行产生的新值"。

## 一个具体例子

看 `tests/fixtures/llm_traces/spot/chain_write_read.json` 这个 fixture:它驱动 "write file → read file → confirm" 的链路。replay 时:

1. TraceLlm 返回 `tool_calls: [{name: "file_write", arguments: {path: "/tmp/x", content: "..."}}]`
2. 业务代码 → ToolDispatcher → **真的写文件**到 `/tmp/x`
3. tool_result 灌回 messages
4. TraceLlm 返回下一个 step:`tool_calls: [{name: "file_read", arguments: {path: "/tmp/x"}}]`
5. 业务代码 → ToolDispatcher → **真的读 `/tmp/x`**,拿到上一步写的内容
6. tool_result 灌回 messages
7. TraceLlm 返回 text step:确认成功

**这条链路验证的是 file_write / file_read 的工具实现、ToolDispatcher 的 dispatch 逻辑、tool result 回灌 messages 的 context accumulation、整个 agent loop 的流转**——LLM 只是这一长串真实行为的"导演",告诉系统"下一步该做什么"。

## 官方 README 这段话为啥要单独列出来

"Tool execution | No" 这一行很关键——它**反驳了一个常见误解**(我刚才就掉进了这个误解):

> "Trace 回放 = 整个系统都是 mock 的"

真实情况是:

> **Trace 回放只 mock LLM 那一层**——剩下的所有东西(工具、安全、context、approval、auth)都是真的,因为这些才是每次 PR 改动可能引入回归的地方。

LLM 本身是黑盒(外部 API),不能也不该在测试里真调;但你改了 dispatcher、改了 safety、改了 tool 逻辑、改了 context management——这些改动**只能通过"LLM 驱动真实工具执行"的链路**才能被验证。trace fixture 就是用脚本化的 LLM 决策,**真实驱动**这些被改动的代码路径。

## 我之前的错误在哪

我说"取决于测试 setup"——错把"测试 mock 工具"当成一种常见模式。实际上:

- **常见**:测试用 `libSQL`(真数据库)、真 workspace、真 tools,TraceLlm 只替 LLM
- **罕见**:测试连工具都 mock(一般是单元测试 fixture 边界,不在 e2e 体系内)

## 小结

- **TraceLlm 只 mock LLM 一层**
- **工具、safety、context accumulation 全是真的**——这是 trace 回放系统的核心价值所在
- **模板替换 `{{...}}` 让"工具真跑 + 每次跑出新数据"和"fixture 跨环境复用"两件事兼容**
- 你那句"不走真实系统怎么验证"是对的——trace 回放本来就**专门设计成走真实系统**,只是用 TraceLlm 把"LLM 该说什么"这件事脚本化了

如果你想看具体的某个 trace fixture 跑的时候,到底哪些代码路径被真实执行了,我可以挑一个 fixture(比如 `auth_gate_request_id.json` 这种带 auth gate 的)跟踪一下它的执行链路。