系列第 3 篇。前两篇都属于"文本派":模型用自然语言作文,我们事后解析。这一篇换思路------既然平台提供了"工具调用"这种机制,为什么不让模型直接把结构化答案填进去?
两个问题先想明白
为什么绑定工具,模型就按工具的结构生成?
你给模型注册一个"函数"(含名字、描述、参数 schema),模型如果决定调用它,参数就必须符合 schema。这不是玄学,是两个层面兜底:
- 训练层面:现役模型在训练/对齐时见过海量 function-calling 数据,学会了"调用函数时参数要符合该函数的参数声明"。
- 平台解码层面(更硬) :工具调用在 API 里被编码成特殊的 token 流 。模型一旦进入工具调用模式,解码器就走专门的语法路径,保证
arguments是合法 JSON;更强力的平台还会按 schema 在解码端校验。
所以"按结构生成"是训练能力 + 平台语法强制的共同结果,不是模型大发慈悲。
为什么工具不需要真实提供?
因为 function calling 是"接口协议",不是"执行通道" 。你把 {name, description, schema} 发给模型,语义只是"存在一个长这样的函数,你可以生成对它的调用"。模型生产的是一段"调用描述"(函数名 + 参数),它从不检查这函数是否真的被你实现。
于是诞生一个骚操作:注册一个假的工具,纯粹当 JSON 的容器 。模型返回的 tool_calls 里,参数就是你要的结构化答案------要不要真的去执行它,由你决定。
arduino
你想让它执行真实功能 ──→ tools 接到搜索/数据库/文件操作
你只想要结构化答案 ────→ 把工具当"表格",取走 tool_calls.args 就行
手写 bindTools:把 schema 变成工具
LangChain 里注册工具最简单的方式是 bindTools:
js
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
const model = new ChatOpenAI({ /* modelName / apiKey / baseURL 照旧 */ });
const scientistSchema = z.object({
name: z.string().describe("科学家的姓名"),
birth_year: z.number().describe("出生年份"),
nationality: z.string().describe("国籍"),
fields: z.array(z.string()).describe("研究领域列表"),
});
// 把 schema 包装成一个"工具"注册给模型
const modelWithTool = model.bindTools([
{
name: "extract_scientist_info",
description: "提取和结构化科学家的详细信息",
schema: scientistSchema, // zod → 自动转成参数 JSON Schema
},
]);
const response = await modelWithTool.invoke("介绍一下爱因斯坦");
// 结构化答案在这
console.log(response.tool_calls[0].args);
// { name: "爱因斯坦", birth_year: 1879, nationality: "德国",
// fields: ["理论物理学", "相对论", "量子力学"] }
注意几点:
tool_calls[0].args天生就是对象 ,全程没有JSON.parse、没有 ```````json```` 围栏、没有"哄"------因为平台把结构化数据放在了专门的参数槽位里。content通常是空的------模型把力气都花在"调用工具"上了,没写作文。这正是"填表格"和"写作文"的区别。- 顺带一个冷知识:OpenAI 原生响应里
arguments其实是字符串 ,是 LangChain 帮你 parse 成对象的。所以两条路线的底层数据类型都是字符串,差别只在约束强度 ------content那串没人管你写啥,arguments那串被平台强制必须是合法 JSON。
一个必须知道的隐患:模型可能拒绝调用
默认的 tool_choice 是 auto------模型有权决定"我不调用工具,直接文字回答" 。一旦它这么干,response.tool_calls 就是 undefined,上面 tool_calls[0] 直接报 TypeError。
想让它"不许空着手走文本通道",就把选择权拿掉:
js
const modelWithTool = model.bindTools([...], {
tool_choice: "required", // 强制必须调用工具
// 或点名:tool_choice: { type: "function", function: { name: "extract_scientist_info" } }
});
两条路线对比
| 文本解析派(前两篇) | 工具调用派(本篇) | |
|---|---|---|
| 数据落点 | content 作文文本 |
tool_calls[0].args 参数槽 |
| 约束力 | 靠 prompt 哄,可被打破 | 平台语法强制 |
| 格式干扰 | 围栏/废话/截断都可能 | 天然结构,无干扰 |
| 需要的动作 | 事后 JSON.parse + 校验 | 取 .args,几乎零解析 |
| 前置条件 | 无 | 模型/服务端支持 tools |
小结
- 工具调用把"结构化"从输出后猜 挪到了请求时预订;
- 工具是接口协议,模型只生成"调用描述",不需要你真去实现------所以能当纯 JSON 容器用;
- 手写版是
bindTools+ 读tool_calls[0].args,默认auto下记得补tool_choice。
但手写这套还是有重复劳动:抽 args、判空、可能还要校验。下一篇的正主 withStructuredOutput 就是把这些封装成一行------但封装背后藏着一个会让兼容服务直接 400 的默认值,务必看完再动手。