3. Tool 系统——Agent 之于工具,如手之于笔

前两篇定义了 Tool 接口并在主循环中调用了它们,但用的还是玩具级 mock。这一篇我们把工具系统做实——从安全计算器到带沙箱的文件读取器,再到并行执行引擎,一步到位。

前两篇文章我们把 Agent 的骨架搭起来了。接口定义好了,主循环跑通了,测试也全绿了。

但如果你仔细看之前的代码,你会发现一个尴尬的事实:Agent 在跟空气斗智斗勇。主循环里调用了 config.tools.get(name)config.tools.listMetadata(),但我们注册进去的唯一工具是一个仅供测试的计算器 mock——它只会返回硬编码的 "42"。

这个工具在任何真实场景下都没用。做测试行,真干活不行。

现在是时候把这个系统做实了。这篇会做三件事:

  1. 设计一个生产级的工具应该长什么样——不只是接口实现,还有错误类型分层、安全性考量、上下文设计
  2. 写两个真正的内置工具——一个安全的计算器、一个带沙箱的文件读取器。它们本身就值得讲,同时也是后续系列(Redis 工具、MySQL 工具)的样板
  3. 实现并行执行引擎——LLM 一次调用多个工具时,怎么让它们同时跑而不是排队等

一个工具的道德底线

在设计具体工具之前,先想一个问题:Agent 的工具和普通的函数有什么区别?

普通函数是你自己调用的。参数你写,结果你读,出了问题你自己知道怎么回事。

Agent 的工具是 LLM 调用的。参数由模型生成,可能有幻觉;调用时机由模型决定,可能出乎你意料;错误信息必须让 LLM 能理解,而不是抛一个 TypeScript 异常等开发者去 catch。

这些差异决定了工具设计的几个底线:

  • 参数校验必须在工具层。不要指望 LLM 永远传对参数。Zod schema 不只是给 LLM 看的描述,更是运行时第一道防线
  • 没有未捕获的异常。工具内部抛出的任何错误都应该被接住,转换成结构化的错误信息返回给 LLM。LLM 不理解 stack trace
  • 安全性不能依赖 LLM 的判断。文件读取工具不能指望 LLM"知道哪些文件不能读"。限制必须在工具实现里硬编码
  • 工具是独立部署单元。一个工具挂了不应该影响其他工具。不能说 CalculatorTool 抛了个异常就把整个 Agent 搞崩了

这些原则听起来像常识,但我看过太多开源项目在工具层用 try-catch 包整个 for 循环——一个工具抛异常,后面所有工具都执行不了。

CalculatorTool:从接口到实现

计算器工具是最简单的 Tool 实现。它没有副作用,输入一个表达式,返回一个数字。

但简单不意味着可以敷衍。来看看"安全计算"这件事到底有多少坑。

export class CalculatorTool implements Tool {
  metadata = calculatorMetadata;

  async execute(
    args: Record<string, unknown>,
    _ctx?: ToolContext
  ): Promise<ToolResult> {
    const { expression, precision } = args as {
      expression: string;
      precision?: number;
    };

    if (!expression || typeof expression !== 'string') {
      return {
        success: false,
        output: 'Error: expression is required and must be a string.',
        error: 'INVALID_ARGUMENT',
      };
    }
    // ...
  }
}

你可能会问:为什么 Toolexecute 参数类型是 Record<string, unknown> 而不是具体的 {expression: string; precision?: number}

因为 Tool 接口需要兼容所有工具。如果 CalculatorTool 的参数类型硬编码成 {expression: string},那 FileReaderTool 的参数类型是什么?没法统一。

接口层面用宽类型,实现层面自己做窄化。每个工具在 execute 入口处拿到的是未校验的原始参数,然后通过 metadata 里的 Zod schema 去做校验。这就是为什么 metadata.parameters 是一个 z.ZodSchema——它既描述了参数结构(给 LLM 看),又校验了参数内容(给运行时用)。

回到计算器的安全实现。核心挑战是:怎么执行用户传入的数学表达式,但不让用户执行任意代码?

最差的做法是 eval(expression)。不用说,这等于给 LLM 一把上膛的枪。稍微好一点的是 new Function('return ' + expression),但它仍然太危险——LLM 可以构造 process.exit(1) 之类的表达式。

我用的方案是:白名单模式。只允许纯数学符号和显式声明的安全函数。

const SAFE_MATH_FUNCTIONS: Record<string, (...args: number[]) => number> = {
  abs: Math.abs,
  sqrt: Math.sqrt,
  sin: Math.sin,
  cos: Math.cos,
  // ...
};

