工具调用——让模型把答案"填进表格"而不是"写在作文里"

系列第 3 篇。前两篇都属于"文本派":模型用自然语言作文,我们事后解析。这一篇换思路------既然平台提供了"工具调用"这种机制,为什么不让模型直接把结构化答案填进去?

两个问题先想明白

为什么绑定工具,模型就按工具的结构生成?

你给模型注册一个"函数"(含名字、描述、参数 schema),模型如果决定调用它,参数就必须符合 schema。这不是玄学,是两个层面兜底:

  1. 训练层面:现役模型在训练/对齐时见过海量 function-calling 数据,学会了"调用函数时参数要符合该函数的参数声明"。
  2. 平台解码层面(更硬) :工具调用在 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: ["理论物理学", "相对论", "量子力学"] }

注意几点:

  1. tool_calls[0].args 天生就是对象 ,全程没有 JSON.parse、没有 ```````json```` 围栏、没有"哄"------因为平台把结构化数据放在了专门的参数槽位里。
  2. content 通常是空的------模型把力气都花在"调用工具"上了,没写作文。这正是"填表格"和"写作文"的区别。
  3. 顺带一个冷知识: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 的默认值,务必看完再动手。

相关推荐
1点东西5 天前
做了近两年的Agent开发,其实真正要学的就是这五件事
llm·agent·ai编程
程序猿编码5 天前
告别改源码适配模型:纯 C++ 可配置 LLM 推理引擎,全格式全结构兼容
c++·大模型·llm·推理引擎
小林ixn5 天前
从 LangChain 到 LangGraph:用「网状工作流」解锁多 Agent 协作的正确姿势
langchain·llm·agent
武子康5 天前
自己做一个 Mini Reviewer:让 AI 审到本次准备提交的代码
人工智能·llm·agent
武子康5 天前
GPU Pod 已经 Running,为什么扩容还没变成推理容量?
人工智能·llm·agent
BlackStar_L6 天前
第二章 上下文工程
大模型·llm·agent
Together_CZ6 天前
DeepSeek-V4.1-Flash:Pushing the Limits of KV Cache Compression——推动 KV 缓存压缩的极限
缓存·llm·compression·kv cache·deepseek·v4.1-flash·推动 kv 缓存压缩的极限
桃西西呀6 天前
你拍的一堆硬币,手机怎么一眼数出有几枚?聊聊边缘、轮廓和模板匹配
人工智能·llm·图像识别
slacker-kian6 天前
[实践]-让 SAP 工程 Skill 脱离 opencode跑在自定义Agent 上
ai·llm·sap·agent·abap·adt·opencode
武子康6 天前
CLAUDE.md 越写越长,哪些规则该放到子目录?
人工智能·llm·agent