那些坑
用 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?
主要有下面几点
- 生成的代码需要区分目录,方便管理
- 隔离每个单独 APP 生成的路径
- 记录工具调用次数(后续分析用)
整体思路
用户发请求 → Ai2ChatClient 接收 → SpringAI 处理 → 切面拦截工具调用 → 事件发布 → 实时推给前端

就这么几条线。核心其实就三件事:切面拦截、事件发布、流合并。
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 {
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 容器,切面自己就找上门来了。
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);
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,多线程环境下也能正常工作。
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关掉事件流。否则这个流会一直挂在那儿,等不到终点。前端就会一直这样
java
@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,让它自己处理流合并。业务代码瞬间清爽了。
java
@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;
}
}
注册一下,全局生效:
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);
}
}
跑起来,控制台陆陆续续打出日志,工具调用的事件也正常推送。整个链路是通的。

