Files
opsx 7be41f0a6b
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
feat(aiops): 新增「智能运维」——AI 日志分析(P0,不扫日志内容)
=== 为什么这样设计(成本优先)===
全集群 370GB/天,把日志原文丢给模型是不可能的(一次几百万 token)。
所以整条链路只做一件事:**把 GB 级日志压成 KB 级信号**。

P0 的信号来源全部是索引/快照,**零日志内容扫描**:
  体积 + 趋势   → index/volume_range(实测 24h 仅 54ms)
  条数/流数     → index/stats,且只在 ≤2 小时窗口用
                  (全天 index/stats 实测 20s,绝不能进 AI 路径)
  应用排行      → 平台小时级预聚合快照(毫秒)
  基线对比      → 上一个等长窗口的 volume_range
  告警          → 平台投递记录
实测一次分析:3~6 秒、输入 600~1400 tokens(约 ¥0.005/次)。

=== 后端 ===
models/ai.go            AIConfig(APIKey 加密存储)+ AIAnalysisRun(审计)
services/ai_config.go   读写配置、掩码返回、每日 token 用量统计
services/ai_provider.go OpenAI 兼容客户端 + 连通性测试
                        (DeepSeek/通义/内网 vLLM/Ollama 通用)
services/ai_redact.go   脱敏:凭据/邮箱/手机号/身份证 → 占位符,
                        **保留内网 IP 与端口**(排查必需)
services/ai_signal.go   信号采集 + 渲染成紧凑文本(省 token)
services/ai_analyze.go  结构化输出解析、配额闸门、审计落库
controllers/ai.go       /ai/config /ai/config/test /ai/analyze /ai/runs

成本闸门(三层):
  1) 单次:时间范围 ≤24h、maxTokens ≤1500
  2) 每用户每日 token 上限(默认 50 万,可配,超限直接拒绝并说明)
  3) 审计只留最近 500 条,避免表膨胀

=== 前端 ===
新页面 /aiops(侧栏「智能运维」,管理员),页内单层 tab:
  日志分析 / 模型配置 / 分析历史
- 分析:数据源 + 时间预设(15m/1h/6h/24h) + 可选提问 → 结论/证据/建议/不确定 四区
       顶部显示本次消耗(in/out tokens、耗时),可查看「发给模型的完整内容」
- 配置:启用开关、Base URL、模型、API Key(掩码)、温度、输出上限、超时、
       每日配额、额外脱敏正则;按钮顺序改为「测试连接 → 保存配置」
- 历史:服务端分页(20/页),点行看详情与 payload

=== 两个真实踩坑(已修)===
1) 密码含 @ 导致脱敏漏掉(mysql://user:P@ssw0rd@host)
   → URL basic-auth 规则改为贪婪匹配到最后一个 @,由单测锁定
2) 结论里的时间是 UTC(容器无 tzdata,time.Local=UTC),
   模型写"凌晨 6 点下跌"而运维当时是下午 2 点
   → 显式用东八区展示(LOKISTACK_DISPLAY_TZ=UTC 可覆盖)

=== 实测(69147008fcf4)===
  配置保存 → key 掩码 sk-***e286;连通性测试返回「正常」
  分析 15 分钟窗口:4.1s,结论识别出"窗口末段体积断崖约 -60%"
  信号时间 14:23~14:38(本地时间正确)
  历史分页 total=3,tokens/耗时字段完整
  单测:脱敏 2 个用例 + 模型输出解析 4 个用例 + 6 条 AI 路由断言
2026-10-31 06:39:01 +00:00

