CrazyAirhead

疯狂的傻瓜,傻瓜也疯狂——傻方能执著,疯狂才专注!

0%

七个多月前,我写过一篇《部署和使用 FunASR》,记录从 whisper 换到 FunASR 的过程。七个多月后,我把这套服务换成了 Confucius4-R2T2。这篇文章完整记录决策链:为什么换、架构怎么定、量化怎么选、靠什么数据下结论,以及付出了什么代价。

一、为什么选 Confucius4-R2T2

我有个 Java 小项目:采集音视频,本地转写,入库供检索。环境使用 MacBook Pro M1 Pro (32GB) + OrbStack,无 GPU。

这套转写栈已经做了两次迭代。一开始使用 whisper:识别不准、没有标点、时不时蹦出繁体,最难受的是会在结尾冒出些奇怪的内容 —— 长期靠 LLM 修正表兜底。后来 FunASR(aliyun funasr runtime: Paraformer-large + VAD + 标点 + ngram 语言模型 + 数字规整)就是为了治这些毛病换的:检测、识别、标点、数字规整一站式,服务端自带 ffmpeg,还支持热词;WebSocket 协议,单容器,RTF≈0.05(Real-Time Factor,实时率 = 转写耗时 ÷ 音频时长,小于 1 即比实时快,越小越快) —— 3.7 分钟的音频 11 秒转完。一用就是七个多月,看起来够用了。

最近看到 Confucius4-R2T2 霸榜的消息,网易有道子曰-Live 系列的 Confucius4-R2T2:与 Qwen3-ASR 同架构的 2B 微调版。同期开源的还有流式翻译模型 Confucius4-T3PO,两个模型发布时在 HuggingFace 双双登顶各自细分 Trending 榜 —— R2T2 是 ASR 榜第一。

让我觉得可以再次迭代的原因,我的 Mac 能跑得起来。官方放出了 GGUF 量化仓库(netease-youdao/Confucius4-R2T2-GGUF ),海外开发者还主动做了 C++ 移植(audio.cpp);llama.cpp 官方二进制直接能跑,Ollama 一条命令就能拉起。别人愿意真花时间做适配,说明这东西能用。

之前有人问我 FunASR 对比 Qwen3-ASR 怎么样 —— 严格说 FunASR 是一套框架/平台,包含一整套流水线,其中负责转写的默认模型是 Fun-Asr,是可以更换的(比如换成 Paraformer、SenseVoice,Qwen3-ASR 也可以),具体参考 HuggingFace 上的这篇讨论。这次同题对比也算顺带验证了那个问题,毕竟 Confucius4-R2T2 就是基于 Qwen3-ASR 架构。

二、技术验证

让 AI 作了技术验证,得出五条结论:

  1. 用 FunASR 直接跑 Confucius4-R2T2 大模型,CPU 上 RTF≈90,完全不可用(3.7 分钟音频要转 4~6 小时);但它的两个小模型是毫秒级的 —— VAD(fsmn-vad)RTF 0.003,说话人嵌入(cam++)RTF 0.006。
  2. llama.cpp 只有 HTTP(含 SSE),没有 WebSocket。旧栈的 WS 客户端要自己初始化配置、按块发送音频、发结束标志、解析回包,其实也不简单;新栈换成一次 HTTP multipart 上传就完事,倒也省事。
  3. mp3 可以直传 llama.cpp、mp4/AAC 不行,报 “Failed to tokenize prompt”。视频必须先经 ffmpeg 抽音轨。
  4. 单请求受上下文限制:音频约占 13 token/s,-c 4096 下约 5.2 分钟封顶。长音频必须分段。
  5. 输出带 language Chinese<asr_text> 前缀,需要剥离。

FunASR 不能直接使用,Mac 跑不动,特别是在容器里跑,应该以 llama.cpp 为主,但是因为 FunASR 的 VAD 和说话人嵌入等还是不错的,也应该吸收过来。

三、FunASR 降级为预处理层

最终架构各取所长:

