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

逐行解读 Agent Loop:为什么只需要一个 bash 工具

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

上一篇我们说 Agent 的本质是一个 while 循环。今天我们把这个循环摊开,逐行看清每一个设计决策。然后回答一个很多人的疑问:为什么只给 Agent 一个 bash 工具就够了?


完整代码一览

先把我们的 Agent 循环完整贴出来,一共 56 行(第 84-139 行)。后面会逐段拆解。

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

    while (true) {

        // 1. 调用模型
        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. 追加结果
            messages.add(Map.of(
                    "role", "tool",
                    "tool_call_id", callId,
                    "content", output
            ));
        }
    }
}

现在逐段拆解。


第 1 行:while (true)

while (true) {

这里只有一个无限循环,退出条件由模型决定。当模型说"我还需要调工具",循环继续;模型说"我搞定了",循环结束。

如果你去看其他 Agent 项目,会发现大家都是这么干的。

OpenAI 的 Codex CLI 用 Rust 写的核心循环(codex.rs 第 5877 行):

loop {
    // ...准备模型输入...
    let sampling_request_input = sess.clone_history().await;
    
    match run_sampling_request(...).await {
        Ok(output) => {
            if !needs_follow_up { break; }  // 模型说搞定了,退出
        }
        Err(...) => handle_error_or_return,
    }
}

Princeton 的 mini-swe-agent 用 Python 写的循环(default.py 第 85 行):

while True:
    try:
        self.step()          # step() = query() + execute_actions()
    except InterruptAgentFlow as e:
        self.add_messages(*e.messages)
    finally:
        self.save(self.config.output_path)
    if self.messages[-1].get("role") == "exit":
        break                # 最后一条消息是 "exit" 角色时退出

第 1.5 段:告诉模型"你有什么工具"

模型怎么知道自己能调用工具?在 createOpenAIClient 方法里(第 144-176 行):

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);        // ← 关键:每次请求都带上工具定义
        // ...发送 HTTP 请求...
    };
}

TOOLS 是一个常量,定义了我们给模型提供的工具清单(第 29-47 行):

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")
            )
        )
    )
);

这是 OpenAI 的 function calling 协议:你在请求体里放一个 tools 数组,每个工具用 JSON Schema 描述它的名字、用途和参数格式。模型看到这些定义后,就知道自己可以调用哪些工具、每个工具需要传什么参数。

如果你不传 tools,模型永远不会返回 finish_reason: "tool_calls"——它根本不知道有工具可用。所以 tools 参数是整个 Agent 循环能运转的前提。

这个格式不是 OpenAI 独有的。DeepSeek、Groq 等兼容 OpenAI 的服务可以直接复用。Anthropic(Claude)和 Google(Gemini)有自己的工具定义格式,但核心思路一样:用结构化的 schema 告诉模型"你有什么工具、参数长什么样"。


第 2 段:调用模型

Map<String, Object> choice = modelClient.call(messages);

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

把完整的消息历史发给模型,拿回两样东西:

  • finishReason:模型为什么停下来了?"stop" 表示任务完成,"tool_calls" 表示它想调工具
  • assistantMsg:模型的回复内容,可能是文本,也可能包含工具调用请求

注意我们传的是 完整的 messages 列表,不是只传最新一条。模型是无状态的——它不记得上一轮说了什么。每次调用都要把所有历史消息重新发一遍,它才能做出连贯的决策。

这也是为什么消息数组会越来越大,最终塞满上下文窗口。

第 3 段:追加回复

messages.add(assistantMsg);

模型的回复必须被追加到消息历史里。因为如果模型这一轮调用了工具,下一轮它需要看到自己"请求调用工具"这条消息,才能把工具的执行结果和自己的请求对应起来。

如果你删掉这行,Agent 会立刻混乱。模型看到了工具结果,但不知道是谁、为什么调用的。

第 4 段:检查退出条件

if (!"tool_calls".equals(finishReason)) {
    String content = (String) assistantMsg.get("content");
    if (content != null) {
        System.out.println("\nAssistant: " + content);
    }
    return;
}

这是整个循环唯一的出口。

finishReason 有几种可能的值:

finish_reason 含义 Agent 该怎么做
stop 模型主动结束,任务完成 输出最终回答,退出循环
tool_calls 模型想调用工具 执行工具,继续循环
length 输出达到 max_tokens 上限 被截断了,可能需要处理
content_filter 内容被安全过滤 需要告知用户

我们只关心一件事:finishReason 是不是 tool_calls。是,就继续干活;不是,就退出。

我们没有区分 stoplength。一个更健壮的实现应该在 length 时做点什么,比如提示用户"输出被截断了"。

第 5 段:执行工具

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);

注意 toolCalls 是一个列表。模型可以在一次回复中请求调用多个工具。比如它可能同时想运行 lscat README.md

每个工具调用包含三个关键信息:

  • id:调用的唯一标识,用来把结果和请求对应起来
  • function.name:要调用哪个工具(我们只有 bash,所以不需要 dispatch)
  • function.arguments:工具参数的 JSON 字符串

arguments 是一个 JSON 字符串而非对象,这是 OpenAI API 的设计。模型生成的 JSON 可能有格式问题(比如多余的逗号、缺少引号),所以生产级代码通常需要更健壮的解析。

第 6 段:追加工具结果

    messages.add(Map.of(
            "role", "tool",
            "tool_call_id", callId,
            "content", output
    ));
}

