# AI Chat 上下文管理增强设计 > 性质: 功能设计(经代码勘察确认可行性 + 多角度分析) > 日期: 2026-06-16 > 关联: [Agent 架构说明](Agent架构说明-2026-06-14.md)(现有裁剪机制盘点依据) > 用途: 实施依据,实施方据此文档执行,不再二次设计 --- ## 1. 背景与问题 ### 1.1 现有裁剪机制的致命缺陷 当前 `ContextManager.build_for_request`(context.rs:219)超预算时的行为: ``` history_tokens > available_budget ↓ 从最旧消息开始丢弃(按三元组原子单元),保护最近 6 条(PROTECT_COUNT) ↓ 被丢弃的消息 → 彻底不发给 LLM,零保留 ↓ trimmed=true → agentic.rs:226 `_trimmed` → 直接忽略,无任何动作 ``` **问题**:丢掉的消息里可能有「用户说要改 XX 文件的 YY 函数」「AI 已经用了方案 B 不是方案 A」这类关键上下文。丢弃 = 遗忘 = AI 重复读文件 / 改错地方。 ### 1.2 现有清理机制的缺陷 `ai_chat_clear`(commands.rs:360):`session.messages.clear()` + DB `clear_messages` 置 `messages='[]'` → **历史彻底丢失**,无法回溯。 ### 1.3 用户需求(三条) 1. **会话分段**:清理上下文不删记录,同一对话内产生 a'/a''/a''' 多段,DB 全量保留,LLM 只看当前段 2. **手动压缩**:用户主动发起,把当前消息让 LLM 总结成摘要,摘要替代原始消息作为上下文起点 3. **智能裁剪**:超预算自动触发压缩,不直接丢弃,用摘要保留旧消息语义 --- ## 2. 代码勘察(事实依据) ### 2.1 数据流真相链 ``` DB (ai_conversations.messages JSON 列) ↕ save_conversation / switchConversation → restore_from_messages ContextManager (内存真相源, Vec) ↓ build_for_request(sys_tokens) ↓ → sanitize_messages (step 0: filter !is_active + step 1-3: 畸形三元组自愈) ↓ → 超预算时裁剪旧消息(保护最近 PROTECT_COUNT=6 条) LLM 请求 = [system_prompt] + [裁剪后的 active 消息] ``` ### 2.2 关键代码位置 | 机制 | 位置 | 行为 | |------|------|------| | `ChatMessage.status` | `df-ai-core/provider.rs:59` | `Option`,现有值:`None`/`"active"`/`"truncated"` | | `is_active()` | `df-ai-core/provider.rs:79` | `!matches!(status, Some("truncated"))` — **反面排除模式** | | `sanitize_messages` | `context.rs:284` | step 0 调 `is_active()` 过滤,**唯一的发送视图过滤点** | | `build_for_request` | `context.rs:219` | 先 sanitize 再裁剪,返回 `(messages, trimmed: bool)` | | `push()` | `context.rs:174` | 追加消息 + 计 token,**不区分 status 全量计入 history_tokens** | | `clear()` | `context.rs:170` | 清空 messages Vec + history_tokens=0 | | `restore_from_messages` | `context.rs:414` | 从 DB JSON 全量恢复,**push 全量消息含 !active 的,token 全量计入** | | `ai_chat_clear` | `commands.rs:360` | session.clear() + DB clear_messages 置 `messages='[]'` | | `build_for_request` 调用点 | `agentic.rs:226` | `let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens)` — **trimmed 被忽略** | | 前端过滤 | `useAiConversations.ts:79` | `.filter(m => m.status !== 'truncated')` — **硬编码字符串** | | `title.rs` LLM 调用 | `title.rs:111-140` | `generate_title_via_llm` — **完整的非流式 complete() 调用范例**,压缩可直接复用此模式 | | `build_provider_for` | `secret.rs` | 构造 provider 句柄(含 keyring 解析),title.rs 和压缩共用 | ### 2.3 ContextConfig 预算参数 ```rust // context.rs:88-94 pub struct ContextConfig { max_tokens: u32, // 128_000 output_reserve: u32, // 8_192 safety_ratio: f32, // 0.85 } // budget_limit() = (128_000 - 8_192) × 0.85 ≈ 102_000 tokens ``` ### 2.4 消息分组(裁剪原子性) ```rust // context.rs:115-123 enum MessageGroup { Standalone, // 普通 User/Assistant 文本 ToolCallHead, // Assistant 带 tool_calls(三元组的头) ToolResultTail, // Tool 结果消息(三元组的尾) } ``` `build_eviction_units`(context.rs:526)已实现按三元组分组:Head + 所有 Tail + 紧随的文本 Assistant 作为不可分割的淘汰单元。**分段标记和压缩可复用此分组逻辑保证原子性**。 --- ## 3. 统一设计 ### 3.1 ChatMessage.status 值域(统一) ``` None / "active" → 正常消息,进 LLM 上下文 + 前端可见 "truncated" → UX-09 编辑软删,不进上下文 + 前端隐藏 "archived_segment" → 会话分段标记,不进上下文 + 前端折叠可见 "compressed" → 被压缩替代,不进上下文 + 前端折叠可见 ``` ### 3.2 is_active() 改为正面白名单 ```rust // 改前(反面排除,每加一个新状态都要改) pub fn is_active(&self) -> bool { !matches!(self.status.as_deref(), Some("truncated")) } // 改后(正面白名单,新状态自动不 active) pub fn is_active(&self) -> bool { matches!(self.status.as_deref(), None | Some("active")) } ``` **影响面**:`sanitize_messages` step 0 调 `is_active()` 过滤,改后 `archived_segment` 和 `compressed` 自动被过滤,**零额外改动**。 ### 3.3 push() / restore_from_messages() 的 token 计算修正 **问题**:当前 `push()` 不区分 status 全量计入 `history_tokens`。`restore_from_messages` 从 DB 恢复时 push 全量消息(含 compressed/archived_segment),导致 token 虚高 → `build_for_request` 误判超预算 → 不必要的裁剪。 **修正**: ```rust // push() 加 status 守卫 pub fn push(&mut self, message: ChatMessage) { let tokens = self.estimator.estimate_message(&message); let group = classify_group(&message); // 仅 active 消息计入 token 预算(compressed/archived_segment 不进 LLM 上下文) if message.is_active() { self.history_tokens += tokens; } self.messages.push(TrackedMessage { message, token_count: tokens, group }); } ``` **注意**:`all_messages_clone()` 仍返回全量(含 !active),持久化不受影响。`build_for_request` 先 sanitize 过滤再算预算,行为一致。 --- ## 4. 三大功能设计 ### 4.1 会话分段(清理上下文不删记录) **语义**:用户点「清理上下文」→ 当前所有 active 消息标记 `archived_segment` → 后续新消息作为新段继续同一对话。 **IPC**:`ai_chat_clear_context` **后端逻辑**: ```rust pub async fn ai_chat_clear_context(state: State<'_, AppState>) -> Result<(), String> { let conv_id = { let mut session = state.ai_session.lock().await; let id = session.active_conversation_id.clone(); // 标记当前所有 active 消息为 archived_segment for tm in session.messages.messages_mut() { if tm.message.is_active() { tm.message.status = Some("archived_segment".to_string()); // token 从预算中扣除 session.messages.history_tokens = session.messages.history_tokens.saturating_sub(tm.token_count); } } id }; // 落库(全量含标记) if let Some(id) = conv_id { save_conversation(&state.ai_session, &state.db, &id, None, None).await; } Ok(()) } ``` **不插分隔线 system 消息**——前端按 status 渲染分隔即可,避免无意义的 system 消息污染 DB。 **前端渲染**:`archived_segment` 消息折叠为灰色分隔条,点击展开。 ``` ┌─────────────────────────────────────────┐ │ ▸ --- 上下文已清理 (12 条消息) --- │ ← 折叠态 ├─────────────────────────────────────────┤ │ [当前段消息正常展示] │ └─────────────────────────────────────────┘ ``` **关键约束**:标记时按三元组原子标记(一个 assistant(tool_call) + 其所有 tool_result 必须在同一段)。复用 `build_eviction_units` 的分组逻辑。 ### 4.2 手动上下文压缩(LLM 摘要) **语义**:用户点「压缩上下文」→ 当前段所有 active 消息发给 LLM 生成摘要 → 摘要作为 system 消息替代原始消息。 **IPC**:`ai_chat_compress_context` **后端逻辑**: ```rust pub async fn ai_chat_compress_context(state: State<'_, AppState>) -> Result { // ① 取当前 active 消息 + provider 配置 let (active_msgs, provider_config, conv_id) = { /* lock session, extract */ }; // ② 构建 summary prompt(复用 title.rs 的 generate_title_via_llm 模式) let provider = build_provider_for(&provider_config)?; let summary = compress_via_llm(&*provider, &provider_config.default_model, active_msgs).await?; // ③ 原始消息标记 compressed + 插入摘要 system { let mut session = state.ai_session.lock().await; for tm in session.messages.messages_mut() { if tm.message.is_active() { tm.message.status = Some("compressed".to_string()); } } // 插入摘要作为新的上下文起点 session.messages.push(ChatMessage::system(&format!("## 上下文摘要\n\n{}", summary))); } // ④ 落库 save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; Ok(summary) } ``` **摘要 prompt**(放 `prompt.rs`): ```rust pub fn compress_prompt(lang: &str) -> &'static str { match lang { "en" => "Summarize the following conversation context. Preserve:\n\ 1) User's core intent and requirements\n\ 2) Key decisions made (which approach, which files)\n\ 3) Files modified/created with what changes\n\ 4) Important constraints or preferences\n\ Be concise. Output structured summary only.", _ => "请将以下对话总结为关键上下文摘要,保留:\n\ 1) 用户的核心意图和需求\n\ 2) 已做的关键决策(用了什么方案、改了哪些文件)\n\ 3) 已修改/创建的文件及变更内容\n\ 4) 重要约束或偏好\n\ 简洁输出结构化摘要,不要输出其他内容。", } } ``` **前端**:压缩按钮 + loading 态(`AiCompressing` / `AiCompressed` 事件)+ 压缩后摘要卡片(可展开看原始消息)。 **关键约束**: - 压缩是单向的(不能"解压缩"恢复 LLM 上下文,但 DB 原始消息保留可查看) - 压缩后 `history_tokens` 重算(仅含摘要 + 后续新消息) ### 4.3 智能裁剪(自动压缩) **语义**:agentic loop 每轮 `build_for_request` 前检测,超预算时自动压缩被淘汰的旧消息,而非直接丢弃。 **核心约束**:压缩不能在 `build_for_request` 内同步做(它是纯函数 + 持 session 锁 + LLM 调用 1-5 秒)。 **落点**:agentic loop 循环体顶部,`build_for_request` 之前。 ```rust // agentic.rs run_agentic_loop 循环体内 for iteration in start_iteration..max_iterations { // ... stop_flag 检查 + 对话一致性校验 ... // ★ 智能压缩检查(build_for_request 之前) { let session = session_arc.lock().await; let budget = session.messages.config().budget_limit(); let available = budget.saturating_sub(sys_tokens); if session.messages.history_tokens() > available && session.messages.has_compressible_messages(PROTECT_COUNT + 4) && !session.messages.is_compressing() // 防重入 { // 标记 compressing 防重入 session.messages.set_compressing(true); drop(session); // emit 前端「正在压缩…」 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressing { conversation_id: conv_id.clone() }); // 取被淘汰消息 → LLM 摘要 → 标记 compressed → 插入摘要 compress_old_messages( &*provider, &provider_config.default_model, &session_arc, sys_tokens, &conv_id, &llm_concurrency, ).await; let mut session = session_arc.lock().await; session.messages.set_compressing(false); drop(session); // emit 前端「压缩完成」 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressed { conversation_id: conv_id.clone() }); } } // 正常 build_for_request(此时已压缩,不超预算或缓解) let messages = { /* build_for_request as before */ }; // ... stream_llm ... } ``` **`compress_old_messages` 核心逻辑**(抽为公共函数,手动/自动共用): ```rust async fn compress_old_messages( provider: &dyn LlmProvider, model: &str, session_arc: &Arc>, sys_tokens: u32, conv_id: &str, llm_concurrency: &LlmConcurrency, ) { // ① 计算淘汰边界(保护区 + 余量) let compress_end = { let session = session_arc.lock().await; let protect = session.messages.len().saturating_sub(PROTECT_COUNT + 4); protect }; if compress_end == 0 { return; } // ② 取被淘汰的 active 消息 let (to_compress, lang) = { let session = session_arc.lock().await; let msgs: Vec = session.messages.iter() .take(compress_end) .filter(|m| m.is_active()) .map(|m| m.message.clone()) .collect(); (msgs, session.agent_language.as_deref().unwrap_or("zh")) }; if to_compress.is_empty() { return; } // ③ LLM 摘要(复用 compress_prompt + complete(),对齐 title.rs 模式) let summary = compress_via_llm(provider, model, to_compress, lang, llm_concurrency).await; match summary { Some(s) => { // ④ 标记原始消息 compressed + 插入摘要 system let mut session = session_arc.lock().await; for i in 0..compress_end { if session.messages[i].message.is_active() { session.messages[i].message.status = Some("compressed".to_string()); session.messages.history_tokens = session.messages.history_tokens .saturating_sub(session.messages[i].token_count); } } // 插入摘要在压缩点 session.messages.insert_at(compress_end, ChatMessage::system( &format!("## 上下文摘要\n\n{}", s) )); // 落库 drop(session); save_conversation(session_arc, &db, conv_id, None, None).await; } None => { // LLM 摘要失败 → 降级为原有裁剪行为(不阻塞 loop) tracing::warn!("自动压缩失败,降级为原有裁剪"); } } } ``` **阈值参数**: ``` COMPRESSION_TRIGGER: history_tokens > budget_limit × 0.6 时触发 COMPRESS_KEEP_RECENT: PROTECT_COUNT(6) + 4 = 10 条(保护区外再留余量) ``` **幂等**:`has_compressible_messages()` 检查保护区外是否存在 `status=None/active` 的消息。已标 `compressed` 的不参与二次压缩。`is_compressing()` 标志防重入。 **降级**:LLM 摘要失败时,`build_for_request` 仍走原有裁剪逻辑(丢弃旧消息),不阻塞 loop。 --- ## 5. 数据流(修订后) ``` ContextManager (内存) ├── messages[0..k]: status="compressed" (已压缩,不进 build_for_request) ├── messages[k]: system "## 上下文摘要\n..." (压缩摘要,active) ├── messages[k+1..k+1+m]: status="archived_segment" (分段标记,不进) ├── messages[k+1+m..]: status=None (当前活跃段) build_for_request: → sanitize_messages → step 0: filter is_active() → 剩 [摘要system] + [当前活跃消息] → step 1-3: 畸形三元组自愈 → 超预算时仍裁剪(兜底,正常情况压缩后不超) ``` --- ## 6. 三个功能的关系 ``` 用户场景时间线: ┌──────────────────────────────────────────────────┐ │ 段 a': 初始对话(改了5个文件) │ │ ↓ 用户点「清理上下文」 │ │ 段 a'': 新话题(基于之前的修改继续) │ │ ↓ 对话变长,自动触发智能压缩 │ │ 段 a''(压缩态): 旧消息被摘要替代 │ │ ↓ 继续对话 │ │ 段 a''(续): 新消息追加在摘要后 │ │ ↓ 用户主动点「压缩上下文」 │ │ 段 a''(再压缩): 全部 active 消息被摘要替代 │ └──────────────────────────────────────────────────┘ ``` | 维度 | 会话分段 | 手动压缩 | 智能裁剪 | |------|------|------|------| | 触发 | 用户点「清理上下文」 | 用户点「压缩上下文」 | `build_for_request` 前自动检测 | | 范围 | 当前段全部 active 消息 | 当前段全部 active 消息 | 仅被淘汰的旧消息(保护区外) | | 摘要 | 不生成摘要 | 全量摘要 | 部分摘要(仅淘汰部分) | | status | `archived_segment` | `compressed` | `compressed` | | 语义 | 开始新话题,旧的不可见 | 压缩全部,摘要替代 | 自动节约 token | 三者共享底层机制:`is_active()` 过滤 + status 标记 + `compress_via_llm` 公共函数。 --- ## 7. 实施清单 ### 7.1 基础层(其他都依赖) | # | 改动 | 文件 | 说明 | |---|------|------|------| | B1 | `is_active()` 改白名单 | `df-ai-core/provider.rs:79` | `matches!(status, None \| Some("active"))` | | B2 | `push()` 不计 !active 消息 token | `df-ai/context.rs:174` | `if message.is_active() { history_tokens += tokens }` | | B3 | 压缩 prompt 模板 | `commands/ai/prompt.rs` | `compress_prompt(lang)` 函数 | | B4 | `compress_via_llm` 公共函数 | `commands/ai/` 新文件或 title.rs 扩展 | 复用 title.rs 的 complete() 模式 | | B5 | ContextManager 辅助方法 | `df-ai/context.rs` | `has_compressible_messages()` / `messages_mut()` / `history_tokens()` / `insert_at()` | ### 7.2 功能层 | # | 改动 | 文件 | 依赖 | |---|------|------|------| | F1 | IPC `ai_chat_clear_context`(会话分段) | `commands.rs` + `lib.rs` | B1 | | F2 | IPC `ai_chat_compress_context`(手动压缩) | `commands.rs` + `lib.rs` | B1-B4 | | F3 | agentic loop 自动压缩 | `agentic.rs` | B1-B5 + F2 的 `compress_old_messages` | | F4 | `AiCompressing` / `AiCompressed` 事件 | `mod.rs` | F3 | ### 7.3 前端层 | # | 改动 | 文件 | 说明 | |---|------|------|------| | U1 | `clearContext()` / `compressContext()` API | `api/ai.ts` | 两个新 invoke | | U2 | `clearContext()` / `compressContext()` 方法 | `useAiPanel.ts` | 调 API + 更新 state | | U3 | 清理上下文按钮 + 压缩按钮 | `AiChat.vue` | 现有垃圾桶旁加两个按钮 | | U4 | 分段折叠渲染 | `AiChat.vue` + `useAiConversations.ts:79` | archived_segment 过滤+折叠分隔条 | | U5 | 压缩摘要卡片 | `AiChat.vue` | compressed 消息折叠为摘要卡片 | | U6 | 自动压缩 loading 提示 | `useAiEvents.ts` | AiCompressing/AiCompressed 事件处理 | | U7 | i18n | `aiChat.ts` zh/en | clearContext/compressContext/compressing/compressed 等 key | ### 7.4 实施顺序 ``` 阶段1(基础): B1 → B2 → B3 → B4 → B5 阶段2(手动功能): F1 + F2 → U1-U5 + U7 (用户可立即用) 阶段3(自动): F3 + F4 → U6 (智能裁剪) ``` 阶段 1-2 完成后用户已有「会话分段 + 手动压缩」完整能力。阶段 3 是锦上添花(自动触发)。 --- ## 8. 风险与约束 ### 8.1 is_active() 改白名单的兼容性 改前 `!matches!(Some("truncated"))` = 除了 truncated 都 active。 改后 `matches!(None | Some("active"))` = 只有 None/active 才 active。 **兼容性**:现有 status 值只有 `None`/`"active"`/`"truncated"` 三种。改后 `None` 和 `"active"` 仍 active,`"truncated"` 仍不 active。**零行为变化**。新增的 `archived_segment`/`compressed` 自动不 active(正是期望行为)。 ### 8.2 工具调用三元组跨段/跨压缩 分段标记和压缩时,一个 assistant(tool_call) + 其 tool_result 必须在同一组。 **保障**:复用 `build_eviction_units`(context.rs:526)已实现的三元组分组逻辑。标记时按组原子操作。 ### 8.3 压缩后 token 计数 压缩后 ContextManager 的 `history_tokens` 只含摘要 system + 后续 active 消息。`push()` 修正后(B2),`restore_from_messages` 从 DB 恢复时 compressed 消息不计入 token。 ### 8.4 智能压缩的延迟 LLM `complete()` 调用 1-5 秒,在 agentic loop 内同步等待。用户感知为「正在压缩上下文…」loading。 **缓解**:仅在超预算 60% 时触发(不是每轮),典型对话全程不触发。 ### 8.5 摘要质量 LLM 可能遗漏关键信息。`compress_prompt` 四段式结构化(意图/决策/文件/约束)最大化保留关键信息。摘要质量直接影响后续对话质量,prompt 模板放 `prompt.rs` 可迭代调优。 ### 8.6 前端渲染复杂度 现有消息列表是 `v-for="msg in store.state.messages"` 线性渲染,无分组概念。 **最小方案**:archived_segment / compressed 消息各自折叠为分隔条/卡片,不引入复杂分组逻辑。`useAiConversations.ts:79` 过滤条件从 `status !== 'truncated'` 扩展为 `!status || status === 'active'`(与后端 is_active 白名单对齐),archived_segment/compressed/truncated 全部从线性列表过滤掉,由专门的折叠组件渲染。 --- ## 9. 涉及文件汇总 | 文件 | 改动类型 | |------|----------| | `crates/df-ai-core/src/provider.rs` | B1: is_active() 改白名单 | | `crates/df-ai/src/context.rs` | B2: push() token 修正 + B5: 辅助方法 | | `src-tauri/src/commands/ai/prompt.rs` | B3: compress_prompt 模板 | | `src-tauri/src/commands/ai/title.rs` 或新文件 | B4: compress_via_llm 公共函数 | | `src-tauri/src/commands/ai/commands.rs` | F1+F2: 两个新 IPC | | `src-tauri/src/commands/ai/agentic.rs` | F3: loop 顶部自动压缩检查 | | `src-tauri/src/commands/ai/mod.rs` | F4: AiCompressing/AiCompressed 事件 | | `src-tauri/src/lib.rs` | 注册新 IPC | | `src/api/ai.ts` | U1: 两个新 invoke | | `src/composables/ai/useAiPanel.ts` | U2: clearContext/compressContext | | `src/composables/ai/useAiConversations.ts` | U4: 过滤条件扩展 | | `src/composables/ai/useAiEvents.ts` | U6: 压缩事件处理 | | `src/components/AiChat.vue` | U3+U4+U5: 按钮 + 分段折叠 + 摘要卡片 | | `src/i18n/{zh-CN,en}/aiChat.ts` | U7: i18n key |