"Agent Loop"(智能体推理循环)是所有 Agent 系统的心脏。这篇文章讲清楚三件事:Agent Loop 到底是什么;我们的内部 Agent 平台里两套 Loop 实现分别长什么样(主 Agent 委托给框架、子 Agent 手写 while 循环);以及背后的工程权衡——为什么同一个系统里会有两种写法。
技术栈背景:Java 21 + Spring Boot(WebFlux 响应式)+ Spring AI 2.0,输出走 SSE 流式推送。
一、什么是 Agent Loop
Agent Loop 是 ReAct(Reasoning + Acting)模式的核心:LLM 拿到任务后,反复进行"思考 → 行动 → 观察结果 → 再思考"的循环,直到任务完成。
┌──────────────────────────────────────────┐
│ Agent Loop │
│ │
│ ① 收消息(用户输入 / inbox) │
│ ↓ │
│ ② LLM 推理 → 输出文本 or 决定调工具 │
│ ↓ │
│ ③ 如果调了工具 → 执行 → 拿结果 │
│ ↓ │
│ ④ 工具结果注入 LLM 上下文 → 回到 ② │
│ ↓ │
│ ⑤ LLM 不再调工具 → 输出最终答案 → 结束 │
└──────────────────────────────────────────┘
我们的平台里有两套 Agent Loop 实现,分别对应主 Agent 和子 Agent:
| 主 Agent | 子 Agent | |
|---|---|---|
| Loop 实现 | 委托给 Spring AI ChatClient(递归内部实现) | 手写 while 循环 |
| 调用方式 | 回调式 subscribe() |
阻塞式 .block() |
| 运行线程 | WebFlux NIO 线程(Netty 事件循环) | Java 21 虚拟线程 |
| 输出方式 | 实时流式推 SSE | 全部收集后发消息 |
为什么同一个系统里会有两种写法?因为它们面对的约束完全不同。下面分别展开。
二、主 Agent 的 Loop:委托给框架
2.1 代码长什么样
主 Agent 的一次完整推理,就是这一段链式调用:
// ★ 核心:这一整段就是主 Agent 的一次完整推理
chatClient.prompt()
.system(ctx.systemPrompt) // ① 系统提示词
.messages(historyMsgs) // ② 历史消息
.user(message) // ③ 用户当前消息
.options(...) // ④ 模型参数(含 tool-calling 配置)
.tools(tools) // ⑤ 注册工具(含 send_message 等)
.stream().chatResponse() // ⑥ 开始流式推理
.flatMapSequential(cr -> { ... }) // ⑦ 将输出帧转为 SSE 事件
.subscribe( // ⑧ 绑定 SSE emitter
emitter::next, // 每帧 → 推送前端
error -> ..., // 出错 → 发错误事件
() -> ... // 结束 → 发 done 事件
);
我们没有自己写 while 循环。整个 Agent Loop 由 ChatClient 内部自动完成。
2.2 ChatClient 内部原理
Spring AI 的 ChatClient 在调用 .stream().chatResponse() 时,内部实现了一个完整的 ReAct 循环。以下是简化版的源码逻辑:
// Spring AI DefaultChatClient 内部逻辑(简化示意)
public Flux<ChatResponse> stream() {
return Flux.defer(() -> {
// 第 1 步:组装初始消息
List<Message> conversation = new ArrayList<>();
conversation.add(new SystemMessage(systemPrompt));
conversation.addAll(historyMessages);
conversation.add(new UserMessage(userInput));
// 第 2 步:调用 LLM(可能多轮)
return doStreamLoop(conversation, tools, 0);
});
}
private Flux<ChatResponse> doStreamLoop(List<Message> conversation,
List<ToolCallback> tools,
int step) {
if (step >= MAX_STEPS) return Flux.error("超过最大步数");
// ① 调用 LLM
return chatModel.stream(new Prompt(conversation, options))
.flatMap(chatResponse -> {
AssistantMessage assistantMsg = (AssistantMessage) chatResponse.getOutput();
// ② LLM 决定调工具了吗?
if (assistantMsg.hasToolCalls()) {
// ★ 关键:LLM 想调工具 → 执行工具 → 结果加入对话 → 递归再调 LLM
// Step A: 把 LLM 的回复(含工具调用指令)加入对话
conversation.add(assistantMsg);
// Step B: 执行每个工具
for (ToolCall tc : assistantMsg.getToolCalls()) {
String toolResult = executeTool(tc);
conversation.add(new ToolResponseMessage(toolResult));
}
// Step C: 递归——用工具结果重新调 LLM
return doStreamLoop(conversation, tools, step + 1);
}
// ③ LLM 不再调工具 → 输出最终文本 → 结束
return Flux.just(chatResponse);
});
}
一句话总结:ChatClient 用一个递归方法 doStreamLoop 实现了 Agent Loop。LLM 每轮输出后,如果有工具调用就执行工具然后递归,没有就结束。这就是"单轮流式 LLM 调用 + 原生 tool-calling"的真实形态——应用层看不到循环,但循环确实在跑。
2.3 配套机制
| 机制 | 作用 |
|---|---|
| 工具适配层 | 将平台内各类工具(内置工具 / MCP 工具 / 通信工具)转为 Spring AI 的 FunctionToolCallback,绑定执行逻辑 |
| 工具调用日志 Advisor | 拦截每次 LLM 输出,记录日志并收集 SSE 事件(工具开始/结束) |
| 事件收集器 | 收集工具调用的 SSE 事件和媒体生成结果 |
Flux.create(emitter -> ...) |
把 ChatClient 的异步回调桥接成 WebFlux 的 SSE 流 |
2.4 为什么主 Agent 不能用阻塞调用
主 Agent 跑在 WebFlux 的 reactor-http-nio 线程上(Netty 事件循环)。如果调用 .block(),会把整个事件循环线程卡住,导致所有其他请求都不能响应。
所以必须用回调 + Flux 的方式:ChatClient.stream() 返回一个 Flux,通过 .subscribe(emitter::next, ...) 把每个 token 实时推送到前端。这不是风格偏好,是响应式运行时下的硬性约束。
三、子 Agent 的 Loop:手写 while 循环
3.1 代码长什么样
子 Agent 是被主 Agent 通过消息总线调度的独立推理单元。它需要额外的控制逻辑——等待收件箱、进度报告、退出检测、媒体结果收集——框架不提供这些,所以手写:
public void run() {
int step = 0;
List<String> generatedMediaUrls = new ArrayList<>();
while (step < config.maxSteps() && !shouldShutdown) { // ★ 手写 while 循环
// ① 读收件箱(阻塞等待)
List<AgentMessage> inbox = messageBus.readInbox(sessionId, agentId);
if (inbox.isEmpty()) {
inbox = waitForMessage(); // 订阅 Pub/Sub 等待唤醒,最多等 60s
if (inbox.isEmpty()) break; // 超时退出
}
// ② 检查 shutdown
if (inbox.stream().anyMatch(m -> "shutdown".equals(m.type()))) break;
// ③ 发进度通知
messageBus.send(sessionId, agentId, leadAgentId, "progress", "已收到任务...", ...);
// ④ 格式化 inbox 为 LLM 输入
String inboxText = formatInboxForLLM(inbox);
// ⑤ 调 LLM(这里也用了 ChatClient,但改为同步阻塞写法)
var result = runSingleRound(systemPrompt, memory, inboxText, effectiveTools);
generatedMediaUrls.addAll(result.mediaUrls());
memory.add(new Message("assistant", result.text(), now));
// ⑥ LLM 完成了 → 退出循环
if (result.finished()) break;
step++;
}
// ⑦ 退出后:发 final result(含生成的媒体 URL)
String finalResult = buildResultSummary(memory, step, startedAt, generatedMediaUrls);
messageBus.send(sessionId, agentId, leadAgentId, "result", finalResult, ...);
listener.onSubAgentExit(agentId);
}
3.2 为什么子 Agent 可以阻塞
子 Agent 跑在 Java 21 虚拟线程上:
Thread.ofVirtual()
.name("sub-agent-" + agentId + "-" + sessionId)
.start(() -> {
var runner = new SubAgentRunner(...);
runner.run(); // ← 这里面的 .block() 不会卡主线程
});
虚拟线程的关键性质:当虚拟线程执行阻塞 I/O(如 .block()、网络请求)时,底层真正被占用的操作系统线程(Carrier Thread)会被释放去干别的事情。等阻塞结束,JVM 找个空闲的 Carrier Thread 继续执行。
代价对比:
| 传统平台线程 | 虚拟线程 | |
|---|---|---|
| 阻塞一个线程 | 消耗 ~1MB 栈内存 | 只在堆上保存少量对象 |
| 创建 1000 个 Agent | 1GB 内存 + OOM 风险 | 几乎不影响内存 |
这就是一个会话中能同时运行多个子 Agent 的基础。
3.3 单轮内部:ChatClient 仍然在跑 Loop
private RoundResult runSingleRound(...) {
var response = chatClientBuilder.build().prompt()
.system(systemPrompt)
.messages(historyMsgs)
.user(userMessage)
.tools(tools)
.stream().chatResponse() // ChatClient 内部仍然会跑 Agent Loop!
.collectList() // 收集所有帧到 List
.block(LLM_TIMEOUT); // 阻塞等待(虚拟线程上安全)
// 从事件收集器提取生成的媒体 URL
List<String> mediaUrls = new ArrayList<>();
for (var item : collector.getMediaResults()) {
mediaUrls.addAll((List<String>) item.get("urls"));
}
// 检测 LLM 是否调用了 send_message(result/task),判断本轮是否完成
boolean llmSentResult = /* 检查工具调用中是否有 send_message */;
boolean finished = !hasToolCalls || llmSentResult;
return new RoundResult(text, mediaUrls, finished);
}
注意一个容易忽略的点:子 Agent 内部也调了 ChatClient,ChatClient 仍然会自动执行 Agent Loop(LLM 调工具 → 执行 → 递归再调 LLM)。手写 while 循环是在更高的层级上——它循环的单元是"处理一批收件箱消息",而不是"处理一次工具调用"。区别只是我们把单轮的最终结果 .block() 同步拿回来了。
四、两种 Loop 的对比
| 维度 | 主 Agent | 子 Agent |
|---|---|---|
| Loop 实现 | ChatClient 递归内部实现 | 手写 while 循环 |
| ChatClient 调用 | 回调式 subscribe() |
阻塞式 .block() |
| 运行线程 | WebFlux NIO 线程 | Java 21 虚拟线程 |
| 输出方式 | 实时流式推 SSE | 全部收集后发消息 |
| 上下文 | 同一会话连续对话 | 独立子会话,首次消息触发 |
| inbox 检查 | 每轮 LLM 前(通过 system prompt 注入) | 每轮 while 迭代前(通过消息总线读取) |
| 结束条件 | LLM 不再调工具 | LLM 调了 send_message(result) 或无工具调用 |
五、时序图:一次典型的协作推理
用户: "帮我画两只猫,Tom 和 Jerry"
│
▼
主 Agent (ChatClient 内部 Loop):
│
├─[Round 1] LLM → "我先把任务分给子Agent" + 调 send_message(to=sub, task="画Tom")
├─[Round 2] LLM → "再发给它第二个" + 调 send_message(to=sub, task="画Jerry")
├─[Round 3] LLM → 不再调工具 → 输出 "请稍等..." → 结束
│
▼ (ChatClient 递归结束,Flux 完成)
│
▼ (事件轮询开始)
│
子 Agent (手写 while Loop):
│
├─ inbox: ["画Tom", "画Jerry"](两条消息一次收到)
├─[Round 1] LLM → 调 generate_image("Tom") → 生图 10s → 结果
├─ LLM → 调 generate_image("Jerry") → 生图 8s → 结果
├─ LLM → 调 send_message(to=lead, result="完成") → finished=true
│ → break
│
├─ finally: 发 progress → 发 final result (含两张图的 URL)
│
▼
主会话读到 result → 推送结果事件(含 media URLs) → 前端显示两张图片
六、关键设计决策
主 Agent 不自己写 Loop:利用框架的成熟实现,避免重复造轮子。ChatClient 的递归
doStreamLoop已经处理了工具调用 → 再推理的完整流程,包括流式输出与工具调用的交错。子 Agent 手写 Loop:它需要框架之外的控制逻辑——inbox 等待、进度报告、退出检测、媒体收集。手写
while循环把这些显式表达出来,比把状态机塞进 Reactor 操作符链里清晰得多。虚拟线程使同步代码变简单:子 Agent 的逻辑用传统的
while + .block()写法,比 Reactor 的Flux.interval + handle链式写法更容易理解和维护。虚拟线程让"阻塞"从一种性能罪恶变回一种合法的编程风格——前提是阻塞发生在虚拟线程上。两层 Loop 的职责分离:框架的递归 Loop 负责"工具调用 → 再推理"的微观循环;手写的 while Loop 负责"收消息 → 干活 → 汇报"的宏观循环。两者嵌套但不重叠,各自处理自己层级的终止条件。