简介
近年来,大型语言模型(LLM)已成为一种改变游戏规则的技术,彻底改变了我们与机器交互的方式。这些模型以 OpenAI 的 GPT 系列(例如 GPT-3.5 或 GPT-4)为代表,可以接收一连串的输入文本,并生成连贯、与上下文相关且听起来像人话的文本作为回复。因此,它的应用范围非常广泛,涵盖了客户服务、内容创建、语言翻译或代码生成等多个领域。然而,这些能力的核心是先进的机器学习/统计技术,包括改进自然语言理解过程的注意机制、提供大规模基础模型的迁移学习、数据扩增,甚至是从人类反馈中强化学习,这些技术使这些系统能够扩展其训练过程,并在推理过程中不断改进其性能。
作为人工智能的一个子集,机器学习负责处理数据集,以识别模式并开发能准确代表数据性质的模型。这种方法能产生有价值的知识,并能完成各种任务,例如内容生成,是推动大型语言模型的生成式人工智能领域的基础。值得强调的是,这一领域并不只关注自然语言,也关注任何类型的可生成内容。包括能够生成声音、语音或音乐的音频模型;通过 OpenAI 的 SORA 等最新模型生成的视频;图像,以及文本序列的编辑和风格转换。后一种数据格式尤其有价值,因为通过使用多模态集成和图像/文本嵌入技术,可以有效地说明通过自然语言进行知识表征的潜力。
然而,创建和维护模型以执行此类操作,尤其是大规模操作,并非易事。其中一个主要原因是数据,因为数据是对一个运作良好的模型的主要贡献。也就是说,用结构优化的架构和高质量的数据来训练模型,会产生有价值的结果。反之,如果提供的数据不佳,模型就会产生误导性输出。因此,在创建数据集时,数据集应包含适合特定模型架构的适当数据量。这一要求使数据处理和质量验证变得更加复杂,此外,如果数据是通过自动化或刮擦收集的,还必须考虑潜在的法律和隐私问题。
另一个原因在于硬件。现代部署的模型需要同时处理来自许多用户的大量数据,不仅体积庞大,而且需要大量计算资源来执行推理任务并为客户提供优质服务。从经济角度看,这同样体现在巨大的成本上。一方面,考虑到要提供可靠的服务,就需要 GPU、TPU、DPU 以及精心挑选的组件来最大限度地提高效率,使用合适的硬件建立服务器和数据中心的成本高得惊人。另一方面,硬件的维护需要熟练的人力资源--合格的人员来解决潜在的问题并根据需要进行系统升级。
目标
围绕这种模式的构建和大规模部署还有许多其他问题。总之,我们很难建立一个具有足够强大的支持基础设施的系统,以匹配市场上像 ChatGPT 这样的领先服务。不过,由于公共领域有大量的开源内容和技术,我们还是可以实现与参考服务相当的、可接受的、合理的近似值。此外,鉴于其中一些技术的高度进步,它们被证明使用起来非常简单,使我们能够受益于它们的抽象性、模块性、易集成性和其他可增强开发过程的宝贵品质。
因此,本文旨在展示我们如何设计、实现和部署一个支持类似 ChatGPT 服务的计算系统。虽然最终结果可能无法达到预期的服务能力,但使用高质量的依赖关系和开发工具,以及良好的架构设计,可以保证系统根据用户需求轻松扩展到所需的计算能力。也就是说,系统可以在资源非常有限的极少数机器(可能只有一台)上运行,提供与这些资源相匹配的吞吐量;也可以在拥有适当硬件的大型计算机网络上运行,提供扩展服务。
架构
最初,系统的主要功能是允许客户端提交文本查询,由 LLM 模型处理后返回给源客户端,所有这些都要在合理的时间范围内完成,并提供公平的服务质量。这是对我们系统的最高级描述,具体地说,是对该系统所提供的应用功能的描述,因为所有的实现细节,如组件之间的通信协议、所涉及的数据结构等都被有意省略了。但是,既然我们已经有了一个明确的目标,我们就可以开始分解,逐步增加解决问题所涉及的细节,这通常被称为功能分解(Functional Decomposition)。因此,从接收和返回查询的黑盒系统(抽象)开始,我们可以开始全面定义客户如何与系统交互,以及实现这种交互的技术。
首先,我们必须确定什么是客户端,特别是用户需要哪些工具或界面来与系统进行交互。如上图所示,我们假定系统目前是一个全面实施和运行的功能单元,这样我们就可以把重点放在客户端和客户端与系统的连接上。在客户端实例中,界面将通过网站提供,网站的设计具有多样性,但主要针对台式设备。移动应用程序也可以开发和集成,以使用相同的系统服务和特定的界面,但从抽象的角度来看,最好是将所有类型的客户端统一为一个,即网络客户端。
随后,有必要找到一种将客户机与系统连接起来的方法,以便在它们之间进行信息交换,这里指的是查询。在这一点上,值得注意的是,网络客户端将依赖于一种特定的技术,如 JavaScript,以及由此产生的所有通信影响。对于其他类型的平台,该技术很可能会发生变化,例如移动客户端中的 Java 或物联网设备中的 C/C++,而兼容性要求可能会要求系统做出相应调整。
建立通信的一种方法是在较低层次使用套接字和类似工具,以便对整个协议进行详尽的控制。不过,这一方案需要满足上述与所有客户端技术的兼容性限制,因为系统需要能够收集来自所有可用客户端类型的查询。此外,由于必须考虑到许多额外的细节,详尽无遗的控制意味着开发时间更长,也可能更加复杂,这就大大增加了代码行数,使代码的可维护性和可扩展性变得更加复杂。
如上所述,最理想的选择是建立一个应用编程接口(API),作为客户端与负责计算的系统部分(即解决查询的部分)之间的中介。使用应用程序接口的主要优势在于,所有内部连接处理,如打开和关闭套接字、线程池和其他重要细节(数据序列化),都由构建应用程序接口的框架执行。这样,我们就能确保客户端只需向执行 API 的服务器发送查询,然后等待响应即可,所有这些都依赖于简化 API 请求管理的依赖关系。上一点带来的另一个好处是,通过修改应用程序接口端点,可以轻松实现服务扩展。例如,如果我们想在系统中添加一个新模型或任何其他功能,只需添加并实现一个新的端点即可,而无需更改通信协议本身或客户端与系统交互的方式。
计算服务
一旦我们建立了客户与系统进行优雅通信的机制,我们就必须解决如何处理传入的查询并在合理的时间内将其返回给相应客户的问题。但首先需要指出的是,当查询到达系统时,它必须被重定向到内存中装有 LLM 的机器上,该机器上有相应的推理管道,并通过该管道遍历查询,获得稍后返回的结果文本(LLM 答案)。因此,推理过程不能分布在多台机器上进行查询解析。有鉴于此,我们可以开始设计支持推理过程的基础设施。
在上一张图片中,计算服务被表示为一个单独的单元。如果我们将其视为一台机器,通过使用套接字的单一通道与 API 服务器相连,我们就能将所有 API 查询重定向到这台机器,从而将所有系统负载集中到一处。可以想象,这对于只有少数人使用的家庭系统来说是个不错的选择。但是,在这种情况下,我们需要一种方法来使这种方法具有可扩展性,这样随着计算资源的增加,我们就可以为尽可能多的额外用户提供服务。但首先,我们必须将前面提到的计算资源划分为若干单元。这样,我们就能全面了解它们之间的相互联系,并通过改变它们的结构或组成方式来优化我们的项目吞吐量。
计算单元(从现在起,为方便实现,我们将其称为节点)将由一台物理机器集成,该机器接收需要解决的请求(并非所有请求)。此外,我们还可以将节点视为机器数量(可能会减少)的虚拟化,目的是通过在本地引入并行性来提高每个节点的总吞吐量。至于所使用的硬件,这在很大程度上取决于服务的定位以及我们想要达到的目标。不过,在本案例中介绍的版本中,我们将假定使用标准 CPU、大量 RAM 以避免加载模型或转发查询时出现问题,并使用 GPU 等专用处理器,在某些特定情况下还可能使用 TPU。
现在,我们可以建立一个连接多个节点的网络,通过其中一个节点与 API 服务器相连,在整个网络中分发查询,从而充分利用系统的所有资源。在上图中,我们可以看到所有节点在结构上呈树状连接,树根负责收集应用程序接口查询并将其转发。如何将这些节点相互连接起来,在很大程度上取决于系统的具体用途。在这种情况下,选择树形结构是为了简化分配基元。举例来说,如果我们想最大限度地增加在应用程序接口和节点之间传输的查询次数,那么就必须从应用程序接口连接到多个树的根节点,或者根据需要连接到其他不同的数据结构。
最后,我们需要定义查询到达根节点后如何转发和处理。如前所述,有许多同样有效的备选方案。不过,我们要遵循的算法也有助于理解为什么要选择树形结构来连接系统节点。
由于查询必须在单个节点上解决,分配算法的目标就是在系统中找到一个空闲节点,并将输入的查询分配给它来解决。如上图所示,如果我们考虑按自然顺序编号(1 索引)的有序查询序列,则每个编号都对应于与被分配解决该查询的节点相连的边。为了理解这个具体例子中的编号,我们可以假设到达节点的查询需要无限长的时间才能解决,因此,确保每个节点逐渐繁忙有助于理解算法启发式。
简而言之,我们将让根节点不执行任何解析处理,将其所有能力留给转发 API 请求。对于任何其他节点来说,当它收到来自上一级节点的查询时,第一步是检查它是否正在为之前的查询执行任何计算;如果它处于空闲状态,它将解析该查询,而在其他情况下,它将通过循环往复将该查询转发给它的一个下级节点。通过轮询,每次查询都会被重定向到不同的后代节点,就像循环缓冲区一样遍历整个后代节点列表。这意味着节点的本地负载可以向下平均分配,同时有效利用每个节点的资源,并通过增加后代节点来扩展系统。
最后,如果系统目前正在为许多用户提供服务,而查询到达的叶节点也很忙,那么它将没有任何后代节点可以将其重定向。因此,所有节点都将有一个查询排队机制,在这种情况下它们将等待,并能在排队查询之间进行批量操作,以加速 LLM 推断。此外,当查询完成后,为了避免向上转发直至到达树顶而造成系统超载,查询会直接发送到根节点,随后到达应用程序接口和客户端。我们可以将所有节点连接到应用程序接口,或采用其他替代方案,但为了保持代码的简洁性和系统的高性能,所有节点都将被发送到根节点。
Web客户端
在确定了完整的系统架构和执行任务的方式后,我们就可以开始构建网络客户端,以便用户与我们的解决方案进行交互。
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Estilos y parte de la interfaz: https://github.com/unconv/chatgpt-clone-->
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
:root {
--color-white: #fff;
--color-main: #2c2d30;
--color-main-fade: #2c2d3000;
--color-secondary: #171717;
--color-secondary-fade: #17171700;
--color-button-hover: #242629;
--color-button-hover-fade: #24262900;
--color-user-icon: #8e0000;
--color-groupings: #9ca6b5;
--color-gpt-icon: #000000;
--color-black: #1e1e1f;
--color-user-menu-hover: #383b42;
--color-text: #f5f9ff;
--color-gpt3: #5fc319;
--color-gpt4: #f22626;
--color-secondary-p: #c9ccd1;
--color-logo: #848484;
--color-model-name: #ffffff;
--color-assistant-bg: #3f4042;
--color-assistant-text: #e1e6ed;
--color-disclaimer: #d0d2e1;
--color-border1: #484a4e;
--color-user-menu-border: #34373a;
--color-user-menu-selected-border: #4a5562;
--color-border2: #292d32;
--color-user-message-border: #2f353d;
}
body {
background: var(--color-main);
display: flex;
font-size: 1em;
font-family: system-ui, sans-serif;
}
#sidebar {
position: relative;
left: 0;
background: var(--color-secondary);
width: 260px;
padding: 8px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
flex-direction: column;
transition: all 0.2s ease-in-out;
}
.float-top {
display: flex;
flex-direction: column;
height: calc(100% - 50px);
}
#sidebar.hidden {
left: -260px;
margin-right: -260px;
}
#sidebar.hidden .hide-sidebar {
left: 60px;
transform: rotate(180deg);
padding: 15px 13px 11px 13px;
}
button {
display: block;
background: inherit;
border: 1px solid var(--color-border1);
border-radius: 5px;
color: var(--color-white);
padding: 13px;
box-sizing: border-box;
text-align: left;
cursor: pointer;
}
button:hover {
background: var(--color-button-hover);
}
.sidebar-controls {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.sidebar-controls button {
padding: 12px 13px 12px 13px;
}
.hide-sidebar {
position: relative;
left: 0;
top: 0;
transition: all 0.2s ease-in-out;
transform: rotate(0deg);
}
.new-chat i {
margin-right: 13px;
}
.new-chat {
flex: 1;
}
.conversations {
width: calc(100% + 8px);
overflow-y: scroll;
}
.conversations,
.conversations li {
list-style: none;
list-style-type: none;
margin: 0;
padding: 0;
}
.conversations li {
position: relative;
}
.conversations li .fa {
margin-right: 7px;
}
.conversations li > button {
width: 100%;
border: none;
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
}
.conversations li.active > button {
background: var(--color-main);
}
.edit-buttons {
display: none;
position: absolute;
right: 8px;
top: 0;
}
.conversations li:hover .edit-buttons {
display: flex;
}
.fade {
position: absolute;
right: 0;
top: 0;
background: var(--color-user-icon);
width: 40px;
height: 100%;
border-radius: 5px;
background: transparent;
background: linear-gradient(90deg, var(--color-secondary-fade) 0%, var(--color-secondary) 50%);
}
.conversations li.active .fade {
background: linear-gradient(90deg, var(--color-main-fade) 0%, var(--color-main) 50%);
}
.conversations li:hover .fade {
width: 80px;
background: linear-gradient(90deg, var(--color-button-hover-fade) 0%, var(--color-button-hover) 30%);
}
.edit-buttons button {
border: none;
padding: 0;
margin: 13px 1px 13px 1px;
opacity: 0.7;
}
.edit-buttons button:hover {
background: none;
opacity: 1;
}
.conversations li.grouping {
color: var(--color-groupings);
font-size: 0.7em;
font-weight: bold;
padding-left: 13px;
margin-top: 12px;
margin-bottom: 12px;
}
i.user-icon {
padding: 6px;
color: var(--color-white);
background: var(--color-user-icon);
display: inline-block;
text-align: center;
width: 15px;
border-radius: 3px;
margin-right: 6px;
font-style: normal;
width: 18px;
height: 18px;
font-size: 15px;
text-transform: uppercase;
font-family: system-ui, sans-serif;
}
.gpt.user-icon {
background: var(--color-gpt-icon);
}
.user-menu {
position: relative;
border-top: 1px solid var(--color-border1);
}
.user-menu button {
width: 100%;
border: none;
}
.user-menu .dots {
position: relative;
top: 11px;
float: right;
opacity: 0.7;
}
.user-menu > ul,
.user-menu li {
list-style: none;
list-style-type: none;
padding: 0;
margin: 0;
}
.user-menu > ul {
display: none;
position: absolute;
top: 0;
left: 0;
opacity: 0;
transform: translateY(-100%);
background: var(--color-black);
border-radius: 10px;
width: 100%;
transition: all 0.2s ease-in-out;
}
.user-menu > ul.show-animate {
display: block;
}
.user-menu > ul.show {
opacity: 1;
margin-top: -8px;
}
.user-menu li button {
border-radius: 0;
}
.user-menu li button:hover {
background: var(--color-user-menu-hover);
}
.user-menu li:first-child button {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.user-menu li:last-child button {
border-top: 1px solid var(--color-user-menu-border);
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
::-webkit-scrollbar {
width: 9px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: transparent;
}
:hover::-webkit-scrollbar-thumb {
background-color: var(--color-text) c3;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text);
border-radius: 5px;
}
main {
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
align-content: center;
justify-content: space-between;
padding: 0 0 30px 0;
box-sizing: border-box;
}
main .view {
display: flex;
flex-direction: column;
}
.model-selector {
position: relative;
border-radius: 11px;
background: var(--color-secondary);
display: flex;
padding: 4px;
gap: 4px;
margin: 24px auto;
z-index: 2;
}
.model-selector > button {
border-radius: 9px;
text-align: center;
width: 150px;
border: none;
font-weight: bold;
opacity: 0.5;
}
.model-selector > button:hover {
background: none;
opacity: 1;
}
.model-selector > button.selected {
border: 1px solid var(--color-user-menu-selected-border);
background: var(--color-user-menu-hover);
opacity: 1;
}
.model-selector button .fa {
margin-right: 5px;
}
.gpt-3 .fa {
color: var(--color-gpt3);
}
.gpt-4 .fa {
color: var(--color-gpt4);
}
.model-info {
display: none;
position: absolute;
bottom: 5px;
left: 0;
transform: translateY(100%);
padding: 15px;
cursor: default;
}
.model-info-box {
padding: 20px 20px 10px 20px;
border-radius: 15px;
background: var(--color-secondary);
color: var(--color-white);
text-align: left;
}
.model-selector > button:hover .model-info {
display: block;
}
.model-selector p {
font-size: 1.1em;
margin: 0 0 15px 0;
}
p.secondary {
font-size: 1em;
color: var(--color-secondary-p);
}
.logo {
position: relative;
z-index: 1;
color: var(--color-logo);
font-weight: bold;
text-align: center;
font-size: 2.3em;
}
.view.conversation-view {
display: none;
overflow-y: auto;
}
.model-name {
background: var(--color-main);
text-align: center;
color: var(--color-model-name);
padding: 23px;
border-bottom: 1px solid var(--color-border2);
font-size: 0.85em;
}
.message {
display: flex;
gap: 20px;
padding: 25px 60px 15px 60px;
border-bottom: 1px solid var(--color-border2);
font-size: 0.95em;
}
.message .content {
padding-top: 5px;
}
.user.message {
color: var(--color-text);
}
.assistant.message {
background: var(--color-assistant-bg);
color: var(--color-assistant-text);
}
#message-form {
margin: 0 auto;
width: 100%;
box-sizing: border-box;
max-width: 850px;
text-align: center;
padding: 0px 45px 0 45px;
box-shadow: var(--color-main) 0 0 50px;
}
.message-wrapper {
position: relative;
}
#message::placeholder {
color: var(--color-groupings);
}
#message {
background: var(--color-user-menu-hover);
border-radius: 13px;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-user-message-border);
resize: none;
padding: 17px 85px 17px 15px;
font-family: inherit;
font-size: 1em;
color: var(--color-white);
box-shadow: rgba(0, 0, 0, 0.2) 0 0 45px;
outline: none;
}
.disclaimer {
margin-top: 12px;
color: var(--color-disclaimer);
font-size: 0.7em;
}
.send-button {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: var(--color-gpt3);
border-radius: 5px;
display: inline-block;
font-size: 1em;
padding: 7px 9px 7px 7px;
color: var(--color-white);
border: none;
margin-top: -2px;
}
button.send-button:hover {
border: none;
background: var(--color-gpt3);
color: var(--color-white);
}
p {
margin: 0 0 1.5em 0;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"
integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA=="
crossorigin="anonymous" referrerpolicy="no-referrer"/>
<title>Chat-GPT</title>
</head>
<body>
<nav id="sidebar">
<div class="float-top">
<div class="sidebar-controls">
<button class="new-chat"><i class="fa fa-plus"></i> New chat</button>
<button class="hide-sidebar"><i class="fa fa-chevron-left"></i></button>
</div>
<ul class="conversations">
<li class="grouping">Today</li>
</ul>
</div>
<div class="user-menu">
<button>
<i class="user-icon">u</i>
username
<i class="fa fa-ellipsis dots"></i>
</button>
<ul>
<li>
<button>My plan</button>
</li>
<li>
<button>Custom instructions</button>
</li>
<li>
<button>Settings & Beta</button>
</li>
<li>
<button>Log out</button>
</li>
</ul>
</div>
</nav>
<main>
<div class="view new-chat-view">
<div class="model-selector">
<button class="gpt-3 selected">
<i class="fa fa-bolt"></i> GPT-2
<div class="model-info">
<div class="model-info-box">
<p>Our fastest model, great for most every day tasks.</p>
<p class="secondary">Available to Free and Plus users</p>
</div>
</div>
</button>
<button class="gpt-4">
<i class="fa fa-wand-magic-sparkles"></i> GPT-2 (Large)
<div class="model-info">
<div class="model-info-box">
<p>Our most capable model, great for creative stuff.</p>
<p class="secondary">Available for Plus users.</p>
</div>
</div>
</button>
</div>
<div class="logo">
SDIS2?
</div>
</div>
<div class="view conversation-view">
<div class="model-name">
<i class="fa fa-bolt"></i> Default (GPT-2)
</div>
</div>
<div id="message-form">
<div class="message-wrapper">
<textarea id="message" rows="1" placeholder="Send a message"></textarea>
<button class="send-button"><i class="fa fa-paper-plane"></i></button>
</div>
<div class="disclaimer">Letra pequeña</div>
</div>
</main>
<script>
const sidebar = document.querySelector("#sidebar");
const hide_sidebar = document.querySelector(".hide-sidebar");
const new_chat_button = document.querySelector(".new-chat");
hide_sidebar.addEventListener("click", function () {
sidebar.classList.toggle("hidden");
});
const user_menu = document.querySelector(".user-menu ul");
const show_user_menu = document.querySelector(".user-menu button");
show_user_menu.addEventListener("click", function () {
if (user_menu.classList.contains("show")) {
user_menu.classList.toggle("show");
setTimeout(function () {
user_menu.classList.toggle("show-animate");
}, 200);
} else {
user_menu.classList.toggle("show-animate");
setTimeout(function () {
user_menu.classList.toggle("show");
}, 50);
}
});
const models = document.querySelectorAll(".model-selector button");
for (const model of models) {
model.addEventListener("click", function () {
document.querySelector(".model-selector button.selected")?.classList.remove("selected");
model.classList.add("selected");
});
}
const message_box = document.querySelector("#message");
message_box.addEventListener("keyup", function () {
message_box.style.height = "auto";
let height = message_box.scrollHeight + 2;
if (height > 200) {
height = 200;
}
message_box.style.height = height + "px";
});
function show_view(view_selector) {
document.querySelectorAll(".view").forEach(view => {
view.style.display = "none";
});
document.querySelector(view_selector).style.display = "flex";
}
new_chat_button.addEventListener("click", function () {
show_view(".new-chat-view");
});
/*
//Pruebas de carga:
for (let i = 0; i < 10; i++) {
var formData = {
text: "Hello",
timestamp: new Date().toISOString()
};
sendData(formData);
}*/
const sendButton = document.querySelector('.send-button');
sendButton.addEventListener('click', function () {
const messageBox = document.querySelector('#message');
const messageText = messageBox.value.trim();
if (messageText !== '') {
const formData = {
text: messageText,
timestamp: new Date().toISOString()
};
sendData(formData);
appendMessage("user", messageText);
checkAndAddConversation(messageText);
messageBox.value = '';
show_view(".conversation-view");
}
});
function checkAndAddConversation(messageText) {
const conversationsList = document.querySelector('.conversations');
const existing = document.querySelector('.conversation-button');
if (!existing) {
let newConvo = document.createElement('li');
newConvo[xss_clean] = `
<button class="conversation-button"><i class="fa fa-message fa-regular"></i> ${messageText}</button>
<div class="fade"></div>
<div class="edit-buttons">
<button><i class="fa fa-trash"></i></button>
</div>`;
conversationsList.appendChild(newConvo);
newConvo.querySelector('button').click();
}
document.querySelectorAll(".conversation-button").forEach(button => {
button.addEventListener("click", function () {
show_view(".conversation-view");
})
});
}
function sendData(data) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/arranca/', true);
xhr.setRequestHeader('Content-Type', 'application/json');
// Fetch CSRF token from cookies
xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken'));
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
var responseData = JSON.parse(xhr.responseText);
appendMessage("assistant", responseData.text);
} else {
console.error('Error:', xhr.status);
}
}
};
xhr.send(JSON.stringify(data));
}
function appendMessage(sender, message) {
const conversationView = document.querySelector('.conversation-view');
const messageWrapper = document.createElement('div');
messageWrapper.classList.add('message', sender);
const identity = document.createElement('div');
identity.classList.add('identity');
identity[xss_clean] = `<i class="${sender === 'user' ? 'user' : 'gpt'} user-icon">${sender === 'user' ? 'u' : 'G'}</i>`;
messageWrapper.appendChild(identity);
const content = document.createElement('div');
content.classList.add('content');
content[xss_clean] = `<p>${message}</p>`;
messageWrapper.appendChild(content);
conversationView.appendChild(messageWrapper);
conversationView.scrollTop = conversationView.scrollHeight;
}
// CSRF (galletas)
function getCookie(name) {
var cookieValue = null;
if ([xss_clean] && [xss_clean] !== '') {
var cookies = [xss_clean].split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
</script>
</body>
</html>
不出所料,网络客户端是用基本的 HTML、CSS 和 JavaScript 实现的,为了方便起见,所有内容都嵌入到一个 .html 文件中。每次客户端发出与应用程序启动相对应的请求时,API 都会提供该文件,也就是说,当客户端进入浏览器并输入 API 寄存入口点的地址时,API 将返回 .html 文件并在浏览器中呈现。
随后,当用户希望向系统发送文本查询时,JavaScript 会在内部向 API 提交 HTTP 请求,并提供相应的详细信息,如数据类型、端点或 CSRF 安全令牌。通过在此过程中使用 AJAX,就可以非常简单地定义一个基元,当 API 返回所提请求的某个值时执行该基元,并负责将结果显示在屏幕上。此外,值得一提的是,发送的信息并不是直接写入或返回的文本,而是用 JSON 包装的,其中还包含其他重要参数,如时间戳,这就为即时添加额外字段以管理某些系统组件的同步提供了可能性。
Django API
Web客户端准备就绪后,我们就可以着手实现提供必要服务的API。
有许多技术可用于构建API,但在本项目中,我们将特别通过专用服务器上的 Python 使用 Django。之所以这样决定,是因为该框架具有高度的可扩展性,易于与其他 Python 依赖项集成,此外还有其他有用的特性,如安全性或默认管理面板。
要配置的端点之一是网络客户端的入口点,由默认 URL 斜线 / 表示。 因此,当用户通过默认 HTTP 请求(如上图所示)访问服务器时,API 将返回显示界面和开始向 LLM 服务发出请求所需的 HTML 代码。
同时,它还必须支持客户端访问接口后提出的请求。这些请求必须以特殊方式进行管理,它们将有自己的端点,名为“/arranca”,查询数据将以相应的 JSON 格式发送到该端点,API 在用节点树处理后会返回已解决的查询。在该端点中,服务器使用先前与层次结构中的根节点建立的 Socket 通道转发查询,并通过同步机制等待其响应。
#urls.py
from django.contrib import admin
from django.urls import path
from . import views
views.start_server_thread()
urlpatterns = [
path("admin/", admin.site.urls),
path('', views.index, name='index'),
path('arranca/', views.arranca, name='arranca')
]
#settings.py
DEBUG = False
ALLOWED_HOSTS = ["*"]
#views.py
from django.shortcuts import render
from django.http import HttpResponse, JsonResponse
import json, socket, threading
from django.views.decorators.csrf import csrf_exempt
java_sock = None
request_lock = threading.Condition()
id_lock = threading.Condition()
response_data = None
global_request_id = 0
def getAndIncrement():
global global_request_id, id_lock
with id_lock:
global_request_id += 1
return global_request_id
def index(request):
if request.method == 'GET':
return render(request, 'index.html')
else:
return HttpResponse('')
@csrf_exempt
def arranca(request):
global java_sock, request_lock, response_data
if request.method == 'POST':
request_id = getAndIncrement()
data = json.loads(request.body)
data.update({'request_id': request_id})
java_sock.sendall((json.dumps(data) + "\n").encode())
print(data, java_sock)
# A esperar
with request_lock:
while response_data is None or response_data.get("request_id") != request_id:
request_lock.wait()
print(response_data)
return JsonResponse(response_data)
else:
return HttpResponse('')
def server():
global java_sock, request_lock, response_data
port = 1000
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.bind(("0.0.0.0", port))
server_socket.listen()
print("Python server listening on port", port)
while True:
java_sock, addr = server_socket.accept()
print("Connected to Java client at", java_sock, addr)
while True:
response_from_java = java_sock.recv(4096)
print(response_from_java)
with request_lock:
response_data = json.loads(response_from_java.decode())
request_lock.notifyAll()
def start_server_thread():
server_thread = threading.Thread(target=server)
server_thread.start()
关于代码,我们将在 urls.py 文件中存储 URL 和端点之间的关联,这样默认的空 URL 就会被分配给相应的函数,以便从模板文件夹中读取 .html 并将其发送回来,或者 URL /arranca,以便执行解决查询的函数。此外,还将执行一个视图函数来启动主服务器线程。同时,在 settings.py 中,只需将 DEBUG 参数改为 False,并输入允许连接到服务器的主机的必要权限。
最后是 views.py 脚本,所有 API 功能都在这里实现。首先,我们有一个主线程负责接收和处理传入的连接(来自根节点)。起初,这个连接在整个系统的生命周期内都是永久性的。不过,它被置于一个无限循环中,以防连接被中断而必须重新建立。其次,默认端点使用 index() 函数实现,如果客户端执行 GET 请求,该函数会将 .html 内容返回给客户端。此外,用户在应用程序中提交的查询会通过 /arranca 端点传输到应用程序接口,该端点由同名函数实现。在那里,输入的查询会被转发到根节点,阻塞直到从根节点收到响应并返回给客户端。
这种阻塞是通过锁和同步机制实现的,其中每个查询都有一个唯一的标识符,由 arranca() 函数作为 JSON 消息中的一个字段插入,名为 request_id。从本质上讲,它是一个自然数,与查询到达顺序相对应。因此,当根节点向应用程序接口发送一个已解决的查询时,就有可能知道哪个被阻塞的执行是生成查询的执行,从而解除阻塞、返回并重新阻塞其他执行。
Java 计算节点
随着 API 的运行,我们将着手用 Java 实现节点系统。选择这种语言的主要原因是,我们可以利用这种技术在节点之间进行通信。为了在这一层面获得尽可能简单的通信语义,我们将放弃使用套接字和手动序列化消息,而代之以 RMI,尽管其他平台也提供了解决方案,如 Python 中的 Pyro4,但 RMI 在其他平台上的应用会更加复杂。
远程方法调用(RMI)是一种通信范式,它可以创建由托管在不同机器上的远程对象组成的分布式系统,这些远程对象能够获取彼此的远程引用,并在其服务接口中调用远程方法。因此,由于 Java 的高度抽象性,节点间的查询传输将通过远程调用发送方节点引用的对象来实现,而无需像以前在 Python 中那样手动处理复杂的 API 连接过程。
import java.net.*;
import java.rmi.*;
public interface NodeInterface extends Remote {
InetAddress getIP() throws Exception;
void receiveMessage(String incomingQuery) throws Exception;
void connectParent(String parentName) throws Exception;
void connectChild(String childName) throws Exception;
void sendMessagePython(String outgoingQuery) throws Exception;
String log() throws Exception;
}
首先,我们应该定义远程接口,以确定每个节点的远程可调用方法。一方面,我们有一些方法可以返回相关信息用于调试(log() 或 getIP())。另一方面,还有一些方法负责获取其他节点的远程引用,并将它们作为升序或降序节点注册到本地层次结构中,我们将假定每个节点都使用一个唯一的名称。此外,它还有两个原语,分别用于接收来自其他节点的传入查询(receiveMessage())和向 API 发送已解决的查询(sendMessagePython()),这两个原语只在根节点中执行。
import javax.naming.directory.DirContext;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.rmi.MarshalledObject;
import java.rmi.server.UnicastRemoteObject;
import java.util.ArrayList;
import java.util.concurrent.*;
public class Node extends UnicastRemoteObject implements NodeInterface {
private String name;
private String registry;
private NodeInterface root;
private NodeInterface parentNode;
private ArrayList<NodeInterface> childList;
private LLMProcess process;
private Socket webServerSocket;
private PrintWriter outStream;
private BufferedReader inStream;
private DirContext ldapContext;
private ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newCachedThreadPool();
private int requestCounter = 0;
public Node(String name, String registry, String rootName) throws Exception {
super();
this.name = name;
this.registry = registry;
childList = new ArrayList<>();
process = new LLMProcess();
ldapContext = Utilities.createLDAPContext(this.registry);
root = getRemoteNode(rootName);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
process.destroyProcess();
}));
}
private NodeInterface getRemoteNode(String nodeName) {
try {
MarshalledObject<NodeInterface> marshaledNode = (MarshalledObject<NodeInterface>) ldapContext.lookup("cn=" + nodeName + ",ou=Nodes");
NodeInterface remoteNode = marshaledNode.get();
return remoteNode;
} catch (Exception e) {
return null;
}
}
public void connectParent(String parentName) throws Exception {
parentNode = getRemoteNode(parentName);
parentNode.connectChild(name); // Parent connects with child // Return host name
System.out.println("Parent node connected");
}
public void connectChild(String childName) throws Exception {
childList.add(getRemoteNode(childName));
System.out.println("Child node connected");
}
public String log() throws Exception {
return String.format("Name:%s\nRegistry:%s\nIP:%s\nParent:%s\nChildren:%s",
name, registry, getIP().getHostAddress().toString(), parentNode, childList);
}
public InetAddress getIP() throws Exception {
return InetAddress.getLocalHost();
}
public synchronized void receiveMessage(String incomingQuery) throws Exception {
if (executor.getActiveCount() == 0) { // Node is not busy
consultLLM(incomingQuery);
} else {
if (childList.isEmpty()) {
consultLLM(incomingQuery);
} else {
childList.get(requestCounter).receiveMessage(incomingQuery);
requestCounter = (requestCounter + 1) % childList.size();
}
}
}
private synchronized void receiveMessagePython(String incomingQuery) throws Exception { // Only for the Root Node
childList.get(requestCounter).receiveMessage(incomingQuery);
requestCounter = (requestCounter + 1) % childList.size();
}
public void consultLLM(String userQuery) {
executor.execute(() -> {
try {
String resolvedQuery = process.sendQuery(userQuery);
root.sendMessagePython(resolvedQuery);
} catch (Exception e) {
e.printStackTrace(System.err);
}
});
}
public void sendMessagePython(String outgoingQuery) throws Exception {
outStream.println(outgoingQuery);
outStream.flush();
}
public void connectServer(String host, int port) throws Exception {
if (webServerSocket != null)
webServerSocket.close();
Thread webServerThread = new Thread(() -> {
try {
webServerSocket = new Socket(host, port);
outStream = new PrintWriter(webServerSocket.getOutputStream());
outStream.flush();
inStream = new BufferedReader(new InputStreamReader(webServerSocket.getInputStream()));
String message;
while ((message = inStream.readLine()) != null) {
receiveMessagePython(message); // Incoming query from a user
}
webServerSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
});
webServerThread.start();
}
}
根据该接口,我们可以在节点类中实现其操作,每次启动系统并决定在节点树中添加新机器时,节点类都会实例化。节点类的主要功能包括 getRemoteNode() 方法,该方法可从另一个节点的名称中获取远程引用。为此,该方法访问名称注册表并执行 lookup() 原语,如果已注册,则以接口形式返回远程引用,否则返回null。
获取远程引用在构建树的过程中是必不可少的,尤其是对于其他将父节点连接到子节点或获取根节点引用以发送已解决查询的方法而言。其中一个方法是 connectParent(),当子代节点需要连接父代节点时会调用它。正如你所看到的,它首先使用 getRemoteNode() 来检索父节点,一旦获得引用,就将其赋值给每个节点实例的本地变量。然后,它调用 connectChild(),将调用它的远程节点添加到后代列表中。如果父节点不存在,它将尝试调用一个空对象上的函数,并引发异常。其次,需要注意的是,从 API 接收查询的 receiveMessagePython() 方法和从其他节点接收查询的 receiveMessage() 方法都受同步子句保护,以避免出现干扰系统正确运行的竞赛条件。这些方法还负责实现查询分配启发式,该启发式使用一个本地变量来确定传入查询应发送到的相应节点。
最后,节点类有一个线程池,用于管理 consultLLM() 方法中的查询解析。这样,Java 代码中的调用就会立即终止,因为线程池会分配一个线程来执行所需的计算,并将控制权返回给程序,使其可以接受其他查询。这也是检测节点是否正在执行任何计算的一个优势,因为只需检查活动线程数是否大于 0 即可。另一方面,节点类中线程的另一个用途(这次是在池之外)是在 connectServer() 方法中,该方法负责将根节点与用于查询交换的 API 连接起来。
import javax.naming.*;
import javax.naming.directory.*;
import java.util.Hashtable;
public class Utilities {
public static DirContext createLDAPContext(String registry) throws Exception {
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://" + registry + "/");
//env.put(Context.SECURITY_AUTHENTICATION, "simple");
//env.put(Context.SECURITY_PRINCIPAL, "cn=admin,dc=example,dc=com");
//env.put(Context.SECURITY_CREDENTIALS, "your-password");
return new InitialDirContext(env);
}
}
在实用工具类中,我们只有创建 LDAP 使用上下文的方法,通过该上下文,我们可以注册节点并根据节点名称查找节点的远程引用。这个方法可以直接放在节点类中,但如果我们需要更多这样的方法,我们就把它放在实用工具类中,以利用设计模式的优势。
import javax.naming.*;
import javax.naming.directory.*;
import java.rmi.MarshalledObject;
import java.util.*;
public class Launcher {
public static void main(String[] args) throws Exception {
//Input arguments
String ipPort = args[0]; // To connect with the LDAP server <ip:port>
String nodeName = args[1]; // Name of the node
String rootName = null;
if (args.length > 2) {
rootName = args[2];
}
DirContext ldapContext = Utilities.createLDAPContext(ipPort);
//Check that the name does not exist in the registry
try {
ldapContext.lookup("cn=" + nodeName + ",ou=Nodes");
System.err.println("Error: Node (" + nodeName + ") already registered");
return;
} catch (NameNotFoundException ignored) {
}
Node newNode = new Node(nodeName, ipPort, rootName);
ldapContext.bind("cn=" + nodeName + ",ou=Nodes", new MarshalledObject<>(newNode), null);
//Remove node from LDAP registry when the process ends
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
ldapContext.unbind("cn=" + nodeName + ",ou=Nodes");
System.out.println(nodeName + " unbound");
System.out.flush();
} catch (Exception e) {
e.printStackTrace(System.err);
}
}));
System.out.println(newNode.log());
try (Scanner scanner = new Scanner(System.in)) {
String[] command;
while (true) {
command = scanner.nextLine().trim().split(" ");
switch (command[0]) {
case "log":
System.out.println(newNode.log());
break;
case "parent":
newNode.connectParent(command[1]);
break;
case "registry":
NamingEnumeration allEntries = ldapContext.list("ou=Nodes");
while (allEntries.hasMore()) {
NameClassPair entry = (NameClassPair) allEntries.next();
System.out.println("Registered node: " + entry.getName());
}
break;
case "server":
newNode.connectServer(command[1], Integer.parseInt(command[2]));
break;
}
}
} catch (
Exception e) {
e.printStackTrace(System.err);
}
}
}
Launcher实例的创建及其管理由启动器类实现,每个节点实例的创建和管理都需要手动完成。它使用命令行界面来指示相应的节点,节点在启动时以在指定 LDAP 服务器中注册的特定名称创建。部分命令如下:
LDAP 服务器
由于节点是远程对象,因此它们必须能够访问注册表,以便从它们的名称中获取对其他节点的远程引用。Java 提供的解决方案是使用 rmiregistry 在一台机器上初始化注册表服务。但是,当从另一台主机执行 rebind() 等受保护操作时,会抛出安全异常,阻止新节点在包含注册表的机器以外的机器上注册。因此,除了简单之外,本项目还将使用 Apache 服务器作为注册表,并使用轻量级目录访问协议(LDAP)。该协议允许在目录系统中管理 “名称”->“重置节点 ”对的存储,并具有其他附加功能,与 Java 注册表提供的服务相比,可显著改善注册表服务。
使用 LDAP 的优势首先在于其操作的复杂性,乍看之下似乎恰恰相反,但实际上,正是这种复杂性使系统能够在更高的细节级别上适应各种安全和配置需求。一方面,它提供的身份验证和安全功能允许任何主机执行受保护的操作,如注册新节点,只要该主机被 LDAP 服务器识别即可。例如,当创建一个上下文对象以访问服务器并执行操作时,可以选择在其构造函数的 HashMap 中添加带有验证数据的参数。如果创建了上下文,就意味着数据符合服务器的预期,否则,就会认为连接是由未经认证(“恶意”)的主机建立的,从而确保只有系统节点才能操作服务器信息。另一方面,LDAP 可以更有效地集中节点注册,实现更先进的互操作性,并轻松集成 Kerberos 等附加服务。
为确保服务器能作为节点注册表运行,我们必须对其进行特定配置。首先,由于该项目不会部署在有真实用户(可能是恶意用户)的环境中,因此省略了所有身份验证选项,以保持简单明了。接下来,必须定义一个区分名称,以便将节点名称与其对应的远程对象关联起来。在这种情况下,假设我们要防止多个节点注册相同的名称,我们只需将节点名称存储在给定组织单位(ou=节点)内的一个属性中,如 cn=(通用名称)。因此,区分名称的形式为:cn=Node_Name,ou=Nodes
每当创建一个新节点时,都会使用其区分名称和节点实例在 LDAP 服务器中注册,作为目录形式的新条目。同样,删除节点或从注册表中获取其远程引用也需要使用区分名称。在注册表上执行这些操作意味着要与 LDAP 服务器建立开放连接。但是,由于节点是用 Java 制作的,我们可以使用服务来抽象整个连接过程,而只专注于调用操作。节点使用的服务将是目录上下文,通常由 DirContext 接口定义。因此,访问服务器和执行某些管理的过程非常简单,只需创建一个实现 DirContext 接口的对象(在本例中为 InitialDirContext),并为其分配适当的参数来标识服务器,包括 ldap://IP:port/ 形式的 URL、要使用的协议标识,甚至认证参数(在本项目中将不会使用)。
查找、绑定和解除绑定
为简单起见,Launcher 将拥有自己的上下文对象,而每个节点也将拥有自己的上下文对象。这样,Launcher 就可以创建条目和执行删除操作,而每个节点则可以执行查找操作,从节点名称中获取远程引用。删除操作是最简单的,因为它们只需要与要删除的节点相对应的服务器条目的区分名称。如果它存在,则会被删除,调用 unbind() 会成功结束,否则会抛出异常。另一方面,查找和注册操作需要遵循 RFC-2713。在向服务器添加节点的情况下,会使用 bind() 原始函数,其参数是节点所在条目的区分名称及其远程对象。但是,由于节点对象不可序列化,而且 bind() 无法直接获取接口 “实例”,因此绑定函数无法获得节点对象的原样,也无法获得其接口。作为一种变通方法,上述 RFC 强行用 MarshalledObject 对节点实例进行屏蔽。因此,绑定将收到一个由服务器中正在注册的节点组成的 MarshalledObject,而不是原始节点实例。
最后,在上下文中通过 lookup() 原语执行查找操作。如果名称和节点之前未注册,或在此过程中出现意外错误,则会抛出异常。相反,如果操作成功,则会返回与查询的区分名称相关联的 MarshalledObject。但是,lookup() 返回的远程引用包含在 MarshalledObject 封装器中,该封装器存储在注册表中。因此,必须使用 MarshalledObject 的 get() 操作才能获得可用的远程引用。此外,利用该功能还可以防止注册与已注册节点同名的节点,因为在执行 bind() 之前,将通过 lookup() 检查是否存在与区分名称相关的异常。
LLM 推断
关于每个节点的推理过程,节点树中有一个 LLMProcess 类,负责实例化一个用 Python 实现的过程,在该过程中,查询将在返回求解结果之前被传输,因为在 Python 中,我们可以轻松管理 LLM 及其推理流水线。
import java.io.*;
import java.net.*;
public class LLMProcess {
private Process process;
private int port;
public LLMProcess() throws Exception {
ServerSocket serverSocket = new ServerSocket(0);
port = serverSocket.getLocalPort();
serverSocket.close();
process = new ProcessBuilder("python", "basicModule/llm.py", String.valueOf(port)).start();
}
public String sendQuery(String userQuery) throws Exception {
Socket pythonSocket = new Socket("localhost", port);
PrintWriter pythonOutput = new PrintWriter(pythonSocket.getOutputStream(), true);
BufferedReader pythonInput = new BufferedReader(new InputStreamReader(pythonSocket.getInputStream()));
pythonOutput.println(userQuery);
pythonOutput.flush();
String output = pythonInput.readLine();
pythonSocket.close();
return output;
}
public void destroyProcess() {
process.destroyForcibly();
}
}
当实例化一个新的 LLMProcess 时,有必要在机器上找到一个可用端口,以便 Java 和 Python 进程进行通信。为简单起见,这种数据交换将通过套接字完成,因此在通过打开和关闭 ServerSocket 找到可用端口后,llm.py 进程将以端口号为参数启动。它的主要功能是 destroyProcess()(在系统停止时杀死进程)和 sendQuery()(向 llm.py 发送查询并等待响应,每次查询都使用一个新连接)。
import socket, sys, json, threading
from transformers import pipeline
from concurrent.futures import ThreadPoolExecutor
generator = pipeline('text-generation', model='gpt2-large', device="cuda", num_workers=512)
def process_input(input_text):
request = generator(input_text, min_length=200)
return request[0]["generated_text"]
def handle_connection(conn):
with conn:
data = conn.recv(10240).decode()
data = json.loads(data)
data["text"] = process_input(data["text"])
conn.sendall((json.dumps(data) + "\n").encode())
PORT = int(sys.argv[1])
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
with ThreadPoolExecutor() as executor:
s.bind(('localhost', PORT))
s.listen()
while True:
conn, addr = s.accept()
executor.submit(handle_connection, conn)
在 llm.py 中,有一个循环持续等待接受来自 Java 进程的传入连接。连接建立后,ThreadPoolExecutor() 线程会通过 handle_connection() 函数对其进行处理,该函数会读取通道中的输入数据,将其解释为 JSON 格式,并将 “文本 ”字段转发给推理管道。数据返回后,会被发送回 Java 进程(在连接的另一端),函数也会返回,同时释放相应的线程。
模型性能
从脚本中可以看到,管道实例允许我们选择将在托管节点上执行的 LLM 模型。这让我们可以访问所有上传到 Huggingface 网站的模型,其中包括代码生成模型、聊天、一般响应生成等多种选项。
默认情况下,我们使用 gpt2 模型,该模型拥有约 1.17 亿个参数和约 500MB 的权重,是最轻、最容易集成的选项。由于它是一个很小的模型,所以它的答案也比较基本,注意查询解析与输入文本的预测非常吻合。
除了 OpenAI GPT 系列,你还可以选择许多其他可用的模型,不过其中大多数都需要在脚本中插入验证令牌。例如,最近发布了一些现代模型,它们在占用空间和查询完成整个推理过程所需的时间方面进行了优化。Llama3 就是其中之一,它的小型版本有 8B 个参数,而大型版本则有 70B 个参数。
然而,为一个系统选择模型不应该仅仅基于它的参数数量,因为它的架构表示它可以建模的知识量。因此,可以发现小型模型的性能与大型模型非常相似,即它们生成的答案具有非常相似的语言理解水平,同时优化了生成答案所需的计算资源。作为指导,你可以使用 Huggingface 本身提供的基准或专门测试来测量任何 LLM 的上述参数。
上述测试结果以及在特定硬件上响应所需的平均时间是选择机型的一个相当全面的指标。不过,请始终牢记,LLM 必须适合其运行的芯片内存。因此,如果我们在 llm.py 脚本中使用 CUDA 进行 GPU 推理,图形内存必须大于模型大小。如果不是这样,就必须将计算分配到多个 GPU 上,可以是同一台机器上的 GPU,也可以是多台机器上的 GPU,这取决于你想达到的复杂度。
Kotlin 移动客户端
在结束之前,我们可以看看如何在系统中加入新型客户端,从而展示我们迄今为止所构建的一切所提供的可扩展性。当然,这个项目是分布式系统的一次尝试,所以你当然希望它能兼容移动设备,就像普通的 ChatGPT 应用程序兼容 Android 和 iOS 一样。就我们而言,我们可以开发原生 Android 应用程序,不过更好的办法是将该系统改编为多平台 jetpack compose 项目。这一方案在未来的更新中仍有可能实现。
最初的想法是将移动客户端连接到应用程序接口,并使用与网络客户端相同的请求以及 HttpURLConnection 等依赖项。代码实现并不难,Android 官方页面上提供的文档也很有用。不过,我们也可以使用自定义的 Kotlin 中间组件来模拟 API 的功能,使用普通的 TCP Android 套接字进行通信。套接字的使用相对简单,只需稍加管理,即可确保一切运行正常,而且还能对代码进行一定程度的控制。为了解决缺乏监管应用程序接口的问题,我们可以在移动客户端和 Java 节点树之间放置一个 Kotlin 节点,只要网络客户端和应用程序接口是分开的,它就能管理根节点和移动客户端之间的连接。
关于界面,我们模仿的 ChatGPT 应用程序拥有非常简洁和现代的外观,由于 HTTP 版本已经完成,我们可以尝试在 Android Studio 编辑器中尽可能地复制它。
在使用套接字时,我们必须确保用户连接到服务器的正确 IP 地址和端口,这样才能解决用户的问题。我们可以通过每次打开应用程序时都会出现的一个新的初始界面来实现这一目标。如上图所示,这是一个简单的视图,包含一个按钮、一个用于输入 IP 地址的文本视图和一个用于提供用户实时信息的小文本标签。
然后,我们需要让界面类似于真正的聊天,即新信息显示在底部,旧信息向上移动。为此,我们可以插入一个循环视图(RecyclerView),它将占据屏幕的 80% 左右。我们的计划是在该视图中动态添加一个预定义的消息视图,并根据消息是来自用户还是系统而改变。
最后,Android 连接的问题是不能在主线程中进行任何与网络相关的操作,因为这样会产生 NetworkOnMainThreadException(网络主线程异常)。但同时,如果不在主线程中,也无法管理组件,因为会产生 CalledFromWrongThreadException。我们可以通过将连接视图移入主线程来解决这个问题,最重要的是充分利用例程,让你可以通过例程执行与网络相关的任务。
现在,如果你运行系统并输入文本查询,答案应该会在发送几秒钟后出现,就像在 ChatGPT 等大型应用程序中一样。
总结
尽管系统功能完善,但你还可以根据软件和硬件的实施技术进行重大改进。不过,它可以为数量有限的用户提供像样的服务,其范围主要取决于可用资源。最后,应该指出的是,要达到 ChatGPT 这样的真实系统的性能是很复杂的,因为支持它所需的模型大小和硬件都特别昂贵。本文所展示的系统对于小型甚至是中型解决方案来说具有很高的可扩展性,但要实现大规模解决方案,则需要复杂得多的技术,而且可能需要利用该系统的某些结构。