我把本机 2.3G 的 AI 工具目录压缩到 13M 同步到了另一台设备。过程里踩了五个坑,其中一个让同一条会话在侧边栏出现了两次,另一个差点把数据库写坏。这篇写那五个坑 —— 以及怎么验证它们确实被修好了。
背景:为什么不是「整个目录 rsync 一下」
目标很朴素:让两台机器上的 AI 助手「差不多是同一个」—— 身份记忆、技能、会话历史都在。
第一反应当然是整个目录同步。但一上手就发现不对:本机目录 2.3G,而真正值得跨机搬运的资产只有十几个 M。剩下的是日志、运行时二进制、插件市场缓存、链路追踪 —— 全是每台机器自己装的东西,搬过去纯属浪费。
更要命的是那 2.3G 里有几类搬过去会坏事的东西。
所以整个方案的第一原则是:先分类,再决定谁进同步通道。
坑 1:进程独占的数据库文件,双向同步必坏
目录里有个 workbuddy.db(SQLite),旁边还有 workbuddy.db-wal 和 -shm。
直觉上这是「最重要的数据」,必须同步 —— 于是灾难开始了。
WAL(预写日志)是 SQLite 的事务机制:写入先落到 -wal 文件,再择机合并回主库。这个文件由持有数据库的进程独占,而且它的状态与主库严格配对。
两台机器各自开着这个数据库,同时持有 -wal:A 机器的未合并事务对 B 机器完全不可见,B 的写入也可能覆盖 A 的中间状态。结果不是「数据错了一点」,而是可能直接损坏。
我的处理方式是把铁律写死成三条:
1. 只 INSERT 缺失行,绝不 UPDATE / DELETE
2. 写库前自动备份
3. 同名但内容不同 → 跳过并报告,不静默覆盖
只 INSERT,不 UPDATE,这一条是全部安全性的来源:本机已有的行永远不会被远端数据改写。同步只能「补充」,不能「篡改」。
# 伪代码:判重后只插入缺失的
for row in incoming_sessions:
if exists(row.id):
if same_content(row):
skipped_same += 1
else:
conflicts.append(row) # 留给人判断
continue
insert(row) # ← 只有这一条写路径
配套的还有 verify(全量 sha256 比对)和 restore(幂等)两个子命令,以及一个 selftest —— 在临时目录里跑完整链路并做 13 项断言,不碰真实数据。
顺便一提:定时任务里跑
push时,这个库可能被主进程占用。打包阶段对它只读就没事,所以定时 push 是安全的;写库只发生在pull阶段。
坑 2:会话数据存在两个地方,只搬一个等于没搬
这是最容易「看起来成功了」的一个坑。
会话在磁盘上有两份:
| 位置 | 内容 | 作用 |
|---|---|---|
workbuddy.db 的 sessions 表 | 标题、时间、摘要 | 侧边栏列表的数据源 |
projects/**/*.jsonl | 逐条消息正文 | 点进去看到的对话内容 |
我只同步了 jsonl 正文,结果:文件确实在磁盘上,但侧边栏空空如也 —— 因为列表读的是数据库,不读文件。
反过来只补数据库也一样:侧边栏点得到会话,点进去一片空白。
实测的差集数据:
DB 会话行: 48
jsonl 正文: 31~38
DB 有、正文没有:17 条 ← 清理过的工作区
正文有、DB 没有: 0 条 ← 反向差集为空
反向差集为 0 这个结果很有价值 —— 它说明「正文一定有对应的 DB 行」,所以补 DB 是安全的(只需要 INSERT 那些正文已存在的会话)。
所以两份必须成对搬,一次做完。
这个坑的通用形态:任何「列表 + 详情」结构的数据,如果它们分属不同存储,同步时就必须成对处理。 只搬详情,列表不会有。
坑 3:同一个 ID 有两种格式,同步后同一条会话出现两次
修好坑 2,我在侧边栏看见同一条会话出现了两次。
排查后发现,ID 有两种写法:
标准 UUID: 550e8400-e29b-41d4-a716-446655440000
无连字符 32 位:550e8400e29b41d4a716446655440000
它们指的是同一个东西,但在判重时是两个不同的字符串。
于是同步逻辑这样写:
# ✗ 有问题:把两种格式当成两个不同的会话
if row.id not in existing_ids:
insert(row)
# ✓ 先归一化再判重
def norm_sid(s):
s = s.strip().lower()
if len(s) == 32 and "-" not in s:
return f"{s[:8]}-{s[8:12]}-{s[12:16]}-{s[16:20]}-{s[20:]}"
return s
教训:任何跨系统的 ID 判重,都要先确认「同一个 ID 在源系统里有没有多种表示」。 不统一格式就去判重,等于没判。
坑 4:压缩包每次都不一样,Git 仓库迅速膨胀
把资产打包成 gzip 提交进 Git。第一次很顺利,第二次就发现仓库体积开始不正常增长。
原因是 gzip 默认把当前时间写进文件头。于是同一个文件,两次打包产生两个不同内容的压缩包 —— Git 认为你改了两份文件,各自存一份完整副本。
原始会话 72M → gzip 后 12.6M ← 压缩本身很有效
但重复提交后仓库体积翻倍 ← 差异性带来的额外开销
解法是让压缩确定性:固定 mtime,这样同样的输入永远得到同样的字节。
# 固定 mtime,让 gzip 输出可复现
buf = io.BytesIO()
with gzip.GzipFile(filename="", mode="wb",
fileobj=buf, mtime=0) as gz:
gz.write(data)
deterministic = buf.getvalue()
改完之后,重复 push 不再产生任何新差异。这是个小改动,但影响仓库长期健康度的修正。
坑 5:大体积的技能目录,逐文件同步毫无意义
技能目录里有个 skillhub 包,7063 个文件、36M。大部分是各机器都装得上的通用内容。
逐文件进 Git 的问题不只是体积,还有维护成本:它会一直变动,每次变动都是几百个文件的 diff,把提交历史冲得稀碎。
我改成分级策略:
| 体积 | 处理方式 |
|---|---|
| 小(自建 skill) | 完整打包进仓库 |
| 大(超阈值) | 只在 manifest 里登记一份清单(路径 + 数量 + 体积),目标机按名重装 |
这样清单里只有一行 aliyun-ecs-skill__skillhub 7063 files 36M,真正的内容交给「每台机器自己装」这条铁律。
通用原则:能用「装一次」解决的,不要用「搬一次」解决。 同步通道应该只承载本机独有的东西。
附带的两个设计:敏感资产与体积控制
敏感文件:默认只记指纹,不打包
密钥材料、凭据目录这类东西,我的默认行为是只登记 sha256 指纹,不把明文放进仓库:
sensitive inventory:
keyblob sha256:9f2c... (未打包)
security/ sha256:1a7e... (未打包)
preferences.json sha256:4b03... (未打包)
清单本身是公开的(可以告诉别人「我这里有个叫 keyblob 的文件」),但内容不外流。确实需要同步时,用 age 加密:公钥进仓库,私钥离线。
排除清单要硬编码,不能靠配置文件
真正危险的几个目录必须在代码里硬编码排除,不能交给配置文件决定:
*.db-wal *.db-shm logs/ traces/ cache/
binaries/ plugins/cache/ app/ __pycache__ *.pyc *.lock
理由很直接:配置文件可能被误提交、可能被覆盖、可能因为跨版本不兼容而失效。安全相关的排除必须写死在代码里,不能依赖外部输入。
小结:五个坑的共同点
| 坑 | 表面症状 | 真实原因 |
|---|---|---|
| 双向同步数据库 | 可能损坏 | 并发进程独占同一文件 |
| 只搬正文不搬索引 | 侧边栏空 | 列表与详情分属不同存储 |
| ID 两种格式 | 同一条出现两次 | 判重前没归一化 |
| gzip 不确定 | 仓库膨胀 | 输出字节每次不同 |
| 大目录逐文件搬 | diff 噪声 | 本可「装一次」而非「搬一次」 |
前四个有个共同特征:失败是静默的。数据看起来搬过去了,只有在特定视角(换台机器打开、跑第二次同步)才暴露。
所以这类工具必须配一个不碰真实数据的自检(selftest),在临时目录里跑完整链路 + 断言。我的清单是 13 项,包括:
- 日志/缓存/WAL/密钥未被打包
- 数据库补插幂等(跑两次结果一致)
- 正文确实到位
- 重复执行
restore不产生副作用
断言要写在临时目录里跑。拿真实数据做自测,一次误操作就够受的。
如果你要给自己的工具做同步
三句话版本:
- 先分类,把「本机独有的」和「每台都该装的」分开 —— 只同步前者。
- 成对处理:列表数据与详情数据分属不同存储时,一次搬完。
- 判重前先归一化 ID,并且压缩输出要可复现。
剩下的都是细节,但细节决定了这个工具是「能用一年」还是「三个月后不敢动」。