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
作者分享
提到用 Rust 重写项目,很多人的第一反应往往是性能提升。以 Bun 为例,它最初主要采用 Zig 开发,后来在 2026 年发布的 v1.4 版本中将核心代码迁移到了 Rust。用 Rust 重写,性能、安全性往往是大家想要的,但是有没有想过用 Rust 重写还能发现 bug 呢? pgrust 这个项就是,他用 Rust 重新实现 PostgreSQL,保持通信协议和 SQL 方言兼容。为了确认新系统的行为和原版一致,开发者需要对着 PostgreSQL 的实际返回逐个比对。结果查着查着,有些异常不是新代码的锅,PostgreSQL 自己就是这样的。pgrust 开发者 Michael Malis 把其中几个报告给了 PostgreSQL 社区。 大概的问题: 1)SP-GiST 空间索引里有个函数判断点的方位,代码认为上、下、同一水平线三种情况已经穷尽了,末尾放了句「impossible case」兜底。结果在特定浮点边界附近,三种判断同时不成立,程序真走进了这个「不可能」的分支 2)日期格式缓存复用时,一个解析模式的标记没重置,导致同一条 JSONPath 查询在同一个会话里,换个执行顺序结果就不一样。 3)pgcrypto 的 crypt() 在某些输入下截断了多字节字符,返回的文本编码不合法。能存进去,但 pg_dump 导出时就炸了。 一个用 Rust 重写的新项目,测着测着,把运行了几十年的 PostgreSQL 里埋着的东西翻了出来。Rust 重写的价值,不一定只在跑得更快、更安全这一件事上。为了做到真正的兼容,开发者得把前人没怎么碰过的边界全走一遍,这个过程本身就在帮原版排雷。
4
ARTS 1025: 唯一字符与同构字符串、省词可能增加阅读负担与重新估算软件的实现成本
5
🎉 1024 程序员节快乐! 祝编程导航里的每一位程序员:offer 多多,薪资翻倍,面试顺利,早日拿下心仪 offer!💻
17
AST:从 Java 代码理解抽象语法树
5
原来维基百科还有专门研究「AI 味」的页面 今天偶然发现一个挺有意思的维基百科页面:https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing,专门整理 AI 写作的典型特征。 里面总结了很多我们平时经常看到的 AI 味,比如喜欢用“值得注意的是”“在当今快速发展的时代”“综上所述”,还有过度使用排比、加粗、列表,以及一些看起来很有道理,实际上没什么信息量的表达。 感觉以后可以定期翻翻这个 wiki,尤其是经常使用 AI 辅助写作的人,应该能从中发现不少所谓的 AI 味。 链接: https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing
5
下载 APP