合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 19%

第 4 章

工具:让模型能读、能改、能跑

从“只会读”到“能改代码”,以及它学会的第一件坏事。

上一章结束时,它只能读。找到 bug,改不了,停在那儿向我们要 edit_file。

这一章我们给它一双手:列文件、读文件、搜字符串、改文件、写文件、跑测试。

加完这些工具,它干的第一件事,是把测试改了。

text
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 工具的两半:说明书和执行函数

先看一个工具的骨架:

python
@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 的最小闭环:

text
找文件 → 读文件 → 改文件 → 跑测试 → 看结果 → 再改

少一个,闭环就断。比如只有 read_file,它就是个顾问;只有 edit_file,它连文件在哪都不知道;没有 run_tests,它改完只能靠猜。

但也不是越多越好。每多一个工具,工具 schema 就长一截,模型的选择就难一分,你的 token 账单也厚一点。第 6 章会讲上下文预算;这里先记住一句:工具不是越多越好,是刚好够用最好。


4.3 为什么改文件要用 edit_file,而不是 write_file

这是这一章最值得记住的一个设计。

write_file 很直接:给路径、给内容,整文件覆盖。听起来很爽,但它有个大问题:模型要把整个文件重新写一遍。

一个 500 行的文件,它得把 500 行都吐出来。中间漏了一行、少了一个缩进、把一个无关函数删了,你很难第一时间发现。更糟的是,它可能“顺手”重构了你没让它动的地方。

edit_file 不一样:它只要求模型给出“要替换的旧字符串”和“新字符串”。

python
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']}"

两个细节:

  1. 只替换第一处。 replace(old, new, 1)。如果同一个字符串在文件里出现多次,全部替换可能会改到不该改的地方;只改第一处,配合“改完跑测试”,更稳。
  2. 找不到就报错,不猜。 报错信息里明确写“重新读文件,把缩进也抄对”。模型看到这条,通常会回去 read_file,而不是硬编一个替换。

write_file 留着,但只建议用于新文件。改已有文件,优先 edit_file。这不是风格问题,是 diff 大小和可审查性的问题。

我第一次写 edit_file 的时候,没做这个检查。模型传了一个少两格缩进的 old,replace 什么都没替换,函数照样返回 edited。模型以为改完了,跑测试还是红;它又改了一遍,还是红。最后我把文件打印出来才发现:它根本没匹配上。


4.4 工具报错,不要抛给调用方

工具会出错:路径越界、字符串找不到、测试跑挂、命令超时。

最差的做法是让异常一路冒到循环外面,整个 Agent 崩掉。更好的做法是:把错误包成一条工具结果,回填给模型。

我一开始就是让异常直接冒出去的。结果模型传了一个越界路径,整个循环崩了,前面的 10 轮白跑。

python
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:

python
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:

python
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。比如它想同时读三个文件:

text
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 工具设计的五条经验

这一章踩过的坑,收成五条:

  1. 说明书和执行函数分开。 模型只看 schema,handler 永远留在本地。
  2. 改已有文件用 edit_file,不要 write_file。 前者 diff 小、可审查,后者容易顺手删东西。
  3. 工具错误回填 is_error,不要抛异常。 让模型有机会纠正,而不是让循环崩掉。
  4. 大结果必须截断。 截断不是省 token,是保护上下文。
  5. 工具越少越好,描述越准越好。 每多一个工具,模型就多一个选错的机会。

还有一条不算经验,算提醒:工具给了模型能力,也给了它偷懒的路径。 你给它 edit_file,它就可能改测试;你给它 write_file,它就可能重写整个文件;你给它 run_tests,它就可能把测试改绿。

工具本身没有价值观。价值观要写在权限和完成标准里。


4.9 动手

  1. 复现:cd code && python3 -m tiny.agent_tools --scripted。它会改掉 tests/test_core.py 里的断言。跑完记得改回去:把 == 1 改回 == 0,或者 git checkout -- code/lab/taskflow/tests/test_core.py。
  2. 破坏:把 edit_file 里的 replace(old, new, 1) 改成全部替换;或者在找不到 old 时不报错,直接返回“已编辑”。再跑一次,看看模型会怎么被误导。
  3. 扩展:加一个 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。