将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
22 KiB
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(新建):
//! 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.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 上方或下方加一行属性:
#[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 |
修改 | DeepSeekProvider 和 QwenProvider 新增公开 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 的 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 模式完全一致):
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 时保留已有默认 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):
// 现状 —— 硬编码 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 | 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<Box<dyn LlmProvider>, 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通过,无任何 warningcargo 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 Clientmap_reqwest_error中不再硬编码Duration::from_secs(120),改为参数化读取