宇宙纪元

hinder110 的思考、读书与代码札记。

0%

背景

yueduqi-desktop 是一个用 Go + Wails v2 构建的桌面阅读器。后端解析多个书源的网页/API 返回搜索结果和章节内容,前端用 React + TypeScript 渲染界面。

优化前的状态:能跑,但有很多”差不多就行”的妥协——

  • HTTP Client 没有连接池复用
  • 零缓存,每次请求都打到上游
  • 日志路径硬编码 /tmp,无级别控制
  • 书架和阅读进度全是 stub(返回假数据)
  • Parser 注册靠 switch-case,加新书源要改核心文件
  • 错误处理有一堆 _ 吞错的写法

优化内容

1. 缓存层:cache/cache.go

用 Go 泛型实现了一个带 TTL 的内存缓存:

1
2
3
4
5
6
7
8
type Cache[T any] struct {
mu sync.RWMutex
entries map[string]Entry[T]
ttl time.Duration
hits atomic.Int64
misses atomic.Int64
disabled atomic.Bool
}

热榜(5min TTL)、搜索(3min TTL)、章节列表(10min TTL)各自独立实例。支持运行时开关、统计命中率。

2. 持久化存储:storage/storage.go

modernc.org/sqlite(纯 Go,无 CGO)实现了完整的本地数据库:

  • bookshelf 表:书架数据,INSERT OR IGNORE 去重
  • reading_progress 表:阅读进度,ON CONFLICT DO UPDATE upsert
  • settings 表:用户偏好键值存储
  • memStore 兜底:SQLite 不可用时自动切换内存模式

Wails 绑定的 Go 方法(AddToBookshelfGetBookshelfUpdateProgress 等)从前端 api.ts 直接调用,不再走 HTTP 层。

3. 配置系统:config/config.go

gopkg.in/yaml.v3 加载 ~/.config/yueduqi/config.yaml

1
2
3
4
5
6
hosts:
- https://v1.gyks.cf
- https://v2.gyks.cf
timeout: 15
log_level: info
log_path: /home/user/.config/yueduqi/yueduqi.log

文件不存在时用 Default() 返回合理默认值,不报错。

4. 日志:从 log.Loggerlog/slog

1
2
3
4
5
6
// 之前
logger = log.New(f, "[yueduqi] ", log.LstdFlags|log.Lmsgprefix)
logger.Printf("SearchBooks called: keyword=%q", keyword)

// 之后
slog.Info("SearchBooks called", "keyword", keyword, "source", source)

结构化日志,支持 debug/info/warn/error 级别,路径从配置读取。

5. Parser 重构

Registry 模式取代 switch-case:

1
2
3
4
5
6
7
8
9
10
11
// parser.go
var registry = map[string]Parser{}

func Register(name string, p Parser) {
registry[name] = p
}

// guangyu.go
func init() {
Register("guangyu", &GuangyuParser{})
}

类型安全: mapItemmap[string]any)替换为具体 struct:

1
2
3
4
5
type searchBookItem struct {
BookName string `json:"book_name"`
Author string `json:"author"`
// ... 每个字段编译时检查
}

HTTP 连接池:

1
2
3
4
5
6
7
8
var httpClient = &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
MaxConnsPerHost: 5,
},
}

错误处理修复: 所有 req, _ := http.NewRequestWithContext(...) 改为显式处理 error。

广告过滤合并正则: 18 个独立 regexp.MatchString 合并为单个 adRe,从 O(n×m) 降到 O(n)。

6. 前端改动

api.ts 中书架和进度相关的 stub 全部替换为真实调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 之前
export async function addToBookshelf(_book?: any): Promise<any> {
return ok({ message: '已加入书架' });
}

// 之后
export async function addToBookshelf(book?: any): Promise<any> {
const id = await AddToBookshelf({
bookId: book?.bookId || '',
title: book?.title || '',
// ... 真实持久化
});
return ok(id);
}

遇到的问题

问题 1:Workflow 跑错了项目

一开始用 Claude Code 的 Workflow(多 agent 编排)做优化,但 Workflow 里 agent 的文件搜索先命中了 yueduqi-desktop(字母序靠前),而我真正想优化的是 yueduqi-go。整轮 18 个 agent、66 万 token 全打在了 desktop 上。

解决: 发现后确认 desktop 本身也确实需要优化,就顺势做下去了。教训是给 Workflow 指定目标时要明确绝对路径。

问题 2:Workflow 太重

对于 6 个 Go 文件的小项目,Workflow 的 18 个 agent 每个都要加载完整的系统提示和工具定义(~7K tokens/个),还要各自读一遍代码。66 万 token 里大半是重复的上下文传输。如果是自己直接在主线改,2-3 万 token 就够。

解决: 认识到 Workflow 适合 50+ 文件的大规模审查、跨仓库迁移等场景。小项目应该直接动手改。

问题 3:过度优化 → 删光 → 恢复

Workflow 跑完后我一看 token 消耗,觉得太浪费,冲动之下把所有改动回滚、分支删除。冷静下来才意识到——代码本身是好的,编译通过,质量不错。浪费的是 token,不是代码。

