Java 后端有一个在 90% 场景下好用的惯例:把请求级上下文(用户 ID、租户 ID、trace ID)塞进 ThreadLocal,在请求入口 set,在请求出口 clear。Spring 的 RequestContextHolder、自定义的 RequestContext,遍地都是这个模式。

但当你把 LLM Agent 架在 WebFlux + 虚拟线程上时,一个请求的生命周期会横跨多个线程——同一个 trace 的不同 span 跑在不同的虚拟线程上。这时候 ThreadLocal 的"当前线程 = 当前请求"假设就塌了。

本文记录我们如何用一个显式值对象替代隐式 ThreadLocal,让 trace 上下文在 LLM 调用、工具回调、子 Agent 运行器之间可靠传播。


1. 问题:一个推理请求横跨多少线程?

以我们平台的一次典型主 Agent 推理为例:

用户发请求 → WebFlux NIO 线程(Netty worker)
  ├── buildChatFlux 建 root span → Reactor 线程(或 Netty worker)
  ├── LLM 流式调用 → BxChatModel 虚拟线程(阻塞读循环)
  ├── 工具回调 #1(查知识库)→ TOOL_EXECUTOR 虚拟线程 #1
  ├── 工具回调 #2(调 MCP)→ TOOL_EXECUTOR 虚拟线程 #2
  └── 流结束 handleStreamComplete → Reactor 线程(或 Netty worker)

一个请求 → 至少 5 个线程。 如果主 Agent 还 spawn 了子 Agent(bt_send_message),线程数还会翻倍。

而我们需要在所有这些线程上做的事:

  • 任何埋点代码要知道当前的 traceIdrootSpanId——否则产生的 span 不知道挂到哪棵树上
  • 工具回调要能创建 tool_call span,parent 是 root span——它必须知道 root span 是谁
  • 子 Agent 要知道父 Agent 的 traceId——才能建立跨 session 的血缘链

ThreadLocal.set() 只在当前线程可见。换一个线程,拿到的就是 null。


2. 为什么不加新的 ThreadLocal / 全局状态

面对跨线程传播问题,常见补救方案:

方案 做法 为什么不行
处处传参 每个方法加 traceIdrootSpanId 参数 工具回调 API 是 Spring AI 框架定义的接口,我们改不了签名
InheritableThreadLocal new Thread() 时复制父线程的 ThreadLocal 工具执行用的是虚拟线程池,创建时机和提交时机分离,复制不到正确的值
全局 Map ConcurrentHashMap<requestId, TraceContext> 手动管理生命周期(什么时候 remove?),泄漏风险高;同一用户并发请求会串
响应式 Context Reactor ContextView Reactor Context 沿 Flux 管道传播,但工具回调不在 Flux 管道里——它们是独立的虚拟线程

结论:这些方案的共同问题是试图用隐式机制弥补 ThreadLocal 的不足,但穿不过"框架层 + 线程池"的边界

正确的方向:把上下文变成一个显式的值对象,挂在一个本来就流通到所有 callback 的载体上。


3. 方案:TraceContext 搭 ChatEventCollector 的便车

3.1 载体选择

