【AI全栈12-07】Spring Boot 用 Agent 自动完成多步运营任务

本文是「Spring Boot + AI 全栈后端」系列第 07 篇。前面几篇我们让模型能对话、能省钱、能按格式输出、能查实时数据、能读私有文档。这一篇往前迈一大步:让模型自己串起一串动作,把一个多步任务从头跑到尾。示例基于 Spring AI 2.0 / Boot 4.1。

一个每周五下午都会发生的场景

V哥 给一家做电商代运营的客户做方案时,运营小姑娘给我演示了她每周五下午的固定动作:

  1. 打开数据后台,导出本周 GMV、订单量、客单价;
  2. 打开上周的表格,算环比;
  3. 把 GMV 曲线截图贴进周报模板;
  4. 写一段"本周表现如何、下周注意什么"的结论;
  5. 发给运营总监和品牌方对接人。

五个步骤、四个系统、约四十分钟。真正的问题不是慢,是漏:有一次她算完环比就到点下班了,周报发出去才发现图表还是上周的,品牌方拿着旧图开了一小时会。

这个场景的难点非常具体:单轮对话搞不定。你问模型"帮我出本周周报",它要么直接编一份(数据全是假的),要么反问你"请问本周 GMV 是多少"------而答案在数据库里,它自己是不知道的。

要把这件事交给程序,需要的不只是"调一次模型",而是一个能自己决定下一步做什么的循环:查数据 → 看到结果 → 决定再算环比 → 看到结果 → 决定画图 → 最后发邮件。这就是 Agent。

一、先说清楚 Agent 到底是什么

很多人把 Agent 想得很玄。V哥 把它拆成一句话:Agent = 模型 + 工具 + 一个循环。

  • 模型负责判断"下一步该干什么";
  • 工具负责真正干活(查库、算数、画图、发邮件);
  • 循环把两者接起来:把已经做过的事告诉模型 → 模型给出下一步 → 执行 → 把结果记下来 → 再问模型。

注意这里的关键转变:模型不再是"回答者",而是"决策者"。它每一轮输出的不是一个答案,而是一张"决策单"------我这步要调哪个工具、传什么参数、任务做完了没有。

Spring AI 2.0 并没有内置一个"Agent 框架"让你开箱即用,但好消息是它的两个能力刚好够我们自己搭出一个可靠的循环:

  1. 结构化输出(第 04 篇):让模型按你定义的 POJO 输出决策单,比让它吐一段话再正则去抠稳得多;
  2. 工具调用(第 05 篇):把本地 Java 方法暴露给模型调用。

这一篇我们走"结构化输出 + 自建循环"这条路。原因很实在:自己写的循环,每一步你都能看见、能打断、能限流、能审计。让框架黑盒地跑十步,中间某一步跑偏了,你连日志都没有。

二、第一步:把工具登记成一张表

Agent 能干什么,完全取决于你给了它哪些工具。先把"工具"这个东西定义清楚------名字、说明、动作三件套:

java 复制代码
public record AgentTool(String name, String description,
                        Function<Map<String, String>, String> action) {

    public String execute(Map<String, String> args) {
        return action.apply(args == null ? Map.of() : args);
    }
}

三个字段各有分工:名字 给模型填,说明 给模型判断"什么时候该用我",动作 才是真正执行的代码。用 record 装一个 Function,是为了让"加一个工具"这件事只占一行------Agent 好不好扩展,全看这一步做得多轻。

然后是注册表:

java 复制代码
@Component
public class AgentToolRegistry {

    private final Map<String, AgentTool> tools = new LinkedHashMap<>();

