我的学习笔记

土猛的员外

如何训练大模型(LLM)

英文原文:How to train your own Large Language Models

概要介绍

大型语言模型,如OpenAI的GPT-4或Google的PaLM,已经席卷了人工智能领域。然而,大多数公司目前没有能力训练这些模型,并且完全依赖于只有少数几家大型科技公司提供技术支持。

在Replit,我们投入了大量资源来建立从头开始训练自己的大型语言模型所需的基础设施。在本文中,我们将概述我们如何训练LLM(Large Language Models),从原始数据到部署到用户面向生产环境。我们将讨论沿途遇到的工程挑战以及如何利用我们认为构成现代LLM堆栈的供应商:Databricks、Hugging Face和MosaicML。

虽然我们的模型主要是针对代码生成用例设计的,但所讨论的技术和教训适用于所有类型的LLMs,包括通用语言模型。在未来几周和月份中,我们计划深入探讨过程中繁琐细节系列博客文章。

为什么要训练自己的LLMs?

Replit的AI团队经常被问到“为什么要训练自己的模型?”有很多原因可以解释公司决定训练自己的LLM,包括数据隐私和安全性、对更新和改进具有更大的控制力等。

在Replit,我们主要关注定制化、降低依赖性和成本效益。

  • 定制化。训练一个定制模型使我们能够根据特定需求和要求进行调整,包括平台特定功能、术语和上下文,在通用模型(如GPT-4)或甚至代码专用模型(如Codex)中可能无法很好地覆盖。例如,我们的模型经过了针对Replit上流行的特定基于Web的语言(包括Javascript React(JSX) 和Typescript React(TSX))进行了优化。
  • 降低依赖性。虽然我们总是会根据任务选择正确的模型,但我们认为减少对少数人工智能提供商之一产生依赖是有好处的。这不仅适用于Replit还适用于更广泛开发者社区。这就是为什么我们计划开源一些模型,而没有训练它们就无法做到这一点。
  • 成本效益。尽管成本将持续下降,但LLMs对于全球开发者社区来说仍然是难以承受的。在Replit,我们的使命是将下一个十亿软件创作者带到线上。我们相信,在印度用手机编码的学生应该能够访问与硅谷专业开发人员相同的AI。为了实现这一点,我们训练定制模型,这些模型更小、更高效,并且可以大幅降低成本进行托管。

Data pipelines(数据管道)

LLMs需要大量的数据来进行训练。训练它们需要构建强大的数据管道,这些管道高度优化,同时又足够灵活,可以轻松地包含新的公共和专有数据来源。

技术栈

在整个技术栈中,我们的主要数据源可以在 Hugging Face的The Stack 获得。Hugging Face 是一个很好的数据集和预训练模型资源。他们还提供了一系列有用的工具作为 Transformers 库的一部分,包括标记化、模型推理和代码评估等工具。

The Stack由BigCode项目提供。有关数据集构建的详细信息可在Kocetkov et al. (2022)中找到。经过去重处理,数据集的1.2版本包含约350种编程语言编写的大约2.7 TB的许可源代码。

Transformers库在抽象化模型训练中处理数据规模等多个挑战方面表现出色。然而,我们发现它对于我们的流程来说还不够,因为我们需要更多地控制数据并能够以分布式方式进行处理。

llm-training

数据处理

当需要进行更高级的数据处理时,我们使用Databricks来构建我们的流水线。这种方法也使得我们能够轻松地将其他数据源(如Replit或Stack Overflow)引入到我们的过程中,在未来的迭代中计划这样做。

第一步是从Hugging Face下载原始数据。我们使用Apache Spark在每种编程语言之间并行化数据集构建器进程。然后,我们重新分区数据,并以优化设置为下游处理重写成parquet格式。

接下来,我们转向清理和预处理数据。通常情况下,去重复和修复各种编码问题很重要,但The Stack已经使用Kocetkov et al.(2022年)概述的近似去重技术为我们完成了这项工作。然而,在开始引入Replit数据到我们的流水线时,必须重新运行去重复过程。这就是拥有像Databricks这样的工具所带来好处之一,在其中可以将The Stack、Stackoverflow和Replit 数据视为较大数据湖中的三个来源,并根据需要在下游过程中利用它们。

使用Databricks 的另一个好处是可以对底层数据运行可扩展且易于跟踪分析。 我们对所有类型的统计信息进行汇总统计,并检查长尾分布,并诊断任何问题或不一致性 。所有这些都在Databricks笔记本中完成,这些笔记本还可以与MLFlow集成,以跟踪和重现我们沿途的所有分析。这一步相当于对数据进行定期X光检查,也有助于为预处理采取各种步骤提供信息。

对于预处理,我们采取以下步骤:

  • 我们通过删除任何个人可识别信息(PII),包括电子邮件、IP地址和秘密密钥来匿名化数据。
  • 我们使用多种启发式方法来检测和删除自动生成的代码。
  • 对于某些语言的子集,我们会删除无法编译或不符合标准语法解析器要求的代码。
  • 根据平均行长度、最大行长度和字母数字字符百分比过滤文件。

the-stack-db-notebook

Tokenization and vocabulary training(分词和词汇训练)

在进行分词之前,我们使用相同数据的随机子样本训练自己的定制词汇表来进行模型训练。自定义词汇表使我们的模型更好地理解和生成代码内容。这将导致改进模型性能,并加快模型训练和推断速度。

这一步是整个过程中最重要的步骤之一,因为它在我们流程(数据管道、模型训练、推断)的所有三个阶段中都被使用到。这凸显了拥有一个强大而完全集成化基础设施对于您的模型训练过程至关重要。

我们计划在未来的博客文章中深入探讨标记化。从高层次上看,需要考虑一些重要事项,如词汇量大小、特殊token以及保留用于哨兵token的空间。

一旦我们完成了自定义词汇表培训,就会对数据进行标记化处理。最后,我们构建培训数据集并将其写入优化用于输入到模型培训过程中分片格式文件当中。

Model training(模型训练)

我们使用MosaicML来训练我们的模型。在此之前,我们曾经部署过自己的训练集群,但是发现MosaicML平台为我们提供了一些关键优势。

多个云服务提供商。Mosaic使我们能够利用不同云服务提供商的GPU,而无需设置帐户和所有必需的集成开销。
LLM训练配置。Composer库具有多种针对各种模型和不同类型培训目标进行调整的配置。
托管基础设施。他们管理的基础设施为我们提供了编排、效率优化和容错(即从节点故障中恢复)。

在确定模型参数时,我们考虑到模型大小、上下文窗口、推理时间、内存占用等方面之间的各种权衡取舍。较大的模型通常具有更好性能,并且更适合于迁移学习。然而这些模型需要更高计算要求进行培训和推理, 后者对于我们尤其重要. Replit是一个云原生IDE,其性能感觉就像桌面本地应用程序一样快速, 因此代码完成模型需要非常快速. 出于这个原因, 我们通常会选择具有较小内存占用量和低延迟推理的较小模型。

除了模型参数之外,我们还可以从各种训练目标中选择,每个目标都有其独特的优点和缺点。最常见的培训目标是下一个令牌预测。这通常对于代码完成效果很好,但无法考虑文档更深层次上下文。这可以通过使用“填充-中间”目标来减轻,其中在文档中掩盖一系列令牌,并且模型必须使用周围上下文来预测它们。另一种方法是UL2(无监督潜在语言学习),它将不同的客观函数作为去噪任务进行语言模型培训,在该任务中,模型必须恢复给定输入的缺失子序列。

loss-curves-replit

一旦我们确定了模型配置和训练目标,就会在多节点GPU集群上启动训练运行。我们能够根据正在训练的模型大小以及希望完成训练过程的速度来调整为每个运行分配的节点数。运行大规模GPU集群非常昂贵,因此重要的是我们以最有效的方式利用它们。我们密切监视GPU利用率和内存,以确保从计算资源中获得最大可能使用。

我们使用Weights&Biases监控培训过程,包括资源利用情况以及培训进展情况。 我们监视损失曲线,以确保该模型在培训过程中每一步都有效地学习。 我们还观察损失峰值。 这些是损失值突然增加并通常表示底层培训数据或模型架构存在问题。 由于这些事件通常需要进一步调查和潜在调整,在我们的流程中强制执行数据确定性,因此可以更轻松地复制、诊断和解决任何此类损失峰值潜在来源问题。

Evaluation(评估)

为了测试我们的模型,我们使用了 Chen et al. (2021).所描述的 HumanEval 框架的变体。我们使用该模型生成一个 Python 代码块,给定函数签名和文档字符串。然后,我们运行一个测试用例来确定生成的代码块是否按预期工作。我们运行多个样本并分析相应的 Pass@K 数字。

这种方法最适合 Python,并配备了可用于评估器和测试用例。但是由于 Replit 支持许多编程语言,因此我们需要对各种其他语言进行模型性能评估。我们发现这很难做到,并且没有广泛采用的工具或框架提供全面综合解决方案。两个特定挑战包括在任何编程语言中召唤出可重复生产环境以及缺乏广泛使用标准化测试用例(例如 HTML、CSS 等)。幸运的是,“在任何编程语言中创建可重复生产环境” 是 Replit 的专长! 我们目前正在构建一种评估框架,允许任何研究人员插入并测试他们的多语言基准数据集。 我们将在未来发布博客文章时讨论此问题。

humaneval-results-replit

Deployment to production生产环境部署

一旦我们训练和评估了模型,就该将其部署到生产环境中。正如之前提到的那样,我们的代码补全模型应该感觉非常快速,并且请求之间延迟非常低。我们使用NVIDIA的FasterTransformer和Triton Server来加速推理过程。FasterTransformer是一个实现基于transformer神经网络推理加速引擎的库,而Triton则是一个稳定且具有易于配置性能的推理服务器。这种组合为变压器模型与底层GPU硬件之间提供了高度优化的层,并允许对大型模型进行超快速分布式推断。

在将我们的模型部署到生产环境后,我们可以使用Kubernetes基础架构自动缩放以满足需求。虽然我们在先前博客文章中已经讨论过自动缩放,但值得一提的是,在托管推理服务器时会出现一系列独特挑战,包括大量工件(即模型权重)和特殊硬件要求(即不同大小/数量GPU)。 我们设计了部署和集群配置以便能够快速可靠地交付产品。例如,我们设计集群以解决单个区域内GPU短缺问题并寻找最便宜的可用节点。

在将模型放在实际用户面前之前,我们喜欢自己测试一下并了解模型的“氛围”。我们之前计算的HumanEval测试结果很有用,但与模型一起工作才能真正感受到它,包括其延迟、建议的一致性和总体帮助程度。将模型放在Replit员工面前就像翻开一个开关那样容易。 一旦我们对此感到满意,我们会再次翻转开关,并将其推出给其他用户使用。

monitoring-replit

我们继续监控模型性能和使用指标。对于模型性能,我们监测请求延迟和GPU利用率等指标。对于使用情况,我们跟踪代码建议的接受率,并将其分解成多个维度,包括编程语言。这也使我们能够进行A/B测试不同的模型,并得到一个量化的比较一个模型与另一个模型之间的差异的方法。

Feedback and iteration反馈和迭代

我们的模型训练平台使我们能够在不到一天的时间内从原始数据转换为部署在生产环境中的模型。但更重要的是,它允许我们训练和部署模型、收集反馈,然后根据反馈快速迭代。

对于我们的流程来说,保持稳健性以适应底层数据源、模型训练目标或服务器架构上任何变化也很重要。这使我们能够利用一个日新月异且每天都似乎带来新鲜令人兴奋消息的领域中出现的新进展和功能。

接下来,我们将扩展平台以使其能够使用Replit本身来改善我们的模型。这包括诸如基于人类反馈进行强化学习(RLHF)等技术,以及使用从Replit赏金活动收集到的数据进行指导调整。

下一步

虽然我们已经取得了很大的进展,但是训练LLM仍处于非常早期阶段。我们有很多改进需要做,还有许多难题需要解决。随着语言模型的不断发展,这种趋势只会加速。与数据、算法和模型评估相关的新挑战将持续出现。

如果您对训练LLMs所涉及到的各种工程挑战感到兴奋,我们很乐意与您交流。我们喜欢反馈,并且希望听听您对我们缺失之处以及您会采取哪些不同方法的看法。

Replit AI团队一直在寻找才华横溢的工程师、研究人员和建设者。请务必查看我们职业页面上公开招聘岗位信息。如果没有合适的角色但认为自己可以做出贡献,请与我们联系; 我们很乐意收到来信!



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Transformer原理简明讲解

原文:What Are Transformer Models and How Do They Work?

Transformer模型是机器学习中最令人兴奋的新发展之一。它们在论文Attention is All You Need中被介绍。Transformer可以用于写故事、文章、诗歌,回答问题,翻译语言,与人类聊天,甚至可以通过对人类来说很难的考试!但是它们到底是什么呢?你会很高兴地知道,Transformer模型的架构并不复杂,它只是一些非常有用组件的串联,并且每个组件都有自己的功能。在本篇文章中,您将了解所有这些组件。

此博客文章包含简单概念性介绍。关于Transformer模型及其工作原理更详细描述,请查看Cohere公司Jay Alammar撰写的这两篇优秀文章!

简而言之,Transformer的作用是什么?想象一下你在手机上写短信。每输入一个单词后,你可能会得到三个建议的单词。例如,如果你输入“Hello, how are”,手机可能会建议下一个单词为“you”或“your”。当然,如果你继续选择手机中的建议单词,很快就会发现这些单词组成的信息毫无意义。如果你看每一组连续的3或4个单词,则它们可能有意义,但这些单词并不构成任何有意义的句子。这是因为手机使用的模型没有携带消息整体上下文,只是预测最近几个字后更可能出现哪个字。相反地,“Transformer”可以跟踪所编写内容背景,并且这就是它们编写文本使其具有意义之处所在。

img

​ 手机可以建议在短信中使用下一个单词,但没有生成连贯文本的能力。

我必须对你诚实,第一次发现Transformer逐字建立文本时,我简直不敢相信。首先,这不是人类构成句子和思想的方式。我们首先形成一个基本思想,然后开始完善它并添加单词。这也不是ML模型完成其他任务的方式。例如,图像不是以这种方式构建的。大多数基于神经网络的图形模型会形成图像的粗略版本,并逐渐完善或添加细节直到完美为止。那么为什么Transformer模型要逐字构建文本呢?一个答案是因为这样做非常有效。更令人满意的答案是因为Transformer非常擅长跟踪上下文,所以它选择下一个单词时恰好符合保持某个想法进行所需。

那么Transformer如何训练呢?使用了大量数据,事实上包括互联网上所有数据。因此当您将“你好吗”输入到转换器中时,它只需要根据互联网上所有文本知道最佳下一个单词就是“你”。如果您给出更复杂的命令,则可能会确定使用良好的下一个单词,“曾经”。然后它将该单词添加到命令中,并确定下一个好单词是“在……之上”,以此类推。逐字逐句,直到它写出一篇故事为止。

命令:Write a story.
回应:Once

下一条命令:Write a story. Once
回应:upon

下一条命令:Write a story. Once upon
回应:a

下一条命令:Write a story. Once upon a
回应:time

下一条命令: Write a story. Once upon a time
回复: there

等等…

现在我们知道了transformers的作用,让我们来看一下它们的架构。如果你已经看过transformer模型的架构,你可能会像我第一次看到时那样惊叹不已,因为它看起来非常复杂!然而,当你将其分解成最重要的部分时,就没有那么难了。Transformer有4个主要部分:

  1. 分词
  2. 嵌入
  3. 位置编码
  4. Transformer块(其中包含多个)
  5. Softmax

其中第四个——Transformer块是最复杂的。可以连接多个这样的块,并且每一个都包含两个主要组件:Attention(注意力)和Feedforward(前馈)组件。

transformer-arc

Transformer模型的架构

让我们逐个学习这些部分。

Tokenization

Tokenization (分词)是最基本的步骤。它包括一个大型的标记数据集,其中包含所有单词、标点符号等。分词步骤将每个单词、前缀、后缀和标点符号都发送到库中已知的Token。

Tokenization
Tokenization :将单词一个个装进token

例如,如果句子是“Write a story.”,那么对应的4个token将是和<.>。

Embedding

一旦输入内容被tokenized,就该将单词转换成数字了。为此,我们使用embedding(嵌入)。Embedding是任何大型语言模型中最重要的部分之一;它是实现文本与数字转换的桥梁。由于人类善于处理文本而计算机善于处理数字,因此这个桥梁越强大,语言模型就越强大。

简而言之,文本嵌入将每个文本转换为一个向量。如果两个文本片段相似,则其对应向量中的数字也相似(逐位意味着同一位置上的每对数字都相似)。否则,如果两个文本片段不同,则其对应向量中的数字也不同。如果您想了解更多信息,请查看有关文本嵌入的文章视频

尽管嵌入是数值化的,但我喜欢从几何角度来想象它们。试想一下存在一个非常简单的嵌入方式,可以将每个单词映射到长度为2(即包含2个数值) 的向量上。如果我们按照这两个数值所表示坐标定位每个单词(比如在街道和大道上),那么所有单词都站在一个巨大平面上。在这张平面上,相似的单词会靠近彼此,而不同的单词则会远离。例如,在下面这个嵌入中,“cherry”的坐标是[6,4],与“strawberry” [5,4] 接近但与“castle” [1,2] 相距较远。

embedding
Embedding:将单词(标记)转换为向量

在更大的embedding情况下,每个单词都被赋值到一个更长的向量(比如长度为4096),那么这些单词不再存在于二维平面上,而是存在于一个大的4096维空间中。然而,在这个高维大空间中,我们仍然可以认为单词之间有近有远,因此embedding概念仍然具有意义。

词embedding可以推广到文本embedding,包括整个句子、段落甚至更长的文本都会被赋值到一个向量中。然而,在transformer的情形中,我们将使用词嵌入,这意味着句子中的每个单词都会被赋值到相应的向量中。更具体地说,输入文本中的每个token都将被定位到其对应的embedding向量中。

例如,如果我们正在考虑的句子是“Write a story.”并且标记是和<.>,那么每个标记都将被赋值到一个向量中,并且我们将有四个向量。

embedding2
通常embedding将每个单词(token)赋值到一个数字列表中。

Positional encoding(位置编码)

一旦我们获得了与句子中每个token对应的向量,下一步就是将它们全部转换为一个向量进行处理。将一堆向量转换为一个向量最常见的方法是逐分量相加。也就是说,我们单独添加每个坐标。例如,如果这些(长度为2)向量分别是[1,2]和[3,4],则它们对应的总和为[1+3, 2+4],即[4,6]。这种方法可以工作,但有一个小细节需要注意:加法满足交换律,也就是说如果你以不同顺序添加相同的数字,则会得到相同的结果。在这种情况下,“我不难过我很开心”和“我不开心我很难过”两句话将得到相同的向量结果(假设它们具有相同单词但顺序不同)。这并不好。因此我们必须想出一些方法来给出两个句子不同的向量表示方式。多种方法可行,在本文中我们选择其中之一:位置编码(Positional Encoding) 。位置编码包括将预定义序列中的一系列向量添加到单词嵌入(embedding) 向量上去,并确保我们获得每个句子都有唯一表示形式且具有相似语义结构、仅单词顺序不同的句子将被分配到不同的向量。在下面的示例中,“Write”、“a”、“story”和“.”所对应的向量成为带有位置信息标签“Write(1)”,“a(2)”,“story(3)”和“. (4)”的修改后向量。

positional-encoding

位置编码会为每个单词添加一个位置向量,以便跟踪单词的位置。

现在我们知道每个句子都有一个独特的向量,这个向量携带了句子中所有单词及其顺序的信息,因此我们可以进入下一步。

Transformer block

让我们回顾一下目前为止的内容。单词被输入并转换成token(分词),然后考虑到它们的顺序(位置编码)。这给了我们每个输入模型的token一个向量。现在,下一步是预测这个句子中的下一个单词。这是通过一个非常大、非常复杂的神经网络来完成的,该网络专门训练用于预测句子中的下一个单词。

我们可以训练这样一个大型网络,但是通过添加关键步骤:Attention(注意力)组件,我们可以极大地改进它。在开创性论文《Attention is All you Need》中引入的注意力机制是Transformer模型的关键成分之一,也是它们如此有效的原因之一。下面将解释注意力机制,但现在先想象它作为一种向文本中每个单词添加上下文的方式。

在前馈网络的每个块中都添加了注意力组件。因此,如果您想象一个大型前馈神经网络,其目标是预测下一个单词,并由几个较小的神经网络块组成,则在每个这些块中都添加了注意力组件。然后,Transformer的每个组件(称为transformer 块)由两个主要组件构成:

  • 注意力组件
  • 前馈组件

Transformer是许多Transformer块的串联。

transformer-add

Transformer是许多Transformer块的串联。每个Transformer块由一个注意力组件和一个前馈组件(神经网络)组成。

Attention

Attention步骤涉及一个非常重要的问题:上下文问题。有时,同一个单词可以用不同的意思。这往往会让语言模型感到困惑,因为embedding只是将单词赋值到向量中,而不知道他们使用的单词定义。

Attention是一种非常有用的技术,可以帮助语言模型理解上下文。为了理解Attention的工作原理,请考虑以下两个句子:

句子1: The bank of the river
句子2: Money in the bank.

正如您所看到的,单词“bank”在两个句子中都出现了,但含义不同。在第一个句子中,我们指的是河流旁边的土地,在第二个句子中则指持有货币的机构。计算机对此一无所知,因此我们需要以某种方式将这些知识注入其中。什么能帮助我们呢?好吧,似乎句子中其他单词可以拯救我们。对于第一个句子,“the”和“of”这些单词对我们没有任何作用。但是,“river”这个单词让我们知道正在谈论河流旁边的土地。同样,在第二个句子中,“money”这个单词让我们明白“bank”的意思现在是指持有货币的机构。

bank

Attention有助于根据句子(或文本)中的其他单词为每个单词提供上下文。

简而言之,注意力机制的作用是将句子(或文本片段)中的单词在词嵌入中靠近。这样,在句子“Money in the bank”中,“bank”一词将被移动到“money”的附近。同样,在句子“The bank of the river”中,“bank”一词将被移动到“river”的附近。这样,两个句子中修改后的单词“bank”都会携带周围单词的某些信息,为其添加上下文。

Transformer模型中使用的注意力机制实际上更加强大,它被称为多头注意力。在多头注意力中,使用了几个不同的嵌入来修改向量并为其添加上下文。多头注意力已经帮助语言模型在处理和生成文本时达到了更高的效率水平。如果您想更详细地了解注意力机制,请查看这篇博客文章及其相应视频

The Softmax Layer

现在你已经知道一个transformer是由许多层transformer块组成的,每个块都包含一个attention和一个feedforward层,你可以将它看作是一个大型神经网络,用于预测句子中的下一个单词。Transformer为所有单词输出分数,其中得分最高的单词被赋予最有可能成为句子中下一个单词的概率。

Transformer的最后一步是softmax层,它将这些分数转换为概率(总和为1),其中得分最高对应着最高的概率。然后我们可以从这些概率中进行采样以获取下一个单词。在下面的例子中,transformer给“Once”赋予了0.5的最高概率,并给“Somewhere”和“There”赋予了0.3和0.2 的概率。一旦我们进行采样,“once”就被选定,并且那就是transformer 的输出结果。

softmax

softmax层将分数转换为概率,这些概率用于选择文本中的下一个单词。

现在怎么办?好的,我们重复这个步骤。现在我们将文本“Write a story. Once”输入模型中,很可能输出结果是“upon”。再次重复此步骤,Transformer最终会写出一个故事,例如:“Once upon a time, there was a …”(“从前有一天,有一个……”)。

Summary总结

在这篇文章中,您已经学习了transformers的工作原理。它们由几个块组成,每个块都有自己的功能,共同工作以理解文本并生成下一个单词。这些块如下:

Tokenizer:将单词转换为token。
Embedding:将token转换为数字(向量)。
Positional encoding:在文本中添加单词顺序。
Transformer block:猜测下一个单词。它由注意力块和前馈块组成。
Attention:为文本添加上下文信息。
Feedforward:是Transformer神经网络中的一个模块,用于猜测下一个单词。
Softmax函数: 将得分转换为概率以便采样出下一个单词。

重复执行这些步骤就可以写出您所看到的transformers创建的惊人文本。

Post Training(后期训练)

现在你已经知道了Transformer是如何工作的,但我们还有一些工作要做。想象一下:你扮演Transformer,“阿尔及利亚的首都是什么?” 我们希望它回答“阿尔及尔”,然后继续进行。然而,这个Transformer是在整个互联网上训练出来的。互联网很大,并不一定是最好的问题/答案库。例如,许多页面会列出长长的问题列表而没有答案。在这种情况下,“阿尔及利亚的首都是什么?”之后的下一个句子可能会是另一个问题,比如“阿尔及利亚人口数量?”,或者“布基纳法索首都在哪里?”。 Transformer不像人类那样思考他们的回应,它只是模仿它看到过(或提供过)数据集中所见到内容。

那么我们该怎样使Transformer回答问题呢?

答案就在于后期训练。就像您教导一个人完成某些任务一样,您可以让Transformer执行任务。 一旦将Transformer训练成整个互联网上使用时,则需要再次对其进行大量数据集培训以涉及各种问题和相应答案。Transformer(就像人类一样)对他们最后学到的事情有偏见,因此后期训练已被证明是帮助Transformer成功完成所要求任务的非常有用的步骤。

后期训练还可以帮助处理许多其他任务。例如,可以使用大量对话数据集来进行Transformer的后期培训,以使其作为聊天机器人表现良好,或者帮助我们编写故事、诗歌甚至代码。

了解更多

如上所述,这是一个概念性的介绍,让您了解transformers如何生成文本。如果您想要深入了解transformer背后的数学原理,请观看以下视频(YouTube)

写在最后

正如你所看到的,Transformer的架构并不复杂。它们是由几个块连接而成,每个块都有自己的功能。它们之所以能够工作得如此出色,主要原因在于它们具有大量参数,可以捕捉上下文中许多方面的信息。我们很期待看到您使用Transformer模型构建什么!






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



AI大模型面面观,还不上车?

这可能是有史以来最大的变革

AI会把人类的工作抢完?

这波AI巨浪是由OpenAI掀起的,ChatGPT就是那个炸起巨浪的炸弹。通过ChatGPT这类对话式应用的使用,我相信大部分朋友都已经知道了这波AI和以前很不一样,这次不开玩笑!

2030

看看红杉资本去年发的一篇AI将逐步取代人类工作的预测,以目前全球AI军备竞赛的状况来看,极有可能会到来的更快。到2030年,以下工作会被取代:

  • 文字工作:影响到绝大部分的办公室白领,但是别担心,只要老板还是个人,他会留几个人聊聊天的;
  • 编程和计算:影响到程序员、架构师、数据库工程师等等;
  • 美术和设计:UI设计师、建筑设计师、非顶级艺术家、摄影师等等;
  • 视频、3D制作与游戏开发:电影工作者、3D制作工程师,以及游戏设计和开发人员。

