逐行解读 Agent Loop:为什么只需要一个 bash 工具
上一篇我们说 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。是,就继续干活;不是,就退出。
我们没有区分 stop 和 length。一个更健壮的实现应该在 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 是一个列表。模型可以在一次回复中请求调用多个工具。比如它可能同时想运行 ls 和 cat 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 做的是精确字符串替换。它能保证只修改目标行,不影响其他内容。而 sed 或 echo 写文件容易出错——转义字符、换行符、引号嵌套,每一个都是坑。
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行 |
骨架一样,多出来的几千行都在处理真实世界的边界情况:流式响应、并发工具执行、错误恢复、轮次限制。
总结
while (true):退出由模型决定,不由你决定- 调用模型时传完整历史:模型无状态,每次都要重新发
- 追加 assistant 消息:模型需要看到自己上一轮说了什么
finishReason是唯一的退出信号tool_call_id把请求和结果一一对应- 工具函数永远不抛异常:把错误当字符串返回给模型
bash 作为唯一的工具,之所以够用,是因为它本身就是一个万能接口。先用它跑通循环,再按需拆分——这是每个 Agent 项目的自然演进路径。