修复: 流式重试N+1气泡根治(UX-260618-15第一批方案A)

- stream_recv: InitFailed加error字段,5处重试路径移除AiError emit(retryable交agentic统一emit)
- agentic: 重试loop只emit AiStreamRetry,Fatal/全candidate耗尽emit最终AiError(单气泡聚合)
- useAiEvents: AiStreamRetry改首次创建+后续更新(N+1气泡消除)
- 注释残留修正(stream_recv :251/:299 旧"emit AiError"→"返回InitFailed")
N+1独立气泡降为「1重试气泡+1最终错误气泡」
This commit is contained in:
2026-06-19 01:07:50 +08:00
parent 1dc8b12ec4
commit 0142654be3
5 changed files with 144 additions and 70 deletions

View File

@@ -119,12 +119,14 @@ impl Drop for GeneratingGuard {
/// - `InitFailedExhausted`:流前 `InitFailed{retryable=true}` 在本 provider 上重试
/// 耗尽(或预算 30s 耗尽)。retryable=true 即「瞬态错误,换 provider 可能成功」→
/// 外层 `continue` 切下一 candidate(重建 build_provider + resolved_model 重算)。
/// 携带 `error`(最后一条诊断文本),供外层「全 candidate 耗尽」时 emit 最终 AiError。
/// - `Fatal`:流前 `InitFailed{retryable=false}`(4xx 非429/鉴权/参数错)。立即放弃
/// 整个 fallback(对齐 retry.rs Fatal 分类 + provider_pool 文档「不可重试错误
/// 立即放弃不浪费备用 provider」)。stream_llm 已 emit AiError,调用方仅做
/// guard.reset + return。空 key(build_provider_for Err)同样归 Fatal 语义——
/// key 缺失/钥匙串损坏非瞬态,换 provider 无意义(但实际场景:外层已对 primary
/// 做了启动 Auth 早失败,故此分支多见于 secondary 配置不一致,保守 Fatal 收敛)。
/// 立即放弃不浪费备用 provider」)。携带 `error`,stream_one_provider 内 Fatal 分支
/// emit AiError(终态不切候选,无残留气泡风险),调用方仅做 guard.reset + return。
/// 空 key(build_provider_for Err)同样归 Fatal 语义——key 缺失/钥匙串损坏非瞬态,
/// 换 provider 无意义(但实际场景:外层已对 primary 做了启动 Auth 早失败,故此分支
/// 多见于 secondary 配置不一致,保守 Fatal 收敛)。
enum StreamOutcome {
Success {
text: String,
@@ -135,8 +137,10 @@ enum StreamOutcome {
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>,
},
InitFailedExhausted,
Fatal,
/// UX-260618-15: error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。
InitFailedExhausted { error: String },
/// UX-260618-15: error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。
Fatal { error: String },
}
/// 单 provider 流式调用 + 重试(F-260614-04b)。
@@ -177,13 +181,9 @@ async fn stream_one_provider(
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(candidate) {
Ok(p) => p,
Err(msg) => {
// 不复用 stream_llm 的 AiError emit(那是 HTTP 诊断),此处补一条 Auth 分类错误
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: msg,
error_type: Some(ErrorType::Auth),
conversation_id: Some(conv_id.to_string()),
});
return StreamOutcome::Fatal;
// UX-260618-15: Fatal 分支 emit 移到外层(stream_one_provider 调用方 Fatal 分支统一 emit)
// 此处仅返回 error 文本,避免 stream_one_provider 内 emit 与外层 emit 重复(Fatal 终态单 emit)。
return StreamOutcome::Fatal { error: msg };
}
};
@@ -240,7 +240,7 @@ async fn stream_one_provider(
reasoning_content,
};
}
StreamResult::InitFailed { retryable } => {
StreamResult::InitFailed { retryable, error } => {
// Fatal(4xx 非429/鉴权/参数错)立即放弃整个 fallback。
if !retryable {
tracing::warn!(
@@ -250,7 +250,8 @@ async fn stream_one_provider(
"[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback",
candidate.name,
);
return StreamOutcome::Fatal;
// UX-260618-15: 携带 error 文本,外层 Fatal 分支 emit AiError(终态单气泡)。
return StreamOutcome::Fatal { error };
}
let is_last = retry_attempt >= max_retries;
@@ -259,7 +260,9 @@ async fn stream_one_provider(
if is_last || budget_exhausted {
// 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。
// stream_llm emit AiError(每次 InitFailed 均发),故不再重复 emit
// UX-260618-15: stream_llm 不再 emit AiError(retryable 重试过程 emit 权归此处)
// 此处不 emit(可能切下一 candidate 成功,emit 会留残留气泡);
// 携带 error 交外层「全 candidate 耗尽」时 emit 最后一条 AiError。
tracing::warn!(
conv_id = %conv_id,
provider = %candidate.name,
@@ -270,7 +273,7 @@ async fn stream_one_provider(
if budget_exhausted { "预算耗尽" } else { "耗尽" },
max_retries + 1,
);
return StreamOutcome::InitFailedExhausted;
return StreamOutcome::InitFailedExhausted { error };
}
// 退避复用 retry::backoff_delay(retry_attempt+1)(1s→2s→4s ±20% jitter)。
@@ -301,7 +304,9 @@ async fn stream_one_provider(
// 仅 max_retries=0(无重试配置)且首试 InitFailed retryable=true 时到达此处:
// 0..=0 循环首趟即 is_last → 已在循环内 return InitFailedExhausted。此为防御兜底。
StreamOutcome::InitFailedExhausted
StreamOutcome::InitFailedExhausted {
error: "AI 调用失败:重试预算耗尽(防御兜底)".to_string(),
}
}
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
@@ -677,6 +682,10 @@ pub(crate) async fn run_agentic_loop(
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = {
// outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
let mut outcome: Option<(String, std::collections::HashMap<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None;
// UX-260618-15: 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
// Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功,
// emit 会留残留气泡)。
let mut last_exhausted_error: Option<String> = None;
'candidate: for candidate in &candidate_chain {
// per-provider permit(可选):set_provider_caps 未配置时返回 None(单 provider 零变化);
@@ -708,22 +717,30 @@ pub(crate) async fn run_agentic_loop(
outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone()));
break 'candidate;
}
StreamOutcome::InitFailedExhausted => {
StreamOutcome::InitFailedExhausted { error } => {
// 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。
// candidates 空(单 provider)时此即「耗尽 return」语义——
// 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。
// UX-260618-15: 不在此 emit AiError(可能切下一 candidate 成功留残留气泡),
// 仅记录 error 供全耗尽兜底 emit 最后一条。
tracing::warn!(
conv_id = %conv_id,
provider = %candidate.name,
"[ai] 候选 {} 流前失败重试耗尽,F-04b 切换下一 provider",
candidate.name,
);
last_exhausted_error = Some(error);
continue 'candidate;
}
StreamOutcome::Fatal => {
StreamOutcome::Fatal { error } => {
// Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。
// stream_llm emit AiError,仅做 guard.reset + return
// UX-260618-15: stream_llm 不再 emit AiError,此处统一 emit 最终错误气泡(单气泡聚合)
guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error,
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.clone()),
});
return;
}
}
@@ -733,13 +750,20 @@ pub(crate) async fn run_agentic_loop(
Some(r) => r,
None => {
// 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。
// 各 candidate 的 stream_llm emit 最后一条 AiError,此处仅复位退出。
// UX-260618-15: stream_llm 不再 emit AiError,此处 emit 最后一个 Exhausted candidate 的
// 诊断文本作为最终错误气泡(单气泡聚合,替代原各 candidate emit 多气泡)。
tracing::warn!(
conv_id = %conv_id,
candidates_tried = candidate_chain.len(),
"[ai] 全 provider 候选流前失败重试耗尽,放弃本轮",
);
guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: last_exhausted_error
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.clone()),
});
return;
}
}

View File

@@ -10,7 +10,7 @@ use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ErrorType, ToolCallDraft};
use super::{AiChatEvent, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
///
@@ -115,22 +115,27 @@ pub(crate) enum StreamResult {
/// DeepSeek thinking 模式推理内容(部分累积)
reasoning_content: Option<String>,
},
/// Init 失败provider.stream() Err建连/鉴权/HTTP non-2xx)。已 emit AiError
/// Init 失败provider.stream() Err建连/鉴权/HTTP non-2xx,或流建立后空文本各类中断)
/// UX-260618-15: **不再 emit AiError**——重试可恢复路径的 emit 权交调用方(agentic.rs),
/// 由 agentic 在重试耗尽/Fatal 时统一 emit 最终 AiError(单气泡),避免 N 次重试 push N+1 气泡。
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect
/// 可重试false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
InitFailed { retryable: bool },
/// `error`: 已格式化的错误文本(fmt_diag 拼接 provider 标识 + 上下文 + 状态 + 原文),
/// 供 agentic 重试耗尽/Fatal 时 emit AiError 用,保留原诊断信息不丢失。
InitFailed { retryable: bool, error: String },
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理(返回 StreamResult 显式区分出口):
/// - Init 失败provider.stream() Erremit AiError + 返回 InitFailed{retryable}。
/// retryable 据状态码分类(retry::is_status_retryable 镜像)5xx/429/timeout/connect=true
/// 4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 retryable=true,≤max_retries 次,
/// 复用 retry::backoff_delay 退避 + 30s 总预算)
/// - Init 失败provider.stream() ErrUX-260618-15 **不 emit AiError**,返回
/// InitFailed{retryable, error}。retryable 据状态码分类(retry::is_status_retryable 镜像)
/// 5xx/429/timeout/connect=true4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅
/// retryable=true,≤max_retries 次, 复用 retry::backoff_delay 退避 + 30s 总预算),重试耗尽或
/// Fatal 时由调用方 emit 最终 AiError(error 字段携带诊断文本,单气泡聚合)。
/// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished
/// **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete)
/// + 系统提示网络中断。空文本仍 emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
/// + 系统提示网络中断。空文本仍返回 InitFailed{retryable=true}(无文可保,交重试,emit 权归调用方)。
/// - 用户停止stop_flag返回 Completeincomplete 语义非异常中断)。
///
/// 心跳与停止响应B-260615-02 / B-260615-04单 `tokio::select!` 三分支
@@ -180,7 +185,7 @@ pub(crate) async fn stream_llm(
}
// 三分支 select!(B-260615-02 + B-260615-04 合并):
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断仍 emit AiError)
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断返回 InitFailed 交调用方处理)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) stop_notify.notified():用户停止即时打断
//
@@ -195,14 +200,13 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => {
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
// 空文本无文可保,emit AiError + InitFailed{retryable=true}(timeout 瞬态可重试)。
// 空文本无文可保,UX-260618-15: 不再 emit AiError(retryable=true 重试路径,
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
return StreamResult::InitFailed {
retryable: true,
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
error_type: Some(ErrorType::Timeout),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
};
}
warn!(
provider = %provider.name(),
@@ -244,7 +248,7 @@ pub(crate) async fn stream_llm(
}
// provider 流式错误事件Anthropic SSE `type=="error"` 等):
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
// 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true,
// 不重试。空文本无文可保,返回 InitFailed(保守 retryable=true,
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
if let Some(err_msg) = &chunk.error {
warn!(
@@ -254,18 +258,19 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] provider 流式错误事件",
);
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
// 不重试。空文本无文可保,UX-260618-15: 不再 emit AiError(保守 retryable=true,
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
return StreamResult::InitFailed {
retryable: true,
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
"stream-error",
err_msg,
),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
};
}
return StreamResult::Partial {
text: full_text,
@@ -291,22 +296,21 @@ pub(crate) async fn stream_llm(
"[ai] 流式接收中途错误",
);
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
// 已有文本则保文(Partial),不重试。空文本无文可保,emit AiError +
// 已有文本则保文(Partial),不重试。空文本无文可保,返回
// InitFailed{retryable=classify_status_or_class(status_or_class)}
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
// UX-260618-15: 不再 emit AiError。retryable=true 走重试路径,
// retryable=false(4xx Fatal)也交 agentic Fatal 分支统一 emit
// (InitFailed 携带 retryable + error,agentic 据此决定重试或 Fatal emit)。
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
&status_or_class,
&raw,
),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
};
}
return StreamResult::Partial {
@@ -355,12 +359,12 @@ pub(crate) async fn stream_llm(
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
if !finished_received {
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
// B-260615-05:空内容无 finished。UX-260618-15: 不再 emit AiError(retryable=true
// 重试路径,emit 权交 agentic 重试耗尽时统一发)。
return StreamResult::InitFailed {
retryable: true,
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
};
}
warn!(
provider = %provider.name(),
@@ -395,20 +399,19 @@ pub(crate) async fn stream_llm(
error = %raw,
"[ai] LLM 流式调用失败",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
// UX-260618-15: 不再 emit AiError(retryable 走重试路径,Fatal 立即放弃,均交 agentic 统一 emit
// 最终错误气泡,避免重试过程 N 次失败 push N+1 气泡)。诊断文本经 fmt_diag 拼好携带出,
// agentic 重试耗尽/Fatal 时 emit AiError 用此文本,诊断信息不丢失。
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
error: fmt_diag(
provider.name(),
DiagKind::Init,
&status_or_class,
&raw,
),
error_type: None,
conversation_id: Some(conv_id.to_string()),
});
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
}
}
}