那么问题就来了:我们的孩子们怎么办?

他们现在学的,大差不差还是我们以前学的内容,但是他们面临的工作可不是我们出社会时面对的工作了,虽然长期来看AI可以让人类更少工作、更多获得。两次工业革命、一次信息化革命,至少没有让现有知识体系产生动摇,相反让知识体系变得更加重要,学历就变得尤为重要。但这次(算第四次)革命却一锤一锤地在敲碎人类几千年的知识体系。

这个问题也困扰了我好些时间,最终,我觉得自己就做个AI降临派吧——去融入,去拥抱,去理解LLMs(大语言模型)、在工作中使用AI去提升工作,比如用好Prompt。我觉得自己学习都还不晚,那孩子们就更不用担心了,他们会深度使用好AI,就像我们熟练使用计算机和汽车一样。

不做砸珍妮纺纱机的人

好吧,那就先保持自己不落后。

飞船

我觉得目前的AI就像一个从未见过的新机器,从人类以前没有想象过的方式和速度呼啸而来。我们不能等到一年后才后知后觉,那时候已经追不上了…

我们需要现在就开始,从了解底层的技术——大模型开始,再来看看我们需要怎么去拥抱AI。

各类大模型概览

首先我们来看大模型。AILLMs

这是我自己整理的目前大模型分类,当然了肯定不止这些,但是这些对我来说相对熟悉,且本身也具备一定代表性。

行业标准OpenAI

