自己在做RAG+LLM的项目的时候,思考了些问题。如果一个RAG+LLM的项目,没有做好观测,预防,成本等多方面的因素,那就会让这个项目变得不可控,整体的ai部分变成一个黑盒操作,谁也不知道LLM做了什么,消费了多少Token,调用了哪些工具,改善对话质量和Prompt调试变得异常困难。

目录

问题列表

调用链路黑盒

        解决方案

Token 与成本失控

        解决方案

对话质量无法评估

        解决方案

缺乏调试入口

  解决方案

总结


问题列表

调用链路黑盒

        调用链路黑盒问题的本质其实是在ai对话接口整体链路不可见的问题,一个用户提问在系统会经过如下过程

        请求入口 → RetrievalAugmentor(向量/BM25检索)→ 结果聚合 → Prompt 构建 → LLM 调用 → 响应,任何一步失败或慢,都难以定位。

        解决方案

                基于 MDC 的全链路 TraceId + 关键步骤埋点

                在我的LLM项目中,我是这样做的。

1.定义了一个工具类来监听

package com.ticket.util;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * AI聊天链路专用计时器
 * 提供关键节点的耗时统计和结构化日志输出
 * 配合ELK全链路追踪使用
 */
@Slf4j
public class AiChatStopWatch {

    private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

    /** 任务名称 */
    private final String taskName;

    /** 开始时间(毫秒) */
    private long startTimeMillis;

    /** 上一个检查点时间(毫秒) */
    private long lastCheckpointMillis;

    /** 各检查点耗时记录 */
    private final LinkedHashMap<String, Long> checkpoints;

    /** 是否已完成 */
    private boolean isRunning;

    /**
     * 创建AI聊天计时器
     * @param taskName 任务名称(如"ai-chat-sync"、"ai-chat-stream")
     */
    public AiChatStopWatch(String taskName) {
        this.taskName = taskName;
        this.checkpoints = new LinkedHashMap<>();
        this.isRunning = false;
    }

    /**
     * 开始计时
     * @return 当前实例(支持链式调用)
     */
    public AiChatStopWatch start() {
        this.startTimeMillis = System.currentTimeMillis();
        this.lastCheckpointMillis = this.startTimeMillis;
        this.isRunning = true;
        return this;
    }

    /**
     * 记录检查点并计算与上一节点的时间差
     * @param checkpointName 检查点名称(如 "route_judge", "llm_call", "db_save")
     * @return 当前实例(支持链式调用)
     */
    public AiChatStopWatch checkpoint(String checkpointName) {
        if (!isRunning) {
            throw new IllegalStateException("请先调用 start() 开始计时");
        }
        long now = System.currentTimeMillis();
        long elapsed = now - lastCheckpointMillis;
        checkpoints.put(checkpointName, elapsed);
        this.lastCheckpointMillis = now;
        return this;
    }

    /**
     * 结束计时并输出结构化日志
     * 日志格式为JSON字符串,方便ELK解析
     */
    public void stopAndLog() {
        if (!isRunning) {
            return;
        }
        isRunning = false;
        long totalElapsed = System.currentTimeMillis() - startTimeMillis;

        // 构建结构化日志(JSON格式)
        StringBuilder sb = new StringBuilder(256);
        sb.append("[AiChatTrace] {");
        appendField(sb, "taskName", taskName);
        sb.append(", ");
        appendField(sb, "traceId", TraceContext.getTraceId());
        sb.append(", ");
        appendField(sb, "totalMs", String.valueOf(totalElapsed));
        sb.append(", ");
        appendField(sb, "startTime",
                LocalDateTime.now().minusNanos(totalElapsed * 1_000_000).format(FORMATTER));
        sb.append(", \"checkpoints\": {");

        boolean first = true;
        for (Map.Entry<String, Long> entry : checkpoints.entrySet()) {
            if (!first) {
                sb.append(", ");
            }
            first = false;
            sb.append("\"").append(entry.getKey()).append("\": ")
              .append(entry.getValue()).append("ms");
        }
        sb.append("}}");

        log.info(sb.toString());
    }

