Files
agcore/docs/9e-llm-cycle-and-upstream.md
T

255 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LlmCycle 改造与上层适配
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(Message、MessageRequest、ContentBlock 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider traitchat、chat_stream 签名)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略(system prompt 处理方式)
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(tool 定义传递路径等)
## 6. LlmCycle 改造
### 6.1 内部存储变化
```rust
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
messages: Vec<Message>, // ← 原 Vec<OpenaiChatMessage>
system_prompt: Option<String>,
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
compact_state: CompactState,
}
```
### 6.2 build_request → 新签名
```rust
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
let mut messages = self.messages.clone();
if let Some(sys_prompt) = &self.system_prompt
&& !messages.iter().any(|m| matches!(m, Message::System { .. }))
{
messages.insert(0, Message::system(sys_prompt));
}
MessageRequest {
model: self.config.model.clone(),
messages,
tools: tools.to_vec(),
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
..Default::default()
}
}
```
> **🔄 待深入推演:system prompt 的双重表达冲突**
> `MessageRequest` 同时存在两个 system prompt 入口:
> 1. `messages` 中的 `Message::System { ... }`
> 2. 顶层字段 `system: Option<String>`
>
> 问题在于:
> - **Anthropic** 要求 system prompt 只能用顶层 `system` 参数,`messages` 中不能包含 system role
> - **OpenAI** 没有顶层 `system` 参数,system prompt 放在 messages 中
> - 当前 `build_request` 的逻辑是:将 `self.system_prompt` 插入到 `messages` 开头(作为 `Message::System`),
> 但**不**填充 `request.system`。这对 OpenAI 是正确的,但对 Anthropic 是错的——AnthropicProvider
> 需要反向提取:遍历 messages 找出 System 消息,移到 `request.system`,再从 messages 中移除。
>
> **需要推演:**
> 1. **方案 A(当前方案):** `build_request` 始终把 system prompt 放进 messagesAnthropicProvider 内部
> 做反向提取。问题:当 LlmCycle 的消息历史中已有一个 `Message::System``self.system_prompt` 也被设置时,
> 两个 source 哪个优先?当前代码只检查 `messages` 中是否已有 System 来决定是否插入,但 LlmCycle 的
> `with_system_prompt` 设置的 prompt 可能与消息历史中已有的 System 内容不同。
> 2. **方案 B** 统一通过顶层 `request.system` 传递,OpenaiProvider 内部将 `request.system` 转为
> System message 插入 messages 开头(与 AnthropicProvider 方向相反)。缺点:OpenAI 用户习惯
> 在 messages 中直接放 system message,可能不设置顶层 system。
> 3. **方案 C** 约定 `messages` 中的 System 和顶层 `system` 互斥——`build_request` 检查两者,
> 同时存在时报错或合并(顶层 system 覆盖 messages 中的 System)。
> 优先级:高(Phase 2-3 实现 AnthropicProvider 前必须解决)
主要变化:
- 返回类型 `MessageRequest`(非 `ChatRequest`
- `tools` 直接传入,不再需要 `OpenaiTool::Function` 包装
- `..Default::default()` 填充剩余字段
### 6.3 submit_with_tools —— 新的 tool 循环逻辑
```rust
pub async fn submit_with_tools(
&mut self,
prompt: String,
registry: &ToolRegistry,
) -> Result<MessageResponse, LlmError> {
let tools = registry.definitions();
let max_turns = self.config.max_tool_turns.unwrap_or(10);
self.messages.push(Message::user(prompt));
self.maybe_compact();
let mut turn = 0;
loop {
turn += 1;
if turn > max_turns { /* error */ }
let response = self.submit_request(&tools).await?;
// 从 content blocks 中检测 ToolUse(不再需要额外函数)
let tool_uses: Vec<&ContentBlock> = response.message.content.iter()
.filter_map(|b| if let ContentBlock::ToolUse { .. } = b { Some(b) } else { None })
.collect();
let should_execute = matches!(response.stop_reason, StopReason::ToolUse) && !tool_uses.is_empty();
self.messages.push(response.message.clone());
if !should_execute { return Ok(response); }
let calls: Vec<(String, Value)> = tool_uses.iter()
.map(|b| match b {
ContentBlock::ToolUse { name, input, .. } => (name.clone(), input.clone()),
_ => unreachable!(),
})
.collect();
let results = registry.invoke_all(calls, self.config.tool_timeout_secs).await;
for result in results {
let content = /* 序列化/截断逻辑 ... */;
self.messages.push(Message::tool_result(result.tool_name, content));
}
self.maybe_compact();
}
}
```
关键简化:
- 不再需要 `has_tool_calls_in_message()``extract_tool_calls_from_message()` 辅助函数
- 仅仅遍历 `content` 即可发现所有 `ToolUse` block
- 逻辑对 Provider 类型**完全透明**
### 6.4 submit_stream —— Provider 直接返回 StreamEvent
```rust
pub async fn submit_stream(
&mut self,
prompt: String,
tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
self.messages.push(Message::user(prompt));
let request = self.build_request(&tools);
// Provider 直接返回 StreamEvent 流,无需二次转换
let stream = self.provider.chat_stream(request).await?;
// 如果需要,可在 LlmCycle 层叠加额外处理
// (当前 pipelinestream → hook 触发 → 直接返回)
Ok(stream)
}
```
不再需要 `parse_chunk_stream()``stream.rs` 中的 `ChunkToEventStream` 可以移除)。
### 6.5 HookContext 引用调整
```rust
pub struct HookContext<'a> {
pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest
pub error: Option<&'a LlmError>,
pub attempt: u32,
pub turn_index: Option<u32>,
pub plan_step_index: Option<usize>,
}
```
### 6.6 compact 逻辑调整
`compact.rs` 中的 `estimate_message_tokens()``microcompact()` 需要从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,核心逻辑不变。
> **🔄 待深入推演:compact 在 IR 上的具体改法与 token 估算**
> 当前的 `microcompact` 通过将 `Tool` 消息的内容替换为 `"[pruned]"` 来释放 token。在 IR 中,
> 工具结果有两种表达方式:
> 1. `Message::Tool { content: [ContentBlock::Text { text: "大段工具结果..." }], tool_call_id }`
> 2. `Message::Assistant { content: [.., ContentBlock::ToolResult { content: [Text], .. }] }`(如果使用 IR 的 ToolResult block
>
> **需要推演:**
> 1. IR 下 `microcompact` 压缩哪个?只压缩 `Message::Tool` 还是也压缩 `ContentBlock::ToolResult`
> 如果两者共存,谁先被压缩?
> 2. `estimate_message_tokens` 的估算策略:当前按字符数估算(4 字符 ≈ 1 token),对 `ContentBlock`
> 的新类型(Thinking、ToolUse、ToolResult、Image 等)如何估算?Image block 用固定 50 token 的经验值
> 是否仍然合理?
> 3. `ContentBlock::Thinking` 是否在压缩范围内?thinking 内容通常不应该被压缩(因为思考是推理过程,
> 压缩后可能丢失上下文)。是否需要为 `CompactConfig` 增加 `compact_thinking` 选项?
> 优先级:中(Phase 3 适配 LlmCycle 时需同步修改)
---
## 7. 对上层的影响
### 7.1 ProviderRegistry —— 零改动
```rust
pub struct ProviderRegistry {
providers: HashMap<String, Box<dyn LlmProvider>>,
default_name: Option<String>,
}
// 所有方法逻辑不变
```
### 7.2 AgentSession —— 极小影响
```rust
// 当前
let response: ChatResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage); // Usage 类型不变
// 新
let response: MessageResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage); // 仍然可用——Usage 类型一致
```
`response.usage` 类型不变(仍是 `Usage`),`response.text()` 替代了 `response.message.content` 的文本提取。
### 7.3 Agent trait —— 零改动
`Agent::tool_definitions()` 返回 `Vec<ToolDefinition>`,类型不变。
### 7.4 PromptComposer —— 内部类型替换
`PromptComposer` 内部存储从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,公共方法签名不变(返回 `Message` 类型)。
---
## 8. 兼容性策略
### 8.1 From trait 双向转换
提供新旧类型之间的转换,平滑迁移:
```rust
// IR → 旧类型(兼容层)
impl From<ChatResponse> for MessageResponse { ... }
impl From<MessageResponse> for ChatResponse { ... }
impl From<OpenaiChatMessage> for Message { ... }
impl From<Message> for OpenaiChatMessage { ... }
impl From<ChatRequest> for MessageRequest { ... }
impl From<MessageRequest> for ChatRequest { ... }
```
### 8.2 LlmCycle 兼容 getter
```rust
impl LlmCycle {
// 新
pub fn messages(&self) -> &[Message] { &self.messages }
// 兼容旧
pub fn messages_openai(&self) -> Vec<OpenaiChatMessage> {
self.messages.iter().map(|m| m.clone().into()).collect()
}
}
```
### 8.3 测试代码的过渡
测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。