文章

2024 年初 LLM 与编程 [译]

开篇先说,这篇文章并不是对大语言模型(LLM)的回顾。显然,2023 年对人工智能是个特别的年份,再次强调似乎多余。相反,这篇文章旨在分享一个程序员的亲身体验。自从 ChatGPT 问世,再到后来使用本地运行的 LLM,我大量利用了这项新技术。主要目的是提高编码效率,但不止于此。我也希望减少在编程中那些既费时又无趣的部分,比如漫长地搜索文档、学习复杂的 API,以及编写几小时后就弃用的程序。特别是在现在,Google 搜索结果充斥着无用信息,想找到有价值的内容变得越来越难。

同时,我也不是编程领域的新手。不借助任何工具,我也能写出代码,而且经常这么做。随着时间的推移,我越来越依赖 LLM 来写高级别的代码,特别是 Python,而 C 语言用得较少。我对 LLM 的体验特别有感触,我学会了何时该用,何时不用。我也发现,LLM 有点像维基百科和 YouTube 上的视频课程:对有意愿、能力和自律的人有所帮助,但对落后者帮助不大。我担心最初它们可能只对那些已有优势的人有益。

但我们还是一步一步来看。

全能还是只是鹦鹉?

这波机器学习的新进展中最令人担忧的现象之一是,很多 AI 专家难以接受自己的知识局限性。人类发明了神经网络,更关键的是,发明了自动优化神经网络参数的算法。硬件的进步使得我们能训练更大型的模型,利用关于待处理数据的统计知识(即先验知识),通过大量试错不断接近更有效的架构。但总的来说,神经网络仍然比较神秘。

面对这种无法解释 LLM 某些新兴能力的难题,本应该更加谨慎。相反,很多人低估了 LLM,认为它们不过是稍微高级点的马尔可夫链,最多能复制训练集中的内容。然而,随着证据的积累,这种看法几乎被普遍否定了。

同时,很多人过分夸大了 LLM 的能力,认为它们具有实际上不存在的超自然能力。不幸的是,LLM 最多只能对训练过程中接触到的数据进行插值,而这已经很了不起了。实际上,它们的插值能力是有限的(但仍然令人惊叹且出乎意料)。真要是今天的大语言模型能够在看过的所有代码构成的空间内自如插值,它们即便不能创造真正的新颖事物,也足以取代 99% 的程序员了。但现实情况并没有这么乐观。LLM 确实能够编写一些它之前未见过的程序,展现出一定的创新能力,将训练集中不同的想法融合起来。但显然,目前这种能力还有很大的限制,一旦涉及到微妙的推理,LLM 就会失败。尽管如此,它们代表了从 AI 诞生到今天的最伟大成就,这一点似乎无庸置疑。

愚蠢但无所不知

事实上,LLM 最多只能进行基础的推理,经常不准确,甚至夹杂着关于不存在事实的幻觉。但它们拥有丰富的知识。在编程领域,以及其他拥有高质量数据的领域,LLM 就像是知识渊博的愚者。与这样的伙伴一起编程可能会很糟糕:它们可能会提出无意义的想法,让我们不得不不断斗争以推行自己的观点。但如果这个博学的傻瓜能够回答我们提出的所有问题,情况就完全不同了。目前的 LLM 不会引导我们走出已知的知识领域,但如果我们想要处理自己不太了解的主题,它们通常可以帮助我们从完全无知到知道足够的信息,以便自己继续前进。

在编程领域,或许在二十或三十年前,这样的能力并不那么引人注目。那时候,你只需要了解一两种编程语言、经典算法和一些基本的库。剩下的就靠自己的智慧、专业知识和设计能力了。如果你具备这些要素,你就是一个能应对各种情况的专家级程序员。但随着时间的推移,我们见证了框架、编程语言和各种库的爆炸性增长。这种复杂性的增加往往是不必要的,甚至是不合理的,但现实就是如此。在这种背景下,一个知识渊博的「傻瓜」是一个宝贵的盟友。

举个例子,我在机器学习方面的实验至少持续了一年,当时使用的是Keras。后来由于各种原因,我转向了PyTorch。我已经知道什么是嵌入层或残差网络,但我不想一步一步地学习PyTorch的文档(就像我曾经学习Keras一样,那时ChatGPT还不存在)。有了LLM,编写使用 Torch 的 Python 代码变得非常容易。我只需要对我想要构建的模型有清晰的理解,并提出正确的问题。

举例说明

