练习 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_fileedit_file 的破坏面本来就小(只能替换已经在文件里、且唯一出现的一段 文字),但"小"不等于"零"——加分练习 1 让你自己把同一招搬过去。

加分练习

  1. backupIfExists 也接进 editFileTool.execute,覆盖前"整个文件"没了 和替换"一段文字"没了,破坏的严重程度不同,但都值得留一份退路。
  2. restore 加一个 -list 模式:列出 trashDir 里所有备份文件的名字 和大小,而不是直接恢复——真要恢复前,你大概率想先看看有哪些版本可选。
  3. 照着 常见问题 里提到的 octo Enforce 写一个最简单的清理:程序启动时 删掉 .trash/ 里超过某个总大小(比如 1MB)的最老文件,直到低于上限。
  4. 试着连续覆盖同一个文件三次,然后只用文件名(不看时间戳)从 .trash/ 里把三份都找出来,按时间顺序排好——这是 restore 现在悄悄替你做的事, 自己动手做一遍,会对"最新"两个字多一分警惕:它默认对,但没人规定 你要的一定是最新那份。