package service import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "model-gateway/model/dto" "model-gateway/model/entity" "github.com/gogf/gf/v2/frame/g" ) // 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 } const ( analysisTimeout = 15 * time.Second analysisMaxBody = 2000 analysisMaxTokens = 256 ) const analysisSystemPrompt = `你是 AI 模型网关的错误分析器。上游 AI 模型调用返回了一个错误,你需要判断该错误是否"值得指数退避后重试"。 ## 值得重试(retryable: true) - 限流:429、rate limit、请求过密、并发超限 - 服务端瞬时故障:5xx、InternalServiceError、服务过载、上游临时不可用 - 超时/取消/连接:Timeout、RequestCanceled、Error while connecting、连接抖动 - 媒体源暂不可用(视频/音频生成类上游常见):Invalid video_url、Invalid audio track、Error while downloading、download failed —— 通常是源尚未就绪或下载瞬断,重试可成功,不要误判为永久参数错误 - 资源暂时不足:quota 暂时受限 ## 不值得重试(retryable: false) - 请求/参数错误:400、invalid_argument、格式错误(注意 Invalid video_url / Invalid audio track 属上类的媒体源错误,不归此类) - 鉴权失败: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(ctx context.Context, modelName, code, msg, body string) map[string]any { user := fmt.Sprintf("错误码: %s\n错误消息: %s\n错误响应体: %s", code, msg, truncateStr(body, analysisMaxBody)) // 打印错误信息 g.Log().Debugf(ctx, "分析请求体: %s", user) 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(ctx, 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) }