Spring AI 工具调用回调与流式前端展示的完整落地方案

那些坑

用 SpringAI 重写 零代码生成 时,前端展示工具调用这件事把我卡住了。

Langchain4j 写回调多舒服啊

▼
java
复制代码
public interface StreamingChatResponseHandler { default void onPartialToolCall(PartialToolCall partialToolCall) {} default void onPartialToolCall(PartialToolCall partialToolCall, PartialToolCallContext context) {} default void onCompleteToolCall(CompleteToolCall completeToolCall) {} void onCompleteResponse(ChatResponse completeResponse); void onError(Throwable error); }

再看看 @ToolMemoryId,直接往方法参数里一扔,conversationId 就到手了,多省心:

▼
java
复制代码
class Tools { @Tool String addCalendarEvent(CalendarEvent event, @ToolMemoryId memoryId) { // memoryId 直接能用 } }

SpringAI 呢?这些它都没有(也有可能是我没找到)。adviseStream 工具调用的时候也感知不到

为什么一定要 conversationId?

主要有下面几点

  1. 生成的代码需要区分目录,方便管理

  2. 隔离每个单独 APP 生成的路径

  3. 记录工具调用次数(后续分析用)

整体思路

用户发请求 → Ai2ChatClient 接收 → SpringAI 处理 → 切面拦截工具调用 → 事件发布 → 实时推给前端

41ae79fb-5207-4d5d-ae8b-2a38219acf83.png

就这么几条线。核心其实就三件事:切面拦截、事件发布、流合并。

AOP 依赖

▼
xml
复制代码
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop </artifactId> </dependency>

举个例子:TodoList 工具

这是我们项目里实际在用的工具类:

具体提示词参考的是 OpenCode 的 https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/todoread.txt

▼
java
复制代码
@Component public class TodolistTools extends BaseTools { /** * cache */ private static final Cache<String, String> TODOLIST_CACHE = Caffeine.newBuilder() .maximumSize(10_00) .expireAfterWrite(Duration.ofMinutes(30)) .build(); @Tool(description = "Write or update the todo list for current task. " + "Use this to track progress and plan remaining work. " + "Each todo item should be a clear, actionable task. " + "Format: numbered list with status markers like [ ], [x], [>], [-]. " + "Status meanings: [ ] pending, [x] completed, [>] in progress, [-] blocked/cancelled." ) public String todoWrite( @ToolParam(description = "The todo list content to save. Format as a structured list with status indicators.") String todoContent, ToolContext toolContext ) { String conversationId = ConversationIdUtils.getConversationId(toolContext); if (StringUtils.isBlank(todoContent)) { TODOLIST_CACHE.invalidate(conversationId); return "Todo list cleared."; } TODOLIST_CACHE.put(conversationId, todoContent); return "Todo list saved successfully.\n\nCurrent todo list:\n" + todoContent; } @Tool(description = "Read the current todo list for this conversation. " + "Use this to check progress and see what tasks remain. " + "Returns an empty message if no todo list exists yet." ) public String todoRead(ToolContext toolContext) { String conversationId = ConversationIdUtils.getConversationId(toolContext); String todoContent = TODOLIST_CACHE.getIfPresent(conversationId); if (StringUtils.isBlank(todoContent)) { return "No todo list for this conversation."; } return "Current todo list:\n" + todoContent; } @Override String getToolName() { return "Todo List Tool"; } @Override String getToolDes() { return "Read and write task todo lists to track progress"; } }

切面是怎么工作的

SpringAI 没给我们留回调接口,那就自己造一个。切面这东西好就好在不改动原代码,加个注解就能生效。

我们用 @Before 抓工具调用开始的那一刻,用 @AfterReturning 抓调用结束的那一刻。工具类丢给 Spring 容器,切面自己就找上门来了。

▼
java
复制代码
@Aspect @Component @Slf4j public class ToolContextAspect { private final ToolEventPublisher toolEventPublisher; public ToolContextAspect(ToolEventPublisher toolEventPublisher) { this.toolEventPublisher = toolEventPublisher; } @Pointcut("execution(* com.leikooo.codemother.ai.tools..*.*(..)) && @annotation(org.springframework.ai.tool.annotation.Tool)") public void anyToolExecution() { } @Before("anyToolExecution()") public void beforeToolCall(JoinPoint joinPoint) { ToolContext toolContext = getToolContext(joinPoint); String className = joinPoint.getTarget().getClass().getSimpleName(); String methodName = joinPoint.getSignature().getName(); if (Objects.isNull(toolContext)) { log.warn("SKIPPED: Tool method [{}.{}] was called but does not accept ToolContext as a parameter.", className, methodName); return; } handleToolContext(toolContext, className, methodName, null, true); } @AfterReturning(pointcut = "anyToolExecution()", returning = "result") public void afterToolCall(JoinPoint joinPoint, Object result) { ToolContext toolContext = getToolContext(joinPoint); String className = joinPoint.getTarget().getClass().getSimpleName(); String methodName = joinPoint.getSignature().getName(); if (toolContext != null) { handleToolContext(toolContext, className, methodName, result, false); } } private void handleToolContext(ToolContext context, String className, String methodName, Object result, boolean isBefore) { Message message = context.getToolCallHistory().getLast(); AssistantMessage.ToolCall toolCallInfo = ((AssistantMessage) message).getToolCalls().getLast(); String toolCallId = toolCallInfo.id(); String sessionId = ConversationIdUtils.getConversationId(context); if (isBefore) { toolEventPublisher.publishToolCall(sessionId, className, methodName, toolCallId); } else { toolEventPublisher.publishToolResult(sessionId, className, methodName, toolCallId, result); } } /** * toolContext * @param joinPoint joinPoint * @return ToolContext */ private ToolContext getToolContext(JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); ToolContext toolContext = null; for (Object arg : args) { if (arg instanceof ToolContext) { toolContext = (ToolContext) arg; break; } } return toolContext; } }

测试结果

事件发布:把消息送出去

这里用到了 Project Reactor 的 Sinks。每个会话一个 Sink,多线程环境下也能正常工作。

▼
java
复制代码
@Component public class ToolEventPublisher { private final Map<String, Sinks.Many<ToolEvent>> sinks = new ConcurrentHashMap<>(); private Sinks.Many<ToolEvent> getSink(String sessionId) { return sinks.computeIfAbsent(sessionId, k -> Sinks.many().multicast().onBackpressureBuffer()); } public void publishToolCall(String sessionId, String toolName, String methodName, String toolCallId) { getSink(sessionId).tryEmitNext(new ToolEvent(sessionId, "tool_call", toolName, methodName, toolCallId, null)); } public void publishToolResult(String sessionId, String toolName, String methodName, String toolCallId, Object result) { getSink(sessionId).tryEmitNext(new ToolEvent(sessionId, "tool_result", toolName, methodName, toolCallId, result)); } public Flux<ToolEvent> events(String sessionId) { return getSink(sessionId).asFlux(); } public void complete(String sessionId) { Sinks.Many<ToolEvent> sink = sinks.remove(sessionId); if (sink != null) sink.tryEmitComplete(); } public record ToolEvent(String sessionId, String type, String toolName, String methodName, String toolCallId, Object result) {} }

流怎么合并到主响应里

这里有两种玩法

玩法一:自己动手丰衣足食

直接在业务方法里把两个流 merge 起来。好处是代码都在明面上,坏处是每个方法都得写一遍。

一个小细节:mainFlux 结束时会触发 doFinally,但 toolEventFlux 不会。所以必须在 doFinally 里手动调用 complete 关掉事件流。否则这个流会一直挂在那儿,等不到终点。
前端就会一直这样: image.png

▼
java
复制代码
@Component public class Ai2ChatClient { private final ChatClient chatClient; private ToolEventPublisher toolEventPublisher; public Ai2ChatClient(ChatModel openAiChatModel, TodolistTools todolistTools, ToolAdvisor toolAdvisor, ToolEventPublisher toolEventPublisher) { this.toolEventPublisher = toolEventPublisher; this.chatClient = ChatClient .builder(openAiChatModel) .defaultTools(todolistTools) .build(); } public Flux<String> chat2Ai(String msg, String appId) { Flux<String> mainFlux = chatClient.prompt() .system(""" You are a helpful, precise, and reliable AI assistant. Respond clearly and concisely. Prioritize correctness, safety, and practicality. If information is uncertain, state the uncertainty explicitly. """) .user(msg) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, appId)) .toolContext(Map.of(CONVERSATION_ID, appId)) .stream().content() .doFinally(s -> toolEventPublisher.complete(appId)); Flux<String> toolEventFlux = toolEventPublisher.events(appId) .map(event -> { Object result = Optional.ofNullable(event.result()).orElse(""); String message = switch (event.type()) { case "tool_call" -> String.format("%s: %s", "正在进行工具调用", result); case "tool_result" -> String.format("%s: %s", "工具调用完成", result); default -> ""; }; return String.format("\n\n[选择工具] %s \n\n", message); }); // 合并流 return Flux.merge(mainFlux, toolEventFlux); } }

玩法二:把脏活累活扔给 Advisor

写个 StreamAdvisor,让它自己处理流合并。业务代码瞬间清爽了。

▼
java
复制代码
/** * @author <a href="https://github.com/lieeew">leikooo</a> * @date 2025/12/31 * @description */ @Slf4j @Component public class ToolAdvisor implements CallAdvisor, StreamAdvisor { private final ToolEventPublisher toolEventPublisher; public ToolAdvisor(ToolEventPublisher toolEventPublisher) { this.toolEventPublisher = toolEventPublisher; } @Override public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) { return callAdvisorChain.nextCall(chatClientRequest); } @Override public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) { String appId = ConversationIdUtils.getConversationId(chatClientRequest.context()); Flux<ChatClientResponse> toolEventFlux = getToolEventFlux(appId); Flux<ChatClientResponse> mainFlux = streamAdvisorChain.nextStream(chatClientRequest) .doFinally(signalType -> toolEventPublisher.complete(appId)); return Flux.merge(mainFlux, toolEventFlux); } @Override public String getName() { return "ToolAdvisor"; } @Override public int getOrder() { return Integer.MIN_VALUE + 100; } /** * 工具调用推送流 * @param sessionId sessionId * @return flux */ private Flux<ChatClientResponse> getToolEventFlux(String sessionId) { return toolEventPublisher.events(sessionId) .map(event -> { Object result = Optional.ofNullable(event.result()).orElse(""); final String methodName = event.methodName(); String message = switch (event.type()) { case "tool_call" -> String.format("正在进行工具调用 %s: %s", methodName, result); case "tool_result" -> String.format("工具调用完成 %s: %s", methodName, result); default -> ""; }; return String.format("\n\n[选择工具] %s \n\n", message); }).map(message -> { AssistantMessage assistantMessage = new AssistantMessage(message); Generation generation = new Generation(assistantMessage); ChatResponse chatResponse = ChatResponse.builder() .generations(List.of(generation)) .build(); return ChatClientResponse.builder().chatResponse(chatResponse).build(); }); } }

