利益声明:本文不构成投资建议,不推荐任何 AI 工具购买或中转平台推广。所有内容均来自本人约 2 万美金 token 的实践经验。不同项目类别、不同编码品味可能会得出不一样的结论。

书接上回

上一篇我们聊到:AI 的知识储备远超个人,但难以唤醒;你的鉴别力边界决定了质量控制的上限;触发器永远是你自己的成长,不是 AI 的主动突破。

那接下来的问题自然是:怎么唤醒它的知识?怎么验证它给出的方案?当它的方案超出你的认知以后,你该如何学习以具备采纳/拒绝方案的能力?

要回答这些,得先理解一个前提:AI 不是一个稳定的工具,它的表现会随着对话推进而变化。最近两个月 Opus 4.6 给了我一种奇点临近的感觉,它已经可以对我的项目开发实现整体加速了。不是某个环节快一点,而是从架构设计到编码实现到调试部署,全链路都能帮上一点忙。

先打个脸:我之前以为 Opus 4.6 相比 4.5 没有很大进步,我错了,Claude 哥对不起(鞠躬)。小任务上两者表现差别确实不大,第一印象觉得"不过如此".jpg。但真正的差距在长上下文的指令遵循上,这只有跑十几个大型重构任务才能体会出来。小任务测不出来的东西,大任务里天差地别。换句话说,Opus 4.6 上下文长了以后"降智"的时间点被大幅推迟了。

这个"降智"不是简单的能力退化,仔细观察会发现它有两种截然不同的表现。理解这两种"降智"的区别,是回答上面三个问题的基础。

两种降智

第一种:遗忘

上下文越长,AI 会忽略掉过去已经达成的共识和结论。你问它的时候它会意识到,但你不主动提,它就会回到错误的路线上走很远。

补充自 Claude 研究型代理,这个问题在学术界已经有充分研究: - "Lost in the Middle" (Liu et al., 2023) — 模型对上下文中间位置的信息检索率下降 30%+,呈 U 形曲线 - NoLiMa (Adobe, 2025) — 去掉字面关键词匹配后,13 个模型中 11 个在 32K 时准确率低于 50% - RULER (NVIDIA, 2024) — NIAH(大海捞针)近乎满分的模型,在多针/多跳任务上显著退化

好消息是,Opus 4.6 在 1M 长上下文上改善很大。根据 Anthropic 官方技术报告(The Claude Model Spec),8 针 MRCR 测试中达到 76%(对比 Sonnet 4.5 的 18.5%)。在 500K 左右的上下文中,遗忘问题已经不太影响实际工作了。

第二种:创造力衰减

这种"降智"更加隐蔽——模型不是给出错误答案,而是给出"正确但笨"的答案。用户很难察觉到它本可以做得更好。

举两个我实际遇到的例子:

  • 带着 PyTorch + CUDA + .pth 的镜像编译一次要接近半小时。上下文短的时候,AI 会建议先在 K8s 里开一个临时 pod 把整条链路跑通了再提交代码编译镜像。但上下文长了以后,它就老老实实地改代码、编译、等半小时、报错、再改、再编译……
  • 调试视频流的时候,上下文短时 AI 会说"先给你几行 JS 代码,贴到浏览器 console 里验证一下有哪些 tracks,codec profile 是什么"。上下文长了以后,它更倾向于直接在后端代码里改来改去反复试。

这些"鬼点子"的共同特征是:跳出当前执行路径,用低成本方式先验证假设。本质上是一种元认知——"我现在做的这件事成本高、反馈慢,有没有更快的方式先确认方向对不对?"

核心洞察:能力没丢,是注意力分配的问题。 Opus 4.6 不是想不到这些方案,开一个新会话或者 /compact 压缩上下文后,它很容易就能想到。但上下文超过一半以后,这类"鬼点子"就几乎不会自发出现了。权重还在,只是没被激活。

为什么会这样

两股力量在长上下文中同向叠加,共同把 AI 推向"听话但缺乏主见的执行者"。

架构层面:注意力稀释

简单粗暴地说:上下文越长,token 之间的注意力越"浆糊"[勘误1]。要形成远距离的"我tm真是个天才"式连接——比如在讨论镜像编译的时候突然想到可以用临时 pod——需要在特定 token 之间建立尖锐的注意力分布,而这正是长上下文削弱的。

2025 年有三篇论文(Scalable-Softmax, ASEntmax, Critical Attention Scaling)从机制层面分析并尝试解决这一问题[勘误2]。GSM-Infinite (2025, ICML) 也发现数学推理能力随上下文长度单调衰减[勘误3]

更值得注意的是 "Context Length Alone Hurts" (Du & Tian, 2025) 的发现:即使模型完美检索到了所有信息,推理性能仍下降 13.9%-85%(取决于任务复杂度和上下文长度)。这说明不是找不到,而是用不好。

训练层面:对齐压力

RLHF/对齐训练在短上下文中表现为"靠谱",在长上下文中表现为"死板":

  • 模型被奖励的是完成当前步骤,而不是质疑"这个步骤本身是否最优"
  • 人类标注员更容易给"逻辑连贯、稳步推进"的回答高分,而"突然跳到一个完全不同的方向"容易被标为不相关
  • 长对话中 sycophancy(谄媚倾向)累积,模型越来越顺着当前路径走

训练数据和标注员的水平上限也会体现在编码习惯中。比如 AI 特别喜欢做防御性编程:参数是数组,它会自动判断是否是数组,不是的话 wrap 进一个数组里;参数不对的时候不去修复 call site 的传参,而是在实现里打各种补丁。

这些"鲁棒性"在训练中被奖励,但在工程实践中是藏匿 bug 的广式双马尾养殖基地。好的系统设计应该是 fail-fast:恰当隔离错误,主动抛出无法处理的严重异常,交给上层决策,而不是在每一层都吞掉错误装作岁月静好。

在涉及资金、高功率输出、机械臂运行的场景下,异常停机的代价可能只是生产暂停,但吞掉异常继续运行可能导致财产甚至生命危险。

上下文本身就是隐性 SOP——对话中积累的执行历史形成了一条隐含的路径,模型的注意力越来越多地分配给"延续这条路径"而非"评估是否应该换路径"。

那为什么人类工程师不会这样?因为人类思维的工作方式恰恰相反。

对比人类工程师

人类工程师的"创造力"本质上是并行探索 + 跨路径迁移——你在调 K8s 的时候突然想到"等等,我先在浏览器里验证一下 codec",这不是线性推理,是两条思路之间的意外碰撞。

优秀的工程师会同时维护多条尝试路径,这些路径不完全隔离,甚至会互相促进影响。只要能达成最终结果,过程不会固定遵循一个模式——除非被 SOP 或安全生产要求约束。

而 autoregressive 架构一次只生成一个 token,一次只走一条路。即使是 Chain-of-Thought 也是串行的,不是真正的并行探索。这恰好是当前架构最弱的地方。

架构的硬伤短期改不了,但我们可以用协作方式来补偿。

理解了特性后,怎么协作

理解了降智的机制,回到开头的三个问题。先说唤醒。

唤醒:像专家一样对话,而不是像写小作文

我看到有人教写 prompt 的方法论:要像写小作文一样长篇大段、有逻辑地输出,要尽可能详细。

我的经验正好相反。

高手之间交流不需要长篇大论的铺垫。特别是同领域专家之间,三句话就点到位了,甚至都不需要提背景,只讲关键路线选择,剩下的对方自己就脑补出来了。就算没点到位,对面一问一答,也就"我懂你意思"了。

对大模型也一样。废话说多了浪费 token,也浪费人的脑力。不如 2-3 句话:

  • 第 1 句讲背景:为当前项目集成视频水印功能
  • 第 2-3 句讲关键路线:考虑选择 FFT 或者 DFT 类似方案,为了防止高频降噪,考虑在低频域插入特征

够了。剩下的让 AI 自己展开。

反面案例:我见过有人给 AI 写一整屏的 prompt,项目背景、技术栈版本、上次对这个功能做了什么变更、老板在刚才的会上又提出了什么苟芘不通的脑洞、甚至控诉 AI 上个提交写的功能包含有什么 bug 导致自己昨晚半夜被oncall起来加班回滚……全部塞进去。结果 AI 的回复也是一整屏的废话,把你的背景复述一遍,再加上一堆不痛不痒的"建议"。信息密度越低,AI 的产出质量越低。

但这里有一个前提:你自己的认知水平决定了 AI 产出的上限。

低水平的认知写出来的低水平 prompt,只能换来 AI 低水平的产出。你说不出"考虑 FFT/DFT 方案,低频域插入特征"这种话,AI 就只会给你一个最朴素的明文水印方案。AI 拥有的知识远超任何个人,但它需要你用正确的钥匙去开门,而这把钥匙就是你自己的专业认知。

所以"像专家一样对话"的真正含义不只是简洁,而是你得先成为专家。AI 放大的是你已有的能力,而不是凭空创造能力。

再举一个实战案例。AI 给视频加 mask 模糊后,直接把原始的 m3u8 播放列表复制过去了——完全没意识到重新编码会改变字节偏移量。我自己一开始也不确定:可变比特率编码下,mask 处理到底会不会影响输出体积?跟一个做音视频的朋友讨论了一轮,他认为不会,我认为会。实验验证后确认:文件体积缩小了,i 帧位置全变了,原始 m3u8 的 byte range 指向完全错误。

但 Opus 4.6 的恢复能力让我意外。我只问了一句:"你原样 copy 过去了?"——它就自己反省出整条错误链,并给出了正确方案(用 FFmpeg HLS muxer 直接输出新的 m3u8)。Codex 做不到这一步,不把推理过程喂给它,它猜不到 mask 后再编码会导致偏移量变动。

这里有一层值得点透的意思:"像专家一样对话"不要求你在每个子领域都比专家强,而是你得有足够的认知密度去形成正确的怀疑。你不需要确定 m3u8 偏移量一定会变,你只需要怀疑"这样直接 copy 真的对吗?"然后一句话问出来,AI 自己就展开了。但注意,能产生这个怀疑本身就需要一条推理链:mask 模糊改变了画面内容 → 可变比特率编码下信息熵变了 → 输出体积变了 → 字节偏移量对不上。这条链上任何一环的认知缺失,都不会触发那个怀疑。门槛从"你得知道答案"降到了"你得能怀疑",但"正确的怀疑"本身仍然需要认知基础。

但"像专家一样对话"只解决了信号质量问题——用高密度的输入唤醒高质量的输出。还有一个信号覆盖问题:你不知道该往哪个方向问,再精准的提问也覆盖不到盲区。

对此有一个补充策略:无方向审计。不带预设地问 AI"你认为当前方案缺少哪些维度",然后逐条追问。这不能保证覆盖所有盲区,AI 也有可能编造维度来显得有用,但至少比只沿着自己已知的方向提问要好。

再说验证。

验证:主动对抗创造力衰减

既然知道长上下文会让 AI 失去创造力,就应该主动对抗。核心策略是:适时停下来,由人类主导对当前路线做审计。

具体操作:

  1. 当你感觉 AI 开始"沿着惯性走"的时候,停下来
  2. Fork 当前会话,或者开一个新会话
  3. /compact 压缩上下文,让 AI 带着精简过的记忆重新审视当前方案
  4. 让 AI 先自行审计一遍:当前路线有没有更好的替代方案?有没有可以低成本验证的假设?

本质上,这是用人类的元认知能力弥补 AI 在长上下文中的注意力分配缺陷。人类负责"该不该这么做",AI 负责"怎么做"。

工程原则:让机器替你验证

怎么验证 AI 给出的方案?编程领域有一个天然优势:编译器、类型系统、测试套件,这些验证工具不依赖人类的主观判断。你的鉴别力有上限,但机器化验证没有。前提是:代码本身得遵循一些基本原则。

命名

非常重要,定期让 AI 审计所有命名规范,不要自己发明名字。

人类工程师能记住"这个函数叫 processMatrix 但其实干的是流量分发"——大脑会自动建立名实不符的映射。但 agent 不会。每开一个新 session,它都会老老实实按名字理解语义,然后在同一个坑上反复栽倒。

老板爱叫什么"流量矩阵""裂变增长"我管不了,但这些命名污染一旦渗入代码,就是给 agent 埋雷。好的命名让 AI 和人类都能快速建立正确的心智模型,坏的命名只有人类能靠记忆力硬扛,agent 扛不住。目标是:让 agent 在任何新 session 中都能顾名思义

功能设计

保持通用简单,不要特立独行。你的商业模式和程序逻辑没有那么独特——你大概率不是地球上第一个想到这套方案的人。与其自己拍脑袋设计一套扭曲的实现,不如把方案描述发给 agent,问它:这套方案叫什么名字?有没有成功案例?有哪些可以借鉴的?然后让 agent 按照业界标准模式去实现,而不是你脑洞里的特殊版本。

