Files
nl-game-api/internal/ai/llm.go
2026-08-14 13:17:03 +08:00

227 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package ai 用工厂模式封装对战 AI
// - LLM Provider讯飞星火 Lite / DeepSeekOpenAI 兼容接口)负责"思考"
// - 本地规则 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 解析模型输出中的决策 JSONchoice 越界视为失败
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
}