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,这样每条日志都带着 sessionId、nodeType、stepIndex,在 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()?
这是一个坑中坑。BaseAdvisor 的 before() 方法只能变换请求内容,没办法阻止后续的链路继续执行。想要"直接返回、不调 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 相关项目,欢迎评论区交流~