首字节响应 0ms?我用 1000 行代码驯服了 Spring AI Agent 的“不确定性”

0 阅读8分钟

Spring AI Agent 工程化踩坑实录——一个大二学生的三次灵魂拷问

最近在做一个基于 Spring AI 的 AI Agent 项目,踩了几个让我一度怀疑自己的坑。记录下来,一是备忘,二是希望能帮到和我一样初学的同学。


背景介绍

项目是一个多节点的自动 Agent,用户发一个问题,系统会经过分析→执行→质检→汇总四个节点,最后用 SSE(Server-Sent Events)把结果流式推给前端,背后接的是 OpenAI的大模型 API。

技术栈:Spring Boot 3 + Spring AI 1.0 + MySQL + Redis + 线程池


第一坑:CallerRunsPolicy 在 SSE 场景下会直接把服务搞崩

发现过程

我最开始配置线程池的时候,"理所当然"地用了 CallerRunsPolicy 作为拒绝策略——毕竟网上很多文章都说这个策略"温柔",队列满了也不丢任务,让调用方自己跑就行了。

// 我最开始写的——看起来没问题,实际上是定时炸弹
new ThreadPoolExecutor(
    10, 20, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100),
    new CallerRunsPolicy()  // 💣 这里埋了雷
);

我一直没发现问题,直到我想起来测一下高并发场景,才发现:连接经常超时,而且超时的时候服务器其他请求也全部卡住了。

根本原因分析

SSE 的工作方式是这样的:

用户请求 → Tomcat 线程 → 提交任务到线程池 → 立即 return emitter → Tomcat 线程归还
                                                        ↓
                                              Worker 线程异步调用 LLM,把结果推给 emitter

关键点:Tomcat 线程必须在提交完任务后立即归还,才能继续接收其他请求。

CallerRunsPolicy 的逻辑是:线程池满了?那你(提交任务的线程,也就是 Tomcat 线程)自己来跑!

用户请求 → Tomcat 线程 → 线程池满了 → CallerRunsPolicy:Tomcat线程你自己跑!
                  ↓
           Tomcat线程开始调用 LLM(耗时 10~30 秒)
                  ↓
           期间所有新请求:对不起,没有 Tomcat 线程处理你了

结果就是:高并发时,Tomcat 线程全部被"借走"去跑 AI 任务,服务对外完全不响应,看起来就像崩了。

解决方案

换成 AbortPolicy,线程池满了直接抛异常,在 Controller 里捕获,返回 429(请求过多):

// ThreadPoolConfig.java
@Bean
public ThreadPoolExecutor threadPoolExecutor() {
    return new MdcAwareThreadPoolExecutor(
        10, 20, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(100),
        Executors.defaultThreadFactory(),
        // ✅ 队列满时抛异常,Tomcat 线程立即捕获处理,不会被阻塞
        new ThreadPoolExecutor.AbortPolicy()
    );
}
// AiAgentController.java
try {
    threadPoolExecutor.execute(() -> {
        // 异步调用 LLM,推送 SSE 结果
        autoAgentExecuteStrategy.execute(executeCommandEntity, emitter);
    });
} catch (RejectedExecutionException e) {
    // 线程池满了 → 捕获异常 → 告诉用户稍后重试,Tomcat 线程不阻塞
    int queueSize = threadPoolExecutor.getQueue().size();
    log.warn("线程池已满,拒绝请求,队列积压: {}", queueSize);
    sendErrorAndComplete(emitter, 429,
        String.format("当前排队人数过多,请稍后再试(队列积压:%d)", queueSize));
}
// ✅ 无论如何,Tomcat 线程在这里 return,立即归还
return emitter;

一句话总结:CallerRunsPolicy 是普通 HTTP 接口的温柔策略,是 SSE 场景的致命毒药。


第二坑:日志里的 traceId 全是空的

发现过程

我配了 ELK 做日志可观测,logback 里配置了 %X{traceId} 来打印链路追踪 ID。

Controller 里我是这样生成和注入的:

// 生成 traceId,注入 MDC
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);

然后在日志里一看,Controller 那层打出来的 traceId 是正常的,但是 Agent 执行节点(Step1、Step2……)里打出来的全是空的。

当时我就纳闷了:我明明放进去了,为什么读不到?

根本原因分析

MDC(Mapped Diagnostic Context)底层用的是 ThreadLocal

ThreadLocal 的特性是:只属于当前线程,其他线程看不到。

Tomcat线程:MDC.put("traceId", "abc123")  ← 存在 Tomcat 线程的 ThreadLocal 里
                    ↓
            提交任务给线程池
                    ↓
Worker线程:MDC.get("traceId")  ← 读自己的 ThreadLocal,根本没有!返回 null

任务提交到线程池之后,执行任务的是 Worker 线程,它有自己的 ThreadLocal,完全不知道 Tomcat 线程存了什么。

