2023年11月06日
关于LangChain TS中的类型安全
在Octomind,我们使用大型语言模型(LLMs)与Web应用程序UI进行交互,并提取我们想要生成的测试用例步骤。我们使用LangChain库来构建与LLMs的交互链。LLM接收任务提示,而我们作为开发人员提供工具,模型可以利用这些工具来解决任务。
LLM输出的不可预测和非确定性性质使得确保类型安全成为一项相当具有挑战性的任务。LangChain对解析输入和处理错误的方法往往会导致类型系统内出现意外和不一致的结果。我想分享一下我对LangChain解析和错误处理的一些了解。
我将解释:
- 我们为什么首选TypeScript?
- LLM输出存在的问题
- 类型错误可能被忽略的原因
- 这可能带来的后果
所有代码示例均使用2023年9月22日的LangChain TS主分支(大约版本0.0.153)。
为什么选择LangChain TS而不是Python?
LangChain支持两种语言——Python和JS/TypeScript。TypeScript有一些优点和一些缺点:
- 缺点: 我们必须接受这样一个事实,即TypeScript的实现在代码和尤其是文档方面略显滞后于Python版本——如果你愿意牺牲文档而只是直接查看源代码,这是一个可以解决的问题。
- 优点: 我们不必再用另一种语言编写服务,因为我们在其他地方使用TypeScript,并且我们据说可以获得类型安全的保证,我们在这里是大的支持者。
我们决定选择TypeScript版本的LangChain来实现我们基于AI的测试发现的部分。
完全透明地说:我没有深入研究Python版本如何处理下文所述的问题。你是否在Python版本中发现了类似的问题?欢迎直接在我创建的GitHub问题中分享。在文章末尾找到链接。
LLM中的类型问题
在LangChain中,您可以提供一组工具,模型可以在认为有必要时调用这些工具。对于我们的目的,工具只是一个具有**_call** 函数的类,该函数执行模型无法单独完成的操作,比如在网页上点击按钮。该函数的参数由模型提供。
当您的工具实现依赖于开发人员了解输入格式(与仅对模型生成的文本执行操作相反),LangChain提供了一个名为StructuredTool 的类。
StructuredTool 为工具添加了一个zod模式,用于解析模型决定调用工具时提供的任何内容,以便我们可以在我们的代码中使用这些知识。
让我们以我们想要模型给我们一个要点击的查询选择器为例来构建我们的“点击”示例:
现在,当您查看这个类时,它似乎相当简单,没有太多可能出错的地方。但是模型实际上如何知道要提供什么模式?它本身没有这样的功能。它只是对提示生成一个字符串响应。
当LangChain告知模型其可以使用的工具时,它将为每个工具生成格式说明。这些说明定义了JSON是什么,以及模型应该生成的特定输入模式以使用工具。
为此,LangChain将生成一个附加到您自己的提示的内容,看起来像这样:
您可以访问以下工具。
您必须将您的输入格式化为下面的“JSON模式”定义。
“JSON模式”是一种声明性语言,允许您注释和验证JSON文档。
例如,示例“JSON模式”实例{"properties": {"foo": {"description": "a list of test words," "type": "array," "items": {"type": "string"}}}, "required": ["foo"]}}
将匹配具有一个必需属性“foo
”的对象。属性“type
”指定“foo
”必须是一个“array
”,并且“description
”属性在语义上描述它为“一组测试单词”。在“foo
”内的项目必须是字符串。
因此,对象{"foo": ["bar," "baz"]}
是这个示例“JSON模式”的格式良好的实例。对象{"properties": {"foo": ["bar," "baz"]}}
则格式不良好。
以下是您可以访问的工具的JSON模式实例:
click: left click on an element on a web page represented by a query selector, args: {"selector":{"type": "string," "description": "The query selector to click on."}}
不要相信LLM
现在,我们有了一种尽力使模型使用正确模式的方法。不幸的是,尽力并不能保证一切。完全有可能模型生成的输入不符合模式。
因此,让我们来看看StructuredTool
的实现,看看它如何处理这个问题。StructuredTool.call
是最终调用我们上面的_call
方法的函数。
它开始如下:
arg
的签名解释如下:
如果在解析工具的模式后,输出可以只是一个字符串,那么这也可以是一个字符串,或者是模式定义的任何对象。如果您将模式定义为schema = z.string()
,那么就是这种情况。
在我们的情况下,我们的模式不能解析为字符串,因此这简化为类型{ selector: string }, or ClickSchema
。
但这实际上是这种情况吗?
根据实现,我们只在call
内部检查输入是否符合模式。签名看起来好像我们已经对输入做出了一些假设。
因此,可以用以下内容替换签名:
但是进一步观察,甚至这也存在问题。我们唯一确定的是模型将给我们一个字符串。这意味着有两种选择:
call
实际上应该有以下签名:
- 还有另一个元素
- 必须已经有某个东西决定了模型返回的字符串是有效的JSON并对其进行了解析。
- 如果
z.output<T> extends string
,则在某个地方必须已经决定了字符串是工具的可接受输入格式,我们不需要解析JSON。(单独的字符串不是有效的JSON,JSON.parse("foo")
将导致SyntaxError)。
引入OutputParser类
当然,第二种选择才是正在发生的事情。对于这种情况,LangChain提供了一个名为OutputParser
的概念。
让我们来看一下默认的一个(StructuredChatOuputParser)
及其parse 方法。
我们不需要了解每个细节,但我们可以看到这是模型生成的字符串被解析为JSON的地方,并且如果它不是有效的JSON,则会抛出错误。
因此,我们要么得到AgentAction
,要么得到AgentFinish
。我们不需要关心AgentFinish
,因为它只是一个特殊情况,表示与模型的交互已经完成。
AgentAction
被定义为:
到目前为止,您可能已经看到——AgentAction
和StructuredChatOutputParserWithRetries
都不是泛型的,也没有办法将toolInput
的类型与我们的ClickSchema
连接起来。
由于我们不知道代理实际上选择了哪个工具,因此我们无法(轻松地)使用泛型来表示实际类型,所以这是可以预料的。但更糟糕的是,toolInput
被标记为string
类型,即使我们刚刚使用了JSON.parse
来获取它!
考虑模型生成与我们的模式匹配的输出的正面情况,比如字符串"{\"selector\": \"myCoolButton\"}"
(包含LangChain要求的所有额外内容)。使用**JSON.parse
** ,这将反序列化为对象{ selector: "myCoolButton" }
,而不是一个string
。
但是因为JSON.parse
的返回类型是any
,TypeScript编译器无法意识到这一点。不幸的是,这也意味着我们作为开发人员很难意识到这一点。
对我们生产代码的影响
要理解为什么这是令人困扰的,我们需要深入了解执行循环,其中AgentActions
用于实际调用工具。
这发生在这里的AgentExecutor._call
中。我们实际上不需要了解这个类的所有内容。可以将其视为处理模型与工具实现交互的包装器。
循环中的第一件事是查找要执行的下一个操作。这就是使用OutputParser
进行解析并处理其异常的地方。
您可以看到,在出现错误的情况下,toolInput
字段将始终是一个字符串(如果this.handleParsingErrors
是一个函数,返回类型也是string
)。
但是我们刚刚看到,在非错误情况下,toolInput
将被解析为JSON!这是不一致的行为。我们从未将handleParsingErrors
的输出解析为JSON。
让我们看看循环如何继续。下一步是使用给定的输入调用所选的工具:
我们只将先前计算的输出传递给工具的tool.call(action.toolInput)
!
如果这导致另一个错误,我们将重复使用相同的函数来处理解析错误,该函数将返回一个字符串,该字符串应该是错误情况下的工具输出。
让我们总结一下所有问题:
-
我们将模型的输出解析为JSON,并使用解析后的结果调用工具
-
如果解析成功,我们将使用任何有效的JSON调用工具
-
如果解析失败,我们将使用一个字符串调用工具
-
工具使用zod解析输入,仅当模式是
const stringSchema = z.string()
时,错误情况下才能通过类型检查 -
我们没有涵盖这一点,但是使用
const stringSchema = z.string()
作为工具模式将根本无法通过类型检查,因为StructuredTool
的泛型参数是T extends z.ZodObject<any, any, any, any>
,而typeof stringSchema
不满足该约束 -
tool.call
的签名允许这种类型检查,因为我们当前不知道具体有哪个工具,所以字符串和任何JSON都有可能是有效的 -
实际的类型检查发生在此函数内部的运行时
-
实现该工具的开发人员对此一无所知。因为只有
StrucStep.actionturedTool._call
是抽象的,你将始终得到模式指示的内容,但StructuredTool.call
将失败,即使你已经提供了一个名为handleParsingErrors
的函数。 -
无论工具被调用时传入什么,都会被序列化为
AgentAction.toolInput: string
,这个类型并不正确 -
库用户可以访问带有错误类型的
AgentActions
,因为可以通过returnIntermediateSteps=true
请求它们作为整体循环的返回值。
无论开发人员现在做什么,都绝对不是类型安全的!
我们是如何遇到这个问题的?
在 Octomind,我们使用 AgentSteps
来提取我们想要生成的测试用例步骤。我们注意到模型经常在工具输入格式上犯同样的错误。
回想一下我们的 ClickSchema
,它只是 { selector: string }
。
在我们的点击示例中,它可能会根据模式生成 { element: string }
,或者只是一个我们想要的值的字符串,比如 "myCoolButton."
因此,我们为这些常见的错误情况构建了一个自动修复工具。修复工具基本上只是检查它是否可以使用上述任一选项修复输入。我们可以在不覆盖 LangChain 提供的大量规划逻辑的情况下最早地注入这段代码是在 StructuredTool.call
中。
我们无法使用 handleParsingErrors
来处理它,因为它只接收错误作为输入,而不是原始输入。一旦你覆盖了 StructuredTool.call
,你就依赖于该函数的签名是正确的,而我们刚刚看到这并不是情况。
在这一点上,我陷入了必须弄清楚上述所有内容的困境,以了解为什么我得到了错误类型的输入。
类型安全的解决方案
虽然这些障碍可能令人沮丧,但它们也提供了深入研究库并提出可能解决方案的机会,而不是抱怨。
我在 LangChain JS/TS 上开了两个问题,讨论如何解决这些问题的想法: