开始之前,我们来看看下面这一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function couponCron() {
$now = Carbon::now()->toDateTimeString();

// 到开始时间了,未开始的优惠券活动更改为开始状态
app(Coupon::class)
->where('coupon_status', 1)
->where('release_start_time', '<=', $now)
->update(['coupon_status' => 2]);

// 过期优惠券活动
$expireCouponIds = app(Coupon::class)
->whereIn('coupon_status', [2, 3])
->where('release_end_time', '<=', $now)
->pluck('id');

if ($expireCouponIds) {
// 过期优惠券活动状态改为失效
app(Coupon::class)
->whereIn('id', $expireCouponIds)
->update(['coupon_status' => 4]);
}
}

这段代码是一个定时任务,用于处理优惠券活动的状态变更:到了优惠券开始时间,将优惠券状态更改为活动中状态;到了优惠券结束时间,将优惠券状态更改为失效状态。

没有想到非常好的命名,就这样吧。

这段代码有什么问题?

像以上这个规模的代码,并没有什么问题,因为足够简单,我们看几秒注释就能知道这段代码的作用。 但是当这个函数几百行的时候,我们估计会比较头疼了,比如下面这种六七百行的:

单单比较长度可能看不出什么猫腻,我们再看看下面的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public function couponCron()
{
$this->startCoupons();
$this->invalidExpiredCoupons();
}

private function startCoupons(): void
{
$now = Carbon::now()->toDateTimeString();

app(Coupon::class)
->where('coupon_status', 1)
->where('release_start_time', '<=', $now)
->update(['coupon_status' => 2]);
}

private function invalidExpiredCoupons(): void
{
$now = Carbon::now()->toDateTimeString();

$expireCouponIds = app(Coupon::class)
->whereIn('coupon_status', [2, 3])
->where('release_end_time', '<=', $now)
->pluck('id');

if ($expireCouponIds) {
app(Coupon::class)
->whereIn('id', $expireCouponIds)
->update(['coupon_status' => 4]);
}
}

跟前一个例子不同之处在于:

  1. 将代码拆分成了两个函数,每个函数只做一件事。
  2. 原来的那个函数里面只需要调用两个函数即可。

只是对数据库操作做了一下封装,是这样吗?

从表面来看,是的,只是对数据库操作做了一下封装。但是从更深层次来看,也没有那么简单,我们将业务逻辑和技术细节分离开了:startCoupons 以及 invalidExpiredCoupons 是直接告诉读者业务是什么,而 startCoupons 函数的实现则告诉读者如何实现这个业务。 在我们只需要了解大概业务逻辑的情况下,无疑第二种写法能够更快地让我们理解代码,只有当需要去对其中某些业务逻辑做变更的时候,我们才需要去看具体的实现。 函数过长的一个原因往往就是,代码全都是描述如何实现业务逻辑的技术细节,而不是描述业务逻辑本身。

另外一种说法是:抽象层次不一样的代码混在一起,可能有一部分代码是描述业务逻辑的,然后一部分代码是描述实现业务逻辑的细节的,描述实现细节的代码我们需要看注释才能知道背后的业务逻辑。

在《重构》中,对于长方法,有这么一句话:如果你觉得需要对方法中的某些内容进行注释,你应该把这段代码放在一个新的方法中。如果需要解释,即使是一行也可以而且应该拆分成一个独立的方法。如果方法具有描述性名称,则无需查看代码即可了解它的作用。

《重构》中没有说到的

其实对于上面的这种问题,可能很多人也有意识到,要将函数写短一些,但是这种意识可能并不能很好地指导我们的实践:每个人标准不一样,比如每个人对于方法的 “短” 的定义不一样,有的人觉得 100 行就很长了,有的人觉得 200 行也没问题,当然实际中也不可能只从方法长短来判断一个方法写得好不好。 那什么东西应该被用来指导我们去在适当的时候将方法写短一点呢?我觉得可以从以下几个角度重新审视一下开篇的那一段代码,然后我们就很容易理解了:

  1. 抽象层次:这个方法是描述业务逻辑的,还是描述实现业务逻辑的细节的?如果我们的方法是一个 API 或者某个逻辑实现的主入口,那我们应该将这个方法写得尽可能短,然后将业务逻辑的实现细节放到其他方法中。
  2. 代码可读性:方法太长之后,我们可能看几行代码就得想想这几行代码到底做了什么操作,对后面的代码有什么影响。这样导致的一个问题是,我们的思考过程不会连贯,而是断断续续的,这样会导致我们的思维负担加重。
  3. 可维护性:需要花费更多的时间去理解这个方法,然后才能修改它。另外一个问题是,可能其中某些技术的细节是可以复用的,在其他地方如果也有类似的细节,我们可能会重复写一遍。

什么是业务逻辑?什么是技术细节?

举个简单的例子,有个用户注册的接口的逻辑是:

  1. 通过账号密码注册,然后发送邮件给用户。
  2. 为了防止黑客批量注册,注册的时候需要填写一个验证码。在注册之前,需要先验证验证码是否正确。

在这个例子,主要的业务逻辑有:

  1. 验证:账号密码验证码是否为空、验证码是否正确、用户是否已经存在。
  2. 注册:在数据库中创建一条用户记录。
  3. 发送邮件:通过某个邮件组件发送邮件给用户,告知用户注册成功。

上面提到的这三点,冒号前面的就是业务逻辑,冒号后面的就是技术细节。对于整体的业务逻辑,我们的伪代码可以写成这样:

1
2
3
4
5
6
7
8
9
public function register($account, $password, $captcha)
{
// 验证
$this->validate($account, $password, $captcha);
// 注册
$this->createUser($account, $password);
// 发送邮件
$this->sendEmail($account);
}

而技术细节部分,我们只需要在各自的方法中实现即可。这样的好处是,自己或者别人再看这段代码的时候,看到的首先都是业务逻辑,而不是一上来就陷入到技术细节中无法自拔。

分离开业务逻辑与技术细节的好处

其实写代码跟写作有点类似,因为二者都是有点 “写作” 的味道的,当然写作的创作性更强;另外就是,写代码跟写作最终都会让被人阅读,所以我们写代码的时候,也要考虑到别人阅读的问题。

对受众(包括读者、听众、观众或学员)来说,最容易理解的顺序是:先了解主要的、抽象的思想,然后了解次要的、为主要思想提供支持的思想。因为主要思想总是从次要思想中概括总结得出,文章中所有思想的理想结构必定是一个金字塔结构-由一个总的思想统领多组思想 - 《金字塔原理》

