查看原文
其他

Rethinking LangChain(1):封装的灵活度 【2023Q3】

孔某人 孔某人的低维认知 2024-04-04

本文于2023.9.1首发于知乎。

系列前言

LangChain目前已经是一个影响力很大的库,但在一个认真的场景深度开发的人经常会发现LangChain无法满足自己的需求,甚至也很难通过简单的对LangChain代码hack来实现。从这种意义上来说,LangChain的作用更像是:

  • 刚学骑车的小朋友的自行车辅助轮

  • 一些新范式设计思路的样例

LangChain更像是一门实践课程,而不是一个老木匠手中好用的工具。我在不同渠道看到了很多人有类似的表达。

那么问题来了:

  • 是什么阻碍了LangChain成为一个高手仍然会使用的工具?

  • LangChain是否只是新手的快餐?

这些问题并不简单,而我有一些尚不是共识的认知,所以才开此系列来分项进行讨论。

1、Chain的基本设计

简单来说,Chain是一个最基本的LLM调用的封装,包括了一次最基本的LLM调用需要的prompt模板、调用的LLM API设置、输出文本的结构化解析等。可以将多个Chain组合链接起来,也可以将一些非LLM的策略算法等包装成同样的封装。这个视角是把LLM当作一个高级函数来使用的,而不是一个chatbot。

但这个设计有个问题,如果输出有多种情况,例如需要不同的后续逻辑进行处理,则不适用这种封装。

虽然说LangChain也提供了Router这样的“下游调用的Chain的选择逻辑”封装,但这对于逻辑的编写仍然是一个很强且不必要的限制。这是相对于什么来说呢?相对于直接写Python代码本身。

2、异常处理与Chain

先回顾传统程序中的异常处理

在一个偏工程性而不是策略性的程序中,异常处理是一个经典的思考维度。传统编程语言习惯上有两种处理方式:

  • 抛出异常,不走返回值流程,交给上层某个不确定的异常处理逻辑进行处理。

  • 通过返回值的方式把所有状态信息都交给调用方处理。

这两种方式都不是银弹。

从异常的处理思路上分为两类:

  • 并不特别的区分各种小概率的问题,要么自动重试、要么交给上层调用者考虑如何进行重试、要么返回失败给上层调用者由调用者决定是否暂时取消该任务。这里的上层调用者有的时候会上升到操作软件的人类用户。

  • 有些问题不能指望重试就能绕开,或者是需要全自动的处理各种情况,此时就需要针对各种可能性分支进行设计和逻辑开发。

再来看基于LLM的程序遇到的异常,大概可以概括为:

  • 【E1】传统RPC调用会遇到的问题:远程错误、超时、达到最大并发限制等等

  • 【E2】LLM返回的结果并不符合预期,例如:指令遵从失败、返回的结果语义不对 等等

【E1】类问题的处理经验基本可以参考过去,不过由于LLM的调用成本明显高于过去的其他RPC服务,所以也有一些特殊性。

【E2】类问题则与过往认知不同,虽然ML模型也会有各种各样的意外情况,但LLM的应用场景更加多样,出现的问题种类也跟难预计。根据后续自动化识别难度的不同,还可以进一步分为:

  • 【E2.1】容易自动化识别的问题(但不一定代表这个有问题的结果可以被低成本的修正)

  • 【E2.2】难以自动化识别的问题

我之前曾讨论过:虽然基于LLM的程序很难做到100%正确,但无论应用场景如何,我们仍然要尽量提升结果的准确率与成功率。(相关讨论见 基于LLM的程序/初级Agent 策略开发 基础(2) 。)那么由此推论,我们要努力发现【E2.1】类问题,并尽量尝试进行处理。

所以一般一次LLM调用之后,如果有【E2.1】类问题的检测手段,往往会附带一些异常检查逻辑,如果发现某类问题,则需要针对性的进行后续流程处理。也就是说,“单输入,多种可能的输出”会是策略的常态。

