【Agent】从 0 手写智能体框架:ReAct + ToolCall 分层设计

目录

[一、三个 Agent,三份复制粘贴的代码](#一、三个 Agent,三份复制粘贴的代码)

二、设计:四层抽象

三、AgentState:四态状态机

[四、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)

六、ToolCallAgent:把工具循环拿回自己手里

[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();

主循环每步开始前置 falsepushToken 被调过就置 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 非空值覆盖 defaultDashScopeChatOptionsmultiModel 字段带非空默认值 false,新建的对象会把它覆盖掉配置文件里的 multi-model: true,结果多模态模型被请求到普通文本接口,阿里云直接返回 HTTP 400 url 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()),结果 ToolCallingManagerNo 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 内部持有 messageListstate是有状态的。做成单例 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 单独构造带 toolCallbacksToolCallingChatOptions
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 消耗上报)。这些都能在现有分层上加,不用推倒重来。


相关推荐
Geek-Chow1 小时前
09. LLM 接缝:把“厂商协议“关进一个可替换的盒子
人工智能
来了就不要走1 小时前
亿数平台怎么样:亿象生态与数实融合路径观察
大数据·人工智能·区块链
小坏讲微服务1 小时前
Spring AI 高频面试题20道
java·spring·ai·agent·springai
知几蜗牛1 小时前
流式JSON为什么总报错?理解结构化输出就能接稳AI接口
人工智能
Bs_MoneyMagnet1 小时前
基于springboot+vue的会议室预约管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
Zentceh1 小时前
AI-ISP vs 传统ISP全面对比
人工智能·深度学习·计算机视觉·车载系统·transformer·无人机·智能硬件
知几蜗牛1 小时前
AI代码评审开始跑测试,真正该升级的是团队证据链
人工智能
魔镜er1 小时前
11-循环神经网络
人工智能·pytorch·python·深度学习·神经网络
海宇AI1 小时前
零信任架构实战:基于海宇公安二要素认证即时版构建自动化灵活用工准入网关
运维·人工智能·架构·自动化