Spring AI 中实现“在流式响应中包含工具调用信息”的完整解决方案

13 阅读6分钟

在 Spring AI 的项目开发中,工具调用(Tool Calling)是实现复杂业务逻辑的关键。然而,在实际业务场景中,我们往往不仅需要模型的最终文本回复,还需要将工具调用的详细过程(如工具名称和参数)实时反馈给前端。

本文将分享一个在 Spring AI 中实现“在流式响应中包含工具调用信息”的完整解决方案,通过 AOP 切面与事件流合并技术,完美解决这一痛点。

一、需求背景与痛点分析

1. 核心需求
我们需要在前端展示类似如下的交互信息,包含工具名称和调用参数

image.png 2. 现有局限

  • Spring AI 的默认行为:在最终的 ChatResponse 输出中,默认是不包含具体的工具调用元数据的。
  • Prompt 工程的失败尝试:尝试通过提示词(Prompt)强制 AI 在输出中描述工具调用,往往效果不佳,甚至会导致 AI 拒绝触发工具调用。
  • LangChain4j 的对比:在 LangChain4j 中,可以通过 TokenStream 的 onToolExecuted 回调轻松解决,但在 Spring AI 中,官方并未提供类似的现成 API。

既然没有银弹,那咱就自己造轮子。

二、整体架构设计

本方案的核心思路是: “拦截 -> 发布 -> 合并”

  1. 拦截:利用 AOP 在工具执行前后进行拦截。
  2. 发布:将拦截到的信息封装为事件发布。
  3. 合并:利用 StreamAroundAdvisor 将事件流与模型输出流合并,最终统一推送到前端。

ChatGPT Image May 5, 2026, 10_21_04 PM.png

三、核心代码实现

1. AOP 切面拦截工具调用
首先定义一个切面,在工具方法执行前和执行后进行拦截,并发布相应的事件(开始/结束)。

/**
 * 工具调用执行拦截器
 */
@Component
@Aspect
@Slf4j
public class ToolExecuteInterceptor {

    @Resource
    private ToolEventPublisher toolEventPublisher;

    @Pointcut("execution(* com.libo.aicodemother.ai.tools.*.*(..)) && @annotation(org.springframework.ai.tool.annotation.Tool)")
    public void anyToolExecute() {}

    public ToolExecuteInterceptor(ToolEventPublisher toolEventPublisher) {
        this.toolEventPublisher = toolEventPublisher;
    }

    @Before("anyToolExecute()")
    public void beforeToolCall(JoinPoint joinPoint) {
        ToolContext toolContext = getToolContext(joinPoint);
        if(Objects.isNull(toolContext)) {
            return;
        }
        handleToolContext(toolContext, joinPoint, null, true);
    }

    @AfterReturning(pointcut = "anyToolExecute()", returning = "result")
    public void afterToolCall(JoinPoint joinPoint, Object result) {
        ToolContext toolContext = getToolContext(joinPoint);
        if(Objects.isNull(toolContext)) {
            return;
        }
        handleToolContext(toolContext, joinPoint, result, false);
    }

    /**
     * 处理工具调用上下文信息
     * @param context 工具调用上下文信息
     * @param joinPoint 连接点
     * @param result 结果
     * @param isBefore 是否在方法调用之前
     */
    private void handleToolContext(ToolContext context, JoinPoint joinPoint, Object result, boolean isBefore) {
        ToolCallContext toolCallContext = new ToolCallContext();
        String className = joinPoint.getTarget().getClass().getSimpleName();
        //获取方法名
        String methodName = joinPoint.getSignature().getName();
        //获取工具会话历史
        Message message = context.getToolCallHistory().getLast();
        //从消息中拿到最新的工具调用信息
        AssistantMessage.ToolCall toolCallInfo = ((AssistantMessage)message).getToolCalls().getLast();
        //工具调用id
        String toolCallId = toolCallInfo.id();
        String toolName = toolCallInfo.name();
        //会话id
        String appId = context.getContext().get("appId").toString();
        if(isBefore) {
            //发布工具调用开始事件
            toolEventPublisher.publishToolCall(appId, toolName, methodName, toolCallId);
        }else {
            if(methodName.equals("writeFile") && className.equals("FileWriteTool")) {
                //如果是文件写入工具,则需要获取文件写入的相对路径和内容
                Object[] args = joinPoint.getArgs();
                String relativePath = (String) args[0];
                String content = (String) args[1];
                toolCallContext.setRelativePath(relativePath);
                toolCallContext.setContent(content);
            }
            toolCallContext.setResult(result);
            //发布工具调用结束事件
            toolEventPublisher.publishToolResult(appId, toolName, methodName, toolCallId, toolCallContext);
        }
    }

