在这里插入图片描述

前两篇我们把模型和 Prompt 都摸透了,现在你能写出一个「会好好聊天、还能流式输出」的程序。但说句实在话,它再能聊,也只是个只会动嘴的嘴炮选手——你问它「北京今天几度」,它要么胡编一个温度,要么老实告诉你「我查不了实时天气」。

想让模型从「动嘴」升级到「动手」,就得教它两件事:

  • Tool calling(工具调用):让模型能查数据库、调 API、执行计算——真正去「干活」。
  • Structured Output(结构化输出):让模型别再吐一大段自由文本,而是稳定地返回你要的 JSON 结构。

这两样,是从「聊天机器人」迈向「真正的 Agent」最关键的一步。这一篇我们就用 tool()bindTools()withStructuredOutput() 把它们拿下。

老规矩,本文 API 均以官网最新文档核对过(https://docs.langchain.com/oss/javascript)。

Tool:用 zod 把普通函数变成「模型能调的工具」

先想清楚一件事:模型本身不会执行你的代码。所谓 Tool calling,其实是模型看了工具的描述后,告诉你「我想调 get_weather 这个函数,参数是 { location: "北京" }」,然后由你的代码去真正执行,再把结果喂回给它。

所以定义一个工具,本质上要告诉模型三件事:它叫什么、能干嘛、需要什么参数。LangChain 用 @langchain/core/tools 里的 tool() 函数,把一个普通 async 函数包装成模型能识别的工具:

import { tool } from "@langchain/core/tools";
import * as z from "zod";

const weatherTool = tool(
  async ({ location }) => {
    // 真实项目里,这里去调真正的天气 API
    return `${location} 今天晴,25°C`;
  },
  {
    name: "get_weather",
    description: "查询指定城市的当前天气",
    schema: z.object({
      location: z.string().describe("城市名,例如:北京、上海"),
    }),
  }
);

三个关键配置:

  • name:工具的唯一标识,模型靠它决定调哪个工具。
  • description:自然语言描述,模型靠它判断「什么时候该用这个工具」。写清楚很重要。
  • schema:用 zod 定义参数结构。每个字段都要加 .describe()——这不是可选项,模型全靠这句描述来理解「这个参数该传什么」。

zod 就是上一篇差异表里提到的:Python 用 Pydantic 做 schema 校验,JS 这边的等价物就是 zod。它既帮模型描述参数,又在运行时帮你校验模型传回来的参数对不对,一举两得。

bindTools:把工具绑到模型上

工具定义好了,还得让模型「知道」它的存在。这一步用 bindTools()

const llmWithTools = llm.bindTools([weatherTool]);
const response = await llmWithTools.invoke("北京今天天气怎么样?");

绑定之后,模型回复时就多了一种可能:它不再直接给你一段文本,而是返回一个带 tool_callsAIMessage,告诉你它想调哪个工具、传什么参数:

if (response.tool_calls?.length) {
  for (const call of response.tool_calls) {
    console.log(call.name, call.args);
    // 例如:get_weather { location: "北京" }
  }
}

注意,到这一步模型还没拿到任何天气数据,它只是「表达了调用意图」。真正执行、把结果还回去,是我们的活儿。

完整的 Tool calling 循环

一次完整的工具调用,是这样一个「四步走」的往返(Agent 里就是把它循环起来跑):

1. 用户提问        → 模型返回 AIMessage(带 tool_calls)
2. 我们执行工具    → 拿到结果
3. 结果包成 ToolMessage → 回传给模型
4. 模型看到结果    → 生成最终的自然语言回复

关键在第 3 步:工具结果必须包成 ToolMessage 传回去,而且要带上对应的 tool_call_id,模型才知道「这个结果是哪次调用的」。

好消息是,新版 LangChain 有个很省事的写法——直接把整个 tool_call 对象丢给工具的 .invoke(),它会自动帮你返回一个填好 tool_call_idToolMessage

// call 是 response.tool_calls[0],包含 name / args / id
const toolMessage = await weatherTool.invoke(call);
// 直接得到一个 ToolMessage,tool_call_id 已自动对应好,不用手动拼

比起自己 new ToolMessage({ content, tool_call_id }),这种写法更不容易出错,我推荐优先用它。

withStructuredOutput:逼模型吐出规整的 JSON

Tool calling 解决「动手」,结构化输出解决「输出格式可控」。

很多时候你不想要模型啰里八嗦的自然语言,你想要的是能直接喂给下游代码的数据——比如从一段文本里抽取出 { name, age, skills }。这时候用 withStructuredOutput(),传一个 zod schema,模型的输出就会被约束成这个结构:

import * as z from "zod";

const JokeSchema = z.object({
  setup: z.string().describe("笑话的铺垫"),
  punchline: z.string().describe("笑话的包袱"),
  rating: z.number().describe("好笑程度,1-10"),
});

const structuredLlm = llm.withStructuredOutput(JokeSchema);
const joke = await structuredLlm.invoke("讲一个关于程序员的笑话");
// joke 已经是类型安全的 { setup, punchline, rating } 对象,拿来即用

这里有个 TypeScript 的甜头:返回值的类型会自动从 zod schema 推断出来joke.ratingnumberjoke.setupstring,编辑器全程给你补全和类型检查。

关于 Ollama 的结构化输出,有两种模式:

  • 默认模式:走 Ollama 原生的 JSON schema 约束,官方说法是所有模型都支持(但实测小模型质量不稳,后面会讲)。
  • method: "functionCalling":改走 function calling 通道输出,适合需要和工具调用逻辑保持一致的场景。
const structuredLlm = llm.withStructuredOutput(JokeSchema, {
  method: "functionCalling",
});

bindTools 与 withStructuredOutput:别在同一实例上混用

这是个容易踩的坑。在同一个模型实例上链式写 .bindTools([...]).withStructuredOutput(...),很可能导致 schema 打架——模型会分不清某个字段到底是「工具参数」还是「结构化输出字段」。

我的建议是分开用:

需求 方案
只要 Tool calling llm.bindTools(tools)
只要 Structured Output llm.withStructuredOutput(schema)
两个都要 拆成不同环节/节点分别处理(后面进 LangGraph 会很自然),或用 withStructuredOutput 的相关选项统一走 tool 通道
// 别这么写
// llm.bindTools(tools).withStructuredOutput(schema);

// 各用各的实例
const toolLlm = llm.bindTools([weatherTool]);
const structuredLlm = llm.withStructuredOutput(JokeSchema);

关于模型选择:不是所有模型都会「调工具」

这一点得给你提前打个预防针:Tool calling 对模型能力有要求,不是随便拉个模型都能稳定用的。小模型(1B~3B)经常输出格式错误的 JSON、或者干脆虚构一个不存在的工具名。

我这个系列一直用的 qwen2.5:7b 在中文理解和 function calling 上都比较均衡,适合本地跑。给你一份参考:

模型 Tool calling 说明
qwen2.5:7b 推荐 中文友好,性能与能力平衡,本系列默认
llama3.1:8b 可选 Meta 官方对 function calling 支持较好
llama3-groq-tool-use 可选 专为 tool use 微调的模型
llama3.2:1b 等小模型 不推荐 tool call 格式不稳,容易乱编参数

如果 tool call 老是失败,排查顺序是:① 先确认模型本身支持;② 把 temperature 降到 0 ~ 0.2;③ 在 Prompt 里明确要求它使用某个工具(有些模型需要你直说「请使用 get_weather 工具」才肯动)。

代码示例

示例 1:天气 Tool + 完整调用循环

把前面讲的四步走完整串起来(这里用了更简洁的 tool.invoke(call) 写法):

import { ChatOllama } from "@langchain/ollama";
import { tool } from "@langchain/core/tools";
import { HumanMessage } from "@langchain/core/messages";
import * as z from "zod";

// 定义工具
const getWeather = tool(
  async ({ location }: { location: string }) => {
    // 模拟一次 API 调用
    const data: Record<string, string> = {
      北京: "晴,25°C",
      上海: "多云,22°C",
    };
    return data[location] ?? "暂无该城市数据";
  },
  {
    name: "get_weather",
    description: "获取指定城市的当前天气",
    schema: z.object({
      location: z.string().describe("中国城市名,例如北京、上海"),
    }),
  }
);

const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const llmWithTools = llm.bindTools([getWeather]);

const question = "上海今天天气如何?请使用 get_weather 工具查询。";
const messages = [new HumanMessage(question)];

// 第一步:模型决定是否调用工具
const aiMsg = await llmWithTools.invoke(messages);
messages.push(aiMsg);

if (aiMsg.tool_calls?.length) {
  for (const call of aiMsg.tool_calls) {
    // 第二、三步:执行工具,并自动得到带 tool_call_id 的 ToolMessage
    const toolMessage = await getWeather.invoke(call);
    messages.push(toolMessage);
  }

  // 第四步:把工具结果回传,让模型生成最终回复
  const finalMsg = await llmWithTools.invoke(messages);
  console.log(finalMsg.content);
}

看出来了吗?这个「调用 → 执行 → 回传 → 再调用」的循环,如果不止一轮(模型可能连续调用好几个工具),你就得用一个 while 循环把它转起来——这基本就是一个 Agent 的雏形了。等进入 LangGraph,我们会用更优雅的方式把这个循环管理起来。

示例 2:withStructuredOutput 提取结构化信息

从一段自由文本里抽取出规整的对象:

import { ChatOllama } from "@langchain/ollama";
import * as z from "zod";

const PersonSchema = z.object({
  name: z.string().describe("人物姓名"),
  age: z.number().describe("年龄"),
  skills: z.array(z.string()).describe("技能列表"),
});

const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const structuredLlm = llm.withStructuredOutput(PersonSchema);

const person = await structuredLlm.invoke(
  "从以下文本提取人物信息:张三,28岁,精通 TypeScript 和 Vue。"
);

console.log(person);
// { name: "张三", age: 28, skills: ["TypeScript", "Vue"] }

小结

这一篇我们让模型真正「长出了手」:

  • Tools:用 tool() + zod 定义工具(name / description / schema 三件套,字段必带 .describe()),用 bindTools() 绑到模型,靠 tool_calls 拿到调用意图,再走「执行 → ToolMessage 回传 → 最终回复」的循环。
  • Structured Output:用 withStructuredOutput(zodSchema) 逼模型吐规整 JSON,返回值类型还能自动推断。
  • 注意:两者别在同一实例混用;Tool calling 挑模型,本地建议 qwen2.5:7b 起步。

你有没有发现,示例 1 结尾那个「循环」已经隐隐有 Agent 的味道了?但现在模型的知识还被锁在训练数据里——它能调你给的工具,却没法回答「我们公司内部文档里写了啥」这类问题。

更多推荐