如何基于大模型开发应用接口

872 阅读11分钟

“本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!”

一、前言

针对自然语言处理方向,以前要开发一个情感分析、命名实体识别之列的应用是非常麻烦的,我们需要完成收集数据、清理数据、构建模型、训练模型、调优模型等一系列操作,每一步都非常耗时。如今大语言模型(LLM)的出现,极大简化了其中一些任务。LLM像一个全能的自然语言机器,通过一些简单调整,就可以实现一些特定任务。

这些特定任务不只限于自然语言的基本任务,我们还可以实现更复杂的应用。今天我们就来详细讨论一下。

二、大语言模型

2.1 大语言模型的能力

相信大家都知道了LLM是怎么工作的,也是就玩文字接龙游戏。经过预训练和RLFH后,LLM具备了非常强大的自然语言理解能力和指令遵循能力。

我们可以看几个实际例子,现在假设我们是2020年的人类,ChatGPT还未出现,站在这个视角下,我们才能明白LLM的强大。

  1. 多轮对话

首先是多轮对话能力,早期要实现多轮对话需要人类的许多干预,而ChatGPT则可以一步到位实现多轮对话:

You:你好啊
ChatGPT:你好!有什么我可以帮你的吗?
You:我上一句说了啥
ChatGPT:你说了"你好啊",我回复了"你好!有什么我可以帮你的吗?"

那为什么可以呢?因为LLM的训练方式是文字接龙,如果我们的训练数据中本身就有对话数据,那LLM能实现对话功能也不足为奇。而多轮对话则是每次把历史对话也作为输入,因为LLM已经用多轮对话的数据训练了,那他自然可以实现多轮对话。比如: 第一轮输入:

You:你好啊

第二轮输入:

You:你好啊
ChatGPT:你好!有什么我可以帮你的吗?
You:我上一句说了啥
  1. Zero-Shot

Zero-Shot的意思是零样本,就是在没有不需要额外数据的情况下,完成某个任务。以情感分析为例,我们可以用下面的方式和LLM对话:

You:“我很喜欢这部电影”包含的情感是:
ChatGPT:积极

在LLM预训练阶段,如果我们用下面模板生成数据交给LLM训练:

“{{ sentence }}”包含的情感是:{{ label }}

那么上面的结果也就不奇怪了。

  1. 代码能力

起初我认为代码生成工具无非就是查数据库,而实际体验后,却发现工具真的对代码有自己的理解。比如我们可以让工具写出Sleep排序算法的代码,而这个排序算法实际并不存在。我们只需要描述Sleep排序的逻辑即可。

而现在LLM的代码能力包括如下几个:

  • 根据注释生成代码
  • 输入代码,让其纠错
  • 输入代码,让其简写或优化
  • 输入代码,让其修改为其它语言版本

在2020年的我们面前,上面这些功能都不是近十年可以实现的,但是第二年就已经可以做到了。

  1. 图形理解

LLM有一个惊人的能力,就是字符图形理解能力。这里我们不讨论VisionLLM。比如我们可以和LLM下棋:

You:我们来玩一个游戏,在3x3的网格里面,谁先连成连续三个谁赢。我先手:
x | |
 | |
 | |
 
ChatGPT:好的,我也选择一个空格:
x | |
 |o|
 | |

而原因的话,可能是某群无聊的人正好使用上面的方式玩游戏,而这些数据正好被OpenAI爬取了。

2.2 结构化和非结构化

LLM还有需要其它能力,这里不再列举,我们来讨论一下将LLM接入应用的前提。

对于程序员来说,结构化数据是非常有用的。我们编写各种api接口返回的数据都遵循一定格式,这种我们称为结构化数据。比如天气接口,下面是一种可能的形式:

{
    "city": "广州",
    "temperature": "34"
}

当前端程序员拿到这个接口后,可以很容易将数据展现出来。

现在我们不再固定形式,而是用下面方式返回:

广州今天的温度是34

或:

今天广州34°

等等。这种数据我们称为非结构化数据,如果我们返回的输入是上面的形式,那么前端确定不了怎么展示。

在非结构化数据中,我们包含了结构化数据里的信息,但是组织方式不一样。或许可以通过硬编码,编写一堆if else把city和temperature提取出来,然后把非结构化数据转换成结构化数据,但是这样太麻烦了,而且实际情况要更为复杂。

