从Language Model到Chat Application:对话接口的设计与实现

发布于 2024-3-27 12:01
浏览
1收藏

01、前言

从2022年底chatGPT的一炮走红开始,基于大语言模型的对话应用如雨后春笋一般全面开花。刚刚过去的2023年是千帆竞发的一年,在这一年里我们见证了百模大战,开源模型社区可谓繁荣昌盛:从llama到它的无数变体、qwen系列的完整中文大模型生态构建、Mixtral等多模态的成功尝试、再到llava等视觉大语言模型的蓄势待发。在语言模型上,我们已经有了十分丰富的选择,这些模型在RTP-LLM上都得到了较好的支持,可以高效地完成推理。

在最早设计的RTP-LLM推理引擎中,我们认为llm本质都是语言模型,因此提供的只有语言模型调用方式,将所有请求简化为输入一个string,输出一个string的模式。然而,从语言模型到chat应用之间仍然有一个gap:输入prompt的拼写。text-in-text-out的设计可以简化引擎开发,但是prompt拼写的难题就被丢给了用户。实际上,对于某些模型,比如chatglm3,如果不加干预,使用text-in-text-out的模式是无法正确进行chat推理的,详细原因我们后面会展开解释。

从前端的需求侧来看,text-in-text-out模式也无法满足日益增长的chat应用需求。让每个用户都学习甚至实现一遍chat prompt的拼写既浪费人力、又会提高错误率,因此,在LLM推理引擎层面实现chat接口的需求迫在眉睫。

众所周知,openai作为最早推出chatGPT服务的开创式厂家,他们定义好的“openai接口”也是业界普遍采用的chat接口事实标准,不但定义了基础的多轮对话能力,还提供了funciton call、多模态输入等多种功能。那么要在推理引擎实现chat能力,openai接口就是最合适的格式。本文就来聊一聊,在实现openai chat接口中遇到的种种问题。

02、开源实现大赏

在介绍我们的方案之前,我们先来看一看业界的其他框架是怎么实现chat能力的。这里讨论的对象不仅限于openai接口的实现,凡是实现多轮对话能力的,都在讨论范围之内。提供chat能力的核心需求是如何将多轮对话按照模型训练时的格式渲染成模型的input id。这听起来是个很简单的事情,但是随着模型类型不断扩张,各种五花八门的实现方式要做到正确却并不容易,更不用说如果加上function call,问题就变得更加复杂。

2.1 huggingface tokenizer

hugging face将所有的LLM抽象成了text-generation pipeline,由Model和Tokenizer两部分组成。其中,tokenizer需要继承PreTrainedTokenizer类进行实现,该类提供了apply_chat_template方法,可以将多轮对话的dict转换为input id或者prompt。

具体到实现上,该方法需要tokenizer在config中配置chat_template,这个template大概长这样:

{% for message in messages %}
{{‘<|im_start|>’ + message[‘role’] + ‘\n’ + message[‘content’] + ‘<|im_end|>’ + ‘\n’}}
{% endfor %}
{% if add_generation_prompt %}
{{ ‘<|im_start|>assistant\n’ }}
{% endif %}

相信聪明的你看一眼就知道这玩意是啥语法了。有了这个模板,就可以把这样的messages

[
{“role”: “user”, “content”: “Hi there!”},
{“role”: “assistant”, “content”: “Nice to meet you!”},
{“role”: “user”, “content”: “Can I ask a question?”}
]

拼成这样的prompt:

<|im_start|>user
Hi there!<|im_end|>
<|im_start|>assistant
Nice to meet you!<|im_end|>
<|im_start|>user
Can I ask a question?<|im_end|>
如果想进一步详细了解,可以参考官方文档

https://huggingface.co/docs/transformers/main/chat_templating

这个设计看起来简单高效,非常美好。对很多模型来说,它也确实好用。那么我们马上就来看一个失败的例子。

2.2 chatglm

在chatglm3 官方repo的tokenizer config中,我们可以看到它定义了chat_template:

图片

very good, 让我们跑一下试试:

图片

不对啊,这模型根本不说人话。

那么问题出在哪里呢?我们来看看拼好的prompt:

图片

看起来像模像样。但是如果对结果id逐个进行detokenize,马上就漏出了马脚:

图片

