Antirez-LLMs and Programming in the first days of 2024
首先,我想明确这篇文章并不是对LLMs的回顾。2023年对于人工智能来说确实是特殊的一年:重申这一点似乎毫无意义。相反,这篇文章旨在从一个个体程序员的角度提供一份见证。自从ChatGPT的出现,以及后来使用本地运行的LLMs,我已经广泛使用了这项新技术。目标是加快我的编程能力,但这并不是唯一的目的。还有意图是避免在不值得努力的编程方面浪费精神能量。无数小时花在寻找那些古怪、智力上无趣的文档上;努力去学习一个过于复杂的API,往往没有充分的理由;编写立即可用的程序,然后在几小时后就丢弃。这些都是我不想做的事情,尤其是现在,Google已经成为了一个充斥着垃圾信息的海洋,在其中寻找有用的信息变得困难重重。
与此同时,我当然不是编程方面的新手。我有能力不借助任何帮助就能写代码,事实上,我经常这样做。随着时间的推移,我越来越多地使用LLMs来编写高级代码,尤其是在Python中,而在C语言中使用得较少。关于我个人使用LLMs的经验,我感到惊讶的是,我确切地学会了何时使用它们,以及何时使用它们只会拖慢我的速度。我还了解到LLMs有点像维基百科和YouTube上散布的所有视频课程:它们帮助那些有意愿、能力和纪律的人,但对于落后的人来说益处有限。我担心至少一开始,它们只会使那些已经处于优势地位的人受益。
但我们还是一步一步来吧。
全知还是鹦鹉?
在机器学习这一波新奇和进步中,最令人担忧的现象之一是AI专家接受自己有限知识的能力有限。人类发明了神经网络,然后,更重要的是,发明了一种自动优化神经网络参数的算法。硬件已经能够训练越来越大的模型,并且利用关于待处理数据的统计知识(先验)以及通过大量试错进行连续逼近,发现了比其他人更有效的架构。但总的来说,神经网络仍然相当不透明。
面对这种无法解释LLMs某些新兴能力的情况,人们本应期待科学家们更加谨慎。相反,许多人严重低估了LLMs,说它们毕竟不过是一些高级马尔可夫链,至多能够在训练集中看到的东西上复述极其有限的变体。然后,面对证据,这种鹦鹉观念几乎被普遍撤回。
与此同时,许多热情的大众赋予了LLMs不存在于现实中的超自然力量。不幸的是,LLMs最多只能在它们在训练期间看到的数据的表示空间中插值:而这已经很多了。实际上,它们的插值能力是有限的(但仍然惊人,也出乎意料)。哦,如果今天的最大的LLMs能够在它们看到的所有代码所限定的空间中连续插值!即使它们不能产生真正的新颖性,它们也能够取代99%的程序员。现实更为谦逊,几乎总是如此。LLMs肯定能够编写它没有以那种确切形式看到的程序,显示出一定的能力将训练集中出现的不同想法以一定频率混合在一起。很明显,这种能力目前有很深的限制,每当需要微妙的推理时,LLMs就会灾难性地失败。然而,它们代表了从AI诞生到今天最伟大的成就。这似乎是不可否认的。
愚蠢却全知
确实:LLMs最多只能进行初级推理,经常不准确,很多时候还夹杂着对不存在的事实的幻觉。但它们拥有巨大的知识。在编程领域以及其他有高质量数据可用的领域,LLMs就像愚蠢的神童一样知道很多事情。与这样一个伙伴进行配对编程将是可怕的(对我来说,即使是最一般的配对编程也是可怕的):他们会提出毫无意义的想法,我们必须不断努力强加我们自己的想法。但如果这个博学的傻瓜在我们手中并回答所有问题,情况就变了。当前的LLMs不会带我们超出知识的道路,但如果我们想要解决一个我们不太了解的话题,它们通常可以将我们从绝对的无知中提升到足以自己前进的程度。
在编程领域,也许直到二三十年前他们的能力才非常有趣。那时你必须知道几种编程语言、经典算法和那十个基本库。其余的你得自己添加进去,你的智慧、专业知识、设计技巧。如果你有了这些成分,你就是一个专家程序员,能够做几乎所有事情。随着时间的推移,我们见证了框架、编程语言、各种库的爆炸式增长。复杂性的爆炸通常是完全没有必要和不合理的,但事实是事情就是这样。在这样的背景下,一个知道一切的白痴是一个宝贵的盟友。
让我举一个例子:我的机器学习实验至少进行了一年使用的是Keras。然后出于各种原因,我转向了PyTorch。我已经知道了什么是嵌入或残差网络,但我不想再像我之前学习Keras那样逐步研究PyTorch的文档(当我还没有ChatGPT时我学到了Keras)。有了LLMs,编写使用Torch的Python代码非常容易。我只需要对我想要构建的模型有一个清晰的思路并提出正确的问题。
示例时间
我并不是在谈论一些简单的事情,比如:“嘿,类X的方法如何做Y”?如果只是为了这个,人们可能会倾向于同意那些对LLM持怀疑态度的人。更复杂的模型所能做的事情要复杂得多。直到几年前,这还是纯粹的魔法。我可以告诉GPT4:看,这是我在PyTorch中实现的神经网络模型。这些是我的批量数据。我想调整张量的大小,使发出批量数据的函数与神经网络的输入兼容,并且我希望以这种特定的方式来表示事物。你能告诉我需要做什么来重新调整形状吗?GPT4写出了代码,我所要做的就是在Python CLI中测试张量的维度是否真的对我有用,以及数据布局是否正确。
这里还有另一个例子。一段时间前,我不得不为某些基于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向我解释问题所在以及如何解决它。确实,LLM没有写很多那部分代码,但也确实加速了写作过程。如果没有ChatGPT,我能做到吗?当然可以,但最有趣的事实不是它会花费我更长的时间:事实是,我甚至不会尝试,因为这样做不值得。这个事实至关重要。编写这样一个程序的努力与收益之比,相对于我的项目来说,将是不便的。此外,这比程序本身有一个更有用的双重副作用:为了那个项目,我修改了我的行编辑库linenoise(linenoise是我用于行编辑的一个库),使其能够进行多路复用。
另一个例子,这次不那么关注代码编写,而是更多关于数据解释。我想设置一个使用在线找到的卷积神经网络的Python脚本,但它的文档相当缺乏。网络的优势在于它是ONNX格式的,所以我可以轻松地提取输入和输出列表及其指定名称。我对这个convnet只知道一件事:它在图像中检测某些特征。我不知道输入图像的格式和大小,特别是,网络的输出比我想象的要复杂得多(我以为它是一个二分类器:观察到的图像是否没问题?两个输出,但有数百个)。我开始将ONNX网络元数据输出复制粘贴到ChatGPT中。我向助手解释了我对网络所知的一点点信息。ChatGPT假设输入是如何组织的,输出可能是规范化的框,指示图像中潜在的缺陷部分,以及其他指示这些缺陷可能性的输出。经过几分钟的来回交流后,我得到了一个能够进行网络推理的Python脚本,以及将起始图像转换为适合输入的张量的必需代码等等。让我印象深刻的是那次会话中ChatGPT最终“理解”了网络的功能,一旦它观察到测试图像上的原始输出值(基本上是logits):一系列浮点数提供了上下文来识别确切的输出细节、规范化、框是否居中或左上角是否指定等等。
一次性程序
我可以记录上面叙述的数十个这样的案例。但这是没有意义的,因为故事在以或多或少相同的方式重复自己。我遇到了一个问题,如果我需要验证LLM是否给我提供了无用的信息,我需要快速了解一些东西。在这种情况下,我使用LLM来加快我对知识的需求。
然而,还有一些情况下,我让LLM编写所有的代码。例如,每当我需要编写一个或多或少是一次性的程序时。就像这个:
https://github.com/antirez/simple-language-model/blob/main/plot.py
我需要可视化一个小神经网络学习过程中的损失曲线。我向GPT4展示了PyTorch程序在学习过程中生成的CSV文件的格式,然后要求如果我在命令行上指定多个CSV文件,我不再想要同一个实验的训练和验证损失曲线,而是不同实验的验证损失曲线的比较。以上是GPT4生成的结果。总共30秒。
同样地,我需要一个程序读取Airbnb的CSV报告,并根据月份和年份将我的公寓分组。然后,考虑到清洁费用和每个预订的入住天数,它会对一年中不同月份的平均租金价格进行统计。这个程序对我来说非常有用。同时,编写它是极其无聊的:没有什么有趣的地方。因此,我从CSV文件中提取了一段漂亮的代码,并在GPT4上进行了复制粘贴。我向LLM说明了要解决的问题:程序第一次尝试就成功了。下面我将完整地展示给你。
import pandas as pd
pd.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.year
reservations['Month'] = reservations['Start Date'].dt.month
reservations['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)
为了确切了解如何对数据进行分组,这些数据在其他方面相当分散和无序,需要进行一些简单的推理:这不是一个LLM可以通过简单地吐出近似于它所见过的语言来解决的问题。GPT4在其训练过程中肯定观察到了类似的程序,但几乎可以肯定没有看到过一个必须以我要求的确切方式对事物进行分组的程序,并且是以具有特定格式的CSV文件的形式。这表明(在我看来)LLMs可以在训练集中不同程序所概述的空间内进行一定程度的插值。
在我看来,编写这种类型的程序是对我的时间的不明智使用。LLM可以为我做到这一点的事实无疑加快了我以一种相当直接的方式生成代码的能力:我可以专注于重要的事情。
当事情不如预期时:系统编程
尽管我在LLM和编程方面取得了成功,但当我用C语言编写程序时,我几乎总是只将LLM用作一种更方便的文档形式。我是一名系统编程专家,在这种背景下,只有LLM能够进行复杂推理时,它们对我来说才有用:在这种情况下,它们几乎总是失败的。
让我们从这个实验的提示开始:
概述一个漂亮、小巧但实用的C实现布隆过滤器的要求。重点关注哈希函数的重要方面。然后用高质量的C语言编写它。
还需要考虑你的示例实现应该能够存储10万个元素,误报概率最多为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 functions
unsigned 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生成的实现并不是非常出色。布隆过滤器已经相当普遍了,它不是一个特别奇特的数据结构。然而,很明显,编写一个像样的布隆过滤器需要更强的抽象能力:例如,如何有效地对同一个字符串进行N次哈希,并确保哈希值之间充分无关是一个问题。
然而,如果你明确要求GPT4修改哈希函数,使其产生N个无关的输出,它提出的解决方案就更加可接受了:
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;
}
如果它自己提出了这个想法,它会以不同的方式编写布隆过滤器,使用单个哈希函数一次设置K个位。
GPT4能够独立地执行编写适当和更通用的哈希函数的任务。但在编写布隆过滤器这个更大的项目中,它未能展示出足够的推理能力,而是输出了两个不同但相似的哈希函数:这正是人们期望从一篇关于布隆过滤器的糟糕示例文章中看到的。
当前大型语言模型的较弱推理能力,加上该主题可能缺乏来源,甚至更糟糕的是可能存在许多低质量的来源,共同导致了这种贫乏的结果。然而,这并不是一个孤立的案例。我尝试在算法或系统编程领域使用LLMs的次数很少,结果非常糟糕,即使预期的推理能力并不比LLMs在像Python编程这样的上下文中所拥有的推理能力更高。
与此同时,GPT-4能够对其生成的函数进行反编译(在单独的会话中),并准确理解其目的:
https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1
因此,断言在系统编程上下文中没有用途肯定是不准确的。
一个有趣且几乎可以预料到的事情是,在这些情况下,较小和较大模型之间的差异确实很大。让我们看看两个本地模型的表现如何。
例如,Mixtral,即Mistral的混合专家模型,对同一个问题做出了如下回答:
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是一个非常适合许多用途的模型,但对于LLM已经非常弱的推理能力来说,到目前为止的规则肯定是:越大越好。
这是与本地LLM deepseek-coder的对话,我将其量化为4位,因为我没有足够的RAM来使用更高精度的模型。然而,由于拥有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;
}
也许我使用的策略不好?我该如何改变它?
[deepseek-coder回复]
你的实现中可能存在一个问题,即你只是简单地将hash_id
添加到哈希的末尾。这可能会导致分布不良,特别是如果hash_id
太大或分配不均匀的话。
考虑使用位操作来混合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 + 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
更好地将hash_id
分布在你的哈希中,因为简单地添加可能导致分布不均匀。[省略了实际回复中的更多信息]
对于在我的MacBook M1 Max上运行的模型来说,表现还不错。它还使用异或运算符混合了求和结果。在这种情况下,我提供了关于要解决的问题的线索,这当然有助于模型,但正是模型识别出了真正的问题根源,并且提出的替代方案是有效的。类似上述情况的内容无法通过任何书籍、文档或谷歌搜索获得。无论人们如何看待LLMs,说它们对程序员没有帮助是非常草率的。
然而,与此同时,我过去几个月的经验表明,对于系统编程来说,如果你已经是有经验的程序员,LLMs几乎从不提供可接受的解决方案。让我给你展示另一个真实世界的例子。我的当前项目ggufflib涉及到编写一个读取和写入GGUF格式文件的库,这是llama.cpp加载量化模型所使用的格式。最初,为了理解量化编码是如何工作的(出于速度原因,每个量化位以特殊的方式存储),我尝试使用ChatGPT,但后来我决定逆向工程llama.cpp的代码:这样更快。如果一个LLM看到一个数据编码“结构体”声明和解码函数,它应该能够重建数据格式文档,从而在一定程度上辅助系统程序员。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 weight
typedef struct {
uint8_t ql[QK_K/2]; // quants, lower 4 bits
uint8_t qh[QK_K/4]; // quants, upper 2 bits
int8_t scales[QK_K/16]; // scales, quantized with 8 bits
ggml_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”的低/高4位如何根据权重位置存储块。为了这篇博客文章,我还尝试让它编写一个简单的函数,展示数据是如何存储的(也许它不能用文字来解释,但可以用代码)。生成的函数有很多问题,索引错误,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能为我做到这一点,那将是非常有用处的,我敢打赌这只是几个月的事情,因为这类任务在没有突破的情况下是可以达到的,只需要进行一些扩展。
把事情放在正确的角度看待
我很遗憾地说,但这是事实:今天的大部分编程都由以稍微不同的形式重复相同的东西组成。不需要高水平的推理能力。LLMs非常擅长做这个,尽管它们仍然受到上下文最大大小的限制。这真的应该让程序员思考。编写这种类型的程序值得吗?当然,你会得到报酬,而且相当可观,但如果LLM可以完成其中的一部分,也许在未来五到十年内这不是最好的选择。
然后,LLMs是否具有某种推理能力,还是只是虚张声势?也许有时候,它们似乎只是在推理,因为正如符号学家所说,“符号”给人一种实际上不存在的意义的印象。那些与LLMs共事足够多的人,在接受它们的局限性的同时,肯定知道情况并非如此:它们将以前见过的内容混合在一起的能力远远超出了随机重复单词的范围。虽然它们的训练主要在预训练阶段进行,但在预测下一个标记时,这个目标迫使模型创建某种形式的抽象模型。这个模型是薄弱的、不完整的和不完美的,但如果我们观察到我们所观察到的现象,它必须存在。如果我们的数学确定性令人怀疑,而最伟大的专家往往持相反的立场,相信自己亲眼所见似乎是明智的做法。
最后,今天不使用LLMs进行编程有什么意义呢?向LLMs提出正确的问题是一项基本技能。如果不经常练习,那么就无法通过AI来提高自己的工作质量。此外,在与其他人类交流时,对问题进行描述的能力也是有用的。LLMs并不是唯一有时不理解我们想要表达什么的存在。沟通不畅是一个很大的限制,许多程序员尽管在自己的特定领域非常有能力,但沟通却非常糟糕。而现在Google已经无法使用了:即使只是作为压缩形式的文档使用LLMs也是一个很好的想法。就我个人而言,我将继续广泛地使用它们。我从来没有喜欢过学习某个晦涩难懂的通信协议的细节或某个想展示自己有多好的人所编写的库的复杂方法。对我来说,这似乎是一种“垃圾知识”。LLMs每天都在越来越多地帮助我摆脱这一切。