解决方案

自定义一个 MdcAwareThreadPoolExecutor,在任务提交的那一刻把当前 MDC 快照下来,然后在 Worker 线程执行之前恢复进去:

// MdcAwareThreadPoolExecutor.java
public class MdcAwareThreadPoolExecutor extends ThreadPoolExecutor {

    public MdcAwareThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
                                      long keepAliveTime, TimeUnit unit,
                                      BlockingQueue<Runnable> workQueue,
                                      ThreadFactory threadFactory,
                                      RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit,
              workQueue, threadFactory, handler);
    }

    @Override
    public void execute(Runnable command) {
        // 提交时:快照当前线程(Tomcat线程)的 MDC
        super.execute(new MdcAwareRunnable(command, MDC.getCopyOfContextMap()));
    }

    @Override
    public <T> Future<T> submit(Callable<T> task) {
        return super.submit(new MdcAwareCallable<>(task, MDC.getCopyOfContextMap()));
    }

    // 包装 Runnable:执行前恢复 MDC,执行后清理
    private record MdcAwareRunnable(
            Runnable delegate,
            Map<String, String> mdcSnapshot) implements Runnable {

        @Override
        public void run() {
            if (mdcSnapshot != null) {
                MDC.setContextMap(mdcSnapshot);  // ← Worker线程:恢复 MDC
            } else {
                MDC.clear();
            }
            try {
                delegate.run();
            } finally {
                MDC.clear();  // ← 执行完:清理,避免污染线程池里的下一个任务
            }
        }
    }

    // 包装 Callable:同理
    private record MdcAwareCallable<V>(
            Callable<V> delegate,
            Map<String, String> mdcSnapshot) implements Callable<V> {

        @Override
        public V call() throws Exception {
            if (mdcSnapshot != null) {
                MDC.setContextMap(mdcSnapshot);
            } else {
                MDC.clear();
            }
            try {
                return delegate.call();
            } finally {
                MDC.clear();
            }
        }
    }
}

顺便,每个 Agent 执行节点也要在自己的 doApply() 里把上下文信息放进 MDC,这样每条日志都带着 sessionIdnodeTypestepIndex,在 Kibana 里按 traceId 一过滤,整条链路一览无余:

// AbstractExecuteSupport.java(各 Step 的公共父类)
protected void putMdc(ExecuteCommandEntity request,
                      DefaultAutoAgentExecuteStrategyFactory.DynamicContext ctx,
                      String nodeType) {
    MDC.put(MDC_SESSION_ID, request.getSessionId());
    MDC.put(MDC_AGENT_ID, request.getAiAgentId());
    MDC.put(MDC_NODE_TYPE, nodeType);
    MDC.put(MDC_STEP_INDEX, String.valueOf(ctx.getStep()));
}

protected void clearMdc() {
    MDC.remove(MDC_SESSION_ID);
    MDC.remove(MDC_AGENT_ID);
    MDC.remove(MDC_NODE_TYPE);
    MDC.remove(MDC_STEP_INDEX);
}
// Step1AnalyzerNode.java(其他 Step 同理)
@Override
protected String doApply(ExecuteCommandEntity request,
                         DefaultAutoAgentExecuteStrategyFactory.DynamicContext ctx) {
    putMdc(request, ctx, "Step1AnalyzerNode");
    try {
        // ... 正常业务逻辑
    } finally {
        clearMdc();  // 无论成功还是异常,都清理
    }
}

一句话总结:ThreadLocal 不跨线程,自定义线程池在提交时快照 MDC、执行时恢复,彻底解决异步场景下的 traceId 丢失问题。


第三坑:接口没有限流,API Key 额度随时可能被刷爆

发现过程

其实这个不算"坑",是我自己想到的一个隐患:项目对外开放接口,背后用的是付费的 LLM API Key。如果有人写个脚本疯狂调用,我的额度分分钟清空,还得自己掏钱。

更严重的是:每次请求不只是调一次 LLM,而是经过 4 个节点,每个节点都要调,一次用户请求可能触发 4~8 次 LLM 调用。

解决方案

在 Spring AI 的 Advisor 拦截链里加一个令牌桶限流 Advisor。

为什么选 Advisor 链而不是拦截器?

Spring AI 的请求在真正发给 LLM 之前会经过 Advisor Chain(类似 Spring MVC 的过滤器链)。在这里拦截的好处是:RAG 向量检索、Redis 读取对话记忆这些操作也不会触发,是最前置的拦截点,真正做到一刀切断,零资源浪费。

为什么在 adviseCall() 里做,而不是 before()

这是一个坑中坑。BaseAdvisorbefore() 方法只能变换请求内容,没办法阻止后续的链路继续执行。想要"直接返回、不调 LLM",必须重写 adviseCall()adviseStream(),在里面判断完限流之后选择性地调用 chain.nextCall() 或者直接 return。

