优化: 弱模型工具行为治理(项目/任务清单补id + 缓存命中警告 + read_symbol 提示)

This commit is contained in:
lxy
2026-08-08 18:11:21 +08:00
parent 3dfa043bb6
commit 6aa334fc9b
5 changed files with 100 additions and 45 deletions
+13 -8
View File
@@ -15,7 +15,7 @@ use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
use super::diff::build_write_file_diff;
use super::reason::build_approval_reason;
use super::record::audit_tool_call;
@@ -320,29 +320,34 @@ pub(super) async fn handle_approval_tool(
// ── Step 3: F-05 高危去重缓存(仅 High) ──
if matches!(risk_level, RiskLevel::High) {
// find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status,
// 返回 (cached_content, status) 时不持锁
if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await {
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
// 返回 (cached_content, status, hit_count) 时不持锁
if let Some((cached, status, hit_count)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await {
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
// AC-1 根治:同只读缓存,前置「重复调用拦截」警告头,弱模型才知道这是缓存结果,
// 不会再次重试同命令(防「超时→重试→重新审批」循环反复触发)。
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached);
tracing::info!(
tool = %draft.name,
new_tool_call_id = %draft.id,
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
hit_count,
"[F-05] 高危工具去重命中(第 {} 次):LLM 重试同命令,复用缓存结果跳过审批(断循环)",
hit_count
);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
}
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(cached.clone()),
result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failedSW-260618-16decided_by=auto_dedup),不进 pending
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await;
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
return;
}
}
+53 -22
View File
@@ -46,6 +46,35 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
df_ai::context_helpers::is_pending_placeholder(content)
}
/// 缓存命中时前置的「重复调用拦截」警告头。
///
/// 治弱模型(尤其小参数模型)死循环重调同参工具:LLM 只能看到 tool_result,
/// prompt 层说教无效,必须在结果里机制化显式警告。仅真命中缓存才加(首次真执行不加),
/// 命中次数 hit_count 累计「第 N 次」,数字递增对弱模型更有威慑,促其换参数/换工具。
/// 返回的字符串以换行结尾,调用方拼接缓存结果原样附后。
pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String {
format!(
"[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n",
tool_name, hit_count
)
}
/// namespace 引用展开为真实内容(缓存回填前置)。
///
/// 大工具结果(read_file/list_directory/grep 等)存 namespace 引用(ns://tool/key)于消息,
/// 发 LLM 前由 agentic 侧按「内容以 namespace:// 开头」展开。缓存命中回填要前置警告头,
/// 若仍原样带引用拼在警告后,前缀判定失效致 LLM 只看到无意义 URI。故此处先展开:
/// 引用 → 真实内容;引用被 LRU 淘汰 → EVICTED_PLACEHOLDER 提示;非引用 → 原样。
fn resolve_namespace_content(session: &AiSession, content: &str) -> String {
if df_ai::namespace_store::is_namespace_ref(content) {
session.namespace_store.read_only(content)
.map(str::to_owned)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
} else {
content.to_string()
}
}
/// 高危工具去重(根治 run_command 超时→重试→重新审批循环)。
///
/// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具
@@ -78,7 +107,7 @@ pub(crate) async fn find_cached_high_risk_result(
audit_repo: &AiToolExecutionRepo,
tool_name: &str,
args: &serde_json::Value,
) -> Option<(String, String)> {
) -> Option<(String, String, u32)> {
use df_ai::provider::MessageRole;
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
@@ -87,7 +116,7 @@ pub(crate) async fn find_cached_high_risk_result(
// 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
let cached: Option<(String, String)> = (async {
let cached: Option<(String, String, u32)> = (async {
let session = session_arc.lock().await;
// 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
@@ -96,9 +125,11 @@ pub(crate) async fn find_cached_high_risk_result(
// 单对话消息量小(百级),collect 开销可忽略。
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
// 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
let mut prev_tool_call_id: Option<String> = None;
let mut hit_count: u32 = 0;
for msg in msgs.iter().rev() {
if !matches!(msg.role, MessageRole::Assistant) {
continue;
@@ -113,13 +144,12 @@ pub(crate) async fn find_cached_high_risk_result(
continue;
};
if canonical_args_key(&old_args) == new_args_key {
prev_tool_call_id = Some(tc.id.clone());
break;
if prev_tool_call_id.is_none() {
prev_tool_call_id = Some(tc.id.clone());
}
hit_count += 1;
}
}
if prev_tool_call_id.is_some() {
break;
}
}
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
@@ -141,21 +171,21 @@ pub(crate) async fn find_cached_high_risk_result(
if is_pending_placeholder(&msg.content) {
return None;
}
return Some((old_id, msg.content.clone()));
return Some((old_id, resolve_namespace_content(&session, &msg.content), hit_count));
}
None
}).await;
// 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
let (old_id, content) = cached?;
let (old_id, content, hit_count) = cached?;
let status = audit_repo
.find_by_tool_call_id(old_id.as_str())
.await
.ok()
.and_then(|opt| opt.map(|rec| rec.status))
.unwrap_or_else(|| "completed".to_string());
Some((content, status))
Some((content, status, hit_count))
}
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
@@ -195,7 +225,7 @@ pub(crate) async fn find_cached_readonly_result(
audit_repo: &AiToolExecutionRepo,
tool_name: &str,
args: &serde_json::Value,
) -> Option<String> {
) -> Option<(String, u32)> {
use df_ai::provider::MessageRole;
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
@@ -206,13 +236,15 @@ pub(crate) async fn find_cached_readonly_result(
let new_args_key = canonical_args_key(args);
// 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
let cached: Option<(String, String)> = (async {
let cached: Option<(String, String, u32)> = (async {
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return None };
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id,
// 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。
let mut prev_tool_call_id: Option<String> = None;
let mut hit_count: u32 = 0;
for msg in msgs.iter().rev() {
if !matches!(msg.role, MessageRole::Assistant) {
continue;
@@ -226,13 +258,12 @@ pub(crate) async fn find_cached_readonly_result(
continue;
};
if canonical_args_key(&old_args) == new_args_key {
prev_tool_call_id = Some(tc.id.clone());
break;
if prev_tool_call_id.is_none() {
prev_tool_call_id = Some(tc.id.clone());
}
hit_count += 1;
}
}
if prev_tool_call_id.is_some() {
break;
}
}
// 2) 用旧 tool_call_id 找对应 tool_result
@@ -248,12 +279,12 @@ pub(crate) async fn find_cached_readonly_result(
if is_pending_placeholder(&msg.content) {
return None;
}
return Some((old_id, msg.content.clone()));
return Some((old_id, resolve_namespace_content(&session, &msg.content), hit_count));
}
None
}).await;
// 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会)
let (old_id, content) = cached?;
let (old_id, content, hit_count) = cached?;
let status = audit_repo
.find_by_tool_call_id(old_id.as_str())
.await
@@ -263,7 +294,7 @@ pub(crate) async fn find_cached_readonly_result(
if status != "completed" {
return None;
}
Some(content)
Some((content, hit_count))
}
/// 可缓存的只读幂等工具白名单。
+12 -6
View File
@@ -67,7 +67,7 @@ pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。
mod cache;
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, pending_placeholder_for};
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
@@ -586,13 +586,17 @@ pub(crate) async fn process_tool_calls(
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
for (draft, args, risk_level) in low_risk {
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
if let Some(cached_content) = cached {
if let Some((cached_content, hit_count)) = cached {
// 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源),
// 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)。
// AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的
// tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result),
// 命中次数递增威慑,告知勿再重复调用相同参数的工具。
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content);
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(cached_content.clone()),
result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
@@ -600,18 +604,20 @@ pub(crate) async fn process_tool_calls(
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached_content));
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
}
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(cached_content), Some("cache_hit"),
"completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id,
).await;
tracing::info!(
conv_id = %conv_id,
tool = %draft.name,
tc_id = %draft.id,
"[ai] 只读工具缓存命中,跳过真执行(治 LLM 死循环重调)"
hit_count,
"[ai] 只读工具缓存命中(第 {} 次),已加警告头回填,跳过真执行(治 LLM 死循环重调)",
hit_count
);
} else {
low_risk_uncached.push((draft, args, risk_level));
+8 -8
View File
@@ -249,8 +249,8 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 name/status/description(无 path),
/// 供 LLM 知道当前存在哪些实体(非精准引用)。
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path),
/// 供 LLM 知道当前存在哪些实体并拿到各自 id(清单含 id,引用时用 id 而非名称)。
///
/// **被@实体的精准投影**(含 path 脱敏、剥 frontmatter 的 skill 正文)由 chat.rs 的
/// augmentation 层(`ResolverRegistry::resolve_all` + `build_augmentation_segment`)处理,
@@ -301,7 +301,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_project_ids.iter().any(|id| id == &p.id) {
continue;
}
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description));
prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description));
if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir));
}
@@ -320,7 +320,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue;
}
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description));
prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description));
}
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang));
@@ -349,11 +349,11 @@ pub(crate) async fn build_system_prompt_with_excluded(
fn projects_listed_note(lang: &str, shown: usize) -> String {
match lang {
"en" => format!(
"(Listed {} active projects above; the active set is shown, no need to call list_projects again.)\n",
"(Listed {} active projects above; the active set is shown, no need to call list_projects again. Each entry carries its id — reference projects by id, not by name.)\n",
shown
),
_ => format!(
"(以上为活跃项目清单,无需重复调用 list_projects)\n"
"(以上为活跃项目清单,无需重复调用 list_projects;每条已含 id,引用项目时用 id 而非名称)\n"
),
}
}
@@ -364,8 +364,8 @@ fn projects_listed_note(lang: &str, shown: usize) -> String {
/// 而反复调 list_tasks 校验(实测单会话 20 次)。
fn tasks_listed_note(lang: &str) -> String {
match lang {
"en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool.)\n".to_string(),
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具)\n".to_string(),
"en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool. Each entry carries its id — reference tasks by id, not by title/name.)\n".to_string(),
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具;每条已含 id,引用任务时用 id 而非名称)\n".to_string(),
}
}
+14 -1
View File
@@ -182,8 +182,21 @@ pub fn register(
consumed,
))
} else { None };
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
let content_field = if offset_used.is_none()
&& crate::commands::ai::code_intel::is_supported_ext(
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
)
{
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
} else {
result
};
Ok(serde_json::json!({
"path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
"path": path, "content": content_field, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
"offset": offset_used,
"returned_lines": returned_lines,
"truncated": has_more,