优化: 供应商级采样温度覆盖配置
受限模型(经路由透传)仅接受特定 temperature 其它值 400; provider config JSON 带 temperature 字段则覆盖默认,未配置走 0.7,设置页可编辑。
This commit is contained in:
@@ -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<String>,
|
||||
) -> 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::<serde_json::Value>(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<Mutex<AiSession>>,
|
||||
@@ -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<String> = {
|
||||
// 同时并行抽取回环样本(写/删工具记路径,其他占位),两缓冲同序位对齐。
|
||||
let (signatures, loop_samples): (Vec<String>, Vec<DestructiveLoopSample>) = {
|
||||
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<String> = Vec::new();
|
||||
let mut loops: Vec<DestructiveLoopSample> = 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<Mutex<AiSession>>,
|
||||
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 自纠机会) ──
|
||||
|
||||
@@ -97,6 +97,11 @@ pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProvi
|
||||
}
|
||||
|
||||
/// 保存/更新 AI 提供商配置
|
||||
///
|
||||
/// `temperature_override`:供应商级采样温度覆盖(可选)。Some(t) 落库进 config JSON
|
||||
/// `{"temperature": t}`,agentic 主链路读取覆盖默认 0.7 —— 治受限模型(如 Kimi 系
|
||||
/// 经 u-switch 透传)仅接受 temperature=1、其它值 400 "field Temperature invalid" 的问题。
|
||||
/// None(前端不传/留空)→ config 保持 None,走默认 0.7。
|
||||
#[tauri::command]
|
||||
pub async fn ai_save_provider(
|
||||
state: State<'_, AppState>,
|
||||
@@ -107,6 +112,7 @@ pub async fn ai_save_provider(
|
||||
default_model: String,
|
||||
provider_type: String,
|
||||
model_configs: Vec<ModelConfig>,
|
||||
temperature_override: Option<f32>,
|
||||
) -> Result<String, String> {
|
||||
// 接收前端传入的 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 编辑保留原值(负载均衡池可编辑层),新建走默认。
|
||||
|
||||
@@ -101,6 +101,12 @@
|
||||
Gemini 2.5→3.5 等),提示用户点「测试连接」拉真实模型列表覆盖,降低踩过时默认值的困惑。 -->
|
||||
<div class="form-field-hint">{{ $t('settings.defaultModelHint') }}</div>
|
||||
</div>
|
||||
<!-- 供应商级采样温度覆盖:部分受限模型(如 Kimi 系)仅接受 temperature=1,留空走默认 0.7 -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelSamplingTemp') }}</label>
|
||||
<input type="number" v-model.number="providerForm.temperature" class="setting-input"
|
||||
:placeholder="$t('settings.phSamplingTemp')" step="0.1" min="0" max="2" />
|
||||
</div>
|
||||
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
|
||||
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
|
||||
用户看到错误同时表单保持编辑态,改完点保存即可重试。 -->
|
||||
@@ -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
|
||||
|
||||
@@ -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-...',
|
||||
|
||||
@@ -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-...',
|
||||
|
||||
Reference in New Issue
Block a user