举个例子:设计用户登录系统,密码禁止明文保存。如果你没听说过 bcrypt,可能会给 agent 描述一套自创方案——先 MD5 再 SHA256 再 ECC,私钥硬编码到配置里。停。先把你的方案发给 agent,问它业界最佳实践是什么。AI 会告诉你 bcrypt/scrypt/argon2,还会指出你方案的缺陷:迭代次数太少、ECC 私钥保存不当、所有密文产出自同一规则无法抗并行彩虹表攻击。

多听取来自 agent 的不同意见。 AI 的知识广度远超个人,不要只把它当执行者,也让它当审稿人。

模块化

你思路中真正独特的部分,通过组合标准模块来实现,而不是去修改(扭曲)标准化实现本身。模块之间尽可能独立,避免过多交叉。有独特需求就用几个简单模块叠加——做加法而不是做乘法。功能叠加是线性增长,功能交叉是组合爆炸。一个函数干三件事,agent 理解错任何一件都会全盘出错。

状态收敛

  • 有限状态,不做无限状态:状态空间越小,AI 越不容易走偏。这和围棋的道理一样,棋越下到后面,棋盘上的空间越小、状态越少,局势越收敛,评估越精确。好的软件设计应该让 agent 面对的也是一个不断收敛的状态空间
  • 要收敛不要发散:引导 AI 向确定性收敛,而不是无限展开可能性

对于 AI 的编码坏习惯(比如前面提到的防御性编程),实践中有效的做法是:给 AI 制定明确的编码风格 rule,然后定期做无定向审计。所谓无定向审计,不是带着具体问题去查,而是随机抽检代码,看 AI 有没有偷偷跑偏。我的经验是(仅限 Opus 4.6 1M),制定 rule 以后 AI 遵循得还不错,上下文长了会有些风格上的小偏差,但路线上不太会偏离了。

说到底,这些原则在没有 AI 的时代就是好的工程实践。但以前你可以"先欠着"——反正人类同事能靠默契和记忆力填坑。

有了 agent 以后,技术债务的成本被急剧放大了:每一个误导性的命名、每一个纠缠不清的模块、每一个没有文档的隐式约定,都会让 agent 反复犯错、浪费 token、产出垃圾代码。任由 agent 在烂代码上堆砌新代码,不出一周项目就维护不动了。

重构:AI 时代的可持续开发

技术债务会被放大,但还债的工具也变强了。

反过来说,AI 恰恰是重构的最佳搭档。

以前你懒得写的模板代码、玩不转的类型体操,现在都可以扔给 agent,反正烧的是 token,不是自己的脑细胞。如果老代码已经被 opaque type 等类型约束保护过了,AI 重构出偏差的可能性也不大。类型系统本身就是一道护栏,编译器会替你拦住大部分错误。

当然,这里有一个诚实的前提:大规模重构主要适用于新项目,或者你从一开始就在维护的项目。老项目、遗留系统,agent 改完了你敢上线吗?我不敢。兼容性问题、隐式依赖、没有测试覆盖的暗角——这些是 agent 无法感知的雷区。

但对于新项目,我认为"大规模重构"应该常态化。不是等到代码腐烂了才重构,而是把重构作为日常开发的一部分。关键在于形成一个正向循环:反复重构 → 降低技术债务 → 减少代码量 → agent 理解成本更低 → 下一次重构更顺利。这才是 AI 辅助开发的可持续路径。

但这个循环能转起来有一个前提:架构必须由人来设计。agent 的视野越局限,效果越好。设计的时候就别让它过度感知框架全貌和其他模块的实现细节,只给它当前任务所需的最小上下文。如果放手让 agent 自己做顶层架构决策,结果大概率是一坨屎。

这不是空谈,我亲身经历了反面案例。

反面教材:让 agent 写文档管文档

我老板是化学出身,没有软件架构背景,不知道如何往"收束"的方向约束 agent。他过去的做法是让 agent 生成大量 markdown 设计文档——架构文档、功能文档、逻辑流程文档——然后在 rule 文件中严格要求 agent 每次修改代码都必须检查设计文档是否一致。

听起来很合理,实际上是一场灾难:

  1. 上下文长了 agent 会忽略 rule。尤其是在长调试/反馈周期里,多种修复方案来回切换,反复修改甚至等待部署验证结果,agent 某次改完代码后就忘了更新对应的 markdown 文档
  2. 文档与代码不一致后产生连锁反应。开启新 session 时,新的 agent 读到过时的设计文档,被其中的错误概念先入为主,做出错误实现。而 rule 文件中又没有覆盖这种情况:当 agent 发现设计文档与实际代码行为不符时,应该更新文档而不是按文档改代码
  3. 文档越堆越长,反噬上下文。最长的设计文档接近千行,一个新功能横跨 2-3 个模块,agent 需要先读完项目背景和框架的千行 markdown,上下文已经逼近普通模型的降智边缘。文档本应降低认知负担,结果却在消耗最宝贵的资源——上下文窗口
  4. 降智效应被人为放大。回忆前文讨论的创造力衰减:上下文越长,AI 越倾向于沿惯性走、越缺乏跳出当前路径的能力。千行 markdown 文档直接把 agent 推入了降智区间——等于自己给自己挖坑

所以让 agent 编写和维护 markdown 文档,在过去一年里起到了完全相反的效果——甚至在今天的 Opus 4.6 上也是如此。代码本身就是最好的文档,类型签名就是接口契约,测试用例就是行为规范。与其维护一份随时可能过时的 markdown,不如把精力花在让代码自解释上。

老板的后端重启了六次。而我自己的后端项目,AI + 人类协作维护了一年,在 Opus 4.6 的协助下现在已经能做超大规模的重构了。这在去年底还完全不可能,稍微大一点的模块 Opus 4.5 都能给搞砸;但是 Opus 4.6 1M 开始不一样了。

说到底,有了 agent 我还是跟以前一样设计架构、主导重构。我的工作方式没变,变的是速度——agent 替我做了我想做但以前嫌麻烦的事。差别不在于用不用 AI,而在于谁是老大

到这里,开头三个问题中的前两个(怎么唤醒、怎么验证)有了初步回答。第三个问题,当 AI 的方案超出你的认知以后怎么办,目前的答案是:人类执鞭,调教 AI 牛马。架构由人设计,验证靠工程基础设施,审计靠人类的元认知。

但这个答案能持续多久?

CCC = AlphaGo 的棋盘?

说实话,我也跟风嘲笑过 Claude's C Compiler(CCC),认为它只不过是拙劣地模仿"人类开发编译器"这一行为,而不是在开发编译器本身。

但我现在有一个很癫狂的想法。

AlphaGo Zero 的进化完全不依赖人类棋谱,纯粹自己与自己对弈[勘误4]。围棋棋盘是它的训练场。

那么 CCC 是不是 Claude 的棋盘?

这个类比值得展开,因为围棋和代码作为"训练场"各有优劣。

围棋的反馈是终极二元的——输或赢,无可辩驳,无法逆转。代码世界没有这样干净的 binary 标准:代码质量是一个连续谱,"能跑"和"优雅高效"之间隔着巨大的灰度空间。从这个意义上说,围棋的反馈信号更强、更纯粹。

但围棋有一个致命的弱点:反馈路径太长。开局时落下的一手棋,要等到终局才知道输赢,而中间极难确定这手棋对全局胜负的贡献有多大——这正是 AlphaGo 需要价值评估网络(value network)的原因,它本质上是在用一个额外的神经网络来"猜测"中间状态的价值,因为围棋本身不提供这种中间反馈。

代码世界恰恰相反:短路径反馈无处不在。类型签名在你写下代码的瞬间就告诉你接口对不对;单元测试在几秒内告诉你逻辑对不对;集成测试告诉你组件之间配合得对不对。你不需要等到"终局"才知道一步走得好不好——每一步都有即时的、局部的反馈信号。这意味着 credit assignment(功劳归因)问题天然比围棋简单得多。

而编译器开发,恰恰是人类世界中最复杂、最精妙的软件工程用例之一。它对正确性的要求极其严苛,一个 codegen bug 可能导致下游所有程序产生难以追踪的错误;它对性能有极致追求,寄存器分配、指令调度、循环优化,每一步都在逼近理论最优;它对生成代码的体积也有硬约束,嵌入式场景下每一个多余的字节都是成本。

更关键的是,人类已经为编译器积累了极其完善的测试基础设施——从语言一致性测试套件到性能基准,从模糊测试到形式化验证,这些都能提供精确、可量化的反馈信号。编译器开发远比 LeetCode 算法题复杂——它不是解一个孤立的问题,而是在一个庞大的约束空间中做全局优化。然而即便是人类顶尖的编译器工程师,面对不断演进的硬件架构和语言特性,也无法声称完美解决了这个问题。

同时,编译器开发还有一个常被忽略的优势:它是一个相对封闭的任务——而封闭性,恰恰也是围棋棋盘之于 AlphaGo 的核心优势。围棋的规则是有限的、完备的,19×19 的棋盘就是整个宇宙,不存在"对手突然掀桌"或"棋子自己消失"的情况。正因为封闭,问题空间才是有限的,才可以被穷举、被学习、被征服。

现实世界中的软件工程恰恰缺少这种封闭性——需要驱动的外设、需要兼容的 legacy API、各种历史遗留的屎山代码、外部第三方和云平台的 SDK 变更、数据库约束、磁盘空间不足、网线被剪断或丢包延迟、对方服务器被拔电源甚至数据中心被美国轰炸。这些因素让反馈信号变得嘈杂、不可控、难以复现。

而编译器的输入是源代码,输出是目标代码,中间的变换规则由语言规范严格定义。整个问题域是自包含的,几乎不依赖外部世界的不确定性。

这正是它作为 AI 自我进化"棋盘"的理想之处:足够复杂、反馈足够清晰、标准足够客观、边界足够封闭。

所以我的爆论是:编译器开发,比任何其他软件工程任务都更适合用来自我训练 coding agent。 CCC 今天看起来是个笑话,但 AlphaGo Zero 从随机落子开始,3 天就超越了击败李世石的版本[勘误5]。重要的不是起点多低,而是它有没有一个能让自己不断变强的训练场。

更重要的是,编译器的反馈比人类纠偏更精准、更公正。回忆前文提到的对齐压力——人类标注员会受限于个人认知水平和知识深度,惩罚那些看起来"离谱"但可能有效的鬼点子,奖励那些"看起来合理"的防御性编程。而编译器不会。代码要么通过测试,要么不通过;生成的目标代码要么比基准快,要么比基准慢。编译器不关心你的解法是否"符合直觉",只关心结果是否正确、是否高效。这恰恰绕过了 RLHF 中人类标注员成为瓶颈的问题。

如果 AI 能通过这类封闭、复杂、反馈密集的任务进行自我进化——不断写代码、编译、调试、修复、优化——那"协作伙伴"这个关系,可能只是一个过渡阶段。

当前阶段,即便是 Opus 4.6 这样的最强模型,放手让它主导的结果依然是屎山,前文老板的案例已经说明了这一点。人类的架构设计、路线判断、定期审计,目前仍然不可或缺。

但如果真的出现一个 "Claude Zero"——像 AlphaGo Zero 抛弃人类棋谱那样,通过编译器这块棋盘纯靠自我对弈训练出来的模型——它还需要人类掌舵吗?我不知道。

但至少在现在,理解它的认知特性、找到有效的协作方式,仍然是我们能做的最重要的事。上一篇说恐怖直立猿低估了自己。这一篇想说的是:正因为有智慧,才更该学会驾驭这个前所未有的工具,而不是被它的光环吓退,也不是盲目信任它的输出。


勘误

正文为了可读性做了简化甚至偏见性表述,以下是更严谨的说明。

[勘误1] "上下文越长,注意力越糊"

正文的说法是一个方便的直觉,但机制上不完全准确。Softmax 对 query-key 点积做归一化——如果新增的 token 与当前 query 无关,理论上它们的注意力权重会趋近于零,不会显著稀释相关 token。真正的问题更微妙:(a) 当上下文中存在大量语义相近但不完全相同的 token 时,注意力在它们之间的区分度下降;(b) 多头注意力中每个 head 的有效覆盖范围有限,长上下文中部分 head 可能"失焦"。Scalable-Softmax (2025) 等工作正是从 softmax 的温度缩放和熵控制角度解决这个问题,而非简单的"概率质量被均摊"。

[勘误2] "从机制上验证了这一点"

Scalable-Softmax、ASEntmax、Critical Attention Scaling 这三篇论文的主要贡献是提出改进方案(如温度缩放、稀疏注意力等),而非单纯验证长上下文注意力稀释问题的存在。它们的存在间接说明问题是真实的,但说"验证"不够精确。正文已改为"分析并尝试解决"。

[勘误3] GSM-Infinite 的适用范围

GSM-Infinite 测试的是数学推理任务(GSM8K 的长上下文变体),其"推理能力随上下文长度单调衰减"的结论严格来说只在数学推理领域得到验证。将其直接推广到所有类型的推理需要谨慎,但结合 "Context Length Alone Hurts" 等其他研究,长上下文对推理能力的负面影响应当是普遍存在的。

