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
title: "我的AI在for循环中从磁盘读取JSON 900次(为什么没有静态代码检查工具能拯救你)"
date: 2026-02-24T14:00:00+01:00
draft: false
slug: "ai-read-json-900-times-in-for-loop"
slug_en: "llm-read-json-900-times-loop-performance"
description: "一个LLM写了段代码,在一个包含900个文件的循环中,每次迭代都从磁盘读取并解析一个JSON文件。这是个新手犯的错误,没有任何静态代码检查工具能察觉到它。"
tags: ["AI", "LLM", "性能", "Swift", "Tokamak", "对抗性开发"]
categories: ["观点"]

translation:
  hash: ""
  last_translated: ""
  notes: |
    - "de primero de carrera": 可以理解为“新手错误”/“初学者的错误”,不必直接翻译成“职业生涯的第一年”。
    - "enseño a mis alumnos al mes de empezar": 可译为“我会在学生入门后的第一个月教这个”,表示这是一项非常基础的内容。
    - "marear la perdiz": 翻译为“拖延问题”/“绕圈子”。猎人隐喻来源。
    - "chapuza": 表示粗糙的解决方案,可译为“临时方案”/“hack”。
    - "burrada": 可翻译为“明显的错误”,语气强于“mistake”,弱于“atrocity”。
    - "barra del bar": 可译为“酒吧柜台”,表示非正式的场景,不是字面意义的菜单操作。
    - "ojo al dato": 翻译为“请注意”。 
    - "dicho en cristiano": 翻译为“简单来说”。无宗教意味。
---

上周,我的AI写了一段代码,每次都从磁盘读取一个JSON文件,对其解析后再进行一次**查找**,然后总共重复了900次。这意味着每次迭代里都会经历:打开文件,解码JSON,找到某个值,再全部丢弃从头开始。

这个错误是我在教学生编程的第一个月就会告诉他们千万不要犯的。

## 发生了什么事(长话短说)

我在开发Tokamak,一个用于macOS的菜单栏应用,用来监控Claude Max的配额使用情况。它的一部分功能需要扫描约900个Claude Code会话数据生成的JSONL文件(JSON Lines)。对于每个文件,它需要知道在上一次的扫描中读取的**字节偏移量**(增量读取——只读取新数据)。

偏移量都保存在一个JSON文件中:

```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生成的扫描循环代码:

1
2
3
4
5
6
7
8
9
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,260489
OffsetStore.load() 样本数1,704(88%)10(2%)
检查时间>20秒<0.5秒
CPU占用率81%~1.5%

扫描时间的88%都浪费在反复读取和解析这900行JSON上了。就像西西弗斯推石头,只不过这次他用的是JSONDecoder

修复方法(令人羞愧)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 修复前:每次迭代都进行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中的拼写检查器帮你找到逻辑谬误一样,不可能实现。

唯一有效的方法:事后性能测试

初次修复这个问题后,我写了如下测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@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不会犯新手级的错误。它会,而且会反复犯错。哪怕你已经明确告诉它不要这么做。
  • 你不能相信静态代码检查工具会帮你发现这些问题。它们做不到,因为问题高于抽象的层级。

你可以做的是:

  1. 性能测试,作为事后发现问题的安全网
  2. 实际性能分析工具(xctrace, Instruments)测量真实开销,避免主观猜测
  3. 深度防御策略:多层验证,因为单一层验证不足以覆盖所有缺陷

简单来说:验证的系统就像洋葱,它有分层的结构。当一层失效时,下一层会捕捉问题。

给怀疑论者的附言

“但是,费尔南多,一个人类程序员就不会犯这个错误了吗?”

初学者会,哪怕是高级程序员也未必能在第一次完美进行优化——但他们有经验,所以会在代码审查中察觉到问题。AI生成代码的问题是它的巨大产量:10分钟能生成50个文件,而没有人会一行一行地检查这些文件。不用说,光是看它们就能让人感到疲惫。

这也是为什么验证必须是自动化的,而不是依赖人工。性能测试不会累,它不会分心,也不会倦怠。每次运行make test都会自动检测是否有异常。

和我在防止幻觉的五种策略中提到的一样:验证系统一定要与生成系统分离。如果AI负责写代码,验证环节一定得由其他安全系统负责。在这里,验证系统就是用时间维度来静静观察的时钟。