本节结束时,你会有一个能自己看代码的 agent。你问”Calculator 有没有 bug”, 它会自己去 grep、去 glob、去读文件,然后给你答案。
这一节是整个项目的分水岭。M1 只是个会聊天的程序;M2 之后它才是 agent。
0. 本节目标与验收标准
新增/修改的文件
src\main\java\dev\learn\agent\
├── Main.java ✏️ 改成用 Agent
├── provider\
│ ├── ToolSpec.java ✨ 新
│ ├── ToolCall.java ✨ 新
│ ├── Message.java ✏️ 加三个字段
│ ├── ChatRequest.java ✏️ 加 tools
│ ├── Provider.java ⚪ 一个字没改
│ ├── StreamEvent.java ⚪ 一个字没改
│ └── deepseek\DeepSeekProvider.java ✏️ 序列化工具
├── tool\ ✨ 全部新增
│ ├── Schema.java 拼 JSON Schema 的小 DSL
│ ├── Tool.java ★ 工具接口
│ ├── ToolContext.java 工作目录 + 路径沙箱
│ ├── ToolResult.java 执行结果
│ ├── ToolRegistry.java ★ 注册表 + 并发执行
│ └── impl\
│ ├── ReadFileTool.java
│ ├── GlobTool.java
│ └── GrepTool.java
└── agent\ ✨ 全部新增
├── AssistantTurn.java ★★ 参数累积器(本节核心)
├── AgentListener.java 回调接口
└── Agent.java ★★ 核心循环(本节核心)
💡 注意
Provider和StreamEvent一个字都没改。 这是 M1 那个抽象设计的回报——加工具不需要动协议层。 如果 M2 逼你去改StreamEvent,说明 M1 的抽象切错了。
验收标准
$ mvn -q compile exec:java
模型=deepseek-flash thinking=false raw=false
工作目录:D:\codeSpace\agent
可用工具:[glob, grep, read_file]
你> Calculator 类里有哪些方法?有没有明显的 bug?
⚙ grep {"pattern": "class\\s+Calculator"}
⚙ glob {"pattern": "**/*Calculator*"}
← 2 行
← 2 行
⚙ read_file {"path": "src/main/java/demo/Calculator.java"}
⚙ read_file {"path": "src/test/java/demo/CalculatorTest.java"}
← 16 行
← 7 行
**方法**(`src/main/java/demo/Calculator.java`):...
✅ 模型主动调用工具,你看得见它调用参数在流式生成 ✅ 一轮里能同时调多个工具(grep 和 glob 同时发) ✅ 工具结果回灌后模型继续推理,直到给出最终答案 ✅ 问一个需要跨多个文件才能回答的问题,它能自己收集齐信息
先准备一个测试工作区
不要拿 agent 直接在你的 agent 项目上跑(它可能会把 M1/M2 的源码读进来,干扰判断)。先造一个小项目:
mkdir -p /tmp/ws/src/main/java/demo /tmp/ws/src/test/java/demo
/tmp/ws/src/main/java/demo/Calculator.java:
package demo;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
return a / b;
}
public int max(int a, int b) {
return a > b ? a : b;
}
}
/tmp/ws/src/main/java/demo/StringUtils.java:
package demo;
public class StringUtils {
public static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
public static String reverse(String s) {
return new StringBuilder(s).reverse().toString();
}
}
/tmp/ws/src/test/java/demo/CalculatorTest.java:
package demo;
public class CalculatorTest {
public void testAdd() {
// TODO: real assertions
}
}
然后 cd /tmp/ws 再启动 agent。
1. 回顾:M1 留下的两个引信
M1 结束时有两条你记下了但还没真正踩到的东西:
StreamEvent里的ToolUseStart/ToolUseArgsDelta从来没被触发过——因为 M1 不带工具。M2 会让它们真正跑起来。- 铁律 6:带 tools 的请求必须回传
reasoning_content。M1 开了思考模式,但没带工具,所以这条规则没生效。
M2 两个引信同时引爆。
2. 概念:什么是”工具”
类比:给一个蒙着眼睛的专家配手和眼睛
模型的处境是这样的:
它极其聪明,但什么都看不见、也动不了手。它不知道你的项目里有哪些文件, 不知道
Calculator.java写了什么,更不能修改任何东西。 它唯一能做的就是说话。
那怎么让它干活?说清楚它能做哪些动作,然后它开口要,你替它做。
模型:「我需要看 src/main/java/demo/Calculator.java」
你: (读了文件)「这是内容:...」
模型:「我看到 divide 没处理除零。我还想看看测试文件」
你: (读了测试文件)「这是内容:...」
模型:「好了,我总结一下:...」
工具就是”你能替它做的动作清单”。
一次工具的完整生命周期
你 Agent 模型(API)
│ │ │
│──"Calculator有bug?"─▶│ │
│ │──messages + tools────▶│
│ │ │ 模型决定:
│ │◀─ tool_calls: [ │ "我要 glob"
│ │ {name:"glob", │
│ │ arguments:"**/*Calculator*"}]
│ │ │
│ │ ★ 执行工具 │
│ │ glob("**/*Calculator*")
│ │ → ["src/.../Calculator.java"]
│ │ │
│ │──tool 消息(结果)────▶│
│ │ │ 模型看到结果,
│ │◀─ tool_calls: [read_file] 决定读文件
│ │ │
│ │ ★ 执行工具 │
│ │ read_file(...) │
│ │ │
│ │──tool 消息────────────▶│
│ │ │
│ │◀─ 最终答复(不带工具)─│ 模型满足了,
│◀───显示答复──────────│ │ 结束循环
这个”翻来覆去”就是 agent loop。 它不复杂,但它是所有 agent 的心跳。
为什么必须有循环,不能一次问完?
因为模型一次只能想一步。它看到”Calculator 有没有 bug”时,真的不知道项目里有没有这个文件、在哪、写了什么。它只能先要”帮我找找”。
类比:像一个只能打电话、看不见现场的医生。他会不停地说”请帮我量一下体温”、“请看一下喉咙”,你照做并汇报,他再决定下一步。你俩一人一步,直到他说”诊断清楚了”。
3. 第一步:provider 包的改动
3.1 ToolSpec.java(新增)
package dev.learn.agent.provider;
import com.fasterxml.jackson.databind.JsonNode;
/**
* 一个工具的"说明书",用厂商无关的形式描述。
*
* <p>注意 parametersSchema 是标准 JSON Schema,不含任何厂商特有的包装。
* 各个 Provider 负责把它翻译成自己家的方言:
* <pre>
* DeepSeek/OpenAI: {"type":"function","function":{"name":..., "parameters":...}}
* Anthropic: {"name":..., "input_schema":...}
* </pre>
* ⚠️ 同一个 ToolSpec,两种完全不同的线格式。这就是抽象的价值所在 ——
* 到 M5 你会亲眼看到同一份 ToolSpec 被翻译成完全不同的样子。
*/
public record ToolSpec(String name, String description, JsonNode parametersSchema) {
}
3.2 ToolCall.java(新增)
package dev.learn.agent.provider;
/**
* 一个已经"组装完成"的工具调用。
*
* <p>注意 arguments 是<b>原始 JSON 字符串</b>,不是解析好的对象。两个原因:
* <ol>
* <li>回传给 API 时必须原样送回字符串,解析再序列化可能改变格式;</li>
* <li>解析失败时你还能把原始文本打进日志,否则排查无从下手。</li>
* </ol>
*/
public record ToolCall(String id, String name, String arguments) {
}
💡
arguments存字符串而不是JsonNode,是个刻意选择。你在下面AssistantTurn里会看到: 累积阶段本来得到的就是字符串,解析推迟到真正执行的那一刻。在数据流的每一段用最自然的形式,不要过早转换。
3.3 Message.java(改)
package dev.learn.agent.provider;
import java.util.List;
/**
* 对话历史里的一条消息。
*
* <p>M2 相比 M1 多了三个字段,都是为了工具调用:
* <ul>
* <li>{@code reasoningContent} —— 见铁律 6。带 tools 的请求必须把历史轮次的思考内容回传,否则 400。</li>
* <li>{@code toolCalls} —— assistant 消息里"我要调这些工具"。</li>
* <li>{@code toolCallId} —— tool 消息回答的是哪一次调用(铁律 1)。</li>
* </ul>
*
* <p>用一堆可空字段而不是 sealed 子类型,是因为消息的这几种形态和线格式几乎一一对应,
* 硬拆成子类型反而要在序列化时再合并回去,得不偿失。
*/
public record Message(
String role,
String content,
String reasoningContent,
List<ToolCall> toolCalls,
String toolCallId) {
public static Message system(String content) {
return new Message("system", content, null, null, null);
}
public static Message user(String content) {
return new Message("user", content, null, null, null);
}
/** toolCalls 为空时会被规整成 null,避免序列化时多出一个空数组。 */
public static Message assistant(String content, String reasoningContent, List<ToolCall> toolCalls) {
List<ToolCall> calls = (toolCalls == null || toolCalls.isEmpty())
? null
: List.copyOf(toolCalls);
return new Message("assistant", content, reasoningContent, calls, null);
}
/** 工具执行结果。toolCallId 必须对应某条 assistant 消息里的某个 toolCall.id。 */
public static Message tool(String toolCallId, String content) {
return new Message("tool", content, null, null, toolCallId);
}
}
3.4 ChatRequest.java(改)
package dev.learn.agent.provider;
import java.util.List;
/**
* 一次模型请求。
*
* <p>M2 相比 M1 多了一个 {@code tools} 字段 —— 这就是当初把它做成对象而不是
* 直接传 List<Message> 的回报:Provider 接口一个字都没改。
*/
public record ChatRequest(
String model,
List<Message> messages,
List<ToolSpec> tools,
boolean thinking) {
public static ChatRequest plain(String model, List<Message> messages) {
return new ChatRequest(model, messages, List.of(), false);
}
}
3.5 DeepSeekProvider.java(改)
只改了请求构建部分,流解析部分完全不动。
private String buildBody(ChatRequest request) {
ObjectNode body = json.createObjectNode();
body.put("model", request.model());
body.put("stream", true);
body.putObject("thinking")
.put("type", request.thinking() ? "enabled" : "disabled");
ArrayNode messages = body.putArray("messages");
for (Message m : request.messages()) {
messages.add(messageToJson(m, request.thinking()));
}
// ⚠️ 空数组有些实现会报错,没工具时干脆不带这个字段
if (!request.tools().isEmpty()) {
body.set("tools", toolsToJson(request.tools()));
}
return body.toString();
}
/**
* 把内部 Message 翻译成 DeepSeek 的线格式。
*
* <p>这是整个 Provider 里最容易出错的地方,因为消息有四种形态:
* <pre>
* system/user : {role, content}
* assistant : {role, content, reasoning_content?, tool_calls?}
* tool : {role, tool_call_id, content}
* </pre>
*/
private ObjectNode messageToJson(Message m, boolean thinking) {
ObjectNode node = json.createObjectNode();
node.put("role", m.role());
// ---- tool 消息:工具执行结果 ----
if ("tool".equals(m.role())) {
node.put("tool_call_id", m.toolCallId());
node.put("content", m.content() == null ? "" : m.content());
return node;
}
// ---- assistant 消息:思考内容(铁律 6)----
// ⚠️ 只在 thinking 开启时回传。不开思考模式时没有 reasoning_content 可言,
// 硬塞一个空串进去是无意义的噪声。
if (thinking && "assistant".equals(m.role()) && m.reasoningContent() != null) {
node.put("reasoning_content", m.reasoningContent());
}
// ---- assistant 消息:工具调用 ----
boolean hasCalls = m.toolCalls() != null && !m.toolCalls().isEmpty();
if (hasCalls) {
// content 允许是 null:模型可能"只调工具不说话"
if (m.content() == null || m.content().isEmpty()) {
node.putNull("content");
} else {
node.put("content", m.content());
}
ArrayNode calls = node.putArray("tool_calls");
for (ToolCall c : m.toolCalls()) {
ObjectNode call = calls.addObject();
call.put("id", c.id());
call.put("type", "function");
// ⚠️ arguments 必须是字符串,不是对象!原样送回我们收到的那段 JSON。
call.putObject("function")
.put("name", c.name())
.put("arguments", c.arguments());
}
return node;
}
node.put("content", m.content() == null ? "" : m.content());
return node;
}
/** ToolSpec → DeepSeek 的三层嵌套格式(铁律 4)。 */
private ArrayNode toolsToJson(List<ToolSpec> specs) {
ArrayNode tools = json.createArrayNode();
for (ToolSpec spec : specs) {
ObjectNode tool = tools.addObject();
tool.put("type", "function");
ObjectNode fn = tool.putObject("function");
fn.put("name", spec.name());
fn.put("description", spec.description());
fn.set("parameters", spec.parametersSchema());
}
return tools;
}
💡
arguments是字符串不是对象这件事,有个隐蔽的坑:如果你用 Jackson 反序列化时 把它当成JsonNode,再序列化回去,可能变成一个真正的 JSON 对象("arguments":{...}), API 就会报参数格式错误。原样透传字符串是最安全的做法。
4. 第二步:tool 包
4.1 Schema.java(新增)
package dev.learn.agent.tool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.ArrayList;
import java.util.List;
/**
* 拼 JSON Schema 的小 DSL。
*
* <p>没有它的话,每个工具都要写十几行 createObjectNode / putObject / putArray,
* 六个工具就是上百行噪声,而且 schema 长什么样反而看不出来了。
*
* <p>⚠️ 这里刻意不用 Jackson 的链式调用,而是包一层。原因:
* schema 的可读性直接决定模型的调用准确率。工具描述和参数说明写得清楚,
* 模型就用得对;写得含糊,模型就乱传参数。值得为"一眼能看懂"多写一个类。
*/
public final class Schema {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final ObjectNode root = MAPPER.createObjectNode();
private final ObjectNode properties = MAPPER.createObjectNode();
private final List<String> required = new ArrayList<>();
private Schema() {
root.put("type", "object");
root.set("properties", properties);
}
public static Schema object() {
return new Schema();
}
public Schema string(String name, String description) {
return add(name, "string", description);
}
public Schema integer(String name, String description) {
return add(name, "integer", description);
}
public Schema bool(String name, String description) {
return add(name, "boolean", description);
}
/** 把某个参数标记为必填。可以链式调用多次。 */
public Schema required(String name) {
required.add(name);
return this;
}
public JsonNode build() {
if (!required.isEmpty()) {
ArrayNode arr = root.putArray("required");
required.forEach(arr::add);
}
return root;
}
private Schema add(String name, String type, String description) {
properties.putObject(name)
.put("type", type)
.put("description", description);
return this;
}
}
用起来是这样——一眼就能看出这个工具要什么参数:
return Schema.object()
.string("path", "文件路径,相对工作目录")
.integer("offset", "从第几行开始读。可选,默认 0")
.required("path")
.build();
4.2 Tool.java(新增)
package dev.learn.agent.tool;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.provider.ToolSpec;
/**
* 一个工具。
*
* <p>实现类只需要回答四个问题:我叫什么、我是干什么的、我的参数长什么样、
* 拿到参数后干什么。前面三个决定了模型会不会正确地用你 —— 比第四个重要得多。
*/
public interface Tool {
/** 工具名。模型就是靠这个名字调用你的,务必用动词开头、语义明确。 */
String name();
/**
* 工具描述。<b>这是整个工具系统里最需要用心写的部分。</b>
* 模型只看到这段文字来决定用不用你、什么时候用你。
* 要写清楚:做什么、什么时候该用、什么时候不该用、有什么限制。
*/
String description();
/** 参数的 JSON Schema。 */
JsonNode parametersSchema();
/**
* 执行工具。
*
* @param args 模型给的参数,已经解析成 JSON 树。⚠️ 不要假设它一定合法,要自己校验。
* @param ctx 执行上下文(工作目录等)
* @return 要回灌给模型的文本结果。<b>这是模型唯一能看到的东西</b>,所以要写得让它看得懂。
* @throws Exception 抛出去会被 ToolRegistry 捕获并转成错误结果,不会让整个 agent 崩掉
*/
String execute(JsonNode args, ToolContext ctx) throws Exception;
/** 转成厂商无关的说明书。 */
default ToolSpec spec() {
return new ToolSpec(name(), description(), parametersSchema());
}
}
4.3 ToolContext.java(新增)
package dev.learn.agent.tool;
import java.nio.file.Path;
/**
* 工具执行时的上下文。
*
* <p>M2 里只有一个工作目录。M3 会在这里挂上权限管理器 ——
* 到时候所有工具会自动获得权限检查能力,不需要改任何一个工具的代码。
* 这就是"把横切关注点放进上下文"的价值。
*/
public final class ToolContext {
private final Path root;
public ToolContext(Path root) {
this.root = root.toAbsolutePath().normalize();
}
public Path root() {
return root;
}
/**
* 把模型给的路径解析成绝对路径,并<b>确保它没有跑出工作目录</b>。
*
* <p>这是整条安全防线里最基础的一层。三种越界都能挡住:
* <pre>
* "../../etc/passwd" → normalize 后跑出 root,拒绝
* "/etc/passwd" → 绝对路径直接覆盖 root,拒绝
* "src/../../../x" → 同上
* </pre>
*
* <p>⚠️ 这里<b>没有</b>处理符号链接(symlink)。工作目录里如果有一个
* 指向外部的软链接,仍然能绕过去。真实产品要用 {@code toRealPath()}。
* 对学习项目来说,知道这个缺口存在就够了 —— 但你必须知道。
*
* @throws IllegalArgumentException 路径越界
*/
public Path resolve(String userPath) {
if (userPath == null || userPath.isBlank()) {
throw new IllegalArgumentException("路径不能为空");
}
Path candidate = root.resolve(userPath).normalize();
if (!candidate.startsWith(root)) {
throw new IllegalArgumentException("路径越出工作目录,已拒绝:" + userPath);
}
return candidate;
}
/** 绝对路径 → 相对工作目录、用 / 分隔的显示形式。给模型看的路径越短越省 token。 */
public String display(Path path) {
Path abs = path.toAbsolutePath().normalize();
if (!abs.startsWith(root)) {
return abs.toString();
}
return root.relativize(abs).toString().replace('\\', '/');
}
}
💡 为什么 M2 就要做路径沙箱,不等到 M3? 因为这是正确性问题,不是判断力问题。让工具能读到
/etc/passwd是纯粹的 bug; 而”要不要允许 agent 删文件”是判断力问题,那个留给 M3。 安全的基础设施要早于安全的策略。
4.4 ToolResult.java(新增)
package dev.learn.agent.tool;
/**
* 一次工具执行的结果。
*
* <p>⚠️ 注意 {@code error} 标记只用于<b>我们自己的显示</b>。
* 回传给模型时没有独立的错误字段 —— 错误信息就是 content 的内容本身。
* 所以错误文案要写成模型看得懂、能据此纠正的样子,
* 比如"文件不存在:src/Main.java",而不是 Java 的堆栈。
*/
public record ToolResult(String toolCallId, String content, boolean error) {
public static ToolResult ok(String toolCallId, String content) {
return new ToolResult(toolCallId, content, false);
}
public static ToolResult error(String toolCallId, String message) {
return new ToolResult(toolCallId, message, true);
}
}
4.5 ToolRegistry.java(新增)
package dev.learn.agent.tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.learn.agent.provider.ToolCall;
import dev.learn.agent.provider.ToolSpec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class ToolRegistry {
/** 复用同一个 ObjectMapper。它线程安全,每次 new 一个是浪费。 */
private static final ObjectMapper JSON = new ObjectMapper();
private final Map<String, Tool> byName = new LinkedHashMap<>();
public ToolRegistry register(Tool tool) {
byName.put(tool.name(), tool);
return this;
}
public Tool get(String name) {
return byName.get(name);
}
/**
* 给模型看的工具清单。
* ⚠️ 按名字排序,保证每次请求生成的 JSON 字节完全一致 ——
* 这是 M4 的上下文缓存能命中的前提之一(前缀变了缓存就全废)。
*/
public List<ToolSpec> specs() {
return byName.values().stream()
.map(Tool::spec)
.sorted((a, b) -> a.name().compareTo(b.name()))
.toList();
}
/**
* 并发执行一批工具调用。
*
* <p>为什么要并发?因为模型可以在一轮里同时要求调多个工具(实测确认过,
* 一次问了杭州和北京两个城市,模型同时发了两个 tool_call)。
* 这两个调用之间没有任何依赖,串行跑就是白白浪费时间。
*
* <p>为什么用虚拟线程?因为是纯 IO 等待(读文件、跑命令),
* 虚拟线程在这种场景下几乎零成本。换成异步要写回调,虚拟线程让你写同步代码就够了。
*
* <p>结果按输入顺序返回,方便阅读;即使某个工具抛异常也会得到一个 error 结果,
* <b>绝不让一个失败的工具拖垮整轮</b>。
*/
public List<ToolResult> executeAll(List<ToolCall> calls, ToolContext ctx) {
if (calls.size() == 1) {
// 单个调用没必要开线程池,省掉调度开销
return List.of(executeOne(calls.get(0), ctx));
}
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<ToolResult>> futures = new ArrayList<>(calls.size());
for (ToolCall call : calls) {
futures.add(pool.submit(() -> executeOne(call, ctx)));
}
List<ToolResult> results = new ArrayList<>(calls.size());
for (Future<ToolResult> future : futures) {
try {
results.add(future.get());
} catch (Exception e) {
results.add(ToolResult.error("?", "工具执行异常: " + e.getMessage()));
}
}
return results;
}
}
private ToolResult executeOne(ToolCall call, ToolContext ctx) {
Tool tool = byName.get(call.name());
if (tool == null) {
// 模型调用了一个不存在的工具。不要说"未知错误",
// 要把可用工具列出来,模型才知道下一步怎么办。
return ToolResult.error(call.id(),
"没有名为 " + call.name() + " 的工具。可用工具:" + byName.keySet());
}
try {
var args = JSON.readTree(call.arguments());
String output = tool.execute(args, ctx);
return ToolResult.ok(call.id(), output);
} catch (Exception e) {
// ⚠️ 把原始参数也带上。模型看到自己传了什么,才知道怎么改。
return ToolResult.error(call.id(),
"工具 " + call.name() + " 执行失败:" + e.getMessage()
+ "\n你传入的参数是:" + call.arguments());
}
}
}
三个设计点值得停下来想:
① 为什么 specs() 要排序?
因为 LinkedHashMap 的顺序是注册顺序——你哪天调换两行 register 的顺序,发给 API 的
tools 数组就变了,上下文缓存的前缀就变了,缓存全废。排序是让输出字节稳定。
这一行代码是 M4 能省钱的前提。
② 为什么用虚拟线程而不是线程池?
工具执行是纯 IO 等待(读文件、跑命令),线程 99% 的时间在阻塞。虚拟线程的调度成本近乎为零,
你可以无脑为每个任务开一个,不用算线程池大小。换成平台线程池你需要判断 corePoolSize 该设多少——
那是个没有正确答案的问题。
③ 为什么工具失败不往外抛? 因为失败也是信息。模型看到”文件不存在”就知道换个路径试试;看到”正则无效”就知道要转义。 如果你直接抛异常中断整个循环,模型永远没机会纠正自己。 把错误当成对话的一部分,而不是流程的终止。
5. 第三步:三个只读工具
5.1 ReadFileTool.java
package dev.learn.agent.tool.impl;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Schema;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** 读取文件内容。只读工具,最安全,也最常用。 */
public final class ReadFileTool implements Tool {
/** 一次最多返回多少行。见下面"为什么要截断"。 */
private static final int MAX_LINES = 2000;
@Override
public String name() {
return "read_file";
}
@Override
public String description() {
return """
读取一个文本文件的内容,返回带行号的文本。
什么时候用:需要看某个文件的具体实现、配置或内容时。
什么时候不要用:想"看看目录里有什么"时用 glob;想按内容搜索时用 grep。
注意:一次最多返回 2000 行;文件很长时用 offset 分段读,不要反复重读同一个文件。
""";
}
@Override
public JsonNode parametersSchema() {
return Schema.object()
.string("path", "文件路径,相对工作目录,例如 src/main/java/dev/learn/agent/Main.java")
.integer("offset", "从第几行开始读(从 0 计数)。可选,默认 0")
.integer("limit", "最多读多少行。可选,默认 2000")
.required("path")
.build();
}
@Override
public String execute(JsonNode args, ToolContext ctx) throws Exception {
Path file = ctx.resolve(args.path("path").asText());
if (!Files.exists(file)) {
return "错误:文件不存在:" + ctx.display(file);
}
if (Files.isDirectory(file)) {
// 💡 错误信息要指向正确的下一步动作,模型才知道怎么办
return "错误:" + ctx.display(file) + " 是目录不是文件。用 glob 查看目录里有什么。";
}
if (Files.size(file) > 2_000_000) {
return "错误:文件超过 2MB,太大不便整体读取。请用 grep 定位具体内容。";
}
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
int offset = Math.max(0, args.path("offset").asInt(0));
int limit = args.has("limit") ? args.get("limit").asInt() : MAX_LINES;
if (limit <= 0) {
limit = MAX_LINES;
}
if (offset >= lines.size()) {
return "错误:offset=" + offset + " 超出文件长度(共 " + lines.size() + " 行)";
}
int end = Math.min(offset + limit, lines.size());
StringBuilder sb = new StringBuilder();
for (int i = offset; i < end; i++) {
sb.append(String.format("%6d\t%s%n", i + 1, lines.get(i)));
}
if (end < lines.size()) {
// 💡 明确告诉模型"还有多少"以及"怎么继续拿",它才会正确地再调一次
sb.append(String.format("...(还有 %d 行未显示,用 offset=%d 继续读)%n",
lines.size() - end, end));
}
return sb.isEmpty() ? "(文件是空的)" : sb.toString();
}
}
💡 为什么要截断(
MAX_LINES)? 因为工具结果会永久留进对话历史,每一轮请求都要重新发一遍、重新计费。 一个 agent 跑 20 轮,你读一次 5000 行的文件,就是 10 万 token 的输入。 工具输出是上下文预算的主要消耗者,必须在源头截断。这是 M4 的核心思想之一,M2 先埋个种子。
💡 为什么返回带行号的格式? 因为模型接下来很可能要说”第 42 行有问题”。有行号它才能准确指位。工具的输出格式要预判模型下一步要做什么。
5.2 GlobTool.java
package dev.learn.agent.tool.impl;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Schema;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
/** 按 glob 模式查找文件。 */
public final class GlobTool implements Tool {
private static final int MAX_RESULTS = 200;
private static final int MAX_DEPTH = 12;
/** 这些目录永远不进。见下面"为什么要跳过"。 */
private static final Set<String> SKIP_DIRS =
Set.of(".git", "target", "build", "node_modules", ".idea", ".gradle", "dist", "out");
@Override
public String name() {
return "glob";
}
@Override
public String description() {
return """
按 glob 模式查找文件,返回匹配的相对路径列表(按修改时间从新到旧)。
什么时候用:想知道某个文件在哪、想看清项目结构、想按扩展名筛选文件时。
参数示例:"**/*.java"、"src/**/*.md"、"*.xml"。
注意:结果最多 200 条;自动跳过 .git、target、node_modules 等目录。
""";
}
@Override
public JsonNode parametersSchema() {
return Schema.object()
.string("pattern", "glob 模式。要递归查找请用 **,例如 **/*.java")
.required("pattern")
.build();
}
@Override
public String execute(JsonNode args, ToolContext ctx) throws Exception {
String pattern = args.path("pattern").asText("").trim();
if (pattern.isEmpty()) {
return "错误:pattern 不能为空。例如 \"**/*.java\"";
}
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
Path root = ctx.root();
List<Path> matches;
try (Stream<Path> walk = Files.walk(root, MAX_DEPTH)) {
matches = walk
.filter(Files::isRegularFile)
.filter(p -> !isSkipped(root, p))
.filter(p -> matchesPath(matcher, root, p, pattern))
.sorted(Comparator.comparingLong(GlobTool::lastModified).reversed())
.limit(MAX_RESULTS)
.toList();
}
if (matches.isEmpty()) {
return "没有文件匹配 " + pattern + "(工作目录:" + root + ")";
}
StringBuilder sb = new StringBuilder();
for (Path p : matches) {
sb.append(ctx.display(p)).append('\n');
}
if (matches.size() == MAX_RESULTS) {
sb.append("...(已达上限 ").append(MAX_RESULTS).append(" 条,结果可能不完整)\n");
}
return sb.toString();
}
/**
* Java 的 glob 里 "**" + "/*.java" 要求路径里至少有一个 /,
* 所以匹配不到顶层的 Main.java。这里做了一次兼容:
* 模式里不含 / 时,也拿文件名单独试一次。
*/
private static boolean matchesPath(PathMatcher matcher, Path root, Path file, String pattern) {
Path relative = root.relativize(file);
if (matcher.matches(relative)) {
return true;
}
return !pattern.contains("/") && matcher.matches(relative.getFileName());
}
private static boolean isSkipped(Path root, Path file) {
for (Path segment : root.relativize(file)) {
if (SKIP_DIRS.contains(segment.toString())) {
return true;
}
}
return false;
}
private static long lastModified(Path p) {
try {
return Files.getLastModifiedTime(p).toMillis();
} catch (IOException e) {
return 0L;
}
}
}
5.3 GrepTool.java
package dev.learn.agent.tool.impl;
import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Schema;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Stream;
/** 按内容搜索文件。agent 最常用的工具,没有之一。 */
public final class GrepTool implements Tool {
private static final int MAX_MATCHES = 100;
private static final int MAX_FILE_SIZE = 1_000_000;
private static final int MAX_DEPTH = 12;
private static final Set<String> SKIP_DIRS =
Set.of(".git", "target", "build", "node_modules", ".idea", ".gradle", "dist", "out");
@Override
public String name() {
return "grep";
}
@Override
public String description() {
return """
用正则表达式搜索文件内容,返回 "文件路径:行号: 该行内容"。
什么时候用:知道某个标识符/字符串/报错信息,想找到它在哪里定义或被使用。
这是定位代码最快的手段 —— 比 read_file 一个个翻文件高效得多,优先用它。
注意:结果最多 100 条;只搜文本文件;默认跳过 target/.git/node_modules 等目录。
""";
}
@Override
public JsonNode parametersSchema() {
return Schema.object()
.string("pattern", "正则表达式,例如 \"class\\s+Agent\" 或 \"TODO\"。注意反斜杠要转义")
.string("glob", "只搜匹配该 glob 的文件。可选,例如 \"**/*.java\"。默认搜所有文本文件")
.required("pattern")
.build();
}
@Override
public String execute(JsonNode args, ToolContext ctx) throws Exception {
String patternText = args.path("pattern").asText("");
if (patternText.isEmpty()) {
return "错误:pattern 不能为空";
}
Pattern regex;
try {
regex = Pattern.compile(patternText);
} catch (PatternSyntaxException e) {
// 💡 把 Java 的报错原样带回去,模型能据此修正正则
return "错误:正则表达式无效:" + e.getDescription()
+ "(在位置 " + e.getIndex() + ")。你给的是:" + patternText;
}
String glob = args.path("glob").asText("**/*");
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob);
Path root = ctx.root();
StringBuilder sb = new StringBuilder();
int[] count = {0};
int[] scanned = {0};
try (Stream<Path> walk = Files.walk(root, MAX_DEPTH)) {
List<Path> files = walk
.filter(Files::isRegularFile)
.filter(p -> !isSkipped(root, p))
.filter(p -> matcher.matches(root.relativize(p))
|| (!glob.contains("/") && matcher.matches(p.getFileName())))
.toList();
for (Path file : files) {
if (count[0] >= MAX_MATCHES) {
break;
}
scanned[0]++;
searchFile(file, regex, ctx, sb, count);
}
}
if (sb.isEmpty()) {
return "没有找到匹配 " + patternText + " 的内容(搜索了 " + scanned[0] + " 个文件)";
}
if (count[0] >= MAX_MATCHES) {
sb.append("...(已达上限 ").append(MAX_MATCHES).append(" 条)\n");
}
return sb.toString();
}
private void searchFile(Path file, Pattern regex, ToolContext ctx,
StringBuilder sb, int[] count) {
List<String> lines;
try {
if (Files.size(file) > MAX_FILE_SIZE) {
return;
}
lines = Files.readAllLines(file, StandardCharsets.UTF_8);
} catch (IOException | OutOfMemoryError e) {
// ⚠️ 二进制文件会在这里抛 MalformedInputException。
// 静默跳过是对的 —— 但真实项目里应该先按扩展名白名单过滤,
// 否则在含大量图片的目录里会白白扫很久。
return;
}
String display = ctx.display(file);
for (int i = 0; i < lines.size() && count[0] < MAX_MATCHES; i++) {
String line = lines.get(i);
if (regex.matcher(line).find()) {
String trimmed = line.strip();
if (trimmed.length() > 300) {
trimmed = trimmed.substring(0, 300) + "…";
}
sb.append(display).append(':').append(i + 1).append(": ").append(trimmed).append('\n');
count[0]++;
}
}
}
private static boolean isSkipped(Path root, Path file) {
for (Path segment : root.relativize(file)) {
if (SKIP_DIRS.contains(segment.toString())) {
return true;
}
}
return false;
}
}
💡 为什么 glob/grep 都要跳过
.git/target/node_modules? 因为这些目录又大又没信息。node_modules可能有十万个文件,全扫一遍要好几分钟, 而且返回的结果全是噪声,白白烧掉上下文预算。 “不返回什么”和”返回什么”一样重要。
6. 第四步:agent 包 ★本节核心
6.1 AssistantTurn.java ★★ 参数累积器
这是 M2 最重要的一个类。 铁律 3 在这里落地。
package dev.learn.agent.agent;
import dev.learn.agent.provider.StopReason;
import dev.learn.agent.provider.StreamEvent;
import dev.learn.agent.provider.ToolCall;
import dev.learn.agent.provider.Usage;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 把一轮里收到的所有 StreamEvent,攒成一条完整的 assistant 回复。
*
* <p><b>这是 M2 的核心,也是 M1 埋下的铁律 3 真正落地的地方。</b>
*
* <p>为什么不直接边收边用?因为流式事件是"碎片",而写回历史、回灌结果都需要"整块":
* <ul>
* <li>正文是一段段来的 → 拼起来才是完整回复</li>
* <li>工具参数是一片片来的 → 拼起来才是合法 JSON</li>
* </ul>
*
* <p>这就像一个同声传译的翻译官:他一边听一边把句子记下来,
* 等对方说完了,才能把完整的一句话交给你。中途你拿到的都是半句。
*/
public final class AssistantTurn {
private final StringBuilder content = new StringBuilder();
private final StringBuilder reasoning = new StringBuilder();
/**
* ⚠️ 用 LinkedHashMap 保序。
* 工具调用的顺序是模型决定的,回灌结果时最好保持一致,显示出来也更符合直觉。
*
* <p>key 是工具调用的 id —— Provider 已经帮我们把流里的 index 翻译成 id 了,
* 所以这里不需要再关心 index。这就是分层的好处。
*/
private final Map<String, PartialCall> partials = new LinkedHashMap<>();
private StopReason stopReason = StopReason.OTHER;
private Usage usage = Usage.EMPTY;
private String failure;
/** 一个正在拼装的工具调用。 */
private static final class PartialCall {
final String id;
final String name;
final StringBuilder arguments = new StringBuilder();
PartialCall(String id, String name) {
this.id = id;
this.name = name;
}
}
/** 喂给它一个事件。它在内部默默累积。 */
public void accept(StreamEvent event) {
switch (event) {
case StreamEvent.TextDelta t -> content.append(t.text());
case StreamEvent.ReasoningDelta r -> reasoning.append(r.text());
// 第一次见到某个工具:登记它的 id 和名字
case StreamEvent.ToolUseStart s ->
partials.putIfAbsent(s.id(), new PartialCall(s.id(), s.name()));
// 参数碎片:按 id 找到对应的调用,把碎片接上去
// ⚠️ 这就是"分拣线"那一步(铁律 3)。绝不能在这里做字符串匹配判断。
case StreamEvent.ToolUseArgsDelta a -> {
PartialCall call = partials.get(a.id());
if (call != null) {
call.arguments.append(a.partialJson());
}
}
case StreamEvent.Done d -> {
stopReason = d.reason();
usage = d.usage();
}
case StreamEvent.Failure f -> failure = f.message();
}
}
/** 拼装完成的工具调用列表。arguments 是完整 JSON 字符串(还没解析)。 */
public List<ToolCall> toolCalls() {
return partials.values().stream()
.map(p -> new ToolCall(p.id, p.name, p.arguments.toString()))
.toList();
}
public String contentText() {
return content.toString();
}
/** ⚠️ 返回空串而不是 null。带 tools 的请求必须回传这个字段(铁律 6)。 */
public String reasoningText() {
return reasoning.toString();
}
public StopReason stopReason() {
return stopReason;
}
public Usage usage() {
return usage;
}
public boolean failed() {
return failure != null;
}
public String failureMessage() {
return failure;
}
}
这个类为什么值得单独拎出来? 因为它展示了流式处理的核心模式:
事件流是”碎片”,业务逻辑需要”整块”。中间必须有一个地方负责拼装。
这个地方在有的框架里叫”reducer”,有的叫”accumulator”,有的直接叫 state。
名字不重要,重要的是它必须存在,而且必须只有一个。你以后写任何流式处理的东西都会遇到它。
6.2 AgentListener.java
package dev.learn.agent.agent;
import dev.learn.agent.provider.StopReason;
import dev.learn.agent.provider.Usage;
import dev.learn.agent.tool.ToolResult;
/**
* agent 循环里发生的事,用默认空实现全部列出来。
*
* <p>这样调用方只想关心"工具什么时候跑完了",就只覆盖 onToolResult 一个方法。
*/
public interface AgentListener {
/** 模型吐出一段正文。 */
default void onText(String delta) {
}
/** 模型吐出一段思考内容(thinking 开启时才有)。 */
default void onReasoning(String delta) {
}
/** 模型开始调用某个工具(参数还在流式传入)。 */
default void onToolCallStart(String name) {
}
/** 工具参数的一个碎片。<b>边收边显示它,就能亲眼看到铁律 3。</b> */
default void onToolCallArgs(String fragment) {
}
/** 某个工具执行完毕。 */
default void onToolResult(ToolResult result) {
}
/** 一轮(一次模型调用 + 可能的工具执行)结束。 */
default void onRoundEnd(StopReason reason, Usage usage) {
}
/** 出错。 */
default void onFailure(String message) {
}
/** 达到最大轮数保护,强制停止。 */
default void onLimitReached(int maxRounds) {
}
}
6.3 Agent.java ★★ 核心循环
package dev.learn.agent.agent;
import dev.learn.agent.provider.ChatRequest;
import dev.learn.agent.provider.Message;
import dev.learn.agent.provider.Provider;
import dev.learn.agent.provider.StopReason;
import dev.learn.agent.provider.StreamEvent;
import dev.learn.agent.provider.ToolCall;
import dev.learn.agent.provider.Usage;
import dev.learn.agent.tool.ToolContext;
import dev.learn.agent.tool.ToolRegistry;
import dev.learn.agent.tool.ToolResult;
import java.util.ArrayList;
import java.util.List;
/**
* 核心 agent 循环。
*
* <p>整个 agent 的心跳就是这几行:
* <pre>
* while (true) {
* 调模型
* 把回复写进历史
* if (模型不调工具) 结束
* 执行工具,把结果写进历史
* // 然后回到开头,再调一次模型 —— 这次它能看到工具结果了
* }
* </pre>
*
* <p><b>类比</b>:像指挥一个蒙着眼睛的专家。他能思考、能下指令,
* 但什么都看不见、也动不了手。他不停地跟你说"请帮我读一下 X 文件的第 30 行",
* 你读完告诉他,他再决定下一步。你俩一人一步,直到他说"好了,我知道答案了"。
*/
public final class Agent {
/**
* 最大轮数保护。
* ⚠️ 没有这个,模型一旦陷入"读文件 → 不对 → 再读 → 还不对"的循环,
* 就会一直烧钱直到你手动 Ctrl+C。真实产品都必须有这个熔断。
*/
private static final int MAX_ROUNDS = 25;
private final Provider provider;
private final ToolRegistry tools;
private final ToolContext context;
private final String model;
private final boolean thinking;
/** 完整对话历史。这是 agent 的"记忆",也是 M4 要管理的对象。 */
private final List<Message> conversation = new ArrayList<>();
private Usage totalUsage = Usage.EMPTY;
public Agent(Provider provider,
ToolRegistry tools,
ToolContext context,
String model,
boolean thinking,
String systemPrompt) {
this.provider = provider;
this.tools = tools;
this.context = context;
this.model = model;
this.thinking = thinking;
this.conversation.add(Message.system(systemPrompt));
}
/** 处理用户的一次输入,直到模型给出最终答复(或触发熔断)。阻塞方法。 */
public void run(String userInput, AgentListener listener) {
conversation.add(Message.user(userInput));
for (int round = 1; round <= MAX_ROUNDS; round++) {
// ── 1. 调模型,边收边攒 ──────────────────────────
AssistantTurn turn = new AssistantTurn();
provider.stream(
new ChatRequest(model, List.copyOf(conversation), tools.specs(), thinking),
event -> {
turn.accept(event);
relay(event, listener);
});
if (turn.failed()) {
return; // onFailure 已经在 relay 里回调过了
}
// ── 2. 把这一轮的回复写进历史 ────────────────────
// ⚠️ reasoningText() 必须一起写进去,否则带 tools 的请求会 400(铁律 6)
conversation.add(Message.assistant(
turn.contentText(),
turn.reasoningText(),
turn.toolCalls()));
totalUsage = totalUsage.plus(turn.usage());
listener.onRoundEnd(turn.stopReason(), turn.usage());
// ── 3. 模型不调工具 → 说完了,收工 ───────────────
if (turn.stopReason() != StopReason.TOOL_USE) {
return;
}
List<ToolCall> calls = turn.toolCalls();
if (calls.isEmpty()) {
// 防御性检查:finish_reason 说是 tool_calls,但一个工具都没收到。
// 正常不会发生;真发生说明我们对协议的理解有偏差,要吵出来而不是静默继续。
listener.onFailure("finish_reason 是 tool_calls,但没有收到任何工具调用");
return;
}
// ── 4. 执行工具 ────────────────────────────────
// ToolRegistry 内部并发执行(虚拟线程),按顺序返回
List<ToolResult> results = tools.executeAll(calls, context);
// ── 5. 把工具结果写进历史 ───────────────────────
// 每个结果是一条独立的 role:"tool" 消息(铁律 1)
for (ToolResult result : results) {
listener.onToolResult(result);
conversation.add(Message.tool(result.toolCallId(), result.content()));
}
// 循环回到开头:再调一次模型,这次它能看到工具结果了
}
listener.onLimitReached(MAX_ROUNDS);
}
/** 把流事件转成 listener 回调。 */
private static void relay(StreamEvent event, AgentListener listener) {
switch (event) {
case StreamEvent.TextDelta t -> listener.onText(t.text());
case StreamEvent.ReasoningDelta r -> listener.onReasoning(r.text());
case StreamEvent.ToolUseStart s -> listener.onToolCallStart(s.name());
case StreamEvent.ToolUseArgsDelta a -> listener.onToolCallArgs(a.partialJson());
case StreamEvent.Failure f -> listener.onFailure(f.message());
case StreamEvent.Done d -> {
// 用量和停止原因由 AssistantTurn 消化,这里不重复上报
}
}
}
/** 完整历史。M4 的压缩器要读它。 */
public List<Message> conversation() {
return List.copyOf(conversation);
}
/** 整个会话累计的 token 用量。 */
public Usage totalUsage() {
return totalUsage;
}
}
四个值得注意的地方:
① for (int round = 1; round <= MAX_ROUNDS; round++) — 用 for 而不是 while。
因为熔断条件天然属于 for 的第三个子句。写成 while (true) + 内部计数,你会很容易忘了递增。
② List.copyOf(conversation) 每次请求都做一次快照。
成本是 O(n) 的浅拷贝,可以忽略;收益是正在进行的请求绝不会看到后续被追加的消息。
这种”快照隔离”的思维在并发和流式场景里非常值钱。
③ 工具结果一条一条 append,而不是攒成一个。 这正是铁律 1(DeepSeek/OpenAI 风格)。注意 M5 写 Anthropic Provider 时, 同样的一段 agent 代码要面对”所有结果塞进一条消息”的规则——那才是抽象真正的考验。
④ relay 里 case Done -> {} 是空实现,但必须写出来。
因为 switch 是穷举的,少写一个就编译不过。如果不是 sealed,这里漏掉一个 case 会静默出错。
7. 第五步:Main(改成用 Agent)
package dev.learn.agent;
import dev.learn.agent.agent.Agent;
import dev.learn.agent.agent.AgentListener;
import dev.learn.agent.provider.Provider;
import dev.learn.agent.provider.StopReason;
import dev.learn.agent.provider.Usage;
import dev.learn.agent.provider.deepseek.DeepSeekProvider;
import dev.learn.agent.tool.ToolContext;
import dev.learn.agent.tool.ToolRegistry;
import dev.learn.agent.tool.ToolResult;
import dev.learn.agent.tool.impl.GlobTool;
import dev.learn.agent.tool.impl.GrepTool;
import dev.learn.agent.tool.impl.ReadFileTool;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
public class Main {
private static final String MODEL = "deepseek-flash";
private static final String SYSTEM_PROMPT = """
你是一个编码助手,工作目录就是当前目录。
工作方式:
- 需要了解代码时,主动用工具去看,不要凭空猜测或假设。
- 定位代码优先用 grep 找内容,用 glob 找文件,最后才用 read_file 精读。
- 一次可以用多个工具并行收集信息。
回答要求:简洁,不客套,不重复用户的问题。
""";
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("DEEPSEEK_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
System.err.println("请先设置环境变量 DEEPSEEK_API_KEY");
System.exit(1);
}
List<String> flags = List.of(args);
boolean dumpRaw = flags.contains("--raw");
boolean thinking = flags.contains("--think");
Provider provider = new DeepSeekProvider(apiKey, dumpRaw);
// ── 注册工具。加新工具只要在这里多一行 ──
ToolRegistry tools = new ToolRegistry()
.register(new ReadFileTool())
.register(new GlobTool())
.register(new GrepTool());
// ── 工作目录就是启动时所在的目录 ──
ToolContext context = new ToolContext(Path.of("."));
Agent agent = new Agent(provider, tools, context, MODEL, thinking, SYSTEM_PROMPT);
ConsoleListener listener = new ConsoleListener();
System.out.printf("模型=%s thinking=%s raw=%s%n", MODEL, thinking, dumpRaw);
System.out.println("工作目录:" + context.root());
System.out.printf("可用工具:%s%n", tools.specs().stream().map(s -> s.name()).toList());
System.out.println("输入问题,空行退出。");
BufferedReader stdin = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8));
while (true) {
System.out.print("\n你> ");
System.out.flush();
String input = stdin.readLine();
if (input == null || input.isBlank()) {
break;
}
try {
agent.run(input, listener);
} catch (Exception e) {
// ⚠️ agent 挂掉不能把 REPL 一起带走。用户应该能继续问下一个问题。
System.err.println("\n[异常] " + e);
}
listener.reset();
}
System.out.printf("%n再见。累计用量:in=%d out=%d cache=%d%n",
agent.totalUsage().promptTokens(),
agent.totalUsage().completionTokens(),
agent.totalUsage().cacheHitTokens());
}
/** 把 agent 的回调渲染到终端。 */
private static final class ConsoleListener implements AgentListener {
private boolean reasoningOpen = false;
private boolean toolOpen = false;
void reset() {
closeReasoning();
System.out.println();
toolOpen = false;
}
private void closeReasoning() {
if (reasoningOpen) {
System.out.print("\u001B[0m");
reasoningOpen = false;
}
}
@Override
public void onText(String delta) {
closeReasoning();
System.out.print(delta);
System.out.flush();
}
@Override
public void onReasoning(String delta) {
if (!reasoningOpen) {
System.out.print("\u001B[2m");
reasoningOpen = true;
}
System.out.print(delta);
System.out.flush();
}
@Override
public void onToolCallStart(String name) {
closeReasoning();
System.out.print("\n\n\u001B[36m⚙ " + name + " \u001B[2m");
System.out.flush();
toolOpen = true;
}
@Override
public void onToolCallArgs(String fragment) {
// 💡 边收边打印参数碎片 —— 你正在亲眼看铁律 3 发生
System.out.print(fragment);
System.out.flush();
}
@Override
public void onToolResult(ToolResult result) {
if (toolOpen) {
System.out.println("\u001B[0m");
toolOpen = false;
}
String preview = result.content().strip();
String[] lines = preview.split("\n");
String color = result.error() ? "\u001B[31m" : "\u001B[32m";
System.out.println(color + " ← " + lines.length + " 行"
+ (result.error() ? " (失败)" : "")
+ "\u001B[0m");
System.out.flush();
}
@Override
public void onRoundEnd(StopReason reason, Usage usage) {
System.out.printf("%n\u001B[2m[轮次结束 %s in=%d out=%d cache=%d]\u001B[0m%n",
reason, usage.promptTokens(), usage.completionTokens(), usage.cacheHitTokens());
}
@Override
public void onFailure(String message) {
closeReasoning();
System.err.println("\n[失败] " + message);
}
@Override
public void onLimitReached(int maxRounds) {
System.err.printf("%n[已停止] 达到最大轮数 %d。可能是模型陷入了循环。%n", maxRounds);
}
}
}
💡
\u001B是 ANSI 转义序列的起始符(ESC 字符),用来给终端输出上色。\u001B[36m是青色,\u001B[2m是暗色,\u001B[0m是复位。 注意\u001B是写在字符串里的转义,不能写成\\u001B。
8. 跑起来
cd /d/codeSpace/agent
mvn -q compile
# ⚠️ 一定要切到测试工作区再跑,不要拿 agent 自己的源码当靶子
cd /tmp/ws
然后需要把编译好的 class 和 Jackson 放上 classpath。最省事的办法是用 exec:java 的
-Dexec.workingdir,或者干脆在 agent 项目里跑、用 ToolContext 指向别处:
# 简单做法:临时把工作目录传进去
cd /d/codeSpace/agent
mvn -q compile exec:java -Dexec.args=""
如果你想让 agent 在 /tmp/ws 上工作,把 Main 里的
ToolContext context = new ToolContext(Path.of("."));
临时改成:
ToolContext context = new ToolContext(Path.of("/tmp/ws"));
跑起来后,依次试这几个问题(难度递增):
你> 这个项目有哪些文件?
你> Calculator 类里有哪些方法?有没有明显的 bug?
你> StringUtils 里 isBlank 有 bug 吗?
观察这四件事
- 参数是流式打印出来的。
⚙ grep后面那串{"pattern": ...}是一个字符一个字符出现的, 不是一次性出现。你正在亲眼看着铁律 3 发生。 - 一轮里能同时调两个工具。 第二个问题会看到 grep 和 glob 同时发出。
in=在涨。 轮次 1 大概是 800~900(工具定义占了很多),后面每轮都更多。cache=从第二轮开始不为 0。 这是 DeepSeek 的上下文缓存在生效—— M4 会专门利用它。
9. 铁律 6 的真实报错(实测)
现在把思考模式打开,再问一遍:
cd /d/codeSpace/agent
mvn -q compile exec:java -Dexec.args="--think"
如果一切正确,你会看到灰色的思考内容和正常回答交替出现。 但是——
故意把它弄坏
现在去 DeepSeekProvider.messageToJson 里,把这一段的 thinking && 去掉:
// 改成这样(错的):
if ("assistant".equals(m.role()) && m.reasoningContent() != null) {
node.put("reasoning_content", m.reasoningContent());
}
等等——这不会坏。要让它坏,你得不传这个字段。把整个 if 块注释掉,然后跑 --think。
你会拿到一个 HTTP 400:
{
"error": {
"message": "The `reasoning_content` in the thinking mode must be passed back to the API.",
"type": "invalid_request_error",
"code": "invalid_request_error"
}
}
这就是我实测抓到的真实报错。 它出现在第二次请求(也就是第一次工具执行完之后)。
边界在哪?我做了三组对照实验
| 实验 | 条件 | 结果 |
|---|---|---|
| A | thinking 开 + 带 tools,assistant 消息没有 reasoning_content 字段 | ❌ 上面的 400 |
| B | thinking 关 + 同样缺字段的历史 | ✅ 200,正常回答 |
| C | thinking 开 + "reasoning_content": ""(空字符串) | ✅ 200,正常回答 |
结论:要求的是”字段必须存在”,内容可以是空字符串。
所以 AssistantTurn.reasoningText() 返回 "" 而不是 null 是刻意的——
即使模型这一轮一句话没想,字段也得在。
为什么这是个”难查的 bug”
因为它只在三个条件同时满足时才出现:
- 开了 thinking
- 带了 tools
- 对话进入了第二轮(第一轮没有历史 assistant 消息,所以不触发)
M1 全都不满足。你 M2 写完、关着 thinking 测,也全都不满足。只有当你打开 --think
并且让模型真的调了工具之后,它才炸。 而且报错信息里没有”第几轮”、没有”哪条消息”,
你只能自己推。
这就是我为什么在 M1 就反复强调它。
10. 自检清单
-
mvn -q compile无错误 - 问”这个项目有哪些文件”,agent 会调 glob
- 问”Calculator 有没有 bug”,agent 会先 grep/glob,再 read_file,最后给答案
- 工具参数字符串是逐字符流式出现的
- 一轮里出现过两个工具同时被调用
-
--think下能看到灰色思考内容 -
--think下不会 400(说明 reasoning_content 回传正确) - 故意注释掉 reasoning_content 回传后,
--think会 400(你亲眼见过这个错误了) -
cache=从第二轮开始不为 0
可以自己动手改的练习
- 加一个
list_dir工具(只列一层目录,不递归)。你会发现read_file遇到目录时的 错误提示已经指向了它——好的错误信息会告诉你该补什么工具。 - 把
MAX_ROUNDS改成 2,问一个需要三轮才能回答的问题,看看熔断长什么样。 - 把
GrepTool的MAX_MATCHES改成 5,看模型在结果被截断时的反应—— 它会不会换更精确的模式重搜?这能让你理解”截断要配上提示”有多重要。 - 故意让某个工具抛异常(比如
ReadFileTool里加一行if (true) throw new RuntimeException("测试");), 看错误是怎么变成tool_result内容回到模型那里的。
11. 踩坑速查
| 症状 | 原因 | 修法 |
|---|---|---|
| 模型说要用工具但从不真调 | tools 数组位置错了,或者 parameters 不在 function 里面 | 铁律 4,用 --raw 看实际发出去的请求体 |
400 reasoning_content must be passed back | thinking 开 + 带 tools + 第二轮 | 铁律 6,Message 必须带 reasoningContent |
| 工具参数解析失败 | 拿单次碎片去 readTree 了,没累积 | 铁律 3,必须有 AssistantTurn 那一层 |
| 模型反复调同一个工具 | 工具返回的结果信息不足 | 错误信息和空结果都要写明”为什么”和”下一步怎么办” |
in= 涨得飞快 | 工具返回太多内容 | 源码级截断(MAX_LINES / MAX_MATCHES) |
| 路径越界异常 | 模型给了 ../ 或绝对路径 | 这是预期行为,ToolContext.resolve 在保护你 |
| 模型调不存在的工具 | 工具名写错 / 没注册 | ToolRegistry 会把可用工具列给它,通常它自己能纠正 |
| 某个工具挂了整个循环也挂了 | 工具抛的异常没被接住 | ToolRegistry.executeOne 必须 catch 一切 |
12. 本节你实际学到了什么
| 概念 | 你做了什么 |
|---|---|
| 核心循环 | 写了”调模型 → 执行工具 → 回灌 → 再调模型”的心跳 |
| 流式累积 | AssistantTurn 把碎片事件攒成完整的消息 |
| 工具 schema | 用三层嵌套格式向模型描述工具(铁律 4) |
| 并发执行 | 用虚拟线程同时跑多个无依赖的工具调用 |
| 错误即信息 | 工具失败不抛异常,而是变成模型能读懂的文本 |
| 熔断保护 | MAX_ROUNDS 防止无限循环烧钱 |
| 路径沙箱 | ToolContext.resolve 挡住越界路径 |
| 上下文预算 | 工具结果截断,因为输出会永久留在历史里 |
最重要的收获:你现在理解了 agent 的本质是一个循环,而循环的每一步都在 “把模型看不见的世界翻译给它看”。所有花里胡哨的能力——多 agent、长任务、MCP—— 都是在这个循环上叠。
13. 下一节预告(M3)
M2 的 agent 只能看,不能改。M3 给它手:
write_file/edit_file—— 写文件和精确替换run_bash—— 跑命令(最强大,也最危险)PermissionManager—— allow / deny / ask 三级审批- 危险命令识别 ——
rm -rf /、git push --force这类要拦下来 - 会话级授权 —— “这个工具在这个会话里别再问我了”
M3 的关键不是代码量,而是设计一套让人敢用的信任模型。