
ThreadLocal 在流式 Agent 里静默失效LangChain4j 轨迹埋点的零侵入方案给质检分析 Agent 加工具调用轨迹实时展示时我们走了两条歧路最后用「每请求一个 Agent 实例 闭包捕获」把埋点做到了零侵入。这篇文章记录完整的排查过程与最终方案以及为什么中间那两个看似完美的方案会静默地死掉。一、需求让前端看见 Agent 在干什么场景是一个质检分析 AgentSpring Boot 4 LangChain4j 1.14.1用户问一句分析 R20260808001 的根因模型自主决定调用哪些工具——getReportByNo→getReport→getDefects→getProductionParams→searchKnowledge最后给出结论。传统的做法是用户盯着转圈圈几秒后收到一坨结论——过程是黑盒。我们的做法用 SSE 把工具调用轨迹实时推给前端渲染成 running / done 卡片让用户看着 Agent “工作”需求拆成三条工具开始执行时推一个running事件带参数工具执行完成时推一个done事件带结果工具代码里不能出现任何埋点代码零侵入前两条不难难的是第三条以及一个一开始没意识到的拦路虎SSE 的emitter是本次请求的私有对象而工具执行发生在模型调用深处中间隔着线程跳转。emitter 怎么送到回调里二、先看清线程都在哪一张图说清难点工具执行模型网络 I/OstreamExecutor 线程Servlet 线程工具执行模型网络 I/OstreamExecutor 线程Servlet 线程Servlet 线程立即返回去处理别的请求推送 tool 事件 ← emitter 必须在这里可用创建 SseEmitterexecutor.execute(λ)agent.analyze(question)返回工具调用请求执行工具顺序模式同一线程工具结果问题一句话总结emitter 出生在 Servlet 线程却要在 executor 线程以及将来可能的工具线程池线程上使用。三、歧路一ThreadLocal——看似完美静默失效第一反应几乎一定是 ThreadLocal请求进来set回调里get多优雅。TraceContext.set(emitter);agent.analyze(question);// 回调里AgentTraceContext.send(TraceContext.get(),tool,payload);它确实诱人工具代码依旧干净回调里 get 一下就行。但它会死而且分两层死法——错法 A今天就死。在 Controller 方法Servlet 线程里set回调跑在streamExecutor线程上get→ 直接读到null。ThreadLocal 是线程本地存储天生不跨线程。错法 B今天恰好能活明天静默死。改在 executor 的 lambda 里set当前版本工具顺序执行、模型同步调用恰好都跑在 executor 这条线程上能读到。但这是建立在两个脆弱假设上的假设工具永远顺序执行——一旦开启executeToolsConcurrently()工具被派发到独立线程池回调在池线程上执行 →get()是null假设模型永远同步调用——换成StreamingChatModel回调在异步回调线程上 → 同理最小复现20 行可直接跑publicclassThreadLocalDemo{staticfinalThreadLocalStringCTXnewThreadLocal();staticfinalExecutorServicePOOLExecutors.newSingleThreadExecutor();publicstaticvoidmain(String[]args)throwsException{CTX.set(本次请求的 emitter);POOL.submit(()-System.out.println(读到: CTX.get())// 期望: emitter).get();// 实际: nullPOOL.shutdown();}}最可怕的不是失败而是失败的方式静默。没有异常、没有错误日志前端只是收不到事件。你在控制器里打断点一切正常跑到回调里get()永远是null——排查一下午最后怀疑人生。四、歧路二InvocationContext.methodArguments()——框架的内部机制排查途中会翻到 LangChain4j 回调上下文里的InvocationContext。有人发现它似乎能拿到方法参数于是想把 emitter 塞进参数列表搭便车传进去。不行。这是框架内部用来定位ChatMemory等托管类型的机制managedParameters/LangChain4jManaged把它当自定义参数通道属于未文档化行为——框架升级随时可能失效。教训框架留了个口子不等于框架允许你用。五、正路每请求一个 Agent 实例 闭包捕获核心思想一句话上下文不靠线程、不靠框架内部机制而是变成实例状态。5.1 三个组件AgentFactory——每次请求建一个 Agent 实例FunctionalInterfacepublicinterfaceAgentFactory{QcAnalysisAgentcreate(SseEmitteremitter);}BeanProfile(cloud)publicAgentFactorycloudAgentFactory(QcAgentToolstools){// 模型只建一次连接池、API 密钥、Token 统计 listener 都挂在模型上ChatModelmodelOpenAiChatModel.builder().baseUrl(...).apiKey(...).modelName(...).listeners(List.of(newTokenUsageListener())).build();// Agent 每请求建一次闭包把 emitter 绑到实例上returnemitter-buildAgent(model,tools,emitter);}privateQcAnalysisAgentbuildAgent(ChatModelmodel,QcAgentToolstools,SseEmitteremitter){returnAiServices.builder(QcAnalysisAgent.class).chatModel(model).tools(tools).beforeToolExecution(b-{// 官方钩子 1开始执行MapString,ObjectpayloadnewLinkedHashMap();payload.put(name,b.request().name());payload.put(args,b.request().arguments());payload.put(status,running);AgentTraceContext.send(emitter,tool,payload);}).registerListener(newAgentTraceListener(emitter))// 官方钩子 2执行完成.build();}AgentTraceListener——工具完成的钩子publicclassAgentTraceListenerimplementsToolExecutedEventListener{privatefinalSseEmitteremitter;// 闭包捕获跟着实例走OverridepublicvoidonEvent(ToolExecutedEventevent){MapString,ObjectpayloadnewLinkedHashMap();payload.put(name,event.request().name());payload.put(status,done);payload.put(result,truncate(event.resultText()));AgentTraceContext.send(emitter,tool,payload);}}为什么闭包行得通emitter 被 lambda 捕获后跟着 Agent 实例走不依赖当前线程是谁。无论回调在哪条线程触发闭包里的 emitter 永远是本次请求的那一个。ThreadLocal 的线程绑定假设被彻底绕开了。5.2 性能权衡“贵的只建一次便宜的一次一建”模型贵连接池、API 密钥、Token 统计 listener——Bean 级只建一次Agent便宜实测一次AiServices.builder().build()稳态平均0.27ms预热 500 次后循环 5000 次取均值JDK 17而一次完整分析约 5.2s日志实测构建开销占比约0.005%——可忽略顺带一个诚实的小数字JVM 内首次构建花了 352ms内部类加载 JIT 编译这是一次性成本只影响第一个请求。5.3 零侵入的证据QcAgentTools全文四个工具只有业务代码和log.info没有一行埋点——工具根本不知道追踪的存在。六、小结ThreadLocal 在流式 Agent 里静默失效根因是线程跳转emitter 出生在 Servlet 线程却要在 executor / 工具池 / 异步回调线程上使用。两条歧路ThreadLocal、InvocationContext.methodArguments()都试图跨线程传上下文——前者依赖线程绑定、后者依赖框架内部机制都不可靠。最终方案把上下文从线程挪到实例每请求一个 Agent 实例emitter 通过闭包捕获绑定到实例上。无论回调在哪条线程触发闭包里的 emitter 永远是本次请求的那一个。模型只建一次贵Agent 每次请求建一次0.27ms便宜零侵入、无静默失效。这个模式可称为Per-request Instance Closure-captured Context——不止 SSE 轨迹审计日志、请求级限流标记、traceId 传递都是同一类问题。另外几个值得抄的生产细节前端断开时静默跳过、LinkedHashMap防 null、结果截断 800 字符。完整实现见 github.com/CKX3197273653/Nozomimate10/src/main/java/org/mate/mate10/agent/包本文基于 LangChain4j 1.14.1