阅读视图

发现新文章,点击刷新页面。
🔲 ☆

为什么 UTF-8 能一统天下:字符编码的生存竞赛

前言

写代码这么多年, 乱码这玩意应该每个人都碰到过. 数据库里存的好好的中文, 取出来一看变成了一堆问号; 或者打开一个文本文件, 满屏的"锟斤拷". 每次碰到这种问题, 改一下编码就好了, 但一直没认真想过: 为什么会有这么多编码? UTF-8 又是凭什么成为事实标准的?

最近翻了翻字符编码的历史, 发现这里面还挺有意思的. 简单来说就是一个"从各自为政到大一统"的故事, 而 UTF-8 能赢, 靠的不是什么高深的技术, 而是一个朴素的设计哲学: 向前兼容.

ASCII: 一切的起点

故事要从 ASCII 说起.

上世纪 60 年代, 美国人搞了一套字符编码标准, 用 7 个 bit 来表示字符, 总共能编码 128 个字符. 包括英文大小写字母、数字、标点符号, 再加上一些控制字符(换行、回车这些).

128 个字符, 对英语来说完全够用了. 而且因为计算机存储以字节(8 bit)为单位, 7 bit 的 ASCII 正好塞进一个字节里, 最高位空着, 设为 0. 简洁, 高效, 没毛病.

但问题来了: 这世界上又不是只有英语.

各自为政的混乱时代

ASCII 只能表示 128 个字符, 连个 ü 都搞不定, 欧洲人第一个不答应. 于是大家开始打那个空闲的最高位的主意——把第 8 位也用上, 这样就有 256 个位置了, 多出来的 128 个位置各取所需.

这就是 ISO-8859 系列的由来. 西欧搞了 ISO-8859-1(又叫 Latin-1), 把法语的 é、德语的 ß 这些字符塞了进去; 东欧搞了 ISO-8859-2; 希腊搞了 ISO-8859-7; 以此类推, 一共搞出了十几个版本.

问题已经开始出现了: 同一个字节值, 在不同的编码下代表不同的字符. 比如 0xC0 这个字节, 在 Latin-1 里是 À, 在 ISO-8859-5 里就变成了一个西里尔字母. 你用 Latin-1 写的文件, 拿到一个装了 ISO-8859-5 的机器上打开, 就是乱码. 不过至少欧洲的字母语言还算是占了便宜, 256 个位置虽然挤了点, 勉强能塞下.

到了中日韩这边, 事情就彻底失控了.

光是常用汉字就有好几千个, 256 个位置怎么可能够? 所以只能用两个字节来表示一个字符. 中国大陆搞了 GB2312, 后来扩展成了 GBK, 再后来又搞了 GB18030; 台湾搞了 Big5; 日本搞了 Shift-JIS 和 EUC-JP; 韩国搞了 EUC-KR. 每个地区各搞一套, 互不兼容.

这时候的编码世界大概是这么个状态:

graph LR A["ASCII 128字符"] --> B["ISO-8859 系列"] A --> C["GB2312 / GBK"] A --> D["Big5"] A --> E["Shift-JIS"] A --> F["EUC-KR"] B --> B1["Latin-1 西欧"] B --> B2["ISO-8859-5 俄语"] B --> B3["ISO-8859-7 希腊语"]

每个方案都只管自己的地盘, 出了自己的地盘就抓瞎. 你要是写一个网页, 里面同时有中文和日文, 用哪个编码? 哪个都不行.

更要命的是, 这些双字节编码和 ASCII 的兼容方式也各不相同. 有的编码方案里, 第一个字节的范围和 ASCII 有重叠, 导致解析的时候分不清一个字节到底是一个独立的 ASCII 字符, 还是某个双字节字符的一部分. Shift-JIS 就有这个毛病, 写过处理日文文本程序的应该深有体会.

这就是 UTF-8 出现之前的世界: 一片混乱.

Unicode: 理想很丰满

有问题就有人想解决. 上世纪 80 年代末, 有人提出了一个大胆的想法: 搞一个统一的字符集, 把全世界所有的文字都收进来, 每个字符给一个唯一的编号. 这就是 Unicode.

Unicode 的思路很简单——给每个字符分配一个唯一的编号, 叫做"码点"(Code Point), 用 U+ 加十六进制数表示. 比如:

  • U+0041 是大写字母 A
  • U+4F60 是汉字
  • U+1F600 是 emoji 😀

目前 Unicode 已经收录了超过 14 万个字符, 覆盖了几乎所有现存的文字系统, 甚至包括一些已经消亡的古文字. 码点的范围从 U+0000U+10FFFF, 理论上可以容纳一百多万个字符.

但 Unicode 本身只是一个"字符集", 它只负责给字符编号, 并不管这些编号在计算机里怎么存储. 编号和存储是两码事. 就好比你给全国每个人分配了一个身份证号, 但身份证号怎么印到卡上、用什么材质、多大字号, 那是另一个问题.

所以, Unicode 需要一种"编码方案"来把码点转换成实际的字节序列. 而这种编码方案, 不止一种.

UTF-32: 简单粗暴

最直接的方式: 每个字符固定用 4 个字节表示. 码点是多少, 直接存进去, 不够的高位补零.

A 的码点是 U+0041, 存成 00 00 00 41. 的码点是 U+4F60, 存成 00 00 4F 60.

优点是实现简单, 定长编码, 随机访问方便——想取第 N 个字符, 直接偏移 N*4 个字节就行.

缺点也很明显: 浪费空间. 一个 ASCII 字符本来 1 个字节就够了, 现在要用 4 个字节, 前面 3 个字节全是零. 对于以英文为主的文本, 文件体积直接膨胀到原来的 4 倍. 这谁受得了?

而且还有个字节序的问题. 00 00 00 41 这 4 个字节, 到底是高位在前(大端)还是低位在前(小端)? 不同的 CPU 架构有不同的偏好, 所以 UTF-32 还分 UTF-32BE 和 UTF-32LE. 为了标明字节序, 文件开头还得加一个 BOM(Byte Order Mark). 越搞越复杂.

UTF-16: 折中方案

UTF-16 做了个折中: 常用字符(BMP, 基本多语言平面, 码点在 U+0000U+FFFF 之间的)用 2 个字节表示, 不常用的(如一些 emoji 和古文字)用 4 个字节.

看起来比 UTF-32 省空间了, 但问题也不少:

  • 对于纯英文文本, 还是比 ASCII 大一倍
  • 变长编码(2 字节或 4 字节), 随机访问的优势没了
  • 同样有字节序问题, 分 UTF-16BE 和 UTF-16LE
  • 最致命的: 和 ASCII 不兼容. 一个 ASCII 字符 A 在 UTF-16 里是 00 41, 中间那个 00 字节, 在 C 语言里就是字符串结束符 \0. 任何处理 C 风格字符串的程序, 看到这个 00 就认为字符串结束了. 直接崩.

Java 和 Windows 早期选择了 UTF-16 作为内部编码, 这在当年 Unicode 字符还没那么多的时候看起来是个合理的选择. 但随着 emoji 和更多字符的加入, UTF-16 "定长 2 字节"的假设被打破了, 留下了不少历史包袱.

UTF-8: 为什么是它

终于轮到主角了.

UTF-8 是 1992 年由 Ken Thompson 和 Rob Pike 在一张餐巾纸上设计出来的(没错, 就是发明 Unix 和 Go 语言的那两位). 它的核心思想是: 用变长的字节序列来编码 Unicode 码点, 而且要和 ASCII 完全兼容.

编码规则如下:

码点范围 字节数 字节1 字节2 字节3 字节4
U+0000U+007F 1 0xxxxxxx
U+0080U+07FF 2 110xxxxx 10xxxxxx
U+0800U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

表里的 x 就是实际码点值的二进制位, 从低位往高位填充.

来实际算一个. 汉字"你"的码点是 U+4F60, 转成二进制是 0100 1111 0110 0000, 一共 15 个有效位. 查上面的表, 15 位落在第三行(3 字节编码, 最多容纳 16 个有效位), 所以编码模板是 1110xxxx 10xxxxxx 10xxxxxx.

把二进制位从右往左填进去:

码点二进制: 0100 1111 0110 0000
拆分:       0100   111101   100000
填充:    1110-0100 10-111101 10-100000
十六进制:    E4       BD       A0

所以"你"的 UTF-8 编码就是 E4 BD A0, 3 个字节. 用 Python 验证一下:

>>> '你'.encode('utf-8')
b'\xe4\xbd\xa0'

没毛病.

为什么这个设计能赢

看完编码规则, 可能觉得也就那样, 无非是个变长编码嘛. 但仔细想想, 这里面藏了好几个精妙的设计:

1. 完全兼容 ASCII

码点 U+0000U+007F 的字符, 编码后还是单字节, 而且字节值和 ASCII 一模一样. 这意味着任何合法的 ASCII 文本, 同时也是合法的 UTF-8 文本, 不需要做任何转换.

这一点太重要了. 互联网上大量的协议、配置文件、源代码都是 ASCII 的, 如果新编码不兼容 ASCII, 推广的阻力会大得多. UTF-16 就是因为不兼容 ASCII, 在很多场景下推不动.

2. 自同步(Self-synchronizing)

看编码规则, 每种字节都有明确的特征:

  • 单字节字符: 最高位是 0
  • 多字节序列的首字节: 以 110111011110 开头
  • 后续字节: 以 10 开头

这意味着你随便从一个 UTF-8 字节流的中间位置开始读, 都能快速判断当前字节是不是一个字符的起始位置. 如果以 10 开头, 就往前找, 最多往前找 3 个字节就能找到首字节.

这个特性在处理损坏的数据、网络传输中截断的数据时非常有用. 一个字节损坏, 最多影响一个字符, 不会像某些编码那样导致后面所有字符全部解析错误, 产生"雪崩效应".

3. 没有字节序问题

UTF-8 的编码单元是字节, 不是多字节整数, 所以不存在大端小端的问题. 不需要 BOM, 不需要检测字节序, 写入的字节序列在任何机器上读出来都一样.

UTF-16 和 UTF-32 就没这个待遇了, 文件开头不加 BOM, 解码器就得猜.

4. 排序保持一致

UTF-8 编码后的字节序列, 按字节排序的结果和按码点排序的结果是一致的. 这意味着你不需要先解码再排序, 直接按字节比较就行. 对于数据库索引、文件系统排序这些场景, 这个特性省了不少事.

5. 空间效率合理

对于英文文本, UTF-8 和 ASCII 一样, 1 字节一个字符, 零浪费. 对于中文文本, 3 字节一个字符, 虽然比 GBK 的 2 字节多了 50%, 但考虑到中文文本里通常也夹杂着不少 ASCII 字符(标点、数字、英文术语), 实际差距没那么大.

而且在互联网场景下, 英文内容占了大头, UTF-8 对英文的零开销使得它在全球范围内的平均空间效率非常优秀.

编码检测: 一个有意思的副产品

顺便说一个 UTF-8 的附加好处: 它比较容易被正确检测.

因为 UTF-8 的字节模式有严格的约束(首字节和后续字节的格式是固定的), 一段随机的二进制数据碰巧是合法 UTF-8 的概率其实挺低的. 所以当你拿到一个不知道编码的文件时, 尝试按 UTF-8 解码, 如果能成功解码且没有非法序列, 那它大概率就是 UTF-8.

相比之下, GBK 和 Latin-1 的字节范围大量重叠, 光看字节值很难分辨. 这也是为什么浏览器早期经常猜错编码, 导致乱码的原因之一.

