文章总结: 本文是《从零构建AI驱动的二进制安全系统》系列第3章,重点讲解AI安全系统中工具集成与函数调用机制。核心内容包括:工具系统四层架构设计(Schema定义、注册发现、执行处理、描述优化)、使用Zod库定义工具参数结构并生成JSONSchema、采用装饰器模式实现工具注册与动态发现机制。文章通过天气查询工具等代码示例,演示如何让Agent从只能对话升级为能执行文件操作和网络查询的实用系统。
综合评分: 85
文章分类: AI安全,安全开发,安全工具,二进制安全
3. 工具集成与函数调用
原创
李北辰 李北辰
SPEEDCoding
2026年5月13日 22:20 山西
在小说阅读器读本章
去阅读
完整docx文件关注公众号回复:从零构建AI驱动的二进制安全系统
前两章你已经了解了Agent的核心架构模式——ReAct的循环思维和Plan-and-Execute的计划执行策略,也构建了ConfigManager、LLM Provider、MessageHistory、ToolRegistry和ExecutionEngine等基础模块。但一个只能”思考”却无”手脚”的Agent,就像一位被困在玻璃房中的大脑:洞察力再强,也无法触碰外部世界。工具(Tool)就是Agent的手脚,Function Calling(函数调用)则是连接大脑与手脚的神经系统。
想象一个场景:用户问Agent”帮我看看sample-project目录下的main.py文件质量如何,顺便检查一下它的依赖是否有过时的”。没有工具的Agent只能回答”我无法访问您的文件系统”——这是一个教科书式的正确答案,却毫无实用价值。而配备了文件系统工具、代码分析工具和HTTP查询工具的Agent,则会这样工作:首先调用 list_directory 确认项目结构,然后调用 read_file 读取main.py的内容,接着调用 analyze_file 计算圈复杂度和检测代码异味,再调用 read_file 读取requirements.txt,最后调用 http_get 查询PyPI确认依赖版本——整个过程完全自主,无需人工干预。这种从”只能对话”到”能做事”的跨越,正是工具集成赋予Agent的核心能力。
在AI工程实践中,工具系统的质量往往决定了一个Agent的实用上限。再强大的LLM,如果无法准确地调用工具、处理工具返回的结果、从错误中恢复,都只能是一个”聪明的聊天机器人”。本章将带你从零开始构建一套完整的工具系统。你将学习如何用Zod定义工具参数结构、用装饰器模式实现工具注册、如何安全地执行文件和网络操作、如何让LLM准确理解工具用途,最终整合第1-3章的全部知识构建一个支持文件分析和网络查询的命令行Agent工具。
3.1 工具(Tool)系统设计
在深入实现之前,你需要理解工具系统的整体架构。一个成熟的工具系统包含四个核心层面:Schema定义层负责描述工具的参数结构,让LLM知道调用工具时需要传什么参数;注册与发现层负责管理工具的生命周期,支持动态添加和查询;执行与错误处理层负责调用工具并处理超时、异常等各种边界情况;描述优化层则确保LLM能准确理解每个工具的用途,减少误调用。这四个层面协同工作,构成了Agent与外部世界交互的完整管道。
从LLM的视角来看,工具调用遵循一个固定的循环:用户输入 → LLM分析意图 → 判断是否需要工具 → 生成工具调用JSON → 系统执行工具 → 结果返回LLM → LLM基于结果继续思考或生成最终回复。这个循环就是Function Calling的核心流程。你的工具系统需要在这个循环的每个环节都提供可靠的支持:注册阶段给LLM完整的工具目录,执行阶段可靠地运行工具并返回结构化结果,错误处理阶段确保即使工具失败也不会打断整个对话流程。
3.1.1 工具的定义与Schema:Zod对象定义工具参数结构,自动生成JSON Schema
工具的本质是一个带有元数据的函数。这个元数据告诉LLM:这个工具叫什么、能做什么、需要传入什么参数。LLM收到用户的自然语言请求后,会分析意图并生成一个结构化调用——{"tool": "read_file", "arguments": {"path": "/home/user/main.py"}}。但LLM如何知道 read_file 存在、它需要哪些参数?答案就是JSON Schema。
JSON Schema是一种标准化的数据格式描述语言(IETF RFC规范)。当你向LLM发送请求时,需要将每个工具的参数定义转换为JSON Schema格式作为请求的一部分。OpenAI的Function Calling接口、Anthropic的Tool Use接口、Google的Function Calling接口,以及MCP协议,底层都依赖JSON Schema来描述工具参数。理解JSON Schema的语法规则,是设计工具系统的必备基础。
在TypeScript生态中,Zod是定义参数Schema的最佳选择。它不仅是运行时类型验证库,还能自动生成JSON Schema。更重要的是,Zod与TypeScript的类型系统深度融合——z.infer<typeof schema> 能自动推导出参数的类型,你无需维护两套定义。这种”单一真相源”的设计大大降低了维护成本,当你修改参数结构时,类型系统和LLM接口会自动同步更新。
来看第一个完整代码示例,涵盖Zod的各种参数类型:
// examples/01_tool_schema_definition.ts
import { z } from "zod";
// ====== 基础类型参数 ======
// 1. 字符串参数(最常用)
const stringSchema = z.object({
query: z.string()
.min(1, "搜索关键词不能为空")
.max(200, "关键词过长,最大200字符")
.describe("搜索关键词,如 'TypeScript 教程'"),
});
// 2. 数值参数与范围限制
const numberSchema = z.object({
page: z.number()
.int("页码必须为整数")
.min(1, "页码从1开始")
.max(1000, "最大页码1000")
.optional()
.default(1)
.describe("页码,默认第1页"),
pageSize: z.number().int().min(1).max(100).optional().default(20)
.describe("每页条数,默认20条"),
});
// 3. 布尔参数
const booleanSchema = z.object({
recursive: z.boolean().optional().default(false)
.describe("是否递归处理子目录,默认 false"),
});
// ====== 复合类型参数 ======
// 4. 枚举参数(限制取值范围)
const enumSchema = z.object({
format: z.enum(["json", "markdown", "table", "yaml"])
.optional().default("json")
.describe("输出格式:json(结构化数据)、markdown(易读文本)、table(表格)、yaml"),
});
// 5. 对象嵌套(复杂配置)
const nestedSchema = z.object({
file: z.string().describe("目标文件路径"),
options: z.object({
encoding: z.enum(["utf8", "base64", "binary"]).default("utf8")
.describe("文件编码格式"),
maxSize: z.number().int().max(10 * 1024 * 1024).default(1024 * 1024)
.describe("最大读取字节数,默认1MB"),
}).optional().describe("高级读取选项"),
});
// 6. 数组参数与对象数组
const arraySchema = z.object({
files: z.array(z.string()).min(1).max(100)
.describe("待处理的文件路径列表"),
operations: z.array(z.object({
type: z.enum(["read", "write", "delete", "analyze"]),
target: z.string().describe("操作目标路径"),
})).min(1).describe("要执行的操作序列"),
});
// ====== 工具接口与Schema生成器 ======
interface ToolDefinition<T extends z.ZodTypeAny> {
name: string;
description: string;
parameters: T;
execute: (args: z.infer<T>) => Promise<unknown>;
}
function zodToJsonSchema(schema: z.ZodTypeAny): any {
if (schema instanceof z.ZodObject) {
const shape = schema.shape;
const properties: Record<string, any> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
const zodValue = value as z.ZodTypeAny;
properties[key] = extractTypeInfo(zodValue);
if (!(zodValue instanceof z.ZodOptional) && !(zodValue instanceof z.ZodDefault))
required.push(key);
}
return { type: "object", properties, required };
}
return { type: "unknown" };
}
function extractTypeInfo(schema: z.ZodTypeAny): any {
if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault)
return extractTypeInfo(schema._def.innerType);
if (schema instanceof z.ZodString) return { type: "string", description: schema.description };
if (schema instanceof z.ZodNumber) return { type: "number", description: schema.description };
if (schema instanceof z.ZodBoolean) return { type: "boolean", description: schema.description };
if (schema instanceof z.ZodEnum) return { type: "string", enum: schema._def.values, description: schema.description };
if (schema instanceof z.ZodArray) return { type: "array", items: extractTypeInfo(schema._def.type), description: schema.description };
if (schema instanceof z.ZodObject) return zodToJsonSchema(schema);
return { type: "string" };
}
// ====== 实战:天气查询工具 ======
const weatherSchema = z.object({
city: z.string().describe("城市名称,如 '北京'、'Shanghai'、'Tokyo'"),
units: z.enum(["celsius", "fahrenheit"]).optional().default("celsius")
.describe("温度单位:celsius(摄氏度)或 fahrenheit(华氏度)"),
});
const weatherTool: ToolDefinition<typeof weatherSchema> = {
name: "get_weather",
description: `获取指定城市的当前天气信息。当用户询问天气、温度、湿度、降雨或户外活动建议时,必须使用此工具。不适用于历史天气查询或长期预报。`,
parameters: weatherSchema,
execute: async ({ city, units = "celsius" }) => ({
city, temperature: units === "celsius" ? 25 : 77,
condition: "Sunny", humidity: 60, units,
}),
};
console.log(JSON.stringify(zodToJsonSchema(weatherSchema), null, 2));
这个示例覆盖了Zod的六种核心类型。字符串 z.string() 是最常用的参数类型,通过 .min() 和 .max() 限制长度。数值 z.number() 配合 .int() 确保整数,配合 .min() 和 .max() 限定范围。枚举 z.enum() 是控制LLM参数取值的最有效方式。特别值得关注的是 .optional().default() 链——它标记参数为可选并提供默认值,LLM不确定时可以省略这个参数。
为什么要用Zod而不是直接手写JSON Schema?答案有三个层面。第一是类型安全:Zod Schema在编译时提供TypeScript类型推断,z.infer<typeof weatherSchema> 会自动推导出参数类型。第二是运行时验证:工具被调用时,Zod的 safeParse() 自动校验参数合法性,不合法的调用在执行前就会被拦截。第三是描述内联:.describe() 与类型定义写在一起,避免了”改Schema忘改描述”的维护问题。这三者的结合让Zod成为工具Schema定义的事实标准。
3.1.2 工具注册与发现机制:装饰器模式注册、运行时工具列表动态构建
定义好工具后,你需要一个注册中心来统一管理它们。ToolRegistry是Agent架构中的核心模块,负责工具的注册、发现和元数据查询。这里采用装饰器模式来实现——这是TypeScript中最优雅的元数据注册方式。
装饰器模式的核心思想是”声明即注册”。当你用 @Tool({...}) 装饰一个类方法时,工具的元数据就被附加到该类的元数据存储中。运行时通过反射读取这些元数据,自动完成工具注册。这种方式让你无需在代码角落维护冗长的注册列表,工具的定义和实现始终在一起。
// examples/02_tool_registry_decorator.ts
import { z } from "zod";
import "reflect-metadata";
const TOOL_METADATA_KEY = Symbol("tool:metadata");
interface ToolMetadata {
name: string;
description: string;
schema: z.ZodTypeAny;
target: any;
propertyKey: string | symbol;
}
function Tool(config: { name: string; description: string; schema: z.ZodTypeAny }) {
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const existing: ToolMetadata[] =
Reflect.getMetadata(TOOL_METADATA_KEY, target.constructor) || [];
existing.push({ name: config.name, description: config.description,
schema: config.schema, target, propertyKey });
Reflect.defineMetadata(TOOL_METADATA_KEY, existing, target.constructor);
};
}
class ToolRegistry {
private tools = new Map<string, {
metadata: ToolMetadata;
handler: (args: any) => Promise<unknown>;
jsonSchema: object;
}>();
registerFromInstance(instance: any): void {
const constructor = instance.constructor;
const tools: ToolMetadata[] =
Reflect.getMetadata(TOOL_METADATA_KEY, constructor) || [];
for (const meta of tools) {
const method = instance[meta.propertyKey].bind(instance);
const jsonSchema = this.zodToJsonSchema(meta.schema);
this.tools.set(meta.name, { metadata: meta, handler: method, jsonSchema });
console.log(`[ToolRegistry] Registered: ${meta.name}`);
}
}
register<T extends z.ZodTypeAny>(name: string, description: string,
schema: T, handler: (args: z.infer<T>) => Promise<unknown>): void {
if (this.tools.has(name)) throw new Error(`工具 '${name}' 已存在`);
this.tools.set(name, {
metadata: { name, description, schema, target: null, propertyKey: name },
handler, jsonSchema: this.zodToJsonSchema(schema),
});
}
get(name: string) { return this.tools.get(name); }
has(name: string): boolean { return this.tools.has(name); }
get size(): number { return this.tools.size; }
getToolNames(): string[] { return Array.from(this.tools.keys()); }
listTools(): Array<{ name: string; description: string; parameters: object }> {
return Array.from(this.tools.values()).map(({ metadata, jsonSchema }) => ({
name: metadata.name, description: metadata.description, parameters: jsonSchema,
}));
}
private zodToJsonSchema(schema: z.ZodTypeAny): object {
const jsonSchema: any = { type: "object", properties: {}, required: [] };
if (schema instanceof z.ZodObject) {
const shape = schema.shape;
for (const [key, value] of Object.entries(shape)) {
const v = value as z.ZodTypeAny;
jsonSchema.properties[key] = this.extractZodType(v);
const isOptional = v instanceof z.ZodOptional || v instanceof z.ZodDefault;
if (!isOptional) jsonSchema.required.push(key);
}
}
return jsonSchema;
}
private extractZodType(schema: z.ZodTypeAny): any {
if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault)
return this.extractZodType(schema._def.innerType);
if (schema instanceof z.ZodString) return { type: "string", description: schema.description };
if (schema instanceof z.ZodNumber) return { type: "number", description: schema.description };
if (schema instanceof z.ZodBoolean) return { type: "boolean", description: schema.description };
if (schema instanceof z.ZodEnum) return { type: "string", enum: schema._def.values, description: schema.description };
if (schema instanceof z.ZodArray) return { type: "array", items: this.extractZodType(schema._def.type), description: schema.description };
if (schema instanceof z.ZodObject) return this.zodToJsonSchema(schema);
return { type: "string" };
}
}
// ====== 使用示例 ======
class FileAnalysisToolkit {
private allowedPaths: string[];
constructor(allowedPaths: string[] = [process.cwd()]) { this.allowedPaths = allowedPaths; }
@Tool({
name: "read_file",
description: `读取指定文本文件的内容。当用户要求查看、分析、总结某个文件时使用此工具。`,
schema: z.object({
path: z.string().describe("文件的绝对路径"),
maxLines: z.number().optional().default(100).describe("最多读取行数"),
}),
})
async readFile({ path, maxLines = 100 }: { path: string; maxLines?: number }) {
if (!this.isPathAllowed(path)) return { error: `访问被拒绝` };
try {
const fs = await import("fs/promises");
const content = await fs.readFile(path, "utf8");
const lines = content.split("\n");
return { path, content: lines.slice(0, maxLines).join("\n"), totalLines: lines.length, truncated: lines.length > maxLines };
} catch (error) { return { error: `读取失败` }; }
}
@Tool({
name: "list_directory",
description: `列出指定目录下的文件和子目录。用于浏览项目结构。`,
schema: z.object({
path: z.string().describe("目录的绝对路径"),
recursive: z.boolean().optional().default(false).describe("是否递归"),
}),
})
async listDirectory({ path, recursive = false }: { path: string; recursive?: boolean }) {
if (!this.isPathAllowed(path)) return { error: `访问被拒绝` };
try {
const fs = await import("fs/promises");
const entries = await fs.readdir(path, { withFileTypes: true, recursive });
return { path, entries: entries.map(e => ({ name: e.name, type: e.isDirectory() ? "directory" : "file" })) };
} catch (error) { return { error: `目录读取失败` }; }
}
private isPathAllowed(targetPath: string): boolean {
const resolved = require("path").resolve(targetPath);
return this.allowedPaths.some(base => resolved.startsWith(require("path").resolve(base)));
}
}
const registry = new ToolRegistry();
const toolkit = new FileAnalysisToolkit(["/home/user/projects"]);
registry.registerFromInstance(toolkit);
console.log("已注册工具:", registry.getToolNames());
这段代码展示了装饰器模式的完整实现链路。@Tool 装饰器是一个高阶函数,接收工具配置,返回实际的装饰器函数。装饰器通过 Reflect.defineMetadata 将工具信息存储在类的元数据中,使用Symbol作为Key避免命名冲突。
需要特别注意的是,reflect-metadata 是TypeScript装饰器系统的核心依赖。你需要在 tsconfig.json 中开启 experimentalDecorators: true 和 emitDecoratorMetadata: true,并在运行时入口文件顶部 import "reflect-metadata"。缺少任何一步都会导致装饰器失效。此外,.bind(instance) 绑定实例上下文是关键——如果不绑定,方法内的 this 会指向错误上下文。
3.1.3 工具执行与错误处理:同步/异步工具执行、错误捕获和友好提示
工具的执行不仅仅是调用一个函数那么简单。生产环境中,你需要处理超时、异常、权限错误等各种情况。一个健壮的工具执行层应该将这些细节屏蔽在内部,向LLM返回结构化的结果——无论成功还是失败。
想象这样一个场景:Agent调用 http_get 查询API,但目标服务器响应缓慢。如果没有超时控制,调用可能挂起几分钟,Agent完全停滞。或者Agent调用 read_file 但路径拼写错误,ENOENT 异常直接抛出导致进程崩溃。这些都是实际开发中的典型问题。
// examples/03_tool_execution_engine.ts
import { z } from "zod";
interface ToolSuccessResult<T = unknown> {
status: "success";
toolName: string;
data: T;
executionTimeMs: number;
}
interface ToolErrorResult {
status: "error";
toolName: string;
error: string;
errorType: "validation" | "execution" | "timeout" | "permission" | "unknown";
suggestion?: string;
}
type ToolResult<T = unknown> = ToolSuccessResult<T> | ToolErrorResult;
interface ExecutionConfig {
timeoutMs?: number;
retries?: number;
validateInput?: boolean;
}
const DEFAULT_CONFIG: Required<ExecutionConfig> = {
timeoutMs: 30000, retries: 1, validateInput: true,
};
class ToolExecutionEngine {
async execute<T extends z.ZodTypeAny>(
toolName: string,
handler: (args: z.infer<T>) => Promise<unknown>,
schema: T,
rawArgs: unknown,
config: ExecutionConfig = {}
): Promise<ToolResult> {
const cfg = { ...DEFAULT_CONFIG, ...config };
const startTime = Date.now();
try {
// 阶段1:参数验证
let validatedArgs: z.infer<T>;
if (cfg.validateInput) {
const parseResult = schema.safeParse(rawArgs);
if (!parseResult.success) {
const issues = parseResult.error.issues
.map(i => `${i.path.join(".")}: ${i.message}`).join("; ");
return this.buildError(toolName, "validation",
`参数验证失败: ${issues}`, "请检查参数类型和必填字段");
}
validatedArgs = parseResult.data;
} else {
validatedArgs = rawArgs as z.infer<T>;
}
// 阶段2:带超时控制的执行
const result = await this.runWithTimeout(
() => handler(validatedArgs), cfg.timeoutMs);
// 阶段3:结果序列化
return {
status: "success", toolName,
data: this.makeSerializable(result),
executionTimeMs: Date.now() - startTime,
};
} catch (error) {
return this.handleExecutionError(toolName, error);
}
}
private async runWithTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`超时(${timeoutMs}ms)`)), timeoutMs);
fn().then(resolve).catch(reject).finally(() => clearTimeout(timer));
});
}
private handleExecutionError(toolName: string, error: unknown): ToolErrorResult {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("超时") || message.includes("timeout"))
return this.buildError(toolName, "timeout", `超时: ${message}`, "减小数据量或增加timeoutMs");
if (message.includes("EACCES") || message.includes("permission"))
return this.buildError(toolName, "permission", `权限不足: ${message}`, "检查文件权限");
if (message.includes("ENOENT") || message.includes("not found"))
return this.buildError(toolName, "execution", `目标不存在: ${message}`, "确认路径拼写");
return this.buildError(toolName, "execution", `失败: ${message}`, "检查参数值是否合法");
}
private buildError(toolName: string, errorType: ToolErrorResult["errorType"],
error: string, suggestion: string): ToolErrorResult {
return { status: "error", toolName, error, errorType, suggestion };
}
private makeSerializable(data: unknown): unknown {
try {
return JSON.parse(JSON.stringify(data, (key, value) => {
if (value instanceof Error) return { message: value.message, name: value.name };
if (typeof value === "function") return undefined;
if (value instanceof Buffer) return value.toString("base64");
if (value instanceof Date) return value.toISOString();
return value;
}));
} catch { return { raw: String(data), note: "结果不可序列化" }; }
}
}
// ====== 使用示例 ======
const engine = new ToolExecutionEngine();
const calcSchema = z.object({
a: z.number().describe("第一个数字"),
b: z.number().describe("第二个数字"),
operation: z.enum(["add", "subtract", "multiply", "divide"]).describe("运算类型"),
});
async function calculator(args: z.infer<typeof calcSchema>) {
const { a, b, operation } = args;
switch (operation) {
case "add": return { result: a + b };
case "subtract": return { result: a - b };
case "multiply": return { result: a * b };
case "divide": if (b === 0) throw new Error("除数不能为零"); return { result: a / b };
}
}
// 三种情况测试
await engine.execute("calc", calculator, calcSchema, { a: 10, b: 3, operation: "add" });
await engine.execute("calc", calculator, calcSchema, { a: 10, b: 0, operation: "divide" });
await engine.execute("calc", calculator, calcSchema, { a: "bad", b: 3, operation: "add" });
这个执行引擎遵循”防御式编程”原则。schema.safeParse() 代替 schema.parse() 进行验证,前者不会抛出异常而是返回包含错误信息的结果对象。runWithTimeout() 利用 Promise 和定时器实现超时控制——如果工具在超时前完成,定时器会被清除;如果超时,Promise被拒绝。
makeSerializable() 方法通过JSON.stringify的replacer函数处理各种非标准类型:Error转为 { message, name }、Buffer转为base64、Date转为ISO字符串。这种”尽力序列化,失败降级”的策略确保无论工具返回什么,LLM都能收到可理解的数据。
3.1.4 工具描述优化:让LLM准确理解工具用途的描述编写技巧
工具描述是LLM决定调用哪个工具的唯一依据。描述写得模糊,LLM会选错工具;写得冗长,会浪费Token且可能引入干扰信息。
| 技巧 | 反例(不佳) | 正例(优化后) | 说明 |
| — | — | — | — |
| 说明”何时使用” | "获取天气数据" | "获取指定城市的当前天气。当用户询问天气、温度、降雨时使用。不适用于历史天气。" | 告诉LLM触发条件和使用边界 |
| 参数举例 | "city: 城市名称" | "city: 城市名称,如 '北京'、'London'" | 具体例子减少歧义 |
| 枚举值说明 | "format: 输出格式" | "format: 可选 'json'(结构化)或 'markdown'(易读),默认 'json'" | 解释每个选项的含义 |
| 指明限制 | "读取文件内容" | "读取文本文件。最大1MB,二进制文件返回错误。" | 明确容量限制和不适用场景 |
| 避免过度描述 | "一个非常强大的工具..." | "计算数学表达式。支持 + - * / 和括号。" | 去掉主观评价,只陈述事实 |
来看一个因描述不当导致的问题案例。假设定义了两个工具:
// 问题版本:描述模糊
const badRead = { name: "read", description: "Read something from the system" };
const badSearch = { name: "search", description: "Search for files and content" };
当用户问”帮我找到项目中所有用到Auth组件的地方”时,LLM可能错误地调用 read。优化后:
// 优化版本:描述精确,边界清晰
const goodRead = {
name: "read_file",
description: `读取单个文本文件的完整内容。适用于查看代码文件、配置文件、日志文件。不支持目录读取,不支持二进制文件。`,
};
const goodSearch = {
name: "grep_code",
description: `在多个代码文件中搜索匹配指定正则表达式的文本行。适用于查找API调用、组件引用。返回文件路径和行内容,不返回文件完整内容。`,
};
优化后的描述清晰地区分了两个工具的使用场景。描述优化还有一个高级技巧:利用”否定式说明”来明确工具边界。"不适用于..."、"请勿用于..." 能有效防止LLM在错误场景下调用工具。在Vercel AI SDK的最佳实践中,建议每个工具描述的第一句话说明”什么时候应该调用”,最后一句话说明”什么时候不应该调用”,这种”双边界”描述法能让准确率提升20%以上。
3.2 内置工具开发
有了工具系统的基础框架,现在来开发Agent最常用的四类内置工具:文件系统操作、网络请求、代码分析和字符串处理。这些工具是后续构建命令行Agent的基石。每类工具的设计都需要考虑安全性、健壮性和LLM友好性——返回的结果应该是结构化的、自解释的,让LLM能直接理解并用于后续推理。
3.2.1 文件系统工具:read_file/write_file/execute_command的实现与安全限制
文件系统是最常用的工具类别之一,Agent需要读取代码、分析项目结构、写入分析报告。但文件操作也是安全风险最高的——unrestricted file access等于给了Agent系统级的控制权。恶意构造的查询可能诱导Agent读取 /etc/passwd、覆盖重要配置文件、或执行破坏性命令。因此,安全限制是文件系统工具的第一设计原则。
从架构角度看,文件系统工具的安全模型应该包含四个层次。第一层是路径白名单,Agent只能访问明确允许的目录;第二层是敏感文件黑名单,即使路径在白名单内,匹配敏感模式的文件仍然禁止访问;第三层是操作限制,写入操作应该有扩展名白名单、文件大小上限,删除操作默认禁用;第四层是命令执行限制,只允许特定的安全命令,拦截危险模式。这四层安全模型构成了纵深防御体系,任何一层被突破,后续层次仍能提供保护。
// examples/04_filesystem_tools.ts
import * as fs from "fs/promises";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
interface FileSecurityPolicy {
allowedBasePaths: string[];
blockedPatterns: RegExp[];
maxReadSize: number;
maxWriteSize: number;
allowedWriteExtensions: string[];
allowDelete: boolean;
maxCommandTimeout: number;
}
const DEFAULT_POLICY: FileSecurityPolicy = {
allowedBasePaths: [process.cwd()],
blockedPatterns: [
/\.env/i, /\.ssh/i, /\.gnupg/i, /\/etc\//, /\/proc\//,
/password/i, /secret/i, /token/i,
],
maxReadSize: 10 * 1024 * 1024,
maxWriteSize: 5 * 1024 * 1024,
allowedWriteExtensions: [".txt", ".md", ".json", ".yaml", ".yml", ".js", ".ts", ".html", ".css", ".log"],
allowDelete: false,
maxCommandTimeout: 30000,
};
class PathSecurityChecker {
constructor(private policy: FileSecurityPolicy) {}
validatePath(targetPath: string): { allowed: boolean; reason?: string } {
const resolved = path.resolve(targetPath);
const inAllowedBase = this.policy.allowedBasePaths.some(base =>
resolved.startsWith(path.resolve(base)));
if (!inAllowedBase) return { allowed: false, reason: `路径 '${resolved}' 超出允许范围` };
for (const pattern of this.policy.blockedPatterns) {
if (pattern.test(resolved)) return { allowed: false, reason: `路径匹配禁止模式` };
}
return { allowed: true };
}
validateWriteExtension(filePath: string): boolean {
return this.policy.allowedWriteExtensions.includes(path.extname(filePath).toLowerCase());
}
}
class FilesystemToolkit {
private security: PathSecurityChecker;
constructor(policy: Partial<FileSecurityPolicy> = {}) {
this.security = new PathSecurityChecker({ ...DEFAULT_POLICY, ...policy });
}
async readFile(args: { path: string; maxLines?: number; offset?: number }) {
const { path: filePath, maxLines = 500, offset = 0 } = args;
const check = this.security.validatePath(filePath);
if (!check.allowed) return { error: check.reason };
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) return { error: `'${filePath}' 不是文件` };
if (stats.size > DEFAULT_POLICY.maxReadSize)
return { error: `文件过大 (${(stats.size / 1024 / 1024).toFixed(1)}MB)` };
// 检测二进制文件(检查null字节)
const buffer = await fs.readFile(filePath);
if (buffer.includes(0)) {
return { path: filePath, size: stats.size, isBinary: true,
preview: buffer.slice(0, 64).toString("hex"),
note: "二进制文件,仅返回十六进制预览" };
}
const content = buffer.toString("utf8");
const allLines = content.split("\n");
return { path: filePath, size: stats.size, lines: allLines.length,
isBinary: false,
content: allLines.slice(offset, offset + maxLines).join("\n"),
truncated: allLines.length > maxLines };
} catch (error) { return { error: `读取失败` }; }
}
async writeFile(args: { path: string; content: string; append?: boolean }) {
const { path: filePath, content, append = false } = args;
const check = this.security.validatePath(filePath);
if (!check.allowed) return { error: check.reason };
if (!this.security.validateWriteExtension(filePath))
return { error: `不允许写入 '${path.extname(filePath)}' 文件` };
if (Buffer.byteLength(content, "utf8") > DEFAULT_POLICY.maxWriteSize)
return { error: `内容过大` };
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { flag: append ? "a" : "w" });
return { path: filePath, bytesWritten: Buffer.byteLength(content, "utf8"), operation: append ? "append" : "write" };
} catch (error) { return { error: `写入失败` }; }
}
async listDirectory(args: { path: string; recursive?: boolean }) {
const { path: dirPath, recursive = false } = args;
const check = this.security.validatePath(dirPath);
if (!check.allowed) return { error: check.reason };
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive });
const tree = entries.map(e => ({
name: e.name, type: e.isDirectory() ? "directory" : "file",
path: path.join(dirPath, e.name),
}));
return { path: dirPath, entries: tree,
summary: { directories: tree.filter(e => e.type === "directory").length,
files: tree.filter(e => e.type === "file").length } };
} catch (error) { return { error: `目录读取失败` }; }
}
async executeCommand(args: { command: string; cwd?: string; timeout?: number }) {
const { command, cwd = process.cwd(), timeout = DEFAULT_POLICY.maxCommandTimeout } = args;
const allowedCommands = ["ls", "cat", "grep", "find", "wc", "head", "tail", "git", "npm", "node", "pwd", "echo"];
const baseCmd = command.trim().split(/\s+/)[0];
if (!allowedCommands.includes(baseCmd)) return { error: `命令 '${baseCmd}' 不在允许列表中` };
const dangerousPatterns = [/rm\s+-rf/i, />\s*\//, /\|.*rm/, /curl.*\|.*sh/i];
for (const p of dangerousPatterns) if (p.test(command)) return { error: `命令包含危险模式` };
const cwdCheck = this.security.validatePath(cwd);
if (!cwdCheck.allowed) return { error: cwdCheck.reason };
try {
const { stdout, stderr } = await execAsync(command, { cwd, timeout, encoding: "utf8", maxBuffer: 1024 * 1024 });
return { command, cwd, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
} catch (error: any) { return { command, error: `执行失败`, stdout: error.stdout?.trim() || "", stderr: error.stderr?.trim() || "", exitCode: error.code || -1 }; }
}
}
export { FilesystemToolkit };
这个文件系统工具集遵循”最小权限原则”。PathSecurityChecker 通过白名单确保Agent只能访问指定目录,通过正则模式匹配阻止访问敏感文件。executeCommand 实现了三层防护:命令白名单只放行安全命令、危险模式正则拦截管道注入攻击、工作目录校验防止越权访问。
二进制文件检测是一个实用的细节。通过检查文件内容中是否包含null字节,工具能自动区分文本文件和二进制文件。对于二进制文件,返回十六进制预览而不是原始字节——大多数二进制格式(图片、可执行文件、压缩包)都会在文件头部包含null字节,而纯文本文件几乎不会。这个检测方法虽然简单,但准确率很高。
3.2.2 网络请求工具:HTTP调用、API请求、结果解析的封装
Agent经常需要查询外部API——天气数据、搜索引擎、GitHub仓库信息、版本检查等。一个健壮的网络请求工具需要处理超时、重试、响应解析和错误降级。与文件系统工具不同,网络工具面临的主要风险是外部依赖的不确定性:服务器可能超时、返回非预期格式、或暂时不可用。
网络请求工具的设计要重点考虑三个问题。第一是超时控制——外部API的响应时间不可控,没有超时可能导致Agent无限期等待。第二是重试策略——临时性网络故障很常见,合理的重试能显著提高成功率。第三是响应解析——不同API返回的数据格式各异,工具应该尽可能智能地处理各种情况,而不是把原始响应直接抛给LLM。
// examples/05_http_request_tool.ts
interface HttpRequestConfig {
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
url: string;
headers?: Record<string, string>;
body?: string | object;
timeoutMs?: number;
retries?: number;
}
interface HttpResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: unknown;
contentType: string;
timeMs: number;
}
class HttpToolkit {
private defaultTimeout = 30000;
private defaultRetries = 1;
async request(config: HttpRequestConfig): Promise<HttpResponse | { error: string }> {
const { method, url, headers = {}, body, timeoutMs = this.defaultTimeout, retries = this.defaultRetries } = config;
// URL安全检查
if (!this.isSafeUrl(url)) return { error: `不安全的URL: ${url}。仅支持 http:// 和 https://` };
const startTime = Date.now();
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const fetchOptions: RequestInit = {
method,
headers: { "User-Agent": "AI-Agent-Toolkit/1.0", "Accept": "application/json, text/plain, */*", ...headers },
signal: controller.signal,
};
if (body && ["POST", "PUT", "PATCH"].includes(method)) {
fetchOptions.body = typeof body === "string" ? body : JSON.stringify(body);
if (typeof body !== "string") fetchOptions.headers = { ...fetchOptions.headers, "Content-Type": "application/json" };
}
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
const contentType = response.headers.get("content-type") || "unknown";
let parsedBody: unknown;
// 智能响应解析
if (contentType.includes("application/json")) parsedBody = await response.json();
else {
const text = await response.text();
try { parsedBody = JSON.parse(text); } catch { parsedBody = text; }
}
return { status: response.status, statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: parsedBody, contentType, timeMs: Date.now() - startTime };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < retries) await this.delay(1000 * (attempt + 1));
}
}
return { error: `HTTP请求失败: ${lastError?.message}` };
}
async get(url: string, headers?: Record<string, string>) {
return this.request({ method: "GET", url, headers });
}
async post(url: string, body: string | object, headers?: Record<string, string>) {
return this.request({ method: "POST", url, body, headers });
}
/** 调用REST API并提取关键字段 */
async callAPI(args: { url: string; method?: "GET" | "POST"; headers?: Record<string, string>; body?: object; extractFields?: string[] }) {
const { url, method = "GET", headers, body, extractFields } = args;
const result = await this.request({ method, url, headers, body });
if ("error" in result) return result;
if (result.status >= 400) return { error: `API错误: HTTP ${result.status}`, body: result.body };
if (extractFields && extractFields.length > 0) {
const extracted: Record<string, unknown> = {};
for (const field of extractFields) extracted[field] = this.getNestedValue(result.body, field);
return { extracted, fullResponse: result.body };
}
return result.body;
}
private isSafeUrl(url: string): boolean {
try { const p = new URL(url); return p.protocol === "http:" || p.protocol === "https:"; }
catch { return false; }
}
private getNestedValue(obj: unknown, path: string): unknown {
return path.split(".").reduce((c: any, k) => c && typeof c === "object" ? c[k] : undefined, obj);
}
private delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
}
export { HttpToolkit };
HttpToolkit 的设计重点是”智能解析”和”错误恢复”。request() 方法会自动根据 Content-Type 解析JSON或文本响应,即使API没有正确设置响应头,它也会尝试将文本作为JSON解析。重试机制采用指数退避策略,避免对故障服务造成过大压力。
callAPI() 方法的字段提取功能特别实用——当API返回大量嵌套数据时,可以通过 extractFields: ["data.weather.temperature", "data.location"] 直接提取关键信息,减少LLM处理无关数据的负担。这个功能的设计思路是:LLM的上下文窗口有限,不应该让LLM在海量原始数据中自己找关键信息,而是工具层就完成数据筛选,只把LLM需要的部分传递过去。
URL安全检查 (isSafeUrl) 虽然简单但不可或缺——它确保Agent不会尝试访问 file://、ftp:// 等非HTTP协议,防止通过URL进行本地文件访问攻击。这是安全设计中的”输入校验”原则:永远不要信任外部传入的URL。
3.2.3 代码分析工具:静态代码扫描、依赖分析、复杂度计算的实现
代码分析是Agent在软件开发场景下的核心能力。通过静态扫描,Agent可以发现潜在bug、分析依赖关系、评估代码复杂度——这些信息能帮助Agent给出更精准的开发建议。你不需要实现一个完整的ESLint或SonarQube,一个轻量级的分析引擎就能覆盖80%的常见需求。
代码分析工具的设计理念是”快速、轻量、可扩展”。与专业的静态分析工具不同,Agent内置的代码分析不需要达到工业级的精确度——它的目标是为LLM提供足够的上下文信息,让LLM能给出有意义的开发建议。因此,分析规则可以简化,重点覆盖最常见的代码质量问题。
// examples/06_code_analysis_tool.ts
import * as fs from "fs/promises";
import * as path from "path";
interface CodeMetrics {
linesOfCode: number;
commentLines: number;
blankLines: number;
functions: Array<{ name: string; line: number; complexity: number; params: number }>;
imports: string[];
maxNestingDepth: number;
averageFunctionLength: number;
}
interface AnalysisReport {
filePath: string;
language: string;
metrics: CodeMetrics;
issues: Array<{ severity: "warning" | "error" | "info"; line: number; message: string; rule: string }>;
summary: string;
}
class CodeAnalysisToolkit {
private readonly patterns: Record<string, { ext: string[]; patterns: Record<string, RegExp> }> = {
typescript: {
ext: [".ts", ".tsx"],
patterns: {
comment: /\/\/.+|\/\*[\s\S]*?\*\//g,
function: /(?:function|const|let|var)\s+(\w+)\s*(?:=\s*(?:async\s+)?function|:\s*(?:async\s+)?\([^)]*\)\s*=>)/g,
import: /import\s+(?:(?:{[^}]*}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"];?/g,
ifStatement: /\bif\s*\(/g, loop: /\b(for|while|do)\b/g,
switchCase: /\bcase\b/g, catchBlock: /\bcatch\b/g,
},
},
python: {
ext: [".py"],
patterns: {
comment: /#.*$/gm, function: /^def\s+(\w+)\s*\(/gm,
import: /^(?:import\s+(\S+)|from\s+(\S+)\s+import)/gm,
class: /^class\s+(\w+)/gm, ifStatement: /\bif\b/g,
loop: /\b(for|while)\b/g, tryBlock: /\btry\b/g,
exceptBlock: /\bexcept\b/g,
},
},
};
async analyzeFile(filePath: string): Promise<AnalysisReport> {
try {
const content = await fs.readFile(filePath, "utf8");
const ext = path.extname(filePath).toLowerCase();
const language = this.detectLanguage(ext);
if (!language) return this.createErrorReport(filePath, `不支持的文件类型: ${ext}`);
const langPatterns = this.patterns[language].patterns;
const metrics = this.calculateMetrics(content, langPatterns);
const issues = this.scanIssues(content, metrics, language);
return { filePath, language, metrics, issues,
summary: `${path.basename(filePath)}: ${metrics.linesOfCode}行代码, ${metrics.functions.length}个函数, ${metrics.maxNestingDepth}层嵌套, ${issues.filter(i => i.severity === "error").length}个错误, ${issues.filter(i => i.severity === "warning").length}个警告` };
} catch (error) { return this.createErrorReport(filePath, String(error)); }
}
async analyzeDependencies(projectPath: string) {
const packageJsonPath = path.join(projectPath, "package.json");
try {
const content = await fs.readFile(packageJsonPath, "utf8");
const pkg = JSON.parse(content);
return {
direct: Object.keys(pkg.dependencies || {}),
dev: Object.keys(pkg.devDependencies || {}),
peer: Object.keys(pkg.peerDependencies || {}),
totalCount: Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }).length,
};
} catch { return { error: `无法读取 ${packageJsonPath}` }; }
}
private calculateMetrics(content: string, patterns: Record<string, RegExp>): CodeMetrics {
const lines = content.split("\n");
const commentMatches = content.match(patterns.comment) || [];
const commentLines = commentMatches.reduce((s, m) => s + m.split("\n").length, 0);
const blankLines = lines.filter(l => l.trim().length === 0).length;
const funcMatches = [...content.matchAll(patterns.function || /./g)];
const controlFlow = [
...(content.match(patterns.ifStatement) || []),
...(content.match(patterns.loop) || []),
...(content.match(patterns.switchCase || /./g) || []),
...(content.match(patterns.catchBlock || /./g) || []),
].length;
const functions: CodeMetrics["functions"] = [];
for (const match of funcMatches.slice(0, 50)) {
const funcLine = content.substring(0, match.index).split("\n").length;
functions.push({
name: match[1] || "anonymous", line: funcLine,
complexity: 1 + Math.min(controlFlow, 20),
params: (match[0].match(/,/g) || []).length + 1,
});
}
const imports = [...content.matchAll(patterns.import)].map(m => m[1] || m[2] || "").filter(Boolean);
return {
linesOfCode: lines.length - blankLines, commentLines, blankLines,
functions, imports: [...new Set(imports)],
maxNestingDepth: this.calculateNestingDepth(content),
averageFunctionLength: functions.length > 0 ? Math.round((lines.length - blankLines) / functions.length) : 0,
};
}
private scanIssues(content: string, metrics: CodeMetrics, language: string): AnalysisReport["issues"] {
const issues: AnalysisReport["issues"] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 120)
issues.push({ severity: "warning", line: i + 1, message: `行长度${line.length}超过120字符`, rule: "max-line-length" });
if (/console\.(log|warn|error|debug)\(/.test(line) && !line.trim().startsWith("//") && !line.trim().startsWith("#"))
issues.push({ severity: "info", line: i + 1, message: "发现console.log调用", rule: "no-console" });
if (/\b(TODO|FIXME|HACK|XXX)\b/i.test(line)) {
const m = line.match(/\b(TODO|FIXME|HACK|XXX)\b/i);
issues.push({ severity: "info", line: i + 1, message: `发现${m?.[0]}标记`, rule: "todo-marker" });
}
if (/(password|secret|token|key)\s*[=:]\s*['"][^'"]+['"]/i.test(line) && !line.includes("process.env") && !line.includes("getenv"))
issues.push({ severity: "error", line: i + 1, message: "可能的硬编码敏感信息", rule: "no-hardcoded-secrets" });
}
for (const func of metrics.functions) {
if (func.complexity > 10)
issues.push({ severity: "warning", line: func.line, message: `函数'${func.name}'圈复杂度${func.complexity}过高`, rule: "function-complexity" });
}
return issues;
}
private calculateNestingDepth(content: string): number {
const lines = content.split("\n");
let maxDepth = 0, currentDepth = 0;
for (const line of lines) {
const open = (line.match(/{/g) || []).length;
const close = (line.match(/}/g) || []).length;
currentDepth += open - close;
maxDepth = Math.max(maxDepth, currentDepth);
}
return maxDepth;
}
private detectLanguage(ext: string): string | null {
for (const [lang, info] of Object.entries(this.patterns))
if (info.ext.includes(ext)) return lang;
return null;
}
private createErrorReport(filePath: string, error: string): AnalysisReport {
return { filePath, language: "unknown",
metrics: { linesOfCode: 0, commentLines: 0, blankLines: 0, functions: [], imports: [], maxNestingDepth: 0, averageFunctionLength: 0 },
issues: [{ severity: "error", line: 0, message: error, rule: "system" }],
summary: `分析失败: ${error}` };
}
}
export { CodeAnalysisToolkit };
CodeAnalysisToolkit 实现了三层分析能力。文件级别的 analyzeFile() 计算圈复杂度、检测代码异味;analyzeDependencies() 解析 package.json 中的依赖关系。圈复杂度的阈值设为10是因为McCabe在1976年的研究表明,复杂度超过10的函数出错概率显著增加。
代码异味检测规则兼顾了实用性和误报率。”过长行”是现代编码规范的普遍要求;”console.log残留”是开发转生产时最常见的遗漏;”硬编码敏感信息”通过正则匹配 "password" = "xxx" 这样的模式来检测。在Agent场景下,宁可多报也不要漏报,因为Agent会在下一步推理中自行判断严重性。
3.2.4 字符串处理工具:编码转换、正则匹配、格式化输出的工具集
字符串处理是最基础但最频繁使用的工具类别。Agent经常需要解析文本输出、提取关键信息、格式化结果呈现给用户。
// examples/07_string_tools.ts
class StringToolkit {
regexExtract(args: { text: string; pattern: string; flags?: string; group?: number }) {
const { text, pattern, flags = "g", group = 0 } = args;
try {
const regex = new RegExp(pattern, flags.includes("g") ? flags : flags + "g");
const matches = [...text.matchAll(regex)].map(m => m[group] || m[0]);
return { matches: [...new Set(matches)], count: matches.length };
} catch { return { matches: [], count: 0 }; }
}
textSlice(args: { text: string; maxLength: number; strategy?: "head" | "tail" | "middle" | "smart"; overlap?: number }) {
const { text, maxLength, strategy = "smart", overlap = 0 } = args;
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
switch (strategy) {
case "head": chunks.push(text.slice(0, maxLength)); break;
case "tail": chunks.push(text.slice(-maxLength)); break;
case "middle": { const s = Math.floor((text.length - maxLength) / 2); chunks.push(text.slice(s, s + maxLength)); break; }
case "smart": {
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) { chunks.push(remaining); break; }
const sliceEnd = this.findNaturalBreak(remaining, maxLength);
chunks.push(remaining.slice(0, sliceEnd));
remaining = remaining.slice(sliceEnd - overlap);
}
break;
}
}
return chunks;
}
formatOutput(args: { data: Record<string, unknown> | unknown[]; format: "json" | "markdown" | "table" | "yaml"; title?: string }) {
const { data, format, title } = args;
const parts: string[] = title ? [`# ${title}`] : [];
switch (format) {
case "json": parts.push(JSON.stringify(data, null, 2)); break;
case "markdown": parts.push(this.toMarkdown(data)); break;
case "table": parts.push(this.toTable(data)); break;
case "yaml": parts.push(this.toYaml(data, 0)); break;
}
return parts.join("\n\n");
}
encodingConvert(args: { text: string; from?: "utf8" | "base64" | "hex" | "url"; to?: "utf8" | "base64" | "hex" | "url" }) {
const { text, from = "utf8", to = "base64" } = args;
try {
let buffer: Buffer;
switch (from) {
case "utf8": buffer = Buffer.from(text, "utf8"); break;
case "base64": buffer = Buffer.from(text, "base64"); break;
case "hex": buffer = Buffer.from(text, "hex"); break;
case "url": buffer = Buffer.from(decodeURIComponent(text), "utf8"); break;
default: return { error: `不支持的源编码` };
}
switch (to) {
case "utf8": return buffer.toString("utf8");
case "base64": return buffer.toString("base64");
case "hex": return buffer.toString("hex");
case "url": return encodeURIComponent(buffer.toString("utf8"));
default: return { error: `不支持的目标编码` };
}
} catch { return { error: `编码转换失败` }; }
}
textStats(args: { text: string }) {
const { text } = args;
const words = text.split(/\s+/).filter(w => w.length > 0).length;
return { chars: text.length, words, lines: text.split("\n").length,
sentences: text.split(/[.!?。!?]+/).filter(s => s.trim().length > 0).length,
avgWordLength: words > 0 ? Math.round((text.length / words) * 10) / 10 : 0 };
}
private findNaturalBreak(text: string, maxPos: number): number {
const paraMatch = text.slice(0, maxPos).lastIndexOf("\n\n");
if (paraMatch > maxPos * 0.5) return paraMatch + 2;
const sentenceMatch = text.slice(0, maxPos).lastIndexOf(". ");
if (sentenceMatch > maxPos * 0.7) return sentenceMatch + 2;
const spaceMatch = text.slice(0, maxPos).lastIndexOf(" ");
if (spaceMatch > maxPos * 0.8) return spaceMatch + 1;
return maxPos;
}
private toMarkdown(data: unknown, depth = 0): string {
if (typeof data !== "object" || data === null) return String(data);
if (Array.isArray(data)) return data.map(item => `- ${this.toMarkdown(item, depth + 1)}`).join("\n");
return Object.entries(data as Record<string, unknown>).map(([k, v]) => typeof v === "object" && v !== null && !Array.isArray(v) ? `**${k}**:\n${this.toMarkdown(v, depth + 1)}` : `**${k}**: ${v}`).join("\n");
}
private toTable(data: unknown): string {
if (!Array.isArray(data) || data.length === 0) return "(无数据)";
const keys = Object.keys(data[0] as object);
return ["| " + keys.join(" | ") + " |", "| " + keys.map(() => "---").join(" | ") + " |",
...data.map(row => "| " + keys.map(k => String((row as any)[k] ?? "")).join(" | ") + " |")].join("\n");
}
private toYaml(data: unknown, indent: number): string {
const prefix = " ".repeat(indent);
if (typeof data !== "object" || data === null) return `${prefix}${data}`;
if (Array.isArray(data)) return data.map(item => `${prefix}- ${this.toYaml(item, indent + 2).trim()}`).join("\n");
return Object.entries(data as Record<string, unknown>).map(([k, v]) => typeof v === "object" && v !== null ? `${prefix}${k}:\n${this.toYaml(v, indent + 2)}` : `${prefix}${k}: ${v}`).join("\n");
}
}
export { StringToolkit };
textSlice() 中的”smart”策略通过分层分割(段落 > 句子 > 空格 > 硬切)让每个块尽可能保持语义完整性,避免在代码逻辑中间截断。formatOutput() 支持四种格式,Agent可以根据场景选择最合适的呈现方式。分析结果用 table(易读比较),结构化配置用 json 或 yaml,面向用户的报告用 markdown。
3.3 Agent与工具协同
现在你已经拥有了完整的工具集。但工具本身不会思考——它们需要Agent来编排调用顺序、传递参数、处理中间结果。本节实现Agent与工具的协同机制:动态工具选择让LLM根据任务判断用什么工具,工具链组合实现多工具的流水线式调用,日志系统记录每次调用的详细信息,最终整合为一个完整的命令行Agent。
Agent与工具协同的核心挑战在于”如何让LLM正确地使用工具”。这不仅仅是给LLM一份工具清单那么简单。你需要考虑工具选择的时机——是在每次对话开始时选择全部工具,还是根据用户查询动态选择?你需要考虑工具调用的格式——如何让LLM输出结构化的调用指令,而不是自由文本?你还需要考虑错误处理——当工具调用失败时,如何让LLM理解错误原因并决定下一步行动?这些问题的解决方案共同构成了Agent与工具协同的完整技术体系。
3.3.1 动态工具选择:LLM根据任务自动判断调用哪个工具的决策机制
当工具数量较多(超过10个)时,每次调用LLM都传入全部工具的Schema会带来两个问题:一是占用大量Token(每个工具的Schema描述可能几十到几百Token),二是可能干扰LLM的决策(无关工具的描述会分散注意力)。动态工具选择通过”先分析意图、再选择工具”的策略解决这些问题。
动态工具选择的本质是一个匹配问题:给定用户查询和工具集合,找到最相关的工具子集。这个问题可以从两个维度来解决——规则引擎维度和语义理解维度。规则引擎维度通过关键词匹配、标签匹配、触发词匹配等规则来快速筛选工具;语义理解维度则通过LLM的深度理解能力来判断工具相关性。两种维度各有优劣,实际应用中通常采用混合策略。
// examples/08_dynamic_tool_selection.ts
interface ToolWithTags {
name: string;
description: string;
parameters: object;
category: string;
tags: string[];
triggers: string[];
}
class DynamicToolSelector {
private allTools: ToolWithTags[] = [];
registerTool(tool: ToolWithTags): void { this.allTools.push(tool); }
/** 基于关键词匹配的快速工具选择 */
selectByKeywords(query: string, maxTools = 5): ToolWithTags[] {
const query_lower = query.toLowerCase();
const scores = this.allTools.map(tool => {
let score = 0;
// 触发词匹配(权重最高 = 3)
for (const trigger of tool.triggers) {
if (query_lower.includes(trigger.toLowerCase())) score += 3;
}
// 标签匹配(权重 = 1)
for (const tag of tool.tags) {
if (query_lower.includes(tag.toLowerCase())) score += 1;
}
// 工具名匹配(权重 = 2)
if (query_lower.includes(tool.name.toLowerCase())) score += 2;
return { tool, score };
});
return scores.filter(s => s.score > 0).sort((a, b) => b.score - a.score)
.slice(0, maxTools).map(s => s.tool);
}
/** 基于LLM意图分析的高级工具选择 */
async selectByLLM(
query: string,
llmProvider: { generate: (prompt: string) => Promise<string> },
maxTools = 5
): Promise<ToolWithTags[]> {
const toolSummaries = this.allTools.map(t =>
`- ${t.name}: ${t.description} [类别: ${t.category}, 标签: ${t.tags.join(", ")}]`
).join("\n");
const prompt = `你是一个工具选择助手。根据用户的查询,从以下工具列表中选择最合适的工具。
可用工具列表:
${toolSummaries}
用户查询: "${query}"
请分析用户意图,选择最相关的工具。只返回工具名称列表(逗号分隔),不要其他解释。
格式: tool1, tool2, tool3`;
const response = await llmProvider.generate(prompt);
const selectedNames = response.split(/[,,\n]/).map(s => s.trim().toLowerCase()).filter(Boolean);
return this.allTools.filter(t => selectedNames.includes(t.name.toLowerCase())).slice(0, maxTools);
}
/** 混合策略:关键词快速筛选 + LLM精细选择 */
async selectHybrid(
query: string,
llmProvider: { generate: (prompt: string) => Promise<string> },
confidenceThreshold = 3
): Promise<{ tools: ToolWithTags[]; strategy: "keyword" | "llm" }> {
const keywordResults = this.selectByKeywords(query, 10);
const maxScore = keywordResults.length > 0
? this.calculateMaxScore(query, keywordResults[0]) : 0;
// 如果最高分工具的置信度足够高,直接返回关键词结果
if (maxScore >= confidenceThreshold) {
return { tools: keywordResults.slice(0, 5), strategy: "keyword" };
}
// 否则调用LLM进行精细选择
const llmResults = await this.selectByLLM(query, llmProvider, 5);
return { tools: llmResults, strategy: "llm" };
}
private calculateMaxScore(query: string, tool: ToolWithTags): number {
const query_lower = query.toLowerCase();
let score = 0;
for (const trigger of tool.triggers) {
if (query_lower.includes(trigger.toLowerCase())) score += 3;
}
return score;
}
}
// ====== 使用示例 ======
const selector = new DynamicToolSelector();
selector.registerTool({ name: "read_file", description: "读取文件内容", parameters: {}, category: "filesystem", tags: ["file", "read", "content"], triggers: ["查看", "读取", "文件内容", "看一下", "打开文件"] });
selector.registerTool({ name: "list_directory", description: "列出目录中的文件", parameters: {}, category: "filesystem", tags: ["directory", "list", "files"], triggers: ["列出", "目录", "有哪些文件", "文件列表"] });
selector.registerTool({ name: "analyze_file", description: "分析代码文件的复杂度", parameters: {}, category: "analysis", tags: ["code", "analyze", "complexity"], triggers: ["分析", "代码", "复杂度", "质量", "重构"] });
selector.registerTool({ name: "http_get", description: "发送HTTP GET请求", parameters: {}, category: "network", tags: ["http", "api", "request"], triggers: ["请求", "API", "HTTP", "获取数据", "查询"] });
const selected = selector.selectByKeywords("帮我分析这个代码文件的复杂度");
console.log("选择结果:", selected.map(t => t.name));
// 预期输出: ["analyze_file", "read_file"]
动态工具选择有三种策略,各有适用场景。selectByKeywords() 是规则引擎驱动的快速匹配,适合工具数量少(<20个)且查询意图明确的场景,优点是不消耗LLM Token、延迟极低(毫秒级)。它的核心思想是加权评分——触发词匹配权重最高(3分),因为触发词是专门为工具意图识别设计的关键词;工具名匹配次之(2分),因为用户有时会直接说出工具名;标签匹配最低(1分),作为辅助信号。这种加权设计确保了即使工具名和标签都没有直接命中,只要触发词匹配上了,工具仍然能被选中。
selectByLLM() 将工具选择也交给LLM判断,适合工具数量多(20+)或查询意图模糊的场景。它的优点是LLM能处理复杂的语义关联——比如用户说”看看这个项目怎么样”,LLM能理解这需要”列出目录”+”分析代码”+”检查依赖”三个工具的组合,这是关键词匹配做不到的。缺点是每个查询都需要额外消耗一次LLM调用,增加了延迟和成本。
selectHybrid() 是推荐的生产策略——先用关键词快速匹配计算置信度,如果置信度低于阈值再调用LLM,在速度和精度之间取得平衡。这种策略的设计理念是”80/20法则”:80%的查询意图明确,用关键词匹配就能解决;只有20%的查询需要LLM的语义理解能力。混合策略用最低的成本覆盖了绝大多数场景,只在必要时才调用LLM。
触发词(triggers)的设计是关键。triggers 数组中存储的是与该工具强相关的自然语言动词和短语,覆盖了中文语境下可能表达同一意图的各种说法。比如 read_file 的触发词包括”查看”、”读取”、”文件内容”、”看一下”、”打开文件”——这些词在日常对话中都可能表达”读文件”的意图。这种设计比单纯匹配工具名要灵活得多,因为用户很少会精确地说出”read_file”,但他们很可能说”帮我看一下这个文件”。
3.3.2 工具链组合:多工具顺序调用的编排(如先读取文件再分析内容)
许多任务需要多个工具按特定顺序协作完成。比如”分析src目录下所有TypeScript文件的复杂度”这个任务,需要依次执行:列出目录 → 筛选.ts文件 → 逐个读取文件 → 分析代码 → 汇总结果。工具链组合机制让Agent能够自动编排这种多步骤工作流。
工具链的核心设计挑战在于”数据传递”——前一个工具的输出如何作为后一个工具的输入?在简单的场景中,这可以通过硬编码的参数映射来实现;但在复杂的场景中,可能需要动态数据路由、条件分支、错误处理等高级功能。一个成熟的工具链引擎应该支持声明式的步骤定义,让开发者像写配置文件一样描述多工具协作流程。
// examples/09_tool_chain_orchestration.ts
interface ToolChainStep {
id: string;
toolName: string;
args: Record<string, any>;
condition?: string;
outputMapping?: Record<string, string>;
retries?: number;
}
interface ToolChain {
name: string;
description: string;
steps: ToolChainStep[];
maxExecutionTimeMs?: number;
}
class ToolChainEngine {
private toolRegistry: Map<string, (args: any) => Promise<unknown>>;
private context: Record<string, any> = {};
constructor(tools: Record<string, (args: any) => Promise<unknown>>) {
this.toolRegistry = new Map(Object.entries(tools));
}
/** 执行预定义的工具链 */
async execute(chain: ToolChain) {
const results: Array<{ stepId: string; toolName: string; result: unknown; timeMs: number }> = [];
const startTime = Date.now();
const maxTime = chain.maxExecutionTimeMs || 120000;
for (const step of chain.steps) {
// 超时检查
if (Date.now() - startTime > maxTime)
return { success: false as const, results, error: `工具链执行超时` };
const stepStart = Date.now();
try {
// 解析动态参数(将 ${variable} 替换为上下文值)
const resolvedArgs = this.resolveArgs(step.args);
// 条件检查
if (step.condition && !this.evaluateCondition(step.condition)) {
results.push({ stepId: step.id, toolName: step.toolName, result: { skipped: true }, timeMs: Date.now() - stepStart });
continue;
}
// 执行工具
const tool = this.toolRegistry.get(step.toolName);
if (!tool) throw new Error(`工具 '${step.toolName}' 不存在`);
const result = await this.executeWithRetry(tool, resolvedArgs, step.retries || 0);
// 结果存入上下文
if (step.outputMapping) {
for (const [field, ctxKey] of Object.entries(step.outputMapping)) {
this.context[ctxKey] = this.getNestedValue(result, field);
}
}
this.context[`${step.id}_result`] = result;
this.context.lastResult = result;
results.push({ stepId: step.id, toolName: step.toolName, result, timeMs: Date.now() - stepStart });
} catch (error) {
results.push({ stepId: step.id, toolName: step.toolName, result: { error: String(error) }, timeMs: Date.now() - stepStart });
return { success: false as const, results, error: `步骤 '${step.id}' 执行失败` };
}
}
return { success: true as const, results, finalOutput: this.context.lastResult };
}
/** 从自然语言描述自动生成工具链 */
async generateChain(
description: string,
availableTools: Array<{ name: string; description: string }>,
llmProvider: { generate: (prompt: string) => Promise<string> }
): Promise<ToolChain> {
const toolList = availableTools.map(t => `- ${t.name}: ${t.description}`).join("\n");
const prompt = `根据任务描述,设计一个工具执行链。
可用工具:
${toolList}
任务描述: "${description}"
请以JSON格式输出工具链(只输出纯JSON):
{"name": "", "description": "", "steps": [{"id": "", "toolName": "", "args": {}, "outputMapping": {}}]}`;
const response = await llmProvider.generate(prompt);
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("LLM未返回有效JSON");
return JSON.parse(jsonMatch[0]) as ToolChain;
}
private resolveArgs(args: Record<string, any>): Record<string, any> {
const resolved: Record<string, any> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string" && value.startsWith("${") && value.endsWith("}")) {
resolved[key] = this.getNestedValue(this.context, value.slice(2, -1));
} else {
resolved[key] = value;
}
}
return resolved;
}
private evaluateCondition(condition: string): boolean {
try {
const fn = new Function("ctx", `with(ctx) { return ${condition}; }`);
return fn(this.context) === true;
} catch { return false; }
}
private async executeWithRetry(fn: (args: any) => Promise<unknown>, args: any, retries: number) {
let lastError: Error | null = null;
for (let i = 0; i <= retries; i++) {
try { return await fn(args); }
catch (e) { lastError = e instanceof Error ? e : new Error(String(e)); if (i < retries) await new Promise(r => setTimeout(r, 1000 * (i + 1))); }
}
throw lastError;
}
private getNestedValue(obj: any, path: string): any {
return path.split(".").reduce((c, k) => c && typeof c === "object" ? c[k] : undefined, obj);
}
}
export { ToolChainEngine, ToolChain };
ToolChainEngine 的核心是上下文传递机制。每个步骤执行完后,通过 outputMapping 将结果的关键字段映射到共享上下文中,后续步骤可以通过 ${variableName} 语法引用这些值。这种声明式的数据流让工具链的可读性接近配置文件——你可以直接看JSON定义就理解整个数据流向,而不需要在代码中追踪变量传递。
generateChain() 方法展示了LLM在工具编排中的另一个重要作用:给定一个自然语言描述和可用工具列表,LLM能自动生成工具链的JSON定义。这在Agent需要处理用户模糊指令时特别有用——比如用户说”帮我看看这个项目怎么样”,LLM能自动将其分解为”列出目录→筛选代码文件→分析主文件→检查依赖→生成报告”的完整工具链。这种”自然语言到执行计划”的转换是Plan-and-Execute架构的核心能力,也是Agent区别于简单问答系统的关键特征。
3.3.3 工具调用日志:调用记录、耗时统计、结果追踪的日志系统
生产环境中,你需要知道Agent调用了什么工具、传了什么参数、花了多长时间、返回了什么结果。这些信息不仅是调试的关键,也是审计和安全分析的依据。一个完善的日志系统应该支持查询、统计和链路追踪。
日志系统的设计要考虑三个维度:记录维度(记录什么信息)、存储维度(如何存储)、分析维度(如何分析)。记录维度上,每次工具调用应该至少记录:工具名称、输入参数、执行结果、执行时间、状态(成功/失败)、错误信息。存储维度上,开发阶段可以使用内存存储,生产环境应该使用持久化存储(文件或数据库)。分析维度上,日志系统应该提供统计功能:调用次数、成功率、平均耗时、工具使用频率分布、错误类型分布。
// examples/10_tool_call_logger.ts
interface ToolCallLog {
id: string;
timestamp: Date;
sessionId: string;
toolName: string;
arguments: Record<string, any>;
result: unknown;
status: "success" | "error" | "timeout";
executionTimeMs: number;
errorMessage?: string;
metadata?: { llmModel?: string; source?: "user_direct" | "llm_decision" | "tool_chain" };
}
interface LogStats {
totalCalls: number;
successRate: number;
averageExecutionTimeMs: number;
toolUsage: Record<string, number>;
errorBreakdown: Record<string, number>;
}
class ToolCallLogger {
private logs: ToolCallLog[] = [];
private sessionId: string;
constructor() {
this.sessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/** 包装工具执行,自动记录日志 */
async wrap<T>(toolName: string, args: Record<string, any>, fn: () => Promise<T>, metadata?: ToolCallLog["metadata"]): Promise<T> {
const start = Date.now();
try {
const result = await fn();
this.logs.push({
id: `log_${Date.now()}`, timestamp: new Date(), sessionId: this.sessionId,
toolName, arguments: args, result: this.truncate(result),
status: "success", executionTimeMs: Date.now() - start, metadata,
});
return result;
} catch (error) {
this.logs.push({
id: `log_${Date.now()}`, timestamp: new Date(), sessionId: this.sessionId,
toolName, arguments: args, result: null,
status: "error", executionTimeMs: Date.now() - start,
errorMessage: error instanceof Error ? error.message : String(error), metadata,
});
throw error;
}
}
async getSessionStats() {
const logs = this.logs.filter(l => l.sessionId === this.sessionId);
const total = logs.length;
const successes = logs.filter(l => l.status === "success").length;
const totalTime = logs.reduce((s, l) => s + l.executionTimeMs, 0);
const toolMap = new Map<string, { count: number; totalTime: number }>();
for (const log of logs) {
const e = toolMap.get(log.toolName) || { count: 0, totalTime: 0 };
e.count++; e.totalTime += log.executionTimeMs;
toolMap.set(log.toolName, e);
}
const toolBreakdown: Record<string, { count: number; avgTimeMs: number }> = {};
for (const [name, d] of toolMap) {
toolBreakdown[name] = { count: d.count, avgTimeMs: Math.round(d.totalTime / d.count) };
}
return { totalCalls: total, totalTimeMs: totalTime,
successRate: total > 0 ? Math.round((successes / total) * 100) : 100,
toolBreakdown };
}
private truncate(result: unknown, maxLength = 1000): unknown {
if (typeof result === "string" && result.length > maxLength)
return result.slice(0, maxLength) + `... [${result.length} chars]`;
return result;
}
}
export { ToolCallLogger, ToolCallLog, LogStats };
ToolCallLogger 的 wrap() 方法是最实用的功能——AOP(面向切面编程)风格的设计,它接收一个工具函数,自动记录执行前后的所有信息,无需在每个工具的调用处手动编写日志代码。这种透明日志让工具的业务逻辑和日志逻辑完全解耦。当你需要添加日志时,不需要修改工具的实现,只需要用 wrap() 包装一下即可。
日志的价值不仅在于事后排查问题,更在于实时性能监控。getSessionStats() 返回的成功率、平均执行时间、各工具调用频次等数据,能帮助你快速发现系统瓶颈。比如如果发现 http_get 的失败率突然升高,可能是目标API服务不稳定;如果 analyze_file 的平均耗时超过5秒,可能需要优化分析算法或增加超时控制。这些数据对于持续改进Agent的性能至关重要。
3.3.4 命令行Agent工具v1:整合前两章知识,构建支持文件分析和网络查询的CLI工具
这是本章的里程碑——将第1-3章的全部知识整合为一个可运行的命令行Agent工具。这个工具能够理解自然语言指令,调用文件系统和网络工具,以ReAct模式逐步推理完成任务。
CLI Agent的架构设计需要整合多个模块:ToolRegistry管理工具注册,ToolExecutionEngine执行工具调用,ToolCallLogger记录调用日志,LLM Provider处理自然语言理解和生成,ReAct循环控制整体推理流程。这些模块的协作方式如下:用户输入查询 → ReAct循环构建Prompt(包含工具描述和历史)→ 调用LLM获取Thought/Action → 解析Action获取工具名和参数 → 通过ExecutionEngine调用工具 → 工具结果作为Observation返回给LLM → 循环直到LLM输出Final Answer。
// examples/cli-agent/src/index.ts
import { z } from "zod";
import * as readline from "readline";
import { FilesystemToolkit } from "./tools/filesystem";
import { HttpToolkit } from "./tools/http";
import { CodeAnalysisToolkit } from "./tools/code-analysis";
import { StringToolkit } from "./tools/string";
import { ToolRegistry } from "./core/tool-registry";
import { ToolExecutionEngine } from "./core/execution-engine";
import { ToolCallLogger } from "./core/logger";
interface ReActStep {
step: number;
thought: string;
action?: { tool: string; input: Record<string, any> };
observation?: unknown;
finalAnswer?: string;
}
class CLIAgent {
private registry = new ToolRegistry();
private engine = new ToolExecutionEngine();
private logger = new ToolCallLogger();
private history: ReActStep[] = [];
private maxSteps = 10;
async initialize(): Promise<void> {
const fsToolkit = new FilesystemToolkit({ allowedBasePaths: [process.cwd(), "/tmp"] });
const httpToolkit = new HttpToolkit();
const codeToolkit = new CodeAnalysisToolkit();
const stringToolkit = new StringToolkit();
this.registry.register("read_file", `读取指定文本文件的内容。适用于查看代码文件、配置文件、日志文件。最大支持1MB。`,
z.object({ path: z.string().describe("文件绝对路径"), maxLines: z.number().optional().default(100) }),
(args) => fsToolkit.readFile(args));
this.registry.register("list_directory", `列出目录下的文件和子目录。用于浏览项目结构。`,
z.object({ path: z.string(), recursive: z.boolean().optional().default(false) }),
(args) => fsToolkit.listDirectory(args));
this.registry.register("http_get", `发送HTTP GET请求,获取网页或API数据。`,
z.object({ url: z.string().describe("请求URL") }),
(args) => httpToolkit.get(args.url));
this.registry.register("analyze_file", `分析代码文件的质量指标:圈复杂度、代码异味。`,
z.object({ path: z.string() }), (args) => codeToolkit.analyzeFile(args.path));
this.registry.register("analyze_project", `分析项目结构和代码质量。`,
z.object({ path: z.string() }), (args) => codeToolkit.analyzeProject(args.path));
this.registry.register("extract_with_regex", `从文本中提取匹配正则表达式的内容。`,
z.object({ text: z.string(), pattern: z.string() }),
(args) => Promise.resolve(stringToolkit.regexExtract(args)));
console.log(`✅ Agent初始化完成,已注册 ${this.registry.size} 个工具`);
}
async run(query: string): Promise<string> {
console.log(`\n🤔 用户: ${query}`);
this.history = [];
for (let step = 1; step <= this.maxSteps; step++) {
const prompt = this.buildReActPrompt(query, step);
const llmResponse = await this.callLLM(prompt);
const parsed = this.parseReActResponse(llmResponse);
this.history.push({ step, ...parsed });
if (parsed.finalAnswer) {
console.log(`\n✅ 最终答案: ${parsed.finalAnswer}`);
return parsed.finalAnswer;
}
if (parsed.action) {
console.log(` [步骤${step}] 🛠️ 调用: ${parsed.action.tool}`);
const tool = this.registry.get(parsed.action.tool);
if (!tool) { this.history[this.history.length - 1].observation = { error: `工具不存在` }; continue; }
const result = await this.engine.execute(parsed.action.tool, tool.handler, tool.metadata.schema, parsed.action.input);
this.history[this.history.length - 1].observation = result.status === "success" ? result.data : result;
const obsPreview = JSON.stringify(this.history[this.history.length - 1].observation).slice(0, 200);
console.log(` 结果: ${obsPreview}${obsPreview.length >= 200 ? "..." : ""}`);
}
}
return "达到最大步骤限制,未能完成任务。";
}
private buildReActPrompt(query: string, currentStep: number): string {
const toolsDesc = this.registry.listTools().map(t =>
`- ${t.name}: ${t.description}\n 参数: ${JSON.stringify(t.parameters)}`
).join("\n");
let prompt = `你是一个智能助手,可以使用以下工具解决用户问题。
可用工具:
${toolsDesc}
请使用ReAct格式:
Thought: 分析当前状况,决定下一步
Action: {"tool": "工具名", "input": {"参数": "值"}}
Observation: 工具结果(自动填入)
Final Answer: 最终答案
用户问题: ${query}
`;
if (this.history.length > 0) {
prompt += "\n执行历史:\n";
for (const h of this.history) {
prompt += `步骤${h.step}:\nThought: ${h.thought}\n`;
if (h.action) prompt += `Action: ${JSON.stringify(h.action)}\n`;
if (h.observation) prompt += `Observation: ${JSON.stringify(h.observation).slice(0, 400)}\n`;
}
}
prompt += `\n步骤${currentStep}的思考:`;
return prompt;
}
private async callLLM(prompt: string): Promise<string> {
// 实际实现中调用ConfigManager获取的Provider
// 这里返回模拟响应用于演示
return `Thought: 我需要先了解项目结构\nAction: {"tool": "list_directory", "input": {"path": "."}}`;
}
private parseReActResponse(response: string): Omit<ReActStep, "step"> {
const thoughtMatch = response.match(/Thought:\s*(.+?)(?=\n(?:Action|Final Answer):|$)/s);
const actionMatch = response.match(/Action:\s*({.+?})/s);
const finalMatch = response.match(/Final Answer:\s*(.+?)$/s);
if (finalMatch) return { thought: thoughtMatch?.[1]?.trim() || "", finalAnswer: finalMatch[1].trim() };
if (actionMatch) {
try { const action = JSON.parse(actionMatch[1]); return { thought: thoughtMatch?.[1]?.trim() || "", action: { tool: action.tool, input: action.input || {} } }; }
catch { return { thought: "", action: { tool: "unknown", input: {} } }; }
}
return { thought: response, finalAnswer: response };
}
}
async function main() {
console.log("═══════════════════════════════════════════");
console.log(" 🤖 CLI Agent v1.0 - 智能命令行助手");
console.log("═══════════════════════════════════════════");
const agent = new CLIAgent();
await agent.initialize();
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = () => rl.question("\n💬 请输入指令 (quit退出): ", async (q) => {
if (q.trim().toLowerCase() === "quit") { console.log("👋 再见!"); rl.close(); return; }
try { await agent.run(q); } catch (e) { console.error("❌ 错误:", e); }
ask();
});
ask();
}
main().catch(console.error);
ReAct模式的核心价值在于”试错能力”。如果某个工具调用没有返回预期结果,Agent可以在下一步的Thought中分析原因,尝试不同的工具或参数。比如用户问”这个项目的代码质量怎么样”,Agent可能先调用 list_directory 浏览结构,发现是Python项目后,再调用 analyze_project 获取详细指标。整个过程中Agent自主选择了合适的工具、决定了分析顺序、从结果中提取关键信息——这种自适应的推理过程是简单问答系统无法实现的能力。
buildReActPrompt() 的动态构建逻辑也值得注意。它不仅包含了工具描述,还包含了完整的执行历史——每一步的Thought、Action和Observation都会被记录并传递给LLM。这让LLM能基于前面的观察和当前状态做出连贯的决策,而不是每一步都从头开始思考。这种”记忆机制”是ReAct模式能够处理复杂多步骤任务的关键。
3.4 项目里程碑:命令行Agent工具
3.4.1 完整项目结构:本章实现的文件目录和模块组织
一个专业的CLI Agent工具需要清晰的项目结构和完整的配置文件。以下是推荐的项目组织方式:
cli-agent/
├── package.json # 项目配置、依赖和脚本
├── tsconfig.json # TypeScript编译配置
├── .env.example # 环境变量模板(LLM API Key等)
├── README.md # 使用文档和快速入门
├── bin/
│ └── cli-agent # 全局命令入口
├── src/
│ ├── index.ts # CLI入口,readline交互循环
│ ├── agent.ts # ReActAgent核心类
│ ├── types.ts # 共享类型定义
│ ├── core/ # 框架核心模块
│ │ ├── tool-registry.ts
│ │ ├── execution-engine.ts
│ │ ├── logger.ts
│ │ └── config.ts
│ ├── tools/ # 内置工具实现
│ │ ├── filesystem.ts
│ │ ├── http.ts
│ │ ├── code-analysis.ts
│ │ └── string.ts
│ ├── llm/ # LLM Provider接口
│ │ ├── provider.ts
│ │ ├── openai-provider.ts
│ │ └── anthropic-provider.ts
│ └── utils/
│ └── helpers.ts
├── tests/ # 单元测试
│ ├── tool-registry.test.ts
│ ├── filesystem.test.ts
│ └── execution-engine.test.ts
└── dist/ # 编译输出
package.json需要配置全局命令入口和TypeScript编译脚本:
{
"name": "cli-agent",
"version": "1.0.0",
"description": "智能命令行Agent工具 - 支持文件分析和网络查询",
"type": "module",
"main": "dist/index.js",
"bin": { "cli-agent": "./bin/cli-agent" },
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"test": "vitest run",
"lint": "eslint src/**/*.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.0",
"reflect-metadata": "^0.2.0",
"dotenv": "^16.4.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/node": "^20.0.0",
"tsx": "^4.0.0",
"vitest": "^2.0.0",
"eslint": "^9.0.0"
},
"engines": { "node": ">=18.0.0" }
}
tsconfig.json需要开启装饰器支持:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
3.4.2 功能演示:用命令行工具分析一个Python项目并给出优化建议
假设你的项目目录中有一个待分析的Python项目 sample-project/,其结构如下:
sample-project/
├── main.py # 入口文件(150行,包含Flask应用)
├── utils/
│ ├── __init__.py
│ ├── database.py # 数据库操作(280行,SQL拼接)
│ └── auth.py # 认证逻辑(120行)
├── requirements.txt # 依赖列表
└── README.md
以下是完整的交互演示,展示了Agent从任务理解到工具选择、执行、结果整合的全流程:
$ npm run build
$ node dist/index.js
═══════════════════════════════════════════
🤖 CLI Agent v1.0 - 智能命令行助手
支持: 文件分析 | 网络查询 | 代码检查
═══════════════════════════════════════════
✅ Agent初始化完成,已注册 7 个工具
可用工具: read_file, list_directory, http_get, analyze_file, analyze_project, extract_with_regex
💬 请输入指令 (quit退出):
> 分析 sample-project 目录的代码质量并给出优化建议
🤔 用户: 分析 sample-project 目录的代码质量并给出优化建议
[步骤1] 🛠️ 调用: list_directory
参数: {"path":"/home/user/sample-project","recursive":true}
结果: {"path":"/home/user/sample-project","entries":[{"name":"main.py","type":"file"}...]
[步骤2] 🛠️ 调用: analyze_file
参数: {"path":"/home/user/sample-project/utils/database.py"}
结果: {"filePath":"...database.py","language":"python","metrics":{"linesOfCode":280...}
[步骤3] 🛠️ 调用: analyze_file
参数: {"path":"/home/user/sample-project/main.py"}
结果: {"filePath":"...main.py","language":"python","metrics":{"linesOfCode":150...}
[步骤4] 🛠️ 调用: read_file
参数: {"path":"/home/user/sample-project/requirements.txt"}
结果: {"path":"...requirements.txt","content":"Flask==2.0.1\nPyMySQL==1.0.2\nredis==3.5.3"}
✅ 最终答案:
## sample-project 代码质量分析报告
### 项目概况
- 总代码行数: ~550 行
- 文件数量: 5 个Python文件
- 依赖项: Flask 2.0.1, PyMySQL 1.0.2, redis 3.5.3
### 发现的问题
**1. 数据库文件 utils/database.py(高优先级)**
- ⚠️ 圈复杂度 15,建议重构拆分为小函数
- 🔴 发现SQL拼接代码,存在SQL注入风险
- 🔴 发现 `except:` 裸异常捕获,可能隐藏错误
- ⚠️ 函数 `get_connection` 长度 85 行,建议拆分
**2. 主文件 main.py(中优先级)**
- ⚠️ 使用 `app.run(debug=True)` 不适合生产环境
- ⚠️ 路由函数缺少错误处理
- ⚠️ 缺少日志记录
**3. 依赖安全(高优先级)**
- Flask 2.0.1 有已知安全漏洞,建议升级到 3.x
- redis 3.5.3 版本较旧,建议升级到最新稳定版
### 优化建议
1. **立即处理**: 将SQL拼接改为参数化查询,防止SQL注入
2. **重构**: 将 database.py 拆分为 connection.py、query.py、models.py
3. **安全**: 添加统一的错误处理和输入验证
4. **依赖**: 运行 `pip list --outdated` 检查所有依赖更新
5. **测试**: 添加单元测试,目标覆盖率 >= 80%
这个演示展示了Agent的完整工作流:首先 list_directory 了解项目结构,然后 analyze_file 逐个分析关键代码文件(database.py和main.py),read_file 读取依赖信息,最后综合所有信息生成结构化的质量报告。整个过程中Agent自主选择了合适的工具、决定了分析顺序、从结果中提取关键信息并给出可操作的优化建议。
3.4.3 代码质量优化:TypeScript类型完善、单元测试编写、文档注释规范
一个专业的Agent工具需要有完善的类型系统、可靠的测试覆盖和清晰的文档。这三个方面构成了生产级TypeScript项目的基础——类型系统在编译阶段捕获错误,测试在运行前验证行为,文档降低维护成本。
类型完善的关键在于消除 any 类型,用Zod Schema推导的类型替代手动接口定义。z.discriminatedUnion 是Zod的高级特性,用于定义联合类型:
// src/types.ts - 核心类型定义
import { z } from "zod";
export const ToolResultSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.unknown(), executionTimeMs: z.number() }),
z.object({ status: z.literal("error"), error: z.string(), errorType: z.enum(["validation", "execution", "timeout", "permission", "unknown"]), suggestion: z.string().optional() }),
]);
export type ToolResult = z.infer<typeof ToolResultSchema>;
ToolResultSchema 使用 "status" 字段作为判别键——当 status 为 "success" 时结果必须有 data 和 executionTimeMs;当 status 为 "error" 时结果必须有 error 和 errorType。TypeScript编译器会根据 status 的值自动收窄类型。
单元测试使用Vitest框架,覆盖工具注册、执行引擎和文件系统工具的核心路径:
// tests/tool-registry.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { ToolRegistry } from "../src/core/tool-registry";
describe("ToolRegistry", () => {
it("应该注册并检索工具", () => {
const registry = new ToolRegistry();
registry.register("read_file", "读取文件", z.object({ path: z.string() }), async (args) => args.path);
expect(registry.has("read_file")).toBe(true);
});
it("应该拒绝重复注册", () => {
const registry = new ToolRegistry();
const schema = z.object({});
registry.register("test", "测试", schema, async () => "ok");
expect(() => registry.register("test", "测试", schema, async () => "ok")).toThrow();
});
});
// tests/filesystem.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { FilesystemToolkit } from "../src/tools/filesystem";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
describe("FilesystemToolkit", () => {
let toolkit: FilesystemToolkit;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-test-"));
toolkit = new FilesystemToolkit({ allowedBasePaths: [tmpDir] });
});
it("应该读取文本文件", async () => {
const f = path.join(tmpDir, "test.txt");
await fs.writeFile(f, "Hello, Agent!", "utf8");
const result = await toolkit.readFile({ path: f }) as any;
expect(result.content).toBe("Hello, Agent!");
});
it("应该拒绝越权路径", async () => {
const result = await toolkit.readFile({ path: "/etc/passwd" }) as any;
expect(result.error).toContain("超出允许范围");
});
it("应该检测二进制文件", async () => {
const f = path.join(tmpDir, "test.bin");
await fs.writeFile(f, Buffer.from([0x00, 0x01, 0x02, 0x03]));
const result = await toolkit.readFile({ path: f }) as any;
expect(result.isBinary).toBe(true);
});
});
文档注释使用TSDoc格式,为所有公开API提供参数说明、返回值和使用示例。这三个方面的优化不是可选的”锦上添花”,而是生产环境的基本要求。对于Agent工具来说尤其重要,因为LLM生成的工具调用参数可能出现各种意外格式,严格的Schema验证和TypeScript类型检查是第一道防线。没有它们,Agent在面对边缘情况时会频繁出错,严重影响用户体验。通过完善的类型系统、可靠的测试覆盖和清晰的文档,你的Agent工具才能真正达到生产环境的质量标准,为用户提供稳定可靠的服务。
在深入探讨Agent与工具协同的具体机制之前,有必要先理解这种协同的本质。Agent与工具的关系,类似于人类大脑与四肢的关系——大脑负责思考和决策,四肢负责执行具体的物理操作。但不同于人类的神经系统,Agent与工具之间的连接需要通过精心设计的接口来实现,因为LLM本质上是一个文本生成模型,它不能直接调用函数或访问文件系统。Function Calling机制就是为了解决这个问题而诞生的:它定义了一套标准的文本协议,让LLM能够通过生成特定格式的文本来”表达”调用意图,然后由外部的执行引擎将这些文本转换为实际的函数调用。
这种设计的妙处在于解耦。LLM不需要知道工具的具体实现细节——它不需要知道 read_file 是如何读取文件的,不需要知道 http_get 底层使用的是 fetch 还是 axios。LLM只需要知道每个工具的名称、描述和参数Schema,就能做出正确的调用决策。这种解耦让工具系统具有极强的可扩展性:你可以随时添加新工具,修改现有工具的实现,甚至替换整个工具类别(比如把文件系统工具从本地读取换成远程SFTP),而无需对LLM Prompt或Agent核心逻辑做任何修改。
在实际项目中,工具系统的设计还需要考虑一个重要的工程问题:工具的版本管理。当你的Agent被多个用户使用,或者你的工具集在不断演化时,你需要确保LLM看到的工具描述与实际的工具实现保持一致。一个常见的做法是在ToolRegistry中为每个工具记录版本号,当工具的实现发生破坏性变更时(比如参数名称改变、返回结果结构变化),相应地更新版本号,并在系统启动时检查版本兼容性。这种版本控制机制虽然在小项目中可能显得多余,但在生产环境中是防止”LLM调用正确但工具返回意外格式”这类错误的有效保障。
另一个工程实践是工具的依赖注入。在复杂的Agent系统中,工具之间可能存在依赖关系——比如日志工具依赖配置工具来获取日志级别设置,网络工具依赖认证工具来获取API Key。直接在工具内部硬编码这些依赖会导致测试困难和耦合度过高。更好的做法是通过构造函数注入依赖,让每个工具只关心自己的核心逻辑,依赖的获取交给外部容器。这与传统的依赖注入框架(如InversifyJS、TSyringe)的理念完全一致,也是保证工具系统可测试性和可维护性的关键设计。
在工具执行的并发控制方面,也需要考虑一些边界情况。当Agent需要同时调用多个独立工具时(比如同时分析5个文件),并行执行能显著提高效率。但并非所有工具都适合并行——有些工具之间存在数据依赖(比如必须先列出目录才能读取文件),有些工具共享资源(比如同时写入同一个文件会导致冲突)。ToolExecutionEngine应该支持两种执行模式:顺序执行(保证依赖关系)和并行执行(提高效率)。对于并行模式,可以使用 Promise.all() 来并发执行独立的工具调用,但需要配合适当的资源锁机制来避免冲突。
工具的结果缓存也是一个值得关注的优化点。有些工具调用的结果是确定性的、不随时间变化的——比如读取一个不常变动的配置文件,或者查询一个稳定API的版本信息。对这些结果进行缓存可以避免重复调用,既节省Token又降低延迟。简单的缓存策略可以使用内存中的Map,以工具名称和参数JSON作为Key;更复杂的策略可以考虑TTL(Time To Live)过期、LRU(Least Recently Used)淘汰等。当然,缓存也引入了新的复杂性——你需要判断哪些工具的结果是可缓存的(读操作),哪些是不可缓存的(写操作),以及如何处理缓存失效的情况。
从LLM Prompt工程的角度来看,工具描述的排列顺序也会影响LLM的工具选择行为。研究表明,LLM对列表中靠前位置的工具有轻微的偏好——这类似于人类的”首因效应”。因此,在构建工具列表时,可以考虑将最常用、最核心的工具放在前面。同时,工具描述的长度应该尽量保持一致,避免某个工具的描述过长而”淹没”其他工具的信息。一个好的经验法则是:每个工具的描述控制在50-150字之间,参数描述每个控制在10-30字之间。
在跨语言工具支持方面,Agent的工具系统并不限定于特定的编程语言。虽然本章的示例使用TypeScript实现,但工具的核心概念——Schema定义、注册发现、执行引擎——在任何语言中都是相通的。如果你需要在Python环境中使用类似的工具系统,可以考虑使用Pydantic替代Zod(Pydantic同样支持JSON Schema生成和运行时验证),使用Python的装饰器语法实现 @tool 装饰器。事实上,许多流行的Python Agent框架(如LangChain、AutoGen)都采用了与本章类似的工具系统设计,这证明了这种架构的通用性和可移植性。
安全审计是生产环境中不可忽视的环节。每次工具调用都应该记录完整的审计日志:谁在什么时候调用了什么工具、传了什么参数、得到了什么结果。这些日志不仅用于故障排查,更是安全合规的要求。想象一下,如果Agent的 execute_command 工具被恶意利用执行了危险命令,你需要审计日志来追踪攻击路径。审计日志应该具备不可篡改性——一旦写入就不能修改,这可以通过只追加的文件模式(append-only)或专门的审计数据库来实现。
最后,工具系统的可观测性(Observability)是现代Agent工程的标配。除了调用日志,你还应该收集工具执行的指标数据:调用频率分布(哪些工具最常用)、延迟分布(P50、P95、P99延迟)、错误率趋势(随时间变化的错误比例)。这些数据可以发送到Prometheus、Grafana等监控系统中,构建实时的Agent性能仪表盘。当工具错误率突然升高时,监控系统可以自动触发告警,通知开发者及时介入。这种”可观测+可告警”的闭环是保障Agent系统稳定运行的最后一道防线。
通过本章的学习,你已经构建了一个完整的工具系统——从Schema定义到装饰器注册,从执行引擎到错误处理,从动态选择到工具链编排,从日志记录到CLI集成。这个工具系统虽然还称不上企业级的完备,但已经具备了核心骨架,可以作为后续扩展的基础。在第4章中,你将学习MCP协议的基础知识,把本地工具系统升级为标准化、可插拔的工具服务,让你的Agent能够与更多外部系统无缝集成。
回到工具Schema的设计,有一个容易被忽视但对LLM调用准确率影响巨大的细节:参数的命名规范。LLM对参数名的理解直接影响它生成参数值的准确性。一个好的参数名应该是自描述的——path 比 p 更好,maxLines 比 ml 更好。但这还不够,参数名还应该与工具描述中使用的术语保持一致。如果你在描述中说”文件路径”,那么参数名应该叫 filePath 或 path,而不应该叫 location 或 target。这种命名一致性减少了LLM在”理解描述”和”生成参数”之间的认知转换成本。
另一个影响工具调用准确率的因素是参数的顺序。虽然JSON对象的键值对理论上是顺序无关的,但LLM在生成参数时往往会受到Schema定义顺序的影响。建议将最重要的、最常用的参数放在前面。比如 read_file 工具的参数顺序应该是 path 在前、maxLines 在后,因为用户每次调用都必须提供 path,而 maxLines 有默认值可以省略。这种”必填优先”的排列顺序让LLM先生成最关键的信息,减少了遗漏必填参数的可能。
在工具执行的错误反馈设计中,suggestion 字段是一个容易被低估的功能。当工具调用失败时,仅仅告诉LLM”出错了”是不够的——LLM需要知道”下一步该怎么做”。suggestion 字段就是为此而设计的。比如在路径访问被拒绝时,suggestion可以是”请检查路径是否在允许范围内,或使用 list_directory 查看可用文件”;在超时错误时,suggestion可以是”请尝试减小 maxLines 参数以读取更少内容”。这些建议直接影响了Agent的自我修复能力——一个有好建议的错误响应,可能让Agent在下一步就成功完成任务;一个没有建议的错误响应,则可能导致Agent在同样的错误上反复尝试。
从架构演进的角度看,工具系统的发展正在经历从”本地工具”到”远程工具”、从”静态工具”到”动态工具”的转变。本地工具是指与Agent运行在同一进程中的函数调用,这是本章实现的模式;远程工具则是通过MCP(Model Context Protocol)等协议暴露的网络服务,Agent通过RPC(远程过程调用)来使用它们。动态工具是指Agent在运行时发现和加载的工具,而不是启动时就固定好的工具集。这两种趋势的结合,预示着未来的Agent将能够动态发现和调用互联网上任意位置的工具服务——想象一个Agent在解决问题时,自动发现并调用GitHub上的代码分析服务、调用天气预报API、调用在线翻译服务,所有这些都是实时发现、按需使用的。第4章将介绍的MCP协议,正是这一趋势的技术基础。
在工具系统与LLM的交互中,还有一个重要的优化技巧:工具结果的摘要化。当工具返回大量数据时(比如一个1000行的文件内容,或者一个包含上千条记录的API响应),直接将完整结果传给LLM会迅速耗尽上下文窗口。更好的做法是在执行引擎层添加摘要功能——对于文件内容,只返回前N行加上总行数;对于表格数据,只返回前N条记录加上总记录数;对于API响应,只提取关键字段。摘要的阈值应该根据工具的用途动态调整:分析工具可能需要更多数据,查询工具可能只需要摘要。这种”按需供给”的数据策略能显著延长Agent的有效上下文长度,让它在有限的窗口内处理更多的工具调用。
工具系统的国际化支持也是值得考虑的问题。如果你的Agent需要服务中文、英文、日文等多语言用户,工具描述是否需要翻译?参数值是否需要本地化?实践中发现,LLM对英文工具描述的理解普遍优于其他语言——即使是中文 queries,用英文描述工具通常也能获得更准确的调用结果。这是因为主流LLM的训练数据以英文为主,对英文的工具命名和描述更加”熟悉”。因此,一个常见的做法是将工具名和参数名保持英文,只在 .describe() 中提供中文说明。这样既保证了LLM的理解准确性,又照顾到了中文用户的阅读体验。
最后,关于工具系统的测试策略,除了单元测试外,还应该引入集成测试和端到端测试。单元测试验证单个工具的正确性,集成测试验证工具之间的协作(比如工具链的正确执行),端到端测试验证整个Agent的推理流程(比如给定一个用户查询,Agent是否能正确选择工具、调用工具、整合结果)。集成测试可以用 “test containers” 模式——在临时目录中创建真实的文件结构,让Agent实际操作,验证整个流程的正确性。这种测试模式虽然比纯单元测试更慢,但能发现许多只有在真实交互中才会出现的问题,比如工具选择错误、参数传递错误、结果解析错误等。
在实际部署Agent工具系统时,还需要考虑一个关键问题:工具的热加载与动态更新。在开发阶段,你可能频繁地修改工具的实现或添加新工具。如果每次修改都需要重启整个Agent进程,开发效率将大打折扣。一个更好的方案是实现工具的热加载机制——在运行时检测工具文件的变化,自动重新加载修改后的工具。这可以通过文件系统监视(fs.watch 或 chokidar 库)来实现:当工具文件发生变化时,ToolRegistry自动注销旧版本并注册新版本。这种机制在开发环境中极为实用,但在生产环境中需要谨慎使用,因为热加载可能引入运行时错误——如果新版本工具的Schema与旧版本不兼容,而LLM仍然按照旧的Schema生成调用,就会导致运行时错误。因此,生产环境的热加载应该配合Schema版本兼容性检查。
关于工具调用的成本优化,也值得一提。每次工具调用都可能涉及外部资源消耗——文件读取消耗磁盘I/O,HTTP请求消耗网络带宽和API配额,代码分析消耗CPU计算。对于高频使用的Agent,这些成本会累积成显著的运营开销。优化策略包括:对于文件类工具,实现文件内容缓存(基于文件修改时间戳的失效策略);对于HTTP类工具,实现响应缓存(基于Cache-Control头的TTL策略);对于分析类工具,实现结果缓存(基于文件哈希的增量分析)。这些缓存策略的共同原则是:只有当输入没有变化时,才返回缓存的结果;一旦检测到输入变化,立即重新执行工具。这种”有缓存但不依赖缓存”的设计,既保证了性能,又保证了结果的新鲜度。
工具系统的文档生成也是一个实用的工程能力。有了Zod Schema和装饰器元数据,你可以自动生成工具文档——包括工具列表、每个工具的参数说明、示例调用、返回值描述。这些文档不仅对人类开发者有用(快速了解Agent的能力),对LLM本身也有用——在ReAct Prompt中嵌入的正是这些文档的自动生成本。更进一步,你可以将这些文档导出为OpenAPI格式,让其他系统(如Swagger UI)能够可视化地浏览你的工具集。这种”自文档化”的设计减少了维护成本——当你修改了工具的Schema或描述,文档会自动同步更新,无需手动维护。
在多Agent协作的场景中,工具共享成为一个重要议题。假设你有多个Agent,每个Agent都有自己的工具集,但有些工具是所有Agent共用的(比如文件读取、日志记录)。与其在每个Agent中重复定义这些工具,不如建立一个共享的工具库(Tool Library),所有Agent从同一个库中加载工具。这种模式类似于微服务架构中的”共享服务”——通用的能力集中管理,业务特定的能力各自维护。共享工具库的实现可以通过依赖注入容器来完成:每个Agent在初始化时从容器中获取自己需要的工具实例,容器负责工具的生命周期管理和依赖解析。这种架构不仅减少了代码重复,还确保了所有Agent使用一致的工具实现,降低了维护成本。
最后,工具系统的安全性不仅是技术问题,更是治理问题。你需要建立一套工具安全治理流程:新增工具需要经过安全审查(特别是涉及文件写入、命令执行、网络访问的工具),工具的权限变更需要经过审批,工具的使用情况需要定期审计。这种治理流程在团队开发中尤为重要——当多个开发者都可以向Agent添加工具时,缺乏治理很容易导致安全漏洞。一个实用的做法是在ToolRegistry中添加 “securityLevel” 标记,将工具分为 “safe”(只读操作)、”restricted”(写操作,需要额外确认)、”dangerous”(命令执行,需要高级权限)三个级别,Agent根据当前的安全策略决定是否允许调用对应级别的工具。
通过以上这些深入的设计考量和实践技巧,你的工具系统将不再是一个简单的”函数注册表”,而是一个成熟的、企业级的Agent能力平台。它能够安全地管理工具的整个生命周期,高效地执行工具调用,智能地处理错误和恢复,灵活地适应不断变化的业务需求。这为你在后续章节中学习更高级的Agent技术——如MCP协议、多Agent协作、长期记忆管理等——打下了坚实的基础。
在结束本章之前,让我们回顾一下工具系统设计中的几个关键决策点,这些决策将在后续章节中产生深远影响。首先是Schema设计的选择——本章使用Zod作为Schema定义工具,这是TypeScript生态的最佳实践,但如果你在其他语言环境中工作,需要找到对应的替代方案(如Python中的Pydantic、Go中的JSON Schema标签)。其次是注册机制的选择——装饰器模式在TypeScript中非常优雅,但在不支持装饰器的语言中,可以使用Builder模式或函数注册模式来达到同样的效果。最后是执行模型的选择——本章采用的同步执行模型(每次只执行一个工具)是最简单的实现,但在需要高性能并发的场景中,你可能需要引入异步执行队列或线程池。
这些设计决策没有绝对的对错,关键在于理解每种选择的权衡,并根据你的具体场景做出最适合的决定。工具系统的架构设计本质上是在”简单性”、”灵活性”、”性能”和”安全性”四个维度之间寻找最佳平衡点。对于个人项目或小型团队,简单性和灵活性可能更重要;对于企业级应用,性能和安全性则需要优先考虑。无论你选择哪种方案,本章提供的核心概念——Schema定义、注册发现、执行引擎、错误处理、日志记录——都是构建任何工具系统的基础构件,值得深入理解和熟练掌握。
当你完成了本章的所有代码示例和练习,你就已经拥有了一个功能完备的工具系统。这个系统虽然还只是一个起点,但它已经展示了Agent工具集成的核心原理和最佳实践。在接下来的第4章中,你将学习如何把这些本地工具升级为符合MCP协议标准的服务,让你的Agent能够与更广阔的外部世界进行交互。
回顾整个工具系统的设计过程,我们从最基础的Schema定义开始,逐步构建起了注册中心、执行引擎、错误处理、日志记录等核心模块。每一个模块的设计都遵循了”单一职责原则”——Schema只管结构定义,Registry只管注册发现,Engine只管执行调度,Logger只管记录追踪。这种模块化的设计让整个系统具有高度的可替换性和可测试性,你可以独立地替换任何一个模块的实现,而不会影响其他模块的正常工作。这也是软件工程中经过数十年实践验证的最佳设计模式。
随着Agent应用场景的不断扩展,工具系统的设计也在不断演进。从最初的简单函数调用,到本章介绍的完整工具框架,再到下一章将要学习的MCP标准化协议,工具系统正在变得越来越开放、越来越智能。掌握本章的知识,你就已经走在了这条演进道路的前列,具备了设计和实现复杂Agent工具系统的能力。
工具系统的设计是一项需要反复迭代的工作。本章为你提供了坚实的基础知识和可运行的代码框架,但真正的精进来自于在实际项目中不断使用和优化这些工具。每一次LLM的误调用、每一个工具执行的错误、每一个用户的反馈,都是改进系统的宝贵机会。保持对细节的关注,对设计原则的坚持,你的Agent工具系统一定会越来越成熟、越来越强大。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:SPEEDCoding 李北辰 李北辰《3. 工具集成与函数调用》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论