Agent 实战(二):工具系统与核心循环,agent 的心跳在这里

本节结束时,你会有一个能自己看代码的 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 结束时有两条你记下了但还没真正踩到的东西:

  1. StreamEvent 里的 ToolUseStart / ToolUseArgsDelta 从来没被触发过——因为 M1 不带工具。M2 会让它们真正跑起来。
  2. 铁律 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&lt;Message&gt; 的回报: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 吗?

观察这四件事

  1. 参数是流式打印出来的。 ⚙ grep 后面那串 {"pattern": ...} 是一个字符一个字符出现的, 不是一次性出现。你正在亲眼看着铁律 3 发生。
  2. 一轮里能同时调两个工具。 第二个问题会看到 grep 和 glob 同时发出。
  3. in= 在涨。 轮次 1 大概是 800~900(工具定义占了很多),后面每轮都更多。
  4. 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"
  }
}

这就是我实测抓到的真实报错。 它出现在第二次请求(也就是第一次工具执行完之后)。

边界在哪?我做了三组对照实验

实验条件结果
Athinking 开 + 带 tools,assistant 消息没有 reasoning_content 字段❌ 上面的 400
Bthinking 关 + 同样缺字段的历史✅ 200,正常回答
Cthinking 开 + "reasoning_content": ""(空字符串)✅ 200,正常回答

结论:要求的是”字段必须存在”,内容可以是空字符串。

所以 AssistantTurn.reasoningText() 返回 "" 而不是 null 是刻意的—— 即使模型这一轮一句话没想,字段也得在。

为什么这是个”难查的 bug”

因为它只在三个条件同时满足时才出现:

  1. 开了 thinking
  2. 带了 tools
  3. 对话进入了第二轮(第一轮没有历史 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

可以自己动手改的练习

  1. 加一个 list_dir 工具(只列一层目录,不递归)。你会发现 read_file 遇到目录时的 错误提示已经指向了它——好的错误信息会告诉你该补什么工具。
  2. 把 MAX_ROUNDS 改成 2,问一个需要三轮才能回答的问题,看看熔断长什么样。
  3. 把 GrepTool 的 MAX_MATCHES 改成 5,看模型在结果被截断时的反应—— 它会不会换更精确的模式重搜?这能让你理解”截断要配上提示”有多重要。
  4. 故意让某个工具抛异常(比如 ReadFileTool 里加一行 if (true) throw new RuntimeException("测试");), 看错误是怎么变成 tool_result 内容回到模型那里的。

11. 踩坑速查

症状原因修法
模型说要用工具但从不真调tools 数组位置错了,或者 parameters 不在 function 里面铁律 4,用 --raw 看实际发出去的请求体
400 reasoning_content must be passed backthinking 开 + 带 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 给它手:

  1. write_file / edit_file —— 写文件和精确替换
  2. run_bash —— 跑命令(最强大,也最危险)
  3. PermissionManager —— allow / deny / ask 三级审批
  4. 危险命令识别 —— rm -rf /、git push --force 这类要拦下来
  5. 会话级授权 —— “这个工具在这个会话里别再问我了”

M3 的关键不是代码量,而是设计一套让人敢用的信任模型。