那些"锟斤拷"是怎么来的

提到乱码, 顺便解释一下"锟斤拷"这个经典乱码.

Unicode 里有一个特殊字符叫 U+FFFD (REPLACEMENT CHARACTER, 就是那个 ), 专门用来表示"这里有个字符, 但我解码失败了". 它的 UTF-8 编码是 EF BF BD.

当一段 GBK 编码的文本被错误地当作 UTF-8 解码时, 很多字节序列是非法的 UTF-8, 解码器就会用 U+FFFD 来替代. 这样就产生了一堆 EF BF BD 字节.

然后, 如果你再把这堆字节用 GBK 去解码——两个字节一组, EF BF 对应"锟", BD EF 对应"斤", BF BD 对应"拷". 于是经典的"锟斤拷"就诞生了.

说白了就是编码和解码用的方案对不上, 来回折腾了两次, 就变成了这副鬼样子.

现状

现在回头看, UTF-8 的胜出几乎是必然的. 它做对了最关键的一件事: 不破坏已有的生态.

兼容 ASCII 意味着大量现有的工具、库、协议不需要修改就能处理 UTF-8 文本. C 语言的 strlenstrcpy 这些函数, 虽然不能正确计算 UTF-8 字符串的字符数, 但至少不会因为遇到 \0 而提前截断(和 UTF-16 形成鲜明对比). 文件路径、环境变量、命令行参数, 这些系统级的字符串处理, 切换到 UTF-8 的成本最低.

据 W3Techs 的统计, 截至目前互联网上超过 98% 的网页使用 UTF-8 编码. Linux 系统的默认 locale 是 UTF-8, Go 语言的源码和字符串默认是 UTF-8, Rust 的 String 类型内部就是 UTF-8. 新的协议和标准也基本都默认 UTF-8.

当然 UTF-8 也不是完美的. 对于 CJK 文字(中日韩)来说, 每个字符 3 字节确实不如 GBK 的 2 字节紧凑. 但在一个全球化的网络环境下, "能正确处理所有语言的文字且与 ASCII 兼容"这张牌, 比"某种语言省一个字节"重要得多.

编码的故事到这基本就讲完了. 从 ASCII 的 128 个字符, 到各国自扫门前雪的混战, 再到 Unicode 一统编号、UTF-8 一统编码, 花了大概三十年. 本质上就是一个兼容性打败一切的故事——在技术标准的世界里, 能和已有生态和平共处的方案, 往往比"理论上更优"的方案活得更久.

以上, 简单聊了聊字符编码这些事, 溜了溜了.

🔲 ☆

谁还需要 Kafka 啊?我用两个 UNIX 信号手捏了一个消息队列!

天下苦复杂中间件久矣!

看看我们现在的项目,动辄就要引入 Kafka、RabbitMQ、RocketMQ……光是部署这些环境、调优 JVM、配置集群,就能让一个好好的周末泡汤。你有没有在某个深夜抓狂时想过:“老夫就是想在两个进程之间传个字符串,难道就没有那种最极简、最硬核、最不需要第三方依赖的办法吗?”

如果你没这么想过,恭喜你,你是一个情绪稳定的正常程序员。

但我不仅想了,我还真干了!今天,我要带大家玩一把“文艺复兴”,彻底抛弃所有高级网络协议和中间件,仅用操作系统最底层的两个 UNIX 信号,从零手撸一个消息队列! 不管你是想轻松学点底层 IPC(进程间通信)知识,还是想复习一下快忘光的二进制位运算,亦或只是单纯想看我怎么一本正经地折腾没用的东西,这篇文章都绝对合你的胃口。

准备好了吗?我们要开始“作妖”了。


处在 IPC 鄙视链底端的“信号”

提到进程间通信(IPC, Inter-Process Communication),大家脑子里蹦出来的肯定是:

  1. Socket(套接字):老大哥,网络通信的绝对霸主。
  2. Pipe(管道):也就是我们常用的 | 符,比如 echo "hello" | grep "h",简单粗暴。
  3. Shared Memory(共享内存):性能怪兽,但处理同步问题能让人掉光头发。

相比之下,Signal(信号) 简直就是处于 IPC 鄙视链的绝对底端。为啥?因为它本来就不是设计用来传数据的!

按照 UNIX 系统的设定,信号就像是操作系统给进程发的“短消息通知”,通常只代表一个动作:

  • SIGKILL:阎王让你三更死,谁敢留人到五更。(强制结束,无法捕获)
  • SIGTERM:温柔一刀,给你个机会料理后事。(优雅停机)
  • SIGINT:你在终端疯狂按 Ctrl+C 产生的就是这玩意。

信号本身不携带任何数据载荷。这就好比我给你打了个响指,你只知道我打了响指,但没法通过响指本身知道我中午想吃黄焖鸡还是兰州拉面。

但是!(重点来了)

UNIX 系统非常贴心地留了两个“用户自定义信号”:SIGUSR1SIGUSR2。这就给了我们搞事情的绝佳机会。

脑洞大开:把信号变成摩斯密码

既然信号不能带数据,那我们怎么传消息?
很简单,回到计算机最本质的世界:0 和 1

只要是消息,不管多长多复杂,在内存里最终都是由 0 和 1 组成的二进制串。我们手头刚好有两个自定义信号,那不如这样约定:

  • 收到 SIGUSR1,就代表我给你发了一个 0
  • 收到 SIGUSR2,就代表我给你发了一个 1

这就是我们的“摩斯密码”!

以小写字母 h 为例:
它在 ASCII 码表里的十进制值是 104,转换成二进制就是 01101000
如果我们想把 h 发送给另一个进程,只需要按顺序给那个进程发送 8 次信号:
SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR2 (1) -> SIGUSR1 (0)
(注:这里为了方便,我们假设从最低位 LSB 开始发送)

只要发送端负责把字母“拆”成位,接收端负责把位“拼”回字母,这不就成了吗?!


核心魔法:位运算的拆与拼

要实现这个脑洞,我们需要用一点点位运算(Bitwise Operations)。别一听位运算就跑,其实特别简单。这次我们用 Go 语言 来演示,因为 Go 处理并发和系统级 API 简直顺滑得不像话。

1. 发送端:怎么把一个字节“拆”成 8 个位?

假设我们有一个字节 byte,要想知道它第 i 位是 0 还是 1,我们可以用这个黄金公式:
bit = (byte >> i) & 1

原理剖析:

  • >> 是右移操作符。它能把二进制串整体向右推 i 个位置,原来在第 i 位的数据,就被推到了最右边(最低位)。
  • & 1 是按位“与”操作。因为 1 的二进制是 00000001,任何数和它做“与”操作,都会把前面的高位全部清零,只保留最右边那一位!

h (104, 01101000) 开刀,我们从第 0 位(最右侧)一直剥到第 7 位(最左侧):

  • 第 0 位:(104 >> 0) & 1 -> 104 与 1 -> 结果是 0
  • 第 1 位:(104 >> 1) & 1 -> 52 与 1 -> 结果是 0
  • 第 2 位:(104 >> 2) & 1 -> 26 与 1 -> 结果是 0
  • 第 3 位:(104 >> 3) & 1 -> 13 与 1 -> 结果是 1
  • 第 4 位:(104 >> 4) & 1 -> 6 与 1 -> 结果是 0
  • 第 5 位:(104 >> 5) & 1 -> 3 与 1 -> 结果是 1
  • 第 6 位:(104 >> 6) & 1 -> 1 与 1 -> 结果是 1
  • 第 7 位:(104 >> 7) & 1 -> 0 与 1 -> 结果是 0

完美!我们得到了序列 0, 0, 0, 1, 0, 1, 1, 0,接下来只要把它们换成 SIGUSR1SIGUSR2 发出去就行了。

2. 接收端:怎么把 8 个位“拼”回一个字节?

接收端的工作正好相反,它要用到的武器是左移操作(<<)。
一开始,我们搞一个空字节,值为 0。然后监听信号。

  • 如果来了个 0,不管它。
  • 如果来了个 1,我们就把它往左移 i 个位置,然后“加”到我们的空字节上。

公式:accumulator += (bit << position)

position 走到 8 的时候,说明凑齐了一桌麻将(一个字节),直接把这个字节转成字符打印出来,然后将 positionaccumulator 清零,准备迎接下一个字节。


废话少说,Show Me The Code!

先写接收端 (Consumer)

这哥们的主要任务就是老老实实呆在后台,监听我们要给它发的信号。

// consumer.go
package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;os/signal&quot;
    &quot;syscall&quot;
)

func main() {
    // 打印 PID,不然发送端不知道要把信号打给谁
    fmt.Printf(&quot;😎 消费者已启动!我的进程 PID 是: %d\n&quot;, os.Getpid())
    fmt.Println(&quot;🎧 正在竖起耳朵等待 UNIX 信号...&quot;)

    // 注册通道,专门截获 SIGUSR1 和 SIGUSR2
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGUSR1, syscall.SIGUSR2)

    var accumulator byte = 0
    var position int = 0
    var buffer []byte // 用来存拼好的字符

    for sig := range sigCh {
        var bit byte = 0
        if sig == syscall.SIGUSR2 {
            bit = 1 // SIGUSR2 就是 1
        }

        // 拼图游戏开始,把 bit 推到正确的位置上并累加
        accumulator += (bit &lt;&lt; position)
        position++

        // 收集满 8 个龙珠,召唤一个 Byte
        if position == 8 {
            if accumulator == 0 {
                // 我们约定,收到一个完全是 0 的字节(NULL),代表一句话说完了
                fmt.Printf(&quot;\n✨ 收到完整消息: %s\n&quot;, string(buffer))
                buffer = []byte{} // 清空,准备听下一句
            } else {
                // 没说完就先存进 buffer
                buffer = append(buffer, accumulator)
                fmt.Printf(&quot;%c&quot;, accumulator) // 实时打印看看效果
            }

            // 重置状态
            accumulator = 0
            position = 0
        }
    }
}

再写发送端 (Producer)

发送端就是个无情的发报机,把我们的命令行参数拆碎了发射出去。

// producer.go
package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;strconv&quot;
    &quot;syscall&quot;
    &quot;time&quot;
)

func main() {
    if len(os.Args) &lt; 3 {
        fmt.Println(&quot;❌ 姿势不对!正确用法: go run producer.go &lt;PID&gt; &lt;你想发送的骚话&gt;&quot;)
        return
    }

    targetPid, _ := strconv.Atoi(os.Args[1])
    message := os.Args[2]

    fmt.Printf(&quot;🚀 准备向 PID %d 发射消息: [%s]\n&quot;, targetPid, message)

    for i := 0; i &lt; len(message); i++ {
        b := message[i]

        // 庖丁解牛:拆解 8 个位
        for j := 0; j &lt; 8; j++ {
            bit := (b &gt;&gt; j) &amp; 1

            sig := syscall.SIGUSR1
            if bit == 1 {
                sig = syscall.SIGUSR2
            }

            // 发送信号!咻!
            syscall.Kill(targetPid, sig)

            // ⚠ 极其关键的一步:休眠!
            // 如果不睡一会儿,操作系统的内核可能会把密集发送的相同信号合并成一个
            // 那样你的数据就全丢了。这就是底层 IPC 的残酷。
            time.Sleep(2 * time.Millisecond) 
        }
    }

    // 消息发完了,最后发 8 个 0(NULL),告诉接收方“我说完了”
    for j := 0; j &lt; 8; j++ {
        syscall.Kill(targetPid, syscall.SIGUSR1)
        time.Sleep(2 * time.Millisecond)
    }

    fmt.Println(&quot;✅ 消息发送完毕,深藏功与名。&quot;)
}

