← Back to Blog
系列: build-agent-from-scratch (1/2) 系列页
EN中文

Agent 到底是什么?一个 while 循环的本质

完整代码:github.com/geyuxu/yuxu-java-agent

如果你在社交媒体上搜索 "AI Agent",你会看到无数花哨的定义:自主智能体、具身认知系统、目标驱动的决策引擎……

忘掉它们。

今天我们从最朴素的角度出发,用 一段 Java 代码 告诉你 Agent 的全部秘密。


一句话定义

Agent = while 循环 + 工具调用

就这么简单。

一个 LLM 本身只能生成文本。它不能读文件、不能跑测试、不能看报错。如果没有循环,每次工具调用都需要你手动把结果粘贴回去——你自己就成了那个循环

Agent 做的事情,就是把你从这个循环里解放出来。

最小 Agent 的架构

Agent Loop

整个控制流只有 一个退出条件:模型的 finish_reason 不再是 "tool_calls"

这意味着:

  • 模型决定该调用什么工具(推理)
  • 系统执行工具并返回结果(执行)
  • 模型根据结果决定下一步(再推理)
  • 直到模型认为任务完成,输出最终回答(终止)

用 Java 实现一个完整的 Agent

下面我们从零开始,用 Java 实现一个真正可运行的 Agent。没有框架魔法,没有 Spring,就是最裸的 HTTP 调用——这样你能看清每一个细节。

我们使用 OpenAI 的 Chat Completions API,通过 function calling 机制让模型调用工具。

第一步:准备工作

先引入两个 Java 世界的基础库:OkHttp 负责 HTTP 请求,Gson 负责 JSON 序列化。

Maven 依赖:

<dependencies>
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>4.12.0</version>
    </dependency>
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.10.1</version>
    </dependency>
</dependencies>

第二步:定义工具

Agent 需要"手脚"。我们只给它一个工具——执行 bash 命令。一个工具就够了,因为 bash 几乎能做任何事。

OpenAI 的 function calling 格式如下:

static final List<Map<String, Object>> TOOLS = List.of(
        Map.of(
                "type", "function",
                "function", Map.of(
                        "name", "bash",
                        "description", "Execute a bash shell command and return the output.",
                        "parameters", Map.of(
                                "type", "object",
                                "properties", Map.of(
                                        "command", Map.of(
                                                "type", "string",
                                                "description", "The bash command to execute"
                                        )
                                ),
                                "required", List.of("command")
                        )
                )
        )
);

工具的执行函数同样简单——用 ProcessBuilder 跑一个 shell 命令,拿到输出:

static String executeBash(String command) {
    try {
        Process process = new ProcessBuilder("sh", "-c", command)
                .redirectErrorStream(true)  // 合并 stdout 和 stderr
                .start();

        String output = new String(process.getInputStream().readAllBytes());
        boolean finished = process.waitFor(120, TimeUnit.SECONDS);

        if (!finished) {
            process.destroyForcibly();
            return "Error: Timeout (120s)";
        }
        return output.isBlank() ? "(no output)" : output.trim();
    } catch (Exception e) {
        return "Error: " + e.getMessage();
    }
}

第三步:核心——Agent 循环

这是整篇文章最重要的部分。所有 Agent 的本质,都藏在下面这个方法里:

static void agentLoop(List<Map<String, Object>> messages, ModelClient modelClient) 
        throws IOException {

    while (true) {

        // 1. 调用模型:把完整的消息历史发给 LLM
        Map<String, Object> choice = modelClient.call(messages);

        String finishReason = (String) choice.get("finish_reason");
        Map<String, Object> assistantMsg =
                (Map<String, Object>) choice.get("message");

        // 2. 把模型的回复追加到消息历史
        messages.add(assistantMsg);

        // 3. 检查退出条件:模型不再调用工具 → 任务完成
        if (!"tool_calls".equals(finishReason)) {
            String content = (String) assistantMsg.get("content");
            if (content != null) {
                System.out.println("\nAssistant: " + content);
            }
            return;
        }

        // 4. 执行每一个工具调用,收集结果
        List<Map<String, Object>> toolCalls =
                (List<Map<String, Object>>) assistantMsg.get("tool_calls");

        for (Map<String, Object> toolCall : toolCalls) {
            String callId = (String) toolCall.get("id");
            Map<String, Object> function =
                    (Map<String, Object>) toolCall.get("function");
            String arguments = (String) function.get("arguments");

            Map<String, String> args = gson.fromJson(arguments, Map.class);
            String command = args.get("command");

            System.out.println("\n> bash: " + command);
            String output = executeBash(command);
            System.out.println(output);

            // 5. 把工具结果追加到消息历史(OpenAI 格式:role=tool)
            messages.add(Map.of(
                    "role", "tool",
                    "tool_call_id", callId,
                    "content", output
            ));
        }

        // 回到第 1 步
    }
}

就这么多,这就是一个完整的 Agent。

逐步拆解循环

让我们慢下来,看看每一步到底在干什么:

步骤 做什么 为什么
调用模型 把完整消息历史发给 LLM 模型需要看到所有上下文才能做出正确决策
追加回复 把 assistant 消息加入历史 保持对话连贯性,模型下一轮要看到自己上一轮说了什么
检查退出 finish_reason 不是 tool_calls 模型认为不需要再调工具 = 任务完成
执行工具 运行 bash 命令 让模型的"想法"变成"行动"
追加结果 tool 消息加入历史 模型需要看到执行结果才能推理下一步

这个循环有一个精妙之处:模型自己决定什么时候停下来。你不需要写任何条件判断逻辑,不需要状态机,不需要流程图。模型说"我还需要调工具",循环就继续;模型说"我搞定了",循环就结束。

第四步:HTTP 调用

