大家好,我是二哥呀。
说实话,用 Claude Code、Qoder CLI 和 Codex 进行 AI编程也有一段时间了,我一直很好奇这玩意儿到底是怎么实现的。
它们看起来就是简单的命令行界面,但背后却能理解我的需求、调用各种工具、读写文件、执行命令,甚至能自己改代码。
这到底是什么原理?
今天这篇文章,我们就从零开始,用 Java 实现一个最简单的 Agent CLI。它能配置 GLM-5.1 的 API Key,能接收你的输入,有 Agent Loop,能 Tool Call,能根据上下文编程或输出内容。
一个超级简化版的 Claude Code,大概 400 行代码就能跑起来。
01、Agent 的核心原理
在动手写代码之前,先搞清楚 Agent 到底是什么东西。
Agent 的核心就三个东西:推理(Reasoning)、行动(Acting)、观察(Observing)。这三者循环起来,就是著名的 ReAct 模式。
流程是这样的:
- 你输入一个任务
- LLM 思考:我需要做什么?要不要调用工具?
- 如果需要工具,LLM 输出工具调用请求
- Agent 执行工具,拿到结果
- 把结果喂给 LLM,继续思考
- 重复 2-5,直到 LLM 觉得任务完成
- 输出最终结果
这个循环就是 Agent 的灵魂。Claude Code、Qoder CLI、OpenClaw,本质上都是这个循环的不同实现。
02、项目结构
我们用 Java 17 + Maven 来搭建项目,不依赖任何第三方 Agent 框架,从零手写。
paicli/
├── pom.xml
├── .env
└── src/main/java/com/paicli/
├── cli/Main.java # 入口类
├── agent/Agent.java # Agent 核心(ReAct 循环)
├── llm/GLMClient.java # GLM-5.1 API 客户端
└── tool/ToolRegistry.java # 工具注册表
Maven 依赖
我们需要这几个依赖:
- Jackson:处理 JSON
- OkHttp:发送 HTTP 请求
- SLF4J:简单日志
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
03、GLM-5.1 API 客户端
Agent 要能理解我们的提示词,得能调用大模型。所以我们需要先封装一个 GLMClient,支持普通对话和工具调用。
public class GLMClient {
private static final String API_URL =
"https://open.bigmodel.cn/api/paas/v4/chat/completions";
private static final String MODEL = "glm-5.1";
private final String apiKey;
private final OkHttpClient httpClient;
public GLMClient(String apiKey) {
this.apiKey = apiKey;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build();
}
}
核心是 chat 方法,它接收消息历史和一个工具列表,返回 LLM 响应。
消息格式
GLM-5.1 的 API 兼容 OpenAI 格式,消息有三种角色:
system:系统提示,定义 Agent 的身份和能力user:用户输入assistant:助手回复,可以包含文本或工具调用tool:工具执行结果
public record Message(String role, String content,
List<ToolCall> toolCalls, String toolCallId) {
public static Message system(String content) {
return new Message("system", content, null, null);
}
public static Message user(String content) {
return new Message("user", content, null, null);
}
public static Message assistant(String content) {
return new Message("assistant", content, null, null);
}
public static Message tool(String toolCallId, String content) {
return new Message("tool", content, null, toolCallId);
}
}
工具定义格式
要让 LLM 知道有哪些工具可用,需要按照特定格式定义工具:
public record Tool(String name, String description, JsonNode parameters) {}
parameters 是一个 JSON Schema,描述工具需要哪些参数、参数类型是什么。比如 write_file 工具的参数定义:
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径"
},
"content": {
"type": "string",
"description": "文件内容"
}
},
"required": ["path", "content"]
}
LLM 会根据这个定义,在需要时生成正确的参数。
完整的 chat 方法
public ChatResponse chat(List<Message> messages, List<Tool> tools)
throws IOException {
// 构建请求体
ObjectNode requestBody = mapper.createObjectNode();
requestBody.put("model", MODEL);
// 添加消息历史
ArrayNode messagesArray = requestBody.putArray("messages");
for (Message msg : messages) {
ObjectNode msgNode = messagesArray.addObject();
msgNode.put("role", msg.role());
msgNode.put("content", msg.content());
// 如果有工具调用,序列化 tool_calls
if (msg.toolCalls() != null && !msg.toolCalls().isEmpty()) {
ArrayNode toolCallsArray = msgNode.putArray("tool_calls");
for (ToolCall tc : msg.toolCalls()) {
ObjectNode tcNode = toolCallsArray.addObject();
tcNode.put("id", tc.id());
tcNode.put("type", "function");
ObjectNode functionNode = tcNode.putObject("function");
functionNode.put("name", tc.function().name());
functionNode.put("arguments", tc.function().arguments());
}
}
// 如果是工具结果,添加 tool_call_id
if (msg.toolCallId() != null) {
msgNode.put("tool_call_id", msg.toolCallId());
}
}
// 添加工具定义
if (tools != null && !tools.isEmpty()) {
ArrayNode toolsArray = requestBody.putArray("tools");
for (Tool tool : tools) {
ObjectNode toolNode = toolsArray.addObject();
toolNode.put("type", "function");
ObjectNode functionNode = toolNode.putObject("function");
functionNode.put("name", tool.name());
functionNode.put("description", tool.description());
functionNode.set("parameters", tool.parameters());
}
}
// 发送 HTTP 请求
RequestBody body = RequestBody.create(
requestBody.toString(),
MediaType.parse("application/json")
);
Request request = new Request.Builder()
.url(API_URL)
.header("Authorization", "Bearer " + apiKey)
.post(body)
.build();
// 解析响应
try (Response response = httpClient.newCall(request).execute()) {
String responseBody = response.body().string();
JsonNode root = mapper.readTree(responseBody);
// 提取消息内容、工具调用、token 使用等信息
// ...
}
}
这里有个关键点:工具调用。
当 LLM 决定调用工具时,它会返回一个 tool_calls 数组,包含工具名和参数。Agent 执行完工具后,要把结果以 tool 角色的消息返回给 LLM,这样 LLM 才能继续思考。
这个往返过程是 ReAct 模式的核心。
LLM 不直接执行任务,而是通过工具调用来“行动”,然后观察行动结果,再决定下一步。这种分离能让 LLM 专注于推理,工具专注于执行,各司其职。
为什么用 GLM-5.1
选择 GLM-5.1 有几个原因:
第一,工具调用支持好。GLM-5.1 对 Function Calling 的支持非常稳定,能准确理解工具定义,生成正确的参数。
第二,刚好我是max会员。哈哈哈。
当然,这个框架很容易扩展到其他模型。只要模型支持 OpenAI 格式的 API,改一下 API_URL 和 MODEL 就能用。
04、工具注册表
Agent 要能干实事,得有一套工具。我们实现几个最基础的工具:
read_file:读取文件write_file:写入文件list_dir:列出目录execute_command:执行 Shell 命令create_project:创建项目结构
public class ToolRegistry {
private final Map<String, Tool> tools = new HashMap<>();
public ToolRegistry() {
registerFileTools();
registerShellTools();
registerCodeTools();
}
}
工具的定义
每个工具包含四部分:名字、描述、参数定义、执行逻辑。
public record Tool(
String name,
String description,
JsonNode parameters,
ToolExecutor executor
) {}
public interface ToolExecutor {
String execute(Map<String, String> args);
}
描述和参数定义会传给 LLM,让 LLM 知道什么时候该用这个工具、需要什么参数。执行逻辑是实际的 Java 代码,负责完成任务。
文件操作工具
文件操作是最基础的能力。
read_file 让 Agent 能看代码,write_file 让 Agent 能写代码,list_dir 让 Agent 能浏览项目结构。
private void registerFileTools() {
// read_file 工具
tools.put("read_file", new Tool(
"read_file",
"读取文件内容,用于查看代码、配置文件等",
createParameters(new Param("path", "string", "文件路径", true)),
args -> {
String path = args.get("path");
try {
String content = Files.readString(Path.of(path));
return "文件内容:\n" + content;
} catch (Exception e) {
return "读取文件失败: " + e.getMessage();
}
}
));
}
注意工具描述要写清楚用途。LLM 根据描述来判断什么时候该调用这个工具。描述越清晰,LLM 的判断越准确。
Shell 命令工具
execute_command 是最强大的工具,让 Agent 能执行任意 Shell 命令。这也最危险,实际产品中需要加权限控制。
tools.put("execute_command", new Tool(
"execute_command",
"执行Shell命令,用于编译、运行、Git操作等",
createParameters(new Param("command", "string", "要执行的命令", true)),
args -> {
String command = args.get("command");
try {
ProcessBuilder pb = new ProcessBuilder("bash", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
// 读取命令输出
StringBuilder output = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
}
int exitCode = process.waitFor();
return String.format("命令执行完成 (exit code: %d)\n%s",
exitCode, output);
} catch (Exception e) {
return "执行命令失败: " + e.getMessage();
}
}
));
这里用 ProcessBuilder 执行命令,捕获标准输出和错误码。Agent 能根据错误码判断命令是否成功,根据输出决定下一步行动。
每个工具包含三部分:名字、描述、参数定义、执行逻辑。描述和参数定义会传给 LLM,让 LLM 知道什么时候该用这个工具。
比如 write_file 工具的定义:
tools.put("write_file", new Tool(
"write_file",
"写入文件内容",
createParameters(
new Param("path", "string", "文件路径", true),
new Param("content", "string", "文件内容", true)
),
args -> {
String path = args.get("path");
String content = args.get("content");
Files.writeString(Path.of(path), content);
return "文件已写入: " + path;
}
));
工具执行的结果会作为 tool 消息返回给 LLM,LLM 根据结果决定下一步行动。
参数定义的生成
工具参数用 JSON Schema 定义,我们写了个辅助方法 createParameters 来生成:
private JsonNode createParameters(Param... params) {
ObjectNode parameters = mapper.createObjectNode();
parameters.put("type", "object");
ObjectNode properties = parameters.putObject("properties");
ArrayNode required = parameters.putArray("required");
for (Param param : params) {
ObjectNode prop = properties.putObject(param.name());
prop.put("type", param.type());
prop.put("description", param.description());
if (param.required()) {
required.add(param.name());
}
}
return parameters;
}
这个方法接收可变参数,每个参数包含名字、类型、描述、是否必填。生成的 JSON Schema 符合 OpenAI 的函数调用规范,GLM-5.1 能正确解析。
工具的动态注册
现在的工具是硬编码的,实际可以做成动态注册。比如从配置文件加载、从插件系统加载、甚至让 Agent 自己定义工具。
动态注册的核心是统一的接口:
public interface ToolProvider {
List<Tool> getTools();
}
不同的实现可以从不同来源加载工具:
// 从 JSON 配置文件加载
public class JsonToolProvider implements ToolProvider {
private final String configPath;
@Override
public List<Tool> getTools() {
// 解析 JSON,创建 Tool 实例
}
}
// 从注解扫描加载
public class AnnotationToolProvider implements ToolProvider {
private final String packageName;
@Override
public List<Tool> getTools() {
// 扫描 @Tool 注解,创建 Tool 实例
}
}
这种设计让工具系统变得可扩展。Claude Code 的 Skills 系统本质上就是动态工具注册的一种实现。
05、Agent 核心:ReAct 循环
现在来到最核心的部分——Agent 类。它实现 ReAct 循环,协调 LLM 调用和工具执行。
public class Agent {
private final GLMClient llmClient;
private final ToolRegistry toolRegistry;
private final List<Message> conversationHistory;
private static final int MAX_ITERATIONS = 10;
public Agent(String apiKey) {
this.llmClient = new GLMClient(apiKey);
this.toolRegistry = new ToolRegistry();
this.conversationHistory = new ArrayList<>();
// 添加系统提示
conversationHistory.add(Message.system(SYSTEM_PROMPT));
}
}
系统提示词告诉 LLM 它是谁、能干什么、有哪些工具可用:
private static final String SYSTEM_PROMPT = """
你是一个智能编程助手,可以帮助用户完成各种任务。
你可以使用以下工具来完成任务:
1. read_file - 读取文件内容
2. write_file - 写入文件内容
3. list_dir - 列出目录内容
4. execute_command - 执行Shell命令
5. create_project - 创建新项目结构
当需要操作文件、执行命令或创建项目时,请使用工具调用。
使用工具后,根据工具返回的结果继续思考下一步行动。
请用中文回复用户。
""";
ReAct 循环的核心逻辑:
public String run(String userInput) {
// 添加用户输入
conversationHistory.add(Message.user(userInput));
int iteration = 0;
while (iteration < MAX_ITERATIONS) {
iteration++;
// 调用 LLM
ChatResponse response = llmClient.chat(
conversationHistory,
toolRegistry.getToolDefinitions()
);
// 如果有工具调用
if (response.hasToolCalls()) {
// 记录助手消息
conversationHistory.add(
Message.assistant(response.content(), response.toolCalls())
);
// 执行每个工具调用
for (ToolCall toolCall : response.toolCalls()) {
String result = toolRegistry.executeTool(
toolCall.function().name(),
toolCall.function().arguments()
);
// 记录工具结果
conversationHistory.add(
Message.tool(toolCall.id(), result)
);
}
// 继续循环,让 LLM 根据结果继续思考
continue;
} else {
// 没有工具调用,任务完成
conversationHistory.add(
Message.assistant(response.content())
);
return response.content();
}
}
return "达到最大迭代次数限制";
}
这个循环的关键点是:只要 LLM 还在请求工具调用,循环就继续。只有当 LLM 直接回复内容(没有 tool_calls)时,才认为任务完成。
为什么需要循环
你可能会问:为什么不能一次调用就完成?为什么需要循环?
因为复杂任务往往需要多步操作。比如“创建一个 Spring Boot 项目并写一个 HelloController”:
- 先调用
create_project创建项目结构 - 再调用
write_file写pom.xml - 再调用
write_file写主类 - 再调用
write_file写 Controller - 最后调用
execute_command编译验证
每一步都依赖前一步的结果。LLM 需要根据上一步的结果决定下一步做什么,这就需要循环。
防止无限循环
循环必须有终止条件,否则可能陷入死循环。我们设置了 MAX_ITERATIONS = 10,超过就强制退出。
实际产品中,还需要其他保护机制:
- 时间限制:单次任务不能超过 N 秒
- Token 限制:总 Token 消耗不能超过上限
- 用户确认:危险操作前询问用户
- 状态检测:检测循环是否陷入重复模式
上下文管理
对话历史 conversationHistory 是 Agent 的“记忆”。它包含完整的对话过程,让 LLM 能了解任务的上下文。
但上下文不能无限增长。当历史太长时,需要:
- 压缩:把早期对话压缩成摘要
- 截断:丢弃最旧的对话
- 向量化:把历史存入向量数据库,按需检索
Claude Code 的上下文管理非常复杂,能处理几十万字的代码库。我们的简化版用简单列表,但原理是一样的。
错误处理
实际运行中会有很多错误:网络超时、API 限流、工具执行失败等。需要完善的错误处理:
try {
ChatResponse response = llmClient.chat(conversationHistory, tools);
// ...
} catch (IOException e) {
// 网络错误,可以重试
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
return "网络错误: " + e.getMessage();
} catch (Exception e) {
// 其他错误,返回错误信息
return "执行错误: " + e.getMessage();
}
错误信息也要传给 LLM,让 LLM 知道发生了什么,可能的话给出修复建议。
06、交互式 CLI
最后,我们需要一个交互式命令行界面,让用户能输入指令、看到结果。
public class Main {
public static void main(String[] args) {
printBanner();
// 加载 API Key
String apiKey = loadApiKey();
if (apiKey == null || apiKey.isEmpty()) {
System.err.println("❌ 错误: 未找到 GLM_API_KEY");
System.exit(1);
}
// 创建 Agent
Agent agent = new Agent(apiKey);
// 交互式循环
Scanner scanner = new Scanner(System.in);
System.out.println("💡 提示: 输入 'clear' 清空历史, 'exit' 退出\n");
while (true) {
System.out.print("👤 你: ");
String input = scanner.nextLine().trim();
if (input.isEmpty()) continue;
if (input.equalsIgnoreCase("exit")) break;
if (input.equalsIgnoreCase("clear")) {
agent.clearHistory();
System.out.println("🗑️ 历史已清空\n");
continue;
}
// 运行 Agent
String response = agent.run(input);
System.out.println("🤖 Agent: " + response + "\n");
}
}
}
用户体验设计
CLI 虽然简单,但用户体验很重要。我们加了几个细节:
启动 Banner:让程序看起来更专业
private static void printBanner() {
System.out.println("""
╔══════════════════════════════════════════════════════════╗
║ ██████╗ █████╗ ██╗ ██████╗██╗ ██╗ ║
║ ██╔══██╗██╔══██╗██║ ██╔════╝██║ ██║ ║
║ ██████╔╝███████║██║ ██║ ██║ ██║ ║
║ ██╔═══╝ ██╔══██║██║ ██║ ██║ ██║ ║
║ ██║ ██║ ██║███████╗╚██████╗███████╗██║ ║
║ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝╚══════╝╚═╝ ║
║ 简单的 Java Agent CLI v1.0.0 ║
╚══════════════════════════════════════════════════════════╝
""");
}
Emoji 提示:让输出更易读
👤 你:表示用户输入🤖 Agent:表示 Agent 回复🔧 执行工具:表示正在调用工具📊 Token使用:显示资源消耗🗑️✅❌等表示状态
帮助信息:启动时显示可用命令
💡 提示:
- 输入你的问题或任务
- 输入 'clear' 清空对话历史
- 输入 'exit' 或 'quit' 退出
这些小细节让 CLI 用起来更舒服。
API Key 从 .env 文件或环境变量加载:
private static String loadApiKey() {
// 先尝试从当前目录读取 .env
File envFile = new File(".env");
if (envFile.exists()) {
return readApiKeyFromFile(envFile);
}
// 再尝试从环境变量读取
return System.getenv("GLM_API_KEY");
}
07、运行测试
编译打包:
mvn clean package
创建 .env 文件:
GLM_API_KEY=your_api_key_here
运行:
java -jar target/paicli-1.0-SNAPSHOT.jar
试试这些指令:
示例 1:创建项目
👤 你: 创建一个Java项目叫demo
🤔 思考中...
🔧 执行工具: create_project
参数: {"name":"demo","type":"java"}
结果: 项目已创建: demo (类型: java)
📊 Token使用: 输入=156, 输出=89
🤖 Agent: 已成功创建 Java 项目 "demo",包含基本的 Maven 结构。
Agent 识别出你的意图是创建项目,调用了 create_project 工具,传入项目名和类型。工具执行成功后,Agent 给你确认信息。
示例 2:多步任务
再来一个复杂点的:
👤 你: 在demo/src/main/java/com/example下创建一个Hello.java,
输出Hello World
🤔 思考中...
🔧 执行工具: write_file
参数: {
"path": "demo/src/main/java/com/example/Hello.java",
"content": "package com.example;\n\npublic class Hello {\n public static void main(String[] args) {\n System.out.println(\"Hello World\");\n }\n}"
}
结果: 文件已写入: demo/src/main/java/com/example/Hello.java
📊 Token使用: 输入=312, 输出=156
🤖 Agent: 已创建 Hello.java 文件,包含 main 方法输出 "Hello World"。
这个任务只需要一步,但 Agent 自动处理了路径和包名。你不需要告诉它具体怎么写,只需要描述需求。
示例 3:读取和修改
试试读取文件再修改:
👤 你: 读取 demo/src/main/java/com/example/Hello.java 的内容,
然后把输出改成 "Hello PaiCLI"
🤔 思考中...
🔧 执行工具: read_file
参数: {"path": "demo/src/main/java/com/example/Hello.java"}
结果: 文件内容:
package com.example;
public class Hello {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
🔧 执行工具: write_file
参数: {
"path": "demo/src/main/java/com/example/Hello.java",
"content": "package com.example;\n\npublic class Hello {\n public static void main(String[] args) {\n System.out.println(\"Hello PaiCLI\");\n }\n}"
}
结果: 文件已写入: demo/src/main/java/com/example/Hello.java
📊 Token使用: 输入=523, 输出=234
🤖 Agent: 已将输出从 "Hello World" 修改为 "Hello PaiCLI"。
这个任务有两步:先读取,再修改。Agent 自动规划了执行顺序,先调 read_file 获取内容,再调 write_file 写入修改后的内容。
示例 4:执行命令
试试执行 Shell 命令:
👤 你: 进入demo目录,编译并运行Hello.java
🤔 思考中...
🔧 执行工具: execute_command
参数: {"command": "cd demo && javac src/main/java/com/example/Hello.java"}
结果: 命令执行完成 (exit code: 0)
🔧 执行工具: execute_command
参数: {"command": "cd demo && java -cp src/main/java com.example.Hello"}
结果: 命令执行完成 (exit code: 0)
Hello PaiCLI
📊 Token使用: 输入=678, 输出=312
🤖 Agent: 编译成功,运行输出: Hello PaiCLI
Agent 自动分解任务:先编译,再运行。并且能根据命令输出判断执行结果。
08、深入理解 ReAct 模式
我们的 PaiCLI 实现了最基础的 ReAct 循环,但 ReAct 本身有很多变种和优化。理解这些,才能做出更强大的 Agent。
ReAct 的三种模式
基础 ReAct:我们实现的就是这种模式。LLM 每一步都输出 Thought + Action,然后执行 Action,观察结果,继续循环。
Thought: 用户要我创建一个 Java 项目,我需要用 create_project 工具
Action: create_project({"name": "demo", "type": "java"})
Observation: 项目已创建
Plan-and-Execute:先让 LLM 制定完整计划,然后按步骤执行。适合复杂多步任务。
Plan:
1. 创建项目结构
2. 写 pom.xml
3. 写主类
4. 写测试类
然后按顺序执行每一步,不再反复询问 LLM。
这种模式效率更高,因为减少了 LLM 调用次数。但缺点是灵活性差,如果某一步出错,需要重新制定计划。
Reflexion:在 ReAct 基础上增加了自我反思。Agent 会回顾自己的行动,总结成功经验和失败教训,保存到记忆中。下次遇到类似任务,就能做得更好。
Thought: 刚才创建项目时我没有检查目录是否存在,导致可能覆盖已有文件。下次应该先 list_dir 检查。
这种模式让 Agent 具备学习能力,越用越聪明。
Tool Use 的进化
我们的工具调用是同步的:调用工具,等待结果,继续下一步。但现实中很多工具是异步的。
异步工具调用:比如编译一个大型项目可能需要几分钟,Agent 不应该干等着。可以提交任务后去做其他事,等结果回来再继续。
// 提交异步任务
String taskId = tool.executeAsync(args);
// 做其他事...
// 轮询检查结果
while (!tool.isComplete(taskId)) {
Thread.sleep(1000);
}
String result = tool.getResult(taskId);
并行工具调用:有些任务可以并行执行。比如同时读取多个文件、同时执行多个测试。
List<CompletableFuture<String>> futures = toolCalls.stream()
.map(tc -> CompletableFuture.supplyAsync(
() -> executeTool(tc)
))
.toList();
// 等待所有工具执行完成
List<String> results = futures.stream()
.map(CompletableFuture::join)
.toList();
LLM 可以同时请求多个工具调用,Agent 并行执行,最后把结果一起返回。这能大幅提升效率。
上下文工程的进阶
我们的上下文管理很简单,就是消息列表。但真正的 Agent 需要更复杂的上下文工程。
分层上下文:把上下文分成不同层次:
- 系统层:Agent 的身份、能力、约束
- 会话层:当前对话的历史
- 任务层:当前任务的相关信息
- 工具层:工具的定义和示例
不同层次有不同的更新频率和保留策略。系统层几乎不变,任务层经常更新。
向量化检索:当上下文太长时,可以把它存入向量数据库。需要时根据查询检索相关片段。
// 存入向量库
vectorStore.add("文件:Hello.java", embedding(content));
// 检索相关上下文
List<String> relevant = vectorStore.search(
embedding("Hello 类在哪里定义?"),
topK: 3
);
这让 Agent 能处理海量信息,比如整个代码库。
结构化输出:强制 LLM 按特定格式输出,方便解析。
{
"thought": "我需要创建项目",
"action": {
"tool": "create_project",
"args": {"name": "demo", "type": "java"}
},
"confidence": 0.95
}
用 JSON Schema 约束输出,比解析自然语言更可靠。
安全与沙箱
我们的 Agent 直接执行 Shell 命令,这在生产环境非常危险。真正的 Agent 需要安全机制。
权限控制:不同操作需要不同权限级别。
- Level 1: 只读操作(read_file, list_dir)
- Level 2: 写操作(write_file)
- Level 3: 执行操作(execute_command)
用户可以为不同任务设置权限级别,防止误操作。
沙箱执行:在隔离环境中执行危险操作。
// 在 Docker 容器中执行命令
ProcessBuilder pb = new ProcessBuilder(
"docker", "run", "--rm", "-v", workDir + ":/work",
"sandbox-image", "bash", "-c", command
);
即使命令有问题,也不会影响宿主机。
人工确认:危险操作前询问用户。
🤖 Agent: 我要执行命令: rm -rf /,确认吗? [y/N]
👤 你: n
🤖 Agent: 操作已取消
Claude Code 就有这种机制,执行 rm 等危险命令前会要求确认。
从 PaiCLI 到生产级 Agent
我们的 400 行代码是个起点,要变成生产级产品,还需要:
可观测性:记录完整执行日志,方便调试和审计。
logger.info("[Agent] 开始任务: {}", task);
logger.info("[Agent] 调用工具: {} 参数: {}", toolName, args);
logger.info("[Agent] 工具结果: {}", result);
持久化:对话历史和执行状态保存到数据库,支持断点续传。
多用户:支持多个用户同时使用,会话隔离。
插件系统:动态加载工具,用户可以自己扩展。
Web 界面:除了 CLI,还要提供 Web UI,更友好的交互。
这些都是 PaiCLI 正在做的方向。从简单的 CLI 到完整的产品,需要大量的工程工作,但核心原理不变:ReAct 循环 + 工具调用 + 上下文管理。
理解了这个核心,再看任何 Agent 框架,都能快速上手。因为万变不离其宗,都是在这些基础之上的扩展和优化。
09、扩展思路
这个基础框架可以往很多方向扩展:
增加工具:可以接入更多 API,比如搜索、数据库、Git 操作等。
改进上下文:现在的上下文是简单列表,可以实现更智能的上下文压缩和召回。
多 Agent 协作:可以设计多个 Agent,每个负责不同领域,通过消息协作。
图形界面:可以用 JavaFX 或 Swing 做个简单 GUI,体验会更好。
ending
从零实现一个 Agent CLI,最核心的收获是理解 ReAct 模式。
推理、行动、观察,这三步循环起来,Agent 就能完成复杂任务。LLM 负责思考,工具负责执行,Agent 框架负责协调。
我们写的这 400 行代码,虽然简单,但包含了 Agent 的所有核心要素:
- LLM 调用
- 工具注册和执行
- ReAct 循环
- 上下文管理
【理解原理比会用工具更重要。】
当你明白 Agent 是怎么工作的,再用 Claude Code、Qoder CLI、OpenClaw 时,就能更好地理解它们的行为,也能更好地控制它们。
代码已开源在 GitHub:github.com/itwanger/paicli,欢迎 Star 和 PR。如果有问题,也可以在评论区交流,我会尽量回复。
希望这篇文章对你有帮助,我们下期见。
回复