原来,由于chatglm3 tokenizer实现的问题,诸如[gMASK]、<|user|>等特殊token,在tokenize时会被错误地分割成多个token而非一个。而chatglm3的tokenizer实际上实现了一个非标准的build_chat_input接口,能正确处理多轮对话的id渲染。

通过这个例子可以看到,chat template 不一定靠谱。

2.3 qwen

qwen系列模型并没有在tokenizer config里提供chat_template字段,使用默认模板渲染的结果不难想象当然是错的。实际上,qwen和chatglm类似,自己实现了非标准的chat接口和渲染方法make_context,逻辑仅对自己的模型生效。对于单一模型来说当然没问题,但是并不能做成通用逻辑。

这时已经不难发现,开源模型有着五花八门的prompt拼写方式。很多支持多模型的开源框架都号称提供了openai格式的chat接口,那么来看看兼容多模型的开源框架做得如何。

2.4 vllm

vllm可以说是开源推理框架界的一哥,feature list里写着支持openai接口。先翻翻代码:

...

@app.post(“/v1/chat/completions”)
async def create_chat_completion(request: ChatCompletionRequest,
raw_request: Request):
try:
prompt = tokenizer.apply_chat_template(
conversation=request.messages,
tokenize=False,
add_generation_prompt=request.add_generation_prompt)

result_generator = engine.generate(prompt, sampling_params, request_id,
token_ids)

直接就无条件信赖chat template。看起来就不太靠谱,让我们起个qwen的服务

python3 -m vllm.entrypoints.openai.api_server --model Qwen/Qwen-7B-Chat --trust-remote-code

找个前端接上试试:

图片

第一句话似乎是对的,但是显然,没有正确处理eos和stop words。约等于没法用。

vllm还提供了手动指定chat_template文件的能力,但是这样一来就对用户有一定的使用门槛,做不到开箱即用;二来没有解决tokenizer无法tokenize special token的问题。

2.5 llama.cpp

作为一个cpu first并且支持多种异构加速方式的框架,llama.cpp在开源社区的呼声也很高。