这次冲在最前面的大模型是基于GPT(Generative Pre-trained Transformer,基于Transformer的生成式预训练模型)的GPT-3.5、GPT-4。这些模型的基础信息我就不多说了,大家可以参考之前的文章(#链接)。

OpenAI的产品当然不止ChatGPT,他们还有作图的DALL·E、语音转文字的Whisper,以及自动代码工具等。

OpenAI在GPT-2的时候是开源的,但是到GPT-3之后就闭源了,目前外界不知道OpenAI目前的最高能力已经到什么层度了,他们是这一波潮流的引领者。

搅局者LLaMa

目前在OpenAI的开源对手里面,最活跃的是Meta(原Facebook)出品的LLaMa大模型,他们拥有7B(70亿参数,后同)、13B、33B和65B等多个版本,能力也是依次上升,但是硬件要求也会越来越高。作为拥有深度学习三巨头之一杨立昆的Meta,本来应该是想在AI大模型界当一个Android的,那个最大的“在野党”,就像现在Android之于iOS。但是LLaMa目前申请权重文件非常麻烦(虽然其实已经泄露),加上不能直接用于商业,所以在商业公司层面上已经有一丝丝降温。

基于LLaMa的升级很多,先是有斯坦福大学主导的升级版——Alpaca,再是一直在宣传多么厉害多么厉害、几个大学联合制作的Vicuna。因为LLaMa的商用问题,所以我就没有去下载部署,只是在线试用了LLaMa的集大成者Vicuna。就中文来说,Vicuna可不咋滴,当然了,如果是本地部署,不断训练,肯定是会明显好于现在的能力。

另外,LLaMa大模型还有比较多的中文版或LoRA版(可以理解为简配版,为了大家更容易在消费级显卡上炼丹),比如用C++重新做了一遍的LLaMa.cpp,听说效率提升了不少。还有LLaMa的中文版,专门喂了很多中文预料来训练的,就是Chinese-LLaMa-Alpaca,还未亲测。Vicuna也有中文版,大家有兴趣可以下载安装试试,反正我对LLaMa一类的,还是继续观望,等它可以商用再说吧。

老牌劲旅

这次AI大爆发的一个核心技术组件是Transformer,它很好地解决了机器的长期记忆的问题,这个技术的发明者就是Google。但是Google却在后来移情别恋,以至于OpenAI用了大量Google挖过来的人,发布了ChatGPT之后才发觉事情不妙,仓促发布了Bard。虽然我觉得就技术和数据来说,Google都是有希望追上来的,但是他们的现有业务会不会限制他们在AI上发力(毕竟这和搜索肯定是有直接冲突的),这是一个创新者的窘境。

另外一家目前已经很不错的老牌劲旅是Claude,而且现在Claude提供免费API与Slack结合,对OpenAI已经有一些冲击了。

对于亚马逊来说,他们的Titan肯定还是基于云,买买买,我都能服务。

中国选手

下面出场的是中国选手,网络上已经有一张图了,展示了十多家国内的参赛选手。但是仔细来看,目前可申请试用的应该自有百度的文心一言和阿里的通义千问

百度有丰富的搜索数据,加上有陆奇、吴恩达等多位货真价实的国际顶级大牛,还是有一些积累的,应该是村里面第一个站出来的人。只是前面可能更多的方向是在无人驾驶(更多是在感知智能),而忘了点认知智能的科技树。

阿里这次动作有点迅速,这一块感觉并不是阿里强项,虽然同样拥有足够的数据,拥有TensorFlow和Pytorch1.0的作者之一贾扬清(已离开阿里)。

目前外界最看好的第三家可以站出来是字节跳动,有钱、有数据、有用户,还有人才。

至于其他的公司,感觉都还是比较难的,这件事情,再去找开源模型包个壳其实意义不大,因为这里还要涉及到数据、算力和训练,以及工程化地调优。数据把一大众公司挡在门外了,然后算力又把另一家巨头挡住。最后,很多公司是真的没有技术、没有人才密度的,拿项目也许没问题,但是这种拼硬实力的事情,怎么可能做得成。

这次清华大学无愧国内NO.1的存在,推出的ChatGLM还不错,特别是对于中文的支持。我自己部署了ChatGLM-6B的模型,在我的RTX-4090上跑起来还是很顺畅的,但是也只能先玩着了,因为听到他们7位数的授权费,感觉为了后面不吃官司,还是要提早找一个可商用的大模型练起来。

6b

第一个可商用的开源大模型

这周,第一个可商用大模型它就来了,是Databricks发布的Dolly2.0。首先介绍一下Databricks,它最著名的产品就是Spark,对的,就是那个Apacha Spark。

spark2

说回Dolly2,为什么它可以商用的?先从LLaMa系列大模型为什么不能商用说起。

大模型的技术开发原理其实对于大部分顶尖公司来说不难,因为前面说了GPT-2是开源的,而且牛逼的公司可以根据Google、OpenAI等发布的论文中描述的技术原理开发出大模型。但是,训练却很难,最大的问题就是训练数据,据说OpenAI里面30%的人是在做数据标准、校验等工作,而且在训练ChatGPT的时候,ChatGPT还采用众包方式,让大量肯尼亚人参与数据处理,花费了不少钱。而LLaMa、Alpaca和Vicuna等都使用了ChatGPT去获得非常有价值的原始数据(问题与配对的答案),等于转载使用,所以存在商业化问题。

而Dolly呢?他们在Dolly1的时候也是使用ChatGPT来获得训练数据的,所以依然无法商业。于是,Databricks做了一个大胆的尝试,让公司的5000员工去想,不能借助ChatGPT,也不能直接去其他网站是抄版权内容,全部要自己想,还要保证质量,为此他们还办了个内部比赛。就这样,Databricks在一周内完成1.5万条数据,平均每人3条,因此,他们的这个模型是可以商业的。这个故事在他们的官方博客上有介绍,可以查看。

关于Dolly2,代码可以去Github:databrickslabs/dolly上查看;可商用的模型在Hugging Face:dolly-v2-12b(23G的基于Pytorch的Model)。

目前看到的个别已经部署过的推友评价,中文的处理能力是够呛的,但是咱们不怕啊,数据就是咱们优势啊(后面会讲到是为什么)。

作图模型

Midjouney目前的知名度应该仅次于ChatGPT,作图最好的平台之一,但是真的要用,最好是付费(我已经交了20美元)。

对了,首图就是Midjouney上制作的,还包括下面这张图(一艘GPU飞船):

GPU飞船

目前最大对手是开源的Stable Diffusion,可以直接部署在像我这样的消费级显卡上的,而且处理速度极快。当然,在普遍的自动生成图片质量上还是不如Midjouney的。作图这个服务,对于prompt的收集、意见反馈等感觉比ChatGPT还重要,更加需要人类反馈自学习(RLHF)。

AI管家出现

前面说了这么多大模型,都还没说太多基于这些大模型的应用呢,嗯,我也不太想展开来讲应用,太太太多了。

那么面对这么多的AI应用,怎么简便地去使用呢,怎么样能让我写出更好的Prompt呢?

这时候,LongChain就出来了,一种可以自动帮助我们把一个个应用串起来的应用。

然后,微软出了一个Jarvis(贾维斯),听名字你应该知道是什么了;

这几天最火的,当然是Auto-GPT了,一个月不到,github上的stars已经过了6万。

auto

我们有弯道超车的办法

看到上面说了这么多大模型,会不会觉得头麻了。如果你是第一次接触,那就是麻妈给麻子开门,麻到家了!

但是这里面中国企业起步晚,参与少,对于AI这种必然的未来趋势,我们该怎么办呢?有办法像移动互联网一样后发先至吗?

我觉得是有的,这里非常简单的聊一下。

AI这个事情,主要是有三个决定因素:大模型、算力和数据。

大模型今天已经说了,其实开源的肯定也会不断完善,加上相关核心论文是公开的,这方面对我国来说应该不是最大的问题(君不见,国外这一波,华人比例非常之高);

算力应该也不会是长期的问题,芯片这个问题,我就不多谈了,反正这个不会成为长期的问题;

那么剩下的就是数据了。前面也说了,数据非常重要,也是模型“聪明”还是“白痴”最重要的影响因素。国内的弯道超车应该就在这里了。大家应该还有印象,今年的Q1,国家强调了数字化改革,然后由宣布组建国家数据局。我不知道大家怎么看,虽然也看到一些冷嘲热讽,但是我觉得国家数据局最终的目的是要把国内大部分的重要领域的数据都要收集上来。然后(我yy一下),进行大模型的训练。如果有充分详实的数据,以后很多事情就好办了。比如问“怎么让杭州市民用更低的价格,或者更丰富更便宜的菜品?” 如果是数据充分的AI大模型,会给出好几条方案,其中可能有(依然是yy,内容上别当真)“增加安徽、山东到杭州的高速公路建设,增加蔬菜、肉类等食材的供给,中间涉及到XX个县,根据当前土地补贴政策,整个工程造价XXXX”。

然后呢,我觉得可能只有中国在数据量(看看移动互联网我们是怎么用的,各地支付、遍地小哥)、数据汇总组织统筹能力上都是最佳的,所以在数据这个事情上,我们是可以后发先至,弯道超车的。

东市买骏马,西市买鞍鞯

对于个人呢,前面说了,要先扒上这趟车,不能再等等,等等就可能追不上了。

对于有编程基础的朋友,可以恢复Python的学习、Pytorch的学习等,把大模型研究起来,开发个基于大模型的应用玩玩;

对于产品经理,每天你需要花一个小时去看看这个世界在AI领域发生了什么,借助AI,甚至可以将所有应用重做一遍;

对于所有人,如果我今天说的大部分内容你都没有听过,那你已经接近被时代甩远的危险了,找资料好好学。比如书、比如博客,也有类似极客时间这类,大家也可以看看,每天上下班都可以听,真的别落后了。

ad1ad2

上周末,花了22400元攒了台主机,主要是为了那块RTX4090。有句话是“要做瓷器活,先要有金刚钻”,所以这台电脑,要买!

rtx4090

AI是一个新时代的战场,个人玩家也可以玩,但是,先准备好你的装备吧。木兰诗:“东市买骏马,西市买鞍鞯”,买好上战场了!

这是微信公众号的文章链接:土猛的员外






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



ChatGPT的原理、咒语和应用

前言

今天给大家讲讲ChatGPT的工作原理,如何使用,基于它的应用等内容。

ChatGPT的工作原理

其实要讲清楚ChatGPT的原理是很难的,因为我看的几篇能把原理讲清楚的文章基本上都在3万字以上,而且还配了大把的公式和图片。但是我不想这么写,所以就抛弃掉很多细节(特别是数学公式和数学图例),希望能用大白话给大家讲清楚,let’s go!

ChatGPT最浅显的一个工作原理就是:一个单词一个单词的输出,它会根据前面的内容猜测后面应该输出什么单词——是的,这就是ChatGPT最浅显也是最核心的原理,如果你不想深究技术方面的内容,那记住这一点是非常重要的。当ChatGPT在写一篇文章时,它实际上只是一遍又一遍地问自己:“在已有的文本基础上,下一个词应该是什么?“

jinwan

为了能够输出下一个词,你可以想象的到,ChatGPT的内部是有一个概率表的,比如上图所示的”今晚我想和你一起“和后面的词之间的概率表,然后它每次都带有一点随机的挑选概率最高的20%的词。嗯,那么接下来你可能就会问:

概率哪里来的?

首先说答案:ChatGPT”吃“下去很多文本内容,从这些文本中将发现了各个词之间的关系,做了这个统计表。

为了你可以更容易理解,我再举个例子:我们把维基百科搜索”猫“,把前100篇英文文章进行字母统计,得到a后面各单词的概率、b后面各单词的概率……统计后你可以看到如下这样一个字母对的概率表(”2-gram“):

img

根据这个表,我们可以看到,r、s、t和n几个字母后面,a出现的概率都极高,回想”今晚我想和你一起“之后的概率,我们应该可以知道概率是怎么来的了。

但是!!!字母的统计是没有用的,我们至少要把最小颗粒度变成单词,单词一个个联想才有意义。英文中目前有4万个单词,如果像上面的字母一样进行单词的二元组合的话,可以得到16亿个结果,如果是三元就是60亿个结果….如果是20个单词的文章片段呢?可能数量已经超过了宇宙中所有的粒子数量。那怎么办呢?

人类的归纳能力:模型

我们不需要去穷举每种可能性的,因为人类很善于归纳总结,我们早就已经会用模型了。

如果你不清楚什么是模型的话,我们先来看一个非常简单的模型:比如说我们要测试物体落地的距离和时间。我们可以在1楼、2楼、3楼、….、1万楼分别扔下来一个高尔夫球,测试落地时间。当然,真的这么去穷举实验的话你就傻了,我们只需要取其中的一些楼层做实验就可以了。如下图(X轴:层数,Y轴:落地时间):

img

根据这些实验,我们就可以用数学来做模型了,比如:
$$
y=a+bx+cx^2
$$

img

当然,要调整到这个样子,需要不断去调整模型,模型可大可小,有最简单的y=ax(一个参数),有上图这样的(三个参数的)一元多次函数,还有由各种简单模型不断叠加的大模型,参数就更加复杂了。比如ChatGPT-3.5的模型参数高达1750亿个,不仅仅是数值函数,还包括图像识别函数模型、自然语言处理(NLP)的模型等等。

mimapoyi

那么这1750亿的参数是怎么调整出来的?那肯定是用计算机自动调整的,不可能是人去拧这些螺丝。这里的螺丝指的就是参数,计算机出现之前,图灵做的密码破译机(计算机的原型)的参数就是用螺丝来拧的。这里面就需要引入机器学习和神经网络了。

AI的仿生:神经网络

目前最流行和最成功的方法使用神经网络。神经网络在1940年代被发明出来,其形式与今天使用的形式非常接近,可以被认为是大脑运作方式简化后的理想化模型。人类大脑中有约1000亿个神经元(神经细胞),每个神经元能够产生电脉冲高达一千次/秒。这些神经元以复杂网状连接在一起,每个神经元都有树枝状分支,使其能够向数千个其他神经元传递电信号。粗略地说,在任何给定时刻一个特定的神经元是否会产生电脉冲取决于它从其他不同连接收到了哪些“权重”不同的信号。

在计算机中,人们就发明了人工的神经元——感知机。感知机得作用就是接收三个输入条件x1、x2、x3,输出一个结果。

image-20230402110056251

这么说可能有点难理解,我们讲的例子。我们可以将感知机当做一个根据权重来做决策的机器,以周末是否去外面野餐为例,我们有可能有三个影响因素:

  1. 天气情况?
  2. 你的家人是否想去?
  3. 附近是否可以方便地停车?

$$
这三个因素可以称为x_1、x_2、x_3。如果天气好,那么x_1=1,否则x_1=0;类似的,x_2=1或x_2=0。
$$

但各个因素的重要性不同,如果下雨,那野餐的几率为零。
$$
所以我们把x_1的权重设置的更大,如w_1=6,其他权重w_2=2,w_3=2。
$$
如果我们给这个感知机的阈值设置为5(最终的结果>5就出行),那么只要天气情况好,那最终输出就是1(去野餐),其他两个条件几乎成了摆设。这里,三个x的权重和最终的阈值就是所谓的参数!!!

sigmoid1

通过这么一个多因子的权重分配系统,就把一个决定给做出来,是不是有点神奇。其实神经网络实际比这个复杂的多,因为这里仅仅是一层,实用的神经网络都具备很多层,如下图所示:

shenjingwangluo1

我们刚才做出来的野餐出行决定仅仅是其中一个人工神经元,将这个结果传递给下一层的神经元(上一层的输出结果作为下一层的输入因子)进行更复杂的判定。需要注意的是一个神经元的输出只有一个,上图其实一个神经元有多个箭头输出只是说明同一个输出可以被下一层的多个神经元接收作为输入因素。

当然,说回感知机,有些感知机是“F二代、G二代”,可以赢在起跑线上,比如我们加一个偏置值4,那么不管三个条件哪一个满足,最终的值都大于5了,都可以去野餐,这就是二代们的霸气…..这个偏置值b,以及前面的w,是不是就是我们最上面那个一元二次函数的a(偏置值)、b、c啊。

如果你认真看到这里,那么就可以知道数学函数与神经元之间的关系了。现在我们要做的事情是要让机器能自动帮我们找到权重和偏置值(w、b),使得神经网络输出的y(x)能够拟合(匹配)各种情况。这里就需要展现著名的损失函数了,有些地方也叫代价函数。
$$
C(w,b) \equiv \frac{1}{2n} \sum_x |y(x)-a|^2
$$
这里就不展开函数的具体细节了,目前常见的训练方案是使用梯度下降算法来找参数,以便最小化C(w,b)。梯度下降是一种微积分里面的求导函数,用图表来表示的话,它们在做的事情就是不管输入点(比喻成一个小球)在哪个位置,让它滚动去找最低点。整体的正地点找到了的话,那么这时候的参数就是我们要找的参数了。

img

要说明白ChatGPT的原理其实还有很多大的概念,但其实它就是一个巨大的神经网络——之前使用的是1750亿个权重版本的GPT3.5网络。在很多方面上,这个神经网络的特别之处在于处理语言的能力,并且它最显著的特点是它基于“Transformer”构建了神经网络。Transformer 的想法是对组成文本片段序列标记进行类似操作, 但不同之处在于 Transformer 引入了“注意力”概念和更加关注某些序列片段而非全部. 或许总有一天只需启动通用神经网络并通过训练进行所有自定义就足够了,但至少目前来看,在实践中“模块化”事物似乎是至关重要的——正如 Transformer 所做的那样,也可能与我们的大脑所做的类似。

ChatGPT的总体目标是根据其训练过程中看到的内容(即查看了来自Web、电子书、word等数十亿页文本),以“合理”的方式继续文本。因此,在任何给定时刻,它都有一定数量的文本,并且其目标是为下一个要添加的标记提供适当选择。

它分为三个基本阶段:首先,它获取与迄今为止已有文本相对应的标记序列,并找到表示这些标记序列的嵌入式数组(即一组数字)。然后,在神经网络中进行操作——通过在网络中连续层之间传播值——以生成新嵌入式数组(即新一组数字)。接着,它取该数组最后部分并从中生成大约50,000个值得出不同可能下一个标记概率的数组。

好了,你能感觉到越到后面我越讲不清楚了,那是因为后面需要太多的公式和图例,我直接放上来的话,懂的人自然懂,但是大部分想了解ChatGPT的朋友可能就很难理解了。不过,ChatGPT的基本概念在某种程度上相当简单:从网络、书籍等大量人类创作文本样本开始,然后训练神经网络生成“类似于这些”的文本。

和BERT的比较

这里需要补充一点就是ChatGPT中的GPT和BERT的区别。这两者都是基于Transformer的,但是BERT相对来说考虑的比较周全一些,它会去分析上下文(双向的)和做推理,我们可以认为是它对我们问的话做了一些思考,再返回。这里的返回形式,包括去召回既有的知识库,还有就是用GPT来生成。

但是GPT的模式就粗暴的多,它可不考虑这么多,直接就是根据已有的文字(比如我们输入的问题或者一些全局性的文本内容)一个单词一个单词地往下走。GPT更像是东方朔测字算命,我不想了解你,你就写几个字,我根据自己读过的“一千个北大图书馆”的知识,看字来算….

AI魔法师的咒语——Prompt

正是ChatGPT这种不求甚解,只根据我们键入的文本进行推演的工作方式,造成了我们怎么“问”就非常重要了!

这个问法,就是Prompt,也叫提示,这是现代AI魔法师的咒语啊!

我们先来看看几个简单的问法。

01.翻译

翻译1

翻译是最常见的用法之一,目前也有一些翻译插件,挺好用的,后面也会提到。

02.归纳总结

归纳

不仅仅是归纳,我还可以让它单独罗列一些内容:

归类列表

我觉得ChatGPT能力还是很强的,就这两下子,已经可以把一些“理工男”打败了。

03.润色

润色-1

再让它帮我们把文字润色一下,可以有很多种风格,包括鲁迅、李白的等等,只要你能想到,如果你写过大量文章,也可以喂给ChatGPT让它学习你的语言风格,以后可以让它用你的说话风格把一些文章给润色一下。

04.日程安排

日程安排-约会议时间

你可以把自己和朋友的日程告诉它,让它来帮助预约会议,我这里展示的是两个人,更多人的依然有效。

这只是我之前能想到的内容….然而,真正的ChatGPT魔术师的Prompt可就了不得了,我分享一下别人的内容。

image-20230402190246103

是的,这就是专业啊!原文地址

网上其实还有很多的prompt的网站,大家有兴趣可以自行搜索。

另外,OpenAI的创始人Sam Altman的想法是5年之内解决prompt的问题,以后的交互会更加简单。言外之意prompt也许是你接下来最需要学好的知识。

这五年,“用好prompt你”可以把“不使用promt的你”甩开十八条街,你信不信!!!

基于ChatGPT的应用

基于ChatGPT的应用现在太多了,除了网页版的ChatGPT之外,我自己最先用的、也算用的比较多的是翻译。

01.翻译工具

我用的是yetone开发的产品,大家可以在Chrome应用商店搜索“OpenAI Translator”找到这个插件,它除了多种语言的翻译,还可以做归纳、润色等工作。

fanyi_yetone

02.个人知识库

看到倪爽老师在用Jiayuan开发的Copilot Hub创建自己的知识库,他把自己的所有推文(大家可以理解为微博上自己发过的所有文字)都导出来,放进这个知识库中。然后,随时可以对“自己”提问,问问以前对某件事的看法,问问就自己的价值观来说,当前面临的这件事情以前的“自己”会怎么选择。是不是有点《流浪地球2》中数字意识的感觉了。

ns1 ns

Copilot Hub的地址是Copilot Hub

03.OpenAI插件

chajian

随着OpenAI开始发布插件,官方的说法是有了眼镜和耳朵,让内容更加实时了,基于OpenAI的应用肯定会出现井喷。有些应用,我相信OpenAI他们自己也没想到。未来会怎么样,值得期待!

这一波AI潮的其他优秀应用

这一波AI浪潮因ChatGPT的横空出世而被推到了大众面前,其实大部分的应用前几年就已经在发展了。虽然多次听到OpenAI与医疗、量化等行业的公司有密切合作,但下图中的大部分应用其实和OpenAI没什么关系。

img

前往查看详细AI应用列表

微软、Adobe等都已经在大步迈进AI,我们现在几乎每天都能听到很多新的基于AI的产品。嗯,当然,这也激起了一些人的紧张,比如马斯克等人联名要求暂停比GPT-4更先进的AI研发6个月。

好,不去管这些科学伦理的事情,我想介绍一位我认识的创业者,他可算是把AI用的非常溜了。

yong

以前要做数字人,制作3D是一件非常重的事情,而且价格昂贵。但是这次,他直接在家里只用一台电脑和网线,通过各种prompt就把一段数字人介绍他自己公司产品(Omniedge,一种可以连接任何设备到局域网的方案)的视频做好了,用的是他在midjourney上生成的理想代言人,还有普通话、粤语、陕西话等多个版本。数字人行业应该会被迭代,特别是制作3D模型的人,以后,真的不一定还值钱。

总结

去拥抱未来吧,何况未来已来!






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Rust原子性和锁(01)-Rust并发基础

文本为《Rust Atomics and Locks》翻译文章,英文原文:https://marabos.nl/atomics/basics.html
仅供学习使用

早在多核处理器普及之前,操作系统就允许一台计算机同时运行多个程序。这是通过在进程之间快速切换来实现的,允许每个进程一个接一个地重复地取得一点点进展。如今,几乎我们所有的电脑,甚至我们的手机和手表都有多核处理器,这可以真正地并行执行多个进程。

操作系统尽可能地将进程彼此隔离,允许程序在完全不知道其他进程在做什么的情况下做自己的事情。例如,如果不事先询问操作系统内核,一个进程通常不能访问另一个进程的内存,或以任何方式与其通信。

然而,一个程序可以产生额外的执行线程,作为同一个进程的一部分。同一进程内的线程并不是相互隔离的。线程共享内存,并可以通过该内存相互交互。

本章将解释Rust中线程是如何生成的,以及围绕线程的所有基本概念,比如如何在多个线程之间安全共享数据。本章解释的概念是本书其余部分的基础。

如果你已经熟悉Rust的这些部分,可以跳过前面的内容。然而,在你继续下一章之前,确保你对线程、内部可变性、“发送”和“同步”有很好的理解,并且知道互斥量、条件变量和线程停放是什么。

Rust中的线程概念

每个程序都从一个线程开始:主线程。这个线程将执行你的“main”函数,如果需要的话,可以用来生成更多的线程。

在Rust中,使用标准库中的’ std::thread::spawn ‘函数来生成新线程。它只有一个参数:新线程将要执行的函数。一旦这个函数返回,线程就会停止。

让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::thread;

fn main() {
thread::spawn(f);
thread::spawn(f);

println!("Hello from the main thread.");
}

fn f() {
println!("Hello from another thread!");

let id = thread::current().id();
println!("This is my thread id: {id:?}");
}

我们生成两个线程,它们都将执行’ f ‘作为它们的主函数。这两个线程都将打印一条消息并显示它们的thread id,而主线程也将打印它自己的消息。

Thread ID

Rust标准库为每个线程分配一个唯一的标识符。这个标识符可以通过’ Thread::id() ‘访问,类型为’ ThreadId ‘。没有太多你可以做一个’ ThreadId ‘除了复制它周围,并检查是否相等。不能保证这些id是连续分配的,只能保证每个线程的id是不同的。

如果您多次运行上面的示例程序,您可能会注意到每次运行的输出是不同的。这是我在机器上运行时得到的输出:

1
2
3
Hello from the main thread.
Hello from another thread!
This is my thread id:

令人惊讶的是,部分输出似乎缺失了。

这里发生的情况是,主线程在新生成的线程完成执行它们的函数之前完成了’ main ‘函数的执行。

从’ main ‘返回将退出整个程序,即使其他线程仍在运行。

在这个特殊的例子中,一个新生成的线程只有足够的时间来处理第二条消息的一半,然后程序就被主线程关闭了。

如果我们想要确保线程在我们从’ main ‘返回之前完成,我们可以通过“加入”它们来等待它们。为此,我们必须使用’ spawn ‘函数返回的’ JoinHandle ‘:

1
2
3
4
5
6
7
8
9
fn main() {
let t1 = thread::spawn(f);
let t2 = thread::spawn(f);

println!("Hello from the main thread.");

t1.join().unwrap();
t2.join().unwrap();
}

‘ .join() ‘方法会一直等待,直到线程完成执行,并返回一个’ std::thread::Result ‘。如果线程因为恐慌而未能成功完成其功能,则该文件将包含恐慌消息。我们可以尝试处理这种情况,或者在加入一个恐慌线程时调用’ .unwrap() ‘来引发恐慌。

运行这个版本的程序将不再导致截断的输出:

1
2
3
4
5
Hello from the main thread.
Hello from another thread!
This is my thread id: ThreadId(3)
Hello from another thread!
This is my thread id: ThreadId(2)

唯一在运行之间仍然会改变的是消息打印的顺序:

1
2
3
4
5
Hello from the main thread.
Hello from another thread!
Hello from another thread!
This is my thread id: ThreadId(2)
This is my thread id: ThreadId(3)

Output Locking(输出锁定)

println宏使用’ std::io::Stdout::lock() ‘来确保它的输出不会被中断。println!() 表达式将等待任何并发运行的表达式完成后再写入任何输出。如果不是这样的话,我们可以得到更多的交错输出,比如:

1
2
3
4
5
Hello fromHello from another thread!
another This is my threthreadHello fromthread id: ThreadId!
( the main thread.
2)This is my thread
id: ThreadId(3)

与其像上面的例子一样将函数名传递给 std::thread::spawn,更常见的做法是传递一个 闭包。这允许我们捕获值并将其移动到新线程中:

1
2
3
4
5
6
7
let numbers = vec![1, 2, 3];

thread::spawn(move || {
for n in &numbers {
println!("{n}");
}
}).join().unwrap();

在这里,由于我们使用了 move 闭包,因此将 numbers 的所有权转移到了新生成的线程。如果我们没有使用 move 关键字,则该闭包会通过引用捕获 numbers。这将导致编译器错误,因为新线程可能会超出该变量的生命周期。 由于线程可能一直运行到程序执行结束,所以 spawn 函数在其参数类型上有一个 static 生命周期限制。换句话说,它只接受可以永久保留的函数。通过引用捕获本地变量的闭包可能无法永久保留,因为该引用将在本地变量停止存在时失效。 从线程中获取返回值是通过从闭包中返回它来完成的。可以从 join 方法返回的结果中获取此返回值:

1
2
3
4
5
6
7
8
9
10
11
let numbers = Vec::from_iter(0..=1000);

let t = thread::spawn(move || {
let len = numbers.len();
let sum = numbers.iter().sum::<usize>();
sum / len 1
});

let average = t.join().unwrap(); 2

println!("average: {average}");

这里,线程闭包返回的值(1)通过 join 方法(2)发送回主线程。

如果 numbers 为空,线程在尝试除以零时会出现 panic(1),join 将返回该 panic 消息,导致主线程也因为 unwrap 而 panic(2)。

Thread Builder

std::thread::spawn 函数实际上只是 std::thread::Builder::new().spawn().unwrap() 的简便写法。 std::thread::Builder 允许您在生成新线程之前设置一些设置。 您可以使用它来配置新线程的堆栈大小并为其命名。 线程名称可通过 std :: thread :: current() .name() 获得,在恐慌消息中将被使用,并且在大多数平台上的监视和调试工具中可见。 此外,Builderspawn 函数返回一个 std :: io :: Result, 允许您处理生成新线程失败的情况。 如果操作系统耗尽内存或者资源限制已应用于程序,则可能会发生这种情况。 如果无法生成新线程,则 std :: thread :: spawn 函数仅会引发 panic。

作用域Threads

如果我们确定一个生成的线程肯定不会超出某个范围,那么该线程可以安全地借用一些不会永久存在的东西,比如局部变量,只要它们在该范围内存活。 Rust标准库提供了std::thread::scope函数来生成这样的作用域线程。它允许我们生成不能超出传递给该函数的闭包作用域的线程,从而使得安全地借用局部变量成为可能。 最好通过示例来说明其工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
let numbers = vec![1, 2, 3];

thread::scope(|s| { 1
s.spawn(|| { 2
println!("length: {}", numbers.len());
});
s.spawn(|| { 2
for n in &numbers {
println!("{n}");
}
});
}); 3
1 我们使用闭包调用 std::thread::scope 函数。我们的闭包直接执行并获得一个参数 s,表示作用域。
2 我们使用 s 来生成线程。闭包可以借用像 numbers 这样的局部变量。
3 当作用域结束时,所有尚未加入的线程将自动加入。

这种模式保证了在作用域中生成的线程都不会超出该作用域的生命周期。因此,这个有范围限制的 spawn 方法没有对其参数类型施加 static 约束,允许我们引用任何东西只要它在作用域内存在,比如 numbers。 在上面的例子中,两个新线程都同时访问 numbers。这是可以的,因为它们(以及主线程)都没有修改它。如果我们将第一个线程更改为修改 numbers ,就像下面所示那样,则编译器将不允许我们再次生成使用 numbers 的另一个线程:

1
2
3
4
5
6
7
8
9
10
let mut numbers = vec![1, 2, 3];

thread::scope(|s| {
s.spawn(|| {
numbers.push(1);
});
s.spawn(|| {
numbers.push(2); // Error!
});
});

确切的错误信息取决于 Rust 编译器的版本,因为它经常改进以产生更好的诊断结果,但尝试编译上述代码将导致类似于以下内容的结果:

1
2
3
4
5
6
7
8
9
10
11
12
error[E0499]: cannot borrow `numbers` as mutable more than once at a time
--> example.rs:7:13
|
4 | s.spawn(|| {
| -- first mutable borrow occurs here
5 | numbers.push(1);
| ------- first borrow occurs due to use of `numbers` in closure
|
7 | s.spawn(|| {
| ^^ second mutable borrow occurs here
8 | numbers.push(2);
| ------- second borrow occurs due to use of `numbers` in closure

The Leakpocalypse

在 Rust 1.0 之前,标准库有一个名为 std::thread::scoped 的函数,它可以直接生成线程,就像 std::thread::spawn 一样。它允许非 'static 捕获,因为它返回的是一个 JoinGuard 而不是 JoinHandle,在其被丢弃时会加入线程。任何借用的数据只需要比这个 JoinGuard 存活时间更长即可。这似乎很安全,只要确保在某个时刻丢弃了该 JoinGuard.

就在 Rust 1.0 发布之前,在“泄漏启示录”中逐渐清楚地表明无法保证某些东西将被丢弃。有许多方法可以使人们忘记某些东西或者泄漏掉而没有释放它。

最终得出结论:(安全)接口设计不能依赖于对象总是在生命周期结束时被删除的假设。泄漏对象可能合理地导致更多对象泄漏(例如泄漏 Vec 将同时泄漏其元素),但可能不会导致未定义行为。由于这个结论,“std :: thread :: scoped” 不再被认为是安全的,并从标准库中移除了。“std :: mem :: forget”也从“unsafe”函数升级到了safe函数以强调遗忘(或泄漏)总是有可能的。

直到 Rust 1.63,才添加了一个新的 std::thread::scope 函数,其设计不依赖于 Drop 来保证正确性。

共享所有权和引用计数

到目前为止,我们已经学习了如何使用 move 闭包(”Rust 中的线程”)将值的所有权转移给线程,并从寿命更长的父线程中借用数据(”作用域线程”)。当在两个不保证互相存活的线程之间共享数据时,它们都不能成为该数据的所有者。任何在它们之间共享的数据都需要与最长寿命的线程一样长。

Statics(静态)

有几种方法可以创建一个不属于单个线程的东西。最简单的方法是使用“静态”值,它由整个程序而不是单个线程“拥有”。在以下示例中,两个线程都可以访问X,但没有一个线程拥有它:

1
2
3
4
static X: [i32; 3] = [1, 2, 3];

thread::spawn(|| dbg!(&X));
thread::spawn(|| dbg!(&X));

static项目具有恒定的初始化程序,永远不会被丢弃,并且在程序的主函数甚至开始之前就已经存在。每个线程都可以借用它,因为它保证始终存在。

Leaking(泄露)

另一种共享所有权的方式是通过泄露分配。使用Box::leak,可以释放对Box的所有权,并承诺永远不会丢弃它。从那时起,该 Box 将永远存在,没有所有者,允许任何线程在程序运行期间借用它。

1
2
3
4
let x: &'static [i32; 3] = Box::leak(Box::new([1, 2, 3]));

thread::spawn(move || dbg!(x));
thread::spawn(move || dbg!(x));

move闭包可能会让我们觉得我们正在将所有权移动到线程中,但仔细查看x的类型会发现,我们只是给线程一个对数据的引用

引用是Copy的,这意味着当您“移动”它们时,原始副本仍然存在,就像整数或布尔值一样。

请注意,static生命周期并不意味着该值从程序开始就一直存在,而只是表示它在程序结束之前一直存在。过去根本不重要。

泄漏Box的缺点是我们正在泄漏内存。 我们分配了某些东西,但从未删除和释放它。 如果这种情况仅发生有限次数,则可以接受。 但如果我们继续这样做,则程序将慢慢耗尽内存。

Reference Counting(引用计数)

为了确保共享数据被丢弃和释放,我们不能完全放弃其所有权。相反,我们可以共享所有权。通过跟踪拥有者的数量,我们可以确保只有在没有剩余拥有者时才会删除该值。

Rust标准库通过std::rc::Rc类型提供此功能,简称“引用计数”。它与Box非常相似,但是克隆它不会分配任何新内容,而是增加存储在所包含值旁边的计数器。原始的和克隆的 Rc 都将指向同一内存分配; 它们共享所有权

1
2
3
4
5
6
use std::rc::Rc;

let a = Rc::new([1, 2, 3]);
let b = a.clone();

assert_eq!(a.as_ptr(), b.as_ptr()); // Same allocation!

放弃一个 Rc 将会减少计数器。只有最后的 Rc,它将看到计数器降至零,才是放弃和释放包含数据的那个。

然而,如果我们尝试将一个 Rc 发送到另一个线程中,则会遇到以下编译器错误:

1
2
3
4
error[E0277]: `Rc` cannot be sent between threads safely
|
8 | thread::spawn(move || dbg!(b));
| ^^^^^^^^^^^^^^^

事实证明,Rc 不是线程安全的(更多信息请参见“Thread Safety: Send and Sync”)。如果多个线程都有一个指向相同分配的 Rc,它们可能会尝试同时修改引用计数器,这可能会导致不可预测的结果。 相反,我们可以使用 std::sync::Arc,它代表”原子引用计数”。它与 Rc 相同,只是保证对引用计数进行的修改是不可分割的原子操作,使其能够安全地与多个线程一起使用(更多信息请参见第2章 )。

1
2
3
4
5
6
7
use std::sync::Arc;

let a = Arc::new([1, 2, 3]); 1
let b = a.clone(); 2

thread::spawn(move || dbg!(a)); 3
thread::spawn(move || dbg!(b)); 3
1 我们将一个数组和一个引用计数器放在新的分配中,该计数器从一开始。
2 克隆 Arc 将引用计数增加到两个,并为我们提供了第二个指向同一分配的 Arc
3 两个线程都通过自己的 Arc 访问共享数组。当它们丢弃其 Arc 时,都会减少引用计数器。最后一个丢弃其 Arc 的线程将看到计数器降至零,并将是释放和回收数组的线程。

克隆命名

不得不为每个 Arc 的克隆体分配一个不同的名称,这可能会使代码变得混乱且难以跟踪。虽然每个 Arc 的克隆体都是单独的对象,但每个克隆体代表着相同的共享值,这并不能通过给它们命名来很好地反映出来。

Rust 允许(并鼓励)您通过定义具有相同名称的新变量来 遮盖 变量。如果在同一作用域中执行此操作,则原始变量将无法再被命名。但是通过打开一个新作用域,在该作用域内可以使用类似于 let a = a.clone(); 这样的语句重复使用相同的名称,并在作用域外保留原始变量。

通过将闭包包装在新范围内(使用 {}),我们可以在将其移动到闭包中之前对变量进行克隆,而无需重新命名它们。

| let a = Arc::new([1, 2, 3]); let b = a.clone(); thread::spawn(move || { dbg!(b); }); dbg!(a); The clone of the Arc lives in the same scope. Each thread gets its own clone with a different name. | let a = Arc::new([1, 2, 3]); thread::spawn({ let a = a.clone(); move || { dbg!(a); } }); dbg!(a);The clone of the Arc lives in a different scope. We can use the same name in each thread. |
| ———————————————————— | ———————————————————— |
| | |

因为所有权是共享的,引用计数指针(Rc<T>Arc<T>)与共享引用(&T)具有相同的限制。它们不会给您对其包含值的可变访问权限,因为该值可能同时被其他代码借用。 例如,如果我们尝试对 Arc<[i32]> 中的整数切片进行排序,则编译器将阻止我们这样做,并告诉我们不允许修改数据:

1
2
3
4
error[E0596]: cannot borrow data in an `Arc` as mutable
|
6 | a.sort();
| ^^^^^^^^

Borrowing and Data Races(借用和数据竞争)

在 Rust 中,值可以通过两种方式进行借用:

  • 不可变借用 使用 & 借用某个东西会得到一个 不可变引用。这样的引用是可以复制的。对其所指向数据的访问在所有此类引用副本之间共享。正如名称所示,编译器通常不允许您通过这样的引用来 改变 某些东西,因为那可能会影响当前正在借用相同数据的其他代码。

  • 可变借用 使用 &mut 借出某个东西会得到一个 可变引用。可变借贷保证它是该数据唯一活动租赁者。这确保了修改数据不会更改其他代码正在查看的任何内容。

    这两个概念结合起来完全防止了 数据竞争:其中一个线程在修改数据而另一个线程同时访问它的情况。 数据竞争通常是 未定义行为,这意味着编译器无需考虑这些情况,并且只需假定它们不会发生即可。

    为了澄清其含义,让我们看一个例子,在此例中编译器可以使用借贷规则做出有益假设:

1
2
3
4
5
6
7
8
fn f(a: &i32, b: &mut i32) {
let before = *a;
*b += 1;
let after = *a;
if before != after {
x(); // never happens
}
}

在这里,我们获得了一个不可变的整数引用,并存储了 b 所引用的整数在增加之前和之后的值。编译器可以自由地假设借用和数据竞争方面的基本规则得到遵守,这意味着 b 不可能指向与 a 相同的整数。实际上,在 a 借用该整数期间,程序中没有任何东西可以对其进行可变借用。因此,编译器可以轻松地推断出 *a 不会改变,并且 if 语句条件永远不会为真,并完全将调用 x 的代码作为优化从程序中删除。 除非使用 unsafe 块禁止一些编译器安全检查,否则无法编写破坏 Rust 编译器假设的程序。

Undefined Behavior(未定义行为)

像C、C++和Rust这样的语言有一组需要遵循的规则,以避免出现所谓的未定义行为。例如,Rust的一个规则是任何对象都不能有多个可变引用。

在Rust中,只有使用unsafe代码时才可能违反这些规则。 “不安全”并不意味着代码是错误或永远不安全使用,而是编译器没有验证代码是否安全。如果代码确实违反了这些规则,则称其为不完整

编译器可以假设这些规则从未被破坏,而无需检查。当破坏时,会导致所谓的未定义行为,我们必须尽一切努力避免它。如果我们允许编译器做出实际上并非如此的假设,则很容易对您代码中其他部分产生更多错误结论,并影响整个程序。

作为具体示例,请看下面一个小片段,在其中使用了slice上的get_unchecked方法:

1
2
let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };

get_unchecked 方法可以通过索引获取切片中的元素,就像 a[index] 一样,但是允许编译器假设索引始终在边界内,而不进行任何检查。 这意味着在此代码段中,因为a的长度为3,编译器可以假设index小于三。我们需要确保它的假设成立。 如果我们打破了这个假设,例如将index设置为3,则可能会发生任何事情。它可能导致从存储在a右侧字节中的内存读取任何内容。它可能导致程序崩溃。它可能最终执行某些完全无关的程序部分。它会造成各种混乱。 也许令人惊讶的是,在未定义行为甚至可以 “穿越时间”,影响到先前代码中出现问题。要理解如何发生这种情况,请想象我们之前有一个 match 语句:

1
2
3
4
5
6
7
8
match index {
0 => x(),
1 => y(),
_ => z(index),
}

let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };

由于不安全的代码,编译器可以假设 index 只会是 0、1 或 2。它可能会逻辑上得出结论,我们 match 语句的最后一个分支只能匹配到数字 2,并且因此 z 函数只被调用为 z(2)。这个结论不仅可以优化 match,还可以优化 z 函数本身。这包括抛弃未使用的代码部分。

如果我们将其执行时传入了一个值为3 的index,则程序可能尝试执行已经被优化掉的部分,导致完全无法预测的行为,在我们到达最后一行的 unsafe 块之前就发生了。就像那样,未定义行为可能通过整个程序向前和向后传播,并以通常非常意外的方式表现出来。

在调用任何一个带有 unsafe 标记函数时,请仔细阅读其文档并确保您充分理解其安全要求:作为调用者需要遵守哪些假设才能避免未定义行为。

Interior Mutability(内部可变性)

在前面的章节中介绍了借用规则,这些规则很简单,但是在涉及多个线程时可能会非常限制——特别是当没有数据可以被多个线程访问和修改时。遵循这些规则使得线程之间的通信极其有限且几乎不可能。

幸运的是,有一个逃生口:内部可变性。具有内部可变性的数据类型略微弯曲了借用规则。在某些条件下,这些类型可以允许通过“不可变”引用进行突变。

在[“引用计数”](#Reference Counting(引用计数))一节中,我们已经看到了一个涉及内部可变性的微妙示例。无论是否存在多个克隆使用相同的引用计数器,RcArc都会突变引用计数器。

一旦涉及到具有内部可变类型,则调用引用为“不可变”或“可变”的术语将会令人困惑和不准确,因为某些东西可以通过两者进行改动。更准确地说,“共享”和“独占”才是更精确的术语:共享引用&T)可以复制并与其他人分享,而独占引用&mut T)则保证它是该 T 的唯一 独占借用。对于大多数类型,共享引用不允许修改,但也有例外情况。由于在本书中我们将主要使用这些异常情况,因此在本书的其余部分中我们将使用更准确的术语。

请记住,内部可变性仅弯曲了共享借用规则以允许在共享时进行突变。它并不改变任何关于独占借用的事情。无论是否具有内部可变性,在某个地方导致超过一个活动独占引用的不安全代码总是会调用未定义行为。

让我们看一下几种具有内部可变性的类型及其如何通过共享引用允许突变而不会导致未定义行为。

Cell

std::cell::Cell<T>简单地包装了一个 T ,但允许通过共享引用进行突变。为避免未定义行为,它只允许您复制值(如果 TCopy),或者整体替换另一个值。此外,它只能在单个线程中使用。 接下来看一个类似前面章节示例的示例,但这次使用的是 Cell<i32> 而不是 i32

1
2
3
4
5
6
7
8
9
10
use std::cell::Cell;

fn f(a: &Cell<i32>, b: &Cell<i32>) {
let before = a.get();
b.set(b.get() + 1);
let after = a.get();
if before != after {
x(); // might happen
}
}

与上次不同,现在if条件可能为真。因为Cell<i32>具有内部可变性,编译器不能再假设只要我们拥有共享引用就不会改变其值。 ab都可能引用相同的值,这样通过b进行突变也可能影响到 a. 但是它仍然可以假定没有其他线程同时访问单元格。

对于一个 Cell, 其限制并不总是易于使用。由于它无法直接让我们借用其持有的值,因此我们需要将一个值移出(留下另一个东西),修改它,然后将其放回以更改其内容:

1
2
3
4
5
fn f(v: &Cell<Vec<i32>>) {
let mut v2 = v.take(); // Replaces the contents of the Cell with an empty Vec
v2.push(1);
v.set(v2); // Put the modified Vec back
}

RefCell(引用计数单元)

与常规的 Cell 不同,std::cell::RefCell 允许您借用其内容,但会带来一些运行时成本。 RefCell<T> 不仅保存了一个 T,还保存了一个计数器来跟踪任何未完成的借用。如果在它已经被可变地借用(或反之亦然)时尝试进行借用,则会引发 panic,从而避免了未定义的行为。就像 Cell 一样,只能在单个线程中使用 RefCell。 通过调用 borrowborrow_mut 来借用 RefCell 的内容:

1
2
3
4
5
use std::cell::RefCell;

fn f(v: &RefCell<Vec<i32>>) {
v.borrow_mut().push(1); // We can modify the `Vec` directly.
}

虽然 CellRefCell 可以非常有用,但当我们需要在多个线程中执行某些操作时,它们变得相当无用。因此,让我们继续介绍与并发相关的类型。

Mutex and RwLock(互斥锁和读写锁)

一个 RwLock读写锁RefCell 的并发版本。一个 RwLock<T> 持有一个类型为 T 的值,并跟踪任何未完成的借用。然而,与 RefCell 不同的是,在出现冲突借用时它不会 panic。相反,它会阻塞当前线程——将其置于睡眠状态——等待冲突借用消失。我们只需耐心地等待轮到我们使用数据,其他线程使用完后再进行。

从一个 RwLock 中获取内容被称为 锁定 。通过 锁定 它,我们暂时阻止了并发的冲突借用,使得我们可以在不引起数据竞争的情况下进行借用。

一个 Mutex 非常类似,但概念上稍微简单一些。它不像 RwLock 一样跟踪共享和独占式借用的数量,而是仅允许独占式借用。

关于这些类型更详细的信息,请参见 “锁:Mutexes 和 RwLocks”

Atomics

原子类型代表了Cell的并发版本,是第2章和第3章的主要内容。与Cell一样,它们通过让我们整体复制值来避免未定义行为,而不直接借用其内容。 但与Cell不同的是,它们不能是任意大小。因此,并没有通用的适用于任何T类型的Atomic类型,而只有特定的原子类型(例如AtomicU32和AtomicPtr)。可用哪些取决于平台,因为它们需要处理器支持以避免数据竞争。(我们将在第7章中深入探讨这个问题)。 由于尺寸非常有限,在线程之间共享信息时原子通常不会直接包含所需信息。相反,它们经常被用作工具来使得可以在线程之间共享其他更大型的东西。当原子被用来描述其他数据时,则可能变得异常复杂。

UnsafeCell(unsafe单元)

一个 UnsafeCell 是内部可变性的基本构建块。

一个 UnsafeCell<T> 包装了一个 T,但没有任何条件或限制来避免未定义行为。相反,它的 get() 方法只是给出了它包装的值的原始指针,这个指针只能在 unsafe 块中有意义地使用。它把如何使用留给用户,在不引起任何未定义行为的情况下使用。

最常见的情况是,一个 UnsafeCell 不会直接使用,而是被包含在另一种类型中,并通过有限接口提供安全性,例如 Cell 或者 Mutex. 所有具有内部可变性(包括上面讨论过的所有类型)都建立在 UnsafeCell 的基础之上。

线程安全:发送和同步

在本章中,我们看到了几种不是线程安全的类型,这些类型只能在单个线程上使用,例如RcCell等。由于需要这种限制以避免未定义行为,因此编译器需要理解并检查您是否可以使用这些类型而无需使用“unsafe”块。

语言使用两个特殊的trait来跟踪哪些类型可以安全地跨越线程使用:

  • Send

    如果一个值得所有权可以转移到另一个线程,则该类型是“Send”。例如,“Arc”是“Send”,但“Rc”不是。

  • Sync

    如果一个共享引用到该类型,“&T”,也是“Send”,则该类型就是“Sync”。例如,“i32” 是 “Sync”,但 “Cell” 不是。(然而,“Cell” 是 “Send”的。) 所有基本数据类型如 i32, bool, 和 str 都同时实现了 SendSync. 这两个 trait 都属于 自动 trait ,这意味着它们会根据字段自动实现你的自定义结构体。如果一个结构体的所有字段都满足了 SendSync, 则该结构体本身也具有相应特性。 要退出其中任何一项,请向您的类型添加未实现该 trait 的字段。为此,通常会用到特殊的 std::marker::PhantomData 类型。该类型被编译器视为 T,但实际上在运行时并不存在。它是一个零大小的类型,不占用空间。 让我们看一下以下结构体:

1
2
3
4
5
6
use std::marker::PhantomData;

struct X {
handle: i32,
_not_sync: PhantomData<Cell<()>>,
}

在这个例子中,如果 handle 是它唯一的字段,则 X 将同时是 SendSync。然而,我们添加了一个大小为零的 PhantomData<Cell<()>> 字段,该字段被视为一个 Cell<()>。由于 Cell<()> 不是可同步的(not Sync),因此 X 也不是可同步的(not Sync)。但它仍然是可发送的(Send),因为它所有的字段都实现了 Send。

原始指针(*const T 和 *mut T`)既不是 Send 也不是 Sync,因为编译器对其所代表内容知之甚少。

选择加入任何其他 trait 的方式相同;使用 impl 块来实现您类型上要用到的 trait:

1
2
3
4
5
6
struct X {
p: *mut i32,
}

unsafe impl Send for X {}
unsafe impl Sync for X {}

请注意,实现这些特性需要使用 unsafe 关键字,因为编译器无法检查它是否正确。这是您向编译器做出的承诺,它只能信任您。 如果您尝试将某个东西移动到另一个不是 Send 的线程中,则编译器会礼貌地阻止您这样做。以下是一个小例子来演示:

1
2
3
4
5
6
fn main() {
let a = Rc::new(123);
thread::spawn(move || { // Error!
dbg!(a);
});
}

在这里,我们试图将一个 Rc<i32> 发送到一个新线程中,但是与 Arc<i32> 不同,Rc<i32> 没有实现 Send。 如果我们尝试编译上面的示例,则会遇到类似于以下内容的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> src/main.rs:3:5
|
3 | thread::spawn(move || {
| ^^^^^^^^^^^^^ `Rc<i32>` cannot be sent between threads safely
|
= help: within `[closure]`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
--> src/main.rs:3:19
|
3 | thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`

thread::spawn”函数要求其参数为“Send”,而闭包只有在其所有捕获的内容都是“Send”时才是“Send”的。如果我们尝试捕获某些不是“Send”的东西,就会被发现错误,从而保护我们免受未定义行为的影响。

锁定:互斥锁和读写锁

在线程之间共享(可变)数据的最常用工具是互斥锁,简称“mutex”。互斥锁的作用是通过暂时阻止试图同时访问它的其他线程来确保线程对某些数据具有独占访问权。

从概念上讲,互斥锁只有两种状态:已锁定和未锁定。当一个线程将一个未锁定的互斥锁加锁时,该互斥锁被标记为已锁定,并且该线程可以立即继续执行。然后,当另一个线程尝试去获取已经被加了锁的互斥量时,这个操作就会阻塞。在等待解除阻塞期间,该线程将进入睡眠状态。仅在已经加了锁的情况下才能解除阻塞,并且应由相同的线程进行解除阻塞操作。如果其他线程正在等待获取该互斥量,则解除阻塞将唤醒其中一个等待中的线程以再次尝试获取并继续其任务。

使用mutex保护数据只需所有参与者达成一致意见:他们只会在拥有mutex时才能访问数据。这样一来,任何两个或多个不同的进城都无法同时访问该数据,从而避免了数据竞争。

Rust的互斥锁

Rust标准库通过std::sync::Mutex<T>提供此功能。它是针对类型T的泛型,该类型是互斥锁所保护数据的类型。通过将这个T作为互斥锁的一部分,数据只能通过互斥锁访问,从而实现了安全接口,并确保所有线程都遵守协议。

为确保被锁定的互斥锁只能由锁定它的线程解除锁定,它没有一个名为“unlock()” 的方法。相反,其“lock()”方法返回一种特殊类型称为“MutexGuard”。该guard表示我们已经成功地获取了互斥锁。 它通过 DerefMut trait 表现得像独占引用, 使我们可以独占地访问受到mutex保护的数据。释放guard时会解除mutex上的加锁状态。当我们放弃使用guard时,就失去了访问数据的权利,“Drop” guard 的实现将解开mutex。

下面看一个例子来看看如何在实践中使用mutex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::sync::Mutex;

fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}

在这里,我们有一个 Mutex<i32>,它是保护整数的互斥锁,并且我们生成十个线程来每次将整数增加一百次。每个线程都会首先锁定互斥锁以获取 MutexGuard,然后使用该 guard 访问并修改整数。当变量超出作用域时,guard 会被隐式释放。

在线程完成后,我们可以通过 into_inner() 安全地从整数中删除保护。into_inner() 方法拥有互斥锁的所有权,这保证了没有其他东西可以再引用该互斥锁了,因此不需要进行加锁操作。

即使增量按照步长为1发生,在观察整数的线程只能看到100的倍数值, 因为它只能在解除互斥锁时查看该整数。有效地说, 多个一百次递增现在成为单个不可分割 - 原子 - 操作得益于互斥体。

要清楚地看到互斥体的效果,请让每个线程等待一秒钟才解除互斥体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::time::Duration;

fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
thread::sleep(Duration::from_secs(1)); // New!
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}

现在运行程序,你会发现它需要大约10秒才能完成。每个线程只等待一秒钟,但互斥锁确保同一时间只有一个线程可以这样做。

如果我们在睡眠一秒钟之前放弃保护(因此解锁互斥锁),我们将看到它并行发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
drop(guard); // New: drop the guard before sleeping!
thread::sleep(Duration::from_secs(1));
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}

