← 博客书房

Agent 实战:从 Dify 到 LangGraph·工具调用:从原生 FC 到框架 Tools

进阶20 分钟

工具调用:从原生 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" } }

你的代码拿到这张工单,去执行,把结果回填。这就是全部。

Tool 契约:一份给模型看的菜单 name 模型用它来调用 description 给模型的 prompt inputSchema 参数的 JSON Schema isReadOnly 安全分级 call() 真正干活 模型看到前三个(name + description + inputSchema)就知道怎么调用 你的代码用后两个(isReadOnly + call)来执行和管理权限 description 写得越清楚,模型调用得越准——这是模型决定「什么时候用这个工具」的唯一依据

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 只是把重复的样板代码收走了。

本章要点

  1. 工具 = 一份菜单(name + description + inputSchema)
  2. 模型不执行代码,它输出结构化的调用工单
  3. description 是给模型的 prompt,写得清楚调用得准
  4. 永远不要信任模型生成的路径——做边界检查
  5. LangChain Tool 和裸实现思想相同,只是语法更声明式

→ 下一章:MCP 协议——工具接入的事实标准