Skip to content

🐣 小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 循环的边界与安全

🧙 循环不是越转越好,要有边界:

  1. 最大轮数:防死循环——模型反复调同一个工具,或永远不说完成
  2. 异常处理:工具出错、模型报错,要能优雅退出而不是卡住
  3. 成本控制:每转一轮都烧 token,循环失控 = 账单失控

这些"边界"是循环能安全运转的前提。一个没有边界的循环,是 Agent 事故的温床。


本章小结

🐣 小a的节奏课

┌──────── Agent 循环 ────────┐
│                            │
│  • 现实任务 = 多步连干     │
│    → 需要 Loop 不是单次调用 │
│                            │
│  • 消息角色:               │
│    system/user/assistant/  │
│    toolResult              │
│    → 循环靠它们流转信息     │
│                            │
│  • 核心循环 5 步:           │
│    喂模型→生成→看StopReason│
│    →执行→回喂→循环         │
│                            │
│  • ReAct = 循环的经典范式  │
│    Observe→Think→Act→循环  │
│                            │
│  • 走查:修 bug 3 轮循环    │
│    每轮=一次调用=一次计费  │
│    → 失败也是反馈信号      │
│                            │
│  • 循环要有边界:            │
│    最大轮数/异常/成本       │
└────────────────────────────┘

关键认知:循环是 Agent 的"节奏"。它让模型能"干一步、看一眼、再干一步",自主推进多步任务。但循环必须有边界(轮数/异常/成本),否则就是事故温床。


课后实验

  1. 画消息流:找一个 agent(任何都行),让它做多步任务("读 X 文件,告诉我有多少行"),还原它的消息角色流转。
  2. 观察 Stop Reason:看模型每次生成的 stop_reason——什么时候是 toolUse,什么时候是 stop。
  3. 测循环边界:把最大轮数设成 1,看 agent 会不会"没干完就被停"。
  4. 走查一次真实循环:用真实 agent 跑一个需要 2-3 步的任务,把每轮的 [assistant toolCall] / [toolResult] 抄下来,数数跑了几轮、烧了几次调用——感受"每轮都是钱"。

下一章:循环转起来了,但聊着聊着,小a发现自己"记性差"——窗口会满、关了会忘。 → 第 8 章 · 记忆与上下文