Files
LokiStack/backend/services/ai_signal.go
T
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

280 lines
9.3 KiB
Go
Raw 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 (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"lokistack/models"
"lokistack/repository"
)
// AI 分析的"信号包":把 GB 级日志压成 KB 级结构化数据。
//
// 成本优先原则(P0 的硬约束):
// - 体积/趋势 → index/volume_range(实测 24h 仅 54ms,不读日志内容)
// - 条数/流数 → index/stats,只允许在 ≤2 小时窗口上用
// (全天 index/stats 实测 20 秒,绝不能进 AI 路径)
// - 应用排行 → 平台按小时预聚合的快照(毫秒级)
// - 告警 → 平台库
//
// 也就是说:**P0 完全不扫日志内容**,一次分析对 Loki 的压力接近 0。
type AISignalBundle struct {
SourceID uint `json:"sourceId"`
SourceName string `json:"sourceName"`
Selector string `json:"selector"`
From string `json:"from"`
To string `json:"to"`
RangeMinutes int `json:"rangeMinutes"`
TotalBytes int64 `json:"totalBytes"`
TotalEntries int64 `json:"totalEntries"`
// EntriesExact 条数是否为精确值(false = 范围过大跳过了 index/stats)
EntriesExact bool `json:"entriesExact"`
Streams int64 `json:"streams"`
Chunks int64 `json:"chunks"`
Trend []AITrendPoint `json:"trend"`
Apps []AISignalApp `json:"apps"`
Baseline *AIBaseline `json:"baseline,omitempty"`
Alerts []AIAlert `json:"alerts"`
// SnapshotHours 平台按小时预聚合已留存的小时数(影响排行的可信度)
SnapshotHours int `json:"snapshotHours"`
// Warnings 采集过程中的降级说明(如"范围过大,条数为估算")
Warnings []string `json:"warnings"`
}
// aiDisplayZone 结论中展示时间用的时区。
//
// 为什么不直接用 time.Local:静态编译的容器镜像通常没有 /usr/share/zoneinfo,
// TZ 也没设,time.Local 就是 UTC —— 结果是模型在结论里写"凌晨 6 点日志下跌",
// 而运维当时是下午 2 点,直接被当成数据错误。
// 默认东八区(本地运维环境),需要别的时区可用 LOKISTACK_DISPLAY_TZ=UTC 覆盖。
func aiDisplayZone() *time.Location {
if strings.EqualFold(strings.TrimSpace(os.Getenv("LOKISTACK_DISPLAY_TZ")), "UTC") {
return time.UTC
}
return time.FixedZone("CST", 8*3600)
}
type AITrendPoint struct {
Time string `json:"time"`
Bytes int64 `json:"bytes"`
}
type AISignalApp struct {
App string `json:"app"`
Namespace string `json:"namespace"`
Entries int64 `json:"entries"`
Bytes int64 `json:"bytes"`
Share string `json:"share"`
}
type AIBaseline struct {
// PreviousBytes 上一周期同时长的体积(等长窗口对比)
PreviousBytes int64 `json:"previousBytes"`
CurrentBytes int64 `json:"currentBytes"`
DeltaPercent float64 `json:"deltaPercent"`
ComparisonNote string `json:"comparisonNote"`
}
type AIAlert struct {
RuleName string `json:"ruleName"`
Status string `json:"status"`
Count int `json:"count"`
CreatedAt string `json:"createdAt"`
}
// CollectAISignals 采集一次分析所需的全部信号。
func CollectAISignals(ctx context.Context, src *models.LogSource, userID uint, admin bool, from, to time.Time) (*AISignalBundle, error) {
if src == nil {
return nil, fmt.Errorf("数据源不存在")
}
if !to.After(from) {
return nil, fmt.Errorf("时间范围无效")
}
// 复用查询授权:AI 能看到的数据范围必须与当前用户一致,不多不少
selector, err := AuthorizedLogQuery(src, userID, admin, `{namespace=~".+"}`)
if err != nil {
return nil, err
}
bundle := &AISignalBundle{
SourceID: src.ID,
SourceName: src.Name,
Selector: selector,
// 时间统一按本地时区展示:Loki 返回的是 UTC,
// 直接给模型 UTC 会让结论里出现"凌晨 6 点"这种和用户作息对不上的时间。
From: from.In(aiDisplayZone()).Format("2006-01-02 15:04"),
To: to.In(aiDisplayZone()).Format("2006-01-02 15:04"),
RangeMinutes: int(to.Sub(from).Minutes()),
Alerts: []AIAlert{},
Trend: []AITrendPoint{},
Apps: []AISignalApp{},
}
// ── 体积 + 趋势:volume_range(只读索引元数据)──
buckets, err := LokiHistogram(ctx, src, selector, from.UnixNano(), to.UnixNano())
if err != nil {
return nil, err
}
for _, b := range buckets {
var bytes int64
switch v := b["count"].(type) {
case int64:
bytes = v
case float64:
bytes = int64(v)
}
ts, _ := b["timestamp"].(string)
if parsed, perr := time.Parse(time.RFC3339, ts); perr == nil {
ts = parsed.In(aiDisplayZone()).Format("15:04")
}
bundle.TotalBytes += bytes
bundle.Trend = append(bundle.Trend, AITrendPoint{Time: ts, Bytes: bytes})
}
// 趋势只保留尾部若干桶,避免 prompt 过长
if len(bundle.Trend) > 24 {
bundle.Trend = bundle.Trend[len(bundle.Trend)-24:]
}
// ── 条数:仅短窗口走 index/stats(长窗口太慢,直接跳过并标注)──
if bundle.RangeMinutes <= 120 {
if st, statErr := LokiStatsCached(ctx, src, selector, from.UnixNano(), to.UnixNano()); statErr == nil {
bundle.TotalEntries = st.Entries
bundle.Streams = st.Streams
bundle.Chunks = st.Chunks
bundle.EntriesExact = true
} else {
bundle.Warnings = append(bundle.Warnings, "条数统计失败:"+truncateStr(statErr.Error(), 120))
}
} else {
bundle.Warnings = append(bundle.Warnings, "时间范围超过 2 小时,条数为估算(跳过 index/stats:全天统计实测需 20 秒)")
}
// ── 应用排行:平台按小时预聚合快照 ──
if rows := GetAppRanking(src.ID, from, to, 10, "entries"); len(rows) > 0 {
var total int64
for _, r := range rows {
total += r.Entries
}
for _, r := range rows {
share := ""
if total > 0 {
share = fmt.Sprintf("%.1f%%", float64(r.Entries)/float64(total)*100)
}
bundle.Apps = append(bundle.Apps, AISignalApp{
App: r.App, Namespace: r.Namespace, Entries: r.Entries, Bytes: r.Bytes, Share: share,
})
}
}
hours, _ := AppVolumeCoverageInfo(src.ID)
bundle.SnapshotHours = hours
if len(bundle.Apps) == 0 {
bundle.Warnings = append(bundle.Warnings, "应用排行暂无数据:平台按小时预聚合还在积累(当前留存 0 小时)")
}
// ── 基线对比:与上一个等长窗口比 ──
prevFrom := from.Add(-to.Sub(from))
prevTo := from
if prevBuckets, perr := LokiHistogram(ctx, src, selector, prevFrom.UnixNano(), prevTo.UnixNano()); perr == nil {
var prevBytes int64
for _, b := range prevBuckets {
switch v := b["count"].(type) {
case int64:
prevBytes += v
case float64:
prevBytes += int64(v)
}
}
if prevBytes > 0 || bundle.TotalBytes > 0 {
delta := 0.0
if prevBytes > 0 {
delta = (float64(bundle.TotalBytes) - float64(prevBytes)) / float64(prevBytes) * 100
}
bundle.Baseline = &AIBaseline{
PreviousBytes: prevBytes,
CurrentBytes: bundle.TotalBytes,
DeltaPercent: delta,
ComparisonNote: fmt.Sprintf("与上一个 %d 分钟等长窗口对比", bundle.RangeMinutes),
}
}
}
// ── 告警:窗口内的投递记录 ──
db := repository.GetDB()
if db != nil {
db.RLock()
for _, d := range db.AlertDeliveries {
if d.CreatedAt.Before(from) || d.CreatedAt.After(to) {
continue
}
bundle.Alerts = append(bundle.Alerts, AIAlert{
RuleName: d.RuleName, Status: d.Status, Count: d.Count,
CreatedAt: d.CreatedAt.In(aiDisplayZone()).Format("2006-01-02 15:04"),
})
}
db.RUnlock()
}
sort.Slice(bundle.Alerts, func(i, j int) bool { return bundle.Alerts[i].CreatedAt > bundle.Alerts[j].CreatedAt })
if len(bundle.Alerts) > 10 {
bundle.Alerts = bundle.Alerts[:10]
}
return bundle, nil
}
// SignalsAsText 把信号包渲染成给模型看的紧凑文本(不是 JSON,省 token)。
func SignalsAsText(b *AISignalBundle) string {
var sb strings.Builder
fmt.Fprintf(&sb, "数据源:%s\n", b.SourceName)
fmt.Fprintf(&sb, "时间范围:%s ~ %s(%d 分钟)\n", b.From, b.To, b.RangeMinutes)
fmt.Fprintf(&sb, "日志体积:%.2f GB\n", float64(b.TotalBytes)/1024/1024/1024)
if b.EntriesExact {
fmt.Fprintf(&sb, "日志条数:%d\n", b.TotalEntries)
} else {
sb.WriteString("日志条数:(范围过大未精确统计)\n")
}
if b.Streams > 0 || b.Chunks > 0 {
fmt.Fprintf(&sb, "流数/块数:%d / %d\n", b.Streams, b.Chunks)
}
if b.Baseline != nil {
fmt.Fprintf(&sb, "基线对比:上一等长窗口 %.2f GB → 当前 %.2f GB,变化 %+.1f%%(%s)\n",
float64(b.Baseline.PreviousBytes)/1024/1024/1024,
float64(b.Baseline.CurrentBytes)/1024/1024/1024,
b.Baseline.DeltaPercent, b.Baseline.ComparisonNote)
}
if len(b.Apps) > 0 {
sb.WriteString("\n应用日志量排行(来自平台小时级预聚合):\n")
for i, a := range b.Apps {
fmt.Fprintf(&sb, " %d. %s/%s 条数=%d 占比=%s\n", i+1, a.Namespace, a.App, a.Entries, a.Share)
}
}
if len(b.Trend) > 0 {
sb.WriteString("\n体积趋势(最近分桶):\n")
for _, t := range b.Trend {
fmt.Fprintf(&sb, " %s %.1f MB\n", t.Time, float64(t.Bytes)/1024/1024)
}
}
if len(b.Alerts) > 0 {
sb.WriteString("\n窗口内告警:\n")
for _, a := range b.Alerts {
fmt.Fprintf(&sb, " %s %s 命中=%d 状态=%s\n", a.CreatedAt, a.RuleName, a.Count, a.Status)
}
} else {
sb.WriteString("\n窗口内告警:无\n")
}
if len(b.Warnings) > 0 {
sb.WriteString("\n数据限制说明:\n")
for _, w := range b.Warnings {
fmt.Fprintf(&sb, " - %s\n", w)
}
}
return sb.String()
}