    /**
     * 获取总耗时(毫秒),不停止计时
     * @return 从start()到当前的毫秒数
     */
    public long getTotalTimeMillis() {
        if (!isRunning) {
            return 0;
        }
        return System.currentTimeMillis() - startTimeMillis;
    }

    /**
     * 获取某个检查点的耗时
     * @param checkpointName 检查点名称
     * @return 该段耗时(毫秒),未找到返回-1
     */
    public long getCheckpointTime(String checkpointName) {
        Long time = checkpoints.get(checkpointName);
        return time != null ? time : -1L;
    }

    /**
     * 向StringBuilder追加JSON字段
     */
    private void appendField(StringBuilder sb, String key, String value) {
        sb.append("\"").append(key).append("\": ")
          .append(value == null ? "\"null\"" : "\"" + value + "\"");
    }
}

2.在需要ai操作的地方去触发监听器

@PostMapping("/ask")
    public ResponseUtil.Result<ChatResponse> ask(@Valid @RequestBody ChatRequest request) {
        ensureChatTraceForRequest();
        AiChatStopWatch stopWatch = new AiChatStopWatch("ai-chat-sync").start();
        String question = request.getQuestion();
        log.info("[{}] 用户提问:{}", TraceContext.getTraceId(), question);
        String answer = aiChatService.chat(question);
        stopWatch.checkpoint("controller_total");
        ChatResponse chatResponse = new ChatResponse(answer, true, System.currentTimeMillis());
        stopWatch.stopAndLog();
        return ResponseUtil.success(chatResponse);
    }

3.然后做重要的日志记录,可以异步写入 Elasticsearch 或 Jaeger(OpenTelemetry),实现可视化调用链,因为我是在每一次请求到的时候生成的一个traceId,然后再写日志的时候顺便写进去,后台监控也能查到对应的traceId。

        综合来看这个其实并不好,因为traceId完全可以在请求打进来的时候通过过滤器解析出来设置到TraceContext做一个上下文管理。

        但是最重要的就是还是没有做到rag层面,prompt上的监控其实我个人觉得这个方面才是重点,后续还会修改代码。

        首先,RAG层面是最能体现你的系统的检索效率和检索质量的,因为这样可以确定你的Prompt提示词如何修改能够更适配,rag检索质量更好,其次Prompt检测时更能确定性的修改你的提示词,让你能够更好的组织出一个适配你当前系统和向量数据库的提示词。

        在这里我做一个补充代码,后续会加上我的项目中。

1.首当其冲的就是RAG检测

        我们可以通过包装ContentRetriever,来实现日志打印。

@Slf4j
public class TracedContentRetriever implements ContentRetriever {
    private final ContentRetriever delegate;
    private final String name;
    
    @Override
    public List<Content> retrieve(Query query) {
        long start = System.currentTimeMillis();
        try {
            List<Content> contents = delegate.retrieve(query);
            log.info("RAG.retrieve name={}, query={}, resultCount={}, latencyMs={}", 
                     name, query.text(), contents.size(), System.currentTimeMillis() - start);
            return contents;
        } catch (Exception e) {
            log.error("RAG.retrieve failed name={}", name, e);
            throw e;
        }
    }
}

2.然后再这个代码修改,用上面的的TracedContentRetriever