    public AgentToolRegistry() {
        register(new AgentTool(
                "querySales",
                "查询某一周的销售数据,返回 GMV、订单量、客单价。参数 week 形如 2026-W39",
                args -> String.format("%s:GMV 128.6 万元,订单 3421 单,客单价 376 元",
                        args.getOrDefault("week", "本周"))));

        register(new AgentTool(
                "compareLastWeek",
                "把某一周的数据和上一周做对比,返回环比涨跌。参数 week 形如 2026-W39",
                args -> String.format("较上一周:GMV +12.4%%,订单 +8.1%%,客单价 +4.0%%(%s)",
                        args.getOrDefault("week", "本周"))));

        register(new AgentTool(
                "renderChart",
                "把指标画成图表,返回图表地址。参数 metric 取值:gmv / orders / aov",
                args -> "图表已生成:chart://weekly-" + args.getOrDefault("metric", "gmv") + "-2026-W39.png"));

        register(new AgentTool(
                "sendReport",
                "把周报正文发送给收件人,返回发送结果。参数 recipient 为邮箱地址",
                args -> {
                    String to = args.getOrDefault("recipient", "");
                    if (!to.contains("@")) {
                        throw new IllegalArgumentException("收件人邮箱不合法:" + to);
                    }
                    return "周报已发送至 " + to + "(含 1 张图表)";
                }));
    }

    public AgentTool get(String name) { return tools.get(name); }
    public Set<String> names() { return tools.keySet(); }

    /** 拼给模型看的"可用工具清单",直接进系统提示词。 */
    public String describe() {
        StringBuilder sb = new StringBuilder();
        tools.forEach((name, tool) ->
                sb.append("- ").append(name).append(":").append(tool.description()).append('\n'));
        return sb.toString();
    }
}

V哥 想强调一句:没登记进这张表的工具,模型永远调不到。这是 Agent 安全的第一道闸------你不用担心模型突然去删库,因为它压根没有那个工具。反过来说,往这张表里加东西要谨慎:每加一个工具,就多一条模型可能走错的路。

三、第二步:让模型每步只交一张"决策单"

接下来定义模型每一步要交的东西:

java 复制代码
public record StepDecision(

        @JsonPropertyDescription("这一步的思考,一句话,不超过 30 字")
        String thought,

        @JsonPropertyDescription("要调用的工具名,必须来自可用工具清单;任务已完成时填空字符串")
        String toolName,

        @JsonPropertyDescription("工具入参,键为参数名、值为参数值;无参数就给空对象")
        Map<String, String> arguments,

        @JsonPropertyDescription("任务是否已经完成:true 表示不需要再调用任何工具")
        boolean done,

        @JsonPropertyDescription("done 为 true 时,这里给出给用户的最终结论;否则填空字符串")
        String finalAnswer) {
}

@JsonPropertyDescription 会被 Spring AI 读进 JSON Schema,变成写给模型看的字段说明书(第 04 篇讲过)。这里写得越准,模型填得越准------尤其是 done 这个字段,说明里必须讲清"true 表示不用再调工具了",否则模型会一边说做完了、一边还要调工具。

拿这一张决策单的方式就是结构化输出:

java 复制代码
private StepDecision decide(String goal, List<AgentStep> steps) {
    try {
        return chatClient.prompt()
                .system(s -> s.text(PLANNER_PROMPT).param("tools", registry.describe()))
                .user(u -> u.text(STEP_PROMPT).param("goal", goal).param("history", renderHistory(steps)))
                .call()
                .entity(StepDecision.class);
    } catch (Exception ex) {
        throw new AiServiceException("模型决策无法解析,任务已中止", ex);
    }
}

三个细节:

  • 系统提示词里放工具清单,用户消息里放目标和历史。工具清单每轮都一样,放进 system 既省 token 又不会被历史冲淡。
  • 历史步骤必须拼进去。没有历史,模型每轮都是"第一次听说这个任务",于是反复查同一份数据。
  • 解析失败要抛业务异常,别让原始异常往外冒,也别静默吞掉------静默的后果是循环空转。

四、第三步:编排循环,三条硬规矩

循环本身长得极其朴素:

