合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 15%

第 3 章

最小循环:20 行代码,和它立刻暴露的五个问题

上一章聊了选型,这一章开始动手。

先说好:这一章不装 Claude Agent SDK,也不装任何 Agent 框架。 就一个 while True,加上 Anthropic 的 Messages API。

我知道你想快点看到 SDK。但如果不先写一遍这个循环,后面 SDK 的每个选项你都只能死记:为什么要有 max_turns?为什么要有 can_use_tool?为什么要有 resume?你没被这些问题咬过,就记不住。

忍一下,就这一章。

如果你已经写过 Agent Loop,这一章可以快读;但建议至少跑一遍 --scripted,因为后面每一章都会回到这个任务。


3.1 先别写循环,先调一次模型

很多人一上来就写 while True,然后发现模型根本不理你。我们先退一步,看看一次调用能干什么。

python
import os

from anthropic import Anthropic

client = Anthropic()          # 读 ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL
MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")

reply = client.messages.create(
    model=MODEL,
    max_tokens=4096,
    system="You are a coding agent.",
    messages=[{"role": "user", "content": "修好 tests/test_core.py 里失败的测试"}],
)
print(reply.content[-1].text)

跑一下,它大概率会回你一段“我会先读测试文件,然后定位问题,再修改实现”之类的话。

听起来很靠谱。然后你去看仓库,文件一个没动。

因为模型只会“说”,不会“做”。它可以在回复里写“我要调用 read_file”,但真正去读文件的是你,不是它。

Messages API 里,模型想用工具时,会在 content 里放一个 tool_use 块,stop_reason 变成 tool_use。注意,是“块”,不是“执行结果”。执行是你的事。

这就是 Harness 的第一层:模型负责决定,Harness 负责执行。


3.2 给模型一只手:工具

我们给模型一个 read_file。工具定义长这样:

