在我们的项目中,有一些参数,需要全局传递,比如userId,从用户一进来就确定了,我们需要在多个智能体之间传递、并且在tools、hook中也可能需要用到,那么如何实现这个参数的传递呢。 gogo-agent 用 TransmittableThreadLocal(TTL)在请求线程里种下一个 AgentSessionContext(userId, sessionId),让它自动跟随 Reactor 的线程切换,一路传播到 prototype Agent 的 build()、子智能体懒加载 lambda、Tool 执行和 Hook 回调里,从而在「对 LLM 完全透明」的前提下,把用户身份与会话标识注入到全链路。核心矛盾是:响应式框架会切线程,而普通 ThreadLocal 不跨线程池。 传统 Web MVC 里,一个请求从头到尾在同一个 Tomcat 线程上跑,ThreadLocal 天然够用。但 gogo-agent 的执行链是响应式的:
// AgentPipelineService#executeFullPipeline
return Mono.fromCallable(() -> intentRecognitionRouter.route(originalQuestion))
.subscribeOn(Schedulers.boundedElastic()) // ← 从这里开始,后续操作都在 boundedElastic 线程上跑
.flatMap(fastHit -> {
...
return dispatchByIntent(intentJson, inputMessages, originalQuestion, sessionId, userId);
})
.contextWrite(Context.of("sessionId", sessionId, "userId", userId));
subscribeOn开始,执行线程已经从 Tomcat 线程换成了 boundedElastic-1。如果用普通 ThreadLocal,在多线程情况下,是无法传递参数的。
载体:上下文对象与持有者
上下文对象 AgentSessionContext
这是被传递的「货物」——一次调用要随身携带的会话信息:
public class AgentSessionContext {
private final String userId; // 谁(sa-token 登录态)
private final String sessionId; // 哪个会话
private String travelPolicy; // 可变:差旅政策
private String travelPreference; // 可变:出行偏好
private String travelOrderId; // 可变:当前关联的差旅单ID(供 BookingPersistenceHook 关联预订记录)
public AgentSessionContext(String userId, String sessionId) {
Assert.notNull(userId, "userId must not be null");
Assert.notNull(sessionId, "sessionId must not be null");
this.userId = userId;
this.sessionId = sessionId;
}
// getter / setter ...
}
userId/sessionId 是 final 且非空断言(一次调用的不可变身份),而 travelOrderId 等是可变字段——允许在链路中途(如创建差旅单后)回填,供后续 Hook 关联使用。注意这带来了可变共享状态,后面第 8 节会讨论其风险。
持有者 AgentSessionContextHolder——全局唯一 TTL 入口
public final class AgentSessionContextHolder {
// 唯一区别:用 TransmittableThreadLocal 而非 ThreadLocal
private static final TransmittableThreadLocal<AgentSessionContext> HOLDER =
new TransmittableThreadLocal<>();
private AgentSessionContextHolder() {}
public static void set(AgentSessionContext context) { HOLDER.set(context); }
public static AgentSessionContext get() { return HOLDER.get(); }
public static void clear() { HOLDER.remove(); }
}
API 与普通 ThreadLocal 一模一样(set/get/remove),唯一的改动就是把类型换成 TransmittableThreadLocal。这正是 TTL 的最大卖点:零侵入替换,业务代码无感。它的三层能力: 1. ThreadLocal 的能力:线程内隔离; 2. InheritableThreadLocal 的能力:父线程 → 子线程(new Thread())传递; 3. TTL 独有:父线程 → 线程池已存在的线程传递(解决线程复用场景,这正是 Reactor 的模型)。
传播全链路:从 Controller 到子智能体工具
下面这条链是整篇文档的主干,请对照代码逐跳理解。
[http-nio 线程] ChatController.chat
│ AgentSessionContextHolder.set(ctx) ← ★TTL 种值
▼
构造 Mono(声明式,尚未执行)
│
│ subscribeOn(boundedElastic) ← TTL 快照被捕获并搬运
▼
[boundedElastic-N 线程] AgentPipelineService.dispatchByIntent
│ agentRegistry.getAgent("masterAgent")
▼
MasterAgent.build() ── get() ──▶ 得到 ctx ← ★TTL 读值
│ register(ctx) → ToolExecutionContext
│
│ LLM 决策:调用某子智能体工具
▼
[boundedElastic-M 线程] SubAgentProvider lambda
│ context.getBean("itineraryManageAgent")
▼
子 Agent.build() ── createToolCtx()/get() ──▶ ctx ← ★TTL 读值(无需重新 set)
│
├─▶ @Tool 方法:ctx 作为透明参数注入(LLM 不可见)
└─▶ Hook 回调:AgentSessionContextHolder.get() 直接读
跳 1:Controller —— 在请求线程种下上下文
// ChatController#chat
@PostMapping("/{sessionId}")
@SaCheckLogin
public SseEmitter chat(@PathVariable String sessionId, @RequestBody ChatRequest request) {
String userId = StpUtil.getLoginIdAsString(); // sa-token 拿登录态
AgentSessionContextHolder.set(new AgentSessionContext(userId, sessionId)); // ★ 种下 TTL
...
agentExecutor.executeAgent(null, inputMessages, message, emitter, sessionId, userId);
return emitter;
}
respond(工具挂起后用户回复)入口同样会 set 一次(line 124),保证 Tool Suspend 恢复链路也带着身份。
跳 2:Reactor 切线程 —— TTL 自动跟随
executeAgent 里构造的 Mono 通过 subscribeOn(Schedulers.boundedElastic()) 切换执行线程。TTL 在这一跳「接管」了上下文的搬运。
跳 3:prototype build() —— 在线程池线程里读出上下文
Master/子 Agent 都是 @Scope("prototype"),每次 getBean 都重新执行 build()。而 build() 是在 boundedElastic 线程上被调用的,此刻靠 TTL 才能读到值:
// MasterAgent#build
@Bean(name = "masterAgent")
@Scope("prototype")
public ReActAgent build() {
// 从 TransmittableThreadLocal 读取当前会话上下文
// TTL 自动传播到 boundedElastic 线程,SubAgentProvider 无需手动重新 set
AgentSessionContext sessionCtx = AgentSessionContextHolder.get(); // ★ 读出 TTL
String userId = sessionCtx != null ? sessionCtx.getUserId() : null;
LongTermMemory longTermMemory = memoryFactory != null ? memoryFactory.create(userId) : null;
ToolExecutionContext masterToolCtx = sessionCtx != null
? ToolExecutionContext.builder().register(sessionCtx).build() // ★ 灌进 AgentScope
: ToolExecutionContext.empty();
...
}
跳 4:子智能体懒加载 lambda
MasterAgent 把子 Agent 注册成工具,Provider 是个 lambda,真正执行发生在 LLM 决定调用该子 Agent 时(更深的响应式线程):
// MasterAgent#build(节选)
toolkit.registration()
.subAgent((SubAgentProvider<ReActAgent>) () ->
context.getBean("itineraryManageAgent", ReActAgent.class), // ← 延迟到调用时才 build()
SubAgentConfig.builder()
.toolName("itinerary_manage_agent")
.description("行程单全生命周期管理:收集信息、提交审批、查询差旅单...")
.forwardEvents(false)
.build())
.apply();
itineraryManageAgent 的 build()(以及 BaseSubAgent.createToolCtx())内部同样 AgentSessionContextHolder.get()。因为有 TTL,这个 lambda 无论被调度到哪个线程执行,都能拿到正确的 userId/sessionId——不需要在每个 lambda 里手动重新 set,这正是 MasterAgent 注释所强调的价值。 BaseSubAgent 把这段读取收敛成了两个静态工具方法,所有子 Agent 复用:
// BaseSubAgent
public static ToolExecutionContext createToolCtx() {
AgentSessionContext sessionCtx = AgentSessionContextHolder.get(); // ★ 每个子 Agent build() 都这样读
return sessionCtx != null
? ToolExecutionContext.builder().register(sessionCtx).build()
: ToolExecutionContext.empty();
}
protected static LongTermMemory createLongTermMemory(TravelPreferenceLongTermMemoryFactory factory) {
if (factory == null) return null;
AgentSessionContext sessionCtx = AgentSessionContextHolder.get();
String userId = sessionCtx != null ? sessionCtx.getUserId() : null;
return factory.create(userId); // 长期记忆按 userId 隔离,也依赖 TTL
}
参数传递给Tool
前面的agent的build() 里把 sessionCtx register 进 ToolExecutionContext 后,AgentScope 会在调用 @Tool 方法时,把它作为参数自动注入。LLM 完全看不到这个参数(无需 @ToolParam),它不占用 function-calling 的 schema:
@Tool(name = "query_travel_order", description = "查询用户差旅单列表或指定差旅单详情。支持按状态、出发日期范围过滤。")
public String queryTravelOrder(AgentSessionContext sessionCtx) {
// 从会话上下文中获取用户ID ,能确保不出错
String userId = sessionCtx.getUserId();
}
这就实现了「LLM 只管业务语义,userId 这类系统上下文由框架旁路带入」的干净分层。
在Hook中读取参数
有些横切逻辑(用户目录隔离、预订记录持久化)不走 Tool 参数,而是在 Hook 里直接 get():
// RghUserIsolationHook(酒店 CLI 的用户级隔离)
AgentSessionContext sessionCtx = AgentSessionContextHolder.get(); // ★ Hook 里也靠 TTL
if (sessionCtx == null) {
logger.warn("[RghIsolation] AgentSessionContext 为 null,跳过用户隔离");
return Mono.just(event);
}
String userId = sessionCtx.getUserId();
Path userDir = Path.of(USER_HOME_BASE, userId); // 按用户隔离本地工作目录
类似地还有 BookingPersistenceHook、AgentExecutionRegistryHook、AbstractShellApiKeyHook 等。