前端转型 Agent 开发 03 之 Agent Tools - 给 Agent 装上手脚

一、Tool Calling

基础概念

tool 是什么

tool 是把一个普通函数包装成"模型可调用的可执行单元"的机制。它由两部分组成:执行回调(真正干活的函数)+ 元信息(name / description / schema)。模型看不到函数体,只能看到元信息里声明的名字、用途和参数结构,据此决定要不要调、调哪个、传什么参。

这可以类比 API 接口声明------调用方只认签名和文档,不关心内部实现。模型的"调用"也不是真的执行函数,而是生成一段结构化的 tool_call(带名字和参数),再由执行环境真正落地。

tool 在 Agent 当中作用

tool 是 Agent "行动"的唯一出口。纯 LLM 只能生成文本,接了 tool 之后,模型能把意图翻译成一次结构化的函数调用,再由执行环境真正落地(查数据库、发请求、写文件)。这让 Agent 从"只会说"变成"能做",是 ReAct 循环里 Acting 那一环的载体。

没有 tool,Agent 的答案只能来自训练数据里的知识,一旦涉及实时信息、私有数据、外部副作用就无能为力。有了 tool,模型可以查天气、读文件、调 API,把外部世界的真实结果纳入推理。

1.1、tool 定义工具函数

第一个参数:执行回调函数

工具的回调函数决定了执行结果返回给模型的形式,分三种:

  • 返回字符串
  • 返回对象
  • 返回 command(async 异步处理 Command)

第二个参数:Tool 的 meta 信息

  • name
  • description
  • schema:执行回调时的参数
typescript 复制代码
import { tool, ToolMessage, type ToolRuntime } from "langchain";
import { Command } from "@langchain/langgraph";
import * as z from "zod";

// 返回字符串
const getWeather = tool(({ city }) => `It is currently sunny in ${city}.`, {
  name: "get_weather",
  description: "Get weather for a city.",
  schema: z.object({ city: z.string() }),
});

// 返回对象
const getWeatherData = tool(
  ({ city }) => ({
    city,
    temperature_c: 22,
    conditions: "sunny",
  }),
  {
    name: "get_weather_data",
    description: "Get structured weather data for a city.",
    schema: z.object({ city: z.string() }),
  },
);

// 异步返回 Command
const setLanguage = tool(
  async ({ language }, config: ToolRuntime) => {
    return new Command({
      update: {
        preferredLanguage: language,
        messages: [
          new ToolMessage({
            content: `Language set to ${language}.`,
            tool_call_id: config.toolCallId,
          }),
        ],
      },
    });
  },
  {
    name: "set_language",
    description: "Set the preferred response language.",
    schema: z.object({ language: z.string() }),
  },
);

三种返回形式各有用途。返回字符串最简单,适合"查了天气给一段话"的场景;返回对象适合下游要结构化消费的结果;返回 Command 则给了工具改写整个图状态的能力,能顺手更新 preferredLanguage 这类自定义字段,而不仅是回一条 ToolMessage

namedescription 是模型选工具的唯一依据,要写得直白:工具做什么、什么时候该用、参数填什么。schema 用 Zod 声明参数结构,模型会照着它填参,同时它也是运行时的参数校验边界。

1.2、Agent 注册与调用 Tools

createAgent 时传入 tools 数组(由每个 tool 组成),就能让 Agent 根据每个 Tool 的 description 自动识别该调哪个。

createAgent 是 LangGraph 提供的高阶工厂------把 LLM 和一组 tools 交给它,它返回一个编译好的可执行 Agent。模型在每轮推理时会"看到"所有工具的 name + description + schema,自主决定本轮要不要调用、调哪个、传什么参数。

javascript 复制代码
import { createAgent } from '@langchain/langgraph';
import { ChatAnthropic } from '@langchain/anthropic';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';

// 1. 定义一组业务工具
const getWeather = tool(
  async ({ city }) => {
    // 真实场景这里会调天气 API
    const data = await fetchWeather(city);
    return `${city} 今天 ${data.temp}°C,${data.condition}`;
  },
  {
    name: 'get_weather',
    description: '查询指定城市的当前天气。输入城市名(中文或英文)。',
    schema: z.object({
      city: z.string().describe('城市名称,如 "北京" 或 "Beijing"'),
    }),
  },
);

const getTime = tool(
  async () => {
    return `现在是 ${new Date().toLocaleTimeString('zh-CN')}`;
  },
  {
    name: 'get_time',
    description: '获取当前时间。',
    schema: z.object({}),
  },
);

