Spring AI 学习篇(十五)| Spring AI应用的可观测性

Spring AI 学习篇(十五)| Spring AI应用的可观测性

一、本章核心学习目标

学完本章,你将能够:

  1. 深刻理解AI应用可观测性的三大支柱与核心价值
  2. 实现模型调用的全链路日志追踪与性能监控
  3. 精准统计每次调用的token消耗、耗时与成本
  4. 建立向量数据库的性能监控与调优体系
  5. 实现用户行为分析与反馈闭环
  6. 搭建Prometheus + Grafana企业级AI监控看板
  7. 建立告警机制,第一时间发现和定位线上问题

二、前置知识准备

  • 已经完成前14篇的学习,拥有一个完整的智能办公Agent项目
  • 了解Spring Boot Actuator和Micrometer基础
  • 熟悉日志框架(SLF4J/Logback)的基本使用
  • 了解Prometheus和Grafana的基本概念

三、为什么AI应用的可观测性比传统应用更重要?

传统Web应用出问题,通常是接口报错、数据库超时这类确定性故障,排查路径清晰。但AI应用完全不同------它的大部分问题都是"说不清哪里不对,但效果就是不好"

AI应用特有的三大观测难题

  1. 模型调用是黑盒:你不知道大模型内部发生了什么,只能通过输入输出来判断。一次调用耗时从200ms到30s都有可能,完全取决于模型当时的负载。
  2. 成本不可见:每次调用消耗的token数量不同,如果没有精确统计,月底收到账单时可能会吓一跳。一个用户一天问100个问题,可能消耗几十万token,也可能消耗几百万token。
  3. 效果无法量化:传统应用的"正确"是二元的(接口返回200就是正确),但AI应用的"正确"是模糊的------回答准确吗?有没有幻觉?用户满意吗?这些都无法通过简单的错误码来判断。

一句话总结:传统应用的可观测性告诉你"系统有没有挂",AI应用的可观测性告诉你"系统有没有在好好工作"。后者远比前者复杂。

四、可观测性的三大支柱:日志、指标、链路追踪

在AI应用中,我们需要同时关注三个维度:

复制代码
┌─────────────────────────────────────────────────────────┐
│                  AI应用可观测性体系                      │
├─────────────────┬─────────────────┬─────────────────────┤
│   日志(Logs)    │  指标(Metrics)  │  链路追踪(Traces)   │
├─────────────────┼─────────────────┼─────────────────────┤
│ 发生了什么      │ 趋势和数量      │ 整个调用链路       │
│ 模型输入输出    │ token消耗趋势   │ 用户→API→模型→返回 │
│ 错误堆栈        │ 延迟分布        │ RAG检索链路        │
│ 审计记录        │ 成功率          │ Agent工具调用链    │
│ 用户反馈        │ 成本曲线        │ 向量检索耗时分布   │
└─────────────────┴─────────────────┴─────────────────────┘

五、日志体系:从零散到体系化

1. AI应用日志的四层分级策略

复制代码
ERROR   → 模型调用失败、工具执行异常、向量数据库连接断开
WARN    → token消耗异常、响应时间过长、检索结果为0、重试触发
INFO    → 每次模型调用的输入输出摘要、token消耗、耗时
DEBUG   → 完整的模型输入输出内容、向量检索详细结果、提示词内容

2. 模型调用日志的完整实现

我们先实现一个模型调用日志切面,自动记录每次AI调用的完整信息:

java 复制代码
package com.example.ai.monitoring.logging;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;

@Aspect
@Component
public class AiCallLoggingAspect {

    private static final Logger aiLogger = LoggerFactory.getLogger("AI-MODEL-CALL");
    private static final Logger costLogger = LoggerFactory.getLogger("AI-COST-REPORT");