java 复制代码
public AgentRunResult run(String goal, int maxSteps) {
    List<AgentStep> steps = new ArrayList<>();

    for (int i = 1; i <= maxSteps; i++) {
        StepDecision decision = decide(goal, steps);

        if (decision.done()) {
            return new AgentRunResult(goal, steps, decision.finalAnswer(), AgentRunResult.COMPLETED);
        }

        AgentTool tool = registry.get(decision.toolName());
        Map<String, String> args = decision.arguments() == null ? Map.of() : decision.arguments();
        String observation;

        if (tool == null) {
            observation = "工具不存在:" + decision.toolName() + ",可用工具:" + registry.names();
        } else {
            try {
                observation = tool.execute(args);
            } catch (Exception ex) {
                observation = "工具执行失败:" + ex.getMessage();
            }
        }

        steps.add(new AgentStep(i, decision.thought(), decision.toolName(), args, observation));
    }

    return new AgentRunResult(goal, steps, null, AgentRunResult.MAX_STEPS_REACHED);
}

就这么三十行。但真正值钱的是循环里那三个"不",V哥 一条条说为什么:

一是步数上限(不让它转个没完)。 模型会原地打转------这一步查了 GMV,下一步觉得数据不够,又查一遍 GMV。没有上限,一次请求就能把你的 token 预算和线程池一起拖垮。上面默认 6 步,用尽就返回 MAX_STEPS_REACHED,把已经走过的步骤原样带回去,交给人工接手。熔断不是失败,是止损。

二是工具名必须来自注册表(不让它自创工具)。 模型偶尔会自信地填一个 mailTo------你根本没有这个工具。这时候不要崩、也不要重试,把"工具不存在 + 可用清单"作为这一步的观察值回给它,绝大多数情况下它下一轮就改过来了。

三是工具异常不中断主流程(不让一步搞砸整个任务)。 上面 sendReport 收到一个不合法的邮箱会抛异常。我们不让它冒出去,而是变成观察值记进步骤,让模型自己决定:是换个参数重试,还是换条路。把失败信息喂回给模型,比替它做决定更聪明。

还有个容易被忽略的小地方:renderHistory 在没有任何步骤时要明确返回"(还没有执行任何步骤)",而不是空字符串。否则模型看到一片空白,可能会误以为自己已经做过什么了。

五、第四步:接上接口,把过程一并返回

java 复制代码
@RestController
@RequestMapping("/api/agent")
public class AgentController {

    private final AgentOrchestrator orchestrator;

    public AgentController(AgentOrchestrator orchestrator) {
        this.orchestrator = orchestrator;
    }

    @PostMapping("/weekly-report")
    public AgentRunResult weeklyReport(@Valid @RequestBody AgentRunRequest request) {
        return orchestrator.run(request.goal());
    }
}

public record AgentRunRequest(@NotBlank(message = "任务目标不能为空") String goal) {}

响应体长这样:

java 复制代码
public record AgentRunResult(String goal, List<AgentStep> steps,
                             String finalAnswer, String status) {

    public static final String COMPLETED = "COMPLETED";
    public static final String MAX_STEPS_REACHED = "MAX_STEPS_REACHED";

    public boolean completed() { return COMPLETED.equals(status); }
}

注意这里刻意把每一步都返回给调用方。理由很实在:Agent 最怕的不是跑错,而是"跑完了但没人知道它干了什么"。周报数据不对的时候,你要能一条条回放:它查的是哪一周、算的环比是多少、发给了谁。这些步骤留痕,同时也是审计凭据------尤其当工具里包含"发邮件""改数据"这类动作时。

status 显式带回前端也有用:看到 MAX_STEPS_REACHED 就提示"任务没跑完,请人工接手",而不是假装一切正常。

六、怎么验证

Agent 最大的测试难题是"依赖真实模型就不可控"。解决办法是把模型的决策脚本化:预先写好每一轮它应该返回什么,然后断言整个循环的行为。

java 复制代码
public class ScriptedAgentChatModel implements ChatModel {

    private final List<String> script;
    private int cursor;

