Python 复刻:agents/s01_agent_loop.py
Claude Code 内部是一个不断重复的循环。每跑一轮就跟模型对话一次:把当前的对话内容发过去,模型回一句话或者发起一次工具调用,工具的结果再回到对话里,进入下一轮。什么时候停由模型自己决定,代码不判断你的任务进行到哪一步。
1.1 这个循环是什么
把 Claude Code 想象成一个研究助理在工作。你给它一句"按 CLAUDE.md 第二节的 14 词保护清单审计 docs/ 与 06_结果输出/tables_tsv/",它把工作拆成若干小步骤推进。一句指令很少一步做完。每一小步就是一轮"循环":助理读一下当前手头的所有信息,包括你的指令以及之前几轮已经做完的事,决定下一步该做什么——再读一份变量字典、跑一次 Grep 扫"长期资本"是否在 csv 表头里出现、列一份疑似违规处的清单,或者认为已经做完可以停下来等你说话。
循环不持有任何"任务进度"的概念。所有"还差几词没扫""下一步该查 docs/01_实证研究设计 还是查 07_论文写作/01_主表/PC_ESG_主表汇总.docx""要不要回头核对前面"的判断都在模型那一头。代码本身只负责把当前累积的对话记录发给模型,再把工具调用产生的结果追加回对话记录里。
1.2 每一轮里发生什么
模型每次拿到的输入是一份"对话记录",可以理解成一份不断往后追加的会议纪要。从你最初的指令开始,每一轮模型说了什么、调用工具产生了什么结果,全部按时间顺序记在这份纪要里。
模型每轮的输出有两种可能。一种是回一段文字给你看。另一种是发起一次工具调用,也就是模型告诉系统"我想让 Read 工具读 CLAUDE.md",系统照办、把工具返回的内容放回会议纪要,下一轮模型就能看到。
模型在每轮回复里附带一个标记,说明这一轮还要不要继续。标记说"我刚才调了工具,还要看结果再决定下一步"——循环继续。标记说"我没调用工具了"——循环退出,控制权回到你这边,等你下一句指令。
1.3 一次真实运行的轮次串联
下面这段记录的是 phase 2 跑完表 10 稳健性后做术语审计的一次会话,按轮次排开。模型每一轮独立做"看到什么 → 决定做什么"的判断,代码侧从不替它选下一步。
| 轮 | 模型看到 | 模型决定 | 工具调用 | 工具结果 |
|---|---|---|---|---|
| 1 | 用户:"按 CLAUDE.md 第二节的 14 词保护清单审计 docs/ 与 06_结果输出/tables_tsv/" | 先读保护清单本身 | Read CLAUDE.md | 拿到第二节 14 词全表(含"耐心资本""稳定型机构投资者""战略型机构投资者""关系型债权""管理者短视主义""华证 ESG 评级""KZ 指数"等) |
| 2 | 14 词清单 | 用 Grep 在 docs/ 下扫每个词的近义违规表达 | Grep "长期资本" docs/ | 0 处匹配 |
| 3 | "长期资本" 没出现 | 继续扫"长期机构投资者" | Grep "长期机构投资者" docs/ | 0 处匹配 |
| 4 | 仍然干净 | 扫"长期债务"(关系型债权的近义违规) | Grep "长期债务" docs/ | docs/01_实证研究设计 第 47 行命中 1 处:"Deeg & Hardie (2016) 的'长期股权 + 长期债权'二元识别框架"——文献引述,可豁免 |
| 5 | 一处疑似但可豁免 | 扫"高管短视" | Grep "高管短视" docs/ 06_结果输出/ | 0 处匹配 |
| 6 | 继续向下 | 扫"长期负债"在 06_结果输出/tables_tsv/ 表头是否被误用 | Grep "长期负债" 06_结果输出/tables_tsv/ | 0 处匹配(csv 表头用的是"PC仅Rdebt"代号,符合保护清单) |
| 7 | 主目录干净 | 检查 07_论文写作/01_主表/ 下 docx 文件名 | Bash ls 07_论文写作/01_主表/ | 文件名含"主表汇总.docx",未出现违规近义 |
| 8 | 全部扫完 | 生成审计报告,明确"docs/01 第 47 行是文献引述、可保留"这条豁免 | Write 06_结果输出/term_audit_report.md | 报告落盘,含 14 词逐项扫描记录与 1 处豁免说明 |
| 9 | 报告已写完 | 没有下一个工具调用需要发起 | —— | 模型在响应里附带 stop_reason=end_turn,循环退出 |
循环里没有任何代码层判断"该不该再扫一个词""扫完去哪里"。代码只读模型每轮回复里的 stop_reason 字段——值是 tool_use 就执行工具、把结果回流到对话历史;值是 end_turn 就退出循环,把控制权交回到你。
1.4 为什么这样设计
把"什么时候停"交给模型决定,不在代码层写死。代码无法预先知道每个任务要做几步。同样一句"审计保护词一致性",可能像上面那次只在 docs/01 命中 1 处可豁免,也可能在 docx 长文里命中十几处真实违规需要逐处确认。每一处要不要列入报告、要不要先问你确认、要不要回头核对前面——这种判断必须在看到当前对话内容的时候才能做出来。代码层提前写死规则反而会卡死。
代码只做调度、不做判断的另一个好处是 Claude Code 用同一套循环处理所有任务。同一份 30 行循环代码,既能跑上面那次术语审计,也能跑 do0–do6 七个 Stata 脚本的顺序推进、也能跑 26441 条 main_panel 观测的字段一致性核对。新工具进来不改循环,新任务类型也不改循环,复杂性都在模型这一头。
1.5 容易踩的坑
模型有时会在任务还没完成时就停下来等你确认下一步。这是循环故意留出的人机交接点。例如上面那次审计在第 4 轮命中"长期债务"时就先停了一次,问"docs/01 第 47 行是文献引述(Deeg & Hardie 2016),按 CLAUDE.md 第二节是否豁免文献原文",等你拍板"豁免"再继续。如果它停了你觉得应该继续,再补一句指令循环就重新启动。
模型也偶尔会误判"任务完成了"提前停下来。这种情况下你补一句"还有 02_变量字典/测算方法说明.md 里的术语没扫",模型在下一轮看到完整对话历史和你的补充,会调整判断重新开始干。
工具调用失败时——比如 Grep 时拼错了路径、要读的 docx 不存在——错误信息会作为工具结果回到对话里。模型在下一轮看到错误,自己决定是重试、是换个路径、还是向你报错。循环本身不处理错误。
1.6 知识地图
| 关键词 | 含义 | 容易误会的点 |
|---|---|---|
| 循环 | 模型 → 工具 → 结果回到模型 → 下一轮 | 一句"按保护清单审计"通常触发 7 到 15 轮循环;它属于多轮对话,跟单次问答的交互形态不同 |
| 停止标记 | 模型每轮决定循环是否继续的字段 | 停止权在模型,代码只读取这个字段不主动判断 |
| 工具调用 | 模型在某轮请求执行 Read/Grep/Write 等动作 | 一轮可以发起多个工具调用——一次扫完"长期资本/长期机构/长期债务"三个近义词;工具结果合并后进入下一轮 |
| 对话记录 | 从开始到现在所有交互的完整文本 | 不是模型的"内部记忆";每次都重新整份发给模型 |