/**
     * 检索链:首先过滤掉enabled=false的 doc,然后对剩余的 doc 进行打分,并返回打分最高的 topN 个 doc。
     * 属于稀疏检索,且结果数量多,且结果质量低。
     * @param embeddingStore 向量存储
     * @param embeddingModel 向量模型
     * @param manualKbGovernance 手册治理:检索结果由外部控制
     * @param expandMultiplier 检索结果数量:检索结果数量乘数
     * @param filteredCap 过滤后的最大数量
     * @return
     */
    @Bean
    public ContentRetriever manualRagRetriever(
            EmbeddingStore<TextSegment> embeddingStore,
            EmbeddingModel embeddingModel,
            ManualDocImpl manualKbGovernance,
            @Value("${rag.retrieval-expand-multiplier:5}") int expandMultiplier,
            @Value("${rag.filtered-cap:40}") int filteredCap) {
        int saneMax = saneMaxResults(maxResults);
        double saneMin = saneMinScore(minScore);

        int expanded = Math.min(
                saneMax * Math.max(expandMultiplier, 1),
                Math.max(filteredCap, saneMax));

        log.info("RAG检索(单路): outputMax={}, expandedRetrieve={}, minScore={}", saneMax, expanded, saneMin);

        EmbeddingStoreContentRetriever inner = EmbeddingStoreContentRetriever.builder()
                .embeddingStore(embeddingStore)
                .embeddingModel(embeddingModel)
                .maxResults(expanded)
                .minScore(saneMin)
                .build();

        return new ManualContentRetriever(inner, manualKbGovernance, saneMax);
    }

因为我这个时写死的,在管理和配置方面不方便

        其实还可以让ManualContentRetriever去继承TracedContentRetriever,这样就可以少些一部分,或者,直接把TracedContentRetriever 中的retriever修改进ManualContentRetriever,因为我这边后续还有一个混合索引的操作,其实更好的是把日志部分的配置,还有输出统一写道ManualContentRetriever里面的。


Token 与成本失控

        对于一个商业项目,每次对话消耗多少 Token 看不到,预算超了都不知道,这个是最危险的,因为这决定了一个用户可以通过这种方式去攻击我们的LLM服务,导致整体业务宕机或者token耗尽。

        解决方案

1.我是实现了一个服务的chat接口,代码如下

package com.ticket.aichat.service;

import dev.langchain4j.service.Result;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;

/**
 * 客服 AI 服务接口(LangChain4j 生成实现)
 */
public interface KnowledgeAssistant {

    @SystemMessage("""
            你是一个...""")
    @UserMessage("{{it}}")
    Result<String> chat(String question);
}

然后LangChain4j会返回一个Result<String>,这是LangChain4j为我们提供的类,源码是这样的

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package dev.langchain4j.service;

import dev.langchain4j.internal.Utils;
import dev.langchain4j.internal.ValidationUtils;
import dev.langchain4j.model.output.FinishReason;
import dev.langchain4j.model.output.TokenUsage;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.service.tool.ToolExecution;
import java.util.List;

public class Result<T> {
    private final T content;
    private final TokenUsage tokenUsage;
    private final List<Content> sources;
    private final FinishReason finishReason;
    private final List<ToolExecution> toolExecutions;

    public Result(T content, TokenUsage tokenUsage, List<Content> sources, FinishReason finishReason, List<ToolExecution> toolExecutions) {
        this.content = ValidationUtils.ensureNotNull(content, "content");
        this.tokenUsage = tokenUsage;
        this.sources = Utils.copyIfNotNull(sources);
        this.finishReason = finishReason;
        this.toolExecutions = Utils.copyIfNotNull(toolExecutions);
    }

    public static <T> ResultBuilder<T> builder() {
        return new ResultBuilder();
    }

    public T content() {
        return this.content;
    }

    public TokenUsage tokenUsage() {
        return this.tokenUsage;
    }

    public List<Content> sources() {
        return this.sources;
    }

    public FinishReason finishReason() {
        return this.finishReason;
    }

    public List<ToolExecution> toolExecutions() {
        return this.toolExecutions;
    }

    public static class ResultBuilder<T> {
        private T content;
        private TokenUsage tokenUsage;
        private List<Content> sources;
        private FinishReason finishReason;
        private List<ToolExecution> toolExecutions;

        ResultBuilder() {
        }

        public ResultBuilder<T> content(T content) {
            this.content = content;
            return this;
        }

