147 lines
5.1 KiB
Go
147 lines
5.1 KiB
Go
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、服务过载、上游临时不可用、连接抖动
|
||
- 资源暂时不足: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)
|
||
}
|