function safeEval(expr: string): number {
  // 只允许安全字符
  const sanitized = expr.replace(/\s/g, '');
  if (!/^[\d+\-*/().,%^a-zA-Z\[\]]+$/.test(sanitized)) {
    throw new Error('Expression contains disallowed characters');
  }

  // 替换安全函数调用
  let processed = expr;
  processed = processed.replace(/(\d+)\s*\^\s*(\d+)/g, 'Math.pow($1, $2)');
  for (const [name] of Object.entries(SAFE_MATH_FUNCTIONS)) {
    const re = new RegExp(`\\b${name}\\s*\\(`, 'g');
    processed = processed.replace(re, `Math.${name}(`);
  }

  // 注入 process: undefined,覆盖全局的 process 对象
  // 注意:Buffer、globalThis 等仍可访问,生产环境用 vm 模块或 mathjs
  const fn = new Function(
    ...Object.keys(SAFE_MATH_FUNCTIONS),
    'process',
    `"use strict"; return (${processed})`
  );
  return fn(...Object.values(SAFE_MATH_FUNCTIONS), undefined);
}

这个实现的关键点:
- 字符白名单:正则 /^[\d+\-*/().,%^a-zA-Z\[\]]+$/ 拦截任何不是数学表达式的输入
- 函数白名单:只用 SAFE_MATH_FUNCTIONS 里显式声明的函数,不暴露 Math 对象本身
- 作用域隔离new Function 的参数列表把安全函数和 process 都作为参数传入,process 值为 undefined,阻断了 process.exit() 这类攻击

坦白说一个风险:这个方案不是完全安全的BufferglobalThis 等全局对象仍然可通过 new Function 访问,字符白名单中的方括号 [] 也可以用来构造数组访问表达式。生产环境请用 vm 模块或 mathjs。但作为 Tool 系统的安全设计原则演示,这个白名单方案已经足够说明问题了:"你永远不能让 LLM 决定安全边界"。

你可能会问:为什么不用 mathjs 这种现成的库?因为这是一个零依赖的工具演示,而且我想展示"安全 eval"这个经典问题的解决思路。生产环境用 mathjs 没问题。

FileReaderTool:沙箱、路径遍历、二进制检测

如果说 CalculatorTool 展示了"参数安全",那 FileReaderTool 展示的就是"环境安全"。

Agent 需要读取文件是很常见的场景——读配置文件、读代码、读文档。但如果 LLM 构造了 ../../etc/passwd 这样的路径,你的工具必须能挡住。

function isPathTraversal(filePath: string): boolean {
  // 用 split 分割路径段,逐段检查 '..'
  // 用 includes('..') 会把文件名 "test..txt" 误判为路径遍历
  const normalized = path.normalize(filePath);
  const segments = normalized.split(path.sep);
  return (
    segments.includes('..') ||
    path.isAbsolute(normalized)
  );
}

const resolved = path.join(this.options?.workDir ?? DEFAULT_WORK_DIR, filePath);

// 检查路径遍历
if (isPathTraversal(filePath)) {
  return {
    success: false,
    output: 'Error: Path traversal is not allowed.',
    error: 'SECURITY_VIOLATION',
  };
}

路径遍历防护是必须的,但这只是第一层。还有两个坑:

文件大小限制。LLM 的 context window 是有限的。如果 Agent 读了一个 100MB 的日志文件,整个工具结果会撑爆 token 预算。所以每个读文件操作必须有大小上限。

const stat = await fs.stat(resolved);
if (stat.size > sizeLimit) {
  return {
    success: false,
    output: `Error: File size (${stat.size} bytes) exceeds limit (${sizeLimit} bytes).`,
    error: 'SIZE_EXCEEDED',
  };
}

二进制文件检测。Agent 不应该试图读取图片或二进制文件——结果是一堆乱码,浪费 token。通过检查文件头魔数和 NUL 字节可以准确判断:

function isBinaryFile(buf: Buffer): boolean {
  const BINARY_PATTERNS = [
    Buffer.from([0xff, 0xd8]), // JPEG
    Buffer.from([0x89, 0x50]), // PNG
    // ...
  ];
  for (const pattern of BINARY_PATTERNS) {
    if (buf.slice(0, pattern.length).equals(pattern)) return true;
  }
  return buf.includes(0); // NUL 字节是强信号
}

这个检测方法比单纯检查文件扩展名可靠得多。重命名 photo.jpgphoto.txt 是无法绕过魔数检测的。

FileReaderTool 里还有一个细节值得注意:错误类型的结构化

error: 'FILE_NOT_FOUND' | 'PERMISSION_DENIED' | 'SIZE_EXCEEDED' | 'BINARY_FILE' | 'SECURITY_VIOLATION' | 'IO_ERROR'

为什么不在 output 里只写一条错误信息就够了?因为这些错误类型可以让 Agent 上层做智能路由:
- FILE_NOT_FOUND → 可以换个路径重试
- SIZE_EXCEEDED → 可以用 head 之类的方式只读前几行
- PERMISSION_DENIED → 不用重试了,权限问题是硬伤