把工具执行结果追加到消息历史。注意三个字段:

  • role: "tool":OpenAI 的消息角色之一,专门用于工具结果
  • tool_call_id:必须和请求中的 id 对应,否则 API 会报错
  • content:工具的输出文本

tool_call_id 的存在是因为模型可能同时调用多个工具。每个结果必须明确对应哪个调用,模型才能正确解读。

追加完所有工具结果后,循环回到第 1 步,带着更长的消息历史再次调用模型。模型会看到自己之前的请求和工具的执行结果,然后决定下一步。


为什么只需要一个 bash 工具?

这是最常被问到的问题。答案很简单:bash 是一个万能工具

想想你在终端里能做什么:

# 读文件
cat src/main/java/Agent.java

# 写文件
echo "hello" > test.txt

# 搜索代码
grep -r "while (true)" --include="*.java"

# 查找文件
find . -name "*.java" -type f

# 运行测试
mvn test

# 查看 git 状态
git status && git diff

# 安装依赖
pip install requests

# 发送 HTTP 请求
curl -s https://api.example.com/data

读文件、写文件、搜索、执行、网络请求——全都能做。一个 bash 工具等价于无数个专用工具的集合。

Princeton 的 mini-swe-agent 项目用大约 100 行 Python 就实现了一个在 SWE-bench 上达到 74% 准确率的 Agent,它也只用了 shell 这一个工具。

那为什么生产级 Agent 要拆成几十个工具?

既然 bash 万能,为什么 Claude Code 有 40+ 个工具,而不是只用一个 bash?

三个原因:

1. 精确控制

FileEditTool 做的是精确字符串替换。它能保证只修改目标行,不影响其他内容。而 sedecho 写文件容易出错——转义字符、换行符、引号嵌套,每一个都是坑。

2. 安全边界

BashTool 能执行任何命令,包括 rm -rf /。拆成专用工具后,你可以给每个工具设定精确的权限。读文件不需要确认,写文件需要确认,删文件需要双重确认。一个万能工具没法做这种细粒度控制。

3. 降低模型出错率

当工具参数是结构化的(比如 {"file_path": "...", "old_string": "...", "new_string": "..."}),模型犯错的概率比写一段完整的 sed 命令低得多。结构化输入等于在帮模型减少自由度,自由度越低越不容易出错。

渐进式策略

所以最佳策略是渐进式的:

一个 bash 工具   →   验证 Agent 循环能跑通
拆出文件操作     →   读/写/编辑/搜索,精确可控
拆出执行工具     →   bash 限制为只做命令执行
加入专用工具     →   LSP/Git/Web 等,各司其职

先用一个工具证明循环有效,再拆分出专用工具解决精度和安全问题。

我们的项目也会沿着这条路演进。


executeBash:唯一的工具执行函数

最后看看工具的实际执行:

static String executeBash(String command) {
    try {
        Process process = new ProcessBuilder("sh", "-c", command)
                .redirectErrorStream(true)
                .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();
    }
}

几个关键设计:

设计 为什么
sh -c 允许管道、重定向等 shell 特性
redirectErrorStream(true) 合并 stdout 和 stderr,模型能看到错误信息
waitFor(120, SECONDS) 超时保护,防止命令卡住
destroyForcibly() 超时后强制杀掉进程
空输出返回 "(no output)" 告诉模型"命令成功了但没输出",而非让它困惑于空字符串
异常返回 "Error: ..." 永远不抛异常,始终返回字符串给模型

最后一点尤其重要:工具函数永远不该抛异常。无论发生什么,都返回一个字符串让模型看到。模型擅长从错误信息中恢复:它看到 "Error: Permission denied" 后可能会换一种方式重试。但如果你抛了异常,循环直接中断,模型连重试的机会都没有。


三个项目的循环对比

我们把自己的循环和两个生产级 Agent 对比一下:

yuxu-java-agent mini-swe-agent Claude Code Codex CLI
语言 Java Python TypeScript Rust
循环 while (true) while True 第85行 while (true) 第307行 loop {} 第5877行
退出判断 finishReason != "tool_calls" role == "exit" !needsFollowUp !needs_follow_up
工具执行 同步,逐个执行 同步,逐个执行 流式 + 并发执行 异步 + FuturesOrdered 并发
API 调用 同步,等全部返回 同步,等全部返回 流式,逐 token 返回 流式,event-driven
错误恢复 异常捕获 + 消息追加 prompt-too-long 自动压缩、max-tokens 自动扩展 hook 系统 + 重试
轮次限制 有,防止无限循环
行数 ~56行 ~100行 ~1400行 ~7600行

骨架一样,多出来的几千行都在处理真实世界的边界情况:流式响应、并发工具执行、错误恢复、轮次限制。


总结

  1. while (true):退出由模型决定,不由你决定
  2. 调用模型时传完整历史:模型无状态,每次都要重新发
  3. 追加 assistant 消息:模型需要看到自己上一轮说了什么
  4. finishReason 是唯一的退出信号
  5. tool_call_id 把请求和结果一一对应
  6. 工具函数永远不抛异常:把错误当字符串返回给模型

bash 作为唯一的工具,之所以够用,是因为它本身就是一个万能接口。先用它跑通循环,再按需拆分——这是每个 Agent 项目的自然演进路径。


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

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