我讲的并不是像「嘿,X类的Y方法是什么?」这种简单的问题。如果仅限于此,人们可能会倾向于认同那些对 LLM 持怀疑态度的人。但更复杂的模型能做的远不止于此。几年前,这些还被视为魔法。例如,我可以告诉 GPT-4:「看,这是我用 PyTorch 实现的神经网络模型。这些是我的数据批次。我想调整张量大小,让生成批次的函数与神经网络的输入兼容,我还想以这种特定方式表示它们。你能给我展示如何修改代码吗?」GPT-4 写出代码后,我只需要在 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 上剪切和粘贴我想要实现但不确定如何操作的功能来编写的,因此它们起初并不完全有效。通过让 LLM 解释问题所在并提出解决方案,我最终完成了编码。虽然 LLM 并没有编写所有代码,但它显著加快了编写过程。如果没有 ChatGPT,我当然也能完成,但最有趣的不是它节省了我的时间:事实是,如果没有 ChatGPT,我甚至不会尝试去做,因为付出的努力与获得的效益不成比例。这一点至关重要。这个项目的次要目的是修改了我的 linenoise 库,使其支持多路复用,这比程序本身更有价值。

再举一个例子,这次与编码关系不大,更多的是关于数据解读。我想要设置一个 Python 脚本,使用我在网上找到的一个卷积神经网络(convolutional neural network),但它缺乏相应的文档。这个网络的优势在于它是 ONNX 格式的,所以我可以很容易地提取出输入和输出的列表及其命名。我唯一知道的关于这个卷积网的信息是:它能在图像中检测特定特征。我不清楚输入图像的格式和大小,特别是网络的输出比我想象的要复杂得多(我原以为它是一个二元分类器:图像是否正常或存在问题?只有两个输出,但实际上有数百个)。我开始将 ONNX 网络的元数据输出复制粘贴到 ChatGPT。我向助手解释了我所知道的有关网络的一切。ChatGPT 推测了输入的组织方式,并认为输出可能是标准化的框,用于指示图像中与潜在缺陷对应的部分,以及其他输出可能表示这些缺陷的可能性。经过几分钟的讨论,我得到了一个能进行网络推断的 Python 脚本,以及必要的代码来将起始图像转换成适合输入的张量等。那次对话中让我印象深刻的是,一旦 ChatGPT 观察到测试图像上的原始输出值(基本上是 logits),一系列浮点数提供了上下文,帮助它精确地识别了输出的具体细节,包括标准化方式、框是居中的还是指定了左上角等。

一次性程序

我可以举出许多类似的例子,但那没有必要,因为故事基本相同。我遇到一个问题,需要快速了解一些我可以验证的内容。在这种情况下,我就会使用 LLM 来加快获取知识的速度。

然而,有些情况下我会让 LLM 写所有的代码。比如,每当我需要写一个或多或少是一次性的程序时。像这样:

https://github.com/antirez/simple-language-model/blob/main/plot.py

我需要可视化一个小型神经网络学习过程中的损失曲线。我向 GPT-4 展示了 PyTorch 程序在学习期间产生的 CSV 文件的格式,然后我要求如果我在命令行上指定了多个 CSV 文件,我不再希望有同一实验的训练和验证损失曲线,而是不同实验的验证损失曲线的比较。以上是 GPT-4 生成的结果。总共用了三十秒。

同样,我需要一个程序,它读取 AirBnB 的 CSV 报告并按月和年对我的公寓进行分组。然后,考虑到清洁费用和每次预订的夜数,它将对不同月份的年份的平均租金价格进行统计。这个程序对我来说非常有用。与此同时,编写它是非常无聊的:没有什么有趣的。所以我拿了一大块 CSV 文件做了剪切和粘贴到 GPT-4。我向 LLM 写了要解决的问题:程序第一次尝试就工作了。我在下面完整展示它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 可以简单地通过重复它所见过的语言来解决的问题。GPT-4 当然在训练中观察到了类似的程序,但几乎可以肯定没有看到一个必须以我要求的确切方式对事物进行分组的程序,并且是在一个具有特定格式的 CSV 文件中。这表明(在我看来)LLM 可以在训练集中不同程序所勾勒出的空间内进行一定程度的插值。

在我看来,编写这种类型的程序是对我的时间的不谨慎使用。LLM 能够为我做这件事肯定加速了我以相当直接的方式产生代码的能力:我可以专注于重要的事情。

事与愿违时的系统编程

尽管我在 LLM 和编程方面取得了成功,但当我写 C 语言程序时,我几乎总是只将 LLM 用作更方便的文档形式。我是系统编程方面的专家,在那个背景下,LLM 对我来说只有在它们能够进行复杂推理的情况下才有用:在这种情况下,它们几乎总是失败的。

