# Phase 5:热身准备 — 实施方案 ## 1. 背景与目标 Phase 5 是 v0.2.0 发布周期的**热身准备阶段**,包含三个互不依赖的 Step,为后续 Phase 6-12 的端到端集成提供基础设施。 **核心目标**: - 为 Phase 8(端到端示例)提供零 API key 的运行路径(Ollama) - 为公共枚举的向后兼容性加上编译期护栏(`#[non_exhaustive]`) - 为 Provider 构造提供统一的超时与重试配置入口(`ProviderConfig` 扩展) 三个 Step 之间**无依赖关系**,但出于实现效率考虑,按 **5.2 → 5.3 → 5.1** 顺序执行。理由:5.2 先新增 `Ollama` 枚举变体,5.3 再加 `#[non_exhaustive]`,避免枚举标记后添加变体需要在外部 crate 加 `_ =>` 兜底分支的困扰。 ## 2. 需求分析 ### Step 5.2 — Ollama Provider | 维度 | 内容 | |------|------| | **需求** | 新增 `OllamaProvider`,newtype 包装 `GenericOpenaiProvider`,默认连接本地 Ollama 实例 | | **优先级** | P0 — 为 Phase 8 端到端示例提供无需 API key 的运行路径 | | **预期交付物** | `src/llm/provider/ollama.rs` 新建文件;`ProviderType` 新增 `Ollama` 变体 | | **代码量** | ~55 行 | ### Step 5.3 — `#[non_exhaustive]` 前置标记 | 维度 | 内容 | |------|------| | **需求** | 为 4 个公共枚举添加 `#[non_exhaustive]` 属性,避免后续新增变体时破坏下游 match | | **优先级** | P1 — 编译期兼容性保障 | | **预期交付物** | 修改 4 个枚举定义,各加一行属性 | | **代码量** | ~4 行 | ### Step 5.1 — ProviderConfig 扩展 | 维度 | 内容 | |------|------| | **需求** | `ProviderConfig` 新增 `timeout_secs` 和 `max_retries` 字段;实现 `Default`、`from_env()` 构造;timeout 传导到各 Provider HTTP Client | | **优先级** | P0 — 与 Roadmap 一致,Phase 8(MVP 出口)依赖 from_env | | **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 | | **代码量** | ~60 行 + 测试 | ## 3. 方案设计 ### 3.1 Step 5.2 — Ollama Provider(先执行) #### 改动文件清单 | 文件 | 操作 | 说明 | |------|------|------| | `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 | | `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 | | `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` | #### 关键代码 **`src/llm/provider/ollama.rs`**(新建): ```rust //! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。 //! //! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。 //! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。 use reqwest::Client; use std::pin::Pin; use async_trait::async_trait; use futures_core::Stream; use super::openai::GenericOpenaiProvider; use super::{LlmProvider, ProviderCapabilities}; use crate::llm::error::LlmError; use crate::llm::types::request_v2::MessageRequest; use crate::llm::types::response_v2::{MessageResponse, StreamEvent}; pub struct OllamaProvider(pub GenericOpenaiProvider); impl OllamaProvider { pub fn new(base_url: String, api_key: String, model: String) -> Self { let url = if base_url.is_empty() { "http://localhost:11434/v1".to_string() } else { base_url }; Self(GenericOpenaiProvider::new_with_name( url, api_key, model, "ollama", )) } /// 替换默认 HTTP Client(用于 timeout 注入等场景)。 /// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。 pub fn with_client(self, client: Client) -> Self { Self(self.0.with_client(client)) } } #[async_trait] impl LlmProvider for OllamaProvider { async fn chat(&self, request: MessageRequest) -> Result { self.0.chat(request).await } async fn chat_stream( &self, request: MessageRequest, ) -> Result> + Send>>, LlmError> { self.0.chat_stream(request).await } fn capabilities(&self) -> ProviderCapabilities { let mut caps = self.0.capabilities(); caps.provider_name = "ollama"; caps } } ``` **`src/llm/provider.rs`** 的修改: ```rust // ProviderType 新增变体 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderType { OpenaiChat, OpenaiResponse, Anthropic, DeepSeek, Qwen, /// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。 Ollama, } // FromStr 加解析 fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { // ... 已有条目 ... "ollama" => Ok(ProviderType::Ollama), _ => Err(format!("未知的 Provider 类型: {s}")), } } // create_provider() 加分支 // Step 5.2 阶段仅展示基本构造。Step 5.1(ProviderConfig 扩展) // 执行到此分支时,将同步补充 with_client 链式调用注入 timeout: // // let client = Client::builder() // .timeout(Duration::from_secs(config.timeout_secs)) // .build()?; // Ok(Box::new( // ollama::OllamaProvider::new(config.base_url, config.api_key, config.model) // .with_client(client), // )) ProviderType::Ollama => Ok(Box::new(ollama::OllamaProvider::new( config.base_url, config.api_key, config.model, ))), ``` #### 集成方式 OllamaProvider 的 newtype 包装模式与 `DeepSeekProvider`、`QwenProvider` 完全一致,`LlmProvider` trait 委托给 `self.0`。`capabilities().provider_name` 返回 `"ollama"`。 ### 3.2 Step 5.3 — `#[non_exhaustive]` 前置标记 #### 改动文件清单 | 文件 | 行号 | 枚举 | 操作 | |------|------|------|------| | `src/llm/provider.rs` | ~21 | `ProviderType` | 加 `#[non_exhaustive]` | | `src/llm/types/response_v2.rs` | ~22 | `StopReason` | 加 `#[non_exhaustive]` | | `src/llm/types/shared.rs` | ~16 | `FinishReason` | 加 `#[non_exhaustive]` | | `src/memory/store.rs` | ~35 | `EvictionPolicy` | 加 `#[non_exhaustive]` | **排除清单**:`SlotMode`。 **决策理由**:`SlotMode` 枚举在 Phase 10(`src/llm/context.rs`)中才实际定义,Phase 5 尚不存在此类型。`#[non_exhaustive]` 无法标注不存在的枚举,因此排除标注。Roadmap(v0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。 #### 关键代码 每个枚举在 `derive` 上方或下方加一行属性: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub enum ProviderType { // ... } ``` #### 影响分析 - `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为 - 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举) - 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break - **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确 ### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行) #### 改动文件清单 | 文件 | 操作 | 说明 | |------|------|------| | `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()`;`create_provider` 注入 timeout | | `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 | | `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 | | `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result`) | | `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider` 和 `QwenProvider` 新增公开 `with_client()` 方法 | | `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 | | `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency | | 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 | #### 数据结构 ```rust /// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。 pub struct ProviderConfig { pub base_url: String, pub api_key: String, pub model: String, /// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。 pub timeout_secs: u64, /// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集, /// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。 /// 未来可合并到统一的 retry 配置。 pub max_retries: u32, } impl Default for ProviderConfig { fn default() -> Self { Self { base_url: String::new(), api_key: String::new(), model: String::new(), timeout_secs: 30, max_retries: 3, } } } impl ProviderConfig { /// 从环境变量构造 ProviderConfig。 /// /// 必填变量: /// - `{prefix}_BASE_URL` /// - `{prefix}_API_KEY` /// - `{prefix}_MODEL` /// /// 可选变量(有默认值): /// - `{prefix}_TIMEOUT_SECS`(默认 30) /// - `{prefix}_MAX_RETRIES`(默认 3) pub fn from_env(prefix: &str) -> Result { let base_url = std::env::var(format!("{prefix}_BASE_URL")) .map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?; let api_key = std::env::var(format!("{prefix}_API_KEY")) .map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?; let model = std::env::var(format!("{prefix}_MODEL")) .map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?; let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) { Ok(v) => v.parse().unwrap_or_else(|_| { tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30"); 30 }), Err(_) => 30, }; let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) { Ok(v) => v.parse().unwrap_or_else(|_| { tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3"); 3 }), Err(_) => 3, }; // ponytail: max_retries 当前仅采集,不传入 Provider。 // 实际重试由 CycleConfig.retry.max_retries 控制。 // 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时 // 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。 if max_retries != 3 { tracing::warn!( "ProviderConfig.max_retries={} 已采集但当前未生效;\ 重试次数由 CycleConfig.retry.max_retries 控制", max_retries, ); } Ok(Self { base_url, api_key, model, timeout_secs, max_retries, }) } } ``` #### Timeout 传导模式 在 `create_provider()` 中,对基于 `GenericOpenaiProvider` 的 Provider(OpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`。 所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法: | Provider | 方法 | 位置 | |----------|------|------| | `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` | | `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` | | `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` | | `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) | **关于 `new_with_client` 的说明**:`DeepSeekProvider` 和 `QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。 代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致): ```rust ProviderType::DeepSeek => { let client = Client::builder() .timeout(Duration::from_secs(config.timeout_secs)) .build() .map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?; Ok(Box::new( openai_compat::DeepSeekProvider::new( config.base_url, config.api_key, config.model, ) .with_client(client), )) } ``` Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式: AnthropicProvider 新增 `with_timeout` 方法: ```rust impl AnthropicProvider { /// 替换默认 HTTP Client 的超时配置。 /// /// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client` /// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。 pub fn with_timeout(mut self, secs: u64) -> Result { // ponytail: 重建 http_client 时保留已有默认 headers(x-api-key / anthropic-version)。 // 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。 let key_header = HeaderValue::from_str(&self.api_key) .map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?; let version_header = HeaderValue::from_static("2023-06-01"); self.http_client = Client::builder() .timeout(Duration::from_secs(secs)) .default_headers({ let mut headers = HeaderMap::new(); headers.insert("x-api-key", key_header); headers.insert("anthropic-version", version_header); headers }) .build() .map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?; Ok(self) } } ``` #### `map_reqwest_error` 中的硬编码超时修复 `openai.rs` 和 `anthropic.rs` 中的 `map_reqwest_error` 辅助函数当前在超时错误中返回硬编码的 `Duration::from_secs(120)`: ```rust // 现状 —— 硬编码 120s,与可配置 timeout 脱节 LlmError::Timeout { duration: Duration::from_secs(120) } ``` **修复方式**:采用**方案 A**——在 Provider struct 中存储 `timeout_secs` 字段,`map_reqwest_error` 读取该字段的值而非硬编码 120s。 ```rust // 修复后 —— 参数化,从 Provider 存储的 timeout_secs 读取 // GenericOpenaiProvider 新增 timeout_secs 字段: pub struct GenericOpenaiProvider { http_client: Client, base_url: String, api_key: String, model: String, provider_name: &'static str, extra_headers: Vec<(String, String)>, timeout_secs: u64, // ← 新增,由 new_with_name 的参数传入 } // map_reqwest_error 使用 self.timeout_secs 而非硬编码 120: LlmError::Timeout { duration: Duration::from_secs(self.timeout_secs) } ``` **方案 B(从 reqwest::Client 提取 timeout)已被否决**:`reqwest::Client` 不提供 timeout getter,无法从已构造的 client 中反向读取超时配置。 如果漏掉此修复,用户设置 `AG_LLM_TIMEOUT_SECS=60` 后超时,错误消息仍显示 "LLM 请求超时(120s)",与实际配置不符。 --- #### max_retries 说明 `ProviderConfig.max_retries` 当前仅由 `from_env()` 采集存储,**实际重试操作由 `CycleConfig.retry.max_retries` 控制**。两者之间的关系通过文档注释声明: ```rust /// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集, /// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。 /// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。 ``` #### 测试设计 使用 `temp_env` 在单元测试中隔离环境变量: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn provider_config_from_env_requires_all_vars() { // 未设置任何变量时应返回 Err let result = ProviderConfig::from_env("TEST_PROVIDER"); assert!(result.is_err()); } #[test] fn provider_config_from_env_uses_defaults() { temp_env::with_vars([ ("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")), ("TEST_PROVIDER_API_KEY", Some("")), ("TEST_PROVIDER_MODEL", Some("llama3")), ], || { let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap(); assert_eq!(config.timeout_secs, 30); assert_eq!(config.max_retries, 3); }); } #[test] fn provider_config_from_env_reads_custom_timeout() { temp_env::with_vars([ ("TEST_PROVIDER_BASE_URL", Some("http://x")), ("TEST_PROVIDER_API_KEY", Some("k")), ("TEST_PROVIDER_MODEL", Some("m")), ("TEST_PROVIDER_TIMEOUT_SECS", Some("60")), ("TEST_PROVIDER_MAX_RETRIES", Some("5")), ], || { let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap(); assert_eq!(config.timeout_secs, 60); assert_eq!(config.max_retries, 5); }); } } ``` ## 4. 实现计划 ### Step 5.2 — Ollama Provider(~55 行) | 步骤 | 操作 | 验证 | |------|------|------| | 1 | 创建 `src/llm/provider/ollama.rs`,实现 `OllamaProvider` newtype | 编译通过 | | 2 | 在 `provider.rs` 注册 `pub mod ollama` | 编译通过 | | 3 | `ProviderType` 新增 `Ollama` 变体 | 编译通过 | | 4 | `FromStr` 加 `"ollama"` 解析 | 编译通过 | | 5 | `create_provider()` 加 `Ollama =>` 分支 | 编译通过 | | 6 | 运行 `cargo build` | 无错误 | ### Step 5.3 — `#[non_exhaustive]` 前置标记(~4 行) | 步骤 | 操作 | 验证 | |------|------|------| | 1 | `ProviderType`(`provider.rs`)加 `#[non_exhaustive]` | 编译通过 | | 2 | `StopReason`(`response_v2.rs`)加 `#[non_exhaustive]` | 编译通过 | | 3 | `FinishReason`(`shared.rs`)加 `#[non_exhaustive]` | 编译通过 | | 4 | `EvictionPolicy`(`memory/store.rs`)加 `#[non_exhaustive]` | 编译通过 | | 5 | 运行 `cargo build --all-targets` | 无 warning | ### Step 5.1 — ProviderConfig 扩展(~60 行 + 测试) | 步骤 | 操作 | 验证 | |------|------|------| | 1 | `ProviderConfig` 加 `timeout_secs` / `max_retries` 字段 | 编译通过 | | 2 | 实现 `impl Default for ProviderConfig` | 编译通过 | | 3 | 实现 `ProviderConfig::from_env()` | 编译通过 | | 4 | `GenericOpenaiProvider` 和 `AnthropicProvider` 新增 `timeout_secs` 字段,`new_with_name`/`new()` 接受 timeout 参数 | 编译通过 | | 5 | `map_reqwest_error` 在各 Provider 中改为从 `self.timeout_secs` 读取,移除硬编码 120s | 编译通过 | | 6 | `create_provider()` 中各分支注入 timeout(OpenAI-compatible 用 `Client::builder().timeout()` + `with_client`;Anthropic 用 `with_timeout()`) | 编译通过 | | 7 | `DeepSeekProvider`/`QwenProvider` 的 `new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 | | 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 | | 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 | | 9 | 完整验证 | 见第 6 节 | ## 5. 风险评估 | 风险 | 影响 | 概率 | 缓解措施 | |------|------|------|----------| | `create_provider()` 中 `Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result, LlmError>`,`map_err` 天然适配。新增的 timeout 注入路径逐一检查 | | `AnthropicProvider` 的 `default_headers` 在 `with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 | | Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 | | `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 | | `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` | ## 6. 验收标准 以下条件**全部满足**方可认为 Phase 5 完成: - [ ] `cargo build --all-targets` 通过,无错误 - [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景 - [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning - [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`) - [ ] 新增文件:1(`ollama.rs`) - [ ] 修改文件:9(`provider.rs`、`openai.rs`、`anthropic.rs`、`openai_compat.rs`、`response_v2.rs`、`shared.rs`、`store.rs`、`Cargo.toml`、测试文件) - [ ] 净代码增量:~160 行 - [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析 - [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性 - [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值 - [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置 - [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`) - [ ] `DeepSeekProvider`、`QwenProvider`、`OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client - [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取