目录
[一、三个 Agent,三份复制粘贴的代码](#一、三个 Agent,三份复制粘贴的代码)
[四、BaseAgent:主循环 + 同步/流式双通道](#四、BaseAgent:主循环 + 同步/流式双通道)
[4.1 同步通道 run()](#4.1 同步通道 run())
[4.2 流式通道 runStream()](#4.2 流式通道 runStream())
[4.3 三个必须做对的细节](#4.3 三个必须做对的细节)
[五、ReActAgent:Thought → Action → Observation](#五、ReActAgent:Thought → Action → Observation)
[6.1 先关掉 Spring AI 的内部工具执行](#6.1 先关掉 Spring AI 的内部工具执行)
[6.2 think():这一轮要不要调工具](#6.2 think():这一轮要不要调工具)
[6.3 act():真正执行工具](#6.3 act():真正执行工具)
[6.4 流式下 tool call 的分片合并(最难的一段)](#6.4 流式下 tool call 的分片合并(最难的一段))
[八、组装:一个全能助手只要 44 行](#八、组装:一个全能助手只要 44 行)
[Controller 里怎么用](#Controller 里怎么用)
摘要:不满足于"调 ChatClient 一调到底",从 BaseAgent 抽象开始手写一套分层智能体框架:抽象基类负责生命周期与主循环,ReActAgent 落地"思考-行动-观察",ToolCallAgent 关掉 Spring AI 的内部工具执行、自己接管原生 tool_calls 循环,最后组装出一个 44 行的全能助手。附完整类图、AgentState 状态机、流式 tool call 分片合并的写法,以及七个真实踩过的坑(回答输出两遍、模型回答被吞、multiModel 被静默覆盖成 false 导致 400 等)。
标签:Agent框架、ReAct、ToolCall、Spring AI、大模型应用架构
分类:AI 应用开发 / Spring AI
一、三个 Agent,三份复制粘贴的代码
前面五篇我们把能力攒齐了:会记忆(ChatMemory)、能查知识(RAG)、能动手(Tool)、还能复用别人的能力(MCP)。
然后项目里出现了三个智能体:
FoxManus 全能助手 贪心搜索 + 网页抓取 + 图片搜索 + PDF 生成 + 终止
小养 饮食助手 本地 RAG 检索 + 人设提示词
客服 问答助手 云端知识库 + 多轮记忆
写完第三个的时候我发现,这三个类长得几乎一模一样。把重复的部分圈出来:
| 重复项 | 每个类里都得写一遍 |
|---|---|
| 消息容器 | List<Message> messageList 手动 add、手动传给模型 |
| 工具循环 | while (模型要调工具) { 执行; 把结果塞回上下文; 再问 } |
| 终止判断 | 要么调了终止工具,要么步数用完,要么模型自己说完了 |
| 步数上限 | 没有兜底就会无限循环烧 token |
| 异常处理 | try-catch 一把,还得决定是中断还是继续 |
| 流式推送 | 同步 call() 和 SSE stream() 两套几乎重复的代码 |
更要命的是:任何一处改动要改三遍。给工具执行加个超时,翻三个类;想让 SSE 区分"工具过程"和"最终回答",再翻三个类。
这就是框架要解决的问题。不是炫技,是把变化的部分和不变的部分分开。
二、设计:四层抽象
先上类图:
┌──────────────────────────────────────┐
│ BaseAgent (abstract) │
│──────────────────────────────────────│
│ - name / systemPrompt / nextStepPrompt│
│ - state : AgentState │
│ - currentStep / maxSteps │
│ - chatClient │
│ - messageList : List<Message> │
│──────────────────────────────────────│
│ + run(prompt) : String │
│ + runStream(prompt): SseEmitter │
│ + step() : abstract │
│ # pushToken(token) │
└────────────────┬─────────────────────┘
│ extends
┌────────────────▼─────────────────────┐
│ ReActAgent (abstract) │
│──────────────────────────────────────│
│ - lastStepType : "tool" | "answer" │
│──────────────────────────────────────│
│ + think() : abstract boolean │
│ + act() : abstract String │
│ + step() : 模板方法 think→act │
└────────────────┬─────────────────────┘
│ extends
┌────────────────▼─────────────────────┐
│ ToolCallAgent │
│──────────────────────────────────────│
│ - availableTools : ToolCallback[] │
│ - toolCallingManager │
│ - chatOptions (内部工具执行 = false) │
│──────────────────────────────────────│
│ + think() : 调模型 → 是否要调工具 │
│ + act() : ToolCallingManager 执行 │
└────────────────┬─────────────────────┘
│ extends
┌────────────────▼─────────────────────┐
│ FoxManus (具体 Agent) │
│──────────────────────────────────────│
│ 构造里设:人设 / 下一步提示 / 步数上限 │
└──────────────────────────────────────┘
每一层只负责一件事:
| 层 | 负责什么 | 把什么变成可变的 |
|---|---|---|
BaseAgent |
生命周期、主循环、记忆、状态、流式出口 | step() ------ 单步长什么样由子类定 |
ReActAgent |
把单步拆成"思考"和"行动"两阶段 | think() / act() ------ 怎么思考、怎么行动由子类定 |
ToolCallAgent |
用原生 tool_calls 实现思考和行动 |
工具清单、ChatOptions |
FoxManus |
只写人设和参数 | ------ |
判断分层对不对的标准:新增一个 Agent,你只需要写第 4 层。后面的实测会告诉你,确实是这样。
三、AgentState:四态状态机
public enum AgentState {
IDLE, // 空闲
RUNNING, // 运行
FINISHED, // 完成
ERROR // 错误
}
四个状态看着简单,但它解决了三个真问题:
① 并发保护。 run() 开头就校验:
if (state != AgentState.IDLE) {
throw new RuntimeException("Agent is not idle");
}
Agent 是有状态对象 (内部持有 messageList)。同一次会话被重复触发、或者对象被复用,就会把消息历史搅乱。状态机是一道廉价的护栏。
② 循环退出。 主循环条件除了步数,还看状态:
for (int i = 0; i < maxSteps && state != AgentState.FINISHED; i++) { ... }
这样"结束"这件事就有两个入口:模型自己说完了(think() 里设 FINISHED),或者工具执行完发现调了终止工具(act() 里设 FINISHED)。不用在循环外面套一堆 boolean 标志。
③ 错误可观测。 出异常时置 ERROR,前端可以据此展示不同的提示,监控也能直接读状态。
状态流转:
run()/runStream()
IDLE ─────────────────► RUNNING
▲ │
│ │ 模型给出最终回答 / 调了终止工具 / 步数耗尽
│ ▼
│ FINISHED
│ │
│ cleanup() │ 异常
└────────────────────────┴──► ERROR
四、BaseAgent:主循环 + 同步/流式双通道
4.1 同步通道 run()
public String run(String userPrompt) {
if (state != AgentState.IDLE) throw new RuntimeException("Agent is not idle");
if (StrUtil.isBlank(userPrompt)) throw new RuntimeException("User prompt is empty");
this.state = AgentState.RUNNING;
messageList.add(new UserMessage(userPrompt));
List<String> results = new ArrayList<>();
try {
for (int i = 0; i < maxSteps && state != AgentState.FINISHED; i++) {
currentStep = i + 1;
log.info("Step {}/{} :", currentStep, maxSteps);
String stepResult = step();
results.add("Step" + currentStep + ": " + stepResult);
}
if (currentStep >= maxSteps) {
state = AgentState.FINISHED;
results.add("Terminated: Reached max steps (" + maxSteps + ")");
}
return String.join("\n", results);
} catch (Exception e) {
state = AgentState.ERROR;
return "Error: " + e.getMessage();
} finally {
cleanup();
}
}
这段不复杂,但步数上限是必须的。没有它,一个"再搜一次"的模型能给你烧掉几百次调用。
实践下来 8 步是个甜点:够覆盖"搜索 → 抓取 → 生成"这类三段式任务,又不至于让模型在同类工具上反复打转。
4.2 流式通道 runStream()
流式是这里最绕的地方。先说设计选择 :怎么让子类(可能隔了三层)把 token 推给 SSE,而子类又不用知道 SseEmitter 的存在?
答案是给一个函数式出口:
protected transient Consumer<String> streamTokenSink;
protected void pushToken(String token) {
if (streamTokenSink != null && StrUtil.isNotBlank(token)) {
streamedThisStep = true;
streamTokenSink.accept(token);
}
}
子类只管 pushToken(text),不用 import 任何 Spring Web 的类。transient 不能漏 ------ 这个字段持有 SseEmitter 的引用,万一哪天要序列化状态,会直接炸。
runStream 里把它接上:
SseEmitter sseEmitter = new SseEmitter(300000L);
this.streamTokenSink = text -> {
try {
sseEmitter.send(buildEvent("answer", text));
} catch (Exception sendEx) {
log.warn("SSE 片段推送失败:{}", sendEx.getMessage());
}
};
CompletableFuture.runAsync(() -> { /* 同一个主循环 */ });
4.3 三个必须做对的细节
① 结构化事件,而不是裸文本
private static String buildEvent(String type, String content) {
Map<String, Object> payload = new HashMap<>();
payload.put("type", type); // "tool" | "answer"
payload.put("content", content);
return JSONUtil.toJsonStr(payload);
}
前端靠 type 区分"工具执行过程"和"最终回答"。工具过程默认折叠 ------ 否则用户满屏看到的都是网页搜索返回的原始 JSON 片段。
② 回答只推一次(真实踩坑)
流式模式下,模型在思考阶段就逐 token 推给前端了 。如果 step() 再把整段文本返回、主循环再推一次,用户就会看到回答出现两遍。
解决办法是一个标志位:
protected transient boolean streamedThisStep = false;
// 子类 ReActAgent.step() 里:
return streamedThisStep ? null : lastAssistantText();
主循环每步开始前置 false,pushToken 被调过就置 true。推过了就返回 null,主循环碰到 null 跳过整段推送。
③ 兜底总结
我遇到过这样一种情况:整轮跑完,模型调了 5 次工具,最后一步是工具执行结果,没产出面向用户的回答。前端显示"完成 5 个过程步骤",用户一脸问号 ------ 过程都给你看了,答案呢?
if (!hasAnswer) {
String summary = generateFinalAnswer();
if (StrUtil.isNotBlank(summary)) {
sseEmitter.send(buildEvent("answer", summary + "\n"));
}
}
generateFinalAnswer() 往上下文末尾补一条指令,让模型基于已有工具结果总结:
promptMsgs.add(new UserMessage(
"请基于上面工具返回的结果,用简洁的自然语言直接回答用户最初的问题。" +
"要求:不要输出 JSON、不要罗列原始数据、不要提及工具名称,直接给出结论。"));
五、ReActAgent:Thought → Action → Observation
ReAct 的核心就是一句话:把单步拆成"想"和"做"。
@Override
public String step() {
try {
boolean shouldAct = think();
if (!shouldAct) {
lastStepType = "answer";
return streamedThisStep ? null : lastAssistantText();
}
lastStepType = "tool";
return act();
} catch (Exception e) {
lastStepType = "tool";
String brief = (msg == null || msg.isBlank())
? e.getClass().getSimpleName()
: msg.replaceAll("\\s+", " ").trim();
if (brief.length() > 300) brief = brief.substring(0, 300) + "...(已截断)";
return "步骤失败:" + brief;
}
}
三个细节值得说:
① lastStepType 是给前端看的。 主循环拿它决定这条事件是折叠的工具过程,还是展开的最终回答。
② 异常收敛成一行摘要。 这是从工具篇继承过来的原则:异常不能往外抛(会中断整个循环),也不能把完整堆栈塞进消息历史(污染上下文、浪费 token)。去换行 + 截断 300 字符刚好。
③ lastAssistantText() 这个私有方法,是踩过坑才有的(真实踩坑):
private String lastAssistantText() {
List<Message> msgs = getMessageList();
for (int i = msgs.size() - 1; i >= 0; i--) {
Message m = msgs.get(i);
if (m instanceof AssistantMessage am && StrUtil.isNotBlank(am.getText())) {
return am.getText();
}
}
return "思考完成,不需要行动";
}
第一版我直接 return "思考完成,不需要行动" ------ 结果模型真实生成的回答被这句占位文案丢了,用户看到的是"思考完成"。必须从消息历史里倒着找最后一条助手消息,把真实文本取出来。占位文案只在真找不到时兜底。
六、ToolCallAgent:把工具循环拿回自己手里
这一层是整套框架的技术核心。
6.1 先关掉 Spring AI 的内部工具执行
第 4 篇埋过一个钩子:Spring AI 的 ChatModel 默认会替你完成"解析工具调用 → 执行 → 自动追问"这一整套。写框架时必须关掉,否则你根本插不进手。
private static ChatOptions buildChatOptions(ChatModel chatModel) {
ChatOptions defaults = chatModel.getDefaultOptions();
if (defaults instanceof ToolCallingChatOptions toolCallingChatOptions) {
toolCallingChatOptions.setInternalToolExecutionEnabled(false);
return defaults;
}
return ToolCallingChatOptions.builder()
.internalToolExecutionEnabled(false)
.build();
}
注意这里不能用"新建一个只含 internalToolExecutionEnabled 的 DashScopeChatOptions"------这是个很隐蔽的坑(真实踩坑):
Spring AI 的
ModelOptionsUtils.merge规则是 runtime 非空值覆盖 default 。DashScopeChatOptions的multiModel字段带非空默认值false,新建的对象会把它覆盖掉配置文件里的multi-model: true,结果多模态模型被请求到普通文本接口,阿里云直接返回 HTTP 400url error, please check url。
正确做法是从 chatModel.getDefaultOptions() 拿基底 (它已经合并好了 yml 里的全部配置),只改一个字段。getDefaultOptions() 返回独立副本,改它不会污染全局 Bean。
6.2 think():这一轮要不要调工具
@Override
public boolean think() {
try {
// nextStepPrompt 只注入一次,避免每轮重复追加撑爆上下文
if (StrUtil.isNotBlank(getNextStepPrompt()) && !nextStepPromptInjected) {
getMessageList().add(new UserMessage(getNextStepPrompt()));
nextStepPromptInjected = true;
}
if (streamTokenSink != null) {
return thinkStream(); // 流式分支,见 6.4
}
Prompt prompt = new Prompt(getMessageList(), this.chatOptions);
ChatResponse chatResponse = getChatClient().prompt(prompt)
.system(getSystemPrompt())
.toolCallbacks(availableTools)
.call()
.chatResponse();
this.toolCallChatResponse = chatResponse;
return decideAfterThink(chatResponse.getResult().getOutput());
} catch (Exception e) {
log.error("{} 思考过程出错:{}", getName(), shorten(String.valueOf(e), 300));
getMessageList().add(new AssistantMessage("思考过程出错:" + shorten(e.getMessage(), 200)));
setState(AgentState.FINISHED); // 别让同一错误刷满 maxSteps
return false;
}
}
nextStepPromptInjected 这个标志看着多余,实际必需:主循环每次都调 think(),不防重复的话,"下一步该做什么"这段提示会被追加 N 次,白白吃掉上下文。
决策逻辑抽成一个方法,同步和流式共用:
private boolean decideAfterThink(AssistantMessage assistantMessage) {
List<AssistantMessage.ToolCall> toolCallList = assistantMessage.getToolCalls();
// 分支 1:模型直接给答复,没有工具调用 → 结束
if (toolCallList.isEmpty()) {
getMessageList().add(assistantMessage);
setState(AgentState.FINISHED);
return false;
}
// 分支 2:模型给了文本回答 + 只调了终止工具 → 采纳文本,别走工具路径
boolean onlyTerminate = toolCallList.stream()
.allMatch(toolCall -> "doTerminate".equals(toolCall.name()));
if (onlyTerminate && StrUtil.isNotBlank(assistantMessage.getText())) {
getMessageList().add(assistantMessage);
setState(AgentState.FINISHED);
return false;
}
// 分支 3:本轮要调工具
log.info("{} 思考完成:本轮将调用 {} 个工具 ------ {}", ...);
return true;
}
分支 2 是踩出来的 (真实踩坑):模型经常这样输出 ------ 先写一段完整回答,再调 doTerminate 请求结束。如果不特判,就会走 act(),最终 step() 返回的是"工具 doTerminate 返回的结果:任务已终止",模型那一段真正的回答被覆盖了,用户看不到答案。
6.3 act():真正执行工具
@Override
public String act() {
if (!toolCallChatResponse.hasToolCalls()) {
return "没有工具需要被调用";
}
try {
ToolCallingChatOptions executeOptions = ToolCallingChatOptions.builder()
.toolCallbacks(availableTools)
.build();
Prompt prompt = new Prompt(getMessageList(), executeOptions);
ToolExecutionResult toolExecutionResult =
toolCallingManager.executeToolCalls(prompt, toolCallChatResponse);
setMessageList(toolExecutionResult.conversationHistory());
ToolResponseMessage toolResponseMessage =
(ToolResponseMessage) CollUtil.getLast(toolExecutionResult.conversationHistory());
boolean terminateToolCalled = toolResponseMessage.getResponses().stream()
.anyMatch(response -> response.name().equals("doTerminate"));
if (terminateToolCalled) {
setState(AgentState.FINISHED);
}
return toolResponseMessage.getResponses().stream()
.map(r -> "工具 " + r.name() + " 返回的结果:" + r.responseData())
.collect(Collectors.joining("\n"));
} catch (Exception e) {
setState(AgentState.FINISHED); // 失败也结束,否则下轮继续发起同样调用空转
return "工具执行失败:" + shorten(e.getMessage(), 200);
}
}
两个关键点:
① executeOptions 必须带 toolCallbacks。 我第一版图省事用 new Prompt(getMessageList()),结果 ToolCallingManager 抛 No ToolCallback found ------ 它是从 Prompt 的 options 里找工具清单的,不是从 ChatResponse 里。这个报错信息指向性很差,排查了半天。
② setMessageList(toolExecutionResult.conversationHistory())。 ToolCallingManager 返回的会话历史已经包含了"助手的工具调用消息 + 工具响应消息",直接用它覆盖,比自己手动拼省事且不易错。
③ 工具失败也置 FINISHED。 让它继续循环,模型大概率会用同样参数再调一次同样的工具,纯空转烧钱。
6.4 流式下 tool call 的分片合并(最难的一段)
上面的 6.2 走的是 call() 全量返回。但 SSE 模式下我们要边生成边推,问题来了:
流式输出时,模型返回的
tool_calls里的arguments是一段一段送过来的 。第一个 chunk 可能是{"qu,第二个ery":"AI,第三个Agent"}。只有拼完整才是合法 JSON,也才能交给ToolCallingManager执行。
所以要自己聚合:
private boolean thinkStream() {
Prompt prompt = new Prompt(getMessageList(), this.chatOptions);
StringBuilder textBuilder = new StringBuilder();
Map<String, ToolCallBuilder> toolCallBuilders = new LinkedHashMap<>();
Flux<ChatResponse> flux = getChatClient().prompt(prompt)
.system(getSystemPrompt())
.toolCallbacks(availableTools)
.stream()
.chatResponse();
flux
.publishOn(Schedulers.boundedElastic()) // ← 关键,见下方说明
.doOnNext(chunk -> {
AssistantMessage out = chunk.getResult().getOutput();
if (StrUtil.isNotBlank(out.getText())) {
textBuilder.append(out.getText());
pushToken(out.getText()); // 打字机效果
}
for (AssistantMessage.ToolCall toolCall : out.getToolCalls()) {
mergeToolCall(toolCallBuilders, toolCall); // 分片累
}
})
.blockLast();
List<AssistantMessage.ToolCall> toolCallList = toolCallBuilders.values().stream()
.map(ToolCallBuilder::build)
.collect(Collectors.toList());
AssistantMessage assistantMessage = AssistantMessage.builder()
.content(textBuilder.toString())
.toolCalls(toolCallList)
.build();
this.toolCallChatResponse = new ChatResponse(List.of(new Generation(assistantMessage)));
return decideAfterThink(assistantMessage);
}
publishOn(Schedulers.boundedElastic()) 不能省 (真实踩坑):sseEmitter.send() 是同步阻塞 IO 。WebFlux 的响应式流默认跑在 Netty 的 event loop 线程上,在那里做阻塞 IO 会把整个服务的吞吐拖垮。切到 boundedElastic 是标准解法。
分片累加器:
private static void mergeToolCall(Map<String, ToolCallBuilder> builders,
AssistantMessage.ToolCall toolCall) {
// 没有 id 时退化用 name + 序号区分,避免多个同类调用被错误合并
String key = StrUtil.isNotBlank(toolCall.id())
? toolCall.id()
: toolCall.name() + "#" + builders.size();
ToolCallBuilder builder = builders.computeIfAbsent(key, k -> new ToolCallBuilder());
if (StrUtil.isNotBlank(toolCall.id())) builder.id = toolCall.id();
if (StrUtil.isNotBlank(toolCall.name())) builder.name = toolCall.name();
if (StrUtil.isNotBlank(toolCall.type())) builder.type = toolCall.type();
if (StrUtil.isNotBlank(toolCall.arguments())) builder.arguments.append(toolCall.arguments());
}
那个"没有 id 时退化用 name + 序号"的判断,是为了兼容部分模型在流式下不回 tool call id 的情况 ------ 不加的话,同一轮里调两次搜索会被错误合并成一个。
七、两条路线对比与取舍
| 维度 | ReAct(文本解析) | ToolCall(原生 tool_calls) |
|---|---|---|
| 工具请求格式 | 模型输出 Action: searchWeb(...),靠正则/JSON 解析 |
模型返回结构化 tool_calls 字段 |
| 解析可靠性 | 依赖模型守格式,输出一变形就解析失败 | 协议层保证,几乎不会解析失败 |
| 对模型要求 | 提示词工程要求高,小模型容易跑偏 | 需要模型支持 Function Calling(现在基本都支持) |
| 可控性 | 高 ------ 自己解析,可以插入校验、改写、限流 | 中 ------ 走框架标准流程 |
| 多工具并行 | 难,通常一次一个 | 天然支持一轮多个 tool call |
| 代码量 | 中(要写解析器) | 少(交给 ToolCallingManager) |
我的取舍:项目里走 ToolCall 路线,因为可靠性压倒一切 ------ 生产环境里"模型输出格式跑偏导致解析失败"是最难排查也最难复现的 bug 类型。
但 ReAct 分层没有白做 :think() / act() 这个切分,ToolCall 路线直接用上了。哪天要接一个不支持 Function Calling 的模型,只需要在 ToolCallAgent 同级写一个 TextReActAgent(用正则解析 Action:),上层 BaseAgent 一行都不用改 ------ 这就是分层预留的价值。
八、组装:一个全能助手只要 44 行
有了上面三层,FoxManus 变成这样:
@Component
public class FoxManus extends ToolCallAgent {
public FoxManus(ToolCallback[] allTools, ChatModel dashscopeChatModel) {
super(allTools, dashscopeChatModel);
this.setName("FoxManus");
this.setSystemPrompt("""
You are FoxManus, an all-capable AI assistant, aimed at solving any task presented by the user.
You have various tools at your disposal that you can call upon to efficiently complete complex requests.
""");
this.setNextStepPrompt("""
Decide whether tools are actually needed for the user's request:
- For simple conversations, greetings, or general knowledge questions,
answer directly in natural language WITHOUT calling any tool.
- Only call tools when the task truly requires them.
- Do NOT call the same kind of tool more than twice.
- IMPORTANT: as soon as you have enough information, stop calling tools
and write your final answer in natural language, then call `terminate`.
Never call `terminate` without giving the answer first.
""");
setMaxSteps(8);
ChatClient chatClient = ChatClient.builder(dashscopeChatModel)
.defaultAdvisors(new MyLoggerAdvisor())
.build();
this.setChatClient(chatClient);
}
}
除了人设和参数,没有一行流程代码。
提示词里那几条是调出来的经验:
- "简单对话不要调工具" ------ 不写这句,用户说"你好"它也要去搜一下
- "同类工具最多调两次" ------ 不写这句,它能连续搜五次同一个关键词
- "先给答案再调 terminate" ------ 对应 6.2 的分支 2,不写这句就会出现"过程一堆、答案没有"
Controller 里怎么用
@GetMapping("/manus/chat")
public SseEmitter doChatWithManus(@RequestParam String message, @RequestParam String chatId) {
FoxManus foxManus = new FoxManus(allTools, dashscopeChatModel);
return foxManus.runStream(message);
}
注意这里是 new,不是注入单例 (真实踩坑)。虽然类上标了 @Component,但 Agent 内部持有 messageList 和 state,是有状态的。做成单例 Bean,多个用户的会话会互相串台 ------ A 的对话历史里冒出 B 的问题。每请求一个新实例是最简单正确的做法;要复用就得自己实现对象池 + 会话隔离。
九、实测:一个多步任务跑下来
请求:"帮我查一下 2026 年 Java 后端面试最常被问的技术,整理成要点"
服务端日志:
Step 1/8 :
FoxManus 思考完成:本轮将调用 1 个工具 ------ searchWeb({"query": "2026 Java 后端面试 高频技术"})
FoxManus 工具执行结果:searchWeb 返回 3842 字符:2026 年 Java 后端面试高频技术栈主要包括...
Step 2/8 :
FoxManus 思考完成:模型给出最终回答并请求终止,直接采纳回答
前端收到的 SSE 事件流:
data:{"type":"tool","content":"工具 searchWeb 返回的结果:..."} ← 折叠展示
data:{"type":"answer","content":"2026"}
data:{"type":"answer","content":" 年 Java"}
data:{"type":"answer","content":" 后端面试"}
... ← 逐 token 打字机
对比没框架之前:需要手动维护消息列表、手写 while 循环、手动判断终止、SSE 和同步写两套。现在这些全在 BaseAgent 里,具体 Agent 只写人设。
十、七个踩过的坑
| # | 现象 | 根因 | 修法 |
|---|---|---|---|
| 1 | 回答出现两遍 | 流式下思考阶段已逐 token 推送,step() 又返回整段被主循环再推一次 |
streamedThisStep 标志,推过就返回 null |
| 2 | 用户看到"思考完成"而不是模型回答 | think() 返回 false 时直接返回占位文案,丢弃了模型真实输出 |
lastAssistantText() 倒序取最后一条助手消息 |
| 3 | 最终回答被工具日志覆盖 | 模型"先给答案 + 再调 terminate",走了 act() 路径 | decideAfterThink 特判 onlyTerminate 分支 |
| 4 | No ToolCallback found |
act() 里 Prompt 的 options 没带 toolCallbacks |
单独构造带 toolCallbacks 的 ToolCallingChatOptions |
| 5 | HTTP 400 url error |
新建 DashScopeChatOptions 触发 merge,multiModel 被覆盖成 false |
从 chatModel.getDefaultOptions() 拿基底再改 |
| 6 | 流式下工具调用参数残缺 | tool call 的 arguments 是分片 JSON | ToolCallBuilder 按 id 累加,无 id 时 name+序号 |
| 7 | 多用户会话串台 | Agent 有状态(messageList),做成单例 Bean |
每请求 new;要复用必须做会话隔离 |
还有两个设计层面的注意点:
streamTokenSink必须transient------ 它持有SseEmitter引用,序列化会炸- 同步 IO 必须切线程 ------
sseEmitter.send()是阻塞的,别在 Netty event loop 上跑,用publishOn(Schedulers.boundedElastic())
十一、框架带来的可扩展性
回头看开头那个问题------"新增一个 Agent 要写什么":
public class MyAgent extends ToolCallAgent {
public MyAgent(ToolCallback[] tools, ChatModel model) {
super(tools, model);
setName("MyAgent");
setSystemPrompt("...");
setNextStepPrompt("...");
setMaxSteps(8);
setChatClient(ChatClient.builder(model).build());
}
}
流程、循环、状态、流式、异常处理、兜底总结------全部继承。
再看"改一处要改三遍"的问题:
| 要改的东西 | 改哪里 |
|---|---|
| 所有 Agent 的步数上限策略 | BaseAgent |
| 流式事件的字段结构 | BaseAgent.buildEvent() |
| 工具执行前的统一鉴权/限流 | ToolCallAgent.act() |
| 新增一条终止判断 | ReActAgent.step() |
| 换一个模型的工具调用协议 | 只改 ToolCallAgent |
边界很清晰,改哪个层级不会误伤其他层。
还没做但可以加的:多 Agent 协作(一个 Agent 把另一个当工具调)、执行状态持久化(现在重启就丢)、可观测(每步耗时、token 消耗上报)。这些都能在现有分层上加,不用推倒重来。