背景

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

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

  • 影响范围广:用户在终端看到的大多数价格都是通过这块代码计算得到的,一旦出错,影响面很大。
  • 逻辑复杂:不过这里指的是代码写得过于复杂了,其实这块业务本身并不复杂,属于比较正常的线性结构,只是流程多了几个步骤。
  • 没有办法完全理解原作者写这段代码的思路,主要由于:
    • 代码不规范:变量名、函数名、注释等等都不规范,理解成本偏高。
    • 类的层次结构混乱:没有比较明显的层次结构(比如说自顶向下、逐步细化这样去实现)。
    • 比较分散的业务逻辑数据:这里主要指的是一条数据,被拆分为多个字段,不同字段丢进不同的 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)的工具,可以根据用户的输入来选择使用不同的大模型来回答用户的问题。

在一些入门例子中,我们会发现,我们可以告诉 LLM 如何输出,然后输出的结果真的是我们想要的,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.5, max_tokens=200)

summarizing_prompt_template = """
输出为 JSON 格式,包含字段 content、summary。

总结以下文本为一个 20 字以内的句子:
---
{content}
"""
prompt = PromptTemplate.from_template(summarizing_prompt_template)

summarizing_chain = prompt | llm | StrOutputParser()

print(summarizing_chain.invoke({"content": "这是一个测试。"}))

在实际使用中,content 可能是一个很长的文本。

输出:

1
2
3
4
{
"content": "这是一个测试。",
"summary": "这是一个测试。"
}

正如某些例子上经常写的 "You are a helpful assistant",其实从某种程度上来说,我们确实可以把 LLM 看作是我们的一名得力助手。 这名助手是可以理解我们说的话并作出回应的。

因此,我们就可以告诉 LLM,我们希望输出的格式是 JSON,然后我们可以在 JSON 中定义我们希望输出的字段。

langchain 中的 JSON 输出

在上面这个例子中,其实是等于我们给了 LLM 一个指令,告诉它我们希望输出的格式是 JSON,然后我们定义了 JSON 的格式。 既然很多时候我们都想要给我们的 LLM 一个指令,那为何不把这些逻辑固定下来呢?

为了解决这个问题,langchain 的 PromptTemplate 为我们提供了指定输出的指令的通用解决方案。

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
from langchain.output_parsers import StructuredOutputParser, ResponseSchema
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
model_name="gpt-3.5-turbo",
temperature=0.5,
max_tokens=200
)

response_schemas = [
ResponseSchema(name="content", description="The original content"),
ResponseSchema(name="summary", description="The summary of the content"),
]
output_parser = StructuredOutputParser.from_response_schemas(response_schemas)
format_instructions = output_parser.get_format_instructions()

summarizing_prompt_template = """
{format_instructions}

总结以下文本为一个 20 字以内的句子:
---
{content}
"""
prompt = PromptTemplate.from_template(summarizing_prompt_template, partial_variables={'format_instructions': format_instructions})

summarizing_chain = prompt | llm | output_parser

print(summarizing_chain.invoke({"content": "这是一个测试。"}))

输出:

1
2
3
4
{
"content": "这是一个测试。",
"summary": "这是一个测试。"
}

说明:

  1. ResponseSchema 用于定义输出的字段,name 为字段名,description 为字段描述。这些信息是给 LLM 看的。LLM 会根据这些信息来输出我们想要的结果。
  2. partial_variables 用于传递部分变量给模板,剩下的变量会在调用 LLM 的时候再传递。

在上面这个例子中,我们实际传递给 LLM 的模板是:

1
2
3
4
5
6
7
8
9
10
11
12
The output should be a markdown code snippet formatted in the following schema, including the leading and trailing "```json" and "```":

```json
{
"content": string // The original content
"summary": string // The summary of the content
}
```

总结以下文本为一个 20 字以内的句子:
---
这是一个测试。

这个模板告诉 LLM,我们希望输出的格式是 JSON,然后我们定义了 JSON 的格式。

总结

在 langchain 中,我们可以通过 ResponseSchema 来定义我们希望输出的字段,然后生成一个 prompt,传递给 LLM,让 LLM 知道我们希望输出的格式是什么。

如何做好 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
from langchain.output_parsers import StructuredOutputParser, ResponseSchema
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
model_name="gpt-3.5-turbo",
temperature=0.5,
max_tokens=200
)

response_schemas = [
ResponseSchema(name="content", description="The original content"),
ResponseSchema(name="summary", description="The summary of the content"),
]

output_parser = StructuredOutputParser.from_response_schemas(response_schemas)
format_instructions = output_parser.get_format_instructions()

