在开发AI对话功能时,遇到了一个典型的异步场景下用户身份获取异常问题,全程踩坑,耗时大半天终于完美解决。整理了完整的排查过程、试错经历和解决方案,希望能帮到遇到类似问题的开发者,避免重复踩坑。
场景:学生端通过AI对话(流式SSE)与大模型交互,发送"查看我的心理评估报告"等指令时,会使用Function Calling让AI调用业务工具类,查询学生个人数据并返回。核心需求是无需学生手动输入ID,AI自动识别当前登录学生身份,而这一过程中,用户ID的稳定获取成为了核心痛点。看似简单的需求,却因为Spring AI流式异步执行特性、若依框架SecurityUtils的实现限制、框架版本差异及Maven配置隐患,踩了一系列坑。
一、初始需求与初步实现
核心需求明确:学生在AI流式对话中,查询个人心理评估报告、基本信息等数据时,AI能自动获取当前登录学生ID,无需手动输入,确保交互的便捷性和安全性。
本次开发基于若依框架,项目中集成Spring AI实现大模型交互,工具调用采用Spring AI的Function Calling特性。结合现有技术栈,初步实现思路如下:
-
配置学生端ChatClient,将业务工具类MentalHealthTools注入,确保AI能正常调用工具方法
-
工具类中通过若依框架自带的SecurityUtils.getUserId()获取当前登录用户ID,获取个人数据
补充说明:若依框架的SecurityUtils.getUserId()底层基于ThreadLocal实现,ThreadLocal用于存储当前线程的用户身份信息,在同步接口开发中,该方法能稳定获取用户ID,是日常开发中获取用户身份的常用方式。
- 业务层实现流式对话核心方法,调用ChatClient发起流式请求,完成AI与学生的实时交互
工具类初始代码片段如下:
java
// 工具方法示例
@Tool(name = "get_psychological_assessment_reports")
public List<Map<String, Object>> getPsychologicalAssessmentReports(Integer limit) {
// 初衷:通过若依SecurityUtils获取当前登录用户ID,同步接口开发中均正常使用
Long userId = SecurityUtils.getUserId();
// 后续根据userId查询学生信息、评估报告等业务逻辑...
}
本地同步测试(直接通过接口调用工具方法)时,一切正常,SecurityUtils.getUserId()能稳定获取到用户ID,业务逻辑正常执行。但当启动流式对话(SSE)后,工具方法调用直接翻车,用户ID获取失败,导致无法查询个人数据,系统报错!!!

二、第一个坑:Spring AI流式输出异步执行,ThreadLocal跨线程失效
问题现象
同步调用工具方法时,SecurityUtils.getUserId()能正常获取用户ID;启动Spring AI流式对话(Flux实现SSE实时输出)后,工具方法中SecurityUtils.getUserId()返回null,导致无法查询相关数据,业务流程中断,报错提示"当前用户未绑定学生信息"。
问题核心原因
该问题的核心矛盾的是"Spring AI流式输出的异步特性"与"若依SecurityUtils的ThreadLocal实现"不兼容,具体分析如下:
-
Spring AI的stream流式输出基于Flux实现,属于异步执行模式,会启动新的线程处理流式响应,且线程会动态切换(如线程池复用);
-
若依框架的SecurityUtils.getUserId()底层依赖ThreadLocal,ThreadLocal的核心特性是"线程隔离",即存储的数据仅能在当前线程中访问,异步线程无法继承主线程(Tomcat接收请求的同步线程)的ThreadLocal数据;
-
流式对话中,工具方法的调用发生在异步线程中,此时ThreadLocal中未存储任何用户身份信息,因此SecurityUtils.getUserId()返回null。
三、尝试InheritableThreadLocal传递,依然失败
试错思路
发现ThreadLocal跨线程失效后,第一个想到的解决方案是改用InheritableThreadLocal替换普通ThreadLocal。InheritableThreadLocal是ThreadLocal的子类,支持子线程继承父线程的ThreadLocal数据,理论上能解决异步线程获取主线程用户信息的问题。
试错实现
修改若依框架SecurityUtils的底层实现,将存储用户ID的ThreadLocal替换为InheritableThreadLocal,核心修改代码如下:
java
// 原SecurityUtils中ThreadLocal定义(若依默认)
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
// 修改后,改用InheritableThreadLocal
private static final ThreadLocal<Long> USER_ID = new InheritableThreadLocal<>();
试错结果
修改后重新测试,流式对话中工具方法依然无法获取用户ID,问题未解决。