flowchart TD
    A(["音频/视频"]) --> B["ffmpeg 转 16k 单声道"]
    subgraph PRE["毫秒级小模型, CPU 随便跑"]
        P1["fsmn-vad 按静音切段"]
        P2["cam++ 说话人嵌入 + 层次聚类
(小于 2s 碎段就近继承邻段标签)"] P3["相邻同说话人段合并为 ≤55s 转写块"] P1 --> P2 --> P3 end B --> P1 P3 --> T["llama.cpp GGUF(Q8_0) 逐块转写
重活交给量化后的大模型"] T --> O(["输出: JSON 说话人轮次 + Markdown 文稿"])

FunASR 从“转写引擎”降级成“预处理层”,只负责 VAD 切段和说话人分离;文本生成的重活全部交给 GGUF,比 FunASR 调用 Confucius4-R2T2 原生 CPU 转写快两个数量级。

说话人分离的输出长这样:

1
**【说话人 1】**(00:00-00:06)之前有顾客自己带酒水,也没加收钱或者不让喝。  

实测:224.5 秒独白音频,speakers=1 无误检;42.3 秒双人交替对话,speakers=2 且四轮完美交替。预处理开销可以忽略 —— 耗时全在转写本身。

四、量化怎么选:Q8_0 是甜点位

GGUF 部署要选量化档。7 个测试文件(约 7.5 分钟)做全量化 A/B:

量化 容器 CPU RTF 镜像体积 质量
f16 0.52-0.57 6.87GB 基准
Q8_0 0.42-0.45 5.26GB 与 f16 字词级几乎全同
Q4_K_M 0.42 4.24GB 歧义短语上与 f16 各有输赢

一个有意思的发现:f16 在容器 CPU 和 Mac Metal 上输出逐字节一致 —— 意味着结果可复现,所有 A/B 对比因此可信。

结论:Q8_0 质量≈f16、速度≈Q4_K_M,标准的甜点位,设为默认模型。差异仅在个别标点与语气词(Q8 的标点反而更规范);至于“推倒重来/推导出来”这类音频本身就歧义的短语,任何引擎、任何量化都会翻转,不计分。

五、同题 A/B:可以换成 R2T2

用 5 个真实 mp3(共 3.7 分钟)同题对比:

维度 FunASR(aliyun runtime) R2T2(Q8_0)
幻觉 1 处:结尾凭空多出”没有有没有。” 无,5/5 干净收尾
断句 “推导出来了。若干次”(错位) “推倒重来了若干次”(断句正确)
伪影 ITN 空格伪影(“14 个”) 无
语气词 丢(“呃”) 完整保留
标点 粗糙生硬 细腻自然
专有名词 “李笑来”✓ “定投”✓ 同样 ✓
速度 单文件 1.7-3.1s(RTF≈0.05) 单文件 12-25s(RTF 0.42-0.45)

质量上新栈全面占优,且没有一处实质字词错误输给对方;速度上 FunASR 快约 10 倍 —— 但我的场景是批量离线转写,RTF 0.05 和 0.42 都远快于实时,都是后台任务,没有本质区别。质量优先,决定切换成 R2T2。

六、最终形态:一个容器一个端口

最后一步,把所有能力收进一个自包含镜像 r2t2-gguf:latest(5.26GB,模型全部内置):

1
docker run -d --name r2t2-asr -p 8080:8080 r2t2-gguf:latest

起来就是一个 ASR 网关,四组端点:

  • POST /v1/audio/transcriptions —— OpenAI 兼容,短音频字节级透传,老客户端零改动;
  • POST /asr/jobs —— 提交长音视频任务(mp4/ts/m4a 等任意 ffmpeg 支持的格式),返回 202 + job_id;
  • GET /asr/jobs/{id} —— 轮询进度,逐块更新 n/N;
  • DELETE /asr/jobs/{id} —— 取消,块间生效,保留已完成部分。

内部逻辑:≤270s 的音频单发直译;超了就自动走完整管线 —— ffmpeg 抽轨、VAD、说话人分离、分块转写。网关本体 asr_server.py 约 300 行,纯 Python 标准库,没有给镜像增加任何依赖。Java 侧只对接两个方法:短音频同步透传;长视频提交任务 + 10 秒轮询 + 进度停滞看门狗,结果按“源文件.txt”旁路缓存,幂等可重试。

为什么长音频必须分块,不能放大上下文硬扛?实测过:-c 32768 单发 10.4 分钟尚可用(RTF 0.72);但 31.2 分钟时生成速度从 6.6 token/s 一路衰减到 3.0 以下,比实时还慢(RTF>2)。而分块方案的 RTF 恒定在 0.43-0.50,与时长无关 —— 31.2 分钟音频分块转写 835.8s(RTF 0.447)。

实战验证:63.1 分钟的真实 mp4(AV1+AAC,英文演讲)直接扔给网关 —— 容器内自动抽轨,74 块、识别出 3 个说话人、45,367 字符、端到端 1751.6s,RTF 0.462,与预估一致;首尾干净,无幻觉;中途 DELETE 也能干净取消。以我现在 Mac 上的容器跑法,29 分钟能转完一小时视频。

顺带记录一个值得警惕的 bug:早期 Dockerfile 里多源 COPY fsmn-vad campplus /models/funasr/ 会把两个目录的内容摊平进同一层,子目录从未真正存在;而 FunASR 收到不存在的本地路径时会静默回退到在线下载 —— 所谓”运行时免下载”其实从未成立,小模型一直是运行时在线拉的,只是恰好没人断网发现过。修复后加了显式路径检查才算闭环。

七、代价清单

切换后的问题:

  • 速度慢了约 10 倍:RTF 0.42 对 0.05,一小时视频转 29 分钟。离线批量无感,但要实时出稿的场景,这套 CPU 方案不适合;
  • 热词降级:旧栈原生热词,新栈只能经 prompt 字段透传,效果依赖 llama.cpp 版本对该模型上下文偏置的支持;
  • 没有流式:需要的话得上 R2T2 官方 NVIDIA CUDA 栈;
  • 真要快还有后手:Mac 上原生 llama.cpp + Metal 能到 RTF≈0.06(容器里没有 GPU 加速);Linux 服务器换 CUDA 版 llama.cpp 即可,镜像构建留了变体参数。

换来的是:转写质量提升;说话人分离、视频直转能力上线;为 whisper 幻觉长期兜底的 LLM 修正表等防御性逻辑,这次可以正式退休了。

八、写在最后

回顾这次的迭代升级,有三点心得值得留下来:

  1. 选型时与其盯着 benchmark,不如看有没有人真金白银地为它做移植、做量化、做集成。这样可以在不同的环境中验证是否适合自己。
  2. FunASR 调用R2T2大模型在 CPU 上不可用,但它的毫秒级小模型正好补位。“新模型干重活、旧栈降级当预处理”的混合流水线,可以推广到很多”大模型本地跑不动”的场景。
  3. 多作 A/B 测试,要同题、要真实数据、要可复现,f16 跨设备逐字节一致,让所有对比有了基准;5 个真实 mp3 的胜负表,升级迭代决定一目了然。

两次 clone 不等于 worktree —— 熟悉的陌生人最可怕。

8 月 18 日的 AI 课程叫《git 真用起来》。笑来老师讲,git 是一台有平行世界的时光机,创造平行世界有两种办法:一种叫 branch,大家都熟;另一种叫 worktree,更独立、更隔绝 —— 因为它连目录都换了。你可以在同一个项目的若干个互不干扰的目录里同时工作,需要合并的时候用 rebase 收回来。

课里举的例子是笑来老师自己写电子书阅读器:library view 拆成三块,book view 拆成四块,编辑器里开七个标签,每个标签挂一个 worktree,七个硅基劳动力同时干活。这边跑起来了,去看看那边;那边也在跑,再看看另一边 —— 七个里面总有要干的事。

听课的时候我心里一动。在此之前,我并不了解 worktree,但需求是真实存在的:ficus 要挂着最新开发分支 feat-v1.7.6 一路向前,历史版本的修复又得在 feat-v1.7.4 上做。于是有了 ficus 和 ficus-fix 两个目录,各挂各的分支,各干各的。我以为多个目录(多工作区)就是 worktree,我已经在用了。

不过我还是开了个会话,把两个目录都 @ 给 AI,问它:这两个目录现在是 git worktree 的方式使用吗?问的时候没有半点犹豫 —— 我真心以为自己在用它,也希望 AI 回答”是”。

AI 核实之后,回复的大意是:这两个目录不是真正的 git worktree,而是两个独立的完整克隆。

判断的依据很简单:真正的 worktree,它的 .git 是一个文件,内容只有一行,指向主仓库;而 ficus-fix/.git 是一个完整的目录。两边各自执行 git worktree list,都只看得到自己。它们唯一的交集,是共享同一个 origin。

两个独立克隆的实际影响:

  1. 跨目录不同步 —— 在 ficus-fix 里提交的修复,ficus 目录看不见,必须 push 之后再去 fetch;
  2. 没有分支互斥 —— 同一个分支可以被两边同时检出,各改各的,冲突留到未来某个措手不及的时刻爆炸;
  3. 对象库各存一份 —— 磁盘双倍,fetch 双倍,什么都双倍。

我甚至不知道 worktree 的存在,用第二个克隆满足了同样的需求,等真正遇到这个词时,就顺理成章地把它当成了自己已有的做法 —— 连核实都省了。以为自己知道,是比不知道更危险的状态:不知道的人会去查,以为自己知道的人,连查的动作都不会有。如果不是这次笑来老师的提醒,如果不是 AI 一句”这两个目录不是真正的 worktree”,我可能也就一直这么用着,多同步一次,费事点就费事点,困扰就困扰吧 —— 我以为这已经是最优解了。

事后想,这个误会几乎注定发生:多克隆和 worktree 从外面看几乎一模一样 —— 两个目录、两个分支、各干各的,日常用起来除了多一次 push 和 fetch,也没太大区别。区别全藏在 .git 里面:一个是完整仓库,一个是只有一行的链接文件。外观相同、机制不同的东西,最容易长成熟悉的陌生人。

既然知道用错了,就把它改过来。我说:需要使用 worktree,为我执行相关的操作。

AI 没有直接动手。它先做了一轮安全核查,确认删掉旧目录不会丢任何东西:

  • 工作区干净,没有 stash;
  • ficus-fix 里有 14 个本地分支,逐个和 origin 对比 —— 其中 feat-v1.6.0、feat-v1.7.5、feat-v1.7.x、master 这几个和 origin 不一致,需要分辨是「本地超前」(有未推送提交,删了就丢数据)还是「本地落后」(只是过时)—— 核查结果是全部 0 超前,不一致的仅仅因为过时;
  • 2 个 tag 与 origin 完全一致。

结论:ficus-fix 里没有任何 origin 上不存在的提交,可以安全删除重建。

这一段是整个过程中最值得学的。删一个用了很久的目录之前,耐心把 14 个分支一个个和远程对账,把”会不会丢数据”证明清楚,而不是靠感觉说”应该没事”。这种枯燥的核查,正是人类绝对不会有耐心做完、而硅基劳动力毫不在意的活。

核查通过,执行就两步:删掉旧的独立克隆,从主仓库挂一个真正的 worktree:

1
git worktree add /Users/airhead/WorkSpace/PolarData/ficus-fix feat-v1.7.4

验证结果:ficus-fix/.git 变成了链接文件,内容只有一行 gitdir: .../ficus/.git/worktrees/ficus-fix;两边执行 git worktree list 互相可见 —— 它们终于承认彼此属于同一个仓库;ficus 主目录的一切未受影响。转换完成时的状态:

1
2
/Users/airhead/WorkSpace/PolarData/ficus      1dee48ef3 [feat-v1.7.6]  ← 主 worktree
/Users/airhead/WorkSpace/PolarData/ficus-fix ada8dc353 [feat-v1.7.4] ← 挂修复分支

真正挂上 worktree 之后,行为立刻不一样了。实时同步:在 ficus-fix 里提交的修复,ficus 目录立刻可见 —— 共享对象库,不需要再绕远程一圈。分支互斥:ficus 占着 feat-v1.7.6,ficus-fix 就不能再检出这个分支 —— 听起来像限制,其实正是 worktree 的价值:同一个分支永远不会被两个目录同时改,减小签错分支的可能性。

在 worktree 里切分支,和普通仓库没有任何区别:

1
2
3
4
5
cd ficus-fix
git switch feat-v1.7.2 # 切到要修复的分支
git pull --ff-only # 先快进到 origin 最新,别在过时代码上修
# ……修复、提交、推送
git switch feat-v1.7.4 # 切回,或者去下一个修复分支

那句 git pull --ff-only 是 AI 特意提醒的:老版本分支常年不动,切过去时本地往往是落后的,git switch 不会自动更新 —— 不先快进,修复就可能打在过时的代码上。

最后,我把自己的使用模式说给它听,它写进了长期记忆:

ficus 只向前推进 —— 始终检出最新开发分支;ficus-fix 通常需要切换其他分支 —— 在各历史版本之间做修复。

两者的分工天然错开,以后的会话它会默认按这个模式工作:修复类任务先确认目标分支,落到 ficus-fix;新功能开发,跟随 ficus 的最新分支。这大概就是课程里说的”和自己协作”的另一种形态 —— 不仅 git 记得这个结构,AI 也记得。

写这篇文章的时候,我特意把 ficus-fix 已经切到 feat-v1.7.5 上。

回头看,这件事里有几个课程内容的直接印证。

worktree 是更独立、更隔绝的平行世界。 真正挂上之后才理解”隔绝”二字的准确:目录隔离、分支互斥 —— 但对象库共享。隔绝的是工作现场,共享的是历史。这个设计比我之前”两个克隆”的伪平行世界高明得多。

以前最有经验的工程师掌握的技巧,也不见得比 AI 强。 判断”两个克隆还是 worktree”只需要看 .git 是文件还是目录。课程说 AI 能把 git 的潜力开发到几乎 100%,我还缺很多。

花更多的时间做计划,而不是操作。 这件事里我花时间的部分,是决定”ficus 向前推进、ficus-fix 来回切换”这个分工 —— 这是 what 和 why;至于怎么核查、怎么转换、怎么验证,全部是 how,交给硅基劳动力就可以了。

熟悉的陌生人,要一个一个抓出来。 我在 worktree 摔的这一跤,路径有点绕:先是不了解它,用”再克隆一份”满足了多工作区的需求;听到这个词的时候,又把自己已有的做法直接映射了过去 —— 我以为多个目录(多个工作区)就是 worktree。还好 AI 核查的成本很低,让我了解我用错了。往后每学一个新词,除了问它是什么、怎么用,值得再问一句:帮我去现有的环境里核实一下,我是不是真的在用它。

课程最后说:你一定要用上 git 的 worktree 功能,这样的话,你就算是把这个工具用透了,比绝大多数人强。

现在,我的项目里,ficus 和 ficus-fix 终于真正是同一个仓库的两个平行世界了。

把一份脚本植入自己大脑的记录

2025 年底,我在得到上看了李笑来和脱不花在《长谈》里的对谈 —— 在某次听课中知道有这么一个对谈,然后翻出来看的。话题是:如何不靠意志力改掉坏习惯。李笑来讲了自己戒烟的方法:一个 40 年烟龄的人,用 self-talk(自我对话)无痛戒了烟。方法不复杂,就是一个句式:

我从不抽烟

我听完的反应是:既然如此,拿自己试一次。

选什么?没纠结。选那个每次想起来都让我最烦自己的事:

走路看手机。

这个选择不是凭空来的。

我自己受过伤。一次是回家路上,刚下公交车就掏出手机看,结果脚崴了,肿了好几天。又一次是在家里,端着手机往厨房走,一头撞在玻璃移门上,额头疼了很久。还有一次是旁观:亲眼看到外卖小哥骑车看手机,把人给撞了。

按说我是个会有意识控制手机使用的人:软件通知全关了(除了短信和电话),娱乐手机和工作手机分开,微信取消了小红点。但上下班的路上,下车走路的那一小段,我还是会掏出手机,点亮,把几个 IM 都打开看一遍;没有消息,就从订阅的公众号里挑一篇文章来读 —— 似乎连这点时间也不能浪费。受过伤,也看过别人受伤,走路的时候照旧掏手机。为这件事,我苦恼了很久。

所以从第一天起,我就开始用 self-talk。一个人走路的时候,把那句脚本读出声:

“我是一个走路不看手机的人。”

“我是走路不看手机的人。”

这不算我第一次用语言给自己下咒。之前听罗胖的罗辑思维,讲探险家斯坦利在非洲丛林里每天坚持刮胡子的故事,我深受触动,当时就用”不刮胡子就不是文明人”这样的话吓自己,至今几乎每天刮胡子(原来都是胡子长了才刮),除非临时出差。那次是吓出来的,这一次,我给咒语加上了理由:走路看手机会受伤;走路看手机,和行尸走肉有什么区别。

真正的考验,在手伸进口袋的那一刻。手会自己伸进去,指尖碰到手机的瞬间,有一种极细微的踏实感,说不清为什么,就是”它在,就好”。头几天,我还是会掏出来看。于是我补了一条规则:要看,就停下来,站在原地,把几个 IM 的消息确认完,再开始走。剩下的路程交给脚本 —— 碰到手机就念,边走边念,念完再念。

一段时间后,变化来了:不碰手机,心里也不再有那种空落落的感觉;即便手机抓在手上,也可以做到不看。

就这样过了半年多,我以为自己已经完全做到了走路不看手机。

然后,前两周,老家出了些事,好些天上班时间我都待在医院里。手机通知我一直关着,那几天却开始发慌 —— “万一漏掉什么呢?””耽误同事的进度怎么办?””项目进度本来就紧,我已经在请假了。”于是走路时,我又开始掏手机。每次掏出来,往往什么都没有。但下一次,照掏。

更让我不安的是,从老家回来之后,这个行为并没有自己消失。爬楼梯的时候,我又会把手机掏出来,一直看到进家门。

半年攒下的咒语效果,两周就退了回去。

好在作息恢复正常之后,我也警醒过来:这不是我想要的行为。我又开始念动咒语 ——

“我是走路不看手机的人。”

一遍,两遍,十遍。只要是我自己一个人走路、不用接送小孩,我就一直念,从下车到工位的路上念,从下车到家的路上也念。同时,把那条规则重新立了起来:

只要在路上想起任何需要处理的事 —— 停下来,处理完,再走。

咒语管身份,规则管动作。

也是因为这次反复,我把《半秒之间》完整地看了一遍。

这些天,我处的状态是:大部分时候正常,偶尔还是会掏手机。但和从前不一样——

想看的时候,知道自己在看,并且手里握着选择权。 我可以停下来看一会,我可以就这么抓着手机,我可以把手机放回口袋。

所以这不是一篇成功的记录,此刻的我依然会掏手机,改变远没有完成,只是方向对了。

如果你身上也有这样一件”每次想起来都烦自己”的事,方法都在上面了,拢共四步。

先找一个真实的理由。最好带着具体的画面和疼痛,别用别人的道理。

再写出你的脚本。套那个格式:”因为我是 X,当 Y 发生时,我会做 Z。”一句话,越短越好。

然后在触发场景里,用自己的声音念出来。出声,重复,允许它枯燥;带感情更好,但别演。

最后,准备迎接反复。反复不是失败,它只是过程的一部分。每次复发之后,你只需要再次念动咒语。

植入一个思想,不需要三层梦境,只需要重复和半秒。

《盗梦空间》里有一句台词:“一个念头,一旦生根,就几乎不可能被拔掉。”

电影中,柯布团队耗时数周,潜入一层又一层梦境,冒着永远迷失在潜意识边缘的风险,才在费舍脑中植入了一个想法。而植入的最后一步最关键:他们把“毁掉父亲的产业”包装成“父亲希望你去过自己的生活”。

费舍醒来,看着风车流泪,做出了改变一生的决定。

他坚信不疑:这是我自己的选择。

这才是“思想植入”最可怕的地方——最高级的植入,是让你以为那是你的本能。

你可能会说,电影是科幻,现实里哪有这种事。

现实里不仅有,而且每天都在你身上发生。它不需要造梦机,不需要化学药剂师 —— 它只需要重复和半秒。

01 现实里的盗梦者,不需要进入你的梦

电影的植入之所以难,是因为要对抗人的本能防御:人抗拒一切别人塞给自己的想法。

所以柯布团队的破解方案,是让植入的想法“感觉像自己长出来的”。

而现实中,这个难题早已被绕过。因为有一套机制,植入的想法从来不会以“别人的想法”的面目出现——它直接伪装成你的第一反应。

你小时候被说了八千次“你怎么这么笨”,“我不行”就被写进了系统。成年后每次遇到挑战,半秒之内你就自动退缩了——你以为那叫“没自信”,其实是自动播放。

你从小看了几十万条广告,每一条都在说“拥有它你才完整”。“消费即幸福”成了你的默认反应,月底看到账单,你都不知道钱是怎么花出去的。

你每天刷短视频,算法不断投喂“三秒必爆”的刺激。你的大脑被重新训练:长文看不下去,深度思考坐不住,半秒之内,手已经划走了。

这些都不是你的选择。这是别人趁你不注意,写进你大脑的代码。

思想就像病毒。一旦被植入,它就开始自我复制,而你甚至不知道自己是宿主。

而最可怕的是:你把这段代码,当成了“自我”的一部分。

02 为什么是半秒?因为你的意志力根本来不及

这背后的机制,比电影精密得多。

诺贝尔奖得主卡尼曼在《思考,快与慢》中指出,人脑有两套系统:

系统 1:本能、快速、不耗能的自动反应,在 0.5 秒内完成判断。
系统 2:慢、耗能、需要刻意启动——这才是你以为的“你自己”。

而意志力,属于系统 2。

也就是说,当你终于反应过来“不该抽这根烟”时,烟已经点上了;当你想起“不该发脾气”时,话已经吼出去了。

你不是败给了欲望。你是败给了速度。

那么,谁在控制这半秒?

李笑来在《The Half Second: How First Reactions Get Installed, and How to Edit Them》中给出了答案:大脑里有一个类似“频率计数器”的机制 —— 像一台最原始的投票器。

它的核心特征只有一条:只数次数,不辨真伪。

认知心理学的研究反复验证了这一点。1977 年,Hasher、Goldstein 和 Toppino 的“虚幻真实效应”实验发现:被重复过的陈述,无论真假,都会获得更高的真实度评分。2015 年,Fazio 的实验更狠 —— 受试者明明知道苏格兰短裙叫 kilt,但在反复听到“苏格兰短裙叫 sari 的错误说法之后,对错误信息的“真实感”评分依然上升。

你明确知道一件事是假的,仍然挡不住重复带来的“真实感入侵”。

这就是现实版“盗梦”的底层原理:

谁重复得多,谁就赢。

03 反制的第一步:在旧代码和行动之间,开一道缝

听起来很绝望:大脑里装满了别人植入的程序,而且改都来不及改。

别急。知道程序是怎么装进去的,就是卸载重装的第一步。

而反制分两步。第一步,不是改 —— 是看见。

你无法阻止第一反应的出现,它在意识到达之前就生成了。但你可以不让它直接变成行动。

当焦虑、愤怒、自我怀疑涌起时,在心里做一个标记:

“这不是我。这是旧代码在运行。”

这个标记只需要零点几秒,但它能在旧反应和行动之间,撕开一道缝隙。电影里,柯布靠旋转的陀螺分辨梦境与现实;现实里,你需要的不是陀螺,是一句“觉察咒语”:

“等一下,这是谁的脚本?”

“我选择不马上反应。”

不需要复杂。半秒的觉察,就能打破半秒的劫持。

先别急着重写:问一个更根本的问题——“我是谁?”

写新脚本之前,有一个问题必须先处理。

有人会问:你说“我是一个不抽烟的人”要重复到变成第一反应——这不也是植入吗? 用一个假身份骗自己,跟被广告骗,有什么区别?

问得好。这个问题,正好把讨论推向了最深处。

在回答它之前,先搞清楚一件事:植入的机制是中性的。别人用它来写你的代码,你也可以用它来写自己的代码。区别不在“重复”这个动作本身,而在重复开始之前——有没有经过你的审问。

批判性思维里有一个基本功:审视一个信念的来源,而不是只看它的内容。 面对脑子里任何一个“我是______”的句子,问四个问题:

1. 这个说法,最早是谁告诉我的?(来源)

2. 我是自己验证过它,还是只是听得次数多了?(证据 vs 重复)

3. 如果我相信它,谁会受益?(利益)

4. 它帮我活得更好,还是更差?(后果)

拿“我不行”来过一遍:来源是童年的一句评价;你从没验证过,只是听了几千次;如果你信它,你的父母获得了一个“听话谦虚”的孩子、你的老板获得了不给你加薪的理由、你的恐惧获得了继续管着你的权力;后果是你一次次退缩。

四个问题问完,你会得到一个让人脊背发凉的结论——

“我不行”从来没有被证明过。它只是被重复过。

而“重复”不构成证据。频率计数器把它当成真的,只因为它数不清“事实”和“复读”。

哲学家大卫·休谟在两百多年前就发现了这件事。他说:我往内看,试图找到那个“自我”,找到的从来只是一串具体的知觉 —— 一个念头、一阵情绪、一段记忆——“自我”这个东西本身,从来没被找到过。

现代心理学接过了这个结论:所谓“我是谁”,不是一块被发现的矿石,而是一个被讲述的故事。 而只要是故事,就有作者。问题只在于 —— 现在执笔的,是不是你。

这就是新旧身份的本质区别,也是对开头那个问题的回答:

被广告植入的身份和自我对话的身份,装法确实一样 —— 都是重复。区别不在安装过程,而在安装之前有没有经过你自己的审问。 一个通过了四个问题的身份,不是自我欺骗,而是自我选择。你没骗自己说“我天生不抽烟”,你只是决定:从今天起,这句话由我来写。

所以,在写新脚本之前,先做一件更根本的事:把你脑子里所有的“我是______”列出来,逐条过那四个问题。

但注意:写这份清单的时候,脑子里想的是“我身上已经装了什么”,而不是“我应该是什么”。前者是调查,后者是目标。先把调查做完,再定目标 —— 顺序不能反。

这份清单可能会像这样:

  • “母亲说我悲观,父亲说我实际,同学说我严肃”
  • “老师说我聪明但不努力”
  • “大家都说我是好人”
  • “我不适合当众讲话”

一条一条审问。然后你会分成三堆:

  • 有证据、对我有用的 —— 留下。这是你的。
  • 只是重复、对我不利的 —— 标记。这是别人的代码。
  • 只是重复、但对我有用的 —— 也留下。来源不重要,你审问过它,它就是你的了。

比如:“我不行”——划掉。
“我是学习者”——留下。
“我是乐观的人”——哪怕它来源不明(可能是你母亲经常这么说),但你发现它确实在帮助你面对困难,那就保留。

注意,批判性思维的目的不是拆毁一切,而是把无意识接受,变成有意识选择。

而这个整理过程本身,就是“我是谁”的答案——

你不是你脑中的那些代码。你是那个能把代码拉出来逐条审问的东西。

代码会恐惧、会退缩、会上瘾,但审问本身不恐惧、不退缩、不上瘾。你平时感觉不到它,因为它不吵不闹——就像你看得见屏幕上的画面,却注意不到投影仪的光。

它是观察者。是审问者。是那个在半秒缝隙里能说“等一下”的东西。

前面的觉察咒语为什么有效?“这不是我,这是旧代码在运行” —— 现在你知道这句话不是修辞了。它是一个身份声明:代码在跑,而我在看。

想清楚这一点,自我对话就不再有“骗自己”的心理负担 —— 你不是一个容器在被灌输,而是一个作者在改稿。

那支笔,现在在你手上。下一节,我们写新脚本。

05 第二步:用你的脚本,覆盖别人的程序

我们问完了“我是谁”,现在开始写新的脚本。

方法叫 self-talk(自我对话)。但它不是对着镜子说“我要变好”——它的核心是改身份,而不是对抗行为。

还是以抽烟为例。一个第一反应由三样东西构成:

  • 身份:我是抽烟的人
  • 情境:饭后、焦虑时、朋友递烟时
  • 动作:点一根

如果你只对抗动作,就要在每一个情境里分别作战:饭后不能抽、焦虑不能抽、别人递烟不能接……情境是无穷的,你挡不住。

但如果你改身份 —— “我是一个不抽烟的人” —— 这一条一旦植入,会自动作用于所有情境。

身份是枢纽。改一个枢纽,胜过打一百场遭遇战。

为什么身份改得动?还记得频率计数器吗?它不辨真伪,只数次数。旧身份是被重复几千次植入进去的;那么新的身份声明,只要重复的次数足够多,同样植入得进去。

你是在用植入你的机制,反过来自己编码自己。

具体怎么写这个新脚本?李笑来给出了 ARISE 框架:

  • A(Action,动作) :身体能执行、半秒内能启动的具体动作
  • R(Reason,理由) :做这个动作的原因
  • I(Identity,身份) :你想成为什么样的人
  • S(Situation,情境) :触发旧习惯的具体场景
  • E(Emotion,情绪) :行动时的感受

脚本的核心是动作。不要给大脑下“状态命令”,要给身体下动作命令:

  • “我很冷静” → 不如 “我深呼吸三次再回答”
  • “我不焦虑” → 不如 “账单来了,我 60 秒内打开它”
  • “我不冲动交易” → 不如 “价格波动时,我 24 小时不操作”
  • “我要更爱孩子” → 不如 “孩子说话时,我看着他的眼睛再回答”

完整的脚本长这样:

因为我是 ______ (你想成为的身份),
当 ______ (触发旧习惯的场景)发生时,
我会做 ______ (一个具体的身体动作),
因为 ______ (做这个动作的理由)。

然后,用自己的声音,在触发场景里反复说给自己听。

研究发现,一个新习惯稳定下来的中位时间约为 66 天,范围从 18 天到 254 天不等。你的旧脚本被重复了几千次,新脚本不可能几天就取代它。但只要你的重复次数追上旧脚本,频率计数器就会调转枪口——

这次,为你而战。

06 陀螺倒下之前

把电影和现实放在一起看:

《盗梦空间》 你的现实
谁在植入 专业盗梦团队 广告、算法、童年、环境
植入方式 进入梦境 利用频率计数器,重复再重复
植入结果 费舍以为想法是自己的 你以为第一反应是“直觉”
谁来破解 柯布团队 只有你自己

电影结尾,柯布回到家中,旋转陀螺,镜头在将倒未倒时切黑。诺兰问的是:你确定自己不在梦中?

《半秒之间》问的是另一个:你确定你的每一个第一反应,真的是“你”的?

答案多半是:不是。它们是被植入的。被父母、被广告、被算法、被重复了无数次的某句话。

但好消息藏在坏消息里——既然能被植入,就能被重写。

你无法阻止半秒之内的反应,它快过意识。但你可以决定那之后的一切。你脑中的代码不是你;你是那个在半秒缝隙里能说“等一下”的观察者。

从今天起,在每一个微小的瞬间里,给自己留出半秒的觉察,然后问一句:

“这是我的选择,还是别人植入的程序?”

能回答这个问题的人,才算真正从梦里醒来。

陀螺终会倒下——而旧代码失效的那一天,你会发现:这次醒来的感觉,和植入的梦,完全不同。

附:两份工具,请收好

工具一:四问清单(先审后写)

把你脑中的“我是______”逐条列出——写的时候想的是“我已经被植入了什么”,不是“我应该是什么”。然后过四个问题:

  1. 这个说法,最早是谁告诉我的?
  2. 我是自己验证过它,还是只是听得次数多了?
  3. 如果我相信它,谁会受益?
  4. 它帮我活得更好,还是更差?

工具二:ARISE 脚本填空模板(写下来,每天说一遍)

因为我是 ______ (身份),
当 ______ (情境)发生时,
我会做 ______ (具体动作),
因为 ______ (理由)。

代码写完,只算把事情做了一半;文档建起来,这件事才算完整。

个人开源项目的宿命,大多是这样的:代码写完,发一个 README,心里默念「文档以后再补」—— 而「以后」永远不会来。不是不想补,是完整性太贵。一个像样的文档站,意味着选型、配置、部署、CI、内容规范、校验脚本……每一项都得查资料、踩坑、返工。于是索性砍掉,美其名曰「聚焦核心」。

前面介绍过 aifei-go —— Java 版 Aifei 的 Go 移植。它的处境比一般项目更尴尬:这是一个宣称「为 AI Coding 而生」的框架,Java 版有官方文档 aifei.cn/doc 摆在那里当基准,Go 版要是只有一个 README,多少有点打脸。而且这里还藏着一个顺理成章的推论 —— 既然是为 AI Coding 而生的框架,它的文档,本来就该由 AI 来写。于是花了两天,把文档站建了起来:https://crazy-airhead.github.io/aifei-go/。

完整性的坑,AI 都记得

技术上没什么新鲜事:VitePress + pnpm + GitHub Actions,push 到 master 自动构建、发布到 gh-pages。新鲜的不是技术,是细节有人替你想着。随手摘两段:

1
2
3
4
5
6
7
8
9
10
11
- name: Checkout(完整历史,供 lastUpdated 读取时间)
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
publish_dir: docs/.vitepress/dist
# dist 是纯生成产物:单提交孤儿分支,保证删除的页面同步消失
force_orphan: true

配置里的这些注释,本身就说明创建一个文档站不是一件容易的事情。没写进注释里的还有一堆:base 路径要配 /aifei-go/、head 里的 favicon 不会自动加前缀得写全路径、paths 过滤让只有 docs 变更才触发构建、concurrency 把排队的旧部署取消掉、sitemap 要配 hostname……但这些坑,每一个都是前人踩过的,AI 都记得。以前要花一个下午翻 issue 才能凑齐的事,现在是一轮对话。

先写「怎么写」,再写「写什么」

真正值得记的不是部署,是内容的生产方式。开工第一步,不是让 AI 写文档,而是先写「文档怎么写」—— 一份 docs/guide/_STYLE.md 写作规范。统一模板大纲(背景 → 架构 → 关键 API → 核心机制 → 配置集成 → 模块结构 → 总结)、风格规则(多用表格和代码块、交叉链接、信息密度要高),再加几条质量红线:

  • 内容来源必须实际读取源码,不得凭记忆编造;
  • 代码示例的类型名 / 方法签名 / 配置键必须与源码一致,逐项核实;
  • 篇幅 300~500 行,完成后用 wc -l 和 grep 自查行数与标题结构。

规范开头有一句:「本规范人与 AI 均适用」。人做决策,AI 生成 —— 落到这件事上,就是人定标准、立标杆(风格范例是那篇五百行的 data-isolate 文档),AI 读源码、照模板写。最后落地的,是二十五篇模块文档。这套招数其实是框架自己的招数:Just Service 用命名约定让 AI 稳定生成代码,_STYLE.md 用模板和红线让 AI 稳定生成文档,一回事。

抽卡之后,要有验收

规范把写作经验沉淀了下来,让下一篇的起点更高。但光有规范还不够 —— AI 的输出是基于概率的,「抽卡」式的不确定性不会因为换了任务就消失。git 历史里躺着证据:首次部署之后,紧跟一串提交 —— 更新 Logo、更新文档图、把 Actions 升级到 Node 24 运行时、修 Markdown 语法错误、修 index.md 语法错误。所以要有验收闭环:构建、校验、人工过目,一轮下来,概率性的输出才算变成确定性的成品。

最让我意外的是 scripts/check-mermaid.mjs。VitePress 构建时并不校验 mermaid 图表的语法,错了要到浏览器渲染时才暴露。这件事我没有让 AI 做,是它自己想到的:文档里有图、图会坏、坏了要在上线前发现 —— 于是有了这个脚本:jsdom 搭环境,调 mermaid.parse 把 docs 下所有 mermaid 代码块逐个校验,内部文件自动跳过。这种「想到你没让它想的事」,是完整性的另一个来源:人容易在「能用」的地方停下来;AI 不会累,也就没有「差不多得了」。

过程留痕,成品干净

仓库里有个 docs/issues/ 目录,编号归档了移植过程中发现的十九个缺陷:enjoy 的算术精度降级、for 循环迭代不了 map、内置指令缺失、db 缺方言……每一条都是留了案的复盘。这些记录通过 srcExclude 排除在发布站点之外 —— 对外的成品要干净,对内的过程要留痕。完整,不是把所有东西都端出去,而是该在的都在。

完整是长出来的

有了 AI 的帮助,让自己做事情更完整 —— 改变的到底是什么?不是 AI 会写文档了,文档它一直会写;是完整性的成本变了。以前文档、CI、校验、sitemap、孤儿分支,这些收尾活最劝退;现在它们的边际成本趋近于零,「能用」和「完整」之间的那段距离,走着走着就走完了。

你得知道「完整」长什么样—— 当然也不必一开始就知道。完整是在深入的过程中长出来的:每一步追问一句「还差什么」,追问多了,「完整」的认知自然成形。这两天下来,我对「完整」的理解,就比开工时具体得多。

最后是个自举:一个为 AI Coding 而生的框架,它自己的文档站,也是 AI Coding 做出来的。文档站的地址挂在那里,往后每一次 push,它都会自己生长。

人负责想要什么,AI 负责让它完整。

类比,是从已知通往未知的桥梁。

四年多前,我做过一款小程序「类比宝库」—— 一个用来收集类比的本子。

四年多后,我把它重做了一版,改名为「翻翻类比」。

为什么还做这件事

上帝说要有光,于是便有了光。笑来老师说,每个精彩的类比都是一笔财富,于是便有了这个小程序。

当年设计 Logo >≈< 时的想法:类比是约等于,不是等于号 —— 用得恰当,它就是大于号;用得不恰当,它就是小于号。

如果参加过笑来老师的写作课,应该知道第五课有个作业 —— 准备一个本子,从今天开始,遇到任何类比都收集起来。

最新的 AI 课程里,他又用了一个类比:Git 是平行时光机。你看,好类比永远管用。

「翻翻类比」,就是这个本子。

从「宝库」到「翻翻」

「类比宝库」走的是众人拾柴的路线 —— 大家一起攒。后来停更了,很现实的原因:微信的认证费和云服务开销,对一个个人小程序来说,扛不动。

这中间,我用墨问小程序更新、收集过一段时间的类比,但拿它收集和分享类比,总觉得隔了一层纱,不够直接。后来想明白了,我自己的需求其实很简单:收集类比,是为了有一天能用上 —— 随时翻到,翻得顺手,翻得舒服。

所以这次,思路被成本逼出来了:与其做一个「在线宝库」,不如先做一个「离线卡片集」。

  • 类比库随小程序一起发布、随版本更新。如今一共 185 句:其中一部分从当年历史数据库里迁出来的 —— 众人拾过的柴,还在这座炉子里烧着;剩下的,是我陆陆续续攒的。
  • 无网络、无登录、无云开发,不采任何信息。运维成本?题目直接删掉了。

翻起来什么样

指尖轻舞,流光溢彩。「翻翻类比」邀您步入一场全屏的思维漫游。左右滑动,在绚烂渐变中拾取智慧的隐喻;长按定格,将瞬间的顿悟化作海报珍藏。类比,是已知通往未知的桥梁 —— 一句妙语,洞见更广阔的世界。

一句话概括:全屏,左滑,无尽头。打开就是一句类比,居中衬在渐变色板上。左滑下一句,右滑上一句,卡片跟着手指回弹,翻页轻轻一震。

几点设计:

  • 20 套渐变色板:深蓝、米白、荧光绿……相邻两张不重样,翻起来像一叠老明信片。
  • 一屏一句:字号自适应,短句舒展,长句也容得下。
  • 长按生成海报:当前句子和配色渲染成一张卡片,右下角带小程序码,可存可分享。
  • 搜索:多关键词过滤,命中高亮,找到后直接进入卡片模式,不影响首页进度。

看不见的讲究:

  • 洗牌队列:不是随机抽一句,而是用 Fisher-Yates 把整个类比库洗成一轮 —— 一轮之内句句不重、句句都会出现,翻完自动重洗,衔接处还做了去重。
  • 记住进度:退出再进,从上次翻到的地方接着翻。

还是那个场景

等车时,排队时,想刷点什么的时候 —— 打开它。

现在刷的是短视频同款手势。刷十条短视频,笑完就忘了;翻十句类比,总有一句会留下来,变成你的财富。

一些说明,也是求助

  • 类比库随小程序发版更新。想分享的,直接发我微信(Crazy_Airhead),精选后收录。至于怎么让大家不经我手就能往里添柴 —— 还在想,有好点子随时喊我。
  • 小程序预留了广告位,目前没有开启;开了的话,多包涵。
  • 四年前,自己主要做后端开发,小程序开发是个新手。四年后有了 AI 的帮助,这个新手做出来的小程序,交互体验好了不少 —— 当然肯定还有不尽如人意的地方,请继续多多包涵,继续提建议。

微信里搜「翻翻类比」,翻翻看。

最早听笑来老师的课,知道他在写一款叫 VMark 的 Markdown 编辑器。当时看了看,觉得还有不少问题,就继续用着 MarkText。最近又看到笑来老师发的《我为什么制作了一个 Markdown 编辑器 VMark?》 ,正好自己这段时间一直在用 IDEA 的 cc-gui 插件配合 Claude Code 做 Vibe Coding。我发现,自己平时更多是在编辑 Markdown、在 AI 会话里聊天,可 IDEA 侧重的毕竟是源码编辑,一旦切到分栏或预览模式,渲染效果就很差,体验不好;而 cc-gui 虽然提供了多标签,方便多任务,却经常卡死,逼得我不得不重启 IDEA。于是,自己动手写一款 Markdown 编辑器的念头,就慢慢成形了。我已经写了自己的 SSH 客户端管理工具 vshell,那么再定制一款自己的 Markdown 编辑器,似乎也不是不可以。

前面说过,我平时最常用的 Markdown 编辑器是开源的 MarkText(虽然也买了 Typora)。又因为我是程序员,深知 Git 的重要性,而自己最常用的编程工具 IDEA,它的 Git 集成做得相当好。把这些凑到一起,就有了 GMark —— 一个 AI 驱动的创作编辑器。它的核心理念是:

人用 Markdown 下达指令(工作区),AI 生成内容(制品区),人审核确认,Git 全程记录。

简单说,GMark = Git + Markdown + AI,这也是它叫 GMark 的由来 —— 名字也参考了 VMark。

GMark 的另一个核心理念,是工作区与制品区的分离:用两个目录(两个 Git 仓库)。一个是工作区目录,里面是各种 Markdown 文件,还包含 AI 引擎相关的文件,比如 Claude Code 的 .claude、CLAUDE.md 等;另一个是制品区目录,用来放生成的代码、图片、文件等等。这样做最大的好处,是不用再纠结哪些文件该进 Git、哪些不该进 Git —— 比如苹果官方 App 误打 CLAUDE.md 上新闻那种事,就不会发生。

GMark 支持多个 AI 引擎,一个是 Claude Code,一个是 SolonCode。其实 AI 给我规划方案的时候,还顺带配了 Codex 的适配,但我想着自己完全没用过 Codex,适配反而给自己增加难度,就取消了。如果你听过笑来老师的课,他把「人审核」这一步也交给 AI 了 —— 这或许会是 GMark 下一步的方向。笑来老师用的是自己写的 cc-suite 插件,而我得想办法让两个 AI 引擎能彼此沟通起来。

GMark 和 vshell 用的是同一套技术栈:Go + Wails3 + Vue3 + Naive UI;Markdown 编辑器核心用了 MarkText 的编辑器 Muya,源码编辑器用的是 Monaco,其他一些辅助选型是 AI 帮我挑的。GMark 目前没有开源计划:一来它本身是个很个性化的需求;二来它还有不少问题,只适合自己用。更重要的是 —— 如果你有心,我也已经把技术栈交代清楚了,你应该会想自己造一个,而不是用我的版本。随着 AI 让软件创建越来越容易,定制化的需求也会越来越多。但 AI 的本质是基于概率的,存在「抽卡」式的不确定性,所以需要沉淀 —— 而 GMark,只是我沉淀出来的一个版本。

VMark 是一个高度固执己见(Highly Opinionated)的东西 —— 事实上,我猜,以后所有 Vibe Coded Software/Services 都是高度固执己见的…… 这其实是没办法的事儿,因为一切 Vibe Coding 的过程自然而然地都是「不需要与人开会」的「生产过程」—— 只有我自己和另外一个绝不争辩的执行者。

我只是一个 Producer(制作人)。

另外,还有个「高度固执己见」自动带来的后果:VMark 就算开源了,也不能指望「社群贡献」。首先,这完全是为了让自己顺手才写的东西,很多功能对别人来说并无太大价值。最为关键的是,Markdown 编辑器不是什么科技前沿的东西,是个无数人实现过无数次的编辑器中的一个简单分子,所以,AI 可以帮助我们解决关于它的任何问题。

本来计划用 GMark 来改进 GMark,吃自己的狗粮,让 GMark 实现自举、自我迭代。无奈问题还是太多,而且自举的时候需要重启自己,要么就得维护多个版本来回切换,徒增麻烦,于是放弃了自我迭代。

最近看到两样东西。一个是小木老师的 TokUI —— 号称全球首个「For AI & 零依赖」的流式 UI 描述与渲染框架:后端用极简 DSL 描述组件,经 SSE 或 WebSocket 流式推送,前端增量解析,首个 Token 就开始渲染,让 AI 用极少的 Token 输出更灵活、更有表现力的 UI。GMark 的 AI 会话记录,就是用 TokUI 渲染的,用下来感觉不错。另一个是 Martin Fowler 的一篇文章,《DSLs Enable Reliable Use of LLMs》。如果 DSL 确实更可靠,那 TokUI 应该是个正确的发展方向;再加上之前也看过「HTML 比 Markdown 更好」的说法,所以 —— 为什么不用 DSL/UI 来当练手项目呢,也能继续吃自己的狗粮,迭代 GMark。

于是在 cnb.cool 上建了两个仓库,一个工作区,一个制品区。目前两个仓库都是公开的,有兴趣的同学可以拿去参考。不过,如果你想拿 TurboUI 用于生产,请谨慎:一来我还在实验阶段,内容变动会比较大;二来它没有经过验证,使用有风险。真要上生产,我还是推荐你用 TokUI,能获得更多支持。

1
2
3
4
5
# 工作区仓库,设计
https://cnb.cool/goldsyear/onestep/TurboUI-Design

# 制品区仓库,作品
https://cnb.cool/goldsyear/onestep/TurboUI

TurboUI 第一个版本的需求,其实非常粗暴。我当时应该是在动车上,用手机的 DeepSeek 网页端写的。别问我为什么用 Turbo、Stimulus —— 这只是个人喜好,我喜欢 37signals(Basecamp),而 Hotwire 的技术也确实先进。就像笑来老师新 AI 课里讲的,不过是搜索空间的不同。

当然,新学的招得用上 —— 多模型互搏,于是我把需求也发给了 ChatGLM。

经过它们几轮「互殴」,我选了 TurboDSL/TurboUI 这套名字,但整体方案用了 GLM 的。最终形成的文档,在 TurboUI-Design 的 design.md 里。然后 AI 照着文档一顿设计:architecture.md、sdk-architecture.md,还生成了 sdk-spec —— 也就是 TurboDSL 的契约。接着它自己把剩下的事也包圆了,顺手给我整了个小 demo,我一看,像那么回事。

不过,笑来老师不是说要 Grill yourself 么?于是我在让它 Grill 拷问自己的时候,它给我生成了 why-dsl-grill.md。

核心论点(贯穿全文):把「TurboUI 值得做」和「必须是一门方括号 DSL」拆开看。前者成立;后者的每一条理由都可用「受约束 HTML profile + server-resolve 桥 + 词表校验」等价达成,且保留 HTML 的语料红利。所以「为什么是方括号 DSL」这道题,design.md 目前答不及格。

它推荐用 HTML 的子集,而不是用方括号。后来它自己做了对比测试,只能说是不相上下,于是我就继续保留了方括号的形式,以保持和 TokUI 一致。

不过在逐步讨论的过程中,其实也补强了我的一些主张。比如,click 是一个 token,clk 看起来更短,却不一定是一个 token —— 人觉得更短,AI 分词的时候未必这么认为。不能一味用简写,要尽量用常见的单词,一方面能确定 token,另一方面能减少误解。

后面陆续让 AI 处理、生成 TODO,逐步核查和修改。碰到最多的问题,其实笑来老师的课里也讲过 —— 就是只处理一半问题,或者在处理过程中说「这个是旧问题,不是这次改动引入的」。没办法,还是没办法,得好好看看笑来老师提供的 Skills 了。

实现的过程中,还得顺手修 GMark 的各种问题:工作区和制品区的限制问题、AI 提供的审核问题、Git 扫描 node_modules 导致 CPU 飙升卡顿、文件引擎的问题、AI 引擎切换时参数没完整切换导致接口异常、发布后路径无法识别、Claude Code 被判离线、Terminal 没识别到环境变量,等等。光是自己在使用时就登记下来的问题,就有 80 多个。总而言之,这狗粮,是真难吃。

不过看着 TurboUI 相对干净的工作区和制品区,感觉还是挺好的。

最后,我让 AI 看 TokUI 的演示站点,给我重新生成一个更完整的 Demo,效果也还不错 —— 它直观地展示了 DSL 相比 HTML 节省的 Token 数,效果满意。

1
2
3
# 获取代码后执行
pnmp install
pnpm demo

Aifei-Go:为 AI Coding 而生的 Go 服务端框架

Aifei-Go 是 Aifei(Java 版)的 Go 语言移植版。它继承了 Aifei 的核心设计理念 —— Just Service 扁平架构、HIO 自主 IO 模型、Enjoy 自研模板引擎、Db + Row 数据库模式,并在 Go 的语言特性与生态上重新落地:用接口与 Handler 包装链替代动态代理,用 net/http 替代 Undertow,用 Radix 树路由替代注解扫描,同时又参考了 Solon,引入 Dami 提供进程内事件总线,引入 Nami 作为 HTTP RPC 客户端框架,用于支撑微服务。

注意事项:Aifei-Go 由 GLM5.2 生成,不喜勿入。Aifei 是基准,AI 翻译。人做决策,AI 生成。

Java 版文档:https://aifei.cn/doc

1. 什么是 Aifei-Go

Aifei 原本是”一款用于 AI Coding 的 Java 服务端框架”,其核心设计目标是 极小化 Token 消耗、极大化 Attention 浓度,让 AI 稳定生成高品质代码。要做到这一点,最有效的手段就是消除冗余分层与样板代码 —— 这也是 Aifei 开创 Just Service 范式 的根本动因。

近十年间,前后端分离成为主流,页面路由、交互编排与渲染职责大量转移到前端(react-router、vue-router 已接管了过去由服务端 Controller 承担的职责)。路由既然在前端,服务端就不再需要 Controller 这一层。Just Service 范式之下,开发者无需编写 Controller、Render、Repository、Mapper 这类冗余代码,直接写业务即可。

Aifei-Go 把这套理念原汁原味地带到了 Go 语言:

  • 只写 Service,不写 Controller —— 方法名即路由,按命名约定自动映射为 RESTful 端点。
  • 核心零外部依赖 —— 核心库与独立框架(aifei / enjoy / db / json / log / nami / dami)仅用 Go 标准库;插件按需引入第三方库。
  • 模块化、按需组合 —— 每个模块可独立 go get,不拉入多余依赖。
  • AI 友好 —— 代码量少、结构扁平、约定明确,AI 生成时上下文负担小、命中率高。

1.1 为什么从 Java 移植到 Go

Aifei Java 版内核仅 3333 行(内核核心仅 260 行),零第三方依赖,已经是极致精简。移植到 Go 带来三点额外收益:

维度 Java Aifei Go Aifei-Go
部署形态 JVM + 打包 单一静态二进制,毫秒级启动
并发模型 线程池 + 同步阻塞 goroutine 天然并发
依赖管理 Maven 多模块 Go workspace 多模块,按需 import
动态能力 CGLIB/Javassist 动态代理 接口 + Handler 包装链(无运行时反射代理)
HTTP 服务器 Undertow(去 Servlet) net/http(标准库,零依赖)
路由 注解 @Path + 包扫描 Radix 树 + 代码注册(编译期确定)

Java Aifei 中被废弃的三个模块(aifei-proxy、aifei-undertow、aifei-all)在 Go 里不再需要:Go 没有动态代理机制,AOP 由 Handler 包装链 + Interceptor 接口实现;Go 有标准库 net/http;Go 的 import 机制天然按需引入。保留下来的,是 Aifei 真正有价值的内核:HIO、Just Service、Enjoy、Db + Row。

2. 核心理念:Just Service

Just Service 的本质是:方法名即路由。一个 Go struct 的导出方法,按命名约定自动映射成一条 HTTP 路由,无需任何注解、配置文件或注册代码(注册由生成器在 init() 中自动完成)。

1
2
3
4
5
6
7
8
type UserService struct{}

func (s *UserService) List(in aifei.Input) aifei.Output { /* GET /api/user/list */ }
func (s *UserService) Paginate(in aifei.Input) aifei.Output { /* GET /api/user */ }
func (s *UserService) Create(in aifei.Input) aifei.Output { /* POST /api/user */ }
func (s *UserService) GetById(in aifei.Input) aifei.Output { /* GET /api/user/:id */ }
func (s *UserService) UpdateById(in aifei.Input) aifei.Output{ /* PUT /api/user/:id */ }
func (s *UserService) DeleteById(in aifei.Input) aifei.Output{ /* DELETE /api/user/:id */ }

server.Register() 通过两条规则把方法名翻译成 HTTP 方法 + URL:

  1. 默认动作(精确匹配) —— 直接挂在 service 前缀上:Paginate → GET /prefix,Create → POST /prefix,List → GET /prefix/list。
  2. 动词前缀 —— 方法名以(且长于)Get/Post/Put/Delete/Update 开头,动词决定 HTTP 方法,剩余部分 camelCase→kebab-case 作为路径后缀。例如 GetProfile → GET /prefix/profile。

两个特殊约定:

  • ById 后缀自动转为 :id 路径参数:GetById → GET /prefix/:id。
  • 既非默认动作、也不符合动词前缀的方法不会被路由 —— 天然成为私有 helper,无需额外的可见性控制。

对比 Java 版的 @Path 注解 + 包扫描:Go 没有注解,Aifei-Go 用「命名约定 + 代码注册」达成同样的效果,且路由在编译期就已确定,没有运行时反射扫描开销。

3. HIO 架构:Input / Output / Handler

Java Aifei 采用 HIO 自主架构(Handler + Input + Output),让用户自主掌控处理流程与数据结构。Aifei-Go 完整保留了这一设计,并用 Go 接口重新表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 处理流程的单元:Input 进、Output 出
type HandlerFunc func(in Input) Output

// Input = Param(读参数)+ Meta(请求元信息)
type Input interface {
Param // Has / GetStr / GetInt / GetBean / GetMap / PathPara ...
Meta // Context / Header / Path / Body
}

// Output 由业务构建,IoHandler 决定如何渲染
type Output interface {
Code() int
Msg() string
Data() interface{}
}

几个关键点:

  • Input 与 HTTP 解耦。Input 只承载「任何调用源都能满足」的契约——参数读取与请求元信息。HTTP 专属的概念(method 动词、remote 地址、cookie)不在这个接口上,它们留在 HTTP 适配层。这意味着同一个 Service 方法既可被 HTTP 请求驱动,也可被测试桩、内部调用驱动,业务代码不绑死 HTTP。

  • Output 是意图,不是渲染。业务代码构建一个 Out(server.Ok() / server.Of(data) / server.Fail(msg)),它累积 code/msg/data 以及渲染意图(JSON、Enjoy HTML 视图、文件下载、原始字节、重定向)。IoHandler 读这些意图来决定 如何 写响应 —— 业务代码从不直接碰 net/http,文件下载、响应头都通过闭包/构建器表达。

  • 两条包装链。Aifei-Go 区分两类横切逻辑:Handler 级(Input → Output,作用于业务处理,如 Logger、Recover、TxInterceptor)与 HTTP 级(作用于 http.Handler,如 CORS、BasicAuth、RequestID)。前者对应 Java Aifei 的全局拦截器,后者处理纯传输层关切。

Java 用 CGLIB/Javassist 动态代理实现 AOP,Go 没有这套机制 —— Aifei-Go 用 Handler 包装链 + Interceptor 接口 替代:ChainHandlers() 把一组 Handler 包装器组合成一条链,Interceptor 提供方法级 AOP(@Before / @Clear 的等价物)。

4. 模块结构

Aifei-Go 是一个 Go workspace 多模块项目,按角色分层。每个模块独立版本化、可独立 go get,互不拉入多余依赖:

层 模块 职责 依赖
核心框架 aifei-go/aifei Input/Output、Router、Handler wrapper、Interceptor —
核心库 aifei-go/enjoy 模板/SQL 引擎(自研) —
核心库 aifei-go/db 数据库访问(Row/Dao/Dialect/Enjoy SQL) enjoy
核心库 aifei-go/json JSON 工具 —
核心库 aifei-go/log 日志接口 —
核心库 aifei-go/config 分层配置(yml + 环境变量 + 命令行 + 云配置) yaml.v3
运行时 aifei-go/http net/http 适配器 aifei
运行时 aifei-go/server 启动引导、内置 Handler、Out、Register aifei, http, db, enjoy, log
独立框架 aifei-go/nami HTTP RPC 客户端框架 —
独立框架 aifei-go/dami 进程内事件总线(send/call/stream/lpc) —
代码生成 aifei-go/tools/generator Schema → 类型安全 CRUD 代码 db, enjoy
代码生成 aifei-go/tools/damigen dami 相关代码生成 enjoy
插件 plugins/cache 两级缓存(本地 + Redis) jetcache-go, go-redis
插件 plugins/kafka Kafka 生产/消费 franz-go
插件 plugins/nacos 服务注册、配置中心、发现 nacos-sdk-go
插件 plugins/storage 文件存储(本地 + S3 兼容) minio-go
插件 plugins/swagger OpenAPI 文档(knife4j-vue3) swaggo/swag
插件 plugins/dataisolate 租户 + 行/列数据隔离 GoSQLX

「核心库」层的模块(enjoy / db / json / log)刻意保持零外部依赖、可脱离框架独立使用——你完全可以只用 enjoy 做模板渲染,或只用 db 做数据库访问,而不引入整个 web 框架。插件层则把第三方库的集成集中隔离,不污染核心。

5. 快速开始

1
go get github.com/crazy-airhead/aifei-go/aifei

一个最小的 HTTP 服务:

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
package main

import (
"github.com/crazy-airhead/aifei-go/aifei"
"github.com/crazy-airhead/aifei-go/server"
)

func main() {
app := aifei.New()

// 全局 Handler 包装链
app.Use(server.Logger(), server.Recover())

// HandlerFunc: func(in aifei.Input) aifei.Output
app.GET("/", func(in aifei.Input) aifei.Output {
return server.Of("Hello, Aifei!")
})

app.GET("/hello/:name", func(in aifei.Input) aifei.Output {
return server.Ok("Hello, " + in.GetStr("name"))
})

// 启动(支持 CORS、BasicAuth 等 HTTP 级包装器)
server.Run(app, ":8080", server.WithCORS("*"))
}

而一个真实的多 Service 应用,骨架甚至更短——所有表对应的 Service 通过生成器在各自的 init() 里自注册,主程序只需一行 server.AutoRegisterServices(app):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
db.Init("sqlite", "./demo.db")
// ...建表...

app := aifei.New()
app.Use(server.Logger(), server.Recover())

// 每个 per-table 包在 init() 中注册自己的 Table 元数据与 Service 路由
_ "github.com/crazy-airhead/aifei-go/_test/demo/internal/user"
_ "github.com/crazy-airhead/aifei-go/_test/demo/internal/loginlog"

server.AutoRegisterServices(app) // 一行注册全部 Service
server.Run(app, ":8081", server.WithCORS("*"))
}

