diff --git a/docs/superpowers/specs/2026-09-01-llm-error-retry-memory-design.md b/docs/superpowers/specs/2026-09-01-llm-error-retry-memory-design.md new file mode 100644 index 0000000..72ecb9b --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-llm-error-retry-memory-design.md @@ -0,0 +1,173 @@ +# 模型网关 LLM 驱动重试决策 + 持久错误记忆 设计 + +> **日期**:2026-09-01 +> **范围**:model-gateway(仅本服务) +> **状态**:已确认 + +## Goal + +请求上游模型失败时,不再用固定错误码清单决定是否重试,改为**调用对话模型分析错误类型**决定是否重试,并持久化"错误 → 结论"的记忆(知识库),命中记忆时不再调用分析模型。 + +## 背景与现状 + +当前重试判定(`service/retry.go:isRetryableErrorCode`)基于固定错误码集合 `429/500/501/502/503/InvalidParameter/limit_requests/limit_tokens/rate_limit_exceeded`。判定点两处: + +- 同步:`service/session_sync.go:60`(`CreateSession`) +- 流式:`service/session_stream.go:88`,及 `streamRetryCodeOfError` 中对 5xx 的硬编码分支 + +异步任务启动 `service/model_task_start_service.go`(`CreateTask`)目前**没有重试**。 + +错误解析已配置驱动:`parseModelError`(按模型 `ErrorMessageMapping` 提取 code/message)。 + +## 设计决策(已确认) + +| # | 决策 | +|---|---| +| 1 | **完全交给 LLM 判断**:删除固定错误码清单,所有错误(记忆未命中时)都调分析模型决定是否重试 | +| 2 | **持久知识库**:结论存 PostgreSQL 新表,跨实例共享、重启不丢 | +| 3 | **记忆键** = 失败上游 BaseURL + 错误码 + 归一化消息指纹(同因同键、跨实例命中) | +| 4 | **二元输出**:`{retryable: bool, reason: str}` | +| 5 | **永久有效**:条目无 TTL,直到同键重新分析覆盖或经管理端点手动删除 | +| 6 | **分析模型 = 对话模型**(不新增配置段):失败模型自身 `chat_model=true` 优先复用;否则取当前用户对话模型(`GetChatModel`);都没有 → fail-closed 不重试 | +| 7 | **三路接入**:同步 / 流式 / 异步任务启动 | +| 8 | **内联分析 + singleflight 去重**:未命中时同步调分析模型;同键并发 miss 合并为一次分析 | + +## 架构与数据流 + +``` +请求失败(parseModelError 得 code+msg) + └─ shouldRetryWithMemory(ctx, modelInfo, code, msg) → bool: + 1) 归一化消息 → 记忆键 = SHA-256(upstream|code|fingerprint) + 2) 查 model_gateway_error_memory + ├─ 命中 → 返回存储的 retryable + └─ 未命中 → singleflight 合并后调分析模型 + → 解析 {retryable, reason} → UPSERT 落库 → 返回 + 3) retryable=true → 现有指数退避重试(modelCallMaxRetries=10 预算) + retryable=false → 记 ErrorMsg 走现有终态逻辑(不重试) +``` + +## 记忆知识库(新表 `model_gateway_error_memory`) + +表前缀 `model_gateway_`,DDL 追加到 `update.sql`,DAO/entity/consts 按现有惯例新建。 + +| 列 | 类型 | 说明 | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `memory_key` | CHAR(64) | SHA-256(upstream\|code\|fingerprint),**唯一索引** | +| `upstream` | VARCHAR | 失败上游 BaseURL | +| `error_code` | VARCHAR | 解析出的错误码 | +| `msg_fingerprint` | CHAR(32) | 归一化消息 md5 | +| `retryable` | BOOLEAN | LLM 结论 | +| `reason` | VARCHAR | LLM 给出的简短原因(可观测) | +| `analyzed_by` | VARCHAR | 分析所用模型名 | +| `created_at` / `updated_at` | 自动 | 永久有效,无 TTL | + +### 归一化 `normalizeErrorMsg` + +正则剔除易变片段,使同因不同实例命中同一键: + +- UUID(8-4-4-4-12 hex) +- ISO 8601 时间戳 / unix 秒与毫秒数字 +- `req-xxx` / `request-xxx` 类请求 ID +- 连续 ≥4 位数字(去掉具体数值,保留位置标记) + +### DAO / entity / consts + +按 `dao/model_task_start_dao.go` 模式:包名 `dao`,变量 `var ErrorMemory = &errorMemoryDao{}`;表名/库名常量进 `consts/public`;entity 带列常量(`entity.ErrorMemoryCol.*`)。 + +## 分析模型与调用(不新增 config) + +### 分析模型选择 + +1. 失败请求的 `modelInfo` 本身 `chat_model=true` → 直接用它(复用其 baseURL/apiKey/HttpMethod) +2. 否则 → `GetChatModel`(当前用户对话模型,现有语义 Creator + chat_model=true) +3. 都没有 → **fail-closed 不重试** + 日志 + +依赖:重试循环内 `ctx` 需携带用户信息(与现有 X-User-Info 约定一致)。 + +### 调用方式 + +`httpclient.ModelHttpNormalRequest` 对分析模型发 OpenAI messages 格式: + +```json +{ + "model": "<分析模型 modelName>", + "messages": [ + {"role": "system", "content": "<判定错误是否可重试的系统提示词>"}, + {"role": "user", "content": "{error_code, error_message, 截断错误响应体}"} + ], + "max_tokens": 256, + "temperature": 0 +} +``` + +### Prompt(要点) + +- system:定义任务——判断上游模型错误是否值得指数退避重试;考虑限流/瞬时故障/配置/参数/鉴权等类型;输出严格 JSON。 +- user:携带 `error_code`、`error_message`、截断的错误响应体(≤2000 字符)。 +- 输出解析容错:剥 ```json 代码块 / 前后空白 / 首个 `{...}` 提取。 + +### 失败处理 + +分析调用超时(15s)/失败/解析失败 → **fail-closed 不重试** + 日志。分析失败不影响记忆表。 + +## 三路接入 + +| 路径 | 文件 | 改动 | +|---|---|---| +| 同步 | `service/session_sync.go:60` | `isRetryableErrorCode(errCode)` → `shouldRetryWithMemory(...)` | +| 流式 | `service/session_stream.go:88` | 同上替换;`streamRetryCodeOfError` 中硬编码 5xx 分支收敛(移除) | +| 异步启动 | `service/model_task_start_service.go` | 新增重试循环(现无重试),错误时走同一判定 | + +统一入口落在 `service/retry.go`: + +- 删除 `isRetryableErrorCode` 固定清单 +- 新增 `shouldRetryWithMemory(ctx, modelInfo, code, msg) (retry bool)`(内部:查记忆 → miss 则分析 → 落库) +- 保留 `modelCallMaxRetries=10` 与 `retryWait` 指数退避 +- 新增单飞:`golang.org/x/sync/singleflight.Group` 按记忆键合并并发分析(go.sum 已有传递版本,加为直接依赖即可;避免自实现 keyed mutex) + +### 管理端点(手动清理记忆) + +- `GET /errorMemory/list` — 分页查看记忆条目 +- `POST /errorMemory/delete` — 按 id 删除条目(永久记忆的手动纠错途径) + +## 边界与降级 + +| 场景 | 行为 | +|---|---| +| 记忆查询失败(DB 异常) | 不重试 + 日志,不阻塞主流程 | +| 分析模型调用失败/超时/解析失败 | 不重试(fail-closed)+ 日志 | +| 同键并发 miss | singleflight 合并,只调一次分析模型 | +| 消息过长 | 截断到 2000 字符再送分析 | +| 分析模型取不到 | 不重试 + 日志 | +| 重试仍失败(同键) | 烧满 `modelCallMaxRetries` 预算;永久记忆条目保留,靠管理端点手动清理 | + +## 已知取舍 + +- **永久记忆**:上游行为变化后旧结论可能过时。接受,靠管理端点手动纠错;自动"连续失败 N 次降级"明确**不做**(YAGNI,留作未来)。 +- **异步 task_start 重试**:重试=重新调用创建任务;首次调用已报错大概率未建任务,接受"响应丢失但任务已建 → 重复建任务"的既有语义(与同步/流式行为一致)。 + +## 测试策略 + +**单元测试** +- `normalizeErrorMsg`:UUID / 时间戳 / 请求 ID / 连续数字被剔除;稳定文本不变 +- 记忆键构造:同 code+同归一化消息同键;不同上游不同键 +- 分析响应解析:纯 JSON / ```json 代码块 / 前后缀 / 非法响应(返回失败) + +**集成测试** +- 记忆命中:直接复用存储结论,不调分析模型 +- 未命中:调分析模型 → 落库 → 按结论重试/不重试 +- 分析失败:不重试,不落库 +- 并发同键:多个 goroutine 只触发一次分析调用 +- 管理端点:list / delete + +**三路径** +- 同步 / 流式:重试预算与退避行为保持现状,仅判定来源替换 +- 异步 task_start:新增重试循环行为 + +## 非目标 + +- 不做固定错误码快路径(决策 1 已排除) +- 不做记忆自动过期 / 命中计数降级(决策 5 + YAGNI) +- 不新增 config 段(决策 6) +- 不接 ai-agent / prompts-core 做分析(分析在 model-gateway 内完成)