通过这个改变,该程序现在只需要大约一秒钟的时间,因为现在10个线程可以同时执行它们的一秒睡眠。这表明了保持互斥锁锁定时间尽可能短的重要性。将互斥锁锁定时间超过必要时间会完全抵消并行性带来的任何好处,有效地强制所有事情按顺序发生。

锁中毒

上面示例中的 unwrap() 调用与 锁中毒 有关。

在 Rust 中,当一个线程在持有锁时发生 panic 时,Mutex 就会被标记为 已中毒。这种情况下,Mutex 不再被锁定,但调用其 lock 方法将导致返回一个 Err 来指示它已经被中毒了。

这是一种机制来防止保护互斥量所保护的数据处于不一致状态。在我们上面的示例中,如果一个线程在递增整数少于100次后发生 panic,则互斥量将解锁,并且整数将处于意外状态,在那里它不再是100的倍数,可能破坏其他线程所做出的假设。自动标记互斥量为已污染可以强制用户处理此可能性。

对受感染的互斥体调用 lock() 仍然会锁定该互斥体。由 lock() 返回的 Err 包含了 MutexGuard, 允许我们根据需要纠正不一致状态。

虽然看起来像是强大机制, 但实际上从潜在不一致状态恢复并不常见。大多数代码要么忽略 poison 或使用 unwrap() 在 lock 被污染时 panic,从而将 panic 传播给互斥量的所有用户。

MutexGuard 的生命周期

虽然隐式地丢弃 guard 以解锁互斥体很方便,但有时会导致微妙的意外。如果我们使用 let 语句为 guard 分配一个名称(就像上面的示例中一样),那么相对来说比较容易看出它何时被丢弃,因为局部变量在定义它们的作用域结束时被丢弃。尽管如此,不显式地放弃 guard 可能会导致保持互斥体锁定时间超过必要时间,在上面的示例中已经演示了这一点。

在不给 guard 分配名称的情况下使用它也是可能的,并且有时非常方便。由于 MutexGuard 表现得像受保护数据的独占引用,因此我们可以直接使用它而无需先将其分配给一个变量名。例如,如果您有一个 Mutex<Vec<i32>>,则可以在单个语句中锁定互斥体、将项目推入到 Vec 中并再次解锁互斥体:

1
list.lock().unwrap().push(1);

在较大的表达式中产生的任何临时变量,例如lock()返回的保护条件,都将在语句结束时被删除。虽然这似乎很明显和合理,但它会导致一个常见陷阱,通常涉及到matchif let或者 while let语句。以下是一个遇到此问题的示例:

1
2
3
if let Some(item) = list.lock().unwrap().pop() {
process_item(item);
}

如果我们的意图是锁定列表、弹出一个项目、解锁列表,然后在解锁列表之后处理该项目,那么我们在这里犯了一个微妙但重要的错误。临时保护程序直到整个 if let 语句结束才会被释放,这意味着我们在处理该项时不必要地持有锁。

令人惊讶的是,在类似于此示例中的类似 if 语句中不会发生这种情况:

1
2
3
if list.lock().unwrap().pop() == Some(1) {
do_something();
}

这里,在执行 if 语句体之前,临时守卫确实会被删除。原因是常规 if 语句的条件始终是一个普通布尔值,不能借用任何东西。没有理由将从条件到语句末尾的临时变量寿命延长。然而,对于 if let 语句可能不是这种情况。例如,如果我们使用了 front() 而不是 pop() ,则 item 将从列表中借用,这使得必须保留守卫。由于借用检查器只是一个检查,并不影响事物何时以及以什么顺序被删除,即使我们使用了 pop() ,同样也会发生这种情况。

我们可以通过将弹出操作移动到单独的 let 语句中来避免这种情况。然后在该声明结束之前放弃 guard,在 if let 中:

1
2
3
4
let item = list.lock().unwrap().pop();
if let Some(item) = item {
process_item(item);
}

读者-写者锁

互斥锁只关心独占访问。即使我们只想查看数据并且共享引用(&T)已经足够,MutexGuard也会为我们提供对受保护数据的独占引用(&mut T)。

读者-写者锁是互斥锁的稍微复杂一点的版本,它理解排他和共享访问之间的区别,并可以提供任何一种类型。它有三个状态:未加锁、由单个写入器(用于独占访问)加锁以及由任意数量的读取器(用于共享访问)加锁。它通常用于多线程频繁读取但偶尔更新数据。

Rust标准库通过 std::sync::RwLock<T> 类型提供了这种类型的锁。它与标准 Mutex 的工作方式类似,但其接口大部分被拆分成两部分。它具有一个 read() 和一个 write() 方法来进行阻塞式地以读或写模式进行加锁操作。 它带有两种警戒类型,一种是针对读取器而言,另一种则是针对编写器而言:RwLockReadGuardRwLockWriteGuard. 前者仅实现了Deref以表现为受保护数据的共享引用,而后者还实现了DerefMut以表现为独占引用。

它有效地是 RefCell 的多线程版本,动态跟踪引用数量以确保借用规则得到遵守。

无论是 Mutex<T> 还是 RwLock<T> 都需要T为Send,因为它们可以被用于将T发送到另一个线程。此外,RwLock<T> 还要求 T 也实现 Sync ,因为它允许多个线程持有对受保护数据的共享引用(&T)。 (严格来说,您可以创建一个不满足这些要求的 T 的锁定器,但您将无法在线程之间共享该锁定器本身不会实施同步)。

Rust标准库仅提供一种通用目的的 RwLock 类型,但其实现取决于操作系统。读者-写者锁实现之间存在许多微妙差异。当等待编写器时大多数情况下都会阻止新读取器加入即使已经处于读取状态下也一样。这样做是为了防止编写程序饥饿(writer starvation),即许多读取器集体阻止锁从未解除并永远不允许任何编写程序更新数据的情况。

其他语言中的互斥锁

Rust 的标准 MutexRwLock 类型与其他语言(如 C 或 C++)中的类型有所不同。

最大的区别在于 Rust 的 Mutex<T> 包含 它所保护的数据。例如,在 C++ 中,std::mutex 并不包含它所保护的数据,也不知道它正在保护什么。这意味着用户需要记住哪些数据受到保护以及由哪个互斥锁进行了保护,并确保每次访问“受保护”数据时都正确地锁定相应的互斥锁。当阅读涉及其他语言中互斥锁代码或与不熟悉 Rust 的程序员交流时,这一点很有用。一个 Rust 程序员可能会谈论“互斥体内部的数据”,或者说像“将其包装在一个互斥体中”,这对那些只熟悉其他语言中互斥体而非 Rust 时可能会感到困惑。

如果您真正需要一个独立于任何内容且并未包含任何东西的 mutex,例如用于保护某些外部硬件,则可以使用 Mutex<()>。但即使是在这种情况下,您也最好定义一个(可能为零大小)类型来接口该硬件,并将其包装在 Mutex 中。这样,您仍然需要在与硬件交互之前锁定互斥锁。

等待:停车和条件变量

当数据被多个线程改变时,有许多情况需要等待某些事件发生,等待一些关于数据的条件成为真。例如,如果我们有一个互斥锁来保护 Vec ,我们可能希望等到它包含任何内容。

虽然互斥锁允许线程等待直到它解锁,但它不提供等待任何其他条件的功能。如果只有互斥锁可用,我们将不得不继续锁定互斥锁以重复检查是否已经在 Vec 中包含了任何东西。

线程停车

一种从另一个线程中等待通知的方法称为线程停车。一个线程可以 park 自己,这会使其进入睡眠状态,并阻止其消耗任何 CPU 周期。然后另一个线程可以 unpark 已经停放的线程并唤醒它。

通过 std::thread::park() 函数可以实现线程停车。对于取消停放,则调用表示要取消停放的线 程序所代表的 Thread 对象上的 unpark() 方法即可完成操作。这样的对象可以通过由 spawn 返回的 join 句柄获得或者通过当前正在运行该函数本身获取(使用 std :: thread :: current())。

下面我们来看一个使用互斥锁在两个线程之间共享队列的示例。在下面的示例中,新生成的线程将从队列中消耗项目,而主线程将每秒向队列插入一个新项目。当队列为空时,使用线程停车使得消费者线程等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::collections::VecDeque;

fn main() {
let queue = Mutex::new(VecDeque::new());

thread::scope(|s| {
// 消费线程
let t = s.spawn(|| loop {
let item = queue.lock().unwrap().pop_front();
if let Some(item) = item {
dbg!(item);
} else {
thread::park();
}
});

// 生产线程
for i in 0.. {
queue.lock().unwrap().push_back(i);
t.thread().unpark();
thread::sleep(Duration::from_secs(1));
}
});
}

消费线程运行一个无限循环,从队列中弹出项目并使用“dbg”宏显示它们。当队列为空时,它停止并使用“park()”函数休眠。如果被唤醒,则“park()”调用返回,“loop”继续,再次从队列中弹出项目直到为空为止。

生产线程每秒钟生成一个新数字,并将其推入队列中。每次添加项时,它都会在引用消费线程的Thread对象上使用unpark()方法来取消挂起。这样,消费线程就会被唤醒以处理新元素。

这里需要注意的一点是:即使我们删除了parking操作,该程序仍然在理论上是正确的但效率低下。这很重要,因为”park()”不能保证只有匹配的”unpark()”才能返回。虽然罕见, 但可能存在虚假唤醒 。我们的示例可以很好地处理这个问题, 因为消费者线程将锁定队列、检查是否为空, 然后直接解锁并重新进入休眠状态。

线程停车的一个重要属性是,在线程自己停车之前调用unpark()不会丢失请求. 请求取消挂起仍然记录在案,并且下一次尝试让该线程进入休眠状态时清除该请求,并直接继续而不实际进入休眠状态。为了看到这对正确操作的关键性,让我们通过两个线程执行的步骤可能的排序来进行说明:

  1. 消费线程(称之为C)锁定队列。
  2. C尝试从队列中弹出一个项目,但它是空的,结果返回“None”。
  3. C解锁队列。
  4. 生产线程(我们将其称为P)锁定队列。
  5. P将新项推送到队列中。
  6. P再次解锁该队列。
  7. P调用unpark()通知C有新项可用.
  8. C调用park()进入休眠状态, 等待更多项目。

虽然在第3步释放队列和第8步停车之间只有非常短暂的时刻,但第4至7步可能会在该时刻发生。如果’unpark()’如果线程没有挂起,则什么也不做,则通知将丢失。即使在队列中有一个项目, 消费者线程仍然会等待。由于取消挂起请求被保存以供未来调用’park()’, 我们无需担心这一点。

但是, 取消挂起请求并不堆叠。连续两次调用’unpark()’, 然后再连续两次调用’park()’, 仍然导致该线程进入睡眠状态。第一个’park()’清除请求并直接返回,但第二个则像往常一样进入睡眠状态。

这意味着在上面的示例中,重要的是只有在看到队列为空时才将线程挂起,而不是在处理每个项目后都将其挂起。虽然由于巨大(一秒钟)的休眠时间,在此示例中极不可能发生, 但多次’unpark()’调用可能会唤醒仅单个’park()’调用.

不幸的是,这意味着如果在队列被锁定并清空之前,park()返回后立即调用了unpark(),则unpark()调用是不必要的但仍会导致下一个park()调用立即返回。这将导致(空)队列多次被锁定和解锁。虽然这不影响程序的正确性,但它确实影响了其效率和性能。

对于像我们示例中那样简单的情况,此机制运作良好,但当事情变得更加复杂时很快就会崩溃。例如,如果我们有多个消费者线程从同一队列中取出项目,则生产者线程将无法知道哪个消费者正在等待并应该唤醒。生产者将必须准确地知道何时有消费者在等待以及它正在等待什么条件。

条件变量

条件变量是等待由互斥锁保护的数据发生某些事情的更常用选项。它们有两个基本操作:waitnotify。线程可以在条件变量上等待,然后当另一个线程通知同一条件变量时,它们可以被唤醒。多个线程可以在同一条件变量上等待,并且通知可以发送给一个等待线程或所有等待线程。

这意味着我们可以为特定事件或我们感兴趣的条件创建一个条件变量,例如队列非空,并在该条件下进行等待。任何导致该事件或条件发生的线程都会通知该条件变量,而无需知道哪些或多少个线程对该通知感兴趣。

为了避免在解锁互斥锁并等待条件变量之间短暂时刻内错过通知问题, 条件变量提供了一种以原子方式解锁互斥锁并开始等待的方法。这意味着根本没有可能让通知丢失。

Rust标准库提供了std::sync::Condvar作为一个条件变量。其wait方法需要一个证明我们已经锁定互斥锁的 MutexGuard 。它首先解除互斥锁并进入休眠状态,在稍后被唤醒时重新获取互斥锁并返回一个新的MutexGuard(证明互斥锁再次被锁定)。

它有两个通知函数: notify_one用于唤醒等待线程中的一个(如果有),而 notify_all则将它们全部唤醒。

让我们修改我们用于线程停车的示例,改为使用 Condvar:

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
use std::sync::Condvar;

let queue = Mutex::new(VecDeque::new());
let not_empty = Condvar::new();

thread::scope(|s| {
s.spawn(|| {
loop {
let mut q = queue.lock().unwrap();
let item = loop {
if let Some(item) = q.pop_front() {
break item;
} else {
q = not_empty.wait(q).unwrap();
}
};
drop(q);
dbg!(item);
}
});

for i in 0.. {
queue.lock().unwrap().push_back(i);
not_empty.notify_one();
thread::sleep(Duration::from_secs(1));
}
});

我们不得不改变一些东西:

  • 现在我们不仅有一个包含队列的 Mutex,还有一个用于通信“非空”条件的 Condvar
  • 我们不再需要知道要唤醒哪个线程,因此我们不再存储从 spawn 返回的值。相反,我们通过条件变量使用 notify_one 方法通知消费者。
  • 解锁、等待和重新锁定都由 wait 方法完成。为了能够将守卫传递给 wait 方法,同时在处理项目之前放弃它,我们必须稍微重构控制流程。

现在我们可以生成尽可能多的消费线程,甚至稍后再生成更多线程,而无需更改任何内容。条件变量负责将通知传递给感兴趣的任何线程。

如果我们有一个更复杂的系统,其中不同条件下感兴趣的线程,则可以为每个条件定义一个“Condvar”。例如,我们可以定义一个指示队列非空和另一个指示队列为空的“Condvar”。然后每个线程都会等待与其正在执行任务相关联的条件。

通常,“Condvar”仅与单个“Mutex”一起使用。如果两个线程尝试使用两个不同互斥锁并发地等待条件变量,则可能会导致恐慌。

“Condvar”的缺点是它只能在与“Mutex”一起使用时才有效,但对于大多数用例来说这完全没问题,因为已经用于保护数据了。

thread::park()Condvar::wait() 还具有带时间限制的变体:thread::park_timeout()Condvar::wait_timeout()。这些需要额外提供持续时间(Duration)参数,在此之后应放弃等待通知并无条件唤醒。

总结

  • 多个线程可以在同一个程序中同时运行,并且可以随时生成。
  • 当主线程结束时,整个程序也会结束。
  • 数据竞争是未定义的行为,在 Rust 的类型系统中完全被防止(在安全代码中)。
  • 可以将“Send”数据发送到其他线程,“Sync”数据可在多个线程之间共享。
  • 常规线程可能会一直运行到程序结束,因此只能借用 static 数据,例如静态变量和泄漏分配内存等。
  • 引用计数 (Arc) 可以用于共享所有权,确保数据至少有一个线程正在使用它的时间与其生命周期相同。
  • 作用域限定的线程对于限制线程寿命以允许其借用非 static 数据(例如局部变量)非常有用。
  • &T共享引用。 &mut T独占引用。普通类型不允许通过共享引用进行突变操作。
  • 由于 UnsafeCell 的存在,某些类型具有内部可变性,这使得通过共享引用进行突变成为可能。
  • CellRefCell 是单线程内部可变性的标准类型。原子、互斥锁和读写锁则是它们的多线程等效物品
  • Cell 和原子仅允许整体替换值,而 RefCell、Mutex 和 RwLock 允许通过动态执行访问规则直接修改值。
  • 线程停车可以是等待某些条件的便捷方式。
  • 当条件涉及由 Mutex 保护的数据时,使用 Condvar 比线程停车更方便,并且可能更有效。





关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



从游客角度看“一X游”建设

今年的第八篇,这是一篇邀稿,由文旅行业从业者“心晴”撰写。

​注:本文是“心晴”从游客角度出发撰写的体验和问题分析文章,并不代表“土猛的员外”和​相关公司的观点。

1
一X游:表示一机游、一码游、一键游...

首先,笔者想先以旅游从业者且是旅游信息化从业者的角度,简单讲一下“一X游”的基本情况。2017年是一个分界线,虽然从2014年开始,国内旅游信息化建设大体上也是分为服务、管理和营销三个方向,但服务侧都相对分散,没有形成一个体系,更多侧侧重点在于门票电商。而2017年“一部手机游云南”开始,国内开启了“一X游”模式,从全域的视角整合目的地的文旅资源,包括门票、酒店、民宿、特产等,在类OTA电商功能的基础上,丰富公共服务和体验服务,实现与OTA部分程度上的差异化,服务占据了信息化建设很大一部分比重,直到目前依然是这种形态。

这种形态主导的国内旅游信息化建设面向游客端的服务,一般会划分为游前、游中和游后三个阶段。

  • 游前:提供资讯、咨询、预订等服务,如信息查看、攻略查询、票务/酒店预订等;
  • 游中:提供体验相关的服务,如导游导览、语音讲解、导航等;
  • 游后:提供投诉建议、特产预订等服务。

一般前两者会是重点,下文的游客体验方面的叙述也会从这三方面开启。

其次,再解释一下为什么上文一直用的是“旅游信息化”这个词,而不是“智慧旅游”。因为在笔者看来,“信息化”是一个过程,一个工具或者说一种手段,而“智慧旅游”是一个目标,一个结果或者说是一种状态,而目前国内旅游信息化离这个目标还有很长一段距离。

这里,我先以一个旅游爱好者的身份,分享一下笔者的几次出行经历,看看“一X游”是否有给笔者带来什么便利或者价值

01

在说具体案例之前,笔者可以先简单说说通病,以便大家可以更好地理解后面的具体例子。在2019年和2020年,笔者多次出行,去了国内多个城市。出发之前自然是交通和住宿的安排,也就是游前的过程,这两项笔者都是在OTA上完成的,这是一个养成的习惯。到达目的地后,也就是游中的过程,笔者分别去了这几个城市的著名景区和博物馆。去这些地方游玩时,所有的门票都是到达景区后,在景区门口买的,用的也都是景区自己搭建的自营平台。对于一些文化底蕴很厚重的景区和博物馆,自己看的话无疑是走马观花,就分别现场请了导游(或讲解员),一是没看到线下有这次城市已建的“一X游”线上引导的入口,二是也担心“一X游”的实际服务覆盖能力有限,选择景区自己提供的服务会更精确、响应更及时。

回顾这几次出行的整个过程,完全没有这几个城市“一X游”的使用场景,虽然笔者作为参与者,也参与了其中一个城市“一X游”的建设工作,但是在实际出行的过程中,笔者完全没有想起过要使用它,也就是这个平台没有占据笔者的消费心理。一方面,它确实没有成为笔者的消费习惯,另一方面,笔者在游玩的过程中,没有看到它的入口和****宣传,也就没有启动提示作用,导致它被笔者忽视。

好了,下面举两个具体的例子,但是需要事先说明的是,这两个例子已经是笔者认为国内目前“一X游”的“翘楚”了,我们来看看具体的问题。

02

2021年,“一码游贵州”已经是业内仅次于“一部手机游云南”的存在了,无论是宣传上,还是功能上。但是作为游客的笔者,去贵州的时候与它的交道依然很少。基于各种原因,笔者们去贵州选择的是跟团游,在选定跟团游的行程前,笔者线上在UGC平台做了相关攻略,明确了要去的几个景区,然后在OTA上选择了一个较为合适的线路,并预定了大交通,这是游前的过程。在做这些之前,笔者有意在“一码游贵州”上看了下相关的攻略,可能是习惯问题,平台上的攻略不太能满足需求,所以还是决定在UGC平台上完成这个过程。

由于是跟团游,在贵州出行期间的酒店、交通和门票都是旅行社帮忙解决的,但是每个景区的参观导游是没办法一直在一起的,这个时候笔者有意尝试用“一码游贵州”的导游导览功能,功能页面如下图,这其实是要求笔者要知道自己所在的景点是什么,有目的地选择讲解语音,相对来说不是很便利,也就没有使用。



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外

在贵州游玩真真切切使用到了“一码游贵州”的功能的话其实是在青岩古镇,疫情的关系,虽然不用门票,但是需要预约,预约的平台就是这个。这是整个游中唯一一次真切使用到这个“一码游贵州”平台,而这个使用更大程度上是行政要求(预约)的结果,当然也确实给游客和景区带来便利,节省了排队登记时间,也提升了部分游客体验。

在整个贵州游玩期间,无论是刻意还是实际需求,使用“一码游贵州”平台的机会也是很少的,且体验效果上来说也没有觉得非常的便利或者有效。相较于其他地方的那次出行,在游玩期间看到“一码游贵州”线下入口的机会确定提升了很多,且平台实现了LBS(Location Based Services,基于位置的服务)的应用,在导览的时候可以查看不同景点的距离信息并实现导航,但语音讲解的效果并没有明显的感觉。

03

时间来到2023年,一个意外的契机,笔者去了西双版纳游玩,因为是很临时了,且赶上了那边的旺季,笔者有一部分是时间是自由行,一部分是跟团游。没有太多时间做攻略,笔者只是在社交平台上粗粗地看了几篇游记,选定了去参观游览的景区,以此来安排后面的行程,并在12306上预订了大交通,这是整个游前的过程,快速且随意。

达到目的地后,笔者先是自由行,当时在去中科院植物园和曼听公园之间选择,心里想去的是中科院植物园,但是距离较远,尝试了不同渠道的大巴预订没有成功后,改去了距离较近的曼听公园。

曼听公园的门票也是到了景区后现场门口买的,由于没有做详细的攻略,入园之后完全不知道应该走什么路线,哪个是核心景点,怎么走合适,一时有点蒙。无意间看到了“一部手机游云南的”入口,便尝试着用了下,看到的是下面的页面,导览的跟“一码游贵州”一样,需要知道所在的位置手动播放讲解,推荐线路又没有讲解,本来方向感就差,直接放弃了。

去中科院植物园大巴车的预订和在曼听公园对于讲解的需求,是本次西双版纳出行对“一部手机游云南”有最直接需求的两次,但是平台没有大巴车的预订入口,讲解的体验感也没有很好,导致直接放弃了。此后的行程,尽管很多次看到平台的引导物料,但是用不到这个平台,也就没有再触达了。

总结