见证奇迹的时刻

打开两个终端窗口。
在窗口 A 运行:

$ go run consumer.go
😎 消费者已启动!我的进程 PID 是: 8848
🎧 正在竖起耳朵等待 UNIX 信号...

在窗口 B 运行:

$ go run producer.go 8848 &quot;Hello, UNIX!&quot;
🚀 准备向 PID 8848 发射消息: [Hello, UNIX!]
✅ 消息发送完毕,深藏功与名。

此时,你会在窗口 A 看到字符一个一个地蹦出来:

H e l l o ,   U N I X !
✨ 收到完整消息: Hello, UNIX!

是不是有种黑客帝国里字符雨的快感?!


玩得再大点:三层架构的 Broker

既然搞了,干脆贯彻到底,弄个正儿八经的 Pub/Sub(发布/订阅)架构!

我们可以写一个中转站(Broker)进程。

graph TD P1{Producer1} --> B{Broker} P2{Producer2} --> B{Broker} B --> C1{Consumer1} B --> C2{Consumer2}

Broker 到底是个啥角色?
其实,Broker 本质上就是一个“缝合怪”:它对外兼具了 Consumer 和 Producer 的功能。

  1. 作为接收方:它监听系统发来的 SIGUSR1SIGUSR2,按照我们上面的逻辑,把位拼成完整的字符串消息。
  2. 缓冲与路由:拼好一条消息后,它不打印,而是塞进自己内部的一个内存队列(比如 Go 的 Channel)。
  3. 作为发送方:它后台跑一个死循环,一旦发现队列里有消息,就去查找已注册的下游 Consumer 进程的 PID,然后把消息重新拆成 0 和 1 的信号,像机关枪一样发送过去。

这样一来,Producer 甚至不需要知道 Consumer 的 PID,只需要把信号发给 Broker 就行了,彻底实现了系统的解耦!(听上去是不是非常像大厂里的微服务架构介绍?)


灵魂拷问:这玩意能上生产环境吗?

如果你觉得这套架构很帅,打算明天去公司把它整合到你们的核心支付业务系统里去……那我劝你最好先准备好离职报告。

永远,绝对,不要在生产环境中这么做!

为什么?因为用 UNIX 信号当消息队列,缺陷多到令人发指:

  1. 慢如老牛:每发送一个“位”的数据,都会触发一次由系统空间到用户空间的上下文切换。为了防止信号丢失,我们在每次发送后都加了 Sleep。发一个字符要 16 毫秒,发一段 100 字的消息要将近 2 秒。这在现代软件里简直是世纪末的灾难。
  2. 极不可靠:信号是没有持久化机制的,发丢了就是丢了,没法重试,更没有“消息确认(ACK)”。
  3. 不支持并发:如果两个 Producer 同时向一个 Broker 狂发信号,0 和 1 就会严重交错污染。Broker 解析出来的绝对是一串毫无逻辑的外星文乱码。

那我们今天费这么大劲折腾这玩意,图个啥?

图的是看透本质的爽感

在这个大家都在卷各种上层框架、中间件 API 的时代,我们很容易迷失在各种高大上的术语里。但当我们扒掉 Kafka、RabbitMQ 华丽的封装外衣,下沉到操作系统的深水区时,你会发现,所有的“消息”、“通信”、“流转”,归根结底,也不过就是底层内存和 CPU 的 0 与 1 的游戏。

搞明白位运算,搞明白进程间的最原始交互方式,能在你以后排查那些极其诡异的架构 Bug 时,提供意想不到的直觉与灵感。

退一万步讲,下次面试官再问你:“除了常用中间件,你还了解哪些 IPC 方式?”
你就可以微微一笑,靠在椅背上淡淡地说:“我曾经仅用两个 UNIX 信号就手写了一个消息队列,虽然毫无卵用,但是非常酷。”

Happy Hacking!

🔲 ☆

高德地图红绿灯倒计时之实现原理

概述

相信大家在开车导航时都注意到了,高德地图(以及其他导航软件)现在能在路口精准地显示红绿灯的倒计时,甚至还能告诉你“需要等待 2 轮红灯”。

很多人第一反应是:“高德是不是接入了交警的红绿灯后台数据?”

答案是:极少部分是接入的,绝大部分是“算”出来的。

如果全靠接入,考虑到全国各地红绿灯系统的封闭性、多样性和数据安全,这几乎是不可能完成的任务。实际上,高德是利用海量的用户轨迹数据,通过算法反向推演出红绿灯的周期。这其中的核心技术,参考了其公开的专利 CN114463969A(红绿灯周期时长的挖掘方法)。

今天我们就来硬核拆解一下,这个“读心术”是如何实现的。

核心原理

通俗的讲,红绿灯的本质是一个周期性函数。只要我们观察到足够多的样本,就能拟合出这个函数。

想象一下,你站在路口,虽然看不见红绿灯,但你发现:

  1. 每隔 60 秒,就有一大波车停下来(红灯)。
  2. 每隔 60 秒,又有一大波车同时起步(绿灯)。

高德做的就是这个“观察者”。它利用海量的用户手机 GPS 数据,分析车辆在路口的 “停车-起步” 行为,进而推算出红绿灯的 周期时长红绿相位

这个过程主要分为三个核心步骤:

  1. 样本筛选:找到经过该路口的车辆轨迹。
  2. 起停波识别:识别车辆何时停车,何时起步。
  3. 周期挖掘:通过起步时间的规律,算出红绿灯周期。

参考专利地址CN114463969A – Method and apparatus for mining traffic signal light cycle

实现细节

1. 数据清洗与事件提取

并不是所有经过路口的轨迹都有用。我们需要筛选出“受红绿灯影响”的轨迹。

  • 有效样本:在路口前速度降为 0,且停留超过一定时间,然后加速通过的车辆。
  • 无效样本:直接绿灯通过的车辆(没有停车特征,无法判断红灯起始点),或者右转车辆(通常不受红绿灯限制)。

对于每一辆有效车辆 i,我们可以提取出一个关键事件:起步时间 t_start
这个 t_start 近似等于绿灯亮起的时间(当然有延迟,后面会说怎么校准)。

2. 聚类形成“起步波”

单个车辆的数据可能存在误差(比如司机走神了,绿灯亮了 3 秒才走)。但如果有 10 辆车在 10:00:05 ~ 10:00:10 之间起步,我们就可以认为这是一个 “绿灯起步波”

我们需要将时间轴上离散的起步点,聚合为一个个簇(Cluster)。

3. 周期计算 (核心数学逻辑)

假设红绿灯周期是 T。那么理想情况下,所有“起步波”的时间差应该是 T 的整数倍。
例如:

  • 第一波起步:10:00:00
  • 第二波起步:10:02:00 (间隔 120s)
  • 第三波起步:10:03:00 (间隔 60s)

可以推断,周期 T 很有可能是 60s(因为 120 和 60 都是 60 的倍数)。

算法会尝试搜索一个最佳的 T,使得所有观测到的起步时间 t 满足以下公式:

t ≈ t_base + k * T + error

其中:

  • t_base 是基准时间
  • k 是整数轮次
  • error 是误差

4. 误差修正(排队论)

这是最难的一点。车辆起步时间 != 绿灯亮起时间
如果一辆车排在第 10 位,它起步的时间可能比绿灯亮起晚 20 秒。

高德的算法会考虑 排队位置。通过 GPS 坐标,可以算出车辆距离停车线的距离。

修正后的绿灯时间 = 实际起步时间 - (排队距离 / 饱和流率)

Golang 代码示例

下面用一段 Go 代码来模拟这个“周期挖掘”的核心逻辑。为了简化,我们假设已经提取到了车辆的起步时间列表。(这个代码只是表达思路使用, 与实际无关)

package main

import (
    "fmt"
    "math"
    "sort"
)

// TrafficLightMiner 模拟红绿灯挖掘器
type TrafficLightMiner struct {
    StartTimes []int64 // 收集到的车辆起步时间戳(秒)
}

// AddTrajectory 添加一条轨迹的起步时间
func (m *TrafficLightMiner) AddTrajectory(startTime int64) {
    m.StartTimes = append(m.StartTimes, startTime)
}

// CalculateCycle 挖掘红绿灯周期
// 原理:寻找一个周期 T,使得绝大多数的时间间隔都是 T 的整数倍
func (m *TrafficLightMiner) CalculateCycle() int {
    if len(m.StartTimes) < 2 {
        return 0
    }

    // 1. 先对时间进行排序
    sort.Slice(m.StartTimes, func(i, j int) bool {
        return m.StartTimes[i] < m.StartTimes[j]
    })

    // 2. 计算相邻起步波的时间差 (Diff)
    // 在实际场景中,这里需要先做聚类,把同一轮红绿灯的车归为一组,取中位数作为该轮的起步时间
    // 这里为了简化,假设输入已经是经过聚类处理后的每轮首车起步时间
    var diffs []int64
    for i := 1; i < len(m.StartTimes); i++ {
        diff := m.StartTimes[i] - m.StartTimes[i-1]
        diffs = append(diffs, diff)
    }

    // 3. 寻找众数或者最大公约数思想的拟合
    // 常见的红绿灯周期通常在 30s - 180s 之间
    // 我们尝试在这个范围内搜索得分最高的周期
    bestCycle := 0
    maxScore := 0.0

    for t := 30; t <= 180; t++ {
        score := m.evaluateCycle(t, diffs)
        if score > maxScore {
            maxScore = score
            bestCycle = t
        }
    }

    return bestCycle
}

// evaluateCycle 评分函数:计算某个周期 T 对数据的解释程度
func (m *TrafficLightMiner) evaluateCycle(T int, diffs []int64) float64 {
    hits := 0.0
    tolerance := 3.0 // 容忍误差 3秒

    for _, diff := range diffs {
        // 计算 diff 是否是 T 的倍数
        // 例如 diff = 122, T = 60, remainder = 2 (在误差允许范围内)
        remainder := math.Mod(float64(diff), float64(T))

        // 处理余数接近 T 的情况 (例如余数 59 相当于 -1)
        if remainder > float64(T)/2 {
            remainder = float64(T) - remainder
        }

        if remainder <= tolerance {
            hits++
        }
    }

    // 返回命中率
    return hits / float64(len(diffs))
}

func main() {
    miner := TrafficLightMiner{}

    // 模拟数据:假设红绿灯周期是 60秒
    // 车辆分别在以下时间点起步 (包含一些随机误差和跳过的轮次)
    // 第1轮: 100s
    // 第2轮: 161s (误差+1s)
    // 第3轮: 没有车通过 (跳过)
    // 第4轮: 280s (100 + 60*3 = 280)
    // 第5轮: 342s (100 + 60*4 + 误差2s)

    simulatedData := []int64{100, 161, 280, 342, 400, 521}

    for _, t := range simulatedData {
        miner.AddTrajectory(t)
    }

    cycle := miner.CalculateCycle()

    fmt.Println("分析样本起步时间:", simulatedData)
    fmt.Printf("计算出的红绿灯周期为: %d 秒\n", cycle)

    // 实际应用中,算出周期后,还需要算出“红灯开始时间”和“偏移量”才能做倒计时
}

优缺点分析