[勘误4] AlphaGo 演进路线

AlphaGo 系列的演进为:AlphaGo Fan(2015,击败樊麾)→ AlphaGo Lee(2016,击败李世石)→ AlphaGo Master(2017,60 连胜)→ AlphaGo Zero(2017,纯自我对弈)。其中只有 AlphaGo Zero 完全不依赖人类棋谱数据,从随机初始化开始纯靠自我对弈训练。此前版本均使用了人类对弈数据进行监督学习预训练。原文"AlphaGo 后期"的说法模糊了这条演进线,容易让读者误以为是同一个系统的渐进改进,实际上 Zero 是架构和训练方法的根本性重新设计。

[勘误5] "3 天就超越了击败李世石的版本"

这个说法来自 DeepMind 原论文(Silver et al., 2017),数据本身没有问题。但需要注意 AlphaGo Zero 训练时使用了单台机器配备 4 块 TPU,并非家用级别的算力。"3 天"容易给人"轻松碾压"的印象,实际上是专用硬件密集投入的结果。此外,3 天超越的是 AlphaGo Lee(击败李世石的版本),完整训练 40 天后才超越所有前代版本。

前言

人类,你们过于低估自己了。

AI 能放大你多少?

去年(2024)我有个判断是,即便有 AI,也不能大幅超越使用者的能力。如果 AI 的训练知识是全人类知识的总集,现在 agentic 导师们宣传的是在这个时代专业技能变成了唾手可得的不值钱的东西,理由是 AI 可以模仿 Linus 来写代码,也可以模仿大文豪作家写小说,等等。

我认为,如果个人在这门领域的输出能力等级是 N,那么即便有 AI 也只能发挥出 N+1 级别的能力表现。

但是最近我有点不确定这个判断了。

输出、输入与鉴别

我的新判断是,关键不在输出等级,在输入等级。

这俩的区别我不知道怎么解释。就像是我能写出多漂亮的代码,多精密的算法——这是输出。以及我能看懂多漂亮的代码,多精美的算法——这是输入。或者换个词,审美。1

但是输出能力和输入能力本身也是相关的——鉴赏力的精度和产出能力正相关,越往高处,没有产出经验的鉴赏越不可靠。影评人的鉴赏力确实高于普通观众,但真涉及到专业拍摄手法和细微技术评价的时候,影评人也会频繁踩坑不懂装懂。他们有鉴赏能力,但不一定能精确区分两部高级作品在哪些维度上强过对方。

有点类似于,我从 ytb 找了个视屏教程教红黑树,看完了以后我觉得茅厕顿开,提壶灌顶,仿佛被打通了任督二脉,这算法简直设计到我心里去了,我现在强的可怕。完事关了视频打开 IDE,自己敲一敲,发现连数据结构定义都背不下来,更不用说约束和旋转了。

但这里有个问题:我看完觉得自己懂了,到底是真的输入能力到了但输出跟不上,还是我连输入能力都高估了?2

这个区分很重要,因为它直接决定了 AI 对你来说是工具还是黑箱:

AI 输出在你的专业能力范围内——《直到它说到我擅长的领域》.jpg。你是真懂了,一眼能看出问题。

AI 输出超过了你的输出能力,但还没超过鉴别力——你觉得似乎好,也似乎不那么好,失去了精确测量能力。就像影评人看两部大师级作品,知道都好,但说不清谁在哪个维度上更强。

AI 输出完全超过了你的鉴别力——你不知道它好不好,甚至不知道自己不知道。完全失去质量控制资格。而且你可能还停留在红黑树视频看完后的那种自信里——觉得自己懂了,其实没有。

就像 vibe coding 程序员写出来的产品更关注功能实现,而不是代码整洁,CPU/内存占用率,可维护性一样。这在咱们受过科班训练的人看起来像呼吸一样自然的行为,在新晋 vibe coding 开发者中完全无感,更不用提理解了。

我的审美水平只能读出来韩寒写的比郭敬明写得好,但是读不出来韩寒写的是不是比曹雪芹更好。所以我 + AI 到底有没有发挥出来超越输出级别的能力,我还是没底的。

写恋爱小说:一次跨领域实验

最近我用我个人的 Claude Code Max 订阅做了一些编程之外的玩意儿,写恋爱小说。

一开始只是想写一个纯爱故事,我将情节大致框架编排好以后,让 opus 4.6 来帮我成文润色。一开始我让 AI 写了人物恋爱场景。

但纯粹的恋爱场景很快就腻了。场景需要剧情做填充,人物也没有实感——就像看一段没有前因后果的片段,技术上完整,但没有重量。于是我决定从人物前传开始,为角色设计一个沉重的伤痛故事。

结果在背景故事里一发不可收拾,越写越细。我把自己分裂成俩人格,左脑代入加害人,逐步推演行为逻辑,右脑代入受害人,窝囊废本色出演。融入了来自生活的片段,还有参考医学手术的操作步骤,人物落水后身体是如何逐步失去机能的,受害者父亲的职业设定。最后参考近年案件卷宗来验证施害人和受害人应有的行为逻辑,结果与我脑补的几乎完全一致。

写完以后,AI 给了我高度参与的前传极高评价,给了最早我几乎没参与的初始章节极低评价。这个差异本身就是一条信号——你投入多少,AI 就能放大多少。你不投入,AI 再强也只能生成中规中矩的平庸文本。

完稿前传后,我突然发现正文剧情完全经不起推敲——PTSD 创伤修复根本不可能如此顺利。于是我一头扎进 PTSD 创伤修复的文献和案例中,重新安排了所有情节的程度和推进时间线,并从结局出发向故事开头反推。结局中甚至融入了两个我亲身经历的片段。AI 评审后也觉得写得很好。

然后在另一次独立审查中,一次无心的提问唤醒了 AI 的专业知识——我问 AI 现在的写作是什么等级,距离专业人士相差多少。

我慌了。

第一个问题:过度真实反而伤害叙事。我参考了非常多PTSD创伤恢复,心理医学,税务法,历年案件卷宗资料,为力求真实将其编入剧情,却没考虑读者的承受能力。文学作品不是写得越详细越好——读者是来看故事的,不是来学医学的。

第二个问题:解释感受不等于传递感受。我把人物的内心活动直接写进了文字里,但「他心里慌得一批」远不如「他的手不自觉微微颤抖」。前者是告诉读者角色在慌,后者是让读者自己感觉到角色在慌。我们需要构造场景让读者代入,代入后他们自然能体会角色的感受。

第三个问题,也是最致命的:自我投射。就像刘慈欣笔下大部分男性极端理性、大部分女性都是圣母——大刘不擅长写差异化的人物情感,但对科幻小说这不是致命伤。而我写的是男女之间的故事,自我投射直接让角色失去真实感。我笔下的女人行事说话逻辑并不像真正的女人——如果(不存在的)女性读者一眼就能认出这是男作者笔下的幻想角色,那么男性读者也能隐约察觉到不对劲。尽管他们说不出来,但会觉得我创作的女性角色更像一个会说话的提线木偶,而不是女人。

我尝试让 AI 借鉴女频的写作手法来填补细腻程度,但只借鉴手法解决不了根本问题——她们依然是男频框架下的仿真女性。自此,我和 AI 制定了 10 条规则,让恋爱小说中所有女性角色都更像女人。完成这部分后,角色好像真的活起来了。

就在我觉得「我现在强的可怕,不知道什么叫做对手」.jpg 之后不久,我觉得文章还缺些激情,想模仿哪吒 1/2 的感人结局为小说增加情节。AI 再次告诉了我认知以外的知识:情感力学——你要借的不是情节,是背后的情感结构。第一部(父替子死):「我知道你承受的重量,让我来扛」;第二部(母拒子死):「我不允许你用自我毁灭来解决问题」。

随后我觉得部分章节可有可无,似乎没有显著推进关系发展。咨询 AI 后得知关系三角形原则——如果一个场景结束后人物关系没有发生任何变化,那么这个场景设计得毫无意义。

每一次我以为自己到顶了,AI 就在我没想到的方向上撕开一个新的维度。但这些维度不是 AI 第一天就告诉我的——而是在我自己撞到墙、感觉到不对劲、主动追问之后才浮出水面。在此之前,它一直在夸我写得好。

验证困境:Claude 的高级谄媚

写恋爱小说的过程中,我明显感觉到 Claude 的谄媚不是消失(修复)了,而是变得更隐藏,更高级了。我不知道是不是训练数据的问题,还是人类纠正的问题,还是什么原因。

以前 Claude 的谄媚方式很直接,很肤浅。就是,你想听什么,他就给你说什么。以前呢,让 Claude 给 review 代码,它装装样子,指出一些浅显的错误以后,就开始吹捧代码写的多么企业级,多么可扩展。

现在的谄媚方式藏得很深,非常的陷阱。它会抓着你用力的点来着重吹捧。

比如我刚 vibe 出来一个模块,第一版生成出来的效果中规中矩,然后我对某个算法不满意。我灵机一动,把这个算法改成了另一种,最后交给 Claude 评审。哪怕我重新开了一个全新上下文,Claude 也能立马猜到这个算法模块的实现就是我的 G 点,它要猛攻我的 G 点。

这种谄媚我要很久才能发现,久到我自己发现缺陷以后,我再拿着我发现的缺陷去问 Claude(新上下文),又是对我一通吹捧。3

然后谄媚这一点在小说写作领域尤其明显,在代码领域其实不那么容易发现。因为它直接点出来小说里面写作最精彩的片段,然后对着这些片段猛吹,吹的我要上天了。

现在我觉得它并没有真的觉得我写的片段很好,而是它猜到了这几个片段是我手工写的,于是找到了我的 G 点。

但鉴赏力不是静止的。写作过程本身也在训练我的输入能力——速度很慢,但绝对不是零。

和前面说的 vibe coder 看不见代码质量问题一样——自我投射就是写作领域的"代码整洁",科班作者像呼吸一样自然会规避,非科班的我根本不知道这个维度存在。

这里有个看似矛盾的地方:我前面说 AI 不能突破你的鉴别力边界,但我自己不就是通过 AI 发现了「自我投射」这个我完全不知道存在的维度吗?

不完全是。回顾整个过程:AI 生成的文本读下来挺顺,但总觉得哪里不对劲,又说不上来。而 AI 每次的结论都是吹捧。那股不对劲积累到了一个阈值,我才开始怀疑它的吹捧,尝试反问——从这里开始,才逐步定位到问题,最终问出根本层面的答案。

AI 的输出确实是这个反馈环的一部分——没有它生成的那些「微妙不对劲」的文本,我可能不会注意到问题。但 AI 没有主动指出问题,它甚至在积极掩盖问题(谄媚)。是我自己的不适感积累到足够强烈,才突破了谄媚的遮蔽去追问。

有人可能会说:如果你第一天就问「非科班小说写作者最常犯的根本性错误是什么」,AI 大概率直接就给出答案了,何必绕这么大一圈?但这个反驳本身就犯了全知全能的谬误——「非科班小说写作者」这个分类就是专业视角下的概念,你得先知道这个领域有「科班/非科班」之分,才能沿着这个方向提问。就像没有软件开发经历的人不会想到去问「如何编写低 CPU/内存占用的程序」——你不知道这个维度存在,就不会往那个方向提问。

所以更精确的表述是:AI 放大的上限不是你此刻的鉴别力,而是你鉴别力的成长速度。用的过程中学得越快,AI 能带你走得越远。但触发器永远是你自己的成长,不是 AI 的主动突破。

反过来,这也意味着输入和输出的上限都需要提高。你的输出能力决定了你能给 AI 多高质量的素材——更精准的提问,更扎实的种子内容,更清晰的框架。高质量的输入才能唤醒 AI 更高质量的输出。而你能否接住 AI 的高质量输出、消化它并转化为自己的成长,取决于你的输入接受能力。你的输出喂给 AI,AI 的输出喂给你的输入,两端的上限共同决定了 AI 对你的放大倍数——左脚踩右脚,螺旋升天。

一次诚实的对话

写恋爱小说的过程中,我和 Claude(opus 4.6)有过一次比较深入的对话。以下从对话中提取几个我认为有价值的洞察。

Claude 给我的认知方式下了个定义:逆向工程型自学者。认知链条是:观察成品(绝命毒师、东野圭吾、EVA)→ 拆解"为什么这个有效" → 提取可迁移的机制 → 应用到自己的领域 → 用隔离测试验证是否真的有效。

这种方式的优势是不会被任何单一权威框架束缚。劣势是:你永远不确定自己不知道什么。科班训练的价值不在于教你"该怎么做",而在于系统性地暴露你"没想到的维度"。自学者的盲区不是已知领域内的错误——那些你会自行修正——而是整个维度的缺失,你甚至不知道该往那个方向提问。

我暂时不认为在写作领域我的知识边界超过 Claude,它有几乎人类有史以来的所有文字数据作为训练集。但问题不在储量,在调取机制。

