Files
agcore/design/pdd/12-phase5-warmup-implementation.md
T
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

22 KiB
Raw Blame History

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

维度 内容
需求 新增 OllamaProvidernewtype 包装 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_secsmax_retries 字段;实现 Defaultfrom_env() 构造;timeout 传导到各 Provider HTTP Client
优先级 P0 — 与 Roadmap 一致,Phase 8MVP 出口)依赖 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(新建):

//! 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<MessageResponse, LlmError> {
        self.0.chat(request).await
    }

    async fn chat_stream(
        &self,
        request: MessageRequest,
    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + 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 的修改:

// 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<Self, Self::Err> {
    match s.to_lowercase().as_str() {
        // ... 已有条目 ...
        "ollama" => Ok(ProviderType::Ollama),
        _ => Err(format!("未知的 Provider 类型: {s}")),
    }
}

// create_provider() 加分支
// Step 5.2 阶段仅展示基本构造。Step 5.1ProviderConfig 扩展)
// 执行到此分支时,将同步补充 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 包装模式与 DeepSeekProviderQwenProvider 完全一致,LlmProvider trait 委托给 self.0capabilities().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 10src/llm/context.rs)中才实际定义,Phase 5 尚不存在此类型。#[non_exhaustive] 无法标注不存在的枚举,因此排除标注。Roadmapv0.2.0 §Phase 5 Step 5.3)列出的 SlotMode(预置) 推迟到 Phase 10 实现时一并添加。

关键代码

每个枚举在 derive 上方或下方加一行属性:

#[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<Self, LlmError>
src/llm/provider/openai_compat.rs 修改 DeepSeekProviderQwenProvider 新增公开 with_client() 方法
src/llm/provider/ollama.rs 修改 OllamaProvider 新增公开 with_client() 方法
Cargo.toml 修改 temp_env dev-dependency
测试文件(provider.rs 内联或独立) 新增 from_env 单元测试 + timeout 传导集成测试

数据结构

/// 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<Self, String> {
        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 的 ProviderOpenAI / 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 的说明DeepSeekProviderQwenProvider 当前已有测试用的 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 模式完全一致):

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 方法:

impl AnthropicProvider {
    /// 替换默认 HTTP Client 的超时配置。
    ///
    /// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client`
    /// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。
    pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
        // ponytail: 重建 http_client 时保留已有默认 headersx-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.rsanthropic.rs 中的 map_reqwest_error 辅助函数当前在超时错误中返回硬编码的 Duration::from_secs(120)

// 现状 —— 硬编码 120s,与可配置 timeout 脱节
LlmError::Timeout { duration: Duration::from_secs(120) }

修复方式:采用方案 A——在 Provider struct 中存储 timeout_secs 字段,map_reqwest_error 读取该字段的值而非硬编码 120s。

// 修复后 —— 参数化,从 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 控制。两者之间的关系通过文档注释声明:

/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
/// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。

测试设计

使用 temp_env 在单元测试中隔离环境变量:

#[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 ProviderTypeprovider.rs)加 #[non_exhaustive] 编译通过
2 StopReasonresponse_v2.rs)加 #[non_exhaustive] 编译通过
3 FinishReasonshared.rs)加 #[non_exhaustive] 编译通过
4 EvictionPolicymemory/store.rs)加 #[non_exhaustive] 编译通过
5 运行 cargo build --all-targets 无 warning

Step 5.1 — ProviderConfig 扩展(~60 行 + 测试)

步骤 操作 验证
1 ProviderConfigtimeout_secs / max_retries 字段 编译通过
2 实现 impl Default for ProviderConfig 编译通过
3 实现 ProviderConfig::from_env() 编译通过
4 GenericOpenaiProviderAnthropicProvider 新增 timeout_secs 字段,new_with_namenew() 接受 timeout 参数 编译通过
5 map_reqwest_error 在各 Provider 中改为从 self.timeout_secs 读取,移除硬编码 120s 编译通过
6 create_provider() 中各分支注入 timeoutOpenAI-compatible 用 Client::builder().timeout() + with_clientAnthropic 用 with_timeout() 编译通过
7 DeepSeekProviderQwenProvidernew_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<Box<dyn LlmProvider>, LlmError>map_err 天然适配。新增的 timeout 注入路径逐一检查
AnthropicProviderdefault_headerswith_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 有文档注释(///
  • 新增文件:1ollama.rs
  • 修改文件:9provider.rsopenai.rsanthropic.rsopenai_compat.rsresponse_v2.rsshared.rsstore.rsCargo.toml、测试文件)
  • 净代码增量:~160 行
  • ProviderType 新增 Ollama 变体,"ollama" 字符串可解析
  • 4 个公共枚举带有 #[non_exhaustive] 属性
  • ProviderConfig 可从环境变量构造(from_env()),含默认值
  • timeout 值已传导到 create_provider() 中各 Provider 的 HTTP Client 配置
  • timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 LlmError::Timeout
  • DeepSeekProviderQwenProviderOllamaProvider 均有公开 with_client() 方法,可在 create_provider 中注入 timeout Client
  • map_reqwest_error 中不再硬编码 Duration::from_secs(120),改为参数化读取