发散思考文档涵盖: - ContextManager 三维评估(效率/合理性/成本) - 业界方案对标(Claude Code/OpenAI SDK/Mem0/LangGraph 等) - 融合设计: 结构化分层上下文引擎(L1常驻/L2历史/L3摘要) - 9 种方法交叉论证(决策矩阵/ROI/帕累托/Kano/风险矩阵等) - 7 个发散方向与冲突分析,推荐派系 A 路线 任务技术设计文档涵盖: - T2 工具命名空间: 大工具结果不进主队列,NamespaceStore 详细设计 - T4 工作流 DAG 注入: 活跃路径裁剪,结构化 DAG 进 system prompt - T3 L3 结构化摘要: JSON+NL 双格式压缩摘要 - T1 动态压缩阈值: 精确到行的 3 处改动 - 4 种边界情况推演,AI coding 多 agent 并行策略
733 lines
24 KiB
Markdown
733 lines
24 KiB
Markdown
# 上下文管理演进 — 任务技术设计
|
||
|
||
> 创建: 2026-07-20 | 状态: 设计阶段 | 关联文档: `上下文管理演进与发散思考-2026-07-20.md`
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [T2 工具命名空间(详细设计)](#t2-工具命名空间详细设计)
|
||
- [T4 工作流 DAG 注入(详细设计)](#t4-工作流-dag-注入详细设计)
|
||
- [T3 L3 结构化摘要(设计概要)](#t3-l3-结构化摘要设计概要)
|
||
|
||
---
|
||
|
||
## T2 工具命名空间(详细设计)
|
||
|
||
### 现有流程 vs 新流程
|
||
|
||
```
|
||
现有流程:
|
||
AiToolRegistry.execute(name, args)
|
||
→ 工具执行 → 全量结果 String
|
||
→ push(ChatMessage::tool_result(全量内容)) ← 不管大小,全塞进消息队列
|
||
→ build_for_request 时,大 tool_result 撑爆预算
|
||
→ 压缩/裁剪 → 丢失细节
|
||
|
||
新流程:
|
||
AiToolRegistry.execute(name, args)
|
||
→ 工具执行 → 全量结果 String
|
||
→ if should_use_namespace(全量内容)
|
||
→ store_to_namespace(key, 全量内容) ← 存入独立存储
|
||
→ push(ChatMessage::tool_result(引用路径)) ← 主队列只插引用
|
||
else
|
||
→ push(ChatMessage::tool_result(全量内容)) ← 小结果不动
|
||
→ build_for_request 时,引用只占 ~20 tokens
|
||
→ 压缩不影响 namespace 内容
|
||
→ 模型通过引用路径按需读取(或系统自动补全)
|
||
```
|
||
|
||
### 数据结构
|
||
|
||
```rust
|
||
// crates/df-ai/src/namespace_store.rs
|
||
|
||
/// namespace 引用路径格式: "namespace://tool_name/args_hash"
|
||
/// 例: "namespace://read_file/a1b2c3d4"
|
||
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||
|
||
/// 落入 namespace 的字节阈值(> 2048 bytes)
|
||
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
|
||
|
||
/// 落入 namespace 的行数阈值(> 50 行)
|
||
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
||
|
||
/// namespace 存储
|
||
///
|
||
/// 内存中为 HashMap,提供常规 CRUD;
|
||
/// 外部可注入持久化实现(接 SQLite / 内存 fallback)。
|
||
/// 设计为独立于 ContextManager 的结构——不被压缩/裁剪影响。
|
||
pub struct NamespaceStore {
|
||
/// key = 引用路径 hash, value = 原始工具结果
|
||
entries: HashMap<String, NamespaceEntry>,
|
||
/// 总字节数上限(防内存爆炸,超限淘汰最旧条目)
|
||
max_bytes: usize,
|
||
current_bytes: usize,
|
||
}
|
||
|
||
pub struct NamespaceEntry {
|
||
pub key: String, // 完整引用路径
|
||
pub tool_name: String,
|
||
pub args: serde_json::Value,
|
||
pub content: String, // 原始工具执行结果
|
||
pub content_summary: String, // extract_key_info 后的摘要(供 LLM 预览)
|
||
pub created_at: Instant,
|
||
pub access_count: u64,
|
||
}
|
||
```
|
||
|
||
### 关键决策点
|
||
|
||
#### 决策 1:何时进 namespace
|
||
|
||
```rust
|
||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||
// 1. 明确标记为"大结果"的工具(read_file / list_directory 几乎总是大)
|
||
if is_always_large_tool(tool_name) {
|
||
return true;
|
||
}
|
||
// 2. 按大小阈值判定
|
||
content.len() > NAMESPACE_BYTE_THRESHOLD
|
||
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
|
||
}
|
||
|
||
fn is_always_large_tool(tool_name: &str) -> bool {
|
||
matches!(tool_name, "read_file" | "list_directory" | "grep" | "diff_files")
|
||
}
|
||
```
|
||
|
||
#### 决策 2:模型看到什么
|
||
|
||
LLM 在 tool_result 消息中看到的不是全量内容,而是:
|
||
|
||
```
|
||
当前(小结果原样,大结果原样):
|
||
[tool_result] 342 行文件内容...(占 2000 tokens)
|
||
|
||
新流程(引用路径 + 摘要预览):
|
||
[tool_result] <namespace://read_file/a1b2c3>
|
||
[tool_result]
|
||
文件: config.rs
|
||
行数: 342
|
||
关键节点: timeout:15 (行15), pool_size:10 (行42)
|
||
```
|
||
|
||
**LLM 的理解能力验证**:如果模型需要读取完整内容,system prompt 需说明:
|
||
|
||
```
|
||
[上下文说明]
|
||
当工具结果以 <namespace://path> 格式返回时,表示完整内容已存储。
|
||
如果你需要查看完整内容,回复 /read_namespace path
|
||
系统将自动拉取完整内容替换当前引用。
|
||
```
|
||
|
||
或自动更激进——当 LLM 的回复引用某 namespace 路径时,系统在下一轮自动拉取:
|
||
|
||
```
|
||
LLM: "config.rs 第 15 行的 timeout 需要改"
|
||
系统检测: /read_namespace 触发 → 从 namespace 拉取 config.rs 完整内容
|
||
→ 下轮 tool_result 消息中替换为完整内容
|
||
→ LLM 看到原文,精确引用行号
|
||
```
|
||
|
||
#### 决策 3:持久化与生命周期
|
||
|
||
```rust
|
||
impl NamespaceStore {
|
||
/// 存入 namespace
|
||
pub fn store(&mut self, key: &str, tool_name: &str, args: &Value, content: &str) -> String {
|
||
let summary = extract_key_info(content); // 复用现有 extract_key_info
|
||
let entry = NamespaceEntry {
|
||
key: key.to_string(),
|
||
tool_name: tool_name.to_string(),
|
||
args: args.clone(),
|
||
content: content.to_string(),
|
||
content_summary: summary,
|
||
created_at: Instant::now(),
|
||
access_count: 0,
|
||
};
|
||
self.current_bytes += content.len();
|
||
// 超限淘汰:从最旧开始删
|
||
while self.current_bytes > self.max_bytes {
|
||
// ... LRU 淘汰
|
||
}
|
||
format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key)
|
||
}
|
||
|
||
/// 读取完整内容
|
||
pub fn read(&mut self, full_path: &str) -> Option<&str> {
|
||
let key = self.parse_key(full_path)?;
|
||
let entry = self.entries.get_mut(&key)?;
|
||
entry.access_count += 1;
|
||
Some(entry.content.as_str())
|
||
}
|
||
|
||
/// 读取摘要(用于自动补全判断)
|
||
pub fn read_summary(&self, full_path: &str) -> Option<&str> {
|
||
let key = self.parse_key(full_path)?;
|
||
self.entries.get(&key).map(|e| e.content_summary.as_str())
|
||
}
|
||
}
|
||
```
|
||
|
||
### 接线点:process_tool_calls
|
||
|
||
改动点位于 `audit/mod.rs::process_tool_calls`,工具执行完成后的 push 路径:
|
||
|
||
```rust
|
||
// current (audit/mod.rs ~380行附近)
|
||
pub(crate) async fn process_tool_calls(..., conv_id: &str) -> usize {
|
||
// ... 现有审批/执行逻辑 ...
|
||
for (index, draft) in tc_list {
|
||
// ... 执行工具 ...
|
||
let result = tools_arc.execute(&draft.name, &draft.args).await;
|
||
|
||
// ★ 新增:namespace 判断
|
||
let content = if should_use_namespace(&result, &draft.name) {
|
||
let session = session_arc.lock().await;
|
||
let ns = &mut session.namespace_store; // AiSession 新增字段
|
||
let ref_path = ns.store(&draft.name, &draft.args, &result);
|
||
ref_path // ← 主队列只推引用路径
|
||
} else {
|
||
result // ← 小结果原样推
|
||
};
|
||
|
||
// 后续 push 不变
|
||
let msg = ChatMessage::tool_result(draft.tool_call_id.clone(), &content);
|
||
session.conv(conv_id).messages.push(msg);
|
||
}
|
||
}
|
||
```
|
||
|
||
### AiSession 新增字段
|
||
|
||
```rust
|
||
// src-tauri/src/commands/ai/mod.rs 或 state.rs
|
||
|
||
pub struct AiSession {
|
||
pub conversations: HashMap<String, AiConversation>,
|
||
pub current_conv: Option<String>,
|
||
pub generating: HashSet<String>,
|
||
|
||
// ★ 新增
|
||
pub namespace_store: NamespaceStore,
|
||
}
|
||
```
|
||
|
||
### 边界情况推演
|
||
|
||
#### 情况 1:模型不理解为 namespace 引用
|
||
|
||
```
|
||
LLM 收到 [tool_result] <namespace://read_file/a1b2c3>
|
||
LLM 回复: "读了 config.rs,但我不知道内容是什么"
|
||
→ 用户体验差
|
||
```
|
||
|
||
**对策**:引用路径不是结束。在 `process_tool_calls` push 引用时,额外 push 一条 assistant 消息或修改 tool_result 内容格式:
|
||
|
||
```
|
||
[tool_result] 文件 config.rs (342 行)
|
||
| 关键行: timeout:15 (行15), pool_size:10 (行42)
|
||
| 完整内容: <namespace://read_file/a1b2c3>
|
||
```
|
||
|
||
即:`extract_key_info(已有)+ 引用路径` 混合格式。LLM 可以靠摘要感知文件内容,只有需要精确行号时才触发 `/read_namespace`。
|
||
|
||
#### 情况 2:同一文件在 2 轮内被多次引用
|
||
|
||
```
|
||
第 3 轮: read_file("config.rs") → namespace 存 342 行 → 引用
|
||
第 5 轮: 模型需要第 15 行 → /read_namespace → 系统拉取
|
||
```
|
||
|
||
第 5 轮拉取后,第 6 轮应该做什么?
|
||
|
||
**方案 A(推荐)**:拉取后仅在**当轮** `tool_result` 替换为完整内容。下一轮重新压缩时,如果内容大再次进入 namespace。
|
||
|
||
```
|
||
第 5 轮: [tool_result] config.rs 342 行全文(/read_namespace 触发的)
|
||
第 6 轮: 压缩 → config.rs 内容被压缩掉 → 正常
|
||
第 7 轮: 模型再次需要 → 再次 /read_namespace → namespace 还在
|
||
```
|
||
|
||
**方案 B**:拉取后一直保留在消息队列中。
|
||
|
||
→ 不推荐,回到了 tool_result 膨胀的老路。
|
||
|
||
#### 情况 3:namespace 内存爆炸
|
||
|
||
```
|
||
最大对话: 500 轮,平均每轮存 2 个 namespace 条目,每条约 3k bytes
|
||
→ 500 × 2 × 3k = 3MB
|
||
```
|
||
|
||
设 `max_bytes = 10MB`(宽松上限),最旧条目自动淘汰。淘汰后如果有模型再次引用:
|
||
|
||
```
|
||
LLM: /read_namespace namespace://read_file/a1b2c3
|
||
系统: 条目已淘汰 → 重新执行工具 → 重新 namespace 存储 → 返回内容
|
||
```
|
||
|
||
等价于「cache miss」,对用户透明。
|
||
|
||
#### 情况 4:DB 持久化需不需要存 namespace
|
||
|
||
**不需要**。namespace 是运行时缓存优化,不是持久化真相源。全量消息已在 `save_conversation` 写入 `ai_messages` 表(uncompressed),压缩后的摘要也在 system 消息中。namespace 淘汰后,可通过 DB 重新构建,但需要保证:
|
||
|
||
```rust
|
||
// save_conversation 中
|
||
if msg.content.starts_with(NAMESPACE_REF_PREFIX) {
|
||
// 写 DB 前将引用替换回原始内容(从 namespace 中取)
|
||
// 替代方案:DB 也存引用路径,恢复时从 namespace 重建
|
||
}
|
||
```
|
||
|
||
**建议**:DB 存引用路径。恢复时 namespace 可能已淘汰,此时:
|
||
|
||
1. 重新执行工具(不可行——工具可能有副作用)
|
||
2. DB 保留原文(`save_conversation` 时展开引用)
|
||
|
||
**结论**:DB 存原文。save 时 namespace 条目肯定存在(刚执行完),展开引用写入 DB。恢复时直接读 DB 原文,namespace 只服务于运行时。
|
||
|
||
```rust
|
||
// save_conversation 中展开引用
|
||
let content = if msg.content.starts_with(NAMESPACE_REF_PREFIX) {
|
||
namespace_store.read(&msg.content)
|
||
.unwrap_or(&msg.content) // 兜底:用引用路径自身(可能性低)
|
||
} else {
|
||
&msg.content
|
||
};
|
||
record.content = content.to_string();
|
||
```
|
||
|
||
### 推演:T2 在 T-heavy 场景中的行为
|
||
|
||
```
|
||
第 3 轮: read_file("config.rs") → 342 行
|
||
应进 namespace ✓
|
||
主队列: [tool_result] 文件 config.rs (342行), 行15:timeout, 行42:pool
|
||
主队列 token: ~80(→原来 2000)
|
||
节省: 1920 tokens
|
||
|
||
第 4-7 轮: 另有 3 次大工具结果 → 每次省 ~1500-2000 tokens
|
||
|
||
第 8 轮: 压缩触发
|
||
当前: 主队列总 history_tokens ≈ 5000(含 4 条引用)
|
||
→ 未达压缩阈值(原应为 20000+)
|
||
压缩不触发 ✓ 用户无感知
|
||
|
||
第 15 轮: 用户回来 "config.rs 的 timeout 在第几行?"
|
||
LLM 在压缩摘要中仍能找到"行15:timeout" ← 摘要来自 extract_key_info
|
||
→ 精确回答 ✓ 不需要 /read_namespace
|
||
|
||
第 16 轮: "删掉那行,改成 30"
|
||
LLM 需要完整文件结构来做 diff
|
||
→ /read_namespace namespace://read_file/xxx
|
||
→ 系统拉取完整 342 行
|
||
→ 第 16 轮 tool_result 出现完整内容
|
||
→ 第 17 轮压缩 → 完整内容又被 namespace 吸收 → 回到摘要+引用
|
||
```
|
||
|
||
**总节省**:15 轮对话中,原本平均每轮 4000 tokens 的大 tool_result 占用 → 现在每轮 ~100 tokens 引用 + 摘要。**累积节省 ~60k tokens,约 $0.18(Sonnet)。**
|
||
|
||
---
|
||
|
||
## T4 工作流 DAG 注入(详细设计)
|
||
|
||
### 现有流程 vs 新流程
|
||
|
||
```
|
||
现有:
|
||
run_agentic_loop system_prompt 拼接:
|
||
[系统指令] + [工具定义] + [pinned_goals] + [知识注入]
|
||
→ build_for_request
|
||
→ LLM 只能从消息历史中推断"当前在做什么"
|
||
|
||
新流程:
|
||
run_agentic_loop system_prompt 拼接前:
|
||
if conv.workflow_id != None:
|
||
dag = df_workflow::Dag::load(conv.workflow_id)
|
||
dag_block = render_dag_to_system_block(dag)
|
||
system_prompt = dag_block + system_prompt_原有内容
|
||
→ build_for_request(system_prompt + ...)
|
||
→ LLM 看到结构化 DAG,"任务进展"一目了然
|
||
```
|
||
|
||
### 数据结构
|
||
|
||
```rust
|
||
// src-tauri/src/commands/ai/workflow_context.rs
|
||
|
||
/// 工作流上下文块 — 注入 system prompt 的 DAG 摘要
|
||
pub struct WorkflowContextBlock {
|
||
pub workflow_id: String,
|
||
pub workflow_name: String,
|
||
pub total_nodes: usize,
|
||
pub completed_nodes: usize,
|
||
pub current_node: Option<WorkflowNodeSummary>,
|
||
pub next_nodes: Vec<WorkflowNodeSummary>,
|
||
}
|
||
|
||
/// DAG 节点摘要 — 不进完整 DAG,只进关键上下文
|
||
pub struct WorkflowNodeSummary {
|
||
pub node_id: String,
|
||
pub node_type: String, // "AINode" | "ScriptNode" | "HumanNode" | ...
|
||
pub label: String, // 用户或 LLM 设定的节点名称
|
||
pub status: String, // "completed" | "running" | "pending" | "blocked"
|
||
pub output_summary: Option<String>, // 节点输出的摘要(关键产出)
|
||
}
|
||
```
|
||
|
||
### 关键决策点
|
||
|
||
#### 决策 1:DAG 全量注入还是摘要注入
|
||
|
||
```
|
||
DAG 可能很大(50+ 节点)。全量注入 ≈ 2000+ tokens,不可接受。
|
||
```
|
||
|
||
**方案**:只注入「当前活跃路径」。从 DAG 的起始节点到当前节点 + 后续 2 层子节点。其余节点不注入。
|
||
|
||
```mermaid
|
||
graph LR
|
||
subgraph "DAG 全量(50 节点)"
|
||
N1["N1 ✅"] --> N2["N2 ✅"]
|
||
N1 --> N3["N3 ✅"]
|
||
N2 --> N4["N4 🚧 当前"]
|
||
N2 --> N5["N5 pending"]
|
||
N3 --> N6["N6 pending"]
|
||
N4 --> N7["N7 pending"]
|
||
N5 --> N8["N8 pending"]
|
||
N6 --> N9["N9 pending"]
|
||
N3 --> N10["N10 pending"]
|
||
N4 --> N11["N11 pending"]
|
||
end
|
||
|
||
subgraph "注入内容(当前活跃路径,5 节点)"
|
||
I1["N1: 读源码 ✅"]
|
||
I2["N2: 分析依赖 ✅"]
|
||
I3["N4: 改配置 🚧"]
|
||
I4["N5: 验证配置 pending"]
|
||
I5["N7: 提交变更 pending"]
|
||
end
|
||
```
|
||
|
||
```rust
|
||
pub fn build_active_path(dag: &Dag, current_node_id: &str) -> WorkflowContextBlock {
|
||
let path = dag.path_to_root(current_node_id); // 到根的全路径
|
||
let next = dag.children(current_node_id, 2); // 后续 2 层
|
||
|
||
WorkflowContextBlock {
|
||
workflow_id: dag.id.clone(),
|
||
workflow_name: dag.name.clone(),
|
||
total_nodes: dag.nodes.len(),
|
||
completed_nodes: dag.nodes.iter().filter(|n| n.status == "completed").count(),
|
||
current_node: summarize_node(dag.get_node(current_node_id)),
|
||
next_nodes: next.into_iter().map(|n| summarize_node(n)).collect(),
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 决策 2:注入位置
|
||
|
||
注入 system prompt 的最前方(优先级高于工具定义):
|
||
|
||
```
|
||
[system]
|
||
[工作流] 审批模块改造 (ID: wf-abc)
|
||
✅ 1/3 读 config.rs 源码 — 产出: timeout:15 位于行15
|
||
🚧 2/3 改 timeout 配置 — 当前步骤
|
||
⬜ 3/3 验证配置生效
|
||
──────────────────────────────────────
|
||
[工具定义] 30 个工具的 JSON schema...
|
||
[目标提示] ...
|
||
[system prompt 原有内容]
|
||
```
|
||
|
||
**理由**:工作流状态是会话的最上层语境。模型先看到"我们在做什么",再看到"有什么工具可用"。
|
||
|
||
#### 决策 3:无工作流时的行为
|
||
|
||
```rust
|
||
if let Some(wf_id) = &conv.workflow_id {
|
||
if let Ok(dag) = df_workflow::Dag::load(db, wf_id) {
|
||
let block = build_active_path(&dag, &conv.current_node_id);
|
||
system_prompt = format!("{}\n{}", block.to_system_text(), system_prompt);
|
||
}
|
||
// Dag::load 失败 → 静默跳过(workflow 可能被删了)
|
||
}
|
||
// workflow_id == None → 行为完全不变
|
||
```
|
||
|
||
### 推演:T4 在 T-resume 场景中的行为
|
||
|
||
```
|
||
上午:
|
||
用户在工作流 wf-abc 中:
|
||
N1: 读源码 ✅
|
||
N2: 分析依赖 ✅
|
||
N3: 改配置 🚧(当前)
|
||
最后操作: 改了一半配置,离开
|
||
|
||
下午回来:
|
||
现有: 消息历史被压缩 → 摘要"用户讨论过配置修改"
|
||
→ LLM 答:"你讨论了配置,要继续吗?"
|
||
→ 用户需要重新说明
|
||
|
||
T4: system prompt 注入:
|
||
[工作流] 审批模块改造
|
||
✅ 1/3 读 config.rs — timeout:15
|
||
✅ 2/3 分析 db.rs 依赖 — 连接池:10
|
||
🚧 3/3 改 timeout 配置 — 已设为30,未验证
|
||
→ LLM: "你上午在改 timeout 配置,已经改了还没验证,
|
||
要继续验证还是改其他?"
|
||
→ 用户直接继续,不需要重新说明
|
||
```
|
||
|
||
**精度依赖**:`WorkflowNodeSummary.output_summary` 是关键。如果工作流节点在完成时已有结构化的产出记录(`read_file → 行号:timeout:15`),LLM 就能精确回溯。这需要 df-workflow 的节点在完成时主动记录产出摘要——目前不一定有。
|
||
|
||
**缺口**:如果工作流节点跑完但没有产出摘要(`output_summary: None`),LLM 只能看到"节点已完成",不知道完成了什么。`output_summary` 需要在 `AiNode::execute` 完成时自动产生:
|
||
|
||
```rust
|
||
// df-nodes/src/ai_node.rs: 执行完成后
|
||
if let Some(workflow_node_id) = current_workflow_node {
|
||
let summary = extract_key_info(&result); // 复用
|
||
workflow::update_node_output(db, workflow_node_id, summary);
|
||
}
|
||
```
|
||
|
||
这个联动在 T4 之前需要确保。
|
||
|
||
### T4 + T2 组合推演
|
||
|
||
```
|
||
T2 提供了 namespace 精确行号回溯
|
||
T4 提供了工作流节点结构化映射
|
||
|
||
组合:
|
||
LLM: "config.rs 的 timeout 在哪一行?"
|
||
T4 DAG 节点: "N1: 读 config.rs"
|
||
T2 namespace: 关联 namespace://read_file/xxx 到 N1 的产出
|
||
→ LLM 从 DAG 节点取到 "timeout:15 位于行15"
|
||
→ 不需要调用 /read_namespace
|
||
→ 精确行号回答,零额外 token
|
||
|
||
如果 T2 没有、T4 没有:
|
||
LLM: 从压缩摘要猜 → 可能错
|
||
|
||
如果 T2 有、T4 没有:
|
||
LLM: 从 DAG 节点取出 "读 config.rs",但需要/read_namespace 拿行号
|
||
→ 多一次 tool round
|
||
|
||
如果 T2 没有、T4 有:
|
||
LLM: DAG 节点只有 "读源码 ✅",没有行号细节
|
||
→ 重新 read_file
|
||
|
||
组合后: 最大精度,最小 token 开销。
|
||
```
|
||
|
||
---
|
||
|
||
## T3 L3 结构化摘要(设计概要)
|
||
|
||
### 改 compress_via_llm 返回类型
|
||
|
||
```rust
|
||
// current
|
||
pub(crate) async fn compress_via_llm(...) -> Result<String, String>;
|
||
|
||
// new
|
||
pub struct CompressedSummary {
|
||
/// JSON 卡片序列化字符串
|
||
pub json_card: String,
|
||
/// 自然语言摘要(向前兼容)
|
||
pub nl_summary: String,
|
||
}
|
||
|
||
pub(crate) async fn compress_via_llm(...) -> Result<CompressedSummary, String>;
|
||
```
|
||
|
||
### 改 compress_prompt
|
||
|
||
```rust
|
||
pub(crate) fn compress_prompt(lang: &str) -> &'static str {
|
||
match lang {
|
||
"en" => "You are a conversation summarizer. Compress the following conversation \
|
||
into a structured summary that preserves the essential context for \
|
||
continuing the work. \
|
||
\n\
|
||
**You MUST output TWO parts separated by a delimiter:**\n\
|
||
\n\
|
||
Part 1 — JSON (between <<<JSON>>> and <<<END_JSON>>>):\n\
|
||
{\n\
|
||
\"topics\": [\"topic1\", \"topic2\"],\n\
|
||
\"decisions\": [{\"what\": \"...\", \"why\": \"...\"}],\n\
|
||
\"unresolved\": [\"question1\"],\n\
|
||
\"key_files\": [{\"path\": \"...\", \"change\": \"...\"}],\n\
|
||
\"token_saved\": <estimated_tokens>\n\
|
||
}\n\
|
||
\n\
|
||
Part 2 — Natural language summary (between <<<NL>>> and <<<END_NL>>>):\n\
|
||
A concise paragraph summarizing the conversation.\n\
|
||
\n\
|
||
Rules:\n\
|
||
- JSON must be valid.\n\
|
||
- Keep the natural language summary concise; prefer bullet points.\n\
|
||
- Preserve file paths, identifiers, and error messages verbatim in both parts.\n\
|
||
- Drop small talk; keep only technically load-bearing facts.\n\
|
||
- Do NOT invent facts.\n",
|
||
_ => {
|
||
// 中文版本同上,翻译为中文
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### insert_at 处的消费
|
||
|
||
```rust
|
||
// context_lifecycle.rs: 压缩成功后
|
||
match compress_outcome {
|
||
Ok(Some(summary)) => {
|
||
// ★ 新格式:JSON + NL 双格式嵌入 system 消息
|
||
let system_text = format!(
|
||
"{}<<<STRUCTURED_SUMMARY>>>\n{}\n<<<END_STRUCTURED_SUMMARY>>>\n{}",
|
||
SUMMARY_MARKER,
|
||
summary.json_card,
|
||
summary.nl_summary,
|
||
);
|
||
conv.messages.insert_at(0, ChatMessage::system(&system_text));
|
||
// ...
|
||
}
|
||
Ok(None) => { /* noop */ }
|
||
Err(e) => {
|
||
// LLM 压缩失败 → 关键词摘要兜底(同前)
|
||
// 关键词摘要是纯文本格式,不需要 JSON 结构
|
||
// → insert_at(0, system(keyword_fallback)) ← 与旧行为一致
|
||
}
|
||
}
|
||
```
|
||
|
||
### 退化检测适配
|
||
|
||
当前 `clean_summary` 函数只处理纯文本退化。新格式引入后,退化检测需增加 JSON 解析验证:
|
||
|
||
```rust
|
||
fn clean_summary(raw: &str) -> Result<CompressedSummary, String> {
|
||
// 提取 JSON 段
|
||
let json = extract_between(raw, "<<<JSON>>>", "<<<END_JSON>>>")?;
|
||
let nl = extract_between(raw, "<<<NL>>>", "<<<END_NL>>>")?;
|
||
|
||
// 验证 JSON 合法性
|
||
let card: SummaryCard = serde_json::from_str(&json)
|
||
.map_err(|e| format!("JSON 解析失败: {}", e))?;
|
||
|
||
// 退化检测 NL 段
|
||
if is_degenerated_repetition(&nl) {
|
||
return Err("NL 段退化重复".to_string());
|
||
}
|
||
|
||
Ok(CompressedSummary {
|
||
json_card: json,
|
||
nl_summary: nl,
|
||
})
|
||
}
|
||
```
|
||
|
||
### 推演:T3 在检索场景中的行为
|
||
|
||
```
|
||
无 T3(当前):
|
||
压缩摘要: "用户讨论了审批配置,把 timeout 改成了 30"
|
||
LLM 读取后知道"改过",但具体决策原因不清晰
|
||
→ 需要推测或 ask user
|
||
|
||
有 T3:
|
||
system 消息中嵌入:
|
||
<<<STRUCTURED_SUMMARY>>>
|
||
{"decisions": [{"what": "timeout: 15→30", "why": "用户反馈15min太短"}]}
|
||
<<<END_STRUCTURED_SUMMARY>>>
|
||
自然语言: 用户讨论审批配置,决定超时改为 30 分钟
|
||
|
||
LLM 直接从 JSON 读取决策原因:
|
||
→ "15→30,原因是用户反馈太短"
|
||
→ 精确回答 ✓
|
||
→ 不需要 ask user
|
||
```
|
||
|
||
**收益量化**:每次压缩后,JSON 卡片占 ~300 tokens,自然语言占 ~200 tokens。比纯自然语言的 ~400 tokens 多了 ~100 tokens。但 JSON 的结构化让 LLM 的检索精度从「需要推理」变为「可以直接读」,减少了后续追问的轮次。
|
||
|
||
---
|
||
|
||
## T1 动态压缩阈值(设计概要)
|
||
|
||
代码改动精确到行:
|
||
|
||
```rust
|
||
// src-tauri/src/commands/ai/agentic/mod.rs: 调用处(line ~1117)
|
||
if maybe_auto_compress(
|
||
&session_arc,
|
||
&conv_id,
|
||
&app_handle,
|
||
&provider,
|
||
&provider_config,
|
||
&llm_concurrency,
|
||
iteration,
|
||
sys_tokens, // ★ 新增参数
|
||
).await {
|
||
```
|
||
|
||
```rust
|
||
// src-tauri/src/commands/ai/agentic/context_lifecycle.rs: 函数签名(line ~72)
|
||
pub(super) async fn maybe_auto_compress(
|
||
session_arc: &Arc<Mutex<AiSession>>,
|
||
conv_id: &str,
|
||
app_handle: &AppHandle,
|
||
provider: &Box<dyn LlmProvider>,
|
||
provider_config: &AiProviderRecord,
|
||
llm_concurrency: &LlmConcurrency,
|
||
iteration: usize,
|
||
sys_tokens: u32, // ★ 新增
|
||
) -> bool {
|
||
```
|
||
|
||
```rust
|
||
// 同上,line ~88-92 触发条件
|
||
let budget = mgr.budget_limit();
|
||
let available = budget.saturating_sub(sys_tokens); // ★ 新增
|
||
let should = protect_start > 0
|
||
&& (available as u64) * 6 / 10 < history_tokens as u64 // ★ 改 budget→available
|
||
&& mgr.has_compressible_messages(protect_start);
|
||
```
|
||
|
||
---
|
||
|
||
## 实施顺序验证(依赖关系)
|
||
|
||
```mermaid
|
||
graph LR
|
||
T1["T1: 动态阈值<br>~4 行改动"] --> T2["T2: 命名空间<br>~300 行"]
|
||
T1 -.->|可选前置| T3["T3: 结构化摘要<br>~100 行"]
|
||
T2 --> T4["T4: 工作流DAG<br>~500 行"]
|
||
T3 --> T5["T5: WorkingContext<br>~500 行"]
|
||
|
||
T2 -.-> T4
|
||
T3 -.-> T4
|
||
|
||
style T1 fill:#c8e6c9
|
||
style T2 fill:#c8e6c9
|
||
style T3 fill:#fff9c4
|
||
style T4 fill:#fff9c4
|
||
style T5 fill:#ffccbc
|
||
```
|
||
|
||
**并发策略(AI coding 多 agent 并行)**:
|
||
|
||
| 并行流 | 任务 | 前置 | 可独立启动? |
|
||
|--------|------|------|------------|
|
||
| 流 A | T1 + T2 | T1 无前置,T2 无前置 | ✅ 立即,T1 与 T2 可并行编码 |
|
||
| 流 B | T3 | 无(与 T2 独立) | ✅ 可同时启动 |
|
||
| 流 C | T4 | 依赖 T2 的 namespace 接口签名(非实现) | ⚠️ 接口定义后即可启动 |
|
||
| 流 D | T5 | 依赖 T3 的 CompressedSummary 结构 | ⚠️ T3 验收后 |
|