如果现在有一个工具,可以将非结构化数据转换成结构化数据,那事情就简单了,而LLM就是这样一个工具。

我们可以用下面的方式和LLM对话:

You:帮我把:“广州今天的温度是34”转换成json数据,包含city和temperature两个键。只返回json内容。
ChatGPT:{
  "city": "广州",
  "temperature": 34
}

我们换一句话还是可以得到正确结果:

You:帮我把:“今天广州34°”转换成json数据,包含city和temperature两个键。只返回json内容。
ChatGPT:{
  "city": "广州",
  "temperature": 34
}

现在有了这个工具,我们就可以实现各种应用了。

三、LangChain

LangChain是一个用来创建LLM应用的模块,我们可以使用LangChain创建各种Chain,完成各式各样的任务。

3.1 Chain的主要部分

我们要组建一个Chain,需要知道几个主要模块。Chain的最简单组合就是单纯一个LLM,它可以接收文本输入,输出文本。

而在上面的内容中,我们发现了一些特点,即输入内容有一部分是固定的。比如在情感分析部分,我们的输入可以提炼成:

“{{ sentence }}”包含的情感是:

其中sentence是要替换的内容。在非结构化转结构化部分,我们的输入可以提炼成:

帮我把:“{{ sentence }}”转换成json数据,包含city和temperature两个键。只返回json内容。

这种提炼出来的部分,我们就叫做PromptTemplate,或者叫Prompt。这样就引出了Chain的第二个部分,Prompt。

在非结构化转结构化部分,我们得到的结构都是非常标准的json。但是实际情况并没有这么理想,可能会出现下面这些情况:

多余的字符:
```{
    ....具体内容
}```

多余的解释:
好的,下面是转换后的结果
{
    ....具体内容
}

错误的字段

总之并不是每一次结果都如我们预想。这个这么解决呢?最简单的办法就是拿到输出后,我们用正则匹配,拿到最终结果。而对数据结果再次操作,提取内容的这部分叫做OutputParser。

这样Chain的三个主要部分都出来了:Prompt、LLM、OutputParser。

3.2 Prompt

下面就开始实战吧。我们先安装模块:

pip install langchian
pip install langchain_community
pip install langchain_openai

构建Prompt主要有PromptTemplate和ChatPromptTemplate两个类,前者针对基本的LLM,后者则针对ChatModel。这里我们使用ChatPromptTemplate。

在LLM中,通常有三种消息,分别是:system、assistant、human。其中其中system设定该模型在当前整个会话中的情况。比如我们希望当前模型是一个心理医生,我们就可以把system设定为:“你是一个心理医生...”。

在代码上,我们使用ChatPromptTemplate.from_messages方法完成,代码如下:

from langchain_core.prompts import ChatPromptTemplate  
  
prompt = ChatPromptTemplate.from_messages([      ("system", "你是一个心理医生..."),      ("human", "心情不好...")  ])

如果我们向构造一个情感分析的应用,那么我们可以把prompt改成:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个情感分析程序,不管用户输入什么,你都返回积极或消极。"),
    ("human", "{sentence}")
])
print(prompt.invoke({'sentence': '心情不好'}))

在代码里面,我们添加了{sentence}用来占位,然后我们调用prompt.invoke({'sentence', '心情不好'}),可以把prompt里面的sentence标记,替换成后面的内容,运行后输入如下:

messages=[SystemMessage(content='你是一个情感分析程序,不管用户输入什么,你都返回积极或消极。'), HumanMessage(content='心情不好')]

human的消息被替换成了字典中的内容。

3.3 LLM

接下来是LLM,在前面提到两类Prompt,模型也有两类。我们用ChatPromptTemplate构建prompt,所以我们使用ChatModel。

国内比较方便访问GPT的方式之一是Azure,在LangChain里面提供了对应的实现,我们可以通过下面的方式创建一个ChatModel:

from langchain_openai import AzureChatOpenAI

deployment_name = "gpt-35-turbo-16k"
llm = AzureChatOpenAI(
    azure_endpoint=endpoint,
    deployment_name=deployment_name,
    openai_api_key=api_key,
    openai_api_version="2024-02-01",
)
print(llm.invoke('你好啊'))