为了完整性,这里是调用 OpenAI Chat Completions API 的方法。这部分是纯粹的胶水代码,不是重点:

private static final String API_URL = "https://api.openai.com/v1/chat/completions";
private static final String MODEL = "gpt-4o-mini";
private static final OkHttpClient httpClient = new OkHttpClient.Builder()
        .readTimeout(60, TimeUnit.SECONDS).build();
private static final Gson gson = new Gson();

static ModelClient createOpenAIClient(String apiKey) {
    return messages -> {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("model", MODEL);
        body.put("messages", messages);
        body.put("tools", TOOLS);

        Request request = new Request.Builder()
                .url(API_URL)
                .addHeader("Authorization", "Bearer " + apiKey)
                .addHeader("Content-Type", "application/json")
                .post(RequestBody.create(
                        gson.toJson(body),
                        MediaType.get("application/json")))
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new IOException("API error " + response.code()
                        + ": " + response.body().string());
            }
            Map<String, Object> responseBody = gson.fromJson(
                    response.body().string(), Map.class);
            List<Map<String, Object>> choices =
                    (List<Map<String, Object>>) responseBody.get("choices");
            return choices.get(0);
        }
    };
}

第五步:启动入口

public static void main(String[] args) throws IOException {
    String apiKey = System.getenv("OPENAI_API_KEY");
    if (apiKey == null) {
        System.err.println("Please set OPENAI_API_KEY");
        return;
    }

    ModelClient client = createOpenAIClient(apiKey);

    String userInput = args.length > 0 ? args[0] : "List current directory files";
    System.out.println("User: " + userInput);

    List<Map<String, Object>> messages = new ArrayList<>();
    messages.add(Map.of("role", "system", "content", SYSTEM_PROMPT));
    messages.add(Map.of("role", "user", "content", userInput));

    agentLoop(messages, client);
}

消息数组:Agent 的短期记忆

注意到了吗?整个 Agent 最重要的数据结构不是模型,不是工具,而是那个贯穿始终的 messages 列表

messages = [
    { role: "system",    content: "You are a coding agent..." }        // 系统提示
    { role: "user",      content: "帮我创建一个 hello.py" }             // 用户输入
    { role: "assistant", tool_calls: [bash("touch hello.py")] }        // 模型决定调 bash
    { role: "tool",      content: "(no output)" }                      // bash 执行结果
    { role: "assistant", tool_calls: [bash("cat hello.py")] }          // 模型决定再调
    { role: "tool",      content: "print('hello')" }                   // 第二次执行结果
    { role: "assistant", content: "hello.py 已创建..." }               // 最终回答
]

每一轮循环都在往这个列表里追加:模型的回复、工具的结果,如此往复。模型看到完整的历史,才能做出连贯的决策。

这里有一个值得思考的问题:消息只会越来越多,直到塞满上下文窗口。当你的 Agent 执行了 50 次工具调用,messages 数组里可能已经有几十万 token。怎么办?这个问题我们后面会专门讲。

动手试试

# 克隆项目
git clone https://github.com/geyuxu/yuxu-java-agent.git
cd yuxu-java-agent

# 设置 API Key
export OPENAI_API_KEY="your-key-here"

# 编译运行
mvn compile exec:java -Dexec.args="'创建一个 hello.py,写入 Hello World 并运行它'"

试试这些提示词,观察 Agent 是如何一步步调用 bash、获取结果、再决定下一步的:

  • "列出当前目录下所有 Java 文件"
  • "创建一个 test_output 目录,在里面写 3 个文件"
  • "查看系统内存使用情况,找出占用最多的前 5 个进程"

真正的 Agent 多出来的是什么

我们写了不到 100 行核心逻辑。一个生产级的 Agent 可能有几千行。那些多出来的代码在处理什么?

                    +-- 流式响应(逐 token 展示,而非等全部生成完)
                    +-- 权限检查(执行危险命令前需要用户确认)
                    +-- 错误恢复(API 超时、限流、网络抖动的重试)
 while (true) ------+-- 上下文压缩(token 超限时自动摘要历史消息)
                    +-- 中止处理(用户随时可以按 Ctrl+C 中断)
                    +-- 最大轮次限制(防止模型陷入无限循环)
                    +-- 多工具并发(同时执行多个互不依赖的工具调用)

每一项都是 真实世界的需求 在向那个简洁的 while 循环施加压力。但无论代码怎么膨胀,内核从未改变:

调用模型 -> 检查是否需要工具 -> 执行工具 -> 把结果喂回去 -> 重复

你可能注意到了,在我们的 Agent 循环里,有一个看似不起眼但意义深远的设计:模型自己决定什么时候停下来

这不是一个工程上的偷懒。这是 Agent 和传统自动化脚本的根本区别。

一个 Shell 脚本是你写好每一步:先做 A,再做 B,最后做 C。步骤是固定的,遇到意外就挂了。

而 Agent 的循环里,你只定义了"能做什么"(工具),不定义"该做什么"(流程)。模型在每一轮循环中根据当前的完整上下文——用户的原始请求、之前所有工具调用的结果、中间遇到的错误——自主决定下一步。它可能会调 3 次工具,也可能调 30 次。它可能会遇到错误后换一种方式重试,也可能发现任务比预期简单而提前结束。

这就是为什么一个 while 循环 + 工具调用,就构成了一个 Agent。循环提供了持续行动的能力,工具提供了作用于外部世界的能力,而模型提供了在每个节点做出判断的能力。

三者缺一不可。


总结

Agent 的本质是一个 while 循环。模型负责"想",工具负责"做",循环负责把它们串起来。


本文完整代码:github.com/geyuxu/yuxu-java-agent

系列: build-agent-from-scratch (1/2) 系列页