Files

227 lines
7.5 KiB
Go
Raw Permalink Normal View History

2026-08-14 13:17:03 +08:00
// 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
}