让我们从这个测试用的提示词开始:

概述一个不错的、小巧但有效的 C 实现布隆过滤器的要求。专注于哈希函数的重要方面。然后写出来,用好的 C 语言。

还要考虑到你的示例实现应该能够存储 100,000 个元素,最多 5% 的误报概率。添加的元素是以 null 结尾的字符串。

以下是输出:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>

#define N 100000  // 元素数量
#define P 0.05    // 误报概率
#define M (int)(-(N * log(P)) / (log(2) * log(2)))  // 位数组大小
#define K (int)(M / N * log(2))  // 哈希函数数量

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));
}

// 基本哈希函数(演示目的)
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));
    // 如果需要,添加更多哈希函数
}

int is_in(char *str) {
    return getBit(hash1(str)) && getBit(hash2(str));
    // 如果使用了更多哈希函数,请检查
}

int main() {
    char *testStr = "testString";
    add(testStr);

    if (is_in(testStr)) {
        printf("%s 可能在集合中。\n", testStr);
    } else {
        printf("%s 绝对不在集合中。\n", testStr);
    }

    return 0;
}

GPT-4 生成的实现并不完美。布隆过滤器(Bloom filter)是常见的数据结构,不算太特别。然而,编写一个合格的布隆过滤器需要较强的抽象能力,比如找到高效哈希同一个字符串 N 次的方法,并确保哈希结果足够不相关。

但如果明确要求 GPT-4 修改哈希函数以生成 N 个不相关输出,其提出的解决方案显然更为可接受:

1
2
3
4
5
6
7
8
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;
}

如果 GPT-4 自己想到这一点,它就会以不同方式编写布隆过滤器,一次性使用单个哈希函数设置 K 个位。

GPT-4 在单独编写合适和通用哈希函数的任务上表现出色。但在编写布隆过滤器的整体项目中,它未能展现出足够的推理能力,而是输出了两个不同但类似的哈希函数:这是从布隆过滤器的糟糕文章示例中所期望的。

由于大型语言模型当前的推理能力较弱,加之可能缺乏高质量来源,甚至还有许多低质量来源,导致了这种不尽人意的结果。然而,这并非个例。我在尝试在算法或系统编程领域使用 LLM 时,即使预期的推理能力并不高于它们在 Python 编程等领域所展现的,结果通常也很差。

同时,GPT-4 也能反编译它生成的函数(在另一个会话中),并准确理解其用途:

https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1

因此,说在系统编程领域中没有用途显然是不准确的。

有趣的是,在这些情况下,更小和更大模型之间的区别确实很大。让我们看看两个本地模型的表现如何。

例如,Mixtral,Mistral 的 MoE 模型,对同一个问题的回答如下:

1
2
3
4
5
6
7
8
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 的聊天记录,由于我没有足够的 RAM 使用更高精度的模型,所以将其量化为 4 位。然而,作为一个 340 亿参数的大模型,它在这个问题上的推理能力似乎相当先进。以下是用 llama.cpp 进行聊天的日志:

我有一个哈希函数,应该对相同的数据输出 N 个不同的哈希。然而,我看到当改变 hash_id 的值时,产生的哈希的分布非常差:

1
2
3
4
5
6
7
8
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 的例子:

1
2
3
4
5
6
7
8
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; // 使用 XOR 混合 hash_id
     }
    return (hash ^ hash_id); // 在最后再次混合 hash_id
}

这将比简单添加 hash_id 更好地分散 hash_id,这可能导致分布不均匀。[摘要…实际回复包含更多信息]

即使在我的 MacBook M1 Max 上运行,这也不错。它还将总和与 XOR 混合。在这种情况下,模型肯定是因为我提供了有关要解决的问题的线索而受到帮助,但它是该模型识别出了问题的真正来源,提出的替代方案是有效的。像上面这样的东西不在任何书籍、文档或 Google 搜索的范围内。正如原始的、插值的结果,或者无论你想怎么看待它,模型已经进行了某种形式的推理,如果在这种特定情况下,我们接受识别问题的起源和其潜在解决方案的定义。无论人们如何看待 LLM,说它们对程序员没有帮助是极其草率的。

