# Phase 16 — 摘要自动生成 ## 背景与目标 ### 问题 长对话场景中,用户与 Agent 交互 30+ 轮后,消息历史长度远超模型上下文窗口,导致: - LLM 被迫丢弃早期上下文,对话丧失连贯性 - 开发者需要手动管理摘要逻辑(调 LLM → 写 SessionMemory → 注入 FocusedConfig) - v0.2 的 `FocusedConfig.summary_override` 消费端已就绪,但生产端是空的——用户只能手动设字符串 ### 目标 闭环长对话的"上下文压缩"链路: ``` [消费端 v0.2 已就绪] FocusedConfig.summary_override → filter_focused() 注入摘要 [生产端 v0.3 补齐] token 水位检测 → LLM 摘要生成 → 自动写入 summary_override ``` ### 成功标准 1. 开发者只需在 `AgentBuilder` 中链式调用 `.summary_config(cfg)` 即可启用 2. 长对话(如 30+ 轮或 token 水位超过 `max_context_tokens * trigger_token_ratio`)自动触发摘要,下轮 `load_messages()` 返回值包含 `[上下文摘要] {summary}` 3. 摘要生成不改变 `submit_turn` 行为(opt-in、静默失败、不阻断主流程) 4. 零新外部依赖 --- ## 需求分析 ### 功能需求 | # | 需求 | 优先级 | 说明 | |---|------|--------|------| | F1 | `SummaryConfig` 配置结构体 | P0 | `trigger_token_ratio` / `max_context_tokens` / `summary_prompt` / `debounce_turns` / `summary_model` / `max_tool_result_chars` | | F2 | Token 水位自动检测 | P0 | 每轮 OnTurnEnd 之后检查 `cost_so_far` 是否超过 `max * ratio` | | F3 | LLM 摘要生成 | P0 | 复用 `self.bundle.provider`,单次无工具 LLM 调用 | | F4 | 摘要写入 FocusedConfig | P0 | 更新 `summary_override` + `slot.save()` 持久化 | | F5 | 摘要全局快照 | P0 | 同步写入 `SessionMemory::set("conversation_summary", summary)` | | F6 | 防抖机制 | P0 | 两次摘要之间至少间隔 `debounce_turns` 轮(默认 3) | | F7 | 流式路径对称支持 | P0 | `finalize_turn` 中插入相同检查点 | | F8 | 公开 API:`get_conversation_summary()` | P1 | 读取 SessionMemory 中最新的摘要 | ### 非功能需求 | # | 需求 | 指标 | |---|------|------| | N1 | 零外部依赖 | 不修改 `Cargo.toml` | | N2 | 向后兼容 | 未设置 `SummaryConfig` 时行为零变化 | | N3 | 静默失败 | 摘要 LLM 调用失败不阻断 `submit_turn` | | N4 | 摘要延迟 | 首次摘要 LLM 调用 ≤ 3s(依赖 provider 响应速度) | --- ## 当前状态分析 ### 消费端已就绪 `FocusedConfig.summary_override`(`src/agent/context.rs`)已在 Phase 10 实现,当前消费逻辑: ``` filter_focused() → 若 cfg.summary_override = Some(text) → 在消息列表末尾插入 Message::system("[上下文摘要] {text}") ``` 文档注释明确标注:`// v0.3 将支持 Hook 驱动的自动摘要生成` ### 代码上下文 | 模块 | 文件 | 状态 | 与 Phase 16 的关系 | |------|------|------|-------------------| | FocusedConfig | `agent/context.rs` | ✅ 消费端 | 摘要写入 `summary_override` 即生效 | | OnTurnEnd | `agent/session.rs:345` | ✅ 触发点 | 摘要检查点插在此之后 | | CostTracker | `llm/cycle/usage.rs` | ✅ 累计 token | 水位检测的数据源 | | SessionMemory | `agent/session_memory.rs` | ✅ set/get | 摘要全局快照存储 | | AgentBuilder | `agent/builder.rs` | ✅ 链式构造 | 新增 `.summary_config()` | | AgentConfig | `agent/runtime.rs` | ✅ 配置结构 | 新增 `summary_config` 字段 | | LlmProvider | `llm/provider.rs` | ✅ Trait | 摘要 LLM 调用复用 provider | | ContextSlot.save | `agent/context.rs:251` | ✅ 持久化 | 更新 config 后写回 | --- ## 可选方案推演 ### 方案 A(推荐):内联检查点 **做法**:在 `submit_turn` 和 `finalize_turn` 中,OnTurnEnd 触发之后、`turn_index` 递增之前,插入以下逻辑: ```rust if let Some(ref sc) = self.bundle.config.summary_config && self.should_summarize(sc) { // clone 所需数据(释放 &self 借用) let provider = Arc::clone(&self.bundle.provider); let messages = self.slots.get(&self.current_slot_id) .map(|s| s.messages.clone()).unwrap_or_default(); let prompt = sc.summary_prompt.clone(); let model = sc.summary_model.clone(); // 调关联函数(不持有 &self) match Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), sc.max_tool_result_chars).await { Ok(text) => { // 更新 FocusedConfig + 持久化 if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { if let SlotMode::Focused(ref mut cfg) = slot.config.mode { cfg.summary_override = Some(text.clone()); } let _ = slot.save(&*self.resolve_store()).await; } // 全局快照 let _ = self.session_memory.set("conversation_summary", &text).await; self.last_summary_turn = self.turn_index; } Err(e) => tracing::error!("摘要自动生成失败 (turn={}): {}", self.turn_index, e), } } ``` **优点**: - 代码路径最短最清晰(~50 行核心逻辑) - 直接访问所有需要的数据(`cost_so_far`、`slots`、`provider`、`session_memory`) - 流式和同步版本统一处理 - `Option` 本身已提供 opt-in/opt-out - 不改变 Hook 系统签名 **缺点**: - 摘要 LLM 调用延长了 `submit_turn` 的延迟(约 1-3s) - 违反"Hook 哲学"(但 `Option` 配置已足够提供可插拔性) ### 方案 B(否决):扩展 HookContext **做法**:在 `HookContext` 中增加 `messages: &[Message]`、`usage: &Usage`、`provider: Arc` 字段,让 OnTurnEnd Hook 实现者自行做摘要。 **否决原因**: 1. **生命周期冲突**:`&[Message]` 要求 Hook 调用点消息已就绪但未被 `&mut self` 借用——在 `submit_turn` 第 7 步(slot.save)后消息已就绪,但 to pass `&[Message]` 到 HookContext 需要与 `slot.messages` 的不可变引用共存,而 `submit_turn` 流程中后续步骤需要 `&mut self` 2. **流式路径不可行**:`finalize_turn` 触发 OnTurnEnd 时 cycle 已销毁,消息只能从 slot 获取,但 slot 在 `append_messages` 后已被 `&mut` 借用 3. **`Arc` 的 `'static` 需求**与 `HookContext<'a>` 的设计冲突 ### 方案 C(否决):后台 spawn 异步摘要 **做法**:token 检测通过后,`tokio::spawn` 后台任务做摘要生成和写入。 **否决原因**: 1. **写入冲突**:后台任务无法获取 `&mut AgentSession` 来更新 slot config 2. **绕过方式增加复杂度**:后台任务需要直接操作 `Arc` 的原始 key(`slot_config:{session_id}:{slot_id}`),绕过了 `ContextSlot::save()` 的封装 3. **并发风险**:如果前一轮摘要尚未完成而下一轮 `finalize_turn` 又触发,可能导致覆盖写 --- ## 推荐方案(内联检查点) ### 架构图 ``` submit_turn(user_input) │ ├─ 1. Readonly 检查 ├─ 2. OnTurnStart hook ├─ 3. slot.load_messages() ← 历史摘要已注入(如有) ├─ 4. LlmCycle.submit_with_tools ├─ 5. cost_so_far.add(usage) ├─ 6. slot.append_messages + save ├─ 7. OnTurnEnd hook ← 纯通知,不做摘要 │ ├─ [8.5] 摘要检查点 ──────────────────────────────┐ │ ├─ should_summarize(cfg) │ │ │ ├─ cost_so_far >= max * ratio? │ │ │ └─ turn - last_summary >= debounce? │ │ │ │ │ ├─ generate_summary() ← 新 LlmCycle │ │ │ ├─ format_messages_as_text() │ │ │ ├─ replace {messages} │ │ │ └─ submit_messages(无 tools) │ │ │ │ │ └─ 成功 → 更新 summary_override + save │ │ → SessionMemory.set() │ │ → last_summary_turn = turn_index │ │ (流式路径用 saturating_sub(1) 修正) │ │ 失败 → tracing::error! 静默 │ │ │ ├─ 9. turn_index++ └─ 10. return Ok(response) ``` ### 模块划分 **新增文件**:`src/agent/summary.rs` ``` src/agent/summary.rs ├── SummaryConfig // 摘要自动生成配置 ├── format_messages_as_text() // 消息 → 纯文本(简洁版) └── DEFAULT_SUMMARY_PROMPT // 默认 prompt 模板 ``` **修改文件**: | 文件 | 改动 | |------|------| | `agent/runtime.rs` | `AgentConfig` 新增 `summary_config: Option` | | `agent/builder.rs` | 新增 `summary_config(cfg)` 方法 | | `agent/session.rs` | 新增 `last_summary_turn` 字段;`submit_turn` / `finalize_turn` 插入检查点;关联函数 `generate_summary`;`get_conversation_summary()` | | `agent.rs` | `pub mod summary` + re-export | **不变的文件**(无需改动): | 文件 | 原因 | |------|------| | `llm/hooks.rs` | 内联方案不扩展 HookContext | | `llm/cycle.rs` | 摘要调用通过 `submit_messages` 独立使用 | | `agent/context.rs` | `FocusedConfig` 消费端已在 Phase 10 就绪 | | `Cargo.toml` | 零新外部依赖 | ### 核心接口定义 **`SummaryConfig`**(`agent/summary.rs`): ```rust #[derive(Debug, Clone)] pub struct SummaryConfig { /// Token 水位触发比例(0.0 ~ 1.0)。默认 0.75。 pub trigger_token_ratio: f64, /// 模型上下文窗口大小(token)。默认 32_000,覆盖大部分开源模型。 /// 修改为匹配实际使用模型的上下文窗口。 /// ⚠️ 设置为超过模型窗口的值会导致摘要永远不触发。 pub max_context_tokens: u32, /// 摘要 prompt 模板。`{messages}` 将被替换为对话历史文本。 pub summary_prompt: String, /// 防抖轮次。默认 3。 pub debounce_turns: u32, /// 摘要生成模型(None = 沿用主 provider 默认模型)。 /// 默认 None。推荐设为便宜模型(如 "gpt-4o-mini")以节省成本。 pub summary_model: Option, /// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。 /// 超过此值从尾部截断。字符级安全(`chars().take()`)。 pub max_tool_result_chars: usize, } ``` **`generate_summary`**(`AgentSession` 关联函数): ```rust impl AgentSession { async fn generate_summary( provider: &Arc, messages: &[Message], prompt_template: &str, summary_model: Option<&str>, max_tool_result_chars: usize, ) -> Result { ... } } ``` **`should_summarize`**(`AgentSession` 方法): ```rust fn should_summarize(&self, cfg: &SummaryConfig) -> bool { self.turn_index - self.last_summary_turn >= cfg.debounce_turns && self.cost_so_far.total().total_tokens as f64 >= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio } ``` ### 消息格式化(简洁版) `format_messages_as_text` 输出格式: ``` System: 你是一个翻译助手 User: 把这段英文翻译成中文 Assistant: 请提供英文文本 [Tool: translate] Tool Result: 这是中文翻译 User: 谢谢 Assistant: 不客气 ``` 处理规则: - `ContentBlock::Text { text }` → 直接拼接 - `ContentBlock::ToolUse { name, .. }` → `[Tool: {name}]`(不显示参数 JSON) - `Message::ToolResult { content, is_error, tool_call_id }` → `Tool Result [{tool_call_id}]:` / `Tool Error [{tool_call_id}]:`,便于多工具场景下关联调用的返回 - ToolResult 文本截断到前 `max_tool_result_chars` 个 Unicode 字符(`chars().take(n)`,字符级安全,避免多字节截断) - 整段对话若超过 30K 字符,从前面截断(优先保留最新消息) - `Message::UserImage { .. }` → `User: [image]` - 非 Text block(Image / Audio / File 等)统一标记为 `[{kind}]` - 每条消息一行,空行分隔 --- ## 实现计划 ### Step 16.1 — `SummaryConfig` 结构体 **文件**:新增 `src/agent/summary.rs` **内容**: - `SummaryConfig` 结构体定义(6 个字段 + doc comments) - `DEFAULT_SUMMARY_PROMPT` 常量(约 100 字中文 prompt,含 `{messages}` 占位符) - `impl Default for SummaryConfig` - `format_messages_as_text(messages: &[Message]) -> String` 辅助函数 **验证**:`cargo build` ### Step 16.2 — `AgentConfig` 扩展 + `AgentBuilder` 方法 **文件**:`src/agent/runtime.rs` + `src/agent/builder.rs` **改动**: - `AgentConfig` 新增字段:`pub summary_config: Option` - `AgentBuilder` 新增方法: ```rust pub fn summary_config(mut self, cfg: SummaryConfig) -> Self { let mut config = self.config.take().unwrap_or_default(); config.summary_config = Some(cfg); self.config = Some(config); self } ``` **验证**:`AgentBuilder` 单元测试 + `cargo test` ### Step 16.3 — `AgentSession` 新字段 + 检查点 **文件**:`src/agent/session.rs` **改动**: 1. `AgentSession` 新增字段:`last_summary_turn: u32`(初始化 0) 2. `submit_turn` 中 OnTurnEnd 之后、turn_index 之前插入检查点 3. `finalize_turn` 中 OnTurnEnd 之后插入对称检查点。注意:流式路径中 `turn_index` 已在 `submit_turn_stream` 中递增,检查点赋值使用 `self.turn_index.saturating_sub(1)`(与 `OnTurnEnd` hook 保持一致)。 4. 关联函数 `generate_summary`: - 接收 `provider`、`messages`、`prompt_template`、`summary_model`、`max_tool_result_chars` - 入口守卫:`messages.is_empty()` 时直接返回 `Ok(String::new())` - 构造 `LlmCycle`(`max_tokens = Some(1024)`) - 调 `cycle.submit_messages(vec![Message::user_text(prompt)], vec![])` - 提取 text 返回 5. 公开 API:`get_conversation_summary()` → `self.session_memory.get("conversation_summary")` **验证**:`cargo build --all-targets` ### Step 16.4 — re-export **文件**:`src/agent.rs` **改动**: ```rust pub mod summary; pub use summary::SummaryConfig; ``` **验证**:`cargo test --all-targets` ### Step 16.5 — 测试 | 测试 | 验证点 | 方式 | |------|--------|------| | `summary_config_defaults` | 默认值正确 | 单元测试 | | `summary_not_generated_below_threshold` | token < 阈值时不触发 | `MockProvider` + `Usage::from_input_output(10, 5)` | | `summary_generated_above_threshold` | token ≥ 阈值时触发 | 设置 `max_context_tokens=20` + `trigger_token_ratio=0.5` | | `summary_debounce_works` | debounce 内不重复 | 强行触发摘要后验证 3 轮内不触发 | | `summary_injected_into_focused` | Focused 模式 `load_messages()` 含 `[上下文摘要]` | 检查 Message 内容 | | `summary_written_to_session_memory` | `get_session_data("conversation_summary")` 有值 | 集成测试 | | `summary_not_injected_in_full_mode` | Full 模式不改 slot config | 验证 `summary_override` 为 None | | `summary_failure_does_not_block` | LLM error 不阻断 `submit_turn` | MockProvider 返回错误 | | `summary_stream_path` | 流式路径 `finalize_turn` 正确触发 | `submit_turn_stream` 端到端 | | `summary_format_messages` | 格式化输出结构正确 | 单元测试验证格式 | | `summary_skipped_for_empty_messages` | 空消息不调用 LLM | `generate_summary` 直接返回 `""` | | `summary_not_generated_if_max_context_unreachable` | `max_context_tokens` 过大时不触发 | 验证条件不满足 | **验证**:`cargo test --all-targets` 全绿 --- ## 规模估算 | 组件 | 纯实现 | 测试 | 合计 | |------|--------|------|------| | `agent/summary.rs`(SummaryConfig + format_messages + 默认 prompt + 截断守卫) | 60 | 10 | 70 | | `agent/runtime.rs`(1 个字段) | 3 | — | 3 | | `agent/builder.rs`(1 个方法) | 8 | 3 | 11 | | `agent/session.rs`(检查点 + generate_summary + get_conversation_summary) | 40 | 100 | 140 | | `agent.rs`(module 声明 + re-export) | 3 | — | 3 | | **合计** | **109** | **113** | **~222** | --- ## 风险评估 ### 已知风险 | 风险 | 概率 | 影响 | 缓解措施 | |------|------|------|---------| | **同步阻塞**:摘要 LLM 调用延长 submit_turn 延迟 | 高 | 长对话用户多等 1-3s | 对于已达 75% 水位的长对话,用户感知可接受;所有错误静默处理 | | **默认模型不兼容**:非 OpenAI 用户未设置 `summary_model` 但默认 `None` 沿用主模型 | 低 | 无影响 | `summary_model` 默认 `None`,沿用主 provider 默认模型,零兼容问题 | | **无限循环**:摘要不减少 cost_so_far,每轮都超阈值 | 中 | 频繁 LLM 调用浪费 token | `debounce_turns=3` 强制隔断;`last_summary_turn` 记录确保了间隔。注意:摘要 token 不计入 `cost_so_far`(独立 LlmCycle),阈值不会因摘要本身加速膨胀 | | **Focusd 模式摘要位置**:注入为 `system` 消息排在列表末尾 | 低 | LLM 近因效应,摘要可能过度受关注 | 这是 v0.2 消费端的设计选择,Phase 16 不改变 | | **SessionMemory key 冲突**:用户手动写入 `"conversation_summary"` 会被覆盖 | 低 | 数据被摘要覆盖 | 文档建议用户自定义 key;或未来使用 namespaced key | | **可观测性盲区**:`tracing::warn!` 依赖用户配置了 tracing subscriber | 中 | 失败静默不可见 | 提升到 `tracing::error!` 级别,或加 `eprintln!` fallback | ### 不做的事 - ❌ 不扩展 `HookContext` - ❌ 不引入 `tokio::spawn` 后台摘要 - ❌ 不做增量摘要(`SummaryStrategy::Incremental` 留待 v0.4) - ❌ 不改 `filter_focused()` 的摘要注入位置 - ❌ 不追踪摘要 token 消耗(`summary_cost_so_far`) - ❌ 不添加运行时 prompt 校验(不检查 `{messages}` 是否存在) - ❌ 不添加 `MergeStrategy::Summarize` 变体(`context.rs:108` 预占注释将在实施时同步移除或更新) --- ## 验收标准 ### 编译与测试 | 检查项 | 指标 | |--------|------| | `cargo build --all-targets` | ✅ 通过 | | `cargo test --all-targets` | ✅ 全量通过(预计 335 → ~345,新增 ~10 测试) | | `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 | | 测试覆盖范围 | F1-F8、N1-N4 | ### 功能验收场景 **场景 1:启用摘要后的长对话** ```rust let session = AgentSession::new(agent, "session-1", Arc::new( AgentBuilder::new() .provider(provider) .tool_registry(registry) .hook_executor(executor) .summary_config(SummaryConfig { max_context_tokens: 100, trigger_token_ratio: 0.5, debounce_turns: 2, ..Default::default() }) .build()? )); session.submit_turn("msg 1").await?; // ... submit_turn 多次直到 token 超 50 ... // 第 N 轮:摘要自动生成 let summary = session.get_session_data("conversation_summary").await?; assert!(summary.is_some()); // Focused 模式下 load_messages 包含摘要 ``` **场景 2:不启用时零影响** ```rust let session = AgentSession::new(agent, "session-2", bundle); // 无 summary_config for i in 0..50 { session.submit_turn(&format!("msg {}", i)).await?; } // 没有摘要产生,没有额外的 LLM 调用 ``` --- ## 参考来源 - Phase 10 方案文档:`docs/17-phase10-contextslot.md`(§5 FocusedConfig 消费端设计) - Phase 14 方案文档:`docs/20-phase14-document-and-embedding.md`(Provider 复用模式) - 当前代码:`src/agent/session.rs`(submit_turn 流程,OnTurnEnd 位置) - 当前代码:`src/llm/cycle.rs`(submit_messages 签名) - 当前代码:`src/agent/context.rs`(FocusedConfig.summary_override + filter_focused 消费逻辑) - 当前代码:`src/agent/runtime.rs`(AgentConfig 结构) - 当前代码:`src/agent/builder.rs`(Builder 链式模式) - 当前代码:`src/agent/session_memory.rs`(set/get API)