注册一下,全局生效:

▼
java
复制代码
@Component public class Ai2ChatClient { private final ChatClient chatClient; public Ai2ChatClient(ChatModel openAiChatModel, FileTools fileTools, ToolAdvisor toolAdvisor) { this.chatClient = ChatClient.builder(openAiChatModel) .defaultTools(fileTools) .defaultAdvisors(toolAdvisor) .build(); } public Flux<String> chat(String msg, String appId) { return chatClient.prompt() .system(""" You are a helpful, precise, and reliable AI assistant. Respond clearly and concisely. Prioritize correctness, safety, and practicality. If information is uncertain, state the uncertainty explicitly. """) .user(msg) .advisors(spec -> spec.param(CONVERSATION_ID, appId)) .toolContext(Map.of(CONVERSATION_ID, appId)) .stream().content(); } }

两种方案怎么选

看什么方案一自己写方案二用 Advisor
代码位置业务方法里单独一个类
复用性惨不忍睹一次编写到处使用
代码量挺长业务方法就几行

我的建议是使用 advisor

怎么拿到 conversationId

Langchain4j 那个 @ToolMemoryId 是真方便。SpringAI 不给咱们就自己想办法。

在工具方法里加个 ToolContext 参数,从里面把 id 拽出来:

▼
java
复制代码
@Slf4j @Component public class FileWriteTool { @Tool("写入文件到指定路径") public String writeFile( @P("文件的相对路径") String relativeFilePath, @P("要写入文件的内容") String content, ToolContext toolContext ) { String conversationId = toolContext.getContext() .get(ChatMemory.CONVERSATION_ID).toString(); // 接下来就能使用了 } }

这个 id 是在调用链上通过 toolContext 传进来的:

▼
java
复制代码
public Flux<String> chat2AiAdvisor(String msg, String appId) { return chatClient.prompt() .system("你是有用的小助手") .user(msg) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, appId)) // 这里设置到 ToolContext .toolContext(Map.of(CONVERSATION_ID, appId)) .stream().content(); }

跑一下看看

写个测试用例,验证整个链路通不通:

▼
java
复制代码
@SpringBootTest class Ai2ChatClientTest { @Resource private Ai2ChatClient ai2ChatClient; @Test void chat2Ai() throws InterruptedException { Flux<String> flux = ai2ChatClient.chat("帮我生成一个企业级别的后端,帮我生成 todolist", "12345"); flux.doOnNext(System.out::println).subscribe(); // 这里需要睡眠主线程,否则拿不到结果 Thread.sleep(10000); } }

跑起来,控制台陆陆续续打出日志,工具调用的事件也正常推送。整个链路是通的。

image.png

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
leikooo
作者分享
最近看了一个视频叫「被 Vibe Coding 抚平的大脑褶皱,还能救回来吗?」,聊的是 AI 时代学编程的困境,感觉说的挺好的,给鱼友们分享一下。 视频中提到现在学编程和以前最大的区别是,以前卡住了你只能自己想、查文档、翻 Stack Overflow,这个过程虽然痛苦,但你的脑子确实在转。现在有了 AI,卡住的第一反应就是打开 ChatGPT 问一句,代码瞬间就出来了,跑通了,感觉自己搞定了。但问题是,你的大脑在这个过程中几乎没有参与。视频里提到一个实验,有个学生读完题 10 秒钟就放弃思考去问 AI 了,事后还觉得是自己独立完成的。这就是 AI 带来的最大陷阱——你以为自己在学,其实只是在看AI 表演。 视频基于一项研究,总结了学编程时容易掉进去的 8 种思维陷阱。研究表明光是知道这些陷阱的存在,就能明显提升学习效果。前 5 种是编程学习中一直存在的,后 3 种是 AI 时代新出现的: 1)Forming(构建错误):你理解了问题,但用了错误的方法去解决。比如题目要你判断正数多还是负数多,你写了个求和的逻辑,方向对了路走偏了。 2)Dislodging(思维固着):你已经意识到方法不对,但就是转不过弯来换思路,反复在错误的方向上修修补补。 3)Assumption(假设偏差):你完美地解决了一个问题,但不是题目要求的那个问题。比如题目要处理任意个数字,你只处理了四个。4)Location(定位缺失):跳过了关键步骤就开始写代码,感觉快写完了,测试的时候才发现漏了循环或数据结构这种核心东西,得大改。 5)Achievement(成就幻觉):写了一大堆代码,明知道有问题但不愿意推倒重来,总想着再改改就好了,结果越改越乱。 6)Progression(进度错觉):AI 帮你写出了超出你水平的代码,作业都能交,但基础可能已经落后好几周了,自己完全不知道。这个是最危险的,等到面试或者独立写代码的时候才发现脑子里是空的。 7)Interruption(思维中断):你正在集中精力思考,AI 自动补全突然弹出来一段代码,思路直接被打断。有意思的是实验中表现好的学生大多直接忽略了 AI 的补全建议。 8)Mislead(误导跟随):信了 AI 给的一个看似合理但方向错误的建议,白白浪费时间走弯路。 大佬给出的建议是,遇到问题先别急着问 AI,给自己至少五分钟独立思考。卡住、沮丧、想摔键盘,这些不是你学不会的信号,这就是解决问题时的正常感受。AI 生成的代码跑通之后,试着关掉 AI 自己从零写一遍,能写出来才算真的会了。最重要的是分清场景,工作赶进度可以用 AI 提效,但练习和学习的时候请把「拐杖」放下,自己走。别让 AI 替你长脑子。
7
ARTS 0927: 单栈逐层展开嵌套字符串、包管理器与 Agent 沙箱本质同源与单次前向传播复刻极速决策模型
7
祝编程导航的鱼友们中秋快乐,愿代码顺利、生活如意,月圆人团圆,所愿皆可期!
13
ARTS 0913: 双栈互补实现队列、CIDR 聚合化解路由膨胀与 AI 作弊串通绝非偶然 Bug
8
微信爆出 P0 级漏洞😱,接到来电就中招,碰都不用碰手机。 怎么中招的? 黑客只需要是你的微信好友,然后给你打一个语音或视频通话,好了已经搞定了,你已经被黑了。 有多离谱? 完全不需要接这个电话。手机锁着屏扔在桌上,你看都没看一眼,只要响了几秒钟,恶意代码就已经在后台跑起来了。 黑客瞬间拿下你整个微信:翻你聊天记录、看你隐私、用你的身份给别人发消息。 更恐怖的是,你的微信紧接着会变成"丧尸",黑客可以自动给通讯录里所有好友挨个打电话,把他们也感染一遍。每个受害者又立刻变成新的传播者,像病毒一样指数级扩散...,不敢想象如果被利用了,理想情况下那需要多久就能扩散到全国的用户呢?30 分钟左右 好消息是这个 bug 微信团队已经知道了这个 bug ,坏消息这个 bug 好像不是微信团队发现的 文章来源:https://calif.io/research/weworm
7
下载 APP