3. Tool 系统——Agent 之于工具,如手之于笔
前两篇定义了 Tool 接口并在主循环中调用了它们,但用的还是玩具级 mock。这一篇我们把工具系统做实——从安全计算器到带沙箱的文件读取器,再到并行执行引擎,一步到位。
前两篇文章我们把 Agent 的骨架搭起来了。接口定义好了,主循环跑通了,测试也全绿了。
但如果你仔细看之前的代码,你会发现一个尴尬的事实:Agent 在跟空气斗智斗勇。主循环里调用了 config.tools.get(name)、config.tools.listMetadata(),但我们注册进去的唯一工具是一个仅供测试的计算器 mock——它只会返回硬编码的 "42"。
这个工具在任何真实场景下都没用。做测试行,真干活不行。
现在是时候把这个系统做实了。这篇会做三件事:
- 设计一个生产级的工具应该长什么样——不只是接口实现,还有错误类型分层、安全性考量、上下文设计
- 写两个真正的内置工具——一个安全的计算器、一个带沙箱的文件读取器。它们本身就值得讲,同时也是后续系列(Redis 工具、MySQL 工具)的样板
- 实现并行执行引擎——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',
};
}
// ...
}
}
你可能会问:为什么 Tool 的 execute 参数类型是 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() 这类攻击
坦白说一个风险:这个方案不是完全安全的。Buffer、globalThis 等全局对象仍然可通过 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.jpg 为 photo.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。需要考虑几个问题:
- 限速。如果 LLM 一次要调用 20 个工具,并发开 20 个 Promise 可能会把系统资源打满
- 超时。单个工具卡住了不应该拖慢整个批次
- 错误隔离。并行的工具中一个失败了,其他工具的结果依然有效
来看看我的实现:
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 数组。settled 和 chunk 使用相同下标对齐,避免用 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 接受 workDir 和 maxSize,但这些差异都被 Tool 接口屏蔽了。Agent 主循环不关心工具是怎么构造的,只关心 execute() 这个方法。
还有一个细节:ToolContext 的设计。它目前只包含 sessionId 和 metadata 两个字段。`
export interface ToolContext {
sessionId?: string;
metadata?: Record<string, unknown>;
}
metadata 是 Record<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",而不再是一个每次对话都从头开始的工具调用器。
评论
发表评论