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