结合笔者这几次的出行和身边朋友的出游经验,其实游客游前的需求基本上还是市场行为的OTA、生活服务平台和UGC来满足,作为业内人的笔者都很少使用当地的“一X游”平台,更别提非业内的游客了。游中的过程其实**是有需求的,尤其是公共服务类和体验类的需求,但是这部分的需求尚不能得到很好的满足。游后**除非有非常不好的体验,一般不会有投诉上的需求,特产的购买也很少会在目的地平台上进行,主要还是在电商平台上。

游前使用少的情况一方面是OTA、生活服务平台和UGC平台已经占据了游客心智,游客们在出游时首先想到的就是这些平台,而不是其他,且大交通目的地平台基本上都提供不了,这个入口引流很重要。另一方面是游客触达有限,尤其是长途出行的游客,他们很少会知道目的地的平台,也就无从说起使用了,而达到目的地后没有强刺激或者需求,他们也不会主动去使用这些平台。

“一X游”平台大多是在政府或者具有旅游职能的国企主导下建设的,他们的初衷是希望能够整合目的地的资源,打造本地化的OTA,并满足公共服务的职能要求。但现实是骨感的,游前部分无论是游客消费心智的占领,还是产品的多样化,“一X游”平台都比不过OTA和生活服务平台。平台的运营方的属性决定了,他们没有资源也没有能力真正像OTA和生活服务平台那样进行地推式地业务拓展,丰富自身产品,他们只能在可控的资源范围内实现整合。游中部分除非是把各景区自身平台取缔或者做整合,不然使用的概率也很小。

因此,“一X游”平台的定位包括目标人群的定位从一开始偏离了,作为政府或者是国企主导建设的,在基础电商功能很难突破的情况下,应该转移重点,【观点】以周边短途游为目标,并从公共服务和体验服务上着手,目标人群也应该以目的地居民为主,关注游中服务体验更合适

首先:长途游客获客成本太高,且消费频次有限,而本地居民不同,他们对本地化的需求是高频次的,高品质的周末游或短途游产品对他们是有吸引力的,且本地居民的触达相对容易

其次,公共服务本身就是政府职能的部分,且公共服务不等于零消费服务,景区之间的交通服务整合和完善也是可以带来收益的。

再次,真正从游客的角度提供有使用价值的体验服务是能够刺激本地居民多次使用目的地平台,提高平台用户的活跃度的,如从游客参观游览角度设计导游导览功能,提供现场导游拼团的功能。

最后,有政府公信力背书,发挥监管职能,对本地居民是有说服力的。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



ChatGPT的工作原理

这是一篇翻译稿(使用ChatGPT翻译)

英文原文:What Is ChatGPT Doing … and Why Does It Work?

文章非常长2.8万字,文末有该文章的电子书下载,可导入微信读书进行听书

一个词一个词的输出

ChatGPT能够自动生成类似于人类书写的文本,这是非常了不起和出乎意料的。但它是如何做到的?为什么会有效果呢?我的目的在于大致概述ChatGPT内部发生了什么,然后探讨它为什么能够很好地生成我们认为有意义的文本。我应该在开始时说一下,我将专注于正在进行的大局,并且虽然我会提及一些工程细节,但我不会深入研究它们。(而且我所说的要点同样适用于其他当前“大型语言模型”[LLMs]以及ChatGPT。)
首先需要解释的是,ChatGPT 始终基本上试图产生一个“合理的延续”,无论它已经得到了什么样的文本,所谓“合理”,我们指的是“在看过数十亿个网页等内容后,人们可能会写出什么”。
假设我们有一段文本:“AI最好的地方在于它的能力”。想象一下扫描数十亿页人类书写的文本(比如网络和数字化图书)并找到所有这个文本出现的实例,然后看接下来出现什么单词以及占比是多少。ChatGPT 实际上做了类似的事情,只不过(我将解释)它不是查看字面文字;而是寻找某种意义上“匹配”的东西。但最终结果是生成一个可能跟随该文本的单词排名列表,并带有“概率”:

而令人惊奇的是,当ChatGPT做一些事情,比如写一篇文章时,它实际上只是一遍又一遍地问自己:“在已有的文本基础上,下一个词应该是什么?”-每次都添加一个单词。(更准确地说,我将解释为什么它会添加“标记”,这可能只是单词的一部分,这就是为什么它有时可以“创造新单词”的原因。)
但是,每一步它都会得到一个带有概率的单词列表。但是它应该选择哪个单词添加到文章(或其他正在编写的内容)中呢?人们可能认为应该选择“排名最高”的单词(即分配了最高“概率”的单词)。但这就是巫术开始渗入的地方。因为出于某种原因——也许有一天我们会对此有科学式的理解——如果我们总是选择排名最高的单词,我们通常会得到一个非常“平淡无奇”的文章,似乎从未表现出任何创造力(甚至有时逐字重复)。但如果偶尔(随机地)选择较低排名的单词,则可以获得更加“有趣”的文章。
这里的随机性意味着,如果我们多次使用相同的提示,每次得到的文章很可能会不同。并且,符合巫术思想的是,有一个特定的所谓“温度”参数来确定使用低排名词汇的频率,在生成文章方面,“温度”为0.8似乎最好。(值得强调的是这里没有使用任何“理论”,只是根据实践发现什么有效而已。例如,“温度”的概念之所以存在是因为指数分布在统计物理学中很常见,但至少就目前而言,并没有与物理学建立起联系。)
在我们继续之前,我应该解释一下,为了说明的目的,我大多数情况下不会使用ChatGPT中完整的系统;相反,我通常会使用一个更简单的GPT-2系统,它有一个很好的特点:它足够小,在标准桌面计算机上运行。因此,对于我展示的所有内容,我都能够包含明确的Wolfram语言代码,并且您可以立即在您自己电脑上运行。(点击这里任何图片以复制其后面的代码。)

例如,这是如何获取上面的概率表格。首先,我们必须检索基础的“语言模型”神经网络:

稍后,我们将深入研究这个神经网络,并讨论它的工作原理。但现在,我们可以把这个“网络模型”视为黑盒子应用于到目前为止的文本中,并询问模型所说的应该跟随的概率最高的前5个单词:


这将获取该结果并将其转换为显式格式的“数据集”:


如果一个人反复“应用模型”,在每一步中添加具有最高概率的单词(在此代码中指定为模型的“决策”),则会发生以下情况:

如果继续下去会发生什么?在这种“零温度”的情况下,很快就会变得混乱和重复。


但是,如果我们不总是选择“顶部”单词,而是有时随机选择“非顶部”单词(其中的“随机性”对应于温度0.8),会怎样呢?同样可以构建文本:”。

每次这样做时,都会进行不同的随机选择,文本也会因此而不同——就像这5个例子一样:

值得指出的是,即使在第一步,也有很多可能的“下一个单词”可供选择(在温度0.8时),尽管它们的概率很快就会下降(是的,在这个对数-对数图上的直线对应于n-1“幂律”衰减,这非常符合语言的一般统计规律):

那么如果一个人继续下去会发生什么?这是一个随机的例子。它比零温度情况更好,但最多也只是有点奇怪。

这是使用最简单的GPT-2模型(来自2019年)完成的。使用更新和更大的GPT-3模型,结果会更好。以下是使用相同“提示”生成的零温度文本中排名第一的词语,但使用了最大的GPT-3模型:

这里是一个在“温度0.8”下的随机示例:

概率从哪里来?

好的,ChatGPT总是根据概率选择下一个单词。但这些概率从哪里来?让我们先考虑一个更简单的问题。我们考虑逐个字母(而不是单词)生成英文文本。我们如何计算每个字母的概率呢?

我们可以做的非常简单的事情是只需取一段英文文本样本,然后计算其中不同字母出现的频率。例如,这个例子统计了维基百科上“猫”条目中字母的数量:

这也同样适用于“狗”:


结果是相似的,但并不完全一样(“o”在“dogs”文章中无疑更常见,因为毕竟它出现在单词“dog”本身中)。然而,如果我们采集足够大量的英文文本样本,最终可以期望得到至少相当一致的结果:

这是一个样例,如果我们只使用这些概率生成一系列字母,就会得到以下结果:

我们可以通过将空格视为带有一定概率的字母来将其分解为“单词”:

我们可以通过强制“单词长度”的分布与英语中的相符来稍微更好地生成“单词”:

我们这里没有得到任何“实际单词”,但结果看起来稍微好一些了。不过,要进一步发展,我们需要做的不仅仅是随机地挑选每个字母。例如,我们知道如果有一个“q”,下一个字母基本上必须是“u”。

以下是各个字母概率的图表:

这里是一个显示典型英文文本中字母对(“2-gram”)概率的图表。可能的第一个字母显示在页面上方,第二个字母则显示在页面下方:

我们在这里看到,例如,“q”列除了“u”行以外都是空白(零概率)。好的,现在不再一次生成我们的“单词”,而是使用这些“2-gram”概率一次生成两个字母。以下是结果样本——其中包括一些“实际单词”:”。

有足够多的英文文本,我们不仅可以得到单个字母或字母对(2-gram)的概率估计,还可以得到更长一段文字的概率估计。如果我们使用逐渐变长的n-gram概率生成“随机单词”,我们会发现它们逐渐变得“更加真实”。

但是现在让我们假设——就像ChatGPT一样——我们正在处理整个单词,而不是字母。英语中有大约40,000个常用单词。通过查看大量的英文文本(比如几百万本书,总共数百亿个单词),我们可以估计每个单词的使用频率。并且利用这些信息,我们可以开始生成“句子”,其中每个单词都是独立随机选择的,并具有它在语料库中出现的相同概率。以下是一个示例:

毫不奇怪,这是无意义的。那么我们该如何做得更好呢?就像处理字母一样,我们可以开始考虑不仅单个词的概率,还要考虑成对或更长的n元组词语的概率。针对成对情况进行此操作时,以下是5个例子,在所有情况下都从“猫”这个词开始:

它看起来变得稍微更加“合理”。我们可以想象,如果我们能够使用足够长的n-gram,基本上就会“获得一个ChatGPT”——也就是说,我们将获得一些生成具有“正确整体文章概率”的长度为论文的单词序列。但问题在于:甚至没有足够的英语文本被写出来以便推断这些概率。

在网络爬虫中可能有数千亿个单词;在已数字化的书籍中可能还有另外数百亿个单词。但是,即使使用40,000个常用单词,所有可能的二元组合也已经达到了16亿之多——而所有可能的三元组合则高达60万亿。因此,我们无法从现有文本中估计出这些概率。当我们考虑包含20个单词的“文章片段”时,其可能性数量已经超过了宇宙中粒子的数量,因此从某种意义上说,它们永远无法全部写下来。

那我们能做什么呢?大的想法是建立一个模型,让我们能够估计序列出现的概率——即使我们在查看的文本语料库中从未明确地看到过这些序列。而 ChatGPT 的核心正是所谓的“大型语言模型”(LLM),它被构建成能够很好地估计这些概率。

什么是模型?

假设你想知道(就像加利略在16世纪末那样)从比萨斜塔的每层楼扔下炮弹需要多长时间才能撞到地面。好吧,你可以在每种情况下进行测量并制作结果表。或者你可以做理论科学的本质:制作一个模型,给出一些计算答案的过程,而不仅仅是测量和记忆每个案例。

让我们想象我们有(有点理想化的)数据,了解炮弹从各个楼层掉落需要多长时间:

我们如何计算从我们没有明确数据的楼层掉落需要多长时间?在这种情况下,我们可以使用已知的物理定律来计算。但是假设我们只有数据,而不知道支配它的基本规律,则可能会进行数学猜测,例如也许应该将直线作为模型:

我们可以选择不同的直线。但这条直线平均上最接近我们所给出的数据。从这条直线,我们可以估计任何楼层的落下时间。

我们怎么知道要在这里尝试使用一条直线?在某种程度上,我们并不知道。这只是数学上简单的事情,而且我们习惯于许多测量到的数据都能够很好地适应数学上简单的东西。 我们可以尝试更复杂的数学方法 - 比如 a + b x + c x2 - 在这种情况下,效果会更好:

事情可能会变得非常糟糕。比如,这是我们在 a + b/x + c sin(x) 上能做到的最好结果:

值得理解的是,从来没有“无模型模型”。您使用的任何模型都具有某些特定的基础结构-然后是一组“可以转动的旋钮”(即可以设置的参数),以适应您的数据。在ChatGPT的情况下,使用了许多这样的“旋钮”-实际上有1750亿个。

但值得注意的是,ChatGPT 的基础结构 - 仅仅拥有如此之多数量级参数 - 足以制作一个计算下一个单词概率足够好以给我们合理长度文本段落。

类人任务模型

我们上面提到的例子涉及制作数值数据模型,这些数据基本上来自简单物理学——几个世纪以来我们已经知道“简单的数学适用”。但是对于ChatGPT,我们必须制作一个人类大脑产生的语言文本模型。而对于这样的东西,我们(至少目前)没有像“简单数学”那样的任何东西。那么它可能是什么样子呢?

在谈论语言之前,让我们谈论另一项类似人类任务:识别图像。作为其中一个经典机器学习示例,让我们考虑数字图像(是的)。

我们可以做的一件事是为每个数字收集一堆样本图片:

然后,为了确定我们输入的图像是否对应于特定的数字,我们可以使用样本进行逐像素比较。但是作为人类,我们似乎做得更好——因为即使数字是手写的,并且具有各种修改和扭曲,我们仍然能够识别它们:

当我们为上面的数字数据建立模型时,我们能够取得给定的数值x,并计算特定a和b的a + bx。因此,如果我们将每个像素的灰度值视为某些变量xi,则是否存在一些所有这些变量的函数可以告诉我们图像所代表的数字?事实证明是可能构造出这样一个函数。但并不奇怪,它并不特别简单。一个典型例子可能涉及大约50万次数学运算。

但最终结果是,如果我们将图像中每个像素点的集合馈送到该函数中,则会输出指定该数字对应于哪个手写数字图像的编号。稍后,我们将讨论如何构建这样一个函数以及神经网络的概念。但现在让我们把这个功能当作黑盒子来处理,在那里我们输入手写数字(作为像素值数组)并获得相应数字:

但是这里真正发生了什么?假设我们逐渐模糊一个数字。一段时间内,我们的函数仍然“识别”它,例如作为“2”。但很快它就会“失去”,并开始给出“错误”的结果:

但是为什么我们说这是“错误”的结果呢?在这种情况下,我们知道通过模糊“2”来得到了所有的图像。但如果我们的目标是产生一个人类识别图像的模型,真正需要问的问题是:如果向人类展示其中一张模糊的图片,并不知道它来自哪里,那么人类会怎样做?

如果我们从函数中获得的结果通常与人类所说的相符,则说明我们有一个“好模型”。而非平凡科学事实是,在像这样的图像识别任务中,我们现在基本上已经知道如何构建能够完成此任务的函数。

那么,我们能否“数学证明”它们有效呢?嗯,并不能。因为要做到这一点,就必须对人类正在进行什么样的数学理论进行解释。拿出“2”号图像并更改几个像素。也许只需少量变化即可将其视为“2”。但应该持续多久呢?这涉及到人类视觉感知问题。而且,确实可以肯定地说对于蜜蜂或章鱼等动物答案可能会有所不同——对于外星生命体则完全不同。

神经网络

神经网络 好的,那么我们通常用于图像识别等任务的模型是如何工作的呢?目前最流行和成功的方法使用神经网络。神经网络在1940年代被发明出来,其形式与今天使用的形式非常接近,可以被认为是大脑运作方式简化后的理想化模型。

人类大脑中有约1000亿个神经元(神经细胞),每个神经元能够产生电脉冲高达一千次/秒。这些神经元以复杂网状连接在一起,每个神经元都有树枝状分支,使其能够向数千个其他神经元传递电信号。粗略地说,在任何给定时刻一个特定的神经元是否会产生电脉冲取决于它从其他不同连接收到了哪些“权重”不同的信号。

当我们“看到一张图片”时,光子从图像落在眼睛后部(“光感受器”)细胞上时会在神经细胞中产生电信号。这些神经细胞与其他神经细胞相连,并最终通过整个序列层级结构传递信号。正是通过这种过程我们才能够“识别”图像,最终“形成思想”,认为我们正在“看到一个2”(也许最后会做出类似于大声说出“二”的动作)。

前一节中的“黑盒子函数”是这种神经网络的数学化版本。它恰好有11层(尽管只有4个“核心层”)。

这个神经网络并没有什么特别的“理论推导”,它只是在1998年作为一种工程构建出来,并被发现可以工作。当然,这与我们描述大脑是通过生物进化过程产生的方式并没有太大不同。

好了,但是这样的神经网络如何“识别事物”呢?关键在于吸引子的概念。想象一下,我们有手写的数字1和2:

我们希望所有的1都“被吸引到一个地方”,所有的2都“被吸引到另一个地方”。或者换一种说法,如果一幅图像在某种程度上比成为2更接近于成为1,我们希望它最终出现在“1位置”,反之亦然。

作为一个简单的类比,假设我们有平面上的某些位置,用点表示(在实际生活中可能是咖啡店的位置)。那么我们可以想象从平面上任何一点开始,我们总是想要最终到达最近的点(即我们总是去最近的咖啡店)。我们可以通过将平面分割成由理想化“分水岭”隔开的区域(“吸引子盆地”)来表示这一点:

我们可以将其视为实现一种“识别任务”,其中我们不是在识别给定图像“最像哪个数字”,而是直接看到给定点最接近的点。 (我们在这里展示的“ Voronoi 图”设置将 2D 欧几里得空间中的点分开; 数字识别任务可以被认为是做类似的事情 - 但在由每个图像中所有像素的灰度级形成的 784 维空间中进行。)
那么我们如何让神经网络“执行识别任务”呢?让我们考虑这个非常简单的情况:

我们的目标是将与位置{x,y}相对应的“输入” - 然后将其识别为最接近它的三个点之一。换句话说,我们希望神经网络计算像{ x,y }这样的函数:

那么我们如何使用神经网络来完成这个任务呢?最终,神经网络是由理想化的“神经元”组成的连接集合——通常排列在不同层中。一个简单的例子如下:

每个“神经元”都被有效地设置为评估一个简单的数值函数。要使用这个网络,我们只需在顶部输入数字(如我们的坐标x和y),然后让每层神经元“评估它们的函数”,并将结果向前传递到网络中 - 最终在底部产生最终结果:

在传统的(生物启发式)设置中,每个神经元实际上都有一定数量的“输入连接”,这些连接来自前一层的神经元,每个连接被分配一个特定的“权重”(可以是正数或负数)。给定神经元的值由将“先前神经元”的值乘以它们对应的权重,然后加上一个常量并最终应用“阈值化”(或“激活”)函数来确定。在数学术语中,如果一个神经元具有输入x = {x1、x2…},则我们计算f [w.x + b],其中权重w和常量b通常为网络中每个神经元选择不同;函数f通常相同。

计算w . x + b只是矩阵乘法和加法问题。“激活函数”f引入了非线性(最终导致非平凡行为)。各种激活功能通常会得到使用; 在这里我们将仅使用Ramp(或ReLU):

对于我们希望神经网络执行的每个任务(或等效地,对于我们希望它评估的每个总体功能),我们将有不同的权重选择。(正如我们稍后将讨论的那样,这些权重通常是通过使用机器学习从输出示例中“训练”神经网络来确定的。)

最终,每个神经网络只对应某种整体数学函数 - 尽管可能很难写出来。 对于上面的示例,它将是:

ChatGPT的神经网络也只是对应于一个像这样的数学函数——但实际上有数十亿个术语。 但让我们回到单个神经元。以下是一些具有两个输入(表示坐标x和y)的神经元可以使用各种权重和常量计算的功能示例(并且Ramp作为激活函数):

那么,上面的更大网络呢?它计算出了什么:

它不完全是“正确的”,但它接近我们上面展示的“最近点”函数。 让我们看看其他一些神经网络会发生什么。在每种情况下,正如我们稍后将解释的那样,我们使用机器学习来找到最佳权重选择。然后,在这里展示具有这些权重的神经网络计算出了什么:

更大的神经网络通常更能够逼近我们所追求的函数。在每个吸引子盆地的中心,我们通常可以得到确切的答案。但是在边界处——当神经网络“难以做出决定”时——情况可能会变得混乱。

通过这个简单的数学式“识别任务”,很明显可以知道什么是“正确答案”。但在手写数字识别问题中,情况就不那么清晰了。如果有人把一个“2”写得像一个“7”等等,怎么办?尽管如此,我们仍然可以问神经网络如何区分数字——这给出了一些指示:

我们能用“数学方法”来描述网络是如何进行区分吗?实际上不行。它只是在“做神经网络所做的事情”。但事实证明,这通常与我们人类所做出的区分相当一致。 让我们举一个更复杂的例子。比方说,我们有猫和狗的图像,并且有一个已经被训练好了以区分它们的神经网络。以下是它在某些示例中可能会执行的操作:

现在,“正确答案”变得更加不清楚了。比如,一只穿着猫装的狗会怎样呢?无论输入什么,神经网络都会生成一个答案。而且,事实证明它以一种与人类相当一致的方式进行操作。正如我上面所说的那样,这并不是我们可以“从第一原理中推导出来”的事实。这只是某些领域内已被发现为真实的东西。但这也是神经网络有用的关键原因:它们以某种方式捕捉到了“类似于人类”的做事方式。

向自己展示一张猫的图片,并问:“为什么那是只猫?”你可能会开始说:“嗯,我看到了它尖耳朵等等。”但很难解释你如何认出该图像为猫。就好像你大脑里想通了似的。但对于大脑来说(至少目前还没有),没有办法“进入其中”并查看其运作过程。那么对于(人工)神经网络呢?当您展示给它一张猫图片时,每个“神经元”都在做什么非常容易看出来。但即使要得到基本可视化通常也非常困难。

在我们用于上述“最近点”问题的最终网络中,有17个神经元。在识别手写数字的网络中有2190个神经元。而在我们用来识别猫和狗的网络中则有60,650个神经元。通常很难可视化相当于60,650维空间的东西。但由于这是一个处理图像的网络,它许多层次的神经元都被组织成了数组,就像它正在查看像素数组一样。

如果我们拿一张典型的猫图片作为例子……
Cat

然后,我们可以通过一系列派生图像来表示第一层神经元的状态——其中许多我们可以轻松解释为“没有背景的猫”或“猫的轮廓”。

但总的来说,我们可以说神经网络正在“挑选某些特征”(也许尖耳朵是其中之一),并利用这些特征来确定图像的内容。但这些特征是否具有名称,比如“尖耳朵”?大多数情况下不是。

我们的大脑是否使用类似的特征?大多数时候我们不知道。但值得注意的是,像我们在这里展示的神经网络的前几层似乎会挑选出与人类视觉处理第一层所选择相似(例如物体边缘) 的图像方面。

但假设我们想要一个关于神经网络中猫识别理论。 我们可以说:“看,这个特定网络就能做到”,并立即给我们一些关于“问题难度”的概念(例如需要多少个神经元或层数)。 但至少目前为止,我们没有办法“提供叙述性描述”该网络正在执行什么操作。也许这是因为它真正地计算上不可约简,并且除了明确跟踪每个步骤外,没有通用方法找到它所做的事情。 或者可能只是因为我们还没有“弄清楚科学”,并确定了允许我们总结发生了什么事情 的 “自然规律”。

当我们谈论使用ChatGPT生成语言时,我们将遇到相同类型的问题。同样不清楚是否有方法“总结它正在做什么”。但是语言的丰富性和细节(以及我们对其的经验)可能使我们比图像更进一步。

机器学习和神经网络的训练

到目前为止,我们一直在谈论“已经知道”如何执行特定任务的神经网络。但是,使神经网络如此有用(也应该适用于大脑)的原因不仅在于它们原则上可以执行各种任务,而且还可以通过逐步“从示例中进行训练”来完成这些任务。

当我们制作一个区分猫和狗的神经网络时,我们实际上不必编写一个程序(比如明确查找触须);相反,我们只需展示很多关于什么是猫和什么是狗的例子,并让网络从中“机器学习”,以了解如何区分它们。

而且重要的是,受过培训的网络会从所显示出来的具体示例中“概括”。正如我们之前看到过得那样,并不仅仅是因为该网络识别了其所显示出来图像中特定像素模式上面部分属于哪个类别;相反地,在某种程度上说,就算基础数据发生变化或者噪声干扰等情况下, 神经元能够根据一些被认为具有普遍性质并将其与其他物品区分开来。

那么神经网络的训练实际上是如何工作的呢?基本上,我们一直在尝试找到使神经网络成功复制所给出示例的权重。然后,我们依靠神经网络以“合理”的方式进行“插值”(或“概括”)这些示例之间。

让我们看一个比上面最近点问题更简单的问题。让我们只是尝试让神经网络学习函数:

对于这个任务,我们需要一个只有一个输入和一个输出的网络,例如:

但是我们应该使用什么权重等呢?对于每组可能的权重,神经网络都会计算出某个函数。例如,这里是它在几组随机选择的权重下所做的事情:

是的,我们可以清楚地看到,在这些情况下,它甚至都无法接近我们想要的功能。那么如何找到能够复制该函数的权重?

基本思路是提供大量“输入→输出”示例以供“学习”,然后尝试找到能够复制这些示例的权重。以下是使用逐渐增加的示例进行操作所得出的结果:

在这个“训练”过程的每个阶段,网络中的权重都会逐步调整——我们看到最终得到了一个成功复现所需函数的网络。那么我们如何调整权重呢?基本思想是在每个阶段看一下离目标函数还有多远,然后以更接近目标为方式更新权重。

为了找出“离目标还有多远”,我们计算通常称为“损失函数”(或者有时称之为“代价函数”)的东西。在这里,我们使用简单(L2)损失函数,它只是差值平方和与真实值之间差异的总和。随着训练过程不断进行,我们发现损失函数逐渐减小(遵循不同任务不同的特定“学习曲线”),直到达到一个点,在该点上网络至少可以很好地近似复制所需功能:

好的,最后一个需要解释的关键部分是如何调整权重以减少损失函数。正如我们所说,损失函数给出了我们得到的值与真实值之间的“距离”。但是,“我们得到的值”在每个阶段都由当前版本的神经网络和其中的权重确定。现在想象一下,这些权重是变量——比如wi。我们想找出如何调整这些变量的值来最小化依赖于它们的损失。 例如,在对实际使用中典型神经网络进行极度简化时,假设只有两个权重w1和w2。那么我们可能会有一个损失函数作为w1和w2 的函数看起来像这样:

数值分析提供了各种技术来找到这种情况下的最小值。但是,一个典型的方法就是从我们之前拥有的任何w1、w2开始逐步跟随最陡峭的路径:

就像水流下山一样,唯一保证的是这个过程最终会到达表面的某个局部极小值(“山湖”);它很可能不会达到最终的全局极小值。