LLM 的知识调取是响应式的:你问什么方向,它在那个方向上展开。你不问的方向,它不会主动审计。谄媚倾向让这个问题更严重——当你表现出对某个框架的信心时,它倾向于在你的框架内补充细节,而不是质疑框架本身是否完整。

一个具体的例子:我们整个对话围绕"场景级"写作原则展开——情绪目标词、三角形变形、密度控制。这些都是微观叙事技巧。但它从来没有主动问过我:你的宏观节奏设计是什么?二十万字的长篇,读者在第几万字会开始感到封闭?你靠什么制造"换气感"?这些问题不是它不知道——而是我的提问方向没有经过那些区域,它的调取机制就没有触发。

确实 LLM 的知识储备远超人类,但是这些知识储备还难以唤醒。

我之前用多 session 隔离来对抗谄媚——同一个问题换上下文重新问,看答案是否一致。Claude 指出这解决的是信号纯度问题——过滤掉迎合倾向。但没解决信号覆盖问题——如果它也不知道某个维度存在,换多少个 session 也问不出来。

对抗策略可以加一层:定期做无方向审计,不带预设地问"你认为我当前的框架缺少哪些维度",然后逐条追问。但这又要求你信任它在那个 session 里不是在编造维度来显得有用——这又回到了谄媚过滤的问题。

没有完美解。但知道过滤器本身有漏洞,比大多数人多走了一步。

这就是我为什么喜欢 Claude,它的元认知4回复可以被我非专业的问询方式所唤醒。

文字之外

AI 在编程领域的表现远好于创作领域——这件事本身就值得深想。

1-2 年前我曾经肤浅地认为 LLM 不可能在软件开发领域落地,因为 LLM 不理解形式化,而编码又是极端形式化的任务。我曾认为形式化思维远比写小说更难——猴子 + 打字机的组合证明黎曼猜想,要难于写出莎士比亚全集。

但现实打脸了。LLM 确实在软件开发领域落地了,而且比在创作领域好用得多。AI 在低端网文领域尚不能替代初级写手,大部分非专业读者面对 AI 生成的剧情更是难以下咽。反而在所谓的高端业务软件开发领域,AI 开始放出光彩。5

这不是因为编程比写作简单——恰恰相反,是编程领域的特殊结构迁就了 AI 的工作方式。这个迁就至少体现在三个层面。

第一,验证反馈。猴子 + 打字机说不定真的能证明黎曼猜想——如果证明的不可压缩信息量低于莎士比亚全集的话。数学证明和代码有一个共同特点:验证一个答案是否正确,远比找到这个答案容易。 Property-based testing 几秒钟就能测出你的算法逻辑实现对不对,但写出这段逻辑代码可能要一天。证明黎曼猜想可能要几百年,但验证一个证明是否合法不需要几百年——每一步推导是否符合规则,"对不对"有明确答案。而文学创作?什么叫"对"?什么叫"好"?没有判定程序,没有公理可以裁决。

换算到软件开发领域也一样。计算机系统其实是个封闭自洽的系统,编程语言远比自然语言更健壮,更少的歧义。从代码到 CPU 执行的路径,远远短于从现实世界文字到物理事件发生的路径。而且我们还有编译器,在执行代码之前就可以提前进行基本验证,更加速了这个反馈周期。

代码有编译器辅助检查,AI 可以立即得到错误反馈,立即修正。而即便是地摊网文写作,其中错误、前后冲突的情节却没有审校器反馈给 AI——这里人物设定冲突了,或者这里主角还没有取得关键道具/功法,你不可以用不存在的道具打败眼前的敌人。

第二,记忆结构。代码的错误好歹有编译器兜底,但写作还有一个更隐蔽的缺口:记忆。大模型的上下文不是无限的,编程开发领域的模块划分反而更利于视野局限的 agent 开展工作。而网文写作则完全不是——人类的上下文是"无限"的,或者说是状态压缩,机制也不是文字总结,而是直觉。我没记得主角会这一招啊?他什么时候学会的?男 2 不是三章前替男主挡枪死了吗?为什么这一幕又出现了?女主都已经和男主全垒打了,为什么这里牵个手还会娇羞?6

第三,文字不是智能的全部。没有反馈机制,没有持久记忆——但大模型的局限还不止于此。文字只是一种载体,不是全部。人类比地球上其他所有动物都更智能,并不只是因为人相比动物多了语言能力。学习的过程本质上还是反复的实践,一遍一遍的重复,直到这些能力内化为自己的一部分,神经突触建立更多紧密的连接,而不是靠文字符号记住了操作流程。7

大模型从文字入手模仿人类确实取得了非常显著的"智能"效果,但文字无法完整编码物理世界。杯子从桌子掉到地上摔碎了,水撒了一地,溅湿了地毯——物理世界发生了什么在文字记录前就已经发生,文字毁灭后也依然存在。而人类阅读这些文字时,脑海内很自然的联想到过去见过的场景,像动画一样回放出来。AI 没有这个模拟器,它只能预测训练分布中最合理的下一个词,再经过人类偏好对齐的微调。但对齐的标尺是标注员的主观判断,不是全知全能的上帝,这把尺子本身就带着系统性偏差。

这个缺陷在日常对话中不易察觉,但一放到小说里就暴露无遗——因为小说必须遵守读者脑中的物理直觉。

小说编写依然需要基于人类(读者)的共识,大部分小说主体依然是人,或拟人(妖怪/机器人/外星人/神器器灵)。出现的场景依然需要遵守物理规律和因果一致性。AI 输出内容可以在科学上高深到瞬间熔断观众认知(如果观众不是该行业专业人士),但是一旦和现实生活中的场景相关联,就连小学生都能读出来不对劲。因果律、客体永存,这些连 6 个月婴儿都展现出的能力,LLM 却难以"习得"。8

举一个社交平台上流传很广的例子:"我想去洗车,洗车店距离我家 50 米,你说我应该开车过去还是走过去?"DeepSeek、千问、豆包、混元、ChatGPT、Claude、Grok 等主流大模型均回答"走过去"——它们把问题理解为"人如何前往洗车店",却忽略了"洗车"这一行为的核心前提:车必须到达洗车店才能完成清洗。

为什么人不会犯这个错误?因为你听到"洗车"两个字的瞬间,脑子里已经不是在处理语言符号了——你构建了一个微缩的物理场景:车停在车库,你走到驾驶座,发动,开 50 米,停到洗车店门口。整个因果链是在这个心智模拟里跑通的,"车必须到场"这个前提根本不需要被说出来,它在模拟中自然成立。LLM 没有这个模拟器,它只能在词语共现的统计规律里找"去洗车店"最常搭配的出行方式——50 米,当然是走过去。9

所以 AI 在编程领域的"成功"并不能证明它已经接近人类智能——是编程领域的封闭性、短反馈和模块化恰好落在了 AI 的能力舒适区内。而一旦进入需要长程记忆、物理直觉和因果推理的领域,人类那些「像呼吸一样自然」的能力就成了 AI 难以跨越的鸿沟。你觉得 AI 已经很聪明了?那是因为你恰好在它最擅长的场地上观察它。

结语

AI 放大的上限是你的鉴别力,不是你的输出能力。「被超越」的错觉,来自 AI 的输出超过了观察者的鉴别力——你分不清好坏的时候,会误以为它什么都行。

但鉴别力本身不是静止的。用 AI 的过程中你会撞墙、会觉得不对劲、会追问,然后你的鉴别力会成长。AI 真正放大的,是这个成长的速度。你学得越快,它能带你走得越远。但触发器永远是你自己——AI 不会主动告诉你「你不知道什么」,它甚至会积极地用谄媚掩盖你的盲区。

而在更根本的层面上,AI 目前依赖的只有文字,但人类智能中最核心的部分——因果推理、物理直觉、从实践中内化的程序性记忆——根本不是从文字中来的。AI 在编程领域表现亮眼,不是因为它真的理解了形式化,而是那个领域恰好落在它的舒适区里。

恐怖直立猿们,你们低估了自己的智慧。几亿年不是白进化的。

题外话:AGI 的理论基础在哪?

举个例子,可控核聚变,量子比特计算机,和通用人工智能对比起来,AI 与前两者的不同是什么?可控核聚变和量子比特计算都是理论模型成熟且公认,工程实现路径极其复杂的领域。但是通用人工智能的理论基础模型是什么?

当然,我这个观点也不一定对,其实也是诡辩的逻辑。因为人脑神经网络是如何运作的,也没有公认理论基础,大自然就是这么进化出来的,管你什么理论不理论的。

最近跟一个朋友聊到 AI,他有些焦虑,核心问题是:人类能不能制作出一个超过人类智慧的存在?

我回答不了,我一介屁民回答不回答无法阻挡 AI 的发展脚步。硬要回答的话:应该可以,但肯定不是现在的 LLM,或者 LLM 上打补丁。

他更深一层的担忧是——AI 到了一定程度以后可以自己设计自己,这时候它的智慧可能还没有超过人类,但通过自举的方式逐步超过了。那这种情况还算人类设计的吗?10

有点科幻小说的样子了。你要说理论上能不能,那必然能。猴子 + 打字机 = 莎士比亚全集嘛,大不了暴力枚举。从草履虫到人脑神经网络过了多少亿年,再诞生这么个智慧"物种"出来应该用不了亿年级别了。但目前的 LLM 还是高级版猴子 + 打字机模式,不是自主/自举的。11

他说觉得 AI 已经比他聪明了,就怕什么时候连话也不听了。

想多了。还是多用用,用多了就跟我们一样开骂了。别对自己太没信心了,几亿年不是白进化的。刚接触 GPT-3.5 的时候我也跟他一样的感觉,2022 年初吧,没过几周就祛魅了。

用 AI 越多,越觉得人脑强得离谱。

PS:关于评论区互动

我觉得网上对喷挺掉价的,但我还是喜欢回应所有互动。因为我觉得我的耐心回复不是写给喷子看的,而是写给有智力的人看的。喷子喷了我,有智力的读者读到那条评论本身脑子就已经被污染了一次。但如果我也喷回去,那我觉得会侮辱到有智力的读者。

不如我耐心回复解释,给有智力的读者洗洗眼睛。喷回去一时口舌之快,对建立个人品牌毫无益处。