完整可运行示例见 _test/demo(go run ./_test/demo)。

6. 核心特性

6.1 Enjoy 模板引擎

Enjoy 是 Aifei 的招牌特性——一套自研的模板语言(~2800 行),自带词法分析器(DKFF 算法)、递归下降语法分析器(DLRD)和完整的表达式引擎。它不仅是页面渲染引擎,也是 SQL 模板引擎(见 6.3)。

1
2
3
engine := enjoy.NewEngine("myEngine")
tpl := engine.GetTemplateByString("Hello, #(name)! Age: #(age)")
out := tpl.RenderToString(map[string]interface{}{"name": "james", "age": 18})

支持的语法:#() 表达式输出、#if/#else/#elseif、#for、#set/#setLocal/#setGlobal、#define/#call、#include、#switch/#case/#default、#break/#continue/#return;表达式层支持算术/比较/逻辑/三元、空安全(??、?.)、方法调用、map/数组字面量、静态访问(::)。

6.2 Db + Row + Dao

数据库访问沿用 Java Aifei 的 Db + Row 模式(与 JFinal 的 Db + Record 几乎一致),核心是链式 API 与 Active Record 变更追踪。db 模块本身不含驱动,用户自行引入所需驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import (
"github.com/crazy-airhead/aifei-go/db"
_ "modernc.org/sqlite" // 或 _ "github.com/go-sql-driver/mysql"
)