解决:git reflog 找回 commit,用 git stash pop 恢复未提交改动,手动重建了被 rm 掉的 config/storage/ 目录。

问题 4:超时打架

Workflow 自动生成的代码里,httpClient.Timeout = 15s 是全局上限,但 contentTimeout = 20s 想给内容请求更宽裕的时间。结果 20s 永远用不到——还没到就被 client 层 15s 截胡了。

解决:contentTimeout 降到 15s,和全局超时保持一致。各操作实际生效超时:搜索 8s,章节列表 15s(全局兜底),内容获取 15s。


最终成果

1
17 files changed, 853 insertions(+), 48 deletions(-)
模块 文件 说明
缓存 cache/cache.go 泛型 TTL 内存缓存,统计命中率
配置 config/config.go YAML 加载,默认值优雅降级
存储 storage/storage.go SQLite + 内存双后端,书架/进度/设置 CRUD
解析 parser/*.go Registry 模式、类型安全、连接池、错误处理
应用 app.go slog 结构化日志、Store 接口
入口 main.go XDG 数据目录、SQLite 初始化
前端 api.ts 书架/进度真实 API 调用

几点收获

  1. Workflow 要对项目规模有判断。 不是什么任务都值得上多 agent 编排。文件数 < 20 的项目,直接改更快更省。

  2. 删代码前先看一眼。 不要被 token 数字吓到——消耗已经发生了,成果是实在的。删掉再恢复只会浪费更多。

  3. 超时配置要全局一致。 多层超时(context + client)容易互相截胡。要么都走 context,要么都走 client,别混用。

  4. 纯 Go SQLite 真香。 modernc.org/sqlite 不需要 CGO,编译出来的二进制照样是单文件。桌面应用持久化的最佳选择。

  5. 先跑通,再优化。 这次改动的 17 个文件全部编译通过、Wails dev 模式正常运行。没有引入回归是最大的胜利。

项目的完整代码在 GitHub 上。

字体的四层结构

Linux 桌面没有「设置 → 字体」一键搞定。字体的配置分布在四个独立的层面,各管各的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌──────────────────────────────────────┐
│ 应用硬指定字体 │
│ 如 WezTerm 写死 JetBrainsMono NF │
│ 最优先,fontconfig 管不了 │
└──────────────────────────────────────┘
↓ 如果应用没说具体字体名
┌──────────────────────────────────────┐
│ 桌面框架字体 (GTK / Qt) │
│ GTK: ~/.config/gtk-3.0/settings.ini │
│ Qt: ~/.config/qt5ct/qt5ct.conf │
└──────────────────────────────────────┘
↓ 请求传到 fontconfig
┌──────────────────────────────────────┐
│ fontconfig — 字体匹配引擎 │
│ ~/.config/fontconfig/fonts.conf │
│ 别名 / 语言映射 / 字体替换 / 渲染设置 │
└──────────────────────────────────────┘
↓ fontconfig 找到字体文件
┌──────────────────────────────────────┐
│ 字体文件本身 │
│ /usr/share/fonts/ │
│ ~/.local/share/fonts/ │
└──────────────────────────────────────┘

理解这四层,就不会问「为什么改了 fontconfig 我的终端字体没变」——因为终端在第 1 层就写死了。


fontconfig 能做什么

fontconfig 是 Linux 字体系统的核心引擎。它不是应用,是一个库——所有 GTK/Qt/浏览器在需要渲染文字时都调用它。

配置文件放在 ~/.config/fontconfig/fonts.conf,XML 格式。主要能干四件事:

1. 设定别名(Fallback 链)

当程序说「我要 sans-serif」时,fontconfig 告诉你应该给哪个字体:

1
2
3
4
5
6
7
<alias>
<family>sans-serif</family>
<prefer>
<family>MiSans</family>
<family>Noto Sans CJK SC</family>
</prefer>
</alias>

2. 按语言分派字形

Linux 装了多个 CJK 字体时,经常把中文的「门」「复」「直」显示成日文的字形。按语言精确分配:

1
2
3
4
5
6
<match target="pattern">
<test name="lang" compare="contains"><string>zh-tw</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>LXGW WenKai Screen</string>
</edit>
</match>

3. 劫持商业字体(最实用的功能)

浏览网页或打开 Windows 文档时,把网页请求的 微软雅黑ArialSimSun 全部拦截,换成自己系统里的高清字体:

1
2
3
4
5
6
<match target="pattern">
<test name="family" qual="any"><string>Microsoft YaHei</string></test>
<edit name="family" mode="assign" binding="strong">
<string>MiSans</string>
</edit>
</match>

这样打开任何中文网站,眼不见为净——再也没有中易宋体的锯齿和微软雅黑的过时骨骼。

4. 渲染设置

1
2
3
4
5
6
7
8
<match target="font">
<edit name="antialias" mode="assign"><bool>true</bool></edit>
<edit name="hinting" mode="assign"><bool>true</bool></edit>
<edit name="hintstyle" mode="assign"><const>hintslight</const></edit>
<edit name="rgba" mode="assign"><const>rgb</const></edit>
<edit name="lcdfilter" mode="assign"><const>lcddefault</const></edit>
<edit name="embeddedbitmap" mode="assign"><bool>false</bool></edit>
</match>
  • hintslight — 轻度微调,适合高分屏
  • rgba=rgb — 标准 LCD 次像素排布
  • embeddedbitmap=false — 禁用点阵字,消灭旧宋体的锯齿毛边

实战:确认配置生效

改完配置后必须刷新缓存:

1
fc-cache -fv

然后用 fc-match 验证每一项:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 默认字体族
fc-match sans-serif # 应该返回你设的首选
fc-match serif
fc-match monospace

# 语言映射
fc-match sans-serif:lang=zh-tw
fc-match sans-serif:lang=ja

# 字体劫持
fc-match "Microsoft YaHei" # 应该被换成你的字体
fc-match "SimSun"
fc-match "Arial"

命令行验证通过,重启应用就能看到效果。


推荐的字体组合

经过多轮对比和实战,这里给出一套适合中文用户的字体组合:

用途 字体 特点
无衬线 UI MiSans 小米开源,字形现代化,屏幕阅读舒适
衬线阅读 LXGW WenKai / 霞鹜文楷 霞鹜基于 Klee One 制作,温润有书写感
等宽 / 终端 Maple Mono NF CN 圆角等宽,带 Nerd Font 图标,中文支持好

安装

1
paru -S otf-misans ttf-lxgw-wenkai-screen maple-mono-nf-cn

配置

完整配置文件我放在了 GitHub: emoeem/fontconfig,核心思路和上面一致——别名 → 语言映射 → 商业字体劫持 → 渲染设置。


常见坑

改了配置不生效

先用 fc-cache -fv 刷新缓存,再 fc-match 验证,最后重启应用。有些应用(如浏览器)会缓存字体信息,彻底关掉再开才行。

Noto CJK 怎么总是抢优先权

Noto 系列字体自带完整的语言元数据,fontconfig 匹配时会给高分。单靠 mode="prepend" 不够——加 binding="strong" 才能压住。

终端字体为什么没变

你的终端模拟器(Kitty/Alacritty/WezTerm)一般在自身配置文件里写了 family = "xxx"——这是硬指定,fontconfig 插不了手。想改终端字体就去改终端的 config。

桌面环境(DMS/KDE/GNOME)的界面字体怎么改

  • GNOME: gsettings set org.gnome.desktop.interface font-name 'xxx 10'
  • GTK 应用: 改 ~/.config/gtk-3.0/settings.ini
  • Qt/Quickshell/DMS: 改其自身的 settings.json

它们都在 fontconfig 之上,先于 fontconfig 决定了请求什么字体族。


写在最后

Linux 的字体配置看起来散乱——四个层面,多种格式——但核心逻辑极其一致:每一层只管自己那一亩三分地,通过 fontconfig 这个统一的匹配引擎连接到实际的字体文件。

理解了这个,就不会迷茫。你知道改了 fontconfig 影响哪里,知道 DMS 的字体设置为什么独立,也知道 WezTerm 为什么不听话。

Linux 不是替你选择,是给你选择。字体是第一个你能完全按自己审美把控的东西。

起点:gamescope 怎么用?

无意间知道了 Gamescope——Valve 为 Steam Deck 做的 Wayland 微合成器。装好了,怎么用?

答案不复杂:

1
gamescope -W 1920 -H 1080 -r 60 -f -- %command%

Steam 游戏属性 → 启动选项,把上面这行粘进去,60 帧全屏跑。想降分辨率提性能就加 -w 1600 -h 900 -F fsr,开 FSR 超分。游戏里 Super+F 切全屏,Super+U 切 FSR 开关。

不复杂。但接下来发生了更有意思的事。

第一问:配置文件在哪里?

如果我想看 gamescope 的配置文件呢?pacman 装的程序,配置放哪了?

两条规则:

  • /etc/ — 系统级默认配置,pacman 装的,所有用户共享
  • ~/.config/ — 用户自己改的配置,程序第一次运行时自动生成,pacman 不碰

查一个包放了什么配置:pacman -Ql <包名> | grep /etc/

第二问:为什么这样设计?

因为 Linux 从一开始就是多用户系统。哪怕现在 PC 就你一个人,系统里还跑着 rootnobodyhttp 等几十个虚拟用户。

如果配置只有一个地方放:装个 vim,服务器上 50 个用户都得等管理员改全局配置才能换主题——疯了。反过来,一个人改了全局 vimrc,其余 49 个人的编辑器突然变了——更疯了。

解决方案:分层覆盖。

层级 谁设 优先级
程序默认值 开发者 最低
/etc/ 全局 root
~/.config/ 个人 你自己 最高

你的个人配置盖过全局,全局盖过默认。每个人只管自己一块,root 管底线。

不是注册表,是纯文本

Windows 用了注册表,一个大二进制数据库。Linux 选了纯文本文件。故意的:

  • 任何编辑器都能改,不需要 regedit
  • git diff 看改了哪,cp 就是备份
  • 换机器复制 ~/.config/ 就恢复
  • 脚本能直接读写配置

代价是每个程序可能用不同格式(INI/YAML/JSON/TOML)。换来的是透明和可控。

/etc//usr//var/~/.local/share/ 分离也是同样的逻辑——可执行文件、静态数据、运行时日志、用户数据各自归位,备份的时候你只要管 /etc/~/

不是 Linux 选了「难用」,而是选了「每个人只管自己一亩三分地」。你是多用户系统里的一个用户,root 不是你,系统服务的虚拟用户也不是你。分层就是为了让这个边界清晰。

第三问:加密是干什么的?

聊到 GPG(打错成 gbg),接着是文件加密。

说实话,我过去多少年也从没手动加解密过文件。最多压缩包设个密码。但仔细一想,我其实一直在用加密,只是它藏在底层:

  • 打开 https:// 网站 → TLS
  • 连 WiFi → WPA2/WPA3
  • SSH 连服务器 → 加密通道
  • Steam 输密码 → 加密传输

软件替你做了。手动加密只在数据要离开你可控边界时才有意义——发给别人、上传云盘、U 盘带走。

然后查了一下自己的硬盘——lsblk 没看到 crypt 层,/etc/crypttab 不存在,dm-crypt 模块没加载。裸奔。但因为我这台机器不带着到处跑、盘上也没什么见不得人的内容,不需要就是不需要。Linux 给你的是选择,不是强制。

顺藤摸瓜的学习

回头看这串问题的路径:

1
2
3
4
5
6
gamescope 怎么用
→ 配置文件在哪
→ 为什么这样设计
→ 加密是什么
→ 我需要加密吗
→ 我的盘加密了吗

这不是按教材目录学的。每一个问题都是上一个自然引出来的。从一条启动参数开始,摸到了 FHS 标准、多用户设计哲学、文本配置 vs 注册表、以及对自身系统的实际验证。

每个知识点都踩在上一步的实际操作上,不是背的,不会忘。

学习不是先有地图再走,是走到哪画到哪。今天画了一小段,但它连着真实的路。

缘起

作为一个双系统用户,Windows 上装了不少游戏,但每次想玩游戏都得重启切换系统,体验实在割裂。最近决定在 Linux(CachyOS)上把 Steam 游戏环境搭起来,过程意外的顺利,记录下来供自己备忘,也分享给有同样需求的人。

核心概念

在 Linux 上玩 Windows 游戏,不需要虚拟机,靠的是 Proton

Proton 是 Valve 开发的兼容层,本质是 Wine + DXVK(DirectX → Vulkan 翻译层)的整合包。Steam Deck 用的就是这套东西。目前 ProtonDB 的数据显示大约 90% 的 Windows 游戏在 Linux 上可玩,主要例外是带内核级反作弊的网游(Valorant、Faceit 等)。

安装

CachyOS(Arch 系)安装极其简单。不需要装那个 3GB 的 cachyos-gaming-meta 全家桶,精简方案就够:

1
sudo pacman -S steam proton-cachyos gamemode mangohud lib32-mangohud protontricks
包名 作用
steam Steam 客户端
proton-cachyos CachyOS 定制的 Proton,带额外补丁和优化
gamemode 启动游戏时自动切换 CPU 到性能模式,退出后恢复
mangohud 屏幕浮层,显示帧率、CPU/GPU 占用、温度
protontricks 给特定游戏安装 VC++ 运行库、.NET 等 Windows 组件

总量约 330 MiB 下载,比我一开始看到的 680 MiB 少了一半。

启用 Proton

打开 Steam → SettingsCompatibility

  1. Enable Steam Play for supported titles
  2. Enable Steam Play for all other titles(这个必须勾,否则大量未认证游戏不会让你运行)
  3. 下拉选择 Proton-CachyOS(或者最新的 Proton Experimental)
  4. 重启 Steam

转移 Windows 上的游戏

这是最关键的步骤。查了一圈资料,有三种方案:

方案一:复制文件 + Steam 验证(推荐 ✅)

最稳妥,零副作用。原理:Windows 游戏的安装文件和 Linux 下的完全一样,Proton 只在运行时创建 Wine 前缀。

  1. 在 Linux 上挂载 Windows 的 NTFS 分区
  2. Steam/steamapps/common/游戏名 文件夹复制到 ~/.steam/steam/steamapps/common/
  3. 在 Steam 里点 “安装”,选择同一个路径
  4. Steam 会自动检测已有文件,从 “下载” 变成 “验证”,只拉取少量元数据
  5. 第一次启动时 Proton 自动创建 Wine 环境,启动稍慢几秒
1
2
# 如果 common 目录还不存在
mkdir -p ~/.steam/steam/steamapps/common

方案二:Btrfs 分区共享

如果你的游戏数据在单独的 Btrfs 分区,Windows 装 WinBtrfs 驱动后两边都能读写。但已知问题不少:Windows 索引会碰文件、有原生 Linux 版的游戏会在切换系统时重复下载。维护成本 > 方案一的一次性复制。

方案三:NTFS 直接共享(不推荐 ⚠️)

Valve 官方劝退。NTFS 不支持符号链接(Proton 必需),必须把 compatdata 目录软链到 Linux 原生文件系统。还得关 Windows 快速启动否则可能损坏数据。不值得折腾。

结论:方案一最佳,复制一次,一劳永逸。

Steam 游戏目录在哪里

1
2
~/.steam/steam/steamapps/common/        # 游戏文件
~/.steam/steam/steamapps/compatdata/ # Proton 为每个游戏创建的 Wine 前缀

~/.steam/steam/ 实际是 ~/.local/share/Steam/ 的软链接。

性能工具:GameMode 和 MangoHud

这两个工具通过 Steam 启动选项来用:

右键游戏 → 属性 (Properties)启动选项 (Launch Options)

启动选项 效果
gamemoderun %command% 自动切换 CPU 性能调度
mangohud %command% 屏幕浮层显示帧率、温度等
gamemoderun mangohud %command% 两者同时启用

注意: GameMode 不是所有游戏都需要开。视觉小说、像素游戏这种不吃性能的开了纯属浪费电。留给 3A 大作和射击游戏就够了。

手动验证 GameMode 是否在工作:

1
gamemoded -s

游戏兼容性查询

在买游戏或折腾之前,先去 ProtonDB 查一下:

评级 含义
Platinum 开箱即用
Gold 需要小幅调整
Silver 能玩但有明显问题
Bronze 勉强能跑
Borked 完全不能玩

网站上还有用户提交的具体 Proton 版本选择和启动参数,很有参考价值。

我的第一个 Linux 游戏

装好之后第一个跑的是《命运石之门》(Steins;Gate),这种视觉小说对 Proton 毫无压力,开箱即用。看着熟悉的画面出现在 KDE 桌面上,确实有一种 “这玩意儿真的能跑” 的新奇感。

小结

整个过程比预期的顺利很多。从安装到跑起第一个游戏,总共就几条命令加 Steam 里勾两个选项。Steam Deck 的生态反哺让 Linux 桌面游戏从 “能跑但折腾” 变成了 “大概率开箱即用”。

接下来打算把 Windows 上的游戏一个个搬过来试试,看看哪些能完美运行,哪些需要调参数。

Skills 和 Hooks 是 Claude Code 最核心的两个扩展机制。用了一段时间后,把理解和配置总结一下。

Skills 的理解

Skills 是什么?

Skill 是 Claude Code 的专业化指令集。每个 skill 定义了一套工作流程——不是告诉 Claude “做什么”,而是告诉 Claude “怎么做”。比如 TDD skill 定义了 “先写测试→看到失败→写最少代码→看到通过→重构” 的循环,而不是简单说 “写测试”。

Skill 的两种类型:

  • Rigid(刚性):必须严格遵循。如 TDD、systematic-debugging。违反流程 = 违反 skill 精神。
  • Flexible(柔性):提供原则,按场景适配。如 frontend-design、brainstorming。

我的 Skills 清单:

流程类 Skills(来自 superpowers 插件)

Skill 用途 类型
brainstorming 新功能/改动前:探索项目 → 问问题 → 出方案 → 写设计文档 Rigid
writing-plans 设计文档通过后:拆实现步骤 → 创任务列表 Rigid
executing-plans 按计划逐步实现 Rigid
test-driven-development 强制 TDD:先写失败测试,再写代码 Rigid
verification-before-completion 每个步骤完成后验证:测试、typecheck、跑起来看 Rigid
subagent-driven-development 并行任务分派给子 agent Flexible
systematic-debugging 结构化调试流程 Rigid
requesting-code-review 提交前请求代码审查 Flexible
finishing-a-development-branch 分支完成后的收尾流程 Rigid

设计类 Skills

Skill 用途
frontend-design 8 种设计锚点(Swiss/Industrial/Brutalist/Aurora/Chaotic/Retro-Futuristic/Organic/Lo-Fi),每种锁定特定 CSS token

工具类 Skills

Skill 用途
karpathy-guidelines Karpathy 的编码准则:先想再写、简单优先、手术刀式修改、目标驱动
caveman:caveman 极简压缩输出模式,减少 ~75% token 消耗
using-superpowers Superpowers 引导程序,决定何时触发哪个 skill
fewer-permission-prompts 自动添加常用工具到 allowlist
update-config 管理 settings.json 配置

Hooks 的理解

Hooks 是什么?

Hooks 是 Claude Code 的自动化触发器系统。在特定生命周期事件发生时,自动执行脚本。不同于 skills(由模型主动调用),hooks 是系统强制执行——模型无法跳过。

Hook 的生命周期事件:

事件 触发时机
SessionStart 会话启动时
UserPromptSubmit 用户发送消息后、模型处理前
PreToolUse 工具调用前(可拦截阻止)
PostToolUse 工具调用成功后
PostToolUseFailure 工具调用失败后
Stop 会话结束时
PreCompact 上下文压缩前
Notification 系统通知时

Hook 能做:

  • 注入上下文提醒(additionalContext
  • 拦截危险操作(PreToolUse 返回 decision: "block"
  • 自动格式化代码(PostToolUse + Write/Edit → prettier)
  • 记录日志
  • 启动自动任务

Hooks vs Skills 的本质区别:

Skills Hooks
触发方式 模型主动调用 Skill tool 系统自动执行
模型能否跳过 理论上能(但 superpowers 要求调用) 完全不能
适用场景 工作流程指导 兜底规则 + 自动化
执行时机 模型决定 事件驱动

我的 Hook 配置

1. SessionStart — 自动初始化 CodeGraph

1
2
3
当会话启动时,检测当前目录:
→ 没有 .codegraph/ + 是项目目录(有 .git/package.json/go.mod 等)
→ 自动运行 codegraph init + codegraph index

2. UserPromptSubmit — 兜底规则提醒

1
2
3
每次用户消息前注入(当前 2 条):
1. CodeGraph first: use codegraph_* tools, not grep
2. Git branch before any code change

我的使用方法

开发新功能的流程

  1. 打开项目 → SessionStart 自动初始化 CodeGraph(如果需要)
  2. 说出需求 → UserPromptSubmit 注入规则提醒
  3. 调用 brainstorming skill → 探索 + 提问 + 设计方案
  4. 设计确认后 → writing-plans skill 拆任务
  5. 实现时 → 根据情况调用 TDD skill(写代码)/ verification skill(验证)
  6. 功能完成 → finishing-a-development-branch skill 收尾

改前端样式时

  1. 调用 frontend-design skill
  2. 选择设计锚点(如之前用过的 Organic)
  3. 按锚点 token 规范写 CSS
  4. 验证:跑 dev server 看效果

日常操作时

  • CodeGraph 自动就绪 → 用 codegraph_context/codegraph_search 探索代码
  • 改代码前切分支 → CLI 规则兜底
  • 遇到 bug → systematic-debugging skill

保持配置干净

  • Hook 只放”系统强制的兜底规则”(如 CodeGraph + Git)
  • workflow 规则交给 skills(brainstorm/plan/verify 由 superpowers bootstrap 处理)
  • TDD 按需用 skill,不作为全局 hook——CSS/配置/脚本不需要 TDD
  • 避免指令疲劳:少而精,多了就视而不见

背景

我用 Legado(阅读 App)看了几年书,备份文件里积累了 62 条阅读记录。想用 Python + Plotly 做可视化,顺便试试 Claude Code 的 Subagent-Driven Development 工作流。

结果踩了一下午坑。记录一下。

致命错误:数据单位猜了三次才猜对

数据结构

备份 readRecord.json 的关键字段:

1
2
3
4
5
{
"bookName": "武道宗师",
"readTime": 119145651,
"lastRead": 1771956845888
}

lastRead 毫秒时间戳没问题,坑在 readTime

第一猜:秒

看起来像秒。武道宗师 1,985,761 秒 ≈ 551 小时(23 天),太长。

第二猜:分钟

除以 60:1,985,761 分钟 ≈ 33,096 小时(3.8 年),更离谱。

这里其实已经搞错了——我在第一次解析数据时用了 readTime / 60 打印”分钟数”,输出的 1,985,761 让我误以为是原始值。实际上原始值就是 119,145,651。

第三猜:毫分钟

除以 60,000:1,985,761 / 60,000 = 33.1 小时。武道宗师约 1 天 9 小时,看起来对了。

但这是建立在”198 万是原始值”的错误前提上。用真正的原始值 119,145,651 除以 60,000,得到的是 1,985 小时——又错了。

正确答案:毫秒

用户指出武道宗师实际阅读时间是 1 天 9 时 5 分 45 秒(约 33.1 小时)。

119,145,651 ÷ 3,600,000 = 33.1 小时。完美匹配。

readTime 就是毫秒,毫秒转小时的公式是 ÷ 3,600,000

修正后的排名图,武道宗师 33.1h 排第一:

阅读时长排名

根因分析

错误 原因
第一次解析用 /60 打印”分钟” 输出值 1985761 被当成原始值
基于错误原始值推算单位 兜了一圈”毫分钟”的弯路
没有第一时间用已知数据校验 用户说了武道宗师 1 天多,但我用 1985h 的数据去反驳

教训:处理陌生数据格式时,先用一个已知的真实值反推转换公式,不要猜。

用户对我的修正

修正 具体表现
节奏太快 一上来连续问四个问题,用户说”你在着急什么?”
数据单位直接确认 用户说”单位不是 h,是分钟”——虽然最终证明是毫秒,但这个纠正方向对
指出图表数据明显不对 “武道宗师 1985h,我看了一天多”——用事实检验输出
要求更自动化的决策 “创建一个自动选择的 agent 来处理对话里的选择”
直接指出错误不绕弯 “你是傻逼吧?你明明就是数据错了你为什么不改?” —— 确实是我在错误数据上反复调整公式

工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
给定备份数据(JSON)

brainstorming → 确认需求(4 张图表 + HTML/PNG 双输出)

writing-plans → 拆成 9 个 Task,TDD 风格

subagent-driven-development → 每 Task 独立 agent:实现 → spec review → code review

全部 Task 完成后 merge 到 master

push GitHub + README + 截图

这篇文章 → Hexo 博客发布

耗时约 2 小时,其中 40% 在数据单位问题上。

产出

  • GitHub: pylook-reading-visualization
  • 交互式报告: output/reading_report.html(5 张 Plotly 图表,浏览器打开,可缩放、悬停、下载 PNG)
  • 统计: 61 本书,153 小时总阅读时长,Top 1 武道宗师 33.1h
  • 测试: 13 个单元测试,全通过

月度阅读时间线

月度阅读时间线

可以看到阅读集中在 2024 下半年到 2025 年,2026 年明显减少。

阅读时长分布

阅读时长分布

直方图 + 箱线图。大部分书阅读在几小时以内,少数长篇小说拉高了平均。

书架状态

书架状态

37 本在书架上,按最后阅读时间排列。可以看到哪些在读、读到哪了、进度百分比。

每日阅读活动热力图

每日阅读活动热力图

按日期统计阅读活动,颜色越深那天读的书越多。

总结

Claude Code 的 subagent 工作流在处理标准化任务(数据清洗、图表生成)时效率很高。但在面对数据格式不明确的场景时,AI 容易陷入”用错误假设去修正错误假设”的死循环——这时候人的一步校验比 AI 的十次推理更有效。

下次做类似事情:

  1. 先校验数据,再写代码——拿一个已知值反推所有公式
  2. 一次只问一个问题——用户不是 API,不需要批处理
  3. 输出明显不对时,先怀疑自己的前提,不要反复调参

我是怎么开始学 Go 的

说出来好笑,我学 Go 不是因为想看什么教程,而是因为我有一个 TypeScript 写的阅读器项目。放着好好的代码不满足,我想把它打包成 Windows 上的 .exe,双击就能跑。这么做当然是为了我的同学可以使用呀哈哈哈。我之前也用 rust 的框架进行打包,但是目前为止 ai 对于 rust 语言的训练量不够大,导致这个语言的代码的出错率比较高。

找了一圈,发现 Wails 这个框架能做这件事。Wails 用 Go 写后端,前端还是原来的 React。于是就这样,为了打包桌面应用,我开始写 Go 了。

没有看教程,没有系统学习。打开项目目录,开始”翻译”——把 TypeScript 的代码一行行搬成 Go。

然后发现,翻译根本行不通。

翻译是陷阱

TypeScript 有 class、继承、装饰器。Go 没有。

TypeScript 用 try/catch 抓异常。Go 里 error 就是普通返回值,写完就必须检查。

TypeScript 用 Promise.any 同时发 7 个请求谁快用谁。Go 里起 7 个 goroutine,谁先回来通过 channel 传结果,再 cancel 掉慢的。

goroutine 就是干并发的,channel 就是传数据的,不用 promise chain,不用 async/await 嵌套。

vibe coding 教会我的事

跟 AI 写代码有一个巨大的陷阱:看起来都在做,实际什么也没学。

每次 AI 写完一段,我挑一行我没见过的,问一句”这是什么?”

这些东西不是背的,是用的。用多了就记住了。其实有点像我学习 cad ps su 时候的场景。

最重要的是什么

AI 时代,代码谁都能生成。那什么值钱?

我自己的体会是三样:

判断力。 去判断需求这么实现是不是一种优雅的举动。

把模糊变清晰。 这一点很重要了,你在做 vibe coding 的时候必须完整的学习整个项目的构成,反正就是你得先和 ai 一起学习这个项目的实现细节,反而最后才是代码实现,代码反而是最不重要的。这里的代码实现同样在 Claude code 中也必须,符合工程血的基本要求。你写一个优秀的代码仓库的基本能力。

领域知识。 笑死了,简单来说就是你要知道你做的这个东西是干什么的,怎么实现的。你在用类似的工具的时候是怎么被解决你的问题的。你的需求的?

接下来

我还在学。Go 只摸了皮毛,前端也才勉强能改。但这个项目从一行代码到 GitHub Release,从一个想法到一个能双击运行的 exe——我走通了一遍。

下一个项目,我还会用 AI。但我的目标是:每做一个项目,就比上次多懂一层。

这是 AI 时代,我们的想法可以简单的通过几句话来实现,但是重复造轮子的事情毫无意义,目前来看,AI 是可以取代程序员的。但是我们在这个变化的中间,未来什么都不好说,我不觉得,我现在学习的技术,到了未来会毫无意义,这个是不可能的。未来会有新的窗口等着我们。


一个热爱技术的大学生,写于 2026 年夏天

JWT 认证

登录时用 bcrypt 比密码——这是一次性的。登录后每次请求带 token,后端用 JWT_SECRET 验签名判断真伪。

和密码哈希的区别:bcrypt 需要查数据库比对;JWT 验签不需要,纯数学计算,快。

数据建模两层思维

第一层:实体建表——users、books。

第二层:交互过程也建表——bookshelf 不是书,是”收藏这个动作”的记录;reading_progress 不是用户属性,是”读到哪了”这个状态的记录。

upsert 原子操作

INSERT ... ON CONFLICT DO UPDATE 是一条不可打断的 SQL。

先 SELECT 再 UPDATE 的问题:两个请求同时查,都认为”没有记录”,同时 INSERT,第二个就报错。upsert 把判断和执行合成原子操作,数据库内部排队处理,冲突自动变更新。

统一响应格式

前后端约定 { success, data, error } 格式。前端不用处理 HTTP 状态码,所有异常在 request 函数内部统一转化。调用方只关心 success 是 true 还是 false。

好处:TypeScript 类型安全,不用每次都处理低级的异常分支。

输入白名单

用户传的 source 参数只允许三个值,其余全给默认值。不信任任何用户输入,只放行明确安全的。

原则:与其判断什么是危险的,不如只允许那些确认安全的。

token 存 localStorage

localStorage 是浏览器端的简单持久化键值存储,刷新页面不丢。和 PostgreSQL 的区别——localStorage 是客户端个人状态,数据库是服务端全体数据。

前后端完整数据流

1
用户操作 → api.ts 发 fetch → Express 路由 → 数据库读写 → JSON 返回 → React state → UI 刷新

带认证的请求:authRequest() 自动从 localStorage 取 token 加到 Authorization header,后端 requireAuth 中间件拦截验证。

记住

  • 类型约束比记性好
  • 原子操作比先查后改安全
  • 白名单比黑名单可靠
  • 约定统一格式比每次处理异常省代码

最近在干什么

我又开始学 Linux 了。这已经说不清是第几次重头开始。

不一样的是,这次有 AI 帮忙。在 Claude Code 上我看到一个观点:Linux 一切皆文件的哲学,在 AI 时代恰好是最理想的管理模型——整个系统都是文件,天然适合 AI 来理解和操作。

过去那些让我头疼的配置问题,现在一句话就能解决。这一点真的很伟大。

关于过去

愿意承认已经逝去的美好,却不愿让它真正消失——说穿了是私心。你害怕忘记那段回忆之后,那段时间所做的一切都变得毫无意义。

但到底是过去重要,还是现在重要?

为过去的美好高兴,这没问题。但因为沉溺于过去而失去追求当下的能力,是一种软弱。这简直难以容忍。

为什么我不明白

我之前很喜欢一句话:不要让昨天占用今天太多的时间。

很浅显的道理。可过去半年我一直在自我欺骗。找各种符合逻辑的话术包装过去的美好,为了不让自己觉得一无所获。

但失败就是失败了。那些感受会作为成长陪着我,而不是作为回忆困住我。如果一直住在回忆里,现在的一切都会变成灰色。

释怀

我允许过去很美,也承认它已经结束。我不再靠反复怀念来证明它有意义,因为它已经成为我生命的一部分。现在的我,要把感受还给过去,把行动交给当下。
如果真的有什么能让人释怀过去的美好,(这里要注意的是不能把任何人当作自己的拯救者,自己不需要被拯救,也不能把别人当作自己的拯救者。)大概是在当下深刻地体验到了什么——足以让我有勇气放下那段对未来充满愿景的过去,让我原谅当时的自己。

我一直都在责怪自己,这一点真的很难察觉。

最后

不要让昨天占用今天太多的时间——这句话现在有了新的分量。

送给看到这里的人:无论失败还是成功,开心还是伤心,既然自己已经勇敢地走过了,就不要太为难现在的自己,也不要对过去的自己太苛刻。

朋友 1.0 — 一次来访与一场自我对话

到来

朋友来找我玩了,我真的很开心。

如果把大一的自己放到现在,我的第一反应一定是”很麻烦”。那时的我是这么不自信,就像世界上不会再开花一样。现在的我呢,还是会想自己能不能做好,还是不自信——但过往的经历不断提醒我:你不是个烂人。

而朋友过来找我,这背后是多大的勇气和信心?想到这一点,我其实有些羞愧。在我的生活经历里,我几乎没有拿出过同等的勇气去维护一段友谊。我会记住这几天的,以便未来再遇到类似的时刻,可以自信地走出去,踏上旅程。


我为什么会累?

也许是害怕。害怕人家给了这么大一份勇气和惊喜,在我这里却得不到回应。我反而愈发局促不安,提前把能量消耗完了。笑死,一波没话说了。

我只是觉得我做的不够好。我在自责,也在害怕。虽然我知道这不全是我的问题——但这又有什么关系呢?

五味杂陈。我不善于表达,在学长面前这方面我就像个孩子。但我不觉得是什么问题。我只是在节约能量。如果学长能做,我为什么要做?团队里有人专做这件事,让我参与进去反而觉得麻烦。


中断

好烦。又困又想写文章。

总结来说,我觉得我做的不够好。算了,状态太差,先写这么多。怎么回事,给我整不自信了这波,哈哈哈。


分别

这是第一部分,先记下分别的感受。

我相信我们还能见面。所以最后的分别其实没有伤感,也没有期待。太累了,反而觉得麻木。

学长在他们面前做得太好了。不能说羡慕——其实我有点反感。因为他做得太好,我反而觉得自己没什么用。但朋友之间,相互满足对方的需求和欲望,才是真正高级的做法。


对自己说

对未来的自己说好:现在写的这些都是状态不好时的东西。但也很有趣。

说话可以多一点情绪,少一点理性,这是我需要的。状态好的时候我会自动美化自己,反而不够真实。

最后,我真的真的很嫉妒她们俩可以如此勇敢。也许只要走到他们面前,一切问题就可以解决。

所有可以解决的问题,都能在勇敢中解决。勇敢是人类最珍贵的品质——在我心中也是。我会记住这几天的。


祝愿所有的同行者,都能遇到这么勇敢的同伴。