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

436 lines
17 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 {
// `system_prompt` 是权威来源:显式设置时替换 messages 中的任何 System
messages.retain(|m| !matches!(m, Message::System { .. }));
messages.insert(0, Message::system(sys_prompt));
}
// 如果 `system_prompt` 为 Nonemessages 中的 System 保持原样,
// 由各 Provider 在 IR→原生映射层各自处理
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()
}
}
```
> **✅ 推演结论(2026-06-22):方案 D —— 移除 `system` 字段,IR 中只留一个入口**
>
> **决策:** 从 `MessageRequest` 中移除 `system: Option<String>` 字段,统一通过 `messages: Vec<Message>`
> 中的 `Message::System { content }` 表达系统提示。各 Provider 在 IR→原生 映射层自行处理差异。
>
> **理由:**
> 1. **与整套 IR 设计的理念一致**——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、
> Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异"
> system prompt 的双重入口是同一个问题,应用同样的原则。
> 2. **消除歧义的最佳方式是砍掉一个入口**——两个入口导致的"谁优先"问题在类型层面就解决了,
> 不需要运行时规则。
> 3. **每个 Provider 做自己的转换本来就是 Provider 层的职责**——OpenaiProvider 几乎零成本
> System 直接序列化为 role=`system`),AnthropicProvider 做提取+移除(约 10 行代码),
> DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。
>
> **具体做法:**
>
> **① `MessageRequest`[9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求)**
> 删除 `system: Option<String>` 字段。`messages: Vec<Message>` 是系统提示的唯一载体。
>
> **② `LlmCycle::build_request`(本节上方代码)**
> 增加"替换"语义:`self.system_prompt` 设置时,先 `retain` 移除 messages 中已有的所有
> `Message::System`,再插入新的。确保 `system_prompt` 作为权威来源。
>
> **③ `OpenaiProvider::ir_to_native`**
> 零改动。`Message::System { content }` 直接映射为 `role: "system"`(或 `role: "developer"`)。
>
> **④ `AnthropicProvider::ir_to_native`**[9d-provider-implementations.md](9d-provider-implementations.md)
> 新增提取逻辑:
> ```rust
> // 1. 遍历 messages,收集所有 System 的纯文本内容
> // 2. 若有多个 System,合并为一个字符串(Anthropic 只接受一个)
> // 3. 设置 Anthropic 请求的顶层 `system` 参数
> // 4. 从 messages 中移除所有 System 消息
> // 5. 非文本 ContentBlock 静默丢弃 + warn! log
> ```
>
> **边界情况处理:**
> | `self.system_prompt` | messages 中已有的 System | 结果 |
> |---|---|---|
> | `None` | 无 System | messages 不变 |
> | `None` | `System("B")` | 保留,Provider 层处理 |
> | `Some("A")` | 无 System | 插入 System("A") |
> | `Some("A")` | `System("B")` | **移除 B,插入 A**(显式设置优先) |
> | `Some("A")` | 多个 System("B1"), ("B2") | **移除所有,插入 A** |
>
> **何时实现:** Phase 2-3 实现 AnthropicProvider 时同步完成。
> **影响范围:** `MessageRequest` 删除一个字段 + `build_request` 增 2 行 `retain` + AnthropicProvider 增约 10 行提取逻辑。
主要变化:
- 返回类型 `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>`,核心逻辑不变。
> **✅ 推演结论(2026-06-22):`microcompact` 压缩对象 + 估算策略 + Thinking 压缩策略**
>
> 推演覆盖三个议题(压缩对象、token 估算、Thinking 压缩),并引入**大小 × 重要性二维决策框架**作为统一的压缩决策模型。
>
> ---
>
> ### 议题 1`microcompact` 压缩哪个?
>
> **决策:方案 B —— 同时压缩 `Message::Tool` 和 `Message::Assistant` 中的 `ContentBlock::ToolResult`。**
>
> **理由:**
> 1. 两者存储的是相同语义的数据(工具执行结果),组织结构不同但语义等价。只压缩一种会漏掉另一种。
> 2. LlmCycle 工具循环主路径产生 `Message::Tool`,但 Anthropic 格式反序列化后可能出现 `ToolResult` 嵌入在 Assistant 中。未来 `AnthropicProvider` 实现后后者场景会增加。
> 3. `ContentBlock::ToolUse` **不压缩**——id/name/input 是工具调用的必需元数据,体积小(通常 < 1K),压缩反而破坏后续工具重放。
>
> 无需优先级排序(两者不在同一条消息中,不存在先后问题):
> - `Message::Tool`:独立的 Tool 消息,压缩其整个 `content` 字段
> - `Message::Assistant` 中的 `ToolResult`:遍历 content 找到所有 `ToolResult` block,压缩其内部的 `content`
>
> **实现示意:**
>
> ```rust
> pub fn microcompact(messages: &mut [Message], keep_recent: usize, config: &CompactConfig) -> u32 {
> if messages.len() <= keep_recent { return 0; }
> let prune_start = messages.len() - keep_recent;
> let mut freed_tokens: u32 = 0;
>
> // Phase 1:估算压缩前 token 数
> for msg in &messages[..prune_start] {
> freed_tokens += estimate_compressible_tokens(msg, config);
> }
>
> // Phase 2:执行压缩
> for msg in &mut messages[..prune_start] {
> apply_compact_action(msg, config);
> }
>
> freed_tokens
> }
> ```
>
> ---
>
> ### 议题 2`estimate_message_tokens` 的估算策略
>
> **决策:方案 B —— 按 ContentBlock 类型分档估算,非 Text block 使用差异化经验值。**
>
> **理由:**
> - compact 是启发式压缩,不需要精确 token 计数(最终由 Provider 的 tokenizer 精确计算),方案 C 的精度收益不值得这个复杂度
> - 方案 A(统一固定 50)精度太低——Thinking block 可长达几万 token,固定 50 会导致严重低估
>
> **分档表:**
>
> | `ContentBlock` 类型 | 估算方式 | 说明 |
> |---|---|------|
> | `Text { text }` | `(len * 4).div_ceil(3)` | 保持当前字符估算逻辑 |
> | `Image { .. }` | 固定 85 | OpenAI 低分辨率固定定价 |
> | `Audio { .. }` | 固定 100 | 音频通常大于图片 |
> | `File { source }` | 50 + 文件名文本估算 | 元数据开销 + 文件名 |
> | `ToolUse { name, input }` | `estimate_text(name) + estimate_text(input.to_string())` | name + JSON 参数 |
> | `ToolResult { content }` | 递归估算内部 content block 列表 | 递归到叶子节点 |
> | `Thinking { text }` | `estimate_text(text)` | 按文本估算(内容往往很长) |
> | `Extension { data }` | `estimate_text(data.to_string())` | 逃生舱,按 JSON 大小估算 |
>
> ---
>
> ### 议题 3Thinking 是否在压缩范围内
>
> **决策:方案 A —— 默认不压缩 Thinking`CompactConfig` 增加 `compact_thinking: bool` 选项。**
>
> **理由:**
> 1. Thinking 不同于 ToolResult——ToolResult 是"已执行的事实结果",压缩后语义无损;Thinking 是"推理过程",压缩后可能丢失决策上下文
> 2. 但 thinking 内容确实可能很长(尤其 Anthropic extended thinking 模式),不应完全放弃压缩能力
> 3. 提供选项让用户根据场景自行决定:任务型 Agent(可压) vs 推理密集型 Agent(不压)
>
> ```rust
> #[derive(Debug, Clone)]
> pub struct CompactConfig {
> pub context_window: u32,
> pub reserved_tokens: u32,
> pub keep_recent: usize,
> /// 是否压缩 Thinking 内容。默认 false。
> pub compact_thinking: bool,
> }
> ```
>
> 压缩时将 Thinking block 的 `text` 替换为 `"[pruned thinking]"` 以区别于 ToolResult 的 `"[pruned]"`。
>
> ---
>
> ### ⭐ 新推演维度:大小 × 重要性二维决策框架
>
> 上述三个议题各自独立解决,但**压缩强度的选择**需要融合两个互补指标:
>
> | 维度 | 解决什么问题 | 来源 | 性质 |
> |------|------------|------|------|
> | **大小** | "这东西有多重?"——为释放空间值不值得动手 | 纯计算(字符数) | 精确 |
> | **重要性** | "动了之后损失什么?"——压缩对后续推理的影响 | 多来源综合 | 语义 |
>
> #### 二维决策矩阵
>
> ```
> 重 要 性
> 低 (Action) 高 (Factual)
> ┌────────────────────────────────
> 小 │ 跳过 跳过
> │ (< 200 char, 重要但小,不值得为它费劲
> 大 | 不值得)
> | │
> 小 │ 截断保留开头 结构化摘要
> │ ("[前200字]...") (保留 JSON 骨架 + 截断数据体)
>
> 大 │ 替换 [pruned] 截断优先 → 元数据保留
> │ (零价值 + 大体积) (实在太大才 [pruned])
> ```
>
> #### 重要性的四个来源
>
> | 来源 | 时机 | 输入 | 输出 |
> |------|------|------|------|
> | **① 工具注册声明** | 编译/初始化时 | `ToolDefinition.result_semantic: ResultSemantic` | 基础分:Factual=+1, Action=-1, Mixed=0 |
> | **② 内容模式启发式** | compact 触发时 | 检测结果中的 JSON 特征 | 加分:列表数据+1,状态确认-1,错误结果→跳过 |
> | **③ 对话轮次衰退** | compact 触发时 | 距离最后一次被引用的轮次 | 每超 K 轮 → -0.5 |
> | **④ 后续引用跟踪** | 事后(为下次积累) | Assistant 内容与结果的关键词重叠 | 更新引用时间戳 |
>
> ```rust
> /// 工具注册时声明结果的语义类型
> #[derive(Debug, Clone, Copy)]
> pub enum ResultSemantic {
> /// 结果是"事实依据"——后续对话可能反复引用(搜索、文档查询)
> Factual,
> /// 结果是"一次性动作确认"——执行完就过了(发送邮件、创建记录)
> Action,
> /// 兼具两者特征 / 不确定(默认)
> Mixed,
> }
>
> /// 压缩动作 —— 由 [大小, 重要性] 综合决定
> pub enum CompactAction {
> /// 不压缩
> Skip,
> /// 截断保留前 N 字符
> Truncate { keep: usize },
> /// 尝试保留 JSON 结构 + 截断数据体
> TruncateStructured { keep: usize },
> /// 替换为 [pruned] 或 [pruned thinking]
> Replace,
> }
> ```
>
> #### 综合评分 → 动作映射
>
> ```rust
> fn decide_strategy(size: usize, score: i8) -> CompactAction {
> match (size, score) {
> (0..=200, _) => CompactAction::Skip, // 太小不压
> (201..=2000, s) if s >= 1 => CompactAction::Skip, // 重要 + 中等 → 保留
> (201..=2000, _) => CompactAction::Truncate { keep: 200 },
> (2001.., s) if s >= 2 => CompactAction::TruncateStructured { keep: 500 },
> (2001.., s) if s >= 1 => CompactAction::Truncate { keep: 200 },
> (2001.., _) => CompactAction::Replace, // 大 + 不重要 → 全换
> }
> }
> ```
>
> **何时实现:** Phase 3 适配 LlmCycle 时同步修改 compact.rs。
> **影响范围:** `compact.rs` 全部重写(保留函数签名,内部逻辑适配 IR)+ `CompactConfig` 变更 + 需要在 `ToolDefinition` 中新增 `result_semantic` 字段(Phase 3+ `llm-cycle.rs` 中 compact 调用点的接口适配。
---
## 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 中先保留旧类型别名以减少改动。