    @Around("execution(* org.springframework.ai.chat.client.ChatClient.call(..))")
    public Object logAiCall(ProceedingJoinPoint joinPoint) throws Throwable {
        Instant start = Instant.now();
        String methodName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();

        try {
            // 记录输入摘要
            if (args.length > 0 && args[0] != null) {
                aiLogger.info("[AI调用开始] 方法: {}, 输入长度: {} 字符",
                        methodName,
                        args[0].toString().length());
            }

            Object result = joinPoint.proceed();

            Instant end = Instant.now();
            long elapsed = Duration.between(start, end).toMillis();

            // 提取并记录token使用情况
            if (result instanceof ChatResponse response) {
                Usage usage = response.getMetadata().getUsage();
                if (usage != null) {
                    aiLogger.info("[AI调用完成] 方法: {}, 耗时: {}ms, " +
                                    "输入token: {}, 输出token: {}, 总计token: {}",
                            methodName, elapsed,
                            usage.getPromptTokens(),
                            usage.getGenerationTokens(),
                            usage.getTotalTokens());

                    // 成本日志(精确到分)
                    // DeepSeek: 输入 0.001元/1K tokens, 输出 0.002元/1K tokens
                    double inputCost = usage.getPromptTokens() / 1000.0 * 0.001;
                    double outputCost = usage.getGenerationTokens() / 1000.0 * 0.002;
                    double totalCost = inputCost + outputCost;

                    costLogger.info("[AI成本] 调用: {}, 输入费用: {} 元, 输出费用: {} 元, 总费用: {} 元",
                            methodName,
                            String.format("%.6f", inputCost),
                            String.format("%.6f", outputCost),
                            String.format("%.6f", totalCost));
                }
            }

            return result;
        } catch (Throwable e) {
            Instant end = Instant.now();
            long elapsed = Duration.between(start, end).toMillis();
            aiLogger.error("[AI调用失败] 方法: {}, 耗时: {}ms, 错误: {}",
                    methodName, elapsed, e.getMessage(), e);
            throw e;
        }
    }
}

3. RAG检索日志

RAG系统有自己的日志需求------你需要知道每次检索召回了多少结果、每条结果的相似度分数、以及最终选择了哪些分块送入大模型:

java 复制代码
@Component
public class RagRetrievalLogger {

    private static final Logger ragLogger = LoggerFactory.getLogger("RAG-RETRIEVAL");

    public void logRetrieval(String queryId, String query, List<Document> rawResults,
                              List<Document> rerankedResults, List<Document> finalResults) {
        ragLogger.info("[RAG检索] 查询ID: {}, 查询语句: {}", queryId, query);
        ragLogger.info("[RAG检索] 原始召回: {} 条", rawResults.size());

        // 记录Top 5召回结果的相似度分数
        rawResults.stream().limit(5).forEach(doc ->
                ragLogger.debug("[RAG检索] 召回结果: 分数={}, 来源={}, 摘要={}",
                        doc.getMetadata().getOrDefault("score", "N/A"),
                        doc.getMetadata().getOrDefault("source", "N/A"),
                        truncate(doc.getContent(), 100))
        );

        ragLogger.info("[RAG检索] 重排序后: {} 条, 最终使用: {} 条",
                rerankedResults.size(), finalResults.size());

        // 当检索结果为0时发出警告
        if (rawResults.isEmpty()) {
            ragLogger.warn("[RAG检索告警] 查询召回为0! 查询ID: {}, 查询语句: {}", queryId, query);
        }

        // 当重排序后有效结果过少时发出警告
        if (finalResults.size() < 3) {
            ragLogger.warn("[RAG检索告警] 有效结果不足! 查询ID: {}, 最终结果: {} 条", queryId, finalResults.size());
        }
    }

    private String truncate(String content, int maxLength) {
        return content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
    }
}

4. Agent执行日志

Agent的执行链路比普通模型调用复杂得多------它包含多轮思考、工具调用和结果处理。我们需要记录每一步的执行情况:

java 复制代码
@Component
public class AgentExecutionLogger {

    private static final Logger agentLogger = LoggerFactory.getLogger("AGENT-EXECUTION");

    public void logAgentStart(String sessionId, String task) {
        agentLogger.info("[Agent开始] 会话ID: {}, 任务: {}", sessionId, task);
    }

    public void logReActStep(String sessionId, int stepNumber, String thought, String action) {
        agentLogger.info("[Agent步骤{}] 会话ID: {}, 思考: {}, 行动: {}",
                stepNumber, sessionId, truncate(thought, 200), action);
    }

    public void logToolCall(String sessionId, String toolName, String params, long elapsedMs, boolean success) {
        if (success) {
            agentLogger.info("[工具调用成功] 会话ID: {}, 工具: {}, 参数: {}, 耗时: {}ms",
                    sessionId, toolName, params, elapsedMs);
        } else {
            agentLogger.error("[工具调用失败] 会话ID: {}, 工具: {}, 参数: {}, 耗时: {}ms",
                    sessionId, toolName, params, elapsedMs);
        }
    }

