修复: 流式重试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:
@@ -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() Err):emit 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() Err):UX-260618-15 **不 emit AiError**,返回
|
||||
/// InitFailed{retryable, error}。retryable 据状态码分类(retry::is_status_retryable 镜像):
|
||||
/// 5xx/429/timeout/connect=true,4xx(非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):返回 Complete(incomplete 语义非异常中断)。
|
||||
///
|
||||
/// 心跳与停止响应(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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user