2024 年初的大语言模型编程实践 [译]
Antirez
首先我要明确,这篇文章并不旨在回顾大语言模型。显而易见,2023 年对人工智能来说是不平凡的一年,再去强调这一点似乎没有多大必要。这篇文章更多是作为一位程序员的个人体验分享。自从 ChatGPT 出现,再到使用本地运行的大语言模型,我就开始广泛应用这项新技术。我的目标不仅仅是提高编码效率,更重要的是,我不想在编程中那些无需过多精力投入的地方浪费时间。不愿意花费大量时间去查找某些专业且无趣的文档,不想为了学习一些过于复杂且往往无需如此的 API 而劳心费力,也不想编写那些几小时后就会被我抛弃的临时代码。尤其是如今 Google 成了一个充斥着垃圾信息的海洋,我们只能在其中努力寻找那少数有用信息。
同时,我也不是编程领域的新手。我完全有能力在没有任何辅助的情况下编写代码,而且我也常常这么做。随着时间的推移,我越来越频繁地借助大语言模型来编写高级代码,特别是 Python 代码,而在 C 语言方面则相对少一些。在体验大语言模型的过程中,我深刻认识到,应该在何时使用它们,以及何时它们的使用反而会拖慢我的步伐。我还了解到,大语言模型有点类似于维基百科和 YouTube 上琳琅满目的视频课程:它们对那些有意愿、有能力和自律的人大有裨益,但对于其他人来说,帮助有限。我担心,至少在初始阶段,它们更多的是惠及那些本就占据优势的人。
但我们还是一步一个脚印来吧!
全知全能还是鹦鹉学舌?
在机器学习新浪潮中,最让人担忧的是 AI 专家们难以接受自己知识的局限性。人类发明了神经网络,更关键的是,还发明了一个自动优化神经网络参数的算法。随着硬件能力的提升,能够训练更大的模型,利用数据的统计知识(即先验知识),通过不断的尝试和错误,逐渐找到了一些比其他架构更有效的模型设计。但不管怎样,神经网络本质上还是相当复杂且不透明的。
面对大语言模型一些新的无法解释的能力,谨慎的科学家们反而低估了它们。许多人认为,大语言模型不过是稍微高级点的马尔科夫链 (Markov chains),最多只能重复训练集里有限变化的内容。然而,越来越多的证据表明,这种看法几乎可能是大错特错的。
同时,很多吃瓜群众过分夸大了大语言模型的能力,认为它们拥有现实中根本不存在的超自然力量。事实上,大语言模型最多只能在其训练数据所代表的空间内进行插值,即使如此,它们的这一能力也已经相当惊人。真要是今天的大语言模型能够在看过的所有代码构成的空间内自如插值,它们即便不能创造真正的新颖事物,也足以取代 99% 的程序员。但现实情况要并没有这么乐观。大语言模型确实可以编写一些它之前未曾见过的程序,展示出将不同思想的训练数据融合的能力,但这种能力目前还有很大的限制,尤其是在需要细腻推理时,它们往往无法胜任。尽管如此,它们仍代表着从人工智能诞生至今的最伟大成就,这一点似乎无庸置疑。
无知却博学
确实,大语言模型大体上只能进行初级的推理,这些推理经常不准确,甚至会掺杂着一些关于不存在事实的幻觉。但它们却拥有海量的知识。在编程领域,以及其他有高质量数据的领域,大语言模型就像是理解力有限却知识渊博的人。如果要和这样的伙伴进行结对编程(对我来说,结对编程本身就是个痛苦),可能会很糟糕:它们可能提出一些荒谬的想法,我们需要不断斗争才能贯彻我们自己的想法。但如果这个博学的傻瓜能够听从我们的指令,回答我们提出的所有问题,那一切都会变得不同。现有的大语言模型虽然不能引领我们超越已知的路径,但如果我们想探索一个不熟悉的领域,它们往往能够帮我们从一无所知到掌握足够的知识来独立前行。
在编程领域,直到二十或三十年前,这些能力可能还不太引人注目。那时,你需要掌握几种编程语言、经典算法以及那些基本的库。其余的则依靠你自己的智慧、专业知识和设计能力。具备这些素质,你就能成为一名全能的专家级程序员。然而,随着时间的推移,我们见证了框架、编程语言、各种库的大量涌现。这种复杂性通常是不必要的,甚至无法自圆其说,但事实就是如此。在这样的情况下,一个无所不知的“白痴”成了宝贵的盟友。
我来举个例子:我对机器学习的实验最初是用 Keras 进行的,持续了至少一年。后来因各种原因,我转向了 PyTorch。我已经了解什么是嵌入(Embedding)或残差网络(Residual Networks, ResNets),但我并不想深入研究 PyTorch 的文档(就像我学习 Keras 那样,那时 ChatGPT 还不存在)。有了大语言模型,用 Torch 编写 Python 代码变得非常容易。我只需清楚地了解我想要构建的模型,并提出合适的问题。
应用案例
我要讨论的不是那些简单的问题,比如“嘿,类 X 是如何执行 Y 操作的?”如果只是这些问题,那些对大语言模型保持怀疑态度的人可能就有理由了。但实际上,更复杂的模型能做的事情远远超出这些。几年前,这些还被认为是不可思议的魔法。我可以这样对 GPT4 下指令:看,这是我在 PyTorch 中实现的神经网络模型。这里是我的数据批次。我想调整这些张量的大小,以便它们能与神经网络输入的要求相匹配,并且我希望以这种特别的方式展现它们。你能帮我写出调整这些张量尺寸的代码吗?GPT4 帮我写出了代码,我接下来要做的,就是在 Python 命令行界面中测试这些张量是否真的符合我需要的大小,以及数据结构是否正确。
再举一个例子。不久前,我需要为基于 ESP32 的设备开发一个蓝牙低能耗 (BLE) 客户端。经过研究后,我发现多平台蓝牙编程接口大都不好用。解决方法很简单,就是用 Objective C 和 macOS 的原生 API 来编写代码。但这样一来,我就面临着两个问题:一是学习 Objective C 中复杂的 BLE API,这些 API 充满了我认为完全没有必要的复杂设计(作为一个极简主义者,这些设计与我所认为的“好设计”截然相反);二是回忆起怎样使用 Objective C 编程。我上次使用 Objective C 编写程序是十年前了,很多细节,比如事件循环、内存管理等,我都已经记不清了。
最后的结果就是这段代码,虽然它看起来不是很美观,但它完成了它的任务。我在极短的时间内就编写完成了。否则根本不可能做到这一点。
https://github.com/antirez/freakwan/blob/main/osx-bte-cli/SerialBTE.m
这段代码主要是通过在 ChatGPT 上复制粘贴我想实现但不太确定如何着手的功能来编写的,因此最初它们并未能正确运行。然后,大语言模型帮我指出了问题所在并告诉我如何解决。虽然大部分代码不是由 LLM 直接编写的,但它确实极大地加快了编程速度。不用 ChatGPT 我也能完成这个任务吗?答案是肯定的,但更有趣的不仅是它节省了我很多时间:事实上,如果没有 ChatGPT,我连尝试的勇气都没有,因为那似乎并不值得。这一点至关重要。对于我的项目来说,编写这样一个不重要的程序的努力与其带来的好处的比例本来是不划算的。此外,这个过程产生了一个比程序本身更有用的副作用:为了这个项目,我对 linenoise(我用于行编辑的一个库)进行了改造,使其可以在多路复用环境下运行。
这是另一个例子,更多地涉及到数据解释而非代码编写。我打算用一个我在网上发现的卷积神经网络 (Convolutional Neural Network) 设置一个 Python 脚本,但这个网络缺乏详细文档。网络的一大优势是它采用了 ONNX (Open Neural Network Exchange) 格式,这使我能够轻松地识别出网络的输入和输出以及它们对应的名称。我对这个卷积网络了解的唯一一点是:它能识别图像中的特定特征。但我不知道所需输入图像的格式和大小,更何况,网络的输出比我预想的要复杂得多(我原本以为它是一个二元分类器 (binary classifier),用于判断观察到的图像是否正常或存在问题。我原以为它只有两个输出,但实际上有数百个)。我首先把 ONNX 网络的元数据输出复制粘贴到 ChatGPT 中,并向助手阐述了我所知道的关于网络的有限信息。ChatGPT 推测了输入的组织方式,以及输出可能是标准化后的框,用于指出图像中潜在缺陷的部分,还有其他输出表示这些缺陷的可能性。经过几分钟的交流,我得到了一个能进行网络推断的 Python 脚本,以及将起始图像转换为适合输入的张量等必要代码。让我印象深刻的是,当 ChatGPT 观察到测试图像上的原始输出值(基本上是逻辑单元 (logits))时,它终于“理解”了网络的运作方式:一系列浮点数为识别输出的确切细节和标准化提供了上下文,比如框是否居中或指定了左上角等细节。
一次性程序
我曾经遇到过很多类似的情况,就像我之前叙述的那样。但记录这些并没有太大意义,因为这些情况重复的故事基本相同。我的问题是,我需要迅速了解一些事情,特别是在大语言模型给出的回答可能是无稽之谈时,我得能够验证这些信息的真实性。在这种情况下,我会利用大语言模型加快我的知识获取速度。
但在其他情况下,我会让大语言模型完全编写代码。举个例子,当我需要编写一个基本可以随时丢弃的程序时。比如这个:
我需要可视化一个小型神经网络学习过程中的损失曲线(loss curve)。我向 GPT4 展示了 PyTorch 程序在学习过程中生成的 CSV 文件格式,然后我提出了一个需求:如果我在命令行中指定了多个 CSV 文件,我不想再看到同一实验的训练和验证损失曲线,而是想比较不同实验的验证损失曲线。上面就是 GPT4 生成的结果,总共用了三十秒。
类似地,我需要一个程序来读取 AirBnB 的 CSV 报告,并按月份和年份对我的公寓进行分组。接着,它会考虑清洁成本和每次预订的夜晚数量,统计不同月份的平均租金价格。这个程序对我非常有用,但编写它又极其无聊,因为过程中没有什么新颖之处。因此,我把 CSV 文件的一部分复制粘贴到 GPT4 上,告诉大语言模型我要解决的问题。程序第一次尝试就运行成功了,下面是完整的展示。
import pandas as pdpd.set_option('display.max_rows', None)df = pd.read_csv('listings.csv')reservations = df[df['Type'] == 'Reservation']reservations['Start Date'] = pd.to_datetime(reservations['Start Date'])reservations['Year'] = reservations['Start Date'].dt.yearreservations['Month'] = reservations['Start Date'].dt.monthreservations['Nightly Rate'] = (reservations['Amount'] - reservations['Cleaning Fee']) / reservations['Nights']all_listings = reservations['Listing'].unique()all_years = reservations['Year'].unique()all_months = range(1, 13)index = pd.MultiIndex.from_product([all_listings, all_years, all_months], names=['Listing', 'Year', 'Month'])all_data = pd.DataFrame(index=index).reset_index()merged_data = pd.merge(all_data, reservations, on=['Listing', 'Year', 'Month'], how='left')average_nightly_rates = merged_data.groupby(['Listing', 'Year', 'Month'])['Nightly Rate'].mean().fillna(0)print(average_nightly_rates)
要准确地理解如何对这些原本分散和无序的数据进行分组,需要一些简单的逻辑推理。这不是大语言模型仅凭重复它所见过的语言模式就能简单解决的问题。虽然 GPT4 在训练中肯定观察到了类似的程序,但几乎可以肯定它没有看到一个程序需要以我所要求的方式,在具有特定格式的 CSV 文件中对数据进行分组。这证明了(至少在我看来),大语言模型能够在其训练集中不同程序所勾勒的范围内进行一定程度的创新和推理。
在我个人看来,编写这类程序可能是对时间的不明智使用。一个大语言模型能替我完成这项工作,显著提升了我编程的效率。这意味着,我能将精力集中在真正重要的事务上。
当事情不按计划进行:系统编程的挑战
在大语言模型和编程领域,我取得了一定的成功。但是,当我使用 C 语言编写程序时,我通常只把 LLM 作为一种更便捷的文档工具。作为一名系统编程专家,我发现只有在 LLM 能进行复杂的推理分析时,它们才对我有实际用处。而在这方面,LLM 经常无法满足期望。
让我们看一个实验例子:
试着概述一个高效、小型但完整的 C 语言实现的布隆过滤器(Bloom Filter)的要求,重点在于哈希(hashing)功能的关键要素。然后用优质的 C 语言编写它。
另外,考虑到你的例子应该可以存储 100,000 个元素,并且误报概率不超过 5%。添加的元素是以空字符结尾的字符串。
以下是输出结果:
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <math.h>#define N 100000 // Number of elements#define P 0.05 // False positive probability#define M (int)(-(N * log(P)) / (log(2) * log(2))) // Size of bit array#define K (int)(M / N * log(2)) // Number of hash functionsunsigned char bitArray[M / 8 + 1];void setBit(int n) {bitArray[n / 8] |= 1 << (n % 8);}int getBit(int n) {return bitArray[n / 8] & (1 << (n % 8));}// Basic hash functions (for demonstration purposes)int hash1(char *str) {int hash = 5381;int c;while ((c = *str++))hash = ((hash << 5) + hash) + c;return abs(hash % M);}int hash2(char *str) {unsigned long hash = 0;int c;while ((c = *str++))hash = c + (hash << 6) + (hash << 16) - hash;return abs(hash % M);}void add(char *str) {setBit(hash1(str));setBit(hash2(str));// Add more hash functions if needed}int is_in(char *str) {return getBit(hash1(str)) && getBit(hash2(str));// Check more hash functions if used}int main() {char *testStr = "testString";add(testStr);if (is_in(testStr)) {printf("%s is probably in the set.\n", testStr);} else {printf("%s is definitely not in the set.\n", testStr);}return 0;}
GPT4 所生成的布隆过滤器实现上并不算很出色。布隆过滤器应用广泛,并不是什么稀奇古怪的数据结构。然而,要编写一个合格的布隆过滤器,需要更高层次的抽象思维。比如,需要找到一种有效的方法,对同一个字符串进行多次哈希处理,同时确保这些哈希值相互之间的独立性。
但是,如果你明确指示 GPT4 改进哈希函数,使之能产生多个互不相关的输出,它提出的方案就显得更加合理:
unsigned int hash(const char* str, unsigned int hash_id) {unsigned int hash = 5381;int c;while ((c = *str++)) {hash = ((hash << 5) + hash) + c + hash_id;}return hash % BF_SIZE;}
如果 GPT4 能够自行提出这种方案,它编写的布隆过滤器将会有所不同,可能会使用单一的哈希函数一次性设置多个位。
GPT4 能够独立完成编写合适且更通用哈希函数的任务。但在整个布隆过滤器的编写过程中,它未能表现出足够的推理能力。相反,它只是提供了两种不同但相似的哈希函数,这种结果更像是从一篇关于布隆过滤器的低质量文章中看到的。
大语言模型在推理能力上的不足,加上可能缺乏高质量的相关资料,甚至还可能存在大量低质量的资料,共同造成了这种不尽人意的结果。事实上,这并非个例。在算法或系统编程领域,我尝试使用 LLM 的体验通常都很差劲,哪怕在类似 Python 编程这样的领域,LLM 表现出来的推理能力并不算高。
与此同时,GPT-4 不仅能反编译它生成的函数(在另一个独立会话中),还能精准地理解该函数的用途:
https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1
因此,认为 GPT-4 在系统编程领域无用的说法显然是不恰当的。
有趣的是,这些情况下,小型与大型模型之间的差异显著。来看看两个本地模型的对比表现。
例如,Mistral 的专家模型 (MoE) Mixtral,对同样的问题给出了以下回应:
unsigned int hash(const char* str, unsigned int hash_id) {unsigned int hash = 5381;int c;while ((c = *str++)) {hash = ((hash << 5) + hash) + c;}return hash + hash_id;}
显然,仅在最后加入 hash_id
是一个非常不佳的解决方案。Mixtral 对许多应用而言是一个优秀的模型,但对于大语言模型本就有限的推理能力来说,目前的规则显然是:越大越好。
这是我与本地大型语言模型 deepseek-coder
的对话记录。由于 RAM 有限,我只能将模型量化到 4 位来使用。尽管如此,作为一个拥有 340 亿参数的庞大模型,它在这个问题上的推理能力似乎相当出色。以下是我用 llama.cpp 进行的聊天记录:
我有一个哈希函数,它应该为相同的数据输出 N 个不同的哈希。但是,我发现当我改变 hash_id 的值时,生成的哈希分布非常差:
unsigned int hash(const char* str, unsigned int hash_id) {unsigned int hash = 5381;int c;while ((c = *str++)) {hash = ((hash << 5) + hash) + c;}return hash + hash_id;}
可能是我使用的加盐策略不够好?我该如何改进呢?
[Deep-seek coder 的回复]
你的实现中可能存在的一个问题是,你只是简单地在哈希的末尾添加了 hash_id
。这可能会导致分布不均,尤其是当 hash_id
过大或分布不均时更为明显。
考虑使用位运算来混合 hash_id
。这里有一个使用异或 (XOR) 运算的例子:
unsigned int hash(const char* str, unsigned int hash_id) {unsigned int hash = 5381;int c;while ((c = *str++)) {hash = ((hash << 5) + hash) ^ c + hash_id; // Using XOR to mix in the hash_id}return (hash ^ hash_id); // Mixing the hash_id one more time at the end}
这样做可以比简单添加更有效地分布 hash_id
,从而使哈希分布更均匀。[省略……实际回复更详尽]
在我的 MacBook M1 Max 上运行的这个模型表现得相当不错。它成功地将求和和异或操作混合使用了。在这个案例中,模型得到了我提供的关于问题的线索的帮助,但最终是模型自己识别出了问题的真正所在,并提出了一个有效的解决方案。这样的成就是任何书籍、文档或 Google 搜索都无法达到的。不管你如何看待这个模型——无论是作为一个初级的、基于插值的结果,还是以其他方式——它确实展现了某种形式的推理能力。在这个特定案例中,如果我们认为识别问题的根源和找到可能的解决方案是一种推理,那么它确实做到了这一点。但无论我们如何看待大语言模型,断言它们对程序员无用是非常草率的。
然而,根据我过去几个月的经验,对于系统编程来说,如果你已是资深程序员,大语言模型往往无法提供令人满意的解决方案。我来举一个真实世界中的例子。我的最新项目,ggufflib,涉及到开发一个读写 GGUF 格式文件的库,而这正是 llama.cpp 用来加载量化模型的格式。起初,为了弄懂量化编码的工作原理(因为速度原因,每个量化比特都以特殊方式存储),我试过使用 ChatGPT,但最后我选择了对 llama.cpp 代码进行逆向工程,这样更加迅速。一个能够有效协助系统程序员的大语言模型,在看到数据编码的结构声明和解码函数后,应该能够重建数据格式的文档。虽然 llama.cpp 的功能足够简短,可以完全放入 GPT4 的上下文中,但它的输出却毫无用处。在这些情况下,我们还是得回归传统方式:纸笔在手,细读代码,寻找解码器提取的比特在哪里注册。
为了让你更好地理解上述案例,如果你感兴趣,可以尝试一下。这里有一个来自 llama.cpp
实现的结构。
// 6-bit quantization// weight is represented as x = a * q// 16 blocks of 16 elements each// Effectively 6.5625 bits per weighttypedef struct {uint8_t ql[QK_K/2]; // quants, lower 4 bitsuint8_t qh[QK_K/4]; // quants, upper 2 bitsint8_t scales[QK_K/16]; // scales, quantized with 8 bitsggml_fp16_t d; // super-block scale} block_q6_K;
然后是用于执行去量化的函数:
void dequantize_row_q6_K(const block_q6_K * restrict x, float * restrict y, int k) {assert(k % QK_K == 0);const int nb = k / QK_K;for (int i = 0; i < nb; i++) {const float d = GGML_FP16_TO_FP32(x[i].d);const uint8_t * restrict ql = x[i].ql;const uint8_t * restrict qh = x[i].qh;const int8_t * restrict sc = x[i].scales;for (int n = 0; n < QK_K; n += 128) {for (int l = 0; l < 32; ++l) {int is = l/16;const int8_t q1 = (int8_t)((ql[l + 0] & 0xF) | (((qh[l] >> 0) & 3) << 4)) - 32;const int8_t q2 = (int8_t)((ql[l + 32] & 0xF) | (((qh[l] >> 2) & 3) << 4)) - 32;const int8_t q3 = (int8_t)((ql[l + 0] >> 4) | (((qh[l] >> 4) & 3) << 4)) - 32;const int8_t q4 = (int8_t)((ql[l + 32] >> 4) | (((qh[l] >> 6) & 3) << 4)) - 32;y[l + 0] = d * sc[is + 0] * q1;y[l + 32] = d * sc[is + 2] * q2;y[l + 64] = d * sc[is + 4] * q3;y[l + 96] = d * sc[is + 6] * q4;}y += 128;ql += 64;qh += 32;sc += 8;}}}
当我请求 GPT4 编写关于使用格式的概述时,它难以清晰地说明“ql”中上下四位的数据块是如何存储的,这与权重位置有关。在撰写这篇博客时,我还尝试让它编写一个简化版本的函数来展示数据的存储方式(可能它难以用文字解释,但可以通过代码来表达)。然而,它生成的函数存在诸多问题,比如索引不正确,从 6 位到 8 位的符号扩展处理错误(仅仅是将其转换为 uint8_t 类型),等等。
对了,这是我最终自己编写的代码:
} else if (tensor->type == GGUF_TYPE_Q6_K) {uint8_t *block = (uint8_t*)tensor->weights_data;uint64_t i = 0; // i-th weight to dequantize.while(i < tensor->num_weights) {float super_scale = from_half(*((uint16_t*)(block+128+64+16)));uint8_t *L = block;uint8_t *H = block+128;int8_t *scales = (int8_t*)block+128+64;for (int cluster = 0; cluster < 2; cluster++) {for (uint64_t j = 0; j < 128; j++) {f[i] = (super_scale * scales[j/16]) *((int8_t)((((L[j%64] >> (j/64*4)) & 0xF) |(((H[j%32] >> (j/32*2)) & 3) << 4)))-32);i++;if (i == tensor->num_weights) return f;}L += 64;H += 32;scales += 8;}block += 128+64+16+2; // Go to the next block.}}
从上述函数中,我移除了这段代码的核心贡献:即长篇注释,详细记录了 llama.cpp
中 Q6_K
编码使用的确切格式。现在,如果 GPT 能够帮我完成这一工作,那将非常有帮助。我相信这只是时间问题,因为这类任务在没有技术突破的情况下也是可行的,只需适当的扩展即可。
重新审视编程工作
不得不说,这是一个事实:现今的编程大多是在微调同样的内容,只是形式略有变化。这种工作并不需要太高的推理能力。大语言模型在这方面表现出色,尽管它们的能力仍然受限于上下文长度。这个现象应该引起程序员的深思:真的值得去编写这类程序吗?虽然可以赚到不错的收入,但如果大语言模型也能完成其中一部分工作,那么在未来五到十年,这可能并非最佳的职业发展方向。
再来看,大语言模型真的具备一定的推理能力,还是只是表面上的假象?有时候,它们似乎在进行推理,但这可能只是因为,像符号学家所说,使用的“符号”造成了一种实际上并不存在的意义错觉。足够了解大语言模型的人会明白,事实并非如此:这些模型整合既有信息的能力,远非简单的词汇重复。它们在预训练期间的训练主要是预测下一个 Token,这个过程迫使模型构建了一种抽象的模型。虽然这个模型可能脆弱、零散且不完美,但从我们观察到的现象来看,它确实存在。在数学确定性存在疑问,且领域内顶尖专家意见分歧的情况下,相信自己的直觉似乎是明智之举。
最后,今天还有什么理由不去使用大语言模型辅助编程呢?
正确地向大语言模型提问是一项关键技能。这项技能练习得越少,利用 AI 改善工作的能力就越弱。而且,无论是与大语言模型还是与人类交流,清晰描述问题同样重要。沟通不畅是一个严重的障碍,很多程序员尽管在自己的专业领域很有能力,但在沟通上却做得很糟糕。现在,连 Google 都变得不那么好用了,所以即便是将大语言模型作为一种压缩文档的方式来使用,也是个不错的主意。至于我,我将继续大量使用它们。我从来不喜欢去深究某个晦涩的通讯协议的细节,或者去理解由某些想要炫耀自己技术的人编写的复杂库方法。这些对我来说就像是"无用知识"。有了大语言模型,我就能免于这些困扰,每天都能感觉到它带来的帮助。