    public void logAgentComplete(String sessionId, int totalSteps, long totalTimeMs, boolean success) {
        agentLogger.info("[Agent结束] 会话ID: {}, 总步骤: {}, 总耗时: {}ms, 状态: {}",
                sessionId, totalSteps, totalTimeMs, success ? "成功" : "失败");
    }

    private String truncate(String content, int maxLength) {
        return content != null && content.length() > maxLength
                ? content.substring(0, maxLength) + "..." : content;
    }
}

六、指标监控:让数字说话

日志告诉你"发生了什么",指标告诉你"趋势是什么"。日志查问题,指标发现问题。

1. 核心监控指标体系设计

我们使用Micrometer + Prometheus来采集和暴露指标:

java 复制代码
package com.example.ai.monitoring.metrics;

import io.micrometer.core.instrument.*;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class AiMetricsCollector {

    private final MeterRegistry meterRegistry;

    // 模型调用计数器(按提供商和模型分组)
    private Counter modelCallTotal;
    private Counter modelCallSuccess;
    private Counter modelCallFailure;

    // Token消耗计数器
    private Counter inputTokensTotal;
    private Counter outputTokensTotal;

    // 模型调用耗时分布
    private Timer modelCallLatency;

    // RAG检索计数器
    private Counter ragQueryTotal;
    private Counter ragQueryEmptyResult; // 检索结果为0的次数
    private Timer ragRetrievalLatency;

    // Agent执行计数器
    private Counter agentTaskTotal;
    private Counter agentTaskSuccess;
    private Counter agentTaskFailure;
    private DistributionSummary agentStepsDistribution;

    // 成本累计
    private Counter totalCostInFen; // 总成本,以分为单位

    public AiMetricsCollector(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @PostConstruct
    public void init() {
        // 模型调用指标
        this.modelCallTotal = Counter.builder("ai.model.call.total")
                .description("模型调用总次数")
                .tag("provider", "deepseek")
                .register(meterRegistry);

        this.modelCallSuccess = Counter.builder("ai.model.call.success")
                .description("模型调用成功次数")
                .register(meterRegistry);

        this.modelCallFailure = Counter.builder("ai.model.call.failure")
                .description("模型调用失败次数")
                .register(meterRegistry);

        this.inputTokensTotal = Counter.builder("ai.tokens.input.total")
                .description("输入token总数")
                .register(meterRegistry);

        this.outputTokensTotal = Counter.builder("ai.tokens.output.total")
                .description("输出token总数")
                .register(meterRegistry);

        this.modelCallLatency = Timer.builder("ai.model.call.latency")
                .description("模型调用耗时")
                .publishPercentiles(0.5, 0.95, 0.99) // P50, P95, P99
                .register(meterRegistry);

        // RAG检索指标
        this.ragQueryTotal = Counter.builder("ai.rag.query.total")
                .description("RAG查询总次数")
                .register(meterRegistry);

        this.ragQueryEmptyResult = Counter.builder("ai.rag.query.empty_result")
                .description("RAG查询返回空结果次数")
                .register(meterRegistry);

        this.ragRetrievalLatency = Timer.builder("ai.rag.retrieval.latency")
                .description("RAG检索耗时")
                .register(meterRegistry);

        // Agent执行指标
        this.agentTaskTotal = Counter.builder("ai.agent.task.total")
                .description("Agent任务总数")
                .register(meterRegistry);

        this.agentTaskSuccess = Counter.builder("ai.agent.task.success")
                .description("Agent任务成功数")
                .register(meterRegistry);

        this.agentTaskFailure = Counter.builder("ai.agent.task.failure")
                .description("Agent任务失败数")
                .register(meterRegistry);

        this.agentStepsDistribution = DistributionSummary.builder("ai.agent.steps")
                .description("Agent任务步骤数分布")
                .publishPercentiles(0.5, 0.95)
                .register(meterRegistry);

        this.totalCostInFen = Counter.builder("ai.cost.total.fen")
                .description("AI调用总成本(分)")
                .register(meterRegistry);
    }

    // 记录模型调用成功
    public void recordModelCallSuccess(String provider, String model,
                                        int inputTokens, int outputTokens, long latencyMs) {
        modelCallTotal.increment();
        modelCallSuccess.increment();
        inputTokensTotal.increment(inputTokens);
        outputTokensTotal.increment(outputTokens);
        modelCallLatency.record(latencyMs, TimeUnit.MILLISECONDS);

        // 成本计算(精确到分,1分=0.01元)
        double costInFen = inputTokens / 1000.0 * 0.1   // 输入: 0.001元/1K = 0.1分/1K
                         + outputTokens / 1000.0 * 0.2;   // 输出: 0.002元/1K = 0.2分/1K
        totalCostInFen.increment(costInFen);
    }

    // 记录模型调用失败
    public void recordModelCallFailure() {
        modelCallTotal.increment();
        modelCallFailure.increment();
    }

    // 记录RAG检索
    public void recordRagQuery(int resultCount, long latencyMs) {
        ragQueryTotal.increment();
        if (resultCount == 0) {
            ragQueryEmptyResult.increment();
        }
        ragRetrievalLatency.record(latencyMs, TimeUnit.MILLISECONDS);
    }

    // 记录Agent任务
    public void recordAgentTask(boolean success, int steps) {
        agentTaskTotal.increment();
        if (success) {
            agentTaskSuccess.increment();
        } else {
            agentTaskFailure.increment();
        }
        agentStepsDistribution.record(steps);
    }
}

2. 在业务代码中埋点

先定义一个标记注解 @AiMonitored

java 复制代码
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AiMonitored {
    String provider() default "deepseek";
    String model() default "deepseek-chat";
}

然后用 AOP 切面自动采集指标:

java 复制代码
@Aspect
@Component
public class ModelCallMetricsAspect {

    private final AiMetricsCollector metricsCollector;

    public ModelCallMetricsAspect(AiMetricsCollector metricsCollector) {
        this.metricsCollector = metricsCollector;
    }

    @Around("@annotation(aiMonitored)")
    public Object monitor(AiMonitored aiMonitored, ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed();
            long elapsed = System.currentTimeMillis() - start;

            if (result instanceof ChatResponse response) {
                Usage usage = response.getMetadata().getUsage();
                if (usage != null) {
                    metricsCollector.recordModelCallSuccess(
                            aiMonitored.provider(),
                            aiMonitored.model(),
                            usage.getPromptTokens(),
                            usage.getGenerationTokens(),
                            elapsed
                    );
                }
            }
            return result;
        } catch (Throwable e) {
            metricsCollector.recordModelCallFailure();
            throw e;
        }
    }
}

