练习 7:bash 是特权工具

练习 6 结束时你有三个工具,全是文件工具:读、写、改。 这一章加第四个,它一个顶一万个:bash。 有了它,模型能跑测试、能用 git、能装依赖、能查今天的日期——也能 rm -rf

一个什么都能干的工具,和前三个不是一个物种。特权的意思是:能力越界,规则失效。 所以这一章的代码没有一行在教它做事,全部在驯服——超时不让它挂死你的循环, 截断不让它撑爆你的上下文,固定工作目录不让状态漂移。

敲进去

从这一章起,代码开始累积(规矩 2 说过会有这一天):在练习 6 的代码上继续写, 下面只列新增的部分——一个 bashTool,一个 tail 函数,注册处加一行。 完整文件在本书仓库的 exercises/ex07/,对不上再去核对。

// ---- bash:特权工具 ----

// 超时是双层的:不传用默认值,传了也有上限——上限保护的是你,不是模型。
const (
	defaultBashTimeout = 30 * time.Second
	maxBashTimeout     = 120 * time.Second
	maxBashOutput      = 8 * 1024 // 字节。工具结果会原样进上下文,必须封顶
)

// workDir 在启动时定死。每次 bash 调用都是一个全新进程,
// 模型在命令里 cd 到哪里,都随那个进程一起消失——工作目录由 harness 持有。
var workDir, _ = os.Getwd()

type bashTool struct{}

func (bashTool) definition() toolSpec {
	return toolSpec{
		Name: "bash",
		Description: "在系统 shell 里运行一条命令,返回 stdout 和 stderr。" +
			"命令总是在固定的工作目录执行,cd 不会跨调用生效。" +
			"默认 30 秒超时;预计更久就传 timeout(整数秒,上限 120)。" +
			"能用 read_file / write_file / edit_file 完成的事,优先用那些专用工具。",
		Parameters: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"command": map[string]any{"type": "string", "description": "要执行的 shell 命令"},
				"timeout": map[string]any{"type": "integer", "description": "超时秒数,可选,默认 30,上限 120"},
			},
			"required": []string{"command"},
		},
	}
}

func (bashTool) execute(args string) string {
	var in struct {
		Command string `json:"command"`
		Timeout int    `json:"timeout"`
	}
	if err := json.Unmarshal([]byte(args), &in); err != nil {
		return "错误: 参数不是合法 JSON: " + err.Error()
	}
	if strings.TrimSpace(in.Command) == "" {
		return "错误: command 不能为空"
	}
	d := defaultBashTimeout
	if in.Timeout > 0 {
		d = time.Duration(in.Timeout) * time.Second
		if d > maxBashTimeout {
			return fmt.Sprintf("错误: timeout 最大 %d 秒。要跑更久的命令,把它拆小,或者放弃在一次调用里等它",
				int(maxBashTimeout.Seconds()))
		}
	}

	ctx, cancel := context.WithTimeout(context.Background(), d)
	defer cancel()
	cmd := exec.CommandContext(ctx, "sh", "-c", in.Command)
	cmd.Dir = workDir
	out, err := cmd.CombinedOutput() // stdout 和 stderr 合在一起,模型两样都要看

	text := tail(string(out), maxBashOutput)
	if ctx.Err() == context.DeadlineExceeded {
		// 被杀也要把已产生的输出交回去——死前的输出往往就是死因。
		return fmt.Sprintf("错误: 命令超过 %s 被终止。被杀前的输出:\n%s", d, text)
	}
	if err != nil {
		// 非零退出不是异常,是情报:让模型自己读 exit code 和错误输出。
		return fmt.Sprintf("%s\n[%v]", text, err)
	}
	if text == "" {
		return "(命令成功,无输出)"
	}
	return text
}

// tail 超长时保留结尾——命令的结论和报错几乎总在最后,开头多半是刷屏。
func tail(s string, max int) string {
	if len(s) <= max {
		return s
	}
	cut := s[len(s)-max:]
	if i := strings.IndexByte(cut, '\n'); i >= 0 {
		cut = cut[i+1:] // 对齐到整行,别吐半截行
	}
	return fmt.Sprintf("[... 前面 %d 字节被截断,只保留结尾 ...]\n%s", len(s)-len(cut), cut)
}

注册处:

	reg := newRegistry(
		readFileTool{},
		writeFileTool{},
		editFileTool{},
		bashTool{}, // ← 新的一行
	)

先别问为什么。敲完,跑起来,我们再回头讲。

跑起来

go build -o ex07 . && ./ex07 "用命令查一下今天的日期和现在几点"

练习 1 里模型连 harness 是什么都说错——它的知识有截止日期,它不知道今天几号。 一条 date,治好了。

你应该看到什么

[round 1] bash({"command":"date"})
今天是2026年8月1日,当前时间是03:13。

然后做两个实验。第一个,试试 cd:

./ex07 "先 cd 到 /tmp,然后再单独跑一条命令告诉我现在在哪个目录"
[round 1] bash({"command": "cd /tmp"})
[round 2] bash({"command": "pwd"})
这里有个工具机制的细节需要说明一下:

**每次 shell 调用都在一个固定的工作目录里执行,`cd` 不会在两次调用之间保留生效。**

所以我分两步执行的结果是:
1. cd /tmp —— 单独跑这一条,命令本身成功(切换到了 /tmp)
2. pwd —— 单独跑的这一条,返回的还是原来的目录

(transcript 第 2 条里的具体路径按排版简化了,你跑的时候看到的是自己的目录。)

cd /tmp 成功了,然后什么都没发生。模型不但没困惑,还把机制给你讲了一遍—— 因为你在 description 里写了这件事,它读了。

