Files
Neo-ZQYY/docs/prd/Neo_Specs/review-audit/P5.1-NS3-05.md
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

129 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P5.1→NS3 缺失项 #5LLM 调用的错误处理和降级策略
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 重试机制和异常分类已完整实现调用链级容错步骤失败后继续也已到位。但缺少限流429降级回退、模型不可用时的静态兜底内容、以及面向前端的统一错误码体系。
## 详细审查
### 审查范围
- `apps/backend/app/ai/bailian_client.py` — 百炼 API 统一封装层
- `apps/backend/app/ai/dispatcher.py` — AI 事件调度与调用链编排器
- `apps/backend/app/ai/apps/app1_chat.py` — App1 流式对话错误处理
- `apps/backend/app/ai/apps/app3_clue.py` — App3 数据获取降级示例
- `apps/backend/app/main.py` — AI 事件处理器注册的容错
### 发现
#### ✅ 已实现部分
1. **重试机制(指数退避)**`bailian_client.py``_call_with_retry()` 实现了完整的重试策略:
- 最多重试 3 次(`MAX_RETRIES = 3`
- 间隔1s → 2s → 4s指数退避`BASE_INTERVAL = 1`
- 5xx / 超时 / 连接错误:重试
- 4xx400/401/403/404/422/429不重试直接抛出
2. **异常分类体系**:定义了 4 个异常类:
- `BailianApiError`:通用 API 错误(含 status_code
- `BailianJsonParseError`JSON 解析失败(含 raw_content
- `BailianAuthError`API Key 无效401
- 继承关系清晰,调用方可按需捕获
3. **调用链级容错**`dispatcher.py``_run_step()` 实现了步骤级容错:
- 某步失败 → 记录错误日志 + 写入失败 conversation 记录 → 返回 None
- 后续步骤继续执行,使用已有缓存
- 整条链后台异步执行(`asyncio.create_task`),不阻塞业务请求
4. **App1 流式错误处理**`app1_chat.py``chat_stream()` 在异常时 yield `SSEEvent(type="error", message=str(e))`,前端可据此展示错误提示。
5. **数据获取降级**`app3_clue.py``build_prompt()` 在消费数据获取失败时降级为空值:
```python
except Exception:
member_data = _default_member_data()
data_fetch_failed = True
```
6. **启动容错**`main.py` 中 AI 事件处理器注册包裹在 try/except 中API Key 缺失或注册失败不影响后端启动。
#### ❌ 未实现部分
1. **限流429降级回退**
- 当前 429 RateLimitError 直接抛出,不重试
- 缺少限流时的降级策略(如返回缓存中的上一次结果、排队等待、降低调用频率)
- 消费事件链中如果百炼 API 限流,整个 App 步骤直接失败
2. **模型不可用时的静态兜底**
- 当百炼 API 完全不可用(如网络中断、服务宕机)时,重试 3 次后抛出 `BailianApiError`
- 对于 App2财务洞察、App4关系分析等前端直接展示的应用缺少静态兜底内容
- 前端读取 ai_cache 时如果没有缓存记录(首次调用就失败),会得到空结果
3. **统一错误码体系**
- App1 的 SSEEvent error 只传 `message=str(e)`,缺少结构化错误码
- 前端无法区分"网络超时"、"API Key 过期"、"限流"等不同错误类型
- 不同错误类型应有不同的用户提示(如限流→"AI 繁忙请稍后"、认证→"服务配置异常"
4. **熔断机制**
- 缺少熔断器Circuit Breaker连续失败 N 次后暂停调用,避免无效重试
- 高并发场景下(如多个消费事件同时触发),可能产生大量失败请求
### 证据
`bailian_client.py` 中的重试和异常处理:
```python
# 429 限流:直接抛出,不重试
except openai.RateLimitError as e:
logger.error("百炼 API 限流: %s", e)
raise BailianApiError(str(e), status_code=429) from e
# 5xx / 超时 / 连接错误:重试
except (openai.InternalServerError, openai.APIConnectionError,
openai.APITimeoutError) as e:
last_error = e
if attempt < self.MAX_RETRIES - 1:
wait_time = self.BASE_INTERVAL * (2 ** attempt)
await asyncio.sleep(wait_time)
```
`dispatcher.py` 中的步骤级容错:
```python
async def _run_step(self, app_name, run_func, context) -> dict | None:
try:
result = await run_func(context, self.bailian, ...)
return result
except Exception:
logger.exception("调用链步骤失败: %s", app_name)
# 写入失败 conversation 记录
# ...
return None
```
`main.py` 中的启动容错:
```python
try:
if _api_key and _base_url:
# ... 注册 AI 事件处理器
register_ai_handlers(_dispatcher)
except Exception:
_log.getLogger(__name__).warning(
"AI 事件处理器注册失败AI 功能不可用", exc_info=True)
```
### 建议
1. **429 限流降级**:捕获 `RateLimitError` 后,尝试返回 ai_cache 中该 (cache_type, site_id, target_id) 的最新缓存结果,并在结果中标注 `"_from_cache": true`,让前端知道这是缓存数据。
2. **统一错误码枚举**
```python
class AIErrorCode(str, Enum):
RATE_LIMITED = "rate_limited" # AI 繁忙,请稍后再试
AUTH_FAILED = "auth_failed" # 服务配置异常
TIMEOUT = "timeout" # 请求超时
SERVICE_DOWN = "service_down" # AI 服务暂时不可用
PARSE_ERROR = "parse_error" # AI 返回格式异常
```
3. **熔断器**:在 `BailianClient` 中增加简单的熔断逻辑——连续失败 5 次后,后续 60 秒内直接返回缓存或静态兜底,不再调用 API。
4. **App1 SSE 错误结构化**:将 `SSEEvent(type="error")` 扩展为包含 `error_code` 字段,前端据此展示不同的用户提示。