# P5.1→NS3 缺失项 #5：LLM 调用的错误处理和降级策略

## 简要结论
- 状态：⚠️ 部分解决
- 风险等级：🟠 中
- 重试机制和异常分类已完整实现，调用链级容错（步骤失败后继续）也已到位。但缺少限流（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 / 超时 / 连接错误：重试
   - 4xx（400/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` 字段，前端据此展示不同的用户提示。