它的配套项目llama-cpp-python(https://github.com/abetlen/llama-cpp-python) 也在readme的开头就强调了自己支持openai compatible server。

again,先看看代码实现:在llama_cpp/llama_chat_format.py中定义了一个ChatFormatter类,并针对不同的模型单独写了适配,以qwen为例的话:

@register_chat_format("qwen")
def format_qwen(
    messages: List[llama_types.ChatCompletionRequestMessage],
    **kwargs: Any,
) -> ChatFormatterResponse:
    _roles = dict(user="<|im_start|>user", assistant="<|im_start|>assistant")
    system_message="You are a helpful assistant."
    system_template="<|im_start|>system\n{system_message}"
    system_message=system_template.format(system_message=system_message)
    _messages = _map_roles(messages, _roles)
    _messages.append((_roles["assistant"], None))
    _sep = "<|im_end|>"
    _prompt = _format_chatml(system_message, _messages, _sep)
    _sep2 = "<|endoftext|>"
    return ChatFormatterResponse(prompt=_prompt,stop=_sep2)

看起来像模像样,那么实际跑一下试试看。llama-cpp的运行略微麻烦,需要先转换模型为gguf模式然后运行。这里只展示一下加载命令:

/opt/conda310/bin/python -m llama_cpp.server --model /mnt/nas1/gguf/qwen-14b-chat-f16.gguf  --n_gpu_layers 128 --host 0.0.0.0 --chat_format qwen

然后接上前端:

图片

……总之是哪里不对。

看起来,主流开源推理框架提供的openai接口很难说得上能用。

2.6 llama-factory 和 fastchat

山穷水尽,峰回路转,在一次跟训练同学的交流中,发现有个做finetune的库llama-factory写的模板还不错:

https://github.com/hiyouga/LLaMA-Factory/blob/5a207bb7230789ddefba932095de83002d01c005/src/llmtuner/data/template.py
这个template的设计十分干净,没有多余依赖;对于eos、special token的处理也十分到位,并且提供了proerty可供访问,而且已经适配了主流开源模型。

另外,还有个开源框架fast chat,它也提供了一些chat prompt的渲染模板,适配的模型更多,缺点是只拼了string,无法处理tokenizer的问题。

https://github.com/lm-sys/FastChat/blob/main/fastchat/conversation.py
测试了几个模型的input id渲染结果发现均符合预期,于是决定直接拿过来用。虽然它们也不能解决所有问题,但可以省去很多模型的适配工作。

03、RTP-LLM的实现方案

了解了现状之后,我们就希望能开发一个all in one、适配主流模型、功能丰富且开箱即用的chat接口。综合整理多种模型的实现之后,我们设计了如下的缝合方案:

用户指定template类型
前文提到,我们从开源项目里抄了一些适配规则。对于这部分规则模板,可以通过环境变量MODEL_TEMPLATE_TYPE指定使用。因为其必须显示指定,并且完成度较高,而且还能解决tokenizer的问题,我们给了它最高优先级。

chat_template
如果模型的tokenizer config中带了chat_template属性,那么用它作为除了指定模板以外渲染prompt的首选依据。这样做有两个考量:

一部分开源模型,如01ai的Yi-6B/34B系列,是用了llama的模型结构+自己的chat_template。依靠chat_template属性,无需额外设置即可自动获得正确的渲染结果。

如果有用户希望自己定义chat接口的prompt拼写方式,那么chat_template也是最简单的方式,也是业界的标准做法。用户如果自己定义了拼写模板,在导出checkpoint时设置了chat_template,那么应当起效。

qwen和多模态:特殊处理
对于qwen系列模型,为了支持function,我们单独写了适配逻辑,在下一个section会详细讲解。同样,对于多模态模型,因为需要处理图片,处理逻辑更复杂,我们也单独写了渲染逻辑。这些模型

其他模型:根据model type再次查找模板
这条规则和1类似,只不过是根据model type查找模板,而不是额外指定的环境变量。这样可以完成原始版llama、baichuan等模型的支持。

保底:default chat template
如果以上的所有规则都不能找到合适的渲染方法,那么执行兜底策略,使用chatML的方式拼写prompt。

实现了以上方案后,用户在启动服务时,无需额外指定任何参数,即可自动得到一个好用的openai chat接口;同时又保留了配置能力,可以一键套用常见的开源模板,也可以满足用户自带模板的高级要求。

04、function call 的处理

4.1 基本逻辑

通过llm调用外部函数是一个重要的发展趋势,qwen的全系列也支持用ReAct模板返回函数调用并根据函数返回给出最终结果。ReAct模板的prompt大概长这样:

"<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
Answer the following questions as best you can. You have access to the following APIs:

get_current_weather: Call this tool to interact with the get_current_weather API. What is the get_current_weather API useful for? Get the current weather in a given location. Parameters: {"type": "object", "properties": {"location": {"type": "string", "description": "The city and state, e.g. San Francisco, CA"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}}, "required": ["location"]}

Use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [get_current_weather]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can be repeated zero or more times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

Begin!

Question: 杭州市余杭区天气如何?<|im_end|>
<|im_start|>assistant


具体的拼写逻辑比较复杂,就不展开了。这里比较重要的是,如何处理response的问题。

当遇到模型吐出\nAction: 和\nAction Input: 的组合时,我们就知道结果需要返回函数调用了。这个parse逻辑不复杂,但是LLM往往都是流式返回的结果,而在模型吐字的过程中,框架并不知道它会不会吐出来一个函数调用。

让我们再去先看看开源实现:

qwen官方的openai接口示例

if request.stream:
    if request.functions:
        raise HTTPException(
            status_code=400,
            detail="Invalid request: Function calling is not yet implemented for stream mode.",
        )

偷懒了,直接不允许流式返回和function call同时存在。

再看看chatglm的官方示例:

def contains_custom_function(value: str) -> bool:
    return value and 'get_' in value

这位更是高手,直接假设function call一定是get_开头的。

至于其他开源框架,当前大部分没有不支持返回function call。

4.2 实现方法

最终的实现其实也很简单,在模型吐字时留上一小块buffer不返回,如果没有\nAction: 那就继续返回;如果遇到这个string,则说明模型可能要输出function call,在此收集输出知道遇到eos或者作为stop word 的\nObservation:,然后再把buffer一次性parse成函数并返回。

实际上,不同模型实现function call还有很多其他方式。由于qwen的规模最为完整,并且训练时也对function call做过align,所以目前我们的框架只支持了使用qwen进行function call。未来也会继续探索function的不同定义方式。

05、实战篇:用chat接口构建应用

搞定服务之后,现在我们来实战构建一些基于chat接口的应用。

首先,参照RTP-LLM的文档启动,以启动任意size的qwen为例

export MODEL_TYPE=qwen
export CHECKPOINT_PATH=/path/to/model
export START_PORT=50233

python3 -m maga_transformer.start_server

这里的例子均使用qwen-14b模型完成。

5.1 langchain 文本信息结构化输出

这个例子展示RTP-LLM提供的openai接口返回function call的能力。这个例子中 langchain中对openai function设计了一类单独的chain抽象,这里我们来看一个结构化抽取实体的例子:

# 配置qwen服务域名为openai endpoint
import os
os.environ["OPENAI_API_KEY"] = "xxxx" # you can use any string for key
os.environ["OPENAI_API_BASE"] = "http://localhost:50233"

# langchain打印每一步的完整信息
from langchain.globals import set_debug, set_verbose
set_debug(True)
set_verbose(True)

from langchain_core.pydantic_v1 import BaseModel, Field
from langchain.chains.openai_functions import create_structured_output_chain
from langchain_community.chat_models import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

# 定义一个Dog对象
class Dog(BaseModel):
    """Identifying information about a dog."""
    name: str = Field(..., description="The dog's name")
    color: str = Field(..., description="The dog's color")

# 定义prompt模板:用function call提取{input}中的对象。
llm = ChatOpenAI(model="anything-you-like", temperature=0.2)
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are an algorithm for extracting information into structured formats, and respond with function call."),
        ("user", "extract information from the following input: {input}"),
    ]
)

