导学阶段-工具的设计哲学
本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。
工具写成什么样,决定了模型能不能自己把活干完。功能跑通只是及格线,差别出现在模型犯错的时候:错误被工具吞掉,还是以模型能读懂的形式回到对话里。
定位用内容,不用行号
设想 edit 工具接收 line_number 参数。第 1 步在第 5 行前插入 3 行新代码,文件变长,原第 9 行被推到第 12 行。第 2 步要改”原本的第 12 行”,模型手里的行号还是上一次 read 时的快照,于是改到了别处。它不知道行号变了,也不会主动重读一遍。
1 | edit_at_line("main.py", line=5, mode="insert_before", content="...") |
按内容定位就没有这个问题。edit 接收 old_content 与 new_content,替换的是那段文本本身,前面增删多少行都不影响。模型看到的世界本来就是文本,行号是展示层加上去的坐标,用它做定位等于把显示格式当成了数据。
1 | edit("main.py", old_content="def greet():\n print('hi')", |
唯一性约束是这条设计的另一半。old_content 必须在文件中恰好出现一次。出现零次,说明模型记的内容和文件对不上,缩进、空格、换行任何一处不同都会导致这个结果。出现多次,说明锚点太短:old_content 写成 “return”,文件里有 30 个 return,工具无法判断改哪一个。除了匹配次数,edit 还限制文件大小,超过 10MB 直接拒绝,替换要把整个文件读进内存做字符串处理。
这条规则迫使模型提供足够的上下文。把锚点扩成 “def add(a, b):\n return a + b”,匹配就唯一了。这个约束看似提高门槛,实际是在替模型补上它看不见的信息:它不知道自己给的片段在文件里出现了几次。
最危险的工具要设闸门
bash 什么命令都能跑,也就能把仓库删掉。课程给它加了四道闸门,其中工作目录那一道由工具注册表在组装时注入并检查。
| 闸门 | 做法 | 作用 |
|---|---|---|
| 超时 | timeout=30 | 让 while True 一类不返回的命令强制结束 |
| 输出截断 | max_output=50000 | 避免几百万行输出撑爆上下文 |
| 工作目录 | Registry 注入 workdir,拦截越界路径与越界 cd | 把命令限制在项目目录里 |
| 危险命令拦截 | 正则黑名单 | 挡住 rm -rf /、sudo、远程脚本管道执行、fork 炸弹 |
四道闸门里,工作目录这一条现在确实在拦。通过 ToolRegistry 组装时,注册表会把 workdir 注入 bash,并检查命令里的绝对路径与 cd:越界路径、跳出工作区的 cd、引号不配对导致命令无法解析,都会直接拒绝执行。但它仍是尽力而为的边界,判断依据是命令行文本本身,绕过方式不止一种,生产环境要靠容器或只读挂载兜底。
黑名单同理。它只能拦住已知的坏命令,挡了 rm -rf /,find / -delete 照样能删。这一层兜得住最常见的误操作,兜不住有意绕过。生产上的思路反过来做:把 Agent 放进容器,只给它一个可写目录,其余只读挂载;或者用命令白名单,只放行 python、pytest、git 之类明确的命令。课程实现的是最低版本,价值在于把边界画出来。
读类宽松,写类严格
工具可以分成两类,设计心态相反。
| 读类(read / list_dir / glob / grep) | 写类(write / edit / bash) | |
|---|---|---|
| 失败成本 | 低,重试一次即可 | 高,文件已变更 |
| 副作用 | 无 | 有 |
| 模型策略 | 可以反复试错 | 每次调用要有明确意图 |
读类工具因此可以给足信息:路径不存在时返回”文件不存在: xxx”而不是抛异常,超过 100KB 自动截断,输出带行号。读失败一次,模型再试一次成本很低,工具应当友好、信息丰富。
写类工具则处处设防。write 默认拒绝覆盖已有文件,必须显式传 overwrite=True;edit 强制 old_content 唯一;bash 由注册表注入工作区边界,再过四道闸门。写坏一个文件,文件系统已经变了,回滚要靠人。失败成本不对称,投入方向也不同:读类工具多花心思在容错和信息量上,写类工具多花心思在拦截和校验上。
行号在两处的用法不同。读类工具的输出带行号,方便模型在对话里指认位置;编辑工具定位却不使用行号。同一个信息,用途完全不同:行号用来指路,不用来定位编辑。
出错返回字符串,不抛异常
以 edit 为例,所有异常情况都 return 字符串。
1 | import os |
抛异常的代价是整条 Agent 循环中断。工具抛出后,模型没有机会看到错误,也就没有机会修正,用户拿到的是一个崩掉的会话。返回字符串则把错误变成一条新的观察:模型读到”出现 3 次,请补充上下文”,下一步会去 read 更多内容、把锚点写长,再调一次。
错误消息的措辞因此也是设计的一部分。”失败了”没有信息量,”old_content 出现 3 次,请把前后几行一起放进 old_content”直接给出了下一步该做什么。工具的错误信息是写给模型看的提示,不是给人看的日志。
还有一类翻车不发生在单个工具内部:模型反复用同样的参数调用同一个工具,或者在同一个错误上打转。这需要循环层的最大轮次与重复调用检测来兜底,工具本身解决不了。
这四个选择指向同一个判断:工具是模型的执行器,也是它的护栏。参数怎么收、输出怎么排、错误怎么报,共同构成模型能看见的世界。这个世界越贴合模型的能力与短板,它独立完成任务的概率越高。
工具定好之后,剩下的是装配:把这些工具交给一个 Agent 循环,用哪种形态跑。下一节把前面几章的零件拼成一个能用的 Coding Agent。


