squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
23 KiB
AI Chat 上下文管理增强设计
性质: 功能设计(经代码勘察确认可行性 + 多角度分析) 日期: 2026-06-16 关联: Agent 架构说明(现有裁剪机制盘点依据) 用途: 实施依据,实施方据此文档执行,不再二次设计
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 用户需求(三条)
- 会话分段:清理上下文不删记录,同一对话内产生 a'/a''/a''' 多段,DB 全量保留,LLM 只看当前段
- 手动压缩:用户主动发起,把当前消息让 LLM 总结成摘要,摘要替代原始消息作为上下文起点
- 智能裁剪:超预算自动触发压缩,不直接丢弃,用摘要保留旧消息语义
2. 代码勘察(事实依据)
2.1 数据流真相链
DB (ai_conversations.messages JSON 列)
↕ save_conversation / switchConversation → restore_from_messages
ContextManager (内存真相源, Vec<TrackedMessage>)
↓ 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<String>,现有值: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 预算参数
// 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 消息分组(裁剪原子性)
// 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() 改为正面白名单
// 改前(反面排除,每加一个新状态都要改)
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 误判超预算 → 不必要的裁剪。
修正:
// 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
后端逻辑:
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
后端逻辑:
pub async fn ai_chat_compress_context(state: State<'_, AppState>) -> Result<String, String> {
// ① 取当前 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):
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 之前。
// 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 核心逻辑(抽为公共函数,手动/自动共用):
async fn compress_old_messages(
provider: &dyn LlmProvider,
model: &str,
session_arc: &Arc<Mutex<AiSession>>,
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<ChatMessage> = 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 |