Files
model-gateway/service/error_analysis.go
T

149 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"model-gateway/model/dto"
"model-gateway/model/entity"
)
// 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(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)
}