本章对应源代码:https://github.com/RealKai42/langchainjs-juejin/blob/main/lc-tools.ipynb
上一节中,我们学习了如何直接使用 openAI 的原生 API 去使用 function calling (tools)功能,需要自己维护历史、写参数类型并且自己实现函数的调用,确实比较繁琐。这一节,我们将学习在 langchain 中如何使用该功能,会极大的减缓使用门槛,并且很容易集成到现有 chain 中。
同时,我们会讲解几个使用 tools 对数据进行打标签、信息提取等常见的操作
在 langchain 中使用 tools
在 langchain 中,我们一般会使用 zod 来定义 tool 函数的 JSON schema,我们可以专注在参数的描述上,参数的类型定义和是否 required 都可以有 zod 来生成。 并且在后续定义 Agent tool 时,zod 也能进行辅助的参数类型检测。
zod 是 js 生态中常见的类型定义和验证的工具库,我们这里用一些例子简单带大家快速入门一下:
首先是简单的使用,我们订一个 string 类型的 schema:
import { z } from "zod"; const stringSchema = z.string(); stringSchema.parse("Hello, Zod!");如果我们传入一个非 string 类型的值:
stringSchema.parse(2323);就会报错
ZodError: [ { "code": "invalid_type", "expected": "string", "received": "number", "path": [], "message": "Expected string, received number" } ]报错信息的可读性是非常高的,而且也很适合把报错信息传递给 llm,让它自己纠正错误。
然后,我们用一系列的示例迅速介绍足够我们定义 tool 参数使用的 zod 知识:
// 基础类型 const stringSchema = z.string(); const numberSchema = z.number(); const booleanSchema = z.boolean(); // 数组 const stringArraySchema = z.array(z.string()); stringArraySchema.parse(["apple", "banana", "cherry"]); // 对象 const personSchema = z.object({ name: z.string(), age: z.number(), // 可选类型 isStudent: z.boolean().optional(), // 默认值 home: z.string().default("no home") }); // 联合类型 const mixedTypeSchema = z.union([z.string(), z.number()]); mixedTypeSchema.parse("hello"); mixedTypeSchema.parse(42);考虑到方便 llm 理解和传递参数,一般不建议定义过于复杂的类型,会让 llm 容易犯错。
然后,我们就可以用 zod 去定义我们函数参数的 schem,例如以上一节课中获取天气的函数为例:
const getCurrentWeatherSchema = z.object({ location: z.string().describe("The city and state, e.g. San Francisco, CA"), unit: z.enum(["celsius", "fahrenheit"]).describe("The unit of temperature"), });这里我们定义了两个参数:
- location 是 string 类型,并且添加描述
- unit 是枚举类型,并添加相应的描述
这里我们没有指定 optional,默认就是 required,我们可以使用zod-to-json-schema去将 zod 定义的 schema 转换成 JSON schema:
import { zodToJsonSchema } from "zod-to-json-schema"; const paramSchema = zodToJsonSchema(getCurrentWeatherSchema)就可以将上面我们定义的 schema 转换成 openAI tools 所需要的 JSON Schema :
{ type: "object", properties: { location: { type: "string", description: "The city and state, e.g. San Francisco, CA" }, unit: { type: "string", enum: [ "celsius", "fahrenheit" ], description: "The unit of temperature" } }, required: [ "location", "unit" ], additionalProperties: false, "$schema": "http://json-schema.org/draft-07/schema#" }然后,我们就可以在 model 去使用这个 tool 定义:
const model = new ChatOpenAI({ temperature: 0 }) const modelWithTools = model.bind({ tools: [ { type: "function", function: { name: "getCurrentWeather", description: "Get the current weather in a given location", parameters: zodToJsonSchema(getCurrentWeatherSchema), } } ] }) await modelWithTools.invoke("北京的天气怎么样");这里就会返回一个 AIMessage 信息,并携带着跟 tool call 有关的信息:
AIMessage { lc_serializable: true, lc_kwargs: { content: "", additional_kwargs: { function_call: undefined, tool_calls: [ { function: [Object], id: "call_IMLAkWEhmOyh6T9vYMv65uEP", type: "function" } ] }, response_metadata: {} }, lc_namespace: [ "langchain_core", "messages" ], content: "", name: undefined, additional_kwargs: { function_call: undefined, tool_calls: [ { function: { arguments: '{\n "location": "北京",\n "unit": "celsius"\n}', name: "getCurrentWeather" }, id: "call_IMLAkWEhmOyh6T9vYMv65uEP", type: "function" } ] }, response_metadata: { tokenUsage: { completionTokens: 23, promptTokens: 88, totalTokens: 111 }, finish_reason: "tool_calls" } }跟我们之前直接使用 openai 的 API 的结果是类似的,增加了更多 langchain 内部使用的信息。
这里的 bind 并不是 model 特有的一个工具,是所有 Runnable 都有的方法,可以将 runnable 需要的参数传入,然后返回一个只需要其他参数的 Runnable 对象。
因为绑定 tools 后的 model 依旧是 Runnable 对象,所以我们可以很方便的把它加入到 LCEL 链中:
import { ChatPromptTemplate } from "@langchain/core/prompts"; const prompt = ChatPromptTemplate.fromMessages([ ["system", "You are a helpful assistant"], ["human", "{input}"] ]) const chain = prompt.pipe(modelWithTools) await chain.invoke({ input: "北京的天气怎么样" });多 tools model
同样的,我们也可以在 model 中去绑定多个 tools,就像直接使用 openai 的 API 类似:
const getCurrentTimeSchema = z.object({ format: z .enum(["iso", "locale", "string"]) .optional() .describe("The format of the time, e.g. iso, locale, string"), }); zodToJsonSchema(getCurrentTimeSchema)注意,这里我们对参数使用了 optional 工具函数,就输出的 json scheme 中就不会将这个参数标志为 required
{ type: "object", properties: { format: { type: "string", enum: [ "iso", "locale", "string" ], description: "The format of the time, e.g. iso, locale, string" } }, additionalProperties: false, "$schema": "http://json-schema.org/draft-07/schema#" }然后,使用多个 tools 的代码也是类似,modelWithMultiTools就会根据用户的输入和上下文去调用合适的 function:
const model = new ChatOpenAI({ temperature: 0 }) const modelWithMultiTools = model.bind({ tools: [ { type: "function", function: { name: "getCurrentWeather", description: "Get the current weather in a given location", parameters: zodToJsonSchema(getCurrentWeatherSchema) } }, { type: "function", function: { name: "getCurrentTime", description: "Get the current time in a given format", parameters: zodToJsonSchema(getCurrentTimeSchema) } } ] })控制 model 对 tools 的调用
我们也可以像使用 API 一样通过tool_choice去控制 llm 调用函数的行为:
model.bind({ tools: [ ... ], tool_choice: "none" })或者强制调用某个函数:
const modelWithForce = model.bind({ tools: [ ... ], tool_choice: { type: "function", function: { name: "getCurrentWeather" } } })使用 tools 给数据打标签
在数据预处理时,给数据打标签是非常常见的操作。例如之前我们会使用 jieba 这个 python 库对评论进情感打分,找出评论中含有恶意的部分。
而有了大模型后,跟自然语言相关的绝大部分任务都可以使用 llm 来代替,而且得益于 llm 展现出来非常强大的跨语言理解能力,我们的工具可以是针对任何语言,也可以让 llm 去分辨使用的是什么语言。这些任务在 llm 之前都需要非常复杂的实现才能达到的。
我们首先定义提取信息的函数 scheme :
const taggingSchema = z.object({ emotion:z.enum(["pos", "neg", "neutral"]).describe("文本的情感"), language: z.string().describe("文本的核心语言(应为ISO 639-1代码)"), });这里,我们会核心强调是提取文本中的核心语言,来应对部分中英混杂的情况,如果对语言标记的准确性非常看重,可以在这里加入更多的描述,例如占比 50% 以上的主体语言。
然后,我们将 tool bind 给 model,注意在 tagging 任务中,需要设置为强制调用这个函数,来保证对任何输入 llm 都会执行 tagging 的函数:
const model = new ChatOpenAI({ temperature: 0 }) const modelTagging = model.bind({ tools: [ { type: "function", function: { name: "tagging", description: "为特定的文本片段打上标签", parameters: zodToJsonSchema(taggingSchema) } } ], tool_choice: { type: "function", function: { name: "tagging" } } })然后,我们使用这个 model 去组合成 chain:
import { JsonOutputToolsParser } from "@langchain/core/output_parsers/openai_tools"; const prompt = ChatPromptTemplate.fromMessages([ ["system", "仔细思考,你有充足的时间进行严谨的思考,然后按照指示对文本进行标记"], ["human", "{input}"] ]) const chain = prompt.pipe(modelTagging).pipe(new JsonOutputToolsParser())这里我们也用到了 system prompt 常用的技巧,就是 “仔细思考” 、“你有充足的时间进行严谨的思考”,有论文验证过,这些词有点像 magic word 一样,加入后就能明显提升输出的质量,越来越玄学了。
注意这里,我们并没有必要去实现taggingSchema所对应的函数,因为我们需要的就是 llm 输出的 json 标签,所以我们使用JsonOutputToolsParser直接拿到 tools 的 json 输出即可。
我们可以测试一下:
await chain.invoke({ input: "hello world" }) // [ { type: "tagging", args: { emotion: "neutral", language: "en" } } ] await chain.invoke({ input: "写代码太难了,👴 不干了" }) // [ { type: "tagging", args: { emotion: "neg", language: "zh" } } ] await chain.invoke({ // 日语,圣诞快乐 input: "メリークリスマス!" }) // [ { type: "tagging", args: { emotion: "pos", language: "ja" } } ] await chain.invoke({ input: "我非常喜欢 AI,特别是 LLM,因为它非常 powerful" }) // [ { type: "tagging", args: { emotion: "pos", language: "zh" } } ]可以看到,因为我们声明了提取数据中的核心语言,即使是最后一个例子这种混杂的情况,也能提取到正确的信息。
在这里展现的就是 llm zero-shot learning 的能力,即对于新任务只需要 prompt 的描述,甚至不需要给出任务实例 或者使用一部分数据进行训练,即可以完成任务。
使用 tools 进行信息提取
我们再看 tools 另一个常见的应用,信息的提取。信息提取和打标记类似,如果从学术角度可能有一些区别,但在我们实际工程上没必要做太大的区分。感受上就是打标签是给数据打上给定的一些标记,而信息提取是 llm 理解原始文本后提取其中的信息,类似于我们常用的粘贴快递地址,就自动提取姓名、手机和地址一样。
在信息提取时,一般是会提取多个信息,类似于一段文本中涉及到多个对象的内容,一次性都提取出来。
让我们先定描述一个人的信息 scheme:
const personExtractionSchema = z.object({ name: z.string().describe("人的名字"), age: z.number().optional().describe("人的年龄") }).describe("提取关于一个人的信息");这里 age 我们设计成可选的 number,因为年龄可能是没有的,避免 llm 硬编一个。我们通过对整个 object 添加 describe,让 llm 对整个对象有更多理解。
然后,我们基于这个去构造更上层的 scheme,从信息中提取更复杂信息:
const relationExtractSchema = z.object({ people: z.array(personExtractionSchema).describe("提取所有人"), relation: z.string().describe("人之间的关系, 尽量简洁") })这里我们复用personExtractionSchema去构建数组的 scheme,去提取信息中多人的信息,并且提取文本中人物之间的关系。
得益于 llm 良好的语言能力,我们只需要有简单的 prompt 就让 llm 在信息提取任务上有很好的表现。我们看一下这个复杂的 scheme 转换后的结果:
const schema = zodToJsonSchema(relationExtractSchema){ type: "object", properties: { people: { type: "array", items: { type: "object", properties: { name: { type: "string", description: "人的名字" }, age: { type: "number", description: "人的年龄" } }, required: [ "name" ], additionalProperties: false, description: "提取关于一个人的信息" }, description: "提取所有人" }, relation: { type: "string", description: "人之间的关系, 尽量简洁" } }, required: [ "people", "relation" ], additionalProperties: false, "$schema": "http://json-schema.org/draft-07/schema#" }然后我们把这个 schema 构建成 chain :
const model = new ChatOpenAI({ temperature: 0 }) const modelExtract = model.bind({ tools: [ { type: "function", function: { name: "relationExtract", description: "提取数据中人的信息和人的关系", parameters: zodToJsonSchema(relationExtractSchema) } } ], tool_choice: { type: "function", function: { name: "relationExtract" } } }) const prompt = ChatPromptTemplate.fromMessages([ ["system", "仔细思考,你有充足的时间进行严谨的思考,然后提取文中的相关信息,如果没有明确提供,请不要猜测,可以仅提取部分信息"], ["human", "{input}"] ]) const chain = prompt.pipe(modelExtract).pipe(new JsonOutputToolsParser())这里 prompt 设计,我们使用仔细思考,你有充足的时间进行严谨的思考去增强 llm 输出的质量,然后用如果没有明确提供,请不要猜测,可以仅提取部分信息来减少 llm 的幻想问题。
然后我们先测试一下简单的任务:
await chain.invoke({ input: "小明现在 18 岁了,她妈妈是小丽" })[ { type: "relationExtract", args: { people: [ { name: "小明", age: 18 }, { name: "小丽", age: null } ], relation: "小丽是小明的妈妈" } } ]这里数据中并没有小丽的年龄,所以 llm 直接留空,并没有强行提取信息。
因为 llm 是根据自己对语言的理解能力,而不是根据传统的匹配规则等,所以在语意中隐含的信息也有良好的提取能力:
await chain.invoke({ input: "我是小明现在 18 岁了,我和小 A、小 B 是好朋友,都一样大" })[ { type: "relationExtract", args: { people: [ { name: "小明", age: 18 }, { name: "小A", age: 18 }, { name: "小B", age: 18 } ], relation: "小明是小A和小B的好朋友" } } ]对于 edge case,也有较好的处理效果:
await chain.invoke({ input: "我是小明" })[ { type: "relationExtract", args: { people: [ { name: "小明", age: null } ], relation: "" } } ]小结
这一节我们学习了如何在 langchain 中使用 openAI tools,通过 zod 减少了我们编写 schema 的繁琐。更重要的,我们学习了如何使用 tools 对数据进行打标签和数据提取,这意味着 llm 并不只是一个 chat bot 的用处,我们可以把他融入在日常的很多数据处理任务中,替代传统很多需要复杂编码才能解决的问题。