2024年伊始的LLM与编程
首先我要说明,这篇文章并不是对LLM(大语言模型)的回顾总结。显然,2023年是人工智能的特殊一年:再重复这一点似乎毫无意义。相反,这篇文章旨在作为一个个人程序员的见证。自ChatGPT问世以来,以及后来使用可在本地运行的LLM之后,我对这项新技术进行了大量使用。目标是加速我编写代码的能力,但这并不是唯一的目的。还有一层意图是:不在那些不值得投入精力的编程方面浪费脑力。无数个小时花在搜索那些奇特而智力上乏味的方面的文档上;学习一个过于复杂的API的努力,往往毫无正当理由;编写那些立即可用、几小时后就会被丢弃的程序。这些都是我不想做的事情,尤其是在如今,Google已经变成了一片垃圾信息的海洋,要在其中寻找少数有用的东西。
与此同时,我在编程方面当然不是新手。我完全可以在没有任何辅助的情况下编写代码,事实上我也经常这样做。随着时间推移,我越来越多地使用LLM来编写高层代码,尤其是Python,而在C语言中则少得多。就我个人使用LLM的经验而言,最让我印象深刻的是:我已经学会了究竟什么时候该用它们,什么时候用它们只会拖慢我的速度。我还学到,LLM有点像Wikipedia和散落在YouTube上的各种视频课程:它们能帮助那些有意愿、有能力、有自律性的人,但对那些已经落后的人来说收益甚微。我担心至少在初期,它们只会让已经占据优势的人受益。
不过,让我们一步一步来。
全知还是鹦鹉?
这波机器学习的新颖与进步浪潮中最令人担忧的现象之一,就是AI专家接受自身知识有限的能力十分有限。Homo sapiens(智人)发明了神经网络,随后更关键的是,发明了一种自动优化神经网络参数的算法。硬件已经能够训练越来越大的模型,利用关于待处理数据的统计知识(先验),并通过大量的试错和逐步逼近,人们发现了比其他架构效果更好的架构。但总的来说,神经网络仍然相当不透明。
面对这种无法解释LLM某些涌现能力的情况,人们本期望科学家们更加谨慎。然而,许多人严重低估了LLM,说它们毕竟不过是稍微高级一点的马尔可夫链,充其量只能复述训练集中所见内容的极其有限的变体。后来,面对证据,这种“鹦鹉”论几乎被普遍撤回了。
与此同时,许多热情的大众却把现实中并不存在的超自然能力归给了LLM。遗憾的是,LLM充其量只能在它们在训练期间见过的数据所构成的空间中进行插值:这本身已经很了不起了。实际上,它们的插值能力是有限的(但仍然令人惊叹,而且出乎意料)。唉,要是今天最大的LLM能够在它们见过的所有代码所界定的空间中连续插值就好了!即使它们无法产生真正的新事物,它们也能取代99%的程序员。现实要谦逊得多,正如它几乎总是如此。LLM当然能够编写出它没有以那种确切形式见过的程序,显示出将训练集中以一定频率出现的不同想法融合起来的某种能力。同样明显的是,这种能力目前有着深刻的局限,每当需要细致的推理时,LLM就会灾难性地失败。然而,它们代表了人工智能从诞生至今最伟大的成就。这似乎无可否认。
愚蠢但无所不知
确实如此:LLM充其量只能进行粗浅的推理,往往不准确,很多时候还夹杂着关于不存在事实的幻觉。但它们拥有广博的知识。在编程领域,以及其他有高质量数据可用的领域,LLM就像知道很多东西的愚笨学者。和这样的搭档结对编程将是可怕的(对我来说,结对编程在最普遍的意义上就很可怕):他们会有荒谬的想法,我们将不得不不断斗争来坚持自己的想法。但如果这位博学的傻瓜随时听候差遣并回答你提出的所有问题,情况就不同了。当前的LLM不会带我们超越已知的路径,但如果我们想涉足一个自己不太了解的主题,它们常常能把我们从绝对的无知提升到足以自行前进的程度。
在编程领域,它们的这种能力放在二三十年前也许兴趣寥寥。那时候你必须掌握几种编程语言、经典算法以及那十个基础库。其余的要靠你自己补充:你自己的智力、专业知识和设计能力。如果你具备这些要素,你就是一名专家程序员,几乎什么都能做。随着时间的推移,我们目睹了框架、编程语言、各类库的爆炸式增长。一场往往完全不必要也毫无道理的复杂性爆炸,但事实就是如此。在这样的背景下,一个什么都懂的傻瓜是宝贵的盟友。
举个例子:我的机器学习实验至少用Keras推进了一年。后来出于种种原因,我转向了PyTorch。我已经知道什么是embedding或残差网络,但我不想一步步去啃PyTorch的文档(就像我当年学Keras那样,那时ChatGPT还不存在)。有了LLM,用Torch编写Python代码变得非常容易。我只需要对自己想搭建的模型有清晰的想法,并提出正确的问题。
该看例子了
我说的可不是“嘿,X类里做Y的方法是什么”这类简单问题。如果仅仅如此,人们可能会倾向于同意那些对LLM持怀疑态度的人。更复杂的模型所能做到的事情要精细得多。就在几年前,那还会是纯粹的魔法。我可以对GPT4说:看,这是我用PyTorch实现的神经网络模型。这些是我的batch。我想调整张量的尺寸,使生成batch的函数与神经网络的输入兼容,并且我想以这种特定的方式表示数据。你能给我看看所需的reshape代码吗?GPT4写出了代码,而我所要做的只是在Python CLI中测试一下张量是否真的具有对我有用的维度,数据布局是否正确。
再举一个例子。不久前,我需要为某些基于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上来完成的,因为它们无法正常工作。然后让LLM向我解释问题所在以及如何解决。诚然,那段代码大部分并不是LLM写的,但同样真实的是,它显著加快了写作速度。没有ChatGPT我能做到吗?当然可以,但最有意思的不是会花更长时间这个事实:真相是我根本不会去尝试,因为不值得。这一点至关重要。编写这样一个相对于我的项目属于次要的程序,其付出与收益之比是不划算的。此外,这件事还产生了一个比程序本身有用得多的附带效应:为了那个项目,我修改了linenoise(我的一个行编辑库),使其支持多路复用。
另一个例子,这次更多是关于数据解读而非代码编写。我想用一个在网上找到的卷积神经网络搭建一个Python脚本,但它的文档相当匮乏。这个网络的优势在于它是ONNX格式的,所以我可以轻松提取输入输出列表及其名称。关于这个convnet我只知道一件事:它能检测图像中的某些特征。我不知道输入图像的格式和尺寸,尤其是,网络的输出远比我想象的复杂(我以为它是一个二元分类器:观察到的图像没问题还是有问题?两个输出,但实际上有几百个)。我先把ONNX网络元数据的输出复制粘贴给ChatGPT。我向助手解释了我对这个网络仅有的了解。ChatGPT推测输入是如何组织的,输出很可能是归一化的框,指示图像中对应潜在缺陷的部分,以及其他指示这些缺陷可能性的输出。来回几分钟之后,我就有了一个能进行网络推理的Python脚本,外加把原始图像转换成适合输入的张量的必要代码等等。那次会话让我印象深刻的是,ChatGPT在观察到一张测试图像上的原始输出值(基本上就是logits)后,终于“理解”了这个网络的工作方式:一系列浮点数提供了上下文,让它确定了输出的确切细节、归一化方式、框是以中心还是左上角指定的,等等。
一次性程序
像上面叙述的这类案例,我可以记录几十个。但没有意义,因为都是同一个故事以大致相同的方式重复上演。我遇到一个问题,需要快速了解某样东西,而且*我可以验证*LLM是否在给我胡编乱造。好吧,在这种情况下,我就用LLM来加速获取所需的知识。
不过,还有一些不同的情况,我会让LLM写出全部代码。比如,每当我需要编写一个或多或少一次性的程序时。就像这个:
https://github.com/antirez/simple-language-model/blob/main/plot.py
我需要在一个小型神经网络的学习过程中可视化loss曲线。我把PyTorch程序在学习期间生成的CSV文件的格式展示给GPT4,然后要求:如果我在命令行指定多个CSV文件,我不想再看同一实验的训练和验证loss曲线,而是想看不同实验的验证loss曲线的对比。上面就是GPT4生成的结果。总共三十秒。
类似地,我需要一个程序来读取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文件的程序。在我看来,这说明LLM能在一定程度上在训练集中不同程序所勾勒的空间内进行插值。
在我看来,编写这类程序是对我时间的不明智使用。LLM能替我做这件事,无疑以一种相当直接的方式加快了我产出代码的能力:我可以专注于重要的事情。
当事情不如预期时:系统编程
尽管我在LLM与编程方面颇为成功,但当我用C语言编写程序时,我几乎总是只把LLM当作一种更方便的文档形式。我是系统编程方面的专家,在这种场景下,只有当LLM具备复杂推理能力时才会对我有用:而这种情况下,它们几乎总是失败。
先看这个实验的prompt:
Outline the requirements of a nice, small but working C implementation of a bloom filter. Focus on the important aspects of the hashing function. Then write it, in good quality C.
另外还要考虑到,你的示例实现应该设计为存储100,000个元素,误报概率至多5%。添加的元素是以null结尾的字符串。
以下是输出:
#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产出的实现并不算出色。Bloom filter(布隆过滤器)相当常见,并不是什么奇异的数据结构。然而很明显,写出一个像样的bloom filter需要更强的抽象能力:例如,存在这样一个问题——找到一种有效的方法对同一个字符串哈希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;
}如果它当初自己想到这个主意,就会以不同的方式编写bloom filter,用一个哈希函数一次设置K个位。
GPT4能够孤立地完成编写一个合适且更通用的哈希函数的任务。但在编写bloom filter这个更大的项目中,它未能展现出足够的推理能力,而是吐出了两个不同但相似的哈希函数:这正是人们在一篇写得糟糕的bloom filter文章示例中所期待的东西。
当前大语言模型薄弱的推理能力,加上该主题可能的资料匮乏,甚至更糟的是可能存在大量低质量资料,共同导致了这一贫瘠的结果。然而,这并非孤例。我尝试在算法或系统编程领域使用LLM的寥寥几次中,结果都极差,即使所需的推理能力并不高于LLM在Python编程等场景中似乎具备的水平。
与此同时,GPT-4能够反编译它自己生成的函数(在单独的会话中),并准确理解其用途:
https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1
因此,断言LLM在系统编程场景中没有用处肯定是不准确的。
一件有趣且基本在意料之中的事情是,在这些情况下,较小模型与较大模型之间的差异确实显著。让我们看看两个本地模型的表现如何。
例如,Mixtral——Mistral的MoE模型——对同一个问题的回答如下:
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比特。尽管如此,凭借340亿参数的规模,它在这些问题上的推理能力看起来相当先进。以下是对话记录(通过llama.cpp进行):
I have a hash function that should output N distinct hashes for the same data. However, I'm seeing a very poor distribution of the produced hashes as I change the value of 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; }Maybe the salting strategy I'm using is poor? How could I change it?
[Deep-seek coder reply]
A possible issue with your implementation is that you're simply adding `hash_id` to the end of the hash. This can potentially lead to a poor distribution, especially if `hash_id` is too large or is not evenly distributed.
Consider using bitwise operations for mixing the `hash_id` in. Here is an example with 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
}This will spread `hash_id` throughout your hashes better than simply adding it, which can lead to less uniform distribution. [Snipped… the actual reply contained more informative stuff]
对于一个跑在我的MacBook M1 Max上的模型来说,这已经不错了。它还把加法与异或混合了起来。在这个案例中,模型当然得益于我提供了关于待解决问题的线索,但识别出问题的真正根源的是模型本身,而且它提出的替代方案是有效的。类似上面的东西,任何书籍、文档或Google搜索都无法企及。无论多么原始,无论被视为插值的结果还是随你怎么看待,模型都执行了某种形式的推理——如果在这特定情况下,我们把“识别问题的根源及其潜在解决方案”也算作推理的话。无论你想如何看待LLM,断言它们对程序员没有帮助是极其草率的。
但同时,我过去几个月的经验表明,对于系统编程,如果你已经是一名经验丰富的程序员,LLM几乎从不提供可接受的解决方案。让我再给你看一个现实世界的例子。我目前的项目ggufflib涉及编写一个读写GGUF格式文件的库,llama.cpp正是以这种格式加载量化模型的。起初,为了理解量化编码是如何工作的(出于速度原因,每个quant的比特以花哨的方式存储),我尝试使用ChatGPT,但后来我决定逆向工程llama.cpp的代码:这样快得多。一个能够体面地辅助系统程序员的LLM,如果看到数据编码的“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能替我做这件事,那将无比有用,而且我敢打赌这只是几个月内的事,因为这类任务不需要任何突破就能达到,只需一点扩展即可。
理性看待
我很遗憾这么说,但这是真的:当今的大部分编程工作就是把同样的东西以略微不同的形式复述出来。并不需要高水平的推理。LLM在这方面相当擅长,尽管它们仍然受到上下文最大尺寸的强烈限制。这确实值得程序员深思。值得编写这类程序吗?当然,你能拿到报酬,而且相当丰厚,但如果LLM能完成其中一部分,那么五到十年后,这也许就不是最好的立足之地了。
那么,LLM到底有没有某种推理能力,还是全然是一场虚张声势?也许有时它们看似在推理,只是因为,正如符号学家所说,“能指”给人一种实际并不存在的意义的印象。那些与LLM打过足够交道的人,在接受其局限的同时,可以确定事情绝非如此:它们融合先前所见内容的能力远远超出了随机复述词语。尽管它们的训练主要是在预训练阶段通过预测下一个token完成的,但这个目标迫使模型创建某种形式的抽象模型。这个模型是微弱的、残缺的、不完美的,但只要观察到我们所观察到的现象,它就必须存在。既然我们的数学确定性尚存疑虑,最顶尖的专家们也常常立场对立,相信自己亲眼所见似乎是明智的做法。
最后,在今天不用LLM编程还有什么意义呢?向LLM提出正确的问题是一项基本功。练习得越少,就越难借助AI改进自己的工作。而且,培养描述问题的能力在与其他人交谈时也有用。并不是只有LLM有时不理解我们想表达什么。沟通不畅是一大局限,许多程序员尽管在自己的专业领域非常能干,沟通起来却非常糟糕。而现在Google已经没法用了:即便只是把LLM当作一种压缩形式的文档来用,也是个好主意。就我而言,我会继续大量使用它们。我从来就不喜欢去学习某个晦涩通信协议的细节,或者某个由急于炫耀自己有多厉害的人写的库的绕弯方法。在我看来,那就是“垃圾知识”。LLM每天都在越来越多地把我从这一切中解救出来。
随机一篇博客