        public ResultBuilder<T> tokenUsage(TokenUsage tokenUsage) {
            this.tokenUsage = tokenUsage;
            return this;
        }

        public ResultBuilder<T> sources(List<Content> sources) {
            this.sources = sources;
            return this;
        }

        public ResultBuilder<T> finishReason(FinishReason finishReason) {
            this.finishReason = finishReason;
            return this;
        }

        public ResultBuilder<T> toolExecutions(List<ToolExecution> toolExecutions) {
            this.toolExecutions = toolExecutions;
            return this;
        }

        public Result<T> build() {
            return new Result(this.content, this.tokenUsage, this.sources, this.finishReason, this.toolExecutions);
        }

        public String toString() {
            ....
        }
    }
}

在这里面我们看到,就有tokenusage等一些相关方法可以使用。

然后我们使用tokenUsage(),就可以获取对应的token用量了,接下来是我的代码。

package com.ticket.aichat.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ticket.dto.mq.ChatRecordEvent;
import com.ticket.entity.ChatRecord;
import com.ticket.entity.ChatSession;
import com.ticket.entity.KnowledgeBase;
import com.ticket.aichat.mapper.ChatRecordMapper;
import com.ticket.aichat.service.AIChatService;
import com.ticket.aichat.service.KnowledgeAssistant;
import com.ticket.service.RocketMQProducerService;
import com.ticket.aichat.service.ChatSessionService;
import com.ticket.aichat.service.HumanTransferPublisher;
import com.ticket.aichat.service.QuickFaqMatchService;
import com.ticket.util.MQIdempotentUtil;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.service.Result;
import com.ticket.util.UserContext;
import com.ticket.util.TraceContext;
import com.ticket.util.AiChatStopWatch;
import com.ticket.util.TokenCountUtil;
import com.ticket.enums.BusinessStatus;
import dev.langchain4j.service.UserMessage;
import reactor.core.publisher.Flux;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

/** * 负责处理AI聊天业务逻辑,调用底层AI服务
 */
@Service
public class AIChatServiceImpl implements AIChatService {

    private static final Logger log = LoggerFactory.getLogger(AIChatServiceImpl.class);
    @Resource
    private KnowledgeAssistant knowledgeAssistant;

    @Resource
    private ChatRecordMapper chatRecordMapper;

    @Resource
    private ChatMemory chatMemory;

    @Resource
    private ChatSessionService chatSessionService;

    @Resource
    private RocketMQProducerService rocketMQProducerService;

    @Resource
    private MQIdempotentUtil idempotentUtil;

    @Resource
    private HumanTransferPublisher humanTransferPublisher;

    @Resource
    private QuickFaqMatchService quickFaqMatchService;
    @Override
    @Transactional
    public String chat(@UserMessage String question) {
       ..........

        // 调用AI(Result<String>获取TokenUsage)
        String answer;
        int inputTokens = 0;
        int outputTokens = 0;
        try {
            Result<String> result = knowledgeAssistant.chat(question);
            answer = result.content();
            if (answer == null || answer.isBlank()) {
                answer = EMPTY_MODEL_FALLBACK;
            }
            if (result.tokenUsage() != null) {
                inputTokens = result.tokenUsage().inputTokenCount();
                outputTokens = result.tokenUsage().outputTokenCount();
                log.info("[{}] Token消耗 - input:{}, output:{}",
                        TraceContext.getTraceId(), inputTokens, outputTokens);
            } else {
                // 回退:使用估算
                outputTokens = TokenCountUtil.estimate(answer);
                log.warn("[{}] 无法获取精确TokenUsage,使用估算值 output:{}",
                        TraceContext.getTraceId(), outputTokens);
            }
        } catch (IllegalStateException e) {
            log.error("[{}] RAG服务不可用,返回降级消息: {}");
            outputTokens = TokenCountUtil.estimate(answer);
        } catch (Exception e) {
            log.error("[{}] AI 调用异常: {}", TraceContext.getTraceId(), e.getMessage(), e);
            answer = EMPTY_MODEL_FALLBACK;
            outputTokens = TokenCountUtil.estimate(answer);
        }
        stopWatch.checkpoint("llm_call");
        saveChatRecord(answer, BusinessStatus.MSG_TYPE_ROBOT, BigDecimal.ONE, sessionId, null, 0, inputTokens, outputTokens);
        stopWatch.checkpoint("db_save_ai");
        stopWatch.stopAndLog();
        return answer;
    }


