feat: 对话模型错误分析调用
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("截断错误")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user