    /**
     * 获取工具调用上下文信息
     * @param joinPoint 连接点
     * @return 工具调用上下文信息
     */
    private ToolContext getToolContext(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        for (Object arg : args) {
            if(arg instanceof ToolContext) {
                return (ToolContext) arg;
            }
        }
        //否则返回null,目标方法没有ToolContext参数
        return null;
    }
}

2. 事件发布器实现
利用 Project Reactor 的 Sinks.Many 维护一个事件流池,根据 appId 进行隔离,支持随时发布和订阅。

/**
 * 工具调用事件发布器
 */
@Component
public class ToolEventPublisher {

    private final Map<String, Sinks.Many<ToolEvent>> sinks = new ConcurrentHashMap<>();

    /**
     * 获取工具调用事件的发布者
      * @param appId 应用ID
     * @return Sinks.Many<ToolEvent>
     */
    public Sinks.Many<ToolEvent> getSink(String appId) {
        return sinks.computeIfAbsent(appId, k -> Sinks.many().multicast().onBackpressureBuffer());
    }

    /**
     * 发布工具调用请求
     * @param appId 应用ID
     * @param toolName 工具名称
     * @param methodName 方法名称
     * @param toolCallId 工具调用ID
     */
    public void publishToolCall(String appId, String toolName, String methodName, String toolCallId) {
        getSink(appId).tryEmitNext(new ToolEvent(appId, "tool_call", toolName, methodName, toolCallId, null));
    }

    /**
     * 发布工具调用结果
     * @param appId 应用ID
     * @param toolName 工具名称
     * @param methodName 方法名称
     * @param toolCallId 工具调用ID
      * @param toolCallContext 工具调用结果
     */
    public void publishToolResult(String appId, String toolName, String methodName, String toolCallId, ToolCallContext toolCallContext) {
        getSink(appId).tryEmitNext(new ToolEvent(appId, "tool_result", toolName, methodName, toolCallId, toolCallContext));
    }

    /**
     * 获取工具调用事件的Flux
      * @param appId 应用ID
     * @return Flux<ToolEvent>
     */
    public Flux<ToolEvent> getToolEvents(String appId) {
        return getSink(appId).asFlux();
    }

    /**
     * 完成工具调用事件发布
      * @param appId 应用ID
     */
    public void complete(String appId) {
        Sinks.Many<ToolEvent> sink = sinks.remove(appId);
        if(!Objects.isNull(sink)) {
            sink.tryEmitComplete();
        }
    }

    /**
     * 工具调用事件对象
     * @param appId 应用ID
     * @param type 事件类型
     * @param toolName 工具名称
     * @param methodName 方法名称
     * @param toolCallId 工具调用ID
      * @param toolCallContext 工具调用结果
     */
    public record ToolEvent(String appId, String type, String toolName, String methodName, String toolCallId, ToolCallContext toolCallContext){}

}

3. 流合并与最终输出
通过实现 StreamAroundAdvisor,在不侵入业务代码的情况下,将工具事件流转换为 ChatResponse 并与主模型流合并。记得要在构建ChatClient对象的时候注册这个advisor。

@Component
@Slf4j
public class ToolCallAdvisor implements StreamAroundAdvisor {

    @Resource
    private ToolEventPublisher toolEventPublisher;

    @Override
    public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
        String appId = advisedRequest.toolContext().get("appId").toString();