# 构建chain并调用,输出解析的结果。output是一个`Dog`对象。
chain = create_structured_output_chain(Dog, llm, prompt)
output = chain.run("John had a dog named Harry, who was a brown beagle that loved chicken")
print(str(output))

运行就可以得到解析好的结果

name='Harry' color='brown'

5.2 用llamaindex实现RAG

下面来看一个基础的RAG例子,用llamaindex配合几行代码即可实现带搜索增强的对话系统。在这个例子里,我们克隆一个chatglm3的官方github repo到本地,对目录里的所有文档做索引,并进行增强对话。这里的例子不设计function call,所以理论上所有模型都能使用。

import os
os.environ["OPENAI_API_KEY"] = "xxxx" # you can use any string for key
os.environ["OPENAI_API_BASE"] = "http://localhost:50233"

from llama_index.readers import SimpleDirectoryReader, JSONReader, PDFReader
from llama_index.embeddings import HuggingFaceEmbedding
from llama_index import VectorStoreIndex, ServiceContext
from llama_index.llms import OpenAI

# 先从huggingface上拉一个embedding模型,给文本召回用
embed_model = HuggingFaceEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
service_context = ServiceContext.from_defaults(
    llm=llm, embed_model=embed_model
)
llm = OpenAI()

# 从本地目录加载所有文档,并建立向量索引
documents = SimpleDirectoryReader("/home/wangyin.yx/workspace/ChatGLM3").load_data()
index = VectorStoreIndex.from_documents(documents, service_context=service_context)

# 进行对话查询
query_engine = index.as_query_engine()
response = query_engine.query("如何在mac上部署chatglm?")
print(response)

运行即可得到如下带搜索增强的返回:

针对搭载 Apple Silicon 或 AMD GPU 的 Mac,可以借助 MPS 后端,在 GPU 上运行 ChatGLM3-6B。参照 Apple 的 官方说明以安装 PyTorch-Nightly(正确的版本号应为 2.x.x.dev2023xxxx,而非 2.x.x)。目前 MacOS 只支持从本地加载模型。将代码中的模型加载方式改为从本地加载,并使用 mps 后端,即可在 Mac 上部署 ChatGLM。


model=AutoModel.from_pretrained("your local path", trust_remote_code=True).to('mps')



## 06、总结

使用RTP-LLM的openai兼容接口,使得调用开源模型一键构建chat应用变得非常容易。同时,框架也提供了足够丰富的配置项,用户可以适配多种方式train出来的模型。

#### 相关资料

[01]chatglm3 官方repo的tokenizer config
https://huggingface.co/THUDM/chatglm3-6b/blob/main/tokenizer_config.json
[02] llama-cpp-python

https://github.com/abetlen/llama-cpp-pytho
文末添加:
本文转载自阿里技术,作者:网隐
原文链接:https://mp.weixin.qq.com/s/DfMJVZnqFpsubKJ60H8s7g

已于2024-3-27 12:03:30修改
收藏 1
回复
举报
回复
相关推荐