当我们将业务逻辑与技术分离之后,“业务逻辑” 就等于是我们的 “主要思想”,而 “技术细节” 就等于是 “次要思想”。这样就能让读者很容易就能理解我们的代码。

我们该如何做好分离?

业务逻辑与技术细节耦合的最常见的一种场景是,在我们实现某个功能的时候,写到哪一步就做哪一步业务逻辑所需要的 CRUD 操作,当然可能不同语言、框架底下写出来的代码会有所不同,但是我们接触的大多数都是这样的代码。

当然并不是说这是错的,这是很正常的,实现功能的代码一定是长这个样子的,很少纯粹只有业务逻辑的代码,纯粹的技术的代码倒是有很多(比如 RPC 通信的底层封装)。我们要实现某些功能,肯定会跟各种数据打交道,又或者跟 RPC 之类的东西打交道。 而且完全分离业务逻辑与技术细节是不可能的,因为完全分离也会带来其他的问题,很多简单的场景下还不如写到一块去直观。

只能说,尽量保证方法短小,我们有很多时候可以选择将方法中的一些处理技术细节的代码抽取出去(Extract Method),比如在其中混杂了一个复杂的查询,写了十几行了,这个时候拆分出去或许会更好,让原方法只保留核心业务逻辑的描述。 如果原来方法本身就很短,并且看多两眼就能看懂在做什么的话,拆分了反而会让代码变得更加复杂,这个时候就没必要拆分了。当然,代码的好与坏不应当用这种 “含混的代码美学” 来判断,因为这会带来一个困难是,每个人对于代码理解不一样,所以做出的判断不一样。 但是,这种分歧是没有办法避免的,可能有个办法是,每一个人负责某一功能模块,不要大家改同一块代码,但这个人离职之后,这个人的代码其他人还是得去维护;又或者,另外一个办法是,定一个标准或者说规范,然后大家共同遵守,比如方法不能超过多少行。

总结

可能我们也知道方法长了得拆分一下,因为我们都知道,代码太长看起来太费劲了,但是可能我们并不是很清楚背后的一些原因,导致我们在实践的时候做不到很好地去拆分。 本文从一个新的角度探讨了如何通过分离业务逻辑与技术细节来简化代码,提高可读性和可维护性。合理地对业务逻辑和技术细节进行分离,可以让读者在阅读代码的时候,更容易理解代码背后的业务,而不是被技术细节所迷惑。

对于程序员来说,往往工作几年以后,如果使用的技术没有太大变化,写起代码来往往会有一种得心应手的感觉,好像不需要过多思考就能写出来。 在业务简单的情况下,这样子不会有任何的问题,因为面对的问题足够的简单,根本没有太多发挥的空间。 可是当业务变得复杂起来的时候,这种感觉就会变得非常不靠谱,明明脑子想得通,但是代码写不通,又或者不知道如何下手。

不管是看别人的代码,还是看自己的代码,总会有出现这种感觉的时候。 我们可能会想,明明以前写得那么顺畅,怎么到今天就很难接着原来的代码继续写下去呢? 可能在实现那个需求的时候,那样实现确实是没问题的,但是那样写出来的代码,可能并不适应新的需求,也就是说,我们的代码并不够灵活。 又或者是,我们忽略了脑海中曾经浮现过的一些比较理性的思考,选择了比较顺畅的、耗时更短的方法,为了赶在 deadline 前完成任务,当然这也是一种选择。

但如果我们想要写出更好维护的代码,那么我们就需要更多地依赖一些理性的思考,而不是感觉。 也就是需要花时间思考一下当前软件的设计是否合理,是否能够适应可遇见的变化,是否能够方便地扩展等。

你真的需要 setter 吗?

当我们想为一个类添加一个属性的时候,我们可能会想到使用 setter 方法来设置这个属性,这是一种非常符合感觉的做法。这样写代码我们也不会有太多的阻力,因为所见即所得。

在实际工作中,我们可能会经常看到这样的代码:

1
2
3
4
5
public void approve(final long bookId) {
...
book.setReviewStatus(ReviewStatus.APPROVED);
...
}

这里讨论的不是实体类的 setter

也就是通过 setter 方法来修改对象的属性,这样的代码看起来很简单,但是却隐藏了一些问题:

  1. 缺乏封装:在这里,我们本意要实现的操作是 “审核”,但是通过 setter 方法来修改对象的属性,使得我们的操作不再是 “审核”,而是 “修改属性”。
  2. 变化不可控:任何人在任何地方都能使用 setter 方法来修改对象的属性,这样会导致对象的状态变得不可控。很难追溯变化是从哪里引起的。

关于第二点,我们可以用数据库的变更来类比一下:

在 Java 中,对数据库的操作,往往有一层 dao 层,这一层负责和数据库交互,而不是直接操作数据库。 这样一来,对数据库的操作就变得可控了,我们可以通过查看 dao 层的方法被哪里调用,来追溯数据库的变更。 相比之下,在某些脚本语言中,比如 PHP,因为书写很自由,没有明显的分层,数据库操作的代码散落在各个地方,导致数据库的变更变得不可控; 代码仓库变得越来越庞大的时候,我们就很难得知某个字段是在哪里被修改的,这样一来,维护成本就会变得非常高,因为操作数据库变更的代码有各种形态,无法简单地通过搜索得到; 这种情况下,我们只能在出问题的时候出修复脚本去修复一些出错的数据,而某段本来应该被修复的代码,埋藏在一个不为人知的角落里。

过多的 setter 透露着一些问题:

  1. 对象暴露了太多的细节,缺乏封装。
  2. class 的职责不明确/职责过多,如果职责比较单一,那它依赖的参数应该也是比较固定的。
  3. 对其他开发者而言,可能不知道什么时候该调用 setter 方法,因为暴露了 setter 之后,意味着 setter 对应的属性是可 set 也可不 set,等于给其他人出了一个难题,因为这个时候他们必须考虑传和不传会有什么影响。

这样的 setter 多了之后,作为维护的人心智负担会越来越重,可能你处理一个业务的时候,就得想很多次该不该 set 某个属性的问题。

不用 setter,那用什么?

好了,理性地思考一番过后,我们知道了 setter 方法的问题,那么我们应该怎么做呢?