找到“权重景观”上最陡峭下降路径似乎并不明显可行。但微积分来拯救了我们。正如我们上面提到的,人们总是可以将神经网络视为计算一个数学函数——该函数取决于其输入和权重。但现在考虑对这些权重进行微分。结果发现,微积分中的链式法则实际上让我们能够“展开”神经网络中连续层所做操作。而结果是,在某种本地逼近意义下,我们可以“反转”神经网络操作,并逐步找到使输出损失最小化的权重。

上图显示了在仅有2个权重时可能需要执行的最小化类型情况(非常简单)。但事实证明即使使用更多权重(ChatGPT使用1750亿),仍然可以进行最小化处理,至少在某种逼近级别上是可能的。事实上,“深度学习”的巨大突破发生在2011年左右,与此相关联:人们发现,在涉及许多权重时(至少近似)最小化可能更容易,而在涉及相当少的权重时则更难。

换句话说——有些违反直觉——使用神经网络解决更复杂的问题可能会更容易。这个粗略的原因似乎是,当一个人拥有许多“权重变量”时,就会有一个高维空间,“很多不同方向”可以引导一个人到达极小值处——而对于较少的变量,则更容易陷入局部极小值(“山湖”),从中没有“出路”。

值得指出,在典型情况下,存在许多不同的权重集合,它们都将给出几乎具有相同性能的神经网络。通常,在实际神经网络培训中进行了大量随机选择——导致产生“不同但等效”的解决方案,如以下所示:

但是每个“不同的解决方案”都会有至少稍微不同的行为。如果我们要求在我们给出训练示例之外进行“外推”,我们可能会得到截然不同的结果:

但这些哪一个是“正确”的呢?实际上没有办法说。它们都“与观察到的数据一致”。但它们都对应着不同的“内在”方式来思考如何“跳出框架”。有些可能对我们人类来说似乎比其他更为合理。

神经网络训练的实践和技巧

特别是在过去十年中,神经网络训练的艺术已经有了很多进展。而且,它基本上就是一门艺术。有时候——尤其是事后——人们可以看到至少有一个“科学解释”来解释正在进行的某些事情。但大多数情况下都是通过试错发现的,添加想法和技巧逐渐建立了关于如何处理神经网络的重要传说。

其中有几个关键部分。首先,对于特定任务应该使用什么样的神经网络结构这个问题非常重要。然后就是如何获取用于训练神经网络的数据这个关键问题。越来越多地不再需要从头开始训练一个新网:相反,新网可以直接包含另一个已被训练好的网,或者至少可以使用那个网为自己生成更多的训练示例。

人们可能认为每种特定类型任务都需要不同架构设计的神经网络才能完成。但所发现的是,在表面上看起来完全不同任务中甚至相同架构似乎也能够工作得很好。在某种程度上这使人想起通用计算(以及我的计算等价原理)的想法,但正如我稍后将讨论的那样,我认为这更多地反映了我们通常试图让神经网络完成“类似人类”的任务——而神经网络可以捕捉相当普遍的“类人过程”。

在神经网络的早期,人们倾向于认为应该“尽可能让神经网络做得少”。例如,在将语音转换为文本时,人们认为应该先分析语音,将其分解成音素等。但是发现,在至少“类似于人类任务”的情况下,最好只是尝试在“端到端问题”上训练神经网络,并让它自己“发现”必要的中间特征、编码等。

还有一个想法是应该向神经网络引入复杂的单个组件,以使其实际上“明确地实现特定算法思想”。但同样地,这大多数情况下都不值得;相反,最好只处理非常简单的组件,并让它们(虽然通常以我们无法理解的方式)自行“组织”,从而达到(可能)与那些算法思想等效的结果。

这并不意味着没有适用于神经网络的“结构性思想”。例如,在图像处理的早期阶段使用具有局部连接的2D神经元数组似乎非常有用。并且具有集中于“回顾序列”的连接模式似乎很有用——正如我们稍后将看到——在处理诸如人类语言之类的事物时, 例如在ChatGPT中。

但神经网络的一个重要特征是,就像计算机一样,它们最终只处理数据。而当前的神经网络——使用当前的神经网络训练方法——具体处理数字数组。但在处理过程中,这些数组可以完全重新排列和重塑。例如,在上面用于识别数字的网络从2D“类似图像”的数组开始,迅速“加厚”到多个通道,然后“集中”成一个1D数组,最终包含表示不同可能输出数字的元素:

但是,好吧,如何确定特定任务所需的神经网络大小呢?这有点像一门艺术。在某种程度上,关键是要知道“任务有多难”。但对于类似人类的任务来说,通常很难估计。是的,可能有一种系统化的方法可以通过计算机非常“机械地”完成任务。但很难知道是否存在可以让人以至少“与人类水平”轻松完成任务的技巧或捷径。可能需要枚举一个巨大的游戏树才能“机械地”玩某个游戏;但可能会有更简单(“启发式”的)方法来实现“人类级别”的游戏。

当处理微小神经网络和简单任务时,有时候可以明确看到自己无法从此处开始解决问题。例如,在前面章节中使用几个小型神经网络进行该任务时最好做到了什么。

我们看到的是,如果神经网络太小,它就无法复制我们想要的功能。但是在某个大小以上,只要训练足够长时间并提供足够多的示例,它就没有问题了。顺便说一下,这些图片说明了神经网络传说中的一个部分:如果中间有一个“挤压”,强制所有东西都通过较少数量的中间神经元,则通常可以使用更小的网络。(值得一提的是,“无中间层”或所谓“感知器”网络只能学习基本上线性函数——但只要有一个中间层即可原则上任意逼近任何函数,并且至少具备足够多神经元时总是可能实现这一点;不过为了使其可行地进行训练,通常需要某种形式的正则化或归一化。)

好吧,假设我们已确定了某种神经网络架构。现在存在获取用于训练该网络数据方面的问题。对于神经网路和机器学习等领域很多实际挑战都集中在获取或准备必要训练数据方面。(在很多情况下(“监督学习”),人们希望获得输入和期望输出之间明确示例)。例如,在图像分类任务中,人们可能希望对图像进行标记。也许需要明确地经过大量努力进行标记。但是很多时候,事实证明可以利用已有的东西或将其用作某种代理。例如,在网络上提供了图片的alt标签;在不同领域中,人们可能会使用为视频创建的闭式字幕;或者在语言翻译培训方面,可以使用存在于不同语言中的网页或其他文档的平行版本。

需要多少数据来展示神经网络以训练它完成特定任务?从第一原理出发很难估计。当然,通过使用“迁移学习”将已在另一个网络中学习的重要特征列表“转移”,可以大大减少要求。但通常情况下,神经网络需要“看到很多例子”才能进行良好的训练。对于某些任务而言,至少有一个关键点是例子可能会非常重复。实际上,只需向神经网络显示所有现有的例子即可成为标准策略,在每个这样的“训练轮次”(或“时代”)中,神经网络都将处于至少稍微不同的状态,并且以某种方式提醒它记住特定示例对于使其记住该示例是有用的。(是的,“也许这类似于人类记忆中重复性所具有的用处。”)

但仅仅反复显示相同示例并不足够。还需要向神经网络展示该示例变化版本。而且作为神经网路传说中一项功能,“数据增强”的变化并不必须精密才能派上用场。只需使用基本图像处理轻微修改图像即可使其在神经网路培训方面与新的图像一样好。同样,当用于训练自动驾驶汽车的实际视频等数据已经耗尽时,可以继续在模型类似于电子游戏环境中运行的仿真中获取数据,而不需要所有实际现实场景的详细信息。

那么ChatGPT之类的东西呢?嗯,它有一个很好的特点,即它可以进行“无监督学习”,这使得从中获得示例以进行培训变得更加容易。回想一下ChatGPT的基本任务是找出如何延续给定文本片段。因此,要获得“培训示例”,只需获取一段文本,并屏蔽其末尾部分,然后将其用作“输入来进行培训”——输出为完整未屏蔽文本片段。我们稍后会进一步讨论这个问题,但主要观点是与例如学习图像内容相比,“没有明确标记”的必要性; ChatGPT 实际上可以直接从任何给定文本示例中学习。

好的,那么神经网络中的实际学习过程怎样呢?最终目标是确定哪些权重能够最好地捕捉到所给出的训练示例。有各种详细选择和“超参数设置”(因为权重可以被视为“参数”)可用于调整如何完成此操作。有不同选择的损失函数(平方和、绝对值之和等)。有不同的损失最小化方法(每步在权重空间移动多远等)。然后还有像展示多少个“批次”的示例来获得每个连续估计要尽量减小的损失这样一类问题。是的,我们可以应用机器学习(例如 Wolfram 语言中所做的)来自动化机器学习,并自动设置诸如超参数之类的东西。

但归根结底,整个训练过程可以通过观察损失逐渐减少来描述(就像这个 Wolfram 语言进度监视器显示了一个小型训练)。

通常情况下,我们会看到损失函数在一段时间内逐渐减小,但最终会趋于某个固定值。如果这个值足够小,则可以认为训练成功;否则就意味着需要尝试改变网络结构。

有人能说出“学习曲线”要平稳需要多长时间吗?就像许多其他事情一样,似乎存在大致的幂律缩放关系,这取决于使用的神经网络大小和数据量。但总体结论是训练神经网络很难,并且需要大量计算工作。实际上,其中绝大部分工作都花费在对数字数组进行操作上,而这正是GPU擅长的领域——这也是为什么神经网络训练通常受到GPU可用性限制的原因。

在未来,训练神经网络或者说完成神经网络所做的事情是否会有根本性的改进?我认为几乎肯定会。神经网络的基本思想是利用大量简单(基本上相同)组件创建一个灵活的“计算结构”,并使这个“结构”能够通过逐步修改从示例中学习。在当前的神经网络中,人们基本上使用微积分理论——应用于实数——来进行逐步修改。但越来越清楚地是,高精度数字并不重要;即使使用当前方法,8位或更少可能已经足够了。

使用计算系统,如元胞自动机,在许多个体位于并行操作的情况下,如何进行这种增量修改一直不太清楚,但没有理由认为这是不可能的。实际上,就像“2012年深度学习突破”一样,在更复杂的情况下进行这种增量修改可能会更容易。

神经网络——也许有点像大脑——被设置为具有基本固定的神经元网络,并且所修改的是它们之间连接的强度(“权重”)。 (也许在至少年轻人脑中可以显着增长全新连接数。)但是虽然这对生物来说可能是一个方便的设置,但它根本不清楚是否接近我们需要实现功能性所需最佳方法。而涉及等效于渐进式网络重写(也许类似于我们物理项目)的某些东西最终可能会更好。

但是,即使在现有神经网络的框架内,目前仍存在一个关键限制:神经网络训练现在基本上是顺序进行的,每个示例批次的影响被传播回来更新权重。实际上,在当前计算机硬件(即使考虑到GPU)中,大部分神经网络在训练期间都处于“空闲”状态,只有一部分正在更新。从某种意义上说,这是因为我们当前的计算机往往具有与其CPU(或GPU)分离的存储器。但在大脑中可能不同——每个“记忆元素”(即神经元)也可以成为潜在的活动计算元素。如果我们能够以这种方式设置未来的计算机硬件,则可能会更有效地进行培训。

一个足够大的网络一定可以做任何事情

像ChatGPT这样的东西的能力似乎非常令人印象深刻,以至于人们可能会想象,如果可以“继续前进”并训练更大的神经网络,那么它们最终将能够“做任何事情”。如果一个人关心那些对立即人类思考容易获得的事情,那么这很有可能是真的。但过去几百年科学所教导我们的教训是:有些问题可以通过形式化过程解决,但不一定容易被立即理解。

非平凡数学就是一个很好的例子。但总体来说实际上是计算。最终问题在于计算不可约性现象。有些计算看起来需要多个步骤才能完成,但实际上可以“简化”为某种相当直接的东西。然而发现了计算不可约性意味着这种方法并不总是奏效。相反地存在一些过程——可能像下面这个——无论如何都需要基本追踪每个计算步骤才能弄清楚会发生什么:

我们通常用大脑做的事情,可能是有意选择避免计算不可简化的。在脑中进行数学运算需要特别努力。而且,在实践中,“思考”任何非平凡程序操作步骤只靠大脑基本上是不可能的。

但当然我们有电脑可以解决这个问题。通过电脑,我们可以轻松地完成长时间、计算不可简化的任务。关键点在于一般来说没有捷径。

是的,我们可以记忆许多某些特定计算系统中发生了什么事情的具体例子。也许我们甚至能够看到一些(“计算可简化”的)模式,从而使我们能够进行少量泛化推理。但重要的是,计算不可简化意味着我们永远无法保证出现意外情况——只有通过明确地执行计算才能确定任何特定情况下实际发生了什么。

最终,在学习和计算不可简约之间存在根本性张力。学习实质上涉及利用规律压缩数据。但是,计算不可简约意味着最终存在规律性所限制的极限。

作为实际问题,人们可以想象将小型计算设备(如元胞自动机或图灵机)构建到可训练的系统中,例如神经网络。事实上,这些设备可以作为神经网络的良好“工具”,就像 Wolfram|Alpha 可以成为 ChatGPT 的良好工具一样。但是计算不可约性意味着我们不能指望“进入”这些设备并使它们学习。

换句话说,在能力和可训练性之间存在最终权衡:您希望系统充分利用其计算能力越多,则它会显示出更多的计算不可约性,并且它将变得越来越难以训练。而基本上易于训练的系统则无法进行复杂的计算。

对于当前版本的 ChatGPT 来说,情况实际上更加极端,因为用于生成每个输出标记的神经网络是纯“前馈”网络,没有循环结构,因此无法执行任何具有非平凡“控制流”的计算。)

当然,人们可能会想知道是否真正重要能够进行不可约化运算。事实上,在人类历史大部分时间里都不是特别重要。但我们现代技术世界已经建立在至少使用数学计算的工程基础之上,而且越来越多地使用更一般的计算。如果我们观察自然界,它充满了不可约化运算——我们正在逐渐理解如何模拟和利用这些运算以实现技术目的。

是的,神经网络肯定可以注意到自然界中我们也可以轻松注意到的那些规律。但是,如果我们想解决数学或计算科学范畴内的问题,除非它有效地“使用”一个“普通”的计算系统作为工具,否则神经网络将无法完成这项任务。

但是所有这些可能会让人感到困惑。过去有很多任务——包括写文章——我们认为电脑在某种程度上“根本太难了”。现在我们看到像ChatGPT这样的东西完成了这些任务,就突然认为电脑一定变得更加强大了——特别是超越了它们已经基本能够做到的事情(比如逐步计算细胞自动机等计算系统的行为)。

但这不是正确的结论。计算不可约化过程仍然是计算不可约化过程,并且对于电脑来说仍然基本上很难——即使电脑可以轻松地计算它们各个步骤。相反,我们应该得出结论:像写文章之类的任务虽然人类能够做到,但我们并没有认为电脑能够做到,在某种意义上实际上比我们想象中要容易处理。

换句话说,神经网络能够成功地写一篇文章的原因是因为写作事实上比我们想象中更浅显易懂。从某种意义上讲,这使我们更接近“拥有一个理论”,即如何像人类那样处理语言或撰写文章。

如果你有足够大的神经网络,那么你可能能够做到人类可以轻松完成的任何任务。但你不会捕捉到自然界普遍具备的特性——或者说我们从自然界中创造出来的工具所能做到的事情。正是使用这些工具——无论是实用还是概念性的——在最近几个世纪里使我们超越了“纯粹依靠人类思维”的边界,并为人类目标捕获了更多物理和计算宇宙中存在着什么。

embedding的概念

神经网络——至少目前的设置是基于数字的。因此,如果我们要用它们来处理文本之类的东西,我们需要一种用数字表示文本的方法。当然,我们可以像ChatGPT一样(实质上)为字典中的每个单词分配一个数字。但有一个重要的想法——例如ChatGPT所关注的那个——超越了这一点。这就是“embedding(嵌入)”的概念。人们可以将嵌入视为通过一系列数字尝试以某种方式表达事物“本质”的方法,并且具有“附近事物”由相邻数字表示的属性。

因此,例如,我们可以将单词embedding视为在某种意义上接近于含义空间中排列单词,在该空间中,“意思相近”的单词会出现在embedding附近。实际使用的嵌入(比如ChatGPT)往往涉及大量数字列表。但是如果我们投影到2D,则可以展示单词如何被embedding布局:

是的,我们所看到的确实非常好地捕捉了典型的日常印象。但是我们如何构建这样一个embedding呢?大致上的想法是查看大量文本(这里来自网络的50亿个单词),然后观察不同单词出现在其中“环境”有多相似。因此,例如,“鳄鱼”和“鳄鱼”通常几乎可以互换使用在其他类似句子中,这意味着它们将被放置在嵌入附近。但是,“萝卜”和“老鹰”不太可能出现在其他类似句子中,因此它们将被放置在embedding远处。

但是如何使用神经网络实际实现这样的事情呢?让我们先谈论一下图像而不是单词的嵌入。我们希望以某种方式通过数字列表来表征图像,使得“我们认为相似”的图像被分配相似的数字列表。

我们如何判断是否应该“认为图像相似”?如果我们的图像是手写数字,则如果它们属于同一个数字,则可以“认为两个图像相似”。早些时候,我们讨论过一个用于识别手写数字的神经网络。 我们可以将这个神经网络看作是设置了一个最终输出,将图像放入10个不同的箱中,每个数字一个。

但是如果我们在“它是‘4’”决定之前“拦截”神经网络内部正在发生的事情呢?我们可能会预期,在神经网络内部有一些数字来表征图像为“大多数类似于4但有点2”的形式。而这个想法就是挑选出这样的数字用作嵌入中的元素。

这里是概念。与其直接尝试表征“哪个图像靠近哪个图像”,我们反而考虑一个明确定义的任务(在这种情况下是数字识别),为此我们可以获得明确的训练数据,然后利用事实,在完成这项任务时神经网络隐含地必须做出类似于“接近性决策”的决策。因此,我们不需要明确谈论“图像的接近程度”,而只需谈论关于一张图片代表什么数字的具体问题,然后将“神经网络”留给它来隐含地确定这意味着关于“图像接近程度”的内容。

那么,对于数字识别网络,这个工作的详细过程是怎样的呢?我们可以将该网络看作由11个连续层组成,我们可以用如下图标示来概括(其中激活函数显示为单独的层):

在开始时,我们将实际图像输入到第一层中,这些图像由表示为2D像素值数组的数据组成。在最后一层,我们得到一个包含10个值的数组,可以认为是网络对每个数字0到9所对应的图像“确定性”的度量。

输入图像4,最后一层神经元的值为:

换句话说,神经网络此时“非常确定”这张图片是一个4——为了得到输出的“4”,我们只需要找出具有最大值的神经元位置。

但如果我们往前看一步呢?网络中最后一个操作是所谓的softmax,它试图“强制确信”。但在应用之前,神经元的值是:

代表“4”的神经元仍然具有最高的数值。但是其他神经元的值中也包含了信息。我们可以期望这个数字列表在某种程度上可以用来描述图像的“本质”,从而提供一些可用作嵌入的东西。因此,例如,在这里每个4都有略微不同的“签名”(或“特征嵌入”)-与8完全不同:

在这里,我们基本上使用10个数字来描述我们的图像。但是通常最好使用更多的数字。例如,在我们的数字识别网络中,通过访问前一层,我们可以获得一个包含500个数字的数组。这可能是一个合理的用作“图像嵌入”的数组。

如果我们想要对手写数字进行“显式可视化”,则需要通过将我们获得的500维向量投影到三维空间中来“降低维度”。

我们刚刚讨论了如何创建图像的表征(从而嵌入),方法是通过确定它们是否(根据我们的训练集)对应于相同的手写数字来有效地识别图像的相似性。如果我们有一个标识每个图像属于哪种5000种常见物体类型(猫、狗、椅子等)的训练集,那么我们可以更普遍地为图像做同样的事情。这样,我们就可以制作一个由我们对常见物体进行识别“锚定”的图像嵌入,但是根据神经网络行为“泛化”。关键在于,在这种行为与人类感知和解释图像方式一致时,这将成为一个“看起来正确”的嵌入,并且在实践中用于执行“类似人类判断”的任务。

基于大量文本语料库(比如网络文本内容),哪些不同的单词可能填补空白?或者换句话说,“___ black ___”中不同“侧翼单词”的概率是多少?

我们如何为神经网络设置这个问题?最终,我们必须用数字来表达所有东西。一种方法就是为英语中约50,000个常见单词中的每一个分配一个唯一编号。例如,“the”可能是914,“cat”(前面有一个空格)可能是3542。(这些都是GPT-2使用的实际数字)。因此,在“the ___ cat”问题上,我们的输入可能为{914、3542}。输出应该像什么呢?好吧,它应该是一个包含大约50,000个数字列表,有效地给出了每个可能填充字眼所对应概率。再次寻找嵌入时,我们希望在神经网络“达成结论之前截取其内部”,然后获取发生在那里并且可以认为代表每个单词特征的数字列表。

好的,那这些特征是什么样子的呢?在过去的10年中,已经开发了一系列不同的系统(如word2vec、GloVe、BERT、GPT等),每个系统都基于不同的神经网络方法。但最终它们都将单词转化为由数百到数千个数字组成的列表来进行表征。

在它们的原始形式下,这些“嵌入向量”是相当无信息的。例如,以下是GPT-2为三个特定单词生成的原始嵌入向量:

如果我们像测量这些向量之间的距离一样做事情,那么我们就可以找到词语之间的“接近程度”。稍后我们将更详细地讨论这种嵌入可能具有的“认知”意义。但现在主要观点是,我们有一种有效的方法将单词转化为“神经网络友好”的数字集合。

实际上,我们不仅可以通过数字集合来描述单词,还可以对单词序列或整个文本块进行此操作。而 ChatGPT 内部正是这样处理事情的。它获取到目前为止所拥有的文本,并生成一个嵌入向量来表示它。然后其目标是找到可能出现下一个单词的不同概率。并且它将其答案表示为一系列数字列表,这些数字基本上给出了每个可能单词中大约 50,000 种可能性中各自发生概率。

(严格来说,ChatGPT 不处理单词,而是处理“标记”——方便的语言单位,可能是整个单词,也可能只是像“pre”、“ing”或“ized”这样的片段。使用标记使 ChatGPT 更容易处理罕见、复合和非英语单词,并且有时候为了好坏不一地发明新单词。)

深入ChatGPT

好的,我们终于准备讨论一下ChatGPT内部是什么了。最终,它其实就是一个巨大的神经网络——目前使用的是1750亿个权重版本的所谓GPT-3网络。在很多方面上,这个神经网络与我们之前讨论过的其他神经网络非常相似。但它特别适用于处理语言,并且它最显著的特点是一种名为“transformer”的神经网络架构。

在我们上面讨论过的第一个神经网络中,每个层次上的每个神经元基本上都与前一层次上所有其他神经元(至少有某些权重)连接起来。但如果要处理具有特定已知结构数据,则这种完全连接型网络可能会导致资源浪费。因此,在处理图像时通常会使用所谓卷积神经网(”convnets”),其中将神经元有效地布置在类似于图像中像素排列方式并仅与附近格子中其他单元相连。

Transformer 的想法则是对组成文本片段序列标记进行类似操作, 但不同之处在于 Transformer 引入了“注意力”概念和更加关注某些序列片段而非全部. 或许总有一天只需启动通用神经网络并通过训练进行所有自定义就足够了,但至少目前来看,在实践中“模块化”事物似乎是至关重要的——正如 Transformer 所做的那样,也可能与我们的大脑所做的类似。

好的,那么ChatGPT(或者更确切地说,它所基于的GPT-3网络)到底是做什么的呢?请记住,它的总体目标是根据其训练过程中看到的内容(即查看了来自Web等数十亿页文本),以“合理”的方式继续文本。因此,在任何给定时刻,它都有一定数量的文本,并且其目标是为下一个要添加的标记提供适当选择。

它分为三个基本阶段。首先,它获取与迄今为止已有文本相对应的标记序列,并找到表示这些标记序列的嵌入式数组(即一组数字)。然后,在神经网络中进行操作——通过在网络中连续层之间传播值——以生成新嵌入式数组(即新一组数字)。接着,它取该数组最后部分并从中生成大约50,000个值得出不同可能下一个标记概率的数组。(是啊,“恰好”使用了与英语常用单词数量相同数量级左右个令牌/片段作为输入数据)

关键点在于管道中每个部分都由神经网络实现,并且该神经网络通过端对端训练确定权重。换句话说,在效果上除了整体架构外,“显式工程”几乎没有什么东西;一切都是从训练数据中“学习”的。

然而,管道设置方面有很多细节——反映了各种经验和神经网络知识。尽管这肯定会变得非常复杂,但我认为谈论其中的一些细节是有用的,至少可以对构建ChatGPT所需付出的努力有一个大致了解。

首先是嵌入模块。以下是GPT-2的Wolfram语言示意图:

输入是一个由n个标记组成的向量(如前一节所述,表示为1到约50,000的整数)。每个标记都被转换为嵌入向量(通过单层神经网络),长度为768(对于GPT-2)和12,288(对于ChatGPT的GPT-3)。同时,还有一个“次要路径”,它将标记的整数位置序列作为输入,并从这些整数创建另一个嵌入向量。最后,将来自令牌值和令牌位置的嵌入向量相加,以产生来自嵌入模块的最终嵌入向量序列。

为什么只需将令牌值和令牌位置嵌入向量相加?我认为这并没有特别科学之处。只是尝试了各种不同的方法,这似乎是有效的其中之一。而且,在神经网络领域中,“大致正确”的设置通常足以通过充分训练找到细节,而无需真正理解神经网络如何配置自己在工程水平上运行。

以下是嵌入模块的操作,它作用于字符串“hello hello hello hello hello hello hello hello hello bye bye bye bye bye bye bye bye bye”。

每个标记的嵌入向量元素显示在页面下方,横跨页面我们首先看到一系列“hello”嵌入,然后是一系列“bye”嵌入。上面的第二个数组是位置编码器 - 其略微随机的结构正好是所学习到的(在这种情况下为GPT-2)。

好吧,在嵌入模块之后就来到了变形金刚的“主要事件”:所谓的“注意力块”的序列(对于GPT-2有12个,对于ChatGPT’s GPT-3有96个)。这一切都非常复杂,并且让人想起典型的大型难以理解工程系统或生物系统。但无论如何,以下是单个“注意力块”的示意图(适用于GPT-2):

在每个这样的注意力块中,都有一组“注意头”(GPT-2为12个,ChatGPT的GPT-3为96个),每个头独立地在嵌入向量中不同的值块上运行。 (是的,我们不知道将嵌入向量分成几部分或其不同部分的含义是一个好主意;这只是那些已经被发现有效的事情之一。)

那么,注意力头做什么?基本上它们是一种“回顾”标记序列(即迄今为止生成文本),并以对于找到下一个标记有用形式来“打包过去”。 在上面第一节中,我们讨论了使用二元概率根据其直接前驱选择单词。转换器中的“关注”机制允许甚至更早期单词进行“关注”,从而可能捕获动词可以引用出现在句子之前多个单词处名词等方式。