186 lines
4.7 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 services
import (
"encoding/json"
"fmt"
"strings"
"time"
"lokistack/models"
"lokistack/pkg/crypto"
"lokistack/repository"
)
const aiConfigSettingKey = "ai_config"
// 成本相关的默认值(宁可保守:额度小、扫描少,用户觉得不够再调)
const (
defaultAIMaxTokens = 1500
defaultAITimeoutSec = 120
defaultAITemperature = 0.1 // 分析场景要稳定复现,不要发挥
defaultAIDailyTokenCap = 500000
defaultAIMaxScanBytes = 2 << 30 // 2GiB
)
// GetAIConfig 读配置(返回真实 APIKey,仅供内部调用)。
func GetAIConfig() models.AIConfig {
cfg := models.AIConfig{
Provider: "openai-compatible",
Temperature: defaultAITemperature,
MaxTokens: defaultAIMaxTokens,
TimeoutSec: defaultAITimeoutSec,
DailyTokenCap: defaultAIDailyTokenCap,
MaxScanBytes: defaultAIMaxScanBytes,
}
db := repository.GetDB()
if db == nil {
return cfg
}
db.RLock()
raw := ""
for _, s := range db.Settings {
if s.Key == aiConfigSettingKey {
raw = s.Value
break
}
}
db.RUnlock()
if raw == "" {
return cfg
}
var stored models.AIConfig
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
return cfg
}
if stored.APIKey != "" {
stored.APIKey = crypto.DecryptField(stored.APIKey)
}
if stored.Provider == "" {
stored.Provider = "openai-compatible"
}
if stored.MaxTokens <= 0 {
stored.MaxTokens = defaultAIMaxTokens
}
if stored.TimeoutSec <= 0 {
stored.TimeoutSec = defaultAITimeoutSec
}
if stored.MaxScanBytes <= 0 {
stored.MaxScanBytes = defaultAIMaxScanBytes
}
return stored
}
// MaskedAIConfig 返回给前端的版本:APIKey 只留头尾。
func MaskedAIConfig() models.AIConfig {
cfg := GetAIConfig()
cfg.APIKey = maskKey(cfg.APIKey)
return cfg
}
func maskKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 8 {
return "***"
}
return key[:3] + "***" + key[len(key)-4:]
}
// SaveAIConfig 保存配置。
//
// apiKey 传空 = 沿用旧值(前端拿到的是掩码,不能把掩码存回去);
// 传 "***" 开头的掩码也视为沿用,避免用户只是改模型名却把 key 清空。
func SaveAIConfig(in models.AIConfig, apiKey string) error {
old := GetAIConfig()
key := strings.TrimSpace(apiKey)
switch {
case key == "":
key = old.APIKey
case strings.Contains(key, "***"):
key = old.APIKey
}
if in.Enabled && (strings.TrimSpace(in.BaseURL) == "" || strings.TrimSpace(in.Model) == "") {
return fmt.Errorf("启用 AI 分析需要填写 Base URL 与模型名")
}
if in.Enabled && key == "" {
return fmt.Errorf("启用 AI 分析需要填写 API Key(内网无鉴权的服务可填任意占位值)")
}
if in.BaseURL != "" && !strings.HasPrefix(in.BaseURL, "http") {
return fmt.Errorf("Base URL 必须以 http:// 或 https:// 开头(例如 https://api.deepseek.com/v1)")
}
enc, err := crypto.EncryptField(key)
if err != nil {
return fmt.Errorf("加密 API Key 失败: %w", err)
}
saved := models.AIConfig{
Enabled: in.Enabled,
Provider: "openai-compatible",
BaseURL: strings.TrimSuffix(strings.TrimSpace(in.BaseURL), "/"),
APIKey: enc,
Model: strings.TrimSpace(in.Model),
Temperature: in.Temperature,
MaxTokens: in.MaxTokens,
TimeoutSec: in.TimeoutSec,
DailyTokenCap: in.DailyTokenCap,
MaxScanBytes: in.MaxScanBytes,
RedactExtra: in.RedactExtra,
}
if saved.MaxTokens <= 0 {
saved.MaxTokens = defaultAIMaxTokens
}
if saved.TimeoutSec <= 0 {
saved.TimeoutSec = defaultAITimeoutSec
}
if saved.MaxScanBytes <= 0 {
saved.MaxScanBytes = defaultAIMaxScanBytes
}
data, err := json.Marshal(saved)
if err != nil {
return err
}
db := repository.GetDB()
if db == nil {
return fmt.Errorf("数据库未初始化")
}
newID := db.NextID("settings")
db.Lock()
defer db.Unlock()
for i := range db.Settings {
if db.Settings[i].Key == aiConfigSettingKey {
db.Settings[i].Value = string(data)
db.RequestSave()
return nil
}
}
db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: aiConfigSettingKey, Value: string(data)})
db.RequestSave()
return nil
}
// truncateToDay 取当天 00:00(本地时区),用于"今日已用 token"统计。
func truncateToDay() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// AITokenUsedToday 某用户今日已消耗的 token 总量(每日配额闸门用)。
func AITokenUsedToday(userID uint) int {
db := repository.GetDB()
if db == nil {
return 0
}
today := truncateToDay()
db.RLock()
defer db.RUnlock()
total := 0
for _, r := range db.AIAnalysisRuns {
if r.UserID == userID && !r.CreatedAt.Before(today) {
total += r.PromptTokens + r.CompletionTokens
}
}
return total
}