在 Spring AI 的项目开发中,工具调用(Tool Calling)是实现复杂业务逻辑的关键。然而,在实际业务场景中,我们往往不仅需要模型的最终文本回复,还需要将工具调用的详细过程(如工具名称和参数)实时反馈给前端。
本文将分享一个在 Spring AI 中实现“在流式响应中包含工具调用信息”的完整解决方案,通过 AOP 切面与事件流合并技术,完美解决这一痛点。
一、需求背景与痛点分析
1. 核心需求
我们需要在前端展示类似如下的交互信息,包含工具名称和调用参数
2. 现有局限
- Spring AI 的默认行为:在最终的
ChatResponse输出中,默认是不包含具体的工具调用元数据的。 - Prompt 工程的失败尝试:尝试通过提示词(Prompt)强制 AI 在输出中描述工具调用,往往效果不佳,甚至会导致 AI 拒绝触发工具调用。
- LangChain4j 的对比:在 LangChain4j 中,可以通过
TokenStream的onToolExecuted回调轻松解决,但在 Spring AI 中,官方并未提供类似的现成 API。
既然没有银弹,那咱就自己造轮子。
二、整体架构设计
本方案的核心思路是: “拦截 -> 发布 -> 合并” 。
- 拦截:利用 AOP 在工具执行前后进行拦截。
- 发布:将拦截到的信息封装为事件发布。
- 合并:利用
StreamAroundAdvisor将事件流与模型输出流合并,最终统一推送到前端。
三、核心代码实现
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;
}
}
四、避坑指南(注意事项)
在实际落地过程中,有两个关键点容易踩坑,请务必注意:
-
Bean 管理:AOP 切面只能代理 Spring 容器管理的 Bean。因此,工具类必须加上
@Component注解。 -
代理对象陷阱:如果在工具类上使用了 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();
这种构建方式,可能会导致工具调用失效。