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),线程数还会翻倍。
而我们需要在所有这些线程上做的事:
- 任何埋点代码要知道当前的
traceId和rootSpanId——否则产生的 span 不知道挂到哪棵树上 - 工具回调要能创建
tool_callspan,parent 是 root span——它必须知道 root span 是谁 - 子 Agent 要知道父 Agent 的
traceId——才能建立跨 session 的血缘链
ThreadLocal.set() 只在当前线程可见。换一个线程,拿到的就是 null。
2. 为什么不加新的 ThreadLocal / 全局状态
面对跨线程传播问题,常见补救方案:
| 方案 | 做法 | 为什么不行 |
|---|---|---|
| 处处传参 | 每个方法加 traceId、rootSpanId 参数 |
工具回调 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. 经验总结
ThreadLocal 的"当前线程 = 当前请求"假设在虚拟线程 + WebFlux 混用时不成立。 一个 LLM Agent 请求横跨 N 个虚拟线程,ThreadLocal 天然带不过去。
找已有的载体而不是新建通道。
ChatEventCollector本来就流到每个工具回调(为了 SSE 事件)——trace 上下文搭它的便车,零额外成本。值对象 + 闭包捕获是最简单的跨线程传播方案。 比 InheritableThreadLocal 可靠、比全局 Map 安全、比 Reactor Context 通用。
跨 session 用消息传参。 子 Agent 和父 Agent 没有共享 collector——但消息总线天然是它们之间的数据通道。把
parentTraceId塞进 message context,在消费端按 key 提取,干净、可推导、无魔法。显式优于隐式。
collector.getTraceContext().getTraceId()比RequestContext.getCurrentTraceId()多打几个字,但它明确告诉读者:这个值从哪里来、什么时候可用。隐式 ThreadLocal 让你写起来快,debug 起来慢。