前言

之前我做 AIHub 的时候通过 gRPC 的方式接入了 ChatGLM 等开源大模型,对于大模型这块我搞了个 StarAI 框架,相当于简化版的 langchain ,可以比较方便的把各种大模型和相关配套组合在一起使用。

主要思路还是用的 OpenAI 接口的那套,降低学习成本,但之前为了快速开发,就只搞了个简单的 gRPC 接口,还差个多轮对话功能没有实现,这次就来完善一下这个功能。

简述

系统分为LLM后端和客户端两部分,LLM后端使用 gRPC 提供接口,客户端就是我用 Blazor 开发的 AIHub

所以这次涉及到这几个地方的修改

  • proto
  • 客户端 - C# 代码
  • AIHub页面 - Blazor 的 razor 代码
  • gRPC 服务端 - Python 代码

修改 proto

来改造一下 proto 文件

\syntax = "proto3";

import "google/protobuf/wrappers.proto";

option csharp_namespace = "AIHub.RPC";

package aihub;

service ChatHub {
  rpc Chat (ChatRequest) returns (ChatReply);
  rpc StreamingChat (ChatRequest) returns (stream ChatReply);
}

message ChatRequest {
  string prompt = 1;
  repeated Message history = 2;
  int32 max_length = 3;
  float top_p = 4;
  float temperature = 5;
}

message Message {
  string role = 1;
  string content = 2;
}

message ChatReply {
  string response = 1;
}

增加了 Message 类型,在 ChatRequest 聊天请求中增加了 history 字段作为对话历史。

修改 C# 的 gRPC 客户端代码

上面的 proto 写完之后编译项目,会重新生成客户端的 C# 代码,现在来修改一下我们的调用代码

可以看到 ChatRequest 多了个 RepeatedField<Message> 类型的 history 属性,这个属性是只读的,所以每次聊天的时候传入对话历史只能使用添加的方式。

为了方便使用,我封装了以下方法来创建 ChatRequest 对象

private ChatRequest GetRequest(string prompt, List<Message>? history = null) {
  var request = new ChatRequest {
    Prompt = prompt,
    MaxLength = 2048,
    TopP = 0.75f,
    Temperature = 0.95f
  };

  if (history != null) {
    request.History.AddRange(history);
  }

  return request;
}

继续改写两个聊天的方法,增加个一个 history 参数

public async Task<string> Chat(string prompt, List<Message>? history = null) {
  var resp = await _client.ChatAsync(GetRequest(prompt, history));
  return RenderText(resp.Response);
}

public async IAsyncEnumerable<string> StreamingChat(string prompt, List<Message>? history = null) {
  using var call = _client.StreamingChat(GetRequest(prompt, history));
  await foreach (var resp in call.ResponseStream.ReadAllAsync()) {
    yield return RenderText(resp.Response);
  }
}

搞定。

修改 gRPC 服务端的 Python 代码

先来看看 ChatGLM2 是如何传入对话的

对官方提供的 demo 进行调试,发现传入模型的 history 是列表里面包着一个个元组,表示一个个对话,奇奇怪怪的格式。

history = [('问题1', '回答1'), ('问题2', '回答2')]

但是 AIHub 的对话是按照 OpenAI 的思路来做的,是这样的格式:

history = [
  {'role': 'user', 'content': '问题1'},
  {'role': 'assistant', 'content': '回答1'},
  {'role': 'user', 'content': '问题2'},
  {'role': 'assistant', 'content': '回答2'},
]

现在需要把 OpenAI 对话格式转换为 ChatGLM 的格式

直接上代码吧

def messages_to_tuple_history(messages: List[chat_pb2.Message]):
    """把聊天记录列表转换成 ChatGLM 需要的 list 嵌套 tuple 形式"""
    history = []
    current_completion = ['', '']
    is_enter_completion = False

    
    for item in messages:
        if not is_enter_completion and item.role == 'user':
            is_enter_completion = True

        if is_enter_completion:
            if item.role == 'user':
                if len(current_completion[0]) > 0:
                    current_completion[0] = f"{current_completion[0]}\n\n{item.content}"
                else:
                    current_completion[0] = item.content
            if item.role == 'assistant':
                if len(current_completion[1]) > 0:
                    current_completion[1] = f"{current_completion[1]}\n\n{item.content}"
                else:
                    current_completion[1] = item.content

                is_enter_completion = False
                history.append((current_completion[0], current_completion[1]))
                current_completion = ['', '']

    return history

目前只处理了 user 和 assistant 两种角色,其实 OpenAI 还有 system 和 function ,system 比较好处理,可以做成以下形式

[('system prompt1', ''), ('system prompt2', '')]

不过我还没测试,暂时也用不上这个东西,所以就不写在代码里了。

接着继续修改两个对话的方法

class ChatService(chat_pb2_grpc.ChatHubServicer):
    def Chat(self, request: chat_pb2.ChatRequest, context):
        response, history = model.chat(
            tokenizer,
            request.prompt,
            history=messages_to_tuple_history(request.history),
            max_length=request.max_length,
            top_p=request.top_p,
            temperature=request.temperature)
        torch_gc()
        return chat_pb2.ChatReply(response=response)

    def StreamingChat(self, request: chat_pb2.ChatRequest, context):
        current_length = 0
        for response, history in model.stream_chat(
                tokenizer,
                request.prompt,
                history=messages_to_tuple_history(request.history),
                max_length=request.max_length,
                top_p=request.top_p,
                temperature=request.temperature,
                return_past_key_values=False):

            print(response[current_length:], end="", flush=True)
            yield chat_pb2.ChatReply(response=response)
            current_length = len(response)

        torch_gc()

对了,每次对话完成记得回收显存

def torch_gc():
    if torch.cuda.is_available():
        with torch.cuda.device(CUDA_DEVICE):
            torch.cuda.empty_cache()
            torch.cuda.ipc_collect()

这样就搞定了。

PS: Python 日志组件可以用 loguru ,很好用,我最近刚发现的。

小结

gRPC 方式调用开发起来还是有点麻烦的,主要是调试比较麻烦,我正在考虑是否改成统一 OpenAI 接口方式的调用,GitHub 上有人贡献了 ChatGLM 的 OpenAI 兼容接口,后续可以看看。

不过在视觉这块,还是得继续搞 gRPC ,传输效率比较好。大模型可以使用 HTTP 的 EventSource 是因为数据量比较小,次要原因是对话是单向的,即:用户向模型提问,模型不会主动向用户发送信息。

10-19 11:18