Files
LokiStack/backend/services/ai_provider.go
T
opsx 448f695c28
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
fix(ai): 推理模型 content 为空时回退 reasoning_content
根因(带响应体日志实测确认):deepseek-v4-pro 等推理系模型把输出写在
reasoning_content,MaxTokens 较小时最终 content 为空串,被误判为「模型返回了
空内容(检查 baseURL)」——与 key/端点无关,保存的 key 解密与沿用逻辑均正常
(空参/掩码传参两路均能取到库内真值)。
修复:content 空白时回退使用 reasoning_content;移除调试日志。
2026-11-01 01:50:44 +08:00

218 lines
8.0 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"lokistack/repository"
"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"`
ReasoningContent string `json:"reasoning_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))
log.Printf("[ai-chat] url=%s model=%s keyLen=%d status=%d body=%.300s", url, cfg.Model, len(cfg.APIKey), resp.StatusCode, string(raw))
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 为空)")
}
content := parsed.Choices[0].Message.Content
// DeepSeek 推理系模型(deepseek-reasoner / v4-pro 高推理档)会把过程写在
// reasoning_content,最终 content 在 MaxTokens 太小时可能为空 —— 回退使用推理内容
if strings.TrimSpace(content) == "" {
content = parsed.Choices[0].Message.ReasoningContent
}
return content, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, nil
}
// AIConnectionTest 连通性测试结果:面向展示的结构化信息,不外发原始回复
type AIConnectionTest struct {
Model string `json:"model"`
LatencyMs int64 `json:"latencyMs"`
TokensUsed int `json:"tokensUsed"`
ReplyLength int `json:"replyLength"`
}
// TestAIConnection 连通性测试:发一句最小请求,验证 baseURL/key/model 三件套。
// 返回结构化结果(模型/耗时/token);模型原始回复不回传 —— 测试要的是"通没通、多快",不是看模型说了什么。
func TestAIConnection(cfg models.AIConfig) (*AIConnectionTest, error) {
cfg.MaxTokens = 32
cfg.TimeoutSec = 30
start := time.Now()
reply, promptTokens, completionTokens, err := aiChat(context.Background(), cfg, []aiMessage{
{Role: "user", Content: "只回复两个字:正常"},
})
if err != nil {
return nil, err
}
trimmed := strings.TrimSpace(reply)
if trimmed == "" {
return nil, fmt.Errorf("模型返回了空内容(检查 baseURL 是否为 OpenAI 兼容端点)")
}
return &AIConnectionTest{
Model: cfg.Model,
LatencyMs: time.Since(start).Milliseconds(),
TokensUsed: promptTokens + completionTokens,
ReplyLength: len([]rune(trimmed)),
}, nil
}
// ChatMessage 浮窗助手的多轮消息(user/assistant 交替)
type ChatMessage struct {
Role string `json:"role" binding:"required,oneof=user assistant"`
Content string `json:"content" binding:"required"`
}
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。
func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) {
if !cfg.Enabled {
return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存")
}
if cfg.DailyTokenCap > 0 && AITokenUsedToday(userID) >= cfg.DailyTokenCap {
return "", fmt.Errorf("今日 AI 用量已达上限,明天再试或调整上限")
}
// 提示词设计:身份固定(不猜底层模型)、风格收敛、话题回引导、上下文数据
system := `你是 LokiStack 日志平台内置的运维助手。
规则:
- 回答不超过 5 句话,优先给排查动作,其次给结论。
- 被问"你是什么模型/版本"时,只答一句:"我是 LokiStack 内置运维助手,底层模型由管理员在「智能运维 → 模型配置」中指定。"然后引导回日志排查。
- 只讨论日志、Loki、采集、告警与平台使用;无关话题(写代码、闲聊、其它产品)用一句礼貌拒绝并引导回排查。
- 引用数据时只使用下方"日志概况"中的数字,不得编造。`
// 页面上下文(如 /logs 刚执行的查询与样本日志):截断并统一脱敏后并入 system
if trimmed := strings.TrimSpace(pageContext); trimmed != "" {
if len([]rune(trimmed)) > 6000 {
trimmed = string([]rune(trimmed)[:6000])
}
system += "\n\n" + RedactTextOnly(trimmed, cfg.RedactExtra)
}
msgs := []aiMessage{{Role: "system", Content: system}}
for _, m := range history {
msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content})
}
// 脱敏:用户消息里的敏感串同样替换
if cfg.RedactExtra != nil {
for i := range msgs {
msgs[i].Content = RedactTextOnly(msgs[i].Content, cfg.RedactExtra)
}
}
reply, promptTokens, completionTokens, err := aiChat(ctx, cfg, msgs)
if err != nil {
return "", err
}
// 计入每日配额:落一条 chat 类型用量记录
db := repository.GetDB()
if db != nil {
db.Lock()
db.AIAnalysisRuns = append(db.AIAnalysisRuns, models.AIAnalysisRun{
UserID: userID, Username: "chat", SourceID: 0, SourceName: page,
Status: "chat", PromptTokens: promptTokens, CompletionTokens: completionTokens,
CreatedAt: time.Now(),
})
db.RequestSave()
db.Unlock()
}
return reply, nil
}