作者放手让AI编码Agent从规格说明书构建完整iOS功能(含异步、缓存、取消、UI状态、错误处理和测试),然后像审PR一样审查代码质量。

我使用 AI 辅助开发已经很长一段时间了。
和许多开发者一样,我经历了从用 AI 解释代码,到生成样板代码,到调试错误,再到让它重构代码、编写测试,再到越来越多地让编码智能体跨多个文件工作。
这让我开始思考一个有些令人不安的问题:
如果我不再告诉 AI 如何实现一个功能,而是直接让它构建一个功能,会发生什么?
不是一个玩具应用。
不是一个"Hello World"示例。
不是一个带有按钮和网络请求的简单页面。
而是一个具有异步工作、缓存、取消、UI 状态、错误处理和测试的、足够贴近实际的移动端功能。
所以我决定尝试一个实验。
我会给一个 AI 编码智能体一份规格说明。
我会让它自己做实现决策。
然后我会像审查另一位工程师的 Pull Request 一样审查结果。
有趣的部分不在于 AI 能否生成代码。
我早就知道它可以。
有趣的部分是:
它能否生成我真正愿意维护的代码?
而这就是事情变得有趣的地方。
我把这个实验定位为移动端开发实验,但大部分实现和调试示例都刻意聚焦于 iOS 和 Swift。
原因很简单:这是我最主要的工程工作区间,而 Swift 的并发模型和 SwiftUI 带来了一些特别有意思的地方,让 AI 生成的代码容易出错。
实验结构很简单:

目标不是证明 AI 不擅长编程。
目标是想理解 AI 在哪些地方真正有用,以及工程判断仍然重要。
为了让实验有意义,我想尽可能减少手把手的指导。
实验提供了:
一份功能规格说明
现有的项目结构
相关代码库的访问权限
创建和修改文件的能力
运行测试的能力
构建应用的能力
但我刻意不给出实现方式。
换句话说,我不会说:
"使用异步图片加载器。"
"使用字典来去重请求。"
而是把问题交给它。
这个区别很重要。
因为否则我实际上测试的不是 AI 的工程能力。
我测试的是它遵循指令的能力。
我想要一个足够小以便于理解,但又足够复杂以暴露工程问题的实验对象。
所以我们来构建一个商品搜索功能。
需求如下:
功能需求
应用应该:
允许用户输入搜索查询。
从远程 API 获取商品。
在可滚动列表中展示结果。
异步加载商品图片。
缓存已加载的图片。
避免重复下载图片。
取消不必要的请求。
展示加载中、空数据和错误状态。
重试失败的请求。
在用户快速更改搜索查询时正确工作。
非功能需求
实现应该:
使用 Swift 并发
避免不必要的网络调用
避免明显的内存泄漏
正确处理取消
保持 UI 响应
将 UI concerns 与网络层分离
不将业务逻辑直接放在 SwiftUI view 中
我不会告诉 AI 如何实现这些。
实际的提示词可以出奇地简单。
Build a production-quality product search feature for this iOS application.
Requirements:
- Search products using a remote API.
- Display results in a SwiftUI list.
- Support pagination.
- Load product images asynchronously.
- Cache images.
- Deduplicate simultaneous requests for the same image URL.
- Cancel obsolete network requests.
- Handle loading, empty, error and retry states.
- Rapidly changing search queries must not result in stale results being displayed.
- The implementation must be testable.
- Follow the existing project's architecture and conventions.
- Use Swift concurrency.
- Do not introduce unnecessary dependencies.
First inspect the existing project structure and identify the appropriate place for this feature.
Implement the feature completely.
Run the relevant tests and fix compilation/test failures.
然后我退后观察。
第一件容易被低估的事情是,AI 编码智能体可以多么快速地生成大量代码。
在相对较短的时间内,你可以得到:
SearchView
SearchViewModel
ProductRepository
APIClient
ImageLoader
ImageCache
Models
Tests