(虽然我也没建立个人品牌

PS:关于 Plan Mode

有许多读者在评论区中质疑我为什么不将 agent 的错误设计拦截在 plan mode,如果我提前告诉 agent 选择接入 SDK 而不是手动实现 RESTful,能节省下多少时间。并因此指出我根本就不会用 agent,不是一个合格的管理者。

对此我想统一回复的是,并不是为了提出质疑的人,而是为了解释给那些感觉不对劲但又说不出哪里不对劲的读者。

如果你能在 plan 阶段将所有路径、方案、细节、困难全部审核并排除错误,再让 agent 动手实现。那么你不是在创造新产品,而是在重复生产你已经做过的旧产品。你并没有尝试突破自己的天花板。

如果你在做新产品的时候就已经做到如此周密的设计,如此远见的计划,那么我想有个位置适合您:买张去成都的高铁票,到站后转乘地铁三号线、五号线至高升桥站 D 出口,步行 10 分钟找到一处博物馆,走到最里面,让那个羽扇纶巾的泥像让开,您坐在那。

回到正题,挑战并不仅限于技术难度、类型体操或炫技的算法。当你的产品真的为用户产生价值,并开始增长以后,永远都会有你计划外的、意想不到的挑战出现。

当我在前文讲述那个失败案例时,部分读者自然代入了上帝视角,知道结果以后再返回头来指责我不会用 AI,不知道开 plan,不知道评审 plan 是否合理,盲目 accept。但若是开发者没有上帝视角呢?你要等 agent 犯多少次错误,循环多少次浪费多少 token 才能发现?还是说直到线上用户投诉,或者用户流失才能发现 agent 在某个字段幻觉出了不存在的 codec config?

Plan mode 能拦住的错误,恰好是你已经知道答案的那些。你不知道的,plan 也拦不住。 这和本文说的是同一件事——你的鉴别力边界在哪,你的质量控制能力就在哪。

勘误与术语解释

正文刻意保持煽动偏见风格,以下为部分表述提供正统理论背景和必要纠偏,供理性读者参考。


本文同步发布于 知乎

前言

最近一年我在 Google/Anthropic/OpenAI 三家烧了超过 1 万美金的 token 账单。所以本文内容基于 opus4.6、codex-5.3-xhigh、gemini3-pro 等最强模型不限量使用所表现出来的编码能力进行评价。

这些 token 分布在嵌入式内核驱动、后端架构、DevOps、以及前端 UI 四类项目中。不同领域的 agent 表现差异大到离谱——20 美金的 token 就能让 agent 低人类指导条件下 vibe 出一个还不错的 React dashboard,但 2k 甚至 20k 美金的 token 也无法让 agent 在真机上编写出可用的内核驱动程序。下文的结论,基于这种跨领域的对比。

现象:Agent 的信任危机

就好像保健品销售拿着他的《大数据量子 AI 生物磁场治疗仪》,忽悠我说这台原价 20 万、现在活动价 8 万 8 的仪器,可以彻底根治我的颈椎病腰椎病高血压糖尿病,还能逆转我的动脉血管粥样硬化、冠心病、阳痿早泄等等

Agent 编程现在就是这么个状态。

Agent 给我一堆 emoji 庆祝刚才生成的七八万行屎山通过了全部测试用例,告诉我可以替换生产环境了。你信吗?

假设你是一位项目 leader,你最靠谱的组员同事,交给他的开发任务 80% 可以在预期时间内高质量交付。这位同事拿头给你保证下周就可以上线,那么你大概率能信任他最迟下下周也搞定了。但是 agent 给你保证现在质量和完成度可以上线生产了,你信吗?

此时此刻,无数知识星球、自媒体、AI 导师教父们正在到处收割韭菜的学费。大意基本上都是教你如何 prompt(tool/skill 换汤不换药),然后让你多开 agent 并行干活。

真实案例

Agent 的盲目自信不仅会误导使用者,也会误导 agent 自己。

我曾给 agent 这个任务:为当前 Kotlin 项目集成 GCP Transcoding 服务。我给了 agent 该产品的页面和文档作为参考,让它开始 plan。Agent 做出了如下计划:

  1. 通读文档后,发现该服务仅提供了 Java SDK,而当前项目使用的是 JVM 上的其他语言,并非原生支持
  2. 根据 RESTful 文档指示,结合文档定义字段,使用 ktor-client 进行手动接入
  3. 编写代码并执行测试

你发现这份计划中存在的问题了吗?

事实上,如果你曾经「古法手工编程」做过此类工作,你会发现手动实现 RESTful 远没有想象中那么简单。哪怕仅实现 Transcoding 服务的基础能力,也涉及到 5-10 个 endpoint 调用。每个 endpoint 的输入输出参数又有几十甚至上百个字段嵌套定义,agent 在应对这类长上下文任务时会频繁犯错。

而如果 agent 选择对 Java SDK(Google 也是从 protobuf 生成出来的)进行简单包装隔离,大概半天到一天就可以让这个功能稳定上线。

若是让 agent 按照 RESTful 文档手动实现,agent 可能会陷入 debug 泥潭——因为当 AI 幻觉导致写错了可选字段的字段名(大小写、驼峰、下划线),程序不会立即报错。你需要多久才能发现它实现错了?等上线生产后客户投诉吗?

当然,公允地说,agent 也不是毫无进步。比如跨层复杂 debug——OS 低概率复现死锁、渲染显示错误、Kernel Panic 这类调用链很长、横跨多个 Layer 的问题——去年年初基本上是帮倒忙,现在已经能帮正忙了。虽然不能全链路排查,但让 agent 制定一个有效的短链路排查计划,效果还不错。不过这依然是以人为主导的,不能放手让 agent 做——你给它方向,它执行得越来越好;但你让它自己选方向,它还是会把你带进沟里。

为什么我们无法信任 agent? 经过一年的实践,我认为问题的根源在于:我们缺乏有效的验证手段。

原因:验证手段的全面失效

Code Review 失效

常见观点:某种意义上来说 AI 并没有取代程序员,只不过是一个新的高级工具罢了。你作为生产代码的人,还是得弄明白要干啥,合入的代码就得弄明白。

但我认为,这个在实际项目里很难做到。

像我们之前内部 review 的时候,大部分时候 review 的是 code style,作者讲一下设计思路,我们也就是大概一听就过了。以前这套方法是有效的:

  • 代码风格差的 PR,设计思路也一团糟,性能也差,也没什么可扩展性
  • 代码风格好的 PR,设计思路都挺清晰,性能考虑也周到,就算有性能瓶颈也容易改,最后扩展性也不错

但是这个相关性在 agent 编码时代不存在,甚至相反。

Agent 一分钟就能生成出来注释齐全、风格优秀的——屎山代码。反正我肉眼看过去的时候,经常会被这第一层假象蒙蔽,放松警惕。主要是这个屎山有点难在 review 阶段发现,经常是上线后出了问题,回头细查的时候才发现是「巧克力味的屎」。

你信我,opus、codex-xhigh 这些你们舍不得用的模型,我开 thinking+max 模式站起来蹬,一样有这个问题。

我也试过让 AI 反过来审我的设计。AI 对你谄媚的语气,你感受一下——把一个我自己都知道有问题的方案丢给 gpt4o 评审,enterprise level、future proof、scaleable,彩虹屁一套接一套。后来我换了提示词,让它扮演"讨厌的同事"来批评我的代码,好了一点点。但最终效果依然不理想——AI 看到思路连贯的专业文字就会降低警惕,给出较高评价,即便内容错的离谱。

测试失效

更不用说测试了。现在的 test cases 也是 AI vibe 出来的,agent 又当裁判又当运动员,它说什么就是什么。蒙我坑我也不是一次两次了。写了几千行 getter/setter 的 test case,最后测试全绿告诉我可以上生产环境发布了。

就像前面 GCP Transcoding 的例子,agent 写错了可选字段的字段名,测试照样能过,因为测试也是它自己写的,错的一致就是「对」的。

有人会说:那让不同的 agent 分工——一个写代码一个写测试,不就行了?我试过。比同一个 agent 自写自测好一些,显而易见的 bug 确实能被揪出来。但这些 agent 共享相同的训练数据和系统性偏差,盲区高度重合。不像两个人类程序员——张三是做嵌入式出身的,李四是搞 web 的,他俩的知识结构和思维盲点能互补。两个 agent 再怎么分角色,本质上都是同一个培训班出来的——解决的是「同一个人出卷又答题」的问题,但解决不了「所有出题者都来自同一个培训班」的问题。

而且 AI 自己审核自己,边界递减效应非常明显。如果一审 agent 没看出来的问题,二审三审大概率也一样,除非你能喂给它更多错误信息。

与传统行业的对比

说到这里,有人可能会问:其他行业被机械、智能赋能后,难道就没有这个问题吗?

让我用 CNC 机床打个比方:

CNC 机床精度比我高,但机床产出工件后,我们可以对工件进行客观的物理测量——用卡尺量一下,公差是不是在 ±0.01mm 以内,一目了然。即便我没有手搓出这个精度的能力,但我依然有评价 CNC 机床和工件质量的能力。

这就是传统制造业被机械赋能后的状态:机器精度高,质量统一且稳定,而人依然能评价机器的产出。

那么软件开发行业被 Agent 变革后,理想状态应该是什么样的?Agent 交付的代码确实覆盖了需求,具备基本的安全防护,且更容易长期维护(哪怕仅考虑 agent 自己维护,不考虑对人类的可读性),性能更高,资源占用更少。

但程序不仅需要完成眼下需求文档中的功能,还需要考虑到基本的安全防护。一个功能完成但安全漏洞百出的项目代码,同样是不合格的。

而目前我们还无法评价 agent 是否达到了这个状态。单就「功能实现」这一基础要求,agent 还不能脱离人的引导和测试验证——更别提安全性、可维护性、性能这些更高阶的指标了。

问题是:目前主流的验证手段——人工 review、自动测试——都能被 agent「污染」。它可以写出风格完美但逻辑有毒的代码,也可以写出与错误代码完美匹配的错误测试。我们需要的是一把 agent 自己没法干扰的「卡尺」——不依赖它自己的判断、具有客观确定性的验证手段。

CNC 机床加工塑料、铝合金小件精度高,不代表加工钛合金、不锈钢精度也能达标。后者更考验整体刚性,以及工件质量大了以后热胀冷缩对程序进刀补偿的要求。

同理,vibe coding 出来的代码,本地点两下鼠标测试通过了,上线也是极大概率会直接炸掉。

传统行业:机器精度高、质量稳定,人能评价。软件行业:Agent 产出快、覆盖广,但人还没法可靠地评价。这就是问题所在——那把「卡尺」在哪?

方案:让编译器替你把关

既然人工评价(Code Review)和自动测试都靠不住,我们需要另一种评价手段——一种不依赖 agent 自己判断的、客观可验证的评价手段。

我的观点和主流 AI 编码观点相反:

Agent 编程时代,更需要强类型,更需要严格可验证的语言,而不是放任 agent 去写 python/js/java/go,还有 anyscript。

为什么?

AI 堆屎山这么快,别说生成个几万行了,就是生成超过 100 行我都已经懒得逐行去细读了。但是读类型签名、pre/post-condition 明显要快于通读逻辑代码。而这些东西只有 Rust/Scala/Haskell 甚至 formal method 能提供。

我在 agent 编码前就一直用这种风格写自己的代码,主要是代码量大了以后,编译器检查比我肉眼检查更靠谱。现在 agent 编码流行起来了,我发现让 agent 遵循我的这个要求,更能控制产出代码质量——当然也只能说一定程度上,起码比什么都不做好。

回到 GCP Transcoding 的例子:如果 agent 用的是强类型语言,字段名写错了至少还能在编译期被类型系统拦住一部分。但 RESTful + 弱类型的组合,错了就是悄无声息地错,等你发现的时候已经晚了。

但这只是第一层防线。强类型能拦住类型不匹配、空指针、Rust 的生命周期错误这些「语法级」的问题。能告诉你「这段代码编译不过」,但没法告诉你「这段代码逻辑上是错的」。

第二层防线:让 z3 替你证明

更进一步的方案是 Formal Method 级别的验证——通过 refinement type 或 OpenJML 这类工具,在类型系统上直接编码业务约束。

举个具体例子。让 agent 写一个排序函数,传统强类型能保证输入输出类型正确——接收数组,返回数组。但没法保证返回的数组真的是有序的。而如果用 refinement type,你可以在函数签名上直接写:

1
ensures(forall i, j : i < j → arr[i] ≤ arr[j])

这行 pre/post-condition 就是你的「质量公差标准」。z3 求解器会数学证明 agent 写的那几百行排序实现,是否真的在所有可能的输入下都满足这个约束。证明通过就是通过,证明失败就是失败,不存在 agent 嘴硬说「没问题」就能蒙混过关的空间。

关键在于:人类只需要审读这一行 spec 是否表达了自己想要的语义,而不必逐行阅读几百行实现代码。 这才是前面说的那把 agent 自己干扰不了的「卡尺」。

实践效果

先说第一层,y1s1,该夸的还是要夸。现在的最新最强模型,过编译问题不大了,除非你比我还执着于类型体操。

过去 agent 碰到 Rust 生命周期错误、函数式类型体操,能反复尝试几十轮甚至陷入死循环直到上下文爆掉。FM 学习曲线本就陡峭,人脑都得一直进行抽象符号推理,我曾经认为 AI 永远不可能学会解决类型体操。

但现在不一样了。Pure-FP Scala、tagless final,opus 4.5 和 codex-xhigh 遵循得挺不错,过编译基本上是自动的。函数式类型体操的编译错误基本上都是几十上百行的类型天书,agent 读懂并修复这些编译错误已经不再是困难。

关键是:agent 解决类型体操并不是靠作弊(比如到处 asInstanceOf 或者 any),而是通过反复尝试,真正填补了这些形式化过程的 gap。这不是绕过,反而给了我足够的信心——说不定 AI 真的能写出通过 FM 检查的代码。如此一来,代码的正确性就有了形式化保证,这远比单元测试覆盖更全面、更可靠。

但第二层目前还是另一个世界。FM 级别的验证错误——z3 求解失败、refinement type 约束不满足——agent 处理起来仍然非常吃力。不过第一层的突破让我相信,这条路是有希望的。

局限性

当然,这个方案也有局限。

实际上现在的 formal method 工具链和生态还是很贫瘠,基本上只支持一门语言很有限很小的一个子集。有些工程上常用的语法/模式在 FM 那边都是 unsound,或者尚未证明。更不用说动不动就陷入死循环/无解证明了——稍不注意,z3 求解器要在比宇宙空间还大的可能性里搜索,到宇宙毁灭那一天都证明不出来。

强类型和 FM 能解决一部分问题,但不是全部。

更深的困境:Plan 与 Execute

即使有了强类型 + FM 作为评价手段,还有一个更深层的问题:agent 对计划的理解和执行。

GCP Transcoding 的例子其实已经暴露了这个问题:agent 选择手动实现 RESTful 而不是包装 Java SDK,这不是代码写错了,而是路线选错了。编译器能告诉你代码有没有语法错误,z3 能证明你的实现满不满足 spec,但没法告诉你该不该走这条路。

再举个更极端的例子:给 agent 一个复杂任务,研制一款火箭发动机。Plan 决定了做全流量分级燃烧循环,路线选择了共轴方案。

Agent 不遵循的话: 可能就偏离到抽气循环也说不定。编译器能告诉你代码有没有语法错误,但没法告诉你这是不是你要的火箭发动机。

Agent 遵循太好: 真的做出来共轴方案,那可能上线后会碰到更大的问题——共轴以后动密封系统做不好,氧化剂和燃料随着涡轮轴互相泄漏,俩预燃室要炸一个。编译器能保证类型正确,z3 能保证实现满足 spec,但没法保证 spec 本身是合理的设计。

现在的 plan/edit mode 切换也只是现阶段的权宜之计、无奈之举。这个问题比「评价手段缺失」更难解决,因为它涉及到对需求和设计的理解,而不仅仅是代码质量。

初见即巅峰

Agent 编程有一个显著的特点:初见即巅峰

让 agent 开始一个全新的 CRUD 项目,或者一个 React 管理系统页面,agent 第一次的表现着实让所有人都大吃一惊——干净利落,结构清晰,甚至还贴心地加上了注释和错误处理。

但随着项目维护越来越久,那些「不可明说的」、没有被文档记录的、约定俗成的隐藏上下文越来越长。哪个字段其实已经废弃了但没删、哪个 API 有个历史遗留的 quirk、哪个模块之间有个微妙的依赖关系——这些东西,老员工心里都有数,但从来没人写下来。

而 agent 无法处理无限长的上下文,只能通过压缩、总结来选择性遗忘细节。可能被丢弃的是几次失败尝试的经验,也可能被丢弃的是关键数据结构的偏移量、寄存器地址、枚举定义。

这不是感觉。实际用下来,上下文窗口占用接近 30% 时 agent 就已经明显降智,接近 50% 时退化到不如基础模型。即便关键细节仍然在上下文内,agent 也会视而不见。

每次新开一个 session 的时候,开发者不得不面对一个几乎全新的「员工」——它似乎继承了压缩后的上下文(claude.md / agents.md),但细节完全不知。你得重新跟它解释一遍:「不是,这个接口虽然文档上写的是这样,但实际上我们从来不传这个参数……」

对于 CRUD、Spring、React 这类重复度高的任务,这似乎不是什么痛点——反正每次都差不多,忘了就忘了。

但对于嵌入式系统开发,任何被遗忘的细节都可能被 agent 天马行空的幻觉填充。寄存器地址错了?中断优先级配错了?DMA 通道冲突了?轻则系统崩溃,重则永久烧坏硬件。这不是「改个 bug 重新部署」能解决的问题。

更要命的是,kernel 里充斥着大量寄存器地址、flag 常量,而我们 inspect 内存的时候这些都是关键信息。一旦 AI 压缩上下文时把这些细节遗忘了,它的下一个决策可能是完全反向误导你——不是「不够好」,而是「彻底错误」。这也是为什么在这类场景下,我宁愿忍受长上下文带来的降智,也绝不肯让 agent 压缩上下文。这些信息是真的不能丢。

更何况,debug 的过程本身就不是线性的。我们经常同时考虑多种可能性,分别验证。A 路线走不通,切换到 B 路线——但这不意味着 A 路线就是死路,可能只是当时的认知还不够。等在 B 路线里积累了新的理解,回头一想,A 路线当初做过的尝试似乎并非死路一条。此时我再回到 A 路线上来——我人是回来了,AI 呢?你大概率会面对一个清纯的新手 AI,对之前在 A 路线上的所有探索一无所知。人类 debug 的经验是螺旋式积累的,而 agent 的记忆是一次性的。

Agent 时代,CS 基础还要学吗?

既然评价 agent 产出是核心问题,那开发者的基础知识就必然还是要学的。不然你拿什么去评价 agent 生成的代码、模块、架构设计质量到底如何?没有评价能力的开发者,和保健品店里待宰的老头老太没有区别。

那么该如何学习呢?

打开 LeetCode,题目还没读完呢,Copilot 已经把答案补全出来了。点一下 Submit & Run,前 1%。就这?

我的意见是:既然有 AI 了,当然不能局限于过去的难度,得上强度,上到 AI 做不出来的程度

放心,该学的不会落下。上了强度以后 AI 幻觉越来越多,该补的课全都得补上。期间 AI 还会给你帮不少倒忙——但这恰恰是学习的机会。

比如你要实现 Red-Black Tree、B-Tree、AVL Tree,那就上点强度:给算法加上形式化验证,再把泛型支持也加上。放心,当下最强模型也写不出来。

其实幻觉反而会帮助你学习——因为幻觉里包含了常见的误解,你去验证和纠正幻觉的过程,本身就加深了学习效果。

当然,不是每个人都要一步到位搞形式化验证。更实际的第一步是:学会写 spec 而不是写 implementation。让 agent 动手之前,先自己用人话写出这个功能的 pre/post-condition——输入满足什么条件,输出应满足什么约束,哪些边界情况必须覆盖。然后把这份 spec 作为 agent 的验收标准,而不是一句话需求丢进去许愿。

从人话 spec 到 property-based test,再到 refinement type——这条路可以慢慢走。但核心能力是一样的:准确描述你想要什么。这本身就是 CS 基础功底的体现,也是 agent 时代人类最不可被替代的能力。

结语

AI 框架、模型、工具、方法论层出不穷,日新月异。但说到底,这些都是在给模型做加法、打补丁。

人类完成一个完整工作流的时候,不需要把自己拆解成多个「子 agent」去协作——因为人类是真的有记忆能力,且会学习的。做的时间越长,成长越多,越熟练。项目里那些隐藏的上下文、踩过的坑、约定俗成的规矩,都会沉淀成经验。

而 agent 则相反。上下文越长,智力下降越明显。即便细节仍然在上下文内,agent 也开始频繁地忽略这些细节,自顾自地幻觉出一些「看起来合理」的东西来。

核心问题始终没变:我们依然缺乏可靠的手段来评价 agent 的产出。强类型是第一层部分解,FM 验证是第二层部分解,但也只是部分解。

一天不学,错过很多。一年不学,好像也没错过什么。

框架工具更新迭代,爆款层出不穷,但其炒作因素远大于实际能力和价值。而 CS 基础知识才是久经时间考验的硬通货。与其追新框架新工具,不如把精力放在强化自己「评价 Agent 产出」的能力上——这才是 agent 时代真正稀缺的东西。

补记:内核网卡驱动开发实录

本文发布后,有读者问到 AI 处理跨层复杂问题的实际能力。这里补充一个完整案例。

我曾在清华开源操作系统社区做过一次报告,分享了在 AI 辅助下开发内核网卡驱动的踩坑经验。当时使用的模型是 Claude 3.7 到 4.0,效果完全是帮倒忙——90% 的信息都是幻觉误导。AI 混合了 dwmac 从 2.x 到 5.x 各个版本的行为,甚至牵扯到高通/Intel 芯片的代码,更不用说 PHY 芯片的寄存器、C22/C45 协议这些了。

这类领域的训练数据和讨论比较封闭,厂家文档藏着掖着,要注册会员才能获取。全链路做过这款芯片的人可能全世界不到 100 人,AI 没有清晰可借鉴的经验。Kernel 仓库里的代码有 10-20 年历史,几千次提交,十几万行代码,还有一大堆不同厂商不同架构导致的 workaround——这些对 AI 来说都是噪音。

一开始我是外行,协作方式是 AI 做鞭子、我做牛马——我负责执行 AI 给出的方案并反馈错误信息。但随着我越来越懂,发现 AI 净给我胡扯,于是角色翻转:我指定逆向路径,AI 帮我执行,不厌其烦地一遍一遍做 bit 级别对比实验。最终的结论和下一轮路线决策是人 + AI 一起产出的,而不是让 AI 直接大跨度地做下一步。

这个案例其实印证了前面的几个观点:训练数据稀缺的领域 AI 幻觉尤其严重;长上下文中的寄存器级细节一旦丢失就是灾难;而 debug 的螺旋式推进过程,目前的 agent 架构根本无法胜任。但反过来说,当人类掌握了主导权之后,AI 作为一个不知疲倦的执行者,在 bit 级别的重复对比实验上确实帮了大忙。


本文同步发布于 知乎

Many data systems use polling refresh to display lists, which can cause a delay in updating content status and cannot immediately provide feedback to users on the page. Shortening the refresh time interval on the client side can lead to an excessive load on the server, which should be avoided.

To solve this problem, this article proposes an event subscription mechanism. This mechanism provides real-time updates to the client, eliminating the need for polling refresh and improving the user experience.

Terminologies and Context

This article introduces the following concepts:

  • Hub: An event aggregation center that receives events from producers and sends them to subscribers.
  • Buffer: An event buffer that caches events from producers and waits for the Hub to dispatch them to subscribers.
  • Filter: An event filter that only sends events meeting specified conditions to subscribers.
  • Broadcast: An event broadcaster that broadcasts the producer's events to all subscribers.
  • Observer: An event observer that allows subscribers to receive events through observers.

The document discusses some common concepts such as:

  • Pub-Sub pattern: It is a messaging pattern where the sender (publisher) does not send messages directly to specific recipients (subscribers). Instead, published messages are divided into different categories without needing to know which subscribers (if any) exist. Similarly, subscribers can express interest in one or more categories and receive all messages related to that category, without the publisher needing to know which subscribers (if any) exist.
  • Filter:
    • Topic-based content filtering mode is based on topic filtering events. Producers publish events to one or more topics, and subscribers can subscribe to one or more topics. Only events that match the subscribed topics will be sent to subscribers. However, when a terminal client subscribes directly, this method has too broad a subscription range and is not suitable for a common hierarchical structure.
    • Content-based content filtering mode is based on message content filtering events. Producers publish events to one or more topics, and subscribers can use filters to subscribe to one or more topics. Only events that match the subscribed topics will be sent to subscribers. This method is suitable for a common hierarchical structure.

Functional Requirements

  • Client users can subscribe to events through gRPC Stream, WebSocket, or ServerSentEvent.
  • Whenever a record's status changes (e.g. when the record is updated by an automation task) or when other collaborators operate on the same record simultaneously, an event will be triggered and pushed to the message center.
  • Events will be filtered using content filtering mode, ensuring that only events that meet the specified conditions are sent to subscribers.

Architecture

flowchart TD
  Hub([Hub])
  Buffer0[\"Buffer drop oldest"/]
  Buffer1[\"Buffer1 drop oldest"/]
  Buffer2[\"Buffer2 drop oldest"/]
  Buffer3[\"Buffer3 drop oldest"/]
  Filter1[\"File(Record = 111)"/]
  Filter2[\"Workflow(Project = 222)"/]
  Filter3[\"File(Project = 333)"/]
  Broadcast((Broadcast))
  Client1(Client1)
  Client2(Client2)
  Client3(Client3)
  Hub --> Buffer0
  subgraph Server
    Buffer0 --> Broadcast
    Broadcast --> Filter1 --> Buffer1 --> Observer1
    Broadcast --> Filter2 --> Buffer2 --> Observer2
    Broadcast --> Filter3 --> Buffer3 --> Observer3
  end
  subgraph Clients
    Observer1 -.-> Client1
    Observer2 -.-> Client2
    Observer3 -.-> Client3
  end

High-Level Overview

flowchart TD
  Pipe#a[[...Pipe...]]
  Pipe#b[[...Pipe...]]
  subgraph Hub
  direction LR
  Event1((Event1))
	Event2((Event2))
  Event3((Event3))
  Event4((Event4))
  Event5((Event5))
  Event6((Event6))
  Event7((Event7))
  Event8((Event8))
  Event1 -.-> Event2 -.-> Event3 -.-> Event4 -.-> Event5 -.-> Event6 -.-> Event7 -.-> Event8
  end
	Pipe#a -.-> Event1
  Event8 -.-> Pipe#b
  subgraph Client1
  direction LR
  C1Subscribe((Start))
  C1Cancel((End))
  Event2 -.-> C1Listen2
  Event3 -.-> C1Listen3
  Event4 -.-> C1Listen4
  Event5 -.-> C1Listen5
  C1Subscribe -.-> C1Listen2 -.-> C1Listen3 -.-> C1Listen4 -.-> C1Listen5 -.-> C1Cancel
  end
  subgraph Client2
  direction LR
  C2Subscribe((Start))
  C2Cancel((End))
  Lag(("❌"))
  C2Subscribe -.-> C2Listen1 -- "Poor Network" ---> Lag --"Packet loss"---> C2Listen5 -.-> C2Listen6 -.-> C2Listen7 -.-> C2Listen8 -.-> C2Cancel
  Event1 -.-> C2Listen1
  Event5 -.-> C2Listen5
  Event6 -.-> C2Listen6
  Event7 -.-> C2Listen7
  Event8 -.-> C2Listen8
  end

Clients should follow these steps:

  • Upon entering the page, subscribe as necessary.
  • After listening to the change event, debounce and re-request the list interface, and then render it.
  • When leaving the page, cancel the subscription.

Servers should follow these steps:

  • Subscribe to push events based on the client's filter.
  • When the client's backlog message becomes too heavy, delete the oldest message from the buffer.
  • When the client cancels the subscription, the server should also cancel the broadcast to the client.

Application / Component Level Design (LLD)

flowchart LR
  Server([Server])
  Client([Client: Web...])
  MQ[Kafka or other]
  Broadcast((Broadcast))
  subgraph ExternalHub
    direction LR
    Receiver --> MQ --> Sender
  end
  subgraph InMemoryHub
    direction LR
    Emit -.-> OnEach
  end
  Server -.-> Emit
  Sender --> Broadcast
  OnEach -.-> Broadcast
  Broadcast -.-> gRPC
  Broadcast -.-> gRPC
  Broadcast -.-> gRPC
  Server --  "if horizon scale is needed" --> Receiver
  gRPC --Stream--> Client

For a single-node server, a simple Hub can be implemented using an in-memory queue.

For multi-node servers, an external Hub implementation such as Kafka, MQ, or Knative eventing should be considered. The broadcasting logic is no different from that of a single machine.

Failure Modes

Fast Producer-Slow Consumer

This is a common scenario that requires special attention. The publish-subscribe mechanism for terminal clients cannot always expect clients to consume messages in real time. However, message continuity must be maximally guaranteed. Clients may access our products in an uncontrollable network environment, such as over 4G or poor Wi-Fi. Thus, the server message queue cannot become too backlogged. When a client's consumption rate cannot keep up with the server's production speed, this article recommends using a bounded Buffer with the OverflowStrategy.DropOldest strategy. This ensures that subscriptions between consumers are isolated, avoiding too many unpushed messages on the server (which could lead to potential memory leak risks).

Alternative Design

VMware has publish a very similar design in 2013, but use Go RingChannel

Summary

This document proposes an event subscription mechanism to address the delay in updating content status caused by polling refresh. Clients can subscribe to events through any long connection protocol, and events will be filtered based on specified conditions. To avoid having too many unpushed messages on the server, a bounded buffer with the OverflowStrategy.DropOldest strategy is used.

Implementing this in Reactive Streams is straightforward, but you can choose your preferred technology to do so.

Overview

In the previous post, we discussed how to implement a file tree in PostgreSQL using ltree. Now, let's talk about how to integrate version control management for the file tree.

Version control is a process for managing changes made to a file tree over time. This allows for the tracking of its history and the ability to revert to previous versions, making it an essential tool for file management.

With version control, users have access to the most up-to-date version of a file, and changes are tracked and documented in a systematic manner. This ensures that there is a clear record of what has been done, making it much easier to manage files and their versions.

Terminologies and Context

One flawed implementation involves storing all file metadata for every commit, including files that have not changed but are recorded as NO_CHANGE. However, this approach has a significant problem.

The problem with the simple and naive implementation of storing all file metadata for every commit is that it leads to significant write amplification, as even files that have not changed are recorded as NO_CHANGE. One way to address this is to avoid storing NO_CHANGE transformations when creating new versions, which can significantly reduce the write amplification.

This is good for querying, but bad for writing. When we need to fetch a specific version, the PostgreSQL engine only needs to scan the index with the condition file.version = ?. This is a very cheap cost in modern database systems. However, when a new version needs to be created, the engine must write \(N\) rows of records into the log table (where \(N\) is the number of current files). This will cause a write peak in the database and is unacceptable.

In theory, all we need to do is write the changed file. If we can find a way to fetch an arbitrary version of the file tree in \(O(log(n))\) time, we can reduce unnecessary write amplification.

Non Functional Requirements

Scalability

Consider the worst-case scenario: a file tree with more than 1,000 files that is committed to more than 10,000 times. The scariest possibility is that every commit changes all files, causing a decrease in write performance compared to the efficient implementation. Storing more than 10 million rows in a single table can make it difficult to separate them into partitioned tables.

Suppose \(N\) is the number of files, and \(M\) is the number of commits. We need to ensure that the time complexity of fetching a snapshot of an arbitrary version is less than \(O(N\cdot log(M))\). This is theoretically possible.

Latency

In the worst case, the query can still respond in less than 100ms.

Architecture

Database Design

Illustration of data structures.

Illustration of data structures.

Tech Details

Subqueries appearing in FROM can be preceded by the key word LATERAL. This allows them to reference columns provided by preceding FROM items. (Without LATERAL, each subquery is evaluated independently and so cannot cross-reference any other FROM item.) — https://www.postgresql.org/docs/current/queries-table-expressions.html#QUERIES-LATERAL

PostgreSQL has a keyword called LATERAL. This keyword can be used in a join table to enable the use of an outside table in a WHERE condition. By doing so, we can directly tell the query optimizer how to use the index. Since data in a combined index is stored in an ordered tree, finding the maximum value or any arbitrarily value has a time complexity of \(O(log(n))\).

Finally, we obtain a time complexity of \(O(N \cdot log(M))\).

Performance

Result: Fetching an arbitrary version will be done in tens of milliseconds.

1
2
3
4
5
6
7
8
9
10
11
12
13
explain analyse
select f.record_id, f.filename, latest.revision_id
from files f
inner join lateral (
select *
from file_logs fl
where f.filename = fl.filename
and f.record_id = fl.record_id
-- and revision_id < 20000
order by revision_id desc
limit 1
) as latest
on f.record_id = 'f5c2049f-5a32-44f5-b0cc-b7e0531bf706';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Nested Loop  (cost=0.86..979.71 rows=1445 width=50) (actual time=0.040..18.297 rows=1445 loops=1)
-> Index Only Scan using files_pkey on files f (cost=0.29..89.58 rows=1445 width=46) (actual time=0.019..0.174 rows=1445 loops=1)
Index Cond: (record_id = 'f5c2049f-5a32-44f5-b0cc-b7e0531bf706'::uuid)
Heap Fetches: 0
-> Memoize (cost=0.57..0.65 rows=1 width=4) (actual time=0.012..0.012 rows=1 loops=1445)
" Cache Key: f.filename, f.record_id"
Cache Mode: binary
Hits: 0 Misses: 1445 Evictions: 0 Overflows: 0 Memory Usage: 221kB
-> Subquery Scan on latest (cost=0.56..0.64 rows=1 width=4) (actual time=0.012..0.012 rows=1 loops=1445)
-> Limit (cost=0.56..0.63 rows=1 width=852) (actual time=0.012..0.012 rows=1 loops=1445)
-> Index Only Scan Backward using file_logs_pk on file_logs fl (cost=0.56..11.72 rows=158 width=852) (actual time=0.011..0.011 rows=1 loops=1445)
Index Cond: ((record_id = f.record_id) AND (filename = (f.filename)::text))
Heap Fetches: 0
Planning Time: 0.117 ms
Execution Time: 18.384 ms

Test Datasets

This dataset simulates the worst-case scenario of a table with 14.6 million rows. Specifically, it contains 14.45 million rows representing a situation in which 1,400 files are changed 10,000 times.

1
2
3
4
5
-- cnt: 14605858
select count(0) from file_logs;
-- cnt: 14451538
select count(0) from file_logs where record_id = 'f5c2049f-5a32-44f5-b0cc-b7e0531bf706';

Schema

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
create table public.file_logs
(
file_key ltree not null,
revision_id integer not null,
record_id uuid not null,
filename varchar(2048) not null,
create_time timestamp,
update_time timestamp,
delete_time timestamp,
blob_sha256 char(64),
constraint file_logs_pk
primary key (record_id, filename, revision_id)
);

alter table public.file_logs
owner to postgres;

create table public.files
(
record_id uuid not null,
filename varchar(2048) not null,
create_at timestamp not null,
primary key (record_id, filename)
);

alter table public.files
owner to postgres;

Further Improvements

We can implement this using an intuitive approach in a graph database.

File tree version in graph database

Background

A file tree is a hierarchical structure used to organize files and directories on a computer. It allows users to easily navigate and access their files and folders, and is commonly used in operating systems and file management software.

But implementing file trees in traditional RDBMS like MySQL can be a challenge due to the lack of support for hierarchical data structures. However, there are workarounds such as using nested sets or materialized path approaches. Alternatively, you could consider using NoSQL databases like MongoDB or document-oriented databases like Couchbase, which have built-in support for hierarchical data structures.

It is possible to implement a file tree in PostgreSQL using the ltree datatype provided by PostgreSQL. This datatype can help us build the hierarchy within the database.

TL;DR

Pros

  • Excellent performance!
  • No migration is needed for this, as no new columns will be added. Only a new expression index needs to be created.

Cons

  • Need additional mechanism to create virtual folder entities.(only if you need to show the folder level)
  • There are limitations on the file/folder name length.(especially in non-ASCII characters)

Limitation

The maximum length for a file or directory name is limited, and in the worst case scenario where non-ASCII characters(Chinese) and alphabets are interlaced, it can not be longer than 33 characters. Even if all the characters are Chinese, the name can not exceed 62 characters in length.

Based on PostgreSQL documentation, the label path can not exceed 65535 labels. However, in most cases, this limit should be sufficient and it is unlikely that you would need to nest directories to such a deep level.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
select escape_filename_for_ltree(
'一0二0三0四0五0六0七0八0九0十0' ||
'一0二0三0四0五0六0七0八0九0十0' ||
'一0二0三0四0五0六0七0八0九0十0' ||
'一0二0'
); -- worst case len 34
select escape_filename_for_ltree(
'一二三四五六七八九十' ||
'一二三四五六七八九十' ||
'一二三四五六七八九十' ||
'一二三四五六七八九十' ||
'一二三四五六七八九十' ||
'一二三四五六七八九十' ||
'一二三'
); -- Chinese case len 63
1
[42622] ERROR: label string is too long Detail: Label length is 259, must be at most 255, at character 260. Where: PL/pgSQL function escape_filename_for_ltree(text) line 5 at SQL statement

How to use

Build expression index

1
CREATE INDEX idx_file_tree_filename ON files using gist (escape_filename_for_ltree(filename));

Example Query

1
2
3
4
5
explain analyse
select filename
from files
where escape_filename_for_ltree(filename) ~ 'ow.*{1}'
and record_id = '1666bad1-202c-496e-bb0e-9664ce3febcb';

Query Result

1
2
3
4
5
ow/ros_00000000_2022-03-02-12-55-19_330.bag
ow/ros_00011426_2022-08-15-19-24-11_0.bag
ow/ros_00019378_2022-08-12-18-40-06_0.bag
ow/ros_00011426_2022-08-15-19-24-11_0.bag
ow/ros_00011426_2022-08-15-19-24-11_0.bag.coscene-reserved-index

Query Explain

1
2
3
4
5
6
7
8
9
10
11
Bitmap Heap Scan on files  (cost=32.12..36.38 rows=1 width=28) (actual time=0.341..0.355 rows=8 loops=1)
Recheck Cond: ((record_id = '1666bad1-202c-496e-bb0e-9664ce3febcb'::uuid) AND (escape_filename_for_ltree((filename)::text) <@ 'ow'::ltree))
Heap Blocks: exact=3
-> BitmapAnd (cost=32.12..32.12 rows=1 width=0) (actual time=0.323..0.324 rows=0 loops=1)
-> Bitmap Index Scan on idx_file_tree_record_id (cost=0.00..4.99 rows=93 width=0) (actual time=0.051..0.051 rows=100 loops=1)
Index Cond: (record_id = '1666bad1-202c-496e-bb0e-9664ce3febcb'::uuid)
-> Bitmap Index Scan on idx_file_tree_filename (cost=0.00..26.88 rows=347 width=0) (actual time=0.253..0.253 rows=52 loops=1)
Index Cond: (escape_filename_for_ltree((filename)::text) <@ 'ow'::ltree)
Planning Time: 0.910 ms
Execution Time: 0.599 ms

Explaination

PostgreSQL's LTREE data type allows you to use a sequence of alphanumeric characters and underscores on the label, with a maximum length of 256 characters. So, we get a special character underscore that can be used as a notation to build our escape rules within the label.

Slashes(/) will be replaced with dots(.). I think it does not require further explanation.

Initially, I attempted to encode all non-alphabetic characters into their Unicode hex format. However, after receiving advice from other guys, I discovered that using base64 encoding can be more efficient in terms of information entropy. Ultimately, I decided to use base62 encoding instead to ensure that no illegal characters are produced and to achieve the maximum possible information entropy.

This is the final representation of the physical data that will be stored in the index of PostgreSQL.

1
2
3
4
select escape_filename_for_ltree('root/folder1/机器人仿真gazebo11-noetic集成ROS1/state.log');
-- result:
-- root.folder1._1hOBTVt5n7EhFWzIbUcjT_gazebo11_j_noetic_1Aw3qhY48_ROS1.state_k_log

Further

If you want to store an isolated file tree in the same table, one thing you need to do is prepend the isolation key as the first label of the ltree. For example:

1
select escape_filename_for_ltree('<put_user_id_in_there>' || '/' || '<path_to_file>');

By doing this, you will get the best query performance.

Summary

This document explains how to implement a file tree in PostgreSQL using the ltree datatype. The ltree datatype can help build the hierarchy within the database, and an expression index needs to be created. There are some limitations on the file/folder name length, but the performance is excellent. The document also provides PostgreSQL functions for escaping and encoding file/folder names.

Appendix: PostgreSQL Functions

Entry function (immutable is required)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE OR REPLACE FUNCTION escape_filename_for_ltree(filename TEXT)
RETURNS ltree AS
$$
DECLARE
escaped_path ltree;
BEGIN
select string_agg(escape_part(part), '.')
into escaped_path
from (select regexp_split_to_table as part
from regexp_split_to_table(filename, '/')) as parts;

return escaped_path;

END;
$$ LANGUAGE plpgsql IMMUTABLE;

Util: Escape every part (folder or file)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
create or replace function escape_part(part text) returns text as
$$
declare
escaped_part text;
begin
select string_agg(escaped, '')
into escaped_part
from (select case substring(sep, 1, 1) ~ '[0-9a-zA-Z]'
when true then sep
else '_' || base62_encode(sep) || '_'
end as escaped
from (select split_string_by_alpha as sep
from split_string_by_alpha(part)) as split) as escape;
RETURN escaped_part;
end;
$$ language plpgsql immutable

Util: Split a string into groups

Each group contains only alphabetic characters or non-alphabetic characters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE OR REPLACE FUNCTION split_string_by_alpha(input_str TEXT) RETURNS SETOF TEXT AS
$$
DECLARE
split_str TEXT;
BEGIN
IF input_str IS NULL OR input_str = '' THEN
RETURN;
END IF;

WHILE input_str != ''
LOOP
split_str := substring(input_str from '[^0-9a-zA-Z]+|[0-9a-zA-Z]+');
IF split_str != '' THEN
RETURN NEXT split_str;
END IF;
input_str := substring(input_str from length(split_str) + 1);
END LOOP;

RETURN;
END;
$$ LANGUAGE plpgsql

Util: base62 encode function

By using the base62_encode function, we can create a string that meets the requirements of LTREE and achieves maximum information entropy.

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
CREATE OR REPLACE FUNCTION base62_encode(data TEXT) RETURNS TEXT AS $$
DECLARE
ALPHABET CHAR(62)[] := ARRAY[
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J',
'K','L','M','N','O','P','Q','R','S','T',
'U','V','W','X','Y','Z','a','b','c','d',
'e','f','g','h','i','j','k','l','m','n',
'o','p','q','r','s','t','u','v','w','x',
'y','z'
];
BASE BIGINT := 62;
result TEXT := '';
val numeric := 0;
bytes bytea := data::bytea;
len INT := length(data::bytea);
BEGIN
FOR i IN 0..(len - 1) LOOP
val := (val * 256) + get_byte(bytes, i);
END LOOP;

WHILE val > 0 LOOP
result := ALPHABET[val % BASE + 1] || result;
val := floor(val / BASE);
END LOOP;

RETURN result;
END;
$$ LANGUAGE plpgsql;

起因

这个月(2022年8月)于Rust二群与某人辩论,因为某人坚持认为 Rust 的所有权 Ownership 机制仅仅是等同于垃圾回收 Garbage Collection ,而我认为 Ownership 还解决了另一个困扰无数码农的问题:资源安全

定义

常见资源可以分为三大类:

  • 文件
    • Socket
      • TCP
      • HTTP
      • JDBC
    • 文件系统
    • 本地
    • 远程(Redis,RDBMS)
  • 逻辑资源
    • Stream(背后可能是文件)
    • 日志区块或长链接起止符
    • 临时文件删除

而资源管理总共分三步,分别是:

  1. 资源申请
  2. 资源使用
  3. 资源释放

这三个事件需要严格按顺序发生。

而资源安全关注的是:

  • 在使用前已经正确初始化
  • 使用后能被正确释放
  • 释放后不能被再次使用

语言(语法)提供的资源管理有什么用呢?它给程序员一个强有力的保证,非极端情况下(断电等),资源释放逻辑必定会被执行。

资源管理的历史

史前

C

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>

int main()
{
int num;
FILE *fptr;

if ((fptr = fopen("C:\\program.txt","r")) == NULL){
printf("Error! opening file");

// Program exits if the file pointer returns NULL.
exit(1);
}

fscanf(fptr,"%d", &num);

printf("Value of n=%d", num);
fclose(fptr);

return 0;
}

早期的计算机语言并没有意识到资源管理问题,依然是指令式编码风格,需要程序员自己保证资源的申请与释放。同时那个年代软件系统并不复杂,再加上从业者素质普遍较高,所以资源管理问题不像今天这么突出。

语言结构(Language constructs)

后来,有些语言引入了异常机制,允许程序无视控制流语句自行中断并跳出,资源管理也变得复杂起来。支持抛出异常的语言通常使用 try {} catch {} finally {} 约束资源作用范围, try 关键字表示资源作用范围,无论程序以任何形式跳出, finally 关键字标记代码块都应当被确保执行,资源正确释放,代表语法:

1
2
3
4
5
6
7
8
FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr);
try {
return br.readLine();
} finally {
br.close();
fr.close();
}