    @Override
    public ChatResponse call(Prompt prompt) {
        String json = cursor < script.size() ? script.get(cursor) : script.get(script.size() - 1);
        cursor++;
        return ChatResponse.builder()
                .generations(List.of(new Generation(new AssistantMessage(json))))
                .build();
    }
}

有了它,四种剧情都能离线复现,不需要真实密钥:

剧情 脚本怎么编 断言什么
正常收尾 查数 → 对比 → 出图 → 发送 → done COMPLETED、4 步、最后一步是 sendReport
原地打转 每轮都要求重新查数 3 步后被熔断、MAX_STEPS_REACHED、无结论
乱填工具名 第一轮填一个不存在的工具 不崩、观察值含"工具不存在"且回给了可用清单
工具抛异常 给 sendReport 传非法邮箱 观察值含"执行失败",下一轮修正后成功

编排逻辑的这些分支直接 new AgentOrchestrator(new ScriptedAgentChatModel(script), new AgentToolRegistry()) 就能跑,不用起 Spring 容器;只有 Web 层(200 / 400)走一次 MockMvc。把"编排逻辑"和"Web 层"分开测,是 Agent 这类代码能不能长期维护的分水岭。

七、落地要点

  • 工具说明要写给人看、也要写给模型看 :description 里带上参数格式和取值范围(比如"参数 week 形如 2026-W39"),模型填对参数的概率会明显上升。
  • 先给工具,再给自由度:V哥 的建议是新场景先只放开只读工具(查询类),跑顺了再放写操作(发邮件、改状态)。
  • 危险工具加二次确认 :sendReport 这类动作,真上线时应该在工具内部校验收件人白名单,而不是指望模型不乱填。
  • 步数上限按场景给:周报这种 4-6 步足够,别一股脑给 20 步------上限越宽,失控时的代价越大。
  • 每一步都要落库 :AgentStep 别只留在响应里,持久化下来才能做分析和追责。
  • 成本要算清楚:一次多步任务是 N 次模型调用,不是 1 次。第 03 篇的路由思路在这里同样适用------规划和判断这类简单决策,未必需要旗舰档。

V哥 带学员做 Agent 时立的规矩是:任何一个会自动执行写操作的 Agent,必须先能在只读模式下跑通全流程。跳过这一步,等着你的就是"AI 自己发了封不该发的邮件"。


最后一句:Agent 不神秘------把工具登记成表、让模型每步只交一张决策单、再用一个带步数上限的循环把两者接起来,你就能让一串原本靠人肉串联的动作自己跑完;而真正决定它能不能上线的,是那三条硬规矩,不是循环本身。下一篇(08)V哥 带你解决工具越加越乱的问题:用 MCP 把内部系统的接入统一成一套协议。

相关推荐
未秃头的程序猿1 小时前
我用 Spring AI + MCP 把公司内部系统接给了 AI,过程没想象中顺利
java·后端
RuoyiOffice1 小时前
SpringBoot3+Vue3 员工全生命周期:入转调离如何衔接账号、权限、资产与交接
spring boot·vue3·权限管理·员工管理·hrm·ruoyi office·人事变更
m0_587383001 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
geovindu1 小时前
rust: Chain of Responsibility Pattern (续)
开发语言·后端·设计模式·rust·责任链模式·行为模式
诚心呈意共享外卖配送系统1 小时前
多平台订单聚合系统的技术架构:接 API 只是开始
java·开发语言·架构·系统架构·创业创新
Java爱好狂.2 小时前
Java小白如何三天速通redis?
java·数据库·redis·架构师·分布式锁·java面试·java程序员
大风起兮云飞扬丶2 小时前
Java——多线程
java
郝学胜-神的一滴2 小时前
Web安全入门:读懂Web世界里的攻防
前端·网络·后端·安全·web安全·hibernate
天若有情6732 小时前
从零搭建校园二手交易系统:Vue2 + SpringBoot 4.x 全栈实战
java·spring boot·后端