Skip to main content

Command Palette

Search for a command to run...

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

Updated
5 min readView as Markdown

那些坑

用 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 处理 → 切面拦截工具调用 → 事件发布 → 实时推给前端

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


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 关掉事件流。否则这个流会一直挂在那儿,等不到终点。
前端就会一直这样

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

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

More from this blog

我用 SubAgent 做了一个 AI 自动修复闭环:流式修代码、自动构建、失败重试

最近用 AI 零代码项目生成前端页面有时候会出现问题,但是直接把原始报错信息返回给前端,再让用户手动点击按钮才能开始修复,用户体验不是很好,那么如何优化呢?我想到了 SubAgent 来进行修复! 如果把这类修复工作一直交给主 Agent 来做,会带来两个明显问题: 修复过程会不断污染主 Agent 的上下文。 错误日志、修复尝试和中间结果会迅速挤占上下文窗口,影响主 Agent 后续继续处理

Mar 30, 20264 min read

《Linux是怎样工作的》进程管理

不先讲虚拟内存,只是单纯的讲述进程的创建和删除 创建线程 在 Linux 中,创建进程有如下两个目的。 将同一个程序分成多个进程进行处理(例如,使用 Web 服务器接收多个请求) 创建另一个程序(例如,从 bash 启动一个新的程序) Linux 提供了 fork() 函数与 execve() 函数(其底层分别请求名为 clone() 与 execve() 的系统调用)。 fork() 函数 将一个程序分成多个进程进行处理,使用 fork() 函数即可,在调用 fork() 函数的时候就...

Dec 12, 20256 min read

leikooo

28 posts