feat(aiops): 新增「智能运维」——AI 日志分析(P0,不扫日志内容)
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

=== 为什么这样设计(成本优先)===
全集群 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 路由断言
This commit is contained in:
opsx committed 2026-10-31 06:39:01 +00:00
1 parent 3861b8b128
commit 7be41f0a6b
19 files changed
+2236 -1

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
package services
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"lokistack/models"
"lokistack/repository"
)
// AIAnalysisResult 结构化结论。
//
// 强制模型输出这个结构而不是自由文本,原因:
// 1. 前端能分区渲染(结论/证据/建议/不确定),可读性远好于一段散文;
// 2. 每条结论都要挂 evidence,逼模型把话落在数据上,降低编造;
// 3. unknowns 单独一栏,明确"不知道什么"比编一个答案有价值得多。
type AIAnalysisResult struct {
Summary string `json:"summary"`
Findings []AIFinding `json:"findings"`
Actions []string `json:"actions"`
Unknowns []string `json:"unknowns"`
RawText string `json:"-"`
ParseErr string `json:"parseError,omitempty"`
}
type AIFinding struct {
Title string `json:"title"`
Evidence string `json:"evidence"`
Severity string `json:"severity"` // high | medium | low
}
// AIAnalysisOutcome 一次分析的完整返回(含成本信息)。
type AIAnalysisOutcome struct {
Result AIAnalysisResult `json:"result"`
Signals *AISignalBundle `json:"signals"`
Prompt string `json:"prompt"`
Run models.AIAnalysisRun `json:"run"`
Warnings []string `json:"warnings"`
}
const aiSystemPrompt = `你是资深 SRE,负责分析 Loki 日志平台的运行状况。
严格规则:
1. 只能基于我给你的"信号数据"下结论。**不许编造**日志内容、错误信息、时间点或服务名。
2. 每一条结论都必须在 evidence 里引用信号数据中的具体数字或条目。
3. 如果信号数据不足以判断根因,把它写进 unknowns,不要猜。
4. 你看不到日志原文(只有统计与排行),因此不要描述"日志里写了什么"。
5. 输出必须是**单个 JSON 对象**,不要 markdown 代码块,不要任何解释性前后缀。
JSON 结构:
{
"summary": "两三句话的整体判断",
"findings": [{"title": "简短结论", "evidence": "引用具体数据", "severity": "high|medium|low"}],
"actions": ["可执行的下一步动作,按优先级排序"],
"unknowns": ["无法从当前数据判断的点"]
}`
// RunAIAnalysis 执行一次分析:采集信号 → 脱敏 → 调模型 → 解析 → 落审计。
func RunAIAnalysis(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, username string, admin bool, from, to time.Time, question string) (*AIAnalysisOutcome, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("AI 分析未启用:请先在「智能运维 → 模型配置」中开启并保存")
}
// ── 每日配额闸门(成本控制第一层)──
if cfg.DailyTokenCap > 0 {
used := AITokenUsedToday(userID)
if used >= cfg.DailyTokenCap {
run := models.AIAnalysisRun{
UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name,
Status: "quota-exceeded", Error: fmt.Sprintf("今日 AI token 用量已达上限(%d/%d)", used, cfg.DailyTokenCap),
CreatedAt: time.Now(),
}
persistAIRun(&run)
return nil, fmt.Errorf("今日 AI 用量已达上限(%d/%d tokens),可在模型配置中调整上限或明天再试", used, cfg.DailyTokenCap)
}
}
started := time.Now()
signals, err := CollectAISignals(ctx, src, userID, admin, from, to)
if err != nil {
return nil, err
}
// 扫描预算闸门:P0 全是索引/快照,实际扫描为 0,这里留出扩展位
if cfg.MaxScanBytes > 0 && signals.TotalBytes > cfg.MaxScanBytes*8 {
signals.Warnings = append(signals.Warnings,
fmt.Sprintf("该范围日志体积 %.1f GB 偏大,已只使用索引统计(未读取日志内容)",
float64(signals.TotalBytes)/1024/1024/1024))
}
// ── 脱敏(第二层:隐私)──
rawPrompt := SignalsAsText(signals)
redacted, invalidRules := RedactText(rawPrompt, cfg.RedactExtra)
if question = strings.TrimSpace(question); question != "" {
redacted += "\n\n运维补充的问题:" + RedactTextOnly(question, cfg.RedactExtra)
}
warnings := append([]string{}, signals.Warnings...)
for _, r := range invalidRules {
warnings = append(warnings, "额外脱敏规则无效已跳过:"+r)
}
// ── 调模型 ──
reply, promptTokens, completionTokens, callErr := aiChat(ctx, cfg, []aiMessage{
{Role: "system", Content: aiSystemPrompt},
{Role: "user", Content: redacted},
})
run := models.AIAnalysisRun{
UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name,
RangeMinutes: signals.RangeMinutes, Model: cfg.Model, Question: question,
PromptPayload: truncateStr(redacted, 20000),
PromptTokens: promptTokens, CompletionTokens: completionTokens,
ScanBytes: 0, // P0 不扫内容
DurationMs: time.Since(started).Milliseconds(),
CreatedAt: time.Now(),
}
if callErr != nil {
run.Status = "failed"
run.Error = callErr.Error()
run.DurationMs = time.Since(started).Milliseconds()
persistAIRun(&run)
return nil, callErr
}
result := parseAIResult(reply)
conclusion, _ := json.Marshal(result)
run.Status = "ok"
run.Conclusion = string(conclusion)
run.DurationMs = time.Since(started).Milliseconds()
persistAIRun(&run)
return &AIAnalysisOutcome{
Result: result, Signals: signals, Prompt: redacted, Run: run, Warnings: warnings,
}, nil
}
// RedactTextOnly 只脱敏不做规则校验提示(用于短文本如用户提问)。
func RedactTextOnly(text string, extra []string) string {
out, _ := RedactText(text, extra)
return out
}
// parseAIResult 宽松解析模型输出。
//
// 模型常见的"不听话"有三种:包了 ```json 代码块、前后加了解释、
// 直接返回纯文本。前两种剥壳后重试,第三种降级为 summary=原文,
// 保证用户至少能看到内容,而不是一个"解析失败"。
func parseAIResult(reply string) AIAnalysisResult {
text := strings.TrimSpace(reply)
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
if idx := strings.Index(text, "{"); idx > 0 {
text = text[idx:]
}
if end := strings.LastIndex(text, "}"); end >= 0 && end < len(text)-1 {
text = text[:end+1]
}
var parsed AIAnalysisResult
if err := json.Unmarshal([]byte(text), &parsed); err == nil && (parsed.Summary != "" || len(parsed.Findings) > 0) {
return parsed
}
return AIAnalysisResult{
Summary: strings.TrimSpace(reply),
Findings: []AIFinding{},
Actions: []string{},
Unknowns: []string{},
RawText: reply,
ParseErr: "模型未按 JSON 格式返回,已按纯文本展示",
}
}
// persistAIRun 落审计(同时用于每日 token 统计)。
func persistAIRun(run *models.AIAnalysisRun) {
db := repository.GetDB()
if db == nil {
return
}
newID := db.NextID("aiAnalysisRuns")
db.Lock()
defer db.Unlock()
run.ID = newID
db.AIAnalysisRuns = append(db.AIAnalysisRuns, *run)
// 只保留最近 500 条,避免审计表无限膨胀(明细可通过日志追溯)
if len(db.AIAnalysisRuns) > 500 {
db.AIAnalysisRuns = db.AIAnalysisRuns[len(db.AIAnalysisRuns)-500:]
}
db.RequestSave()
}
// ListAIRuns 分页查询审计记录(按时间倒序)。
func ListAIRuns(page, pageSize int) ([]models.AIAnalysisRun, int) {
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
db := repository.GetDB()
if db == nil {
return nil, 0
}
db.RLock()
all := make([]models.AIAnalysisRun, len(db.AIAnalysisRuns))
copy(all, db.AIAnalysisRuns)
db.RUnlock()
total := len(all)
// 倒序(新的在前)
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
all[i], all[j] = all[j], all[i]
}
start := (page - 1) * pageSize
if start >= total {
return []models.AIAnalysisRun{}, total
}
end := start + pageSize
if end > total {
end = total
}
return all[start:end], total
}
// GetAIRun 取单条审计记录(用于查看完整 payload)。
func GetAIRun(id uint) *models.AIAnalysisRun {
db := repository.GetDB()
if db == nil {
return nil
}
db.RLock()
defer db.RUnlock()
for i := range db.AIAnalysisRuns {
if db.AIAnalysisRuns[i].ID == id {
run := db.AIAnalysisRuns[i]
return &run
}
}
return nil
}