这里使用AzureChatOpenAI,填写对应的参数创建llm。这里同样调用invoke方法进行推理,输出结果如下:

content='你好!有什么我可以帮助你的吗?'

现在可以把prompt和llm结合起来,只需要分别执行prompt的invoke和llm的invoke即可:

from langchain_openai import AzureChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

deployment_name = "gpt-35-turbo-16k"
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个情感分析程序,不管用户输入什么,你都返回积极或消极。"),
    ("human", "{sentence}")
])
llm = AzureChatOpenAI(
    azure_endpoint=endpoint,
    deployment_name=deployment_name,
    openai_api_key=api_key,
    openai_api_version="2024-02-01",
)

print(llm.invoke(
    prompt.invoke({'sentence': '心情不好'})
))

输出结果如下:

content='消极'

现在看上去一切都很顺利,但是并不是一直这么顺利。

从上面的结果,我们可以很自然地觉得,模型上面的Chain只会返回“积极”或“消极”,但是某天如果Chain背刺你,给你返回一个“消极的。”或者“消极。”,那我们可能会因为这个意向不到的回复调试半天程序。

所以下面我们就要为我们的Chain添加一个OutputParser了。

3.3 OutputParser

为了从LLM的输出中解析出我们真正想要的内容,我们还需要有一个OutputParser。我们可以自己使用正则等方式来实现OutputParser,不过这里我们使用另一种简单的方式。这里还是以天气为例,首先定义一个Model:

from langchain_core.pydantic_v1 import BaseModel, Field


class Weather(BaseModel):
    city: str = Field(default=None, description="城市")
    temperature: str = Field(default=None, description="温度")

在Model里面,定义了数据的格式,然后我们只需要使用下面一句,就可以给llm添加一个OutputParser:

chain = llm.with_structured_output(schema=Weather)

下面我们来测试一下:

print(chain.invoke('你好啊'))

这里我们用了一个离谱的例子,给chain发送“你好啊”,而且没有像前面Prompt一样添加一些限制,那代码能顺利运行吗?下面是输出结果:

city='北京' temperature=None

没有报错,而且正确输出了一部分内容。由此可以知道,上述方式并不是简单的正则匹配。如果查看源码可以发现,其实是使用了function_calling和json_mode:

@beta()
def with_structured_output(
    self,
    schema: Optional[_DictOrPydanticClass] = None,
    *,
    method: Literal["function_calling", "json_mode"] = "function_calling",
    include_raw: bool = False,
    **kwargs: Any,
) -> Runnable[LanguageModelInput, _DictOrPydantic]:

另外这个功能目前是beta状态,使用时需要注意。

3.4 合成Chain

最后我们要做的就是组成一条Chain了,这里不需要自己一直调用invoke了,而是使用“|”组成Chain,然后调用一次invoke即可:

chain = prompt | llm.with_structured_output(schema=Weather)
chain.invoke({"input": "北京"})

四、应用接口

最后我们部署一个接口,这里使用Fastapi,代码如下:

import uvicorn
from fastapi import FastAPI
from enum import Enum
from langchain_openai import AzureChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field

from fine_tunning.configs import endpoint, api_key


class SentimentEnum(Enum):
    positive = 'positive'
    negative = 'negative'
    normal = 'normal'


class Weather(BaseModel):
    sentence: str = Field(default=None, description="句子")
    sentiment: SentimentEnum = Field(default=None, description="情感")


deployment_name = "gpt-35-turbo-16k"
prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个情感分析程序,不管用户输入什么,请返回包含sentence和sentiment两个键的json格式数据。只要json内容,不要其它内容"),
    ("human", "{sentence}")
])
llm = AzureChatOpenAI(
    azure_endpoint=endpoint,
    deployment_name=deployment_name,
    openai_api_key=api_key,
    openai_api_version="2024-02-01",
)

chain = prompt | llm.with_structured_output(schema=Weather)

app = FastAPI()


@app.get('/sentiment-analysis')
async def sentiment_analysis(sentence: str):
    return dict(chain.invoke({'sentence': sentence}))


if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8000)

运行后,我们访问:

http://localhost:8000/sentiment-analysis?sentence=这电影一般般

返回如下结果:

{"sentence":"这电影一般般","sentiment":"normal"}

这样,我们的接口就开发好了。