:从 Agent Loop 到工具、权限、Hooks 与任务规划)
最近在学 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_loops05_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一个循环 一个工具 一个 Agent2.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( modelMODEL, systemSYSTEM, messagesmessages, toolsTOOLS, max_tokens8000, ) 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, shellTrue, cwdos.getcwd(), capture_outputTrue, textTrue, errorsreplace, timeout120, ) 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 fError: {exc}核心循环只需要几十行完整程序还需要客户端初始化、工具声明、执行函数和命令行入口。分工非常清晰模型负责决策——要不要调工具、调哪个、什么时候停harness 负责执行——真跑命令把结果塞回messages。这里的shellTrue会让 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_tokens8000也不是整个任务的预算。三、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(fPath 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 fBlocked: {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 Reminderharness 主动提醒而不是祈祷模型自觉光有工具不够模型聊嗨了会忘了更新计划。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: reminderUpdate 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分支重置计数未进一步判断更新是否成功提醒只是上下文中的普通文本也不保证模型一定服从。计划保存在内存里进程退出后不会自动持久化。七、收个尾五章下来架构长什么样回头看这五章其实是一条非常干净的递进线章节机制对循环的改动格言s01Agent Loop从零建立while True一个工具 一个循环 一个 Agents02Tool Use执行处换成查表分发工具实现、声明与注册配套s03Permission执行前加入权限判断和拒绝分支先检查再执行s04Hooks硬编码检查换成trigger_hooks挂在循环上不写进循环里s05TodoWrite6 号工具 reminder 计数器没有计划的 agent 走哪算哪在本文对照的版本中s05 完整脚本约 362 行含注释和空行不是前五章文件加起来只有这么多。到这里教学骨架已经齐了决策归模型执行归 harness扩展走 hook安全走闸门规划走结构化状态。保持稳定的是“请求模型 → 执行工具 → 回传结果”的闭环循环内部确实随着权限、Hooks 和提醒机制而扩展。后续章节还有更多好玩的东西s06 子 Agent上下文隔离、s07 技能按需加载、s08 上下文压缩、s10 任务系统、s13 多 Agent 协作……如果我勤快的话下篇继续整理 s06s10感兴趣的可以先去仓库自己跑。三个实践建议收尾一定要动手跑每一章的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为准。