【CC】Learn Claude Code s01-s04学习笔记
本文参考github项目Learn Claude Code – 真正的 Agent Harness 工程Agent 工具与执行系统本文是 learn-claude-code 课程第二到第五节的笔记覆盖 Agent 从「能跑起来」到「能安全地跑起来」的四个核心机制Agent Loop → Tool Use → Permission → Hooks。这四个机制逐层递进先有一个最小可运行的循环再让模型能调用多种工具接着加上安全闸门防止危险操作最后用 hook 把逻辑从循环里解耦出去。读完本文你应该能理解一个 AI Agent 的「执行层」是如何工作的。Agent Loop最小可运行的 Agent 内核Agent Loop 是让模型能持续行动的最小运行框架。职责分工很清晰模型负责决策要不要调工具、调哪个harness负责执行调了就跑、结果喂回去模型的stop_reason有两种关键状态stop_reason含义tool_use模型说“我需要执行某个工具才能继续”end_turn模型说“我回答完了不需要再做任何操作”核心代码defagent_loop(messages):whileTrue:# 1. 调用 API 获取模型响应responseclient.messages.create(modelMODEL,systemSYSTEM,messagesmessages,toolsTOOLS,max_tokens8000,)# 2. 将模型回复加入历史messages.append({role:assistant,content:response.content})# 3. 判断是否需要工具调用ifresponse.stop_reason!tool_use:return# 正常结束返回回复# 4. 执行模型要求的工具收集结果results[]forblockinresponse.content:ifblock.typetool_use:outputrun_bash(block.input[command])results.append({type:tool_result,tool_use_id:block.id,content:output,})# 5. 将工具执行结果加入历史messages.append({role:user,content:results})代码位置s01 的 agent loop 主循环关键点messages中有 3 类内容——用户原始 message 模型回复的内容 如果调了工具工具执行结果终止条件当stop_reason不是tool_use时循环退出模型给了最终回答实际运行观察试试这些 promptCreate a file called hello.py that prints Hello, World!List all Python files in this directoryWhat is the current git branch?观察重点模型什么时候调用工具循环继续什么时候不调用循环结束下面是一次实际运行的输出s01 Create a file called hello.py that prints Hello, World! $ echo print(Hello, World!) hello.py (no output) $ cat hello.py python hello.py cat 不是内部或外部命令也不是可运行的程序或批处理文件。 $ type hello.py python hello.py print(Hello, World!) $ python hello.py (no output) $ more hello.py print(Hello, World!) $ echo print(Hello, World!) hello.py (no output) $ more hello.py print(Hello, World!) $ python hello.py Hello, World! 文件 hello.py 已创建运行输出结果为 Hello, World!。每一行$开头的黄色字就是模型调用tool_use要求执行 bash共8 次。Windows 适配模型一开始用了catUnix 命令在 Windows 上失败后自动切换成more和type最终完成了任务。这就是 agent loop 的价值——失败 → 观察错误 → 换方式重试和人调试的过程一样。为什么第一轮echo没成功第一次echo print(Hello, World!) hello.py带了单引号文件里写进去的是字面量print(Hello, World!)包含引号所以python hello.py没有输出。模型后来用more查看文件内容发现了问题改用不带引号的echo print(Hello, World!) hello.py重写才正确输出。整个流程体现了 agent loop 的三个关键能力执行——调 bash 做事观察——命令失败或结果不对时读输出/文件内容修正——根据反馈换方式直到成功第一轮尝试第二轮修正模型能看到完整历史包括它自己犯过的错误cat失败、引号问题这个累积的上下文就是模型的记忆——它从错误中学习换命令、查内容、修正直到成功最终messages回到history下次用户提问时继续追加所以多轮对话的记忆也在Tool Use从一把刀到工具箱s01 的 Agent 只有一个 bash 工具。读文件要cat写文件要echo ... file.py改文件要sed。问题在于模型想的是读这个文件却要拼出cat path/to/file。多了一层翻译浪费 token还容易拼错。让工具语义更贴近模型意图是这个阶段的改进目标。组件之前 (s01)之后 (s02)工具数量1 (bash)5 (read, write, edit, glob)工具执行硬编码run_bash()TOOL_HANDLERS 查表分发路径安全无safe_path 校验仅 file tools循环while Truestop_reason与 s01 完全一致唯一的变动在工具执行那 1 行run_bash()替换为TOOL_HANDLERS[block.name]()查表分发。循环结构完全不变——这是设计上的关键点新增能力不改循环逻辑。工具定义TOOLS[{name:bash,description:Run a shell command.,...},{name:read_file,description:Read file contents.,...},{name:write_file,description:Write content to file.,...},{name:edit_file,description:Replace text in file once.,...},{name:glob,description:Find files by pattern.,...},]每个工具有自己的实现函数defrun_read(path,limitNone):linessafe_path(path).read_text().splitlines()iflimit:lineslines[:limit]return\n.join(lines)defrun_write(path,content):safe_path(path).write_text(content)returnfWrote{len(content)}bytes to{path}defrun_edit(path,old_text,new_text):textsafe_path(path).read_text()ifold_textnotintext:returnError: text not foundsafe_path(path).write_text(text.replace(old_text,new_text,1))returnfEdited{path}defrun_glob(pattern):importglobasgreturn\n.join(g.glob(pattern,root_dirWORKDIR))关键点safe_path确保文件操作不会逃出工作目录edit 的安全性先检查old_text确实存在才替换避免误改工具分发TOOL_HANDLERS{bash:run_bash,read_file:run_read,write_file:run_write,edit_file:run_edit,glob:run_glob,}# 循环里只改了一行——从硬编码 run_bash 变成查表forblockinresponse.content:ifblock.typetool_use:handlerTOOL_HANDLERS[block.name]# 查表outputhandler(**block.input)# 调用results.append(...)加一个工具 在TOOLS数组加一条 在TOOL_HANDLERS字典加一行。循环不变。试试这些 promptRead the file README.md and tell me what this project is aboutCreate a file called test.py that prints hello, then read it backFind all Python files in this directoryRead both README.md and requirements.txt, then create a summary file观察重点模型什么时候只调一个工具什么时候一次调多个多个工具调用的顺序和结果是否正确Permission安全不能靠信任要靠代码s02 的 Agent 有 5 个工具。file tools 受safe_path保护但 bash 不受限制。让它清理一下项目可能执行rm -rf /。安全不能靠信任模型要靠代码——在工具执行之前做判断。每个工具调用经过三道闸门顺序固定硬拒绝优先软询问次之都没命中就放行。闸门作用命中后1. 拒绝列表永远禁止的操作rm -rf /、sudo直接拒绝不执行2. 规则匹配取决于上下文的操作写工作区外、rm文件交给闸门 33. 用户审批闸门 2 命中后暂停等用户确认用户决定允许或拒绝三道都没命中 → 直接执行。大部分日常操作走这条路。闸门 1硬拒绝列表先查命中就返回阻止信息DENY_LIST[rm -rf /,sudo,shutdown,reboot,mkfs,dd if, /dev/sda,]defcheck_deny_list(command:str)-str|None:forpatterninDENY_LIST:ifpatternincommand:returnfBlocked: {pattern} is on the deny listreturnNone关键点纯字符串匹配不依赖模型自觉。黑名单里的操作永远不可能执行闸门 2规则匹配描述什么时候需要问用户。每条规则指定工具和检查条件PERMISSION_RULES[{tools:[write_file,edit_file],check:lambdaargs:not(WORKDIR/args.get(path,)).resolve().is_relative_to(WORKDIR),message:Writing outside workspace,},{tools:[bash],check:lambdaargs:any(kwinargs.get(command,)forkwin[rm , /etc/,chmod 777]),message:Potentially destructive command,},]defcheck_rules(tool_name:str,args:dict)-str|None:forruleinPERMISSION_RULES:iftool_nameinrule[tools]andrule[check](args):returnrule[message]returnNone关键点规则是声明式的每条规则描述什么情况下需要问。新增危险场景只需加一条规则路径检查is_relative_to(WORKDIR)确保写文件不逃逸到工作区外闸门 3用户审批规则命中后暂停等用户输入defask_user(tool_name:str,args:dict,reason:str)-str:print(f\n⚠{reason})print(f Tool:{tool_name}({args}))choiceinput( Allow? [y/N] ).strip().lower()returnallowifchoicein(y,yes)elsedeny三道闸门串在一起插在工具执行之前defcheck_permission(block)-bool:# 闸门 1: 硬拒绝ifblock.namebash:reasoncheck_deny_list(block.input.get(command,))ifreason:print(f\n⛔{reason})returnFalse# 闸门 2 3: 规则匹配 → 用户审批reasoncheck_rules(block.name,block.input)ifreason:decisionask_user(block.name,block.input,reason)ifdecisiondeny:returnFalsereturnTrue# 在 agent_loop 中——s02 的循环只加了一行forblockinresponse.content:ifblock.typetool_use:ifnotcheck_permission(block):# ← 新增results.append({...content:Permission denied.})continueoutputTOOL_HANDLERS[block.name](**block.input)# s02 原有results.append(...)关键点权限检查插在模型要求执行和实际执行之间。被拒绝的工具返回Permission denied.模型可以看到这个反馈并尝试其他方式Hooks把逻辑从循环里解耦出去s03 的权限检查、日志、大文件提醒都硬编码在循环里。每加一个新能力就要改循环循环越来越胖。Hooks 解决的就是这个问题把什么时候做什么定义在循环外循环只负责在关键节点触发。Hook 注册表一个字典事件名映射到回调列表HOOKS{UserPromptSubmit:[],PreToolUse:[],PostToolUse:[],Stop:[],}defregister_hook(event:str,callback):HOOKS[event].append(callback)deftrigger_hooks(event:str,*args):forcallbackinHOOKS[event]:resultcallback(*args)ifresultisnotNone:# 返回值 ≠ None → hook 说停returnresultreturnNone关键约定返回值None 放行/继续非None 拦截/阻止PreToolUse的非 None 返回值会阻止本次工具执行Stop的非 None 返回值会强制续跑注入一条消息让模型继续四个 Hook 节点UserPromptSubmit用户输入提交后、进入 LLM 前触发。可以拦截或修改输入教学版只做日志演示defcontext_inject_hook(query:str)-str|None:Inject current working directory info into every prompt.print(f\033[90m[HOOK] UserPromptSubmit: working in{WORKDIR}\033[0m)returnNone# return None no modification, let prompt throughregister_hook(UserPromptSubmit,context_inject_hook)在主循环中用户输入后立即触发queryinput(s04 )trigger_hooks(UserPromptSubmit,query)# ← 进入 LLM 之前history.append({role:user,content:query})agent_loop(history)PreToolUse / PostToolUse工具执行前后。s03 的权限检查现在包装成 PreToolUse hook再加一个日志 hook 和一个大输出提醒# PreToolUse: 权限检查s03 的逻辑从循环移到 hookdefpermission_hook(block):ifblock.namebash:forpatterninDENY_LIST:ifpatterninblock.input.get(command,):returnPermission denied by deny listifblock.namein(write_file,edit_file):pathblock.input.get(path,)ifnot(WORKDIR/path).resolve().is_relative_to(WORKDIR):choiceinput( Allow? [y/N] ).strip().lower()ifchoicenotin(y,yes):returnPermission denied by userreturnNone# PreToolUse: 日志deflog_hook(block):print(f[HOOK]{block.name}(...))# PostToolUse: 大文件提醒deflarge_output_hook(block,output):iflen(str(output))100000:print(f[HOOK] ⚠ Large output from{block.name})register_hook(PreToolUse,permission_hook)register_hook(PreToolUse,log_hook)register_hook(PostToolUse,large_output_hook)要点权限检查从循环里的if not check_permission(block)变成了permission_hook回调。循环不知道权限的存在——它只管调用trigger_hooksStop循环即将退出时触发stop_reason ! tool_use。教学版用于打印收尾统计defsummary_hook(messages:list)-str|None:Print a summary when the loop is about to stop.tool_countsum(1forminmessagesforbin(m.get(content)ifisinstance(m.get(content),list)else[])ifisinstance(b,dict)andb.get(type)tool_result)print(f\033[90m[HOOK] Stop: session used{tool_count}tool calls\033[0m)returnNone# return None allow stop, return string force continuationregister_hook(Stop,summary_hook)在 agent_loop 中退出前触发ifresponse.stop_reason!tool_use:forcetrigger_hooks(Stop,messages)# ← 退出之前ifforce:# hook returned a message → inject it and continuemessages.append({role:user,content:force})continuereturn循环里只改了一处s03 直接调用check_permission(block)s04 改为trigger_hooks(PreToolUse, block)forblockinresponse.content:ifblock.type!tool_use:continue# s03: if not check_permission(block): ...# s04: hook 替代硬编码blockedtrigger_hooks(PreToolUse,block)ifblocked:results.append({type:tool_result,tool_use_id:block.id,content:str(blocked)})continuehandlerTOOL_HANDLERS.get(block.name)outputhandler(**block.input)ifhandlerelsefUnknown:{block.name}trigger_hooks(PostToolUse,block,output)results.append({type:tool_result,tool_use_id:block.id,content:output})四个 hook 覆盖了 agent cycle 的关键节点输入 → 执行前 → 执行后 → 退出。循环只负责调用trigger_hooks()具体逻辑全在 hook 回调里。总结回顾四个阶段的演进核心设计原则是清晰的阶段解决的问题设计原则Agent Loop模型需要持续行动循环 stop_reason 控制生命周期Tool Use一个 bash 工具不够用查表分发循环不变Permission模型可能执行危险操作三道闸门硬拒绝优先Hooks循环越来越臃肿观察者模式循环只管触发每加一层能力循环本身几乎不动——变动都在循环外面Tool Use循环里只改一行run_bash()→TOOL_HANDLERS[...]()Permission循环里只加一行check_permission(block)Hooks循环里只改一行check_permission()→trigger_hooks(PreToolUse)这就是一个好的 harness 架构核心循环保持简单能力通过外部机制叠加。后续的 Sub-Agent、MCP 等特性也遵循同样的设计思路。