func main() {
db.Init("sqlite", "./app.db")

// Active Record —— 插入
row := db.NewRow("user").Set("name", "james").Set("age", 18)
result, _ := db.Insert(row)

// 主键查询
found, _ := db.FindByID("user", result.GetID())

// 分页查询
page, _ := db.RawSql("SELECT * FROM user ORDER BY id DESC").Paginate(1, 10)
_ = found
_ = page
}

内置 MySQL / PostgreSQL / SQLite 三种方言,支持事务、批量操作、类型转换。Row.Set() 追踪变更(用于 UPDATE),Put() 不追踪——精确控制更新范围。

6.3 Enjoy SQL:模板化的动态 SQL

db.Sql(...) 接受 Enjoy 模板作为 SQL,提供 #where / #and / #orderBy / #para 等指令,支持 18 种操作符,条件为空时自动省略——这是处理「动态查询条件」最优雅的方式,告别手写字符串拼接:

1
2
3
4
5
data := map[string]interface{}{"minAge": 18, "status": 1}
list, _ := db.Sql(
"SELECT * FROM user #where() #and(age > #para(minAge)) #and(status = #para(status))",
data,
).Find()

#orderBy 指令接收一个字段白名单,实际排序字段从入参读取并校验——防 SQL 注入的同时支持多字段排序与字段名映射。

