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

那些坑

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

Langchain4j 写回调多舒服啊

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 就到手了,多省心:

class Tools {  
    @Tool  
    String addCalendarEvent(CalendarEvent event, @ToolMemoryId memoryId) {  
        // memoryId 直接能用  
    }  
}  

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


为什么一定要 conversationId?

主要有下面几点

  1. 生成的代码需要区分目录,方便管理
  2. 隔离每个单独 APP 生成的路径
  3. 记录工具调用次数(后续分析用)

整体思路

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

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


AOP 依赖

<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

@Component  public class TodolistTools extends BaseTools {    
    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.")    
	public String todoWrite(    
	@ToolParam(description = "The todo list content to save.")    
	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.";    
}    
    
@Tool(description = "Read the current todo list for this conversation.")    
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"; } 
 }  

切面是怎么工作的

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

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

@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);    
if (toolContext == null) return;handleToolContext(toolContext, joinPoint, null, true);    
}    
    
@AfterReturning(pointcut = "anyToolExecution()", returning = "result")    
public void afterToolCall(JoinPoint joinPoint, Object result) {    
	ToolContext toolContext = getToolContext(joinPoint);    
	if (toolContext != null) {    
		handleToolContext(toolContext, joinPoint, result, false);    
	}    
}    
    
private void handleToolContext(ToolContext context, JoinPoint joinPoint, Object result, boolean isBefore) {    
	String className = joinPoint.getTarget().getClass().getSimpleName();    
	String methodName = joinPoint.getSignature().getName();    
	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);    
	}    
}    
    
private ToolContext getToolContext(JoinPoint joinPoint) {    
	for (Object arg : joinPoint.getArgs()) {    
		if (arg instanceof ToolContext) 
			return (ToolContext) arg;
		}    
	    return null;    
	}  
}  

测试结果


事件发布:把消息送出去

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

@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

@Component  
public class Ai2ChatClient {    
	private final ChatClient chatClient;    
	private final ToolEventPublisher toolEventPublisher;    
    
public Ai2ChatClient(ChatModel openAiChatModel, FileTools fileTools,    
ToolEventPublisher toolEventPublisher) {    
        this.toolEventPublisher = toolEventPublisher;    
        this.chatClient = ChatClient.builder(openAiChatModel)    
                .defaultTools(fileTools)    
                .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(spec -> spec.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", event.methodName());    
                        case "tool_result" -> String.format("工具调用完成: %s", event.methodName());    
                        default -> "";    
};    
                    return String.format("\n\n[选择工具] %s \n\n", message);    
});    
    
        return Flux.merge(mainFlux, toolEventFlux);    
}  }  

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

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

@Slf4j  
@Component  
public class ToolAdvisor implements CallAdvisor, StreamAdvisor {    
private final ToolEventPublisher toolEventPublisher;    
    
    @Override 
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {    
return chain.nextCall(request);    
}    
    @Override
     public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {    
        String appId = ConversationIdUtils.getConversationId(request.context());    
        Flux<ChatClientResponse> mainFlux = chain.nextStream(request)    
					// 这里一定要 complete                
					.doFinally(s -> toolEventPublisher.complete(appId));    
        Flux<ChatClientResponse> toolEventFlux = getToolEventFlux(appId);    
        return Flux.merge(mainFlux, toolEventFlux);    
}    
	// 这里需要 map 转化成 ChatClientResponse ,需要把 String 转化一下
    private Flux<ChatClientResponse> getToolEventFlux(String sessionId) {    
		return toolEventPublisher.events(sessionId)    
                .map(event -> {    
String message = switch (event.type()) {    
                        case "tool_call" -> String.format("正在进行工具调用: %s", event.methodName());    
                        case "tool_result" -> String.format("工具调用完成: %s", event.methodName());    
                        default -> "";    
};    
                    AssistantMessage msg = new AssistantMessage(String.format("\n\n[选择工具] %s \n\n", message));    
                    return ChatClientResponse.builder()    
                            .chatResponse(ChatResponse.builder().generations(List.of(new Generation(msg))).build())    
                            .build();    
});    
}    
    @Override 
    public String getName() { return "ToolAdvisor"; }    
    
	@Override 
	public int getOrder() { return Integer.MIN_VALUE + 100; 
	}  
}  

注册一下,全局生效:

@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 拽出来:

@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 传进来的:

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();  
}  

跑一下看看

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

@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

posted @ 2026-01-21 11:47  leikooo  阅读(4)  评论(0)    收藏  举报