更详细地说,注意力头所做的就是重新组合与不同标记相关联的嵌入向量块,并带有某些权重。例如,在第一个关注块(在GPT-2中)中具有以下针对以上字符串”hello, bye” 的(“回溯到标记序列开端”的) “重新组合权重”模式:

经过注意力头的处理后,生成的“重新加权嵌入向量”(对于GPT-2长度为768,对于ChatGPT的GPT-3长度为12,288)通过标准的“全连接”神经网络层传递。很难掌握这个层正在做什么。但是这里有一个使用它的768×768权重矩阵绘图(这里是针对GPT-2):


取64×64移动平均值,一些(类似随机游走的)结构开始显现:

这个结构是由什么决定的?最终,可能是人类语言特征的某种“神经网络编码”。但目前为止,这些特征可能还不太清楚。实际上,我们正在“打开ChatGPT(或至少是GPT-2)的大脑”,发现它非常复杂,并且我们并不理解——尽管最终它产生了可识别的人类语言。

好吧,在经过一个注意力块之后,我们得到了一个新的嵌入向量——然后依次通过其他注意力块(对于GPT-2总共有12个;对于GPT-3则有96个)。每个注意力块都有自己独特的“关注”和“全连接”权重模式。在这里,对于第一个关注头,“hello, bye”输入序列中的关注权重顺序如下:

这里是全连接层的(移动平均)“矩阵”:

有趣的是,尽管不同注意力块中的这些“权重矩阵”看起来非常相似,但权重大小分布可能会有所不同(并且并不总是高斯分布):

经过所有这些注意力块后,变压器的净效果是什么?本质上,它是将标记序列的原始嵌入集合转换为最终集合。而 ChatGPT 的特定方式是选择此集合中的最后一个嵌入,并“解码”以生成下一个应该出现的标记列表的概率。

所以这就是 ChatGPT 内部大致情况。它可能看起来很复杂(不仅因为其许多必然有些任意的“工程选择”),但实际上涉及到的最终元素非常简单。因为归根结底,我们处理的只是由“人造神经元”组成、每个神经元都执行将数字输入集合与某些权重相结合等简单操作的神经网络。

ChatGPT的原始输入是一组数字(到目前为止令牌的嵌入向量),当ChatGPT“运行”以生成新令牌时,这些数字只是通过神经网络层“涟漪”,每个神经元“做自己的事情”并将结果传递给下一层上的神经元。没有循环或“回溯”。所有东西都只是通过网络“前馈”。

这与典型计算系统(如图灵机)非常不同,在该系统中,结果会被相同的计算元素重复地“重新处理”。在此处-至少在生成给定输出标记方面-每个计算元素(即神经元)仅使用一次。

但在某种意义上,即使是在ChatGPT中仍然存在一个“外部循环”,它重复使用计算元素。因为当ChatGPT要生成新的标记时,它总是“读取”(即将其作为输入)之前出现的所有标记序列,包括ChatGPT自己先前“编写”的标记。我们可以认为这个设置意味着,在最外层至少涉及到一个“反馈循环”,尽管每次迭代都明确可见作为出现在所生成文本中的标记。

但让我们回到ChatGPT的核心:被重复用于生成每个标记的神经网络。从某种程度上来说,它非常简单:一整套相同的人工神经元。网络中有些部分只由(“全连接”)神经元层组成,在给定层上的每个神经元与之前一层上的每个神经元都连接(带有一些权重)。但特别是通过其变压器架构,ChatGPT具有更多结构化部分,在其中只连接不同层上特定的神经元。(当然,“所有神经元都已连接”,但有些权重值为零)。

此外,ChatGPT中神经网络的某些方面并不是最自然地被认为只包含“同质”层。例如,正如上面的标志性摘要所示,在注意力块内部有一些地方会“复制多个”传入数据,每个数据都通过不同的“处理路径”,可能涉及不同数量的层,并且仅在稍后重新组合。但是,虽然这可能是正在发生的事情的便捷表示法,但至少原则上总是可以考虑到“密集填充”的层,只需使一些权重为零即可。

如果看一下ChatGPT中最长路径,则涉及约400(核心)层-在某些方面并不算很多。但有数百万个神经元-共计1750亿个连接因此也就有了1750亿个权重。需要意识到的一件事情是:每次ChatGPT生成一个新令牌时,它都必须进行涉及所有这些权重之间计算。从实现角度来看,“按图层”将这些计算高度并行化成数组操作,并且可以在GPU上轻松完成。但对于每个生成的令牌还需要进行1750亿次计算(最终还要更多),因此使用ChatGPT生成长文本确实需要花费一定时间。

但最终,值得注意的是,所有这些操作——虽然它们各自都很简单——但在一起却能够做出如此出色的“类人”文本生成工作。必须再次强调(至少就我们所知),没有“最终理论原因”可以解释为什么会有这样的结果。事实上,正如我们将要讨论的那样,我认为我们必须把这看作是一个潜在惊人的科学发现:在像ChatGPT这样的神经网络中,不知何故竟能捕捉到人脑在语言生成方面所做到的本质特征。

hatGPT的训练

好的,现在我们已经概述了ChatGPT设置后的工作方式。但是它是如何被设置起来的呢?那1750亿个神经元网络中所有权重是如何确定的呢?基本上,这些权重都是通过大规模训练得出来的,基于人类撰写的大量文本语料库——包括网页、书籍等等。正如我们所说,即使有了所有这些训练数据,一个神经网络能够成功地生成“类似人类”的文本也并不明显。而且,再次强调一下,在实现这一点方面需要详细的工程设计。但ChatGPT带来了一个惊喜和发现:原来这样做是可能的!事实上,“仅”具有1750亿个权重值得神经网络可以制作出人类撰写文本内容“合理模型”。

在现代,有许多人类书写的数字化文本存在。公共网络上至少有数十亿篇人类书写的网页,总计可能达到万亿字。如果包括非公开网页,则数量可能至少大100倍。迄今为止,已经提供了超过500万本数字化图书(约占曾经出版的1亿左右图书的一百分之一),提供了另外约1000亿字的文本。这还没有提及从视频等中获得的语音转换成文本。(作为个人比较,我终身发表材料总量不到300万字,在过去30年里我写了大约1500万封电子邮件,并且总共打了大约5000万字——仅在过去几年里我就在直播中说了超过1000万个单词。是的,我会从所有这些内容中训练一个机器人)

但好吧,考虑到所有这些数据,如何用神经网络进行训练呢?基本流程与我们以上简单示例中所述非常相似:您展示一批样例,然后调整网络权重以最小化该网络对这些样例产生错误(“损失”)。 “反向传播”误差时主要昂贵之处在于每次执行此操作时通常会使网络中每个权重至少微小地改变,并且只是要处理大量权重。(实际的“反向计算”通常只比前向计算难一点点。)

使用现代GPU硬件,可以轻松地并行计算数千个示例批次的结果。但是,当涉及到实际更新神经网络中的权重时,当前方法要求基本上逐批次进行此操作。(是的,在这方面,实际大脑——具有其组合计算和存储元素——目前至少具有架构优势。)

即使是我们之前讨论的学习数字函数看似简单的情况,我们发现我们通常需要使用数百万个示例才能成功地训练一个网络,至少从头开始。那么这意味着为了训练一个“类人语言”的模型,我们需要多少样本?似乎没有任何基本的“理论”方法可以知道。但在实践中,ChatGPT已经成功地在几千亿个文本词汇上进行了训练。

它被喂入了一些文本多次,有些只被喂入一次。但不知怎么的它从所见到的文本中“得到了所需”。但是,在给定这样大量的文本进行学习时,需要多大规模的神经网络才能够“很好地学习”呢?同样地,在理论上还没有根据来回答这个问题。最终——正如下面将进一步讨论——人类语言和人们通常用它说话可能存在某种“总算法内容”。但接下来要问的问题是神经网络在基于该算法内容实施模型时会有多高效。同样地,我们并不知道——尽管ChatGPT取得了成功表现,并且表明其相当有效率。

最后值得注意的是:ChatGPT使用数百亿个权重来完成其任务——与其所接收的训练数据中单词(或标记)的总数相当。在某些方面,这也许令人惊讶(尽管ChatGPT较小规模的类比中也有经验观察到),因为似乎“能够很好地工作”的“网络大小”与“训练数据大小”是如此相近。毕竟,显然并不是因为所有来自网络、书籍等文本都以某种方式被直接存储在ChatGPT内部。实际上,在ChatGPT内部存在一堆数字——精度略低于10位——它们是所有文本结构的分布式编码。

换句话说,我们可以问人类语言的“有效信息内容”是什么,以及通常用它来表达什么。有语言示例的原始语料库,还有ChatGPT神经网络中的表示。这种表示很可能远离“算法最小化”的表示(如下所述)。但这是一种神经网络容易使用的表示形式。在这个表示中,训练数据似乎没有太多压缩;平均而言,一个单词只需要不到一个神经网络权重就能携带其“信息内容”。

当我们运行ChatGPT生成文本时,基本上每个权重都要使用一次。因此如果有n个权重,则需要进行约n次计算步骤——尽管实际上许多计算步骤通常可以在GPU中并行执行。但如果我们需要大约n个单词的训练数据来设置这些权重,则根据以上所述,我们可以得出结论:我们将需要大约n²次计算步骤来完成网络的训练——这就是为什么目前需要谈论数十亿美元培训工作量的原因了。

超越基础训练

培训ChatGPT的大部分工作都花在“展示”来自网络、书籍等大量现有文本上。但事实证明,还有另一个——显然相当重要的——部分。

一旦它完成了从原始文本语料库中获得的“原始训练”,ChatGPT内部的神经网络就准备好开始生成自己的文本,从提示信息等继续进行。但是,尽管这样产生的结果通常似乎合理,但对于较长的文本片段而言,它们往往会以非人类方式“偏离”。这不是通过对文本进行传统统计可以轻易检测到的东西。但实际阅读该文本内容时却容易被真正人类注意到。

构建ChatGPT时关键思想之一是,在像网络这样“被动阅读”的事物之后再加入另一个步骤:让真正人类积极与ChatGPT互动,并查看其生成内容,并在效果上给出反馈,“如何成为良好聊天机器人”。但神经网络如何使用该反馈呢?第一步只是让人类评价神经网络产生的结果。然后建立另一个神经网络模型来预测那些评级。但现在可以运行这个预测模型——实质上像一个损失函数——在原始网络上,从而允许该网络通过已经给出的人类反馈进行“调整”。实践中的结果似乎对系统成功产生“类人”输出有很大影响。

总的来说,有趣的是,“最初训练”的网络似乎需要很少的“刺激”就能使其朝特定方向有用地发展。人们可能认为,要使网络表现得好像它“学到了新东西”,必须运行训练算法、调整权重等。

但事实并非如此。相反,似乎只需在您提供的提示中告诉ChatGPT一次即可,并且当它生成文本时可以成功利用您告诉它的内容。再次强调这种方法有效是我认为理解ChatGPT“真正做什么”以及它与人类语言和思维结构之间关系的重要线索。

这确实有点类似于人:至少经过所有预先培训后,你只需告诉它一次某些内容,然后它就可以“记住”——至少“足够长时间”,以使用该信息生成一段文本。那么在这种情况下发生了什么?可能是“你可能会告诉他任何事情都已经存在于其中某个地方”,而你只是引导他到正确位置。但这看起来不太可信。相反,更有可能的是元素已经存在其中,但具体定义由类似于元素之间轨迹之类的东西确定,并且当您告诉它某些内容时,就是在引入这种轨迹。

实际上,就像对于人类一样,如果你告诉它一些奇怪和意外的东西完全不符合它已知的框架,它似乎无法成功地“整合”这个信息。只有当它基本上在已有的框架之上以相当简单的方式运行时,才能够“整合”这个信息。

值得再次指出的是,神经网络所能“捕捉”的内容必然存在“算法限制”。告诉它形式为“这个变成那个”的浅显规则等等,并且神经网络很可能可以很好地表示和复制这些规则——实际上从语言中所了解到的内容将给予其一个立即可遵循的模式。但是如果试图为涉及许多潜在计算不可约步骤的实际深度计算提供规则,则无法正常工作。(请记住,在每一步中,除了生成新标记外,它总是仅向前馈送数据而没有回路。)

当然,该网络可以学习特定“不可约”计算问题的答案。但是一旦存在组合数量级别以上数量级别以上数量级别以上数量级别以上可能性,则此类表格查找方法将无效。因此,请注意:与人类一样,“神经网络”需要使用实际的计算工具。(是的,Wolfram|Alpha和Wolfram Language非常适合,因为它们已经被构建成“谈论世界中的事物”,就像语言模型神经网络一样。)

是什么让ChatGPT真正工作起来?

人类语言以及生成它所涉及的思维过程一直被认为是复杂性的巅峰。事实上,人脑——其“仅有”约1000亿个神经元(和可能达到1万亿个连接)的网络似乎能够负责这一切,这似乎相当不可思议。或许,一个人可以想象,大脑除了神经元网络之外还有其他未被发现的物理层面。但现在我们通过ChatGPT得到了重要新信息:我们知道一个纯粹、人造神经网络与大脑中神经元数量相当多的连接数能够出奇地好地生成人类语言。

没错,这仍然是一个庞大而复杂的系统——其中权重与当前世界上所有文本单词数量相同。但从某种程度上来说,很难相信所有语言丰富性和它所能谈论的事情都可以包含在如此有限的系统中。部分原因无疑反映了普遍现象(最初在规则30示例中首次显现),即计算过程实际上可以极大地放大系统表面复杂性,即使它们基础规则很简单也是如此。但实际上,正如我们上面讨论的那样,ChatGPT中使用的神经网络往往是特别构建的,以限制这种现象及其相关的计算不可约简性,并使它们更易于训练。

那么,像ChatGPT这样的东西如何能够在语言方面取得如此大的进展呢?我认为基本答案是,语言在某种根本层面上比它看起来要简单。这意味着,即使是具有最终直接的神经网络结构的ChatGPT也成功地能够“捕捉”人类语言及其背后思维的实质。而且,在训练过程中,ChatGPT以某种方式“隐含地发现”了使这一切成为可能的语言(和思考)规律。

我认为ChatGPT的成功给我们提供了一个基础和重要科学证据:它表明我们可以期望存在一些全新、重要的“语言法则”,以及有效地探索其中所涉及到“思想法则”。在作为神经网络构建出来的ChatGPT中,这些法则充其量只是隐含存在。但如果我们能够将这些法则变得显式化,则有潜力以更加直接、高效和透明化方式执行类似于ChatGPT所做事情。

那么好吧,这些规律会是什么样子呢?最终它们必须给我们提供一定程度上关于如何组合语言——以及用它说出来之物的指导。稍后我们将讨论“查看ChatGPT内部”可能如何能够给我们一些关于这方面的提示,以及从构建计算语言中所了解到的内容提供前进道路。但首先,让我们讨论两个长期已知的“语言法则”的例子——以及它们与ChatGPT操作之间的关系。

第一个是语言句法结构。语言不仅仅是单词随意组合而成的混乱堆积物。相反,有(相当)明确的文法规则来定义不同类型单词可以如何组合:例如,在英语中,名词可以由形容词修饰并跟在动词后面,但通常两个名词不能紧挨着放置。这种文法结构可以(至少近似地)通过一组规则来捕捉,这些规则定义了类似于“分析树”的东西如何被拼接起来:

ChatGPT并没有明确的了解这些规则。但在其训练中,它会隐含地“发现”它们,并且似乎很擅长遵循它们。那么这是如何工作的呢?从一个“大局”层面来看,还不太清楚。但为了获得一些见解,也许看一个更简单的例子会有所帮助。

考虑由(和)序列组成的“语言”,其语法规定括号应该始终保持平衡,如表示为类似于分析树:

我们能训练神经网络生成“语法正确”的括号序列吗?在神经网络中处理序列的方法有很多种,但我们可以使用变压器网络,就像ChatGPT一样。给定一个简单的变压器网络,我们可以开始将语法正确的括号序列作为训练示例输入。一个微妙之处(实际上也出现在ChatGPT生成人类语言时)是除了我们的“内容标记”(这里是“(”和“)”)外,还必须包含一个“结束”标记,用于指示输出不应再继续下去(即对于ChatGPT来说,已到达故事的“结尾”)。

如果我们只设置一个具有8个头部和长度为128的特征向量的注意块的变压器网络(ChatGPT也使用长度为128 的特征向量,但每个注意块都有96 个头),那么似乎不可能使其学习关于括号语言方面太多知识。但是通过2个注意块进行设置后,在大约提供1000万个示例后学习过程似乎会收敛——并且与变压器网络常见情况相同地表现出更多示例只会降低性能。

因此,在这个网络中,我们可以做类似于ChatGPT所做的操作,并要求计算下一个标记的概率——在括号序列中:

在第一种情况下,网络“非常确定”序列不能在此结束——这很好,因为如果是这样的话,括号将不平衡。然而,在第二种情况下,它“正确地识别出”序列可以在此结束,尽管它也“指出”,可以“重新开始”,放置一个“(” ,可能会跟着一个“)” 。但是糟糕的是,即使有其400,000个左右费力训练的权重,它仍然说有15%的概率将“)”作为下一个标记——这是不正确的,因为那必定会导致括号不平衡。

如果我们要求网络逐渐更长地完成(’s)序列,则会得到以下结果:

是的,网络在一定长度范围内表现得很好。但是随后它开始失败。这是神经网络(或机器学习)中“精确”情况下常见的问题。人类可以“一眼解决”的情况,神经网络也可以解决。但是需要进行“更算法化”的操作(例如显式计数括号以查看它们是否关闭)的情况下,神经网络似乎会“计算能力不足”,无法可靠地执行。(顺便说一句,即使完整的ChatGPT当前版本也很难正确匹配长序列中的括号)

那么对于像ChatGPT和英语这样的语言语法来说意味着什么?括号语言非常简洁 - 更多地属于“算法故事”。但在英语中,根据单词和其他提示局部选择,“猜测”何时符合文法要求更加现实。而且,是的,在这方面神经网络做得更好 - 即使可能会错过某些形式上正确但人类也可能忽略掉的案例。但主要观点是:事实上有一个整体句法结构存在于该语言中,并且具有所有正则性所暗示的限制程度,在某种程度上限制了神经网络必须学习的“程度”。关键的“自然科学式”观察是,像ChatGPT中的变压器架构似乎成功地能够学习类似于嵌套树状句法结构的语言结构(至少在某种近似情况下存在于所有人类语言中)。

语法为语言提供了一种约束。但显然还有更多。像“Inquisitive electrons eat blue theories for fish”这样的句子在文法上是正确的,但不是人们通常会说出来的话,并且如果ChatGPT生成它,则不会被认为是成功 - 因为基本上没有意义。

但是否有一般方法可以判断一个句子是否有意义?传统上没有整体理论。但这是ChatGPT经过数十亿个(可能具有含义)从网络等处训练而隐含“开发出理论”的东西。

这个理论会是什么样子呢?嗯,有一个小角落基本上已经被人们知道了两千年,那就是逻辑。当然,在亚里士多德发现它的三段论形式中,逻辑基本上是一种说法,即遵循某些模式的句子是合理的,而其他句子则不合理。因此,例如,“所有X都是Y。这不是Y, 所以它不是X”(如“所有鱼都是蓝色的。这不是蓝色的,所以它不是鱼。”)就很合理。正如人们可以略微任性地想象亚里士多德通过大量修辞学例子(类似于“机器学习风格”)来发现三段论逻辑一样,我们也可以想象在ChatGPT 的训练中它将能够通过查看网络上的大量文本等方式“发现三段论逻辑”。 (而且,在这方面 ChatGPT 可以产生包含基于三段论逻辑等内容的“正确推断”的文本;但当涉及到更复杂的形式化逻辑时情况完全不同——我认为出现失败可能与括号匹配失败原因相同)

但除了狭窄范围内关于逻辑之外,还能说些什么关于如何系统地构建(或识别)甚至是合理的文本吗?是的,有像 Mad Libs 这样使用非常特定“短语模板”的东西。但不知怎么的 ChatGPT 隐含了一种更普遍的方法来做到这一点。也许除了“当你拥有1750亿个神经网络权重时它就会发生”之外,没有其他可以说的了。但我强烈怀疑存在一个更简单、更强大的故事。

意义空间和语义运动定律

我们上面讨论过,在ChatGPT中,任何一段文本都可以被有效地表示为一个数字数组,我们可以将其视为某种“语言特征空间”中点的坐标。因此,当ChatGPT继续一段文本时,这相当于在语言特征空间中跟踪轨迹。但现在我们可以问:是什么使得这条轨迹对应于我们认为有意义的文本?或者说是否存在某种“语义运动定律”,定义或至少限制了在保持“有意义性”的同时,语言特征空间中的点如何移动?

那么这个语言特征空间是什么样子的呢?如果我们将这样一个特征空间投影到二维平面上,以下是单词(此处为普通名词)可能被排列的示例:

我们在上面看到了另一个例子,基于代表植物和动物的单词。但两种情况的重点都是“语义相似的单词”被放置在附近。

作为另一个例子,这里是不同词性对应的单词如何排列:

当然,一个单词通常不只有“一个意思”(或者不一定对应于一个词性)。通过观察包含某个单词的句子在特征空间中的布局,人们经常可以“分辨出”不同的含义——就像这里针对“鹤”这个词(是指鸟还是机器?)的例子一样。

好的,那么我们至少可以认为这个特征空间将“意思相近的单词”放在了这个空间中靠近的位置。但是我们能够在这个空间中找到什么样的额外结构呢?例如,是否存在某种“平行传输”的概念来反映该空间中的“平坦性”?了解这一点的一种方法是查看类比:

是的,即使我们将其投影到二维平面上,通常也至少有一点“扁平”的迹象,尽管这并不普遍。

那么轨迹呢?我们可以查看ChatGPT提示在特征空间中遵循的轨迹 - 然后我们可以看到ChatGPT如何延续它:

这里显然没有“几何上明显”的运动定律。这一点并不令人惊讶;我们完全预料到这将是一个更加复杂的故事。例如,即使存在“语义运动定律”,它最自然地陈述在哪种嵌入(或实际上是哪些“变量”)也远非明显。

在上面的图片中,我们展示了“轨迹”的几个步骤——每一步我们都选择ChatGPT认为最有可能的单词(“零温度”情况)。但我们还可以询问在给定点时下一个可能出现的单词及其概率是什么:

在这种情况下,我们看到的是一个高概率词汇的“扇形”,似乎在特征空间中朝着更或多或少明确的方向前进。如果我们继续前进会发生什么?以下是随着轨迹“移动”而出现的连续“扇形”:

这是一个包含40个步骤的3D表示:

是的,这似乎很混乱,并且并没有特别鼓励人们期望通过经验研究“ChatGPT内部正在做什么”来确定“数学物理类”的“语义运动定律”。但也许我们只是看错了变量(或坐标系),如果我们只看对了,我们就会立即看到ChatGPT正在做一些像沿着测地线那样的“数学物理简单”事情。但目前为止,我们还没有准备好从其“内部行为”中“经验性解码”,以确定ChatGPT已经发现有关如何将人类语言组合在一起的内容。

语义语法和计算语言的力量

生产有意义的人类语言”需要什么?过去,我们可能认为这只能由人脑完成。但现在我们知道,ChatGPT的神经网络也可以相当好地完成这项任务。不过,也许这就是我们所能达到的极限了,并且没有更简单或更易于理解的方法可行。但我强烈怀疑ChatGPT成功背后隐含着一个重要的“科学”事实:有关有意义的人类语言结构和简单性比我们以往所知道的要多得多——最终可能会有相当简单明了的规则来描述如何组合这种语言。

正如上面提到的那样,句法语法给出了用于将与词性等不同部分对应的单词组合成人类语言中句子结构规则。但为了处理含义,我们需要进一步思考。其中一种做法是不仅考虑句法文法而且还需考虑语义文法。”

为了语法的目的,我们识别像名词和动词这样的东西。但是为了语义学的目的,我们需要“更细微”的分化。因此,例如,我们可能会确定“移动”概念和一个“保持其独立于位置身份不变”的“对象”概念。每个这些“语义概念”都有无数具体例子。但是对于我们语义文法而言,我们只需拥有一些基本规则即可,基本上说,“物体”可以“移动”。关于所有这些如何工作还有很多要说(其中一部分我以前已经说过)。但在此我将满足于仅发表几点评论,并指出一些未来可能性。

值得注意的是,即使根据语义文法句子完全没有问题也并不意味着它在实践中已被实现(甚至可能无法实现)。 “大象去月球旅行”毫无疑问会通过我们的语义文法测试,但它肯定尚未在我们真正存在世界中被实现(至少到目前为止)- 尽管它绝对适用于虚构世界。

当我们开始谈论“语义语法”时,我们很快就会问:“它下面是什么?”它假设了什么样的“世界模型”?句法语法实际上只涉及从单词构建语言。但是,一个语义语法必然涉及某种“世界模型”,这个模型作为一种“骨架”,在其中可以添加由实际单词组成的语言。

直到最近,我们可能认为(人类)语言将是描述我们的“世界模型”的唯一通用方式。几个世纪前已经开始对特定事物进行形式化处理,尤其基于数学。但现在有了更普遍的形式化方法:计算机语言。

而且,没错,在过去四十多年中(现在体现在 Wolfram 语言中),这一直是我的大项目:开发精确的符号表示,能够尽可能广泛地讨论关于事物和抽象概念方面的内容。因此,例如城市、分子、图像和神经网络都有符号表示,并且我们内置了关于如何计算这些东西的知识。

经过几十年的工作后,我们以这种方式覆盖了许多领域。但过去,我们并没有特别处理“日常话题”。 在“I bought two pounds of apples”中,我们可以轻松地表示(并进行营养和其他计算)“两磅苹果”。但是我们还没有一个符号表示“我买了”的方法。

这一切都与语义语法的概念有关——目标是拥有一个通用的符号“构建工具包”来表示概念,这将为我们提供什么可以与什么相配合的规则,从而为我们可能转化成人类语言的“流程”提供规则。

但假设我们已经拥有了这种“符号话语语言”。那么我们会怎样使用它呢?我们可以开始生成“局部意义文本”。但最终,我们很可能希望获得更多“全球意义”的结果——这意味着需要更多地计算实际存在或发生在世界上(或者也许是某个一致的虚构世界)的内容。

现在,在 Wolfram 语言中,我们内置了大量关于各种事物的计算知识。但对于完整的符号话语语言,我们还必须建立关于世界上普遍事物额外的“演算法则”:如果一个对象从 A 移动到 B 再从 B 移动到 C,则它就从 A 移动到 C 等等。

