=== 为什么这样设计(成本优先)===
全集群 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 路由断言
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
package services
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"lokistack/models"
|
||
)
|
||
|
||
// OpenAI 兼容 Chat Completions 客户端。
|
||
//
|
||
// 只依赖 /chat/completions 这一个端点,所以 DeepSeek、通义、
|
||
// 内网 vLLM、Ollama(/v1)、各类网关都能直接用,不需要按厂商写适配。
|
||
|
||
type aiMessage struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
type aiChatRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []aiMessage `json:"messages"`
|
||
Temperature float64 `json:"temperature"`
|
||
MaxTokens int `json:"max_tokens,omitempty"`
|
||
Stream bool `json:"stream"`
|
||
}
|
||
|
||
type aiChatResponse struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Content string `json:"content"`
|
||
} `json:"message"`
|
||
FinishReason string `json:"finish_reason"`
|
||
} `json:"choices"`
|
||
Usage struct {
|
||
PromptTokens int `json:"prompt_tokens"`
|
||
CompletionTokens int `json:"completion_tokens"`
|
||
TotalTokens int `json:"total_tokens"`
|
||
} `json:"usage"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
Type string `json:"type"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
// aiChat 调一次对话补全。
|
||
//
|
||
// 错误信息必须具体:把上游返回的 message 原样带出来,不要包装成
|
||
// "调用失败" —— 常见原因(key 错、模型名不存在、baseURL 路径少了 /v1、
|
||
// 内网模型没起)都在上游 message 里,藏起来只会让用户去猜。
|
||
func aiChat(ctx context.Context, cfg models.AIConfig, messages []aiMessage) (string, int, int, error) {
|
||
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Model) == "" {
|
||
return "", 0, 0, fmt.Errorf("未配置模型:请先在「智能运维 → 模型配置」填写 Base URL 与模型名")
|
||
}
|
||
url := strings.TrimSuffix(cfg.BaseURL, "/") + "/chat/completions"
|
||
body, err := json.Marshal(aiChatRequest{
|
||
Model: cfg.Model,
|
||
Messages: messages,
|
||
Temperature: cfg.Temperature,
|
||
MaxTokens: cfg.MaxTokens,
|
||
Stream: false,
|
||
})
|
||
if err != nil {
|
||
return "", 0, 0, err
|
||
}
|
||
|
||
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
||
if timeout <= 0 {
|
||
timeout = 120 * time.Second
|
||
}
|
||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", 0, 0, fmt.Errorf("构造请求失败: %w", err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if cfg.APIKey != "" {
|
||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||
}
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
if reqCtx.Err() == context.DeadlineExceeded {
|
||
return "", 0, 0, fmt.Errorf("模型响应超时(%d 秒):可调大「超时」或换更快的模型", cfg.TimeoutSec)
|
||
}
|
||
return "", 0, 0, fmt.Errorf("连接模型服务失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if resp.StatusCode >= 400 {
|
||
var parsed aiChatResponse
|
||
if json.Unmarshal(raw, &parsed) == nil && parsed.Error != nil && parsed.Error.Message != "" {
|
||
return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, parsed.Error.Message)
|
||
}
|
||
return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, truncateStr(string(raw), 300))
|
||
}
|
||
|
||
var parsed aiChatResponse
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
return "", 0, 0, fmt.Errorf("解析模型响应失败(可能不是 OpenAI 兼容接口): %s", truncateStr(string(raw), 200))
|
||
}
|
||
if len(parsed.Choices) == 0 {
|
||
return "", 0, 0, fmt.Errorf("模型没有返回任何内容(finish_reason 为空)")
|
||
}
|
||
return parsed.Choices[0].Message.Content, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, nil
|
||
}
|
||
|
||
// TestAIConnection 连通性测试:发一句最小请求,验证 baseURL/key/model 三件套。
|
||
func TestAIConnection(cfg models.AIConfig) (string, error) {
|
||
cfg.MaxTokens = 32
|
||
cfg.TimeoutSec = 30
|
||
reply, _, _, err := aiChat(context.Background(), cfg, []aiMessage{
|
||
{Role: "user", Content: "只回复两个字:正常"},
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.TrimSpace(reply), nil
|
||
}
|