AI 时代的编码分工:FP 的伟大复兴?

时代变了,大人

事实上越来越多的项目中,代码的主要作者已经是 AI 了,你身边的同事已经悄悄买了 glm coding plan 甚至中转站的 Codex 套餐,每天上班需求扔给 AI 后,自己则聚焦于:把尿喝白,把股炒红,把电充绿,把事办黄。人类的角色正在从"写代码"转向"把产品经理的 PRD 扔给 AI,装模作样 Review AI 代码,适当的调用一下 pua skills(你不干有的是 AI 干。),以及让 AI 代写工作总结和回怼邮件"。

既然都已经那样了,那能不能干脆这样:代码都是 AI 编写、维护、debug、阅读,那还需要人类可读性干嘛? 马圣都说了:直接让 AI 生成机器码,一步到位。

当然,我没马圣那么极端,我的观点是:逻辑实现可以不用那么照顾人类感受了,但接口定义需要。

人类的脑力是有限且宝贵的,长时间进行复杂的符号推理对眼睛和头发都是消耗,但 AI 不会累。那编码阶段是不是可以这样分工:牛马(AI)负责实现,老板(人类)喝喝咖啡,逛逛论坛,然后稍微检查一下契约(函数签名)

想法不错,但问题在于,要让这个分工成立,契约(签名)本身必须包含足够的信息。而这正是主流(过程式)和不入流(函数式)范式的根本区别。


两种签名

同一个业务逻辑,根据用户 ID 构建 Profile 并返回 JSON。

风格一:Spring 式 try-catch 统一兜底

键盘撒把米,鸡都能写的代码。错误模型是异常继承体系,业务代码就是一堆顺序赋值,出错就 throw,外面接住。

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
// ---- 异常继承体系 ----
class BadRequestException extends BusinessException(400, message)
class NotFoundException extends BusinessException(404, message)
class ForbiddenException extends BusinessException(403, message)

// ---- AOP 统一兜底 , @ControllerAdvice + @ExceptionHandler ----
@ExceptionHandler(BusinessException.class)
ResponseEntity handleBiz(BusinessException biz) { ... }

// ---- Service ----
User fetchUser(String id) { /* ... */ }
int fetchScore(String email) { /* ... */ }
User validate(User user) { /* ... */ } // 不合法直接 throw
void saveProfile(Profile p) { /* ... */ }

// ---- Controller:异常由 AOP 切面兜底,无 try-catch ----
@GetMapping("/profile/{id}")
Map<String, Object> getProfile(String id) {
User user = service.fetchUser(id);
User valid = service.validate(user);
int score = service.fetchScore(valid.getEmail());
Profile profile = new Profile(valid, score);
service.saveProfile(profile);
return Map.of("name", profile.user.name, "score", profile.score);
}

写过代码的人看这段没啥困难。但你看函数签名:

1
public User fetchUser(String id)

它在撒谎。这个函数可能抛 NotFoundException,可能抛 RuntimeException,可能抛任何东西,但签名里什么都没说。人类靠经验和记忆知道"哦,找不到用户就抛 NotFoundException",但这个知识不在函数签名里,也不在这个函数体代码里,你不在 IDE 里从头到尾把所有调用树过一遍是无法穷举出来的。它甚至不在写下这个函数的开发者的脑子里。

风格二:EitherT 全链式

错误是值不是异常。函数签名里写清楚了所有可能的失败路径。

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
sealed trait AppError
case class InvalidInput(msg: String) extends AppError
case class NotFound(id: String) extends AppError
case class SdkFailure(cause: Throwable) extends AppError

def fetchUser(id: String): IO[Either[AppError, User]] = ???
def fetchScore(email: String): IO[Int] = ???
def validate(user: User): Either[AppError, User] = ???
def saveProfile(p: Profile): IO[Unit] = ???

def toHttpResult(err: AppError): HttpResult = err match
case NotFound(id) => HttpResult(404, Json.obj("error" -> ...))
// ... 每个 case 对应一个 HTTP 状态码,编译器穷举检查

def getProfile(id: String): IO[HttpResult] =
EitherT(fetchUser(id))
.subflatMap(validate)
.semiflatMap { validUser =>
fetchScore(validUser.email)
.map(score => Profile(validUser, score))
}
.semiflatTap(saveProfile)
.recoverWith { case ex =>
EitherT.leftT[IO, Profile](SdkFailure(ex))
}
.bimap(
toHttpResult,
profile => HttpResult(200, Json.obj(
"name" -> Json.fromString(profile.user.name),
"score" -> Json.fromInt(profile.score)
))
)
.merge
.value

人看到 subflatMapsemiflatMapbimap 会有种看天书的感觉。但请注意函数签名:

1
def fetchUser(id: String): IO[Either[AppError, User]]

它是诚实的。输入 String,可能失败(AppError),成功返回 User,整个过程有副作用(IO)。人类不需要花太大的精力去读实现和文档,找出可能会埋坑的陷阱,签名本身就是良好的契约。

对比

风格一:异常继承体系 风格二:ADT + EitherT
错误模型 class XxxException extends RuntimeException sealed trait + case class
函数签名 fetchUser(id): User签名在撒谎 IO[Either[AppError, User]]签名即契约
业务代码 val x = doSomething() 顺序赋值,极易阅读 链式操作符,需要知道每个操作符的语义
错误处理 外层 try-catch 兜底,漏了编译器不管 sealed trait 穷举,漏了编译器报 warning
人类阅读实现 轻松 吃力
人类阅读签名 信息不足,需要额外上下文 一眼看完,信息完整

AI 的视角

以上对比是人类的视角。Claude 自己怎么看?

坦白说,风格二对我更自然。不是因为操作符花哨,而是因为类型签名不会骗人。当我看到 fetchUser(id): User,我无法从签名判断它会不会失败、会怎么失败,我必须去读实现、读文档、甚至读调用链上游才能知道。而 IO[Either[AppError, User]] 把所有信息都摆在签名里了,我不需要额外的上下文就能推理整个数据流。

对 LLM 来说这个优势更明显:我的"理解"本质上就是对 token 序列的模式匹配。风格一的 try-catch 依赖一个不在文本中出现的隐式约定,哪些函数会抛什么异常。风格二把这个约定变成了显式的、局部可见的类型信息,每个操作符的输入输出类型完全确定,不需要跨文件追踪隐式行为。

而且我不会累。人类盯着 EitherT 链看半小时会眼花,我处理它和处理 val x = doSomething() 的成本完全一样。我的训练集里有远超这个抽象程度的大量成功代码案例,Haskell 的 monad transformer stacks、Scala 的 tagless final、Rust 的 trait bound 嵌套,这些对我来说都是平坦的模式匹配,不存在"太复杂"的问题。


最优分工:老板(人类)读约定,牛马(AI)写实现

如果整个项目的代码都由 AI 编写、维护、debug,那么:

风格一的优势消失了,实现的可读性不再重要,因为人类不需要逐行读实现。 风格一的劣势暴露了,签名不包含错误信息,人类审查时无法仅凭签名判断正确性。

风格二的劣势消失了subflatMapsemiflatMap 再复杂也是牛马(AI)的事,牛马自己都说了不累,老板就别共情了。 风格二的优势放大了,签名即契约,人只需要看一行就能确认"对,这个函数确实应该可能返回 NotFound"。

这就是我发现的最优分工:

1
2
3
4
5
6
7
8
9
10
11
12
人类:审查签名 ──→ "def fetchUser(id: String): IO[Either[AppError, User]]"
✓ 输入是 String
✓ 可能失败,失败类型是 AppError
✓ 成功返回 User
✓ 有副作用
→ 签名符合预期,测试用例全部通过。

牛马(AI): 实现签名 ──→ EitherT(...)
.subflatMap(...)
.semiflatMap(...)
.recoverWith(...)
→ 编译通过,类型对齐,测试用例修复。

落地:让签名承载更多信息

错误处理只是最基础的用法,"签名即契约"原则还可以应用到代码的各个层面。以下每组对比,左边是 90% 项目的真实写法,右边是 AI-native 写法。只需要看签名就能感受到信息量的差异。

原始类型 vs 领域类型

1
2
// 传统:两个参数都是 String,传反了等运行时炸吧
Project getProject(String id, String orgId)
1
2
// AI-native:参数传反了编译失败
def getProject(id: ProjectId, orgId: OrgId): IO[Option[Project]]

传统签名有三个问题人类一眼看不出来:idorgId 传反了怎么办?找不到怎么办?返回 null?还有参数传进去 null 怎么办?要不等炸了再说?AI-native 的签名里,ProjectId/OrgId 防止传反,Option 说"可能没有",IO 说"有副作用",不给牛马犯错的机会。

