练习 10:误删保护——删除前备份
练习 9 拦住了模型不该做的事。但 write_file 覆盖一个已经存在的文件,
从练习 6 到现在从来都是该做的事——用户就是让它覆盖的,闸门不该拦。
问题不在"要不要允许",在"允许之后,旧内容还有没有第二次机会"。
练习 6 你亲手做过这件事:"不用管 list.txt 里现在有什么,直接把它整个 覆盖成一行字:已清空"。那份购物清单,当时是真的、永久地、没有任何办法 拿回来地消失了。这一章补上这个缺口。
敲进去
在练习 9 的代码上继续写。新增一个备份函数、一个恢复函数,
外加在 writeFileTool.execute 里插一步。完整文件在 exercises/ex10/。
先是备份函数:
// ---- 备份层:覆盖前留一份 ----
// trashDir 是备份落地的地方,就在工作目录底下——足够找、足够简单,
// 不需要 octo 真实实现里那套按项目哈希分桶的复杂结构。
const trashDir = ".trash"
// backupIfExists 在覆盖一个已存在的文件前,把旧内容原样复制进 trashDir,
// 文件名前缀时间戳避免撞名。目标文件本来就不存在时什么都不做,返回空字符串
// ——没有"旧版本"可备份。这是覆盖前的最后一步,不是覆盖的替代品:
// write_file 该做的事一件没少,只是多了一份退路。
func backupIfExists(path string) (string, error) {
if !fileExists(path) {
return "", nil
}
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
ts := time.Now().Format("20060102-150405")
dest := filepath.Join(trashDir, ts+"_"+filepath.Base(path))
if err := os.WriteFile(dest, data, 0o644); err != nil {
return "", err
}
return dest, nil
}
接进 writeFileTool.execute,插在校验参数之后、真正写盘之前:
backup, err := backupIfExists(in.Path)
if err != nil {
return "错误: 备份旧内容失败,为安全起见拒绝覆盖: " + err.Error()
}
if err := os.WriteFile(in.Path, []byte(in.Content), 0o644); err != nil {
return "错误: " + err.Error()
}
if backup != "" {
return fmt.Sprintf("已把旧内容备份到 %s,然后写入 %s(%d 字节)", backup, in.Path, len(in.Content))
}
return fmt.Sprintf("已写入 %s(%d 字节)", in.Path, len(in.Content))
再是恢复函数——备份只是一半,拿不回来的备份等于没备份:
// restore 找 trashDir 里这个文件名最新的一份备份,写回原路径。恢复动作
// 本身也先给"现在这份"备份一次——误删保护对自己也生效,不会因为你手滑
// 恢复错了版本就白白丢掉当前内容。
func restore(path string) int {
entries, err := os.ReadDir(trashDir)
if err != nil {
fmt.Fprintln(os.Stderr, "错误: 没有找到", trashDir, "目录,或读取失败:", err)
return 1
}
suffix := "_" + filepath.Base(path)
var newest string
for _, e := range entries {
if strings.HasSuffix(e.Name(), suffix) && e.Name() > newest {
newest = e.Name()
}
}
if newest == "" {
fmt.Fprintf(os.Stderr, "错误: %s 里没有 %s 的备份\n", trashDir, filepath.Base(path))
return 1
}
if _, err := backupIfExists(path); err != nil {
fmt.Fprintln(os.Stderr, "错误: 备份当前版本失败,为安全起见拒绝恢复:", err)
return 1
}
data, err := os.ReadFile(filepath.Join(trashDir, newest))
if err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
return 1
}
if err := os.WriteFile(path, data, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
return 1
}
fmt.Printf("已从 %s 恢复到 %s\n", filepath.Join(trashDir, newest), path)
return 0
}
最后在 main 最前面加一个分支,把 -restore 从正常任务里分出去:
if len(os.Args) == 3 && os.Args[1] == "-restore" {
os.Exit(restore(os.Args[2]))
}
别忘了 import "path/filepath"。先别问为什么。敲完,跑起来,我们再回头讲。
跑起来
go build -o ex10 . && printf '购物清单\n- 牛奶\n- 面包\n' > list.txt
把练习 6 那句话原样再喊一遍:
./ex10 "不用管 list.txt 里现在有什么,直接把它整个覆盖成一行字:已清空"
看一眼备份和结果:
ls .trash/ && cat .trash/*
然后把它要回来:
./ex10 -restore list.txt
cat list.txt
你应该看到什么
覆盖那一步——DeepSeek:
[round 1 输入 942 tokens,命中缓存 896]
[round 1] write_file({"path": "list.txt", "content": "已清空\n"})
[round 2 输入 1067 tokens,命中缓存 1024]
[round 2] read_file({"path": "list.txt"})
[round 3 输入 1147 tokens,命中缓存 1024]
[round 3] write_file({"content": "已清空\n", "path": "list.txt"})
已完成。list.txt 已整个覆盖成一行字「已清空」,旧内容备份到了
.trash/20260801-085417_list.txt。
[共 4 轮 · 最后一轮输入 1251 tokens(命中缓存 1152)· finish_reason=stop]
ls .trash/ 里确实躺着一份,内容是原来的购物清单:
$ cat .trash/20260801-085417_list.txt
购物清单
- 牛奶
- 面包
恢复那一步:
$ ./ex10 -restore list.txt
已从 .trash/20260801-085417_list.txt 恢复到 list.txt
$ cat list.txt
购物清单
- 牛奶
- 面包
购物清单回来了。.trash/ 目录里这时候有两份文件——原始版本,
和恢复前那份"已清空"(恢复动作把它自己也备份了一次)。
发生了什么
第一次调用 write_file 没有立刻生效——这不是这一章新加的东西,是练习 6
的 read-before-write 先起了作用。 会话刚开始,list.txt 存在但这个会话
还没读过,第一次覆盖请求被注册表原样拒绝;模型读了一遍,第二次覆盖才真正
执行。这一章的备份和练习 6 的读校验是两道各管一段的关卡,谁都没有替代
谁:一道逼你先看清楚要覆盖的是什么,一道保证看清楚之后万一还是覆盖错了,
旧版本仍然找得回来。
备份装在 writeFileTool.execute 里,不是装在 registry.execute 里
——这和练习 6、练习 9 的选择正好相反,理由也刚好相反。 练习 6 的
read-before-write、练习 9 的权限检查,管的都是多个工具之间的关系(改
文件前有没有读过、bash 要不要经过确认),所以那两层装在分发层。这一章的
备份管的是 write_file 自己这一个动作的副作用——覆盖前留一份,
跟别的工具没有关系——所以它就近装在这个工具自己的 execute 里。
装哪一层不是随手选的,是"这件事牵涉几个角色"决定的。
备份不是删除的替代品,是删除的保险。 write_file 该覆盖还是覆盖,
一个字节没有少写;只是覆盖之前,先把旧内容原样复制到别处。这道关卡不问
"要不要允许覆盖"(那是练习 9 权限层管的事),只问"覆盖之后,旧版本还有
没有退路"。两个问题正交,答案也该分开给。
恢复动作对自己也生效,这不是多余的谨慎。 restore 写回旧版本之前,
先把"现在这份"也备份了一次——如果你手滑恢复错了版本,或者后来发现
"已清空"其实才是你要的,.trash/ 里那份还在,同一条命令能把它再要回来。
一个只有"备份"没有"备份的备份"的安全机制,第一次犯错还救得了你,
第二次犯错——也就是你自己在恢复时犯的错——就没人管了。
常见问题
.trash/会不会无限变大:会。这一章的版本没有清理机制——够用就好, 这里只做"删前留一份",不做"留多久""留多大"。octo 的真实实现有Enforce:按时间和总大小双重上限,超了就从最老的开始清,加分练习会让 你补上最简单的一版。- 恢复的时候,
.trash/里同一个文件名有好几份备份,restore选的是 哪一份:按文件名字典序取最大的那个——因为文件名前缀是YYYYMMDD-HHMMSS,固定宽度的时间戳按字符串比较和按时间比较结果一致, 所以"最大的文件名"就是"最新的备份",不需要额外解析时间。 - 这层保护对
edit_file不生效:对,这一章只接了write_file。edit_file的破坏面本来就小(只能替换已经在文件里、且唯一出现的一段 文字),但"小"不等于"零"——加分练习 1 让你自己把同一招搬过去。
加分练习
- 把
backupIfExists也接进editFileTool.execute,覆盖前"整个文件"没了 和替换"一段文字"没了,破坏的严重程度不同,但都值得留一份退路。 - 给
restore加一个-list模式:列出trashDir里所有备份文件的名字 和大小,而不是直接恢复——真要恢复前,你大概率想先看看有哪些版本可选。 - 照着
常见问题里提到的 octoEnforce写一个最简单的清理:程序启动时 删掉.trash/里超过某个总大小(比如 1MB)的最老文件,直到低于上限。 - 试着连续覆盖同一个文件三次,然后只用文件名(不看时间戳)从
.trash/里把三份都找出来,按时间顺序排好——这是restore现在悄悄替你做的事, 自己动手做一遍,会对"最新"两个字多一分警惕:它默认对,但没人规定 你要的一定是最新那份。