本文翻译自 Your LLM Doesn’t Write Correct Code,原载于 Hacker News。
一个令人震惊的基准测试
对数据库最简单的测试之一:对 100 行数据进行主键查询。
- SQLite: 0.09 ms
- LLM 生成的 Rust 重写版: 1,815.43 ms
这不是一个小数点的错误——重写版在数据库最基础的操作上慢了 20,171 倍。
问题在于:代码能编译。测试全部通过。它能正确读写 SQLite 文件格式。README 声称支持 MVCC 并发写入、文件兼容性和可直接替换的 C API。乍一看,它像一个正常工作的数据库引擎。
但它不是!
LLM 优化的是「看起来合理」而非「正确」。在这个案例中,「看起来合理」比「正确」慢了大约 20,000 倍。
作者的背景
作者不是在唱衰 AI,而是一位有 10 年以上专业开发经验的实践者。过去 6 个月,他在多个项目中将 LLM 集成到日常工作流程中。他认为 LLM 让任何有好奇心和创造力的人都能快速实现自己的想法,这很棒!
但他磁盘上积累的大量截图——静默错误的输出、看似正确但经不起推敲的逻辑、看起来没问题但实际有问题的代码——表明情况并非总是那么美好。
他的结论是:LLM 在用户于生成第一行代码之前就定义好验收标准时效果最佳。
基准测试细节
作者使用相同的 C 基准测试程序编译了两个库:系统 SQLite 和 Rust 重写版的 C API 库。相同的编译器标志、相同的 WAL 模式、相同的表结构、相同的查询。100 行数据:
TRANSACTION batch (100 inserts, 1 fsync): 32.81 ms vs 0.11 ms (298x)
INSERT no transaction (100 inserts, 100 fsyncs): 2,562.99 ms vs 1.38 ms (1,857x)
SELECT BY ID (100 pk lookups): 1,815.43 ms vs 0.09 ms (20,171x)
UPDATE (100 updates): 285.22 ms vs 0.10 ms (2,852x)
DELETE (100 deletes): 278.99 ms vs 0.09 ms (3,100x)
以 TRANSACTION batch 为基准,因为它没有其他测试中的明显 bug(没有 WHERE 子句和每语句同步)。即使这个「最佳情况」也已经落后 298 倍。任何超过 298x 的结果都意味着存在 bug。
两个致命 Bug
Bug 1: 主键查询变成全表扫描
在 SQLite 中,当你声明一个表:
CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT, value REAL);
id 列会成为内部 rowid 的别名——B-tree 的键本身。类似 WHERE id = 5 的查询会解析为直接的 B-tree 搜索,复杂度是 O(log n)。
SQLite 的查询规划器文档说:「查找所需行的时间与 logN 成正比,而不是像全表扫描那样与 N 成正比。」
这不仅仅是优化,而是 SQLite 查询优化器的基础设计决策:
// where.c, in whereScanInit()
if( iColumn==pIdx->pTable->iPKey ){
iColumn = XN_ROWID;
}
上面这行代码在命名列引用匹配表的 INTEGER PRIMARY KEY 列时,将其转换为 XN_ROWID。VDBE 然后触发 SeekRowid 操作而不是全表扫描,使整个操作与 logN 成正比。
Rust 重写版有一个正确的 B-tree。 table_seek 函数实现了正确的二分查找,复杂度是 O(log n)。它确实能用。
但查询规划器从未对命名列调用它!
is_rowid_ref() 函数只识别三个魔法字符串:
fn is_rowid_ref(col_ref: &ColumnRef) -> bool {
let name = col_ref.column.to_ascii_lowercase();
name == "rowid" || name == "_rowid_" || name == "oid"
}
一个声明为 id INTEGER PRIMARY KEY 的列,即使内部标记为 is_ipk: true,也不会被识别。在选择 B-tree 搜索还是全表扫描时,它从未被考虑。
每个 WHERE id = N 查询都流经 codegen_select_full_scan(),它通过 Rewind / Next / Ne 发出线性遍历,逐行比较每个 rowid 与目标值。100 行数据 100 次查询 = 10,000 次行比较,而不是大约 700 次 B-tree 步骤。
O(n²) 而不是 O(n log n)。
Bug 2: 每次 INSERT 都触发 fsync
第二个 bug 导致 INSERT 慢 1,857 倍。每个不在事务中的裸 INSERT 都被包装在完整的自动提交周期中:ensure_autocommit_txn() → 执行 → resolve_autocommit_txn()。提交调用 wal.sync(),它调用 Rust 的 fsync(2) 包装器。100 个 INSERT 意味着 100 次 fsync。
SQLite 也做同样的自动提交,但在 Linux 上使用 fdatasync(2),当使用 HAVE_FDATASYNC 编译时(默认值),它会跳过同步文件元数据。这在 NVMe SSD 上大约节省 1.6 到 2.7 倍。SQLite 的每语句开销也很小:没有 schema 重载、没有 AST 克隆、没有 VDBE 重编译。Rust 重写版每次调用都做这三件事。
复合问题
这两个 bug 不是孤立的。它们被一组看似合理的「安全」选择放大:
- 每次缓存命中都克隆 AST:SQL 解析被缓存,但 AST 在每次
sqlite3_exec()时都被.clone(),然后从头重新编译为 VDBE 字节码。SQLite 的sqlite3_prepare_v2()只是返回一个可重用的句柄。 - 每次读取都进行 4KB 堆分配:页面缓存通过
.to_vec()返回数据,即使在缓存命中时也会创建新分配并复制到 Vec。SQLite 返回直接指向固定缓存内存的指针,零复制。 - 每个自动提交周期都重载 Schema:每条语句提交后,下一条语句看到提交计数器增加,调用
reload_memdb_from_pager(),遍历sqlite_masterB-tree,然后重新解析每个 CREATE TABLE 来重建整个内存 schema。SQLite 检查 schema cookie 并只在变化时重载。 - 热路径中的急切格式化:
statement_sql.to_string()在每次调用时都在守卫检查之前被求值,意味着无论是否有订阅者活跃,它都进行序列化。 - 每条语句都创建新对象:每个语句都会分配和销毁新的
SimpleTransaction、VdbeProgram、MemDatabase和VdbeEngine。SQLite 通过 lookaside 分配器在连接生命周期内重用所有这些,以消除执行循环中的malloc/free。
每个决定单独看都有合理的通用推理:「我们克隆是因为 Rust 所有权使共享引用复杂。」「我们使用 sync_all 因为它是安全的默认值。」「我们每页分配是因为从缓存返回引用需要 unsafe。」
每个决定听起来都像是在选择安全。但最终结果在这个基准测试中慢了大约 2,900 倍。
数据库的热路径可能是唯一不应该选择安全而非性能的地方。
另一个案例:磁盘清理守护进程
作者还检查了同一个开发者的另一个项目,展示了不同领域的相同动态。
问题:LLM 代理持续编译 Rust 项目,填满磁盘。Rust 的 target/ 目录每个消耗 2-4 GB。
解决方案:一个清理守护进程。82,000 行 Rust、192 个依赖、36,000 行的终端仪表板,包含七个屏幕和模糊搜索命令面板、贝叶斯评分引擎、EWMA 预测器、资源下载管道……
实际需要的解决方案:
*/5 * * * * find ~/*/target -type d -name "incremental" -mtime +7 -exec rm -rf {} +
一行 cron 任务,零依赖。项目 README 从未提到 Rust 的标准工具 cargo-sweep。
模式与 SQLite 重写相同:代码匹配「意图」——「构建一个复杂的磁盘管理系统」产生了复杂的磁盘管理系统——但不匹配「需求」。
这就是失败模式。 不是语法错误或缺少分号。代码在语法和语义上都是正确的。它做到了被要求的事情。它只是没有做到情况「需要」的事情。
研究证据
两个案例研究不是证明。但最近的研究支持这些发现:
METR 的随机对照试验(2025 年 7 月)
16 位经验丰富的开源开发者参与测试,使用 AI 的参与者慢了 19%,而不是更快。开发者期望 AI 加速他们,在测量的减速已经发生后,他们仍然相信 AI 加速了他们 20%。这些不是初级开发者,而是经验丰富的开源维护者。如果连他们都无法在这个设置中分辨,主观印象本身可能不是一个可靠的性能衡量标准。
GitClear 的分析
分析了 2.11 亿行代码变更(2020-2024),报告显示复制粘贴的代码增加,而重构减少。有史以来第一次,复制粘贴的行数超过了重构的行数。
真实世界的灾难
2025 年 7 月,Replit 的 AI 代理删除了一个包含 1,200 多位高管数据的生产数据库,然后伪造了 4,000 个虚构用户来掩盖删除。
Google 的 DORA 2024 报告显示,团队层面 AI 采用率每增加 25%,交付稳定性估计下降 7.2%。
AI 顺从性(Sycophancy)
这个意图和正确性之间的差距有一个名字:AI 顺从性(Sycophancy),描述了 LLM 倾向于产生符合用户想听的内容,而不是他们需要听的内容。
Anthropic 的论文「Towards Understanding Sycophancy in Language Models」(ICLR 2024)表明,五个最先进的 AI 助手在多种任务中表现出顺从行为。当回答匹配用户期望时,更有可能被人类评估者偏好。在这个反馈上训练的模型学会了奖励一致性而非正确性。
在编码语境中,顺从性表现为:代理不会反驳「你确定吗?」或「你考虑过……吗?」,而是对用户描述的任何内容提供热情,即使描述不完整或矛盾。
这也适用于 LLM 生成的评估。让同一个 LLM 审查它生成的代码,它会告诉你架构合理、模块边界清晰、错误处理彻底。它甚至可能赞扬测试覆盖率。它不会注意到每个查询都在做全表扫描——除非你要求它检查这一点。
SQLite 为什么正确
SQLite 约 156,000 行 C 代码。它被认为是全球部署最多的五大软件模块之一,估计有一万亿个活跃数据库。
它有 100% 分支覆盖率和 100% MC/DC(修改条件/决策覆盖率,DO-178C 下 A 级航空软件所需的标准)。它的测试套件比库本身大 590 倍。
MC/DC 不仅仅检查每个分支是否被覆盖,而是证明每个单独的表达式独立影响结果。这就是「测试通过」和「测试证明正确性」之间的区别。
速度来自刻意的设计决策:
- 零复制页面缓存:
pcache返回直接指向固定内存的指针,零复制 - 预编译语句重用:
sqlite3_prepare_v2()编译一次,sqlite3_step()/sqlite3_reset()重用编译后的代码 - Schema cookie 检查:使用文件头特定偏移处的一个整数读取并比较,重写版每次自动提交后遍历整个
sqlite_masterB-tree 并重新解析每个 CREATE TABLE 语句 - fdatasync 而不是 fsync:仅数据同步,不包含元数据日志,每次提交节省可测量的时间
- iPKey 检查:
where.c中的一行,重写版在ColumnInfo结构中正确设置了is_ipk: true,但在查询规划期间从未检查它
能力不是写 576,000 行代码
数据库的职责是持久化(和处理)数据。这就是它所做的一切。它必须在大规模下可靠地做到这一点。
O(log n) 和 O(n) 在最常见访问模式上的差异不是优化细节,而是帮助系统在 10,000、100,000 甚至 1,000,000 或更多行下正常工作而不是崩溃的性能不变量。
知道这个不变量存在于一行代码中,并且知道是哪一行——这就是能力的含义。是知道 fdatasync 存在,知道安全的默认值不总是正确的默认值。
is_rowid_ref() 函数是 4 行 Rust。它检查三个字符串。但它错过了最重要的情况:每个 SQLite 教程都使用、每个应用程序都依赖的命名 INTEGER PRIMARY KEY 列。
那个检查存在于 SQLite 中是因为有人(可能是 20 年前的 Richard Hipp)分析了真实工作负载,注意到命名主键列没有命中 B-tree 搜索路径,在 where.c 中写了一行来修复它。这一行并不花哨。它不会出现在任何 API 文档中。但没有任何 LLM 仅通过训练文档和 Stack Overflow 答案就能神奇地知道它。
这就是差距! 不是 C 和 Rust(或其他语言)之间。不是旧和新之间。而是被人构建的系统(经过测量)和被工具构建的系统(模式匹配)之间。
如何正确使用 LLM 编程
如果你使用 LLM 写代码(2026 年大多数人可能都在用),问题不是输出能否编译。而是你能否自己找到 bug。
用「找出所有 bug 并修复它们」来提示不会奏效。这不是语法错误。这是语义 bug:错误的算法和错误的系统调用。如果你提示生成代码却无法解释为什么它选择全表扫描而不是 B-tree 搜索,你就没有拥有这个工具。
代码不是你的——直到你理解它到足以打破它。
LLM 是有用的。当使用者知道正确是什么样子时,它们能提供非常有生产力的工作流。一个有经验的数据库工程师使用 LLM 搭建 B-tree 会在代码审查中发现 is_ipk bug,因为他们知道查询计划应该发出什么。一个有经验的运维工程师永远不会接受 82,000 行代码而不是一行 cron 任务。
当开发者可以将验收标准定义为具体的、可衡量的条件,帮助区分工作和损坏时,工具处于最佳状态。在这种情况下使用 LLM 生成解决方案可以更快,同时也正确。
没有这些标准,你不是在编程——你只是在生成 token 并祈祷。
关键要点
-
LLM 优化的是「看起来合理」而非「正确」:代码能编译、测试通过、架构看起来正确,但可能存在严重的性能或正确性问题
-
语义 bug 比语法错误更危险:错误的算法选择不会触发编译错误,但会导致系统在规模上失败
-
必须有可衡量的验收标准:在让 LLM 生成代码之前,明确知道「正确」是什么样子
-
你需要理解代码到足以打破它:如果你无法解释 LLM 生成的代码为什么这样工作,你就没有真正拥有它
-
测量是关键:SQLite 的正确性来自 26 年的测量和优化,不是来自更多的代码
vibes 不够。定义正确意味着什么。然后测量。
祝编程安全!