第 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,然后发现模型根本不理你。我们先退一步,看看一次调用能干什么。
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。工具定义长这样:
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:参数长什么样,哪些必填。
真正干活的函数不发给模型:
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 把结果喂回去:循环
完整的循环大概二十行:
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})逐行看,其实只有四件事:
- 把历史带上。
messages是唯一的状态;API 不记得上一轮说过什么。 - 把模型这一轮原样存进去。 包括它想调用的工具;下一轮还要用。
- 执行工具,把结果包成
tool_result。 每个tool_use_id都要对上,不能少,也不能串。 - 没有工具调用就结束。 有的话,把结果塞回
user消息,再转一圈。
最后那个 step >= 10 是刹车。没有它,模型可能一直读同一个文件,或者反复调一个失败的工具,你的账单会替你记住这件事。
这二十行就是 Agent Loop 的全部。剩下的大部分工程,都在它周围。
把这一圈跑起来,消息序列大概是这样:
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 模式,用离线替身回放一条固定的控制流,方便你看结构:
cd code
python3 tiny/agent.py --scripted输出:
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 动手
- 复现:
cd code && python3 tiny/agent.py --scripted,确认你看到的是同一条控制流。有 Key 的话,再跑一次真实版本,看看模型是先读文件,还是先向你要工具。 - 破坏:把
step >= 10改成step >= 1,再跑;它会停在“刚读完测试”的地方。把read_file的 description 改成“读取文件”,再跑;看看模型还会不会准确填path。 - 扩展:加一个
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。