发现不同的问题后,有些问题的解决方式可能是同样的,例如“使用更大参数的模型再试一次”。所以整个流程图可能颇为复杂。

还有个问题是:【E2.1】类问题的发现策略本身也是一种策略,也有准确率和召回率的问题。由于LLM适用场景的多样性,也经常会遇到需要新增检验维度和异常处理流程的情况。也就是说这个流程图中每个节点的出节点数量和每种类型的下游节点是经常会根据数据情况变化来调整的。

但Chain的设计并不能很好的适应这种策略结构。这种策略逻辑就算是直接裸写Python也没有特别好的方式。

也许一种有向图式的API操作会好一点,但“不断的给节点新增输出端子,嵌入识别逻辑函数,接上后续的处理流程等等”操作仍然有些繁琐。

3、LangChain也在发展

到本文写作的时候(2023.9.1),自诩专业LLM应用开发者已经把贬低LangChain作为了一种体现自身专业性的名片。

但LangChain真的这么差么?当我们从自行车辅助轮毕业了之后,就可以把LangChain埋葬了么?在写作本文时,我又重新去看了一下LangChain的近期feature,感觉也许是我们过于自信了。

Chain的设计对于一些新出现的LLM API功能并不能充分发挥其功能,特别是对于function calling的一些非常规的用法。

但LangChain是怎么处理这个问题的呢?LangChain的答案在 langchain项目的 langchain\chains\openai_functions 路径下。

说实话其中的内容超过了我的预测,我所看到的不少团队不一定能“在早期从LangChain的教程上分叉出去后,还能及时的跟上这些想法”。也许你让他们在现在重新思考的话,他们也能得到同样的设计,但这些东西很可能就被遗忘在他们的底层代码中很长时间都没有被更新。导致很可能会是更晚从LangChain学习的团队的平均代码质量更高。

现在想想,LangChain的不符合软件工程的设计、没有长期稳定版本是它的缺点,但也是它优点的基础:它可以很快速的演化。现在OpenAI有了function calling,就可以加上一批绑定LLM实现的封装。当function calling在很多LLM API都普及了之后,它也可以快速调整,重新弄一个新的抽象,几乎不用顾及历史包袱和历史生态。

现在在我来看,LangChain的代码仍然不太适合修改定制,但它有值得我们持续跟踪的价值,从其中汲取有用的新想法移入到自己的项目中。

跟早期一样,LangChain仍然是一个教材,只要LLM API在发展,只要LangChain社区还活跃,那么它就仍然值得借鉴。毕竟很多团队也都是精力有限,不可能时刻对所有领域保持深入思考,能抄的作业还是要及时抄才是合适的路线。

4、更灵活的抽象是什么样?

对于LangChain的很多吐槽都来自于它的代码不便于扩展或者魔改,某些要改的参数没有暴露,要添加某些逻辑需要改动的代码太多。

那么我们是否能做一个足够灵活的设计呢?把所有需要修改或者扩展的地方通通暴露出来,一切皆可配置?

但当我们这么做的时候,会发现这个设计几乎没有封装什么内容,所有的复杂度都在最外层可被感知和控制。与其说我们要做一个这样的封装,不如说Python+API本身就是一个足够灵活的封装,没有太多冗余的部分,也可以通过定义函数进一步根据场景进行特化封装。

这也是尴尬之处,实际项目中,我们并不需要无限的灵活度,因为那往往带来过多的非必要复杂度。我们只需要足够的灵活度,足够的可配置性,其他当前项目不需要的信息统统应该被封装。

而项目和项目是有差异的,同类型的项目大概可以共享类似的基础封装,但可能无法期待所有的项目都可以共用某个最优的抽象与封装。这可能也是我们并没有看到一个更好的长期设计的原因。

交流与合作

如果希望和我交流讨论,或参与相关的讨论群,或者建立合作,请私信联系,见 联系方式

希望留言可以知乎对应文章下留言

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存