使用方式:在想监控的方法上加 @AiMonitored(provider = "deepseek", model = "deepseek-chat") 即可。

3. Prometheus配置与Grafana看板

application.yml中暴露Prometheus端点:

yaml 复制代码
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  metrics:
    export:
      prometheus:
        enabled: true
    tags:
      application: ${spring.application.name}

推荐的AI应用Grafana监控面板布局

复制代码
┌──────────────────────────────────────────────────────────────┐
│ 第一行:核心KPI                                               │
│ [总调用次数] [成功率%] [P99耗时] [今日成本] [活跃用户数]     │
├──────────────────────────────────────────────────────────────┤
│ 第二行:趋势图                                               │
│ [模型调用QPS曲线] [Token消耗趋势] [成本累计曲线]            │
├────────────────────────────┬─────────────────────────────────┤
│ 第三行:分布图              │ 第四行:RAG与Agent             │
│ [调用耗时分布(P50/P95/P99)]│ [RAG检索召回数分布]            │
│ [Token消耗分布]             │ [Agent任务成功率]              │
│ [错误率按错误类型]          │ [Agent步骤数分布]              │
└────────────────────────────┴─────────────────────────────────┘

七、告警机制:不要等到用户投诉才知道出问题

1. AI应用告警规则设计

与传统应用不同,AI应用需要关注一些特殊的告警指标:

java 复制代码
package com.example.ai.monitoring.alerting;

import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;

@Component
public class AiAlertRules {

    private final MeterRegistry meterRegistry;
    private final AlertNotifier alertNotifier;

    public AiAlertRules(MeterRegistry meterRegistry, AlertNotifier alertNotifier) {
        this.meterRegistry = meterRegistry;
        this.alertNotifier = alertNotifier;
    }