同时,然而,过去几个月的经验告诉我,对于系统编程,如果你已经是一名经验丰富的程序员,LLM 几乎从不提供可接受的解决方案。让我向你展示另一个真实世界的例子。我目前的项目,ggufflib,涉及编写一个读写 GGUF 格式文件的库,这是 llama.cpp 加载量化模型时使用的格式。最初,为了理解量化编码的工作原理(出于速度原因,每个量化的位以奇特的方式存储),我尝试使用 ChatGPT,但后来我决定反向工程 llama.cpp 的代码:这样更快。如果一个 LLM 看到数据编码 “struct” 声明和解码函数,就能够适当地协助系统程序员,它应该能够重建数据格式文档。llama.cpp 的函数足够小,可以完全适应 GPT-4 的上下文,但输出完全没用。在这些情况下,事情就像过去一样:纸和笔,阅读代码,看看解码器提取的位在哪里注册。

让我更好地解释上面的用例,以便您可以尝试一下,如果您愿意。我们有来自 llama.cpp 实现的这个结构。

1
2
3
4
5
6
7
8
9
10
// 6 位量化
// 权重表示为 x = a * q
// 每个 16 个元素的 16 个块
// 实际上每个权重 6.5625 位
typedef struct {
    uint8_t ql[QK_K/2];      // 量子化,低 4 位
    uint8_t qh[QK_K/4];      // 量子化,高 2 位
    int8_t  scales[QK_K/16]; // 比例,用 8 位量化
    ggml_fp16_t d;           // 超级块比例
} block_q6_K;

然后有这个函数用于进行反量化:

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
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;
        }
    }
}

如果我要求 GPT-4 写出所使用格式的大纲,它会努力提供清晰解释如何根据权重位置在 “ql” 的低/高 4 位中存储块。为了这篇博客文章,我还尝试让它写一个展示如何存储数据的更简单函数(也许它不能用文字解释,但可以用代码)。产生的函数在许多方面都是错误的,索引是错误的,6 位到 8 位的符号扩展是错误的(它只是将其转换为 uint8_t),等等。

顺便说一下,这是我最终自己写的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    } else if (tensor->type == GGUF_TYPE_Q6_K) {
        uint8_t *block = (uint8_t*)tensor->weights_data;
        uint64_t i = 0; // 第 i 个权重进行反量化。
        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; // 转到下一个块。
        }
   }

从上面的函数中,我去掉了这段代码的实际贡献:长长的注释,记录了 llama.cpp Q6_K 编码使用的确切格式。现在,如果 GPT 能为我做这件事,那将是极为有用的,我敢打赌这只是几个月的问题,因为这类任务在没有任何突破的情况下就可以达到,只需要一点扩展。

正确看待事物

我遗憾地说,这是真的:当今的大多数编程都是在以稍微不同的形式重复相同的事情。不需要高水平的推理。LLM 在这方面做得相当好,尽管它们仍然受到其上下文最大大小的强烈限制。这确实应该让程序员思考。值得编写这种程序吗?当然,你会得到报酬,而且相当可观,但如果 LLM 可以做其中的一部分,也许这不是五到十年后最好的位置。

那么,LLM 有一些推理能力,还是全是虚张声势?也许有时,它们似乎只是因为,正如符号学家所说,「表征者」给人一种实际不存在的意义的印象。那些与 LLM 工作足够时间的人,在接受它们的限制的同时,肯定知道情况不可能是这样:它们将所见过的内容混合的能力远远超出了随机重复单词。尽管它们的训练主要在预训练期间进行,以预测下一个 token,这个目标迫使模型创建了某种形式的抽象模型。这个模型是脆弱的、零散的、不完美的,但如果我们观察到我们所观察到的,它必须存在。如果我们的数学确定性是可疑的,最伟大的专家们经常处于对立的立场,相信自己眼睛所见似乎是明智的做法。

最后,今天不使用 LLM 进行编程有什么意义呢?向 LLM 提出正确问题是一项基本技能。越少练习它,就越少能够借助 AI 改善自己的工作。而且,发展问题描述能力也在与其他人交流时很有用。LLM 并不是唯一有时不理解我们想说什么的人。沟通不畅是一个很大的限制,许多程序员尽管在他们的特定领域非常有能力,但沟通非常差。现在 Google 已经无法使用:即使只是作为一种压缩形式的文档,使用 LLM 也是一个好主意。就我而言,我会继续广泛使用它们。我从来不喜欢学习一个晦涩的通信协议的细节,或者一个由某人编写的想要展示自己有多好的复杂库的方法。这对我来说似乎是「垃圾知识」。LLM 每天都在越来越多地让我免于这些事情。



原文作者:Antirez
原文链接:http://antirez.com/news/140

本文由作者按照 CC BY 4.0 进行授权