From 378c363632dcd556f86f64a61e0fa53eb3acac8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 29 Sep 2026 13:33:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E7=BA=A7=E9=87=87=E6=A0=B7=E6=B8=A9=E5=BA=A6=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 受限模型(经路由透传)仅接受特定 temperature 其它值 400; provider config JSON 带 temperature 字段则覆盖默认,未配置走 0.7,设置页可编辑。 --- src-tauri/src/commands/ai/agentic/mod.rs | 133 +++++++++++++++--- .../src/commands/ai/commands/provider.rs | 10 +- src/components/settings/ProviderPanel.vue | 25 ++++ src/i18n/en/settings.ts | 2 + src/i18n/zh-CN/settings.ts | 2 + 5 files changed, 155 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 923d0da..88e55c8 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -342,7 +342,7 @@ use context_lifecycle::maybe_auto_compress; mod title_lifecycle; #[allow(unused_imports)] mod knowledge_lifecycle; -mod workflow_context; +pub(crate) mod workflow_context; // ============================================================ // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 @@ -360,7 +360,10 @@ pub mod command_lock; pub(crate) mod helpers; pub(crate) use helpers::try_continue_agent_loop; pub(crate) use helpers::infer_goal_from_tool_calls; -pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration, is_pure_greeting, is_tool_call_over_limit}; +pub(crate) use helpers::{ + tool_call_signature, is_repetitive_exploration, is_pure_greeting, is_tool_call_over_limit, + DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop, +}; // ============================================================ // 单 Provider 流式结果 + fallback 辅助 @@ -490,11 +493,21 @@ async fn stream_with_retry( resolved_model: &str, last_reasoning_content: &Option, ) -> StreamOutcome { + // 供应商级采样温度覆盖:provider config JSON 带 "temperature" 字段则用之, + // 治受限模型(如 Kimi 系经 u-switch 透传)仅接受 temperature=1、其它值 400 的问题; + // 未配置/非法 JSON 走默认 0.7。candidate 在重试循环内不变,解析提到循环外只做一次。 + let temperature = candidate + .config + .as_deref() + .and_then(|cfg| serde_json::from_str::(cfg).ok()) + .and_then(|v| v.get("temperature").and_then(|t| t.as_f64())) + .map(|t| t as f32) + .or(Some(0.7)); for retry_attempt in 0..=max_retries { let retry_request = CompletionRequest { model: resolved_model.to_string(), messages: messages.to_vec(), - temperature: Some(0.7), + temperature, max_tokens: Some(8192), stream: true, tools: if tool_defs.is_empty() { None } else { Some(tool_defs.to_vec()) }, @@ -1367,6 +1380,11 @@ pub(crate) async fn run_agentic_loop( let mut stall_count: u32 = 0; let mut stall_warned: bool = false; + // G2 破坏性回环计数器(独立于 stall_count,互不干扰):写删同路径回环对检出累计, + // 治「写完就删」自毁循环(签名范式对写删交替漏网)。警示/阈值复用 STALL_BREAKER_*。 + let mut loop_count: u32 = 0; + let mut loop_warned: bool = false; + // 目标验证续轮计数(GOAL_VERIFY_ENABLED 门控):末轮无 tool_calls 但 pinned_goals 未达成 // 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛 // + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。 @@ -2079,12 +2097,14 @@ pub(crate) async fn run_agentic_loop( // G2 探索熔断:连续空结果无进展检查。抽至 check_stall_breaker helper(扁平重构)。 // 返回 true=已熔断(loop 应 return),false=继续。stall_count/stall_warned 传入传出。 if STALL_BREAKER_ENABLED { - let (tripped, new_count, new_warned) = check_stall_breaker( + let (tripped, new_count, new_warned, new_loop_count, new_loop_warned) = check_stall_breaker( &session_arc, &app_handle, &conv_id, iteration, - stall_count, stall_warned, + stall_count, stall_warned, loop_count, loop_warned, ).await; stall_count = new_count; stall_warned = new_warned; + loop_count = new_loop_count; + loop_warned = new_loop_warned; if tripped { guard.reset().await; return; @@ -2533,15 +2553,20 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count } // ── check_stall_breaker: G2 探索熔断检查(扁平抽自原嵌套 6 层块) ── -// 返回 (tripped, new_stall_count, new_stall_warned): +// 返回 (tripped, new_stall_count, new_stall_warned, new_loop_count, new_loop_warned): // - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return) -// - tripped=false → 正常继续,新 stall_count/warned 供调用方回写。 +// - tripped=false → 正常继续,新 stall/loop 计数与警示位供调用方回写。 // // 2026-08-01 根本性重构:判定从「末尾 Tool 结果是否空(is_empty_tool_result)」改为 // 「最近 N 个 assistant tool_calls 签名是否重复(tool_call_signature + is_repetitive_exploration)」。 // 治旧范式误熔断正常排除式搜索(grep 无匹配是有效排除信号,非漂移)。熔断骨架(计数/警示/ // 阈值/emit/return)不变,只换判定输入。 // +// 2026-09-29 补破坏性回环检测:签名范式对「写完就删」自毁循环漏网(write_file/delete_file +// 走签名兜底分支,写删交替每步签名都不同,唯一率≈0.83 不触发)。采样处并行维护 +// (工具, 路径) 结构化缓冲喂 detect_destructive_loop,独立计数器与 stall 并存互不干扰, +// 两段式(警示/阈值)复用 STALL_BREAKER_WARN_FIRST/THRESHOLD。 +// // 嵌套 ≤ 3 层:取签名→计数/警示→达阈值 emit,各段早返回。 async fn check_stall_breaker( session_arc: &Arc>, @@ -2550,11 +2575,14 @@ async fn check_stall_breaker( iteration: usize, stall_count: u32, stall_warned: bool, -) -> (bool, u32, bool) { + loop_count: u32, + loop_warned: bool, +) -> (bool, u32, bool, u32, bool) { // 1) 取最近 N 个 assistant tool_calls 签名:倒序扫 messages,遇 Assistant 且有 tool_calls // 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。 // arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。 - let signatures: Vec = { + // 同时并行抽取回环样本(写/删工具记路径,其他占位),两缓冲同序位对齐。 + let (signatures, loop_samples): (Vec, Vec) = { let session = session_arc.lock().await; // BE2只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用, // 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。 @@ -2563,6 +2591,7 @@ async fn check_stall_breaker( None => Vec::new(), }; let mut sigs: Vec = Vec::new(); + let mut loops: Vec = Vec::new(); for m in messages.iter().rev() { if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE { break; @@ -2579,18 +2608,20 @@ async fn check_stall_breaker( let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null); sigs.push(tool_call_signature(&tc.function.name, &args)); + loops.push(destructive_loop_sample(&tc.function.name, &args)); } } } // 倒序收集的签名需反转为时间正序,保持 is_repetitive_exploration 的语义稳定 - // (虽纯函数对顺序不敏感,但反转后日志/调试更直观)。 + // (虽纯函数对顺序不敏感,但反转后日志/调试更直观)。回环检测依赖序位间隔,必须正序。 sigs.reverse(); - sigs + loops.reverse(); + (sigs, loops) }; - // 本轮无工具调用(纯文本轮或首轮)→ 不计入也不重置,保持 stall_count。 + // 本轮无工具调用(纯文本轮或首轮)→ 不计入也不重置,保持 stall_count/loop_count。 if signatures.is_empty() { - return (false, stall_count, stall_warned); + return (false, stall_count, stall_warned, loop_count, loop_warned); } // 2) 签名重复判定 → 更新 stall_count / stall_warned(沿用原 reset 语义) @@ -2602,6 +2633,16 @@ async fn check_stall_breaker( new_warned = false; } + // 2.5) 破坏性回环判定(独立计数器):窗口内有同路径写删回环对 → 累计,无 → 清零重来。 + let (mut new_loop_count, mut new_loop_warned) = (loop_count, loop_warned); + let loop_hit_path = detect_destructive_loop(&loop_samples); + if loop_hit_path.is_some() { + new_loop_count += 1; + } else { + new_loop_count = 0; + new_loop_warned = false; + } + // 3) 两段式警示:达 THRESHOLD-1 首次警示(给 LLM 自纠机会) if STALL_BREAKER_WARN_FIRST && new_count == STALL_BREAKER_THRESHOLD.saturating_sub(1) @@ -2611,9 +2652,40 @@ async fn check_stall_breaker( insert_stall_warning(session_arc, conv_id, iteration, new_count).await; } - // 4) 熔断:达 THRESHOLD → emit AiHelpRequired + 返回 tripped=true + // 3.5) 回环两段式警示(对齐 stall WARN_FIRST,独立警示位) + if STALL_BREAKER_WARN_FIRST + && new_loop_count == STALL_BREAKER_THRESHOLD.saturating_sub(1) + && !new_loop_warned + { + new_loop_warned = true; + insert_destructive_loop_warning(session_arc, conv_id, iteration, new_loop_count, loop_hit_path.as_deref()).await; + } + + // 4) 回环熔断:独立达 THRESHOLD → emit(与 stall 熔断谁先达谁触发) + if new_loop_count >= STALL_BREAKER_THRESHOLD { + tracing::warn!( + conv_id = %conv_id, + loop_count = new_loop_count, + hit_path = %loop_hit_path.as_deref().unwrap_or("?"), + sample_size = loop_samples.len(), + "[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count + ); + let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径"); + let _ = app_handle.emit( + "ai-chat-event", + AiChatEvent::AiHelpRequired { + reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc), + context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(), + options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], + conversation_id: Some(conv_id.to_string()), + }, + ); + return (true, new_count, new_warned, new_loop_count, new_loop_warned); + } + + // 5) stall 熔断:达 THRESHOLD → emit AiHelpRequired + 返回 tripped=true if new_count < STALL_BREAKER_THRESHOLD { - return (false, new_count, new_warned); + return (false, new_count, new_warned, new_loop_count, new_loop_warned); } tracing::warn!( conv_id = %conv_id, @@ -2630,7 +2702,36 @@ async fn check_stall_breaker( conversation_id: Some(conv_id.to_string()), }, ); - (true, new_count, new_warned) + (true, new_count, new_warned, new_loop_count, new_loop_warned) +} + +// ── insert_destructive_loop_warning: 破坏性回环软提示 insert system 消息(对齐 insert_stall_warning) ── +async fn insert_destructive_loop_warning( + session_arc: &Arc>, + conv_id: &str, + iteration: usize, + loop_count: u32, + path: Option<&str>, +) { + let goal_text = if STALL_BREAKER_GOAL_REMIND { + fetch_goal_summary(session_arc, conv_id).await + } else { + String::new() + }; + let path_desc = path.unwrap_or("同一文件"); + let warn_text = format!( + "⚠ 检测到对 {} 反复写删(写完又删的回环),可能在销毁自己的工作成果{},请回顾目标停止删除或换思路。", + path_desc, goal_text + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.insert_at(0, ChatMessage::system(&warn_text)); + tracing::info!( + conv_id = %conv_id, iteration, loop_count, + "[ai] G2 破坏性回环:写删回环警示已 insert(软提示,给 LLM 自纠机会)" + ); + } } // ── insert_stall_warning: G2 软提示 insert system 消息(给 LLM 自纠机会) ── diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs index 4b14009..ad16f19 100644 --- a/src-tauri/src/commands/ai/commands/provider.rs +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -97,6 +97,11 @@ pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, @@ -107,6 +112,7 @@ pub async fn ai_save_provider( default_model: String, provider_type: String, model_configs: Vec, + temperature_override: Option, ) -> Result { // 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 @@ -178,7 +184,9 @@ pub async fn ai_save_provider( models: None, model_configs, is_default, - config: None, + // 采样温度覆盖非空才写 config JSON;None 保持 config=None(config 列历史恒 None 无旧数据,直接覆盖安全) + config: temperature_override + .map(|t| serde_json::json!({ "temperature": t }).to_string()), created_at, updated_at: now_millis(), // c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 diff --git a/src/components/settings/ProviderPanel.vue b/src/components/settings/ProviderPanel.vue index 76ad5fe..b2dc664 100644 --- a/src/components/settings/ProviderPanel.vue +++ b/src/components/settings/ProviderPanel.vue @@ -101,6 +101,12 @@ Gemini 2.5→3.5 等),提示用户点「测试连接」拉真实模型列表覆盖,降低踩过时默认值的困惑。 -->
{{ $t('settings.defaultModelHint') }}
+ +
+ + +
@@ -221,6 +227,8 @@ const providerForm = reactive({ baseUrl: '', apiKey: '', defaultModel: '', + // 供应商级采样温度覆盖(null=未配置,保存不带该参,主链路走默认 0.7;清空输入可能残留 '' ,保存时防御过滤) + temperature: null as number | null, // 项 D:厂商预设下拉当前值(__custom=自定义;命中预设 id 时仅作展示与回填源头, // 实际字段值由 onPresetChange 写入;用户手改任一字段后 presetId 不自动复位, // 避免编辑态抖动,提交时也不读 presetId —— 以表单实际字段值为准)。 @@ -286,6 +294,7 @@ function formBusinessSnapshot(): string { baseUrl: providerForm.baseUrl, apiKey: providerForm.apiKey, defaultModel: providerForm.defaultModel, + temperature: providerForm.temperature, models: providerForm.models.map(m => ({ model_id: m.model_id, enabled: m.enabled, @@ -385,6 +394,16 @@ function openProviderForm(p?: AiProviderConfig) { providerForm.apiKey = '' // 编辑不回填 api_key(list 返回 mask,留空=不改,FR-S1) providerForm.defaultModel = p.default_model providerForm.providerType = p.provider_type || 'openai_compat' + // 从 config JSON 解析采样温度回显(config 可能为 null/非法 JSON,防御处理兜底 null) + providerForm.temperature = (() => { + try { + const cfg = p.config ? JSON.parse(p.config) : null + const tv = cfg?.temperature + return typeof tv === 'number' && Number.isFinite(tv) ? tv : null + } catch { + return null + } + })() // 项 D:编辑态不反推预设(避免用户已手改 baseUrl/model 却被下拉误标成某预设误导); // 统一标"自定义",下拉仅作新建快捷填充入口。 providerForm.presetId = CUSTOM_PRESET_ID @@ -399,6 +418,7 @@ function openProviderForm(p?: AiProviderConfig) { providerForm.defaultModel = '' providerForm.presetId = CUSTOM_PRESET_ID providerForm.models = [] + providerForm.temperature = null } providerForm.fetching = false providerForm.saveError = null // 重开表单清旧错误横幅 @@ -525,6 +545,11 @@ async function saveProvider() { apiKey: providerForm.apiKey, defaultModel: providerForm.defaultModel, modelConfigs: providerForm.models, + // 采样温度非空且为有效数字才带(清空输入 v-model.number 可能残留 ''/NaN,不过滤会污染后端 config) + temperatureOverride: + typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature) + ? providerForm.temperature + : undefined, }) // 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast providerForm.saveError = null diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index d7c2793..0f05746 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -49,6 +49,8 @@ export default { labelBaseUrl: 'Base URL', labelApiKey: 'API Key', labelDefaultModel: 'Default', + labelSamplingTemp: 'Sampling temperature', + phSamplingTemp: 'default 0.7', phProviderName: 'e.g. DeepSeek, GLM-4', phBaseUrl: 'https://api.deepseek.com', phApiKey: 'sk-...', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index 2fced79..f703286 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -49,6 +49,8 @@ export default { labelBaseUrl: 'Base URL', labelApiKey: 'API Key', labelDefaultModel: '默认模型', + labelSamplingTemp: '采样温度', + phSamplingTemp: '默认 0.7', phProviderName: '如 DeepSeek、GLM-4', phBaseUrl: 'https://api.deepseek.com', phApiKey: 'sk-...',