对照指南逐条评估后落地 4 项真实差距(MCP/Agent Loop/白名单等重型章节不适用,明确排除): - 上下文预算(指南 07 TrimMessages):历史按 24k 字符预算从最旧裁剪,system 标注 「更早的对话已省略」;抽 trimHistory 独立函数+单测(超预算丢最旧/小历史不裁) - 配额超限诚实终态(指南 05.3/06 禁止伪装 completed):不再 400 报错,返回提示 文案;前端渲染为 warning 提示条样式,对话上下文保留 - 回复脱敏(指南 08):assistant 回复同样过 RedactTextOnly,堵住模型复述样本 带回敏感串的通道 - 退避重试(指南 05.2):429/5xx/网络错误指数退避 1s→2s 最多 2 次(计入总超时), 鉴权/参数错不重试
272 lines
10 KiB
Go
272 lines
10 KiB
Go
package services
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"lokistack/repository"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"lokistack/models"
|
||
)
|
||
|
||
// OpenAI 兼容 Chat Completions 客户端。
|
||
//
|
||
// 只依赖 /chat/completions 这一个端点,所以 DeepSeek、通义、
|
||
// 内网 vLLM、Ollama(/v1)、各类网关都能直接用,不需要按厂商写适配。
|
||
|
||
type aiMessage struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
type aiChatRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []aiMessage `json:"messages"`
|
||
Temperature float64 `json:"temperature"`
|
||
MaxTokens int `json:"max_tokens,omitempty"`
|
||
Stream bool `json:"stream"`
|
||
}
|
||
|
||
type aiChatResponse struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Content string `json:"content"`
|
||
ReasoningContent string `json:"reasoning_content"`
|
||
} `json:"message"`
|
||
FinishReason string `json:"finish_reason"`
|
||
} `json:"choices"`
|
||
Usage struct {
|
||
PromptTokens int `json:"prompt_tokens"`
|
||
CompletionTokens int `json:"completion_tokens"`
|
||
TotalTokens int `json:"total_tokens"`
|
||
} `json:"usage"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
Type string `json:"type"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
// aiChat 调一次对话补全。
|
||
//
|
||
// 错误信息必须具体:把上游返回的 message 原样带出来,不要包装成
|
||
// "调用失败" —— 常见原因(key 错、模型名不存在、baseURL 路径少了 /v1、
|
||
// 内网模型没起)都在上游 message 里,藏起来只会让用户去猜。
|
||
func aiChat(ctx context.Context, cfg models.AIConfig, messages []aiMessage) (string, int, int, error) {
|
||
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Model) == "" {
|
||
return "", 0, 0, fmt.Errorf("未配置模型:请先在「智能运维 → 模型配置」填写 Base URL 与模型名")
|
||
}
|
||
url := strings.TrimSuffix(cfg.BaseURL, "/") + "/chat/completions"
|
||
body, err := json.Marshal(aiChatRequest{
|
||
Model: cfg.Model,
|
||
Messages: messages,
|
||
Temperature: cfg.Temperature,
|
||
MaxTokens: cfg.MaxTokens,
|
||
Stream: false,
|
||
})
|
||
if err != nil {
|
||
return "", 0, 0, err
|
||
}
|
||
|
||
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
||
if timeout <= 0 {
|
||
timeout = 120 * time.Second
|
||
}
|
||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", 0, 0, fmt.Errorf("构造请求失败: %w", err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if cfg.APIKey != "" {
|
||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||
}
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
if reqCtx.Err() == context.DeadlineExceeded {
|
||
return "", 0, 0, fmt.Errorf("模型响应超时(%d 秒):可调大「超时」或换更快的模型", cfg.TimeoutSec)
|
||
}
|
||
return "", 0, 0, fmt.Errorf("连接模型服务失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
log.Printf("[ai-chat] url=%s model=%s keyLen=%d status=%d body=%.300s", url, cfg.Model, len(cfg.APIKey), resp.StatusCode, string(raw))
|
||
if resp.StatusCode >= 400 {
|
||
var parsed aiChatResponse
|
||
if json.Unmarshal(raw, &parsed) == nil && parsed.Error != nil && parsed.Error.Message != "" {
|
||
return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, parsed.Error.Message)
|
||
}
|
||
return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, truncateStr(string(raw), 300))
|
||
}
|
||
|
||
var parsed aiChatResponse
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
return "", 0, 0, fmt.Errorf("解析模型响应失败(可能不是 OpenAI 兼容接口): %s", truncateStr(string(raw), 200))
|
||
}
|
||
if len(parsed.Choices) == 0 {
|
||
return "", 0, 0, fmt.Errorf("模型没有返回任何内容(finish_reason 为空)")
|
||
}
|
||
content := parsed.Choices[0].Message.Content
|
||
// DeepSeek 推理系模型(deepseek-reasoner / v4-pro 高推理档)会把过程写在
|
||
// reasoning_content,最终 content 在 MaxTokens 太小时可能为空 —— 回退使用推理内容
|
||
if strings.TrimSpace(content) == "" {
|
||
content = parsed.Choices[0].Message.ReasoningContent
|
||
}
|
||
return content, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, nil
|
||
}
|
||
|
||
// AIConnectionTest 连通性测试结果:面向展示的结构化信息,不外发原始回复
|
||
type AIConnectionTest struct {
|
||
Model string `json:"model"`
|
||
LatencyMs int64 `json:"latencyMs"`
|
||
TokensUsed int `json:"tokensUsed"`
|
||
ReplyLength int `json:"replyLength"`
|
||
}
|
||
|
||
// TestAIConnection 连通性测试:发一句最小请求,验证 baseURL/key/model 三件套。
|
||
// 返回结构化结果(模型/耗时/token);模型原始回复不回传 —— 测试要的是"通没通、多快",不是看模型说了什么。
|
||
func TestAIConnection(cfg models.AIConfig) (*AIConnectionTest, error) {
|
||
cfg.MaxTokens = 32
|
||
cfg.TimeoutSec = 30
|
||
start := time.Now()
|
||
reply, promptTokens, completionTokens, err := aiChat(context.Background(), cfg, []aiMessage{
|
||
{Role: "user", Content: "只回复两个字:正常"},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
trimmed := strings.TrimSpace(reply)
|
||
if trimmed == "" {
|
||
return nil, fmt.Errorf("模型返回了空内容(检查 baseURL 是否为 OpenAI 兼容端点)")
|
||
}
|
||
return &AIConnectionTest{
|
||
Model: cfg.Model,
|
||
LatencyMs: time.Since(start).Milliseconds(),
|
||
TokensUsed: promptTokens + completionTokens,
|
||
ReplyLength: len([]rune(trimmed)),
|
||
}, nil
|
||
}
|
||
|
||
// ChatMessage 浮窗助手的多轮消息(user/assistant 交替)
|
||
type ChatMessage struct {
|
||
Role string `json:"role" binding:"required,oneof=user assistant"`
|
||
Content string `json:"content" binding:"required"`
|
||
}
|
||
|
||
// trimHistory 按字符预算从最旧开始裁剪对话历史(指南 07 TrimMessages);
|
||
// 返回保留的历史与是否发生了裁剪(调用方在 system 里标注省略)
|
||
func trimHistory(history []ChatMessage, budget int) ([]ChatMessage, bool) {
|
||
var kept []ChatMessage
|
||
total := 0
|
||
truncated := false
|
||
for i := len(history) - 1; i >= 0; i-- {
|
||
size := len([]rune(history[i].Content))
|
||
if total+size > budget && len(kept) > 0 {
|
||
truncated = true
|
||
break
|
||
}
|
||
kept = append([]ChatMessage{{Role: history[i].Role, Content: history[i].Content}}, kept...)
|
||
total += size
|
||
}
|
||
return kept, truncated
|
||
}
|
||
|
||
// isRetryableAIError 网络抖动/限流/上游临时故障才值得重试;鉴权与参数错重试无意义
|
||
func isRetryableAIError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
msg := err.Error()
|
||
return strings.Contains(msg, "429") ||
|
||
strings.Contains(msg, "500") || strings.Contains(msg, "502") ||
|
||
strings.Contains(msg, "503") || strings.Contains(msg, "504") ||
|
||
strings.Contains(msg, "connection refused") || strings.Contains(msg, "context deadline exceeded") ||
|
||
strings.Contains(msg, "EOF") || strings.Contains(msg, "timeout")
|
||
}
|
||
|
||
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
|
||
// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。
|
||
func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) {
|
||
if !cfg.Enabled {
|
||
return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存")
|
||
}
|
||
if cfg.DailyTokenCap > 0 && AITokenUsedToday(userID) >= cfg.DailyTokenCap {
|
||
// 指南 05.3:超限不伪装成功 —— 结构化 notice 而非报错,对话上下文在前端保留
|
||
return "\u2014\u2014 今日 AI 配额已用完(" + fmt.Sprintf("%d tokens),明天再试或在「AI 模型接入」调整上限 \u2014\u2014", cfg.DailyTokenCap), nil
|
||
}
|
||
|
||
// 提示词设计:身份固定(不猜底层模型)、风格收敛、话题回引导、上下文数据
|
||
system := `你是 LokiStack 日志平台内置的运维助手。
|
||
规则:
|
||
- 回答不超过 5 句话,优先给排查动作,其次给结论。
|
||
- 被问"你是什么模型/版本"时,只答一句:"我是 LokiStack 内置运维助手,底层模型由管理员在「智能运维 → 模型配置」中指定。"然后引导回日志排查。
|
||
- 只讨论日志、Loki、采集、告警与平台使用;无关话题(写代码、闲聊、其它产品)用一句礼貌拒绝并引导回排查。
|
||
- 引用数据时只使用下方"日志概况"中的数字,不得编造。`
|
||
|
||
// 页面上下文(如 /logs 刚执行的查询与样本日志):截断并统一脱敏后并入 system
|
||
if trimmed := strings.TrimSpace(pageContext); trimmed != "" {
|
||
if len([]rune(trimmed)) > 6000 {
|
||
trimmed = string([]rune(trimmed)[:6000])
|
||
}
|
||
system += "\n\n" + RedactTextOnly(trimmed, cfg.RedactExtra)
|
||
}
|
||
// 上下文预算(指南 07 TrimMessages):按字符预算从最旧开始丢弃,优先保留近期对话
|
||
trimmed, truncated := trimHistory(history, 24000)
|
||
if truncated {
|
||
system += "\n(更早的对话已省略)"
|
||
}
|
||
msgs := []aiMessage{{Role: "system", Content: system}}
|
||
for _, m := range trimmed {
|
||
msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content})
|
||
}
|
||
// 脱敏:用户消息里的敏感串同样替换
|
||
if cfg.RedactExtra != nil {
|
||
for i := range msgs {
|
||
msgs[i].Content = RedactTextOnly(msgs[i].Content, cfg.RedactExtra)
|
||
}
|
||
}
|
||
// 指南 05.2:429/5xx/网络错误指数退避重试(1s→2s,最多 2 次);鉴权/参数错不重试
|
||
var reply string
|
||
var promptTokens, completionTokens int
|
||
var err error
|
||
for attempt := 0; ; attempt++ {
|
||
reply, promptTokens, completionTokens, err = aiChat(ctx, cfg, msgs)
|
||
if err == nil || attempt >= 2 || !isRetryableAIError(err) {
|
||
break
|
||
}
|
||
backoff := time.Duration(1<<attempt) * time.Second
|
||
log.Printf("[ai-chat] 可重试错误(%v),%s 后第 %d 次重试", err, backoff, attempt+1)
|
||
select {
|
||
case <-time.After(backoff):
|
||
case <-ctx.Done():
|
||
return "", ctx.Err()
|
||
}
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
reply = RedactTextOnly(reply, cfg.RedactExtra)
|
||
// 计入每日配额:落一条 chat 类型用量记录
|
||
db := repository.GetDB()
|
||
if db != nil {
|
||
db.Lock()
|
||
db.AIAnalysisRuns = append(db.AIAnalysisRuns, models.AIAnalysisRun{
|
||
UserID: userID, Username: "chat", SourceID: 0, SourceName: page,
|
||
Status: "chat", PromptTokens: promptTokens, CompletionTokens: completionTokens,
|
||
CreatedAt: time.Now(),
|
||
})
|
||
db.RequestSave()
|
||
db.Unlock()
|
||
}
|
||
return reply, nil
|
||
}
|