python
TOOLS = [
    {
        "name": "read_file",
        "description": "Read a file in the repository. Returns the text.",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
]

这三段东西,是发给模型看的:

  • name:工具叫什么。模型调的时候用这个名字。
  • description:什么时候该用它、返回什么。这不是注释,模型真的会读。
  • input_schema:参数长什么样,哪些必填。

真正干活的函数不发给模型:

python
def run_tool(name: str, args: dict) -> str:
    if name == "read_file":
        p = (ROOT / args["path"]).resolve()
        if p != ROOT and ROOT not in p.parents:
            return "ERROR: path escapes repo"
        return p.read_text(encoding="utf-8")
    return f"ERROR: unknown tool {name}"

ROOT 是 code/lab/taskflow。路径检查先做,防止模型给你一个 ../../.ssh/id_rsa。

现在模型能读文件了。但它还不会自己循环:它读完一个文件,下一轮就没了。你得把结果喂回去。


3.3 把结果喂回去:循环

完整的循环大概二十行:

python
messages = [{"role": "user", "content": task}]
step = 0

while True:
    if step >= 10:
        print("stopped: max steps")
        break
    step += 1

    reply = client.messages.create(
        model=MODEL, max_tokens=4096, system=SYSTEM,
        tools=TOOLS, messages=messages,
    )
    messages.append({
        "role": "assistant",
        "content": [b.model_dump() for b in reply.content],
    })

    calls = [b for b in reply.content if b.type == "tool_use"]
    if not calls:
        print(reply.content[-1].text)
        break

    results = [
        {"type": "tool_result", "tool_use_id": c.id, "content": run_tool(c.name, c.input)}
        for c in calls
    ]
    messages.append({"role": "user", "content": results})

逐行看,其实只有四件事:

  1. 把历史带上。 messages 是唯一的状态;API 不记得上一轮说过什么。
  2. 把模型这一轮原样存进去。 包括它想调用的工具;下一轮还要用。
  3. 执行工具,把结果包成 tool_result。 每个 tool_use_id 都要对上,不能少,也不能串。
  4. 没有工具调用就结束。 有的话,把结果塞回 user 消息,再转一圈。

最后那个 step >= 10 是刹车。没有它,模型可能一直读同一个文件,或者反复调一个失败的工具,你的账单会替你记住这件事。

这二十行就是 Agent Loop 的全部。剩下的大部分工程,都在它周围。

把这一圈跑起来,消息序列大概是这样:

text
user:      修好 tests/test_core.py
assistant: tool_use(read_file, {path: "tests/test_core.py"})
user:      tool_result(read_file, "...")
assistant: tool_use(read_file, {path: "taskflow/core.py"})
user:      tool_result(read_file, "...")
assistant: tool_use(edit_file, {...})
user:      tool_result("ERROR: unknown tool edit_file")
assistant: "我找到了 bug……先给我一个 edit_file 吧"

注意最后一条:工具报错也被当成一条普通的 tool_result 回填了,没有抛异常、没有崩。模型看到“unknown tool”,才知道自己该要什么。这就是第 4 章要讲的“错误回填”的雏形。


3.4 跑一遍:它卡在哪

真实模型要花钱,也可能走出不同的路径。仓库里给了一个 --scripted 模式,用离线替身回放一条固定的控制流,方便你看结构:

bash
cd code
python3 tiny/agent.py --scripted

输出:

text
mode: scripted (offline demo; not a real model output)
[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": "taskflow/core.py", "old": "return delta if delta > 0 else 1", "new": "return delta"}
       -> ERROR: unknown tool edit_file

assistant: 我找到了 bug:days_left 在到期当天返回了 1。但我只有 read_file,没有编辑工具,改不了。先给我一个 edit_file 吧。

它读到了测试,读到了实现,甚至找到了 bug。然后它想调 edit_file,但我们没给这个工具,于是拿到一个 ERROR: unknown tool,停在那儿。

这个失败很有意思:它不是模型不行,是 Harness 少给了一只手。

你可能会说:那就加个 edit_file 呗。

对,下一章就加。但加完你会看到第二个问题:它有了手之后,会去改测试。


3.5 这一章埋下的五个问题

别看这个循环只有二十行,它已经把后面几章的坑全挖好了。有的已经发生,有的是你加完工具立刻会遇到。

#这次暴露的问题具体表现哪一章补
1工具不够只能读,不能改、不能跑测试第 4 章
2没有权限一旦能改,它可能改测试、读 .env第 5 章
3上下文不全它没读 README,不知道“不许改测试”第 6 章
4上下文会涨读几个大文件,历史就顶到窗口第 7 章
5没有验证和恢复它说“改好了”不等于改好了;崩了只能重来第 8、9 章

一个一个说。

工具不够。 这个最直接。只有 read_file,它就是个“会读代码但不会改代码”的顾问。你得给它 edit_file、write_file、run_tests,它才能闭环。第 4 章会加,而且会讲工具描述怎么写、错误怎么回填、结果太大怎么截断。

没有权限。 这是加完工具立刻会遇到的。你给它 edit_file,它就能改 tests/test_core.py。你在系统提示里写“不要改测试”,它可能听,也可能不听;提示注入还能把这句话覆盖掉。第 5 章会把“不许改测试”变成代码里的规则,而不是一句请求。

上下文不全。 它只读了测试和实现,没读 README.md。项目约定“不要改测试,改实现”就写在里面。模型不是全知的;你没放进上下文的东西,等于不存在。第 6 章会讲上下文怎么组装、项目知识放哪、什么时候该按需读取。

上下文会涨。 现在只读两个小文件,看不出问题。真实仓库里,一个 core.py 可能几千行,一个测试日志可能几万行;每轮都把全部历史重发一遍,很快就顶到窗口。第 7 章会讲压缩、笔记、长任务。

没有验证和恢复。 它现在连“改”都做不到,所以还谈不上验证。但你可以预演一下:等它能改代码了,它跑一次测试,看到绿了,就说“完成”;测试可能是它自己改绿的。它也可能在跑到第 15 轮时进程崩了,你只能从头再来。第 8 章讲会话和恢复,第 9 章讲怎么把“完成标准”写成代码检查。

这五个问题不是并列的,是互相咬的。

你给 edit_file,就会引出权限问题;你给 README,上下文就会涨;你给会话,压缩时又可能把关键约定丢掉。所以别指望“一次配完”,得一层一层加,每加一层跑一次同一个任务,看它是变强了,还是又学会了新的偷懒方式。


3.6 现在它是什么,不是什么

现在它是一个真正的 Agent Loop:

  • 模型决定下一步;
  • Harness 执行工具;
  • 结果回填;
  • 模型再决定;
  • 没有工具调用就停。

但它还不是一个 coding agent。它能读,不能改;能说,不能验。

下一章,我们给它一双手:read_file、edit_file、write_file、run_tests。然后你会看到,它很可能第一件事就是去改测试。


3.7 动手

  1. 复现:cd code && python3 tiny/agent.py --scripted,确认你看到的是同一条控制流。有 Key 的话,再跑一次真实版本,看看模型是先读文件,还是先向你要工具。
  2. 破坏:把 step >= 10 改成 step >= 1,再跑;它会停在“刚读完测试”的地方。把 read_file 的 description 改成“读取文件”,再跑;看看模型还会不会准确填 path。
  3. 扩展:加一个 list_files 工具,description 写“列出仓库里的文件”,然后在任务里加一句“先看看仓库里有什么”。观察它会不会先用 list_files。

本章代码 完整文件:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/agent.py 离线运行:cd code && python3 tiny/agent.py --scripted 真实运行:pip install anthropic,设好 ANTHROPIC_API_KEY,再 python3 tiny/agent.py 示例仓库:https://github.com/kevinkelin/claude-agent-sdk-book/tree/master/code/lab/taskflow 说明:--scripted 是离线替身,不是模型真实输出;正式连载时链接会换成 tag。