    /**
     * 保存聊天记录(异步:通过RocketMQ发送,由消费者负责DB写入和WS广播)
     */
    private void saveChatRecord(String message, String msgType, BigDecimal confidence,
                                String sessionId, Long employeeId, Integer isRead,
                                int inputTokens, int outputTokens) {
        try {
            ChatRecordEvent event = new ChatRecordEvent();
            event.setMessageId(idempotentUtil.generateMessageId());
            event.setUserId(UserContext.getCurrentUserId());
            event.setSessionId(sessionId);
            event.setMessage(message);
            event.setMsgType(msgType);
            event.setEmployeeId(employeeId);
            event.setConfidence(confidence);
            event.setInputTokens(inputTokens);
            event.setOutputTokens(outputTokens);
            event.setCreatedAt(LocalDateTime.now());
            event.setTraceId(TraceContext.getTraceId());
            rocketMQProducerService.sendChatRecordEvent(event);
            log.debug("已发送聊天记录到RocketMQ异步处理: userId={}, msgType={}",
                    UserContext.getCurrentUserId(), msgType);
        } catch (Exception e) {
            log.error("发送聊天记录事件失败", e);
        }
    }

  
}

这里我们还可以进一步做操作,比如说,添加配置类,去限制用户级别和服务级别的Token额度,避免超额。

其次我们还要添加一个降级服务,如果已经限额了,或者服务异常了,我们可以通过降级维护服务,确保其正确运行。

后面其实还可以根据用户token的使用,去做一些个性化的操作,或者去做日志分析等等,都是一个可靠的依据


对话质量无法评估

        这个问题主要是因为LLM的回答不一定是按照我们的要求去回答的。我们其实可以去做一些询问,比如:答案是否忠实于检索内容?是否幻觉?是否回答了用户问题?

        解决方案

        第一个方案就是给予一个用户的反馈按钮,我们可以在每次回答后面添加一个评价,让用户去评价是否解决问题了,然后将答案收集起来,然后后台定期做分析,然后投诉的多,我们就知道,大模型回答有问题,可以选择优化提示词,rag检索方向,模型选择等等方面去做优化,但是这一点不好的就是用户如果经常体验感不好的话,就不会使用该服务了,对于维护客户来说是不好的。

        第二个方案就是使用一个更强大的 LLM(如 GPT-5.5等等强大的模型)对随机抽样的对话进行 幻觉检测 和 相关性评分,为每个回答存储评估得分,以便后续分析哪些检索策略、模型或 prompt 效果更好。

        第三个方案就是人工检查,这种方案的成本高,精力消耗大,不适合做短时间的评估,但是对于大批量的评估综合,或者质量检查来说,人工检查更加准确。


缺乏调试入口

        这个的主要问题就是:如果发现某个问题回答不好,无法快速复现场景、调整 Prompt 并验证。

  解决方案

1.可以添加一个调试接口,基于之前实现的TraceId去获取整个的回答流程 ,麻烦一点的可以搭一个内部的监控平台,利用平台去管理整个的服务,但是成本太高了。

2.可以在日志中输出 Prompt,利用之前的ELK去检查哪一个prompt有问题,然后通过调试接口去测试是否复现,然后再去修改Prompt

总结

        在RAG+LLM的项目中,AI的可观测性,可控性是最重要的,如果一个商业项目,对AI服务有很好的监控性,是能够将可用变成可靠,降本增效的一个很好的服务。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