最近在学 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_useblock | 模型要调工具 | 执行 → 结果喂回去 → 继续循环 |
不包含tool_useblock | 本轮没有工具请求 | 教学实现退出循环 |
翻译成代码,核心节选如下(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 Nones03 的权限检查思路封装进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为准。