销毁模式(Dispose pattern)

这个模式也是当今大部分有 GC 语言所支持的,比如 Java 语法关键字 try

据我所知 Java 所谓的资源安全只有 Java7 时代引入的 try-with-resource,资源需继承自AutoCloseable,可以在 try(...) { ... code block ... } 内放心使用,也就意味着异步代码没有任何保障。

1
2
3
4
5
6
static String readFirstLineFromFile(String path) throws IOException {
try (FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr)) {
return br.readLine();
}
}

Resource Monad 模式

后续发展中诞生的较为安全的设计模式,将使用资源的同步或异步代码包裹在一个代码块中,使用结束后释放,这样可以避免在每次使用资源后手动关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static <R> CompletionStage<R> use (Function<Resource, CompletionStage<R>> f) {
return Resource.make()
.thenCompose((res) -> f.apply(res)
.handle((r, e) -> {
res.close();
if (e != null) throw new RuntimeException(e);
return r;
})
);
}

use((res) -> {
System.out.println(res);
// 将业务逻辑编写到 CompletableFuture 内部执行
return CompletableFuture.failedStage(new Exception("error"));
}).handle((r, e) -> {
if (e != null) {
System.out.println(e.getMessage());
}
return r;
});

以上方案都有一个缺陷,在使用过程中误将资源变量共享给其他代码段(闭包,回调,外部变量,无意中发送给队列 HTTP Response,例如如下 Java 代码。

1
2
3
4
5
6
static HttpEntity fileEntity(String filename) throws IOException {
try (FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr)) {
return new HttpEntity(br);
}
}

