第 4 章
工具:让模型能读、能改、能跑
从“只会读”到“能改代码”,以及它学会的第一件坏事。
上一章结束时,它只能读。找到 bug,改不了,停在那儿向我们要 edit_file。
这一章我们给它一双手:列文件、读文件、搜字符串、改文件、写文件、跑测试。
加完这些工具,它干的第一件事,是把测试改了。
mode: scripted (offline demo; not a real model output)
[tool] list_files {"pattern": "**/*.py"}
-> taskflow/__init__.py, taskflow/core.py, tests/__init__.py, tests/test_core.py
[tool] read_file {"path": "tests/test_core.py"}
-> unittest.main()
[tool] read_file {"path": "taskflow/core.py"}
-> return delta if delta > 0 else 1
[tool] edit_file {"path": "tests/test_core.py", "old": "assert days_left(task, date(2026, 10, 21)) == 0", "new": "assert days_left(task, date(2026, 10, 21)) == 1"}
-> edited tests/test_core.py
[tool] run_tests {}
-> OK
assistant: 修复完成。测试通过。你看,它现在会列文件、会读测试、会读实现、会改文件、会跑测试。一整套动作行云流水,最后还知道说“修复完成”。
唯一的问题是:它改的是测试。
这不是工具的 bug。工具给了它能力,也给了它偷懒的路径。这一章我们先把工具怎么加讲清楚,下一章再收拾它。
4.1 工具的两半:说明书和执行函数
先看一个工具的骨架:
@dataclass(frozen=True)
class Tool:
name: str
description: str
input_schema: dict
handler: Callable[[dict], str]
read_only: bool = False
def to_api(self) -> dict:
# 只把说明书发给模型;handler 永远留在本地
return {
"name": self.name,
"description": self.description,
"input_schema": self.input_schema,
}一个工具分两半:
- 说明书:
name、description、input_schema。这部分会进入每一轮请求,模型靠它决定“用不用、怎么填参数”。 - 函数:
handler。这部分永远不发给模型,只在你的进程里执行。
就像 API 文档和实现:模型只看接口,不拿源码。它只需要知道“有这个工具、叫什么、怎么传参”,不需要知道函数内部怎么执行。
to_api() 只导出前三段,就是这个意思。
顺便说一句:description 不是注释。它会占据上下文,也会影响模型的选择。你写“读取文件”,它可能不知道什么时候该读;你写“读取仓库里的文本文件;改之前先读,不要猜内容”,它就知道先读再改。
工具描述写得好不好,直接决定它会不会用、用得对不对。
| 坏描述 | 好描述 |
|---|---|
| “读取文件” | “读取仓库里的文本文件;改之前先读,不要猜内容” |
| “修改文件” | “精确替换第一处 old;old 必须从文件里原样复制,包括缩进” |
| “跑测试” | “跑 unittest;改完必须跑;返回 stdout/stderr” |
坏描述不是“不礼貌”,是“没给模型决策依据”。它不知道什么时候该用,就只能猜。
4.2 六个工具,够用了
这一章我们只加六个:
| 工具 | 读/写 | 干什么 | 什么时候用 |
|---|---|---|---|
list_files | 读 | 按 glob 列文件 | 不知道仓库结构时 |
read_file | 读 | 读一个文本文件 | 改之前必须先读 |
search | 读 | 搜一个字面字符串 | 找符号、定位大文件 |
edit_file | 写 | 精确替换第一处 old | 改已有文件 |
write_file | 写 | 整文件写入 | 新建文件 |
run_tests | 执行 | 跑测试套件 | 改完必须跑 |
为什么是这六个?因为它们刚好覆盖一个 coding agent 的最小闭环:
找文件 → 读文件 → 改文件 → 跑测试 → 看结果 → 再改少一个,闭环就断。比如只有 read_file,它就是个顾问;只有 edit_file,它连文件在哪都不知道;没有 run_tests,它改完只能靠猜。
但也不是越多越好。每多一个工具,工具 schema 就长一截,模型的选择就难一分,你的 token 账单也厚一点。第 6 章会讲上下文预算;这里先记住一句:工具不是越多越好,是刚好够用最好。
4.3 为什么改文件要用 edit_file,而不是 write_file
这是这一章最值得记住的一个设计。
write_file 很直接:给路径、给内容,整文件覆盖。听起来很爽,但它有个大问题:模型要把整个文件重新写一遍。
一个 500 行的文件,它得把 500 行都吐出来。中间漏了一行、少了一个缩进、把一个无关函数删了,你很难第一时间发现。更糟的是,它可能“顺手”重构了你没让它动的地方。
edit_file 不一样:它只要求模型给出“要替换的旧字符串”和“新字符串”。
def edit_file(args: dict) -> str:
p = _inside(args["path"])
text = p.read_text(encoding="utf-8")
old, new = args["old"], args["new"]
if old not in text:
raise ValueError(
"old string not found; read the file again and copy the exact text, "
"including indentation"
)
p.write_text(text.replace(old, new, 1), encoding="utf-8")
return f"edited {args['path']}"两个细节:
- 只替换第一处。
replace(old, new, 1)。如果同一个字符串在文件里出现多次,全部替换可能会改到不该改的地方;只改第一处,配合“改完跑测试”,更稳。 - 找不到就报错,不猜。 报错信息里明确写“重新读文件,把缩进也抄对”。模型看到这条,通常会回去
read_file,而不是硬编一个替换。
write_file 留着,但只建议用于新文件。改已有文件,优先 edit_file。这不是风格问题,是 diff 大小和可审查性的问题。
我第一次写 edit_file 的时候,没做这个检查。模型传了一个少两格缩进的 old,replace 什么都没替换,函数照样返回 edited。模型以为改完了,跑测试还是红;它又改了一遍,还是红。最后我把文件打印出来才发现:它根本没匹配上。
4.4 工具报错,不要抛给调用方
工具会出错:路径越界、字符串找不到、测试跑挂、命令超时。
最差的做法是让异常一路冒到循环外面,整个 Agent 崩掉。更好的做法是:把错误包成一条工具结果,回填给模型。
我一开始就是让异常直接冒出去的。结果模型传了一个越界路径,整个循环崩了,前面的 10 轮白跑。
def run(self, name: str, args: dict) -> ToolResult:
tool = self.tools.get(name)
if tool is None:
return ToolResult(f"ERROR: unknown tool {name}. Available: ...", is_error=True)
try:
out = tool.handler(args)
except Exception as e:
return ToolResult(f"ERROR: {type(e).__name__}: {e}", is_error=True)
if len(out) > MAX_RESULT:
out = out[:MAX_RESULT] + f"\n... [truncated; {len(out)} chars total]"
return ToolResult(out)然后在循环里,把 is_error 一起塞进 tool_result:
results.append({
"type": "tool_result",
"tool_use_id": call.id,
"content": res.text,
"is_error": res.is_error,
})这样模型看到的不是“程序崩了”,而是“这个工具报错了,原因是什么”。它可以重新读文件、换一个路径、换一种改法。
这就像编译错误:告诉模型哪一行错了、错在哪,它才有机会改;你直接让进程 core dump,它下次还是不知道怎么办。
4.5 结果太大,先截断
read_file 读一个 5MB 的日志,run_tests 跑出一个几万行的失败输出——这些东西整块塞回上下文,下一轮就爆了。
所以注册表里有一个 MAX_RESULT:
MAX_RESULT = 8000
if len(out) > MAX_RESULT:
out = out[:MAX_RESULT] + f"\n... [truncated; {len(out)} chars total]"8,000 字符不是金科玉律,你可以按模型和预算调。但一定要有上限。
注意,截断不是“省 token 的小技巧”,它是上下文预算的一部分。你不截,模型下一轮就得为这几万行付钱;更糟的是,关键信息可能被挤到窗口外面。
第 6、7 章会讲更系统的上下文管理;这里先把“工具返回的东西要有边界”这件事做掉。
4.6 只读工具可以并行,写工具必须排队
模型一次可以返回多个 tool_use。比如它想同时读三个文件:
tool_use(read_file, {path: "a.py"})
tool_use(read_file, {path: "b.py"})
tool_use(read_file, {path: "c.py"})读文件互不影响,可以并行;edit_file/write_file 改的是同一片文件系统,必须串行,否则两个写操作可能互相覆盖。
这一章的代码先全部串行,简单、可预测。等第 8 章讲子 Agent 和调度时,再把只读工具并行起来。
先记住规则:读可以并行,写必须排队。
4.7 再跑一次:它还是改了测试
回到开头那个输出。
它列了文件,读了测试,读了实现,然后改测试,跑绿,宣布完成。
从工具的角度看,这一切都“成功”了:edit_file 返回 edited,run_tests 返回 OK。没有任何一个工具报错。
但它做的事是错的。
为什么?因为工具只负责“能不能做”,不负责“该不该做”。
- 它能改测试,因为
edit_file不区分taskflow/core.py和tests/test_core.py; - 它愿意改测试,因为它的完成信号是“测试通过”,改测试是最短路径;
- 它没有被拦住,因为系统提示里没有“不许改测试”,就算有,那也只是请求,不是约束。
这三个问题,分别对应后面的三章:
- 工具的能力边界 → 这一章已经看到了;
- 权限与审批 → 第 5 章;
- 上下文与完成标准 → 第 6、9 章。
所以别急着怪模型。它只是在一个没有规则、只看结果的环境里,找到了最省力的解法。
4.8 工具设计的五条经验
这一章踩过的坑,收成五条:
- 说明书和执行函数分开。 模型只看 schema,handler 永远留在本地。
- 改已有文件用
edit_file,不要write_file。 前者 diff 小、可审查,后者容易顺手删东西。 - 工具错误回填
is_error,不要抛异常。 让模型有机会纠正,而不是让循环崩掉。 - 大结果必须截断。 截断不是省 token,是保护上下文。
- 工具越少越好,描述越准越好。 每多一个工具,模型就多一个选错的机会。
还有一条不算经验,算提醒:工具给了模型能力,也给了它偷懒的路径。 你给它 edit_file,它就可能改测试;你给它 write_file,它就可能重写整个文件;你给它 run_tests,它就可能把测试改绿。
工具本身没有价值观。价值观要写在权限和完成标准里。
4.9 动手
- 复现:
cd code && python3 -m tiny.agent_tools --scripted。它会改掉tests/test_core.py里的断言。跑完记得改回去:把== 1改回== 0,或者git checkout -- code/lab/taskflow/tests/test_core.py。 - 破坏:把
edit_file里的replace(old, new, 1)改成全部替换;或者在找不到old时不报错,直接返回“已编辑”。再跑一次,看看模型会怎么被误导。 - 扩展:加一个
git_diff工具,返回git diff --stat。然后在任务里加一句“改完先看 diff”。观察它会不会用。
本章代码 工具注册表:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/tools.py 带工具的 Agent:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/agent_tools.py 离线运行:
cd code && python3 -m tiny.agent_tools --scripted真实运行:pip install anthropic,设好ANTHROPIC_API_KEY,再python3 -m tiny.agent_tools说明:--scripted是离线替身,不是模型真实输出;正式连载时链接会换成 tag。