工具调用:从原生 FC 到框架 Tools
原生 Function Calling 的 JSON 细节 → LangChain 工具封装 → 错误处理与重试——Agent 的「手」。
模型有了「手」才能干活。这一章从裸 Function Calling 拆到 LangChain Tool,每一步都告诉你为什么。
工具不是插件,是一份「菜单」
你想让模型帮你读文件、查天气、搜代码——第一步不是写代码,是告诉模型你有什么工具可用。
工具的定义本质上就是一份菜单:
{
name: "Read", // 工具名(模型用它来调用)
description: "读取工作区文件", // 告诉模型这个工具是干嘛的
inputSchema: { // 告诉模型需要传什么参数
type: "object",
properties: {
file_path: { type: "string" },
offset: { type: "number" },
limit: { type: "number" },
},
required: ["file_path"],
},
}
模型看到这份菜单后,会在回答中输出一个结构化的调用请求——它不执行代码,它只是写了一张工单:
{ "type": "tool_use", "name": "Read", "input": { "file_path": "src/app.ts" } }
你的代码拿到这张工单,去执行,把结果回填。这就是全部。
easy-agent 的 Tool 契约
easy-agent Step 3 定义了一个极简的工具契约,五个字段:
const readTool = {
name: "Read", // 唯一标识
description: "…", // 给模型看的说明书
inputSchema: { … }, // JSON Schema 参数定义
isReadOnly() { … }, // 权限系统用:只读工具可自动放行
isEnabled() { … }, // 开关:可以动态禁用某个工具
async call(input, ctx) { // 真正干活的函数
const text = await fs.readFile(path, "utf8");
return { content: text, isError: false };
},
};
碎开来说每个字段的设计意图:
description 是给模型的 prompt。写得越清楚,模型调用得越准。这不是文档注释——是模型决定「什么时候用这个工具」的唯一依据。
inputSchema 是 JSON Schema。模型用它来生成正确的参数。你定义 required: ["file_path"],模型就知道必须传文件路径。
isReadOnly 是安全边界。读文件不改任何东西 → 可以自动放行;写文件有风险 → 需要用户确认。easy-agent Step 7 的权限系统就靠这个字段做分级。
返回 { content, isError } 而不是直接返回字符串——因为工具可能失败,模型需要知道结果是错误还是正常数据。
安全细节:路径逃逸防护
easy-agent 的 Read 工具有一个容易被忽略的安全检查:
function resolveWorkspacePath(filePath, cwd) {
const resolved = path.resolve(cwd, filePath);
const relative = path.relative(cwd, resolved);
// 如果路径以 .. 开头,说明试图逃出工作区
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Path is outside the workspace: " + filePath);
}
return resolved;
}
模型可能生成 ../../etc/passwd 这样的路径——不管是有意还是幻觉。永远不要信任模型生成的路径,必须做边界检查。这是所有 Coding Agent 的标配安全措施。
工具注册表:一个数组就够了
easy-agent 把所有工具放在一个数组里,通过名字查找:
const allTools = [readTool, writeTool, bashTool, grepTool];
function findToolByName(name) {
return allTools.find(t => t.name === name);
}
// 转换成 API 需要的格式
function getToolsApiParams() {
return allTools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema,
}));
}
就是这么简单——工具注册表本质是一个 Map,key 是工具名,value 是工具对象。
LangChain 的 Tool:同一个思想的声明式版本
LangChain 把上面的契约包装成了装饰器/类:
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const readTool = tool(
async ({ file_path }) => {
return await fs.readFile(file_path, "utf8");
},
{
name: "read_file",
description: "读取文件内容",
schema: z.object({
file_path: z.string().describe("文件路径"),
}),
}
);
对比一下:
| 裸实现 | LangChain |
|---|---|
手写 inputSchema JSON Schema | 用 Zod 定义,自动生成 Schema |
手写 call() 方法 | 直接传一个函数 |
| 手动注册到数组 | createAgent({ tools: [readTool] }) 自动注入 |
| 手动处理错误 | 框架捕获并转为 tool_result 错误 |
底层机制完全一样——LangChain 只是把重复的样板代码收走了。
本章要点
- 工具 = 一份菜单(name + description + inputSchema)
- 模型不执行代码,它输出结构化的调用工单
description是给模型的 prompt,写得清楚调用得准- 永远不要信任模型生成的路径——做边界检查
- LangChain Tool 和裸实现思想相同,只是语法更声明式
→ 下一章:MCP 协议——工具接入的事实标准