// Package ai 用工厂模式封装对战 AI: // - LLM Provider(讯飞星火 Lite / DeepSeek,OpenAI 兼容接口)负责"思考" // - 本地规则 AI 负责兜底(LLM 未配置、超时或返回非法决策时接管) // - 难度(easy/medium/hard)通过提示词与候选着法筛选实现 package ai import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "regexp" "strings" "time" "nl-game-api-gin/internal/config" ) // LLMClient OpenAI 兼容格式的大模型 HTTP 客户端 type LLMClient struct { BaseURL string // 接口基础地址(如 https://api.deepseek.com/v1) APIKey string // 鉴权密钥 Model string // 模型名(如 lite / deepseek-chat) http *http.Client // 复用的 HTTP 客户端 } // newLLMClient 根据配置构造客户端(Key 为空返回 nil 表示不可用) func newLLMClient(conf config.LLMConf) *LLMClient { if conf.APIKey == "" { return nil } return &LLMClient{ BaseURL: conf.BaseURL, APIKey: conf.APIKey, Model: conf.Model, http: &http.Client{Timeout: 20 * time.Second}, } } // chatMessage OpenAI 格式的对话消息 type chatMessage struct { Role string `json:"role"` // system / user / assistant Content string `json:"content"` // 消息内容 } // chatRequest OpenAI 格式的补全请求 type chatRequest struct { Model string `json:"model"` // 模型名 Messages []chatMessage `json:"messages"` // 对话消息 Temperature float64 `json:"temperature"` // 采样温度(难度越低越随机) MaxTokens int `json:"max_tokens"` // 最大生成长度 } // chatResponse OpenAI 格式的补全响应(只取需要的字段) type chatResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } // 并发抢答策略:每次决策同时发出 3 路请求,先通过校验者胜出;单路请求限时 6 秒 const ( chatParallel = 3 chatAttemptTimeout = 6 * time.Second ) // Chat 发送一轮对话请求(并发抢答 + 返回校验),返回模型输出文本。 // 同时异步发出 chatParallel 路相同请求,结果逐个校验: // 第一个通过校验的立即采用(其余在途请求自动取消);全部失败才汇总错误返回。 // 相比串行重试,网络抖动时无需退避等待,正常时延迟等于最快一路 func (c *LLMClient) Chat(ctx context.Context, system, user string, temperature float64) (string, error) { raceCtx, cancel := context.WithCancel(ctx) defer cancel() // 胜出后取消其余在途请求 results := make(chan error, chatParallel) contents := make(chan string, chatParallel) for i := 0; i < chatParallel; i++ { go func() { content, err := c.chatOnce(raceCtx, system, user, temperature) if err == nil { contents <- content results <- nil return } results <- err }() } // 等待全部返回并逐个校验(chatOnce 内已完成三层校验),一有合格结果立刻采用 uniqErrs := []string{} seen := map[string]bool{} for i := 0; i < chatParallel; i++ { select { case <-ctx.Done(): return "", fmt.Errorf("等待模型返回超时:%w", ctx.Err()) case err := <-results: if err == nil { return <-contents, nil } if msg := err.Error(); !seen[msg] { seen[msg] = true uniqErrs = append(uniqErrs, msg) } } } return "", fmt.Errorf("%d 路并发请求全部失败:%s", chatParallel, strings.Join(uniqErrs, ";")) } // chatOnce 单路请求 + 三层返回校验(HTTP 状态码 → 响应体结构 → 内容非空) func (c *LLMClient) chatOnce(ctx context.Context, system, user string, temperature float64) (content string, err error) { attemptCtx, cancel := context.WithTimeout(ctx, chatAttemptTimeout) defer cancel() body, _ := json.Marshal(chatRequest{ Model: c.Model, Messages: []chatMessage{ {Role: "system", Content: system}, {Role: "user", Content: user}, }, Temperature: temperature, MaxTokens: 300, }) req, err := http.NewRequestWithContext(attemptCtx, "POST", c.BaseURL+"/chat/completions", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) res, err := c.http.Do(req) if err != nil { return "", fmt.Errorf("请求失败:%w", err) } defer res.Body.Close() // 防御超大响应体:最多读 1MB raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) // 校验一:HTTP 状态码(错误信息带状态码与响应体片段,便于后台测试时定位) if res.StatusCode < 200 || res.StatusCode >= 300 { return "", fmt.Errorf("HTTP %d:%s", res.StatusCode, bodySnippet(raw)) } // 校验二:响应体必须是结构完整的 JSON var parsed chatResponse if jerr := json.Unmarshal(raw, &parsed); jerr != nil { return "", fmt.Errorf("响应不是合法 JSON:%s", bodySnippet(raw)) } if parsed.Error != nil { return "", errors.New(parsed.Error.Message) } if len(parsed.Choices) == 0 { return "", errors.New("模型未返回任何候选内容") } // 校验三:输出内容非空 content = strings.TrimSpace(parsed.Choices[0].Message.Content) if content == "" { return "", errors.New("模型返回了空内容") } return content, nil } // bodySnippet 截取响应体前 200 字符用于报错展示(避免日志被撑爆) func bodySnippet(raw []byte) string { s := strings.TrimSpace(string(raw)) if runes := []rune(s); len(runes) > 200 { s = string(runes[:200]) + "…" } if s == "" { return "(空响应体)" } return s } // ChatDecision 请求一次"从候选中选编号"的决策并校验输出: // 模型输出不是合法 JSON 或 choice 越界时,追加纠错指令自动重问一次(传输层故障由 Chat 的并发抢答兜底) func (c *LLMClient) ChatDecision(ctx context.Context, system, user string, temperature float64, maxChoice int) (*llmDecision, error) { sys := system var lastErr error for attempt := 0; attempt < 2; attempt++ { output, err := c.Chat(ctx, sys, user, temperature) if err != nil { return nil, err } d, perr := parseDecision(output, maxChoice) if perr == nil { return d, nil } lastErr = perr sys = system + fmt.Sprintf( "注意:你上一次的输出无法解析(%v)。必须只输出一个 JSON 对象,choice 取值范围 0~%d,不要任何多余文字。", perr, maxChoice) } return nil, fmt.Errorf("模型输出连续两次未通过校验:%w", lastErr) } // jsonBlockRe 从模型输出中提取第一个 JSON 对象(模型偶尔会包 markdown 代码块) var jsonBlockRe = regexp.MustCompile(`\{[\s\S]*\}`) // llmDecision LLM 决策的统一 JSON 结构:从候选列表中选一项 + 一句台词 type llmDecision struct { Choice int `json:"choice"` // 候选编号 Say string `json:"say"` // 台词(可为空) } // parseDecision 解析模型输出中的决策 JSON,choice 越界视为失败 func parseDecision(output string, maxChoice int) (*llmDecision, error) { match := jsonBlockRe.FindString(output) if match == "" { return nil, errors.New("输出中没有 JSON") } var d llmDecision if err := json.Unmarshal([]byte(match), &d); err != nil { return nil, err } if d.Choice < 0 || d.Choice > maxChoice { return nil, fmt.Errorf("choice=%d 越界", d.Choice) } // 台词过长时截断,避免刷屏 runes := []rune(d.Say) if len(runes) > 40 { d.Say = string(runes[:40]) } return &d, nil }