=== 为什么这样设计(成本优先)===
全集群 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 路由断言
186 lines
4.7 KiB
Go
186 lines
4.7 KiB
Go
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
|
||
}
|