From e5f205e6e1e333fcd5f04f82413b3bd16b5de093 Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Tue, 1 Sep 2026 10:13:52 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=A8=A1=E5=9E=8B=E9=94=99=E8=AF=AFLLM?= =?UTF-8?q?=E5=88=86=E6=9E=90=E9=87=8D=E8=AF=95+=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=20=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-01-llm-error-retry-memory.md | 1179 +++++++++++++++++ 1 file changed, 1179 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-llm-error-retry-memory.md diff --git a/docs/superpowers/plans/2026-09-01-llm-error-retry-memory.md b/docs/superpowers/plans/2026-09-01-llm-error-retry-memory.md new file mode 100644 index 0000000..085f25a --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-llm-error-retry-memory.md @@ -0,0 +1,1179 @@ +# 模型错误 LLM 分析重试 + 持久记忆 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 请求上游模型失败时,不再用固定错误码清单判定重试,改为调用对话模型分析错误类型决定是否重试,并把"错误→结论"持久化到 PostgreSQL 记忆表,命中记忆不再调分析模型。 + +**Architecture:** 新增统一的 `shouldRetryWithMemory(ctx, modelInfo, code, msg, rawBody) bool` 判定入口,内部:构造记忆键 → 查 `model_gateway_error_memory` → 命中返回存储结论;未命中经 singleflight 合并后调分析模型(对话模型)解析 `{retryable,reason}` → UPSERT 落库 → 返回。同步/流式(缓冲)/异步任务启动三路接入,保留现有 `modelCallMaxRetries` 退避预算。分析模型不新增配置段:失败模型本身 `chat_model=true` 优先复用,否则取当前用户对话模型;任一环节失败均 fail-closed 不重试。 + +**Tech Stack:** Go 1.26+, GoFrame v2(gfdb/Model 链), PostgreSQL(gfdb), `golang.org/x/sync/singleflight`, OpenAI 兼容 chat 接口。 + +## Global Constraints + +- **Spec:** `docs/superpowers/specs/2026-09-01-llm-error-retry-memory-design.md`(已提交 `49c1cb1`)。 +- **⚠️ WIP 前置:** model-gateway 工作区有**未提交的 staged WIP 重构**(涉及 `service/session_sync.go`、`service/session_stream.go`、`service/model_task_end_service.go`、`service/pricing_client.go` 等)。执行前**必须**先与用户确认 WIP 去留(建议先提交 WIP)。本计划每个 task 的提交统一用 `git commit --only <本task文件>` 只提交本 task 文件,避免夹带;但 Task 7 改到 `session_sync.go`/`session_stream.go` 时,工作区版本同时含 WIP+本任务改动,`--only` 会一并提交 —— **该两步前先让 WIP 落库**。 +- **分析调用不复用 `httpclient.ModelHttpNormalRequest`**(其 `modelDoRaw` 响应头超时 30min 且不区分 HTTP 状态码)—— 用独立 `http.Client{Timeout: 15s}`(对齐 `schema_mapping_service.callLLM` 的写法)。这是对 spec §3 的一处**技术修正**,spec 已同步更新。 +- 固定错误码清单 `isRetryableErrorCode` **删除**;`modelCallMaxRetries=10`、`retryWait` 指数退避保留。 +- 记忆条目**永久有效**(无 TTL),靠管理端点手动清理;不做自动过期/连续失败降级(YAGNI)。 +- 表名/列名以 entity orm 为准;`model_gateway_` 前缀;列常量进 `consts/public/table_name.go`、entity 常量进 `model/entity/*`. +- 分析消息截断 2000 字符;`temperature=0`;`max_tokens=256`。 +- 测试:纯函数(归一化/记忆键/解析/请求体/单飞去重)写真实单测;DAO/三路接入以 `go build ./...` + 手动验证。 + +--- + +### Task 1: 错误重试记忆表 + entity + consts + +**Files:** +- Modify: `update.sql`(追加建表 DDL) +- Create: `model/entity/error_memory.go` +- Modify: `consts/public/table_name.go` + +**Interfaces:** +- Consumes: `beans.SQLBaseDO` / `beans.SQLBaseCol`(common/beans,基列 `Id/TenantId/Creator/CreatedAt/Updater/UpdatedAt/DeletedAt`) +- Produces: `entity.ErrorMemory` + `entity.ErrorMemoryCol`;`public.TableNameErrorMemory = "error_memory"` + +- [ ] **Step 1: 追加建表 DDL 到 `update.sql` 末尾** + +```sql +-- ========================= +-- 错误重试记忆:LLM 分析上游模型错误是否可重试的持久知识库 +-- memory_key = SHA-256(upstream|error_code|归一化消息),唯一;命中直接复用,永久有效 +-- ========================= +CREATE TABLE IF NOT EXISTS model_gateway_error_memory ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT DEFAULT 0, + creator VARCHAR(64) DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now(), + updater VARCHAR(64) DEFAULT '', + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ DEFAULT NULL, + memory_key CHAR(64) NOT NULL, + upstream VARCHAR(512) NOT NULL DEFAULT '', + error_code VARCHAR(128) NOT NULL DEFAULT '', + msg_fingerprint CHAR(32) NOT NULL DEFAULT '', + retryable BOOLEAN NOT NULL DEFAULT false, + reason VARCHAR(512) NOT NULL DEFAULT '', + analyzed_by VARCHAR(128) NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_error_memory_memory_key + ON model_gateway_error_memory (memory_key) + WHERE deleted_at IS NULL; +``` + +- [ ] **Step 2: 新建 `model/entity/error_memory.go`** + +```go +package entity + +import "gitea.redpowerfuture.com/red-future/common/beans" + +type errorMemoryCol struct { + beans.SQLBaseCol + MemoryKey string + Upstream string + ErrorCode string + MsgFingerprint string + Retryable string + Reason string + AnalyzedBy string +} + +var ErrorMemoryCol = errorMemoryCol{ + SQLBaseCol: beans.DefSQLBaseCol, + MemoryKey: "memory_key", + Upstream: "upstream", + ErrorCode: "error_code", + MsgFingerprint: "msg_fingerprint", + Retryable: "retryable", + Reason: "reason", + AnalyzedBy: "analyzed_by", +} + +// ErrorMemory 错误重试记忆(LLM 分析结论持久化,永久有效) +type ErrorMemory struct { + beans.SQLBaseDO `orm:",inline"` + MemoryKey string `orm:"memory_key" json:"memoryKey" dc:"记忆键=SHA-256(upstream|code|归一化消息)"` + Upstream string `orm:"upstream" json:"upstream" dc:"失败上游BaseURL"` + ErrorCode string `orm:"error_code" json:"errorCode" dc:"错误码"` + MsgFingerprint string `orm:"msg_fingerprint" json:"msgFingerprint" dc:"归一化消息md5"` + Retryable bool `orm:"retryable" json:"retryable" dc:"是否可重试"` + Reason string `orm:"reason" json:"reason" dc:"分析原因"` + AnalyzedBy string `orm:"analyzed_by" json:"analyzedBy" dc:"分析模型名"` +} +``` + +- [ ] **Step 3: `consts/public/table_name.go` 增加表名常量** + +在 `TableNameModelTaskEnd` 后加一行: + +```go +TableNameErrorMemory = "error_memory" +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd model-gateway && go build ./...` +Expected: PASS(无编译错误) + +- [ ] **Step 5: 提交** + +```bash +cd model-gateway +git add update.sql model/entity/error_memory.go consts/public/table_name.go +git commit --only update.sql model/entity/error_memory.go consts/public/table_name.go -m "feat: 新增错误重试记忆表/entity/consts" +``` + +--- + +### Task 2: 错误消息归一化 + 记忆键构造(纯函数 + 单测) + +**Files:** +- Create: `service/error_memory.go` +- Create: `service/error_memory_test.go` + +**Interfaces:** +- Consumes: 无 +- Produces: `normalizeErrorMsg(msg string) string`、`buildMemoryKey(upstream, code, msg string) string`、`msgFingerprint(msg string) string`(Task 6 消费) + +- [ ] **Step 1: 写失败测试 `service/error_memory_test.go`** + +```go +package service + +import "testing" + +func TestNormalizeErrorMsg(t *testing.T) { + cases := []struct{ in, want string }{ + {"rate limit exceeded for req-abc123", "rate limit exceeded for {reqid}"}, + {"timeout at 2026-09-01T09:00:00Z req_88f1a2", "timeout at {time} {reqid}"}, + {"uuid 0f8fad5b-d9cb-469f-a165-70867728950e remains", "uuid {uuid} remains"}, + {"err code 12345678 quota exceeded", "err code {num} quota exceeded"}, + {"clean message unchanged", "clean message unchanged"}, + } + for _, c := range cases { + if got := normalizeErrorMsg(c.in); got != c.want { + t.Fatalf("normalizeErrorMsg(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBuildMemoryKey(t *testing.T) { + k1 := buildMemoryKey("https://a.com", "429", "rate limit for req-1") + k2 := buildMemoryKey("https://a.com", "429", "rate limit for req-999") // 同因不同请求ID + if k1 != k2 { + t.Fatalf("同因不同请求ID应同键: %s != %s", k1, k2) + } + k3 := buildMemoryKey("https://b.com", "429", "rate limit for req-1") // 不同上游 + if k1 == k3 { + t.Fatalf("不同上游应不同键") + } + k4 := buildMemoryKey("https://a.com", "500", "rate limit for req-1") // 不同错误码 + if k1 == k4 { + t.Fatalf("不同错误码应不同键") + } + if len(k1) != 64 { + t.Fatalf("memory_key 应为 SHA-256 十六进制64位, got %d", len(k1)) + } +} + +func TestMsgFingerprint(t *testing.T) { + if msgFingerprint("a req-1") != msgFingerprint("a req-999") { + t.Fatalf("同因指纹应一致") + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd model-gateway && go test ./service/ -run 'TestNormalizeErrorMsg|TestBuildMemoryKey|TestMsgFingerprint' -v` +Expected: FAIL(函数未定义) + +- [ ] **Step 3: 实现 `service/error_memory.go`** + +```go +package service + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" +) + +var ( + reUUID = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + reISOTime = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?`) + reUnixMs = regexp.MustCompile(`\b1[4-9]\d{12}\b`) // unix 毫秒级时间戳 + reRequestID = regexp.MustCompile(`\b(req[-_]?|request[-_]?|rid[-_:]?)[-_:]?[0-9a-zA-Z-]{4,}\b`) + reLongNum = regexp.MustCompile(`\b\d{4,}\b`) // 连续≥4位数字 +) + +// normalizeErrorMsg 归一化错误消息:剔除易变片段(UUID/时间戳/请求ID/连续数字), +// 使同因不同实例的错误命中同一记忆键。 +func normalizeErrorMsg(msg string) string { + m := msg + m = reUUID.ReplaceAllString(m, "{uuid}") + m = reISOTime.ReplaceAllString(m, "{time}") + m = reUnixMs.ReplaceAllString(m, "{ts}") + m = reRequestID.ReplaceAllString(m, "{reqid}") + m = reLongNum.ReplaceAllString(m, "{num}") + return strings.TrimSpace(m) +} + +// buildMemoryKey 构造记忆键 = SHA-256(upstream|error_code|归一化消息)。 +// 含失败上游维度:不同上游的同类错误互不串扰。 +func buildMemoryKey(upstream, code, msg string) string { + raw := strings.Join([]string{upstream, code, normalizeErrorMsg(msg)}, "|") + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// msgFingerprint 归一化消息的 md5(观测/展示用)。 +func msgFingerprint(msg string) string { + sum := md5.Sum([]byte(normalizeErrorMsg(msg))) + return hex.EncodeToString(sum[:]) +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd model-gateway && go test ./service/ -run 'TestNormalizeErrorMsg|TestBuildMemoryKey|TestMsgFingerprint' -v` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +cd model-gateway +git commit --only service/error_memory.go service/error_memory_test.go -m "feat: 错误消息归一化与记忆键构造" +``` + +--- + +### Task 3: 分析响应 JSON 解析(纯函数 + 单测) + +**Files:** +- Create: `service/error_analysis.go`(本 task 只含 `parseAnalysisResponse`;Task 5 同文件追加) +- Create: `service/error_analysis_test.go` + +**Interfaces:** +- Consumes: 无 +- Produces: `parseAnalysisResponse(content string) (retryable bool, reason string, err error)`(Task 5 消费) + +- [ ] **Step 1: 写失败测试 `service/error_analysis_test.go`** + +```go +package service + +import "testing" + +func TestParseAnalysisResponse(t *testing.T) { + cases := []struct { + name string + in string + wantRetry bool + wantErr bool + }{ + {"纯JSON", `{"retryable": true, "reason": "限流"}`, true, false}, + {"带json代码块", "```json\n{\"retryable\": false, \"reason\": \"参数错误\"}\n```", false, false}, + {"带前后文字", `分析结果: {"retryable": true, "reason": "瞬时故障"} 完毕`, true, false}, + {"非法响应", `抱歉,我无法分析`, false, true}, + {"空串", ``, false, true}, + } + for _, c := range cases { + retry, _, err := parseAnalysisResponse(c.in) + if c.wantErr { + if err == nil { + t.Fatalf("[%s] 期望错误, got retry=%v", c.name, retry) + } + continue + } + if err != nil { + t.Fatalf("[%s] 不应报错: %v", c.name, err) + } + if retry != c.wantRetry { + t.Fatalf("[%s] retryable = %v, want %v", c.name, retry, c.wantRetry) + } + } +} + +func TestParseAnalysisResponseReason(t *testing.T) { + _, reason, err := parseAnalysisResponse(`{"retryable": true, "reason": "服务过载"}`) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if reason != "服务过载" { + t.Fatalf("reason = %q, want 服务过载", reason) + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd model-gateway && go test ./service/ -run TestParseAnalysisResponse -v` +Expected: FAIL(函数未定义) + +- [ ] **Step 3: 实现 `service/error_analysis.go`(本 task 部分)** + +```go +package service + +import ( + "encoding/json" + "fmt" + "strings" +) + +// parseAnalysisResponse 解析分析模型输出的判定 JSON。 +// 容错:剥 ```json 代码块/首尾空白/多余文字,取首个 {...}。 +func parseAnalysisResponse(content string) (retryable bool, reason string, err error) { + s := strings.TrimSpace(content) + s = strings.TrimPrefix(s, "```json") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + start, end := strings.IndexByte(s, '{'), strings.LastIndexByte(s, '}') + if start < 0 || end <= start { + return false, "", fmt.Errorf("分析响应中未找到JSON对象: %q", content) + } + var obj struct { + Retryable bool `json:"retryable"` + Reason string `json:"reason"` + } + if err = json.Unmarshal([]byte(s[start:end+1]), &obj); err != nil { + return false, "", fmt.Errorf("分析响应JSON解析失败: %v", err) + } + return obj.Retryable, obj.Reason, nil +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd model-gateway && go test ./service/ -run TestParseAnalysisResponse -v` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +cd model-gateway +git commit --only service/error_analysis.go service/error_analysis_test.go -m "feat: 分析响应JSON解析" +``` + +--- + +### Task 4: 错误重试记忆 DAO + DTO + +**Files:** +- Create: `dao/error_memory_dao.go` +- Create: `model/dto/error_memory_dao_dto.go` + +**Interfaces:** +- Consumes: `entity.ErrorMemory`/`entity.ErrorMemoryCol`(Task 1)、`public.TableNameErrorMemory`(Task 1) +- Produces: `dao.ErrorMemory.GetByKey(ctx, key) (*entity.ErrorMemory, error)`、`Upsert(ctx, *entity.ErrorMemory) error`、`List(ctx, page, pageSize) ([]entity.ErrorMemory, int64, error)`、`Delete(ctx, id) error`;DTO `GetErrorMemoryListReq/Res`、`DeleteErrorMemoryReq`、`ErrorMemoryItem` + +- [ ] **Step 1: 新建 `model/dto/error_memory_dao_dto.go`** + +```go +package dto + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// GetErrorMemoryListReq 错误重试记忆列表 +type GetErrorMemoryListReq struct { + g.Meta `path:"/errorMemory/list" method:"get" tags:"错误记忆" summary:"错误重试记忆列表" dc:"查看错误→可重试结论记忆"` + *beans.Page `json:"page"` +} + +type GetErrorMemoryListRes struct { + List []ErrorMemoryItem `json:"list" dc:"记忆条目"` + Total int64 `json:"total" dc:"总数"` +} + +type ErrorMemoryItem struct { + Id int64 `json:"id"` + MemoryKey string `json:"memoryKey"` + Upstream string `json:"upstream"` + ErrorCode string `json:"errorCode"` + MsgFingerprint string `json:"msgFingerprint"` + Retryable bool `json:"retryable"` + Reason string `json:"reason"` + AnalyzedBy string `json:"analyzedBy"` +} + +// DeleteErrorMemoryReq 删除错误重试记忆 +type DeleteErrorMemoryReq struct { + g.Meta `path:"/errorMemory/delete" method:"post" tags:"错误记忆" summary:"删除错误重试记忆" dc:"手动清理永久记忆条目"` + Id int64 `json:"id" v:"required#id不能为空" dc:"记忆ID"` +} +``` + +- [ ] **Step 2: 新建 `dao/error_memory_dao.go`**(未命中处理对齐 `model_manage_dao.Get` 的 `One()+Struct` 语义) + +```go +package dao + +import ( + "context" + "model-gateway/consts/public" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" +) + +var ErrorMemory = &errorMemoryDao{} + +type errorMemoryDao struct{} + +// GetByKey 按记忆键查询(未命中返回 (nil, nil)) +func (d *errorMemoryDao) GetByKey(ctx context.Context, key string) (res *entity.ErrorMemory, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameErrorMemory). + Where(entity.ErrorMemoryCol.MemoryKey, key). + One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + +// Upsert 存在则更新 retryable/reason/analyzed_by,不存在则插入 +func (d *errorMemoryDao) Upsert(ctx context.Context, m *entity.ErrorMemory) (err error) { + model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameErrorMemory) + n, err := model.Where(entity.ErrorMemoryCol.MemoryKey, m.MemoryKey).Count() + if err != nil { + return + } + if n > 0 { + _, err = model.Where(entity.ErrorMemoryCol.MemoryKey, m.MemoryKey).Data(map[string]any{ + entity.ErrorMemoryCol.Retryable: m.Retryable, + entity.ErrorMemoryCol.Reason: m.Reason, + entity.ErrorMemoryCol.AnalyzedBy: m.AnalyzedBy, + }).Update() + return + } + _, err = model.Insert(m) + return +} + +// List 分页查询(按 id 倒序) +func (d *errorMemoryDao) List(ctx context.Context, page, pageSize int) (list []entity.ErrorMemory, total int64, err error) { + model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameErrorMemory) + total, err = model.Count() + if err != nil { + return + } + err = model.Page(page, pageSize).OrderDesc(entity.ErrorMemoryCol.Id).Scan(&list) + return +} + +// Delete 按 id 删除(软删除) +func (d *errorMemoryDao) Delete(ctx context.Context, id int64) (err error) { + _, err = gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameErrorMemory). + Where(entity.ErrorMemoryCol.Id, id).Delete() + return +} +``` + +- [ ] **Step 3: 编译验证** + +Run: `cd model-gateway && go build ./...` +Expected: PASS + +- [ ] **Step 4: 提交** + +```bash +cd model-gateway +git commit --only dao/error_memory_dao.go model/dto/error_memory_dao_dto.go -m "feat: 错误重试记忆DAO与DTO" +``` + +--- + +### Task 5: 对话模型错误分析调用 + +**Files:** +- Modify: `service/error_analysis.go`(追加 `analysisSystemPrompt`、`truncateStr`、`buildAnalysisBody`、`resolveAnalysisModel`、`callAnalysisLLM`) +- Modify: `service/error_analysis_test.go`(追加 body 构造与截断单测) + +**Interfaces:** +- Consumes: `parseAnalysisResponse`(Task 3)、`dto.GetChatModelReq/Res`、`service.ModelManage.GetChatModel`、`entity.ModelManage` +- Produces: `buildAnalysisBody(modelName, code, msg, body string) map[string]any`、`resolveAnalysisModel(ctx, *entity.ModelManage) (*entity.ModelManage, bool)`、`callAnalysisLLM(ctx, model, code, msg, body string) (bool, string, error)`、`truncateStr(s string, max int) string` + +- [ ] **Step 1: 追加失败测试到 `service/error_analysis_test.go`** + +```go +func TestBuildAnalysisBody(t *testing.T) { + body := buildAnalysisBody("doubao-lite", "429", "too many", strings.Repeat("x", 5000)) + msg := body["messages"].([]map[string]string)[1] + if len(msg["content"]) >= 2000+len("429")+len("too many")+100 { + t.Fatalf("响应体应被截断到2000字符, got %d", len(msg["content"])) + } + if body["model"] != "doubao-lite" { + t.Fatalf("model 字段错误") + } + if body["temperature"] != 0 { + t.Fatalf("temperature 应为0") + } +} + +func TestTruncateStr(t *testing.T) { + if truncateStr("abc", 5) != "abc" { + t.Fatalf("短串不应截断") + } + if truncateStr("abcdef", 3) != "abc" { + t.Fatalf("截断错误") + } +} +``` + +(需在文件头 import 增加 `"strings"`。) + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd model-gateway && go test ./service/ -run 'TestBuildAnalysisBody|TestTruncateStr' -v` +Expected: FAIL(函数未定义) + +- [ ] **Step 3: 追加实现到 `service/error_analysis.go`** + +在 `parseAnalysisResponse` 后追加常量、辅助函数与三个函数。**下面的 `import` 块并入文件顶部已有的 import 块**(Go 的 import 必须在文件顶部,勿放在函数之间): + +```go +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "model-gateway/model/dto" + "model-gateway/model/entity" +) + +const ( + analysisTimeout = 15 * time.Second + analysisMaxBody = 2000 + analysisMaxTokens = 256 +) + +const analysisSystemPrompt = `你是 AI 模型网关的错误分析器。上游 AI 模型调用返回了一个错误,你需要判断该错误是否"值得指数退避后重试"。 + +## 值得重试(retryable: true) +- 限流:429、rate limit、请求过密、并发超限 +- 服务端瞬时故障:5xx、服务过载、上游临时不可用、连接抖动 +- 资源暂时不足:quota 暂时受限 + +## 不值得重试(retryable: false) +- 请求/参数错误:400、invalid_argument、格式错误 +- 鉴权失败:401、403、invalid_api_key、签名错误 +- 模型不存在:404、model_not_found +- 余额不足:insufficient_quota +- 内容违规:内容安全拦截 +- 明确的永久性配置错误 + +## 输出 +只输出一个 JSON 对象,不要任何多余文字、解释或代码块标记: +{"retryable": true 或 false, "reason": "不超过20字的简要原因"}` + +// truncateStr 按字节截断到 max(中文可能截半个字符,仅用于分析输入,可接受) +func truncateStr(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} + +// buildAnalysisBody 构造分析请求体(OpenAI 兼容 messages 格式),纯函数便于单测。 +func buildAnalysisBody(modelName, code, msg, body string) map[string]any { + user := fmt.Sprintf("错误码: %s\n错误消息: %s\n错误响应体: %s", code, msg, truncateStr(body, analysisMaxBody)) + return map[string]any{ + "model": modelName, + "messages": []map[string]string{ + {"role": "system", "content": analysisSystemPrompt}, + {"role": "user", "content": user}, + }, + "max_tokens": analysisMaxTokens, + "temperature": 0, + } +} + +// resolveAnalysisModel 选择分析模型:失败模型自身是对话模型则复用,否则取当前用户对话模型。 +// 取不到返回 ok=false,调用方 fail-closed 不重试。 +func resolveAnalysisModel(ctx context.Context, modelInfo *entity.ModelManage) (model *entity.ModelManage, ok bool) { + if modelInfo != nil && modelInfo.ChatModel != nil && *modelInfo.ChatModel { + return modelInfo, true + } + chat, err := ModelManage.GetChatModel(ctx, &dto.GetChatModelReq{}) + if err == nil && chat != nil && chat.ModelManage != nil { + return chat.ModelManage, true + } + return nil, false +} + +// callAnalysisLLM 调分析模型(对话模型)判定错误是否可重试。 +// 独立短超时 http.Client;非 200 / 解析失败 / 超时 → 返回 err,调用方 fail-closed。 +func callAnalysisLLM(ctx context.Context, model *entity.ModelManage, code, msg, body string) (retryable bool, reason string, err error) { + reqBody, err := json.Marshal(buildAnalysisBody(model.ModelName, code, msg, body)) + if err != nil { + return false, "", fmt.Errorf("marshal分析请求失败: %w", err) + } + httpMethod := model.HttpMethod + if httpMethod == "" { + httpMethod = http.MethodPost + } + httpReq, err := http.NewRequestWithContext(ctx, httpMethod, strings.TrimRight(model.BaseURL, "/"), bytes.NewBuffer(reqBody)) + if err != nil { + return false, "", fmt.Errorf("创建分析请求失败: %w", err) + } + for k, v := range model.RequestHeadMapping { + httpReq.Header.Set(k, v) + } + httpReq.Header.Set("Authorization", "Bearer "+model.ApiKey) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: analysisTimeout} + resp, err := client.Do(httpReq) + if err != nil { + return false, "", fmt.Errorf("分析请求失败: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return false, "", fmt.Errorf("读取分析响应失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + return false, "", fmt.Errorf("分析接口非200: status=%d body=%s", resp.StatusCode, truncateStr(string(respBody), 500)) + } + var apiResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err = json.Unmarshal(respBody, &apiResp); err != nil { + return false, "", fmt.Errorf("解析分析响应失败: %w", err) + } + if len(apiResp.Choices) == 0 { + return false, "", fmt.Errorf("分析响应无choices") + } + return parseAnalysisResponse(apiResp.Choices[0].Message.Content) +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd model-gateway && go test ./service/ -run 'TestParseAnalysisResponse|TestBuildAnalysisBody|TestTruncateStr' -v` +Expected: PASS + +- [ ] **Step 5: 编译验证 + 提交** + +```bash +cd model-gateway +go build ./... && git add service/error_analysis.go service/error_analysis_test.go +git commit --only service/error_analysis.go service/error_analysis_test.go -m "feat: 对话模型错误分析调用" +``` + +--- + +### Task 6: 统一判定入口 shouldRetryWithMemory + singleflight + +**Files:** +- Modify: `go.mod` / `go.sum`(加 `golang.org/x/sync`) +- Create: `service/error_memory_service.go`(含 `analyzeOnce`、`shouldRetryWithMemory`;Task 8 同文件追加 List/Delete) + +**Interfaces:** +- Consumes: `buildMemoryKey`/`msgFingerprint`(Task 2)、`parseAnalysisResponse`(Task 3)、`dao.ErrorMemory`(Task 4)、`resolveAnalysisModel`/`callAnalysisLLM`(Task 5) +- Produces: `shouldRetryWithMemory(ctx, modelInfo *entity.ModelManage, code, msg, rawBody string) bool`(Task 7 消费)、`analyzeOnce(key string, fn func() (bool, string)) (bool, string)` + +- [ ] **Step 1: 加 singleflight 依赖** + +Run: `cd model-gateway && go get golang.org/x/sync@v0.19.0 && go mod tidy` +Expected: PASS(模块缓存已有 v0.19.0,离线可解析) + +- [ ] **Step 2: 写失败测试 `service/error_memory_service_test.go`** + +```go +package service + +import ( + "sync" + "testing" +) + +// analyzeOnce 应合并并发同键分析请求,只执行一次 fn +func TestAnalyzeOnceDedup(t *testing.T) { + var mu sync.Mutex + calls := 0 + fn := func() (bool, string) { + mu.Lock() + calls++ + mu.Unlock() + return true, "dedup" + } + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if retry, _ := analyzeOnce("k", fn); !retry { + t.Errorf("期望 retryable=true") + } + }() + } + wg.Wait() + if calls != 1 { + t.Fatalf("singleflight应只执行一次fn, got %d", calls) + } +} +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `cd model-gateway && go test ./service/ -run TestAnalyzeOnceDedup -v` +Expected: FAIL(analyzeOnce 未定义) + +- [ ] **Step 4: 实现 `service/error_memory_service.go`** + +```go +package service + +import ( + "context" + + "model-gateway/dao" + "model-gateway/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "golang.org/x/sync/singleflight" +) + +var analysisGroup singleflight.Group + +// shouldRetryWithMemory 统一重试判定:查持久记忆,未命中则调分析模型并落库。 +// 记忆/分析/DB 任一环节失败均 fail-closed 不重试。 +func shouldRetryWithMemory(ctx context.Context, modelInfo *entity.ModelManage, code, msg, rawBody string) (retry bool) { + if modelInfo == nil || (code == "" && msg == "") { + return false + } + key := buildMemoryKey(modelInfo.BaseURL, code, msg) + if row, err := dao.ErrorMemory.GetByKey(ctx, key); err != nil { + g.Log().Errorf(ctx, "查询错误重试记忆失败: %v", err) + return false + } else if row != nil { + return row.Retryable + } + retry, _ = analyzeOnce(key, func() (bool, string) { + model, ok := resolveAnalysisModel(ctx, modelInfo) + if !ok { + g.Log().Warningf(ctx, "无可用分析模型(对话模型),错误不重试: code=%s", code) + return false, "" + } + r, reason, err := callAnalysisLLM(ctx, model, code, msg, rawBody) + if err != nil { + g.Log().Warningf(ctx, "错误分析失败,fail-closed不重试: %v", err) + return false, "" + } + row := &entity.ErrorMemory{ + MemoryKey: key, + Upstream: modelInfo.BaseURL, + ErrorCode: code, + MsgFingerprint: msgFingerprint(msg), + Retryable: r, + Reason: reason, + AnalyzedBy: model.ModelName, + } + if err := dao.ErrorMemory.Upsert(ctx, row); err != nil { + g.Log().Errorf(ctx, "错误重试记忆落库失败: %v", err) + } + return r, reason + }) + return retry +} + +// analyzeOnce 按记忆键合并并发分析请求(singleflight)。 +// 注:fn 失败时结果在本次突发内共享(后续新错误会重新分析)。 +func analyzeOnce(key string, fn func() (bool, string)) (bool, string) { + v, err, _ := analysisGroup.Do(key, func() (any, error) { + retry, reason := fn() + return []any{retry, reason}, nil + }) + if err != nil { + return false, "" + } + vals := v.([]any) + return vals[0].(bool), vals[1].(string) +} +``` + +- [ ] **Step 5: 运行测试确认通过 + 编译** + +Run: `cd model-gateway && go test ./service/ -run TestAnalyzeOnceDedup -v && go build ./...` +Expected: PASS + PASS + +- [ ] **Step 6: 提交** + +```bash +cd model-gateway +git commit --only go.mod go.sum service/error_memory_service.go service/error_memory_service_test.go -m "feat: 重试判定入口shouldRetryWithMemory + singleflight" +``` + +--- + +### Task 7: 三路接入 LLM+记忆重试判定 + +> ⚠️ 本 task 修改的 `service/session_sync.go`、`service/session_stream.go` 含用户 WIP 重构(Global Constraints)。**执行前先让 WIP 落库/经用户确认**。 + +**Files:** +- Modify: `service/session_sync.go`(重试判定替换) +- Modify: `service/session_stream.go`(`streamRetryCodeOfError` → `streamErrorInfoOfError`;两处判定替换) +- Modify: `service/model_task_start_service.go`(新增重试循环) +- Modify: `service/retry.go`(删除 `isRetryableErrorCode`) + +**Interfaces:** +- Consumes: `shouldRetryWithMemory`(Task 6) +- Produces: 无(行为改造) + +- [ ] **Step 1: 删除 `service/retry.go` 的固定错误码清单** + +删除 `isRetryableErrorCode` 函数及其注释(保留 `modelCallMaxRetries`、`retryWait`、`firstText`、`extractChunkText`)。若删除后 `strings`/`gconv` import 变未使用,同步清理。 + +- [ ] **Step 2: 改同步路径 `service/session_sync.go`** + +把 `CreateSession` 中(约 L58-68): + +```go + if errCode != "" { + + if attempt < modelCallMaxRetries && isRetryableErrorCode(errCode) { + attempt++ + wait := time.Duration(1<= 0 { + codeStr := strings.TrimSpace(e[idx+len("状态码异常: "):]) + if comma := strings.IndexByte(codeStr, ','); comma >= 0 { + codeStr = codeStr[:comma] + } + return codeStr, "" + } + return "", "" +} +``` + +(2) `CreateSessionStreamOnce` HTTP 错误重试点(约 L33-48)改为: + +```go + streamReader, err := httpclient.ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) + if err != nil { + if attempt < modelCallMaxRetries { + if code, msg := streamErrorInfoOfError(err); shouldRetryWithMemory(ctx, modelInfo, code, msg, "") { + attempt++ + wait := time.Duration(1< 0 { + page = int(req.Page.PageNum) + } + if req.Page != nil && req.Page.PageSize > 0 { + size = int(req.Page.PageSize) + } + list, total, err := dao.ErrorMemory.List(ctx, page, size) + if err != nil { + return nil, err + } + res = &dto.GetErrorMemoryListRes{Total: total, List: make([]dto.ErrorMemoryItem, 0, len(list))} + for _, m := range list { + res.List = append(res.List, dto.ErrorMemoryItem{ + Id: m.Id, + MemoryKey: m.MemoryKey, + Upstream: m.Upstream, + ErrorCode: m.ErrorCode, + MsgFingerprint: m.MsgFingerprint, + Retryable: m.Retryable, + Reason: m.Reason, + AnalyzedBy: m.AnalyzedBy, + }) + } + return res, nil +} + +// Delete 删除错误重试记忆(手动纠错永久记忆) +func (s *errorMemoryService) Delete(ctx context.Context, req *dto.DeleteErrorMemoryReq) (err error) { + return dao.ErrorMemory.Delete(ctx, req.Id) +} +``` + +> ⚠️ `service.ErrorMemory`(本服务)与 `dao.ErrorMemory` 同名不同包,合法。但 `shouldRetryWithMemory` 中引用的 `dao.ErrorMemory` 需保持包前缀,勿混。 + +- [ ] **Step 2: 新建 `controller/error_memory_controller.go`** + +```go +package controller + +import ( + "context" + "model-gateway/model/dto" + "model-gateway/service" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// ErrorMemory 错误重试记忆控制器 +var ErrorMemory = new(errorMemory) + +type errorMemory struct{} + +// List 错误重试记忆列表 +func (c *errorMemory) List(ctx context.Context, req *dto.GetErrorMemoryListReq) (res *dto.GetErrorMemoryListRes, err error) { + return service.ErrorMemory.List(ctx, req) +} + +// Delete 删除错误重试记忆 +func (c *errorMemory) Delete(ctx context.Context, req *dto.DeleteErrorMemoryReq) (res *beans.ResponseEmpty, err error) { + err = service.ErrorMemory.Delete(ctx, req) + return +} +``` + +- [ ] **Step 3: `main.go` 注册控制器** + +把路由注册改为: + +```go + http.RouteRegister([]interface{}{ + controller.ModelCall, + controller.ModelManage, + controller.ErrorMemory, + }) +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd model-gateway && go build ./...` +Expected: PASS + +- [ ] **Step 5: 手动验证端点(需运行环境)** + +- `GET /errorMemory/list?page[pageNum]=1&page[pageSize]=20` → 返回记忆条目 +- `POST /errorMemory/delete` body `{"id": 1}` → 删除成功,再次 list 不含该条 + +- [ ] **Step 6: 提交** + +```bash +cd model-gateway +git commit --only service/error_memory_service.go controller/error_memory_controller.go main.go -m "feat: 错误记忆管理端点" +``` + +--- + +## Self-Review + +- **Spec 覆盖**:spec 的 8 项决策(LLM 全判断/DB 知识库/键构成/二元输出/永久/对话模型/三路/内联+单飞)分别落在 Task 7、Task 1/4、Task 2、Task 3/5、Task 1/8、Task 5、Task 7、Task 6。管理端点 Task 8;测试策略分布各 Task 单测。无缺口。 +- **占位符扫描**:全部步骤含实际代码,无 TBD/TODO/"适当处理"类占位。 +- **类型一致性**:`shouldRetryWithMemory` 签名在 Task 6 定义、Task 7 三处调用一致(`code/msg string`、`rawBody string`);`buildMemoryKey(upstream, code, msg)` 与 `msgFingerprint(msg)` 命名全链一致;`streamErrorInfoOfError(err) (code, msg string)` 在 Task 7 定义与使用一致;DAO 方法签名 Task 4→Task 6/8 一致。`analysisSystemPrompt`/`analysisTimeout` 等常量单一定义处。 +- **已知行为**:singleflight 失败结果在突发内共享(后续新错误重新分析);永久记忆靠端点清理;task_start 重试可能重复建任务——均为 spec 已记录取舍。