20 KiB
LLM Provider 重构改进方案(最终确认)
本文档记录 2026-06-25 设计评审后确认的方案决策,是对 9 系文档(
9-llm-provider-unified-interface.md及9a-9g子文档)中已有设计的精炼与修订。阅读前提:本文档假设读者已熟悉现有 9 系文档中的背景、架构总览和类型体系概念。
与 9 系的关系:
- 9 系文档中的
ContentBlock、MessageRequest、MessageResponse、StopReason、ThinkingConfig、ToolDefinition、PartialUsage等核心类型定义继续有效,本文档不再重复ProviderCapabilities、LlmProvider trait签名、PartialMessageResponse汇聚算法继续有效- 本文档仅记录本次确认的修订内容和执行计划
1. 修订摘要
| 设计维度 | 9 系文档 | 本次修订 | 修订原因 |
|---|---|---|---|
| Message 模型 | 结构化层次(System/User/Assistant/Tool,每项含 content: Vec<ContentBlock>) |
扁平大枚举(Assistant 拆散为多个独立的消息变体) | 编译器能检查约束,消费方 match 清晰,无需在 Vec 中搜索特定 block 类型 |
| StreamEvent 终端事件 | MessageComplete { stop_reason, thinking_signature } |
增加 full_response: LlmResponse,终端事件携带完整快照 |
消费方无需自己拼接 delta,直接拿到完整响应 |
| Provider 发现 | 未明确 | Enum-based(ProviderType enum + exhaustive match),不做动态注册 |
当前协议数量可控,编译期安全,无运行时查表开销 |
| 项目阶段 | 9 系是"推演中" | 可直接执行,无历史包袱,一步到位 | 项目尚未 release,没有 breaking change 顾虑 |
2. 本次修订的 5 项设计决策
2.1 Decision-01:Message 采用扁平大枚举
定义
/// 跨 Provider 统一的消息类型(扁平大枚举)。
///
/// 设计原则:每个变体直接承载完整语义,
/// 消费方 match 即可获得所有信息,无需在嵌套的 Vec 中搜索。
#[derive(Debug, Clone)]
pub enum Message {
/// 系统提示(User & Assistant 之外的引导指令)
System {
content: Vec<ContentBlock>,
},
/// 用户输入
User {
content: Vec<ContentBlock>,
},
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)
UserImage {
data: String,
mime_type: String,
detail: ImageDetail,
},
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)
///
/// 注意:Assistant 的一次回复可以同时包含文本、思考过程、工具调用。
/// 扁平大枚举并未将 ToolUse 提升为独立变体,而是保留在 content 中,
/// 因为在一次 Assistant turn 中 text 和 tool_use 的**顺序关系**是有意义的。
/// (例如:先输出推理过程,再调用工具)
Assistant {
content: Vec<ContentBlock>,
},
/// 工具调用结果
ToolResult {
tool_call_id: String,
content: Vec<ContentBlock>,
is_error: bool,
},
}
与 9 系结构化层次的差异
| 维度 | 9 系(结构化层次) | 本次(扁平大枚举) |
|---|---|---|
| Assistant 消息结构 | Assistant { content: Vec<ContentBlock> },ToolUse 在 content 中 |
同上,保持 ToolUse 在 content 中 |
| "独立 Assistant 消息"的含义 | 一次 LLM 响应 = 一个 Assistant { content: [...] } |
同上 |
| Thinking / ToolCall 作为独立变体 | ❌ 无独立变体 | Thinking、ToolCall 不作为独立 Message 变体,仍在 Assistant.content 中 |
| UserImage 独立变体 | User { content: [Image{...}] } |
UserImage { data, mime, detail } |
| 为什么不把 ToolUse 提到 Message 层 | — | 因为 text ↔ tool_use 的交错顺序是 Assistant 响应的语义组成部分,拆散后会丢失顺序信息 |
| 实际的参与方差异 | User + UserImage 合并为同一变体 |
User 和 UserImage 拆开,方便消费方 match(无需检查 Vec 内容来区分文字和图片) |
与 9b 文档的关系:9b 的
Message::System、Message::User、Message::Assistant、Message::Tool四个变体分类保留, 但User的图片输入场景通过新增UserImage变体提供便捷路径,减少 boilerplate。Message::Assistant的content: Vec<ContentBlock>保持不变——ToolUse 仍在 content 中。
便捷构造函数
impl Message {
pub fn user_text(text: impl Into<String>) -> Self;
pub fn user_image(data: impl Into<String>, mime_type: impl Into<String>, detail: ImageDetail) -> Self;
pub fn assistant(text: impl Into<String>) -> Self;
pub fn system(text: impl Into<String>) -> Self;
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self;
}
2.2 Decision-02:LlmProvider 感知消息类型
沿用 9c 文档中的 trait 设计,无修订。
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
fn capabilities(&self) -> ProviderCapabilities;
}
每个 Provider 实现内部自行处理 MessageRequest ↔ 原生协议格式的映射。无外部转换层。
2.3 Decision-03:StreamEvent 高精度 + 终端事件携带完整响应
沿用 9c 文档中定义的 StreamEvent,但在终端事件中增加完整响应快照。
修订后的 MessageComplete 事件
pub enum StreamEvent {
// ── Meta ──
MessageStart { id: String, model: String },
// ── Content Block 边界 ──
ContentBlockStart { index: u32, block_type: ContentBlockType },
ContentBlockEnd { index: u32 },
// ── 块内增量 ──
TextDelta { text: String },
ThinkingDelta { text: String },
RefusalDelta { text: String },
ToolCallArgumentsDelta { index: u32, arguments: String },
ToolCallEnd { index: u32 },
// ── 汇总 ──
CostUpdate { usage: PartialUsage },
// ═══════════════════════════════════════════════════════
// 修订:MessageComplete 携带完整响应快照
// ═══════════════════════════════════════════════════════
/// 消息完成。
///
/// `thinking_signature` 仅 Anthropic 场景使用,回填到最后的 Thinking block。
/// `full_response` 携带完整的 MessageResponse(含已拼接完毕的 content + usage + stop_reason),
/// 消费方**无需自行累积 delta**,直接使用此快照继续后续流程。
MessageComplete {
stop_reason: StopReason,
thinking_signature: Option<String>,
/// 完整的响应快照。
///
/// 与 `PartialMessageResponse` 内部累积的状态**最终一致**,
/// 提供此快照是为了让消费方(如 LlmCycle)在流结束后可以直接拿到
/// 完整的 MessageResponse,无需自己实现汇聚算法。
full_response: MessageResponse,
},
// ── 错误 ──
Error { message: String },
}
设计理由
- 简化消费方:
LlmCycle::submit_stream()目前需要在while let循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了full_response,LlmCycle或AgentSession只需要监听MessageComplete事件,拿到快照后直接继续 tool 循环或返回给调用方。 - 与 PartialMessageResponse 保持一致:
PartialMessageResponse::finalize()产生的MessageResponse就是full_response的值。Provider 内部的汇聚逻辑不变,只是在发出MessageComplete时多传一个已完成构建的最终结果。 - 零额外开销:
MessageResponse在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。
对 PartialMessageResponse 的影响
// finalize 在原有逻辑末尾增加一步:
// 将 finalize 的结果提前缓存,由 MessageComplete 事件携带
impl PartialMessageResponse {
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
// ... 原有代码(按 index 升序遍历 blocks) ...
let response = MessageResponse { ... };
// 新增:self. 中缓存 finalize 结果
// (实际由 Provider 的流处理循环在发出 MessageComplete 前调用
// finalize 并填充到事件中)
Ok(response)
}
}
Provider 的流处理循环在发出 MessageComplete 时,提前调用 finalize() 取得 MessageResponse 并填入事件:
// 伪代码:Provider 流处理循环
let mut partial = PartialMessageResponse::new();
while let Some(anthropic_event) = anthropic_stream.next().await {
match map_to_ir_event(anthropic_event) {
StreamEvent::MessageComplete { stop_reason, thinking_signature } => {
// 在此处调用 finalize 并将结果携带到事件中
let full = partial.finalize()?;
yield StreamEvent::MessageComplete {
stop_reason,
thinking_signature,
full_response: full,
};
break;
}
other_event => { other_event.apply_to(&mut partial); }
}
}
2.4 Decision-04:LlmCycle 简化
沿用 9e 文档的改造方向,核心变化是内部消息类型从 Vec<OpenaiChatMessage> 改为 Vec<Message>。
关键变化要点(9e 已有详述):
| 当前 | 改进后 |
|---|---|
messages: Vec<OpenaiChatMessage> |
messages: Vec<Message> |
build_request() 中手动拼接 system prompt |
system prompt 通过 Message::System 在 messages 中表达,Provider 映射层自行处理差异 |
submit_stream() 中自建 delta 聚合逻辑 |
监听 MessageComplete.full_response,直接拿到完整响应 |
tool 循环需自行解析 ChatResponse 中的 tool_calls |
从 MessageResponse.message(Assistant 变体)的 content 中提取 ContentBlock::ToolUse |
2.5 Decision-05:Provider 发现使用 Enum
不使用动态注册表,保留当前 ProviderType enum 模式,但扩展其覆盖范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
OpenaiChat,
OpenaiResponse,
Anthropic,
DeepSeek,
Qwen,
}
工厂函数 create_provider() 做 exhaustive match:
pub fn create_provider(
provider_type: ProviderType,
config: ProviderConfig,
) -> Result<Box<dyn LlmProvider>, LlmError> {
match provider_type {
ProviderType::OpenaiChat => Ok(Box::new(providers::OpenaiChatProvider::new(...))),
ProviderType::OpenaiResponse => Ok(Box::new(providers::OpenaiResponseProvider::new(...))),
ProviderType::Anthropic => Ok(Box::new(providers::AnthropicProvider::new(...))),
ProviderType::DeepSeek => Ok(Box::new(providers::DeepSeekProvider::new(
config.base_url,
config.api_key,
config.model,
))),
ProviderType::Qwen => Ok(Box::new(providers::QwenProvider::new(...))),
}
}
新增 Provider 时,编译器通过 exhaustiveness check 强制要求 match 更新。
理由:当前目标协议数量(4-5 种)完全可控,enum 的编译期安全检查优于运行时的
HashMap::get()。 未来如果扩展到 15+ 种以上,再改为注册表模式。
3. 对 9 系文档的更新映射
| 9 系文档 | 变更类型 | 操作 |
|---|---|---|
9b-ir-type-system.md §3.2 Message |
修订 | UserImage 变体新增;其余部分继续有效 |
9c-llm-provider-trait.md §4.3 StreamEvent |
修订 | MessageComplete 增加 full_response: MessageResponse 字段 |
9c-llm-provider-trait.md §4.4 PartialMessageResponse |
追加 | finalize() 返回结果需在 Provider 发出 MessageComplete 前已可用 |
9d-provider-implementations.md |
继续有效 | 实现策略不变 |
9e-llm-cycle-and-upstream.md |
继续有效 | 改造方向不变 |
9f-edge-cases.md |
继续有效 | 边界情况处理不变 |
9g-risk-and-migration.md |
继续有效 | 风险评估不变 |
本文档 10-... |
新增 | 记录最终决策和修订 |
4. 实施步骤
Phase 0:类型层落地
目标:定义并测试新的类型系统。
涉及文件:
src/llm/types/mod.rs— 新增消息类型模块,保持向后兼容导出src/llm/types/message.rs— 新文件,定义Message、ContentBlock、ContentBlockTypesrc/llm/types/request.rs— 修改ChatRequest = OpenaiChatRequest为type ChatRequest = MessageRequest(过渡期同时保留OpenaiChatRequest作为 Provider 内部类型)src/llm/types/response.rs— 修改ChatResponse为指向新类型src/llm/stream.rs— 扩展StreamEvent,增加MessageComplete.full_responsesrc/llm/compact.rs— 适配新Message类型(compact 逻辑只关心 text 长度,变化小)
具体任务:
- 在
src/llm/types/下新增message.rs,定义Message扁平大枚举 +ContentBlock+ 便捷构造函数 - 将
ContentBlock的现有定义(Text,Image,ToolUse,ToolResult,Thinking,Extension)从 9b 移植过来 - 扩展
StreamEvent:MessageComplete增加full_response: MessageResponse - 确认
ContentBlock、ImageSource、ToolDefinition、PartialUsage等辅助类型在 9b 中的定义,视需要移动或引用 - 类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证
验证:cargo test 通过,新类型可独立编译且 match 是 exhaustive 的。
Phase 1:Provider 适配
目标:重写 OpenaiProvider,新增 AnthropicProvider。
涉及文件:
src/llm/provider.rs— 修改create_provider工厂函数签名src/llm/provider/registry.rs— 适配新LlmProvidertrait(改动极小,只是类型变化)src/llm/provider/openai.rs— 重写:内部实现MessageRequest ↔ OpenaiChatRequest转换src/llm/provider/anthropic.rs— 新文件:MessageRequest ↔ Anthropic Messages API映射src/llm/provider/deepseek.rs— 新文件(与 OpenaiChatProvider 共享 /chat/completions 协议,只需处理 base_url + 差异)src/llm/provider/qwen.rs— 新文件(同上)
具体任务:
OpenaiProvider内部chat():MessageRequest→OpenaiChatRequest(serde 序列化)→ HTTP POST → 解析OpenaiChatResponse→MessageResponse(通过finalize()算法或直接映射)OpenaiProvider内部chat_stream():同样的转换路径,但响应解析改为 SSE 流式 → 逐 chunk 输出StreamEventAnthropicProvider:实现 Anthropic Messages API 的请求/响应映射,包括:- Messages API 请求体构建(
system参数 +messages[]+tools等) - SSE 流解析(
message_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop,ping) - 将 Anthropic SSE 事件映射为 IR
StreamEvent
- Messages API 请求体构建(
DeepSeekProvider/QwenProvider:与OpenaiChatProvider共享相同的/chat/completions协议,通过参数化或 trait 组合复用代码ProviderRegistry的register_with_config()和create_provider()适配新 enum
验证:
- 每个 Provider 的
chat()和chat_stream()基本路径集成测试(mock HTTP 层) - 消息类型双向映射测试(
Message → OpenaiChatRequest,OpenaiChatResponse → MessageResponse) - 错误路径测试(HTTP 400/401/429/500 →
LlmError映射)
Phase 2:LlmCycle 简化
目标:将 LlmCycle 内部消息存储从 Vec<OpenaiChatMessage> 切换到 Vec<Message>,利用 MessageComplete.full_response 简化流处理。
涉及文件:
src/llm/cycle.rs— 主要修改src/llm/cycle/usage.rs— 保持兼容(Usage类型不变)src/llm/cycle/retry.rs— 保持兼容
具体任务:
messages: Vec<Message>替换messages: Vec<OpenaiChatMessage>build_request()改为直接构建MessageRequest(不再手动拼接 system prompt)submit()/submit_messages():调用provider.chat()后,响应类型从ChatResponse改为MessageResponsesubmit_stream():流处理循环改为监听MessageComplete.full_response- tool 循环:从
MessageResponse.message(Assistant)的 content 中提取ContentBlock::ToolUse compact.rs适配:microcompact()和should_compact()的操作对象从OpenaiChatMessage改为Message
验证:
LlmCycle集成测试全部通过- 多轮对话 + 工具调用的端到端流程正常
5. 验证标准
| 维度 | 验证方法 | 通过条件 |
|---|---|---|
| 类型正确性 | cargo test |
所有测试通过 |
| JSON 双向映射 | 单元测试 | Message → JSON → Message 往返不变 |
| Provider 基本路径 | 集成测试(mock HTTP) | 每个 Provider 的 chat + chat_stream 成功 |
| Provider 错误路径 | 集成测试(mock HTTP 4xx/5xx) | 错误映射为正确的 LlmError 变体 |
| StreamEvent 完整快照 | 集成测试 | MessageComplete.full_response 与 PartialMessageResponse 聚合结果一致 |
| LlmCycle 多轮对话 | 集成测试(mock Provider) | 多轮对话 + 工具循环正常 |
| compact | 集成测试 | 超过 token 阈值后消息被正确压缩 |
| 向后兼容(已存在的 pub API) | 编译检查 | 外部 crate 使用 agcore::llm::types::* 的功能不受影响(类型别名过渡) |
6. 回滚方案
由于项目尚无外部消费者,回滚策略比较简单:
| 阶段 | 触发条件 | 操作 |
|---|---|---|
| Phase 0(类型层) | 新类型设计发现重大缺陷 | 回退 git,保留 9 系文档作为参照,重启设计评审 |
| Phase 0 完成时 | 类型定义通过评审和测试 | 打 tag types-v2-prototype |
| Phase 1(Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 unimplemented!()(当前状态),不影响其他 Provider |
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag providers-v2-prototype |
| Phase 2(LlmCycle 简化) | 循环逻辑或 compact 出现问题 | 保留旧 LlmCycle 实现(不改文件名),通过 feature flag 切换 |
风险储备:
- 如果
OpenaiProvider的重写复杂度过高,可以保留旧的OpenaiProvider不变,在旁边新增一个OpenaiProviderV2并行开发 ChatRequest/ChatResponse等类型别名在第 3 个 minor release 前不需要移除,给外部消费者留出迁移时间
7. 开放事项
以下事项已在 9 系文档中充分讨论,本次无修订,但列出以供跟踪:
ContentBlock::Extension作为逃生舱的具体使用场景(OpenAI Response 内置工具、未知 block 类型)- Anthropic 的
/v1/messages流式 SSE 解析状态机细节(9d Provider 实现文档) MessageRequest.extra中每个 Provider 实际需要的 key 清单(9b 已有草案,Phase 1 实现时细化和验证)- Thinking signature 的端到端测试(9f 已有处理策略,Phase 2 时分配合并完成)
- cost 计算逻辑适配新类型(当前
CostTracker在Usage上工作,类型不变、无需修改,但在集成测试中验证)