如果只是为了"告诉 LLM 出错了",那一个 error 字段就够了。但如果是为了让 LLM 能做下一步决策,错误信息需要结构化。

并行执行:让工具同时跑起来

到目前为止,Agent 主循环是按顺序处理 tool_calls 的:

for (const tc of response.toolCalls) {
  const result = await tool.execute(tc.args);
  // ...
}

这在大多数场景下没问题。但如果 LLM 一次要求调用三个工具——查天气、查日历、查交通——这三个工具之间没有任何依赖关系,为什么不让它们同时跑?

并行执行的核心价值不是性能,而是 Latency 的方差控制。串行执行时,三个工具的总耗时是它们的耗时之和;并行执行时,总耗时就取决于最慢的那个。

但在 Agent 场景下,并行不是简单的 Promise.all。需要考虑几个问题:

  1. 限速。如果 LLM 一次要调用 20 个工具,并发开 20 个 Promise 可能会把系统资源打满
  2. 超时。单个工具卡住了不应该拖慢整个批次
  3. 错误隔离。并行的工具中一个失败了,其他工具的结果依然有效

来看看我的实现:

export async function executeToolsParallel(
  registry: ToolRegistry,
  calls: ToolCallRequest[],
  options?: ParallelExecutionOptions
): Promise<ParallelExecutionResult> {
  const results = new Map<string, ToolResult>();
  const failures: Array<{ id: string; name: string; error: string }> = [];
  const maxConcurrency = options?.maxConcurrency ?? 5;
  const timeoutMs = options?.timeoutMs ?? 30_000;
  const failFast = options?.failFast ?? false;

  // 分块执行:每批最多 maxConcurrency 个
  const chunks: ToolCallRequest[][] = [];
  for (let i = 0; i < calls.length; i += maxConcurrency) {
    chunks.push(calls.slice(i, i + maxConcurrency));
  }

  for (const chunk of chunks) {
    // 如果 failFast 模式下已有失败,跳过后续批次
    if (failFast && failures.length > 0) {
      for (const call of chunk) {
        failures.push({
          id: call.id, name: call.name,
          error: 'Skipped due to prior failure (failFast)',
        });
      }
      continue;
    }

    // 当前批次并行执行
    const settled = await Promise.allSettled(
      chunk.map(async (call) => {
        const tool = registry.get(call.name);
        if (!tool) throw new ToolNotFoundError(call.name);

        const result = await withTimeout(tool.execute(call.args), timeoutMs);
        return { id: call.id, name: call.name, result };
      })
    );

    // 聚合结果:成功的结果存入 Map,失败的记录到 failures
    for (let i = 0; i < settled.length; i++) {
      const call = chunk[i];
      const outcome = settled[i];

      if (outcome.status === 'fulfilled') {
        const { id, name, result } = outcome.value;
        results.set(id, result);
        if (!result.success) {
          failures.push({ id, name, error: result.error ?? 'Unknown error' });
        }
      } else {
        const reason = outcome.reason;
        failures.push({
          id: call.id,
          name: call.name,
          error: reason instanceof ToolNotFoundError
            ? `Tool '${call.name}' not found`
            : reason instanceof TimeoutError
              ? `Tool execution timed out`
              : `Unexpected error: ${reason instanceof Error ? reason.message : String(reason)}`,
        });
      }
    }
  }

  return {
    results,
    successes: results.size,
    failures,
    elapsedMs: Date.now() - start,
  };
}

关键设计决策:

分块 + 限速。我用了分块策略而不是信号量。每批最多 maxConcurrency 个,每批内部全部跑完再开始下一批。这样做的好处是简单——不需要引入信号量或并发队列,逻辑非常清晰。坏处是如果有一个工具耗时特别长,同一批的其他工具即使毫秒级完成也要等这个慢工具。如果你的场景有耗时差异极大的工具(比如计算器 ms 级 vs 网络请求数十秒),建议换成信号量来控制并发。

超时包装器。每个工具的执行被 withTimeout() 包裹,超过 timeoutMs(默认 30 秒)时自动 reject。这个超时是防止"永远不会结束"的工具——比如网络请求卡死、死循环等。

failFast 选项。默认情况下,一个工具失败不影响其他工具。但外部可以传入 failFast: true,这样一旦有一个工具失败,后续批次全部跳过。这个模式适用于批处理场景——如果第一批数据校验失败了,后面的不用处理了。

结果聚合。每个并行的结果通过 Promise.allSettled 收集,fulfilled 的结果存入 results Map,失败的记录到 failures 数组。settledchunk 使用相同下标对齐,避免用 indexOf 做模糊匹配。