    @PostConstruct
    public void registerAlertChecks() {
        // 这里简化展示告警逻辑,生产环境建议使用Prometheus AlertManager
    }

    /**
     * 检查模型调用成功率,低于95%告警
     */
    @Scheduled(fixedDelay = 60000) // 每分钟检查一次
    public void checkModelSuccessRate() {
        double successRate = getSuccessRate();
        if (successRate < 95.0) {
            alertNotifier.sendAlert(AlertLevel.WARNING,
                    "模型调用成功率低于95%",
                    String.format("当前成功率: %.2f%%,请检查模型服务状态", successRate));
        }
        if (successRate < 80.0) {
            alertNotifier.sendAlert(AlertLevel.CRITICAL,
                    "模型调用成功率低于80%",
                    String.format("当前成功率: %.2f%%,模型服务可能宕机", successRate));
        }
    }

    /**
     * 检查P99延迟,超过10秒告警
     */
    @Scheduled(fixedDelay = 60000)
    public void checkP99Latency() {
        double p99Latency = getP99Latency();
        if (p99Latency > 10000) { // 10秒
            alertNotifier.sendAlert(AlertLevel.WARNING,
                    "模型调用P99延迟超过10秒",
                    String.format("当前P99延迟: %.0fms,可能影响用户体验", p99Latency));
        }
    }

    /**
     * 检查每日成本,超过预算告警
     */
    @Scheduled(fixedDelay = 3600000) // 每小时检查一次
    public void checkDailyCost() {
        double dailyCost = getDailyCost();
        if (dailyCost > 100) { // 超过100元
            alertNotifier.sendAlert(AlertLevel.WARNING,
                    "AI调用日成本超过100元",
                    String.format("今日成本: %.2f元,请检查是否有异常调用", dailyCost));
        }
    }

    /**
     * RAG检索结果为空率过高告警
     */
    @Scheduled(fixedDelay = 300000) // 每5分钟检查
    public void checkRagEmptyRate() {
        double emptyRate = getRagEmptyRate();
        if (emptyRate > 30.0) {
            alertNotifier.sendAlert(AlertLevel.WARNING,
                    "RAG检索结果为空比例过高",
                    String.format("空结果比例: %.2f%%,请检查知识库内容和检索配置", emptyRate));
        }
    }

    private double getSuccessRate() {
        double total = meterRegistry.get("ai.model.call.total").counter().count();
        double success = meterRegistry.get("ai.model.call.success").counter().count();
        return total > 0 ? (success / total) * 100 : 100;
    }

    private double getP99Latency() {
        return meterRegistry.get("ai.model.call.latency").timer().takeSnapshot().percentileValue(0.99);
    }

    private double getDailyCost() {
        // 简化的日成本统计,实际应通过时间窗口计算
        return meterRegistry.get("ai.cost.total.fen").counter().count() / 100.0;
    }

    private double getRagEmptyRate() {
        double total = meterRegistry.get("ai.rag.query.total").counter().count();
        double empty = meterRegistry.get("ai.rag.query.empty_result").counter().count();
        return total > 0 ? (empty / total) * 100 : 0;
    }
}

2. 告警通知多渠道实现

java 复制代码
@Component
public class AlertNotifier {

    private static final Logger log = LoggerFactory.getLogger(AlertNotifier.class);

    public void sendAlert(AlertLevel level, String title, String detail) {
        // 1. 日志记录(最基础)
        if (level == AlertLevel.CRITICAL) {
            log.error("[AI告警-严重] {} - {}", title, detail);
        } else {
            log.warn("[AI告警-警告] {} - {}", title, detail);
        }

        // 2. 企业微信通知(推荐)
        sendWecomNotification(level, title, detail);

        // 3. 邮件通知(严重级别)
        if (level == AlertLevel.CRITICAL) {
            sendEmailNotification(title, detail);
        }
    }

    private void sendWecomNotification(AlertLevel level, String title, String detail) {
        // 发送企业微信机器人消息
        // 实际代码需要调用企业微信Webhook API
    }

    private void sendEmailNotification(String title, String detail) {
        // 发送邮件通知给运维和开发团队
    }
}

enum AlertLevel {
    WARNING, CRITICAL
}

八、用户行为分析与反馈闭环