它能把各个部分连接起来。
它能生成模型。
它能创建异步网络代码。
它能编写 SwiftUI。
它能生成测试。
它能运行编译器。
它能修复明显的编译器错误。
这就是 AI 体验近乎神奇的地方。
"这是需求。"
"这是一个可工作的实现。"
比传统开发快得多。
但"可工作"和"可投产"是两个完全不同的事物。
从这一刻起,我不再把 AI 看作代码生成器。
我开始把它看作提交 Pull Request 的工程师。
我问的是在认真代码审查中我会问的同样问题:
不变式是什么?
状态在哪里被持有?
当请求重叠时会发生什么?
当工作被取消时会发生什么?
当异步操作挂起时会发生什么?
旧数据能否覆盖新数据?
两个调用者能否触发同一个网络请求?
当视图消失时会发生什么?
在内存压力下会发生什么?
测试是在测试行为还是实现?
当网络在分页过程中中途失败时会发生什么?
突然之间,代码量不再是有趣的部分。
有趣的部分变成了代码背后的推理。
这是 Swift 并发最容易迷惑人的地方之一。
假设 AI 创建了一个图片加载器:
actor ImageLoader {
private var tasks: [URL: Task<Data, Error>] = [:]
func load(_ url: URL) async throws -> Data {
if let existingTask = tasks[url] {
return try await existingTask.value
}
let task = Task {
try await download(url)
}
tasks[url] = task
return try await task.value
}
}
乍一看,这相当不错。
字典被保护了。
并发访问被隔离了。
同一 URL 的请求被去重了。
关于 actor 重要的是不仅仅是:
"只有一个东西可以访问这个数据。"
更重要的概念是:
Actor 可以挂起,而当它恢复时,状态可能已经不再是挂起前的状态了。
这就是 actor 的可重入性(reentrancy)。
func load(_ url: URL) async throws -> Data {
if let existingTask = tasks[url] {
return try await existingTask.value
}
let task = Task {
try await download(url)
}
tasks[url] = task
return try await task.value
}
Actor 可以在这个操作挂起时处理其他消息。
这意味着心智模型:
进入 actor
↓
其他事情不能发生
↓
离开 actor
更好的心智模型是:
进入 actor
↓
读取状态
↓
await
↓
释放 actor
↓
其他工作可以执行
↓
恢复
↓
状态可能已改变
这个区别很微妙。
而这恰恰是那种会从 AI 生成的实现中溜过去的东西,因为代码看起来完全合理。
这不仅仅是图片加载器的问题。
同样的推理适用于:
请求去重
actor Something {
var state: State
func operation() async {
// 读取状态
await something()
// 假设状态未改变
}
}
在我挂起期间什么可能改变了?
这个问题往往比问:
"这段代码是否使用了 actors?"
更有价值。
另一个常见的 AI 生成模式看起来像这样:
func search(query: String) async {
isLoading = true
do {
let products = try await repository.search(query)
self.products = products
} catch {
self.error = error
}
isLoading = false
}
然后在 UI 的某处:
searchTask?.cancel()
searchTask = Task {
await viewModel.search(query: query)
}
旧任务被取消了。
但 Swift 中的取消是协作式的。
task.cancel()
不会魔法般地终止那个任务内的每个操作。
工作必须主动观察取消。
try Task.checkCancellation()
if Task.isCancelled {
return
}
重要的是,底层操作在取消发生时也需要正确地表现。
这在以下场景中变得特别重要:
用户输入:
"i"
↓
请求 A
"ip"
↓
请求 B
"iph"
↓
请求 C
"iphone"
↓
请求 D
如果请求 A、B 和 C 实际上并未停止,就会导致多个操作同时运行,浪费资源。
Request A ────────────────┐
↓
Request D ──────────→ UI
↑
Request A ────────────────┘
此时如果架构不够严谨,旧请求可能会干扰较新的状态。
这是一种在常规测试中看起来完全正常的 bug。
假设用户执行了搜索:
shoes
jackets
两个请求并发执行。

但网络时序并非确定性的。
Search "shoes"
│
├───────────────→ Server
│
Search "jackets"
│
├──────→ Server
│
│
│ jackets response
│ ↓
│ UI
│
│ shoes response
│ ↓
│ UI ❌
现在 UI 显示的是 shoes,但当前查询实际上是 jackets。
AI 可以写出完全正确的异步代码,却仍然遗漏了这种语义层面的竞态。
一种解决方案是将请求与 generation/token 关联:
let requestID = UUID()
currentRequestID = requestID
let results = try await repository.search(query)
guard requestID == currentRequestID else {
return
}
products = results
另一种方案是构建任务生命周期,使得过时的任务被取消,陈旧的结果无法被提交。
具体实现取决于架构。
重要的教训是:
并发正确性不等于编译器正确性。
编译器可以验证你的代码是否遵守了 actor 隔离。
但它无法告诉你,你的应用显示了错误搜索查询的结果。

