⚡从零开发 Agent CLI(五)实现一个可治理、可扩展的工具系统

本文介绍的项目是 dskcode------一个基于 DeepSeek 的 AI 编程助手终端 CLI 工具,采用 TypeScript 实现,只面向国内用户。本文所有设计细节与代码示例均来自 dskcode 的实际实现。

安装试用:npm install -g dskcodenpx dskcode

后续说明:本文基于 agent-clisrc/tool/ 模块实现。代码是 TypeScript,但设计思想与语言无关------任何 LLM agent 在接工具时都绕不开"工具系统"这一层。

受众假设:你已经能让 LLM 调上 bashread_file 之类的工具,但接 5 个工具以后就崩了------参数校验总是漏、并行执行串了一团、错误码五花八门、写工具偶尔越界写到 ~/.ssh。如果你在找"怎么把 agent 的工具系统做强壮",这篇文章就是答案。

  • Agent 工具系统的本质 = 声明式工具注册表 + 强类型契约 + 统一结果协议 + 沙箱化的执行上下文。这四件事都做到位,接 80 个工具也跟接 8 个一样稳。
  • 三条核心原则:运行时不知道类型,但工具自己知道 (类型擦除 + 工厂模式推导)/ LLM 看得懂的 schema 跟开发者写的类型用同一个源头 (Zod ↔ JSONSchema 自动双向)/ 任何工具失败都必须是无歧义的 ToolResult(成功和失败同结构,带 error code)。
  • 关键 trade-off:用 Zod 换来极简的"写一次、出三个东西"(TS 类型 + JSONSchema + 校验器),代价就是引入一个 ~50KB 的依赖------值。

1. 为什么 agent 工具系统值得单独写一篇

LLM agent 工具系统的麻烦,不是"能不能调起来",而是"调起来以后能不能不崩"。

一个 agent 跑久了以后,工具集会长这样:

bash 复制代码
read_file   # 读文件
write_file  # 写文件
edit_file   # 精确替换
bash        # 跑命令
grep        # 搜代码
glob        # 找文件
fetch       # 抓网页
...         # 30 个 + 各种 User-defined

接 5 个工具的时候,每个工具里自己 parse 自己 validate 自己 try/catch,代码量是 5×80=400 行,能撑。

接 30 个工具的时候,你突然发现:

  • 同样的"读文件但太大"错误,有的工具返回 FILE_TOO_LARGE,有的返回 TOOL_ERROR,有的甚至 throw
  • 有的工具的 args 是 { path: string },有的偷偷用 { filePath }{ filepath }
  • LLM 给出错误参数(path 写成 Path),有的工具悄悄 fallback,有的直接 crash
  • bash 跑超时了杀进程不彻底,grep 跑 100M 日志把内存炸了
  • 写工具的安全边界靠每个工具自己记,有几天就有一个忘了
graph LR subgraph 无系统时的真实状态 A1[read_file 工具<br/>自管异常] A2[bash 工具<br/>自管异常] A3[grep 工具<br/>自管异常] A4[fetch 工具<br/>自管异常] A5[你的 user_defined 工具<br/>...也自管] end A1 -.三次返工.-> Z[每个工具都<br/>500 行 boilerplate] A2 -.三次返工.-> Z A3 -.三次返工.-> Z A4 -.三次返工.-> Z

这就是"工具系统"这个抽象层要解决的问题:把跨工具的样板代码、所有工具都要关心的那些事情,集中到一个地方。


2. 设计目标:工具系统要解决哪几类问题

工具系统的"职责范围"划清楚,实现就不会膨胀成大泥球。我们的目标只有四类:

类别 解决的问题 src/tool/ 里的对应
注册 工具有名、有描述、有调用方式,能被框架 find registry.ts
类型 工具自己的 args/返回值有强类型,但注册表能存"任意工具" types.tsAgentTool + AnyAgentTool
校验 LLM 给的参数对不对,不对就告诉它怎么改 schema-validator.ts + zod-schema-validator.ts
沙箱 路径安全、超时、输出截断、二进制检测等横切关注 sandbox.ts + eol.ts + diff.ts

不在范围内的------比如"工具依赖注入"、"工具远程调用"、"工具 trace 持久化"、"工具 dashboard"------我们坚决不做。这些一旦做了,工具系统就退化成"再做个微服务框架"。

下面按这四类一一展开。


3. 注册:ToolRegistry 的三件事

注册表本质上是个有过滤能力的 Map。三个动作:

typescript 复制代码
// src/tool/registry.ts(简化版)
export class ToolRegistry {
  readonly #tools = new Map<string, AnyAgentTool>();