可观测性不只是看系统指标,还要看用户行为。用户的行为数据是最真实的"效果评估"。

1. 用户行为埋点

java 复制代码
@Component
public class UserBehaviorTracker {

    private static final Logger behaviorLogger = LoggerFactory.getLogger("USER-BEHAVIOR");

    /**
     * 记录用户提问
     */
    public void trackQuery(String userId, String sessionId, String query, String intent) {
        behaviorLogger.info("[用户行为] 类型: 提问, 用户: {}, 会话: {}, 意图: {}, 问题: {}",
                userId, sessionId, intent, query);
    }

    /**
     * 记录用户对回答的操作(关键指标)
     */
    public void trackFeedback(String userId, String sessionId, String queryId,
                               FeedbackType type, String comment) {
        behaviorLogger.info("[用户行为] 类型: 反馈, 用户: {}, 查询ID: {}, 反馈: {}, 评论: {}",
                userId, queryId, type, comment);

        // 负面反馈需要重点关注
        if (type == FeedbackType.THUMBS_DOWN || type == FeedbackType.REPORT_INACCURATE) {
            behaviorLogger.warn("[用户行为告警] 负面反馈! 用户: {}, 查询ID: {}, 类型: {}, 评论: {}",
                    userId, queryId, type, comment);
        }
    }

    /**
     * 记录用户复制了答案(正向信号)
     */
    public void trackCopy(String userId, String queryId) {
        behaviorLogger.info("[用户行为] 类型: 复制答案, 用户: {}, 查询ID: {}", userId, queryId);
    }

    /**
     * 记录用户追问(回答不够好,需要追问)
     */
    public void trackFollowUp(String userId, String sessionId, String originalQueryId) {
        behaviorLogger.info("[用户行为] 类型: 追问, 用户: {}, 会话: {}, 原始查询ID: {}",
                userId, sessionId, originalQueryId);
    }

    enum FeedbackType {
        THUMBS_UP,          // 点赞
        THUMBS_DOWN,        // 点踩
        REPORT_INACCURATE,  // 举报不准确
        REPORT_IRRELEVANT   // 举报不相关
    }
}

2. 反馈数据驱动优化

用户反馈不只是"看看",而是要形成闭环,驱动系统持续优化:

java 复制代码
@Service
public class FeedbackDrivenOptimizer {

    private final FeedbackRepository feedbackRepository;
    private final KnowledgeBaseService knowledgeBaseService;

    /**
     * 每日分析负面反馈,生成优化建议
     */
    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
    public void analyzeDailyFeedback() {
        // 1. 统计昨日负面反馈
        List<Feedback> negativeFeedbacks = feedbackRepository
                .findByTypeInAndCreatedAtAfter(
                        List.of("THUMBS_DOWN", "REPORT_INACCURATE"),
                        LocalDate.now().minusDays(1).atStartOfDay()
                );

        if (negativeFeedbacks.isEmpty()) {
            return;
        }

        // 2. 按查询分类聚合
        Map<String, List<Feedback>> groupedByQuery = negativeFeedbacks.stream()
                .collect(Collectors.groupingBy(Feedback::getQueryCategory));

        // 3. 找出高频问题的知识缺口
        for (Map.Entry<String, List<Feedback>> entry : groupedByQuery.entrySet()) {
            if (entry.getValue().size() >= 3) { // 同一类问题出现3次以上负面反馈
                String category = entry.getKey();
                log.warn("[反馈分析] 类别 '{}' 昨日收到 {} 次负面反馈,建议补充相关知识库文档",
                        category, entry.getValue().size());

                // 4. 自动生成知识库补充建议
                generateKnowledgeGapReport(category, entry.getValue());
            }
        }
    }

    private void generateKnowledgeGapReport(String category, List<Feedback> feedbacks) {
        // 生成知识缺口报告,发送给知识库管理员
        StringBuilder report = new StringBuilder();
        report.append("## 知识缺口报告\n\n");
        report.append("**类别**: ").append(category).append("\n");
        report.append("**负面反馈数**: ").append(feedbacks.size()).append("\n\n");
        report.append("**用户原始问题**:\n");
        feedbacks.forEach(f -> report.append("- ").append(f.getOriginalQuery()).append("\n"));
        report.append("\n**建议**: 请补充该类别相关的文档到知识库中");

        // 发送报告给管理员
        knowledgeBaseService.sendGapReport(report.toString());
    }
}