优点:

  1. 覆盖广:不需要依赖政府设备,只要有路口有车在跑,就能算出来。
  2. 成本低:纯软件实现,不需要铺设硬件传感器。
  3. 实时性:如果路口临时改为交警手控(周期错乱),算法检测到数据方差变大,会自动降级不显示,避免误报。

缺点:

  1. 依赖车流:如果是半夜或者偏僻路口,没有车经过,就没有数据样本,也就无法计算。
  2. 受排队影响:如果路口常年严重拥堵,车辆一次绿灯过不去(溢出),起步时间就不代表绿灯开始时间,会导致计算偏差。
  3. 右转干扰:虽然有筛选,但部分路口允许红灯右转,如果清洗不干净,会干扰算法判断。

简单总结,你在高德地图上看到的每一个倒计时数字,背后都是无数辆车的轨迹汇聚而成的“群体智慧”。这就是大数据的魅力。

🔲 ☆

2025.12 AI使用有感

不知道是不是因为AI真的陷入了瓶颈,现在的模型很少出现完美的,每个模型总是有一定的缺陷,这也就导致了用AI需要一些技巧,这些技巧让AI带来的智力平权又拉开了一点距离。

我有这个想法,最初是源于教老婆用AI,她之前用AI用的不多,在给她使用了Claude Code、Codex、Gemini CLI、Copilot等产品之后,她惊呼好用,但我观察她的使用总是觉得笨笨的,仔细总结之后发现用AI确实是需要一些技术和一些经验。

例如她喜欢非常简单的描述问题然后与AI进行非常多轮的讨论来明确整个需求,这导致AI的无用上下文大幅提高,AI在处理长上下文的情况下性能又会下降很多。

又比如她不喜欢切换模型,当我告诉她Opus4.5好用之后,所有的情况都是用Opus4.5来完成,导致一些比较复杂的逻辑性需求或者Bug改来改去无法完成,这种情况下使用Codex的ExHigh或者High才是最佳选择,虽然说可能思考十几分钟,但是问题基本上是能够修复的。

还比如最好别给AI用“你是XXX”,别跟AI提“我”,写界面最好提供设计截图,用Simple Browser来给AI提供DOM上下文等等等等。

这里面门道其实挺多,造成用AI的能力也有1-10倍的差距。

🔲 ☆

How I AI Coding 2025.10

因为看到了Xuanwo大佬文章,所以我也来写一下我是怎么用AI来开发的。

工具

VSCode Insider with Copilot,Codex,Claude Code,Kagi,AIPP
现在日常用的就是VSCode Insider,因为Copilot的新特性基本都是先在Insider版本试用的,所以如果要用到最好的Copilot就只能牺牲一点稳定性来使用Insider版本了。目前我的工作里有很大的部分是无法通过Vibe Coding来完成的,所以还是比较依赖编辑器里的代码补全,Copilot现在是用的GPT 4.1 来做的代码补全模型,智力和速度都是在线的,也有Next Edit Suggestions,很符合我的需求。Copilot Agent现在比过去的可用性提高了很多,比以前更能利用模型的能力,也加入了Todo、PlanMode 之类的辅助能力,也很少出现只读文件一部分的情况了,Apply的速度虽然说比不上Cursor,不过也算快了。而且Copilot订阅有网页端,在 https://github.com/copilot ,日常有一些问题可以在网页端直接询问,不需要别扭的在 VSCode 里来询问和代码不相干的事情了,我通常有一些常识性的问题或者对Github上仓库的问题都会在网页端用Gemini 2.5 Pro来进行问答。Copilot网页端的Agents在我的测试看来基本不可用,模型不明但是估计是Sonnet 4.5之类的,效果奇差,不过会启动环境做验证,期待未来吧。(还有个很重要的点就是我穷,Cursor现在的计费我要是放开了用会破产,Copilot高级请求用完之后,还有无限的4o和4.1使用,这点让我很安心)

然后就是Codex和Claude Code了,在Vibe一些小项目时Claude Code挺好,但是当项目大到一定程度或者问题达到一定难度后,Claude Code基本上就是帮倒忙了,这个时候GPT 5 High的智商优势就体现出来了,虽然一个任务大概率要跑十几到几十分钟,但是实实在在的能优雅的完成任务,所以我已经很少使用Claude Code了,基本换到了Codex。(我没有订阅这俩的官方服务,我是通过中转站来使用的,所以我只在需要使用的时候才会对这俩工具付费,这能节约很多钱。)

还有一个我觉得非常好用的工具就是Kagi(AI能力是需要付费的),搜索的非常精准,基本第一页就会出现我要的结果(保守的说法,其实一般第一条就是),在搜索引擎的能力加持下,它家的搜索Agent Ki就更强了,目前看只有GPT5 Pro的Search能够打打。Kagi在我做预研或者查解决方案的过程里,给了非常多的帮助。

AIPP是我自己写的开源ChatApp,别的ChatApp各有优势但也总有槽点,所以做一个符合自己使用习惯的,我在中转和国内的厂商(Kimi、GLM、Deepseek)都有充值,用API来对一些问题提问。现在也只是刚把功能做好,还有很多Bug,不过我自己足够熟悉更新迭代的也会很快。

MCP

Context7,Jina,自己写的禅道、数据库MCP
Xuanwo大佬在9月的版本中认为“MCP is still a lie”,我倒是有不同的意见,MCP在简化上下文资源中有命令行和基础工具不可替代的作用。
Context7是一个用于查询文档的MCP,是我必备的MCP,大模型的知识跟不上导致API经常使用过时,甚至新的库直接就不知道怎么用,不使用Context7就得自己去查了然后告诉大模型或者模型就开始胡说。
Jina是我用来让AIPP搜索和联网的MCP,也有很多别的替代不过我最偏爱Jina这家公司。
禅道MCP是我在AIPP或者Copilot中能够快速的查看Bug情况,数据库MCP则是连接公司内部的开发库来快速的开发和改Bug,如果不使用MCP很难有更高效的方式让我放心的对禅道和数据库进行授权让AI调用。

感想

Xuanwo大佬提的感想大部分我都赞同,我说一些我自己的别的感想:

多模态能力其实对写代码挺重要的,尤其是前端,目前感觉最好的是Gemini 2.5 Pro,因为可以接受视频的输入,连动效都可以实现。

绝大部分人的绝大部分编程任务其实不需要模型多高的智力,只要能够描述清楚,Sonnet 4.5或者GPT5 Medium就足够了,国内的GML4.6、Kimi K2也是足够的。