我们的 ChatEventCollector 是每请求一个的实例,负责:

  • 收集工具调用事件(ts/tr),推送到 SSE 流
  • 收集 KB 引用(kr
  • 工具去重

本来就流到了每个工具回调的 lambda 闭包里——因为工具回调需要它来 emit SSE 事件。这就是我们要找的载体。

3.2 TraceContext 值对象

在 domain 层新增 TraceContext——一个纯 POJO,不做任何线程操作:

public class TraceContext {
    private String traceId;        // trc_xxxxxxxxxxxx
    private String rootSpanId;     // spn_xxxxxxxxxxxx
    private String sessionId;
    private String spaceId;
    private String instanceId;
    private Long userId;
    private long startTimeMs;
    private TraceSpan rootSpan;    // 当前 trace 的 root span 引用
    private String lastFinishReason;

    public void clear() { /* 重置所有字段 */ }
}

它没有 static、没有 ThreadLocal、没有任何并发设施——它只是一个数据容器。线程安全由它的持有者(ChatEventCollector)保证:collector 是每请求一个的,没有跨请求共享。

3.3 挂车

ChatEventCollector 新增一个字段:

public class ChatEventCollector {
    // ... 已有字段:knowledgeGateway, pending, toolEventSink, calledTools ...

    /** Agent Trace 上下文。
     *  虚拟线程上 ThreadLocal 会丢,由 collector 显式携带。 */
    private final TraceContext traceCtx = new TraceContext();

    public TraceContext getTraceContext() { return traceCtx; }
}

3.4 传播路径

buildChatFlux 阶段:
  collector.getTraceContext().setTraceId("trc_abc123");
  collector.getTraceContext().setRootSpanId("spn_001");
  collector.getTraceContext().setRootSpan(span);    // ★ 建 root span

工具回调阶段(在另一个虚拟线程上):
  var tctx = collector.getTraceContext();   // ★ collector 通过闭包传进来的
  var toolSpan = TraceSpan.builder()
      .traceId(tctx.getTraceId())           // 直接读,没有 ThreadLocal
      .parentSpanId(tctx.getRootSpanId())
      .spanType("tool_call")
      ...
      .build();
  traceRecorder.offer(toolSpan);

流结束阶段:
  var span = collector.getTraceContext().getRootSpan();
  span.setResponseSnapshot(...);
  span.setLatencyMs(...);
  traceRecorder.offer(span);                // ★ 补完 root span 并写入

整个链路上,没有任何 ThreadLocal 操作。上下文通过 collector 引用自然流到所有需要它的地方。


4. 跨 session 血缘:消息传参

子 Agent 的场景更复杂——它运行在另一个 session 里,ChatEventCollector 也不同。怎么知道父 Agent 的 traceId?

我们的方法是:通过消息总线传参——把 parentTraceId 注入 bt_send_message 的 message context 字段:

父 Agent                   消息总线(Redis Pub/Sub)       子 Agent
  │                                                         │
  ├── send_message(to=子Agent, context={...,                │
  │       _parentTraceId: "trc_parent"})                     │
  │                                                         │
  └── messageBus.send() ──────────→ ──────────→ inbox ──→ SubAgentRunner
                                                            │
                                                            ├── extractParentTraceId(inbox)
                                                            │   → "trc_parent"
                                                            │
                                                            ├── 子 Agent span metadata 标
                                                            │   parentTraceId: "trc_parent"

SendMessageExecutor 注入:

Map<String, Object> ctxMap = context != null
    ? JsonUtil.toMap(context) : new LinkedHashMap<>();
ctxMap.put("_parentTraceId", parentTraceId);
messageBus.send(sessionId, fromAgentId, to, type, content,
    JsonUtil.toJson(ctxMap), inReplyTo);

SubAgentRunner 提取:

static String extractParentTraceId(List<AgentMessage> inbox) {
    for (var m : inbox) {
        if (m.context() != null && m.context().contains("_parentTraceId")) {
            var map = JsonUtil.toMap(m.context());
            return (String) map.get("_parentTraceId");
        }
    }
    return null;
}

这是显式传参的最朴素形式——把需要传递的值塞进已有的数据通道(消息 context JSON),在消费端按约定的 key 取出。没有全局状态、没有序列化魔法、没有"我猜你应该知道"的隐式约定。


5. 这个模式的边界

适合

  • 请求级上下文,生命周期 = 一次推理请求
  • 跨线程传播,尤其是虚拟线程/线程池中的异步任务
  • 上下文有多个消费者(LLM 埋点、工具埋点、子 Agent 埋点),但不需要修改

不适合

  • 窄作用域的值:如果只有一处用到,直接传参更干净
  • 需要跨请求传播的值:比如用户认证信息——这类应该走 JWT/Header,而不是靠 collector 传
  • 需要被大量无关代码透传的值:如果上下文需要经过 20 层调用栈才到使用点,ThreadLocal 可以减少噪音——前提是线程不切换

一个关键的判断标准

如果你的上下文需要穿过一个你不控制的 API 边界(框架回调、线程池提交、消息总线),ThreadLocal 永远不可靠。 显式传值是唯一能正确穿过的途径。


6. 经验总结

  1. ThreadLocal 的"当前线程 = 当前请求"假设在虚拟线程 + WebFlux 混用时不成立。 一个 LLM Agent 请求横跨 N 个虚拟线程,ThreadLocal 天然带不过去。

  2. 找已有的载体而不是新建通道。 ChatEventCollector 本来就流到每个工具回调(为了 SSE 事件)——trace 上下文搭它的便车,零额外成本。

  3. 值对象 + 闭包捕获是最简单的跨线程传播方案。 比 InheritableThreadLocal 可靠、比全局 Map 安全、比 Reactor Context 通用。

  4. 跨 session 用消息传参。 子 Agent 和父 Agent 没有共享 collector——但消息总线天然是它们之间的数据通道。把 parentTraceId 塞进 message context,在消费端按 key 提取,干净、可推导、无魔法。

  5. 显式优于隐式。 collector.getTraceContext().getTraceId()RequestContext.getCurrentTraceId() 多打几个字,但它明确告诉读者:这个值从哪里来、什么时候可用。隐式 ThreadLocal 让你写起来快,debug 起来慢。