上周,我的AI写了一段代码,每次都从磁盘读取一个JSON文件,对其解析后再进行一次查找,然后总共重复了900次。这意味着每次迭代里都会经历:打开文件,解码JSON,找到某个值,再全部丢弃从头开始。
这个错误是我在教学生编程的第一个月就会告诉他们千万不要犯的。
发生了什么事(长话短说)
我在开发Tokamak,一个用于macOS的菜单栏应用,用来监控Claude Max的配额使用情况。它的一部分功能需要扫描约900个Claude Code会话数据生成的JSONL文件(JSON Lines)。对于每个文件,它需要知道在上一次的扫描中读取的字节偏移量(增量读取——只读取新数据)。
偏移量都保存在一个JSON文件中:
{
"version": 1,
"offsets": {
"proyecto-a/sesion-1.jsonl": 48231,
"proyecto-b/sesion-2.jsonl": 12044
}
}
---
一个`Dictionary<String, UInt64>`包含了900条记录,约55KB,但并不算大。
但问题在于,更让人抓狂的是:**这个文件是应用自己生成的**。它不是来自于外部的API,也不是Claude Code交付的文件。这是Tokamak自己生成和读取的内部状态文件,用于记录每次扫描的会话进度。
“那你为什么不用Core Data或者SQLite呢?你应用里不是已经有这些东西了吗?”这是个好问题。原因是这个文件是一个**一次性缓存文件**。就算它被损坏了,只需要删除文件,下次扫描时重新读取整个文件即可,数据根本不会有任何丢失。而且我可以简单地用`cat session-offsets.json | jq .`来进行调试(用Core Data的话就需要用`sqlite3`并进入沙盒路径),这也是一个`Sendable`数据结构,不需要来回折腾的后台事务。并且,如果Core Data的SQLite文件损坏了,也不会影响这个偏移量的存储(反过来也是如此)。对于一个只有55KB大小的扁平字典,去创建一个支持schema迁移的实体未免有些大材小用。
所以问题并不是格式,而是**访问方式**。
以下是AI生成的扫描循环代码:
```swift
for file in files { // 900个文件
let storedOffset = offsetStore.offset(for: file.relativePath)
// ↑ 每次读取和解析磁盘上的JSON文件!!!
if file.fileSize == storedOffset { continue }
// ... 读取文件,更新偏移量 ...
offsetStore.setOffset(newOffset, for: file.relativePath)
// ↑ 再读一次,同一个JSON文件,然后修改并保存。
}
每次迭代读两次磁盘,共900次迭代。这意味着总共有1,800次I/O操作,原本应该只需要2次:一次读取和一次写入。
数据(xctrace不会说谎)
我用Instruments(性能监控工具)定位了这个问题。以下是数据:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 总样本数 | 7,260 | 489 |
OffsetStore.load() 样本数 | 1,704(88%) | 10(2%) |
| 检查时间 | >20秒 | <0.5秒 |
| CPU占用率 | 81% | ~1.5% |
扫描时间的88%都浪费在反复读取和解析这900行JSON上了。就像西西弗斯推石头,只不过这次他用的是JSONDecoder。
修复方法(令人羞愧)
// 修复前:每次迭代都进行I/O
for file in files {
let offset = offsetStore.offset(for: file.relativePath) // 读取JSON
// ...
offsetStore.setOffset(newOffset, for: file.relativePath) // 再次读取+保存JSON
}
// 修复后:一次读取,内存操作,一次保存
var offsets = offsetStore.load() // 仅加载一次
for file in files {
let offset = offsets.offsets[file.relativePath] ?? 0 // O(1)内存操作
// ...
offsets.offsets[file.relativePath] = newOffset
}
offsetStore.save(offsets) // 仅保存一次
请注意:数据结构本身并没有变化。它仍然是一个Dictionary<String, UInt64>。哈希表的性能已经非常高效。本质问题是在每次迭代中重新从磁盘构建它。
无效的解决方案:在CLAUDE.md中添加“不要这样做”
修复之后,我在项目的CLAUDE.md中特别加了一句说明:
“绝对不要在循环内进行I/O操作(磁盘、网络、JSON解码、Core Data查询等)——如果你可以在循环前进行数据加载,再单独保存,记住要这么做。”
但这完全没用。
几周之后,当我向项目添加第二个服务(Codex)时,AI又生成了完全相同的模式。即使有这一段说明。这就像竖了个“请勿践踏草地”的标志,却指望别人都遵守一样。
为什么会这样?因为LLM并不真正理解这条规则。它只是见过类似的代码。从统计学角度来看,在训练数据中,它见过的大部分代码都是进行单次I/O,而不是在900次迭代中不断重复I/O。模式load → use → save是它认为最可能的方法。而这个函数是否会出现在一个包含900次迭代的for循环中,对模型来说是无关的上下文。
无效的解决方案:静态代码检查工具
不存在可以检测出这个问题的静态代码检查工具。SwiftLint、ESLint、Ruff或者Clippy,全都不可能。想一想:这段代码在语法上是完全正确的,也在语义上是有效的。对offsetStore.offset(for:)的每次调用都合理合法,问题本身不在任何一行代码,而在组合的方式上。
如果用我的课程《对抗性开发》中的一个概念来说,这个问题出现在代码的不同意义层级:
| 层级 | 问题 | 出现错误? |
|---|---|---|
| 1. 信号 | 这是代码吗? | 否 |
| 2. 语言 | 是有效的Swift代码吗? | 否 |
| 3. 语法 | 是否可以编译? | 否 |
| 4. 局部语义 | 函数是否实现了它的功能? | 否 |
| 5. 系统语义 | 是否遵循约定和性能需求? | 是 |
| 6. 架构 | 是否可以扩展且不降级? | 是 |
错误出在了第5到第6层。恰恰是当前2026年LLM仍无法做到的层次。它在语法和局部逻辑上可能无懈可击,但问题是涌现的:当一个无需问题的逻辑单元,出现在性能敏感场景中时,才会显现问题。
静态代码检查工具的工作范围是第2到第4层。它们对组合和性能毫无感知。这就像要求Word中的拼写检查器帮你找到逻辑谬误一样,不可能实现。
唯一有效的方法:事后性能测试
初次修复这个问题后,我写了如下测试:
@Test("扫描性能不会随文件数量下降")
func scanPerformanceDoesNotDegradeWithFileCount() async throws {
// 创建1000个最简单的JSONL文件
for i in 0..<1000 {
let content = "..." // 一行有效内容
try content.write(to: dir.appendingPathComponent("session-\(i).jsonl"), ...)
}
// 预先填充offset store(模拟重新扫描流程)
var offsets = SessionOffsetStore.OffsetData()
for i in 0..<1000 {
offsets.offsets["session-\(i).jsonl"] = 100
}
offsetStore.save(offsets)
let start = ContinuousClock.now
await service.scan()
let elapsed = ContinuousClock.now - start
#expect(elapsed < .seconds(3)) // 1000文件扫描时间<3s
}
这是一个简单粗暴的回归测试。1000个文件,限制在3秒完成,否则测试失败。如果有人(无论是人类还是AI)再次在循环内加入I/O,这个测试的时间将从0.2秒增加到30秒,并立刻报错。
结果证明它是有效的。
当AI为第二个服务生成相同问题的代码时,第一个服务的性能测试仍然通过(因为是不同的服务)。但为新服务写的性能测试马上失败了。所以,这个测试成功检测到了回归问题并防止了更坏的结果。而CLAUDE.md的警告,或任何静态代码检查工具,都无法做到这一点。
最终确认
这个问题完美地印证了我所说的《对抗性开发》的核心观点:绝不相信,只做验证。
- 你不能相信AI不会犯新手级的错误。它会,而且会反复犯错。哪怕你已经明确告诉它不要这么做。
- 你不能相信静态代码检查工具会帮你发现这些问题。它们做不到,因为问题高于抽象的层级。
你可以做的是:
- 性能测试,作为事后发现问题的安全网
- 实际性能分析工具(xctrace, Instruments)测量真实开销,避免主观猜测
- 深度防御策略:多层验证,因为单一层验证不足以覆盖所有缺陷
简单来说:验证的系统就像洋葱,它有分层的结构。当一层失效时,下一层会捕捉问题。
给怀疑论者的附言
“但是,费尔南多,一个人类程序员就不会犯这个错误了吗?”
初学者会,哪怕是高级程序员也未必能在第一次完美进行优化——但他们有经验,所以会在代码审查中察觉到问题。AI生成代码的问题是它的巨大产量:10分钟能生成50个文件,而没有人会一行一行地检查这些文件。不用说,光是看它们就能让人感到疲惫。
这也是为什么验证必须是自动化的,而不是依赖人工。性能测试不会累,它不会分心,也不会倦怠。每次运行make test都会自动检测是否有异常。
和我在防止幻觉的五种策略中提到的一样:验证系统一定要与生成系统分离。如果AI负责写代码,验证环节一定得由其他安全系统负责。在这里,验证系统就是用时间维度来静静观察的时钟。