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

20 KiB
Raw Blame History

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 公开 APIget_conversation_summary() P1 读取 SessionMemory 中最新的摘要

非功能需求

# 需求 指标
N1 零外部依赖 不修改 Cargo.toml
N2 向后兼容 未设置 SummaryConfig 时行为零变化
N3 静默失败 摘要 LLM 调用失败不阻断 submit_turn
N4 摘要延迟 首次摘要 LLM 调用 ≤ 3s(依赖 provider 响应速度)

当前状态分析

消费端已就绪

FocusedConfig.summary_overridesrc/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_turnfinalize_turn 中,OnTurnEnd 触发之后、turn_index 递增之前,插入以下逻辑:

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_farslotsprovidersession_memory
  • 流式和同步版本统一处理
  • Option<SummaryConfig> 本身已提供 opt-in/opt-out
  • 不改变 Hook 系统签名

缺点

  • 摘要 LLM 调用延长了 submit_turn 的延迟(约 1-3s
  • 违反"Hook 哲学"(但 Option 配置已足够提供可插拔性)

方案 B(否决):扩展 HookContext

做法:在 HookContext 中增加 messages: &[Message]usage: &Usageprovider: Arc<dyn LlmProvider> 字段,让 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<dyn LlmProvider>'static 需求HookContext<'a> 的设计冲突

方案 C(否决):后台 spawn 异步摘要

做法token 检测通过后,tokio::spawn 后台任务做摘要生成和写入。

否决原因

  1. 写入冲突:后台任务无法获取 &mut AgentSession 来更新 slot config
  2. 绕过方式增加复杂度:后台任务需要直接操作 Arc<dyn MemoryStore> 的原始 keyslot_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<SummaryConfig>
agent/builder.rs 新增 summary_config(cfg) 方法
agent/session.rs 新增 last_summary_turn 字段;submit_turn / finalize_turn 插入检查点;关联函数 generate_summaryget_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 零新外部依赖

核心接口定义

SummaryConfigagent/summary.rs):

#[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<String>,
    /// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。
    /// 超过此值从尾部截断。字符级安全(`chars().take()`)。
    pub max_tool_result_chars: usize,
}

generate_summaryAgentSession 关联函数):

impl AgentSession {
    async fn generate_summary(
        provider: &Arc<dyn LlmProvider>,
        messages: &[Message],
        prompt_template: &str,
        summary_model: Option<&str>,
        max_tool_result_chars: usize,
    ) -> Result<String, LlmError> { ... }
}

should_summarizeAgentSession 方法):

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 blockImage / 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<SummaryConfig>
  • AgentBuilder 新增方法:
    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
    • 接收 providermessagesprompt_templatesummary_modelmax_tool_result_chars
    • 入口守卫:messages.is_empty() 时直接返回 Ok(String::new())
    • 构造 LlmCyclemax_tokens = Some(1024)
    • cycle.submit_messages(vec![Message::user_text(prompt)], vec![])
    • 提取 text 返回
  5. 公开 APIget_conversation_summary()self.session_memory.get("conversation_summary")

验证cargo build --all-targets

Step 16.4 — re-export

文件src/agent.rs

改动

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.rsSummaryConfig + format_messages + 默认 prompt + 截断守卫) 60 10 70
agent/runtime.rs1 个字段) 3 3
agent/builder.rs1 个方法) 8 3 11
agent/session.rs(检查点 + generate_summary + get_conversation_summary 40 100 140
agent.rsmodule 声明 + 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:启用摘要后的长对话

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:不启用时零影响

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.mdProvider 复用模式)
  • 当前代码:src/agent/session.rssubmit_turn 流程,OnTurnEnd 位置)
  • 当前代码:src/llm/cycle.rssubmit_messages 签名)
  • 当前代码:src/agent/context.rsFocusedConfig.summary_override + filter_focused 消费逻辑)
  • 当前代码:src/agent/runtime.rsAgentConfig 结构)
  • 当前代码:src/agent/builder.rsBuilder 链式模式)
  • 当前代码:src/agent/session_memory.rsset/get API