"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) → 前端显示两张图片

六、关键设计决策

  1. 主 Agent 不自己写 Loop:利用框架的成熟实现,避免重复造轮子。ChatClient 的递归 doStreamLoop 已经处理了工具调用 → 再推理的完整流程,包括流式输出与工具调用的交错。

  2. 子 Agent 手写 Loop:它需要框架之外的控制逻辑——inbox 等待、进度报告、退出检测、媒体收集。手写 while 循环把这些显式表达出来,比把状态机塞进 Reactor 操作符链里清晰得多。

  3. 虚拟线程使同步代码变简单:子 Agent 的逻辑用传统的 while + .block() 写法,比 Reactor 的 Flux.interval + handle 链式写法更容易理解和维护。虚拟线程让"阻塞"从一种性能罪恶变回一种合法的编程风格——前提是阻塞发生在虚拟线程上。

  4. 两层 Loop 的职责分离:框架的递归 Loop 负责"工具调用 → 再推理"的微观循环;手写的 while Loop 负责"收消息 → 干活 → 汇报"的宏观循环。两者嵌套但不重叠,各自处理自己层级的终止条件。