将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
51 KiB
Phase 10 — ContextSlot 上下文管理
- 文档编号:17
- 标题:Phase 10 — ContextSlot 上下文管理
- 日期:2026-07-06
- 状态:已定稿
- 涉及模块:agent/context(新建)、agent/session、examples
- 关联文档:roadmap.md(§Phase 10)、7-agent-runtime.md、16-phase9-streaming-experience.md
1. 背景与目标
agcore 已发布 v0.2.0-rc.1,Phase 0-9 全部完成。当前 AgentSession 只有"单一对话线程"——所有 submit_turn 调用共享同一消息列表,无法在同一个 session 内部管理多个独立上下文。
Phase 10 的目标是引入 ContextSlot(上下文槽) 概念,使 Agent 能在单个 session 内创建、切换、派生多个独立的消息上下文。开发者可像"多标签页"一样管理对话分支,每个 slot 有独立的配置、消息列表和持久化。
1.1 成功标准
ContextSlot核心类型可编译,支持三种模式(Full / Focused / Readonly)、三种来源(New / Derived / Static)- 基于
MemoryStoretrait 的持久化:save / load / list / delete 完整,session 间隔离 AgentSession扩展:create_slot/switch_slot/list_slots/derive_slot/delete_slot,自动创建"default"slot- 向后兼容:现有
submit_turn签名不变,存量测试 0 回归 - 分支对话示例可运行:
cargo run --example context_slot_demoexit 0
2. 当前状态分析
2.1 AgentSession 现状
当前 AgentSession 没有 slot 概念。其消息流向如下:
submit_turn(user_input)
└─ 组装 initial_messages: [system_prompt]
└─ LlmCycle::new().with_messages(initial_messages)
└─ cycle.submit_with_tools(user_input, tool_registry)
└─ LlmCycle 内部 Arc<Mutex<Vec<Message>>> 管理 tool 循环迭代
└─ turn_index += 1, cost_so_far.add(...)
└─ 返回 MessageResponse
关键观察:AgentSession 当前不维护自身的 messages: Vec<Message>,每次 submit_turn 从头组装消息(system_prompt + user_input),由 LlmCycle 内部管理 tool 循环中的消息迭代。这意味着 session 层面没有任何消息历史保留——跨 turn 的消息积累完全依赖上层应用自行实现。
2.2 相关模块
| 模块 | 路径 | 与 ContextSlot 的关系 |
|---|---|---|
MemoryStore trait |
memory/store.rs |
持久化后端接口(4 方法:save/get/delete/list) |
SqliteStore |
memory/store/sqlite_store.rs |
推荐持久化后端,Phase 7 已完成 |
SessionMemory |
agent/session_memory.rs |
session 级 KV 存储,namespace _session_{session_id} |
RuntimeBundle |
agent/runtime.rs |
含 memory_store、session_memory_backend、config |
CompactConfig |
llm/compact.rs |
context_window=128k, reserved_tokens=20k, keep_recent=6 |
Message |
llm/types/message.rs |
IR 层统一消息类型,带 Serialize/Deserialize |
2.3 预置枚举
Roadmap 中 Phase 5.3 已规划的 #[non_exhaustive] 枚举尚未实际创建,将在 Phase 10 中首次定义:
SlotMode— 三种模式SlotSource— 三种来源DeriveStrategy— 派生策略
2.4 当前约束
- 无跨 turn 消息持久化:AgentSession 不持有
Vec<Message>,submit_turn每次从空白开始 - 无上下文隔离:所有对话共享同一命名空间,无法分支或隔离
- 无显式消息写入:LlmCycle 内部的
Arc<Mutex<Vec<Message>>>无法被外部直接读取
3. 调研发现
3.1 产品视角(PM)
3.1.1 三个核心场景
| 场景 | 描述 | 用户价值 |
|---|---|---|
| 多子任务上下文隔离 | Agent 同时处理多个独立任务(如:同时追踪两个法律咨询),每个任务的消息历史互不干扰 | 避免上下文污染 |
| 派生/假设推演 | 从决策点分出"试错分支"(如:分别推演方案 A 和方案 B),独立演进,不相互影响 | 探索式决策 |
| 长会话聚焦管理 | 大模型注意力有限,聚焦模式裁剪非核心历史,保留关键上下文 | 节省 token 成本 |
3.1.2 关键发现
- CRUD 缺 D:派生场景会持续创建新 slot,若没有
delete_slot会导致存储泄漏。delete_slot必须纳入 v0.2 FocusedConfig.inject_summary半成品风险:当前设计inject_summary: Option<String>字段名暗示"注入",但消费端逻辑未实现。建议改名summary_override: Option<String>,明确表达"覆盖而非自动生成"的语义,v0.2 只做消费端,生成推迟 v0.3- API 极简默认值关键:
create_slot("id")应一行创建,config 全部走Default。开发者只有需要定制时才传SlotConfig - 示例场景选择:Focused 模式的"注意力管理"概念较抽象,示例以**"分支对话"**场景呈现更直观(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证)
- storage 后端统一:ContextSlot 应使用
session_memory_backend(与SessionMemory同一后端),避免引入第三种存储路径 - ContextBudget 工厂函数:需提供
ContextBudget::auto()工厂函数,自动按上下文窗口比例分配预算
3.2 架构视角(SA)
3.2.1 模块归属争议
Roadmap 将 ContextSlot 放在 src/llm/context.rs,但引入 llm → memory 的新依赖方向(当前 llm 模块不依赖 memory)。
依赖关系推演:
llm/context.rs → memory::store::MemoryStore // ❌ 新依赖方向
agent/context.rs → memory::store::MemoryStore // ✅ 已有依赖 (agent → memory)
agent/context.rs ← agent/session.rs // ✅ 同模块内引用
结论:推荐 agent/context.rs,零新依赖方向。
3.2.2 持久化策略
两种存法对比:
| 维度 | 方案 A: JSON blob 批次 | 方案 B: per-message 独立 key |
|---|---|---|
| 存储单位 | Vec<Message> 整体序列化 |
每条消息一个 key |
| 代码量 | ~30 行 | ~150 行 |
| 变更风险 | 低(serde_json 已依赖) | 中(需额外 id 生成) |
| 按消息粒度操作 | 不支持 | 支持 |
| v0.2 使用场景 | 无(只做全量读/写) | 无(无按消息粒度的操作需求) |
结论:推荐 JSON blob 批次存储。serde_json 已是现有依赖,95% 代码减少,v0.2 没有 per-message 操作场景。每 slot 对应 2-3 条 MemoryItem 记录。
3.2.3 submit_turn 改造方向
| 方案 A(推荐) | 方案 B | |
|---|---|---|
| 签名 | submit_turn(&mut self, input) 不变 |
submit_turn(&mut self, slot_id, input) |
| slot 角色 | 内部状态,current_slot_id 隐式消费 |
显式参数 |
| 向后兼容 | ✅ 完全兼容 | ❌ 破坏现有 API |
| 心智负担 | 低 "默认往当前 slot 写" | 高 "每次都要传 slot" |
| 显式度 | 隐式(需开发者意识"当前 slot"存在) | 显式(每次调用明确目标) |
结论:推荐方案 A。submit_turn 签名不变,默认写入 current_slot_id 指向的 slot。当开发者需要操作非当前 slot 时,先 switch_slot 再 submit_turn。零破坏,低侵入。
3.2.4 其他架构发现
| 维度 | 发现 | 结论 |
|---|---|---|
| 消息同步 | LlmCycle 内部 Arc<Mutex<Vec<Message>>> 无法外部观测 |
手动写回:AgentSession 在 submit_turn 返回后显式覆盖 slot.messages |
| Compact 时机 | 压缩逻辑在 LlmCycle 内部下游触发(maybe_compact) |
保持现状,slot 只做纯存储,不干预压缩 |
| Focused 模式 | 读取消息时按策略过滤(非持久化时过滤) | 读时过滤:load_messages() 返回裁剪后的 Vec<Message> |
| Readonly 模式 | 阻止 append/overwrite | 写时阻断:append_messages() 返回 SlotReadonly 错误 |
| 改动量 | 1 新文件 + 2-3 修改文件 | 约 500 行净增(含测试和示例) |
4. 可选方案
4.1 模块归属
| 方案 | 位置 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | agent/context.rs |
零新依赖方向;agent→memory 已存在 | 偏离 Roadmap 文本 |
| B | llm/context.rs |
与 compact.rs 同级,语义上接近"上下文" |
引入 llm→memory 新依赖方向 |
4.2 持久化粒度
| 方案 | 存储方式 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | JSON blob,每 slot 2-3 条记录 | 代码量极小(~30 行),serde_json 已依赖 | 不支持按消息粒度操作 |
| B | per-message 独立 key | 粒度最细 | 代码量 5 倍 +,v0.2 无此场景 |
4.3 submit_turn 改造方式
| 方案 | 签名 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | 签名不变,内部状态 | 向后兼容,侵入小 | 隐式消费当前 slot |
| B | 显式 slot_id 参数 |
调用意图明确 | 破坏向后兼容 |
4.4 消息同步机制
| 方案 | 方式 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | 手动写回(AgentSession 在 submit_turn 后显式 overwrite) | 简单、可预测、职责清晰 | 需注意同步时机 |
| B | 观察者模式(LlmCycle 发出消息变更事件) | 实时同步 | LlmCycle 内部 Arc<Mutex<Vec>>> 无法外部观测,改造成本高 |
4.5 Focused 模式实现
| 方案 | 方式 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | 读时过滤 | 持久化不依赖 mode,切换 mode 无需写回 | 每次读取都需计算 |
| B | 写时裁剪 | 存储空间最小 | 切换 mode 需重新裁剪,逻辑复杂 |
4.6 ContextBudget 消费逻辑
| 方案 | 内容 | 优点 | 缺点 |
|---|---|---|---|
| A(推荐) | v0.2 仅数据结构 + Default + auto(),无消费逻辑 |
低风险,可交付 | 功能半成品 |
| B | v0.2 同时实现消费逻辑 | 完整闭环 | 半成品风险高,时间不足 |
5. 推荐方案
5.1 总体设计
模块归属:src/agent/context.rs(零新依赖方向)
持久化:JSON blob 批次存储,使用 session_memory_backend
submit_turn:签名不变,当前 slot 内部状态
消息同步:手动写回(非观察者模式)
Compact 时机:保持现状(LlmCycle 内部下游压缩)
Focused 模式:读时过滤
Readonly 模式:写时阻断
5.2 核心类型定义
/// 上下文槽 —— 一段带策略配置的消息列表。
pub struct ContextSlot {
/// 当前 slot 的唯一标识(同一个 session_id 内唯一)。
pub id: String,
/// 所属 session。
pub session_id: String,
/// 槽配置。
pub config: SlotConfig,
/// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。
pub messages: Vec<Message>,
/// 槽元数据。
pub meta: SlotMeta,
}
/// 槽配置。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotConfig {
/// 槽模式(Full / Focused / Readonly)。
pub mode: SlotMode,
/// 槽来源(New / Derived / Static)。
pub source: SlotSource,
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
pub budget: ContextBudget,
/// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。
pub compact: bool,
}
impl Default for SlotConfig {
fn default() -> Self {
Self {
mode: SlotMode::Full,
source: SlotSource::New,
budget: ContextBudget::default(),
compact: true,
}
}
}
/// 槽模式。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SlotMode {
/// 完整对话历史(全部消息)。
Full,
/// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。
Focused(FocusedConfig),
/// 只读参考上下文 —— 禁止写入。
Readonly,
}
/// 聚焦模式配置。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocusedConfig {
/// 是否保留 system prompt。
pub keep_system: bool,
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
pub recent_messages: usize,
/// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。
/// v0.3 将支持 Hook 驱动的自动摘要生成。
pub summary_override: Option<String>,
}
/// 槽来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SlotSource {
/// 全新空槽。
New,
/// 从父 slot 派生(记录 parent_id)。
Derived {
parent_id: String,
strategy: DeriveStrategy,
},
/// 预置静态消息(不持久化,随 session 生命周期存在)。
Static(Vec<Message>),
}
/// 派生策略。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DeriveStrategy {
/// 完整复制父 slot 的消息。
Full,
/// 按聚焦策略复制父 slot 的消息。
Focused(FocusedConfig),
}
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextBudget {
/// system prompt 预算。
pub system: u32,
/// 对话历史预算。
pub history: u32,
/// 工具定义预算。
pub tools: u32,
/// 工具结果预算。
pub tool_results: u32,
/// 预留 buffer。
pub reserve: u32,
}
impl Default for ContextBudget {
fn default() -> Self {
Self {
system: 8_000,
history: 80_000,
tools: 10_000,
tool_results: 20_000,
reserve: 10_000,
}
}
}
impl ContextBudget {
/// 自动分配:按上下文窗口的固定比例分配预算。
/// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。
pub fn auto() -> Self {
Self::default()
}
}
/// 槽元数据。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotMeta {
/// 父 slot id(仅 Derived 来源有值)。
pub parent_id: Option<String>,
/// 消息总数。
pub message_count: usize,
/// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。
pub total_tokens: u32,
/// 创建时间(Unix 时间戳,秒)。
pub created_at: u64,
}
impl SlotMeta {
pub fn new() -> Self {
Self {
parent_id: None,
message_count: 0,
total_tokens: 0,
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
}
}
}
5.3 持久化 Key 设计
使用 session_memory_backend(MemoryStore trait)存储,Key 命名规则:
| Key 格式 | 内容 | 序列化方式 |
|---|---|---|
slot_data:{session_id}:{slot_id} |
Vec<Message> |
serde_json::to_string |
slot_meta:{session_id}:{slot_id} |
SlotMeta |
serde_json::to_string |
slot_config:{session_id}:{slot_id} |
SlotConfig |
serde_json::to_string |
slot_rel:{session_id}:{child_id} |
parent_id(纯文本) |
直接字符串 |
每条 MemoryItem 的 id 和 content 字段使用上述 key 格式和序列化后的字符串。每 slot 对应 3-4 条 MemoryItem 记录。
⚠️ Key 编码约定:
session_id和slot_id中不应包含冒号:,否则会导致list的 prefix 匹配(LIKE 'slot_data:{session_id}:%')返回异常结果。建议使用字母、数字、连字符和下划线。如果 session_id 来自外部系统(如 UUID),需确认不含:。
5.4 ContextSlot 方法
impl ContextSlot {
/// 保存 slot 数据到存储后端(全量写入,含 config)。
pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> { ... }
/// 从存储后端加载 slot(config 从存储自行恢复,不依赖调用方传入)。
pub async fn load(
id: &str,
session_id: &str,
store: &dyn MemoryStore,
) -> Result<Option<Self>, AgentError> { ... }
/// 列出某 session 下的所有 slot 元数据。
pub async fn list(
session_id: &str,
store: &dyn MemoryStore,
) -> Result<Vec<SlotMeta>, AgentError> { ... }
/// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。
pub async fn delete(
id: &str,
session_id: &str,
store: &dyn MemoryStore,
) -> Result<(), AgentError> { ... }
/// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。
/// Full / Focused 模式下允许追加。
pub fn append_messages(&mut self, messages: Vec<Message>) -> Result<(), AgentError> { ... }
/// 读取当前 slot 的消息列表。
/// Focused 模式下按策略过滤后返回;Full/Readonly 返回全量。
/// 返回 `Vec<Message>`(而非 `&[Message]`),因为 Focused 模式需要分配新 Vec 来存储裁剪后的消息。
pub fn load_messages(&self) -> Vec<Message> { ... }
}
5.5 AgentSession 扩展
pub struct AgentSession {
pub session_id: String,
pub agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
turn_index: u32,
cost_so_far: CostTracker,
pub session_memory: SessionMemory,
// === Phase 10 新增字段 ===
/// session 内的所有 slot(id → ContextSlot)。
slots: HashMap<String, ContextSlot>,
/// 当前活跃 slot 的 id。
current_slot_id: String,
}
impl AgentSession {
/// 创建新 slot(config 可选,不传则使用默认值)。
pub async fn create_slot(
&mut self,
id: impl Into<String>,
config: Option<SlotConfig>,
) -> Result<(), AgentError> { ... }
/// 切换到指定 slot(必须已存在,否则返回 `SlotNotFound` 错误)。
pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { ... }
/// 列出所有 slot 的 id 列表。
pub fn list_slots(&self) -> impl Iterator<Item = &String> { ... }
/// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。
pub async fn derive_slot(
&mut self,
id: impl Into<String>,
parent_id: &str,
strategy: DeriveStrategy,
) -> Result<(), AgentError> { ... }
/// 删除一个 slot(仅内存 + 持久化记录清理;已删除后再 load 返回 None)。
pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { ... }
// === 内部方法 ===
/// 解析存储后端:fallback 链 session_memory.store → bundle.memory_store → InMemoryStore。
fn resolve_store(&self) -> Arc<dyn MemoryStore> { ... }
}
new() 改动:自动创建 "default" slot,确保简单场景无感使用。
submit_turn 改造:
pub async fn submit_turn(&mut self, user_input: impl Into<String>) -> Result<MessageResponse, AgentError> {
// 1. 从 current slot 加载历史消息
let history = self.load_current_slot_history();
// 2. 组装 LlmCycle(system_prompt + history + user_input)
// 3. submit_with_tools
// 4. 累计 cost
// 5. 将新消息写回当前 slot
// 6. save 到存储后端
// 7. 触发 OnTurnEnd hook
// 8. turn_index += 1
}
finalize_turn 扩展:增加 new_messages_from_cycle 参数,用于流结束时增量追加到 slot:
pub async fn finalize_turn(
&mut self,
response: &MessageResponse,
new_messages_from_cycle: Vec<Message>, // ← 新增
) -> Result<(), AgentError> { ... }
5.6 向后兼容性
| 维度 | 状态 | 说明 |
|---|---|---|
submit_turn 签名 |
✅ 不变 | 仍是 (&mut self, user_input: impl Into<String>);新增 Readonly slot 前置检查(返回 SlotReadonly 错误) |
AgentSession::new 签名 |
✅ 不变 | 内部自动创建 "default" slot |
finalize_turn 签名 |
⚠️ 变更 — 新增 new_messages_from_cycle: Vec<Message> 参数,返回从 () 改为 Result<(), AgentError> |
流式场景的消费者需从 cycle.messages()[input_len..] 提取新增消息传入;之前忽略错误的调用需处理 Result |
| 存量测试 | ✅ 0 回归 | 不传 session_memory_backend 时,resolve_store() fallback 到 InMemoryStore |
| 新增错误变体 | 新增 | AgentError::SlotReadonly(String) + AgentError::SlotNotFound(String) + AgentError::Config(String) 已有 |
| 公开类型 | 新增 | 所有新类型默认 #[non_exhaustive] 策略枚举 |
6. 实施建议
Step 10.1 — 核心类型(约 150 行,可编译 checkpoint)
目标:所有类型定义可编译,无逻辑。
文件变更
| 文件 | 操作 | 说明 |
|---|---|---|
src/agent/context.rs |
新增 | 全部类型定义(~150 行) |
src/agent.rs |
修改 | + pub mod context; |
src/agent/error.rs |
修改 | 追加 SlotReadonly/SlotNotFound/SlotAlreadyExists 错误变体 |
在
AgentError中新增以下变体(用于 slot 相关错误场景):#[error("Readonly slot 不允许写入: {0}")] SlotReadonly(String), #[error("Slot '{0}' 不存在")] SlotNotFound(String), #[error("Slot '{0}' 已存在")] SlotAlreadyExists(String),
核心代码
// src/agent/context.rs
use serde::{Deserialize, Serialize};
use crate::llm::types::message::Message;
/// 上下文槽。
pub struct ContextSlot { /* ... §5.2 完整定义 ... */ }
/// 槽配置。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotConfig { /* ... */ }
/// 槽模式。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SlotMode { Full, Focused(FocusedConfig), Readonly }
/// 聚焦配置。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocusedConfig { pub keep_system: bool, pub recent_messages: usize, pub summary_override: Option<String> }
/// 槽来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SlotSource { New, Derived { parent_id: String, strategy: DeriveStrategy }, Static(Vec<Message>) }
/// 派生策略。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DeriveStrategy { Full, Focused(FocusedConfig) }
/// 上下文预算。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextBudget { /* ... §5.2 完整定义 ... */ }
/// 槽元数据。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotMeta { /* ... §5.2 完整定义 ... */ }
impl Default for SlotConfig { /* ... */ }
impl Default for ContextBudget { /* ... */ }
impl ContextBudget { pub fn auto() -> Self { Self::default() } }
impl SlotMeta { pub fn new() -> Self { /* ... */ } }
src/agent.rs 修改
// 在 pub mod session_memory; 下一行插入
pub mod context;
验证
cargo build --all-targets
# 预期:编译通过,无新 warning
Step 10.2 — 持久化(约 200 行,含测试)
目标:ContextSlot 的 save/load/list/delete/append_messages/load_messages 完整实现并通过测试。
文件变更
| 文件 | 操作 | 说明 |
|---|---|---|
src/agent/context.rs |
修改 | 追加所有方法实现 + #[cfg(test)] mod tests |
核心代码
use std::collections::HashMap;
use time::OffsetDateTime;
use crate::agent::error::AgentError;
use crate::llm::types::message::Message;
use crate::memory::store::MemoryStore;
use crate::memory::types::{MemoryFilter, MemoryItem};
use crate::memory::store::InMemoryStore;
impl ContextSlot {
/// 持久化 key 前缀。
const KEY_DATA: &'static str = "slot_data";
const KEY_META: &'static str = "slot_meta";
const KEY_CONFIG: &'static str = "slot_config";
const KEY_REL: &'static str = "slot_rel";
fn data_key(session_id: &str, slot_id: &str) -> String {
format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id)
}
fn meta_key(session_id: &str, slot_id: &str) -> String {
format!("{}:{}:{}", Self::KEY_META, session_id, slot_id)
}
fn config_key(session_id: &str, slot_id: &str) -> String {
format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id)
}
fn rel_key(session_id: &str, child_id: &str) -> String {
format!("{}:{}:{}", Self::KEY_REL, session_id, child_id)
}
/// 构造 MemoryItem 的辅助函数。
fn make_item(key: String, content: String) -> MemoryItem {
MemoryItem {
id: key,
content,
metadata: serde_json::json!({}),
created_at: OffsetDateTime::now_utc(),
}
}
pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> {
let data = serde_json::to_string(&self.messages)
.map_err(|e| AgentError::Other(e.to_string()))?;
let meta = serde_json::to_string(&self.meta)
.map_err(|e| AgentError::Other(e.to_string()))?;
let config = serde_json::to_string(&self.config)
.map_err(|e| AgentError::Other(e.to_string()))?;
store.save(Self::make_item(Self::data_key(&self.session_id, &self.id), data)).await?;
store.save(Self::make_item(Self::meta_key(&self.session_id, &self.id), meta)).await?;
store.save(Self::make_item(Self::config_key(&self.session_id, &self.id), config)).await?;
// 派生关系
if let SlotSource::Derived { parent_id, .. } = &self.config.source {
store.save(Self::make_item(Self::rel_key(&self.session_id, &self.id), parent_id.clone())).await?;
}
Ok(())
}
/// 从存储加载 slot,config 从 `slot_config` key 自行恢复。
/// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。
pub async fn load(
id: &str,
session_id: &str,
store: &dyn MemoryStore,
) -> Result<Option<Self>, AgentError> {
let meta_item = store.get(&Self::meta_key(session_id, id)).await?;
let data_item = store.get(&Self::data_key(session_id, id)).await?;
let config_item = store.get(&Self::config_key(session_id, id)).await?;
match (meta_item, data_item) {
(Some(m), Some(d)) => {
let meta: SlotMeta = serde_json::from_str(&m.content)
.map_err(|e| AgentError::Other(e.to_string()))?;
let messages: Vec<Message> = serde_json::from_str(&d.content)
.map_err(|e| AgentError::Other(e.to_string()))?;
// config 从存储恢复;不存在则使用 default(兼容旧版本)
let config = match config_item {
Some(c) => serde_json::from_str(&c.content)
.map_err(|e| AgentError::Other(e.to_string()))?,
None => SlotConfig::default(),
};
Ok(Some(Self { id: id.to_string(), session_id: session_id.to_string(), config, messages, meta }))
}
_ => Ok(None),
}
}
pub async fn list(
session_id: &str,
store: &dyn MemoryStore,
) -> Result<Vec<SlotMeta>, AgentError> {
let prefix_str = format!("{}:{}:", Self::KEY_META, session_id);
let filter = MemoryFilter { prefix: Some(prefix_str), ..Default::default() };
let items = store.list(&filter).await?;
let mut metas = Vec::new();
for item in items {
if let Ok(meta) = serde_json::from_str::<SlotMeta>(&item.content) {
metas.push(meta);
}
}
Ok(metas)
}
pub async fn delete(
id: &str,
session_id: &str,
store: &dyn MemoryStore,
) -> Result<(), AgentError> {
store.delete(&Self::data_key(session_id, id)).await?;
store.delete(&Self::meta_key(session_id, id)).await?;
store.delete(&Self::config_key(session_id, id)).await?;
store.delete(&Self::rel_key(session_id, id)).await?;
Ok(())
}
pub fn append_messages(&mut self, new_messages: Vec<Message>) -> Result<(), AgentError> {
if matches!(self.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly("Readonly slot does not allow writes".into()));
}
let count = new_messages.len();
self.messages.extend(new_messages);
self.meta.message_count += count;
Ok(())
}
/// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。
pub fn load_messages(&self) -> Vec<Message> {
match &self.config.mode {
SlotMode::Focused(cfg) => {
let mut result = Vec::new();
// 保留 system prompt
if cfg.keep_system {
if let Some(msg) = self.messages.iter().find(|m| matches!(m, Message::System(_))) {
result.push(msg.clone());
}
}
// 处理 recent_messages=0 边界:上面已处理 system,下面仅取最近 N 条
if cfg.recent_messages > 0 {
let recent: Vec<&Message> = self.messages.iter()
.filter(|m| !matches!(m, Message::System(_)))
.collect();
let start = recent.len().saturating_sub(cfg.recent_messages);
for msg in recent.iter().skip(start) {
result.push((*msg).clone());
}
}
// 注入摘要
if let Some(summary) = &cfg.summary_override {
result.push(Message::system(format!("[上下文摘要] {}", summary)));
}
result
}
_ => self.messages.clone(),
}
}
}
测试用例
| 测试 | 验证点 |
|---|---|
slot_save_load_roundtrip |
创建 → 保存 → 加载 → 消息一致 |
slot_session_isolation |
不同 session_id 下相同 slot_id 不串数据 |
slot_derived_parent_id |
Derived 来源的 parent_id 正确记录 |
slot_readonly_rejects_write |
Readonly 模式下 append_messages 返回 SlotReadonly 错误 |
slot_delete_then_load_none |
delete 后 load 返回 None |
slot_list_multiple |
同一 session 下多个 slot 的 list 正确 |
slot_focused_recent_messages |
Focused 模式 load_messages() 仅返回最近 N 条消息(含 system prompt 可选) |
slot_focused_summary_override |
Focused 模式注入 summary_override 生成 system 摘要消息 |
slot_focused_zero_messages |
Focused 模式 recent_messages=0 返回空列表(keep_system=true 时只含 system) |
slot_empty_messages_roundtrip |
空消息列表 save → load 后仍为空列表 |
slot_save_on_readonly_side_effect |
Readonly slot 调用 save 仍可持久化(只禁止 append) |
验证
cargo test -- --test-threads=1
# 预期:所有新增测试通过,存量 211 个测试 0 回归
Step 10.3 — AgentSession 集成 + 示例(约 150 行)
目标:AgentSession 支持 slot 管理方法,submit_turn 读写当前 slot,分支对话示例可运行。
文件变更
| 文件 | 操作 | 说明 |
|---|---|---|
src/agent/session.rs |
修改 | slots 字段 + 6 新方法 + submit_turn/finalize_turn 改造 |
examples/context_slot_demo.rs |
新增 | 分支对话展示(~朵创建后派生两个方向) |
AgentSession 关键改动
use std::collections::HashMap;
use crate::agent::context::{ContextSlot, SlotConfig, SlotSource, DeriveStrategy};
pub struct AgentSession {
// ... 原有字段不变 ...
slots: HashMap<String, ContextSlot>,
current_slot_id: String,
}
impl AgentSession {
/// 带 slot 的新构造函数。
/// 注意:`new()` 是同步函数,无法执行异步的 `ContextSlot::load()`。
/// 因此始终创建空的 default slot。若需要从存储恢复 session 历史,
/// 可在创建后调用 `switch_slot("default")` 尝试从存储加载。
/// (v0.2 简化:`switch_slot` 在 HashMap 中已有 key 时不会重载——如需恢复,
/// 请在清空 `slots` 后调用 `switch_slot`,或等待 v0.3 的懒加载支持。)
pub fn new(agent: Arc<dyn Agent>, session_id: impl Into<String>, bundle: Arc<RuntimeBundle>) -> Self {
let session_id_str = session_id.into();
let backend = bundle.session_memory_backend.clone()
.unwrap_or_else(|| Arc::new(InMemoryStore::new()));
let session_memory = SessionMemory::new(backend, &session_id_str);
// 自动创建 "default" slot
let default_slot = ContextSlot {
id: "default".to_string(),
session_id: session_id_str.clone(),
config: SlotConfig::default(),
messages: Vec::new(),
meta: SlotMeta::new(),
};
let mut slots = HashMap::new();
slots.insert("default".to_string(), default_slot);
Self {
session_id: session_id_str,
agent,
bundle,
turn_index: 0,
cost_so_far: CostTracker::default(),
session_memory,
slots,
current_slot_id: "default".to_string(),
}
}
fn resolve_store(&self) -> Arc<dyn MemoryStore> {
self.bundle.session_memory_backend.clone()
.or_else(|| self.bundle.memory_store.clone())
.unwrap_or_else(|| Arc::new(InMemoryStore::new()))
}
pub async fn create_slot(&mut self, id: impl Into<String>, config: Option<SlotConfig>) -> Result<(), AgentError> {
let id = id.into();
// 禁止创建已存在的 slot
if self.slots.contains_key(&id) {
return Err(AgentError::SlotAlreadyExists(id));
}
let slot = ContextSlot {
id: id.clone(),
session_id: self.session_id.clone(),
config: config.unwrap_or_default(),
messages: Vec::new(),
meta: SlotMeta::new(),
};
slot.save(&*self.resolve_store()).await?;
self.slots.insert(id, slot);
Ok(())
}
pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> {
if !self.slots.contains_key(id) {
// 尝试从存储加载(config 自动从 slot_config key 恢复)
let store = self.resolve_store();
if let Some(slot) = ContextSlot::load(id, &self.session_id, &*store).await? {
self.slots.insert(id.to_string(), slot);
} else {
return Err(AgentError::SlotNotFound(id.to_string()));
}
}
self.current_slot_id = id.to_string();
Ok(())
}
pub fn list_slots(&self) -> impl Iterator<Item = &String> {
self.slots.keys()
}
/// 按 FocusedConfig 过滤消息(辅助函数,与 ContextSlot::load_messages 的 Focused 逻辑一致)。
fn apply_focused_filter(messages: &[Message], cfg: &FocusedConfig) -> Vec<Message> {
let mut result = Vec::new();
if cfg.keep_system {
if let Some(msg) = messages.iter().find(|m| matches!(m, Message::System(_))) {
result.push(msg.clone());
}
}
if cfg.recent_messages > 0 {
let recent: Vec<&Message> = messages.iter()
.filter(|m| !matches!(m, Message::System(_)))
.collect();
let start = recent.len().saturating_sub(cfg.recent_messages);
for msg in recent.iter().skip(start) {
result.push((*msg).clone());
}
}
result
}
pub async fn derive_slot(&mut self, id: impl Into<String>, parent_id: &str, strategy: DeriveStrategy) -> Result<(), AgentError> {
// 禁止创建已存在的 slot(与 create_slot 行为一致)
let slot_id = id.into();
if self.slots.contains_key(&slot_id) {
return Err(AgentError::SlotAlreadyExists(slot_id.clone()));
}
let parent = self.slots.get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let parent_messages = parent.messages.clone();
let (messages, focused_cfg) = match &strategy {
DeriveStrategy::Full => (parent_messages, None),
DeriveStrategy::Focused(cfg) => {
let filtered = Self::apply_focused_filter(&parent_messages, &cfg);
(filtered, Some(cfg))
}
};
let mode = match focused_cfg {
Some(cfg) => SlotMode::Focused(cfg),
None => SlotMode::Full,
};
let slot = ContextSlot {
id: slot_id.clone(),
session_id: self.session_id.clone(),
config: SlotConfig {
mode,
source: SlotSource::Derived { parent_id: parent_id.to_string(), strategy },
..SlotConfig::default()
},
messages,
meta: SlotMeta::new(),
};
slot.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id.clone(), slot);
Ok(())
}
pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> {
// 禁止删除 "default" slot——它是 session 的基础生命周期锚点
if id == "default" {
return Err(AgentError::Config("Cannot delete the 'default' slot".into()));
}
// 确保 session 至少保留一个 slot
if self.slots.len() <= 1 {
return Err(AgentError::Config("Cannot delete the last slot".into()));
}
ContextSlot::delete(id, &self.session_id, &*self.resolve_store()).await?;
self.slots.remove(id);
if self.current_slot_id == id {
self.current_slot_id = "default".to_string();
}
Ok(())
}
}
submit_turn 改造后的核心流程:
pub async fn submit_turn(&mut self, user_input: impl Into<String>) -> Result<MessageResponse, AgentError> {
let turn_index = self.turn_index;
let hook_executor = Arc::clone(&self.bundle.hook_executor);
// 0. Readonly slot 拒绝 submit_turn
{
let slot = self.slots.get(&self.current_slot_id)
.ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?;
if matches!(slot.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly(
format!("Cannot submit turn on Readonly slot '{}'", self.current_slot_id)
));
}
}
// 1. OnTurnStart hook
let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index);
hook_executor.execute(HookEvent::OnTurnStart, &start_ctx).await;
// 2. 从当前 slot 加载历史消息
let history = self.slots.get(&self.current_slot_id)
.ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?
.load_messages();
// 3. 组装 LlmCycle
let _ = self.agent.tool_definitions(&self.bundle);
let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default());
let initial_messages_len = history.len();
let mut messages_with_prompt = history;
if let Some(prompt) = self.agent.system_prompt() {
messages_with_prompt.insert(0, Message::system(prompt));
}
let input_len = messages_with_prompt.len();
cycle = cycle.with_messages(messages_with_prompt);
if let Some(cfg) = self.bundle.config.compact_config.clone() {
cycle = cycle.with_compact_config(cfg);
}
// 4. submit_with_tools(prompt 参数即为本轮 user_input)
let response = cycle.submit_with_tools(user_input.into(), &self.bundle.tool_registry).await?;
// 5. 累计 cost
self.cost_so_far.add(&response.usage);
// 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据)
// cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response]
// 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容)
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
let new_messages: Vec<Message> = cycle.messages().iter().skip(input_len).cloned().collect();
slot.append_messages(new_messages)?;
let store = self.resolve_store();
slot.save(&*store).await?;
}
// 7. OnTurnEnd hook
let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index);
hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await;
// 8. turn_index 递增
self.turn_index += 1;
Ok(response)
}
finalize_turn 扩展:
/// 结束本轮流式对话,将新增消息追加到当前 slot。
/// - `response`: 本次 turn 的最终响应(用于累计 cost 和触发 hook)
/// - `new_messages_from_cycle`: 本轮**新增消息**(user input + tool 循环中间消息 + 最终回复),
/// 消费者在流消费完成后,从 `cycle.messages()[initial_input_len..]` 获取并传入。
/// 仅增量追加(不覆盖 slot 已有消息),与 `submit_turn` 行为一致。
pub async fn finalize_turn(
&mut self,
response: &MessageResponse,
new_messages_from_cycle: Vec<Message>,
) -> Result<(), AgentError> {
self.cost_so_far.add(&response.usage);
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
// Readonly 检查
if matches!(slot.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly(
format!("Cannot finalize turn on Readonly slot '{}'", self.current_slot_id)
));
}
// 增量追加(不覆盖已有消息),确保 Focused 模式的"读时过滤"语义不丢失数据
slot.append_messages(new_messages_from_cycle)?;
let store = self.resolve_store();
slot.save(&*store).await?;
}
let end_ctx = HookContext::new(HookEvent::OnTurnEnd)
.with_turn_index(self.turn_index.saturating_sub(1));
self.bundle.hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await;
Ok(())
}
分支对话示例
// examples/context_slot_demo.rs
// 场景:法律咨询入口 → 两个派生方向 → 切换 → 隔离验证
// 1. 创建 AgentSession(自动带 "default" slot)
// 2. 在 default slot 中咨询:"我需要法律援助"
// 3. 创建派生 slot "option-a"(DeriveStrategy::Full)
// 4. 切换到 "option-a",咨询具体场景
// 5. 创建派生 slot "option-b"(DeriveStrategy::Full)
// 6. 切换到 "option-b",咨询另一个方向
// 7. 切回 default 验证消息隔离(不包含 option-a/b 的对话)
// 8. 列出所有 slot: default, option-a, option-b
// 9. 删除 option-b,验证 list 不再包含
fn main() {
// 使用 MockProvider(无需 API key)
// 全程通过 assert_eq! 验证消息隔离
}
验证
cargo run --example context_slot_demo
# 预期:exit 0,控制台输出各 slot 的消息数及隔离验证结果
cargo test --all-targets
# 预期:全量测试通过,clippy 0 警告
7. 否决项记录
| 方案 | 否决原因 |
|---|---|
llm/context.rs(Roadmap 原案) |
引入 llm → memory 新依赖方向(当前 llm 不依赖 memory);而 agent → memory 依赖已存在。模块归属改为 agent/context.rs |
| per-message 持久化 | 代码量多 5 倍(~150 vs ~30 行),v0.2 无按消息粒度操作场景。JSON blob + serde_json 已依赖,95% 代码减少 |
submit_turn 签名改 (slot_id, input) |
破坏向后兼容,内在概念泄漏到公共 API。每次调 submit_turn 都需传 slot 对简单场景不友好。改为内部 current_slot_id 状态 |
| 观察者模式自动同步 | LlmCycle 内部消息通过 Arc<Mutex<Vec<Message>>> 管理,无法从外部注册回调。手动写回足够简单且职责清晰 |
| ContextBudget 消费逻辑 v0.2 实现 | 半成品风险:当前没有 tokenizer 绑定(字符估算),消费逻辑需深层侵入 LlmCycle 消息组装路径。v0.2 仅做数据结构 + Default + auto() 占位 |
inject_summary 自动生成 v0.2 |
半成品风险:自动摘要是 Hook 驱动(OnTurnEnd),涉及 LlmCycle 内部事件编排。v0.2 仅支持手动 summary_override: Option<String>,自动生成推迟 v0.3 |
| fork/merge/branch API | 语义复杂("分支合并"的冲突语义需精确定义),v0.2 无此场景。v0.3+ 考虑 |
8. 参考来源
| 来源 | 内容 | 用途 |
|---|---|---|
roadmap.md §Phase 10 |
ContextSlot 原始规划 | 范围定义、Step 划分 |
docs/7-agent-runtime.md §3.2 |
AgentSession 设计 | submit_turn 改造基座 |
docs/16-phase9-streaming-experience.md |
submit_turn_stream + finalize_turn 设计 | finalize_turn 扩展参考 |
src/agent/session.rs |
AgentSession 当前实现 | 字段、方法签名、submit_turn 流程 |
src/agent/runtime.rs |
RuntimeBundle 定义 | resolve_store fallback 链 |
src/memory/store.rs |
MemoryStore trait | save/get/delete/list 方法签名 |
src/memory/types.rs |
MemoryItem / MemoryFilter | 持久化数据格式 |
src/llm/compact.rs |
CompactConfig | context_window 引用 |
src/llm/types/message.rs |
Message 类型 | serde Serialize/Deserialize 确认 |
路线图同步
本方案实施后需同步更新 roadmap.md:
| 更新项 | 内容 |
|---|---|
| Phase 10 状态 | Step 10.1/10.2/10.3 逐项标记 ✅ |
| 里程碑 M6 | ⏳ → ✅ + 日期 |
| 依赖图 | P10 节点 class p1 → done |
| 下一步行动 | Phase 10 → Phase 11 |
| 已完成列表 | 追加 - ✅ Phase 10 ContextSlot — 多上下文分区管理 |
| v0.3 展望 | ContextBudget 消费逻辑 + 摘要自动生成 + fork/merge 移至 v0.3 清单 |
9. 实施计划
9.1 阶段总览
| 阶段 | 涉及文件 | 操作 | 预估行数 | 风险 | 工作量 |
|---|---|---|---|---|---|
| 10.1 核心类型 | agent/context.rs / agent.rs / agent/error.rs |
1 新文件 + 2 修改 | ~150 | 低 | M |
| 10.2 持久化 | agent/context.rs |
修改(+方法 + 测试) | ~200 | 中 | M |
| 10.3 AgentSession 集成 | agent/session.rs + examples/context_slot_demo.rs |
修改 + 新示例 | ~300 | 高 | L |
9.2 任务拆解表
Step 10.1 任务
| 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 |
|---|---|---|---|---|---|---|
| 10.1.1 | 新增 AgentError 变体:SlotReadonly / SlotNotFound / SlotAlreadyExists |
agent/error.rs |
无 | S(3 行) | 低 | cargo build |
| 10.1.2 | 新增 agent/context.rs:ContextSlot 核心类型 + SlotConfig / SlotMode / FocusedConfig / SlotSource / DeriveStrategy / ContextBudget / SlotMeta |
agent/context.rs |
10.1.1 | M(120 行) | 低 | cargo build |
| 10.1.3 | Default/auto 实现 + module 声明 |
agent/context.rs + agent.rs |
10.1.2 | S(15 行) | 低 | cargo build --all-targets |
Step 10.2 任务
| 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 |
|---|---|---|---|---|---|---|
| 10.2.1 | Key 辅助函数 + make_item() + save() 方法(写 data / meta / config / rel) |
agent/context.rs |
10.1.3 | S(45 行) | 低 | cargo build |
| 10.2.2 | load() + list() + delete() 方法 |
agent/context.rs |
10.2.1 | M(50 行) | 中(MemoryStore API 字段名需匹配) |
cargo build |
| 10.2.3 | append_messages() + load_messages() 方法(含 Focused 过滤逻辑) |
agent/context.rs |
10.2.2 | M(40 行) | 中(Focused 过滤逻辑正确性) | cargo build |
| 10.2.4 | 内联单元测试(11 个用例) | agent/context.rs |
10.2.3 | M(70 行) | 中 | cargo test — 211 + 11 passed |
Step 10.3 任务
| 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 |
|---|---|---|---|---|---|---|
| 10.3.1 | AgentSession 新增 slots / current_slot_id 字段 + new() 自动创建 default slot |
agent/session.rs |
10.1.2 | S(30 行) | 低 | cargo build |
| 10.3.2 | resolve_store() + create_slot() + switch_slot() + list_slots() + derive_slot() + delete_slot() |
agent/session.rs |
10.3.1 | L(80 行) | 中(slot 恢复逻辑) | cargo build |
| 10.3.3 | submit_turn 改造(Readonly 检查 → load_messages() → cycle → 增量追加 + save;注意大括号释放借用) |
agent/session.rs |
10.3.2 | L(50 行) | 高(data flow 正确性) | cargo test 全绿 |
| 10.3.4 | finalize_turn 扩展(new_messages_from_cycle 参数 → 增量追加 + Readonly 检查 + save;同步更新 2 个存量测试调用方) |
agent/session.rs |
10.3.3 | M(25 行 + 2 测试适配) | 中 | cargo test 全绿 + 所有 finalize_turn 调用方已适配 |
| 10.3.5 | context_slot_demo 分支对话示例(使用 MockProvider::new(vec![...]) 预置响应,避免空队列) |
examples/context_slot_demo.rs |
10.3.4 | M(80 行) | 低 | cargo run --example exit 0 |
| 10.3.6 | 全量验证:cargo test --all-targets + clippy + 存量测试 0 回归 |
— | 10.3.5 | S(5 min) | 低 | test 全绿 + clippy 0 |
9.3 并行机会分析
| 可并行组合 | 理由 | 风险 |
|---|---|---|
| 10.1.1 + 10.1.2 | 错误变体与核心类型无文件交集 | 无 |
| 10.2.1 + 10.2.2 | save() 与 load() 可独立实现(只要 key 辅助函数就绪) |
低(确保 key 格式一致) |
| 10.3.1 + 10.3.2 | 字段声明与方法新增可同步进行 | 无 |
| 10.3.1 可提前到 10.1.2 后启动 | new() 只依赖类型定义(ContextSlot / SlotConfig / SlotMeta),不依赖持久化方法 |
无 |
| 不可并行:10.2 → 10.3.2 起 | create_slot()/switch_slot() 等需要 ContextSlot::save()/load() 就绪 |
— |
9.4 风险与应对
| 风险 | 可能性 | 影响 | 应对策略 |
|---|---|---|---|
MemoryStore trait 方法签名变化 |
低 | 高 | 验证代码中 MemoryStore API,必要时适配 |
LlmCycle API(cycle.messages() 等)无效 |
低 | 高 | 在实施前验证 cycle 的公共方法 |
| 序列化失败导致 session 数据损坏 | 低 | 高 | 所有 serde_json 调用走 ? 传播错误 |
finalize_turn 签名变更破坏存量测试 |
中 | 中 | 10.3.4 明确列出需适配的 2 个测试调用方,适配后立即运行 cargo test 验证 |
| Focused 过滤逻辑边界条件遗漏 | 中 | 中 | 4 个 Focused 测试覆盖边界(0 turn / keep_system / summary_override) |
new() 同步限制导致 session 恢复困难 |
中 | 中 | 文档已说明限制,v0.3 解决 |
MockProvider 空队列导致示例 panic |
中 | 低 | 示例使用 MockProvider::new(vec![...]) 预置响应,避免 empty() |
Hook 执行器在 slot 写回后触发,HookContext 不携带 slot_id |
低 | 低 | v0.2 已知局限,后续在 HookContext 上扩展 slot_id: Option<&str> |
9.5 测试策略
单元测试覆盖(11 个基础 + 5 个扩展):
- 持久化:save/load roundtrip、session 隔离、delete 后 load None、list 多 slot
- 模式行为:Readonly 拒绝写入、Focused 过滤(最近 N 条 / 摘要注入 / 0 边界)
- 派生:
DeriveStrategy::Full正确复制父 slot 消息、DeriveStrategy::Focused正确裁剪、派生 slot 持久化后可独立加载 - 边界:空消息列表 roundtrip、Readonly save 不影响
- 保护逻辑:
delete_slot禁止删"default"、禁止删最后一个 slot
集成测试覆盖:
- 端到端:
context_slot_demo示例验证多 slot 隔离 - 回归:存量 211 个测试不依赖 slot 行为 → 0 回归预期
9.6 验证标准
| 门禁 | 命令 | 预期结果 |
|---|---|---|
| 编译 | cargo build --all-targets |
0 error + 0 warning |
| 测试 | cargo test --all-targets |
211 → 227 passed, 0 failed |
| 静态检查 | cargo clippy --all-targets -- -D warnings |
0 warning |
| 示例 | cargo run --example context_slot_demo |
exit 0 |
| 文档 | cargo doc --no-deps |
0 warning |