6.4 代码生成器

Aifei 没有 Model 概念——Model 的支持完全由生成器实现。tools/generator 扫描数据库 Schema,每张表生成一个独立包(base.go / model.go / dao.go / service.go),提供编译期类型安全的 CRUD API:

1
2
3
4
5
6
gen := generator.New(pool, dialect, "./myapp/db", "myapp/db")
gen.Generate() // 一次扫描所有表:user/、loginlog/ …

// 使用生成的类型安全 API
u, _ := user.FindById(123)
u.SetName("new name").Update()

每表一包的策略让每张表的字段都有具名 getter/setter,AI 生成业务代码时不必猜测字段名拼法,命中率显著提升。

6.5 分层配置

config 模块提供分层加载(L1–L5):app.yml + app-{env}.yml → 扩展配置 → 环境变量 + 命令行参数 → 编程式 LoadInto() → 云配置(如 Nacos)。线程安全,支持运行时热更新。提供 Get/GetStr/GetBool/GetInt 访问器、Sub(prefix) 作用域切片、Bind(v) YAML 往返到自定义结构体。所有插件统一从 config.Props 读取自身配置(storage.*、cache.*、kafka.* …),约定一致。

6.6 插件生态

插件实现 aifei.Plugin(Start()/Stop() 生命周期),从 config.Props 读自身配置并装配一个包级默认实例,让顶层便捷函数开箱即用:

  • plugins/cache —— 两级缓存(本地 FreeCache/TinyLFU + Redis),GetOrStore 自带 singleflight 与缓存穿透防护,实例级 key 前缀隔离。
  • plugins/storage —— 统一本地文件系统与 S3 兼容后端(AWS S3 / Minio / OSS / COS),按 bucket 路由。
  • plugins/kafka —— 基于 franz-go 的生产/消费,多集群,Subscribe 至少一次投递(失败记录不提交、下次重投)。
  • plugins/nacos —— 服务注册与发现、配置中心,自动桥接到 nami RPC 客户端;init() 自动注册云配置加载器。
  • plugins/swagger —— 内嵌 knife4j-vue3 UI 的 OpenAPI 文档插件。
  • plugins/dataisolate —— 租户 + 行 + 列三正交维度的数据隔离,AST 改写 SQL,应用代码零隔离感知(详见 data-isolate-intro.md)。

