重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes
This commit is contained in:
@@ -9,6 +9,9 @@ use tokio::sync::Mutex;
|
||||
use df_ai::ai_tools::AiToolRegistry;
|
||||
use df_ai::context::TokenEstimator;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
||||
use df_ai::retry;
|
||||
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
@@ -18,7 +21,7 @@ use crate::state::{AppState, LlmConcurrency};
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::maybe_spawn_extraction;
|
||||
use super::prompt::{build_system_prompt, get_active_provider};
|
||||
use super::stream_recv::stream_llm;
|
||||
use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
use super::audit::process_tool_calls;
|
||||
|
||||
@@ -32,6 +35,14 @@ use super::{AiChatEvent, AiSession, ErrorType};
|
||||
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
||||
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
|
||||
/// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1)
|
||||
///
|
||||
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
|
||||
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
|
||||
/// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream:已输出
|
||||
/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
|
||||
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
||||
|
||||
// ============================================================
|
||||
// B-260615-09: generating 状态 RAII guard
|
||||
// ============================================================
|
||||
@@ -99,6 +110,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
knowledge_config: crate::state::KnowledgeConfig,
|
||||
llm_concurrency: LlmConcurrency,
|
||||
max_iterations: usize,
|
||||
max_retries: usize,
|
||||
) {
|
||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone());
|
||||
@@ -170,7 +182,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,34 +220,187 @@ pub(crate) async fn run_agentic_loop(
|
||||
};
|
||||
|
||||
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
|
||||
// 注:F-260616-07 重试循环内每次重建 retry_request(因 provider.stream 消费 body),
|
||||
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
|
||||
let estimated_prompt: u32 = {
|
||||
let est = TokenEstimator::default();
|
||||
messages.iter().map(|m| est.estimate_message(m)).sum()
|
||||
};
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: provider_config.default_model.clone(),
|
||||
messages,
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(8192),
|
||||
stream: true,
|
||||
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
// LLM 并发限流(全局 + 单对话双层),仅覆盖 stream_llm 调用本身;
|
||||
// 工具执行(process_tool_calls)是本地操作无 RPM 成本,permit 在 stream 后立即释放避免占槽
|
||||
//
|
||||
// CR-30-1 / F-260616-07 / 决策 a1: 流前失败(Init Err)重试,流中途失败(MidStream
|
||||
// Partial)不重试保文。重试退避复用 retry::backoff_delay(1s→2s→4s±20% jitter) +
|
||||
// retry::is_status_retryable Fatal 分类(stream_recv classify_status_or_class 镜像,
|
||||
// 4xx 非429 立即放弃) + 30s 总挂钟预算。重试期间持有 permit 不释放(防新请求挤占)。
|
||||
let _global_permit = llm_concurrency.acquire_global().await;
|
||||
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
|
||||
// 流式接收(内部处理 idle timeout / 断连检测 / 停止信号)
|
||||
let (full_text, tool_calls_acc, round_usage) = match stream_llm(&*provider, request, &app_handle, &stop_flag, ¬ify, &conv_id).await {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
// 错误已在 stream_llm 中 emit,直接结束
|
||||
guard.reset().await;
|
||||
return;
|
||||
|
||||
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
|
||||
// 超预算直接放弃重试交最终错误/保文路径。
|
||||
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
|
||||
let (full_text, tool_calls_acc, round_usage, incomplete) = {
|
||||
// outcome 累积最后一次成功/保文结果(Complete 或 Partial)。InitFailed 不写入,
|
||||
// 走重试或耗尽 return。
|
||||
let mut outcome: Option<(String, std::collections::HashMap<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool)> = None;
|
||||
|
||||
for retry_attempt in 0..=max_retries {
|
||||
// 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body)
|
||||
let retry_request = CompletionRequest {
|
||||
model: provider_config.default_model.clone(),
|
||||
messages: {
|
||||
let session = session_arc.lock().await;
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
|
||||
let mut msgs = vec![ChatMessage::system(&system_prompt)];
|
||||
msgs.extend(history_msgs);
|
||||
msgs
|
||||
},
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(8192),
|
||||
stream: true,
|
||||
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
match stream_llm(&*provider, retry_request, &app_handle, &stop_flag, ¬ify, &conv_id).await {
|
||||
StreamResult::Complete { text, tool_calls, usage } => {
|
||||
// 正常完成(流尽 + finished,或用户主动停止)。incomplete=false。
|
||||
outcome = Some((text, tool_calls, usage, false));
|
||||
break;
|
||||
}
|
||||
StreamResult::Partial { text, tool_calls, usage } => {
|
||||
// CR-30-2 / UX-2025-04 / 决策 a1: 流中途失败保文,**不重试**。
|
||||
// 已 emit AiTextDelta(前端 currentText 已累积),此处保文入库 +
|
||||
// AiCompleted(incomplete=true) + 系统提示。currentText 不混乱(未重试无追加)。
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
text_len = text.len(),
|
||||
"[ai] 流中途失败,保文不重试(incomplete=true),入库 + AiCompleted + 系统提示",
|
||||
);
|
||||
outcome = Some((text, tool_calls, usage, true));
|
||||
break;
|
||||
}
|
||||
StreamResult::InitFailed { retryable } => {
|
||||
// stream_llm 已 emit AiError。决定是否重试(仅 Init 失败可重试,决策 a1)。
|
||||
let is_last = retry_attempt >= max_retries;
|
||||
let now = tokio::time::Instant::now();
|
||||
let budget_exhausted = now >= retry_deadline;
|
||||
|
||||
// Fatal(4xx 非429/鉴权/参数错)立即放弃,不浪费预算重试
|
||||
if !retryable {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = retry_attempt + 1,
|
||||
"[ai] 流前失败 Fatal(4xx/鉴权),立即放弃不重试",
|
||||
);
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if is_last || budget_exhausted {
|
||||
// 重试耗尽或预算耗尽:错误已 emit,结束
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = retry_attempt + 1,
|
||||
budget_exhausted = budget_exhausted,
|
||||
"[ai] 流前失败重试{}({}次),放弃",
|
||||
if budget_exhausted { "预算耗尽" } else { "耗尽" },
|
||||
max_retries + 1,
|
||||
);
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
|
||||
// CR-30-1: 退避复用 retry::backoff_delay(attempt+1)(1s→2s→4s + ±20% jitter),
|
||||
// 不再使用纯指数 `1u64 << retry_attempt`。attempt 传 retry_attempt+1
|
||||
// (retry::backoff_delay 是 1-based,第 1 次重试 ~1s,第 2 次 ~2s,第 3 次 ~4s)。
|
||||
let delay = retry::backoff_delay((retry_attempt + 1) as u32)
|
||||
.min(retry_deadline.saturating_duration_since(now));
|
||||
let total_retry = retry_attempt + 1; // 1-based 当前是第几次尝试
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = total_retry,
|
||||
max_attempts = max_retries + 1,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
"[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})",
|
||||
delay.as_millis(), total_retry, max_retries + 1
|
||||
);
|
||||
|
||||
// F-260616-07(d): emit 重试提示事件,前端在错误气泡内显示「重试 n/m」
|
||||
// (对齐决策"错误气泡内更新")。CR-30-2: useAiEvents.ts 补 case 处理。
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry {
|
||||
attempt: total_retry as u32,
|
||||
max_attempts: (max_retries + 1) as u32,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// 不应到达:InitFailed 非 Fatal/未耗尽即重试,耗尽/Fatal 已 return;
|
||||
// Complete/Partial 写入 outcome 后 break。防御兜底。
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
|
||||
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
|
||||
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
|
||||
if incomplete {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
|
||||
completion_tokens: round_usage.completion_tokens,
|
||||
total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens },
|
||||
};
|
||||
tokens.add(usage.prompt_tokens, usage.completion_tokens);
|
||||
|
||||
// 追加 partial assistant 消息(若无 tool_calls 且有文本)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
|
||||
tracing::warn!(
|
||||
stale_conv = %conv_id,
|
||||
active_conv = ?session.active_conversation_id,
|
||||
"[ai] MidStream 保文后对话已切换,丢弃本轮 push(B-260615-11)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !full_text.is_empty() {
|
||||
let mut msg = ChatMessage::assistant(&full_text);
|
||||
msg.model = Some(provider_config.default_model.clone());
|
||||
session.messages.push(msg);
|
||||
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
|
||||
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
|
||||
notice.model = Some(provider_config.default_model.clone());
|
||||
session.messages.push(notice);
|
||||
}
|
||||
}
|
||||
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
|
||||
// 标题生成后台化(失败有 extract_title 兜底)
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed(incomplete=true):前端据此标记消息为不完整
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
incomplete: Some(true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// stream 结束立即释放 permit,后续工具执行不受限流(本地操作无 RPM 成本)
|
||||
drop(_global_permit);
|
||||
drop(_per_conv_permit);
|
||||
@@ -289,7 +454,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -377,7 +542,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
@@ -434,6 +599,7 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
}
|
||||
@@ -485,6 +651,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
||||
@@ -494,6 +662,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user