        Flux<AdvisedResponse> toolEventFlux = getToolEventFlux(appId, advisedRequest.adviseContext());
        Flux<AdvisedResponse> mainFlux = chain.nextAroundStream(advisedRequest)
                .doFinally(s -> toolEventPublisher.complete(appId));
        return mainFlux.mergeWith(toolEventFlux);
    }

    private Flux<AdvisedResponse> getToolEventFlux(String appId, Map<String, Object> adviseContext) {
        return toolEventPublisher.getToolEvents(appId)
                .map(event -> {
                    //写入文件工具写入文件内容
                    ToolCallContext toolCallContext = event.toolCallContext();
                    String content = StringUtils.EMPTY;
                    //写入文件相对路径
                    String relativePath = StringUtils.EMPTY;
                    //写入文件后缀
                    String suffix = StringUtils.EMPTY;
                    if(!Objects.isNull(toolCallContext)) {
                        content = toolCallContext.getContent();
                        relativePath = toolCallContext.getRelativePath();
                        suffix = relativePath.substring(relativePath.lastIndexOf(".") + 1);
                    }
                    //工具调用消息
                    String message = switch (event.type()) {
                        case "tool_call" -> "\n\n[选择工具] 写入文件\n\n ";
                        case "tool_result" -> {
                            String msg = String.format("""
                                    [工具调用] 写入文件%s
                                    ```%s
                                    %s
                                    ```
                                    """, relativePath, suffix, content);
                            yield msg;
                        }
                        default -> "";
                    };
                    return AdvisedResponse.builder()
                            .response(ChatResponse
                                    .builder()
                                    .generations(List.of(new Generation(new AssistantMessage(message))))
                                    .build())
                            .adviseContext(adviseContext)
                            .build();
                });
    }

    @Override
    public String getName() {
        return this.getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        return Integer.MIN_VALUE + 100;
    }
}

四、避坑指南(注意事项)

在实际落地过程中,有两个关键点容易踩坑,请务必注意:

  1. Bean 管理:AOP 切面只能代理 Spring 容器管理的 Bean。因此,工具类必须加上 @Component 注解。

  2. 代理对象陷阱:如果在工具类上使用了 AOP 代理增强,在构建 ChatClient 时,千万不要直接传入代理后的工具类对象

    • 原因:直接传入代理对象可能导致 AI 无法识别工具的元数据(如描述、参数信息),从而导致工具调用失效。
    • 解决方案:使用 ToolCallback 手动构建工具定义。

正确的构建方式:

public AiChatClient(ChatModel chatModel
        , FileWriteTool fileWriteTool
        , ToolCallAdvisor toolCallAdvisor) {
       //vue项目代码生成chatClient
        Method method = ReflectionUtils.findMethod(FileWriteTool.class, "writeFile", String.class, String.class, ToolContext.class);
        ToolCallback writeToolCallback = MethodToolCallback.builder()
                // 指定工具定义:这里手动指定名字和描述,或者从注解读取
                .toolDefinition(ToolDefinition.builder(method)
                        .name("writeFile") // 必须与 @Tool 中的 name 一致,或者手动指定
                        .description("写入文件到指定目录")
                        .build())
                // 指定要调用的具体方法
                .toolMethod(method)
                // 指定目标对象(传入代理对象即可,Spring AI 内部会处理)
                .toolObject(fileWriteTool)
                .build();
        this.chatClient = ChatClient.builder(chatModel)
                .defaultSystem("你是一个AI代理助手")
                .defaultAdvisors(toolCallAdvisor)
                .defaultTools(writeToolCallback)
                .build();
}

错误的构建方式:

public AiChatClient(ChatModel chatModel
        , FileWriteTool fileWriteTool
        , ToolCallAdvisor toolCallAdvisor) {
        this.chatClient = ChatClient.builder(chatModel)
                .defaultSystem("你是一个AI代理助手")
                .defaultAdvisors(toolCallAdvisor)
                .defaultTools(fileWriteTool)
                .build();

这种构建方式,可能会导致工具调用失效。