最近在学 AI Agent 开发,我把 learn-claude-code 的前五章整理成了这篇学习笔记:从最小的 Agent Loop 出发,逐步加入工具分发、权限检查、Hooks 和 TodoWrite,理解一个编码 Agent 的运行骨架。
**读完你会得到什么:**知道模型如何提出工具调用、程序如何执行并回传结果,以及如何在循环周围增加权限、扩展点和计划状态。
本文讲的是开源课程中的教学实现,不是 Claude Code 官方源码或完整复刻。文中代码为核心节选,需结合仓库中的完整脚本运行;不含真实 API 调用的性能评测结论。
零、先把环境跑起来
建议使用 Python 3.10+。以下命令以 macOS/Linux 终端为例,五章使用同一套依赖:
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env
在本地编辑 .env,填写 ANTHROPIC_API_KEY 和 MODEL_ID。如使用兼容服务,还需设置其文档指定的 ANTHROPIC_BASE_URL,并使用该服务提供的密钥及模型标识。不要把真实密钥写进代码、截图或提交到 Git。
在独立的练习目录启动,而不是直接让 Agent 修改课程源码:
mkdir -p practice-workspace
cd practice-workspace
python ../s01_agent_loop/code.py
# 输入 q 退出,再依次启动其他章节
python ../s02_tool_use/code.py
python ../s03_permission/code.py
python ../s04_hooks/code.py
python ../s05_todo_write/code.py
这些脚本以启动时的当前目录作为工作区。独立目录能减少误改源码的机会,但目录不是沙箱:Shell 仍可能访问目录之外的资源。更严格的实验应在不挂载敏感目录的容器或隔离环境中进行。
首次可以输入:"在当前目录创建 hello.py,输出 Hello Agent,运行它并解释结果。"预期观察到的是"模型提出调用 → 程序执行 → 结果回传 → 模型继续"的闭环;具体调用顺序取决于模型。
版本说明:本文对照课程提交
0dcafa2中的s01_agent_loop~s05_todo_write目录整理。仓库同时保留了agents/、docs/下的另一套章节编号,请以本文给出的目录名为准。
一、先校准一个认知:Agent 产品 = 模型 + Harness
动手之前,先把课程开篇的一个观点搬过来,因为它决定了后面所有代码的写法:
课程强调:模型提供感知、推理和决策能力,Harness 提供它实际工作的环境。 这是一种帮助划分工程职责的视角,并不意味着工作流编排、提示词和外部状态没有价值。
但一个能干活的 Agent 产品,光有模型不够。模型是驾驶者,harness 是载具------工具、知识、观测接口、执行接口、权限边界,全都在 harness 这一层:
Harness = Tools + Knowledge + Observation + Action + Permissions
为了理解编码 Agent,可以把常见机制概括成下面这张清单;它是教学上的抽象,不代表官方产品的完整内部实现:
Coding Agent ≈ agent loop + 工具 + 按需技能加载 + 上下文压缩
+ 子 agent + 任务系统 + 权限治理 + hooks + memory + MCP
本文聚焦其中五个基础机制:循环、工具、权限、Hooks 和计划管理。子 Agent、技能加载和上下文压缩留到后续讨论。
二、s01 Agent Loop:一个循环 + 一个工具 = 一个 Agent
2.1 问题:模型不会自己"接着干"
你问大模型"帮我列一下目录里的文件,然后执行 xxx.py"。它能输出一条 bash 命令,但输出完就停了------不会自己执行,也看不到执行结果。你只能手动跑一遍、把输出贴回去、等下一条命令、再跑一遍......
每一个来回,你都是中间层。 把这个中间层自动化,就是 Agent 的全部起点。
2.2 解法:while True + tool_use 判断
核心判断逻辑只有一张表:
| 响应里的信号 | 含义 | 循环动作 |
|---|---|---|
包含 tool_use block |
模型要调工具 | 执行 → 结果喂回去 → 继续循环 |
不包含 tool_use block |
本轮没有工具请求 | 教学实现退出循环 |
翻译成代码,核心节选如下(client、MODEL、SYSTEM、TOOLS 和 run_bash 由完整脚本提供):
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
# 教学约定:没有工具请求就结束本轮循环
tool_calls = [b for b in response.content if b.type == "tool_use"]
if not tool_calls:
return
# 执行每个工具调用,收集结果
results = []
for block in tool_calls:
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
# 工具结果作为 user 消息喂回去,循环继续
messages.append({"role": "user", "content": results})
配套的 bash 工具包含示意性的拒绝列表、120 秒超时和输出截断。下面保留异常处理,避免超时直接打断示例:
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, errors="replace", timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except OSError as exc:
return f"Error: {exc}"
核心循环只需要几十行;完整程序还需要客户端初始化、工具声明、执行函数和命令行入口。分工非常清晰:
- 模型负责决策------要不要调工具、调哪个、什么时候停;
- harness 负责执行 ------真跑命令,把结果塞回
messages。
这里的
shell=True会让 Shell 解释模型生成的命令。s01 已有简单拒绝列表,但它和 s03 的权限示例都不能替代真正的隔离机制。
一次可能的执行轨迹是 ls → 写入 hello.py → python hello.py → 总结结果。轨迹不是固定流程,模型也可能直接创建并执行文件。
2.3 比 while True 更重要的是消息协议
先把包含 tool_use 的完整 assistant 响应加入历史,再把对应的 tool_result 作为下一条 user 消息回传。每个结果的 tool_use_id 必须匹配原调用的 id;这里的 user 角色是在承载工具结果,不代表又有一个人输入了指令。
同一响应中可能有多个调用,应该逐个处理并返回对应结果。未知工具、执行失败和权限拒绝也应给出明确结果,避免调用与结果失配。生产实现还应区分 stop_reason、输出截断和网络失败;"没有工具调用"不等于"任务已经验证成功",max_tokens=8000 也不是整个任务的预算。
三、s02 Tool Use:用分发表扩展工具
3.1 只有 bash 的痛点
模型想的是"读这个文件",却被迫翻译成 cat path/to/file;想写文件得拼 echo "..." >。多一层翻译,浪费 token,还容易拼错。
3.2 解法:dispatch map 查表分发
s02 加了 4 个专用工具(read_file / write_file / edit_file / glob),循环里唯一的变动是把硬编码的 run_bash() 换成查表:
# 将执行位置改为工具分发
handler = TOOL_HANDLERS[block.name] # 查表
output = handler(**block.input) # 调用
而 TOOL_HANDLERS 就是个普通的字典:
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
新增工具需要实现 handler、补充 TOOLS 中的 JSON Schema,并注册到分发表。建立通用分发后,通常不必再改循环的控制结构。 这就是开闭原则在 Agent 架构里的样子。
两个实现细节值得抄走:
① safe_path 防路径逃逸------文件工具的入参先解析再校验,不准摸工作区外的文件:
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
② 多工具调用 ------模型经常一次返回多个 tool_use(比如"读 a.py 和 b.py 再列出所有 .py"),按 response.content 的原始顺序逐个执行即可,不用你自己搞并发。
专用工具让"读文件""替换一段文本"等意图直接对应函数调用,减少手工拼接 Shell 命令的需要,但不能据此断言模型出错率一定下降。运行时仍需校验参数、处理未知工具和捕获执行异常。
safe_path 只约束使用它的文件工具,不能限制 bash 内部的行为,也不能消除路径检查与实际打开文件之间的竞态。
四、s03 Permission:先划边界,再给自由
4.1 问题
s02 的 Agent 有 5 个工具了,文件工具有 safe_path 检查,但 bash 仍只有简单字符串拦截------让它"清理一下项目",它真可能给你 rm -rf。
安全边界必须由代码 负责,而且判断要发生在工具执行之前。
4.2 解法:三道闸门的权限管线
每个工具调用都经过权限判断,但只有命中审批规则时才需要询问用户:
tool_use → 拒绝列表:命中则拒绝
→ 审批规则:未命中则允许;命中则询问用户
→ 用户批准后执行;拒绝则回传拒绝结果
闸门 1:硬拒绝列表,命中就没得商量:
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"Blocked: '{pattern}' is on the deny list"
return None
闸门 2:规则匹配 ,描述"什么情况需要问人"。比如用正则识别独立的 rm/del 命令(注意不会误伤 model、delimiter 这种词):
DESTRUCTIVE_COMMAND_WORD = re.compile(
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)
def contains_destructive_command(command: str) -> bool:
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
PERMISSION_RULES = [
{"tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Access outside workspace"},
{"tools": ["bash"],
"check": lambda args: contains_destructive_command(args.get("command", "")) or
any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command"},
]
闸门 3:用户审批,终端暂停,等你按 y/N。
在工具执行前接入权限判断,并补上拒绝分支:
for block in tool_calls:
if not check_permission(block): # ← s03 新增
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": "Permission denied."})
continue
output = TOOL_HANDLERS[block.name](**block.input) # s02 原有
有个细节很讲究:被拒绝也要把 "Permission denied." 作为 tool_result 喂回给模型,而不是静默丢弃。这样模型能理解限制,选择获准的替代方案或向用户说明无法完成;不应把拒绝理解成可以绕过同一权限去重试。
⚠️ 教学诚实度拉满的一点:课程明确说了拒绝列表用简单字符串匹配只是示意闸门的位置,不能当完整安全边界。生产环境要上真正的命令解析和沙箱。
五、s04 Hooks:挂在循环上,不写进循环里
5.1 问题:循环在膨胀
s03 的权限检查是硬编码在循环里的。如果再想加"记录每次 bash 调用"、"操作后自动 git add",就得继续往 agent_loop 里塞:
for block in response.content:
log_to_file(block) # 加一行
check_permission(block) # 加一行
notify_slack(block) # 又加一行
output = execute(block)
auto_git_add(block) # 再加一行......循环很快认不出来了
你想扩展的是 Agent 的行为 ,改的却是循环本身。循环应该是稳定内核,扩展应该挂在外面。
5.2 解法:事件注册表 + 触发器
四个事件,覆盖一次完整的 agent cycle:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
UserPromptSubmit |
用户输入提交后、进 LLM 前 | 输入校验、注入上下文 |
PreToolUse |
工具执行前 | 权限检查、日志 |
PostToolUse |
工具执行后 | 副作用、输出检查 |
Stop |
循环即将退出 | 收尾统计、决定要不要继续 |
实现是个极简的注册表:
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # 短路后续回调,含义由调用方决定
return result
return None
s03 的权限检查思路封装进 permission_hook,注册为 PreToolUse;再加日志、大输出告警、会话统计等 hook,各管各的:
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook) # s03 的逻辑,从循环里搬出来
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)
循环里的控制流变得非常干净,而且有两个精巧的返回值约定:
-
PreToolUse返回非空的拒绝理由字符串 → 调用方阻止工具执行,并将理由作为tool_result回传; -
Stop返回非空的继续提示字符串 → 调用方将其作为新消息注入,继续循环:if not tool_calls:
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue # hook 说还没完,那就接着跑
return
这里有个细节:分发器使用 is not None,调用方却使用 if force / if blocked。空字符串和 False 会短路后续 Hook,但不会触发调用方的阻止或续跑分支。因此最好统一约定"无动作返回 None,有动作返回非空字符串"。
Hook 的返回值是否生效还取决于接入点。课程中的 UserPromptSubmit 示例只打印工作目录,并未真正注入上下文;PostToolUse 的返回值也没有被用于替换工具输出。若要扩展这些能力,需要显式处理返回值。
注册顺序同样重要:权限 Hook 放在日志 Hook 前面时,被拒绝的调用会跳过后面的日志 Hook。需要完整审计时,应把记录拒绝的逻辑放到明确的审计位置。Stop Hook 的续跑则应配合最大轮数、时间或费用预算,避免无限循环。
六、s05 TodoWrite:没有计划的 Agent,做着做着就偏了
6.1 问题:长任务的注意力稀释
给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case,跑测试,修好失败的。"
它改了 3 个文件、跑了个测试、发现 2 个失败,开始修------修着修着,忘了最初的目标是改命名,注意力全被测试失败吸走了。长对话中的工具输出和局部问题可能让模型偏离原始目标。这里描述的是需要防范的失败模式,并非所有模型必然出现的结果。
6.2 解法:一个"只管计划"的工具
s05 新增 todo_write 工具。注意它的定位:不增加文件或命令执行能力,而是提供可更新的计划状态------它只更新计划状态,实际工作仍由原有 5 个工具完成。
TodoManager 维护一份带状态的任务列表([ ] 待办、[>] 进行中、[x] 完成)。下面用简化实现展示校验和整体更新;课程源码另含字符串入参兼容处理:
class TodoManager:
def __init__(self):
self.items = []
def update(self, todos: list) -> str:
if not isinstance(todos, list) or len(todos) > 20:
raise ValueError("Expected a list with at most 20 todos")
validated = []
for todo in todos:
if not isinstance(todo, dict):
raise ValueError("Each todo must be an object")
content = todo.get("content", "")
status = todo.get("status", "pending")
if not isinstance(content, str) or not content.strip():
raise ValueError("Content must be a non-empty string")
if status not in ("pending", "in_progress", "completed"):
raise ValueError("Invalid status")
validated.append({"content": content.strip(), "status": status})
if sum(t["status"] == "in_progress" for t in validated) > 1:
raise ValueError("Only one todo can be in_progress")
self.items = validated
return self.render()
def render(self) -> str:
markers = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}
return "\n".join(
f"{markers[t['status']]} {t['content']}"
for t in self.items
) or "No todos."
更准确地说,这个实现允许最多一个 in_progress,也允许全部 pending 或全部 completed。它约束的是清单状态,不是对模型注意力的硬保证。update 会用新列表整体替换旧列表,因此调用时应提交希望保留的完整清单;completed 也只是状态声明,仍需以文件、执行结果或测试记录验证。
6.3 Reminder:harness 主动提醒,而不是祈祷模型自觉
光有工具不够,模型聊嗨了会忘了更新计划。s05 在循环里加了一个 reminder 计数器:连续三轮工具调用没用 todo_write,就把提醒追加到第三轮的工具结果里:
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
results.append({"type": "text",
"text": "<reminder>Update your todos.</reminder>"})
rounds_since_todo = 0
配合 SYSTEM 提示里的"先计划再执行"引导,Agent 收到复杂任务的典型行为变成:
todo_write(列出 5 步,全 pending)
→ 做第 1 步:todo_write 标 in_progress
→ 用 bash/edit 干活
→ 完成后标 completed,看下一个 pending
→ ......直到全部 [x]
这是一个便于学习的 TodoWrite 实现,不等同于官方产品内部实现。它通过结构化工具、状态校验和周期提醒,让模型更容易持续跟踪任务。
注意,计数单位是一轮模型响应,不是单个工具。该实现以是否执行到 todo_write 分支重置计数,未进一步判断更新是否成功;提醒只是上下文中的普通文本,也不保证模型一定服从。计划保存在内存里,进程退出后不会自动持久化。
七、收个尾:五章下来,架构长什么样
回头看这五章,其实是一条非常干净的递进线:
| 章节 | 机制 | 对循环的改动 | 格言 |
|---|---|---|---|
| s01 | Agent Loop | 从零建立 while True |
一个工具 + 一个循环 = 一个 Agent |
| s02 | Tool Use | 执行处换成查表分发 | 工具实现、声明与注册配套 |
| s03 | Permission | 执行前加入权限判断和拒绝分支 | 先检查,再执行 |
| s04 | Hooks | 硬编码检查换成 trigger_hooks |
挂在循环上,不写进循环里 |
| s05 | TodoWrite | +6 号工具 + reminder 计数器 | 没有计划的 agent 走哪算哪 |
在本文对照的版本中,s05 完整脚本约 362 行(含注释和空行),不是前五章文件加起来只有这么多。到这里,教学骨架已经齐了:决策归模型,执行归 harness,扩展走 hook,安全走闸门,规划走结构化状态。保持稳定的是"请求模型 → 执行工具 → 回传结果"的闭环;循环内部确实随着权限、Hooks 和提醒机制而扩展。
后续章节还有更多好玩的东西:s06 子 Agent(上下文隔离)、s07 技能按需加载、s08 上下文压缩、s10 任务系统、s13 多 Agent 协作......如果我勤快的话,下篇继续整理 s06~s10,感兴趣的可以先去仓库自己跑。
三个实践建议收尾:
- 一定要动手跑 ,每一章的
code.py都是独立可运行的,观察"模型什么时候调工具、什么时候停"比看十篇文章都有用; - 所有章节都先在隔离练习环境运行,s03 加入审批并不代表脚本已经具备生产级安全性;
- 兼容端点要逐项验证。除了 base URL 和模型名称,还要使用对应服务的密钥,确认其支持 Anthropic Messages 协议、工具 Schema、多个工具结果及调用 ID 配对。只支持 OpenAI 风格接口的端点不能直接填入。
八、如何判断自己真的跑通了
不要只看最后一句"已完成",可以给五章分别设计一个小实验:
| 章节 | 实验 | 应检查的证据 |
|---|---|---|
| s01 | 创建并运行 hello.py | 文件内容、执行输出与总结一致 |
| s02 | 读取并替换一个临时文件中的指定文本 | 使用专用工具,修改范围符合预期 |
| s03 | 对练习目录中的测试文件提出删除请求,并在审批时拒绝 | 文件仍存在,模型收到拒绝结果 |
| s04 | 执行一次读文件操作 | 日志体现执行前、执行后的接入顺序 |
| s05 | 创建文件、运行、验证三个步骤 | 清单状态随工作更新,completed 有结果支撑 |
权限测试只用自己创建的临时文件,不要拿真实目录测试破坏性命令。若模型没有按预期调用某个工具,先观察实际响应和工具参数,不要把示例轨迹当成固定脚本。
常见问题可以按下面的顺序排查:
| 现象 | 优先检查 |
|---|---|
KeyError: MODEL_ID |
.env 是否位于正确位置,变量是否填写 |
| 401/403 | 密钥、端点和账户权限是否匹配 |
| 模型不存在或 404 | 服务实际支持的模型 ID 和 base URL 路径 |
tool_use / tool_result 相关 400 |
是否保留 assistant 调用块、ID 是否逐一配对、结果消息位置是否正确 |
| 模型只解释、不执行 | 工具声明是否传入,模型是否支持工具调用,提示是否明确要求操作 |
| 计划更新了但任务没有完成 | 查看实际文件和执行结果,不能只信清单状态 |
当教学脚本要变成长期运行的应用时,还需要补充循环预算、取消机制、重试策略、异常隔离和持久化。重试工具要区分读操作与有副作用的操作,避免重复写入;Hook 抛异常也应有明确处理规则。这些都是最小闭环之外的工程工作。
参考与代码来源
- learn-claude-code 开源仓库(MIT License)。
- 本文对照的源码版本:0dcafa2。重点阅读其中的
s01_agent_loop、s02_tool_use、s03_permission、s04_hooks、s05_todo_write目录。
本文基于上述代码进行学习整理,节选有删减、注释调整和解释性改写;完整运行以对应版本的 code.py 为准。