gogo-agent

使用TTL实现多智能体之间的上下文传递

在我们的项目中,有一些参数,需要全局传递,比如userId,从用户一进来就确定了,我们需要在多个智能体之间传递、并且在tools、hook中也可能需要用到,那么如何实现这个参数的传递呢。 gogo agent 用 Transmitt…

TL;DR

在我们的项目中,有一些参数,需要全局传递,比如userId,从用户一进来就确定了,我们需要在多个智能体之间传递、并且在tools、hook中也可能需要用到,那么如何实现这个参数的传递呢。 gogo agent 用 Transmitt…

在我们的项目中,有一些参数,需要全局传递,比如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 等。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。