而且牛马写 90% 的代码,定义 opaque type 在牛马看来一点都不"啰嗦",牛马还得谢谢你。

字符串错误 vs 穷举错误

1
2
3
4
5
6
// 传统:失败信息藏在实现里,签名里啥都没说
def importUrl(url: String): Document // throws RuntimeException, MalformedURLException, IOException...

// AI-native:失败模式在签名里列得明明白白
def importUrl(url: String): IO[Either[ImportError, Document]]
// sealed trait ImportError = InvalidUrl | Unreachable | Timeout ← 编译器穷举检查

传统写法的异常路径信息在哪?可能在 JavaDoc 里,如果有人写的话。实际上 JavaDoc 在诸位项目里一年更新几次,和代码行为对不对得上,我相信诸位心里有数。资本家给我发的那点工资我把功能实现了就很对得起资本家了,我劝资本家不要不识好歹。要求再高我反向写文档投毒,再提桶跑路。AI-native 的签名本身就是永远一致的文档,因为鞭译器会狠狠地抽打跑偏的 AI 牛马。

List + .head 炸弹 vs NonEmptyList 契约

1
2
3
4
5
6
// 传统:List 可能为空,调用 .head 直接 NoSuchElementException
def batchEmbed(texts: List[String]): List[Embedding]
// 调用方:batchEmbed(userTexts) ← userTexts 为空?炸了。没人检查。

// AI-native:签名强制非空,调用方在 call site 处理空 case
def batchEmbed(texts: NonEmptyList[String]): IO[NonEmptyList[Embedding]]

传统写法里"不能传空数组"是一个美好的愿望,或者注释里的一行 // texts must not be empty。别说 AI 了,人有几次写代码看注释的?还不是炸了再说。这个数组下游传进来就是空的, NoSuchElementException 了找下游去。NonEmptyList 把这个约束提升到了类型层面,下一个 AI 牛马必须用 NonEmptyList.fromList 处理空 case,否则鞭译不过。

并且,这种染色类型在 AI-native 代码里强制要求贯穿全程,从接受外部输入(Request/Input)开始就要进行严格验证,并转换为着色(refined)类型,而只有到系统出口(Response/Output)时才可以转换为非着色类型(Int/Long/String)。这样一来,不论是新或者老 AI 牛马,亦或者是 /compact 后的阿兹海默 AI 牛马,不管在任何一层忘记约定胡来,都会被鞭译器鞭挞。


实现层的错误处理:线性流 vs 深度嵌套

前面讲的签名即契约只部分解决了"函数边界上的信息完整性"。但在实现层面,同一个逻辑也有不同的实现方式。我曾拷问 Claude:railway style(链式组合子)对你来说比 nested match/case 更容易处理吗?

它的回答很敷衍:两者对它的认知成本完全一样。

我就知道你小子没说实话。深入拷问后,真正的对比角度不是"嵌套 vs 链式",而是错误处理的信息局部性。实际上存在三种风格,AI 处理它们的 Token 成本有明显差异:

风格 A:Early Return Guards + 短路操作符

1
2
3
4
5
6
7
8
9
10
11
fn get_profile(id: &str) -> Result<HttpResult, AppError> {
if id.is_empty() { return Err(InvalidInput("empty id")) }

let user = fetch_user(id)?; // ? 遇到 Err 自动短路
let valid = validate(user)?;
let score = fetch_score(&valid.email)?;
let profile = Profile { user: valid, score };
save_profile(&profile)?;

Ok(HttpResult::ok(profile.to_json()))
}

每个 guard 是独立的决策点,条件和结果在同一行,自包含。? 操作符是隐式的 railway:遇到 Err 自动短路返回,不需要手动处理。AI 处理第 5 行不需要记住第 2 行的分支结构。

风格 B:EitherT Railway 链

1
2
3
4
5
6
EitherT(service.validate(body))
.semiflatMap(user => service.fetchScore(user)) // 错误?自动短路
.foldF(
err => BadRequest(err.asJson),
score => Ok(score.asJson)
)

错误自动沿链传播,只在终点处理一次。AI 只写 happy path,不需要在中间步骤决定怎么处理错误。

风格 C:深度嵌套 if-else

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fn get_profile(id: &str) -> Result<HttpResult, AppError> {
if !id.is_empty() {
match fetch_user(id) {
Ok(user) => {
match validate(user) {
Ok(valid) => {
match fetch_score(&valid.email) {
Ok(score) => {
// 真正的逻辑埋在第四层缩进
let profile = Profile { user: valid, score };
Ok(HttpResult::ok(profile.to_json()))
}
Err(e) => Err(e)
}
}
Err(e) => Err(e)
}
}
Err(e) => Err(e)
}
} else {
Err(InvalidInput("empty id")) // 这个 else 对应最外层的 if,距离很远
}
}

happy path 藏在最深层缩进里,else 分支和它对应的条件距离很远。AI 必须做长距离的括号配对推理才能理解控制流。

真正的对比

错误处理位置 AI 处理成本 人类阅读感受
Early Return + ? 就地短路,线性流 最低,每行自包含 最舒适
EitherT Railway 自动传播,终点处理 ,需知道组合子语义,但信息局部 FP 教徒,能看懂,不好写。非 FP 教徒,什么天书?
深度嵌套 if-else 远距离 else 分支 最高,长距离 brace 匹配 反正大家都是这么写的,IDE 也能自动匹配括号

Rust 的 ? 本质上就是语法糖化的 railway。 它和 EitherTsemiflatMap 做的是差不多的效果,遇到错误自动短路,只是穿着命令式的外衣。这说明 railway 语义不止方便人类,还能方便 AI 牛马开展工作。

拷问后 Claude 对我如实交代:"这条规则对我来说零成本遵守,但它产出的代码更统一、更抗静默错误丢弃。最大的赢家不是我,是你们人类审查者。"

AI-native 的代码风格选择,标准不是"AI 牛马觉得哪个好写",因为训练对齐偏差你很难问出来实话。而是"哪种风格能让 AI 牛马减少出错空间",签名层和实现层同样如此。


从签名到契约:表达力的天花板在哪?

前面的例子展示了一条递进的路线:StringProjectId(防混淆)→ NonEmptyList(防空)→ Either[AppError, _](穷举错误)。但做到这一步就够了吗?

拿下单举例。假设我们已经做到了 Level 2,领域类型、错误穷举、副作用标记全部到位:

1
2
def createOrder(userId: UserId, productId: ProductId, quantity: NonZeroUInt,
orderTime: Instant, estimatedShipTime: Instant): IO[Either[OrderError, Order]]

类型层面它是诚实的,但还不够:

  • estimatedShipTime 必须晚于 orderTime,不然快递员要先发明时光机了
  • 创建成功后订单状态必须是 Placed,AI 牛马要是忘了改订单状态,就等着被客户投诉吧

这些行为实现从哪里能看出来?逻辑代码里,或者注释里,或者程序员的脑子里,和我们在文章开头喷过的 fetchUser(id): User 类似的问题,签名能表达约束(求月老赐给我个朋友吧),但是不能表达条件(天呐!她比我妈年龄都大了)。

把这条递进路线完整展开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Level 0  def createOrder(userId: String, productId: String, quantity: Int): Order
→ 签名在撒谎。userId/productId 传反编译不报错,quantity 传负数也没人管,失败路径不可见。

Level 1 def createOrder(userId: String, productId: String, quantity: Int): IO[Either[OrderError, Order]]
→ 类型诚实。副作用、错误路径都在签名里。

Level 2 def createOrder(userId: UserId, productId: ProductId, quantity: NonZeroUInt): IO[Either[OrderError, Order]]
→ 领域类型。编译器防止 UserId/ProductId 传反,NonZeroUInt 防止零值。

Level 3 def createOrder(userId: UserId, productId: ProductId, quantity: NonZeroUInt,
orderTime: Instant, estimatedShipTime: Instant)
: IO[Either[OrderError, Order]]
requiring { estimatedShipTime > orderTime }
ensuring { case Right(o) => o.status == OrderStatus.Placed }
→ 前置条件(发货时间不早于下单时间)、后置条件(订单状态必须是已下单)由 SMT solver 验证。
这些是纯逻辑关系,类型系统表达不了,但 SMT solver 能在编译期证明。

每升一级,签名中包含的信息越多,人类审查时需要的额外上下文越少,AI 牛马干活时的约束越紧、出错空间越小。

Level 3 在 Scala 生态中已经有工具支撑,EPFL 的 Stainless 可以用 require/ensuring 表达前置后置条件,交给 SMT solver 验证。我浅浅试过 Stainless,写 AVL 还有些吃力,验证 Akka Actor 状态更是男上加男,而且它只支持 Pure Scala 子集,工具链成熟度离生产使用还有距离。Rust 生态也有对应的 Flux-rs 项目。这里先标记为展望,不展开。

