你可能会遇到这样的问题
当你尝试构建一个基于人工智能的对话程序时,你可能会遇到一个令人困惑的现象:当你向程序提问“青岛啤酒今天的收盘价是多少?”或者“上海今天的天气怎么样?”时,人工智能通常会给出两种让你失望的回答。第一种是直接拒绝:“作为一个人工智能,我无法联网获取实时数据。”第二种则更加危险,它可能会基于历史概率,胡乱编造一个看似合理的虚假数字。
这种现象的核心原因在于大语言模型的物理隔离性。那个在显卡里高速运转的人工智能,本质上是一个被困在服务器里的“缸中大脑”。它看不见屏幕,摸不到键盘,也没有连接到真实的互联网数据库。它所能做的全部,仅仅是根据你输入的上文,去预测下一个最可能出现的词语。
因为人工智能本身无法突破物理限制去调用软件接口或读取数据库,所以我们需要一种机制,将外部世界的能力封装起来,并以某种方式教给人工智能,让它在需要的时候向我们(开发者)发出求助信号,由我们代为执行后,再将结果反馈给它。本文将系统性地讲解如何通过“工具调用”(Tool Use)技术,解决人工智能无法获取实时数据和操作外部世界的核心痛点。
本节总结:大模型缺乏与物理世界交互的能力是其产生幻觉和功能受限的根源,工具调用技术正是为了打破这种隔离而存在的。
核心概念拆解
在开始编写代码之前,必须准确理解以下支撑“工具调用”技术的关键计算机术语。
1. 下一个词预测 (Next Token Prediction)
标准定义:大语言模型(Large Language Model)生成文本的核心工作机制。模型根据已有的上下文文本,通过概率计算,推测出最符合逻辑顺序的下一个词或字符集(Token)。 简洁示例:当输入文本为“我今天早上吃了一个”时,模型计算出下一个词是“苹果”的概率为 80%,是“电脑”的概率为 0.1%,因此模型输出“苹果”。
2. 应用程序编程接口 (Application Programming Interface, API)
标准定义:软件系统之间相互通信的约定和桥梁。它允许一个程序通过特定的规则,请求另一个程序提供数据或执行特定功能,而无需了解对方内部的工作细节。 简洁示例:在代码中编写 get_weather(city="上海"),就是调用了一个获取天气的 API。你输入城市名,API 负责返回气温数据,你不需要知道气象局是如何测量温度的。
3. JSON 模式 (JSON Schema)
标准定义:一种用于描述和校验 JSON(JavaScript Object Notation,一种轻量级的数据交换格式)数据结构的规范文本。它通过严格的键值对定义,规定了数据必须包含哪些字段、字段是什么数据类型,以及哪些字段是必填的。 简洁示例:
JSON
{
"type": "object",
"properties": {
"name": { "type": "string", "description": "用户的姓名" }
},
"required": ["name"]
}
这段 JSON Schema 规定了数据必须是一个对象,且必须包含一个名为 name 的字符串类型字段。
4. 运行时 (Runtime)
标准定义:计算机程序执行时的基础软件环境。它负责读取程序员编写的代码,并将其转化为计算机可以实际执行的底层指令,同时管理内存和系统资源。 简洁示例:在使用 Node.js 开发时,你在终端输入 node index.mjs,这里的 Node.js 就是运行时,它负责真正去执行你写好的 JavaScript 代码文件。
5. 序列化与反序列化 (Serialization & Deserialization)
标准定义:序列化是指将内存中的复杂数据结构(如对象或数组)转换为可以通过网络传输或存储的纯字符串格式。反序列化则是其逆过程,将纯字符串重新解析还原为代码可以操作的结构化对象。 简洁示例: 序列化:JSON.stringify({ age: 20 }) 会输出纯字符串 '{"age":20}'。 反序列化:JSON.parse('{"age":20}') 会将字符串还原为 JavaScript 对象,从而可以通过 .age 访问属性。
本节总结:理解这些概念是实现工具调用的基础,因为工具调用本质上就是利用 JSON Schema 约束大模型的 Next Token Prediction 行为,生成标准格式的字符串,再由 Runtime 进行反序列化并调用对应的 API。
分步操作流程
我们将使用 Node.js 运行时和 OpenAI 兼容的 API(以 DeepSeek 为例)来构建一个具备查询股票和天气能力的 AI。
第一步:初始化项目与环境变量配置
动作:在项目根目录创建 .env 文件存储密钥,并在代码顶部加载。 这一步解决什么问题:安全地管理敏感的访问密钥(API Key),防止密钥硬编码在代码中导致泄露。环境变量(Environment Variables)是操作系统或运行时提供的一种全局配置机制。
JavaScript
import OpenAI from 'openai';
import dotenv from 'dotenv';
// 动作:读取 .env 文件中的配置,将其加载到运行时的 process.env 对象中
dotenv.config();
// 动作:初始化大模型客户端连接
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL
});
预期结果:程序成功启动且未报错,client 对象成功实例化,具备了向大模型服务器发送请求的能力。
第二步:定义工具的 JSON Schema(认知植入)
动作:创建一个名为 tools 的数组,使用 JSON Schema 语法描述我们拥有的功能。 这一步解决什么问题:大模型不懂什么是真实的软件接口。这一步将复杂的软件接口降维成大模型能看懂的“自然语言使用说明书”。通过结构化的描述,告诉大模型:“我这里有一个工具,它的名字叫什么,它是用来干什么的,如果你想用它,你需要按什么格式给我提供参数。”
JavaScript
const tools = [
{
"type": "function", // 声明这是一个函数工具
"function": {
"name": "get_closing_price", // 工具的唯一标识名称
"description": "获取指定股票的收盘价", // 极其重要:大模型依靠这段描述来进行意图识别
"parameters": { // 使用 JSON Schema 定义参数规则
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "股票名称,例如:青岛啤酒"
}
},
"required": ["name"] // 规定大模型如果调用此工具,必须提供 name 参数
}
}
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
}
},
"required": ["city"]
}
}
}
];
预期结果:定义了一个符合大模型接口规范的 tools 数组,等待在发送对话请求时一并提交给大模型服务器。
第三步:编写本地执行函数(构建传统软件能力)
动作:在代码中实现 get_closing_price 的具体逻辑。 这一步解决什么问题:大模型只负责“决策”调用哪个工具以及提取参数,它无法执行。我们需要在本地 Runtime 中准备好真正的功能逻辑,以便在收到大模型的调用指令时,有实际的代码可以运行并产生物理世界的数据。
JavaScript
// 传统软件世界:接收参数,返回结果
function get_closing_price(name) {
if (name === '青岛啤酒') {
return '67.92'; // 模拟从数据库或第三方金融 API 获取的数据
} else if (name === '贵州茅台') {
return '1488.21';
} else {
return '未找到该股票';
}
}
// 封装发送消息的网络请求逻辑
async function sendMessage(messages) {
const res = await client.chat.completions.create({
model: 'deepseek-v4-pro',
messages: messages, // 完整的历史对话上下文
tools: tools, // 携带我们在第二步定义的工具说明书
tool_choice: 'auto' // 让模型根据用户的提问自动决定是否需要使用工具
});
return res;
}
预期结果:当传入 青岛啤酒 时,函数能够准确返回字符串 67.92。
第四步:发起首次大模型调用(意图识别)
动作:构造包含用户提问的消息数组,并将其发送给大模型。 这一步解决什么问题:启动人机交互。大模型接收到用户的问题(“青岛啤酒的收盘价是多少?”)和工具列表后,其内部推理引擎开始工作。它发现自己无法直接回答,但比对工具 description 后,判断 get_closing_price 可以解决问题,于是中断普通对话,转而生成一段调用该工具的特定格式数据。
JavaScript
async function main() {
// 动作:初始化上下文数组
let messages = [
{ role: 'user', content: '青岛啤酒的收盘价是多少?' }
];
// 动作:第一次调用大模型
const response = await sendMessage(messages);
const message = response.choices[0].message;
// 动作:将模型的本次回复加入上下文数组,保持记忆的连贯性
messages.push({
role: message.role,
content: message.content,
tool_calls: message.tool_calls // 如果模型决定调用工具,这个字段会包含具体的调用指令
});
预期结果:打印 message 对象,你会发现 message.content 为空(或仅有思考过程),而 message.tool_calls 包含了一个数组,指明了需要调用 get_closing_price 函数,并且传入了 JSON 格式的参数字符串 {"name": "青岛啤酒"}。
第五步:拦截模型响应与本地执行(Runtime 介入)
动作:检查模型的返回值,提取工具名称和参数,执行本地函数,并将结果追加到消息数组中。 这一步解决什么问题:由于模型只给出了“调用指令”,并未给出最终答案,因此我们需要用 JavaScript(Runtime)接管控制权。我们将大模型生成的参数字符串进行反序列化,传递给第三步编写的本地函数,拿到真实的股票价格。
JavaScript
// 动作:判断模型是否发出了工具调用请求
if (message.tool_calls) {
const toolCall = message.tool_calls[0]; // 获取第一个调用的工具信息
// 动作:路由判断,匹配具体的本地函数
if (toolCall.function.name === 'get_closing_price') {
// 动作:反序列化模型返回的参数字符串,将其转化为 JavaScript 对象
const args = JSON.parse(toolCall.function.arguments);
// 动作:执行本地逻辑,获取真实数据
const price = get_closing_price(args.name);
console.log('通过工具获取到的股票收盘价:', price);
// 动作:将本地执行的结果包装成大模型能理解的格式,放入上下文数组
messages.push({
role: 'tool', // 必须指明该条消息是由工具产生的
content: price, // 工具执行的最终结果文本
tool_call_id: toolCall.id // 极其重要:必须携带模型调用时生成的唯一 ID,以进行逻辑关联
});
预期结果:程序成功提取出“青岛啤酒”,调用函数拿到“67.92”,此时 messages 数组包含了三条信息:用户的提问、模型的工具调用指令、以及工具的执行结果。
第六步:将结果返回给大模型生成最终回答
动作:携带包含工具执行结果的完整上下文,再次向大模型发起请求。 这一步解决什么问题:用户期望看到的不是生硬的数字“67.92”,而是一句完整的自然语言回答。大模型需要看到之前的所有过程(用户问了什么 -> 我决定用什么工具 -> 工具查出来的结果是什么),才能综合这些信息,生成最终符合人类阅读习惯的回复。
JavaScript
console.log('更新后完整对话上下文:', messages);
// 动作:第二次调用大模型,此时模型拥有了物理世界的数据
const finalRes = await sendMessage(messages);
// 动作:输出最终的自然语言结果给用户
console.log('最终模型回答:', finalRes.choices[0].message.content);
} else if (toolCall.function.name === 'get_weather') {
// 留给获取天气工具的执行分支...
}
}
}
main();
预期结果:控制台输出完整的自然语言句子,例如:“青岛啤酒今天的收盘价是 67.92 元。”
本节总结:工具调用的完整生命周期是一个“用户提问 → 模型决策并挂起 → 本地运行时接管执行 → 将结果反馈模型 → 模型总结输出”的闭环过程,每一步都在解决信息获取或格式转换的具体问题。
常见报错与解决(速查表)
在实际编写和运行上述代码时,初学者常常会遇到以下几类核心错误。
1. 报错信息:SyntaxError: Unexpected token 'u', "undefined" is not valid JSON 或 TypeError: Cannot read properties of undefined
- 产生原因:大模型没有严格遵守 JSON Schema,返回的参数
arguments不是合法的 JSON 字符串,或者漏掉了必须的字段。在第五步执行JSON.parse()反序列化时,由于字符串格式非法导致程序崩溃。 - 修改方法:回到第二步,优化工具的
description和properties。尽可能使用更清晰的自然语言描述这个字段的含义。如果模型仍然出错,需要在执行JSON.parse前加入try...catch错误捕获机制。 - 修改后的正确表现:即使模型偶尔抽风返回了非标准字符串,程序也能捕获错误并提示大模型重新生成,而不是直接中断退出。
2. 报错信息:Invalid parameter: messages with role 'tool' must be a response to a preceeding message with 'tool_calls'
- 产生原因:在第五步将工具执行结果追加到
messages数组时,丢失了必要的身份证明。大模型接口要求,任何角色为tool的消息,必须明确指定它是用来响应哪一次工具调用的。 - 修改方法:确保在向
messages追加{ role: 'tool' }的对象时,严格附带上对应的tool_call_id属性,并且该 ID 必须与第四步模型下发的toolCall.id完全一致。 - 修改后的正确表现:上下文数组逻辑链条完整,大模型能够准确识别出当前的真实数据是对应它之前提出的哪一个查询请求。
3. 报错信息:模型的最终回答是“我找不到该工具”或依然凭空捏造数据
- 产生原因:大模型未能成功进行“意图识别”。可能是第二步中
tools数组配置错误,或者在第三步调用sendMessage时,忘记将tools数组传递给 API 请求体。也可能是用户的问题描述过于模糊,模型认为不需要使用工具。 - 修改方法:检查传递给大模型客户端的代码,确保
tools字段已正确赋值。同时检查工具描述,例如将description: "获取指定股票的收盘价"改为更具强制性的description: "当用户询问任何关于股票价格的信息时,必须调用此工具"。 - 修改后的正确表现:面对用户提问,模型必定中断普通文本生成,准确输出
tool_calls结构并进入工具调用流程。
本节总结:调试工具调用程序的关键在于监控 messages 数组中数据的状态变化,以及确保大模型与本地代码之间传递的 JSON 结构严格符合预期。