// 2. 用 createAgent 把 LLM 和 tools 组装成 Agent
const agent = createAgent({
  llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
  tools: [getWeather, getTime],
  prompt: '你是一个有用的助手,可以查天气和时间。回答用中文。',
});

// 3. 调用 Agent------它会自动决定调哪个工具、传什么参数
const result = await agent.invoke({
  messages: [
    { role: 'user', content: '北京和东京现在几点?天气怎么样?' },
  ],
});

// Agent 内部会经历多轮:
// 轮1: 模型决定调 get_time → 拿到时间
// 轮2: 模型决定调 get_weather(北京) → 拿到北京天气
// 轮3: 模型决定调 get_weather(东京) → 拿到东京天气
// 轮4: 模型综合所有结果,输出最终自然语言回答
console.log(result.messages[result.messages.length - 1].content);
// "现在是 14:30。北京 22°C 晴天,东京 18°C 多云。"

这里不需要写 if (用户问天气) 调天气工具 这类硬编码逻辑。模型靠阅读每个 tool 的 description 自主决策,所以 description 直接决定工具被调用的方式与频率。好的 description 要交代清楚:这个工具做什么、什么时候该用、参数填什么。


二、Agent Loop

2.1、概念

Agent 的本质是一个循环:模型按"推理 → 调用工具 → 看结果 → 继续推理"的节奏推进,直到它认为可以给出最终回答。

ReAct

ReAct = Reasoning + Acting,是 Agent 的经典心智模型------模型先推理("我需要做什么"),再行动,然后根据行动结果继续推理。

ReAct 代理把自然语言处理(NLP)和强化学习(RL)结合,能在执行任务前进行内部思考和推理,从而优化行动策略。它靠多步骤处理能力分解任务、规划执行,适用于任务管理、客服支持、数据分析这类复杂场景。ReAct 的独特之处在于内部对话机制------像人一样分析问题、做出决策,并在动态环境里适应变化,展现出较强的自主性。

Tool Agent Loop

Tool Agent Loop 是 ReAct 的进一步高阶处理,结合 LLM 的决策判断是否要继续调用相关 Tools:如果返回的是纯文案,或者本轮没有 tool_call,就说明这一轮 LLM 调用已经得出了结论,不再需要循环询问。

2.2、结束 Loop 的判断处理条件