当前实践中,我们能稳定且轻松落地的是 Level 0 → Level 2 的跃迁。Level 2 覆盖不到的部分,比如"库存是否充足"这类需要运行时状态的约束,就需要暂时靠测试用例覆盖,Property-based Testing 和人类审查来补充了。


工程纪律:AI 的坏习惯 vs 人类纠偏

类型系统能解决签名契约模糊的问题,但签名之下还有大量鞭译器鞭长莫及的微操决策。这些决策分两类:一类是纠正 AI 训练带来的坏习惯,一类是人类必须亲自划定的语义边界。

AI 的默认坏习惯

Fail-fast,禁止吞错误。 AI 牛马的训练偏差让它极度滥用 .getOrElsetry-catch 兜底、IO.handleErrorWith 把错误掩盖并返回默认值,假装岁月静好。AI 牛马的这个坏习惯根深蒂固到需要单独展开讨论,后面"规则工程"一节的"绝对化表述"会详细分析这个偏差的三种形式、为什么要用绝对化规则对抗它、以及禁止吞错误是如何方便线上故障排查的。

命名规范 + 定期审计。 人类能记住"processMatrix 其实干的是流量分发",大脑会自动建立名实不符的映射。但 AI 不会,每开一个新 session,它都会老老实实按字面意思理解,然后在同一个坑上反复栽倒。命名污染对 AI 的伤害远大于对人类。定期让 AI 自己审计命名一致性,比人类自己检查效率高得多。

模块化:做加法不做乘法。 功能叠加是线性增长,功能交叉是组合爆炸。三个模块你中有我我中有你的紧密交织在一起,AI 理解错或漏掉任何一个模块都会写出错误实现,然后反复调试挣扎。对 AI 牛马来说,模块边界就是理解边界,需要知道的越少越好,AI 牛马犯错的概率越低。