// RateLimitAdvisor.java
public class RateLimitAdvisor implements BaseAdvisor {

    // Advisor context 里 sessionId 对应的 key
    private static final String USER_ID_KEY = "chat_memory_conversation_id";
    private static final String RATE_LIMITED_MSG = "您的请求太频繁了,请稍后再试~";

    private final double permitsPerSecond;
    private final long timeoutMs;

    // 每个用户独立一个令牌桶,ConcurrentHashMap 保证线程安全
    private final Map<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();

    public RateLimitAdvisor(double permitsPerSecond, long timeoutMs) {
        this.permitsPerSecond = permitsPerSecond;
        this.timeoutMs = timeoutMs;
    }

    // before() 只做请求变换,限流逻辑不放这里
    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        return request;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
        return response;
    }

    // ✅ 重写这里:超限时直接 return,不调 chain.nextCall(),不消耗 LLM Token
    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        String userId = extractUserId(request);
        if (!tryAcquire(userId)) {
            log.warn("限流触发,userId: {}", userId);
            return buildRateLimitedResponse(request);  // 直接返回,LLM 不知情
        }
        return chain.nextCall(request);  // 通过了,继续调 LLM
    }

    // ✅ 流式接口同理
    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
        String userId = extractUserId(request);
        if (!tryAcquire(userId)) {
            log.warn("限流触发(流式),userId: {}", userId);
            return Flux.just(buildRateLimitedResponse(request));
        }
        return chain.nextStream(request);
    }

    private String extractUserId(ChatClientRequest request) {
        Object userId = request.context().get(USER_ID_KEY);
        return (userId != null && !userId.toString().isBlank())
                ? userId.toString() : "anonymous";
    }

    private boolean tryAcquire(String userId) {
        // computeIfAbsent:同一 userId 只创建一个 RateLimiter(线程安全)
        RateLimiter limiter = rateLimiterMap.computeIfAbsent(
                userId, k -> RateLimiter.create(permitsPerSecond));
        return timeoutMs <= 0
                ? limiter.tryAcquire()
                : limiter.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS);
    }

    private ChatClientResponse buildRateLimitedResponse(ChatClientRequest request) {
        ChatResponse chatResponse = ChatResponse.builder()
                .generations(List.of(new Generation(new AssistantMessage(RATE_LIMITED_MSG))))
                .build();
        return ChatClientResponse.builder()
                .chatResponse(chatResponse)
                .context(request.context())
                .build();
    }

    // 最高优先级:在 RAG、记忆等所有 Advisor 之前执行
    @Override
    public int getOrder() {
        return Integer.MIN_VALUE;
    }

    @Override
    public String getName() {
        return this.getClass().getSimpleName();
    }
}

然后在枚举里注册一下,就可以通过数据库配置动态启用了:

// AiClientAdvisorTypeEnumVO.java 里新增
RATE_LIMIT("RateLimit", "多租户令牌桶限流") {
    @Override
    public Advisor createAdvisor(AiClientAdvisorVO aiClientAdvisorVO,
                                 VectorStore vectorStore,
                                 ChatMemoryRepository chatMemoryRepository) {
        AiClientAdvisorVO.RateLimit config = aiClientAdvisorVO.getRateLimit();
        double permitsPerSecond = (config != null) ? config.getPermitsPerSecond() : 1.0;
        long timeoutMs = (config != null) ? config.getTimeoutMs() : 0;
        return new RateLimitAdvisor(permitsPerSecond, timeoutMs);
    }
}

数据库里插一条记录就能启用,不用改代码:

INSERT INTO ai_client_advisor (advisor_id, advisor_name, advisor_type, order_num, extend_info)
VALUES ('rate_limit_01', '用户限流', 'RateLimit', 0,
        '{"rateLimit": {"permitsPerSecond": 1.0, "timeoutMs": 0}}');

一句话总结:利用 Spring AI Advisor 链的前置拦截能力,每个用户独立令牌桶,超限零 Token 消耗直接返回提示,速率可通过数据库动态配置。


总结

问题根本原因解决方案
SSE 高并发时服务卡死CallerRunsPolicy 占用 Tomcat 线程AbortPolicy + 返回 429
异步日志 traceId 丢失ThreadLocal 不跨线程自定义 MdcAwareThreadPoolExecutor 快照恢复 MDC
API Key 可能被刷爆接口无限流保护自定义 RateLimitAdvisor 令牌桶前置拦截

这三个问题说大不大,说小不小,但都是真实生产里会踩的坑。作为一个大二学生,能在学习阶段就碰到并解决这些问题,感觉还是挺有收获的。

如果你也在做 Spring AI 相关项目,欢迎评论区交流~