跟读 learn-claude-code(1):循环内核
本文总结一个 while 循环加三条不变量:看 tool_use 块决定停不停,调用和结果配对,权限与钩子挂在循环外。
跟读 learn-claude-code 第 1 站 · 上一站:总览 · 下一站:计划、子代理与上下文预算 · 全程:总览
这一站对应课程(shareAI-lab/learn-claude-code)的 s01 到 s04。四章合起来只做一件事:搭出一个能自己调工具的循环,再定下几条后面 13 章都不许破坏的规矩。之后的计划、子代理、压缩、记忆、团队,全是挂在这个循环某个位置上的机制,循环本身基本不动。
下文引用的行号都指课程仓库里各章的 code.py。
s01 循环:模型不再调用工具就停
要解决的问题
模型只会输出文本。它可以输出"我想执行 dir *.py"这样的结构化意图,但它不会真的执行,也看不到结果。让它"列出目录里的 .py 文件然后运行 main.py",它给出第一条命令就停了,因为第二步依赖第一步的输出,而它拿不到。命令报错时它也不知道,得有人把 stderr 复制回去。
没有循环的时候,人就是中间层:跑命令、贴结果、再问。s01 要做的就是把这个中间层换成代码。分工从这里定下:模型负责决定调不调工具、调哪个、参数是什么;harness 负责执行,并把结果写回上下文。
机制
核心循环在 s01_agent_loop/code.py 第 96–126 行,去掉打印后是这样:
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS)
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})
messages.append({"role": "user", "content": results})
messages 是这个 Agent 的全部状态。API 本身无状态,每一轮都把完整历史重新发一遍。外层 REPL 把 history 放在 while 之外,所以多轮对话共享同一份列表。
这一章定下三条不变量,课程后面每章都守着:
- 退出条件是本轮 content 里没有 tool_use 块;
- 每个 tool_use 都有一个
tool_use_id相同的 tool_result,所有结果合并进紧随其后的一条 user 消息; - 循环结束时
messages[-1]是 assistant 消息。
为什么这么设计
停止条件看 tool_use 块,不看 stop_reason。 很多示例写的是 if response.stop_reason != "tool_use": break。stop_reason 是元数据,content 才是要执行的事实,两者可能对不上:
stop_reason说 tool_use,content 里却一个 tool_use 都没有(空 content 或只有空文本)。按 stop_reason 走,会执行 0 个工具,再追加一条content: []的 user 消息,下一次请求直接 400。stop_reason说 end_turn(某些兼容接口还会返回空值),content 里却有 tool_use。按 stop_reason 走会直接退出,留下一个没有配对结果的悬空 tool_use,用户下一句话发出去时协议报错。
课程的 tests/test_agent_loop_boundaries.py 第 219–275 行用假客户端把这两种情况各造了一遍,并且对 s01 到 s14 每一章都跑,所以循环不变这件事有测试锁着。第三方的"Anthropic 兼容"接口在 stop_reason 上更容易不规范,这条规则对它们最有用。
结果合并成一条 user 消息,并且带 id。 协议要求 user 和 assistant 严格交替,assistant 里的每个 tool_use 都要在紧接着的那条 user 消息里找到对应结果。如果每个结果单独 append 一条 user 消息,就会出现连续几条 user;如果不带 id 只按顺序对应,一次响应里有多个调用、其中一个失败时就对不上了。
错误作为观察交回模型。 run_bash(第 80–92 行)把超时、找不到程序这些异常都转成 "Error: ..." 字符串返回,危险命令也返回一条拦截说明。换成 raise 会怎样:异常冒泡到 REPL,整个进程退出,用户什么也拿不到,history 里还留着一个悬空 tool_use。错误本来是给模型看的信息,模型看到 "Timeout" 或 "command not found" 可以换个办法。run_bash 还把 stdout 和 stderr 拼在一起返回,报错信息通常在 stderr,模型最需要的恰好是它。
原样保存 response.content。 只抽出 tool_use 存下来看起来更省,可模型这一轮说的"我先看看目录"也是它下一步推理的依据,丢掉会让它前后接不上。如果开启了 extended thinking,原样保存也能保住 thinking 块和签名(推断,本章没开 thinking)。
坑与局限
- 没有步数上限。 教学版刻意不加,代价是模型反复试同一条失败命令时会一直烧 token。线上必须有上限,Agent SDK 提供
max_turns。 - max_tokens 截断。 如果模型的最后一个 tool_use 被截在参数中间,按"有 tool_use 就执行"的规则会去执行一个参数不完整的调用,
block.input["command"]可能直接 KeyError(推断,本章没有处理 max_tokens)。比较稳的做法是:截断时不执行那批调用,给它们回填一条说明结果,再结束。 - 输出静默截断。
out[:50000]截掉了也不告诉模型,模型会以为看到的就是全部。 - 没有
is_error标记。 错误只能靠文本猜,协议里本来有这个字段。 - 工具名和实际不一致。 Windows 上
subprocess.run(shell=True)走的是 cmd.exe,工具却叫 bash,课程靠 system prompt 里的一句环境说明纠正模型。
我拿自己的导游 Agent 按这三条不变量对照过一遍,停止条件和工具配对这两处最容易出问题,尤其是接兼容接口、又遇到回复被截断的时候。
s02 工具:加一个工具,只加一个 handler
要解决的问题
bash 什么都能干,问题在接口。模型想写一个含引号和 $ 的 Python 文件,得先翻译成 echo "..." > a.py,转义错一层就写坏;Windows 上它习惯性地写 cat、sed -i,cmd.exe 不认识;想改一行只能整文件重写。
harness 这边更麻烦。它想做"不许写工作区外的文件"这种策略,只能去解析任意 shell 字符串,而 python -c "open('/etc/passwd')" 这种写法根本解析不出要碰哪个路径。s02 新增的四个文件工具没有给 Agent 增加新能力,改善的是模型和 harness 之间的接口:harness 看到的不再是一串不透明的命令,而是工具名加结构化参数。s03 的权限和 s04 的钩子都建在这个基础上。
机制
两张表必须对齐:TOOLS 是给模型看的 JSON Schema 列表,TOOL_HANDLERS 是给 harness 用的名字到函数的映射(s02_tool_use/code.py 第 153–156 行)。循环里只有执行那一处变了(第 180–181 行):
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
文件工具都先过 safe_path(第 81–85 行):把路径拼到工作区下,resolve() 之后检查是否仍在工作区内,越界就报错。bash 不走这道检查。
为什么这么设计
用字典分发,不用 if/elif。 写成 if name == "bash": ... elif name == "read_file": ...,每加一个工具就要改循环体,循环会随工具数量一起膨胀,而循环是所有功能共享的核心路径。用字典之后,循环对工具数量无感,加工具就是 TOOLS 加一条、TOOL_HANDLERS 加一行。更重要的是工具表成了数据:后面子代理换一张更小的工具表、MCP 在运行时注入外部工具,都依赖这一点。装饰器注册(@tool)也是一种选择,但它最后还是往一个字典里写,教学版没必要多绕一层。
未知工具回一条说明,不崩溃。 README 里的片段写的是 TOOL_HANDLERS[block.name],查不到会 KeyError;实际代码用了 .get。模型偶尔会编出一个不存在的工具名,回一句 "Unknown: list_dir" 让它换一个,比整个进程退出好得多。这条结果同样要回填,配对规则不因为工具不存在而失效。
safe_path 先 resolve() 再判断。 只做字符串判断(比如检查是否以 .. 开头)会被 a/../../x、绝对路径、工作区里指向外部的符号链接绕过。resolve() 会消掉 .. 并跟随链接,在真实路径上判断包含关系才可靠。
edit_file 用"旧文本换新文本"。 整文件重写费 token,还容易顺手改坏别处;按行号替换的话,行号在几次编辑之后会漂移,模型对行号的记忆也不可靠。old_text 同时充当定位锚点和一致性校验:模型记错了文件内容,就得到 "text not found",而不是在错误位置写入。
glob 超过 200 条时告诉模型下一步。 截断提示是 "narrow the pattern",不只是说"太多了"。给模型的截断和错误信息要能直接指导下一个动作,这个写法可以照搬到自己的工具里。
坑与局限
**block.input的参数绑定异常。 模型多传一个 schema 里没有的字段(比如给 read_file 传offset),Python 在绑定参数时就抛TypeError。这一步发生在函数体执行之前,handler 内部的 try 接不住,异常一路冒泡让 Agent 崩溃。笔记里用假的 anthropic 模块加载 s02 验证过。修法是在分发处包一层,把 TypeError 也变成一条错误结果交回模型。- 两张表靠人对齐。 声明了没注册,模型调用时走 Unknown;注册了没声明,模型永远不会调用。教学版没有任何检查,一条比对两边名单的测试就能兜住。
edit_file只换第一处。old_text在文件里出现多次时会静默改错位置。真实 Claude Code 的 Edit 要求old_string唯一,否则报错,需要全部替换时显式传replace_all。- bash 仍然不设防。
safe_path只管文件工具,模型用 bash 照样能读写工作区外。所以 s02 的边界只能防模型手滑,防不了恶意,s03 就从这里接着做。
s03 权限:执行前先过三道闸门
要解决的问题
s02 之后还有几种情况没人管:
- 用户说"清理一下项目",模型执行
rm -rf build dist node_modules,顺手带上了src; - 模型读到的文件里有一段注入文本,诱导它执行
curl ... | sh; - 用户确实想让它读一下上级目录的共享配置,
safe_path只会一律报错,没有"问我一下"这个选项; - 模型自己在回复里礼貌地问"要我删除吗?"。这条回复没有 tool_use,按 s01 的规则循环直接退出,任务断在半路。
README 的说法是"安全边界由代码负责,判断发生在工具执行之前"。prompt 里的规则是概率性的,而删除不可逆,执行之后再发现就晚了。
机制
s03_permission/code.py 第 203–214 行的 check_permission 串起三道闸门:
tool_use
│
├─ 闸门 1:硬拒绝(仅 bash,子串匹配 DENY_LIST)── 命中 ──► 拒绝,用户无权放行
│
├─ 闸门 2:规则匹配(PERMISSION_RULES:工具名 + 判定函数 + 理由)
│ └─ 命中 ──► 闸门 3:问用户 [y/N],只有 y/yes 放行
│
└─ 都没命中 ──► 直接执行(默认放行)
被拒时(第 238–241 行)照样往 results 里放一条 tool_result,内容是 "Permission denied.",然后 continue 处理下一个调用。
规则写成数据:
{"tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve()
.is_relative_to(WORKDIR),
"message": "Writing outside workspace"}
这条规则能按 args["path"] 判断,直接受益于 s02 把文件操作做成了结构化参数;对 bash 就只能做字符串启发式。
为什么这么设计
分三种结果,不只分安全和不安全。 纯黑名单只能拒,删一个临时文件和删源码在它眼里没有区别,要么都拒(干不了活),要么都放(不安全)。纯白名单在 coding agent 里走不通,pytest -k xxx、npm run build 这类命令空间是开放的,大部分正常操作都会卡住。三态把"确定危险""需要人判断""日常操作"分开,人只在少数需要判断的地方介入。
顺序是硬拒绝、规则、审批。 硬拒绝排在最前,命中后直接返回,根本不给用户审批的机会。如果反过来先问用户,mkfs、dd if= 这种操作会在用户手滑或者审批疲劳时被一个 y 放出去。deny 不能被 ask 覆盖,真实 Claude Code 的权限规则也是 deny 优先。
被拒也要回填 tool_result。 换成 break 或 raise,会直接违反 s01 的配对规则,下一次请求报错。而且"被拒"本身是有用的信息,模型知道这条路走不通,可以换方案或者向用户解释。
告诉模型别自己请示。 system prompt 里写着 "Do not ask for approval yourself... the harness will approve or deny before execution"(第 66–71 行)。没有这句,模型容易先用文字问一遍,循环退出;用户回"好"之后它才发调用,harness 再问一遍 y/N,变成双重确认。审批只从 harness 这一个入口走,它就是一个有结构、能审计、模型绕不过去的步骤,不会退化成聊天里的一问一答。
越界从硬报错降级为询问。 s03 的文件工具去掉了 safe_path,越界判断挪进了权限规则,于是同一个越界操作可以由人决定放不放,策略也集中在一处。代价是工具实现本身不再自带防线,以后有人绕过 check_permission 直接调 handler(比如测试或子代理复用工具表),就没有任何路径保护了。更稳的做法是策略层和工具层各守一道。
坑与局限
字符串启发式只能当提醒层。笔记里离线调用 check_rules 和 check_deny_list 测过一组命令:
| 命令 | 结果 | 问题 |
|---|---|---|
echo perform test |
要求审批 | 误报,perform 里含子串 rm |
rmdir /s /q build |
放行 | 漏报 |
git clean -fdx |
放行 | 漏报 |
python -c "import shutil;shutil.rmtree('.')" |
放行 | 换个解释器就绕过 |
rm -rf /tmp/x |
硬拒绝 | 误杀,含子串 rm -rf /,用户也无法放行 |
echo sudoku |
硬拒绝 | 误杀,含子串 sudo |
README 自己也说这张表"不能视为完整的安全边界"。真正的边界要靠操作系统级沙箱,或者干脆不提供危险能力。
另外两处:
- 拒绝不带理由。 只回
"Permission denied.",模型分不清是永久禁止还是用户这次不同意,可能换一个等价写法再试,而等价写法往往不在任何规则里(推断,待验证)。拒绝结果应该带上原因,并说明"不要换写法重试,改为向用户说明"。 - 检查和执行用的是同一个
block.input。 不存在"检查的是 A、执行的是 B"的问题,这是结构化参数的另一个好处。
一个延伸判断:三道闸门的前提是有可信的审批人在场。面向匿名访客的 Agent 没有这个人,第 3 道闸门不成立,更合适的是能力裁剪,构造时就只给只读工具。详见 安全边界:让权限落到工具层。
s04 钩子:权限变成第一个 PreToolUse
要解决的问题
s03 结束时,循环里已经有打印、权限检查、打印输出三处逻辑。接下来还会有人要:每次工具调用写审计日志,输出太大时报警,用户输入前附带当前目录,模型说"完成了"时检查测试过没过。每个需求都在 agent_loop 里加一个 if,扩展的是行为,改的却是所有功能共享的核心路径,每改一次都可能碰坏 s01 的配对和退出规则。
机制
s04_hooks/code.py 第 137–147 行:
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def trigger_hooks(event, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # 第一个非 None 返回值短路
return result
return None
注册顺序就是执行顺序。s03 的整套权限逻辑合并成 permission_hook,注册为 PreToolUse 的第一个回调,日志 hook 排第二。四个事件在循环里的作用:
| 事件 | 触发位置 | 返回值的作用 |
|---|---|---|
| UserPromptSubmit | REPL 进入循环之前 | 被丢弃 |
| PreToolUse | 每个工具执行前(第 245–249 行) | 真值则不执行,返回值转成字符串作为 tool_result |
| PostToolUse | 每个工具执行后(第 254 行) | 被丢弃 |
| Stop | 本轮没有 tool_use 时(第 235–240 行) | 真值则作为一条新 user 消息追加,循环继续 |
为什么这么设计
固定事件点加注册表。 继续在循环里加 if,回归风险全堆在最关键的路径上。改成子类覆写 before_tool(),一个方法只能覆写一次,权限、日志、审计三个独立扩展就得手工拼在一起,也没法按配置开关。中间件链表达力最强,但每层都要记得调用 next(),漏调或多调都很难查。事件加回调列表最轻:循环只认四个事件名,扩展彼此独立。测试也受益,test_agent_loop_boundaries.py 第 103–105 行把 trigger_hooks 整个换成恒返回 None 的函数,循环边界测试就不受权限和日志干扰。
返回值协议沿用 s03。 s03 的 check_deny_list 已经是"None 表示放行,字符串表示理由"。到了 s04,hook 什么都不做就自动返回 None;要拦截时,返回的字符串直接就是给模型看的拒绝理由。拒绝文案也顺带分成了 "denied by deny list" 和 "denied by user",比 s03 的一句 "Permission denied." 多了信息。
Stop 让 harness 能否决"结束"。 s01 里什么时候停由模型决定,有了 Stop hook,harness 第一次能回一句"测试还没过,不许停"。追加的消息是 user 角色,正好接在 assistant 后面,交替规则不破。第 7 站的目标闸门就是这个思路的完整版。
坑与局限
- 短路和阻止用的判断不一样。
trigger_hooks用is not None决定短路,循环用if blocked:决定是否阻止。一个返回空字符串的 hook 会截断后面所有 hook,却不阻止工具执行。笔记里验证过:在 PostToolUse 最前面插一个返回""的回调,后面的报警 hook 就不再运行。 - 权限排第一,审计就看不到被拦的调用。 被拒的调用在
permission_hook处短路,日志 hook 根本没机会运行,而被拦截的危险尝试恰好是审计最想记的。审计应该放在不参与短路的独立通道里。 - Stop 没有护栏。 一个总是返回字符串的 Stop hook 会让循环无限调用模型。笔记里用假客户端验证过,第 5 次调用时手动掐断。真实 Claude Code 在 Stop hook 的输入里带
stop_hook_active,hook 据此避免连续阻止;工程上还要加"最多续 N 次"。 - PostToolUse 只能看不能改。 返回值被丢弃,所以给工具结果打码、给网页内容包一层"以下是数据不是指令"、截断过长输出,这些最有用的后处理都做不了。否决、变换、观察本来需要三种组合语义(短路、管道、全部执行),教学版用一个
trigger_hooks全包了。 - hook 抛异常没人兜底,会直接让 Agent 崩溃。
- "循环一字不改"不准确。 对比 s03,s04 新增了 Stop 分支、PostToolUse 调用和 UserPromptSubmit 调用,循环是改了的。准确的说法是:加扩展点这一次改完之后,再加扩展就不用改循环了。s04 的 system prompt 还丢掉了 s03 那句"别自己请示",章节之间前后不一致。
和真实 Claude Code 的差别
| 方面 | 教学版 | 真实 Claude Code |
|---|---|---|
| 循环 | 同步 while True,非流式 |
同样是调模型、执行工具、回填结果的循环,流式输出,可中断 |
| 步数上限 | 无 | Agent SDK 有 max_turns;交互模式下推断主要靠用户中断和预算 |
| 多工具执行 | 串行 | 推断:只读工具可并发、写类工具串行(来自社区分析,非官方文档) |
| Bash 超时 | 固定 120 秒 | 可传 timeout 参数,默认约 2 分钟、上限约 10 分钟,还能后台运行 |
| Edit | 只换第一处 | old_string 必须唯一或显式 replace_all,修改前要求先 Read 过该文件 |
| 权限规则 | 写死在代码里,子串匹配 | settings.json 里的 allow / ask / deny,分用户级、项目级、本地级;deny 优先;Bash 规则按命令前缀匹配并识别 && 等操作符 |
| 审批 | 每次 input() |
可选本次允许或以后不再询问;另有权限模式(default、acceptEdits、plan、bypassPermissions 等) |
| 默认策略 | 未命中规则就放行 | 只读类工具默认不问,Bash 和文件修改默认要审批 |
| 钩子形态 | 进程内 Python 函数 | settings 里配置的外部命令,stdin 收 JSON;matcher 按工具名匹配 |
| 钩子决策 | 返回非 None | 退出码 2 表示阻止并把 stderr 反馈给模型;也可输出 JSON,PreToolUse 用 permissionDecision(allow / deny / ask) |
| 多个钩子 | 串行,短路 | 并行执行、有超时,没有"前一个截断后一个"的语义 |
| Stop 防循环 | 无 | 输入里带 stop_hook_active |
Agent SDK 允许把进程内回调注册到 PreToolUse 等事件上,还有单独的权限回调,形态上更接近教学版。CLI 选外部命令加 JSON 协议,换来的是语言无关、用户可配置、hook 崩了不拖垮主进程,代价是每次触发起一个进程,而且 hook 以用户权限执行任意命令,配置本身要审查。
自测
为什么用 content 里有没有 tool_use 块来决定是否继续,而不是看 stop_reason?
参考:content 是要执行的事实,stop_reason 只是元数据,两者可能不一致。stop_reason 说 tool_use 但没有块时,会追加一条空 content 的 user 消息,下一次请求 400;stop_reason 说 end_turn 但有块时,会不执行就退出,留下悬空 tool_use,下一轮协议报错。兼容接口更容易出现这种不一致。课程用测试对 s01 到 s14 每章锁定这两种情况。
一次响应里有三个 tool_use,其中第二个被权限拒绝了。回填的 user 消息应该长什么样?
参考:一条 user 消息,里面三个 tool_result,各自带对应的 tool_use_id。第二个的内容是拒绝说明,最好写清原因和"不要换写法重试"。不能漏掉第二个(悬空 tool_use 会让下一次请求报错),也不能拆成三条 user 消息(破坏交替)。第一个被拒也不影响第三个执行。
字典分发相比 if/elif 好在哪里?它带来了什么新风险?
参考:循环对工具数量无感,加工具只改数据;工具表还能在运行时替换,子代理和 MCP 都依赖这一点。新风险有两个:TOOLS 和 TOOL_HANDLERS 两张表靠人对齐,可能不同步;handler(**block.input) 把参数校验交给了函数签名,模型多传或少传字段会在参数绑定阶段抛 TypeError,handler 内部的 try 接不住。
硬拒绝为什么要排在用户审批之前?代价是什么?
参考:有些操作即使用户同意也不该执行,deny 不能被 ask 覆盖,否则一次手滑或审批疲劳就会放出去。代价是子串匹配的误杀用户无法放行,比如 rm -rf /tmp/x 因为含 rm -rf / 被硬拒,echo sudoku 因为含 sudo 被硬拒。
在 s04 里注册一个 PreToolUse hook,它返回空字符串。会发生什么?
参考:trigger_hooks 用 is not None 判断,空字符串不是 None,所以它会短路,后面的 hook(包括日志)都不再运行;循环用 if blocked: 判断,空字符串为假,所以工具照常执行。结果是扩展被静默丢掉、工具却没被拦住。修法是统一成一种判断,或者把返回值做成显式结构,例如带 decision 和 reason 字段。
连接
- 工具调用:本站的 s01 和 s02 就是工具调用在代码里最小的样子,"提议、执行、核对"三件事在这里分别落到模型、handler 和回填。
- 安全边界:让权限落到工具层:s03 的三道闸门是"权限落到工具层"的一个教学实现,也能看出字符串规则为什么不够。
- 人在回路:闸门 3 的审批是人在回路最朴素的形式,确认界面要展示真实参数而不是模型的描述。
- 可靠执行:状态、幂等与恢复:错误作为观察交回、步数上限、截断时不执行半截调用,都是可靠执行在循环层面的要求。
- 可观测性:追踪一条任务的代价:s04 的审计日志被短路吃掉,说明观测通道不能和拦截逻辑混在一起。
- Coding / Harness:面试里讲 Harness 时,这一站的循环、三条不变量和钩子就是可以落到代码的部分。
- 计划、子代理与上下文预算:下一站,messages 只增不减的问题在那里开始处理。
- 跟读 learn-claude-code:17 个 harness 机制怎么挂到一个循环上:七站总览。