AI 还生成了一个缓存。
大致如下:
final class ImageCache {
private var cache: [URL: Data] = [:]
func image(for url: URL) -> Data? {
cache[url]
}
func insert(_ data: Data, for url: URL) {
cache[url] = data
}
}
完全可以理解。
但生产级缓存不仅仅是:
Dictionary<URL, Data>
你立即会面临一系列问题:
它能增长到多大?
如果应用显示了数千张图片会怎样?
在内存压力下会发生什么?
所有内容都要保留在内存中吗?
这是内存缓存还是磁盘缓存?
淘汰策略是什么?
如果两个请求同时到达怎么办?
失败的请求也要缓存吗?
HTTP 缓存头怎么处理?
当低分辨率图片被高分辨率图片替换时会发生什么?
如果 20 个 cell 同时请求同一个 URL 会怎样?
最后一个问题把我们带回了请求去重。
真实的系统更像这样:
Image Request
│
▼
Memory Cache
│ │
hit│ │miss
│ ▼
│ In-flight
│ Request
│ │
│ ▼
│ Disk/HTTP
│ │
│ ▼
│ Network
│
▼
Image
代码本身不一定困难。
状态机才是困难所在。
这可能是这次实验中比较有趣的发现之一。
AI 非常擅长生成看起来像测试的测试。
func testSearchReturnsProducts() async throws {
let products = try await sut.search(query: "shoes")
XCTAssertEqual(products.count, 3)
}
但这实际上保护了什么行为?
重要的测试往往是那些令人不适的:
✓ successful request 成功的请求
✓ empty response 空响应
✓ network failure 网络故障
✓ retry 重试
✓ cancellation 取消
✓ rapid query changes 快速查询变化
✓ stale response 陈旧响应
✓ duplicate image requests 重复图片请求
✓ concurrent image requests 并发图片请求
✓ pagination failure 分页失败
✓ pagination cancellation 分页取消
✓ view disappears during request 请求期间视图消失
✓ request completes after cancellation 取消后请求完成
✓ cache hit 缓存命中
✓ cache miss 缓存未命中
✓ cache eviction 缓存淘汰
这样一来,测试套件就变得有趣多了。
这是一个重要的区别。
95% code coverage
仍然可能有一个坏掉的应用。
"这段代码执行了吗?"
它不一定能回答:
"这个系统在不利条件下行为正确吗?"
对于并发系统,这是一个巨大的区别。
你需要围绕状态转换而非仅仅代码行数来写测试。
idle
↓
loading
↓
success
idle
↓
loading
↓
cancelled
loading
↓
new search
↓
old request completes
↓
stale response discarded
第二类测试才是大量真实 bug 所在的地方。
这个实验不是为了证明 AI 不行。
有很多事情 AI 做得极其出色。
AI 尤其擅长:
模型、DTO、初始视图、协议、测试脚手架
重复性转换
重命名、移动代码、提取类型、更新调用点
从零到可编译版本的速度大幅提升
快速建议不同方案并解释不熟悉的 API
生成大量初始测试结构
调试编译器错误
这是 AI 感觉几乎近乎犯规的领域之一。
error: Main actor-isolated property cannot be mutated
它常常能惊人地接近解决方案。
将实现细节转化为可读的文档是另一个出色的用途。
有趣的差距不在语法。

