28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
436 lines
17 KiB
Markdown
436 lines
17 KiB
Markdown
# 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 trait(chat、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` 为 None,messages 中的 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 层叠加额外处理
|
||
// (当前 pipeline:stream → 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 大小估算 |
|
||
>
|
||
> ---
|
||
>
|
||
> ### 议题 3:Thinking 是否在压缩范围内
|
||
>
|
||
> **决策:方案 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, ®istry).await?;
|
||
self.cost_so_far.add(&response.usage); // Usage 类型不变
|
||
|
||
// 新
|
||
let response: MessageResponse = cycle.submit_with_tools(input, ®istry).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 中先保留旧类型别名以减少改动。
|