  // ① 注册
  register<I, O>(tool: AgentTool<I, O>): this {
    return this.registerErased(eraseTool(tool));   // 自动擦类型
  }

  // ② 按名查
  get(name: string): AnyAgentTool | undefined {
    if (!this.#isToolEnabled(name)) return undefined;
    return this.#tools.get(name);
  }

  // ③ 列出可用的
  list(): AnyAgentTool[] {
    const result: AnyAgentTool[] = [];
    for (const [name, tool] of this.#tools) {
      if (this.#isToolEnabled(name)) result.push(tool);
    }
    return result;
  }
}

真正麻烦的是"过滤"------三层 enable 检查

list() 不是简单 Map.values()在 agent 这种"动态拼装"场景里,工具的可用性是组合出来的:

typescript 复制代码
#isToolEnabled(name: string): boolean {
  const tool = this.#tools.get(name);
  if (!tool) return false;

  // 1. 用户在配置里禁用?------比如项目中禁用了 bash
  if (this.#disabledNames.has(name)) return false;

  // 2. Feature Flag 关闭?------比如某个工具还在实验阶段
  if (!this.#featureFlagChecker(name)) return false;

  // 3. Provider 兼容?------比如这个工具只支持 Anthropic
  if (this.#provider && tool.supportedProviders.length > 0) {
    if (!tool.supportedProviders.includes(this.#provider)) return false;
  }

  return true;
}

这一段代码里藏着一个非显然的设计决定:为什么是 AND 而不是 OR?

考虑这种场景:用户在配置里写了 disabledTools: ["bash"](项目策略禁 bash),但工具自己又标记了 supportedProviders: ["anthropic"](只支持 Claude)。这两条都得满足才启用。任何一条不满足都不可用。

如果用 OR,用户禁用 + Provider 不支持的"双重不可用"就被奇偶校验炸没了。

注册表还做了"按 kind 分桶"的便利 API

typescript 复制代码
listByKind(kind: ToolKind): AnyAgentTool[] { ... }
listReadTools(): AnyAgentTool[]    { return this.listByKind(ToolKind.Read); }
listWriteTools(): AnyAgentTool[]   { return this.list().filter((t) => !isReadOnly(t.kind)); }

这个不是装饰性便利------它是 agent 主循环的关键依赖:读工具可并行 / 写工具要串行。具体见后文 § 8。


4. 类型:AgentTool<I, O> 与类型擦除

这是整个工具系统的"最灵魂"的设计决策之一。

工具自己声明自己的类型

typescript 复制代码
// src/tool/types.ts(节选)
export interface AgentTool<I, O extends ToolResult = ToolResult> {
  readonly name: string;
  readonly kind: ToolKind;
  readonly parameters: JSONSchema;
  readonly description: string;

  // 关键:工具自己的 execute 签名,带 I 的强类型
  execute(args: I, ctx: ToolContext): Promise<O>;

  initialTitle?(args: I): string;
  // ...
}

每个工具的 execute(args, ctx) 在自己的文件里都有完整的 TS 类型 ------read_file 看到 { path: string, startLine?: number },edit_file 看到 { path: string, old_text: string, new_text: string },写错了 IDE 直接报错。

但注册表只能存"任意工具"------这里必须把类型擦掉

typescript 复制代码
// src/tool/types.ts(节选)
export interface AnyAgentTool {
  readonly name: string;
  readonly description: string;
  readonly kind: ToolKind;
  readonly parameters: JSONSchema;
  readonly supportsInputStreaming: boolean;
  readonly supportedProviders: string[];

  // 关键:args 变成 unknown,execute 内部断言
  execute(args: unknown, ctx: ToolContext): Promise<ToolResult>;
  initialTitle?(args: unknown): string;
}

如果注册表保留泛型,那就得 Map<string, AgentTool<unknown, ToolResult>>------接着所有用注册表的地方都得自己知道"哦,这一项是个 read_fileTool"。这套擦除的本质是:让"找工具"和"用工具"在类型层面解耦

擦除本身只一步:

typescript 复制代码
export function eraseTool<I, O extends ToolResult = ToolResult>(
  tool: AgentTool<I, O>,
): AnyAgentTool {
  return {
    get name() { return tool.name; },
    get description() { return tool.description; },
    // ...
    async execute(args: unknown, ctx: ToolContext): Promise<ToolResult> {
      // 内部断言:反正 Registry 调用方知道名字
      return tool.execute(args as I, ctx);
    },
    initialTitle(args: unknown): string {
      return tool.initialTitle?.(args as I) ?? tool.name;
    },
  };
}

调用方拿到 AnyAgentTool 后,需要做的事只有一个:根据名字断言回具体的工具类型:

typescript 复制代码
const readTool = registry.get("read_file")!;
// readTool 是 AnyAgentTool,但我们知道它是 read_file
const result = await readTool.execute({ path: "src/main.ts" }, ctx);
//                                          ^ 强类型仅在我们自己知道时成立

这套"上游强类型 + 注册表擦除 + 执行前断言"是经过权衡的------比"全部 any"安全,比"全部带泛型"灵活。

类型擦除的代价是什么?

唯一代价 :在调用 execute(args) 时,如果 args 是外部输入(从 LLM JSON 解析来的),你没法靠 TS 类型系统保护。这正是下一节要解决的------运行时 schema 校验


5. 校验:Zod 与 JSONSchema 的双轨设计

LLM 给的参数是结构化的 JSON,但它会出错 ------给 read_file 一个 { path: 42 }(数字而不是字符串)、少一个 old_text 字段、timeout 给了 "abc" 等。怎么让 LLM 自我修正?

校验结果必须结构化、可喂回给 LLM

typescript 复制代码
// src/tool/schema-validator.ts(节选)
export interface ValidationIssue {
  path: string;         // JSON Pointer:`$.path`、`$.items[2].name`
  expected: string;     // "string" / "present" / "enum[A,B,C]" / "length >= 3"
  received: string;      // 截断到 60 字符的值描述,避免爆日志
  message: string;       // 人类可读的中文,直接喂给 LLM
}

注意 message 是中文。这个细节救命------LLM 拿到中文错误消息比英文更容易自我修正,因为训练语料里中文 prompt 多。path 用 JSON Pointer 又能精确告诉它"哪个字段错了"。

双轨:JSONSchema 和 Zod 都行,产物同构

工具的参数 schema 有两种声明方式------

typescript 复制代码
// 方式 A:JSONSchema(老 8 个内置工具用)
export const readFileTool: AgentTool<ReadFileArgs> = {
  name: "read_file",
  kind: ToolKind.Read,
  parameters: {
    type: "object",
    properties: {
      path: { type: "string", description: "..." },
      startLine: { type: "number", description: "..." },
    },
    required: ["path"],
    additionalProperties: false,
  },
  // ...
};

// 方式 B:Zod-first(新工具推荐)
import { z } from "zod";
import { defineTool } from "../zod-schema-validator.js";

const ReadFileSchema = z.object({
  path: z.string().min(1, "path 不能为空"),
  startLine: z.number().int().min(1).optional(),
  endLine: z.number().int().min(1).optional(),
});

export const readFileZodTool = defineTool<z.infer<typeof ReadFileSchema>>({
  name: "read_file_zod",
  kind: ToolKind.Read,
  schema: ReadFileSchema,         // 一处写,出三个东西
  async execute(args, ctx) { ... }
});

defineTool 的精髓:写一次 Zod schema,同时拿到 TS 类型 + JSONSchema + 校验器。源头唯一,后面少一半维护工作。

graph LR A[Zod schema<br/>z.object... x] A -->|z.infer| B[TS 类型 I] A -->|zodSchemaToJSONSchema| C[JSONSchema<br/>喂给 LLM] A -->|运行时检测 _def+safeParse| D[Zod 校验路径<br/>给 ValidationIssue] A -.源码里只写一次.-> E[&#34;想加字段?<br/>只改一处&#34;] A2[JSONSchema<br/>手工写] A2 -->|轻量递归校验器| D2[自研校验路径<br/>同构产出 ValidationIssue]

同构这件事比想象重要

校验失败的下游消费者有三个:

  1. LLM ------ 喂进下一轮对话,让它修正
  2. Reflector ------ 决定要不要 reflection / 重试
  3. UI ------ 给开发者展示"哪步出错了"

这三个消费者都不该关心"这个工具是用 Zod 还是 JSONSchema" ------它们只面对 ValidationIssue[]

实现上用一个鸭子类型检测分支:

typescript 复制代码
// src/tool/schema-validator.ts(节选)
export function validateArgs(args: unknown, schema: unknown): ValidationResult {
  // 鸭子类型检测 Zod schema------避免静态 import 拖重
  if (isZodSchema(schema)) {
    return zodSafeValidate(args, schema as never);
  }

  // 否则走 JSONSchema 轻量校验
  if (!isPlainObject(schema)) return { ok: true, issues: [] };
  // ...
}

function isZodSchema(schema: unknown): schema is ZodType {
  const obj = schema as Record<string, unknown>;
  return (
    "_def" in obj &&
    typeof obj.parse === "function" &&
    typeof obj.safeParse === "function"
  );
}

为什么鸭子类型而不是 instanceof z.ZodType?因为静态 import zod 会让 schema-validator 加载时拉入 zod,任何不写 Zod 工具的项目也要背负这个依赖。鸭子检测让 zod 只在确实用到的工具里"按需"被加载。

integer ↔ number 互通这个细节

JS 里没有真正的 int1number,JSON.parse 也给 number。如果 schema 写 integer、LLM 给 1、validate 说"应 integer 实际 number"------这是 false positive

所以:

typescript 复制代码
function typeMatches(actual: string, expected: string): boolean {
  if (actual === expected) return true;
  if (expected === "integer" && actual === "number") return true;
  if (expected === "number" && actual === "integer") return true;
  return false;
}

jsonTypeOf 里区分 integer/number,但校验时互通------这才对得上 LLM 实际行为。


6. 沙箱:横切关注集中在 sandbox.ts

读工具、写工具、bash 工具都关心"路径安全"和"超时"------这些必须集中。我见过有人每个工具写一份 if (path.startsWith("~")) expand...,这是噩梦。

6.1 路径解析与 @ 引用

typescript 复制代码
// src/tool/sandbox.ts(节选)

/** 剩下一个开头的 `@` 引用标记 */
export function stripMentionPrefix(inputPath: string): string {
  if (inputPath.startsWith("@")) return inputPath.slice(1);
  return inputPath;
}

export function resolvePath(inputPath: string, cwd: string): string {
  const stripped = stripMentionPrefix(inputPath);
  const resolved = isAbsolute(stripped) ? stripped : resolve(cwd, stripped);
  return resolve(resolved);   // 二次 normalize
}

为什么二次 resolve?因为 resolve 不会消除 ..(只解析 ~ 和相对路径),所以 resolve("/a/b/../c") 仍是 /a/b/../c。这步把语义"压实"成绝对路径,后面的 realpath 才能可靠比。

@ 这个细节救过我 ------system prompt 里 @<path> 是"文件路径引用"的语法糖,但 LLM 老老实实把 @test.ts 原样传给工具。

6.2 写工具必须confine在白名单里

typescript 复制代码
export async function confine(
  allowedRoots: string[],
  target: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
  if (allowedRoots.length === 0) return { ok: true };   // 没限制就放行

  const realTarget = await realPath(target);

  for (const root of allowedRoots) {
    const realRoot = await realPath(root);
    const rel = relative(realRoot, realTarget);
    // 三种威胁都判到:..跳出、空(==root)、绝对路径
    if (!rel.startsWith("..") && rel !== "" && !rel.startsWith("/") && !rel.startsWith("\\")) {
      return { ok: true };
    }
    if (realTarget === realRoot) return { ok: true };
  }

  return { ok: false, error: `路径 "${target}" 不在允许的写入范围内 ${allowedRoots.join(", ")}` };
}

工具写代码:

typescript 复制代码
if (ctx.writeRoots && ctx.writeRoots.length > 0) {
  const conf = await confine(ctx.writeRoots, filePath);
  if (!conf.ok) {
    return { success: false, data: conf.error, error: "OUTSIDE_WRITE_ROOTS" };
  }
}

这意味着即使 LLM 写出 target = ~/.ssh/authorized_keys,也会被 confine 拒绝。

realPath 是救命细节 ------路径里如果有符号链接,/var/www -> /home/user/www 这种,通过 realpath 解析后才不会被绕过。

6.3 超时中止:外部信号 + 内置计时器

typescript 复制代码
export function createTimeoutSignal(signal?: AbortSignal, timeoutMs = 30_000): AbortController {
  const controller = new AbortController();
  if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
  return controller;
}

为什么外部 signal + 内部计时联动 ?用户可能在 UI 里点 "Stop"(externalAbort),bash 自己跑超 30s(timeoutAbort),任何一个触发都要中止子进程

6.4 bash 子进程的两段杀

bash 的 execCommand 还做了一件粗暴的事:

typescript 复制代码
const timeout = setTimeout(() => {
  child.kill("SIGTERM");
  setTimeout(() => {
    child.kill("SIGKILL");   // 5 秒还不退,强杀
  }, 5000);
}, timeoutMs);

SIGTERM 给个 5s 优雅退出窗口,5s 还没走就用 SIGKILL这避免了"假超时"(进程没真死,资源没释放) ------尤其是 npm installcargo build 这种会 spawn 子子进程的命令。


7. 结果协议:ToolResult 的成功和失败同结构

LLM agent 的工具结果必须遵守一个铁律:成功和失败都返回同一形状的对象。否则 LLM 看到错误时的训练模式就跟正常回应混合,越用越乱。

ToolResult 的核心字段

typescript 复制代码
// src/tool/types.ts(节选)
export interface ToolResult {
  success: boolean;        // 唯一判断成败的标志
  data: string;            // 给 LLM 看的内容(成功是输出,失败是错误)
  error?: string;          // 错误分类标记,LLM 不用关心,但程序要关心
  diff?: FileDiff;         // 仅文件修改类工具携带
  summary?: string;        // 给 UI 看的一行摘要(LLM 看不到)
  issues?: ValidationIssue[];   // schema 校验失败时携带
  denial?: ToolDenial;     // v0.6 新增:权限拒绝详情
}

每个工具错误都必须有一个机器可读的 error code:

错误场景 success error data
文件不存在 false READ_ERROR "读取文件失败:ENOENT..."
文件太大 false FILE_TOO_LARGE "文件过大(15.3MB)..."
二进制文件 false BINARY_FILE "看起来是二进制文件..."
old_text 没找到 false TEXT_NOT_FOUND "未找到要替换的文本..."
old_text 出现多次 false TEXT_MULTIPLE_MATCHES "出现多次,请提供更多上下文..."
超出 writeRoots false OUTSIDE_WRITE_ROOTS "路径不在允许范围内..."
命令退出非 0 false EXIT_CODE_1 "...\n[退出码: 1]"
成功 true (无) 工具输出

错误码不要 Message 这种"描述性"的------error 字段是给程序判断分支用的,必须是稳定的标识符 。LLM 想读错误细节,从 data 里读。

data 永远可被 LLM 直接读

不抛 Error,不扔 undefined,不调用 JSON.stringify(obj)(LLM 看到大堆对象描述会吐)。

失败时 data 是可读的错误描述,LLM 下一轮对话里看到就能自己修正------比如:

typescript 复制代码
return {
  success: false,
  data: "未找到要替换的文本。请确认 old_text 与文件内容完全一致(包括缩进和空格)。",
  error: "TEXT_NOT_FOUND",
};

LLM 看到这段大概率第二轮回放老文本或者先用 read_file 看一眼,而不是卡死。

summary 给 UI,data 给 LLM------两条线

typescript 复制代码
return {
  success: true,
  data: `文件已编辑:${filePath}\n替换位置:第 120 行...`,   // ← 喂 LLM
  summary: `📝 修改: foo.ts (+3 -5)`,                    // ← 喂 UI
  diff,                                                  // ← 喂 UI 比对
};

为什么两条线? 写一个 10MB 文件 diff 出来,UI 直接渲染行不通;但 LLM 需要看完整内容来验证修改对不对。data 是"LLM 友好版",summary 是"人友好版",diff 是"工具友好版"。


8. 工具的并行与串行:从 ToolKind 出发

工具系统的实际能力,不只是"调得通",还包括"调得聪明 "。一个工程性 agent 同时跑 3 个 read_file 是常见需求;同时跑 3 个 edit_file 编辑同一文件就是灾难。

ToolKind 是语义分类

typescript 复制代码
export enum ToolKind {
  Read = "read",       // 纯读,无副作用,可并行
  Edit = "edit",       // 文件/目录内容编辑
  Delete = "delete",   // 删除
  Move = "move",       // 重命名/移动
  Other = "other",     // bash、fetch 兜底
}

export function isReadOnly(kind: ToolKind): boolean {
  return kind === ToolKind.Read;
}

这个字段的本职工作就是 agent 主循环用来决定并行度的开关:

typescript 复制代码
const reads = registry.listReadTools();
const writes = registry.listWriteTools();

// 主循环:
// 1. 把所有 read 类调用 fanout 并行(独立 IO)
// 2. writes 必须等前面批次完成才执行(单线程串行)

为什么这么分?读操作无外部副作用 ------同一秒 5 个 read_file 跑两个不同文件,系统状态不变。写操作有副作用(一个写覆盖另一个,顺序敏感),必须串行。

还有一个隐藏的好处:UI 分类展示

终端里如果把工具调用按 ToolKind 分组渲染,用户一眼能看出"现在在读 / 现在在写":

scss 复制代码
✓ read_file      src/main.ts        (1ms)
✓ read_file      src/utils.ts       (2ms)
✓ grep           "TODO"             (8ms, 3 matches)
── 现在开始写 ──
✓ edit_file      src/main.ts        (+3 -1)
✓ bash           npm test           (exit 0)

这种"读 / 写"分组不是炫技------它让用户对 agent 当前在哪一阶段心里有数


9. 端到端组装

把上面所有零件粘起来是这样的:

graph TB subgraph 注册期 RT[&#34;readFileTool: AgentTool<br/>类型严格&#34;] ET[&#34;editFileTool: AgentTool<br/>类型严格&#34;] BT[&#34;bashTool: AgentTool<br/>类型严格&#34;] end subgraph &#34;注册表 (registry.ts)&#34; REG[&#34;ToolRegistry<br/>tools: Map&#34;] end subgraph &#34;调度期 (Agent Main Loop)&#34; SCHED[&#34;调用方<br/>1. registry.listReadTools() / listWriteTools()<br/>2. validateArgs(args, tool.parameters)<br/>3. 权限闸检查(可选的统一接入点)<br/>4. tool.execute(args, ctx)&#34;] end subgraph 校验 VJ[JSONSchema 校验<br/>→ ValidationIssue[]] VZ[Zod 校验<br/>→ ValidationIssue[]] end subgraph 沙箱 SB[&#34;ToolContext {<br/>cwd, signal, timeout,<br/>writeRoots[] }<br/>+ sandbox.ts 辅助&#34;] end RT -->|eraseTool| REG ET -->|eraseTool| REG BT -->|eraseTool| REG REG --> SCHED SCHED -->|验 schema| VJ SCHED -->|验 schema| VZ SCHED -->|执 行| SB SB -->|ToolResult| SCHED SCHED -->|deny → lastDenial| GT[权限闸<br/>(统一的策略接入点)] SCHED -->|success / fail| LLM[下一轮 LLM] end

一次完整调用的代码长这样:

typescript 复制代码
// ① 找工具
const tool = registry.get(toolName);
if (!tool) return { success: false, data: `工具 "${toolName}" 不存在`, error: "TOOL_NOT_FOUND" };

// ② 校验参数(用工具自己的 schema,JSONSchema 或 Zod 都行)
const validation = validateArgs(rawArgs, (tool as any).schema ?? tool.parameters);
if (!validation.ok) {
  return {
    success: false,
    data: "参数校验失败:\n" + validation.issues.map((i) => i.message).join("\n"),
    error: "INVALID_ARGS",
    issues: validation.issues,
  };
}

// ③ 可选:权限闸检查(若有统一策略接入点;不传则这一段跳过)
const gate = dispatchOptions.gate;     // 调用上下文持有的 Gate,本节不依赖具体实现
if (gate && !(await gate.check(toolName, rawArgs))) {
  return {
    success: false,
    data: `权限拒绝:${gate.lastDenial?.reason ?? "未知原因"}`,
    error: "GATE_DENIED",
    denial: gate.lastDenial,
  };
}

// ④ 准备上下文
const ctx: ToolContext = {
  cwd: process.cwd(),
  signal: externalSignal,
  timeout: 30_000,
  writeRoots: config.writeRoots,   // 可选
};

// ⑤ 执行
try {
  const result = await tool.execute(rawArgs, ctx);
  // ⑥ 任何抛异常都会被 registry.execute 包成 EXECUTION_ERROR(见 registry.ts)
  return result;
} catch (err) {
  return {
    success: false,
    data: `异常:${(err as Error).message}`,
    error: "EXECUTION_ERROR",
  };
}

这就是核心调度逻辑的全部------剩下的都是工具自己的事。


10. 真实工具的例子:edit_file 是怎么"按规矩"写的

edit_file 来讲,因为它涉及沙箱、diff、eol 三个横切关注,最能说明问题:

typescript 复制代码
// src/tool/builtins/edit-file.ts(节选)
export const editFileTool: AgentTool<EditFileArgs> = {
  name: "edit_file",
  kind: ToolKind.Edit,
  description: "对文件进行精确字符串替换...",
  parameters: {
    type: "object",
    properties: {
      path:        { type: "string", description: "..." },
      old_text:    { type: "string", description: "..." },
      new_text:    { type: "string", description: "..." },
    },
    required: ["path", "old_text", "new_text"],
    additionalProperties: false,    // ← 防止 LLM 给多余字段
  },

  async execute(args, ctx) {
    // ① 参数快检(防御性,正式校验在调度期)
    if (!args.path)   return { success: false, data: "缺少 path",   error: "INVALID_ARGS" };
    if (typeof args.old_text !== "string") return { success: false, data: "...",  error: "INVALID_ARGS" };
    if (typeof args.new_text !== "string") return { success: false, data: "...",  error: "INVALID_ARGS" };

    // ② 路径解析
    const filePath = resolvePath(args.path, ctx.cwd);

    // ③ 沙箱:写操作必须在白名单根下
    if (ctx.writeRoots && ctx.writeRoots.length > 0) {
      const conf = await confine(ctx.writeRoots, filePath);
      if (!conf.ok) return { success: false, data: conf.error, error: "OUTSIDE_WRITE_ROOTS" };
    }

    // ④ 读 + LF 归一(原文件可能是 CRLF,LLM 习惯用 LF)
    const content = await readFile(filePath, "utf-8");
    const contentN = toLf(content);
    const oldTextN = toLf(args.old_text);

    // ⑤ 唯一性匹配 ------ 出现 0 次或 2+ 次都报错
    const first = contentN.indexOf(oldTextN);
    if (first === -1) return { success: false, data: "未找到...", error: "TEXT_NOT_FOUND" };
    if (contentN.indexOf(oldTextN, first + 1) !== -1) {
      return { success: false, data: "出现多次...", error: "TEXT_MULTIPLE_MATCHES" };
    }

    // ⑥ 替换并恢复原 EOL
    const newContentN =
      contentN.slice(0, first) +
      toLf(args.new_text) +
      contentN.slice(first + oldTextN.length);
    const writtenContent = normalizeEol(content, newContentN);
    await writeFile(filePath, writtenContent, "utf-8");

    // ⑦ 算 diff 给 UI
    const diff = computeFileDiff(content, writtenContent, filePath);

    return {
      success: true,
      data: `文件已编辑:${filePath}\n替换位置:第 ${startLine} 行\n变更:${diff.additions} +/ ${diff.deletions} -`,
      summary: `📝 修改: ${basename(filePath)} (+${diff.additions} -${diff.deletions})`,
      diff,
    };
  },
};

值得点名的几个细节:

  • additionalProperties: false ------ 防止 LLM 给出多余字段,这些字段多半是幻觉(LLM 不该给的)。
  • 双重错误码 ------ TEXT_NOT_FOUNDTEXT_MULTIPLE_MATCHES 是两个完全不同的情况,LLM 的应对策略不一样(前者读文件确认 old_text,后者加上下文),所以必须细分。
  • CRLF/LF 归一化 ------ 这是 Windows 用户最痛的点。toLf 抹平 EOL 后做匹配,normalizeEol 把替换回写到原文件时还原 EOL,LLM 不会因为平台差异被反复绊倒
  • diff 是结果的一部分,不是副作用 ------ 算 diff 只多几十毫秒,但给 UI 的体验巨大提升(改动一目了然)。

11. Trade-offs 和踩过的坑

坑 1:Zod 引入的代价到底有多大?

zod 大约 50KB(gzip 前)。如果你的项目坚决不用 Zod,完全可以用纯 JSONSchema 撑住------8 个内置工具都是 JSONSchema,跑了半年没问题。

但是 ,每加一个新工具,都得写三遍(JSONSchema + TS interface + 校验),同步改造特别累。Zod-first 一次写对,光节省的人力时间就把那 50KB 赚回来了

正确选择:新工具用 Zod-first,旧工具不动,interface 全留 。我们就是这么干的(_examples/read-file-zod.ts 就是给后人看的样板)。

坑 2:工具抛异常应该被吞掉

如果工具自己 throw new Error,Registry 会拦下来包成 EXECUTION_ERROR。这看起来"温柔",但实际上是保护工具作者------任何工具忘了 try/catch 都不会让整个 agent 崩。

反面教材:有的 agent 框架让异常往上冒,工具作者必须严格 try/catch。结果就是大家互相甩锅"你那边 throw 了"。

坑 3:并行读真的安全吗?

read_file 标 Read,理论上可并行 。但如果 LLM 在同一轮里读了 foo.ts 又读了它产生的日志------逻辑上有依赖。模型自己会 sort 这个依赖,我们不需要工具系统管。

反过来 ,写工具被模型标 Read(想偷懒)怎么办?------别担心,LLM 不会犯这种错 ,因为写工具的 kind: ToolKind.Edit 是声明的,如果模型调用它,我们会按 Edit 走串行逻辑。这是 schema 校验而不是 kindness 校验。

坑 4:ToolResult.data 不要塞对象

typescript 复制代码
// ❌ 错误:LLM 看到一堆 JSON 不认识
return { success: true, data: JSON.stringify(someComplexObject) };

// ✓ 正确:人类语言描述 + 关键事实
return {
  success: true,
  data: `找到 3 个匹配:\n1. src/a.ts:12 → const foo = bar\n2. src/b.ts:5 → ...`,
};

LLM 对"自然语言 + 编号列表"特别敏感,对纯 JSON 描述经常误读。data 字段默认是给 LLM 看的,按 LLM 友好的方式写

坑 5:工具 description 是给 LLM 看的 system prompt

我开始以为 description 是给开发者看的文档,后来发现------它是 LLM 决定"调不调你"的依据。写得糟糕的 description 会让 LLM 永远不调你(或者乱调)。

好实践:

  1. 明确说适用场景 :"适用于查看源代码、配置文件等文本文件"~读取文件~ 有用。
  2. 明确说禁用场景 :"不要用 cat/type 读文件,请用 read_file" ------ 抢同类工具的生态位。
  3. 指明错误修正路径 :"如果 old_text 出现多次,请提供更多上下文" ------ 直接喂给 LLM 当 hint。

坑 6:@ 引用标记这玩意儿得尽早统一

我看见有人让每个工具自己 if (path.startsWith("@")) path = path.slice(1),结果有两个工具忘了 strip,系统 prompt 提的 @xxx.ts 引用语法在它们那里就废了。

typescript 复制代码
// 解决方案:把 stripMentionPrefix 注入 resolvePath,谁都绕不开
export function resolvePath(inputPath: string, cwd: string): string {
  const stripped = stripMentionPrefix(inputPath);   // ← 必经一步
  // ...
}

横切关注,集中做,做一次。所有写文件路径解析的地方都该走 resolvePath ,而不是自己 path.resolve(cwd, args.path)


12. 结语:工具系统的"宪法"

回头看整套设计,会发现它是几条通用原则的具象化:

  • 统一契约 ------ AgentTool.execute(args, ctx) 这一种调用方式撑住所有 8+ 个工具
  • 类型擦除 ------ AnyAgentTool + eraseTool,让"找工具"与"用工具"在类型层面解耦
  • 集中策略 ------ 沙箱 confine 永远是最后一道闸,谁也不许自己再写一份
  • 同构结果 ------ ToolResult.success/data/error 同一形状,LLM 看到的成功和失败都是字符串描述
  • 默认策略 ------ ToolKind.Other 兜底所有非文件类工具

这套工具系统自己内部也"宪法"级的几条规矩:

  • 关注点分离 ------ 沙箱归沙箱、校验归校验、调用归调用,不许塞在一起
  • 同构协议 ------ 成功/失败、Zod/JSONSchema、内置/UDF 必须统一接口
  • 可降级而非崩溃 ------ schema 错了给 issue 让 LLM 重试、文件越界给 error code 让 UI 提示、异常被 Registry.execute 包成 EXECUTION_ERROR 而不是炸进程
  • 横切关注必须集中 ------ 路径、超时、输出截断、EOL、diff 谁写一次就行,不允许每个工具重抄一遍
  • 类型安全可降级 ------ 每个工具自己保留强类型,统一到 Registry 时擦成 AnyAgentTool,调用方按名字回断言;代价是 args 走 schema 校验二次防护

落到代码上,核心模块只有 ~1200 行:types.ts 200 行 / registry.ts 130 行 / sandbox.ts 200 行 / schema-validator.ts 250 行 / zod-schema-validator.ts 400 行。再加 8 个内置工具共 ~1500 行。

这套设计可以直接拷过去用------任何"让 LLM 调工具"的场景都能套。哪天你看到自己 agent 在第 30 个工具上线后代码量没翻倍、可读性没变差、错误处理不混乱------就该知道这不到 2000 行代码值了。

进一步阅读

有问题随时留言。觉得有用的话,顺手点个 收藏 ⭐ ~

相关推荐
字节跳动视频云技术团队1 小时前
让 Agent 成为音视频工作台:AI MediaKit CLI + Skill 发布
人工智能·音视频开发
魏祖潇1 小时前
framework 整合实战——DDD/TDD/SDD 三件套在 framework 仓的真实落地
人工智能·后端
wok1571 小时前
Claude Code 自动更新权限问题解决
claude
咪库咪库咪1 小时前
Vue3-生命周期
前端
Token炼金师2 小时前
去噪扩散:从随机噪声到高保真图像的数学之路
人工智能·aigc
这个DBA有点耶2 小时前
AI写的SQL跑崩了生产库,这锅谁背?
数据库·人工智能·程序员
莪_幻尘2 小时前
你的 AI Skill 越多越蠢?Token 上下文爆炸的求生指南
前端·ai编程
阿里云大数据AI技术2 小时前
阿里云 EMR AI 助手正式发布:从问答工具到全栈智能运维助手
运维·人工智能
lichenyang4532 小时前
从 has.echo 到异步 API 注册表:一次 ASCF API 回调不触发的排查复盘
前端