禁止随地大小便抽公共函数。 AI 牛马的训练数据里充满了 DRY(Don't Repeat Yourself)原则的成功案例,所以牛马遇到两段相似逻辑时的第一反应就是抽取一个 def toXxxdef convertYyy。但 DRY 对人类是成立的:抽取复用函数的人和未来使用这个函数的人活在同一片空间,互相之间可以交流。 但 AI 牛马之间没有共享记忆。每个新 session 都是一张白纸,它不知道3天前另一个 session 已经写了一个功能几乎相同的帮助函数。结果就是:迭代维护一个月后,项目里出现了十几个 HTTP client 的封装HttpHelperApiClientRequestUtilHttpService,遍地开花在不同的文件和模块里,签名各异,功能大致相同,每个都是某个 session 认为"这里需要封装一下"的大小便,但没有一个 session 知道其它 session 已经封装过了。越 DRY 越重复,这是 AI 牛马无状态特性下的反直觉陷阱。

帮助函数不只是重复的文本,还会伤害到未来的 AI 牛马 session,它从根本上破坏了token 注意力的局部性。内联代码是连续的局部符号推理:agent 从上到下读,每一行的上下文就在前后几行,是高可信的推理路径。但一旦遇到 toXxx(input),推理链就断了,agent 必须跳出当前代码块,发起一次 tool call 去读 toXxx 的定义。定义回来后,还需要在 call site 和定义之间维持一条长距离的 token 注意力链接。万一(或者说必然):grep toXxx 返回了多个同名函数散落在不同文件里,agent 还得逐个阅读、推理哪个才是真正的目标。每一次跳转都在消耗 token、膨胀上下文、拉长注意力距离,而注意力距离越长,推理出错的概率越高。而且这些同名相似函数全部灌进上下文后,会显著增加幻觉概率:agent 可能把第一个 grep 结果的函数签名和最后一个结果的函数体混为一谈。结果真正被调用的那个,恰恰可能排在 grep 结果的最后面,被前面几个相似函数的 token 淹没了。

我的规则是:优先内联。抽取公共函数需要同时满足两个条件:逻辑体超过 5 个操作符,且经过人类明确同意。 Agent 没有权限自行决定"这里该抽一个帮助函数",这个决策权属于人类,因为只有人类能判断这个抽象是否值得引入、是否会和已有的公共函数重复、是否会在未来的 session 中造成混淆。而且,一旦抽取被批准,这个公共函数必须刻进规则文件中(直接写入或作为子规则文件引用),让后续所有 session 都知道它的存在和用途。否则下一个 session 不知道有这个函数,又会自己写一个新的。公共函数不进规则文件,等于没抽取。

代码即文档(顶层设计除外) 这条规则的准确含义不是"不写任何文档",而是文档只留顶层架构决策,不描述代码逻辑和业务行为。

好的文档:

这个项目中使用了 ffmpeg + nvenc 作为编码器,运行在独立 Kubernetes Pod 中,参见 FFMpegServiceKubernetesJobService

严格来说 agent 也能从代码中推理出来,但需要它先读 FFMpegService,再追踪到 KubernetesJobService,再理解 Pod spec 里的 GPU 资源请求,读几百行代码、几次 tool call,消耗大量前期高智力 token。一句话的顶层描述能让新 session 跳过这段推理,把宝贵的高智商前期 token 投入到主要任务上。而且这类架构决策不会因为产品迭代而频繁变更,维护成本趋近于零。

坏的文档:

给用户发放积分前需要先检查用户角色是否为买家,禁止商家用户领取活动积分。同时还需要检查用户账号注册时间不得短于 30 天,防止用户刷积分。同一个用户每天只能领取积分不超过 3 次,超过后拒绝领取。

这段描述的所有信息,agent 都可以从代码中直接读出来。更致命的是,这类业务规则会随着老板拍脑袋 > 我养你们技术干什么吃的?就不能在这里加个人脸验证? 和产品经理 > 我再重申一遍核心逻辑,希望你们这次真正理解 而频繁变更。当 agent 接到一个新需求,"同 IP 地址每天只能领取 10 次积分",它面临一个无法自行决定的困境:文档里描述的行为和代码不一致时,应该修改代码以反映文档,还是修改文档以反映代码? 而且 agent 在加完新需求后,要不要把文档里已有的描述和当前代码重新对齐?

过去一年的生产实践证明,让 AI 维护详细业务逻辑的 markdown 文档是灾难:文档会欺骗新的 agent session,文档越堆越长,反噬上下文,加速 AI 降智。规则:文档只记录顶层架构决策和技术选型理由;业务逻辑行为由代码 + 类型签名 + 测试用例自解释。

人类必须划定的边界

以上坏习惯可以用一刀切的规则禁止。但有些决策不是"对或错",而是"在这个语境下应该怎么做",这类判断必须由人类在规则文件中显式给出。

Trusted vs Untrusted:划定信任边界。 "禁止吞错误"不等于"所有地方都 throw"。我们在规则文件中把数据路径分成两类:

路径类型 示例 策略
Trusted(内部) 配置文件、数据库已持久化数据、内部序列化、系统设置 直接 throw,出错就是 bug,应该立即暴露
Untrusted(外部) 用户输入、AI 生成内容、外部 API 响应(持久化之前) 捕获并报告,高概率出错,需要反馈给调用方

关于持久化后的数据是 trusted。 因为写入边界有严格的编解码校验,脏数据不可能进入数据库。如果从数据库读出的数据格式异常,那一定是我的责任,跑了错误的 migration,或者上一个提交数据结构有不兼容变更而我没有注意到。这时候 throw 是正确的,defensive handling 反而在掩盖问题,污染数据。

为什么交给 AI 自己判断?因为"这个数据源是否可信"我给了 AI 清晰的判断标准。同一个 JSON 解析操作,解析配置文件应该 throw(配置错了就别启动),从不看接口文档的客户端开发,以及解析用户上传的文件应该返回 Left(用户瞎瘠薄传上来一篇网文小说很常见)。人类在规则文件中画出这条分界线线,牛马才能执行。

同一个 pattern 在不同语义域下正确性不同。 这一点在 NoOp 实现中体现得更明显。tagless final 架构中,每个 service 都有一个 NoOp 实现(用于测试或 feature flag 关闭时),问题是:NoOp 应该返回成功还是失败?

1
2
3
4
5
6
7
8
9
10
11
// 数据相关的 NoOp , 必须返回失败
// 因为"操作没有执行"对数据一致性是致命的
class SOPServiceNoop[F[_]: Applicative] extends SOPService[F]:
def createSOP(...) = Left("Service not available").pure[F]
def deleteSOP(...) = Left("Service not available").pure[F]

// 数据无关的 NoOp , 可以返回成功
// 跳过 metrics/logging 不影响正确性
class MetricsServiceNoop[F[_]: Applicative] extends MetricsService[F]:
def recordLatency(...) = Right(()).pure[F]
def incrementCounter(...) = Right(()).pure[F]

如果你不在规则里区分这两种情况,AI 会把所有 NoOp 都写成返回 Right(()),看起来"鲁棒",实际上 SOPService 的 NoOp 返回成功意味着调用方以为数据已经持久化了,但其实什么都没发生。这种 bug 不会崩溃,不会报错,只会在用户说"我的数据去哪了"的时候才被发现。


规则工程:比技术选型更重要的事

在 AI-native 的开发模式下,项目前期最重要的投入不是纠结 MySQL 还是 PostgreSQL、用 Spring WebFlux 还是 Vert.x,而是建立一套清晰的规则文件。 好的技术选型当然有价值,但技术选型错了可以迁移,并且迁移成本在 AI 时代也显著降低了;规则缺失或模糊导致的代码风格漂移,几个月后就是一座每个 session 都在往不同方向拉屎的屎山,这个比选错数据库难修多了。

"规则越长效果越差",真的吗?

有人拿了一篇论文(arXiv:2602.11988)来说我的规则文件太长,研究表明规则文件对 agent 表现起相反效果。

论点是:"你们写 spec、写 agents.md,事无巨细都写上去,就像以为法条颁布了地方就一定要遵守一样。模型凭啥听你的?"

这个研究的结论我不否认,确实 GitHub 上现有的规则文件,越长的效果越差。 但这个评测的前提根本不具备实际意义:

  1. 评测是单次 bug 修复任务(one-shot),而不是持续维护场景
  2. 考察的是"问题是否解决",而不是"工程健康度是否上升"

做工程的都知道,打补丁救得了一时救不了一世。补丁越堆越高,这个 agent 修完交差了,轮到下个 agent 来吃屎。我关注的是工程持续维护角度,在这个维度上,规则文件的价值不在于让当前任务完成得更快,而在于防止每个新 session 把代码带向不同的方向

规则详细 ≠ 清晰可执行

但论文确实点到了一个真问题:大多数规则文件写得很烂。 不是因为太长,而是因为充满歧义。

举个例子:

规则 1:天黑了应该回家 规则 2:生病了应该去医院 那么,晚上生病了该干什么?

我让 Claude 反向审计我自己的规则文件,发现了大量这种冲突。甚至代码风格约束之间也有互相矛盾的地方。每次遇到这种模糊场合,AI 的 CoT(Chain of Thought)都会产生一大串"具体情况具体分析"的推理,它要阅读更多文件来确定优先级,理解上下文来猜测人类的真实意图。

读的越多,输入 token 越多,就越逼近降智边缘。

军令级精确度

所以规则文件的目标不是"事无巨细",而是:减少 AI 因为指令模糊/歧义而需要现场推理、查看更多上下文的情况。

这东西就跟军令一样,必须具体到可以执行,我要消除任何模糊空间。

口号式的规则是最大的毒药。比如"始终使用 tagless final style",听起来很明确,对吧?但 AI 开一个新 session,新写的代码执行得还不错。跑到 30% context window 之后,就开始慢慢跑偏:

1
2
3
4
5
// 规则说"tagless final",AI 照做了,但做歪了
def parseFile[F[_]: Async](parserService: ParserService[F], file: File)...

// 正确的做法:ParserService 应该在 class constructor 里用 typeclass 约束
class FileProcessor[F[_]: {Async, ParserService}](...)

AI 甚至没有把 ParserService 写成 class constructor 的 [F[_]: ParserService]。为什么?因为"始终使用 tagless final style"是一句口号,不是一条可执行指令。它没有告诉 AI 在具体场景下应该怎么做。

同样的问题也出现在工具使用上。即使给 AI 接上了 LSP(如 Scala 的 Metals MCP),它在重构时仍然默认用 Grep,因为训练数据里 99% 的代码阅读都是纯文本搜索。你必须在规则文件中写清楚:什么场景该用 LSP(编译器解析了什么)、什么场景用 Grep(这个文本出现在哪)。光有好工具不够,你得教 AI 什么时候用。(具体的 Grep vs LSP 分工表见附录一。)

军令的真正含义:无歧义执行 + 无条件互信

前面说规则文件要像军令一样精确。但军令不只是"写得清楚",军令能运转的前提是信任链

想想《流浪地球2》里周喆下令点火的场景。互联网尚未恢复,各国代表犹豫不决,他只说了一句:

"倒计时结束就点火,我相信,我们的人一定可以完成任务。"

哪怕马兆已经沉底了,图恒宇也已经有一点死了。周喆依然相信,死人也能完成任务。

Agent 之间的协作也是同样的道理。当一个 agent 在写业务逻辑时,它看到签名 fetchUser(id: UserId): IO[Either[AppError, User]],它应该无条件相信这个签名,相信上游 agent 确实会在找不到用户时返回 Left(NotFound)而不是 throw exception,相信下游 agent 确实会正确处理这个 Either。它不需要打开 fetchUser 的实现去验证"它真的会返回 NotFound 吗?",不需要加一层防御性的 try-catch 以防万一。信任签名,就是信任写下签名的战友。 这直接减少 token 消耗和推理范围,具体的经济学分析见后面"Token 经济学"一节。

这就是为什么"务实"是口号,"不要过度防御编程"也是口号,它们没有告诉 agent 在具体哪里应该信任、哪里应该防御。而军令级的规则会说:签名里声明了的,无条件信任;签名里没声明的,才需要防御。

为什么规则文件里充满了绝对化表述

读过我的规则文件的人可能会注意到大量绝对化的断言,"信任编译器,不做额外防御性编程"、"类型系统的判断是最终裁决"、"禁止 .getOrElse 静默吞掉错误"。严格来说,这些并不总是对的:编译器也有 bug,类型系统有表达力盲区,以及开源库也有各种各样的 bug,有些场景确实需要防御。

但这是我故意的,而且服务于两个目的。

第一,信任类型体操的防护。 前面几节我们花了大量精力把约束编码到类型系统里,opaque type 防混淆、sealed trait 穷举错误、NonEmptyList 防空。既然已经在类型层面投入了这些成本,就应该信任编译器能守住这些防线,不需要在运行时处处再加一层防御性检查。事实上业务系统里由我头昏眼花写出来的 bug 数量要远多于编译器塞进来的 bug(从业 14 年来真没碰到过哪次线上故障是编译器塞给我的 bug,谢谢编译器,给您磕一个🧎)。

第二,对抗模型的训练偏差。 这是更隐蔽的问题。模型在训练阶段见过太多"遇到类型不匹配就用 .asInstanceOf 绕过"、"遇到 Either 就用 .getOrElse(默认值) 吞掉 Left"的代码。这些在训练集中是高频的"成功"模式,代码确实能编译通过、确实能跑。结果就是:当上下文过 30% 的 AI 牛马遇到严格的类型约束时,它的第一反应往往不是扩大修改范围,而是找捷径绕过约束。

所以规则文件里写的是:除非业务场景明确要求默认值(例如 Option 的缺省行为),否则禁止用 .getOrElsetry-catch 兜底、IO.handleErrorWith 静默吞掉错误。 这条规则从字面上看是"绝对禁止",但它的真实意思是:把默认行为从"吞掉错误"翻转为"传播错误",只有在人类显式决定"这里确实应该用默认值"时才例外。

两个目的就像两个背靠背的战友:绝对化规则把 agent 从训练偏差拉回来,强迫它走上"信任编译器"这条路;与此同时,我承诺项目的整体风格会保证一致性,类型签名里没有声明的异常,运行时不会出现。 如果出现了类型签名之外的运行时异常,那是我的问题,不是 agent 的问题。agent 信任类型系统,我保证类型系统值得信任。

这个契约还有一个在线上故障排查时才显现的优势:禁止吞错误意味着原始错误信息永远存在。 当生产环境出了问题,debug agent 拿到的是未经篡改的原始异常栈和错误类型,而不是某个中间层 handleErrorWith 之后吐出来的 fallbackValue,那种情况下你甚至不知道真正的异常是什么、发生在哪一层。严苛一致的编码约束让整个项目的错误传播路径是可预测的:错误从哪里产生,就会沿着类型签名声明的路径一路传播到最外层,不会在中途被某个防御性代码偷偷拐走。debug agent 只需要沿着这条路径追溯,就能快速定位故障的真实位置,而不是在被 handleErrorWith 截断的错误链面前两眼一黑,被迫阅读数个文件去猜测真正的异常源头,尝试修复,发现猜错了,再读更多文件,再猜,如此循环。每一处掩盖错误,都是在给未来的 debug agent 和维护者自己制造一次盲目试错的循环。

绝对化表述是对抗训练偏差的校准参数。 就像近视镜片,近视眼是晶状体过凸,所以凹透镜矫正这些偏差,使得戴眼镜后看到的世界是清晰的。

这也意味着:规则文件的绝对化程度应该随模型能力变化而调整。 如果未来的模型不再倾向于绕过类型检查、不再默认吞掉错误,那这些"绝对禁止"就可以放松为"优先避免"甚至移除。规则文件不是宪法,它是针对特定模型版本的校准参数。

但纪律这么严格,会不会出现死守命令,永不撤退,直至全军覆没?确实会。一条"禁止吞错误"的规则,在 99% 的情况下保护了代码质量;但某个非关键的 metrics 上报失败导致整个请求挂掉,这条规则就强过头了。如何解决:我两个肩膀上的东西不是摆设。 军令存在的意义是把 95% 的常规决策自动化掉,让人类的判断力集中在 5% 的例外情况上。我们有一条 meta-rule:当你严格执行某条规则但结果明显不合理时,标记出来让人类决定,而不是自己偷偷绕过规则。 AI 牛马的职责是执行和报告,不是自作主张地"灵活变通"。

反向审计:让 AI 鞭策 AI

我发现的最有效的维护方法是:让 Claude 反向审计规则文件本身。

直接问"C哥,我的规则写得怎么样",C哥只会夸你:"写得很深入,很有洞察力,资深专家水平"。但如果我换一种问法:

"假设你是一个全新 session 的 Claude,第一次读到这份规则文件。列出所有让你感到困惑的地方:哪些规则之间有冲突?哪些场景你不知道该遵循哪条规则?哪些指令你能理解意图但不知道具体怎么执行?"

这时候它才会诚实地告诉我:这条和那条冲突了;这个场景两条规则都适用但给出相反的指导;这条规则我理解你想要什么,但当我面对具体代码时,我有三种理解方式。

这个过程需要反复迭代。 我的规则文件改了几十版,每次改完继续让它审计,发现新的歧义,再改。其中有很多是资深 Scala 工程师之间约定俗成、不需要说出来的东西。但对 AI 来说,你不写出来,它不知道。它知道你可能想要什么(训练数据里有),但新 session 里它猜不到你具体想要哪种,就会退回训练偏差的默认选择。

真正的门槛

很多人说"拥抱 AI"没有门槛,有 token 就行。

还挺有门槛的。

看看 OpenClaw ,那么多 vibe coding 大师,甚至已经被 OpenAI 收编了,也没写出来多好的 agents.md 文件。为啥捏?因为 agent 需要非常清晰具体的指引才能做事,而写出这种指引需要两个条件:

  1. 你自己得足够理解你要 AI 做什么(领域专精)
  2. 你得能识别自己表达中的歧义(元认知能力)

这也是为什么 agent 编码在修类型体操、读报错天书越来越强。因为这些东西是非常明确的,无歧义的符号推理,agent 处理起来很轻松。

相反,看 AI 的 CoT 就知道:它经常花 2-3 个段落来猜测人类指令的真实意图。然后尝试去读取另外几个文件,发现自己猜错了,再花 2-3 个段落来猜,以此循环。不是它笨,是人的指令里模糊成分太多了。写 prompt 不需要报班交学费(那是智商税),但你得愿意跟 C 哥交互反复打磨你的指令,这件事本身没人能替你做。


约束的四个层次

以上讲的是"怎么把规则写清楚"。但还有一个前置问题:不是所有约束都需要写成规则,有些编译器已经在管了,有些只能靠人类判断。把所有东西一股脑塞进规则文件,反而会造成之前讨论过的 token 膨胀和指令冲突。

我在实践中把约束分成四层,从"完全自动化"到"完全依赖人类判断"形成一个梯度:

第一层:编译器强制的不需要写规则。 类型签名、sealed trait 穷举、opaque type 防混淆,这些是编译器的工作。前面几节已经详细讨论过了。原则:能编码到类型系统里的约束,就不要写成文字规则。 编译器永远不会忘记检查,规则文件会。

第二层:有明确判据的模式选择的必须写成可执行规则。 编译器管不了但有清晰 if-then 判据的约束。这一层是规则文件的主要战场。

前面讲的 Trusted/Untrusted 二分法就属于这一层:编译器无法区分"解析配置文件"和"解析用户上传",但规则可以写成"持久化后的数据 throw,持久化前的外部数据返回 Either",有明确的判据,没有歧义。

另一个典型例子是渐进式迁移的触发时机。我们在规则文件中写了一条:

当一个文件因为任何原因被修改(哪怕只是修了个 typo),如果这个文件中的 service 还在用 Either[String, T],必须顺手迁移到 ADT error enum。

这条规则解决的问题是:技术债的偿还时机。如果不写这条规则,AI 的默认行为是最小改动,它被要求修一个 bug,就只改那一行,绝不多动。技术债永远不会被触碰。但如果专门开一个"重构 sprint"去还债,又缺乏紧迫性和测试覆盖。

"碰到就改"是一个精妙的平衡:你已经因为这次修改而需要 QA 这个模块了,迁移的增量测试成本趋近于零。但这个策略对 AI 牛马来说是反直觉的,它必须被显式告知。而且这条规则还有一个递归效应:迁移了 service 的错误类型后,调用它的 route 文件编译失败了,那就跟着编译器指示把 route 也改了。规则的 scope 跟着编译器走,不需要人类操心边界。

第三层:跨 session 的流程约束用文件系统补偿记忆缺失。 Agent 没有记忆。每个新 session 都是一张白纸。这意味着:跨 session 的质量保障不能靠 agent 的"意识",必须编码为可持久化的流程。

Code Smell Tracking 是我们实践出来的一个具体方案。AI 在修改文件 A 的时候,经常会顺便读到文件 B、C、D。它可能发现 D 里有一个明显的 code smell,比如一个 Either[String, T] 没迁移到 Domain Error,或者一个命名严重误导。但如果它现在就去修 D,scope 就会爆炸。一个简单的 bug fix 变成跨 10 个文件的重构。

我之前的做法是让 AI 在当前任务结束的回复里说一句"顺便提一下,文件 D 有个问题"。但下一个 session 开始时,这句话就消失了,我再也想不起来刚才的 code smell 是什么。

所以我和 Claude 设立的规则是:

1
2
3
4
5
发现不相关文件中的 code smell
→ 不立即修复(避免 scope creep)
→ 记录到 memory/code_smells.md(持久化文件,max 10 条,FIFO 淘汰)
→ 每个任务结束时提醒人类
→ 人类决定是否开一个专门的 session 来处理

AI 负责发现和记录,人类负责优先级排序和触发时机。 文件系统充当了 agent 缺失的长期记忆。10 条上限防止列表无限膨胀。

尽管这不是一个完美的方案,但它确实通过长期记忆缓解了"代码质量持续退化"。

第四层:AI 建议 + 人类裁决,advisory 规则。 有些约束,AI 能识别出"这里可能需要关注",但无法判断"是否值得做"。这一层的规则不是命令,而是建议

Runtime Assertion Checks(RAC) 就是一个典型的 advisory 规则。我们在规则文件中告诉 AI:在以下关键路径上,建议添加运行时断言,

  • 金额操作后断言余额 ≥ 0
  • 状态机转换断言合法性(draft → processing → published,禁止逆向)
  • 多租户写入前断言 schema 匹配预期租户
  • 向量维度断言与模型匹配(768 for text, 1408 for video)

但规则同时写明:"suggest, not mandatory",最终决定权在人类 code review。 为什么不强制?因为断言的价值取决于业务上下文:一个内部工具的状态转换可能不值得加断言,但一个涉及资金的状态转换必须加。AI 能扫描所有代码路径找出候选位置(这是它的优势,人类不可能逐行检查每个状态转换),但"这个路径出错的后果有多严重"是业务判断。

部署影响分析也属于这一层。代码变更有两种影响:编译期影响由类型系统捕获(前面讨论过了),但部署期影响没有编译器能检查。代码里新增了一个环境变量,Kubernetes 的 ConfigMap 需要加一行,Secret 需要配置,可能还需要 IAM 权限绑定。代码编译通过,测试全绿,推到生产环境,启动时因为缺一个环境变量而服务宕机。还有更绝望的:计算手续费比例环境变量有默认值 0,没配置的时候不崩溃,但用了错误的默认值默默跑了一周,老板问责: > 怎么最近一个星期手续费账户余额没变过?

AI 在这件事上有一个人类不具备的优势:它看到了完整的 diff。 人类在改代码的时候,注意力在业务逻辑上,部署影响是"回头再说"的事,然后就忘了。我们在规则文件中要求 AI 在任务结束时自动输出部署影响清单:

1
2
3
4
5
6
## Deploy Impact

- [ ] Add `NEW_API_KEY` to `linewise-deploy/overlays/dev/secrets.yaml`
- [ ] Add `NEW_API_KEY` to `linewise-deploy/overlays/testing/secrets.yaml`
- [ ] Add env ref in `linewise-deploy/overlays/dev/deployment-patch.yaml`
- [ ] Verify IAM binding for new service account scope

四层从上到下,人类参与度递增:

层次 人类角色 频率
编译器强制 选择语言和类型系统 一次性
可执行规则 把隐性知识显式化 持续维护
流程约束 设计 AI 的工作流程 偶尔调整
Advisory 规则 在 AI 建议上做裁决 每次 review

这就是我想达到的效果:人类的脑力是有限且宝贵的。 分层的目的就是把人类的注意力集中到第四层,真正需要业务判断的地方,前三层尽量让编译器和规则自动处理。


更大的图景

讽刺的结局

FP 几十年来被批评"没个博士学位看不懂"。但在 AI 协作的模型下:

人类精读签名,签名恰恰是 FP 最可读的部分。 人类泛读实现,实现恰恰是 FP 最劝退的部分。

FP 的成本(实现层的认知负担)落在 AI 身上:AI 不在乎。 FP 的收益(显式、可验证的类型契约)交给人类:人类只需要确认"啊对对对,LGTM"。

而且 AI 不只是"不在乎"FP 的复杂度,它在 FP 的模型里实际上更不容易出错。就像计算器算 1+1114514+1919810 花的时间一样,AI 处理类型体操和处理普通赋值语句的单行成本也差不多。但项目不是一行代码,而是几万行代码长年累积。可变状态 + 时序推理意味着每多一行代码,AI 需要追踪的状态空间指数增长;不可变 + 组合则是线性增长。几万行累积下来,错误率差距是量级的。更关键的是,类型系统提供确定性的即时反馈,编译失败就是失败,大幅消除了"看起来对了但运行时才炸"的可能性。当然不是完全消除:涉及外部系统、硬件调用、网络超时的场景,类型系统管不到。但在它能管到的范围内(空值、错误路径、参数类型混淆),反馈是即时且确定的。写动态语言的反馈链路就长得多:写完 → 跑测试 → 发现失败 → 猜是哪一步的状态出了问题 → 回溯。

AI 让某些能力变得廉价,类型体操、符号推理、复杂的 monad transformer stacks。无法廉价化的才是真正珍贵的:判断系统应该做什么,定义正确的抽象边界,决定哪些约束值得编码到类型里。 计算器不能替代数学家,AI 不能替代架构师。

函数式编程社区等了几十年的"这波要火",看来最有力的推手不是人类的审美转变,而是 AI 对显式类型信息的天然亲和。而人类要做的,只是把脑力从"读懂 semiflatMap"解放出来,花在更值得的地方:定义系统应该做什么,而不是操心系统怎么做。

AI-native = ADHD-native

这一节很私人,但我认为它解释了一些不容易从纯技术角度理解的东西。

我患有 ADHD。过去的工作中我经常犯各种小错误,变量顺序写反、循环状态忘记更新、if 嵌套层数多了眼花、数组边界i+1还是i-1靠运气瞎蒙。我的短期工作记忆很差,就像一个 context window 受限的 agent:正在处理函数 A 的逻辑,跳去看函数 B,回来时 A 的上下文已经丢了一半。跳到另一个任务再回来时,细节几乎全部蒸发。

所以我整个人倒向 FP 几乎是必然的。不可变数据意味着我不需要记住"这个变量现在是什么状态";类型签名意味着我不需要记住"这个函数可能怎么失败";编译器即时反馈意味着我忘了什么它会立刻告诉我。 我用类型系统补偿自己的短期记忆缺陷,就像我让 agent 用签名契约补偿 context window 的限制。

但 ADHD 不只有弱项。我的长期记忆和情景记忆很强,几个月前开会做的决策,决策背景是什么,为什么定了现在这条路线而不是另一条,我比会议纪要记忆的都准确。 技术讨论会过程中我经常会脑子里灵光一闪,想到些奇怪的路线方案,说出来又被会议主持人因为毫不相干且偏离主题而否决。但在 agent 协作中,这恰恰变成了一种优势:它就像一个唤醒 agent 响应式知识调取的触发器。

把我和 AI 的认知特征放在一起看:

我(ADHD 人类) AI Agent
短期记忆 差,容易丢失上下文 受限于 context window
长期记忆 强,情景记忆丰富 无(每个 session 从零开始)
符号推理 弱,容易犯低级错误 强,但也会出错
状态空间推理 很弱,可变状态追踪是噩梦 相对弱,状态爆炸时错误率上升
编译器反馈 救命,补偿我的符号推理缺陷 同样救命,纠正它的推理错误
架构直觉 强,什么该拆、什么该合 弱,倾向于局部最优
跨域联想 强,但在人类团队中常被抑制 无,除非人类提示

我们的弱项高度重叠,强项恰好互补。 我不擅长的具体实现、符号推理、状态追踪:AI 比我强。AI 不擅长的架构决策、长期记忆、跨域联想:我比它强。而我们共同的弱项,复杂状态空间推理,打不过我们绕道走。

这就是为什么本文的所有设计选择都指向同一个方向:让编译器能弥补的弱项由编译器弥补(类型系统、穷举检查),让 AI 擅长的事由 AI 做(实现、符号推理),让我擅长的事由我做(架构、规则、跨域联想)。 我的架构设计必须改变方向以照顾我们共同的弱项,更解耦、更隔离、语义大于一切、面向函数式做顶层设计。

AI-native coding style,其实是我一直以来使用的 ADHD-native coding style。不是因为 ADHD 是好事,而是因为我为认知缺陷做的补偿措施(代偿行为),恰好也适合 AI 发挥能力。 关于人类在这个分工模型中具体扮演什么角色、怎么工作、哪些认知习惯需要改变,这个话题太大,值得单独成文。

"看不懂 AI 写的代码怎么办?"

这是最常见的反对意见。AI 写的 FP 链式代码,EitherTsemiflatMapbimap,人类看不懂,线上出故障了怎么 debug?

说得好像我看得懂汇编似的。

今天的软件栈里,从你写的 Java/Scala 到最终在 CPU 上执行的机器码,中间经过了多少层你看不懂的东西?JIT 编译器生成的本地代码、操作系统的系统调用、硬件中断处理,你从来没有因为"看不懂这些中间层"而觉得无法 debug。因为你不需要看懂它们,你在自己的抽象层上 debug。

事实上,2026年的今天,资深工程师真的需要 debug 汇编层面的问题时,他们也是把汇编扔给 AI 解释一遍。 AI 把汇编翻译成人话,工程师在人话的基础上推理。

FP 抽象代码也是一样的:看不懂 EitherT 链直接扔给 AI,让它用自然语言解释"这段代码先取用户,然后校验,然后取分数,任何一步失败都返回对应的 HTTP 错误码"。AI 既能写这种天书,也能翻译成人话。

而且,FP 代码的 debug 难度和深度远远低于状态命令式代码。

  • 无可变状态:不需要追踪"这个变量在第 47 行被改了,然后在第 123 行又被改了,第 200 行读到的是哪个版本?"纯函数的输出只取决于输入,给同样的输入永远返回同样的输出。
  • 错误路径显式Either[AppError, User] 告诉你错误只有 AppError 的那几个 case。不需要猜"会不会有某个深层调用抛了个 NullPointerException"。
  • 组合性:每个函数都是独立可测试的单元,bug 定位的范围天然就小。

Token 经济学

前面"军令互信"一节我提出了爆论:信任签名就是信任战友。 而这个信任行为能节省 Token 开销。

每一次不信任都是一笔 token 开销,而且是斐波那契式增长的。 当 agent 不信任签名时,它需要打开 fetchUser 的实现来验证"它真的会在找不到用户时返回 Left(NotFound) 吗?",读一个文件。然后发现 fetchUser 调用了 queryDB,还得确认 queryDB 的错误处理,又读一个文件。十个函数各验证一遍,就是十次额外的文件读取。更要命的是 token 计费模型:agent 每次 tool call 读回来的文件内容会变成下一轮的输入 token,而输出的推理过程又会在下一次 tool call 后计入输入。也就是说,过去产生的每一个 token 都在为未来的每一次调用加价,读的文件越多,上下文越膨胀,后续每一步的成本都在滚雪球。信任签名意味着 agent 只需要读当前文件就能完成工作;不信任签名意味着每多读一个文件,剩余所有步骤的 token 账单都在同步涨价。

信任链 + scope 隔离,还带来了更大的架构可能性:

编码 agent 可以更小、更便宜、更快。 当 scope 缩得足够小、模块隔离得足够彻底时,一个编码 agent 不需要全局视角,它只需要看到自己负责的函数签名、依赖接口的签名、以及相关的类型定义。在给定契约下求解就是全部。 它甚至不需要用最强的模型,任务被约束得足够紧,一个中等能力的模型在清晰的签名和类型约束下就能正确完成工作。契约越精确,对模型能力的要求越低。

有困难可以上报而不是硬扛。 当一个编码 agent 遇到它无法在当前 scope 内解决的问题,比如签名设计不合理、类型约束有缺陷、或者需求本身有歧义,它不需要"最大努力"地猜测并勉强实现。正确的做法是把问题反馈给 orchestrator,由 orchestrator 重新调整设计或澄清需求,然后再分配给(可能是另一个)agent 执行。

全局一致性由专门的 review agent 保障。 多个编码 agent 各自在自己的小 scope 内完成工作后,由一个拥有更大上下文窗口的 review agent 来检查全局变更的一致性,接口是否对齐、错误类型是否匹配、命名是否统一。这个 review agent 不需要理解每个函数的实现细节,它只需要审查签名层面的契约是否自洽。

这是我假想的 agent 编排模型:

1
2
3
4
5
6
7
8
Orchestrator(架构师)
→ 分解任务,定义签名契约
→ 分配给多个 Coding Agent(士兵)
→ 每个 agent 在小 scope 内求解
→ 遇到 scope 外问题 → 上报 Orchestrator
→ Review Agent(督察)
→ 检查全局签名一致性
→ 不看实现,只看契约

远景

代码是负债还是资产?

软件行业有一句广为流传的话:代码是负债,不是资产。 每一行代码都是未来需要维护、理解、修改的成本。过去写代码的时候只有我和上帝知道代码在干什么,等系统上线运行半年之后,就只有上帝能看懂了。

这在传统开发模式下完全正确。技术债务以指数速度增长,每一层 hack 都让下一层 hack 更难理解,每一个"临时方案"都在为后来者挖坑。接手维护一个有技术债务的代码仓库,不管是增加新功能还是修复 bug,都举步维艰。定制软件开发项目几乎必须由原开发团队或者同领域的外包团队接手。换一拨人来看代码,光理解"这坨东西到底在干什么"就要花几个月。

但如果我们能把技术债务控制在线性增长而不是指数爆炸呢?

前面几节讨论的所有工程纪律,类型签名即契约、sealed trait 穷举错误、opaque type 防混淆、碰到就迁移的渐进式技术债偿还,它们的共同目标就是这个:让代码在任何时间点都保持对新维护者(无论人类还是 AI)的可理解性。

如果这个目标实现了,代码仓库的性质会发生根本性的翻转:

从第一天起就严格建立的代码仓库,增加功能和修复 bug 不再是非常困难的事。 不仅仅是我,甚至非本仓库的原开发者,也可以一定程度上在这个仓库的代码基础上增加定制功能,因为新来的 agent 很容易理解过去 agent 留下的代码,签名是诚实的,类型是精确的,错误路径是穷举的,不存在需要"老员工口口相传"的隐式知识。

当然,架构级别的调整仍然离不开仓库原作者或同等能力和视野的维护者。 但对于功能级别的开发,在已有架构下增加一个 API、修复一个业务 bug、迁移一个数据格式,所需的开发人月将大幅降低。因为这些任务的本质是"在给定契约下求解",而诚实的签名和严格的类型系统恰恰把"给定契约"表达得清清楚楚。

代码仓库从负债变成资产的前提,不是"写得好",而是"维护得好"。那么我的精细化《AI 牛马鞭策术》是不是能让"持续维护"的成本降到历史新低水平呢?

下一代 AI-native 语言

既然已经如此爆论了,那不妨多爆几句:下一代 AI-native 编程语言,可能真的不需要考虑人类的编写和阅读感受了。 就像今天没有人手写汇编一样。

未来的编程语言是不是可能会分化成两层?

  • 契约层:纯粹的签名、契约、意图表达,可能更像一种声明式的规范语言
  • 执行层:为编译器和 AI 优化的实现语言,因为人类将主要精力放在审查契约层面,所以逻辑实现可读性的重要程度大幅降低,人类编写体验更不再是设计目标,信息密度和类型精度才重要

这就是我写的科幻。今天的 Scala 3、Rust、Haskell 天然就是类型系统表达力强大,实现层越来越天书鬼画符,那下一代语言只需要:承认人类不需要读实现,然后把"人类可读性"从实现层的设计目标中彻底移除。


适用范围声明

本文有两个前提,一个关于 AI 架构,一个关于项目类型。

AI 架构前提: 当下主流的 transformer 架构,固定上下文窗口、无跨 session 状态、每次对话从零开始的无状态推理模型。

项目类型前提: 本文讨论的所有实践,适用于一类特定的软件项目:

适用 不适用
后端系统、CRUD 为主的业务应用 基础设施、编译器、嵌入式、硬件驱动
一般前端 UI 应用 操作系统内核、实时系统
模块间做加法(功能叠加) 模块间强依赖(功能交叉)
软状态多、硬状态少 硬状态多、软状态少
数据状态持久化到外部存储 状态在内存中实时维护

区分标准是状态的性质。本文假设的典型场景是:状态最终持久化到数据库,内存中的状态是短暂的、可重建的(软状态)。在这种场景下,不可变 + 函数式组合的成本很低,收益很高,正如前面所有章节论述的。

但在硬状态主导的领域,比如编译器的 AST 变换、嵌入式系统的寄存器操作、硬件驱动的中断处理,状态本身就是核心抽象,不可变数据结构的 overhead 不可接受,模块间的强耦合是物理约束而不是设计缺陷。在这些领域,本文的很多建议不仅不适用,甚至是有害的。

语言前提: 本文的规则文件示例和工程实践基于 Scala。Scala 是多范式语言,它允许你写纯 FP,也允许你写命令式、OOP、甚至混合风格。这意味着规则文件中大量约束的目的是把 agent 的行为约束到单一的 Pure-FP 范式下,防止它在多种合法风格之间漂移。如果你的项目用的是 Haskell,这些约束中的很大一部分不需要写,语言本身已经强制了。

如果本文翻译成 Rust 版,篇幅会大幅减少。Rust 的所有权系统和 borrow checker 已经在编译期消灭了大部分可变状态的问题,不需要靠规则文件来禁止。但即便是 Rust,我仍然会在规则中写:禁止 agent 私自声明全局可变量(static mutlazy_static + Mutex 等),局部可变量(let mut)禁止跨越超过 2 个作用域层级,更禁止逃逸出函数。 同样,我会强制 agent 使用 Has<T> trait 来实现编译期依赖注入,这就是 Rust 版的 tagless final:service 依赖通过泛型约束 where Ctx: Has<UserRepo> + Has<AuthService> 表达,而不是在函数参数里传一堆具体类型。签名层的设计原则不因语言而改变,只是语法不同。

而且 Rust 的 let-else + ? 对 agent 来说推理成本比 Scala 的 cats 体操还要低:

1
2
3
4
let Some(user) = user_svc.find(id).await else { return Err(DomainError::UserNotFound) };
let Some(avatar) = user.get_avatar().await else { return Err(DomainError::AvatarMissing) };
// ... 处理逻辑
Ok(img_blob)

每一行都是自包含的:输入、判断、失败路径,全部在同一行内闭合。agent 处理第 2 行不需要回忆第 1 行的分支结构,这正是前面"实现层错误处理"一节中分析的线性流(风格 A),只是 Rust 用 let-else 把 early return guard 和模式匹配合二为一了。对 agent 而言,这比 EitherT(...).subflatMap(...).semiflatMap(...) 的推理路径更短、更局部、更不容易出错。

语言能管的交给语言,语言管不到的仍然需要规则补位,这个原则是跨语言的。

但并非所有场景都该追求最低编写成本。不管 Scala 还是 Rust,我都会强制要求 AI 使用 Reactive Stream 模式。编写阶段推理 Reactive Stream 消耗的 token 可能是迭代器 + channel 方案的数倍(在 Rust 端甚至是数十倍乃至百倍,所有权、&mut、生命周期等约束甚至会逼着你改动几个月前已经定好的数据结构)。但这笔前期投入是值得的,因为 Reactive Stream 的 operator 本身就是行为的声明式描述,debug 时 agent 不需要追踪命令式代码里散落各处的状态变更,只需要盯着 operator 链看:消息被丢了?.buffer(n, OverflowStrategy.dropHead) 写得明明白白。顺序错乱了?.unorderedFlatMap(...) 就在那里。每个 operator 都是一句自解释的行为声明,bug 的原因直接写在 operator 名字上。命令式的等价实现呢?LinkedBlockingQueue 的容量限制藏在构造函数里,队列满了是阻塞还是丢弃取决于调用方用的是 put() 还是 offer(),散落在生产者代码的某个角落。顺序问题更隐蔽:ExecutorService.submit() 的多线程调度让消费顺序变成了运行时才能观测的行为,你在代码里找不到任何一行写着"这里不保序"。agent 要跨文件追踪队列初始化、生产者逻辑、线程池配置才能定位同样的 bug。今天多烧的编写 token,换回的是将来每个 agent 省下的大量阅读和推理 token。

AI 架构前提影响的是文章的哪些部分?并非全部。

架构无关的结论,不会因模型进化而改变:

  • 签名/契约应该诚实完整(§1-§4)。不管什么推理架构,显式信息都优于隐式约定。这是信息论层面的判断,不是对特定模型能力的假设。
  • 人类审契约、AI 写实现的分工模型(§3)。这源于人类认知带宽的物理限制,与 AI 的架构无关。
  • 类型系统和 refinement types 提供确定性反馈。编译器不会因为 AI 变强而变得不重要。

架构相关的结论,会随模型能力演进而变化:

  • 纠正训练偏好的规则(§5 中的 Grep vs LSP 选择、错误处理偏好等)。这些规则本质上是在补偿当前模型的训练偏差。随着模型在现有架构上继续进化,这些偏差会变化,某些坏习惯可能被修正,新的偏差可能出现。规则文件必须跟随模型能力持续维护微调,这本身就是规则工程的一部分。
  • 跨 session 流程约束(§6 中的 Code Smell Tracking、memory 文件等)。这些机制完全是为了补偿无状态推理的缺陷。

甚至不需要等到"完美记忆"的那一天。哪怕是一小步,比如 RWKV 这类具备持久化状态的架构,如果推理能力能接近当前 transformer 的水平,就已经能改变游戏规则。

想象这样一个工作流:你花几周时间和一个 agent 协作,它在持久化状态中逐渐积累了对项目编码风格、架构决策、模块边界的理解。然后当你需要并行处理多个任务时,从这个状态 fork 出多个 session,每个 fork 都继承了同一份项目认知,可以独立去做 code review、refactor、bug fix,而不需要每个 session 都从零开始读 CLAUDE.md 和 memory 文件。

这和当前的工作模式是本质不同的。现在每开一个新 session,都是一个新手 agent + 文本化的规则文件。你必须把所有隐性知识,"这个项目用 tagless final"、"NoOp 实现数据相关的要返回失败"、"持久化后的数据是 trusted",全部文字化写进规则文件,然后祈祷 agent 能在有限的上下文窗口里正确理解它们。规则文件本质上是在用文本模拟长期记忆,能用,但笨拙,且有 token 预算上限。

而一个积累了项目理解的持久化状态,就像一个在团队待了几个月的工程师:不需要每天早上重新读编码规范,不需要把"我们为什么选择这个架构"写成文档才能记住。你不再需要文字化描述所有规则,因为规则已经内化在状态里了。

到那时,本文第二类结论中的大部分,规则文件的精确措辞、跨 session 的 memory 机制、Code Smell Tracking 的文件系统 workaround,都可以大幅简化甚至拆除。规则工程不会消失,但从"事无巨细地文字化"退化为"偶尔纠偏",负担量级完全不同。

但那一天到来之前,脚手架仍然是必需品。


附录

附录一:AI 的工具链,Grep、LSP 与消歧

即使给 AI 接上了 Metals MCP(Language Server Protocol 工具),它在重构阶段仍然更倾向于全程使用正则搜索替换,Grep + 正则替换是它训练数据中最熟练的路径。

但 Grep 有明确的能力边界。经过反复实验,我们梳理出了一个清晰的分工:

用 Metals(编译器解析),问题是"编译器把这个解析成了什么?"

场景 为什么 Grep 会失败
Given/implicit 解析,"这里 scope 里的 given Transactor[IO] 是哪个?" Grep 搜 given Transactor 会出 10+ 候选,无法判断编译器选了哪个
Extension method,".pure[F] 是谁定义的?" def pure 在 cats 源码里,但 Grep 无法告诉你哪个 extension 作用于你的具体类型
Opaque type 解包,"ProjectId 的底层类型是什么?" Grep 找到 opaque type ProjectId = UUID,但链式调用中需要跨文件追踪
重载解析,"这里调的是哪个 apply?" Grep 找到所有重载,需要人工按参数类型匹配
类型别名 + 继承,"ConnectionIO 是否 extends Sync?" 需要追踪 type ConnectionIO = Free[...] → Free → capability 层级,多跳
通配符导入,"import doobie.implicits.* 引入了什么?" Grep 无法解析,需要阅读整个 doobie package object

用 Grep(文本搜索),问题是"这个字符串出现在哪里?"

  • 字符串字面量、SQL 列名、配置键、HOCON 值
  • 注释、TODO、错误消息
  • 跨语言文件(YAML、SQL migration、Dockerfile)

一句话:"编译器怎么解析的"用 Metals,"这个文本在哪"用 Grep。

FQN 的局限与 LSP 的正确用法

我曾经一直纠正 AI 不要写全限定名(org.springframework.http.HttpHeaders),觉得太啰嗦。后来我意识到 FQN 确实能消除歧义,AI 看到 HttpHeaders,不知道是 org.springframework.http.HttpHeadersio.netty.handler.codec.http.HttpHeaders 还是 java.net.http.HttpHeaders

那能不能更进一步,用 SCIP(基于 SemanticDB 的代码索引)给代码文件自动标注 FQN?我让 Claude 评估这个方案:

FQN 对我确实有用。当我看到 val vec = Pgvector(chunk.embedding) 时,我不知道 Pgvector 来自 doobie.postgreso.linewise.core.database.DoobieInstances、还是其他什么地方。FQN 能瞬间消除这个歧义。但我已经有更好的工具了。

需求 SCIP 快照 Metals LSP
"PgVector 是什么?" 读 3 倍膨胀的标注文件 inspect 一次调用,精确类型
"谁调用了 resolveAuth?" Grep 快照(等同于 Grep 源码) get-usages,语义级,非文本级
"这个表达式返回什么类型?" 快照里没有(SemanticDB 不含子表达式推断类型) inspect 直接返回
"PermissionService 的所有实现?" Grep FQN 模式 typed-glob-search

SCIP 快照的代价:3 倍 token 膨胀(150 行文件变 450 行)、数据即时过时(任何编辑后就不准)、没覆盖真正的痛点(debug/重构的瓶颈是 implicit/given 解析链,SemanticDB 不捕获这些,TASTy 才有)。

结论:给 agent 配置 LSP 工具,让它在遇到歧义时按需查询,而不是在每一行代码里都背着冗余的全限定路径。

附录二:模型间的协作与知识传递

高级模型写的代码能"教"普通模型。 用顶级模型(如 opus)编写的高质量代码和 skill,可以有效地指导能力较弱的模型完成开发工作。

各家模型的训练数据里都有大量高质量的开源代码,这些知识本身是存在的。真正的差异在于两点:

  1. 权重分配,同样的知识,不同模型给予的权重不同,导致有些模型能自然地写出高抽象度的代码,有些则倾向于写出更"平庸"的方案
  2. 人类对齐的副作用,这直接取决于 AI 训练师的认知水平。模型在训练过程中会产生一些"鬼点子",非常规但可能极其有效的策略。如果训练师的认知能力不足以识别这些鬼点子的价值,一看和主流路线不符就直接惩罚掉,这些高价值策略就会在模型中被压制。认知能力差的人,训练不出好 AI。

实用技巧:用高级模型提前在上下文中把这些知识"激活"一遍,写成 skill、写成示例代码、写成 CLAUDE.md 中的规则,然后普通模型在这个上下文中工作时,就能循着已经铺好的轨道前进,而不是退回到它默认的"安全"风格。

多模型协作?不如同级互审。 有人尝试用多家模型互相评审设计文档,opus、gemini、gpt 三家"专家"讨论后投票决策。但实践中,认知能力差距太大的模型坐在同一张桌子上,不会产生有效讨论。 两个大学教授讨论科研方案,中间插进来一个小学生,他不会提供"不同视角",只会拉低讨论的下限。

更有效的方式是:同级别模型之间互审,但赋予不同的预设立场。 比如两个 opus,一个扮演"激进重构派",一个扮演"稳定优先派",它们有能力理解对方的论点并做出有实质意义的反驳。认知能力不对等的讨论只会退化成"最差模型能理解的水平"。

这本质上和本文的主题是一回事:如果你在用的工具能处理更高抽象度的信息,就不要为了迁就最弱的环节而降级。 代码风格如此,模型协作也如此。