6.7 独立框架:Nami 与 Dami

两个不依赖 aifei 的兄弟框架,可独立使用:

  • nami —— 轻量 HTTP RPC 客户端框架(移植自 Java Solon Nami)。Channel 传输(channel/http)、编解码(coder/json)、Filter 链、Upstream/Discovery 服务发现、流式 Builder/ClientFactory。它是 aifei 服务端的对偶:aifei 暴露服务,nami 消费服务。
  • dami —— 进程内事件总线(send/call/stream/lpc),发布订阅 + 同步调用 + 流式返回,用于解耦模块间通信。

7. Java Aifei → Go Aifei-Go 设计决策

移植并非逐行翻译,而是在保持 API 风格一致的前提下,用 Go 惯用模式替代 Java 机制:

决策点 Java Aifei Go Aifei-Go 理由
请求上下文 Input + Output 接口 Input / Output 接口(保持分离) 与 Java API 一致,职责清晰
AOP / 拦截 CGLIB/Javassist 动态代理 + Interceptor Handler 包装链 + Interceptor 接口 Go 无运行时动态代理
路由注册 @Path 注解 + 反射包扫描 命名约定 + Register() 代码注册 Go 无注解,编译期确定
泛型 Java 泛型 Handler<I, O> Go 接口 简化接口
配置 AifeiConfig 接口 + 多个 config() Functional Options 模式 Go 惯用配置模式
HTTP 服务器 Undertow(去 Servlet) net/http(http 适配 + server 启动) 标准库,零依赖
包扫描 ClassLoader + JAR 扫描 不需要(Go 静态编译) 编译时确定所有代码
依赖注入 @Inject + 反射 构造函数注入 Go 惯例
路由匹配 HashMap + ActionGroup Radix 树 高性能路由标准做法
错误处理 throws Throwable error 返回值 + panic/recover Go 惯例
聚合模块 aifei-all import 按需引入 不再需要