失败原因
Spring AI的流式执行并非简单的"父线程→子线程"继承关系,其底层采用线程池管理异步线程,线程池中的线程是预先创建并复用的,并非每次流式请求都创建新的子线程。InheritableThreadLocal仅能在"父线程创建子线程"的瞬间传递数据,无法支持线程池的线程复用场景,因此依然无法获取到主线程的用户ID。
此次试错虽未成功,但明确了"线程池复用"是核心障碍,排除了ThreadLocal相关的修改方案,只能寻找其他不依赖ThreadLocal的参数传递方式。
四、尝试传递SecurityContext,遗漏RequestContextHolder导致失败
试错思路
排除InheritableThreadLocal方案后,进一步分析问题本质:流式输出(stream)切换异步线程 → 若依的SecurityContext丢失 → Tool方法无法获取userId → 工具执行失败。为了验证这一逻辑,先查看日志中的线程名,找到问题的直接证据。
日志铁证(线程名对比)
通过查看项目日志,清晰发现线程切换导致的上下文丢失问题,具体日志片段如下:
text
// 前端请求线程(Tomcat线程,有权限上下文)
[http-nio-8080-exec-30] INFO com.mc.service.impl.AiChatServiceImpl - 主线程获取userId:10001(SecurityUtils.getUserId() 正常)
// AI执行Tool线程(Spring AI异步线程,无权限上下文)
[boundedElastic-4] ERROR com.mc.tools.MentalHealthTools - 工具调用失败,SecurityUtils.getUserId() 返回null
[boundedElastic-4] INFO com.mc.service.impl.AiChatServiceImpl - AI返回兜底文案:看起来系统暂时没能顺利获取到你的心理健康状态信息
从日志中可以明确看到,Tomcat请求线程(http-nio-8080-exec-30)中能正常获取userId,而Spring AI异步线程(boundedElastic-4)中ThreadLocal上下文丢失,导致SecurityUtils.getUserId()返回null,工具执行失败,AI只能返回兜底文案。
工具执行情况对比(验证问题本质)
为了进一步确认问题,对比了所有工具的执行情况,发现只有需要用户身份的工具失效,无需身份的工具正常执行,完美匹配"线程切换导致上下文丢失"的结论,具体对比如下:
| 工具名称 | 是否需要userId/studentId | 能否正常执行 |
|---|---|---|
| get_student_mental_health_status | ✅ 需要 | ❌ 失败 |
| get_psychological_assessment_reports | ✅ 需要 | ❌ 失败 |
| get_student_info | ✅ 需要 | ❌ 失败 |
| get_recommended_articles | ❌ 不需要 | ✅ 正常 |
| get_community_posts | ❌ 不需要 | ✅ 正常 |
| calculate_risk_level | ❌ 不需要 | ✅ 正常 |
试错实现(尝试传递SecurityContext)
基于上述分析,尝试采用"异步线程传递SecurityContext"的方案,该方案号称"零入侵Tool方法",核心思路是在业务层手动将若依的权限上下文传递给Spring AI的异步线程,修改AiChatServiceImpl流式对话方法如下:
java
// 尝试传递SecurityContext,未传递RequestContextHolder
Long userId = SecurityUtils.getUserId();
SecurityContext securityContext = SecurityContextHolder.getContext();
// 手动传递SecurityContext到异步线程
Flux<String> contentFlux = studentChatClient.prompt()
.messages(userMsg)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.stream()
.content()
.subscribeOn(Schedulers.boundedElastic()) // 手动指定线程池,打乱原有上下文
.publishOn(Schedulers.boundedElastic())
.contextWrite(Context.of(SecurityContext.class, securityContext)); // 仅传递SecurityContext
试错结果
修改后重新测试,工具方法依然无法获取userId,问题未解决,SecurityUtils.getUserId()依然返回null。
失败原因(核心遗漏点)
此次试错失败的核心原因的是"遗漏了若依SecurityUtils依赖的RequestContextHolder",具体分析如下:
-
多余操作:手动添加subscribeOn/publishOn方法,打乱了Spring AI流式默认的线程上下文,Spring AI流式输出默认使用boundedElastic线程池,无需手动指定;
-
核心遗漏:若依框架的SecurityUtils.getUserId()底层并非仅依赖SecurityContextHolder(安全认证上下文),还依赖RequestContextHolder(请求上下文),二者缺一不可;
-
传递不完整:仅传递了SecurityContext,未传递RequestContextHolder,导致SecurityUtils.getUserId()在异步线程中依然无法获取到用户ID,工具执行失败。
此次试错进一步明确了若依权限上下文的依赖细节,排除了"传递SecurityContext"的方案,最终转向Spring AI专属的ToolContext上下文传递方案。
五、改用Spring AI的ToolContext传递参数
作者实在没造了,只能使用这个办法,大家有更好的办法,评论区讨论!
解决方案思路
排除ThreadLocal相关方案后,查阅Spring AI官方文档得知,ToolContext是Spring AI专门为工具调用设计的上下文对象,用于传递业务参数,其不受线程切换、线程池复用的影响,且不会将参数传入大模型(不浪费Token、不增加提示词长度),完全适配流式对话的异步场景。
初步修改(业务层)
在业务层(AiChatServiceImpl)的流式对话方法中,先在主线程(Tomcat同步线程)通过SecurityUtils.getUserId()获取用户ID,再将其存入Map,通过Spring AI的toolContext方法传入ChatClient,确保参数能跨线程传递。代码修改如下:
java
// 主线程(Tomcat同步线程)获取userId,确保能拿到值(同步线程中ThreadLocal有效)
Long userId = SecurityUtils.getUserId();
Map<String, Object> toolContext = new HashMap<>();
toolContext.put("userId", userId);
// 调用学生端ChatClient,传入toolContext,实现参数跨线程传递
Flux<String> contentFlux = studentChatClient.prompt()
.messages(userMsg)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.toolContext(toolContext) // 传入用户ID,跨线程可用
.stream()
.content();
工具方法未注入ToolContext,参数传递失败
问题现象
修改完获取ID的方法后,重新启动项目测试,AI调用工具时依然报错,提示"方法缺少ToolContext参数",工具调用失败。
问题原因
工具类中所有需要获取用户ID的@Tool方法,均未添加ToolContext参数。Spring AI在调用工具方法时,会自动注入ToolContext对象,但如果方法中未声明该参数,Spring AI无法完成注入,导致无法获取到传入的userId。
给所有相关工具方法添加ToolContext参数
修改工具类中所有需要获取用户ID的@Tool方法,添加ToolContext参数(无需手动传入,Spring AI会自动注入上下文对象),确保能正常获取到userId。代码示例如下:
java
// 工具方法添加ToolContext参数,Spring AI自动注入
@Tool(name = "get_psychological_assessment_reports")
public List<Map<String, Object>> getPsychologicalAssessmentReports(
@ToolParam(description = "返回记录数量限制,默认10", required = false) Integer limit,
ToolContext toolContext) { // 新增ToolContext参数
Long studentId = getCurrentStudentId(toolContext); // 传入上下文,获取学生ID
log.info("[MCP-Tools] 获取心理评估报告 - studentId: {}, limit: {}", studentId, limit);
// 后续查询逻辑...
}
运行结果

终于是成功了 !!!太不容易了!!早该应该这样做的了!
五、最终解决方案与效果
完整解决方案总结
-
放弃ThreadLocal、InheritableThreadLocal及SecurityContext传递方案,改用Spring AI的ToolContext传递用户ID,彻底解决异步线程参数传递问题
-
业务层(AiChatServiceImpl):在主线程获取userId,存入Map,通过toolContext(Map)方法传入ChatClient,不手动指定线程池,保留Spring AI默认线程模型
-
工具类(MentalHealthTools):适配旧版ToolContext,通过getContext().get("userId")获取用户ID,再关联查询学生信息
-
所有需要用户ID的@Tool方法,添加ToolContext参数,确保Spring AI能自动注入上下文
如果觉得有帮助,欢迎点赞收藏!有问题欢迎评论区交流~