想象一下,你请一个人工来为你制作一个书架。最终交给你的作品看上去很漂亮:架子、螺丝,一切都井井有条。但是你把书架靠在墙上,结果它倒下了。那些螺丝只是道具,看着像螺丝,但其实是塑料做的。
这就是LLM(大语言模型)滥用类型系统时的表现。它交给你的代码可以编译、能通过测试、格式上看起来挑不出毛病。但内部本该是有意义的类型,却被填上了字符串(string)。本该有明确状态的地方,用了nil,而它的意义因读代码的人而异。本该是一个有两个案例的枚举,却被换成了一个== "claude",某天有人可能会写成错拼的== "cladue",但你要等到线上了才会发现。
模型的最爱捷径:万能的字符串
过去几个月,我一直和一个AI助手合作,开发一个中型的Swift应用。它生成的代码干净、结构清晰、命名合理。但有一个模式一再出现:模型尽量避免创建新类型。
这不是因为它懒惰,而是因为创建新类型需要做设计决策,比如:这是一个枚举(enum)吗?有多少个可能值?应该放在哪儿?由谁来导入?而String则无需任何决策。它可以放在任何地方,总是可以编译。
结果就是会生成这样的代码:
func sessions(harness: String? = nil) -> [Session] {
if let harness {
return all.filter { $0.harness == harness }
}
return all
}
// 用法:
let claudeSessions = sessions(harness: "claude")
let codexSessions = sessions(harness: "codex")
---
看起来没问题,确实能运行。但这个代码有三个隐藏的问题:
1. **如果你写错了`"cladue"`,没人会提醒你。**字符串可以是任何内容,但如果是枚举类型,这种错误是可以避免的。
2. **`nil`被用来表示“全部”。** 但这不是定义在某个类型中,而是一种存在于代码注释中、甚至仅仅是你的大脑中的约定。
3. **每个按`harness`过滤的函数都重复了同样的`String? = nil`模式。** 如果你以后想添加一个第三种`harness`,就必须手动搜遍代码中所有的字符串比较。
换句话说,编译器无法帮助你,因为你剥夺了它所有的语义信息。你用一个`String`代替了领域概念。
## 模型应该写成这样
```swift
enum HarnessID: String, Codable {
case claude
case codex
}
enum HarnessFilter {
case all
case specific(HarnessID)
}
func sessions(harness: HarnessFilter = .all) -> [Session] {
switch harness {
case .all:
return all
case .specific(let id):
return all.filter { $0.harnessID == id }
}
}
// 用法:
let claudeSessions = sessions(harness: .specific(.claude))
let allSessions = sessions() // 默认是.all — 很明确
现在,"cladue"不会编译。“全部”不再是一个含糊不清的nil,而是一个显式的枚举情况。如果你添加一个第三个harness,编译器会强制你在每个switch语句中处理新情况。它将运行时错误转化为了编译时错误。这才是类型系统本该发挥的作用。
nil:万能的工具箱
模型的第二个常用技巧是用nil来表示某些特定含义的东西。模型特别喜欢可选值(Optional),这也不难理解:一个可选字段是最快添加数据而又不破坏现有代码的方法。但“这个值可能不存在”和“这个值代表一个特定的状态,我用nil来编码”是完全不同的概念。
来看一个真实的例子:
var calibrationDate: Date? // nil = 从未校准
var quotaPercent: Double? // nil = 未知
var errorMessage: String? // nil = 无错误
三个字段,对nil的含义有三种不同解释。第一个是合理的:可能确实从未校准过。第二个是可疑的:“未知”是一个值得单独建立类型的状态。而第三个则极其危险:你用“没有值”来表示“没有错误”,用字符串的存在表示“有错误”。但实际上你在用可选值伪装一个Result类型(成功与失败的二选一)。
在这三种情况下,编译器看到的只会是Optional<T>。无法区分“合法缺失”还是“滥用nil作为状态标志”。如果编译器无法区分,当你六个月后再看这段代码时,你的脑子也很难记住当时是怎么想的。
为什么LLM会这么干
这不是懒,而是走错了优化方向。
模型的训练目标是生成能编译并通过测试的代码。一个String总能编译。一个nil总能编译。而一个新的枚举类型需要定义、需要导入、需要更新所有调用它的地方。从模型的角度来看,字符串有更少的阻力,并且可以立刻得到同样的结果。
这和一个新手程序员在TypeScript中随手使用any是一样的道理。这并不是他们不知道有更好的类型存在,而是因为any能通过编译任务就完成了。这是一种错位的激励机制。
| 模型优化的目标 | 你需要的结果 |
|---|---|
| 争取一次编译成功 | 编译器能够帮助你避免错误 |
| 通过已有测试 | 新引入的错误能被完全杜绝 |
| 最少修改已有代码 | 提供尽可能多的语义信息 |
| 解决今天的问题 | 不给未来制造新问题 |
第一道防线:一个类型检查linter
当我意识到模型总是重复这些模式时,我的第一反应还是老办法:写在CLAUDE.md里的规则。“永远不要使用String替代应该是枚举的地方”,“永远不要用nil作为标志”。
结果可想而知:模型看了一眼这条规则,点了点头,三次迭代后又写了一个harness: String? = nil。不是它反叛,而是因为字符串确实阻力最小,而你的200K token上下文早就把规则挤掉了。
所以,我写了一个linter——一个bash脚本,用来寻找代码中那些可疑的模式:
# T4: 字符串字面量的比较,本该是枚举值
fail_if_found "T4" "ERROR" \
'比较 == "claude" / == "codex" (应为 == .claude)' \
'==\s*"(claude|codex)"'
# T7: 用nil代表“全部”在筛选参数中的情况
fail_if_found "T7" "ERROR" \
'参数 harness: String? = nil (应为 HarnessFilter)' \
'harness:\s*String\?\s*='
# T1: 用字符串表达领域类型
fail_if_found "T1" "ERROR" \
'不可接受的ExpressibleByStringLiteral' \
'ExpressibleByStringLiteral'
总计写了8条检查规则。每一条都用grep来识别特定的模式。如果找到了,构建失败。没有主观判断,没有“这个情况例外”的余地。如果模式匹配,CI就变红。
门一关死,一个== "claude"都别想过关。
这个linter非常“呆”。它不会解析AST,也不理解上下文,也没有复杂的启发式判断。它只是匹配文本。而这一点恰恰是它的优点:模型无法欺骗它。如果检测是纯文本的,模型就无法找到理由绕过。
第二道防线:一个审计技能
linter可以抓住症状。但症状的根本原因是,模型本身没有思考它所用的类型是否合适。
为了解决这个问题,我创建了一个审计技能——这是一个Markdown文件,模型可以按需执行它,用来系统地审查任何模块中类型的使用情况。这个技能会关注以下内容:
- 用字符串表达有限集合。 如果字段只可能有3个值,那么它应该用枚举表示,而不是
String。 - 用可选值表示特定状态。 如果
nil代表某种特定的含义(如“未知”、“不适用”或“全部”),那么它应该有一个明确的枚举类型。 - 以字符串为键的字典,而这些键其实是领域概念。
[String: Session]应该是[HarnessID: Session]。 - **
setValue(forKey:)**等绕过Core Data类型系统的操作。 - **
Dictionary(uniqueKeysWithValues:)**未检查键唯一性,会导致运行时崩溃。
这项技能不是魔法,而是一个清单,强迫模型以一种特定的方式放大镜检查代码。模糊的指令“检查类型”与明确的要求“寻找只有N种已知取值的字符串字段”之间,差别就是一份有效审计和一次“看起来没问题”之间的天差地别。
前后对比
在我对一个开发了两个月的模块应用了linter和审计技能之后:
| 问题类型 | 数量 | 严重性 |
|---|---|---|
| 字符串字面量的比较 | 4 | 错误:无法发现运行时拼写错误 |
使用String? = nil做筛选条件 | 3 | 错误:隐藏的nil语义问题 |
| 用字符串字段伪装的枚举 | 2 | 需要重构:缺失了类型安全 |
Core Data中的setValue(forKey:) | 1 | 错误:编译器无法校验字段名 |
总共发现了10个问题,全部是模型引入的。而且这些问题都没能被现有的测试捕获,因为测试代码和业务代码一样,使用了相同的字符串。这是“虚假验证”循环的一个典型例子。
经过重构后,代码多了40行(主要是定义枚举的行数增加了),但所有的“散乱字符串”都被移除。每一处比较操作都需要编译器检查,每一个过滤器都多了显式类型。此后,“模型误写了"cladue"”这类bug将不可能再次发生。
你现在能做的事情
你不必使用Swift,也不需要我的linter。这个模式适用于任何拥有类型系统的编程语言:
在TypeScript中:
// 之前:过于依赖字符串
function getUsers(role: string = "all") { ... }
getUsers("adnin") // 拼写错误,编译通过,运行时错误
// 之后:类型安全
type Role = "admin" | "viewer" | "billing"
type RoleFilter = Role | "all"
function getUsers(role: RoleFilter = "all") { ... }
getUsers("adnin") // 编译错误
在使用Python和枚举时:
# 之前
def process(status: str | None = None): ...
process("pending") # 是"pending"还是"Pending"还是"PENDING"?
# 之后
class Status(Enum):
PENDING = "pending"
DONE = "done"
def process(status: Status | None = None): ...
通用规则: 如果一个值只会有N种已知的可能性,它应该是一个只含N个情况的类型(如枚举),而不是一个有无限取值可能的字符串。如果nil或None表示的是特定的含义,那么它应该有自己的名字。
如果你和AI助手合作开发代码,一定要写一个linter来检测这些可疑模式。不需要多复杂,只要用grep匹配你的领域中常见的错误模式即可。关键是让它在CI上自动运行,确保不依赖模型是否“记得”遵守你的README。
元问题
这一切有点讽刺。我们要求一个基于语言的模型 —— 一个本质上只能操控字符串的系统 —— 不要用字符串来替代应该有的类型。
这就像让木匠不要用木头一样。当然,木匠可以用别的材料。但他的本能还是会选择木头的。对LLM来说,字符串是它的原生类型。一切它所读到的,所生成的,都是文本。让它在一个语义丰富的类型和一个“可以工作”的字符串中做选择,字符串总是-winning策略。
解决方法不是改变模型的本能,而是通过工具来检测这些模式,并在代码合并前就将其拦截。与其期望一个聪明的模型不出错,不如期待简单的工具在关键时机帮你守住底线。
类型是活的文档。如果你把它们降级为字符串,你的代码可以编译,但什么都没表达出来。下一个来读代码的人——无论是你、你的同事,还是另一个模型——都得自己猜测这个nil究竟意味着什么。
消除猜测,让错误无法编译。