
1. 从一次“模型只会说不会做”的尴尬说起AgentLoop 到底是什么你可能遇到过这种场景让模型帮你看看当前目录有哪些 Java 文件它一本正经地回你“你可以运行ls *.java来查看”然后就没有然后了。它能推理、能写代码但它碰不到你的文件系统读不了日志跑不了测试。模型和真实世界之间缺一根线。这根线就是 AgentLoop智能体循环。用一句话概括模型负责决定“下一步做什么”代码负责真的去做并把结果塞回给模型如此往复直到模型说“我不需要再调工具了”。在 learn-claude-code 这个系列里S01 就是把这根线接起来的最小实现后面 S02 到 S12 的所有花活——多工具、任务规划、子 Agent、技能加载——全都是在这个循环上叠buff。为什么用 Java 来学这件事因为 Python 原版虽然短但动态类型把很多“消息结构长什么样”“工具调用怎么解析”的细节藏起来了。Java 的强类型反而逼你把ChatCompletionMessageParam、ChatCompletionTool、toolCallId这些概念一个个摆清楚。等你用 Java 把这条链路走通一遍再回头看任何 Agent 框架都会觉得“哦原来底层就是这么个 while 循环”。这篇面向的是用 Java 学 Agent 原理的开发者。我会带你从零搭一个最小闭环注册一个 bash 工具让模型输出工具调用Java 侧执行真实 shell 命令再把结果回传。跑通之后你就能亲眼看到模型“伸手”碰到真实世界的那一瞬间。核心检索词先摆在这learn-claude-code 的 S01 AgentLoop是理解 Agent 循环、Java 实现模型与真实世界第一道连接的最佳起点。适合谁写过 Java、用过 Maven、对 HTTP 和 JSON 不陌生但没亲手实现过 Agent 循环的人。如果你已经能背出 ReAct 论文的流程却从没写过一行工具调用代码这篇就是给你补上“手感”的。先说清楚一个容易混淆的点AgentLoop 不是“模型自己会循环”。模型是无状态的每次请求你都得把完整历史发过去。循环是你的代码在跑模型只是每次被问“现在该干嘛”时给个答复。理解这一点后面所有代码就顺了。2. 前置准备TaoToken 接入与 openai-java 依赖配置在写循环之前得先让 Java 能调通模型。这里我用 TaoToken 作为模型接入层它兼容 OpenAI 的接口协议所以可以直接用openai-java这个 SDK不用自己手搓 HTTP。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。第一步去控制台创建一个 API Key。地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 登录后在 API Keys 页面新建一个复制出来先存好。这个 Key 就是你 Java 程序里的apiKey别硬编码进代码提交到 Git用环境变量或者本地配置文件。第二步在pom.xml里加依赖。openai-java 负责消息类型和请求构建fastjson 用来解析工具调用的 arguments 字符串dependencies dependency groupIdcom.openai/groupId artifactIdopenai-java/artifactId version4.29.1/version /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version2.0.32/version /dependency /dependencies第三步写一个Commons工具类把 client 初始化、工作目录、文本提取这些杂活收拢。Base URL 指向 TaoToken 的 API 地址Key 从环境变量读public class Commons { public static final String CWD System.getProperty(user.dir); private static final OpenAIClient CLIENT OpenAIOkHttpClient.builder() .apiKey(System.getenv(TAOTOKEN_API_KEY)) .baseUrl(https://taotoken.net/api) .build(); public static OpenAIClient getClient() { return CLIENT; } public static String getText(ChatCompletionMessageParam param) { // 从消息参数里提取纯文本用于最终打印 return param.content().map(Object::toString).orElse(); } }这里有个细节值得说baseUrl一定要写全https://taotoken.net/api不要自己加/v1之类的后缀SDK 内部会拼路径。我见过有人写成https://taotoken.net/api/v1结果请求 404排查半天。模型 ID 我用的是qwen3.5-plus你在 TaoToken 的模型列表里挑一个支持 function calling 的就行工具调用能力是硬门槛不支持 tool calls 的模型这个循环根本跑不起来。环境变量设置好之后可以先写个最小测试只发一条 user 消息不带工具看能不能拿到正常回复。这一步过了说明 Key、Base URL、网络都通了再往上叠工具调用才不会把问题混在一起。排障的时候最怕“不知道是网络问题还是代码问题”分层验证能省你很多时间。3. 可复制的 AgentLoop 骨架消息结构、工具注册与循环控制现在进入正题。整个 AgentLoop 的骨架其实就三块消息列表怎么维护、工具怎么注册、循环什么时候停。我把它拆成能直接复制的代码。先看消息列表。OpenAI 协议里消息分四种角色system、user、assistant、tool。system 放系统提示user 是用户输入assistant 是模型回复可能带 tool_callstool 是工具执行结果。每次调模型前要把 system 拼在最前面后面接完整历史private static final String SYSTEM 你当前工作目录为 Commons.CWD 作为编程智能体使用 Bash 完成任务直接执行、无需解释; public static void agentLoop(ListChatCompletionMessageParam messages) { while (true) { ListChatCompletionMessageParam fullMessages new ArrayList(); fullMessages.add(ChatCompletionMessageParam.ofSystem( ChatCompletionSystemMessageParam.builder().content(SYSTEM).build())); fullMessages.addAll(messages); ChatCompletionCreateParams params ChatCompletionCreateParams.builder() .model(qwen3.5-plus) .messages(fullMessages) .tools(List.of(Tools.bashTool())) .build(); ChatCompletion chatCompletion Commons.getClient() .chat().completions().create(params); ChatCompletionMessage message chatCompletion.choices().get(0).message(); messages.add(ChatCompletionMessageParam.ofAssistant(message.toParam())); OptionalListChatCompletionMessageToolCall toolCalls message.toolCalls(); if (toolCalls.isEmpty()) { return; // 模型不再调工具循环结束 } for (ChatCompletionMessageToolCall toolCall : toolCalls.get()) { ChatCompletionMessageParam toolMessage Tools.exe(TOOL_HANDLERS, toolCall); if (toolMessage ! null) { messages.add(toolMessage); } } } }循环的退出条件就一个message.toolCalls().isEmpty()。模型如果这轮没要求调工具说明它认为任务完成了直接 return。这个判断比 Python 原版的stop_reason ! tool_use更直观因为 Java SDK 把 tool_calls 单独抽成了 Optional。工具注册用一个 Mapkey 是工具名value 是执行函数private static final MapString, FunctionString, String TOOL_HANDLERS new HashMap(); static { TOOL_HANDLERS.put(bash, Tools::runBash); }工具定义部分用ChatCompletionTool描述参数 schema。这里最容易踩的坑是参数结构写错导致模型不知道该怎么传参public static ChatCompletionTool bashTool() { MapString, JsonValue paramMap new HashMap(); paramMap.put(type, JsonValue.from(object)); MapString, JsonValue commandProp new HashMap(); commandProp.put(type, JsonValue.from(string)); commandProp.put(description, JsonValue.from(要执行的shell命令)); paramMap.put(properties, JsonValue.from(Map.of(command, JsonValue.from(commandProp)))); paramMap.put(required, JsonValue.from(List.of(command))); FunctionParameters parameters FunctionParameters.builder() .putAllAdditionalProperties(paramMap) .build(); return ChatCompletionTool.ofFunction(ChatCompletionFunctionTool.builder() .function(FunctionDefinition.builder() .name(bash) .description(在当前工作区中运行 shell 命令) .parameters(parameters) .build()) .build()); }工具执行的分发逻辑在Tools.exe里。它从 toolCall 里取出函数名和 arguments查表找到 handler执行后把结果包成 tool 消息并且必须带上 toolCallId——这是模型把结果和请求对应起来的唯一凭据漏了它模型会报错或者答非所问public static ChatCompletionMessageParam exe( MapString, FunctionString, String handlers, ChatCompletionMessageToolCall toolCall) { if (toolCall null || !toolCall.isFunction()) { return null; } ChatCompletionMessageFunctionToolCall functionCall toolCall.asFunction(); String functionName functionCall.function().name(); String arguments functionCall.function().arguments(); FunctionString, String handler handlers.get(functionName); if (handler null) { return ChatCompletionMessageParam.ofTool(ChatCompletionToolMessageParam.builder() .content(String.format(未知的工具%s, functionName)) .toolCallId(functionCall.id()) .build()); } return ChatCompletionMessageParam.ofTool(ChatCompletionToolMessageParam.builder() .content(handler.apply(arguments)) .toolCallId(functionCall.id()) .build()); }最后是runBash它接收的是模型给的 arguments 字符串形如{command:ls}用 fastjson 解析出真正的命令做危险命令拦截再执行private static final SetString DANGEROUS new HashSet(List.of(rm -rf /, sudo, shutdown, reboot)); public static String runBash(String arguments) { JSONObject obj JSON.parseObject(arguments); String command obj.getString(command); if (DANGEROUS.contains(command.strip())) { return 错误危险命令被阻止; } try { Process process new ProcessBuilder(bash, -c, command) .directory(new File(Commons.CWD)) .redirectErrorStream(true) .start(); String output new String(process.getInputStream().readAllBytes()); process.waitFor(30, TimeUnit.SECONDS); return output.isEmpty() ? (命令执行完成无输出) : output; } catch (Exception e) { return 执行失败 e.getMessage(); } }这套骨架里TOOL_HANDLERS是唯一需要你扩展的地方。想加新工具就往 Map 里 put 一个再写个对应的xxxTool()定义。循环本身一行都不用改——这就是 S01 想传达的“One loop Bash is all you need”。4. 验证一次 Bash 往返从用户输入到真实命令执行代码写完了怎么确认它真的跑通了别急着上复杂任务先用一条最简单的命令验证整条链路。我建议的验证顺序是先确认模型会发起工具调用再确认 Java 侧真的执行了命令最后确认结果回传后模型能给出总结。入口 main 方法长这样public static void main(String[] args) { Scanner scanner new Scanner(System.in); ListChatCompletionMessageParam messages new ArrayList(); while (true) { System.out.print(请输入 ); String query scanner.nextLine(); if (List.of(q, exit, ).contains(query.strip())) { break; } messages.add(ChatCompletionMessageParam.ofUser( ChatCompletionUserMessageParam.builder().content(query).build())); agentLoop(messages); System.out.println( Commons.getText(messages.get(messages.size() - 1))); } }启动程序输入第一条测试指令列出当前目录下所有 Java 文件。预期你会看到这样的过程程序先卡一下在调模型然后模型返回一个 tool_callJava 执行ls *.java或者find . -name *.java把输出塞回消息列表再次调模型模型基于真实输出给你一段总结。最终打印出来的后面应该是你项目里真实的 Java 文件名而不是模型编的。如果这一步成功说明整条链路通了。接着测第二条当前 Git 分支是什么。这条会触发git branch --show-current或类似命令。注意观察模型选的命令——不同模型可能选不同的写法但只要结果对就行。这也是 Agent 有意思的地方你只给了它一个 bash 工具它自己决定用什么命令。再测一条有副作用的创建名为 test_output 的文件夹并在其中新建 3 个文件。这条会触发mkdir和touch。跑完之后你去项目目录下看test_output文件夹和三个文件应该真实存在。这是最有说服力的一步——模型不只是“说”它创建了而是真的在你的磁盘上创建了。这就是“模型与真实世界的第一道连接”的字面含义。验证过程中有个细节每次工具执行完消息列表里会多出 assistant 消息带 tool_calls和 tool 消息带结果。你可以临时加一行打印messages.size()会看到它一轮轮增长。这能帮你直观理解“循环是靠历史消息累积驱动的”。如果模型一直不调工具只是用文字回复“你可以运行 xxx”通常是两个原因一是模型本身 function calling 能力弱换个模型二是工具定义里的 description 写得太模糊模型没意识到该用它。把 description 改成“当需要查看文件、执行命令、检查环境时必须调用此工具”往往就灵了。5. 常见报错排查401、toolCallId 缺失与循环不退出跑这个最小闭环新手最容易撞的几个坑我列一下对照着排。401 Unauthorized。这个基本是 Key 的问题。先确认环境变量TAOTOKEN_API_KEY真的被读到了可以在Commons初始化时打印一下 Key 的前几位别打全。如果 Key 没问题检查baseUrl是不是写成了https://taotoken.net/api多一个斜杠或者少一个/api都会 401 或 404。还有一种情况Key 创建后没复制全尾部少了几个字符这种最隐蔽。报错里出现reading choices或者choices is empty。这通常意味着请求发出去了但响应体不是预期的结构。常见原因是模型 ID 写错了或者用了一个不支持 chat completions 的模型。去 TaoToken 的模型列表确认qwen3.5-plus这个名字拼写正确。另外如果响应被网关拦截返回了 HTMLSDK 解析 JSON 时也会抛类似错误这时候打印原始响应体能快速定位。模型报错说 tool 消息缺少 tool_call_id。这是Tools.exe里最容易漏的一行。每个 tool 消息必须和它对应的 tool_call 用同一个 id 关联。如果你在循环里手动构造 tool 消息却忘了.toolCallId(functionCall.id())模型下一轮就会拒绝。对照第 3 节的exe方法检查。循环不退出一直调工具。理论上模型不调工具就退出了但有些模型会陷入“反复确认”的怪圈比如一直ls同一个目录。这时候需要加一个最大轮数保护int maxTurns 10; while (maxTurns-- 0) { // ... 循环体 }超过轮数就强制退出并打印提示。生产环境里这个保护是必须的不然一个抽风的模型能把你的 API 额度烧光。命令执行卡死。process.waitFor()不带超时的话遇到tail -f这种命令会永久阻塞。我在runBash里加了waitFor(30, TimeUnit.SECONDS)超时就返回。更稳妥的做法是超时后process.destroyForcibly()把子进程杀掉。危险命令拦截太死板。第 3 节的黑名单是精确匹配rm -rf /能拦但rm -rf /*就漏了。S01 只是演示真实场景要做更细的解析比如禁止任何以rm -rf开头、或者包含sudo的命令。这部分 S02 会展开讲路径沙箱S01 先有个意识就行。排障的通用思路是把“模型调用”和“工具执行”分开验证。先注释掉工具执行只打印模型返回的 tool_calls确认模型侧没问题再手动构造一个 toolCall 调Tools.exe确认执行侧没问题。两边都单独通了合起来基本不会错。6. 把循环跑通之后从 S01 到后续机制的延伸跑通 S01 之后你手里就有了一个能用的最小 Agent。它的全部能力就是一个 bash 工具加一个 while 循环但已经能完成不少真实任务查文件、跑测试、看 Git 状态、创建目录。这就是“模型决定何时调用工具代码负责执行并返回结果”的完整闭环。接下来往哪走learn-claude-code 系列的后续章节都是在这个循环上做加法。S02 加更多工具把 bash 之外的读写文件、搜索等能力注册进TOOL_HANDLERSS03 引入任务规划让模型先列 TodoWrite 再执行S04 做子 Agent 隔离避免上下文污染S05 加载技能。你会发现循环本身几乎不变变的是工具集和消息的组织方式。这也是为什么 S01 值得反复读——它是地基。如果你想把模型换成更适合长期编码任务的可以在 TaoToken 的 Coding Plan 里看看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 针对 Agent 场景有专门的配置建议。想直接在线试模型对话效果的模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。API Key 管理还是那个 console 地址接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 遇到 SDK 用法问题可以先翻文档。最后留一个我自己的习惯每次给 Agent 加新工具先用一条“必然触发该工具”的指令验证往返再测边界情况。比如加了文件读取工具先让它读一个确定存在的文件确认能拿到内容再测读不存在的文件时错误信息怎么回传。这样出问题时你能立刻定位是工具定义、参数解析还是结果封装哪一环。S01 的 bash 工具就是最好的练手对象把它调透后面加什么工具都是同一套肌肉记忆。