
本文是「Spring Boot + AI 全栈后端」系列第 07 篇。前面几篇我们让模型能对话、能省钱、能按格式输出、能查实时数据、能读私有文档。这一篇往前迈一大步:让模型自己串起一串动作,把一个多步任务从头跑到尾。示例基于 Spring AI 2.0 / Boot 4.1。
一个每周五下午都会发生的场景
V哥 给一家做电商代运营的客户做方案时,运营小姑娘给我演示了她每周五下午的固定动作:
- 打开数据后台,导出本周 GMV、订单量、客单价;
- 打开上周的表格,算环比;
- 把 GMV 曲线截图贴进周报模板;
- 写一段"本周表现如何、下周注意什么"的结论;
- 发给运营总监和品牌方对接人。
五个步骤、四个系统、约四十分钟。真正的问题不是慢,是漏:有一次她算完环比就到点下班了,周报发出去才发现图表还是上周的,品牌方拿着旧图开了一小时会。
这个场景的难点非常具体:单轮对话搞不定。你问模型"帮我出本周周报",它要么直接编一份(数据全是假的),要么反问你"请问本周 GMV 是多少"------而答案在数据库里,它自己是不知道的。
要把这件事交给程序,需要的不只是"调一次模型",而是一个能自己决定下一步做什么的循环:查数据 → 看到结果 → 决定再算环比 → 看到结果 → 决定画图 → 最后发邮件。这就是 Agent。

一、先说清楚 Agent 到底是什么
很多人把 Agent 想得很玄。V哥 把它拆成一句话:Agent = 模型 + 工具 + 一个循环。
- 模型负责判断"下一步该干什么";
- 工具负责真正干活(查库、算数、画图、发邮件);
- 循环把两者接起来:把已经做过的事告诉模型 → 模型给出下一步 → 执行 → 把结果记下来 → 再问模型。
注意这里的关键转变:模型不再是"回答者",而是"决策者"。它每一轮输出的不是一个答案,而是一张"决策单"------我这步要调哪个工具、传什么参数、任务做完了没有。
Spring AI 2.0 并没有内置一个"Agent 框架"让你开箱即用,但好消息是它的两个能力刚好够我们自己搭出一个可靠的循环:
- 结构化输出(第 04 篇):让模型按你定义的 POJO 输出决策单,比让它吐一段话再正则去抠稳得多;
- 工具调用(第 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 把内部系统的接入统一成一套协议。