四条贯穿全局的重写原则:保持 API 风格一致、Go 惯用优先、核心最小依赖、AI 友好。

8. 继承与差异

继承自 Java Aifei 的(最有价值的设计):Just Service 范式、HIO 自主架构、Enjoy 模板引擎、Db + Row 模式与链式 API、sql(sql, para).find() 式查询入口、生成器生成的 Model、集中式配置中心思想、拦截器(@Before/@Clear 的等价物)。

Go 版新增/调整的:

  • 路由约定化 —— 用动词前缀 + 默认动作的命名约定替代 @Path,方法名直接表达 HTTP 语义。
  • 代码生成深化 —— 每表一包 + typed Dao,编译期类型安全;Service 在 init() 中自注册,主程序零路由样板。
  • 插件生态扩展 —— 在 Java 插件机制之上,新增 cache / kafka / storage / swagger / dataisolate 等开箱即用插件,全部配置驱动。
  • 兄弟框架 —— nami(RPC 客户端)与 dami(事件总线)作为独立模块,与 aifei 服务端组合成完整微服务工具链。
  • 数据隔离 —— 通过 plugins/dataisolate 提供声明式、AST 改写式的租户/行/列隔离,这是 Java 版未单独成插件的能力。

9. 小结

Aifei-Go 的定位很明确:把 Aifei「为 AI Coding 而生」的设计哲学,用 Go 的方式重新实现一遍。

它不追求大而全。核心框架零外部依赖,只依赖标准库;它不引入 Controller/Service/DAO 的冗余分层,一个 struct 方法就是一条路由;它把模板引擎、SQL 模板、ORM、代码生成、配置、缓存、消息、存储、数据隔离做成正交的模块,按需取用。

如果你用过 JFinal 或 Java Aifei,你会对 Db + Row、Enjoy SQL 指令、集中式配置感到熟悉;如果你是 Go 开发者,你会发现它的接口设计、错误处理、并发模型都是地道的 Go 风格。而无论哪种背景,Just Service 让你把注意力放在业务上 —— 这正是 Aifei 一开始就想做到的事。

延伸阅读

今天早上,系统又提示磁盘空间不足。我查看了一下操作系统的存储管理,发现可用空间已不足 5GB,于是打开 Tencent Lemon Lite 做了一次深度扫描。

首先发现 QQ、微信、企业微信、钉钉、Mixin 等聊天软件占用了大量数据。这些软件自带了存储管理功能,把图片、视频和文件清理之后,一下子腾出了约 20G 空间。

接着,我注意到 Ollama 的模型也占用了不少空间。考虑到最近主要使用云服务,基本没用本地模型,便把 Ollama 和 deepseek 和 qwen 的几个模型一起删除掉,又释放了约 15G。

在存储管理中,看到 SystemData 占用了大约 200GB,因为有上一次清理硬盘的经历,我直接去查看 ~/Library/Application Support 和 ~/Library/Caches 目录。这些数据大多是应用运行时产生的,虽然可以删除,也不影响应用的使用。话虽这么说,真要删的时候还是会犹豫,不知道该删哪些好。

清理完这些数据后。系统又多了 100 G 的空余空间,又能撑上一阵子。

最近看 Anthropic 的产品经理说的,如果一件事情重复三次就要考虑自动化。那么为什么不定制个磁盘清理工具,给 AI 提了清理 System Data、清理软件删除残留、清理聊天工具缓存的需求。

系统的整体硬盘情况

img

开发工具产出的缓存数据

img

清理应用残留

img

清理聊天工具缓存

img

开发工具缓存扫描和清理就是一个非常个性化的需求。随着 AI 让软件创建变得越来越容易,定制化需求也会越来越多。但 AI 的本质是基于概率的,存在“抽卡”式的不确定性,所以软件可能会日更,却不可能日抛。软件需要不断积累,需要不停的维护。

说明

2026-06-06 Aifei 框架正式发布了。波总在五一的时候已经放出了 aifei 的正式版和 vip的订阅,作为第一批用户,用上了 aifei-vip-arch 和 aifei-vip,趁着五一学习了一波,顺便让还让AI 生成了Aifei-dev 的 skills,感兴趣的同学可以试一试,https://gitee.com/CrazyAirhead/aifei-dev。当然为了验证这个 skills 我也是做了个示例的,简单的截两张图,看看效果:

