ThreadLocal 在流式 Agent 里静默失效:LangChain4j 轨迹埋点的零侵入方案

给质检分析 Agent 加"工具调用轨迹"实时展示时,我们走了两条歧路,最后用「每请求一个 Agent 实例 + 闭包捕获」把埋点做到了零侵入。这篇文章记录完整的排查过程与最终方案,以及为什么中间那两个看似完美的方案会静默地死掉。

一、需求:让前端"看见" Agent 在干什么

场景是一个质检分析 Agent(Spring Boot 4 + LangChain4j 1.14.1):用户问一句"分析 R20260808001 的根因",模型自主决定调用哪些工具------getReportByNo → getReport → getDefects → getProductionParams → searchKnowledge,最后给出结论。

传统的做法是:用户盯着转圈圈,几秒后收到一坨结论------过程是黑盒。我们的做法:用 SSE 把工具调用轨迹实时推给前端,渲染成 running / done 卡片,让用户看着 Agent "工作":

需求拆成三条:

  1. 工具开始执行 时,推一个 running 事件(带参数)
  2. 工具执行完成 时,推一个 done 事件(带结果)
  3. 工具代码里不能出现任何埋点代码(零侵入)

前两条不难,难的是第三条,以及一个一开始没意识到的拦路虎:SSE 的 emitter 是"本次请求"的私有对象,而工具执行发生在模型调用深处,中间隔着线程跳转。emitter 怎么送到回调里?

二、先看清线程都在哪

一张图说清难点:

sequenceDiagram participant S as Servlet 线程 participant E as streamExecutor 线程 participant M as 模型(网络 I/O) participant T as 工具执行 S->>S: 创建 SseEmitter S->>E: executor.execute(λ) Note over S: Servlet 线程立即返回,去处理别的请求 E->>M: agent.analyze(question) M-->>E: 返回工具调用请求 E->>T: 执行工具(顺序模式:同一线程) T-->>E: 工具结果 Note over E: 推送 tool 事件 ← emitter 必须在这里可用

问题一句话总结:emitter 出生在 Servlet 线程,却要在 executor 线程(以及将来可能的工具线程池线程)上使用。

三、歧路一:ThreadLocal------看似完美,静默失效

第一反应几乎一定是 ThreadLocal:请求进来 set,回调里 get,多优雅。

java 复制代码
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 行,可直接跑):

java 复制代码
public class ThreadLocalDemo {
    static final ThreadLocal<String> CTX = new ThreadLocal<>();
    static final ExecutorService POOL = Executors.newSingleThreadExecutor();

    public static void main(String[] args) throws Exception {
        CTX.set("本次请求的 emitter");
        POOL.submit(() ->
                System.out.println("读到: " + CTX.get())   // 期望: emitter
        ).get();                                            // 实际: null
        POOL.shutdown();
    }
}

最可怕的不是失败,而是失败的方式:静默 。没有异常、没有错误日志,前端只是收不到事件。你在控制器里打断点一切正常,跑到回调里 get() 永远是 null------排查一下午,最后怀疑人生。

四、歧路二:InvocationContext.methodArguments()------框架的内部机制

排查途中,会翻到 LangChain4j 回调上下文里的 InvocationContext。有人发现它似乎能拿到方法参数,于是想把 emitter 塞进参数列表"搭便车"传进去。

不行。这是框架内部 用来定位 ChatMemory 等托管类型的机制(managedParameters / LangChain4jManaged),把它当自定义参数通道,属于未文档化行为------框架升级随时可能失效。

教训:框架留了个口子,不等于框架允许你用。

五、正路:每请求一个 Agent 实例 + 闭包捕获

核心思想一句话:上下文不靠线程、不靠框架内部机制,而是变成实例状态。

5.1 三个组件

AgentFactory------每次请求建一个 Agent 实例:

java 复制代码
@FunctionalInterface
public interface AgentFactory {
    QcAnalysisAgent create(SseEmitter emitter);
}

@Bean
@Profile("cloud")
public AgentFactory cloudAgentFactory(QcAgentTools tools) {
    // 模型只建一次:连接池、API 密钥、Token 统计 listener 都挂在模型上
    ChatModel model = OpenAiChatModel.builder()
            .baseUrl(...)
            .apiKey(...)
            .modelName(...)
            .listeners(List.of(new TokenUsageListener()))
            .build();

    // Agent 每请求建一次:闭包把 emitter 绑到实例上
    return emitter -> buildAgent(model, tools, emitter);
}

private QcAnalysisAgent buildAgent(ChatModel model, QcAgentTools tools,
                                   SseEmitter emitter) {
    return AiServices.builder(QcAnalysisAgent.class)
            .chatModel(model)
            .tools(tools)
            .beforeToolExecution(b -> {
                // 官方钩子 1:开始执行
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("name", b.request().name());
                payload.put("args", b.request().arguments());
                payload.put("status", "running");
                AgentTraceContext.send(emitter, "tool", payload);
            })
            .registerListener(new AgentTraceListener(emitter))  // 官方钩子 2:执行完成
            .build();
}

AgentTraceListener------工具完成的钩子:

java 复制代码
public class AgentTraceListener implements ToolExecutedEventListener {

    private final SseEmitter emitter;   // 闭包捕获,跟着实例走

    @Override
    public void onEvent(ToolExecutedEvent event) {
        Map<String, Object> payload = new LinkedHashMap<>();
        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/CKX31972736...(mate10/src/main/java/org/mate/mate10/agent/ 包,本文基于 LangChain4j 1.14.1)

相关推荐
小宋10212 小时前
OpenTelemetry GenAI可观测性实战:串起模型、工具、Token与错误
java·人工智能·算法·贪心算法
m0_587383002 小时前
工业场景设备维修维护实战技巧 全流程标准化落地与常见问题排查指南
java·spring·小程序·架构·需求分析
郑州光合科技余经理3 小时前
本地生活平台搭建:跨业态用户标识怎么贯通
java·开发语言·前端·后端·uni-app·php·ai编程
木头科技3 小时前
【AI 工程化第八篇】Spring AI 多模型路由实战:DeepSeek、通义、Kimi、OpenAI 如何统一接入和自动切换
java·人工智能·spring
深盾科技_Virbox3 小时前
云端与本地并行交付,软件许可管理如何实现统一运营?
java·大数据·开发语言·安全
thefool1122663 小时前
多线程 3
java
CoderYanger3 小时前
A.每日一题:856. 括号的分数
java·程序人生·算法·leetcode·面试·职场和发展·学习方法
liangbo73 小时前
01-Java 集合框架全景:从 Collection 到 Map 一张关系网理清
java
xuxigifxfh3 小时前
HJ15 求int型正整数在内存中存储时1的个数
java·开发语言·华为机考