九、企业级最佳实践

1. 日志分级策略

  • 开发环境:开启DEBUG级别,记录完整的模型输入输出和检索细节
  • 测试环境:开启INFO级别,重点验证监控指标是否正确
  • 生产环境 :开启INFO级别,WARN和ERROR重点关注。绝对不要记录完整的用户输入和大模型输出到生产日志(数据安全风险)

2. 监控看板分层设计

  • 运维看板:服务可用性、延迟、错误率(关注系统层面的健康)
  • 业务看板:活跃用户数、问题解决率、用户满意度、成本趋势(关注业务价值)
  • AI专项看板:模型调用QPS、token消耗趋势、RAG召回率、幻觉率(关注AI效果)

3. 成本控制三板斧

  1. 设置每日成本上限:超出预算自动降级或熔断
  2. 缓存高频问题:相同问题在短时间内直接返回缓存结果
  3. 路由策略:简单问题用便宜的小模型,复杂问题才用大模型

4. 告警分级与响应

级别 定义 响应时间 通知方式
P0-紧急 模型服务完全不可用 5分钟内 电话+企微+邮件
P1-严重 成功率低于80%或P99延迟>30s 15分钟内 企微+邮件
P2-警告 成功率低于95%或日成本超预算 1小时内 企微
P3-关注 检索空结果率升高或用户负面反馈增加 次日处理 日报

十、常见坑与解决方案

坑1:在生产环境记录了完整的用户问题和大模型输出

后果 :日志中包含大量敏感信息,一旦日志泄露,后果严重。

解决:生产环境INFO级别只记录token数量和摘要,绝不记录完整内容。敏感日志使用专门的脱敏工具处理。

坑2:模型调用的"假成功"

现象 :模型返回了HTTP 200,但内容是"抱歉,我无法回答这个问题"。

后果 :监控指标显示成功率100%,但用户实际上没有得到有效答案。

解决:除了监控HTTP状态码,还需要监控输出内容的长度和关键模式。例如输出为空、只有拒绝回答的模板、与输入高度重复等,都应该标记为"效果异常"。

坑3:token计数不准确

现象 :自己统计的token消耗与API服务商的账单对不上。

原因 :提示词模板在运行时会被渲染,实际发送的token比模板多。而且不同模型的tokenizer不同,计费标准也不同。

解决 :始终使用Usage对象返回的真实token数,不要自己估算。DeepSeek、智谱、OpenAI的令牌计算方式不一样,计费单价也不同,确保分别统计。

坑4:Agent的步骤数爆炸

现象 :Agent陷入无限循环,一个简单任务执行了50步还没结束。

解决 :始终设置maxIterations上限(建议10-15),超过上限强制终止并告警。同时监控Agent平均步骤数,如果突然上升,说明模型可能出了问题。

十一、本章总结与下章预告

本章总结

  1. AI应用的可观测性比传统应用更复杂,因为它关注的不只是"系统有没有挂",更是"系统有没有在好好工作"
  2. 三大支柱:日志告诉你"发生了什么",指标告诉你"趋势是什么",链路追踪告诉你"整个调用路径是什么"
  3. 模型调用的核心监控指标:成功率、P99延迟、token消耗、成本
  4. RAG特有的监控:检索召回数、空结果率、重排序有效率
  5. Agent特有的监控:任务成功率、步骤数分布、工具调用成功率
  6. 用户行为数据是最真实的"效果评估",必须建立反馈闭环
  7. 告警要分级,成本要设上限,日志要脱敏

下章预告

本章我们学习了如何"看"清AI应用的运行状态,但还有一个更重要的问题没有解决------安全。下一章我们将学习AI应用的安全与合规,包括如何防止Prompt注入攻击、如何对敏感数据进行脱敏、如何满足国内AI应用的合规要求。这些都是企业级AI应用上线的必要条件。

十二、课后练习

  1. 为你的智能办公Agent项目添加本章的日志切面和指标收集器
  2. 配置Prometheus端点,搭建一个本地的Grafana看板
  3. 设计你的AI应用的告警规则,至少包含3条告警
  4. 思考:你的应用中有哪些用户行为值得追踪?如何利用这些行为数据来优化AI效果?