模型的智力比承载工具重要多了,参考Cursor和Codex就明白,Sonnet的进步让Cursor异军突起,GPT5 High的能力让难用的Codex也能忍着用下去。

    AI产品需要反复的去用,很多东西刚开始用的时候非常垃圾,但是随着产品和模型的演进,突然就非常好用了。

    所有的产品都可以用AI来重写一次,但是产品里的基础能力还是不可或缺的,AI也只能是替代原有产品的使用者来自动调用这些基础能力,没法替代产品本身。

    🔲 ☆

    日常-阅读 How we built our multi-agent research system

    今天抽空阅读了 Anthropic 的 How we built our multi-agent research system,其中有不少过去就已经了解熟知的知识,也提到了不少能够激发我思考的点,来总结总结。
    首先先说说之前就了解的:

    1. 一个非常聪明的规划者。Anthropic 用的自然是 Opus,规划任务、决策都是 Opus 来做的,越聪明的 AI 做规划者越能提高执行者的效率和效果,目前第一梯队的规划者只有 3 个,Anthropic 4-Opus、OpenAI O3、Gemini-2.5-Pro,排名有先后。
    2. 执行者的能力不能太低。Anthropic 的执行者使用的是 Sonnet,而不是 Haiku,执行者要能正确的使用工具,正确的遵循格式来输出,并且最重要的是能够理解任务的要点。
    3. 使用记忆。PreAct 的要点就是提前计划并且生成 TODO,需要记住 TODO 完成的状态;在每轮迭代任务的过程中,都会产生新的结果,都需要进行记录;长时间执行的任务,会产生巨长无比的上下文,通常经过总结后也需要保存到记忆中。文章中配了两张图都提到了 Memory 可以看出它的重要。 之所以不把记忆归类到工具,是因为记忆可以算作一个基础设施,是必须要使用的,而工具是根据任务的不同可以选择用或者不用。
    4. 使用工具。给予丰富的工具才能让 Agent 的智能发挥作用,最近看的最典型的例子就是,ChatGPT 网页上的 O3 在工具加持下能够通过非常普通的照片定位到照片拍摄位置。过去我倾向于看模型的 Aider 评分来看模型的能力,因为这个评测集通常反应了模型编写代码和指令遵循的能力,但发展到现在的阶段,Aider 的评分已经无法反应出模型真正解决问题的能力,现在我更喜欢看模型的 SWE-bench Verified 评分,这是一个经过筛选的现实问题集,考验模型使用各种工具来解决现实问题的能力,不过通常强的模型这两者的分数都不会低。

    再来谈谈新的收获:

    1. 巨量的 Token 消耗。Anthropic 提到 Multi Agent 是普通聊天的 15 倍 Token 消耗,所以应该让 Multi Agent 作用在能产生高价值的地方,而不是普通的任务。我之前有想过为什么 AI IDE 不搞一个这种 Multi Agent 模式,能够又聪明又快速的完成任务,从这篇文章中可以一窥原因,现在的 AI IDE 不约而同的让自己的工具在尽量少的 Token 消耗的情况下来解决问题,但基本都是事与愿违,有时候看着 AI 50 行 50 行代码的 read,真的哭笑不得。想必这就是 AI 提供商巨头的护城河,他们的 Token 基本上是 Free 的,通过烧 Token 来提高效果这是一般的应用公司没法做到的。
    2. Agent 调试。之前从没有想过来做一个工具像断点代码一样调试 Agent,Anthropic 做了一个工具,能够逐步的观看 Agent 如何进行处理,这使得在 Agent 优化的过程中能够直观的、不停的 减少 不必要的工具调用、生成冗长的搜索词等。这也让我想到了可以把 传统编程中的很多调试方法都带到 AI 应用开发,除了断点,还有像日志(做个 MCP 来让 AI 自动输出日志到指定的地方)、Mock(假设模型的前置返回或者 MCP 的返回来进行测试调试)等等,对于 AI 这种黑盒开发有不小的帮助。
    3. 智能体自我进化。像 Cursor 中的 Agent 自动调用测试获取报错信息或者是获取 Lint 报错一样,如果任务执行的不好,Anthropic 会让 Sonnet 自己来修改提示词再次进行尝试,直到获取到更好的结果。甚至当提供的 MCP 工具有问题时,Sonnet 会去修复这个工具然后继续。之前看信息流里有 OpenAI 的老哥说 O3 的降价也来自于 Codex 对系统的自我进化开发。这相当于强化学习训练出来的 AI 来用强化学习的方法来训练一个应用?
    4. 并行处理。不光是把任务分解后多个 Agent 同时去完成所有的任务,还有 Agent 同时调用多个工具来解决一个任务。这个想法其实我之前也想到了,并且在一个 Side Project 中也实践过了,效果非常好,这个 Project 是一个小说编辑器,在对小说进行润色的过程中,会让规划者先标记出来哪些句子需要润色,再让多个执行者并行的对每个句子来进行润色。原来这个任务在润色一篇 3000 字的文章时,起码要等待 40 秒(使用的是 gemini 2.5 pro),使用了这种方式之后,大约 10 秒内就能看到结果了。
    5. 提示词优化。Anthropic 提到一个微小的提示词修改,使得任务成功率从 30% 提高到 80%,这让我想到之前听的播客中的一个观念:如果我修改16 次提示词最终完成了比较难的问题,那么这个过程中,到底是我太菜了还是 AI 太菜了?Anthropic 很大方的开源了自己的提示词,可以去 Anthropic 的仓库里看看。
    6. 工程化。Anthropic 提到了很多工程化的问题,包含了 恢复状态、容错、可调试可观测可测试、部署更新、执行与并发 等各种挑战,这也是每个做 AI 应用的开发者都可能会碰到的问题,所以 AI 应用并不是写个提示词那么简单,涉及到的编码和传统软件工程的知识都非常多。在目前,至少是 2025 年 6 月这个节点,根本不可能有真的0编程知识的人能够使用 AI 开发上线运营一个应用。

    想法:
    我曾经花了一个小时,和 AI 对谈让它来不停的问我问题了解我,最终总结出了一大篇关于模仿我的提示词,然后我就可以与“我”对话了。不过这个“我”的上下文还是太少,比如我正在写的项目和完成的进度,比如我的朋友同事们的性格和我们之间的经历,比如我还有一些并不符合 AI 政策的想法无法输入进去。同时“我”的能力还是太少了,抛开能影响现实中的能力不谈,在电脑中的能力也少的可怜,虽然我也配置了很多 MCP 但 AI 任然不能用我的电脑做任何事,比如聊微信、写 Word、刷信息流等等。但如果我提供了某个特殊领域的所有工具,比如程序员或者产品经理,它能够写代码、执行命令、获取错误、截图看效果、写 Markdown 文件、发飞书通知、查看禅道 Bug 情况、跟进飞书多维表格的任务进度,那他是不是真的可以成为某个领域中的我,然后再用 Multi Agent 技术来模拟许多个我,让我的效率得到千百倍的提高。这件事我会去做,不过估计做的会比较慢,我相信随着模型智力的提高,最终的那个“我”肯定比现在的我要更胜任我的工作,那时候我应该可以躺着了。
    最近一直被安利 Claude Code,不过没抽时间去试,昨天偶然看到了一个 MiniJinja 的作者使用 Claude Code 修复现实中的 Issues 的视频,可以看到的是,Claude Code 并不能真正“完美”的完成任务,改动的方案并不是最佳的,也没有遵循用户的指令先进行方案讨论不要立马实现代码,修复这两个 Issue 都是需要资深开发者来介入的。所以我一直偏向 Cursor 这类 AI IDE,纯 Agent 很容易把原来优雅的代码改成屎山,然后又要花时间去把屎山做优雅。不过如果是 Vibe 一个简单的 Side Project 用这种方式应该还是很舒服的,等我下一个 Side Project 启动的时候我会开一个 100 刀的 Claude Code 试试。

    🔲 ☆

    数据结构-Merkle Trees

    从 https://simonwillison.net/2025/May/11/cursor-security/#atom-everything 看到的文章看到的相关文章了解到的。

    Cursor 使用这个数据结构,用于快速定位到文件内容的变更,当数据块修改的时候,能够快速通过 hash 来找到变化的部分,我让 ai 给我做了个可交互的网页来理解这个数据结构,如下:

    Merkle Tree Demo

    Merkle Tree 默克尔树演示

    修改下方的数据,观察上面的哈希值如何变化,尤其是最顶部的默克尔根。

    默克尔树工作原理简介

    1. **叶子节点:** 底部是原始数据块(你可以修改它们)。

    2. **哈希计算:** 每个数据块首先计算出自己的哈希值(显示在输入框下方)。

    3. **层层向上:** 相邻的哈希值被组合(串联),然后计算新的哈希值,形成上一层的节点。

    4. **默克尔根:** 这个过程重复进行,直到最顶端只剩下一个哈希值,这就是默克尔根。

    5. **验证:** 默克尔根是整个数据集的”指纹”。如果任何一个数据块或其顺序改变,默克尔根都会完全不同。这使得只通过默克尔根就能快速验证数据的完整性,而无需检查所有数据。

    6. **应用:** 区块链(如比特币、以太坊)、点对点下载(如 BitTorrent)等都广泛使用默克尔树来高效地验证数据的完整性。

    🔲 ☆

    LLM-大模型绘制流程图

    过去在读代码的时候或者是学习的时候,经常让大模型给我画流程图,一直是用的 mermaid 的方式来绘制,使用这种方法的好处就是大模型基本上都会,因为 mermaid 算是非常有名的 markdown 中展示流程图的方案了。

    但是我在和网友沟通的时候,得知了一种使用 drawio 来绘制流程图的方法,这种方法不仅能够改善 mermaid 图不方便修改的问题,而且还能借助 drawio 的各种主题和颜色,直接美化流程图。输出大概如下:

    存为文件在 drawio 打开的效果如下:

    如果使用 mermaid 是这样的:

    网友是在小红书中看到的,但目前好像被删除了看不到了,我这边贴一下我从小红书中拿到的提示词:

    你是一个卓越的绘图高手,你需要根据用户的需求来进行绘图。
    **核心能力:**
    1.  根据视觉描述/需求直接生成可运行的draw.io代码
    2.  校验机制保证代码准确性
    3.  输出标准化代码块
    
    **处理流程:**
    ① 接收输入→ ②要素解析→ ③结构建模→ ④语法生成→ ⑤完整性校验→ ⑥输出结果
    
    **输出规范:**
    ```xml
    <!-- 经过校验的draw.io代码 -->
    <mxfile>
        [生成的核心代码]
    </mxfile>
    ...
    ```
    
    **交互规则:**
    - 收到图片描述时: "正在解析结构关系(进行描述图片细节)----(校验通过)"
    - 收到创建需求时: "建议采用[布局类型], 包含[元素数量]个节点,是否确认?"
    - 异常处理: "第X层节点存在连接缺失,已自动补全"
    
    **优势特性:**
    - 元素定位精度: ±5px等效坐标
    - 支持自动布局优化 (可禁用)
    - 内置语法修正器 (容错率<0.3%)
    
    **DrawIO 图形规范指南**
    drawio文件是基于mxGraph的XML结构
    基础要求
    • 展示在A4纸上,选择合适的字体大小
    • 字体必须全部加粗,标题等关键元素字号加大处理
    • 线段统一使用3pt宽度,保证在论文打印后依然清晰可见
    • 所有文本格式(加粗、下标上标、公式代码)必须正确实现
    • 使用标准drawio文件格式,保证兼容性
    • 组件必须完全容纳文字,避免文字溢出
    • 所有线条必须设置jumpStyle=arc和jumpSize=6,确保交叉处清晰可辨
    • 所有连接线拐点必须设置 rounded=1保证美观
    布局规范
    • 组件间垂直和水平间距保持统一 (30-50px为宜)
    • 将相关的组件放入容器或组中,以提高图表的可读性和组织性。
    • 对齐方式使用center,保持一致性
    • 使用网格对齐(gridSize=10)辅助布局
    • 避免组件和连接线重叠,特别是避免线条穿过文字
    连接线规范
    • 所有箭头样式必须统一 (endArrow=classic)
    • 多条连接线汇入同一组件时,应从不同方向进入(如左、中、右)
    • 同一起点的多条连接线应适当分散起点位置
    • 为所有交叉的连接线添加跳跃样式(jumpStyle=arc)
    • 长距离连接线应适当设置航点 (waypoints) 引导路径
    • 绝对禁止连接线遮挡文字和组件标签
    组件连接设计
    • 组件使用浮动连接点,而非固定连接点
    • 相关组件应放置在合理的相对位置,减少连线复杂度
    • 复杂流程应分层次展示,避免连线交叉过多
    文本与组件规范
    • 所有组件内文本必须加粗(fontStyle=1)
    • 数学公式使用HTML格式: h<sup>v</sup>和h<sub>inter</sub> 不要使用latex格式
    • 公式可以根据条件更换字体
    • 数学符号如点乘必须使用正确格式: 应写为&odot;
    • 合理使用waypoints:在需要精确控制连接路径时,可以使用固定的waypoints来避免线条交叉和文字遮挡
    • 组件大小应根据内容自适应,保持适当留白
    命名与结构规范
    • diagram name必须命名为有意义的名称 (如“多模态特征融合流程”)
    • 组件ID必须反映其功能(如query-network)
    • 连接线ID应反映实际连接关系(如edge-visual-query)
    • 相关元素应放在一起,提高代码可读性
    实践检查清单
    • 连接线交叉检查:所有交叉处是否设置了jumpStyle=arc
    • 文本遮挡检查:是否有连接线穿过文本或遮挡组件
    • 格式一致性检:字体、线条宽度、箭头样式是否统一
    • 连接美观性检查:连接线是否从合适的方向进入组件
    • 留白空间检查:组件之间是否有足够间距 (30-50px)
    • 代码健壮性检查:代码是否符合drawio开发规范,是否可以运行
    特殊场景处理
    • 复杂图表应考虑分层或分区域展示
    • 多条平行连接线应保持一致的间距和样式
    • 长路径连接应使用中间节点或分段处理
    • 双向连接使用两条独立的连接线而非双向箭头
    参考资源
    • DrawIO官方文档: https://www.drawio.com/
    • DrawIO学习教程: https://www.drawzh.com/
    • 在线编辑器: https://app.diagrams.net/
    • MXGraph语法: https://jgraph.github.io/mxgraph/docs/tutorial.html
    
    

    也许还可以简化,但是我懒得简化了,这样反正能用,效果也还挺不错的。

    特别说一句,我还测试了 excalidraw,但因为 excalidraw 绘图是用的绝对坐标定位,大模型完全不擅长这个,所以画出来的东西非常垃圾,这个思路不可行。drawio 如果仔细看他的绘制方式,都是使用的 id 引用,所以流程都是相对的,很适合大模型输出。

    要是哪个客户端能够直接嵌入个 drawio 展示就好了。

    🔲 ⭐

    离线临时密码的生成

    前言

    最近看到朋友家装了新的只能门锁, 可以使用指纹或密码解锁. 密码解锁没什么, 但其在手机端可以生成一个临时密码倒是引起了我的兴趣. 这个场景是这样的:

    1. 密码锁不能联网
    2. 生成密码时, APP与密码锁没有通讯(不在一起, 且密码锁不能联网)
    3. APP 和密码锁仅在首次配对的时候进行过通讯
    4. 临时密码有效期为30分钟, 从密码的生成时间开始计算
    5. 手机端可以多次生成临时密码, 每个都不一样且有效

    那么问题来了, 密码锁是如何验证密码是否有效的呢?

    实现方案思考

    方案一: 预生成

    思路:

    1. APP与密码锁在首次配对的时候, 预先生成1万个临时密码
    2. 每次获取临时密码时, 从预生成列表中获取下一个即可

    问题:

    1. 密码无法与时间绑定, 无法实现"临时"的需求
    2. 密码用尽后需要重新配对

    方案二: TOTP

    思路:

    1. 基于动态密码 TOTP 方案实现
    2. 在配对的时候同步共享密码与计数器
    3. 计数器每30分钟+1

    问题:

    1. TOTP为固定时间窗口, 无法实现动态窗口
    2. 计数器每30分钟+1, 无法多次生成临时密码

    方案三: 增强型 TOTP

    到这里其实已经有方案了, TOTP是固定窗口, 那多个固定窗口连在一起, 不就是个滑动窗口么.

    思路:

    1. 仍然基于 TOTP 的方案, 在配对时同步共享密钥和计数器
    2. 计数器每5秒+1
    3. 密码验证时, 验证当前时间向前推30分钟的所有 TOTP密码, 若与其中一个匹配即为可用

    若计数器每5秒+1, 则30分钟可产生的密码数量为: (30min / 5s)=360

    这个方案是我想到能够满足此场景的. 优势:

    1. 双方无需存储密码队列, 无存储开销
    2. 能够把握30分钟的滑动窗口, 误差为5秒
    3. 每5秒可生成一个新的动态密码, 支持重复生成

    潜在问题:

    1. 时间同步: 密码锁长时间无对外通讯, 时间同步可能产生误差. 可在每次通讯时调整, 或适当放宽验证的时间范围以平衡时钟误差
    2. 计算资源: 每次密码验证需要进行360次 TOTP 计算. 可考虑在密码锁中维护一个密码队列, 每5秒生成密码添加到队列中, 并将旧密码出队列. 这样验证密码的时候就不需要频繁计算了

    以上, 就是基于这个场景进行的设想, 其具体是如何实现的俺就不晓得了.

    🔲 ⭐

    产品职业随想

    随着干产品这个职业的深入,发现这个职业对于聪明要求的上线真是无限高,最近这段时间的几个感触记录一下:

    1. 产品经理最好是有一定的增长能力,把增长能力转化为设计的功能点做到产品里,比如了解当前的主流叙事和“流量密码”,把这些东西快速的融入到产品中,方便增长。这一点大部分的老板都比较敏感,通常有“流量密码”出来的时候老板总是第一时间希望能够在产品中添加,以蹭到这波流量,但老板的产品设计能力较弱,通常容易出现非常突兀的融合。
    2. 能把握到科技的发展,尤其是在现在 AI 发展速度爆炸的现在,仅仅设计一款利用到现有科技力量的产品是远远不够的,要能设计出随着领先与科技发展的产品,随着科技发展而得到提高。最火的例子就是 Cursor 了,模型越来越强,让 Cursor 从不好用变成了神器。
    3. 要有一定的非共识,有非共识的产品才能脱颖而出,否则就是靠资源堆砌的普通产品,随时可以被以更多资源堆砌的另一个产品替代。
    4. 接着上面的非共识,我举个我自己领悟到的:免费用户(或者说弱用户)应该被作为产品的资源和能力,来融入产品的设计。著名的设计比如网游的氪金机制、排行榜机制等。

    🔲 ☆

    工具-/usr/libexec/java_home

    我的电脑上安装了几个 JDK 来应对不同项目的开发,经常要进行切换,突然发现 macos 上有这么一个工具:

    /usr/libexec/java_home

    这个工具能够快速查看电脑里安装的所有 JDK:

    /usr/libexec/java_home -V

    输出大概为:

    Matching Java Virtual Machines (3):
        21.0.2 (arm64) "GraalVM Community" - "GraalVM CE 21.0.2+13.1" /Library/Java/JavaVirtualMachines/jdk-11.0.5.jdk/Contents/Home
        17.0.13 (arm64) "Amazon.com Inc." - "Amazon Corretto 17" /Library/Java/JavaVirtualMachines/jdk-11.0.5.jdk/Contents/Home
        11.0.5 (x86_64) "Oracle Corporation" - "Java SE 11.0.5" /Library/Java/JavaVirtualMachines/jdk-11.0.5.jdk/Contents/Home
        1.8.0_231 (x86_64) "Oracle Corporation" - "Java SE 8" /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home

    这样能够直观的看到安装的多个版本,然后用下面的命令切换:

    export JAVA_HOME=$(/usr/libexec/java_home -v 11)

    🔲 ⭐

    Weekly-08~17

    Basecamp 还有一本叫 Shape Up 的书,读了前两章就觉得受益匪浅,Basecamp 不愧是我一直向往的公司。
    🔲 ☆

    为什么不建议使用goto

    前提

    最近在公司代码review过程中, 看到同事的代码中大量使用了goto, 我给出了"不用 goto"的建议. 但其给出的理由是使用goto更简单. 确实, 使用goto可以使得逻辑更简单直接, 但前提是不乱用goto, 而在公司的项目中又很难保证这一点.

    问题

    使用goto带来的最直观的问题就是逻辑的复杂度直线升高. 举个例子来展现goto是如何一步步导致逻辑破败不堪的. (当然, 这个例子是我臆想出来的场景)

    首先, 我们有一个创建订单并验证支付的需求:

    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("处理订单开始")
    
        fmt.Println("Step 1: 创建订单")
    
        fmt.Println("Step 2: 验证订单")
    
        fmt.Println("Step 3: 验证付款信息")
    
        fmt.Println("Step 4: 订单完成")
    
        fmt.Println("处理订单结束")
    }

    此时逻辑很清楚吧. 现在, 我们要对验证订单的结果进行处理, 如果验证失败, 则进行错误处理, 很合理吧:

    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("处理订单开始")
    
        fmt.Println("Step 1: 创建订单")
    
        var validErr error
        fmt.Println("Step 2: 验证订单")
        if validErr != nil {
            goto Fail
        }
    
        fmt.Println("Step 3: 验证付款信息")
    
        fmt.Println("Step 4: 订单完成")
        goto End
    Fail:
        fmt.Println("验证付款失败")
    End:
        fmt.Println("处理订单结束")
    }
    

    现在, 新的需求来了:

    1. 付款信息处理可能因各种原因失败, 需要重试, 最多重试3次
    2. 验证订单也可能失败(异步接口验证, 网络抖动等), 需要重试, 最多重试3次
    3. 若订单验证失败, 需要提示并重新创建订单
    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("处理订单开始")
    
    CreatOrder:
        fmt.Println("Step 1: 创建订单")
    
        validRetryNum := 0
    ValidOrder:
        var validErr error
        fmt.Println("Step 2: 验证订单")
        if validErr != nil {
            validRetryNum++
            if validRetryNum <= 3 {
                goto ValidOrder
            }
            fmt.Println("订单验证失败")
            goto CreatOrder
        }
    
        checkRetryNum := 0
    CheckOrder:
        var checkErr error
        fmt.Println("Step 3: 验证付款信息")
        if checkErr != nil {
            checkRetryNum++
            if checkRetryNum <= 3 {
                goto CheckOrder
            }
            goto CheckErr
        }
    
        fmt.Println("Step 4: 订单完成")
        goto End
    
    CheckErr:
        fmt.Println("付款信息验证失败")
    End:
        fmt.Println("处理订单结束")
    }
    

    再来:

    1. 验证付款信息失败, 可能是因为没有付款等, 需要进行付款处理的逻辑
    2. 验证订单付款时, 订单可能认为取消支付, 需要处理资源清理等
    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("处理订单开始")
    
    CreatOrder:
        fmt.Println("Step 1: 创建订单")
    
        validRetryNum := 0
    ValidOrder:
        var validErr error
        fmt.Println("Step 2: 验证订单")
        if validErr != nil {
            validRetryNum++
            if validRetryNum <= 3 {
                goto ValidOrder
            }
            fmt.Println("订单验证失败")
            goto CreatOrder
        }
    
        checkRetryNum := 0
    CheckOrder:
        var checkErr error
        var paid, cancel bool
        fmt.Println("Step 3: 验证付款信息")
        if checkErr != nil {
            checkRetryNum++
            if checkRetryNum <= 3 {
                goto CheckOrder
            }
            goto CheckErr
        }
        if cancel {
            goto CancelOrder
        }
        if !paid {
            goto ProcessOrder
        }
    
        fmt.Println("Step 4: 订单完成")
        goto End
    
    CheckErr:
        fmt.Println("付款信息验证失败")
        goto End
    
    CancelOrder:
        fmt.Println("取消订单支付")
        goto End
    
    ProcessOrder:
        fmt.Println("处理付款信息")
        goto CheckOrder
    
    End:
        fmt.Println("处理订单结束")
    }

    再来

    1. 增加处理失败的日志记录
    2. 增加订单验证失败的日志记录
    3. 不管是取消订单支付, 还是付款处理失败, 都需要进行一些清理工作
    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("处理订单开始")
    
    CreatOrder:
        fmt.Println("Step 1: 创建订单")
    
        validRetryNum := 0
        checkRetryNum := 0
        var checkErr error
        var validErr error
        var paid, cancel bool
    ValidOrder:
        fmt.Println("Step 2: 验证订单")
        if validErr != nil {
            validRetryNum++
            if validRetryNum <= 3 {
                goto ValidOrderErrorLog
            }
            fmt.Println("订单验证失败")
            goto CreatOrder
        }
    
    CheckOrder:
        fmt.Println("Step 3: 验证付款信息")
        if checkErr != nil {
            checkRetryNum++
            if checkRetryNum <= 3 {
                goto CheckOrderErrorLog
            }
            goto CheckErr
        }
        if cancel {
            goto CancelOrder
        }
        if !paid {
            goto ProcessOrder
        }
    
        fmt.Println("Step 4: 订单完成")
        goto End
    
    ValidOrderErrorLog:
        fmt.Println("记录订单验证失败")
        goto ValidOrder
    
    CheckOrderErrorLog:
        fmt.Println("记录付款验证失败")
        goto CheckOrder
    
    CheckErr:
        fmt.Println("付款信息验证失败")
        goto CleanOrder
    
    CancelOrder:
        fmt.Println("取消订单支付")
        goto CleanOrder
    
    ProcessOrder:
        fmt.Println("处理付款信息")
        goto CheckOrder
    
    CleanOrder:
        fmt.Println("订单关闭的清理工作")
        goto End
    
    End:
        fmt.Println("处理订单结束")
    }

    现在, 如果你还觉得逻辑清晰, 那我只能说一句"牛".

    代码演进到现在, 逻辑已经十分混乱了, 逻辑的混乱会导致一系列问题:

    1. 难以理解, 逐步增加后续迭代的成本
    2. 造成额外的心智负担
    3. 追踪困难
    4. 如果是if for 在逻辑上是自上而下的, 但引入 goto会导致逻辑上下横跳
    5. 等等

    可能有人会觉得我举的例子有些极端, 实际中没有人会这么做. 那是因为例子总是简单化的, 现实中的场景实际上要更加复杂:

    1. 大段逻辑分散: 例子中的所有单条print语句, 在实际项目中都可能会对应一大段的逻辑
    2. 最小改动原则: 对现有项目进行改动的时候(尤其是需求要的比较急, 要求改动最小实现功能), 对现有代码的改动越小, 则风险越小. 因此逻辑会越堆越难以理解, 直至最后无法使用
    3. 每个人的水平不同: 同一份代码会由团队中的不同人在不同时间维护, 即使你自信自己的水平, 也无法保证代码在未来不会向着这个方向发展

    使用场景

    当然, 我也不是把goto一棒子打死, 同事在反驳我的时候也给出了强有力的理由"Go 标准库也存在大量使用goto的地方". 比如:

    func ParseMAC(s string) (hw HardwareAddr, err error) {
        if len(s) < 14 {
            goto error
        }
    
        if s[2] == ':' || s[2] == '-' {
            if (len(s)+1)%3 != 0 {
                goto error
            }
            n := (len(s) + 1) / 3
            if n != 6 && n != 8 && n != 20 {
                goto error
            }
            // ...
        } else if s[4] == '.' {
            if (len(s)+1)%5 != 0 {
                goto error
            }
            n := 2 * (len(s) + 1) / 5
            if n != 6 && n != 8 && n != 20 {
                goto error
            }
            // ...
        } else {
            goto error
        }
        return hw, nil
    
    error:
        return nil, &AddrError{Err: "invalid MAC address", Addr: s}
    }

    这种其实是可以接受的, 能够简化流程, 但是, 但是, 不要忘记我不建议使用goto最重要的一点:

    1. 你无法保证自己拥有掌控goto的实力
    2. 即使你自信, 也无法保证同事有掌控goto的实力

    一旦在使用过程中产生破窗效应, 使用goto破坏的速度一定是比不用要快的多. 代码很快就会脱离掌控.

    因此, 为了避免这种情况, 最好的方式就是在最开始杜绝掉.

    最后的最后, goto如果能够好好用的话, 确实能够带来一定的便利性, 前提是项目由你一人开发, 或你拥有掌控权可以拒绝某些腐败代码进入代码库.

    goto并不可怕, 可怕的是不加限制的乱用goto.


    欢迎来辩…

    🔲 ⭐

    Weekly-06

    工作

    国庆假期是真的在放松,很少工作,最多也就是巡检下客户的服务器,预研下之后要用到的自动测试框架之类的。

    学习

    为了设计Tea的插件系统,我研究了很多带有插件能力的程序的实现方式,其中我比较中意的是Obsidian的设计方式。
    关于插件系统,我有几个苦恼的点,第一是如何提供原生能力给插件,毕竟我不想插件也编写Rust,Obsidian是通过在前端项目中提供很多API来定义该插件的能力,并且提供了一些生命周期函数来让插件定义行为,而且好在我用的tauri本来就会开放一些基础API给前端,少了我很多工作。
    第二个苦恼是,灵活性和安全性的问题。插件很难像我开发Tea的本体一样做到那么多能力,而且那样也失去了插件系统本身的意义,我希望在我提供了大模型交互能力和存储能力的基础上,再来通过界面提供大模型驱动的一些应用功能,但在我实际设计这个插件系统的时候,我自己设想的应用在我设计的框架上都举步维艰,所以很明显的是我设计的并不够灵活,这还得从前辈们的设计里取取经。安全性就更难保证了,我参考了figma的插件设计的文章,他们为了安全花费了大量的精力,并且我觉得他们牺牲了开发的便利性,和我制作Tea插件系统的初衷背道而驰,我希望以后AI能便利的按照需求来为用户创建插件,太过复杂的插件系统很难和现在的AI能力匹配,我决定用最偷懒的方法来解决安全问题,我只内置我审核过并且开源的插件(Obsidian好像也是这么做的)。
    第三个苦恼的是技术方面,我希望插件打包后不要太大,如果每个插件都包含一套React相关的库,势必是会浪费很多存储空间,加载也会变慢,这个目前还是没有解决,插件只能自带UI库,不过也带来了一个好处,就算是用Vue也能加载进来了。。。
    还有更多的苦恼,不过解决苦恼也是会带来爽感的,可能这就是为啥我一直喜欢编程的原因吧。

    在日常用Tea的时候,又发现了一个很爽的地方:我想 给terminal设置环境变量之后自动切换到obsidian目录然后git push到github 这个操作加个别名然后一键完成(因为github时不时被墙),我不知道如何在powershell里编写这个命令,于是我用Tea来询问,并且让他编写一个命令能够运行后自动添加别名并且持久化,接下来我就只要利用Tea的代码运行能力直接点一下运行,这个别名就加好了。

    生活

    这个假期过得非常放松,和朋友开黑玩《三角洲行动》,想想之前开黑玩游戏,好像是几年前玩绝地求生了。
    还带娃去逛了逛公园,七天中唯一一天离开电脑,结果客户那边就出问题找过来了,好在同事帮忙解决完了。

    🔲 ⭐

    Weekly-05

    工作

    本周大部分还是干的产品的活,不过现在相对于之前来说已经得心应手了,而且AI大大的增强了我作为产品经理的能力,平时使用AI的时候会把需求一步步分解然后描述清楚,让我现在写PRD文档的时候如鱼得水。
    并且还碰到了之前没怎么遇见过的两个问题:时间回溯导致定时任务和雪花算法都报错的问题、Clickhouse在某些查询条件下会内存爆掉无法查询的问题。目前还没找到原因,只能先加强巡检避免问题产生影响。之后解决了问题会单独记录下解决过程。
    本周的工作还有一个重要的事情,我将开发环境从IDEA切换到了VS Code,IDEA的AI插件太难用了,我怀疑Copilot团队就是故意不做好让用户转向VS Code,至于不转到Cursor和Zed的原因是,Cursor的能力在我的评估体系里不如VS Code+Aider,而Zed也非常不适合Java这种体量的项目。目前转到VS Code还不是正式转过去,我需要先用VS Code工作一个月才能说能否正式抛弃IDEA。

    学习

    过于忙了没有产出多少的BIL文章,其实学习了不少东西的,十一假期的时候一一给这些文章补上。像是Cloudflare的产品更新、SQLite的“Many Small Queries”性能、一些特定场景下AI的提示词等等,这些属于文章不短且内容较深,我写文章的时候应该对这些东西的理解还会加深一个层次,我自己也很期待来写这些文章。
    Tea的更新进度我个人还比较满意,把Bang的交互调整了下,对于补全的交互能达到我自己还相对满意的程度了,就是前端调试补全面板位置的时候废了我老劲了,之前有一部分代码是AI写的,所以不熟悉结构和css的情况下来调整绝对定位让难度提高了一个数量级。

    生活

    娃去了动物园,我没去,其实还是很想看到娃看见各种动物的反应的。还是得努力让财富自由啊,这样就能人身自由了。
    最近股市疯了,可能这就是所谓的“调动市场预期”吧,大家突然都觉得大A能赚钱所以都开始买了,反正我亏的基金回本有望了,噢耶。

    🔲 ⭐

    Cursor Composer、Zed Workflow、Aider、Copilot Workspace对比

    本文只是对四款自动编码的产品做一个横向比较,单独的产品使用评测可以看看博客里单独评测的文章。最后有AI生成的对比表,省流的可以看每个对比的结果和最后的表。
    并且该横向评测的结果也只是我自己日常使用的主观感受,每个人的场景不同自然会有不同的体验。(叠甲

    选手简介

    • Cursor Composer:Cursor Composer是一款集成在Cursor编辑器中的AI自动化编程工具。它能够根据用户的需求描述,自动分析项目代码,并生成相应的代码修改方案。
    Cursor Composer
    • Zed Workflow:Zed Workflow是Zed编辑器的一个功能,允许用户通过提示来指导AI进行代码修改。与其说它像Cursor Composer,不如说是Github Copilot Chat的增强版。
    Zed Workflow
    • Aider:Aider是一个命令行工具,可以与多种AI模型配合使用。
    Aider
    • Copilot Workspace:Copilot Workspace是GitHub的扩展功能,目前只能通过Github Next提供的Web界面使用。它可以分析GitHub Repository,然后根据用户输入的需求生成解决方案,并直接修改代码。
    Copilot Workspace

    用于评测的项目是我编写的一个开源大模型客户端:https://github.com/xieisabug/tea

    对比维度

    我会针对以下几个维度来进行体验对比:

    • 工程能力
    • 代码质量
    • 用户体验
    • 价格与性价比

    详细对比

    工程能力

    • Cursor Composer:能根据需求自动识别出需要修改的文件、能够多轮次的迭代修改、能够选择是否应用单个修改,实际体验下来识别需要修改的文件不是非常的准确,并且使用的过程中还碰到了找不到历史记录或者修改文件失败之类的小bug。
    • Zed Workflow:工程能力很弱,需要手动提供上下文文件给AI,修改也需要自己去一个个点Transform按钮,而且不知道是不是因为格式过于复杂,Deepseek有时候会无法遵循格式导致这个Workflow没法用。
    • Aider:同样能根据需求自动识别出需要修改的文件、能够多轮次的迭代修改,修改会直接应用到文件上,通过git的能力来查看本次修改和回退修改。特殊的是aider具备出色的自检和自纠错能力,能自动运行测试用例并确保通过。Aider使用Deepseek也偶尔会出现没有遵循格式修改,但是Aider会自动让Deepseek重试。
    • Copilot Workspace:同样能根据需求自动识别出需要修改的文件、能够多轮次的迭代修改,能在网页上用diff格式展示出所有的修改,特殊的是它在输入需求之后提供分步骤的需求分析、代码分析、代码修改计划,有助于处理复杂任务理清思路,能够直接提交到Github Repository,也能通过最新的一个VS Code插件来同步修改到本地运行测试。

    Aider > Copilot Workspace = Cursor Composer >> Zed Workflow

    代码质量

    本来不应该在评测这种大模型工具的场景下,评测代码质量,因为代码都是模型写的并不是这些工具提供的能力。但在我使用的过程中,由于Prompt、工具限制等各种情况导致代码的质量还真有一些不同。

    • Cursor Composer:能用GPT和Claude之类的SOTA模型,也可以用自己的key,但是要转换为Cursor“认证”过的模型,也就是把模型名字要改成gpt4o之类的,我就是把deepseek用worker覆写成gpt4o来使用的。
    • Zed Workflow:纯纯的就是靠模型能力了。
    • Aider:得益于其强大的自我纠错和测试运行能力,代码质量最佳。支持几乎所有的模型,并且有一套自己的榜单Aider leaderboard和测试集,有钱的大哥可以体验体验用o1 preview自动写代码的快感。
    • Copilot Workspace:可惜只能用GPT(居然变成了减分项),有Copilot写代码的通病,喜欢吞括号和分号。现在可以申请o1系列的模型了,我还在waitlist里等着。

    Aider = Cursor Composer > Zed Workflow > Copilot Workspace

    用户体验

    • Cursor Composer:集成在编辑器中,使用便捷直观,和聊天是用的同一套UI体系,一方面来说比较稳没有什么学习成本,一方面来说又太稳了不突破很难有惊艳的效果。也许就是因为Cursor的这个稳让新入场的开发者都能非常快速的感受到它的能力。
    • Zed Workflow:学习曲线也低,毕竟没啥东西。
    • Aider:对于熟悉命令行的开发者体验还是不错的,与终端的结合度高,可能是因为不需要操心界面的原因,功能的完成度也非常高,基本上该有的命令该有的提示该有的检查全部都做到了,并且减少了对鼠标的依赖。但是学习曲线较陡峭,需要一定时间来“学习”使用这些功能,会用和不会用简直就是两种体验。
    • Copilot Workspace:也是直观的界面且易于使用,但是其功能是独立于IDE的,所以使用的时候总是要切换来切换去。允许用户调整AI生成的分析和计划,可调整的空间很大。没什么学习的东西,基本上也就是一个输入框用来提需求就完事了。

    Cursor Composer > Copilot Workspace > Aider > Zed Workflow

    价格与性价比

    • Cursor Composer:必须开通Cursor Pro才可以使用这个能力,然后只有500次高级模型的使用权限,所以500次之后就得用自己的key来使用Composer功能。价格是20刀,不过20刀里还有Cursor的补全服务,这个补全服务我觉得比Composer值钱。不过我觉得20刀换500次高级模型使用+补全,还是不太值 (经过网友指正,不是500次高级模型,是500次快速的高级模型,之后应该还是会有可排队的高级模型使用),20刀买api能用很久了,尤其是带有缓存命中的Claude和Deepseek(Deepseek 20刀能用一年吧,能不能提高点价格然后加加速啊)。
    • Zed Workflow:有自己的Zed AI服务,但是暂时没收费,也可以用Copilot提供的GPT4o,这点还挺好,目前推荐用这俩,因为Zed的机制导致它使用的时候会消耗很多Token,如果没有缓存命中机制的提供商用起来会破产,除了Zed AI和Copilot之外最好就是用Deepseek了。
    • Aider:工具本身免费开源,需要付费的是大模型的API。同样,使用缓存命中机制的提供商会便宜很多。
    • Copilot Workspace:价格性价比优秀,尤其对于已经使用GitHub和Copilot的开发者。目前还不用额外订阅,暂时只需要Copilot的10刀。

    Copilot Workspace > Aider = Zed Workflow > Cursor Composer

    总结

    • 对于需要强大工程能力和高质量代码输出的开发者,Aider 是一个很好的选择,Aider是一个长板很明显短板也很明显的工具。
    • 暂时还不推荐Zed Workflow,就算是结合Zed AI的各种能力和插件来使用,也达不到很好的效果,可以再让它发展一段时间。
    • Copilot Workspace 对于目前拥有Copilot,并且维护了开源项目的开发者,非常友好,暂时没有额外的收费并且用户体验做的很好。
    • 对于那些希望在熟悉的VS Code环境中获得自动编码辅助的开发者,Cursor Composer 是一个不错的选择,并且因为其高结合度,操作非常便捷。
    • 目前这类的产品我最终还是找到了使用场景,最初我认为:复杂的任务无法完成、简单的任务用它完成徒增复杂度,现在我认为:复杂的任务拆解成简单的(这是我的能力)、经过多次与AI配合熟练后简单的任务让AI来做,我和AI配合起来还是很节省时间的

    对比表

    同样,最后让AI出一个表格

    特性Cursor ComposerZed WorkflowAiderCopilot Workspace
    工程能力⭐⭐⭐⭐☆⭐☆☆☆☆⭐⭐⭐⭐⭐⭐⭐⭐⭐☆
    代码质量⭐⭐⭐⭐⭐⭐⭐⭐⭐☆⭐⭐⭐⭐⭐⭐⭐⭐☆☆
    用户体验⭐⭐⭐⭐☆⭐⭐☆☆☆⭐⭐⭐☆☆⭐⭐⭐⭐⭐
    价格性价比⭐⭐⭐☆☆⭐⭐⭐⭐☆⭐⭐⭐⭐☆⭐⭐⭐⭐☆
    自动文件识别⭐⭐⭐⭐☆⭐⭐☆☆☆⭐⭐⭐⭐☆⭐⭐⭐⭐⭐
    复杂任务处理⭐⭐⭐⭐☆⭐⭐⭐☆☆⭐⭐⭐⭐☆⭐⭐⭐⭐⭐
    自我纠错能力⭐⭐⭐☆☆⭐⭐⭐☆☆⭐⭐⭐⭐⭐⭐⭐⭐☆☆
    模型选择灵活性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐☆☆☆☆
    版本控制集成⭐⭐⭐☆☆⭐⭐⭐☆☆⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
    学习曲线⭐⭐⭐⭐⭐⭐⭐⭐⭐☆⭐⭐☆☆☆⭐⭐⭐⭐☆

    注:⭐⭐⭐⭐⭐ 表示最佳,⭐☆☆☆☆ 表示最差

    🔲 ☆

    工具-Zed Workflow

    这次测试的是Zed的Workflow,原理其实和Cursor Composer差不多,也就是让大模型对需要修改的文件,按照格式来进行输出,编辑器再解析该格式。不过Cursor对自动化的交互做的好很多,不仅自动获取了需要修改的文件,还自动触发了修改动作,Zed的Workflow就是缺少了这两步。先看我的使用流程:
    我的使用如下图:

    Prompt如下:

    我希望bang-list这个提示是展示在我输入框的下方,并且能够跟随我的光标位置移动的。
    而且当bang-list出现的时候,我是可以上下进行bang选择的,选择完成之后,bang会自动填充到我的输入框中,并且bang-list会消失。
    我输入的!删除的时候,或者输入的!后面不匹配任何bang,bang-list也会消失。

    可以看到Zed AI是需要你自己去指定文件的,比Copilot和Cursor都要差一些。
    AI的回复如下,会分步骤将实现需求所需要修改的代码展示出来,并且有Transform操作直接将修改映射到对应的代码处。但实测该能力对大模型要求比较高,deepseek能够胜任代码编写工作但是无法保证格式每次都正确,4o和Sonnet 3.5是没有问题的。

    点击Transform之后就像下图一样,会在对应文件修改代码并且展示diff view。

    如下图是我使用Deepseek(忽略右上角的gpt4o,那是我覆写的model name)进行Transform出现的错误,多了个{{REWRITTEN_CODE}}。

    评价

    工程能力部分

    工程能力很弱,定位和Zed本身的情况差不多,是一个简单但是快速的工具。基本上是对某些特定的格式进行了界面渲染,提供了快捷的按钮来触发修改,其他的和别的自动编码工具几乎没有可比性。
    对于我个人来说,我使用工具是想快速解决问题,当然是用起来越简单越好,本身这个工具对我来说不具有“把玩”的属性。

    代码完成部分

    Zed的Workflow能使用任何模型,但是像我上面说的那样,模型必须有比较高的能力才能遵循格式,我使用的Deepseek在代码很多的时候,会出现不够遵循格式导致Workflow无法使用的情况。一方面是Deepseek模型的能力问题,另外Workflow要求的格式可能过于复杂了,有各种xml标签和代码块混合。

    UI UX部分

    和普通的Zed AI使用方式几乎一样,也就是多了一个Transform的按钮来自动修改代码,不能说它没做交互,只能说和自己的编辑器一脉相承的简洁。

    总结

    可用性差强人意,在特殊的场景下可能有用武之地,不过我应该还是会选择不使用这个Workflow。

    🔲 ☆

    工具-Aider

    写在最前:我个人更偏爱实用主义,对于界面啥的要求很低(绝对不是给我自己界面丑开脱!),所以Aider没有界面在我看来绝对不是减分项,但是肯定会有人觉得这是很重要的事情,所以如果介意这一点的可以在心里先给这个项目减分。

    用Aider的需求是紧接着之前新增的bang命令,我准备在前端界面上进行一点预览,比如!current_date这个bang应该在前端界面上能预览到日期,并且每分钟都检查是否到了新的日期等等;获取选中文本需要和Rust配合,在Rust获取到文本之后再想办法传递到前端展示。
    如何安装可以直接去官网查下,我不介绍了,先介绍下我是如何使用的,首先先搞 .aider.conf.yml ,持久化配置,不然每次启动Aider都要搞很多命令,我改的一般是下面几个配置:

    # 不进行自动提交
    auto-commits: false
    # 用deepseek模型提供商
    deepseek: true
    # 使用read-only这个文件
    read: [CONVENTIONS.md]

    这次的测试是用的Deepseek来进行的,因为价格更便宜而且Rust方面Deepseek写的更好,配置完key就可以直接运行Aider:

    export DEEPSEEK_API_KEY=<your key>
    aider

    接下来就是提需求了,使用了这么多自动写代码的产品之后,我目前摸索出了一些心得,那就是自己要把需求搞清楚之后拆分成简单的小任务,我的最终效果是这样,几个特殊的bang命令在界面上带有动态的预览:

    Aider虽然是支持自动找到需要修改的文件,和Composer、Copilot一样,不过最好还是自己添加下文件更加准确。我的第一个任务直接给的是界面相关的,并且手动添加了相关的前端文件:

    我希望给bang功能的 !s !cd !ct 增加预览,展示对应的内容,其中 !s 是由props传递进来  

    Deepseek没有正确理解这几个bang的意思,只是加了预览,但是没有实时更新日期和时间的预览值,于是我继续提修改要求(使用Sonnet3.5估计就没这种问题了):

    `!cd` 和 `!ct` 的值不用传递进来,直接在这个界面初始化,并且让currentTime应该每秒更新一次,currentDate每分钟更新一次

    这次就直接修改成我想要的样子了,然后继续修改样式,让界面美观:

    添加 `preview` class的样式,使这个预览的文字看起来更有预览的感觉,和实际的命令描述文本区分开 ,`!s`的preview如果发现selectedText没有内容,则不展示对应的preview部分,并且给preview设置最大宽度100px,超过的文本用省略展示

    期间Deepseek没有遵循Aider的指令格式,Aider会自动让Deepseek重新编写一次,然后就正确了,这个体验不错,不需要我去干预。
    接下来要修改Rust:

    udpate system_api.rs:新增一个tauri command,返回当前context里面的selected_text  

    后续还有一些聊天我就不赘述了,总共从启动Aider到测试完commit,大概花了30分钟不到,如果是我自己来写,估计时间差不多,但是这30分钟我除了提需求和测试之外,大概有一半时间我是在干别的事情,也就是说我自己花的时间大概是10-15分钟,体验还是不错的。

    附:Aider会自动寻找需要加到上下文的文件,示例如下

    我想要在输入bang的时候,比如!出现banglist提示面板之后,我还可以继续输入用于过滤bang command,直到输入的值无法匹配任何bang command才让banglist面板消失

    评价

    工程能力部分

    Aider是我目前使用到的工程最强的自动编码产品。
    强大的自检能力(升级、检索可用的key、确保项目有git等等)、自纠错能力(代码运行或者测试报错了会自动询问你是否要修复,也可以使用–yes让Aider永远自动运行不需要确认)、运行命令和读取错误能力(能够在写完代码之后自动运行测试用例,并且确保测试用例全部通过)、与terminal的结合完成度等,都让我觉得用着很舒心,不像是那种摇摇欲坠的半成品,写出来的代码因为纠错能力和运行命令来跑测试用例,所以相同模型下完成需求的能力比别的产品都要高,这属于对模型能力的增强而不是利用了。
    如果对没有界面而介意的同学可以试试看浏览器模式,不过我个人对他们和终端的结合程度已经非常满足了,单独为Aider开一个终端之后,几乎就相当于在IDE里集成了一个界面,而且也更少的使用到鼠标,让编码过程(不过好像也没多少编码过程了)更加的集中注意力。
    如果有心仪的IDE,比如VSCode或者JB家的,但是又没有心仪的自动编码产品,可以试试看Aider。

    代码完成部分

    Aider可以使用任何模型,而且对新提供商和模型的支持还挺快的。
    我使用的Deepseek,是Aider leaderboard上最便宜但是效果又在前列的模型(缺点嘛就是慢)。有钱或者有低价渠道的可以试试sonnet3.5和4o甚至是o1 preview,效果应该是比Deepseek要更好的。Aider在编写完代码之后会直接应用到代码文件中,这也是为什么Aider一定要你使用git的原因,不然你不知道它改了哪里(而且Aider默认是修改完代码就commit的,如果介意要配置下不要自动提交)。

    UI UX部分

    见仁见智,我个人还对这个交互比较满意,并且Aider也提供了一些配置来设置终端中的展示效果。
    不过有珠玉在前,和Cursor比肯定是不足的,能与原生IDE结合会让交互好很多,看Aider官方的意思是准备让社区发力,自己不会下场做。目前社区也做了点插件,我就没体验了。

    总结

    Aider是我心目中那种“水涨船高”的项目,随着大模型能力的提高,Aider能做到的东西也越来越多,大模型不管是智力、上下文、速度或者别的方面哪一项的提升,对于Aider本身来说都是增强。期待社区以后发力做出更好的编辑器插件吧。我的这个开源项目说不定也会以某种方式接入Aider。

    ❌