给定一个符号话语语言,我们可以用它来制作“独立声明”。但是,我们也可以像 “Wolfram|Alpha 风格” 一样使用它来询问世界问题。或者使用它来陈述自己“想要实现的事情”,可能需要一些外部激活机制。或者我们可以用它来做出断言——也许是关于实际世界,也许是关于我们正在考虑的某个特定世界,无论是虚构还是其他。

人类语言基本上不精确,这不仅因为它没有与特定计算实现相联系,而且其含义基本上只由其用户之间的“社会契约”定义。但计算语言天生具有一定的精度——因为最终它所指定的内容总能够在计算机上“明确地执行”。人类语言通常可以逃脱一定程度的模糊性。(当我们说 “行星” 时是否包括系外行星等?)但在计算语言中,我们必须对所有区别进行精确定义和清晰表述。

通常,在编写计算语言名称时利用普通人类语言很方便。但他们在计算语言中所具有的意义必然是准确无误,并可能涵盖典型人类使用方式中某些特殊内涵。

如何确定适用于一般符号话语语言的基本“本体论”?嗯,这并不容易。也许这就是为什么自亚里士多德两千多年前做出原始开端以来,在这些方面几乎没有什么进展。但今天我们已经知道如何计算地思考世界(而且从物理项目和ruliad的概念中获得“基础形而上学”),这确实有所帮助。

但在ChatGPT的背景下,所有这些意味着什么?通过其培训,ChatGPT有效地“拼凑”了相当数量(相当惊人)的语义语法。但它非常成功使我们有理由认为构建更完整的计算机语言形式是可行的。与迄今为止我们已经了解到关于ChatGPT内部情况不同,我们可以期望设计计算机语言以便人类能够轻松理解。

当我们谈论语义语法时,可以将其类比为三段论逻辑。起初,三段论逻辑本质上是关于用人类语言表达的陈述的规则集合。但是(是的,在两千年后),当形式逻辑被发展出来时,三段论逻辑的最初基本结构现在可以用于构建巨大的“正式塔”,其中包括现代数字电路运算等操作。因此,我们可以期望更一般化的语义语法也会如此。起初,它可能只能处理简单模式,例如文本表示方式。但是一旦整个计算机语言框架建立起来了,我们就可以期望它能够被用于建立高耸入云、允许我们以精确和正式方式处理各种以前从未可接近过我们、除了通过带有所有含糊性质地“底层”人类语言之外。

我们可以认为计算机语言和语义语法的构造代表着对事物进行终极压缩的一种形式。因为它使得我们能够谈论到可能性实质而不必考虑普通人类口头表达中存在的所有“措辞”。而且我们也可以把ChatGPT 的强大视作某种相似之处:因为在某种意义上,它也已经“钻研到”了这一点,即可以“以语义有意义的方式组合语言”,而不必考虑不同可能的措辞。

那么如果我们将ChatGPT应用于基础计算机语言会发生什么呢?计算机语言可以描述可能性。但是还可以添加一个“流行”的概念——例如基于阅读网络上所有内容。但是然后,在底层操作计算机语言时,像ChatGPT这样的系统就立即和根本地访问了利用潜在不可约简运算的终极工具。这使得它不仅能够“生成合理文本”,而且可以期望解决关于该文本是否实际上对世界或者所谈论的事物做出了“正确”陈述等问题。

那么…ChatGPT在做什么,为什么它有效?

ChatGPT的基本概念在某种程度上相当简单。从网络、书籍等大量人类创作文本样本开始,然后训练神经网络生成“类似于这些”的文本。特别地,使其能够从“提示”开始,然后继续生成“与其训练内容相似”的文本。

正如我们所见,在ChatGPT中实际的神经网络由非常简单的元素组成——尽管有数十亿个。而神经网络的基本操作也非常简单,基本上是对每个新词(或部分词)产生输入时,“将其通过元素”(没有任何循环等)。

但值得注意和意外的是,这个过程可以产生成功“像”网页、书籍等中存在的文本,并且它不仅是连贯的人类语言,还会利用它所阅读到内容来表达一些东西。“它说出来”的东西符合其提示,并且并不总是说出全局意义上正确计算结果——因为(例如没有使用Wolfram|Alpha 的 “计算超级能力”),它只是根据培训材料中听起来像什么来表达自己。

ChatGPT的特定工程使其非常引人注目。但最终(至少在它能够使用外部工具之前),ChatGPT只是从其积累的“传统智慧的统计数据”中提取了一些“连贯的文本线索”。但令人惊奇的是,结果与人类非常相似。正如我所讨论的那样,这表明某些东西至少在科学上非常重要:人类语言(以及背后思考模式)在结构上比我们想象中更简单、更符合“法则”。 ChatGPT已经隐含地发现了这一点。但我们可以通过语义语法、计算机语言等方式来明确地暴露它。

ChatGPT生成文本时所做的事情非常令人印象深刻——结果通常与我们人类产生的内容非常相似。那么这是否意味着ChatGPT像大脑一样工作?它底层的人工神经网络结构最终是基于对大脑理想化建模而得出来的。当我们产生语言时,很可能有很多方面都与此相似。

当涉及到训练(也称为学习)不同类型硬件和当前计算机(以及可能还有一些未开发完善算法思路)的大脑时,ChatGPT被迫使用一种策略,这种策略可能与大脑相比有所不同(在某些方面效率要低得多)。还有另外一件事:与典型的算法计算甚至不同,在ChatGPT内部没有“循环”或“重新计算数据”。这必然限制了它的计算能力——即使是对于当前的计算机来说也是如此,但肯定是相对于大脑而言。

目前尚不清楚如何“修复”这个问题并仍保持系统合理高效地训练。但做到这一点将可能使未来的ChatGPT执行更多类似于大脑的任务。当然,有很多事情人类大脑做得不太好——特别涉及到等价为基本运算量级别的问题。对于这些问题,无论是人类大脑还是像ChatGPT之类的东西都需要寻求“外部工具”,例如Wolfram语言。

但目前来看,看到ChatGPT已经能够做到什么是很令人兴奋的。在某种程度上,这是一个伟大的例子,说明了基本科学事实:大量简单的计算元素可以做出卓越而意想不到的事情。但它也为我们更好地理解人类语言和思维过程这一核心特征及其原则提供了或许有史以来最好的动力。


为了方便阅读,我也提供了电子书epub版本,下载后导入到微信读书等软件,可以边听边看文字。
ChatGPT工作原理 电子书下载






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



axum中文简述

以下文档基于axum Version 0.6.17

axum是一个专注于极简高效和模块化的web应用程序框架。

高级特性

  • 使用无宏API将请求路由到处理程序。
  • 使用提取器声明性地解析请求。
  • 简单和可预测的错误处理模型。
  • 用最少的样板文件生成响应。
  • 充分利用中间件、服务和实用程序的towertower-http生态系统。

最后一点是“axum”区别于其他框架的地方。axum没有自己的中间件系统,而是使用tower::Service。这意味着“axum”可以免费获得超时、跟踪、压缩、授权等。它还允许您与使用‘ hyper ‘‘ tonic ‘编写的应用程序共享中间件。

兼容性

axum是为配合tokiohyper设计的,运行时层和传输层的独立性不是目标,至少目前不是。

示例

axum的 “Hello, World!” :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use axum::{
routing::get,
Router,
};

#[tokio::main]
async fn main() {
// build our application with a single route
let app = Router::new().route("/", get(|| async { "Hello, World!" }));

// run it with hyper on localhost:3000
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}

注意使用#[tokio::main]需要启用tokio的macrosrt-multi-thread功能,或者只启用full来启用所有功能(cargo add tokio—features macros,rt-multi-thread)。

路由(ROUTING)

Router 用于设置哪些路径指向哪些服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
use axum::{Router, routing::get};

// our router
let app = Router::new()
.route("/", get(root))
.route("/foo", get(get_foo).post(post_foo))
.route("/foo/bar", get(foo_bar));

// which calls one of these handlers
async fn root() {}
async fn get_foo() {}
async fn post_foo() {}
async fn foo_bar() {}

细节可以查看 Router 

处理器(Handlers)

在axum中,处理器(“handler”)是一个异步函数,它接受零个或多个“extractors”作为参数,并返回可以转换为response的内容

处理器是应用程序逻辑存在的地方,而axum应用程序是通过处理器之间的路由构建的。

请参见‘ handler ‘了解处理程序的更多详细信息。

提取器(Extractors)

提取器是一种实现FromRequestFromRequestParts的类型。提取器是您如何分离传入请求以获得处理程序所需的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
use axum::extract::{Path, Query, Json};
use std::collections::HashMap;

// `Path` gives you the path parameters and deserializes them.
async fn path(Path(user_id): Path<u32>) {}

// `Query` gives you the query parameters and deserializes them.
async fn query(Query(params): Query<HashMap<String, String>>) {}

// Buffer the request body and deserialize it as JSON into a
// `serde_json::Value`. `Json` supports any type that implements
// `serde::Deserialize`.
async fn json(Json(payload): Json<serde_json::Value>) {}

更多细节可以参看 extract .

响应(Responses)

任何实现IntoResponse的东西都可以从处理器返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use axum::{
body::Body,
routing::get,
response::Json,
Router,
};
use serde_json::{Value, json};

// `&'static str` becomes a `200 OK` with `content-type: text/plain; charset=utf-8`
async fn plain_text() -> &'static str {
"foo"
}

// `Json` gives a content-type of `application/json` and works with any type
// that implements `serde::Serialize`
async fn json() -> Json<Value> {
Json(json!({ "data": 42 }))
}

let app = Router::new()
.route("/plain_text", get(plain_text))
.route("/json", get(json));

更多细节参看 response 。

错误处理(Error handling)

axum旨在提供一个简单且可预测的错误处理模型,这意味着将错误转换为响应很简单,并且可以保证所有错误都得到处理。

参见error_handling了解更多关于axum错误处理模型以及如何优雅地处理错误的详细信息。

中间件(Middleware)

为axum编写中间件有几种不同的方法。详见‘中间件’

与处理器共享状态(Sharing state with handlers)

在处理程序之间共享某些状态是很常见的。例如,可能需要共享到其他服务的数据库连接或客户端池。

最常见的三种方法是:

  • 使用State提取器
  • 使用请求扩展
  • 使用闭包捕获

使用 State extractor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use axum::{
extract::State,
routing::get,
Router,
};
use std::sync::Arc;

struct AppState {
// ...
}

let shared_state = Arc::new(AppState { /* ... */ });

let app = Router::new()
.route("/", get(handler))
.with_state(shared_state);

async fn handler(
State(state): State<Arc<AppState>>,
) {
// ...
}

如果可能的话,您应该更喜欢使用State,因为它更类型安全。缺点是它不如Request extensions 具有动态性。

有关访问状态的更多细节,请参见State

使用 request extensions

在处理器中提取状态的另一种方法是使用Extension 作为层和提取器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use axum::{
extract::Extension,
routing::get,
Router,
};
use std::sync::Arc;

struct AppState {
// ...
}

let shared_state = Arc::new(AppState { /* ... */ });

let app = Router::new()
.route("/", get(handler))
.layer(Extension(shared_state));

async fn handler(
Extension(state): Extension<Arc<AppState>>,
) {
// ...
}

这种方法的缺点是,如果您尝试提取一个不存在的扩展,可能是因为您忘记添加中间件,或者因为您提取了错误的类型,那么您将得到运行时错误(特别是500 Internal Server Error 响应)。

使用闭包捕获(closure captures)

State也可以使用闭包捕获直接传递给处理器:

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
use axum::{
Json,
extract::{Extension, Path},
routing::{get, post},
Router,
};
use std::sync::Arc;
use serde::Deserialize;

struct AppState {
// ...
}

let shared_state = Arc::new(AppState { /* ... */ });

let app = Router::new()
.route(
"/users",
post({
let shared_state = Arc::clone(&shared_state);
move |body| create_user(body, shared_state)
}),
)
.route(
"/users/:id",
get({
let shared_state = Arc::clone(&shared_state);
move |path| get_user(path, shared_state)
}),
);

async fn get_user(Path(user_id): Path<String>, state: Arc<AppState>) {
// ...
}

async fn create_user(Json(payload): Json<CreateUserPayload>, state: Arc<AppState>) {
// ...
}

#[derive(Deserialize)]
struct CreateUserPayload {
// ...
}

这种方法的缺点是它比使用‘ State ‘或extensions更冗长。

为axum构建集成

想要为FromRequestFromRequestParts,或IntoResponse提供实现的lib作者应该依赖于axum-corecrate,而不是axum axum-core 包含核心类型和特征,不太可能收到破坏性更改。

需要的依赖

为了使用axum,你还需要引入一些依赖项

1
2
3
4
5
[dependencies]
axum = "<latest-version>"
hyper = { version = "<latest-version>", features = ["full"] }
tokio = { version = "<latest-version>", features = ["full"] }
tower = "<latest-version>"

hyper和tokio的“full”功能并不是必须的,但这是最简单的入门方法。

注意hyper::Server 是由axum重新导出的,所以只需要这个,那么你不必显式地依赖hyper。

Tower也不是严格必要的,但有助于测试。请参阅回购中的测试示例,以了解有关测试axum应用程序的更多信息。

更多示例

axum repo包含许多示例,展示了如何将所有部分组合在一起。

功能标志

axum使用一组feature flags来减少已编译和可选依赖项的数量。

可选特性包括:

Name Description Default?
headers 通过TypedHeader使得提取已输入标题成为可能 No
http1 启用 Hyper 的 HTTP1 功能 Yes
http2 启用 Hyper 的 HTTP2 功能 No
json 启用Json类型和一些类似的便利功能。 Yes
macros 启用可选实用宏 No
matched-path 使得能够捕获每个请求的路由路径和MatchedPath提取器。 Yes
multipart 使用Multipart使得解析multipart/form-data请求成为可能 No
original-uri 使得能够捕获每个请求的原始URI和OriginalUri提取器。 Yes
tokio 使得Tokio成为依赖项,并提供axum::ServerSSEextract::connect_info类型。 Yes
tower-log Enables tower’s log feature Yes
tracing 记录内置提取器的拒绝。 No
ws 通过extract::ws启用WebSockets支持 No
form 启用Form提取器 Yes
query 启用Query提取器 Yes





关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Rust原子性和锁(前言)

本文为《Rust Atomics and Locks》翻译文章,原文为:https://marabos.nl/atomics/preface.html
仅供学习使用

前言

Rust在让系统编程更加可用的方面发挥了重要作用,然而,诸如原子性和内存排序之类的底层并发专题仍然经常被认为是一些神秘的内容,最好留给一小群专家来研究。

在过去几年研究Rust的实时控制系统和Rust标准库的过程中,我发现网上关于原子性(Atomics)和相关主题的可用信息只覆盖了我们真正想了解的内容的很小部分。许多资源完全集中在C和C++上,这使得它很难与Rust的(内存和线程)安全和类型系统的概念形成联系。涵盖了抽象理论细节的资源,如C++的内存模型,通常只是模糊地解释了它与实际硬件的关系。有许多资源涵盖了实际硬件的每个细节,例如处理器指令和缓存一致性,但是形成一个整体的理解通常需要从许多不同的地方收集零碎的信息来了解。

本书试图将相关信息放在一个地方,并将它们串联起来,为您提供构建正确的、安全的且符合我们使用习惯的并发原语所需的一切,同时充分了解底层硬件和操作系统的角色,以便能够做出设计决策和基本的优化权衡。

谁适合这本书

这本书的主要读者是Rust开发人员,他们希望了解更多关于低级并发性的知识。此外,这本书也适合那些还不太熟悉Rust,但想从Rust的角度了解底层并发的人。

假设你了解Rust的基础知识,最近安装了Rust编译器,并且知道如何使用“cargo”编译和运行Rust代码。Rust中对并发性很重要的概念在相关的时候会被简要地解释,所以不需要事先了解Rust并发性。

章节预览

这本书由十章组成。以下是每一章的内容,以及值得期待的内容:

第一章——Rust并发的基础知识

本章介绍了Rust中实现基础并发所需要的所有工具和概念,比如线程、互斥、线程安全、共享和独占引用、内部可变性等等,这些都是本书其余部分的基础。对于熟悉这些概念的有经验的Rust程序员,本章可以作为快速复习。对于那些从其他语言中了解这些概念但对Rust还不是很熟悉的人来说,本章将迅速为您补充书中其他部分可能需要的Rust特定知识。

第二章——原子性(Atomics)

在第二章中,我们将学习Rust的原子类型及其所有操作。我们从简单的加载和存储操作开始,逐步构建到更高级的“比较和交换循环”,通过几个真实的用例作为可用的示例来探索每个新概念。虽然内存排序与每个原子操作相关,但这个主题将留待下一章讨论。本章只介绍了“轻松的”内存排序就足够了的情况,这种情况比人们想象的要多。

第三章——内存排序(Memory Ordering)

在学习了各种原子操作和如何使用它们之后,第三章介绍了本书最复杂的主题:内存排序。我们将探索内存模型是如何工作的,什么是先于发生的关系以及如何创建它们,所有不同的内存顺序意味着什么,以及为什么顺序一致可能不是所有问题的答案。

第四章——创建自旋锁

在学习了理论之后,我们将在接下来的三章中通过构建几个常见并发原语的我们自己的版本将其付诸实践。第一章很短,我们实现了一个自旋锁。我们将从一个非常小的版本开始,将释放和获取内存排序付诸实践,然后我们将探索Rust的安全概念,将其转变为符合人体工程学和难以滥用的Rust数据类型。

第五章——创建线程间的通信渠道

在第5章中,我们将从头开始实现one-shot channel的一些变体,这是一个可用于将数据从一个线程发送到另一个线程的原语。从一个非常简单但完全“不安全”的版本开始,我们将通过几种方法来设计一个安全的界面,同时考虑设计决策及其后果。

第六章——手动创建Arc

在第六章中,我们将进行一个更具挑战性的内存排序难题。我们将从头开始实现我们自己版本的原子引用计数。在添加了对弱指针的支持并对其性能进行优化之后,我们的最终版本将与Rust的标准“std::sync::Arc”类型几乎相同。

第七章——深入处理器

第七章是对所有底层细节的深入探讨。我们将探索处理器层面发生了什么,原子操作背后的“汇编指令”在两种最流行的处理器架构上是什么样子,缓存是什么以及它如何影响代码的性能,我们还将讲解硬件级内存模型的剩余部分。

第八章——操作系统原语

在第8章中,我们承认有些事情没有操作系统内核的帮助是做不到的,并了解Linux、macOS和Windows上都有哪些可用的功能。我们将讨论POSIX系统上通过pthreads可用的并发原语,了解我们可以用Windows API做什么,并了解Linux 的futex syscall做什么。

第九章——手动打造锁

利用我们在前几章学到的知识,在第9章中,我们将从头开始构建互斥锁条件变量读写锁的几个实现。对于这些问题,我们将从一个最小但完整的版本开始,然后我们将尝试以各种方式优化它。使用一些简单的基准测试,我们将发现在讨论各种设计权衡时,我们在优化方面的尝试并不总是能够提高性能。

第十章——创意与灵感

最后一章确保你在读完这本书后不会陷入空虚,而是留下一些想法和灵感,用你的新知识和技能来构建和探索事物,也许会开启一段深入底层并发性的令人兴奋的旅程。

代码示例

本书中的所有代码都是针对Rust 1.66.0编写的,并使用Rust 1.66.0进行测试,Rust 1.66.0于2022年12月15日发布。早期版本不包括本书中使用的所有功能。不过,以后的版本应该可以正常工作。

为简洁起见,代码示例不包括’ use ‘语句,除非第一次引入标准库中的新项。为了方便起见,下面的前奏可以用来导入编译本书中任何代码示例所需的所有内容:

1
2
3
4
5
6
7
8
9
10
11
12
#[allow(unused)]
use std::{
cell::{Cell, RefCell, UnsafeCell},
collections::VecDeque,
marker::PhantomData,
mem::{ManuallyDrop, MaybeUninit},
ops::{Deref, DerefMut},
ptr::NonNull,
rc::Rc,
sync::{*, atomic::{*, Ordering::*}},
thread::{self, Thread},
};

补充材料,包括所有代码示例的完整版本,可在 https://marabos.nl/atomics/ 获得

你可以出于任何目的使用本书提供的所有示例代码。

下一章:Rust并发基础



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



总结一下过去三年的产品心路

在前几天公司的年度启动会上,周老师(副总裁)展示的PPT上有一页让我印象深刻,他展示了公司目前现有的8大主力产品在项目中的复用率,力石小知是唯一一款复用率100%的产品。当然,我知道这个100%是在一定时间段内来说的,如果时间拉长到三年半的区间,小知不可能做到100%的复用率,况且加上小知的知识运营、数据运营等工作,100%也就仅限于软件产品层面了。

虽然作为当事人,我会感觉到其中的筚路蓝缕,且SaaS道路一直未真正打开。不过这些都没关系,小知作为一个团队的努力成果,依然有很多值得和大家分享的地方,特别是在产品的0到1过程中的点点滴滴。

在开始分享前,先对复用率给出我的观点:产品复用率这事情,和产品经理的关系可能没想象中那么大,顶多只占1/4

好了,接下来我开始分享小知的三年产品成长心路,看完之后你应该能同意我的一些观点,并且看到力石科技在做产品上的一些方法。

方法1:产品-市场对角线

01 开始于普陀山

2019年夏天,当时我所在的大数据团队逐渐形成一个想法,我们需要有一款服务C(这里指游客)的产品,这样更利于我们对市场有充分感知,并且为我们的客户(旅游局/集团、景区、博物馆、乡村等)服务他们的客户(游客/观众)是一件很酷的事情。于是,我们就开始构思,并做了PPT,去普陀山拜访了客户。

说起来第一步踏出去是很顺利、很幸运的——客户非常接受我们的想法,对创新事物持拥抱态度,并且当场就表示,只要可以给普陀山的游客带来更好的服务,他们愿意当小白鼠。这给了我莫大的鼓舞,回杭州之后,我们就开始规划产品设计和研发。

02 一支很有活力的团队

当时大数据团队除了我之外,全部都是分析师,虽然他们里面最低学历都是硕士(包括牙医和叶子都是世界排名前50的大学的海归双硕士),但是不懂产品设计,也不懂工程型的编程语言(他们精通Python、R等更适合数据分析的语言)。而作为一个刚刚”想象“出来的”产品“,我们也不太可能动用公司的研发中心资源。但这样的团队是有很强的学习能力和创新能力的,于是,用水笔画在A4纸上的一张张原型就出来了。然后向公司申请了一个实习生名额——我的学弟,会写Java,然后再招聘了一位前端工程师(个人感觉他是我合作过最厉害的前端,后面也果然去了阿里等大厂)。就这样,过家家似的,产品在当年的9月26日在普陀山正式上线了。

然后,接下来就是我们自己不断地感受到这个新产品的”Low“,它还有太多我们想做却没完成的功能。客户那边在使用的过程中也不断给出意见,大部分我觉得都是合理的,我们应接不暇,当时我已经准备开始重操代码了。正在这时,和我一起工作多年的小明(也是国内著名开源作者)阴差阳错地加入了小知产研组,于是产品能力开始快速提升,而且更重要的是我们开始了底层架构地调整,目的是为了迎接这个产品不断变化的需求,因为这时候我们也不知道这个产品未来的形态是怎么样的,就像我们老板(他一直非常支持我们的孵化产品)当时说的,只有一个普陀山并不能证明这个产品是符合市场需求的。(以下是小明的开源作品,听说阿里、京东和美团都有人在用,因为他有一个交流群)

knife4j

03 开始走向市场

于是,摆在我面前的新问题就是获得更多用户的认可。在这件事情上,我首先做的事情是自己先去跑更多客户,这里也非常感谢一些现同事、老同事的帮助,介绍了大量机会。我和团队的销售(另外一个小明)在2019年的Q4跑了大概30多家景区,拜访了大量景区客户,收集了很多好的需求(包括部分客户痛点)和使用场景。于是,2020年疫情复工之后,我们在一边不断升级产品的同时,也直接或间接地有了颐和园、天台山、九华山、杏花村、西溪湿地等一波新客户。然后,非常重要的是我们开始了真正的产品方案PPT编写。虽然三年后回头看,当时的PPT是如此的”不堪入目“,但对于当时的我们来说,已经觉得”有点东西“了,特别是后来加入的小黄,让方案有很大提升。我们将方案推向解决方案部门,做讲解、培训,做产品演示,于是”征服“(其实是祈求他们认同)了一些解决方案工程师,包括泥叔、江海等同事。他们将力石小知的方案单独或者整合进他们的项目方案中,于是,在他们的推动下,在销售同事的努力下,我们的客户数增长很快(当然都是以项目的形式)。

04 客户成功组

一段时间之后,大家发现我们的很多客户其实是”静默“的,他们对于小知完全没有存在感,特别是那些在项目中带来的客户,这一点也一直在公司内部被诟病。在之前的一段时间,因为疫情,公司的组织架构也做了调整——我这边的大数据团队被并入到原运营中心,还并入了大量的产品和研发人员,组成了BOD中心,BOD的意思是建设+运营+数据。对我们最大的变化有两个:一是我的注意力被大量迁移到了项目和老游管佳产品(后面还会提到新游管佳);二是因为BOD中心的分管VP是运营方向的,深处运营中心,小知团队开始有了运营意识。这段时间大家都学会了和我们最好的几家客户打成一片,直接的好处就是不断收集到新的、真实的一线需求,而且客户对于小知这个产品也更加重视,真正使用起来了。我们这部分工作目前叫知识运营,其实我觉得后面可以改成客户成功组。

chats

好了,这部分我就说到这里了,我们来看看这个图,什么是产品-市场对角线,不需要去百度查这个名词,因为是我刚刚”发明“的。

产品-市场对角线

你会发现,我上面说的整个历程和这里是完全匹配的,从一个idea,到第一个愿意试错的客户,再到MVP(最小可用品),再到v1.0版本出现,进行渠道推广,再到后面建立运营和客户成功来收集客户反馈数据(加上游客的需求数据,以及后面又加入了埋点分析数据),不断迭代升级,整个产品发展逻辑已经形成了闭环。如果你在打造新的产品,也许可以参考。当然,我们的产品市场是面向ToB和ToG的,和ToC的逻辑会有一些不同,在ToC的领域,也许我说的这些,对于你们来说和吃饭一样平常。

产品-市场对角线对产品可以做到100%复用是有一定关系的,因为我们对市场需求的感知是更加敏感了,是主动在变,而不是被动在修。需要提一下的是:这时候我们还没有产品经理

后面还会讲到另外两三个方法,包括:

  • 创新的选择:如何去取舍洪水一样涌过来的需求?
  • 架构和需求的选择:架构和需求哪个更重要?
  • 组织的思考:组织就像一辆车……

后面会提到我们后面有产品经理了,而且还有一个新产品的诞生…


剩下的等到周末再写吧,年后回来真的好忙好忙!


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!