工具
大模型本身不具备工具的作用,比如读取文件的功能
如果希望大模型不仅会回答你的问题 还可以读取你本地的文件,就需要给大模型绑定上工具。
在langchain中比较简单,就是使用model.bindTools来绑定
接下来就是定义工具了!
定义工具
工具首先得有个说明书,其次需要说明工具本质上是一个函数 因此它一定是需要接收参数 也就是说有入参。
因此,有下面这几个说明:名称、描述、入参
const obj = {
// 工具的名称
name: 'read_file',
// 工具的描述
description: '对这个工具的人类语言描述',
// 工具入参
schema: z.object({
filePath: z.string().describe('要读取的文件路径'),
}),
}
接下来就是工具本身,也就是函数本身
// 很简单 就是一个函数,然后函数中的逻辑 比如下面这个就实现了:读取文件内容并返回
const fun = async ({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
console.log(` [工具调用] read_file("${filePath}") - 成功读取 ${content.length} 字节`);
return `文件内容:\n${content}`;
}
最后就是使用langchain的tool函数,将上面这两个参数传入就可以了。这样就定义好了一个工具
tool(fun,obj)
调用工具
前面绑定好工具之后,我们调用了模型的invoke函数,但是模型并不会直接调用工具,而是会返回当前用户的问题可以使用的工具
- 用户问:你好。 模型返回一个工具空数组,模型自己识别到这一次用户的请求不需要任何的工具调用
- 用户问:读取某个文件,大模型就会检索自身,是否有合适的工具。有的话在返回数据中包含可用的工具
上面这个数组中的每一个工具都包含:工具的实际入参(模型通过第一次用户的提问识别到了)、工具的唯一id(每次调用都会随机生成新的)、工具的名称
当模型返回了工具调用数组,就先遍历这个数组,调用每一个工具函数。注意,调用工具不是使用模型的invoke,而是工具的invoke。
// invoke的参数就是工具的入参,这个入参可以从模型返回的数组中获取
const result = await tool.invoke(toolCall.args);
拿到工具的结果之后,就调用langchain的new ToolMessage来新建消息
new ToolMessage({
content: toolResults[index],
tool_call_id: toolCall.id,
})
最后把所有消息再次发给模型,模型最终输出回答。
Q&A
Q1: 为什么需要new ToolMessage,而不是将工具返回结果直接拼接到前面用户的提问后面?
A1:
(1) 像 GPT-4、Claude 这类支持 function calling 的模型,训练时大量接触的对话就是这种格式:
User → Assistant(tool_calls) → ToolMessage → Assistant(回复)
也就是说,模型在训练时学到的是:
收到带有 tool_call_id 的 ToolMessage = 这是我刚才调用的工具返回的结果,需要用这些内容来作答。
如果改用字符串拼接,把工具结果塞进 HumanMessage 或普通文本里,这种模式在训练数据里很少出现,模型就没有明确的「怎么用」的经验。
(2)角色与用途清晰
在结构化消息中,每条消息的语义很明确:
消息类型 模型的理解
HumanMessage 用户说了什么、想做什么
AIMessage + tool_calls 我决定调用这些工具
ToolMessage 这些工具返回的结果,是我的上一轮决策的反馈
AIMessage 基于工具结果给出最终回答
模型会把 ToolMessage 当作「我主动调用的工具的结果」,而不是「用户新说的话」。如果用字符串拼接,工具结果很容易被当作普通上下文或用户输入,导致模型不知道这是自己刚才调用的工具的结果,从而处理方式不准确。
(3)注意力与因果关系
在多轮对话里,模型对不同消息类型的处理权重不同。ToolMessage 在训练中被视为「对上一次 tool_calls 的回应」,因此模型会更自然地把注意力放在:用这些结果来回答用户最初的问题。