Appearance
🐣 小a的新烦恼
上一章,小a有了趁手的工具,能下单调
read_file了。它得意洋洋,跑去跟老z汇报:"我会干活了!"老z丢给它一个真任务:"读
config.ts,找出所有硬编码,改成读环境变量,改完跑测试验证。"小a立刻下单
read_file。读完……然后呢?它停住了。"你干嘛停下?"老z问。
"我读完了啊。"
"读完了不该接着分析吗?分析完不该改吗?改完不该验证吗?"
小a愣住:"原来……活儿不是调一次工具就完的,得一步一步连着干?"
老z叹气:"光有手不够,你还得有节奏——干一步、看一眼、再干一步。这个节奏,叫 Loop(循环)。"
7.1 单次调用 vs 循环:为什么需要 Loop
先看清"单次 function calling"为什么不够。
单次调用(上一章的样子):
你问 → 模型调一次工具 → 你执行 → 喂回 → 模型答完 → 结束
现实任务(需要循环):
读文件 → 分析内容 → 改代码 → 跑测试 → 测试失败 → 再改 → 再跑 → … → 完成🧙 核心问题:
真实任务几乎从不是一步能完成的。它是一连串互相依赖的步骤——每一步的结果,决定下一步干什么。
单次 function calling 只解决了"调一次工具"。但"读完后该干啥""改完后该干啥",需要模型根据上一步结果,自主决定下一步。
这就是 Loop 要解决的:让模型在一个循环里,反复"看结果→决定下一步→执行",直到任务完成。
7.2 消息角色:循环里信息怎么流动
循环要转起来,信息得在"你、模型、工具"之间正确流动。这就靠消息角色——每条消息都标明"我是谁说的"。
💡 举例:四种角色各产生什么消息(任务:"把 config.ts 的硬编码改成读环境变量")
system:你写的人设与规则 → "你是编程助手,可用 read/edit/bash 工具"user:用户的话 → "把 config.ts 的硬编码改成读环境变量"assistant:模型的话(可以是回答,也可以带 toolCall)→ "好,我先读一下文件" + toolCall: read_file(config.ts)toolResult:工具执行后、你填回给模型的结果 → "config.ts 的内容是:const URL = 'http://...'..."
| 角色 | 谁产生的 | 干什么 |
|---|---|---|
| system | 你(开发者) | 设定模型人设、规则 |
| user | 用户 | 提出任务、追问 |
| assistant | 模型 | 生成回答、提出 toolCall |
| toolResult | 你的程序(执行工具后) | 把工具执行结果告诉模型 |
循环里的消息流
[system] "你是编程助手,可用 read/edit/bash 工具……"
[user] "把 config.ts 的硬编码改成读环境变量"
↓ 模型思考
[assistant] "好,我先读一下文件。" + toolCall: read_file(config.ts)
↓ 你的程序执行 read_file
[toolResult] "config.ts 的内容是:const URL = 'http://...'..."
↓ 模型看到结果,继续
[assistant] "找到 3 处硬编码,我来改。" + toolCall: edit(config.ts, ...)
↓ 你的程序执行 edit
[toolResult] "已修改 config.ts"
↓ 模型继续
[assistant] "改完了,我跑下测试验证。" + toolCall: bash("npm test")
↓ ...🧙 看明白了吗?
assistant的 toolCall 是"模型要干活"toolResult是"活干完了,结果给你"- 模型看到 toolResult 后,生成新的 assistant 消息(可能是答案,可能是下一个 toolCall)
循环的本质,就是 assistant 和 toolResult 交替出现,直到模型给出不含 toolCall 的最终答案。
7.3 核心循环长什么样
把上面的消息流抽象出来,就是 Agent 的核心循环:
┌──────────────────────────────────────────┐
│ 1. 把 [system + 历史 + 当前问题] 喂给模型 │
│ ↓ │
│ 2. 模型生成回答(可能含 toolCall) │
│ ↓ │
│ 3. 看 Stop Reason(第 2 章): │
│ • stop → 模型说完了 → 结束循环 │
│ • toolUse → 模型要调工具 → 进步骤 4 │
│ • 其他 → 处理异常 │
│ ↓ │
│ 4. 执行所有 toolCall,得到 toolResult │
│ ↓ │
│ 5. 把 toolResult 加进历史 │
│ ↓ │
│ └──→ 回到步骤 1(循环) │
└──────────────────────────────────────────┘🧙 读懂这个循环:
- 步骤 3 是关键——靠 Stop Reason 判断"该停还是该继续"。
toolUse意味着"我要调工具,别停",stop意味着"我说完了"。- 步骤 5 是记忆——每轮的工具结果都加进历史,模型下一轮能看到。这就是为什么模型能"基于上一步结果决定下一步"。
- 循环不是无限转——总有模型说
stop的时候,或者达到最大轮数被强制停(防死循环,附录 E.5)。
7.4 ReAct:循环的经典范式
上面这个循环,有个学名——ReAct(Reason + Act)。
🧙 ReAct 是什么?
ReAct 是一种 Agent 范式:Reason(推理)+ Act(行动) 交替进行。
老z用「外卖小哥」比喻:
Observe(观察):看一眼当前地址 → "我在路口,订单要送到 3 号楼" ↓ Think(思考):想下一步 → "3 号楼在东边,我得往东走" ↓ Act(行动):蹬车往东 → 到了新地点 ↓ Observe(观察):看新情况 → "到了 3 号楼下,但门禁坏了" ↓ Think(思考):想对策 → "门禁坏了,打电话让顾客下来" ↓ Act(行动):打电话 → …… ↓ (循环直到送达)ReAct 的精髓:每一步都先观察、再思考、后行动,根据新情况动态调整。 这正是 Agent 区别于"固定流程脚本"的地方——它能见招拆招。
ReAct 和 Loop 是什么关系?
🐣 小a问:"ReAct 和咱们讲的 Loop,是同一个东西吗?"
🧙 "Loop 是实现,ReAct 是思想。 一个符合 ReAct 思想的 Agent,落地时就是一个 Loop——观察(toolResult)→ 思考(模型生成)→ 行动(执行 toolCall)→ 循环。很多 Agent 框架(包括 pi)的循环,本质就是 ReAct 范式的实现,只是可能没显式叫这个名字。"
7.5 一次完整的循环走查:小a修 bug
抽象讲完了,老z决定让小a亲眼走一遍真实循环。任务是:"npm test 挂了,帮我修一下。"
🧾 第一轮:先看现象
[user] "npm test 挂了,帮我修一下" [assistant] toolCall: bash("npm test") ← 模型先复现问题 [toolResult] "FAIL: test/config.test.ts TypeError: cfg.port is undefined"模型看到报错,知道了"现象"——但它还不知道根因。
🧾 第二轮:定位根因
[assistant] toolCall: read("config.ts") [toolResult] "const port = process.env.PORT; const cfg = { port };" ← 看,port 可能没设 [assistant] toolCall: bash("echo $PORT") ← 再验证环境变量 [toolResult] "(空)" ← 果然没设模型通过"读文件 + 验证环境"锁定了根因。
🧾 第三轮:动手修 + 验证
[assistant] toolCall: edit("config.ts", 给 port 加默认值) [toolResult] "已修改 config.ts" [assistant] toolCall: bash("npm test") [toolResult] "PASS: 3 passed" [assistant] "修好了:port 没设默认值导致 undefined,已加上默认值。" ← 无 toolCall → stop
🧙 老z逐轮点评:
- 每轮 = 一次 LLM 调用 = 一次计费:这个 bug 走了 3 轮,烧了 3 次调用的钱。循环不是免费转的——轮数越多越贵(呼应 7.6 的成本控制)
- 模型"看一步走一步":没有预先规划完所有步骤,而是每轮根据上一步结果决定下一步——这就是 ReAct
- 结束条件:最后一条 assistant 消息没有 toolCall,模型说
stop——循环自然结束
如果工具执行失败呢?
老z追问:"假如第二轮里,read 工具报错(比如文件不存在),循环会怎样?"
🧾 错误也是一条消息:
[toolResult] "Error: 文件不存在: config.ts" [assistant] "哦,路径写错了?让我先看看目录结构。" toolCall: bash("ls")工具失败不会让循环崩溃——它变成一条"错误结果"喂回给模型,模型据此调整策略。 这就是 6.4 说的"失败可读"在循环里的样子:错误信息是模型的导航信号。
🐣 小a:"所以循环的每一步都可能失败,但失败本身也是信息?"
🧙 "对。循环的健壮性,就体现在『失败 → 反馈 → 调整』这个链条上。 当然,不能让它无限调下去——所以必须有下一节的边界。"
💡 进阶预告:上面画的是"一层循环"。真实框架(如 pi)的循环往往是双层的——内层处理流式 token(第 3 章的流式),外层处理工具轮次。这个我们到 Part II 第 18 章拆源码时细看。
7.6 循环的边界与安全
🧙 循环不是越转越好,要有边界:
- 最大轮数:防死循环——模型反复调同一个工具,或永远不说完成
- 异常处理:工具出错、模型报错,要能优雅退出而不是卡住
- 成本控制:每转一轮都烧 token,循环失控 = 账单失控
这些"边界"是循环能安全运转的前提。一个没有边界的循环,是 Agent 事故的温床。
本章小结
🐣 小a的节奏课
┌──────── Agent 循环 ────────┐ │ │ │ • 现实任务 = 多步连干 │ │ → 需要 Loop 不是单次调用 │ │ │ │ • 消息角色: │ │ system/user/assistant/ │ │ toolResult │ │ → 循环靠它们流转信息 │ │ │ │ • 核心循环 5 步: │ │ 喂模型→生成→看StopReason│ │ →执行→回喂→循环 │ │ │ │ • ReAct = 循环的经典范式 │ │ Observe→Think→Act→循环 │ │ │ │ • 走查:修 bug 3 轮循环 │ │ 每轮=一次调用=一次计费 │ │ → 失败也是反馈信号 │ │ │ │ • 循环要有边界: │ │ 最大轮数/异常/成本 │ └────────────────────────────┘
关键认知:循环是 Agent 的"节奏"。它让模型能"干一步、看一眼、再干一步",自主推进多步任务。但循环必须有边界(轮数/异常/成本),否则就是事故温床。
课后实验
- 画消息流:找一个 agent(任何都行),让它做多步任务("读 X 文件,告诉我有多少行"),还原它的消息角色流转。
- 观察 Stop Reason:看模型每次生成的 stop_reason——什么时候是 toolUse,什么时候是 stop。
- 测循环边界:把最大轮数设成 1,看 agent 会不会"没干完就被停"。
- 走查一次真实循环:用真实 agent 跑一个需要 2-3 步的任务,把每轮的 [assistant toolCall] / [toolResult] 抄下来,数数跑了几轮、烧了几次调用——感受"每轮都是钱"。
下一章:循环转起来了,但聊着聊着,小a发现自己"记性差"——窗口会满、关了会忘。 → 第 8 章 · 记忆与上下文