From bd649cb52706d33fd3a8820c40363ecfa21520c6 Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Tue, 1 Sep 2026 10:35:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=88=86=E6=9E=90=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/error_analysis.go | 118 +++++++++++++++++++++++++++++++++ service/error_analysis_test.go | 28 +++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/service/error_analysis.go b/service/error_analysis.go index c1232f2..8a6e6bc 100644 --- a/service/error_analysis.go +++ b/service/error_analysis.go @@ -1,9 +1,17 @@ package service import ( + "bytes" + "context" "encoding/json" "fmt" + "io" + "net/http" "strings" + "time" + + "model-gateway/model/dto" + "model-gateway/model/entity" ) // parseAnalysisResponse 解析分析模型输出的判定 JSON。 @@ -26,3 +34,113 @@ func parseAnalysisResponse(content string) (retryable bool, reason string, err e } return obj.Retryable, obj.Reason, nil } + +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) +} diff --git a/service/error_analysis_test.go b/service/error_analysis_test.go index 992d93e..94a5124 100644 --- a/service/error_analysis_test.go +++ b/service/error_analysis_test.go @@ -1,6 +1,9 @@ package service -import "testing" +import ( + "strings" + "testing" +) func TestParseAnalysisResponse(t *testing.T) { cases := []struct { @@ -41,3 +44,26 @@ func TestParseAnalysisResponseReason(t *testing.T) { t.Fatalf("reason = %q, want 服务过载", reason) } } + +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("截断错误") + } +}