将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
20 KiB
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
成功标准
- 开发者只需在
AgentBuilder中链式调用.summary_config(cfg)即可启用 - 长对话(如 30+ 轮或 token 水位超过
max_context_tokens * trigger_token_ratio)自动触发摘要,下轮load_messages()返回值包含[上下文摘要] {summary} - 摘要生成不改变
submit_turn行为(opt-in、静默失败、不阻断主流程) - 零新外部依赖
需求分析
功能需求
| # | 需求 | 优先级 | 说明 |
|---|---|---|---|
| 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 递增之前,插入以下逻辑:
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<SummaryConfig>本身已提供 opt-in/opt-out- 不改变 Hook 系统签名
缺点:
- 摘要 LLM 调用延长了
submit_turn的延迟(约 1-3s) - 违反"Hook 哲学"(但
Option配置已足够提供可插拔性)
方案 B(否决):扩展 HookContext
做法:在 HookContext 中增加 messages: &[Message]、usage: &Usage、provider: Arc<dyn LlmProvider> 字段,让 OnTurnEnd Hook 实现者自行做摘要。
否决原因:
- 生命周期冲突:
&[Message]要求 Hook 调用点消息已就绪但未被&mut self借用——在submit_turn第 7 步(slot.save)后消息已就绪,但 to pass&[Message]到 HookContext 需要与slot.messages的不可变引用共存,而submit_turn流程中后续步骤需要&mut self - 流式路径不可行:
finalize_turn触发 OnTurnEnd 时 cycle 已销毁,消息只能从 slot 获取,但 slot 在append_messages后已被&mut借用 Arc<dyn LlmProvider>的'static需求与HookContext<'a>的设计冲突
方案 C(否决):后台 spawn 异步摘要
做法:token 检测通过后,tokio::spawn 后台任务做摘要生成和写入。
否决原因:
- 写入冲突:后台任务无法获取
&mut AgentSession来更新 slot config - 绕过方式增加复杂度:后台任务需要直接操作
Arc<dyn MemoryStore>的原始 key(slot_config:{session_id}:{slot_id}),绕过了ContextSlot::save()的封装 - 并发风险:如果前一轮摘要尚未完成而下一轮
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_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):
#[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_summary(AgentSession 关联函数):
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_summarize(AgentSession 方法):
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 SummaryConfigformat_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
改动:
AgentSession新增字段:last_summary_turn: u32(初始化 0)submit_turn中 OnTurnEnd 之后、turn_index 之前插入检查点finalize_turn中 OnTurnEnd 之后插入对称检查点。注意:流式路径中turn_index已在submit_turn_stream中递增,检查点赋值使用self.turn_index.saturating_sub(1)(与OnTurnEndhook 保持一致)。- 关联函数
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 返回
- 接收
- 公开 API:
get_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.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:启用摘要后的长对话
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.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)