本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

工具写成什么样,决定了模型能不能自己把活干完。功能跑通只是及格线,差别出现在模型犯错的时候:错误被工具吞掉,还是以模型能读懂的形式回到对话里。

定位用内容,不用行号

设想 edit 工具接收 line_number 参数。第 1 步在第 5 行前插入 3 行新代码,文件变长,原第 9 行被推到第 12 行。第 2 步要改”原本的第 12 行”,模型手里的行号还是上一次 read 时的快照,于是改到了别处。它不知道行号变了,也不会主动重读一遍。

1
2
edit_at_line("main.py", line=5, mode="insert_before", content="...")
edit_at_line("main.py", line=12, mode="replace", content="...") # 目标已经移位

按内容定位就没有这个问题。edit 接收 old_content 与 new_content,替换的是那段文本本身,前面增删多少行都不影响。模型看到的世界本来就是文本,行号是展示层加上去的坐标,用它做定位等于把显示格式当成了数据。

1
2
edit("main.py", old_content="def greet():\n    print('hi')",
new_content="def greet():\n print('hello')")

唯一性约束是这条设计的另一半。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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os

def edit(path: str, old_content: str, new_content: str) -> str:
if not os.path.isfile(path):
return f"文件不存在: {path}"
with open(path, encoding="utf-8") as f:
content = f.read()
count = content.count(old_content)
if count == 0:
return "未找到匹配内容,请用 read 确认文件当前内容"
if count > 1:
return f"old_content 出现 {count} 次,请补充上下文让它唯一"
with open(path, "w", encoding="utf-8") as f:
f.write(content.replace(old_content, new_content, 1))
return f"已替换 {path} 中的内容"

抛异常的代价是整条 Agent 循环中断。工具抛出后,模型没有机会看到错误,也就没有机会修正,用户拿到的是一个崩掉的会话。返回字符串则把错误变成一条新的观察:模型读到”出现 3 次,请补充上下文”,下一步会去 read 更多内容、把锚点写长,再调一次。

错误消息的措辞因此也是设计的一部分。”失败了”没有信息量,”old_content 出现 3 次,请把前后几行一起放进 old_content”直接给出了下一步该做什么。工具的错误信息是写给模型看的提示,不是给人看的日志。

还有一类翻车不发生在单个工具内部:模型反复用同样的参数调用同一个工具,或者在同一个错误上打转。这需要循环层的最大轮次与重复调用检测来兜底,工具本身解决不了。

这四个选择指向同一个判断:工具是模型的执行器,也是它的护栏。参数怎么收、输出怎么排、错误怎么报,共同构成模型能看见的世界。这个世界越贴合模型的能力与短板,它独立完成任务的概率越高。

工具定好之后,剩下的是装配:把这些工具交给一个 Agent 循环,用哪种形态跑。下一节把前面几章的零件拼成一个能用的 Coding Agent。