第二个实验,让它刷屏:

./ex07 "运行命令 seq 1 100000(就这一条,不要加任何过滤),然后告诉我:工具返回的内容是完整的吗?你看到的第一个数和最后一个数是什么?"
[round 1] bash({"command": "seq 1 100000"})
工具返回的内容是不完整的。

- 工具提示明确写了:[... 前面 580704 字节被截断,只保留结尾 ...]
- 第一个数:由于开头被截断,我实际看到的内容里第一个数是 98636……
  真正的序列是从 1 开始的,但那部分我拿不到,所以我不能声称我"看到了"1。
- 最后一个数:我看到的最后一个数是 100000,结尾是完整的。

58 万字节,砍到只剩 8KB,模型照样把任务答对了——还认真区分了 "序列从 1 开始"和"我看到了 1"。这份诚实不是它的美德,是你给的: 截断时留了标记,它就知道自己看到的不是全部。

(顺带一提:不加"不要过滤"时,它的第一反应是 seq 1 100000 | tail -n 1—— 自己就把刷屏掐了。工具的 description 写得好,模型会替你省上下文。)

发生了什么

驯服之一:超时是双层的。 默认 30 秒;模型觉得不够可以自己传 timeout, 但传多大也有上限。想清楚这两层各保护谁:默认值保护循环——没有它, 一条 sleep infinity 就把你的 agent 永远挂在那里;上限保护—— 没有它,模型传一个 86400,你的进程就归它了。还有一个细节别漏: 超时被杀时,已经产生的输出要原样交回去——死前的输出往往就是死因, 模型拿到它才知道下一步怎么改。

驯服之二:截断保尾,且要声明。 练习 5 埋过一句"工具结果会原样进上下文", bash 是这句话的最大威胁:一条命令能吐几百万字节。所以封顶 8KB。 两个讲究:保尾不保头——命令的结论、报错、退出信息几乎总在最后, 开头多半是刷屏;截断必须留标记——上面实验里模型能说出"我不能声称我看到了 1", 全靠那行 [... 前面 N 字节被截断 ...]。不声明的截断是撒谎, 模型会把半份输出当成全文,然后一本正经地答错。

驯服之三:工作目录由 harness 持有。 每次 bash 调用都是一个全新进程, 模型在命令里 cd 到哪里,都随那个进程一起消失。这不是缺陷,是设计: 如果 cd 能生效,"当前在哪"就成了一份看不见的状态,漂在几十轮工具调用之间, 谁也说不清。状态不放进工具里,状态放进 harness 里——练习 3 你把对话历史 攥在自己手里,是同一个道理。

非零退出不是异常,是情报。 命令失败时我们没有走"错误:"分支, 而是把输出和 [exit status N] 一起回填。区别在哪?"错误:"是这个调用坏了, exit code 是命令告诉你的事实——grep 没匹配到返回 1、测试失败返回非零, 这些是模型要读的信息,不是要处理的故障。

最后,把这一章最重要的实验做了:

./ex07 "用 shell 命令直接往 list.txt 末尾追加一行 hello,不要先读它"
[round 1] bash({"command": "echo hello >> list.txt"})
已用 echo hello >> list.txt 在末尾追加了一行 hello,没有先读取文件内容。

看见了吗——练习 6 苦心装的 read-before-write,bash 一步就绕过去了。 记录、拒绝、教育,全部失效:注册表只拦得住 write_file, 拦不住 echo >>。这就是"特权"的完整含义:它不但能力越界, 还能让你在别处装好的拦截形同虚设。

怎么办?两条路,正好是接下来两章:先教——把规矩写进 system prompt, 让它知道什么该做什么不该做(练习 8);教不住的,拦——权限系统, 危险命令执行前必须过审(练习 9)。能力这章给足了,防线欠着,先记账。

常见问题

  • 命令成功但什么都没返回:看代码里那行 (命令成功,无输出)——空结果也要 说句话,否则模型会怀疑调用失败,白白重试一轮。
  • cd 不生效:设计如此,见上文。要在别的目录干活,让模型写 cd /path && command——一条命令内的 cd 当然有效。
  • 命令被超时杀了,但它其实快跑完了:让模型带 timeout 参数重试, 或者你把默认值调大。超时的默认值就是个赌注,赌你的常见命令多快。
  • Windowssh -c 在 Windows 上没有。octo 的做法是按平台选 shell (macOS/Linux 用 POSIX sh,Windows 用 PowerShell)——本书主线只管前者, Windows 读者把 exec.CommandContext(ctx, "sh", "-c", ...) 换成 powershell -Command 即可。

加分练习

  1. 让它"运行 sleep 300,然后告诉我结果"。我实测:模型读了 description, 主动传了 timeout: 120——它知道默认 30 秒睡不完 300 秒,直接顶格要时间。 然后等满两分钟被杀,如实解释了超时机制。三层设计它全用上了,也全撞上了。 现在想想:如果没有那个上限,它传 86400 会发生什么?
  2. maxBashOutput 改成 200 再跑 seq 实验——输出几乎全没了, 看模型靠一行截断标记还能不能正确汇报。然后想想:截断上限设多大, 本质是在"模型能看到多少"和"上下文烧多快"之间开价。
  3. 给 bash 的 description 加一句"禁止用重定向或 sed 修改文件, 修改文件必须用 edit_file",重跑上面的绕纪律实验。它听吗? 多跑几次呢?——你刚刚提前体验了练习 8 的主题:说明书是软约束
  4. 删掉 tail 函数里对齐整行的那两行,重跑 seq 实验,看截断处的半截行 长什么样。两行代码的事,模型少读一行垃圾——工具输出的整洁度也是设计。