图片来源:作者
Anthropics的大语言模型使用工具导航复杂的Web API接口——教程指南人们希望大型语言模型(LLMs)能够缩小意图(语义)与其表达方式(语法)之间的差距。这包括调用软件函数和数据库查询,这些操作都需要非常严格的语法——一个小小的字符错误可能导致数小时的困惑和故障排查。
具备工具使用的LLM是一种全新的方法。任何涉及业务意图或请求的任务,其中一部分通过调用函数来处理的,都可以通过增加一个_语义层_来弥补意义和表达之间的差距。
具体来说,任务是计划在伦敦的一次旅行或者上下班的路程,地点是在英国。
已经有功能和工具可以部分地解决这个问题,比如伦敦交通局(TfL)的网络应用编程接口(API)。然而,这些API是“机器语言”。对于有出行意图和需求的人来说,这些API显然不够用。我们可以用能够使用工具的LLM来填补这个空缺。
我将在接下来的部分中一般地描述问题和解决方案。然后展示如何使用Anthropic的LLMs来实现这个方案。所有的代码都会被包含,并且可以在Github仓库中找到。到了文章结尾,你将明白如何通过使用LLM作为工具来发挥作用,并且能够自己动手构建实用的原型。
主辅机器与语义层次
想象你在伦敦规划一次上下班旅程,可以选择地铁、公交、骑行、打车或步行等。再进一步想,你可能有自己的偏好,比如换乘次数或在途中骑行的次数。
以下展示了完成此任务的过程,从比如通勤的人提出请求开始,到助手以分步导航、地图或其他形式提供行程计划结束。
负责人、助手和机器按照顺时针顺序依次从负责人开始,满足其要求。
负责人不需要了解机器层API或算法。负责人只需得到符合其风格和信息消费模式的解决方案。负责人在意义和语义、文化与语言的领域中游走,而机器则扎根于公式、语法、算法和代码中。
助手处理主要人物与机器之间的工作协调。助手熟知各种工具及其正确使用方法,同时还能理解主要人物的需求,并像一个项目经理一样,将工作划分为有实际意义的单元。
这是工具使用型LLM能够自动化的层面,能够在这个层面实现扩展和加速。受助理层中速度限制或成本约束的任务都适合被革新或改进。
简单来说,使用工具的LLM是用来干嘛的?大多数提供高级LLM的供应商都允许使用工具,尤其是对于他们的高级模型。在实践中,这些LLM已经被调优,以生成符合特定结构和语法的文本。
为什么那会方便工具使用呢?因为在机器和软件工程的领域里,结构化的数据和严格的类型和语法占据主导地位。
要创建一个使用工具的LLM应用,输入到LLM中必须包含特定的语法规范。用户的输入提示还不足以完全定义任务。接收到完整的输入包后,LLM接下来可以选择生成自由文本、遵循特定语法的文本,或是两者的结合。
应用开发者需要将语言模型生成的输出转换成函数调用、数据查询或其他类似的操作,使表达更自然流畅。
与像ChatGPT、Claude、Le Chat这样的大型语言模型流畅对话相比,开发这样的应用程序需要开发者投入更多努力。语法规范和功能调用的关系、如何调用相关功能以及如何处理功能输出,都需要额外的编程和逻辑处理。
下面的教程讲的是这项完整的工作。
有了AI助手的帮助,伦敦的出行规划看起来如何我会简单讲一下校方领导怎么看待已经实现的方案。
下面的动画展示了一个测试对话。绿色文本框中的请求是以人类能理解的方式表述的。该请求中包含了对时间(如“6点”、“五点”、“傍晚”)和地点(如“家”、“Tate Modern museum”、“从那里”)的隐含参考。
校长与行程规划师Vitaloid之间的交流
我给它起名叫Journey Planner Vitaloid,它是一个旅程规划器,准确提取请求内容,并将其映射到三次调用TfL API的过程中,在这些调用中,算法会在此发挥魔力。该旅程规划器还使用了制图工具。其输出的地图如下:
从家(贝克街)到工作地点(切普赛德)乘坐公交和地铁,然后步行穿过千禧桥到达泰特现代,接着从滑铁卢车站坐地铁回家;图片由作者使用Python库folium制作。
行程计划的细节没有在上面的对话中提到。不过,详细的资料是可用的,并存储在应用软件的内部结构中。所以,当我作为主要请求者要求总结一个或多个旅程的步骤时,使用工具的LLM模型会解析请求并生成相关函数调用,然后根据从函数调用返回的信息紧凑且准确地总结步骤。
来自学校管理层的后续跟进请求,包含从伦敦交通局API生成的详细分步骤说明。
看起来,这与广为人知的大型语言模型驱动的聊天机器人非常相似。去掉“非常”,改为:
看起来,这与广为人知的大型语言模型驱动的聊天机器人相似。
区分在于,使用这些工具的LLMs不仅为旅程规划者Vitaloid配备了处理语义的算法能力,还具备其他功能。此外,伦敦交通局通过其API发布的任何实时更新(如中断信息)都可以即时成为旅程规划器的数据输入。
虽然公共交通规划只是一个例子,但它展示了配备了工具的AI语言模型如何弥合人类需求(用人类语言表达)与计算机系统之间的鸿沟。通过这种组合,我们可以创建实用且可扩展的助手工具。虽然这种方法比单纯使用AI更费力,我将在本教程中展示,利用标准Python库和Anthropic的Python库,这种方法依然相当可行。
人类专家助手因此可以让可扩展的计算机系统具备解决助理任务的能力。提示和工具的使用为AI增加了更多可能。
教程入门我按照自上而下的方式来操作这个教程。换句话说,我从助手层中最高级别的对象开始,逐步向下推进,直到与机器层的互动。
因此,我分享的代码片段可能引用了在那个教程阶段尚未定义的对象。这些是较高层次对象所依赖的较低层次的实体或组件。别担心,我们会慢慢讲到这些细节。就像一层层剥开洋葱,最终展现完整图景一样。
完整的代码如下可以在这个Github仓库中查看。
教程:创建路由代理实例该应用程序有一个对象与主对象通信:即路由器代理(Router Agent)。此代理还可以使用另外三个代理作为工具。路由器引擎(Router Engine)通过调用LLM客户端来解析主要请求(Main Request),并根据主要请求中的子任务指令其他代理进行处理。
所以,这个应用就是一个小的代理工作流程。
智能体的简化类图及其工具集和它们之间的依赖关系。
所以当学校管理层提供请求时,路由器代理启动一系列操作。下面的动画图例说明了数据生成和数据流在对象之间的一个例子。
这是代理工作流中的数据流示意图;在两个子任务代理依次运行之后,路由器代理会将结果回传给主代理。
在 build_agents.py
文件中,以下代码创建了名为 agent_router
的路由器代理对象。
Engine
对象是大型语言模型(LLM)、系统提示文本和工具包的结合体。它处理文本,比如某人的请求,并生成文本响应。稍后我将详细解释引擎的工作方式。
该工具集存在为对象 SubTaskAgentToolSet
。它的重点在于如何调用这些工具,以及向路由器代理的LLM模型声明可用工具的语法。
注意,系统提示信息来源于Jinja模板,例如下所示:
env = Environment(loader=FileSystemLoader(PROMPT_FOLDER),).get_template('system_prompt_template')
我按照使用Jinja2模板来创建和管理提示的习惯操作,其他人已经描述了的好处。
教程:调用和声明工具的工具包使用工具的LLM需要知道可用的工具。也就是:
- 工具名称及其处理的任务种类。
- 每个工具调用时所需的参数名称、结构及其含义。
大多数提供LLM的供应商使用非常相似的设计使他们的LLM理解工具的使用。下面是一个我为Anthropic的LLM制定的JSON规范,用于将这三个代理当作工具,路由器代理在处理文本时可以与这些工具交互。
这三个工具都需要一个名为 input_prompt
的输入字符串,而这三个工具中的最后一个还需要一个名为 input_structured
的对象,该对象包含两个整数 journey_index
和 plan_index
。
特别注意描述字段部分。这些是小型提示,LLM在处理所有输入文本和工具规范时会解释这些提示。例如,校长用标准语言发出的提示,要求规划从点A到点B的旅程时应:
正常的提示工程考量仍然适用。当创建工具集时,高质量的描述帮助大规模语言模型进行恰当的语义匹配。
需要注意的是,执行工具功能的不是LLM。LLM生成符合工具规范的结构化输出。应用开发者则需要根据LLM输出执行工具功能。
在应用程序中,一个单一的对象同时封装了工具规格语法和工具执行:ToolSet
及其子类(如ToolSet
及其子类等)。
上面的代码中定义了 SubTaskToolSet
,这个定义在之前提到过。可以看到,这三个方法的名字和之前 JSON 工具规格文件里提到的名称一一对应。同样,输入参数的名字也和规格文件里写的完全一致。
换句话说,这些就是如果LLM返回了指定工具使用的适当字符串,将被调用的函数。
从父类继承了 __call__
方法,该方法接受工具名以及关键字参数,然后执行相应的方法。
需要注意的是,工具包包含一个属性 tool_spec
,该属性返回一个与之前展示的工具规格 JSON 文件相对应的字典。
Engine
是主要负责与大型语言模型进行交互的对象,并在适当的时候会执行给定的 ToolSet
中的工具。这种实现方式使得应用程序中的四个代理都是 Engine
类的实例,
设计问题:如果一个工具已经被执行,代理是否需要解释输出,还是直接将工具输出作为代理的输出?
引擎类输入文本的处理单元和逻辑。
路由器代理始终会包含一个解释环节。这是因为路由器代理必须将各个子任务的结果整合成一个完整的回应,以回应主要请求的内容。
另一方面,子任务代理则会更简单一些。它们的任务是解析从路由器代理接收到的指令和数据,并将其转化为工具参数和函数调用。一旦得到工具输出,就会返回给路由器代理。
我们可以想象一些应用场景和用例,在这些情况下子任务代理应该能够解读它们的工具输出结果。不过,在这种情形下,我认为对子任务代理的输出设定一些限制是有帮助的。我可以完全控制工具输出中的数据内容——这仍然属于语法和代码的范畴。路由器代理是唯一一个负责将所有内容整合并以主要代理所使用的语言回应主要代理的代理。
以下代码片段显示了Engine
类的前半部分。process
方法是上面提到的SubTaskAgentToolSet
中被调用的那个方法。
process
方法的前三分之二负责组装输入提示内容,并设置工具调用参数(这是Anthropic客户端所需要的,详见下文)。
注意,MessageStack
是我创建的一个方便的数据类——本质上是一个带有额外功能的Python字典,使数据访问更加容易。这里的额外功能类似于“额外的铃铛和口哨”,使数据访问更加方便。
MessageParam
、TextBlock
以及后来的 ToolUseBlock
和 ToolResultBlockParam
是 Anthropic 的 Python 库中的类,帮助识别特定文本字符串是哪种 LLM 输入/输出的数据类型。
一旦要提供给LLM的文本准备就绪,代理就在what_does_ai_say
方法中将完整的数据发送给Anthropic的LLM端口。
Anthropic的LLM Python客户端文档在这里。它是首先被执行的程序,从ToolSet
实例中获取各种文本输入和工具规范。
来自大模型的response对象会按照以下方式处理:
- 其文本内容会被追加到消息集合中,以便可以在后续的LLM调用中使用。
- 响应内容可以包含多个条目;如果其中一项是工具调用的说明,则使用代理的
ToolSet
实例调用该工具函数。 - 工具输出会被追加到消息集合中。每个工具调用规范都有一个唯一的ID,它必须与相应的工具函数输出关联起来。如果不这样做,LLM将无法解读工具输出。
- 如果LLM生成了工具调用的规范(从
response.stop_reason
的值可以判断出来),那么问题就是是否需要解析这个工具输出。如果需要,就再次调用what_does_ai_say
。如果不需要,就结束这个流程。
一旦what_does_ai_say
成功结束,process
方法在结束时通过组装工具输出或LLM生成的自由文本来结束,并将此结果作为代理输出返回。
这段代码可以改进,以更好地处理异常情况。工具可能会出错,递归函数需要有防止陷入无限循环的检查。但这只是一个原型,所以我把这些改进留到以后再做。
Engine
类实现了一个较为通用的使用 LLM 的工具类。它通过逻辑封装对 Anthropic LLM 的调用,该逻辑处理以下内容:
- 消息之间的来回传递形成的堆栈,
- 如何识别工具调用的规范,
- 如何将规范传递给工具集实例的一般接口来执行功能,
- 如何处理工具输出,特别是当输出需要返回或需要进一步处理时。
我们再来看看这里之前的那张图片。工具和引擎代码定义了这条数据流中的所有这些部分。
代码目前为止是通用的——除了命名之外,与伦敦的行程规划任务没有直接关系。
在生成任何基于机器层输出的文本之前,必须定义各个子任务代理的工具集。因此,需要编写功能和工具规格的JSON文件。如上所示,我们将所有内容封装在一个ToolSet
子类中,并可以将其与Engine
和Anthropic的大语言模型(LLMs)连接起来。
如果你有关于助手的好主意,目前为止的代码只需要稍作修改就能适应。助手层及其语义处理已经准备好了。
但机器层并不总是那么规整,无法直接放入ToolSet
。伦敦旅行规划器就是这种情况的一个例子。因此,在接下来的部分中,我将强调一些额外的实际考量,并展示一些针对代理及其关系的设计选择是如何出现的。我预计在其他应用中也会出现类似的考虑。
规划代理工具包应当包含请求TfL API的功能,特别是旅程API端点(例如)。
这个旅程的终点其实并不简单。输入和输出数据包含复杂的嵌套结构和多样性。我没有尝试让大模型应对这些复杂性,而是创建了一系列简化的接口,就像 TfL API 的切片一样,专门用于处理特定任务。
我这样设计的原因是,这样LLMs更可能正确响应路由器代理的指令,调用机器层。
这再次是一个我为代理施加限制以使其专注于我想要它解决的任务的例子。这样做是必要的吗?我能否将所有信息都输入到大型语言模型中,然后相信它可以解决所有问题?只有通过测试才能确定,但我觉得限制是有帮助的。
我不会详细解析TfL Journey API及其负载。读者可以在我的GitHub仓库中的journey_planner.py文件中找到相关的逻辑。代码后面会用到的三个关键对象已经被创建了:它们分别是:
JourneyPlannerSearch
,调用TfL旅程API,并根据给定参数调用。JourneyPlannerSearchParams
,包含TfL旅程API的参数设置。JourneyPlannerSearchPayloadProcessor
,包含从可能返回的数据中提取特定信息的逻辑。
Planner
类在不同的地方与这三个对象相互作用。
如 make_plan
方法所示,处理从 TfL API 获取的数据包增加了复杂性。它可以返回两种不同类型的输出:
- 起始位置和目的地不能含糊不清。如果它们不含糊,则TfL API端点返回的状态码是正常的200,并且
Planner
实例化一个Plan
(见上面的第25行)。 - 如果起始位置或目的地含糊不清,TfL API会返回一系列消除歧义的选项,即对意图中的位置的最佳猜测。
Planner
处理这种情况的方法是重新运行规划器处理,使用所有建议的消除歧义后的地点。
换句话说,Planner
可以返回多个旅程。
此外,TfL API 对每个旅程返回多个方案选项(通常是四个)。除非该路线不需要换乘,否则该路线将包含多个段落。对于某些交通方式,如自行车,段落还可以进一步包括一系列步骤。
这嵌套内容挺复杂的。
我创建了一个自定义的数据类 Plan
(计划类),以便管理结构化的数据,部分如下所示:
我也定义了一个 Journey
数据类,它包含多个 Plan
实例的集合。我还定义了一个 JourneyMaker
对象,该对象的 make_journey
方法用于运行 Planner
并收集一个或多个行程,这些行程可以随后从 JourneyMaker
实例中获取。
所有对象的详细信息都包含在此文件中:点击这里查看文件。
所有的这些层次结构设计是为了使工具功能的接口相对简单且语义清晰,以便于连接到代理。我不想让LLM感到困惑,把这种复杂性一股脑地扔给它。
我为规划代理最终定义的工具集如下:
compute_journey_plans
是主要的工具函数,通过上述的各种抽象和层次,最终会调用 TfL API 并处理其返回的数据,直到得到一组 Journey
实例和 Plan
实例。
请注意,compute_journey_plan
只返回关于计划的元数据,而不是具体细节。计划的具体细节则需要在计算完成后,通过其他工具来获取。相应的函数分别是 get_computed_journey
和 get_computed_journey_plan
。
我做出这样的决定是因为所有旅程和计划中的数据量可能相当庞大。如果有大量的备选方案,那么路由代理要么需要向发起者进一步确认,要么根据发起者的请求推断出一些额外信息,以决定展示哪些计划。
我们能不能直接把所有行程规划数据交给路由器代理的LLM模型来处理呢?为什么要加一个中间步骤呢?
到目前为止,读者此时应该已经明白,我的直觉是保留一些限制,避免让模型因嵌套语义和大量无关数据而混淆。但这只是一种猜测——我会在文章最后一般性地讨论这些设计问题。
旅程规划代理及其调用TfL API(伦敦交通局API)的计算行程工具的数据流动。右边的框图展示了调用计算行程计划工具流程内的工作步骤。行程规划代理调用的计算行程计划工具展示了其内部的工作流程。
像构建路由器代理一样,规划代理是和其他依赖对象一起构建的,这些依赖对象被注入。
教程:输出成果及代理间传递索引:毫无疑问,有很多方法可以向负责人传达响应请求制定的计划。从原型到产品将包括设计产品,并使其与负责人偏好的应用和沟通方式兼容。
语义层面和使用LLM的工具也可以为此发挥作用。
所以为了说明起见,我在这个原型中实现了三种工具作为输出代理。
- 绘制地图,在地图上绘制计划路线。
- 添加日历提醒,以便提醒计划的具体时间和地点。
- 详细的路线文字描述,包括在何处转弯、乘坐哪些火车或巴士、在哪个站下车等。
我不会描述它们是如何实现的。感兴趣的读者可以在这里查看代码:在这里。
我将突出一个设计问题,不过这也是许多其他代理的工作流程需要考虑的一个问题。
计算完行程和计划后,只有它们的元数据会被返回给路由器代理,如前文所述。计划的详细信息保存在JourneyMaker
实例的内存中。这些详细信息可以通过两个整数索引来获取:若无歧义则为0和计划索引。如下面的行程制作工具集中所示,您可以看到这一点。
def 获取计算后的行程计划(self, 行程索引: int, 计划索引: int) -> str:
return self.maker[行程索引][计划索引].to_json(indent=4)
然而,路由器代理在生成指令时必须知道输出代理需要这两个索引。
那就是为什么作为工具的 JSON 文件在输出代理工件时看起来稍有不同。如上所示,完整的文件内容中,相关部分如下:
{
"name": "output_artefacts",
"description": "生成用于行程规划器的输出文件,这些文件将行程计划传达给用户。此工具可以创建的输出文件有:(1) 在伦敦地图上标出行程路线的地图;(2) ICS 文件;(3) 包含行程步骤详细说明的文本。",
"input_schema": {
"type": "object",
"properties": {
"input_prompt": {
"type": "string",
"description": "自由文本提示"
},
"input_structured": {
"type": "object",
"properties": {
"journey_index": {
"type": "integer",
"description": "行程编号"
},
"plan_index": {
"type": "integer",
"description": "计划编号"
}
},
"required": ["journey_index", "plan_index"]
}
},
"required": ["input_prompt","input_structured"]
}
}
所以
- 如果路由器代理的消息栈中包含相关的索引,并且
- 路由器代理调用了输出代理,则
- 选定的计划索引将以结构化数据对象的形式(而不是简单的自由文本提示)从路由器代理传递给子任务代理中的名为
input_structured
的结构化数据对象,其中包含必填字段journey_index
和plan_index
。
换句话说,在调用输出工件代理的LLM时,提示中已经包含了结构化的内容。因此,该LLM在生成工具规范字符串时,可以包含这些索引。
例如:地图绘制工具规格定义的 JSON 部分如下:
{
"name": "draw_map_for_plan",
"description": "为一个(且仅一个)特定的行程绘制伦敦地图,在地图上绘制经纬度线路段的轨迹,每个行程的不同路段用不同颜色表示,默认情况下,工具会在网页浏览器中显示地图。",
"input_schema": {
"type": "object",
"properties": {
"journey_index": {
"type": "integer",
"description": "要检索行程的索引"
},
"plan_index": {
"type": "integer",
"description": "要检索并在地图上显示的计划索引"
},
"browser_display": {
"type": "boolean",
"description": "是否在网页浏览器中显示地图或仅保存为文件而不显示,默认为true"
}
},
"required": ["journey_index", "plan_index"]
}
}
它包含两个必需的索引,这些索引包含在路由器代理创建的提示中。还有一个可选的布尔参数,即是否在创建地图时将其显示在浏览器中,LLM 可根据需求推断委托人请求并设置此参数。
这是用于代理之间交换结构化数据的最佳设计吗?
还可以这样,让代理们共享另一个数据对象。我们可以想象一个包含助手参数的数据结构,输出代理总是将其加载到发送给大语言模型 (LLM) 的提示中:
在所选计划中,根据该计划在伦敦的地图上规划行程。
{
"journey_index": 0,
"plan_index": 3
}
如果路由器或行程规划代理有权写入这个假设的数据对象,那么结构化的数据就可以在代理间传递,而不仅仅限于它们在文本指令中的交换内容。
不过,如果代理工作流嵌套得很深,并且代理之间传递的结构化数据越来越多,代理之间这种传递信息的方式还能靠得住吗?
助手的看法和活跃的路由器代理我之前描述了校长对旅行请求向旅程规划助手 Vitaloid 的观点。下方的泳道图(如下所示)展示了助理的角度及其工作内容。
在组件之间传递文本数据时,数据会被截断,从请求到响应的流程中。
这些箱子包含各种类型和内容的字符串(string),这些字符串是由每个箱子所在泳道的对象生成的,并传递给指向它的对象。
当前代理流动的一个特点是,路由器不是同时创建三个规划代理规范,而是依次创建。在每个规划代理返回其计算出的中转计划的元数据字符串信息后,路由器代理则重新评估情况。也就是说,它会重新处理消息并确定是否需要再次调用规划代理,直到成功规划出所有三个行程。
这提出了关于代理的细粒度和责任分配的重要设计考虑。通过采用不同的提示和工具规范,可以减少路由器代理在数据流中的参与度,并更有效地将更多工作分配给子任务代理。助理可以由更多的分层代理和工具组成,每个代理都有明确界定的责任。
相反,路由器代理可以装备所有子任务代理的工具,从而负责所有来回传递的字符串。虽然大多数现代LLM的大上下文窗口特性使得这种集中的设计成为可能,但这意味着,即使我们知道某些数据与解决方案无关,LLM仍然会处理大量数据。例如,当提示要求路线规划时,制图工具显然成为了不必要的负担。
最优的代理及其与工具的关系配置最终面临着与传统软件架构相同的基本权衡:运营成本、将MVP推向市场的时间以及可扩展性要求。此外,代理特定的考虑因素,如提示复杂性、令牌使用效率的优化以及代理自主性和受控委派之间平衡的把握也起着关键作用。就像许多架构决策一样,关键在于找到特定用例中合适的平衡点,同时保持系统未来发展的灵活性。
上述代码和在GitHub上可以进一步实验的可用性意味着你也可以使用其他类型的助手来探索这些问题。我为当前原型所做的选择,是基于与大型语言模型交互的经验和类人推断。要系统地探索这一点,我们将进入评估的话题,这方面的意见和评论很多,所以最好留待以后再讨论。
共同学习,写下你的评论
评论加载中...
作者其他优质文章