这个并行执行器目前还不是 Agent 主循环的默认行为。我只是把它作为工具模块的补充暴露出来。为什么不做成默认?因为串行执行有一个好处:稳定性。LLM 看到前一个工具的结果后可能会改变对后续工具参数的判断。串行执行给了 LLM "观察-调整"的机会。并行执行则假设所有工具调用是独立的。

这个取舍没有标准答案。我倾向于告诉使用者:"默认串行,你确信工具之间无依赖时可以并行"。

Tool 接口之上的抽象

写完两个具体工具和一个执行器后,回头看我们最初的 Tool 接口,你会发现它足够抽象,没有泄露任何具体实现的细节:

export interface Tool {
  metadata: ToolMetadata;
  execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
}

这个接口能承载 CalculatorTool 这样的纯函数工具,也能承载 FileReaderTool 这样的 IO 工具。

不暴露具体实现细节的接口才是好接口。CalculatorTool 构造函数没有参数,FileReaderTool 接受 workDirmaxSize,但这些差异都被 Tool 接口屏蔽了。Agent 主循环不关心工具是怎么构造的,只关心 execute() 这个方法。

还有一个细节:ToolContext 的设计。它目前只包含 sessionIdmetadata 两个字段。`

export interface ToolContext {
  sessionId?: string;
  metadata?: Record<string, unknown>;
}

metadataRecord<string, unknown> 类型——这是一个逃生舱口。后续系列(Redis、MySQL)可以通过 metadata 注入连接池、客户端等依赖,而不需要修改 ToolContext 这个核心接口。比如 MySQL 系列的上下文透传:

// Agent 端注入
ctx.metadata = { dbPool: mysqlPool };

// Tool 内使用
const pool = ctx?.metadata?.dbPool as Pool | undefined;

为什么用 metadata 透传而不是在接口上加新字段?因为每次加新工具就修改核心接口违反了接口闭合原则。metadata 是稳定的,工具之间的差异应该由使用方自己管理。

代码目录结构与使用方式

src/tools/
├── calculator.ts         # 计算器工具
├── file-reader.ts        # 文件读取工具(带沙箱)
├── parallel-executor.ts  # 并行执行引擎
└── index.ts              # 统一导出

使用方式:

import { DefaultToolRegistry } from 'ts-agent-core';
import { CalculatorTool, FileReaderTool } from 'ts-agent-core/tools';

const registry = new DefaultToolRegistry();
registry.register(new CalculatorTool());
registry.register(new FileReaderTool({ workDir: './sandbox' }));

// Agent 的 tool registry 注入
const agent = new DefaultAgent({
  tools: registry,
  // ...
});

注意到 DefaultToolRegistry.register() 会检查重名工具(详见第 1 篇的 ToolRegistry 接口定义):

register(tool: Tool): void {
  if (this.tools.has(tool.metadata.name)) {
    throw new Error(`Tool '${tool.metadata.name}' is already registered`);
  }
  this.tools.set(tool.metadata.name, tool);
}

这是为了防止不小心重复注册同名工具。LLM 如果看到两个相同名称的工具定义,可能会混淆。工具注册是幂等的——一个名字只能对应一个实现

如果后续系列需要覆盖已有工具,使用 unregister + register

registry.unregister('file_reader');
registry.register(new SecureFileReaderTool({ /* 更严格的配置 */ }));

为什么这个设计能"长"

回到系列的主线:这个项目必须能长。Tool 系统的扩展性体现在哪里?

新系列(Redis、MySQL)要做的事情很简单:实现 Tool 接口,注册到 DefaultToolRegistry。Zero config,zero core changes。

class RedisTool implements Tool {
  metadata = {
    name: 'redis_query',
    description: '在 Redis 中执行查询',
    parameters: z.object({
      key: z.string(),
      command: z.enum(['GET', 'SET', 'DEL']),
    }),
  };

  async execute(args, ctx) {
    // 从 ctx.metadata 获取 Redis 连接
    const redis = ctx?.metadata?.redis as Redis | undefined;
    // ...
  }
}

registry.register(new RedisTool());

不需要修改 DefaultAgent,不需要修改 Tool 接口,不需要修改 DefaultToolRegistry。三无变更。

这就是接口先行的力量——不是你写了接口所以扩展容易,而是你规定了扩展的契约,所以每个人都按这个契约办事,不会乱改核心

下一篇预告

Tool 系统做完之后,Agent 的基本能力已经完整了:它能跟 LLM 对话,能调用工具,能处理并行执行。

但 Agent 目前为止还是个"健忘鬼"——每轮对话结束后,所有上下文就丢了。下一篇,我会实现消息模型和记忆系统,让 Agent 能记住历史对话,能在不同 run 之间保持上下文。

然后我们会做一个"会记事的 Agent",而不再是一个每次对话都从头开始的工具调用器。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个