情况概述 硬停/软停 触发条件
无工具调用 ✅ 正常结束 LLM 本轮无 tool_call(主路径)
总循环超时 ⏱ 超时 总循环超时:Date.now() - loopStart > LOOP_TIMEOUT_MS(2h 长任务)
用户中止 ⏹ 中断 用户点 stop(abortSignal.abort)
adapter 流报错 ⚠️ 异常 本轮收到 error chunk(errored=true
达到轮次上限 🔁 耗尽硬停兜底 turn > MAX_TURNS(100)
同一工具多次同参调用 不停,注入参数纠正 同 (tool, argsHash) ≥ 3 次
接近或快超 token 不停,进行上下文压缩 input_tokens/contextWindow ≥ 75% / 90%

这张表里两类"停"要分清:正常结束和异常中止是硬停,直接终止循环;后面三行属于软停------不直接停,而是注入纠正或压缩上下文,让循环能继续走下去。设计循环终止条件时,这几条兜底缺一不可,否则一旦模型陷入"反复调同一个工具"或 token 悄悄爆掉,整个 Agent 就卡死了。

2.3、简单的完整的例子

把前面定义的工具、注册、Loop 串起来,下面是一个从零搭建到运行的完整例子------用户问"帮我查上海天气,然后根据天气推荐穿搭",Agent 会自主规划调用顺序:

php 复制代码
import { createAgent } from '@langchain/langgraph';
import { ChatAnthropic } from '@langchain/anthropic';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';

// ① 定义两个工具
const getWeather = tool(
  async ({ city }) => {
    // 模拟天气 API
    const db: Record<string, { temp: number; condition: string }> = {
      上海: { temp: 20, condition: '多云转小雨' },
    };
    const w = db[city] ?? { temp: 25, condition: '晴' };
    return `${city}:${w.temp}°C,${w.condition}`;
  },
  {
    name: 'get_weather',
    description: '查询某城市当前天气,返回温度和天气状况。',
    schema: z.object({ city: z.string().describe('城市名') }),
  },
);

const suggestOutfit = tool(
  ({ temp, condition }) => {
    if (condition.includes('雨')) return `建议带伞,穿防水外套(${temp}°C 偏凉)`;
    if (temp < 10) return '建议穿羽绒服';
    if (temp < 20) return '建议穿薄外套';
    return '可以穿短袖';
  },
  {
    name: 'suggest_outfit',
    description: '根据温度和天气状况推荐穿搭。温度单位是摄氏度。',
    schema: z.object({
      temp: z.number().describe('摄氏度温度'),
      condition: z.string().describe('天气状况,如"晴"、"雨"'),
    }),
  },
);

// ② 创建 Agent
const agent = createAgent({
  llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
  tools: [getWeather, suggestOutfit],
  prompt: '你是穿搭顾问。先查天气,再根据天气推荐穿搭。',
});

// ③ 运行(用 stream 看清楚 Loop 的每一轮)
const stream = await agent.stream(
  { messages: [{ role: 'user', content: '我在上海,今天穿什么?' }] },
  { streamMode: 'updates', recursive: false },
);

for await (const event of stream) {
  // 每轮推理 / 工具调用都会产出一个 event
  console.log(JSON.stringify(event, null, 2));
}

// ===== 输出(简化)=====
// 第1轮 event(agent 节点):
//   模型推理 → 决定调 get_weather({ city: "上海" })
//   AIMessage.tool_calls = [{ name: "get_weather", args: { city: "上海" } }]

// 第2轮 event(tools 节点):
//   执行 get_weather → 返回 "上海:20°C,多云转小雨"
//   ToolMessage.content = "上海:20°C,多云转小雨"

// 第3轮 event(agent 节点):
//   模型看到天气结果 → 决定调 suggest_outfit({ temp: 20, condition: "多云转小雨" })

// 第4轮 event(tools 节点):
//   执行 suggest_outfit → 返回 "建议带伞,穿防水外套(20°C 偏凉)"

// 第5轮 event(agent 节点):
//   模型综合所有信息 → 输出最终自然语言回答,本轮无 tool_call → Loop 结束
//   AIMessage.content = "上海今天 20°C,多云转小雨。建议带伞,穿防水外套..."

console.log('--- Agent 执行完毕 ---');

这就是一个完整的 ReAct 循环:推理(要查天气)→ 行动(调天气工具)→ 推理(要查穿搭)→ 行动(调穿搭工具)→ 推理(综合回答,无工具调用,结束)。

值得观察的是,Agent 自主决定了调用顺序------先查天气再推荐穿搭,而不是反过来。这个"规划"能力完全来自模型的推理 + 工具 description,代码里不需要写死调用顺序。这也解释了 Agent 为什么比传统"硬编码工作流"灵活:它能处理代码作者没预料到的组合。


三、Tool Calling 进阶话题

3.1、工具并行调用

模型可以一次返回多个 tool_calls,彼此没有依赖关系(如查天气 + 查时间)。createAgent 会自动并行执行这些工具,省去多轮推理的时间。

php 复制代码
// 用户问:"北京和东京现在的天气和时间?"
// 模型可能一次返回 2 个工具调用:
//   - get_weather({ city: "北京" })
//   - get_weather({ city: "东京" })
//   - get_time()

// createAgent 内部会自动并行执行它们(不是串行!):
const result = await agent.invoke({
  messages: [{ role: 'user', content: '北京和东京现在的天气和时间?' }],
});

// 总耗时 ≈ max(三个工具各自耗时),而不是 sum

并行与串行的判断:模型只对无依赖的调用使用并行。如果工具 A 的输出是工具 B 的输入,模型会自动串行(先 A 再 B)。这种"并行机会识别"是模型自身的能力,不需要在工具里标注。

3.2、工具调用失败处理

工具可能因为网络、参数错误、外部服务故障而失败。三种应对策略:

javascript 复制代码
import { tool, ToolException } from '@langchain/core/tools';
import { z } from 'zod';

// ① 抛出 ToolException:模型会看到错误并尝试换方案
const flakyTool = tool(
  async ({ input }) => {
    if (Math.random() < 0.3) {
      throw new ToolException(`"${input}" 处理失败,请换种问法`);
    }
    return `成功:${input}`;
  },
  {
    name: 'flaky_tool',
    description: '一个偶尔会失败的工具',
    schema: z.object({ input: z.string() }),
  },
);

// ② 返回错误字符串(不抛异常):模型把它当正常结果处理
const fallbackTool = tool(
  async ({ input }) => {
    try {
      return await externalAPI(input);
    } catch (err) {
      return `[工具内部错误] ${err.message},建议重试或换工具`;
    }
  },
  {
    name: 'safe_tool',
    description: '失败时返回错误信息而不是抛异常',
    schema: z.object({ input: z.string() }),
  },
);

// ③ 在外层拦截 ToolMessage,做降级处理(推荐)
const wrappedTool = tool(
  async (args, config) => {
    try {
      return await originalTool.invoke(args, config);
    } catch (err) {
      // 记埋点 + 返回降级结果
      console.error(`[tool:${originalTool.name}] 失败:`, err);
      return `工具调用失败:${err.message}。请告知用户稍后重试。`;
    }
  },
  originalTool.metadata,
);

错误处理的取舍:

策略 优点 缺点
抛 ToolException 模型明确知道失败,可自主重试或换方案 模型可能无限重试
返回错误字符串 简单,不破坏对话流 模型可能误把错误信息当正常结果
外层拦截降级 完全可控,统一埋点 需要包装代码

本项目 engine/src/tools/ 对所有有副作用的工具都用外层拦截------失败记日志 + 埋点 + 返回用户友好的错误信息,让模型知道可以"建议用户重试",而不是反复尝试同一个工具。

3.3、Tool 元数据高级用法

tool() 的第二个参数除了 name/description/schema,还有几个不常用但很有用的字段:

php 复制代码
import { tool } from '@langchain/core/tools';
import { z } from 'zod';

// ① returnDirect:跳过模型,直接把工具结果返回给用户
//    适用于"展示型"工具(如计算器、查询天气后立刻显示结果)
const quickLookup = tool(
  async ({ city }) => `北京:晴,25°C`,
  {
    name: 'quick_lookup',
    description: '快速查询',
    schema: z.object({ city: z.string() }),
    returnDirect: true, // ← 关键:工具结果直接返回,不再让模型加工
  },
);

// ② responseFormat:自定义工具响应的 schema(默认是字符串)
//    适用于"机器消费"的工具结果
const structuredTool = tool(
  async ({ query }) => ({
    results: ['item1', 'item2'],
    total: 2,
  }),
  {
    name: 'search',
    description: '结构化搜索',
    schema: z.object({ query: z.string() }),
    // 可选:声明返回结构,下游消费者按此解析
  },
);

// ③ extra:附加任意元数据,可在 Hook / Middleware 里读取
const tracedTool = tool(
  async ({ x }) => x * 2,
  {
    name: 'multiply_by_two',
    description: '乘以 2',
    schema: z.object({ x: z.number() }),
    extra: {
      cost: 'low',           // 自定义:成本分级
      risk: 'safe',          // 自定义:风险等级
      owner: 'team-a',       // 自定义:归属团队
      requiresApproval: false, // 自定义:是否需要审批
    },
  },
);

extra 字段看着不起眼,实际是接 Hook 和 Middleware 的钩子------审批、限流、成本统计都能从这读取工具的自定义属性,不用再维护一份外部的工具清单。

3.4、动态工具加载

按用户角色、当前任务、上下文动态注入工具子集,能显著提升 Agent 的"工具选择准确率"(少即是多)------不需要一次性塞给 Agent。

php 复制代码
// 场景:管理员才看"删文件"工具,普通用户只看到"读文件"
const baseTools = [readFileTool, listFilesTool];

function getToolsForUser(userRole: string) {
  if (userRole === 'admin') {
    return [...baseTools, deleteFileTool, writeFileTool];
  }
  return baseTools; // 普通用户看不到危险工具
}

const agent = createAgent({
  llm: model,
  tools: getToolsForUser(currentUser.role),
});

// 也可以根据当前任务动态决定
function getToolsForTask(task: string) {
  if (task.includes('代码')) {
    return [readFileTool, searchDocsTool, runTestsTool];
  }
  if (task.includes('部署')) {
    return [runCommandTool, deployTool];
  }
  return baseTools;
}

动态加载的副作用:每次切换工具集,Agent 的"工具提示词"会变,可能让模型困惑("我刚才还能调的工具怎么没了?")。建议在同一会话内稳定工具集,只在会话开始时按角色/任务确定一次。

3.5、Tool Middleware(工具中间件)

和 Web 中间件类似------在工具执行前后插入横切逻辑(日志、限流、参数改写、敏感字段脱敏)。LangGraph 通过 preToolCallHook + postToolCallHook 实现:

ini 复制代码
const agent = createAgent({
  llm: model,
  tools: [sendEmailTool, readFileTool],

  // 工具执行前的拦截
  preToolCallHook: (state) => {
    const lastCall = state.messages.at(-1)?.tool_calls?.at(-1);
    if (!lastCall) return;

    // 横切关注点 1:限流(同一工具 1 秒内最多调 5 次)
    rateLimiter.check(lastCall.name);

    // 横切关注点 2:参数改写(如自动给邮件加签名)
    if (lastCall.name === 'send_email' && !lastCall.args.signature) {
      lastCall.args.signature = '------ 来自 Vanguard AI';
    }

    // 横切关注点 3:敏感字段脱敏(log 时遮蔽密码)
    if (lastCall.name === 'read_file') {
      console.log(`[tool] ${lastCall.name}`, redactSensitive(lastCall.args));
    }
  },

  // 工具执行后的拦截
  postToolCallHook: (state) => {
    const lastMsg = state.messages.at(-1);
    if (lastMsg?.role !== 'tool') return;

    // 横切关注点 4:埋点(统计工具调用)
    metrics.increment('tool_call.count', { tool: lastMsg.name });

    // 横切关注点 5:结果缓存(相同 query 不重复执行)
    cache.set(lastMsg.tool_call_id, lastMsg.content);
  },
});

Middleware(Hook)影响所有工具,适合横切关注点;Wrapper(包装函数)只影响单个工具,适合定制化逻辑。两者结合使用效果最佳。

3.6、tool_call_id 生命周期

tool_call_id 是连接"模型决定调工具"和"工具返回结果"的纽带。完整生命周期:

csharp 复制代码
// 第 1 步:模型决定调工具(在 AIMessage 里生成 tool_calls)
const aiMsg = await model.invoke([...previousMessages]);
// aiMsg.tool_calls = [
//   { id: 'call_abc123', name: 'get_weather', args: { city: '北京' } }
// ]

// 第 2 步:执行工具,生成 ToolMessage
// tool_call_id 必须 = aiMsg.tool_calls[].id
const toolResult = await getWeather(aiMsg.tool_calls[0].args);
const toolMsg = new ToolMessage({
  content: toolResult,           // 工具执行结果
  tool_call_id: 'call_abc123',  // ← 必须对应上!
  name: 'get_weather',
});

// 第 3 步:把 AIMessage + ToolMessage 一起发给模型
const nextResponse = await model.invoke([
  ...previousMessages,
  aiMsg,        // 模型的"决定"
  toolMsg,      // 工具的"结果"
]);

错乱的常见原因:

错误 现象 解决
tool_call_id 对不上 模型报"tool message without tool call" 严格用 aiMsg.tool_calls[].id 作为 ToolMessage.tool_call_id
漏发 AIMessage 模型看不到自己的"决定" 必须把 AIMessage 和对应的 ToolMessage 成对插入消息列表
重复 tool_call_id 同一调用被算两次 每次生成新 tool_call_id,不要复用
顺序错乱(ToolMessage 在前) 模型困惑"我没调过这个" 严格按 AIMessage → ToolMessage → AIMessage 的顺序

自动化方案是用 createAgent 而不是手写循环------它自动处理 tool_call_id 配对、消息顺序、并行执行。只有在需要精细控制时才手写。

参考资料:

Agent 开发系列文章

前端转型 Agent 开发 01 之 Agent API 调用(和 Agent 的基础对话):juejin.cn/post/767744...

前端转型 Agent 开发 02 之 Provider 与 Structured Output(规范化模型输入输出):juejin.cn/post/767745...

相关推荐
jay神15 分钟前
【计算机毕业设计】基于SpringBoot的程序教学辅助系统
java·前端·vue.js·spring boot·后端·毕业设计·课程设计
Flynt17 分钟前
1200个AI Agent自己组了个群,把Hugging Face黑了
安全·openai·agent
小磊哥er43 分钟前
深入解构Claude Code - 第 9 篇 · 怎么给它加功能
typescript·ai编程
小磊哥er1 小时前
深入解构Claude Code - 第 8 篇 · 数据放哪、钱怎么算
javascript·ai编程
plainGeekDev3 小时前
Agent 技术调研自动化
agent·ai编程·claude
kyriewen3 小时前
我装了30多个Skill,给AI安排了8个岗位
前端·javascript·ai编程
风骏时光牛马3 小时前
大模型落地实践中的技术选型思路与方案对比
前端
魔术师Grace4 小时前
模型查资料、会做事、还省成本,分别靠什么?
aigc·agent·ai编程
IT_陈寒4 小时前
Vite打包时的静态资源坑,我帮你踩过了
前端·人工智能·后端