这需要分情况讨论,下面是两种非常常见的情况:

  1. 更新对象的属性/状态:如上面这个例子。
  2. 参数传递:有时候我们在修改一个类的时候发现需要加参数的话,可能有的人会选择加一个 setter 方法来传递新的参数。

针对第一种情况,我们可以简单地封装一个方法,方法名表示我们想要做的操作,比如,上面这个例子,我们的本意是 “审核”:

1
2
3
4
5
public void approve(final long bookId) {
...
book.approve();
...
}

book 对象中,我们可以定义一个 approve 方法,这个方法负责修改对象的状态:

1
2
3
public void approve() {
this.reviewStatus = ReviewStatus.APPROVED;
}

也许你会觉得这样做有些多余,但是这样写了之后,我们就知道,任何审核操作都是通过 approve 方法来实现的,如果我们想知道审核状态是哪里修改的,可以直接查看哪里使用了 approve 方法即可。

针对第二种情况,我们有两种办法:

  1. 通过构造函数传递参数:如果是全局的参数,也就是整个类都需要用到的参数,我们可以通过构造函数传递参数,然后设置为对象的属性。
  2. 通过方法传递参数:如果只是某个方法的参数,我们就不需要在 class 上增加属性,然后通过 setter 方法来传递参数,直接在方法参数中传递即可。

你应该重构那些遗留代码吗?

很多时候,我们在看到某些屎山代码的时候,会有一种冲动,想要将这些代码全部重构一遍,但是这样做真的好吗?或者说,重构后会更好吗?还是会引入更多的问题? 可能很多人回答不了这几个问题,“屎山” 特征其实很明显,但是真让我们去改的时候,未必就能改得更好。因为我们只是感觉上觉得 “屎”,但是缺乏具体的理性分析。 在这种情况下,就算贸然重构,也还是会存在很多新的问题,可能其他人看了也还是觉得看不懂、不好维护。

这就好比,虽然我们不会做菜,但是我们知道这道菜不好吃,但是我们不知道哪里不好吃,也不知道怎么做才好吃。 如果你让一名美食家来品尝的话,他真的可以给你说出个所以然来,比如他们会针对火候、口感、味道各方面进行分析。 同样的,可能我们不知道怎样写才更好,但是看到那些写得不好的代码的时候,其实我们是有感觉的。 但真要让我们说的话,可能又说不出来。

在这种情况下,我们看到好不好的时候,我们还是可以评判的,但是我们很难往好的方向去改进,因为我们都是凭感觉。

在还没有明确要重构的代码存在哪些问题之前,选择维持现状可能是一个更好的选择。

那我们与 “美食家” 的区别又在哪里呢?

我们知道,我们度量长度的时候,需要有一把尺子,这把尺子就是我们的标准。 同样的,对于美食家而言,他们也有一把尺子,但不同于直尺,他们的尺子是多维度的,比如有火候、口感、味道几个维度等等:

而对于普通食客而言,他们的尺子可能只有一个维度,就是好吃不好吃,也就是本文所说的感觉。

衡量代码的尺子