img

img

因为 Aifei 刚发布,还有很多生态没有起来,而我日常使用的都是 Solon,而且 Solon 生态已经比较完善,又因为非常喜欢 Aifei,于是先把 aifei-enjoy 和 aifei-db 集成到 Solon 生态里面。Aifei 的完全使用就边用边学吧。

集成 aifei-enjoy

由我贡献的 solon-view-aifei-enjoy 插件已经被合并到 Solon 主版本,将会在Solon 的 4.x 版本发布,https://solon.noear.org/article/1456。

因为 Solon 原来就集成了 enjoy,enjoy 又是波总比较新的作品,此次 aifei-enjoy,主要是调整包名,没有太大的变动。所以集成 aifei-enjoy,主要是在 solon-view-enjoy 插件的基础上修复包名。

有一点需要注意的就是enjoy 不支持 Directive 工厂,Solon 的作者自己做一个 enjoy 的扩展,也就是 solon-view-enjoy 不是使用原版的 enjoy 的 jar 包。在 aifei-enjoy 中,提供了原生的支持,所以可以优先使用 Solon 的容器对象。

1
2
3
4
5
6
7
8
9
10
Engine.setDirectiveFactory(
(cls) -> {
Directive directive = Objects.requireNonNull(Solon.context()).getBean(cls);
if (directive != null) {
return directive;
}

// 兜底
return ClassUtil.newInstance(cls);
});

集成 aifei-db

aifei-db-solon-plugin 的插件,我还在开发中,主体功能是基于 activerecord-solon-plugin 改造的,因为 aifei-db 相对于 activerecord 有较大的变动,我要多验证下,暂时还没有签入和提交 PR。

img

相对于 ActiveRecord,aifei-db 中的 AifeiRow 做了比较多的增强,因此注册AifeiRow被简化。

1
2
3
4
5
6
7
8
9
10
11
/**
* @author airhead
*/
public class ModelManager {
@Getter static Set<Class<? extends AifeiRow<?>>> modelSet = new LinkedHashSet<>();

@SuppressWarnings("unchecked")
public static void addModel(Table table, AifeiRow<?> model) {
modelSet.add((Class<? extends AifeiRow<?>>) model.getClass());
}
}

另外需要注意的是 aifei-core 不包含 Slf4jLogFactory,因此需要自己扩展一个。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
package top.godenyear.ray.framework.aifei.db;

import cn.aifei.log.Log;
import cn.aifei.log.LogFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Aifei LogFactory 的 Slf4j 桥接实现。 解决 aifei-core 不含 Slf4jLogFactory 的问题。
*
* @author airhead
*/
public class Slf4jLogFactory implements LogFactory {

@Override
public Log getLog() {
return getLog(LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME));
}

@Override
public Log getLog(Class<?> clz) {
return getLog(LoggerFactory.getLogger(clz));
}

@Override
public Log getLog(String name) {
return getLog(LoggerFactory.getLogger(name));
}

private Log getLog(Logger slf4jLogger) {
return new Log() {
@Override
public String getName() {
return slf4jLogger.getName();
}

@Override
public boolean isTraceEnabled() {
return slf4jLogger.isTraceEnabled();
}

@Override
public void trace(String msg, Throwable t) {
slf4jLogger.trace(msg, t);
}

@Override
public void trace(String msg) {
slf4jLogger.trace(msg);
}

@Override
public void trace(String format, Object arg) {
slf4jLogger.trace(format, arg);
}

@Override
public void trace(String format, Object arg1, Object arg2) {
slf4jLogger.trace(format, arg1, arg2);
}

@Override
public void trace(String format, Object... args) {
slf4jLogger.trace(format, args);
}

@Override
public void trace(java.util.function.Supplier<String> supplier) {
if (isTraceEnabled()) {
slf4jLogger.trace(supplier.get());
}
}

@Override
public boolean isDebugEnabled() {
return slf4jLogger.isDebugEnabled();
}

@Override
public void debug(String msg, Throwable t) {
slf4jLogger.debug(msg, t);
}

@Override
public void debug(String msg) {
slf4jLogger.debug(msg);
}

@Override
public void debug(String format, Object arg) {
slf4jLogger.debug(format, arg);
}

@Override
public void debug(String format, Object arg1, Object arg2) {
slf4jLogger.debug(format, arg1, arg2);
}

@Override
public void debug(String format, Object... args) {
slf4jLogger.debug(format, args);
}

@Override
public void debug(java.util.function.Supplier<String> supplier) {
if (isDebugEnabled()) slf4jLogger.debug(supplier.get());
}

@Override
public boolean isInfoEnabled() {
return slf4jLogger.isInfoEnabled();
}

@Override
public void info(String msg, Throwable t) {
slf4jLogger.info(msg, t);
}

@Override
public void info(String msg) {
slf4jLogger.info(msg);
}

@Override
public void info(String format, Object arg) {
slf4jLogger.info(format, arg);
}

@Override
public void info(String format, Object arg1, Object arg2) {
slf4jLogger.info(format, arg1, arg2);
}

@Override
public void info(String format, Object... args) {
slf4jLogger.info(format, args);
}

@Override
public void info(java.util.function.Supplier<String> supplier) {
if (isInfoEnabled()) slf4jLogger.info(supplier.get());
}

@Override
public boolean isWarnEnabled() {
return slf4jLogger.isWarnEnabled();
}

@Override
public void warn(String msg, Throwable t) {
slf4jLogger.warn(msg, t);
}

@Override
public void warn(String msg) {
slf4jLogger.warn(msg);
}

@Override
public void warn(String format, Object arg) {
slf4jLogger.warn(format, arg);
}

@Override
public void warn(String format, Object arg1, Object arg2) {
slf4jLogger.warn(format, arg1, arg2);
}

@Override
public void warn(String format, Object... args) {
slf4jLogger.warn(format, args);
}

@Override
public void warn(java.util.function.Supplier<String> supplier) {
if (isWarnEnabled()) {
slf4jLogger.warn(supplier.get());
}
}

@Override
public boolean isErrorEnabled() {
return slf4jLogger.isErrorEnabled();
}

@Override
public void error(String msg, Throwable t) {
slf4jLogger.error(msg, t);
}

@Override
public void error(String msg) {
slf4jLogger.error(msg);
}

@Override
public void error(String format, Object arg) {
slf4jLogger.error(format, arg);
}

@Override
public void error(String format, Object arg1, Object arg2) {
slf4jLogger.error(format, arg1, arg2);
}

@Override
public void error(String format, Object... args) {
slf4jLogger.error(format, args);
}

@Override
public void error(java.util.function.Supplier<String> supplier) {
if (isErrorEnabled()) {
slf4jLogger.error(supplier.get());
}
}
};
}
}

处理序列化问题

不管是 ActiveRecord 中 Model,还是Aifei 中的 AifeiRow,AifeiModel,他的底层数据结构是 Map,通过Bean 方法包装后进行 Bean 的操作,而 AifeiModel 更进一步使用了链式的 setter。

在 Solon 文档 https://solon.noear.org/article/18 中说到,Model 转 Json 输入的方法,不过在我的使用过程中体验不是很好。

1
2
3
4
5
6
7
8
9
10
11
//应用加载完成事件
@Componentpublic
class AppPluginLoadEndEventListener implements EventListener<AppPluginLoadEndEvent> {
@Overridepublic void onEvent(AppPluginLoadEndEvent e) throws Throwable {
//定制 json 序列化输出(使用新的处理接管 "@json" 指令)
e.app().renders().register("@json", (data, ctx) -> {
String json = JFinalJson.getJson().toJson(data);
ctx.outputAsJson(json);
});
}
}

这次在进一步整合 Aifei-db 的过程中,算是找到了一些比较好的集成方式,可以参看Solon 的 issue,重点涉及 EntityConverter 和 PropsConverterExt。

https://gitee.com/opensolon/solon/issues/IJSD9V https://gitee.com/opensolon/solon/issues/IJSCEX

主体的思路是通过完整的替换序列化插件,从而减少扩展链路和调整的地方。

Snack4StringSerializer

mime 为空时也使用JSON序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 是否匹配
*
* @param ctx 请求上下文
* @param mime 内容类型
*/
@Override
public boolean matched(Context ctx, String mime) {
if (mime == null || mime.isEmpty()) {
return true;
} else {
return mime.contains(label) || mime.startsWith(MimeType.APPLICATION_X_NDJSON_VALUE);
}
}

Snack4EntityConverter

changeEntityDo 时使用JSON 进行转换,而不是交给 ClassUtil 处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Snack 实体转换器
*
* @author noear
* @since 3.6
*/
public class Snack4EntityConverter extends AbstractStringEntityConverter<Snack4StringSerializer> {
@Override
protected Object changeEntityDo(Context ctx, ParamWrap p, String name, Class<?> type)
throws Exception {
Props props = new Props().addAll(ctx.paramMap());
Options options = serializer.getDeserializeConfig().getOptions();
return ONode.ofBean(props, options).toBean(type);
}
}

针对 AifeiRow 提供相应的 encoder

ModelEncoder

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
/**
* @author airhead
*/
public class ModelEncoder<T extends AifeiRow<?>> implements ObjectPatternEncoder<T> {
@Override
public ONode encode(EncodeContext ctx, T value, ONode node) {
if (value == null) {
return node;
}

return encode(node, value.data());
}

@Override
public boolean canEncode(Object value) {
return value instanceof AifeiRow;
}

protected ONode encode(ONode node, Map<String, Object> map) {
if (CollUtil.isEmpty(map)) {
return node;
}

node.fill(map);

return node;
}
}

配置 Snack4StringSerializer

为AifeiRow 指定 ModelEncoder

1
2
3
4
5
6
7
8
context.getBeanAsync(
Snack4StringSerializer.class,
serializer -> {
serializer.getDeserializeConfig().addFeatures(Feature.Decode_AllowUseSetter);
serializer.getSerializeConfig().addFeatures(Feature.Write_UseSmlCamelStyle);

serializer.addEncoder(AifeiRow.class, new ModelEncoder<>());
});

小结

经过以上的处理,现在应该能在 Solon 里面愉快的玩耍 aifei-enjoy 和 aifei-db了,enjoy 模版,SQL 模板,原来想要的都回来了。虽然 Solon 比 Aifei 的概念会多一些,但整体还是非常克制的,因此即便多了一些 Controller的逻辑,对于AI 的消耗也不会增加太多。

补充

关于 aifei 的一些尝试,可以仓库代码 https://github.com/crazy-airhead/aifei

  1. 扩展 aifei-json-snack4,替代fastjson2,使用 snack4 进行处理 JSON。
  2. 扩展 aifei-feathttp, 替代undertow,使用 feathttp(原来的smarthttp)作为底层服务器。