summarizing_prompt_template = """
{format_instructions}

总结以下文本为一个 20 字以内的句子:
---
{content}
"""
prompt = PromptTemplate.from_template(
summarizing_prompt_template,
partial_variables={'format_instructions': format_instructions}
)

summarizing_chain = prompt | llm | output_parser

print(summarizing_chain.invoke({"content": "这是一个测试。"}))

format_instructions 会生成关于如何输出的指令,这样 LLM 就知道如何输出了。

吴恩达给出的两大原则

  1. 写出清晰而具体的指示(也就是类似上面的 format_instructions
  2. 给模型思考的时间

OpenAI 官方给出的 6 大策略

  1. 写清晰的指示
  2. 给模型提供参考(也就是示例)
  3. 将复杂任务拆分成子任务
  4. 给 GPT 时间思考
  5. 使用外部工具
  6. 反复迭代问题

提示的结构

在这个提示框架中:

  1. 指令(Instruction)告诉模型这个任务大概要做什么、怎么做,比如如何使用提供的外部信息、如何处理查询以及如何构造输出。这通常是一个提示模板中比较固定的部分。一个常见用例是告诉模型 “你是一个有用的 xx 助手”,这会让他更认真地对待自己的角色。
  2. 上下文(Context)则充当模型的额外知识来源。这些信息可以手动插入到提示中,通过向量数据库检索得来,或通过其他方式(如调用 API、计算器等工具)拉入。一个常见的用例是把从向量数据库查询到的知识作为上下文传递给模型。
  3. 提示输入(Prompt Input)通常就是具体的问题或者需要大模型做的具体事情,这个部分和 “指令” 部分其实也可以合二为一。但是拆分出来成为一个独立的组件,就更加结构化,便于复用模板。这通常是作为变量,在调用模型之前传递给提示模板,以形成具体的提示。
  4. 输出指示器(Output Indicator)标记要生成的文本的开始。这就像我们小时候的数学考卷,先写一个 “解”,就代表你要开始答题了。如果生成 Python 代码,可以使用 “import” 向模型表明它必须开始编写 Python 代码(因为大多数 Python 脚本以 import 开头)。这部分在我们和 ChatGPT 对话时往往是可有可无的,当然 langchain 中的代理在构建提示模板时,经常性的会用一个 “Thought:” (思考)作为提示词,指示模型开始输出自己的推理(Reasoning)。

langchain 提示模板类型

  • PromptTemplate 这是最常用的 String 提示模板
  • ChatPromptTemplate 常用的 Chat 提示模板,用于组合各种角色的消息模板,传入聊天模型
    • ChatMessagePromptTemplate
    • HumanMessagePromptTemplate
    • AIMessagePromptTemplate
    • SystemMessagePromptTemplate
  • FewShotPromptTemplate 少样本提示模板,通过示例的展示来 “教” 模型如何回答
  • PipelinePrompt 用于把几个提示组合在一起使用
  • 自定义模板:langchain 还允许你基于其他模板来定制自己的提示模板

PromptTemplate

1
2
3
4
5
6
7
from langchain import PromptTemplate

template = """你是业务咨询顾问。你给一个销售{product}的电商公司,起一个好的名字。"""

prompt = PromptTemplate.from_template(template)

print(prompt.format(product="手机"))

这个程序的主要功能是生成适用于不同场景的提示,对用户的一种产品或服务提供公司命名建议。

另外一种创建方式:

1
2
3
4
5
6
from langchain import PromptTemplate
prompt = PromptTemplate(
template="""你是业务咨询顾问。你给一个销售{product}的电商公司,起一个好的名字。""",
input_variables=['product']
)
print(prompt.format(product="电脑"))

ChatPromptTemplate

下面代码展示了 OpenAI 的 Chat Model 中的各种消息角色:

1
2
3
4
5
6
7
8
9
10
11
12
from openai import OpenAI

client = OpenAI()

client.chat.completions.create(
model='gpt-3.5-turbo',
messages=[
{'role': 'system', 'content': 'You are a helpful assistant.'},
{'role': 'user', 'content': 'What is the capital of France?'},
{'role': 'assistant', 'content': 'The capital of France is Paris.'}
]
)

OpenAI 对传输到 gpt-3.5-turbo 和 GPT-4 的 message 格式说明如下:

消息必须是消息对象的数组,其中每个对象都有一个角色(系统、用户、助理)和内容。对话可以短至一条消息,也可以来回多次。

通常,对话首先由系统消息格式化,然后是交替的用户消息和助理消息。

系统消息有助于设置助手的行为。例如,你可以修改助手的个性或提供有关其在整个对话过程中应如何表现的具体说明。 但请注意,系统消息是可选的,并且没有系统消息的模型的行为可能类似于使用通用消息,例如 “你是一个有用的助手”。

用户消息提供助理响应的请求或评论。

助理消息存储以前的助理响应,但也可以由你编写以给出所需行为的示例。

下面是使用 ChatPromptTemplate 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from langchain.prompts import (
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate
)
template = "你是一位专业顾问,负责为专注于{product}的公司起名"
system_message_prompt = SystemMessagePromptTemplate.from_template(template)
human_template = "公司主打产品是{product_detail}"
human_message_prompt = HumanMessagePromptTemplate.from_template(human_template)

prompt_template = ChatPromptTemplate.from_messages([
system_message_prompt,
human_message_prompt
])

prompt = prompt_template.format_prompt(product="鲜花装饰", product_detail="创新的鲜花设计").to_messages()

from langchain.chat_models import ChatOpenAI

chat = ChatOpenAI()
result = chat(prompt)

print(result)

FewShot

Few-Shot(少样本)、One-Shot(单样本) 和与之对应的 Zero-Shot(零样本)的概念都起源于机器学习。如何让机器学习模型在极少量甚至没有示例的情况下学习到新的概念或类别, 对于许多现实世界的问题都是非常有价值的,因为我们往往无法获取到大量的标签化数据。

提示工程中的 FewShot

在提示工程中:

  • 在 Few-Shot 学习设置中,模型会被给予几个示例,以帮助模型理解任务,并生成正确的响应。
  • 在 Zero-Shot 学习设置中,模型只根据任务的描述生成响应,不需要任何示例。

使用 FewShotPromptTemplate

下面这个例子中,我们在样本末尾加上了 flower_typeoccasion,这样模型就可以根据这两个变量以及前面的样本生成广告文案。

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
38
39
40
41
42
43
44
45
46
47
48
49
samples = [
{
"flower_type": "玫瑰",
"occasion": "爱情",
"ad_copy": "玫瑰,浪漫的象征,是你向心爱的人表达爱意的最佳选择。"
},
{
"flower_type": "康乃馨",
"occasion": "母亲节",
"ad_copy": "康乃馨代表着母爱的纯洁与伟大,是母亲节赠送给母亲的完美礼物。"
},
{
"flower_type": "百合",
"occasion": "庆祝",
"ad_copy": "百合象征着纯洁与高雅,是你庆祝特殊时刻的理想选择。"
},
{
"flower_type": "向日葵",
"occasion": "鼓励",
"ad_copy": "向日葵代表着阳光与希望,是你鼓励朋友或家人的最佳礼物。"
}
]

from langchain.prompts.prompt import PromptTemplate
template = "鲜花类型:{flower_type}\n适用场合:{occasion}\n广告文案:{ad_copy}"
prompt_sample = PromptTemplate(
input_variables=["flower_type", "occasion", "ad_copy"],
template=template
)
print(prompt_sample.format_prompt(**samples[0]))

from langchain.prompts.few_shot import FewShotPromptTemplate
prompt = FewShotPromptTemplate(
examples=samples,
example_prompt=prompt_sample,
suffix="鲜花类型:{flower_type}\n适用场合:{occasion}",
input_variables=["flower_type", "occasion"]
)
print(prompt.format(flower_type="野玫瑰", occasion="爱情"))


from langchain.chat_models import ChatOpenAI
llm = ChatOpenAI(
model='gpt-3.5-turbo',
temperature=0.5,
max_tokens=200
)
res = llm.invoke(prompt.format(flower_type="野玫瑰", occasion="爱情"))
print(res)

选择最相似的样本

可以使用向量搜索来选择最相似的样本,这样模型就可以根据这个样本生成广告文案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from langchain.prompts.example_selector import SemanticSimilarityExampleSelector
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

example_selector = SemanticSimilarityExampleSelector.from_examples(
samples,
OpenAIEmbeddings(),
Chroma,
k=1
)
prompt = FewShotPromptTemplate(
example_selector=example_selector,
example_prompt=prompt_sample,
suffix="鲜花类型:{flower_type}\n适用场合:{occasion}",
input_variables=["flower_type", "occasion"]
)
print(prompt.format(flower_type="野玫瑰", occasion="爱情"))

总结

FewShot 其实就是给模型一些示例做参考,模型才能明白你要什么。

提供示例对于解决某些任务至关重要,通常情况下,FewShot 的方式能够显著提高模型回答的质量。 不过,当少样本提示的效果不佳时,这可能表示模型在任务上的学习不足。 在这种情况下,我们建议对模型进行微调或尝试更高级的提示技术,或者换一个模型。

0%