You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
318 lines
12 KiB
318 lines
12 KiB
# s01: The Agent Loop (智能体循环)
|
|
|
|
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
|
|
> *"One loop & Bash is all you need"* -- 一个工具 + 一个循环 = 一个智能体。
|
|
>
|
|
> **Harness 层**: 循环 -- 模型与真实世界的第一道连接。
|
|
|
|
## 问题
|
|
|
|
语言模型能推理代码, 但碰不到真实世界 -- 不能读文件、跑测试、看报错。没有循环, 每次工具调用你都得手动把结果粘回去。你自己就是那个循环。
|
|
|
|
## 解决方案
|
|
|
|
```
|
|
+--------+ +-------+ +---------+
|
|
| User | ---> | LLM | ---> | Tool |
|
|
| prompt | | | | execute |
|
|
+--------+ +---+---+ +----+----+
|
|
^ |
|
|
| tool_result |
|
|
+----------------+
|
|
(ChatClient.call() 自动循环直到无工具调用)
|
|
```
|
|
|
|
一个 `call()` 调用控制整个流程。Spring AI 自动循环, 直到模型不再调用工具。
|
|
|
|
## 工作原理
|
|
|
|
### 1. 构建 ChatClient:注入模型 + 注册工具
|
|
|
|
通过 Spring Boot 自动配置注入 `ChatModel`,用 `ChatClient.builder()` 构建客户端,设置系统提示和工具。
|
|
|
|
```java
|
|
// TIP: Python 版在模块级创建 client = Anthropic() 和 MODEL。
|
|
// Spring AI 通过自动配置注入 ChatModel,再用 builder 构建 ChatClient。
|
|
public S01AgentLoop(ChatModel chatModel) {
|
|
this.chatClient = ChatClient.builder(chatModel)
|
|
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
|
|
+ ". Use bash to solve tasks. Act, don't explain.")
|
|
.defaultTools(new BashTool()) // @Tool 注解的工具对象
|
|
.build();
|
|
}
|
|
```
|
|
|
|
### 2. `@Tool` 注解:声明式工具注册
|
|
|
|
Spring AI 通过 `@Tool` 注解自动发现和注册工具。框架在启动时扫描 `defaultTools()` 传入的对象,提取所有 `@Tool` 方法的签名和描述,生成 LLM 需要的 tool schema(名称、参数、描述),然后在每次 `call()` 请求中自动携带。
|
|
|
|
```java
|
|
// BashTool —— 对应 Python 版的 run_bash() 函数
|
|
public class BashTool {
|
|
@Tool(description = "Run a shell command and return stdout + stderr")
|
|
public String bash(@ToolParam(description = "The shell command to execute")
|
|
String command) {
|
|
// 危险命令检查 + ProcessBuilder 执行 + 超时控制 + 输出截断
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
> 对比 Python 版的手动注册方式:
|
|
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
|
|
> - Java: 只需 `@Tool` + `@ToolParam` 注解,框架自动完成 schema 生成和方法分派
|
|
|
|
### 3. Spring AI 内部自动循环:`call()` 的底层实现
|
|
|
|
**这是理解 Java 版与 Python 版最关键的区别。** Python 版本需要手写 while 循环来驱动工具调用:
|
|
|
|
```python
|
|
# Python 版 —— 手动循环
|
|
def agent_loop(messages):
|
|
while True:
|
|
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
|
|
# 收集 assistant 消息
|
|
messages.append({"role": "assistant", "content": response.content})
|
|
if response.stop_reason != "tool_use":
|
|
return response # 模型不再调用工具,退出循环
|
|
# 执行工具并回传结果
|
|
for block in response.content:
|
|
if block.type == "tool_use":
|
|
result = TOOL_HANDLERS[block.name](block.input)
|
|
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
|
|
```
|
|
|
|
Spring AI 的 `ChatClient.call()` **内部封装了完全等价的逻辑**:
|
|
|
|
```
|
|
call() 内部流程:
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ 1. 组装请求: system prompt + user message + tools │
|
|
│ 2. 发送给 LLM │
|
|
│ 3. 解析响应 │
|
|
│ ├── 有 tool_use? ──→ 是: │
|
|
│ │ a. 提取工具名和参数 │
|
|
│ │ b. 通过反射调用对应的 @Tool 方法 │
|
|
│ │ c. 将 tool_result 追加到消息列表 │
|
|
│ │ d. 回到步骤 2(自动循环) │
|
|
│ └── 否 ──→ 返回最终文本 │
|
|
└─────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
关键点:
|
|
- **工具检测**: Spring AI 检查响应中是否有 `tool_use` 类型的 content block(对应 Python 的 `stop_reason == "tool_use"`)
|
|
- **反射分派**: 框架通过 Java 反射机制,根据 LLM 返回的工具名称找到对应的 `@Tool` 方法并调用(对应 Python 的 `TOOL_HANDLERS[block.name]`)
|
|
- **结果回传**: 工具执行结果自动包装为 `tool_result` 消息追加到对话(对应 Python 手动构造 `tool_result` content block)
|
|
- **循环终止**: 当模型返回纯文本(无工具调用)时,`call()` 返回最终结果
|
|
|
|
因此,Python 版约 15 行的 while 循环,在 Java 版中浓缩为一行 `.call()`。
|
|
|
|
### 4. `AgentRunner.interactive()`:REPL 交互循环
|
|
|
|
`AgentRunner` 是所有课程共用的交互式 REPL(Read-Eval-Print Loop)工具类,对应 Python 版 `if __name__ == "__main__"` 中的 `input()` 循环。
|
|
|
|
```java
|
|
public class AgentRunner {
|
|
/**
|
|
* 启动交互式 REPL 循环。
|
|
* @param prefix 提示符前缀(如 "s01")
|
|
* @param handler 处理用户输入并返回 Agent 响应的函数
|
|
*/
|
|
public static void interactive(String prefix, Function<String, String> handler) {
|
|
Scanner scanner = new Scanner(System.in);
|
|
System.out.println("输入 'q' 或 'exit' 退出");
|
|
while (true) {
|
|
System.out.print("\033[36m" + prefix + " >> \033[0m"); // 彩色提示符
|
|
String input;
|
|
try {
|
|
if (!scanner.hasNextLine()) break;
|
|
input = scanner.nextLine().trim();
|
|
} catch (Exception e) {
|
|
break;
|
|
}
|
|
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
|
break;
|
|
}
|
|
try {
|
|
String response = handler.apply(input); // 调用 Agent 处理
|
|
if (response != null && !response.isBlank()) {
|
|
System.out.println(response);
|
|
}
|
|
} catch (Exception e) {
|
|
System.err.println("Error: " + e.getMessage());
|
|
}
|
|
System.out.println();
|
|
}
|
|
System.out.println("Bye!");
|
|
}
|
|
}
|
|
```
|
|
|
|
工作流程:`Scanner` 读取输入 → `handler.apply()` 发给 Agent → 打印响应 → 循环。`handler` 是一个函数式接口,每个课程传入自己的 Agent 调用逻辑。
|
|
|
|
### 5. 组装为完整的 Agent 类
|
|
|
|
```java
|
|
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
public class S01AgentLoop implements CommandLineRunner {
|
|
|
|
private final ChatClient chatClient;
|
|
|
|
public S01AgentLoop(ChatModel chatModel) {
|
|
this.chatClient = ChatClient.builder(chatModel)
|
|
.defaultSystem("You are a coding agent at ...")
|
|
.defaultTools(new BashTool())
|
|
.build();
|
|
}
|
|
|
|
@Override
|
|
public void run(String... args) {
|
|
AgentRunner.interactive("s01", userMessage ->
|
|
chatClient.prompt()
|
|
.user(userMessage)
|
|
.call() // ← 这一个调用 = Python 的整个 while 循环
|
|
.content()
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
> **TIPS — Python → Java 关键适配点:**
|
|
> - Python 的 `while True` + `stop_reason` 手动循环 → Spring AI `ChatClient.call()` 内置自动循环
|
|
> - Python 的 `TOOLS` 数组 + `TOOL_HANDLERS` 字典 → `@Tool` 注解 + `defaultTools()` 自动注册与反射分派
|
|
> - Python 的 `client = Anthropic()` → Spring Boot 自动配置注入 `ChatModel`
|
|
> - Python 的 `input()` 交互 → `AgentRunner.interactive()` 封装 Scanner REPL + 函数式接口
|
|
|
|
不到 40 行核心代码, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
|
|
|
|
## 变更内容
|
|
|
|
| 组件 | 之前 | 之后 |
|
|
|---------------|------------|--------------------------------------------------|
|
|
| Agent loop | (无) | `ChatClient.call()` 内置工具循环 |
|
|
| Tools | (无) | `BashTool` (单一 `@Tool` 工具) |
|
|
| Messages | (无) | Spring AI 内部管理消息列表 |
|
|
| Control flow | (无) | 框架自动判断: 无工具调用时返回最终文本 |
|
|
|
|
```java
|
|
// 核心代码 —— 构建 + 调用
|
|
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
.defaultSystem("You are a coding agent ...")
|
|
.defaultTools(new BashTool())
|
|
.build();
|
|
|
|
AgentRunner.interactive("s01", userMessage ->
|
|
chatClient.prompt().user(userMessage).call().content()
|
|
);
|
|
```
|
|
|
|
## 试一试
|
|
|
|
```sh
|
|
cd learn-claude-code
|
|
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
|
```
|
|
|
|
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
>
|
|
> **当前默认使用 OpenAI 协议**(兼容所有 OpenAI API 格式的服务,包括 OpenAI 官方、Azure OpenAI、各类第三方大模型服务的 OpenAI 兼容接口等)。
|
|
> 如需使用 Anthropic 协议(Claude 系列模型原生接口),请展开下方「切换 AI 协议」。
|
|
|
|
<details>
|
|
<summary><strong>切换 AI 协议(OpenAI ↔ Anthropic)</strong></summary>
|
|
|
|
本项目通过 Spring AI 的 **Starter 依赖 + 配置文件** 来切换底层协议,Java 业务代码(`ChatModel`、`ChatClient`)**无需任何修改**。
|
|
|
|
#### 方式一:OpenAI 协议(默认)
|
|
|
|
`pom.xml` 依赖:
|
|
|
|
```xml
|
|
<dependency>
|
|
<groupId>org.springframework.ai</groupId>
|
|
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
</dependency>
|
|
```
|
|
|
|
`application.yml` 配置:
|
|
|
|
```yaml
|
|
spring:
|
|
ai:
|
|
openai:
|
|
api-key: ${AI_API_KEY:sk-xxx}
|
|
base-url: ${AI_BASE_URL:https://api.openai.com}
|
|
chat:
|
|
options:
|
|
model: ${AI_MODEL:gpt-4o}
|
|
```
|
|
|
|
环境变量示例(以 OpenAI 官方为例):
|
|
|
|
```sh
|
|
export AI_API_KEY=sk-proj-xxxxxxxx
|
|
export AI_BASE_URL=https://api.openai.com # 可替换为任何 OpenAI 兼容接口
|
|
export AI_MODEL=gpt-4o
|
|
```
|
|
|
|
> **TIP**: 许多第三方大模型服务(如 DeepSeek、Mistral、通义千问等)提供了 OpenAI 兼容接口,只需修改 `AI_BASE_URL` 和 `AI_MODEL` 即可接入,无需切换协议。
|
|
|
|
#### 方式二:Anthropic 协议(Claude 原生接口)
|
|
|
|
**第 1 步**:修改 `pom.xml`,将 OpenAI starter 替换为 Anthropic starter:
|
|
|
|
```xml
|
|
<!-- 注释或删除 OpenAI starter -->
|
|
<!-- <dependency>
|
|
<groupId>org.springframework.ai</groupId>
|
|
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
</dependency> -->
|
|
|
|
<!-- 添加 Anthropic starter -->
|
|
<dependency>
|
|
<groupId>org.springframework.ai</groupId>
|
|
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
|
</dependency>
|
|
```
|
|
|
|
**第 2 步**:修改 `application.yml`,将 `spring.ai.openai` 替换为 `spring.ai.anthropic`:
|
|
|
|
```yaml
|
|
spring:
|
|
ai:
|
|
anthropic:
|
|
api-key: ${AI_API_KEY}
|
|
base-url: ${AI_BASE_URL:https://api.anthropic.com}
|
|
chat:
|
|
options:
|
|
model: ${AI_MODEL:claude-sonnet-4-20250514}
|
|
```
|
|
|
|
**第 3 步**:设置环境变量:
|
|
|
|
```sh
|
|
export AI_API_KEY=sk-ant-xxxxxxxx
|
|
export AI_BASE_URL=https://api.anthropic.com
|
|
export AI_MODEL=claude-sonnet-4-20250514
|
|
```
|
|
|
|
#### 切换原理
|
|
|
|
Spring AI 的设计使得 `ChatModel` 是一个统一的抽象接口。不同的 Starter 提供不同的实现:
|
|
|
|
| Starter 依赖 | 自动注入的 ChatModel 实现 | 配置前缀 |
|
|
|---|---|---|
|
|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
|
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
|
|
|
业务代码始终面向 `ChatModel` 接口编程,切换协议只需替换依赖和配置,无需改动任何 Java 代码。
|
|
|
|
</details>
|
|
|
|
试试这些 prompt(英文 prompt 对 LLM 效果更好, 也可以用中文):
|
|
|
|
1. `Create a file called Hello.java that prints "Hello, World!"`
|
|
2. `List all Java files in this directory`
|
|
3. `What is the current git branch?`
|
|
4. `Create a directory called test_output and write 3 files in it`
|
|
|