2024年初的大语言模型与编程
原文由 Salvatore Sanfilippo 于 发布,订阅该博客
首先要说明的是,本文并不打算对大语言模型做一次回顾。2023年是人工智能非同寻常的一年,这一点已再清楚不过,再去赘述似乎毫无意义。相反,这篇文章更想作为一名普通程序员的个人证言。自ChatGPT问世以来,后来又用上了可在本地运行的大语言模型,我一直在大量使用这项新技术。目的在于加快写代码的速度,但也不仅于此。更重要的是,不想把精力浪费在那些不值得费神的编程琐事上。无数小时耗在为某个古怪又乏味的细节翻查文档上;为了掌握某个过于复杂、往往又毫无必要的API而费尽心力;或是编写一些用完几小时就扔掉的即用型程序。这些事我都不想再做了,尤其是在如今的Google早已沦为垃圾信息的海洋、想找点有用的东西都得费力打捞的情况下。
当然,我绝不是编程新手。我完全有能力在没有任何辅助的情况下写代码,而且也经常这么做。久而久之,我越来越多地用大语言模型来写高层代码,尤其是在Python上,C则用得少得多。就我个人使用大语言模型的体验而言,最让我触动的是,我已经确切地学会了何时该用它们,何时用了反而会拖慢进度。我也意识到,大语言模型有点像维基百科和散落在YouTube上的各种视频课程:它们能帮助那些有意愿、有能力、有自律的人,但对已经掉队的人则收效甚微。我担心,至少在起步阶段,它们只会让原本就占有优势的人更加受益。
不过,我们还是循序渐进地来谈。
全知还是鹦鹉?
这一波机器学习新进展中最令人忧虑的现象之一,是AI专家难以承认自身认知的局限。人类发明了神经网络,更关键的是,还发明了一套能自动优化神经网络参数的算法。硬件也已强大到足以训练越来越大的模型,再凭借对所处理数据的统计知识(即先验),并通过大量试错与逐步逼近,人们发现了一些比其他架构更有效的结构。但归根结底,神经网络依然相当不透明。
面对这种无法解释大语言模型某些涌现能力的困境,人们本会期待科学家们能表现得更谨慎一些。相反,许多人却大大低估了大语言模型,声称它们不过是稍显高级的马尔可夫链,最多只能把训练集中见过的内容换个极其有限的花样吐出来。随后,面对证据,这一“鹦鹉”之说又几乎被全盘收回。
与此同时,许多热情的拥趸又赋予了大语言模型本不存在的超自然能力。遗憾的是,大语言模型顶多只能在训练所见数据所张成的空间里做插值——而这本身就已经很了不起了。实际上,它们的插值能力是有限的(但依然惊人,也出乎意料)。要是如今最大的那些大语言模型真能在其所见过的所有代码所围成的空间里做连续插值,那该多好!即便它们无法创造真正的全新事物,也足以取代99%的程序员。现实要平淡得多,一如既往。大语言模型固然能写出未曾以完全相同形式出现过的程序,表现出将训练集中以一定频率出现过的不同想法加以融合的能力。但也显然,这种能力目前有着深刻的局限,一旦需要细微的推理,它们就会一败涂地。即便如此,它们仍是人工智能自诞生以来最伟大的成就。这一点似乎无可否认。
愚钝却无所不知
没错:大语言模型顶多具备 rudimentary 的、往往不准确的推理能力,还时常夹杂着对不存在事实的幻觉。但它们拥有广博的知识。在编程领域,以及其他拥有高质量数据的领域,大语言模型就像一个什么都懂的笨拙博学者。跟这样的搭档结对编程会很糟糕(对我来说,结对编程本身不管怎样都很糟糕):他们会冒出些荒谬的主意,我们得不断抗争才能坚持己见。但如果这个博学的傻瓜任由我们差遣、有问必答,情况就不同了。现有大语言模型不会带我们走出已知知识的边界,但如果我们想涉足某个不太熟悉的领域,它们往往能把我们从一无所知拉到足以自行前进的地步。
在编程领域,这种能力放在二三十年前,或许没什么吸引力。那时你只需要掌握一两门编程语言、经典算法和十来个基础库。剩下的就靠你自己的智力、经验和设计能力去补足。只要具备这些,你就是一名能胜任几乎一切的专家程序员。久而久之,我们见证了框架、编程语言、各类库的大爆炸。这种复杂度的爆炸往往完全没有必要、也毫无道理,但事实就是如此。而在这样的环境下,一个无所不知的笨蛋就是珍贵的盟友。
举个例子:我的机器学习实验起初至少有一年是用Keras做的。后来因为种种原因,我转到了PyTorch。我已经知道什么是嵌入、什么是残差网络,但实在不想像当初学Keras那样(那时ChatGPT还不存在)一步步啃PyTorch的文档。借助大语言模型,用Torch写Python代码变得非常容易。我只需要对想搭建的模型有清晰的想法,然后提出正确的问题就行。
举些例子
我说的不是那种简单的事,比如:“嘿,X类的Y方法怎么用?”如果只是这样,倒也难怪有人会对大语言模型持怀疑态度。更复杂的模型所能做到的要精细得多。放在几年前,这简直就是魔法。我可以对GPT4说:看,这是我在PyTorch里实现的神经网络模型。这些是我的批次数据。我想调整张量的尺寸,让负责生成批次的函数与神经网络的输入兼容,并且想以这样一种特定方式来组织数据。你能给我展示重塑所需的代码吗?GPT4会把代码写出来,而我只需要在Python命令行里测试一下,张量的维度是否真的是我需要的,数据布局是否正确。
再举一个例子。前段时间我需要为几款基于ESP32的设备实现一个BLE客户端。研究一番后我发现,多平台的蓝牙编程绑定几乎都没法用。解决办法很简单,用macOS的原生API以Objective C来写。于是,我同时遇到了两个问题:既要学习Objective C那套繁琐的BLE API,里面充满了我认为毫无道理的模式(我是极简主义者,那类API与我心中的“好设计”背道而驰),又要回想起怎么用Objective C编程。我上一次写Objective C程序已是十年前:事件循环、内存管理等等细节,我都忘得差不多了。
最终成果就是这段代码,说不上漂亮,但能完成任务。我在极短时间内就写完了。否则是不可能做到的。
https://github.com/antirez/freakwan/blob/main/osx-bte-cli/SerialBTE.m
这段代码大多是通过在ChatGPT里复制粘贴写成的——把我想做却不太会做、怎么试都不对的东西贴过去,让大语言模型解释问题出在哪、该怎么解决。诚然,大部分代码并非由大语言模型直接写就,但它确实显著加快了编写速度。没有ChatGPT我能做到吗?当然能,但最有意思的点不在于会多花多少时间:真相是,如果没有它,我根本就不会去尝试,因为不值得。这一点至关重要。编写这样一个对我主项目而言次要的程序,其投入与收益之比本来是很不划算的。此外,这件事还有一个比程序本身更有用的附带收获:为了这个项目,我修改了linenoise(我写的一个行编辑库),让它能在多路复用下工作。
再举一个例子,这次与其说是写代码,不如说是解读数据。我想用一个在网上找到的卷积神经网络搭个Python脚本,但它的文档相当匮乏。好在该网络是ONNX格式的,所以我可以轻松提取出输入输出列表及其命名。我对这个卷积网络只知道一件事:它能检测图像中的某些特征。我不知道输入图像的格式和尺寸,更没想到,网络的输出远比我想象的复杂(我以为是个二分类器:观测到的图像是否正常或有问题?两个输出就够了,结果却有数百个)。我先把ONNX网络的元数据输出复制粘贴给ChatGPT,向它说明我对这个网络仅有的那点了解。ChatGPT推测了输入可能是如何组织的,并判断输出很可能是经过归一化的边框,标示图像中可能存在缺陷的区域,另一些输出则表示这些缺陷的可能性。来回交流几分钟后,我就得到了一个能做网络推理的Python脚本,外加把原始图像转换成适合输入的张量所需的代码,等等。那次对话中最让我惊讶的是,ChatGPT在观察到一张测试图像上的原始输出值(基本上就是logits)——一串浮点数——之后,才终于“理解”了网络是如何工作的:这些数值提供了上下文,得以确定输出的具体细节、归一化方式、边框是以中心还是以左上角为准,等等。
一次性程序
我可以再记录几十个类似上面这样的案例。但没有必要,因为故事总以或多或少相同的方式重复。我遇到一个问题,需要快速了解某些*我可以验证*大语言模型是否在胡说八道的东西。好,在这种情况下,我就用大语言模型来加速获取知识。
不过,也有些情况我会让大语言模型把所有代码都写了。比如,每当我需要写一个或多或少用完即弃的程序。像这个:
https://github.com/antirez/simple-language-model/blob/main/plot.py
我需要可视化一个小型神经网络训练过程中的损失曲线。我把PyTorch程序在训练时产生的CSV文件格式展示给GPT4,然后提出要求:如果在命令行指定多个CSV文件,我不再想要同一实验的训练与验证损失曲线,而是想比较不同实验的验证损失曲线。上面就是GPT4生成的结果。总共花了三十秒。
类似地,我需要一个程序来读取AirBnB的CSV报表,按年月对我的房源进行分组。然后,结合清洁费用和每笔预订的夜数,统计一年中不同月份的平均出租价格。这个程序对我非常有用。同时,写它又极其枯燥:毫无趣味可言。于是我截取了CSV文件的一大段,直接复制粘贴给GPT4,向大语言模型说明要解决的问题:程序一次就跑通了。我在下面完整贴出它。
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)要准确理解如何对那些原本相当零散、无序的数据进行分组,需要一些简单的推理:这不是大语言模型仅靠复述其见过的近似语言就能解决的问题。GPT4在训练中固然见过类似的程序,但几乎可以肯定,它没有见过需要以我所要求的确切方式、针对那种特定格式的CSV文件来做分组的程序。这(在我看来)表明,大语言模型能够在训练集中不同程序所勾勒出的空间内做一定程度的插值。
在我看来,写这类程序是对时间的不明智使用。大语言模型能替我完成,确实相当直接地提升了我产出代码的能力:我可以专注于重要的事情。
事与愿违之时:系统编程
尽管我在大语言模型与编程的结合上颇有收获,但在用C写程序时,我几乎总是只把大语言模型当作更方便的文档来用。我是系统编程方面的专家,在那种情境下,大语言模型只有具备复杂推理能力时才对我有用:而在这一点上,它们几乎总是失败的。
先从这个实验的提示词说起:
概述一个小巧但可用的布隆过滤器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能够孤立地完成编写一个更合适、更通用的哈希函数的任务。但在编写布隆过滤器这个更大的工程中,它却未能展现出足够的推理能力,反而吐出了两个不同却相似的哈希函数:这正是人们对一篇写得不好的布隆过滤器示例文章所能期待的结果。
大语言模型目前薄弱的推理能力,加上该主题可能存在的资料匮乏,更糟的是,可能存在大量低质量资料,共同导致了这一乏善可陈的结果。然而,这并非孤例。我在算法或系统编程领域尝试使用大语言模型的少数几次中,结果都极其糟糕,即便所期望的推理能力并不比大语言模型在Python编程等场景下所展现出的更强。
与此同时,GPT-4却能够(在另一个独立会话中)反编译它自己生成的函数,并准确理解其用途:
https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1
因此,断言在系统编程场景中毫无用处,显然是不准确的。
有一件有意思且基本在意料之中的事是,在这些情况下,大模型与小模型之间的差距确实显著。来看看两个本地模型的表现。
例如,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的一段对话,我以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 reply]
你的实现中一个可能的问题是,你只是在哈希末尾简单地加上了`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`分散到整个哈希中,从而避免分布不均。[已删节……原回复包含更多有信息量的内容]
对于一个在我的MacBook M1 Max上运行的模型来说,表现不算差。它还把求和与异或混在了一起。在这个例子中,模型固然得到了我提供的关于待解决问题线索的帮助,但却是模型自己找出了麻烦的真正根源,并提出了有效的替代方案。像上面这样的结果,不是任何书籍、文档或Google搜索所能企及的。不管你把它看作多么原始、插值的结果,或以其他方式看待,模型都执行了某种形式的推理——如果在此特定情况下,我们把对问题根源的识别及其潜在解决视作推理的话。无论人们怎么看待大语言模型,断言它们对程序员毫无帮助都是极其草率的。
然而,与此同时,过去几个月的经验表明,对于系统编程,如果你已经是一名经验丰富的程序员,大语言模型几乎从不提供可接受的解决方案。让我再展示一个真实世界的例子。我当前的项目ggufflib涉及编写一个读写GGUF格式文件的库,这是llama.cpp加载量化模型所用的格式。起初,为了理解量化编码是如何工作的(出于速度原因,每个量化的比特以很花哨的方式存储),我尝试使用ChatGPT,但最终还是决定去逆向llama.cpp的代码:那样要快得多。一个能够像样地辅助系统程序员的大语言模型,如果看到数据编码“struct”声明和解码函数,应该能够还原出数据格式的文档。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位/高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能替我做这件事,那将无比有用,而我敢打赌这只是几个月的事,因为这类任务无需任何突破,只需稍加扩展规模就能实现。
客观看待
很遗憾,但这是事实:如今的大多数编程都是以略微不同的形式重复同样的东西。不需要高水平的推理。大语言模型在这方面相当在行,尽管它们仍受限于上下文的最大长度。这确实应该让程序员们深思。写这类程序值得吗?当然,你能拿到报酬,而且还相当丰厚,但如果大语言模型能代劳一部分,也许五到十年后这就不是一个好的立足之地了。
那么,大语言模型究竟有没有一些推理能力,还是一切都是虚张声势?也许有时,它们看似在推理,仅仅是因为——用符号学家的话说——“能指”给人一种实际上并不存在的意义的错觉。那些与大语言模型打交道足够多的人,在承认其局限的同时,也确信事实并非如此:它们融合过往所见内容的能力,远超随机复述词语。尽管它们的训练主要是在预训练阶段通过预测下一个词元来完成的,但这一目标迫使模型创建某种形式的抽象模型。这个模型是脆弱、零碎、不完美的,但如果我们观察到那些现象,它就必然存在。如果我们的数学确定性尚且可疑,而最顶尖的专家又常常各执一词,那么相信自己亲眼所见似乎是明智的做法。
最后,如今不用大语言模型来编程还有什么道理?向大语言模型提出正确的问题是一项基本技能。越少练习,就越难借助AI提升工作。而培养清晰描述问题的能力,在与他人交流时也同样有用。大语言模型不是唯一有时听不懂我们想说什么的对象。表达不清是一个巨大的局限,而许多程序员尽管在自己专业领域非常能干,表达能力却很差。何况如今Google已然无法使用:哪怕只是把大语言模型当作一种压缩版文档来用,也是个好主意。就我而言,我会继续大量使用它们。我从不喜欢去学习某个晦涩通信协议的细节,或是某个想炫耀自己多厉害的人写出的繁琐库的复杂方法。在我看来,那都是“垃圾知识”。大语言模型正日益为我省去这一切。
随机一篇博客
评论
登录后参与讨论