练习 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参数重试, 或者你把默认值调大。超时的默认值就是个赌注,赌你的常见命令多快。 - Windows:
sh -c在 Windows 上没有。octo 的做法是按平台选 shell (macOS/Linux 用 POSIX sh,Windows 用 PowerShell)——本书主线只管前者, Windows 读者把exec.CommandContext(ctx, "sh", "-c", ...)换成powershell -Command即可。
加分练习
- 让它"运行 sleep 300,然后告诉我结果"。我实测:模型读了 description,
主动传了
timeout: 120——它知道默认 30 秒睡不完 300 秒,直接顶格要时间。 然后等满两分钟被杀,如实解释了超时机制。三层设计它全用上了,也全撞上了。 现在想想:如果没有那个上限,它传 86400 会发生什么? - 把
maxBashOutput改成 200 再跑 seq 实验——输出几乎全没了, 看模型靠一行截断标记还能不能正确汇报。然后想想:截断上限设多大, 本质是在"模型能看到多少"和"上下文烧多快"之间开价。 - 给 bash 的
description加一句"禁止用重定向或 sed 修改文件, 修改文件必须用 edit_file",重跑上面的绕纪律实验。它听吗? 多跑几次呢?——你刚刚提前体验了练习 8 的主题:说明书是软约束。 - 删掉
tail函数里对齐整行的那两行,重跑 seq 实验,看截断处的半截行 长什么样。两行代码的事,模型少读一行垃圾——工具输出的整洁度也是设计。