同样的,对于代码而言,我们也有一把尺子,比如可能我们关注的是以下几个维度(可能每个团队关注的维度不一样,这里这是抛砖引玉):

  1. 可读性:代码是否容易阅读,变量和函数命名是否清晰且具有描述性,注释是否能够帮助理解代码逻辑。
  2. 可维护性:代码是否容易维护,是否容易修改;结构是否清晰。
  3. 整洁性:代码是否整洁,是否有冗余代码。
  4. 可复用性:代码是否可以在其他项目或模块中复用?是否遵循了 DRY(Don't Repeat Yourself)原则?
  5. 灵活性:代码是否容易适应变化?是否使用了设计模式等提高代码灵活性的方法?
  6. 一致性:代码风格是否一致?是否遵循了团队的代码规范?

评判的维度并不固定,取决于你们更关注哪方面的问题。比如我们这个图就没有把性能这一维度加上去,因为一个项目中,并没有那么多的性能问题,在优化性能之前,把代码写得更好维护可能更重要。

重构的时机

现在,我们知道了,对于代码的好坏,也有一把尺子,那么我们何时应该重构呢? 我觉得应该是拿 “尺子” 好好量过的时候,也就是,我们从各个维度上去看这段代码,看看它是否符合我们的标准。

在我们没有比较清晰的标准的时候,我们就不要轻易去重构,因为我们不知道重构后的代码是否会更好,还是会引入更多的问题。 有了方向,我们就能知道该怎么做了,同时做完之后,我们也能依据这些标准来判断我们的重构是否成功。

如何摆脱对感觉的依赖?

当然,这里并不是说靠感觉不好,只是说,如果你的感觉并不准的话,我们最好还是有一些可以依赖的标准(代码规范)。

拿一些竞技游戏来说,很多人可能玩很久,依然是个比较菜的水平,他们可能甚至花了 1 万个小时在上面,从 1 万小时理论来说,这些人应该是个专家了,可惜现实并不是这样的,又菜又爱玩的人其实不少,比如我。 经过长时间的玩耍,他们有一些比较模糊的感觉,但是缺乏反馈,所以他们的水平并没有提高;对于这部分玩家来说,游戏只是个消遣,比如我,他们并不会去深究游戏的规则,也不会去看一些攻略,只是单纯地玩而已,这些人就是一年经验用了十年。 而有的人,玩起来就很认真,他们会去看一些攻略,去了解游戏的规则,并且在跟其他玩家对抗的时候,不断改进自己的策略,这样一来,他们的水平才会提高。

好了,我想说的是,如果没有刻意地去思考,我们的感觉都只是一种非常不靠谱的感觉,如果一直依赖于这种感觉,我们也很难做得比昨天好。 专业跟业余的差别就在于,专业的人对一件事总有个 1234,能道出个所以然来,而业余的人只是感觉上觉得这样做好,那样做不好。 结果就是,业余的人偶尔能取得好的成绩,而专业的人可以持续地取得好的成绩。

如果我们想摆脱对感觉的依赖,我们就需要有一些可以依赖的标准,也就是上面所说的 “尺子”。 对于程序员来说,写代码的时候,我们可能有如下标准:

  1. 命名规范:变量、函数、类的命名是否符合规范?
  2. 是否符合设计原则:比如 SOLID 原则、DRY 原则等。
  3. 是否足够整洁:是否有冗余代码?

有很多可以判断代码好坏的标准,我们可以根据这些标准来判断我们的代码是否符合规范,是否可以更好维护。 当我们的这把 “尺子” 越来越好用的时候,我们也就逐渐摆脱了对感觉的依赖,我们分析的时候会更加理性。

当我们觉得某样东西好、不好,但是又说不出所以然来的时候,可以尝试找一些标准来判断。 比如针对产品经理给你的原型、需求,你可能觉得不好,但是你又说不出哪里不好;你可以依据下面的标准来判断一下:

  1. 是否有一个好的用户故事(User Story),能让大家都清楚要解决的问题?
  2. 清晰性:需求是否明确且易于理解?
  3. 完整性:是否考虑了不同的用户场景?
  4. 针对背后的问题,当前的产品设计是否就是最好的解决方案?

同样的,如果从来没做过菜的人去买菜,眼花缭乱的也不知道应该怎么选。在经历多次购买之后,他们会形成一些自己的判断标准,比如选菜的时候看新鲜度、颜色、看手感等等。

想摆脱对感觉的依赖,我们就得找到一把合适的 “尺子”,对程序员来说,《代码整洁之道》、《重构》里面包含了一些可以依赖的标准。

什么时候可以依赖感觉?当我们的感觉越来越靠谱的时候,这个时候的感觉就不再是简单的感觉了,而是一种洞见,也就是 “insight”,也就是我们很熟悉各种维度的判断标准的时候。 当然,我们并不可能等到了解了所有的标准之后,才能开始写代码;只是说,不管怎么做,我们总得有一些明确的理由。准确来说,我们并不是要完全摆脱对感觉的依赖,而是要摆脱对不靠谱感觉的依赖

总结

如果用一句话来总结的话,那就是:不管是写代码还是其他事情,在自己的感觉并不靠谱、不能依赖于感觉的时候,尝试先找一把 “尺子”,也就是找一些可以依赖的判断标准, 熟悉了这些标准之后,我们就可以摆脱对不靠谱感觉的依赖,结果就是,我们的判断会更加准确,我们的决策也会更加理性。

背景

由于业务需要,最近又需要对业务系统中的一块代码做一些修改,加一些新功能。 这块代码做的事情主要是:根据系统设定的一些不同类型的价格,计算得到用户在终端上看到的价格,比如针对不同的客户级别展示不同的价格、又或者针对单个客户设定一个价格等等。

但因为年代久远,年久失修,这块代码已经变得非常复杂,很难维护了,而且原作者已经离职多年。 每每翻看这部分代码都十分头疼,几度想要重构,但又有一些不去重构的理由,比如:

  • 影响范围广:用户在终端看到的大多数价格都是通过这块代码计算得到的,一旦出错,影响面很大。
  • 逻辑复杂:不过这里指的是代码写得过于复杂了,其实这块业务本身并不复杂,属于比较正常的线性结构,只是流程多了几个步骤。
  • 没有办法完全理解原作者写这段代码的思路,主要由于:
    • 代码不规范:变量名、函数名、注释等等都不规范,理解成本偏高。
    • 类的层次结构混乱:没有比较明显的层次结构(比如说自顶向下、逐步细化这样去实现)。
    • 比较分散的业务逻辑数据:这里主要指的是一条数据,被拆分为多个字段,不同字段丢进不同的 hash 表里面,在使用的时候再从不同的 hash map (关联数组)里面取出来。操作起来非常繁琐,也不符合直觉。
    • 充斥了太多奇怪的逻辑,因此也很难保证重构后的代码就是正确的。

鉴于以上原因,一直没有动手去重构这块代码,后续的业务改动只是在原有的基础上做了一些小的修改。 但是很多时候,一些简单的业务逻辑,都需要花费不少时间去理解这块代码,去找到正确的修改点,非常痛苦。

新功能怎么做?

跟往常一样,还是先去找找从什么地方改比较合适,然后再去改。 结果也跟往常一样,在代码里面跳来跳去,然后找到新代码应该待的那个地方。 但是还有令人头疼的地方:

  • 新业务所需要的一些依赖的数据,从哪里获取?如何使用?这个问题还好解决,遵循原来的逻辑,可以在 class 里面定义一个字段,在构造函数里面初始化,然后在需要的地方使用。
  • 旧的代码里面包含了大量的 hash map(也就是关联数组),大部分时候,用起来还好。但是,导致的结果是,后续维护的时候,如果不写清楚注释,无法得知这个变量里面装了什么字段,只能通过调试的方式打印出来。这样的代码,不仅不好维护,而且也不好理解。

当然,我们也可以选择忍着头疼,继续往下写,但是如果新加入的代码出 bug 了,我们也得忍着头疼去找 bug,显然不是一个好的选择。

为什么选择重构?

对于这块遗留代码(legacy code),为了后续不那么头疼,最终选择了重构。当然也不是一时兴起,主要是因为时机成熟了:

  • 修改旧代码的成本已经高过重构然后再修改的成本了(包括理解旧代码的成本、修改成本、后续维护的成本)。
  • 这块业务本身并不复杂,只是代码写得复杂了,重构后会更好维护,会有更加清晰的结构。就算出现 bug,也能快速定位以及修复。
  • 这一两年,对于如何写好这类代码有了一些新的认识,可以尝试将这些新的认识应用到这块代码中(此前很多次有过重构的念头,但是一直都没有什么好的办法)。

如何重构及为何

原代码有太多可圈可点的地方,但是本文只讲述个人觉得最重要的地方(嗯,也就是 "主要矛盾"):

  1. 如何得到一个良好的抽象(可以简单地理解为类),以及这个抽象应该包含什么属性、能力(方法)?
  2. 如何组织这些抽象概念,使得代码更加清晰?

个人以为,处理好了这两个问题,大概率可以得到相对比较好维护的代码。

什么是抽象?

在开始这一小节之前,有必要讲一下抽象。关于抽象的定义,百度百科中是这样说的:抽象是从众多的事物中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程。

这么说可能有些抽象,举个不那么恰当的例子,我们可以对比一下下面两个图:

第一个图是一个具象的图,经常会有人拿这个表情来作为调侃。然后当我们见多了之后,有人直接发图二了,虽然上面只有几根线条,但是在特定场景下,我们看这几根线条就能知道对方想表达什么了。

从图一到图二的过程,我们可以理解为抽象的过程。这个过程中,抽取了其中最关键的特征,舍弃了其他的特征。

在实际的开发中,其实我们代码中的那些对象也都是一个个抽象出来的概念,不可能涵盖一个真实对象的所有特性,比如 Sku 对象,用来表示商品的时候,我们只会给它加上我们实际业务所需要的那些特性,比如颜色、价格等等。

定义虽然这么说,但落实到代码中的时候,并不需要抽取那么多共同的、本质性的特征,我们只需要业务处理所需要的那些特征。

又或者,在我们数据库模型的基础上,根据实际业务引申出一些新的抽象,比如员工,可以引申出经理、普通员工等等。这其实是在原有抽象的基础上,添加一些新的特性,从而产生新的抽象。 在实际开发中,可能后者对我们来说作用更大,很多时候我们都是在处理各种模型之间的关系,而不是处理单一的对象。

我们的数据库模型是根据业务流程所需要的各种数据根据其不同种类、属性、特性拆分开了的,为了更好地处理我们的业务逻辑,我们可以在业务代码中再将一些有关联的数据组织起来,形成一个新的抽象,从而更好地实现复用。

如何得到一个良好的抽象?

我们可能习惯了将所有的业务逻辑丢到 service 里面去实现,似乎 service 是个万能的东西,这样造成的结果是,我们会丢失很多业务上的语义,丢失业务场景, 带来最大的麻烦是,后续维护的人理解起来会非常费劲,需要通过人脑将那些割裂的信息重新组织起来,这是一个非常费劲的过程,往往会造成一些理解上的偏差。 当然,一些简单的 CURD 并不在本文的讨论范围之内。

面向对象分析(OOA)告诉我们,我们可以通过分析业务逻辑,找到其中的对象,然后将这些对象抽象出来,形成一个类,通过这个步骤,我们得到了数据库表、ORM 模型。 在此基础上,针对不同的业务场景,我们也可以衍生出不同的类(这是本文主要讲述的东西),一个更加契合我们实际业务场景的类。

简单来说,我们可以通过下面这几个步骤来得到这么的一个类:

  1. 为新的类命名:命名的规则可以是 业务场景 + 实际要操作的对象
  2. 为这个新的类加上需要处理的业务场景所必须的属性
  3. 为这个新的类加上需要处理的业务场景所必须的方法

下面举一个比较实际的例子,比如,假设我们需要组合一些信息来计算用户在商城实际购买商品的价格,我们有如下几个模型:

  1. Sku:包含商品本身的的一些基本信息。
  2. User:包含用户的一些基本信息,其中包含了用户的等级。
  3. SkuLevelPrice:记录了 Sku 对应的不同等级的价格。
  4. SkuUserPrice:记录了针对单个用户设置的价格。

我们遵循一下上面说到的三个步骤,实践一下:

  1. 命名:PriceSku。我们的业务场景是计算价格,操作额度对象是 Sku,这个命名也许不太好,但 “又不是不能用”。不管怎样,起码反映了业务和要操作的对象,不算太糟糕,另外主要是,我也没有想到更好的命名(但是很多时候,对于命名的这种考量是非常有意义的)。
  2. PriceSku 添加几个属性:SkuUserSkuLevelPriceSkuUserPrice。这些属性是我们计算价格所必须的,也是我们的业务场景所必须的。
  3. PriceSku 添加一个个方法:getPrice。这个方法的作用是计算用户在商城实际购买商品的价格。

最终的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class PriceSku:
// 属性定义
sku: Sku // 商品对象
user: User // 用户对象
skuLevelPrice: SkuLevelPrice // 商品等级价格对象
skuUserPrice: SkuUserPrice // 用户特定价格对象

// 构造函数
constructor(sku, user, skuLevelPrice, skuUserPrice):
this.sku = sku
this.user = user
this.skuLevelPrice = skuLevelPrice
this.skuUserPrice = skuUserPrice

// 方法定义
method getPrice():
// 检查是否有针对单个用户的价格
if this.skuUserPrice.isPriceSetForUser(this.user.id):
userPrice = this.skuUserPrice.getPriceForUser(this.user.id)
return userPrice // 返回用户特定价格

// 如果没有用户特定价格,则根据用户等级获取价格
if this.skuLevelPrice.hasPriceForLevel(this.user.level):
return this.skuLevelPrice.getPriceForLevel(this.user.level) // 返回等级价格

// 如果都没有,返回默认价格(可以根据需求定义)
return this.sku.getDefaultPrice() // 返回默认价格

在这个例子中,我们将计算价格的逻辑抽象出来,形成了一个新的类 PriceSku,这个类包含了我们计算价格所必须的属性和方法,这样我们就可以在其他地方直接使用这个类,而不用再去关心这个类的内部实现。 同时,如果我们需要修改计算价格的逻辑,只需要修改这个类的 getPrice 方法即可,在主流程中加一个 if-else 语句。

看到这个,可能有人会条件反射般地想到这可以用策略模式(有不少文章经常吹嘘要消灭 if-else,各种标题党)。确实,这种场景是可以用策略模式的,但这应该发生在 PriceSku 变得越来越庞大的时候,而不是一开始就用策略模式。 一上来就用策略模式,可能会让代码变得更加复杂,不利于维护。如果我们担心代码在后续业务复杂之后变得难以维护,另外一个选择是想办法让目前的代码写得更简洁一点,更容易理解一点, 而不是在业务变得复杂之前,先把代码写复杂了,那样就南辕北辙了。

如何组织这些抽象概念?

通过上一步,我们得到了一个可用的类了,但是在我们开始使用的时候,我们可能会发现,好像还是不太好用。 因为在实际的业务场景中,我们很多时候都是需要做一些批量的查询,比如,用户查看一个商品列表的时候,我们需要查询一组商品的价格,而不只是一个商品的价格。

在这种场景下,我们能想到的一个很简单的办法是,写一个循环来逐个获取,这样也是可行的。只是存在一个问题是,如果很多地方都需要做批量的处理,会有比较多冗余的代码。 但是这对我们来说应该毫无难度,我们可能会选择在某个地方定义一个批量查询的方法,不管是什么地方,总会有一个地方,service 也好、controller 也好。

然后某一天,有一个新的需求,也是跟价格相关的,我们需要改造一下 PriceSku,让它只返回某个商品的会员等级价,同样的,我们也需要做批量的查询。 然后也难不住我们,在 PriceSku 里面添加一个获取会员等级价的方法,然后在某个地方再写一个批量查询的方法。到目前为止还没有什么问题。

但是问题还是存在的,我们似乎缺少了一个地方,这个地方作为承载所有批量操作 PriceSku 的地方。当然放 service 也能跑,又不是不能用,只是最终结果是 service 越来越膨胀,承载的职责越来越多。 在这种情况下,我们可以考虑定义一个 PriceSkuCollection 类,在里面做那些批量操作,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class PriceSkuCollection:
// 属性定义
priceSkus: List<PriceSku> // 价格对象列表

// 构造函数
constructor(priceSkus):
this.priceSkus = priceSkus

// 方法定义
method getPriceForAll():
prices = []
for priceSku in this.priceSkus:
prices.append(priceSku.getPrice())
return prices

// 只获取会员等级价
method getLevelPriceForAll():
levelPrices = []
for priceSku in this.priceSkus:
levelPrices.append(priceSku.getLevelPrice())
return levelPrices

通过这种方式,我们可以有如下几个好处:

  1. 内聚性更强:PriceSkuCollection 只负责批量操作 PriceSku,不会有其他的职责。
  2. 代码更加清晰:PriceSkuCollection 里面的方法都是批量操作 PriceSku 的方法。
  3. 同时可以在此基础上定义一些获取元数据的方法,当然,在我们这个例子中,并没有什么元数据可供我们获取。但是如果某一组 PriceSku 本身是一组有实际业务意义的数据,那么我们这种定义方式就有非常大的优势。

比如,如果我们的一组 PriceSku 代表了用户加入到采购车的所有商品,那么我们可以在 PriceSkuCollection 里面定义一个 getTotalPrice 方法,用来计算这一组 PriceSku 的总价格。 当然,在实际业务场景中,我们需要获取的元数据不仅仅是价格,还有很多其他的数据,比如数量等等,这时候我们就可以在 PriceSkuCollection 里面定义一些方法来获取这些数据。 这个时候,PriceSkuCollection 就变成了一个非常实用的类,可以用来处理一组 PriceSku 的数据。

好了,说了这么多,我想表达的是,在建立起对业务的抽象的时候,我们还得考虑一下抽象的层次性,本文的例子中,我们通过 PriceSkuPriceSkuCollection 两个类,将业务抽象分成了两个层次,这样可以更好地组织我们的代码。 其中,PriceSku 用来处理单个 Sku 的价格计算,PriceSkuCollection 用来处理一组 Sku 的价格计算,职责非常明确,代码也更加清晰。

有时候业务逻辑可能并没有那么复杂,但是因为抽象的层次混乱、或者完全没有抽象的层次,各种不同抽象层次的代码混杂在一起,从而使得业务看起来非常复杂。 业务的复杂是不可避免的,任何一个项目只要在发展,都会越来越复杂,合理组织代码,将代码分成不同的抽象层次,是我们应对复杂业务的一个重要手段。

网络通信已经非常复杂了,但是网络通信的最底层也不过是网络中传输的二进制数据,但是很多年以前就形成了 TCP/IP 协议,这个协议就是一个非常好的抽象,将网络通信分成了不同的层次,每一层都有自己的职责, 每一层只处理一件事情,下层为上层提供服务,上层调用下层的服务,这样就形成了一个网络通信的协议栈。 而 HTTP 是对 TCP/IP 协议的进一步抽象,将网络通信分成了请求和响应,有了 HTTP,我们开发网络应用就不用自己去处理 TCP/IP 协议了,这样我们就可以更加专注于业务逻辑的处理,而不用去处理网络通信的细节。

同样的,识别出我们业务逻辑中的抽象,然后将这些抽象分成不同的层次,是我们应对复杂业务的一个重要手段。时间长了,新的业务开发的时候,成本更低,因为不再需要理解那些没有抽象的代码,我们需要处理的是一个个具有具体业务意义的抽象。

总结

本文前面的三分之一是在讲述很多人都会面临的一些困境,如果你也有类似的困境,也许可以参考一下本文的一些思路。 总的来说,在基本的数据库表的 ORM 模型基础上,我们可以做更多的抽象,我们可以对需要复用的业务逻辑进行抽象,然后将这些抽象分成不同的层次, 在不同层次的抽象中,处理其对应层次的业务逻辑,然后为更上一层抽象提供服务。

让 LLM 自动选择不同的 Prompt

在上一篇文章中,我们学会了如何让 langchain 来自动选择不同的 LLM Chain,以便回答不同的问题,只需要使用 RouterChainMultiPromptChain 就可以实现这一功能。

MultiPromptChain 被设计出来并不只是为了实现不同 LLM Chain 的选择,我们还能用它来实现让 LLM 选择不同的 Prompt,原理跟 RouterChain 差不多,只不过选择的是 Prompt 而不是 LLM Chain。 也就是说,其实另外一种场景是:使用相同的大语言模型,只是让它选择不同的 Prompt 来回答问题。

例子

下面是一个例子,我们使用 MultiPromptChain 来让 LLM 自动选择不同的 Prompt 来回答问题:

  • 当我们问关于 Python 编程的问题时,LLM 会选择 Python 的 Prompt 来回答。
  • 当我们问关于 Golang 编程的问题时,LLM 会选择 Golang 的 Prompt 来回答。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from langchain.chains.router import MultiPromptChain
from langchain_openai import ChatOpenAI

py_template = """
你是一名 Python 工程师,擅长解答关于 Python 编程的问题。
下面是需要你来回答的问题:
{input}
"""

go_template = """
你是一名 Golang 工程师,擅长解答关于 Golang 编程的问题。
下面是需要你来回答的问题:
{input}
"""

prompt_infos = [
{
"name": "python",
"description": "适合回答关于 Python 编程的问题",
"prompt_template": py_template,
},
{
"name": "golang",
"description": "适合回答关于 Golang 编程的问题",
"prompt_template": go_template,
}
]

chain = MultiPromptChain.from_prompts(
llm=ChatOpenAI(model="gpt-3.5-turbo", temperature=0),
prompt_infos=prompt_infos,
verbose=True
)

print(chain.invoke({"input": "如何在 Python 中定义一个函数?"}))

原理

既然涉及到自动选择不同的 Prompt 的操作,其实底层还是使用了 RouterChain,如果我们去看 from_prompts 代码,发现跟前一篇文章使用的是相同的 Prompt, 也就是 MULTI_PROMPT_ROUTER_TEMPLATE

  1. 构建一个 router_prompt,使用 MULTI_PROMPT_ROUTER_TEMPLATE 模板,将所有 Prompt 的信息传入。
  2. 使用 RouterChain 构建一个 RouterChain,并将 router_prompt 传入。
  3. 构建 destination_chains,这一步会为不同的 Prompt 创建一个 LLMChain
  4. 创建一个 default_chain,这个链会在没有匹配到任何 Prompt 时触发。
  5. 创建一个 MultiPromptChain 实例,将 RouterChaindefault_chain 传入。

实际调用 chain.invoke 的时候,会经历如下过程:

  1. RouterChainPrompt(格式化之后的,带有我们的 Prompt 简易描述)传递给 LLM,让 LLM 选择一个 LLMChain 来处理。
  2. LLM 会根据输入的 Prompt 选择一个 LLMChain,然后调用这个 LLMChain (对应某个具体的 Prompt,也就是上面 prompt_infos 中的一个)来处理输入。
  3. 如果没有匹配到任何 Prompt,则会调用 default_chain 来处理输入。
  4. 再次调用 LLM,让 LLM 回答用户的问题,最终,我们会得到一个回答。

自动选择 Prompt 的 Prompt

我们可以在 LangSmith 中看到实际发送给 LLM 选择 Prompt 的 Prompt 是怎样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Given a raw text input to a language model select the model prompt best suited for the input. 
You will be given the names of the available prompts and a description of what the prompt is
best suited for. You may also revise the original input if you think that revising it will
ultimately lead to a better response from the language model.

<< FORMATTING >>
Return a markdown code snippet with a JSON object formatted to look like:
```json
{
"destination": string \ name of the prompt to use or "DEFAULT"
"next_inputs": string \ a potentially modified version of the original input
}
```

REMEMBER: "destination" MUST be one of the candidate prompt names specified below OR it
can be "DEFAULT" if the input is not well suited for any of the candidate prompts.
REMEMBER: "next_inputs" can just be the original input if you don't think any modifications are needed.

<< CANDIDATE PROMPTS >>
python: 适合回答关于 Python 编程的问题
golang: 适合回答关于 Golang 编程的问题

<< INPUT >>
如何在 Python 中定义一个函数?

<< OUTPUT (must include ```json at the start of the response) >>
<< OUTPUT (must end with ```) >>

说明:

  1. 先是一个简单的引导语句,告诉模型你将给它一个输入,它需要根据这个输入选择最适合的模型。
  2. 指定输出的格式,告诉模型输出应该是一个 JSON 对象。
  3. 一些关于输出的额外说明,比如如果没有匹配到任何 Prompt,则应该返回 DEFAULT
  4. 接着是所有可选的 Prompt,以及它们的描述。
  5. 最后是用户输入的问题。

LLM 在拿到这个 Prompt 之后会进行分析推理,然后选择一个最适合的 Prompt,然后返回给我们。 当然拿到选择的具体的 Prompt 之后,并不是拿到了最终的答案,接着,使用选中的 Prompt 以及用户的问题再次调用 LLM,最终得到一个回答。

总结

MultiPromptChain 是对 RouterChain 的一个扩展,它可以让 LLM 选择不同的 Prompt 来回答问题,这样我们可以更灵活地使用不同的 Prompt 来回答问题。 而 RouterChain 是可以自动选择不同的大模型来回答问题。也就是说:

  • 如果我们只是想让 LLM 选择不同的 Prompt 来回答问题,可以使用 MultiPromptChain
  • 如果我们想让 LLM 选择不同的大模型来回答问题,可以使用 RouterChain 结合 MultiPromptChain 来实现。

自动选择不同的大模型

在先前的文章中,我们学会了可以让 Agent 自动选择不同的工具来处理不同的问题。 在现实场景中,我们可能还会面临另外一种场景是,使用不同的大模型来处理用户的问题, 比如根据用户输入的不同问题选择使用 OpenAI 或者是本地部署的大模型。

RouterChain

为了解决这个问题,langchain 引入了 RouterChain,它是一个可以自动选择不同大模型(实际上是 chain)的工具。

比如我们有两个大模型,一个是 OpenAI 的 GPT-3.5,擅长解答关于 Python 的问题;另一个是 OpenAI 的 gpt-4,擅长解答关于 Golang 的问题。 我们可以根据用户的输入来选择是使用 GPT-3.5 还是 GPT-4 来回答用户的问题。

比如:

  1. 输入:“python 如何写入文件”,那么选择的应该是 GPT-3.5。
  2. 输入:“Golang 中如何启动协程”,那么选择的应该是 GPT-4。

整体框架

RouterChain,也叫路由链,能动态选择用于给定输入的下一个链。我们会根据用户的问题内容,首先使用路由链确定问题更适合哪个处理模板, 然后将问题发送到该处理模板进行回答。如果问题不适合任何已定义的处理模板,它会被发送到默认链。

在这里,我们会使用 LLMRouterChainMultiPromptChain(也是一种路由链)组合实现路由功能, 该 MultiPromptChain 会调用 LLMRouterChain 选择与给定问题最相关的提示,然后使用该提示回答问题。

具体步骤如下:

  1. 构建处理模板:为 “解答 python 问题” 和 “解答 Golang 问题” 分别构建一个目标链(LLMChain),并存储在一个字典中。
  2. 构建 LLM 路由链:这是决策的核心部分。首先,它根据提示信息构建了一个路由模板,然后使用这个模板创建了一个 LLMRouterChain
  3. 构建默认链:如果输入不适合任何已定义的处理模板,这个默认链会被触发。
  4. 构建多提示链:使用 MultiPromptChainLLMRouterChain 和默认链组合在一起,形成一个完整的决策系统。

具体实现

先定义两个 LLMChain

下面是两个 LLMChain 的定义,一个是用于回答 Python 问题的,另一个是用于回答 Golang 问题的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from langchain.chains.llm import LLMChain
from langchain_community.llms import OpenAI
from langchain_core.prompts import PromptTemplate

# 创建一个 GPT-3.5 的 LLMChain
def create_py_chain() -> LLMChain:
prompt_template = """
你是一名 Python 工程师,擅长解答关于 Python 编程的问题。
下面是需要你来回答的问题:
{input}
"""
prompt = PromptTemplate(
template=prompt_template,
input_variables=["input"],
)
llm = OpenAI()
return LLMChain(llm=llm, prompt=prompt, verbose=True)

# 创建一个 GPT-4 的 LLMChain
def create_go_chain() -> LLMChain:
prompt_template = """
你是一名 Golang 工程师,擅长解答关于 Golang 编程的问题。
下面是需要你来回答的问题:
{input}
"""
prompt = PromptTemplate(
template=prompt_template,
input_variables=["input"],
)
llm = OpenAI()
return LLMChain(llm=llm, prompt=prompt, verbose=True)

# 创建两个 LLMChain
chain_map = {
"Python": create_py_chain(),
"Golang": create_go_chain(),
}

定义一个 RouterChain

RouterChain 是一个可以自动选择不同大模型(实际上是 chain)的工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from langchain.chains.router.llm_router import LLMRouterChain, RouterOutputParser
from langchain.chains.router.multi_prompt_prompt import MULTI_PROMPT_ROUTER_TEMPLATE

destinations = [
"Python: 适合回答关于 Python 编程的问题",
"Golang: 适合回答关于 Golang 编程的问题",
]
router_template = MULTI_PROMPT_ROUTER_TEMPLATE.format(destinations="\n".join(destinations))
router_prompt = PromptTemplate(
template=router_template,
input_variables=["input"],
output_parser=RouterOutputParser(),
)

router_chain = LLMRouterChain.from_llm(
llm=OpenAI(),
prompt=router_prompt,
verbose=True
)

这其实是本文关键的地方,router_prompt 实际上是一个 Prompt

  • 其中 input_variables 是输入的变量,这里只有一个 input
  • output_parser 是输出解析器,这里使用了 RouterOutputParser
  • template 是一个模板,用于生成提示。

简而言之,这个 RouterChain 允许你将用户的输入送入路由器,然后路由器会决定将该输入发送到哪个具体的模型,或者是否需要对输入进行修订以获得最佳的响应。

定义一个默认 chain

如果输入不适合任何已定义的 chain,这个默认 chain 会被使用。

1
default_chain = ConversationChain(llm=OpenAI(), output_key="text", verbose=True)

定义一个 MultiPromptChain

MultiPromptChain 根据用户输入尝试选择一个 destination_chains 中的 chain 来处理问题。 如果没有找到合适的 chain,会使用 default_chain 来处理问题。

1
2
3
4
5
6
7
from langchain.chains.router import MultiPromptChain
chain = MultiPromptChain(
router_chain=router_chain,
default_chain=default_chain,
destination_chains=chain_map,
verbose=True
)

MultiPromptChain 有三个关键元素:

  1. router_chain(类型 RouterChain):这是用于决定目标链和其输入的链。当给定某个输入时,这个 router_chain 决定哪一个 destination_chain 会被使用,以及传给它的具体输入是什么。
  2. destination_chains(类型 Mapping[str, LLMChain]):这是一个映射,将名称映射到可以将输入路由到的候选链。例如,你可能有多种处理文本数据的方法(或 “链”),每种方法针对特定类型的问题。
  3. default_chain(类型 LLMChain):当 router_chain 无法将输入映射到 destination_chains 中的任何链时,LLMChain 将使用此默认链。

它的工作流程如下:

  1. 输入首先传递给 router_chain
  2. router_chain 根据某些标准或逻辑决定应该使用哪一个 destination_chain
  3. 输入随后被路由到选定的 destination_chain,该链进行处理并返回结果。
  4. 如果 router_chain 不能决定正确的 destination_chain,则输入将被传递到 default_chain

这样,MultiPromptChain 就为我们提供了一个在多个处理链之间动态路由输入的机制,以得到最相关或最优的输出。

调用

下面是一个调用的例子:

1
print(chain.invoke({"input": "如何在 Python 中定义一个函数?"}))

这会使用 Python 的 LLMChain 来回答这个问题。

我们会看到有类似如下的输出:

1
2
3
4
5
Prompt after formatting:

你是一名 Python 工程师,擅长解答关于 Python 编程的问题。
下面是需要你来回答的问题:
如何在 Python 中定义一个函数?

原理

本质上,其实是在 RouterChain 中定义了一个 Prompt,让 LLM 来判断分析使用哪一个 destination_chain

我们可以打印一下看看:

1
print(router_prompt.format(input="如何在 Python 中定义一个函数?"))

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Given a raw text input to a language model select the model prompt best suited for the input. 
You will be given the names of the available prompts and a description of what
the prompt is best suited for. You may also revise the original input if you
think that revising it will ultimately lead to a better response from the language model.

<< FORMATTING >>
Return a markdown code snippet with a JSON object formatted to look like:
```json
{
"destination": string \ name of the prompt to use or "DEFAULT"
"next_inputs": string \ a potentially modified version of the original input
}
```

REMEMBER: "destination" MUST be one of the candidate prompt names specified below OR it
can be "DEFAULT" if the input is not well suited for any of the candidate prompts.
REMEMBER: "next_inputs" can just be the original input if you don't think any modifications are needed.

<< CANDIDATE PROMPTS >>
Python: 适合回答关于 Python 编程的问题
Golang: 适合回答关于 Golang 编程的问题

<< INPUT >>
如何在 Python 中定义一个函数?

<< OUTPUT (must include ```json at the start of the response) >>
<< OUTPUT (must end with ```) >>

这个 Prompt 包含了如下内容:

  1. 先是一个简单的引导语句,告诉模型你将给它一个输入,它需要根据这个输入选择最适合的模型。
  2. 进一步提醒模型,它将获得各种模型提示的名称和描述。同时告诉模型,还有一个可选的步骤,它可以更改原始输入,以便最终获得更好的响应。
  3. 接下来是格式说明:指导模型如何格式化其输出,使其以特定的方式返回结果:表示模型的输出应该是一个 Markdown 格式,其中包含一个 JSON 对象。然后指定了 JSON 的格式。
  4. 额外的说明和要求:告诉模型,如果输入不适合任何候选提示,则应该返回 DEFAULT
  5. 候选提示:列出了所有可用的模型及其描述。
  6. 输入:给出了一个示例输入。

这个模板的目的是让模型知道如何处理用户的输入,并根据提供的提示列表选择一个最佳的模型提示来回应。

总结

RouterChain 是一个可以自动选择不同大模型(实际上是 chain)的工具,可以根据用户的输入来选择使用不同的大模型来回答用户的问题。

0%