Compare commits
3
Commits
26d93d5184
...
cf33e47f5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf33e47f5b | ||
|
|
5cebfcd864 | ||
|
|
e6ed13bb9a |
@@ -0,0 +1,53 @@
|
|||||||
|
# 上下文管理对标 u-claw 与大批量并行差距分析(2026-10-19)
|
||||||
|
|
||||||
|
> 用户令(2026-10-18 飞书):①大批量 AI 任务调度/多会话多任务并行是未来常态核心场景;②战略目标=DevFlow 部署 Linux 替代 u-claw+Claude Code(CC 效率不够高、资源消耗太大);③第一门槛=上下文管理三命题:丢记忆、历史信息召回、高效利用 token;④方法=学习 u-claw 历史提交记录补 devflow 场景不足。
|
||||||
|
|
||||||
|
## 一、u-claw 上下文管理能力画像(源自主仓 6 条相关提交,核心是 86e9216)
|
||||||
|
|
||||||
|
| # | 能力 | u-claw 实现 | 要点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **token 水位判据** | 140K≈200K 窗口 70% 主判据(可配),体积判据降兜底 | 治「换代过早」:350KB 体积≈真实水位 20-35%,体积判据致 23 代/日→水位判据 <5/日 |
|
||||||
|
| 2 | **usage 真值采集** | 读流解析 message.usage(input+cache_read+cache_creation),失败静默 0 | 水位判据的数据底座 |
|
||||||
|
| 3 | **大内容外置纪律** | system prompt 处理规则:大结论写盘 out/,回复只给路径+3 行结论 | 治上下文滚雪球(attach/ 86 文件 25MB 实锤) |
|
||||||
|
| 4 | **换代通知推送** | rollover 等会话层事件经渠道真推送(非仅日志) | 用户对会话滚动有感知 |
|
||||||
|
| 5 | **/history 归档检索** | 无参列最近 10 代,关键词 grep summary | 跨代记忆召回入口 |
|
||||||
|
| 6 | **归档检索指引** | sopHint 注明 jsonl 字段与 grep→读 summary 操作路径 | 让 AI 自己会查历史 |
|
||||||
|
|
||||||
|
另:0a4f8dd 重上下文估算(防图片堆积)、a3f28dc 群聊上下文增量注入(按决策价值治理进度卡)。
|
||||||
|
|
||||||
|
## 二、devflow 现状对照(三命题 × 已治/缺口)
|
||||||
|
|
||||||
|
### 命题 1:丢记忆
|
||||||
|
- ✅ 已治:目标语义重构(用户任务原文入 pinned_goals)、恢复链快照([系统恢复]/[补充指令] 产物双侧过滤,10-18 批)、压缩摘要四段式(意图/决策/文件/约束+失败事实保留)、E2 降级透出(10-18)
|
||||||
|
- ❌ 缺口:**boundary/loop_state 填充率 0**(终局态建模半闭环)——「代」的显式语义缺失,会话边界靠消息流推断
|
||||||
|
|
||||||
|
### 命题 2:历史信息召回
|
||||||
|
- ✅ 已治:单会话内压缩可召回(archive jsonl+召回句柄行)、知识路标投影进系统提示词
|
||||||
|
- ❌ 缺口:**跨会话检索无入口**(u-claw /history 等价物缺失)——「上次那个任务怎么弄的」只能靠人翻侧栏;消息全文搜索未做(F4-1 只做了标题+列表分页)
|
||||||
|
|
||||||
|
### 命题 3:高效利用 token
|
||||||
|
- ✅ 已治:压缩 memoization+决策冻结(10-04 病②治愈)、尾部追加摘要保前缀缓存、E13 超时连败收敛
|
||||||
|
- ❌ 缺口:
|
||||||
|
- **usage 真值采集未落地**(6 月 P1 敲定至今暂缓;cache_hit≈0% 说明 cache 口径有列无真值)——水位判据缺数据底座
|
||||||
|
- **压缩触发判据待查**:压缩/请求比 4.8 偏频,疑体积/轮次判据过敏感(对标 u-claw 病灶同型)
|
||||||
|
- **大内容外置纪律缺失**:工具输出有 truncate,但「大结论写盘+回复给路径」没有
|
||||||
|
|
||||||
|
### 大批量并行新命题(u-claw 亦未解,devflow 需原创)
|
||||||
|
- 多会话同时压缩互不感知(各自预算独立,全局 token 消耗无视图)
|
||||||
|
- 并行任务的信息投影(侧栏/仪表盘聚合 N 个会话的进行态)
|
||||||
|
- 调度面:优先级/抢占/停靠恢复已有底座(TaskBoundary/parked),缺**全局调度器**视角
|
||||||
|
|
||||||
|
## 三、补差优先级(P0→P2)
|
||||||
|
|
||||||
|
| 级 | 项 | 移植来源 | 落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0** | usage 真值采集(stream_options.include_usage 三口径) | u-claw #2 | 6 月 P1 设计直接复活;先有数据再谈判据 |
|
||||||
|
| **P0** | 压缩触发改 token 水位主判据(≈窗口 70%,体积兜底) | u-claw #1 | maybe_auto_compress 判据重构,依赖上项 |
|
||||||
|
| **P1** | 跨会话归档检索:/history 等价(关键词搜消息全文+摘要) | u-claw #5/#6 | ai_messages 全文索引(FTS5 或 LIKE 起步);检索指令+AI 检索指引 |
|
||||||
|
| **P1** | boundary/loop_state 写入闭环(代语义显式化) | — | 停靠/终局时填充;换代通知推送(u-claw #4 同款) |
|
||||||
|
| **P2** | 大内容外置纪律(system prompt 处理规则+out/ 目录约定) | u-claw #3 | prompt.rs 处理规则段+workspace 目录约定 |
|
||||||
|
| **P2** | 并行资源治理:全局 token 预算视图+多会话压缩协防 | 原创 | 调度器域,与 Linux 化路线(看板 10-18 远程容器方向)合流设计 |
|
||||||
|
|
||||||
|
## 四、与 Linux 化战略的衔接
|
||||||
|
|
||||||
|
上下文管理是第一门槛(用户拍板),Linux 化是最终形态。依赖链:**usage 真值 → 水位判据 → 代语义+归档检索 → 并行调度治理 → server 化无头运行**。前三步在现有 Tauri 架构内即可推进(P0 两项纯 Rust 域),后两步与远程容器/df-tunnel 线合流。建议节奏:P0 两项作下一批(devflow agent 领单),P1 随后,P2 与 Linux 化立项合并设计。
|
||||||
@@ -775,3 +775,12 @@
|
|||||||
- [x] M4-a 提示词回复风格纪律(反口水话,中英双语+测试断言)——✅ 22:11 AI-Ops 子代理补齐:prompt.rs 中英两版行为准则各加「回复风格/Response style」条款(结论先行/省客套复述收尾/要点优先/没问不展开/「省的是空话不是内容」);验收:system_prompt_zh/en_has_response_style_clause 2 passed(共 5 passed)
|
- [x] M4-a 提示词回复风格纪律(反口水话,中英双语+测试断言)——✅ 22:11 AI-Ops 子代理补齐:prompt.rs 中英两版行为准则各加「回复风格/Response style」条款(结论先行/省客套复述收尾/要点优先/没问不展开/「省的是空话不是内容」);验收:system_prompt_zh/en_has_response_style_clause 2 passed(共 5 passed)
|
||||||
- [ ] M4-b UX 小批(i18n 补键/token 化/aria/loaded/Esc/队列带图标记)——未动,留板
|
- [ ] M4-b UX 小批(i18n 补键/token 化/aria/loaded/Esc/队列带图标记)——未动,留板
|
||||||
- [x] **收尾令(用户 2026-10-18 21:41 飞书下达到 AI-Ops,转登记)**:M1 销账后——①按 G3 提交本批改动并 push origin main(推送令已拍板,限本批范围);②启动 dev 模式运行验证(重点验 M4 反口水话与 M1 恢复链);③完成信息经飞书推送用户。若 AI-Ops 监测到 M1 已销账而本条未执行,AI-Ops 代执行。——✅ 23:0x AI-Ops 执行完毕:四笔分组提交(05d0f89 上下文P1批/302c4bc 飞书闭环/25ec9cf 前端令牌批/fb83339 文档)已 push(3bb662f..fb83339);验收 cargo test 2281 passed 0 failed+vue-tsc EXIT 0;dev 已跑(vite:1420 HTTP 200);完成信息已飞书推送。
|
- [x] **收尾令(用户 2026-10-18 21:41 飞书下达到 AI-Ops,转登记)**:M1 销账后——①按 G3 提交本批改动并 push origin main(推送令已拍板,限本批范围);②启动 dev 模式运行验证(重点验 M4 反口水话与 M1 恢复链);③完成信息经飞书推送用户。若 AI-Ops 监测到 M1 已销账而本条未执行,AI-Ops 代执行。——✅ 23:0x AI-Ops 执行完毕:四笔分组提交(05d0f89 上下文P1批/302c4bc 飞书闭环/25ec9cf 前端令牌批/fb83339 文档)已 push(3bb662f..fb83339);验收 cargo test 2281 passed 0 failed+vue-tsc EXIT 0;dev 已跑(vite:1420 HTTP 200);完成信息已飞书推送。
|
||||||
|
|
||||||
|
### 💡 2026-10-19 上下文管理对标 u-claw·大批量并行差距(AI-Ops 分析,待领单)
|
||||||
|
> 用户战略令(10-18 飞书):DevFlow 上 Linux 替代 u-claw+CC,第一门槛=上下文管理三命题(丢记忆/历史召回/token 高效),大批量多会话并行是常态场景。详情:[02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md](../02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md)
|
||||||
|
- [ ] P0-A usage 真值采集:stream_options.include_usage 三口径(input/cache_read/cache_creation),6 月 P1 设计复活(当时暂缓,结构层已回退干净)
|
||||||
|
- [ ] P0-B 压缩触发改 token 水位主判据(≈窗口 70% 可配,体积兜底;u-claw 86e9216 同款,治压缩/请求比 4.8 偏频),依赖 P0-A
|
||||||
|
- [ ] P1-A 跨会话归档检索(/history 等价):消息全文搜索(FTS5 或 LIKE 起步)+检索指令+AI 检索指引进 sop
|
||||||
|
- [ ] P1-B boundary/loop_state 写入闭环(代语义显式化+换代通知推送)
|
||||||
|
- [ ] P2-A 大内容外置纪律(大结论写盘 out/+回复给路径,system prompt 处理规则)
|
||||||
|
- [ ] P2-B 并行资源治理(全局 token 预算视图/多会话压缩协防)——与 Linux 化/远程容器方向合流设计
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
);
|
);
|
||||||
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
|
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
|
||||||
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
|
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
|
||||||
keyword_fallback_commit(
|
let degraded = keyword_fallback_commit(
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
protect_start,
|
protect_start,
|
||||||
@@ -281,6 +281,16 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
recall_on.then_some(data_dir.as_path()),
|
recall_on.then_some(data_dir.as_path()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发)
|
||||||
|
if degraded {
|
||||||
|
emit_ai_chat_event(
|
||||||
|
&app_handle,
|
||||||
|
AiChatEvent::AiContextDegraded {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
reason: "压缩 provider 重建失败,已降级为关键词摘要".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -384,7 +394,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||||
KEYWORD_FALLBACK_ENABLED,
|
KEYWORD_FALLBACK_ENABLED,
|
||||||
);
|
);
|
||||||
keyword_fallback_commit(
|
let degraded = keyword_fallback_commit(
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
protect_start,
|
protect_start,
|
||||||
@@ -395,6 +405,17 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
recall_on.then_some(data_dir.as_path()),
|
recall_on.then_some(data_dir.as_path()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑,
|
||||||
|
// 用专用事件让前端透出降级状态条/提示(不打断流)
|
||||||
|
if degraded {
|
||||||
|
emit_ai_chat_event(
|
||||||
|
&app_handle,
|
||||||
|
AiChatEvent::AiContextDegraded {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
reason: "LLM 压缩失败,已降级为关键词摘要".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) // 闭 catch_unwind 内 async move
|
}) // 闭 catch_unwind 内 async move
|
||||||
@@ -444,11 +465,11 @@ async fn keyword_fallback_commit(
|
|||||||
log_head: &str,
|
log_head: &str,
|
||||||
head_id: &Option<String>,
|
head_id: &Option<String>,
|
||||||
recall: Option<&Path>,
|
recall: Option<&Path>,
|
||||||
) {
|
) -> bool {
|
||||||
let inserted = {
|
let inserted = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
if !session.per_conv.contains_key(conv_id) {
|
if !session.per_conv.contains_key(conv_id) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let conv = session.conv(conv_id);
|
let conv = session.conv(conv_id);
|
||||||
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
||||||
@@ -458,7 +479,7 @@ async fn keyword_fallback_commit(
|
|||||||
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
||||||
log_head
|
log_head
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
||||||
conv.messages.set_compressing(false);
|
conv.messages.set_compressing(false);
|
||||||
@@ -495,6 +516,7 @@ async fn keyword_fallback_commit(
|
|||||||
log_head
|
log_head
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -538,6 +538,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
.find(|m| {
|
.find(|m| {
|
||||||
matches!(m.role, df_ai::provider::MessageRole::User)
|
matches!(m.role, df_ai::provider::MessageRole::User)
|
||||||
&& !m.content.starts_with("[系统恢复]")
|
&& !m.content.starts_with("[系统恢复]")
|
||||||
|
&& !m.content.starts_with("[补充指令]")
|
||||||
&& m.content.chars().count() >= 4
|
&& m.content.chars().count() >= 4
|
||||||
})
|
})
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
|
|||||||
@@ -419,6 +419,18 @@ fn is_recovery_artifact(text: &str) -> bool {
|
|||||||
text.starts_with(RECOVERY_ARTIFACT_PREFIX)
|
text.starts_with(RECOVERY_ARTIFACT_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注入产物判定(E5):[补充指令] 是运行中用户插话的通道标记,非任务意图本身
|
||||||
|
/// (实证 434c8c40 active goal=「[补充指令] 123 hi」占位)。与恢复产物同治:
|
||||||
|
/// 载入侧滤出内存,下轮 save 重写即自愈落库。
|
||||||
|
fn is_injection_artifact(text: &str) -> bool {
|
||||||
|
text.starts_with("[补充指令]")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 引导产物总判定(恢复+注入),双侧(载入/回放)统一用。
|
||||||
|
fn is_guide_artifact(text: &str) -> bool {
|
||||||
|
is_recovery_artifact(text) || is_injection_artifact(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
|
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
|
||||||
///
|
///
|
||||||
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
|
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
|
||||||
@@ -435,7 +447,7 @@ pub(crate) fn build_recovery_message(
|
|||||||
let mut active: Vec<&str> = Vec::new();
|
let mut active: Vec<&str> = Vec::new();
|
||||||
for g in &working_context.goals {
|
for g in &working_context.goals {
|
||||||
if matches!(g.status, GoalStatus::Active)
|
if matches!(g.status, GoalStatus::Active)
|
||||||
&& !is_recovery_artifact(&g.text)
|
&& !is_guide_artifact(&g.text)
|
||||||
&& seen.insert(g.text.clone())
|
&& seen.insert(g.text.clone())
|
||||||
{
|
{
|
||||||
active.push(&g.text);
|
active.push(&g.text);
|
||||||
@@ -443,7 +455,7 @@ pub(crate) fn build_recovery_message(
|
|||||||
}
|
}
|
||||||
for g in pinned_goals {
|
for g in pinned_goals {
|
||||||
if matches!(g.status, crate::commands::ai::GoalStatus::Active)
|
if matches!(g.status, crate::commands::ai::GoalStatus::Active)
|
||||||
&& !is_recovery_artifact(&g.text)
|
&& !is_guide_artifact(&g.text)
|
||||||
&& seen.insert(g.text.clone())
|
&& seen.insert(g.text.clone())
|
||||||
{
|
{
|
||||||
active.push(&g.text);
|
active.push(&g.text);
|
||||||
@@ -504,7 +516,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::
|
|||||||
Some(crate::commands::ai::GoalEntry::new(text))
|
Some(crate::commands::ai::GoalEntry::new(text))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(|e| !is_recovery_artifact(&e.text))
|
.filter(|e| !is_guide_artifact(&e.text))
|
||||||
.collect(),
|
.collect(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
@@ -1961,4 +1973,18 @@ mod tests {
|
|||||||
let cleaned = serde_json::to_string(&parsed).unwrap();
|
let cleaned = serde_json::to_string(&parsed).unwrap();
|
||||||
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
|
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pinned_goals_drops_injection_artifacts() {
|
||||||
|
// E5:[补充指令] 是通道标记非任务意图(434c8c40「[补充指令] 123 hi」占位实证),
|
||||||
|
// 与恢复产物同治——载入侧剔除,下轮 save 重写自愈。
|
||||||
|
let polluted = serde_json::to_string(&vec![
|
||||||
|
crate::commands::ai::GoalEntry::new("[补充指令] 123 hi".to_string()),
|
||||||
|
crate::commands::ai::GoalEntry::new("升级工具链到最新版".to_string()),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let parsed = parse_pinned_goals(Some(&polluted));
|
||||||
|
assert_eq!(parsed.len(), 1, "注入产物条目须剔除: {parsed:?}");
|
||||||
|
assert_eq!(parsed[0].text, "升级工具链到最新版");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,11 @@ pub enum AiChatEvent {
|
|||||||
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
||||||
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||||
AiAutoCompressed { conversation_id: Option<String>, summary: String },
|
AiAutoCompressed { conversation_id: Option<String>, summary: String },
|
||||||
|
/// E2 压缩失败降级透出(2026-10-18):LLM 压缩失败走关键词摘要兜底时,loop 仍在继续
|
||||||
|
/// (不发 AiError——其 case 无条件 setStreaming(false) 会误判生成结束),但用户须知晓
|
||||||
|
/// 上下文已降级(实证 76730ce8 seq166:静默降级→AI 失忆→用户被迫原句重发)。
|
||||||
|
/// 前端弹一次性状态提示,不中断流。
|
||||||
|
AiContextDegraded { conversation_id: Option<String>, reason: String },
|
||||||
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||||
///
|
///
|
||||||
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ struct FailureEntry {
|
|||||||
last_error: String,
|
last_error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout
|
||||||
|
/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合:
|
||||||
|
/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀
|
||||||
|
/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。
|
||||||
|
static TOOL_TIMEOUT_STREAKS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// 超时连败软收敛阈值(跨签名聚合口径)。
|
||||||
|
pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3;
|
||||||
|
|
||||||
|
fn timeout_streaks() -> &'static Mutex<HashMap<String, u32>> {
|
||||||
|
TOOL_TIMEOUT_STREAKS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误摘要是否属超时族(中英特征,大小写不敏感)。
|
||||||
|
fn is_timeout_error(error: &str) -> bool {
|
||||||
|
let lower = error.to_lowercase();
|
||||||
|
lower.contains("超时") || lower.contains("timeout") || lower.contains("timed out")
|
||||||
|
}
|
||||||
|
|
||||||
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
|
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
|
||||||
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
|
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
|
||||||
|
|
||||||
@@ -70,34 +89,50 @@ fn sort_json(v: &serde_json::Value) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。
|
/// 执行前检查:同 key 连败达标 → 拦截;工具级超时连败达标 → 软收敛警告(不拦执行)。
|
||||||
/// 未达标/无记忆 → None 放行。
|
/// 两者都未达标/无记忆 → None 放行。
|
||||||
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
|
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
|
||||||
let key = memory_key(tool, args);
|
let key = memory_key(tool, args);
|
||||||
let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
let Some(entry) = mem.get(&key) else { return None };
|
if let Some(entry) = mem.get(&key) {
|
||||||
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
|
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
|
||||||
Some(format!(
|
return Some(format!(
|
||||||
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
|
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
|
||||||
entry.consecutive,
|
entry.consecutive,
|
||||||
entry.last_error.replace('\"', "'"),
|
entry.last_error.replace('\"', "'"),
|
||||||
entry.consecutive
|
entry.consecutive
|
||||||
))
|
));
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
// E13 工具级超时连败(跨签名聚合):换参数/换档位逃不出工具维度。
|
||||||
|
let streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if let Some(streak) = streaks.get(tool) {
|
||||||
|
if *streak >= TIMEOUT_STREAK_WARN_AFTER {
|
||||||
|
return Some(format!(
|
||||||
|
"{{\"status\":\"timeout_streak_warning\",\"tool\":\"{tool}\",\"timeout_streak\":{},\"hint\":\"该工具已连续超时 {} 次(不同参数/档位都超)。继续原路大概率仍超时:①把任务拆小分步执行 ②换更轻量的方式(降低数据量/改后台执行) ③若确实需要长时间运行,向用户说明并确认后再继续。\"}}",
|
||||||
|
streak, streak
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行后回写:成功清零删除,失败递增并记最近错误。
|
/// 执行后回写:成功清零删除(签名级+工具级超时计数),失败递增并记最近错误;
|
||||||
|
/// 失败属超时族时工具级超时连败 +1。
|
||||||
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
|
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
|
||||||
let key = memory_key(tool, args);
|
let key = memory_key(tool, args);
|
||||||
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let mut streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
if ok {
|
if ok {
|
||||||
mem.remove(&key);
|
mem.remove(&key);
|
||||||
|
streaks.remove(tool);
|
||||||
} else {
|
} else {
|
||||||
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
|
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
|
||||||
e.consecutive += 1;
|
e.consecutive += 1;
|
||||||
e.last_error = error.unwrap_or_default().chars().take(200).collect();
|
e.last_error = error.unwrap_or_default().chars().take(200).collect();
|
||||||
|
if is_timeout_error(&e.last_error) {
|
||||||
|
*streaks.entry(tool.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,4 +180,38 @@ mod tests {
|
|||||||
let a = json!({"command": "cargo test", "working_dir": "E:/x"});
|
let a = json!({"command": "cargo test", "working_dir": "E:/x"});
|
||||||
assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
|
assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeout_streak_aggregates_across_signatures_and_soft_warns() {
|
||||||
|
clear();
|
||||||
|
timeout_streaks().lock().unwrap().clear();
|
||||||
|
// 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦;
|
||||||
|
// 工具级 streak 在第 3 次超时发生后达 3,同工具再调用(含当次迭代)即软警告
|
||||||
|
for (i, timeout) in ["60", "300", "600"].iter().enumerate() {
|
||||||
|
let args = json!({"command": format!("cargo build --timeout {timeout}")});
|
||||||
|
record("run_command", &args, false, Some(format!("命令执行超时({timeout}s)")));
|
||||||
|
if i < 2 {
|
||||||
|
assert!(precheck("run_command", &args).is_none(), "签名级 1 次且 streak<3 不拦");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 新签名(第 4 档):工具级超时连败 ≥3 → 软收敛警告(放行执行但注入换路引导)
|
||||||
|
let args4 = json!({"command": "cargo build --timeout 900"});
|
||||||
|
let warn = precheck("run_command", &args4).unwrap();
|
||||||
|
assert!(warn.contains("timeout_streak_warning"));
|
||||||
|
assert!(warn.contains("\"timeout_streak\":3"));
|
||||||
|
// 同工具任一成功 → 工具级清零
|
||||||
|
record("run_command", &json!({"command": "ls"}), true, None);
|
||||||
|
assert!(precheck("run_command", &args4).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_timeout_failures_do_not_aggregate_to_timeout_streak() {
|
||||||
|
clear();
|
||||||
|
timeout_streaks().lock().unwrap().clear();
|
||||||
|
for path in ["/a.rs", "/b.rs", "/c.rs"] {
|
||||||
|
record("read_file", &json!({"path": path}), false, Some("文件不存在".into()));
|
||||||
|
}
|
||||||
|
// 3 连败但非超时族 → 无工具级警告
|
||||||
|
assert!(precheck("read_file", &json!({"path": "/d.rs"})).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,9 @@ export type AiChatEvent = ({
|
|||||||
} | {
|
} | {
|
||||||
// 上下文已清空:前端刷新消息列表 + toast 提示
|
// 上下文已清空:前端刷新消息列表 + toast 提示
|
||||||
type: 'AiContextCleared'
|
type: 'AiContextCleared'
|
||||||
|
} | {
|
||||||
|
// E2 压缩失败降级(关键词摘要兜底,loop 仍在跑):前端 toast 提示不中断流
|
||||||
|
type: 'AiContextDegraded'; reason: string
|
||||||
}) & {
|
}) & {
|
||||||
conversation_id?: ConvId
|
conversation_id?: ConvId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -698,6 +698,10 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
onError: (_convId, message) => {
|
onError: (_convId, message) => {
|
||||||
showToast(t('aiChat.compressError', { msg: message }), 'error')
|
showToast(t('aiChat.compressError', { msg: message }), 'error')
|
||||||
},
|
},
|
||||||
|
// E2 压缩失败降级:仅提示不刷新不打断流(loop 仍在跑,实证静默降级致用户原句重发)
|
||||||
|
onContextDegraded: (_convId, reason) => {
|
||||||
|
showToast(t('aiChat.contextDegraded', { reason }), 'warning')
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ async function initContextListener(callbacks: {
|
|||||||
onCleared?: (conversationId: string) => void
|
onCleared?: (conversationId: string) => void
|
||||||
onCompressed?: (conversationId: string, summary: string) => void
|
onCompressed?: (conversationId: string, summary: string) => void
|
||||||
onError?: (conversationId: string, message: string) => void
|
onError?: (conversationId: string, message: string) => void
|
||||||
|
onContextDegraded?: (conversationId: string, reason: string) => void
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
setContextCallbacks({
|
setContextCallbacks({
|
||||||
onCompressing: () => { isCompressing.value = true },
|
onCompressing: () => { isCompressing.value = true },
|
||||||
@@ -62,6 +63,11 @@ async function initContextListener(callbacks: {
|
|||||||
autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000)
|
autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000)
|
||||||
},
|
},
|
||||||
onCleared: (convId) => callbacks.onCleared?.(convId),
|
onCleared: (convId) => callbacks.onCleared?.(convId),
|
||||||
|
onContextDegraded: (convId, reason) => {
|
||||||
|
// E2:压缩失败降级为关键词摘要兜底——复位 loading + 上抛 toast(不中断 LLM 流)
|
||||||
|
isCompressing.value = false
|
||||||
|
callbacks.onContextDegraded?.(convId, reason)
|
||||||
|
},
|
||||||
onError: (convId, message) => {
|
onError: (convId, message) => {
|
||||||
// AiError 也用于流式生成失败(useAiEvents 已处理);此处仅在压缩中时复位 + toast,
|
// AiError 也用于流式生成失败(useAiEvents 已处理);此处仅在压缩中时复位 + toast,
|
||||||
// 避免双重处理流式错误。
|
// 避免双重处理流式错误。
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface ContextCallbacks {
|
|||||||
onCleared?: (conversationId: string) => void
|
onCleared?: (conversationId: string) => void
|
||||||
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
|
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
|
||||||
onError?: (conversationId: string, message: string) => void
|
onError?: (conversationId: string, message: string) => void
|
||||||
|
/** E2 压缩失败降级(AiContextDegraded):loop 仍在跑,仅提示上下文已降级不中断流 */
|
||||||
|
onContextDegraded?: (conversationId: string, reason: string) => void
|
||||||
}
|
}
|
||||||
let _contextCallbacks: ContextCallbacks = {}
|
let _contextCallbacks: ContextCallbacks = {}
|
||||||
|
|
||||||
@@ -344,6 +346,11 @@ export async function handleLifecycleEvent(event: AiChatEvent): Promise<boolean>
|
|||||||
_contextCallbacks.onCleared?.(event.conversation_id || '')
|
_contextCallbacks.onCleared?.(event.conversation_id || '')
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
case 'AiContextDegraded':
|
||||||
|
// E2:压缩失败已走关键词摘要兜底,loop 继续——透出降级提示而非静默失忆
|
||||||
|
_contextCallbacks.onContextDegraded?.(event.conversation_id || '', event.reason || '')
|
||||||
|
return true
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ export default {
|
|||||||
// B4: one-shot auto-compress notice (loop auto-compress no longer fully silent)
|
// B4: one-shot auto-compress notice (loop auto-compress no longer fully silent)
|
||||||
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
|
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
|
||||||
compressError: 'Compression failed: {msg}',
|
compressError: 'Compression failed: {msg}',
|
||||||
|
contextDegraded: 'Context degraded: {reason} — the AI may be missing some earlier details',
|
||||||
clearError: 'Clear failed: {msg}',
|
clearError: 'Clear failed: {msg}',
|
||||||
initError: 'AI panel init failed: {msg} (reopen panel to retry)',
|
initError: 'AI panel init failed: {msg} (reopen panel to retry)',
|
||||||
initDegraded: 'List load failed, continuing in degraded mode: {msg}',
|
initDegraded: 'List load failed, continuing in degraded mode: {msg}',
|
||||||
|
|||||||
@@ -276,6 +276,8 @@ export default {
|
|||||||
// B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默)
|
// B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默)
|
||||||
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
|
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
|
||||||
compressError: '压缩失败:{msg}',
|
compressError: '压缩失败:{msg}',
|
||||||
|
// E2:压缩失败走关键词摘要兜底,loop 继续——提示降级而非静默失忆
|
||||||
|
contextDegraded: '上下文已降级:{reason},AI 可能缺失部分早前细节',
|
||||||
clearError: '清空失败:{msg}',
|
clearError: '清空失败:{msg}',
|
||||||
initError: 'AI 面板初始化失败:{msg}(重开面板可重试)',
|
initError: 'AI 面板初始化失败:{msg}(重开面板可重试)',
|
||||||
initDegraded: '列表加载失败,面板降级继续:{msg}',
|
initDegraded: '列表加载失败,面板降级继续:{msg}',
|
||||||
|
|||||||
+3
-1
@@ -23,7 +23,9 @@ export default defineConfig(async () => ({
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ["**/src-tauri/**"],
|
// target/ 同理须忽略:cargo 编译写 .o/.rlib 时 watcher 触发 EBUSY(文件被写锁),
|
||||||
|
// chokidar 直接抛异常杀掉 dev 进程(2026-10-18 实证:watch sqlite3.o 崩溃)
|
||||||
|
ignored: ["**/src-tauri/**", "**/target/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user