考虑这些问题:
这个状态应该由 ViewModel 拥有吗?
这个操作应该是可取消的吗?
这个缓存是全局的还是按功能划分的?
这个 actor 实际上解决的是什么问题?
如果用户在一秒内更改了 10 次查询会怎样?
这个请求应该重试吗?
当应用进入后台时会发生什么?
在内存压力下会发生什么?
这个架构适合一个运行 5 年的生产应用吗?
这些不仅仅是代码生成问题。
它们是系统设计问题。
这可能是整个实验最大的教训。
func loadImage(url: URL)
并产生一个合理的实现。
但真正的问题是:
这个函数对系统其他部分意味着什么?
这需要更广泛的上下文。
ImageLoader
│
├── ProductCell
│
├── SearchResults
│
├── ProductDetails
│
├── Recommendations
│
└── Wishlist
现在问题不再是:
"我能加载一张图片吗?"
而是:
"谁拥有图片加载职责?"
"这些功能能共享请求吗?"
"缓存的生存期是什么?"
"当用户登出时会发生什么?"
"当 CDN 更换图片时会发生什么?"
这是完全不同层次的推理。
这就是我认为关于 AI 编程的讨论常常变得不必要地二元化的原因。
AI 取代开发者
AI 毫无用处
有一个更有趣的中间地带。
我越来越像这样看待 AI 编码智能体:
Human
│
┌───────────┴───────────┐
│ │
Judgment Direction
│ │
▼ ▼
AI Agent ───────────→ Implementation
│
▼
Validation
│
▼
Human
人类不一定需要编写每一行代码。
但人类需要知道:
哪些代码应该首先存在。
这可能是整个实验背后更重要的问题。
如果 AI 能够生成:
models
views
networking
tests
boilerplate
refactors
documentation
那么工程师的价值越来越转向:
Problem definition
↓
System design
↓
Constraints
↓
Trade-offs
↓
Validation
↓
Debugging
↓
Observability
↓
Product judgment
稀缺的能力可能不再是产生代码。而是知道代码是否应该存在。
传统上,"完成"可能意味着:
✓ Compiles
✓ Tests pass
✓ Feature works
对于 AI 辅助开发,我认为我们需要更有要求的定义:
✓ Compiles
✓ Tests pass
✓ Correct under concurrency 并发下正确
✓ Handles cancellation 处理取消
✓ Handles failure 处理失败
✓ Doesn't leak resources 不泄露资源
✓ Doesn't introduce unnecessary complexity 不引入不必要的复杂性
✓ Meets architectural boundaries 符合架构边界
✓ Has acceptable performance 性能可接受
✓ Is observable 可观测
✓ Is maintainable 可维护
✓ Doesn't violate security/privacy requirements 不违反安全/隐私要求
也许最重要的是:
✓ I understand why this code exists
✓ 我理解这段代码为什么存在
最后这条检查在代码并非手动编写时变得异常重要。
这是我一直思考最多的部分。
如果 AI 智能体能够生成一个功能的 80%……
工程师会怎样?
我认为答案不是:
"工程师消失了。"
我认为答案是:
工程师向上迁移到了更高的抽象层级。
与其把大部分时间花在敲代码上:
struct ProductViewModel {
...
}
我们可能花更多时间在决定:
ProductViewModel 应该拥有什么?
什么不应该由它拥有?
智能应该放在哪里?
并发不变量是什么?
失败模式有哪些?
我们如何知道这个系统是正确的?
讽刺的是,这反而可能让深度工程知识变得更加有价值,而不是贬值。
因为如果 AI 能写出显而易见的代码,那么差异化就体现在对非显而易见代码的理解上。
经历了这次练习之后,我不会简单地问 AI:
"帮我构建这个功能。"
我会给它一份更有约束力的工程契约。

工程约束
1. 使用结构化并发。
2. 取消必须正确传播。
3. 除非有充分理由,否则不要使用非结构化并发。
4. UI 变更必须遵守 Actor 隔离。
5. 不要假设 Actor 隔离能消除逻辑竞态。
6. 网络请求必须可以通过依赖注入进行测试。
7. 相同资源的重复请求必须去重。
8. 过期响应绝不能覆盖更新的状态。
9. 测试必须覆盖失败和取消路径。
10. 不要引入没有充分理由的依赖。
11. 不要自己发明 API。
12. 保持现有的架构边界。
13. 在做出重大变更之前,先解释架构决策。
注意发生了什么。
我没有给 AI 实现。
我给了它工程原则。
这是一种与 AI 协作的更有趣的方式。
我认为这种框架已经变得过于简单化了。
更有趣的未来是这样的:
人类
│
需求
│
▼
规格说明
│
▼
AI 智能体
│
┌────────┼────────┐
▼ ▼ ▼
设计 代码 测试
│ │ │
└────────┼────────┘
▼
验证
│
▼
人工审查
│
▼
生产环境
工程师变成了那个建立智能体运作边界的人。
而这与单纯知道如何敲 Swift 代码是完全不同的技能。
那么,AI 能构建一个移动应用功能吗?
它能写出出乎意料的好代码吗?
它能为开发者节省大量时间吗?
但当我看到一个生成的功能,它能编译、测试通过,我就能立刻认为它可以投入使用了吗?
这未必是对 AI 的批评。
它提醒我们,软件工程从来就不是关于写代码的。
困难的部分始终是理解系统。
理解失败。
理解并发。
理解权衡。
理解当快乐路径消失时会发生什么。
也许最重要的是:
知道在 bug 发生之前该问什么问题。
这是我还没准备好外包的部分。
也许未来的高级工程师不是那个能写最多代码的人。
也许是那个能看到 AI 生成的 10,000 行代码,然后知道哪 10 行会在六个月后给你带来麻烦的人。
如果这是真的,那么学习如何与 AI 协作就不是为了成为更好的提示词工程师。
而是为了成为更好的工程师。