给质检分析 Agent 加"工具调用轨迹"实时展示时,我们走了两条歧路,最后用「每请求一个 Agent 实例 + 闭包捕获」把埋点做到了零侵入。这篇文章记录完整的排查过程与最终方案,以及为什么中间那两个看似完美的方案会静默地死掉。
一、需求:让前端"看见" Agent 在干什么
场景是一个质检分析 Agent(Spring Boot 4 + LangChain4j 1.14.1):用户问一句"分析 R20260808001 的根因",模型自主决定调用哪些工具------getReportByNo → getReport → getDefects → getProductionParams → searchKnowledge,最后给出结论。
传统的做法是:用户盯着转圈圈,几秒后收到一坨结论------过程是黑盒。我们的做法:用 SSE 把工具调用轨迹实时推给前端,渲染成 running / done 卡片,让用户看着 Agent "工作":

需求拆成三条:
- 工具开始执行 时,推一个
running事件(带参数) - 工具执行完成 时,推一个
done事件(带结果) - 工具代码里不能出现任何埋点代码(零侵入)
前两条不难,难的是第三条,以及一个一开始没意识到的拦路虎:SSE 的 emitter 是"本次请求"的私有对象,而工具执行发生在模型调用深处,中间隔着线程跳转。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)