chore: Spring AI 重构
This commit is contained in:
+264
-62
@@ -20,97 +20,299 @@ A language model can reason about code, but it can't *touch* the real world -- c
|
||||
^ |
|
||||
| tool_result |
|
||||
+----------------+
|
||||
(loop until stop_reason != "tool_use")
|
||||
(ChatClient.call() auto-loops until no tool calls)
|
||||
```
|
||||
|
||||
One exit condition controls the entire flow. The loop runs until the model stops calling tools.
|
||||
A single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User prompt becomes the first message.
|
||||
### 1. Build ChatClient: Inject Model + Register Tools
|
||||
|
||||
```python
|
||||
messages.append({"role": "user", "content": query})
|
||||
Inject `ChatModel` via Spring Boot auto-configuration, build the client with `ChatClient.builder()`, set the system prompt and tools.
|
||||
|
||||
```java
|
||||
// TIP: The Python version creates client = Anthropic() and MODEL at module level.
|
||||
// Spring AI injects ChatModel via auto-configuration, then builds ChatClient with builder.
|
||||
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 object with @Tool annotation
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
2. Send messages + tool definitions to the LLM.
|
||||
### 2. `@Tool` Annotation: Declarative Tool Registration
|
||||
|
||||
```python
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
Spring AI automatically discovers and registers tools via the `@Tool` annotation. At startup, the framework scans objects passed to `defaultTools()`, extracts all `@Tool` method signatures and descriptions, generates the tool schema the LLM needs (name, parameters, description), and automatically includes it in every `call()` request.
|
||||
|
||||
```java
|
||||
// BashTool -- corresponds to the Python version's run_bash() function
|
||||
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) {
|
||||
// Dangerous command check + ProcessBuilder execution + timeout control + output truncation
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done.
|
||||
> Comparison with Python's manual registration:
|
||||
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
|
||||
> - Java: Just `@Tool` + `@ToolParam` annotations; the framework auto-generates schemas and dispatches methods
|
||||
|
||||
### 3. Spring AI Internal Auto-Loop: How `call()` Works Under the Hood
|
||||
|
||||
**This is the most critical difference between the Java and Python versions.** The Python version requires a hand-written while loop to drive tool calls:
|
||||
|
||||
```python
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
```
|
||||
|
||||
4. Execute each tool call, collect results, append as a user message. Loop back to step 2.
|
||||
|
||||
```python
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = run_bash(block.input["command"])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output,
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
```
|
||||
|
||||
Assembled into one function:
|
||||
|
||||
```python
|
||||
def agent_loop(query):
|
||||
messages = [{"role": "user", "content": query}]
|
||||
# Python version -- manual loop
|
||||
def agent_loop(messages):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
|
||||
# Collect assistant message
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
|
||||
results = []
|
||||
return response # Model no longer calling tools, exit loop
|
||||
# Execute tools and feed back results
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = run_bash(block.input["command"])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output,
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
result = TOOL_HANDLERS[block.name](block.input)
|
||||
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
|
||||
```
|
||||
|
||||
That's the entire agent in under 30 lines. Everything else in this course layers on top -- without changing the loop.
|
||||
Spring AI's `ChatClient.call()` **encapsulates fully equivalent logic internally**:
|
||||
|
||||
```
|
||||
call() internal flow:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 1. Assemble request: system prompt + user msg + tools │
|
||||
│ 2. Send to LLM │
|
||||
│ 3. Parse response │
|
||||
│ ├── Has tool_use? ──→ Yes: │
|
||||
│ │ a. Extract tool name and arguments │
|
||||
│ │ b. Invoke corresponding @Tool method via reflection │
|
||||
│ │ c. Append tool_result to message list │
|
||||
│ │ d. Go back to step 2 (auto-loop) │
|
||||
│ └── No ──→ Return final text │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Key points:
|
||||
- **Tool detection**: Spring AI checks if the response contains `tool_use` content blocks (equivalent to Python's `stop_reason == "tool_use"`)
|
||||
- **Reflection dispatch**: The framework uses Java reflection to find and invoke the `@Tool` method matching the tool name returned by the LLM (equivalent to Python's `TOOL_HANDLERS[block.name]`)
|
||||
- **Result feedback**: Tool execution results are automatically wrapped as `tool_result` messages and appended to the conversation (equivalent to Python's manual `tool_result` content block construction)
|
||||
- **Loop termination**: When the model returns pure text (no tool calls), `call()` returns the final result
|
||||
|
||||
Thus, Python's ~15-line while loop is condensed into a single `.call()` in Java.
|
||||
|
||||
### 4. `AgentRunner.interactive()`: The REPL Interaction Loop
|
||||
|
||||
`AgentRunner` is a shared REPL (Read-Eval-Print Loop) utility class used across all lessons, corresponding to the `input()` loop in Python's `if __name__ == "__main__"` block.
|
||||
|
||||
```java
|
||||
public class AgentRunner {
|
||||
/**
|
||||
* Start an interactive REPL loop.
|
||||
* @param prefix Prompt prefix (e.g., "s01")
|
||||
* @param handler Function that processes user input and returns Agent response
|
||||
*/
|
||||
public static void interactive(String prefix, Function<String, String> handler) {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
System.out.println("Type 'q' or 'exit' to quit");
|
||||
while (true) {
|
||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // Colored prompt
|
||||
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); // Call Agent handler
|
||||
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!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Workflow: `Scanner` reads input → `handler.apply()` sends to Agent → print response → loop. The `handler` is a functional interface; each lesson passes in its own Agent invocation logic.
|
||||
|
||||
### 5. Assembled into a Complete Agent Class
|
||||
|
||||
```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() // ← This single call = Python's entire while loop
|
||||
.content()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **TIPS — Key Python → Java Adaptations:**
|
||||
> - Python's `while True` + `stop_reason` manual loop → Spring AI `ChatClient.call()` built-in auto-loop
|
||||
> - Python's `TOOLS` array + `TOOL_HANDLERS` dict → `@Tool` annotation + `defaultTools()` auto-registration with reflection dispatch
|
||||
> - Python's `client = Anthropic()` → Spring Boot auto-configured `ChatModel` injection
|
||||
> - Python's `input()` interaction → `AgentRunner.interactive()` wrapping Scanner REPL + functional interface
|
||||
|
||||
Under 40 lines of core code, and that's the entire agent. The next 11 chapters all layer mechanisms on top of this loop -- the loop itself never changes.
|
||||
|
||||
## What Changed
|
||||
|
||||
| Component | Before | After |
|
||||
|---------------|------------|--------------------------------|
|
||||
| Agent loop | (none) | `while True` + stop_reason |
|
||||
| Tools | (none) | `bash` (one tool) |
|
||||
| Messages | (none) | Accumulating list |
|
||||
| Control flow | (none) | `stop_reason != "tool_use"` |
|
||||
| Component | Before | After |
|
||||
|---------------|------------|-------------------------------------------------|
|
||||
| Agent loop | (none) | `ChatClient.call()` built-in tool loop |
|
||||
| Tools | (none) | `BashTool` (single `@Tool` tool) |
|
||||
| Messages | (none) | Managed internally by Spring AI |
|
||||
| Control flow | (none) | Framework auto-detects: returns final text when no tool calls |
|
||||
|
||||
```java
|
||||
// Core code -- build + call
|
||||
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()
|
||||
);
|
||||
```
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s01_agent_loop.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
||||
```
|
||||
|
||||
1. `Create a file called hello.py that prints "Hello, World!"`
|
||||
2. `List all Python files in this directory`
|
||||
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
||||
>
|
||||
> **The default protocol is OpenAI** (compatible with all OpenAI API-format services, including OpenAI official, Azure OpenAI, and any third-party model services offering an OpenAI-compatible interface).
|
||||
> To use the Anthropic protocol (Claude native API), expand the section below.
|
||||
|
||||
<details>
|
||||
<summary><strong>Switching AI Protocols (OpenAI ↔ Anthropic)</strong></summary>
|
||||
|
||||
This project switches the underlying protocol via **Spring AI Starter dependency + configuration file**. Java business code (`ChatModel`, `ChatClient`) **requires no changes**.
|
||||
|
||||
#### Option 1: OpenAI Protocol (Default)
|
||||
|
||||
`pom.xml` dependency:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
`application.yml` configuration:
|
||||
|
||||
```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}
|
||||
```
|
||||
|
||||
Environment variable example:
|
||||
|
||||
```sh
|
||||
export AI_API_KEY=sk-proj-xxxxxxxx
|
||||
export AI_BASE_URL=https://api.openai.com # Replace with any OpenAI-compatible endpoint
|
||||
export AI_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
> **TIP**: Many third-party model services (e.g., DeepSeek, Mistral, Qwen) provide OpenAI-compatible APIs. Simply change `AI_BASE_URL` and `AI_MODEL` to connect — no protocol switch needed.
|
||||
|
||||
#### Option 2: Anthropic Protocol (Claude Native API)
|
||||
|
||||
**Step 1**: Edit `pom.xml` — replace the OpenAI starter with the Anthropic starter:
|
||||
|
||||
```xml
|
||||
<!-- Comment out or remove the OpenAI starter -->
|
||||
<!-- <dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency> -->
|
||||
|
||||
<!-- Add the Anthropic starter -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
**Step 2**: Edit `application.yml` — replace `spring.ai.openai` with `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}
|
||||
```
|
||||
|
||||
**Step 3**: Set environment variables:
|
||||
|
||||
```sh
|
||||
export AI_API_KEY=sk-ant-xxxxxxxx
|
||||
export AI_BASE_URL=https://api.anthropic.com
|
||||
export AI_MODEL=claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
#### How Switching Works
|
||||
|
||||
Spring AI's `ChatModel` is a unified abstraction interface. Different Starters provide different implementations:
|
||||
|
||||
| Starter Dependency | Auto-injected ChatModel | Config Prefix |
|
||||
|---|---|---|
|
||||
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
||||
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
||||
|
||||
Business code always programs against the `ChatModel` interface. Switching protocols only requires changing the dependency and configuration — no Java code changes needed.
|
||||
|
||||
</details>
|
||||
|
||||
Try these prompts(English prompts work better with LLMs, but Chinese also works):
|
||||
|
||||
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`
|
||||
|
||||
Reference in New Issue
Block a user