227 lines
7.5 KiB
Go
227 lines
7.5 KiB
Go
// 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
|
||
}
|