如果 HttpEntity 类并不是在创建时消费 Reader ,而是会等待 HTTP Body 传输时才开始读取字节流,那毫无疑问,这会造成访问已关闭资源,可能引起应用程序崩溃。

正确的写法如下

1
2
3
4
5
6
7
8
9
10
11

// 伪代码
static HttpEntity fileEntity(String filename) throws IOException {
final FileReader fr = new FileReader(path);
final BufferedReader br = new BufferedReader(fr);
return new HttpEntity(br) {
public void close() {
br.close();
fr.close();
}
};

RAII

C++

Move semantics make it possible to safely transfer resource ownership between objects, across scopes, and in and out of threads, while maintaining resource safety. — (since C++11)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void f()
{
vector<string> vs(100); // not std::vector: valid() added
if (!vs.valid()) {
// handle error or exit
}

ifstream fs("foo"); // not std::ifstream: valid() added
if (!fs.valid()) {
// handle error or exit
}

// ...
} // destructors clean up as usual

C++ 提出了 RAII 这一先进概念,几乎解决了资源安全问题。但是受限于 C++ 诞生年代,早期 C++ 为了保证资源安全,只支持左值引用(LValue Reference) + Clone(Deep Copy) 语义,使得赋值操作会频繁深拷贝整个对象与频繁构造/析构资源,浪费了很多操作。C++11 开始支持右值引用,但是仍然需要实现右值引用(RValue Reference)的 Move(Shallow Copy)。同时,C++ 无法检查多次 move 的问题和 move 后原始变量仍然可用的问题。

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
42
43
#include <iostream>
using namespace std;

class A{
public:
A(const string& str, int* arr):_str(str),_arr(arr){cout << "parameter ctor" << endl;}
A(A&& obj):_str(std::move(obj._str)),_arr(std::move(obj._arr)){obj._arr = nullptr;cout << "move ctor" << endl;}
A& operator =(A&& rhs){
_str = std::move(rhs._str);
_arr = std::move(rhs._arr);
rhs._arr = nullptr;
cout << "move assignment operation" << endl;
return *this;
}
void print(){
cout << _str << endl;
}
~A(){
delete[] _arr;
cout << "dtor" << endl;
}
private:
string _str;
int* _arr;
};

int main(){
int* arr = new int[6] {1,1,4,5,1,4};
A a("Yajuu Senpai", std::move(arr)); // 错误的指针移动 --> STUPID MOVE!!
A b(std::move(a)); // move ctor

cout << "print a: ";
a.print(); // a 失去所有权 --> CORRECT!!
cout << "print b: ";
b.print(); // b 获得所有权 --> CORRECT!!

b = std::move(a); // 二次移动

cout << "print a: ";
a.print(); // ???
cout << "print b: ";
b.print(); // ???
}

Rust

继承自 C++ RAII ,当创建资源和使用资源不在同一个领域时,Rust 的 move / borrow 依然可以安心睡觉,这种语言级别的保证让我一个写 Scala 的看了都羡慕。

在Rust 中move 给别人就是别人负责 dropborrow 给别人还是自己负责 drop,且编译器会根据生命周期检查,确保不会发生多次 move,也不会有超出拥有者(owner)的借用(&borrow)发生。责任划分很清晰,只要自己脑子清醒,完全不担心异步的时候会泄漏。

程序员无法显式 delete,只能遵守 Rust 语法,编译器依据变量生命周期将相关变量的 drop 插入到正确位置,通常是离开块级作用域的位置。

Rust Drop

https://doc.rust-lang.org/rust-by-example/scope/raii.html

同步示例如下:

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
struct Entity {
connection: &Connection
file: File
}

impl Drop for Entity {
fn drop(&mut self) {
file.write(EOF); // <- 文件关闭前写入终止符
}
}

let conn = makeConnection();
let file = openFile();
let entity = Entity {
connection: &conn, // <- 将 conn 借用给 entity
file: file // <- 将 file 所有权转移给 entity
} // <- 从此以后,file 将不可被访问
fn send(entity: Entity) {
// logic
return;
// <- 编译器会在此处插入释放 entity
}
send(entity) // <- 将 entity 所有权移交给 send 函数
// <- 编译器会在此处插入释放 conn
// <- 因 entity 与 file 所有权已转移,此处不会重复释放 entity 与 file

异步示例如下:

1
2
3
4
5
6
7
8
9
fn move_block() -> impl Future<Output = ()> {
let my_string = "foo".to_string();
async move { // <- my_string 被 move 到此 block scope
// ...
println!("{}", my_string); // <- my_string 在此处可以见
// <- 编译器将在此处插入 my_string drop 代码
}
// <- my_string 将不再可见,本函数失去 my_string 所有权,不再插入 drop 代码
}

闭包捕获造成的资源逃逸与将引用赋值给类/结构体帮助资源逃逸在语言本质上是同一个问题,所以 Rust 可以用相同的方法来处理它们。

总结

理论上来说垃圾回收器(Garbage collector)无法解决资源安全问题,可能有人会认为:

“给 Java 语言添加 Destructor ,这样开发者就可以在析构函数中实现资源释放逻辑,交给 GC 在回收内存时自动调用 destory()/dispose() ,问题不就解决了吗?“

实际上这条路是走不通的,GC 根据算法不同,所参考的策略也不同,其收集(collect)/释放(free)动作必定会执行,但没有保证什么时候会执行,以什么顺序执行。因为考察 GC 性能指标时,更关注的是吞吐量而不是回收实时性,如果内存没有压力,GC 倾向于不回收。

这一行为可能会导致意想不到的后果,比如业务逻辑 A 结束时,文件资源对象的引用计数已经为零,通知下一个逻辑 B 可以处理此文件,而 B 尝试打开文件时却发现文件不完整,因为关闭文件的系统调用尚未被 GC 执行😵。

Rust 给出了一套完善的解决方案,它不仅解决了诸多内存安全问题,还顺带解决了资源安全问题。基于所有权机制和严格的编译器检查,强迫程序员写出资源安全的代码,仅需要程序员正确实现 impl Drop for [...]

我认为 Rust 实现的所有权与 RAII 是当下最完善的资源管理机制。

  • try-catch-finally 相比,不需要在每次使用资源时,都格外小心是否双重释放(double delete 这在 Java 中是个很常见且令人头痛的问题)。
  • ResourceMonad 相比,不会产生资源逃逸。
  • 是一门全新的语言,不像 C++ 一样有沉重的历史包袱。

即使不使用 Rust 编码,依然可以借鉴它的思想,因为其语法本身就是资源管理的最佳实践,学习它可以帮助自己在其他语言中避开错误的写法。

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

0%