05 Agent 循环与架构模式
上一篇讲了一次工具调用怎么完成。把”调模型 → 执行工具 → 回填结果”放进一个循环,就是 Agent。循环本身几十行代码,真正的难点在循环之外:什么时候该用循环、什么时候写死流程更好;循环怎么停;停下来凭什么说成功了;复杂任务要不要先做计划。
这篇要讲清楚:工作流和 Agent 的区别与取舍、五种常见工作流模式、Agent 循环的完整骨架、停止条件和预算、ReAct / 先计划后执行 / 反思三种主流模式各自适合什么。
怎么读:
前置:04 工具调用。
第一部分 速记页
| 问题 | 一句话答案 |
|---|---|
| Agent 是什么 | 由模型根据执行反馈动态决定下一步和何时结束的系统;程序负责执行、限制和验收 |
| 工作流是什么 | 步骤和分支由代码预先写好,模型只在某些步骤里被调用 |
| 有工具调用就是 Agent 吗 | 不是。代码固定调一次检索再让模型回答,是工作流 |
| 什么时候用 Agent | 步骤数量和路径事先无法确定,需要根据中间结果调整 |
| 什么时候不用 | 流程固定、要求可预测和低成本、出错代价高且无法验证时 |
| 五种工作流模式 | 提示链、路由、并行(分段 / 投票)、编排者-执行者、评估者-优化者 |
| Agent 循环骨架 | 拼上下文 → 调模型 → 有工具调用就校验执行回填 → 检查停止条件 → 下一轮 |
| 停止条件 | 模型不再调工具(然后独立验收)、步数上限、时间上限、费用上限、卡死检测、被阻塞需人工 |
| 模型说”完成”算成功吗 | 不算。只能触发验收,成功由外部检查决定 |
| 为什么输入 token 远多于输出 | 每轮都要把完整历史重发一次;我项目里输入是输出的 27 倍 |
| ReAct | 思考和行动交替:想一步、做一步、看结果、再想;最通用的 Agent 模式 |
| 先计划后执行 | 先让模型列出完整计划,再逐步执行,失败时重新规划;省模型调用,适合步骤清楚的任务 |
| 反思(Reflection) | 让模型审视自己的结果或失败原因再改;只有配合真实反馈(测试、报错)才可靠 |
| 步数上限怎么定 | 看任务难度分布和”愿意为一次失败付多少钱”;我项目从 25 提到 40,困难项目才过 |
| 卡死检测 | 同一调用在状态没变时重复出现;要排除”改了配置再构建”这种正常重复 |
第二部分 易混对照
| 容易混的两个 | 区别 | 一句话记法 |
|---|---|---|
| 工作流 vs Agent | 前者路径由代码决定;后者路径由模型根据反馈决定 | 谁决定下一步 |
| Agent vs 聊天机器人 | 聊天机器人一问一答;Agent 为完成一个目标自主执行多步 | 聊天机器人等你问,Agent 自己往下做 |
| 模型结束 vs 任务完成 | 模型不再调工具只是它停下了;任务完成要由验收证明 | 停下 ≠ 做完 |
| 步数上限 vs 卡死检测 | 步数上限是总预算;卡死检测是发现”没进展还在重复” | 一个管花多少,一个管有没有用 |
| ReAct vs 思维链(CoT) | 思维链只在脑子里推理,不接触外部;ReAct 推理中穿插真实行动和观察 | CoT 是想,ReAct 是边想边做 |
| 先计划后执行 vs ReAct | 前者一次规划多步,执行时不每步问模型;后者每步都问 | 先写清单 vs 走一步看一步 |
| 反思 vs 评估者-优化者 | 反思通常是同一模型回头看自己;评估者-优化者一般拆成生成和评审两个角色 | 自查 vs 互查 |
| 编排者-执行者 vs 多 Agent | 编排者-执行者是一种任务拆分模式,可以只是函数调用;多 Agent 强调多个独立上下文的智能体协作 | 前者是结构,后者是实现方式之一 |
| 重新规划 vs 重试 | 重试是同一步再做一次;重新规划是根据失败改变后续所有步骤 | 再试一次 vs 换条路 |
第三部分 面试口述稿
3.1 “什么是 Agent?和工作流有什么区别?”
我用”谁决定下一步”来区分。工作流是代码预先写好步骤和分支,模型只是在某些节点里被调用,比如先检索、再生成、再格式检查,每次都走同一条路。Agent 是模型根据每一步的执行结果,自己决定下一步做什么、什么时候结束,路径事先不固定。
所以有工具调用不等于就是 Agent。实际系统大多是两者结合:能确定的部分写成工作流,比如我项目里任务开始前由程序先扫描项目结构、结束后由程序独立验收;真正需要根据报错随机应变的诊断和修复,交给 Agent 循环。
3.2 “什么时候不该用 Agent?”
三种情况我会优先用工作流。一是流程本身固定,比如合同审查就是抽取、比对、出报告三步,用 Agent 只会增加不确定性;二是对延迟和成本敏感,Agent 每一轮都要重发全部历史,我项目里输入 token 是输出的 27 倍;三是出错代价高又没法自动验证结果的场景,Agent 路径不固定,很难测全。
Agent 适合的是步骤数量和路径事先无法确定、而且结果可以验证的任务,比如修编译错误:报错千奇百怪,没法枚举,但编没编过很容易检查。
3.3 “Agent 循环怎么设计?怎么防止无限循环?”
骨架很简单:拼好上下文调模型;模型返回工具调用就校验、执行、回填结果;然后检查停止条件,没停就进下一轮。
难点在停止条件,我会设几层:一是模型不再调工具,这时不直接判成功,而是进入独立验收;二是总步数上限;三是整体超时,而且要落实到每次模型请求和工具进程的单次超时;四是费用上限;五是卡死检测,同样的调用在环境没变的情况下重复出现就提醒换思路,还不行就停。
卡死检测有个坑:修改配置后再次执行同一个构建命令是正常的,不能算重复。我项目里的做法是写文件成功之后,把构建命令的重复计数清零。
3.4 “步数上限设多少合适?”
没有通用数字,要用评测数据定。我项目最早设 25 步,困难档的两个项目三轮六次里有五次跑满 25 步。翻轨迹发现它们的路是对的,比如编 libpng 要先把 zlib 整个交叉编译一遍,属于嵌套任务,25 步刚走到一半。只把上限改成 40,两个项目都过了,分别用了 29 步和 32 步。
但上限提高意味着失败时更贵,跑满 40 步比 25 步多花六成。所以步数上限本质上是”愿意为一次失败付多少钱”的业务选择。更好的做法是按任务难度分级,或者先用小预算跑,失败了再决定是否追加。
3.5 “讲一下 ReAct”
ReAct 是 2022 年的一篇论文提出的,名字是 Reasoning 加 Acting。核心是让模型交替进行推理和行动:先想当前该做什么,然后调用工具,看到结果后再想下一步。推理帮模型制定和调整计划、处理异常,行动让它能从外部拿到真实信息,避免只靠记忆编造。
现在主流模型原生支持工具调用,ReAct 基本就是默认的 Agent 循环形态。它的问题是每一步都要调一次模型,步骤多了慢且贵,而且容易只看眼前、缺少全局规划。
3.6 “先计划后执行和 ReAct 怎么选?”
先计划后执行是让模型先列出完整步骤,再由执行器逐步完成,只在失败时回头重新规划。好处是规划只调一次大模型,执行步骤可以用小模型甚至直接用代码,整体更省;计划也能给用户看、让人确认。缺点是计划是在信息不全时做的,环境反馈和预期差距大时要频繁重新规划。
我的选择原则:任务步骤基本能预见、依赖关系清楚的,用先计划后执行;每一步结果都可能彻底改变方向的,比如排查构建错误,用 ReAct。两者也可以组合,先粗略规划,每个子任务内部用 ReAct。
3.7 “反思机制有用吗?”
有用,但有前提:必须有真实的反馈信号。像 Reflexion 这篇论文的做法,是让 Agent 在失败后根据测试结果或环境反馈,用文字总结哪里做错了,存下来供下次尝试参考,在代码生成上效果明显。
如果没有外部信号,只是让模型”检查一下自己的答案”,它经常会把对的改错,或者自信地说没问题。所以我会优先把反思接到可验证的信号上,比如编译报错、单元测试结果、格式校验,而不是纯靠模型自评。
3.8 “你的 Agent 是什么架构?”
是工作流套着一个 ReAct 循环。进循环之前,程序先扫描项目根目录和 CMakeLists.txt 里的构建选项,放进第一条用户消息,省掉模型自己去摸索的几步。循环里模型每轮决定调哪个工具,程序校验执行,有步数上限 40、同一调用重复超过 3 次提示换思路、所有调用都重复超限就判定卡死。循环结束后,不管模型说什么,程序都会独立执行一次构建,并检查产物是不是目标架构的 ELF 文件,这才决定成功还是失败。
后来用 LangGraph 重写了同一个循环,拆成模型、工具、验收三个节点。评测上两者看不出差别,LangGraph 多给的是检查点和中断续跑。
第四部分 逐个详解
4.1 从一次调用到 Agent:三种形态
先说为什么会有”工作流和 Agent”这种分法。 2023 年 3 月 30 日,游戏公司 Significant Gravitas 的创始人 Toran Bruce Richards 在 GitHub 上发布了 Auto-GPT:给 GPT-4 一个目标,它自己拆任务、上网搜、读写文件,一轮轮循环下去,不用人每步下指令。这个项目几天内就冲上 GitHub 趋势榜第一,“让模型自己跑”一下子成了热门话题。但很多人拿它做正经事时发现,它经常原地打转、走偏,花了不少 token 也没做完。另一边,很多公司做的”AI 应用”其实是写死的流程:先分类、再检索、再回答,每一步调一次模型,稳定但不灵活。两种东西都被叫作 Agent,讨论时经常说岔。
2024 年 12 月 19 日,Anthropic 的 Erik Schluntz 和 Barry Zhang 根据和几十个团队合作的经验写了下面这篇 Building effective agents,把两者分开命名,并给出一个朴素的结论:做得最好的团队大多没用复杂框架,而是用简单、可组合的模式。
官方定义:Anthropic 在 Building effective agents 里把这类系统统称为”智能体系统”(agentic systems),再分成两类:
- 工作流(workflows):模型和工具通过预先定义的代码路径编排
- 智能体(agents):模型动态地指挥自己的过程和工具使用,自己控制如何完成任务
加上最简单的单次调用,一共三种形态:
flowchart LR
subgraph A["单次调用"]
A1[输入] --> A2[模型] --> A3[输出]
end
subgraph B["工作流:代码决定路径"]
B1[输入] --> B2[模型:分类]
B2 --> B3{代码判断}
B3 -- 类别 1 --> B4[检索 + 模型回答]
B3 -- 类别 2 --> B5[模型直接回答]
B4 --> B6[代码:格式检查]
B5 --> B6
end
subgraph C["Agent:模型决定路径"]
C1[目标] --> C2[模型]
C2 -- 调工具 --> C3[执行]
C3 -- 结果 --> C2
C2 -- 认为完成 --> C4[程序验收]
end
打个比方:
- 单次调用像问路人一句”几点了”
- 工作流像工厂流水线,每道工序固定,工人(模型)只负责自己那一环
- Agent 像派一个维修师傅上门,你只说”空调不制冷”,他自己决定先看什么、再拆什么,你只看最后修没修好
| 形态 | 路径谁定 | 优点 | 缺点 | 例子 |
|---|---|---|---|---|
| 单次调用 | — | 最快最便宜、最好测 | 只能做一步 | 把日志归类成 JSON |
| 工作流 | 代码 | 可预测、好测、成本可控 | 遇到没设计到的情况就无能为力 | 客服先分类再走不同回答流程 |
| Agent | 模型 | 能处理事先想不到的情况 | 慢、贵、结果不稳定、难测 | 修一个未知原因的编译错误 |
Anthropic 那篇文章的核心建议是:先用最简单的方案,只在确实需要时才增加复杂度。很多场景一次调用加上检索和几个例子就够了,工作流适合定义清楚的任务,Agent 适合需要灵活性和模型自主决策的场景。
4.2 五种常见工作流模式
面试常被问到”你知道哪些 Agent 设计模式”。下面五种来自 Anthropic 的归纳,名字在业内通用。每种都用构建任务举例。
模式 1:提示链(Prompt Chaining)
把任务拆成固定的几步,上一步的输出是下一步的输入,中间可以插入代码检查(叫”关卡”,gate)。
flowchart LR
I[构建日志] --> S1[模型:提取错误行]
S1 --> G{代码检查<br/>提取到了吗}
G -- 否 --> X[结束:日志里没有错误]
G -- 是 --> S2[模型:判断错误类型]
S2 --> S3[模型:生成修复建议]
S3 --> O[输出]
适合:任务能清楚拆成固定子步骤,愿意用多一点延迟换每一步更准。
模式 2:路由(Routing)
先分类,再把输入交给专门处理这一类的流程。
flowchart LR
I[报错信息] --> R[模型或规则:分类]
R -- 缺依赖 --> H1[依赖处理流程]
R -- 编译器问题 --> H2[工具链检查流程]
R -- 代码不兼容 --> H3[补丁流程]
R -- 无法判断 --> H4[交给 Agent 或人工]
适合:输入有明显不同的类别,各类处理方式差别大。也可以用来按难度选模型:简单问题给便宜的小模型,难的给大模型。
我项目的 agent/errors.py 就是一个用正则做的分类器,把报错分成 dependency_missing、link_error、arch_mismatch 等 8 类,目前只用来统计和展示,没有用来路由。这是一个可以改进的方向。
模式 3:并行(Parallelization)
两种形式:
- 分段(sectioning):把任务拆成互相独立的子任务同时做,最后汇总
- 投票(voting):同一个任务跑多次,取多数结果或综合判断
flowchart TB
subgraph 分段
I1[一个大项目] --> P1[检查依赖]
I1 --> P2[检查编译选项]
I1 --> P3[检查平台相关代码]
P1 --> M1[汇总]
P2 --> M1
P3 --> M1
end
subgraph 投票
I2[这段代码有安全问题吗] --> V1[模型第 1 次]
I2 --> V2[模型第 2 次]
I2 --> V3[模型第 3 次]
V1 --> M2[多数表决]
V2 --> M2
V3 --> M2
end
适合:分段用于提速或让每个子任务注意力更集中;投票用于提高判断的可靠性,代价是成本翻几倍。
模式 4:编排者-执行者(Orchestrator-Workers)
一个”编排者”模型根据具体输入动态拆分子任务,分给”执行者”去做,再汇总。和并行分段的区别是:子任务不是事先写死的,而是编排者看了输入之后才决定的。
flowchart TB
I[任务:把这个仓库编到 aarch64] --> O[编排者模型:分析后决定拆成几个子任务]
O --> W1[执行者:编 zlib]
O --> W2[执行者:编 libpng]
O -.根据仓库不同,子任务数量和内容都不同.-> W3[执行者:...]
W1 --> S[编排者汇总]
W2 --> S
W3 --> S
适合:子任务事先无法预测,比如一次代码修改要动哪些文件取决于具体需求。这个模式再往前走一步就是多 Agent,在 13 多 Agent 篇讲。
模式 5:评估者-优化者(Evaluator-Optimizer)
一个模型生成,另一个模型(或代码)评价并给反馈,循环直到通过。
flowchart LR
I[需求] --> G[生成者:写 CMake 补丁]
G --> E{评估者:检查补丁}
E -- 不通过 + 具体意见 --> G
E -- 通过 --> O[输出]
适合:有清楚的评价标准,而且反馈确实能让结果变好。评估者如果能换成代码(编译、测试、格式校验),效果最可靠。
五种模式的共同点:路径结构都是代码定的,模型只在节点里干活。一旦”下一步做什么”交给模型自己决定,就变成了 Agent。
4.3 Agent 循环的完整骨架
把 04 篇的一次往返放进循环,再加上停止条件和验收,就是一个能用的 Agent:
flowchart TD
START([任务开始]) --> PREP[程序准备<br/>系统提示词 / 工具定义 / 初始信息]
PREP --> BUDGET{预算检查<br/>步数 / 时间 / 费用}
BUDGET -- 超出 --> STOP_B([停止:budget_exceeded 或 timeout])
BUDGET -- 没超 --> CALL[调模型]
CALL --> HAS{返回里有工具调用吗}
HAS -- 没有 --> VERIFY[程序独立验收]
VERIFY -- 通过 --> STOP_OK([停止:completed])
VERIFY -- 不通过 --> STOP_F([停止:failed_verification])
HAS -- 有 --> EXEC[逐个校验 / 执行 / 回填]
EXEC --> STUCK{卡死了吗<br/>重复调用且没进展}
STUCK -- 是 --> STOP_S([停止:stuck])
STUCK -- 否 --> BUDGET
注意三个设计点:
- 预算检查在调模型之前。先确认还有预算,再花钱
- 模型不调工具时进入验收,而不是直接判成功
- 每种停止都有明确的原因。日志和前端展示都要能区分”做完了""验收没过""钱花完了""卡死了”,排查时才知道该改哪里
有些场景验收失败后不直接停,而是把验收结果回给模型让它继续修(只要预算还够)。我的项目选择验收失败就结束,因为评测时要统计”模型认为做完但其实没做完”的次数。两种都合理,按需求定。
4.4 Python 预备:这篇实验用到的新语法
生成器(generator)和 yield:普通函数用 return 一次返回一个结果就结束了。函数里写了 yield,它就变成生成器:每遇到一次 yield 就”交出”一个值并暂停,调用方处理完再让它从暂停处继续。
def count_up():
yield 1
yield 2
for n in count_up():
print(n)
输出 1 和 2。for 每次循环都让生成器往下跑到下一个 yield。
为什么 Agent 要用生成器?Agent 一次任务可能跑几分钟,调用方(命令行打印、网页实时显示)希望每做一步就收到通知,而不是等全部结束。生成器天然就是”做一步交一个事件”。我项目的 build_events 就是生成器,网页后端把它产生的每个事件通过 SSE 推给浏览器(14 AI 应用后端篇讲)。
生成器里的 return 表示结束,不再产生值。
iter() 和 next():iter(列表) 得到一个迭代器(iterator),可以理解成”记住读到哪儿的书签”;next(迭代器) 取出下一个元素并把书签往后移。实验里用它让假模型每被调用一次就返回剧本里的下一句。
函数里定义函数(闭包,closure):
def make_counter():
n = [0]
def counter():
n[0] += 1
return n[0]
return counter
内层函数 counter 能用外层函数的变量 n,而且外层函数返回后这个变量依然”活着”。实验里 make_scripted_model 返回的 model 函数记住了自己的剧本 turns。
类(class)和方法:04 篇讲过类是数据模板。类里用 def 定义的函数叫方法(method),第一个参数固定写 self,代表”这个对象自己”。__init__ 是创建对象时自动执行的初始化方法。self.files = {...} 表示给这个对象存一个叫 files 的属性。
class FakeProject:
def __init__(self):
self.artifact = None
p = FakeProject()
print(p.artifact)
FakeProject() 创建一个对象,自动执行 __init__,输出 None。
字典推导式:{k: v for k, v in seen.items() if 条件} 用一行生成新字典,只保留满足条件的键值对。
time.monotonic():返回一个只增不减的时间(秒),专门用来算”过了多久”。不用 time.time() 是因为系统时间可能被调整,算出负的耗时。
4.5 动手实验 1:带停止条件和验收的 Agent 循环
不需要 API key。用剧本代替模型,演示四种结局。保存为 agent_loop.py,运行 python3 agent_loop.py。
import json
import time
class FakeProject:
def __init__(self):
self.files = {"CMakeLists.txt": "find_package(ZLIB REQUIRED)\n", "build.conf": ""}
self.artifact = None
def read_file(self, path):
if path not in self.files:
return {"ok": False, "error": f"文件不存在: {path}"}
return {"ok": True, "text": self.files[path]}
def write_file(self, path, content):
self.files[path] = content
return {"ok": True, "text": f"已写入 {path}"}
def run_build(self):
if "ZLIB_ROOT" not in self.files["build.conf"]:
return {"ok": False, "error": "Could NOT find ZLIB"}
self.artifact = "libdemo.a (aarch64)"
return {"ok": True, "text": "exit=0"}
def call(name, **args):
return {"id": f"c{time.perf_counter_ns()}", "name": name, "arguments": json.dumps(args)}
def make_scripted_model(script):
turns = iter(script)
def model(messages):
return next(turns)
return model
def verify(project):
return project.artifact is not None and project.artifact.endswith("(aarch64)")
def run_agent(model, project, max_steps=6, repeat_limit=2, deadline_s=5.0):
tools = {"read_file": project.read_file, "write_file": project.write_file,
"run_build": project.run_build}
messages = [{"role": "user", "content": "把项目编译到 aarch64"}]
seen = {}
blocked = 0
started = time.monotonic()
for step in range(1, max_steps + 1):
if time.monotonic() - started > deadline_s:
yield {"type": "stop", "reason": "timeout", "step": step}
return
reply = model(messages)
messages.append(reply)
if not reply.get("tool_calls"):
yield {"type": "model_done", "step": step, "text": reply["content"]}
reason = "completed" if verify(project) else "failed_verification"
yield {"type": "stop", "reason": reason, "step": step}
return
for tc in reply["tool_calls"]:
key = tc["name"] + tc["arguments"]
seen[key] = seen.get(key, 0) + 1
if seen[key] > repeat_limit:
result = {"ok": False, "error": "同样的调用已重复多次且没有进展,换个思路"}
blocked += 1
else:
result = tools[tc["name"]](**json.loads(tc["arguments"]))
if tc["name"] == "write_file" and result["ok"]:
seen = {k: v for k, v in seen.items() if not k.startswith("run_build")}
yield {"type": "tool", "step": step, "name": tc["name"], "ok": result["ok"]}
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": json.dumps(result, ensure_ascii=False)})
if blocked >= 2:
yield {"type": "stop", "reason": "stuck", "step": step}
return
yield {"type": "stop", "reason": "budget_exceeded", "step": max_steps}
def say(text):
return {"role": "assistant", "content": text}
def act(*calls):
return {"role": "assistant", "content": None, "tool_calls": list(calls)}
scenarios = {
"正常修好": [
act(call("read_file", path="CMakeLists.txt")),
act(call("run_build")),
act(call("write_file", path="build.conf", content="ZLIB_ROOT=deps/zlib")),
act(call("run_build")),
say("编译通过"),
],
"模型谎报完成": [
act(call("run_build")),
say("编译通过"),
],
"原地打转": [act(call("run_build")) for _ in range(6)],
"一直换办法但没修对": [act(call("write_file", path="build.conf", content=f"TRY={i}"), call("run_build"))
for i in range(6)],
}
for title, script in scenarios.items():
print(f"== {title}")
for event in run_agent(make_scripted_model(script), FakeProject()):
print(" ", event)
实际输出(Python 3.14):
== 正常修好
{'type': 'tool', 'step': 1, 'name': 'read_file', 'ok': True}
{'type': 'tool', 'step': 2, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 3, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 4, 'name': 'run_build', 'ok': True}
{'type': 'model_done', 'step': 5, 'text': '编译通过'}
{'type': 'stop', 'reason': 'completed', 'step': 5}
== 模型谎报完成
{'type': 'tool', 'step': 1, 'name': 'run_build', 'ok': False}
{'type': 'model_done', 'step': 2, 'text': '编译通过'}
{'type': 'stop', 'reason': 'failed_verification', 'step': 2}
== 原地打转
{'type': 'tool', 'step': 1, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 2, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 3, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 4, 'name': 'run_build', 'ok': False}
{'type': 'stop', 'reason': 'stuck', 'step': 4}
== 一直换办法但没修对
{'type': 'tool', 'step': 1, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 1, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 2, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 2, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 3, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 3, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 4, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 4, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 5, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 5, 'name': 'run_build', 'ok': False}
{'type': 'tool', 'step': 6, 'name': 'write_file', 'ok': True}
{'type': 'tool', 'step': 6, 'name': 'run_build', 'ok': False}
{'type': 'stop', 'reason': 'budget_exceeded', 'step': 6}
逐段讲。
① 假项目 FakeProject。 一个类,模拟要编译的项目。
__init__里准备两个”文件”:CMakeLists.txt声明依赖 ZLIB,build.conf一开始为空。self.artifact = None表示还没有编译产物read_file、write_file就是读写self.files这个字典run_build是关键:if "ZLIB_ROOT" not in self.files["build.conf"]判断配置文件的字符串里有没有ZLIB_ROOT这几个字(in用在两个字符串之间,表示”包含”)。没有就失败;有就生成一个 aarch64 产物
所以”修好”的唯一方法是往 build.conf 里写入 ZLIB_ROOT。
② 构造工具调用 call(name, **args)。 参数列表里的 **args 和 04 篇讲的解包方向相反:定义函数时写 **args,表示把调用时传入的所有关键字参数收集成一个字典。call("read_file", path="a") 里,name 是 "read_file",args 是 {"path": "a"}。time.perf_counter_ns() 返回纳秒级计时,这里只是借它生成不重复的调用 ID。
③ 剧本模型 make_scripted_model(script)。 用闭包返回一个 model 函数。turns = iter(script) 建立书签,model 每被调用一次就 next(turns) 取剧本的下一句。它忽略了传进来的 messages,这是和真模型最大的区别。
④ 验收 verify(project)。 不看模型说了什么,只检查项目里有没有产物、产物是不是 aarch64。and 两边都为真才为真;如果 artifact 是 None,第一个条件已经是假,Python 不会再执行 .endswith(...)(这叫短路求值),所以不会因为 None 没有 endswith 方法而报错。
⑤ 主循环 run_agent(...)。 这是一个生成器,按时间顺序走一遍:
- 准备工具表、初始消息、重复计数字典
seen、被拦截次数blocked、开始时间 for step in range(1, max_steps + 1):range(1, 7)产生 1 到 6,即最多 6 步。循环正常跑完就走到最后一行,产生budget_exceeded- 每步先检查时间:超过
deadline_s秒就yield一个超时事件并return结束 - 调模型,把回复追加进历史
if not reply.get("tool_calls"):没有工具调用,说明模型认为做完了。先产生model_done事件,再调verify决定结局是completed还是failed_verification- 有工具调用就逐个处理:
key = tc["name"] + tc["arguments"]:工具名加参数字符串,作为”这是同一个调用吗”的判断依据seen.get(key, 0) + 1:取已有次数(没有就是 0)加 1- 超过
repeat_limit就不执行,直接返回”换个思路”,并记一次拦截 - 否则
json.loads解析参数,**解包调用工具 - 写文件成功后,把所有
run_build的计数删掉。因为配置变了,再构建是正常验证,不应该算重复。startswith("run_build")判断键是否以这个字符串开头 yield一个工具事件;把结果回填进消息
- 一轮处理完,如果拦截次数到 2,判定卡死
⑥ 四个剧本对应四种结局:
| 剧本 | 发生了什么 | 结局 | 说明了什么 |
|---|---|---|---|
| 正常修好 | 读配置 → 构建失败 → 写入 ZLIB_ROOT → 构建成功 → 说完成 | completed | 第 4 步的构建和第 2 步参数一样,但因为中间写过文件,没被判重复 |
| 模型谎报完成 | 构建失败后直接说”编译通过” | failed_verification | 模型的话不能当成功依据 |
| 原地打转 | 连续 6 次构建,什么都不改 | 第 4 步 stuck | 第 3、4 次超过上限被拦截,拦截 2 次后停止,省下 2 步 |
| 一直换办法但没修对 | 每步写不同内容再构建 | budget_exceeded | 每次写入内容不同,重复检测判断不出来,只能靠总步数兜底 |
最后一个剧本很重要:卡死检测只能发现”完全一样的重复”。模型换着花样试错,每次都不一样但都没用,只有总预算能兜住。所以两道防线都要有。
自己改一改:
- 把
deadline_s改成0,看看会发生什么 - 在”正常修好”剧本里删掉
write_file那一行,第二次run_build会怎样 - 想一想:真实项目里
cmake自己删掉了 build 目录(状态变了),但没经过write_file,这个重复检测会误判吗
4.6 停止条件和预算
所有停止原因可以画成一张状态图:
stateDiagram-v2
[*] --> 运行中
运行中 --> 验收中: 模型不再调工具
验收中 --> 已完成: 验收通过
验收中 --> 验收失败: 验收不通过
运行中 --> 预算耗尽: 步数 / 费用用完
运行中 --> 超时: 超过截止时间
运行中 --> 卡死: 重复调用无进展
运行中 --> 等待人工: 需要确认或缺信息
等待人工 --> 运行中: 人工批准或补充信息
运行中 --> 崩溃: 程序异常 / API 不可用
已完成 --> [*]
验收失败 --> [*]
预算耗尽 --> [*]
超时 --> [*]
卡死 --> [*]
“等待人工”和”崩溃后恢复”需要把状态存下来,在 07 状态与恢复篇讲。
各种预算的区别:
| 预算 | 记什么 | 容易踩的坑 |
|---|---|---|
| 步数 | 模型调用轮数;工具调用次数最好单独再记 | 一轮可以有多个工具调用,只数轮数会低估工作量 |
| 时间 | 整体截止时间;每次模型请求和工具进程的单次超时 | 只在每步开头检查时间,挡不住某一次调用卡住不返回(实验里就有这个问题) |
| 费用 | 输入、输出、缓存命中的 token 分别按价格算 | token 总数不等于金额,输入输出单价不同 |
| 重复 | 工具名 + 规范化后的参数 + 相关状态 | 只看参数会把”改完再构建”误判;只看完全相同又抓不到换花样试错 |
实验 1 里的超时只在每步开头检查,如果 run_build 自己卡住一小时,循环根本走不到下一次检查。所以单次超时必须落实到具体的调用上:模型请求设 HTTP 超时,命令用 subprocess.run(..., timeout=秒数)。我项目的 run_command 就是这样做的,而且系统提示词里告诉模型”configure 传 180,build 传 300”。
为什么 Agent 这么费 token? 每调一次模型,都要把完整的消息历史重新发一遍。假设每轮新增 1000 token 的内容,第 1 轮发 1000,第 2 轮发 2000,第 N 轮发 N×1000,N 轮加起来大约是 N²/2 × 1000。步数翻倍,输入 token 大约翻四倍。
xychart-beta
title "每轮新增 1000 token 时的累计输入量(千 token)"
x-axis "轮次" [5, 10, 15, 20, 25, 30, 35, 40]
y-axis "累计输入" 0 --> 850
line [15, 55, 120, 210, 325, 465, 630, 820]
这是简化模型:真实情况里每轮新增量不固定,prompt caching 能让重复的前缀便宜很多,上下文压缩也会改变增长方式(06 上下文工程篇)。但方向是对的。我项目第一版评测的数据:baseline 一轮输入 137.5 万 token、输出 5.0 万,输入是输出的 27 倍;单个项目最贵的一次 18 步,用了 24 万输入 token。
步数上限的真实故事。 我项目最早 MAX_STEPS = 25,第一版评测里:
| 项目 | 25 步(三轮) | 只改成 40 步 |
|---|---|---|
| libpng | 过 / 过 / 败,三次全部跑满 25 | 通过,29 步 |
| re2 | 败 / 过 / 败 | 通过,32 步 |
翻轨迹发现 libpng 走的路完全正确:识别出缺 zlib,下载 zlib 源码,用同一个工具链交叉编译,再回头编 libpng。第 25 步时它刚开始配置 zlib,预算就没了。这类任务是嵌套的,编主项目之前要先完成整个”编依赖”的子任务。
但这个结论要打折扣:每边只跑了一次,排除不了随机性;而且当时 read_file 只能看文件末尾、不能翻页,模型好几次自己写 CMake 脚本分段读文件,一部分步数是被工具缺陷吃掉的。
面试时可以这样总结:步数上限不是技术参数,是”愿意为一次失败付多少钱”的业务选择。 跑满 40 步比 25 步贵六成。
4.7 ReAct:边想边做
先说为什么会有 ReAct。 2022 年时,让模型解决问题有两条路,是分开研究的。一条是”只想不做”:思维链让模型把推理步骤写出来,但用到的事实全靠模型记忆,记错了就一路错下去。另一条是”只做不想”:让模型直接输出一串动作(搜索、点击、拿起某个东西),但中间不写为什么这么做,遇到意外情况就不会调整计划。普林斯顿大学的博士生姚顺雨(Shunyu Yao)和 Google Research Brain 团队的合作者在 2022 年 10 月提出 ReAct,把两者交替起来:想一步、做一步、看结果、再想。他们在维基百科问答、事实核查、文字冒险游戏和网页购物四类任务上测试:两类要和环境交互的任务上,成功率明显高于只做不想的做法;问答和事实核查上,ReAct 和思维链结合使用效果最好。
官方定义:ReAct(Reasoning + Acting)出自 Yao 等人 2022 年的论文 ReAct: Synergizing Reasoning and Acting in Language Models(ICLR 2023)。它让模型交替生成推理轨迹和具体行动:推理帮助模型制定、跟踪和更新行动计划并处理异常;行动让模型能从外部(知识库、环境)获取信息。
先分清它的”前辈”:思维链(Chain-of-Thought, CoT),出自 Wei 等人 2022 年的论文 Chain-of-Thought Prompting,让模型在给答案前先写出中间推理步骤,复杂推理题的正确率明显提高。但思维链全在”脑子里”,推理用的事实如果是错的,就会一路错下去(也就是幻觉)。ReAct 在推理中间插入真实的行动和观察,用外部信息来纠正。
sequenceDiagram
participant M as 模型
participant E as 环境(工具)
Note over M: 思考:用户要编 ARM64,先看构建配置
M->>E: 行动:read_file CMakeLists.txt
E-->>M: 观察:find_package(ZLIB REQUIRED)
Note over M: 思考:需要 zlib,先试一次配置看缺什么
M->>E: 行动:cmake -B build
E-->>M: 观察:Could NOT find ZLIB
Note over M: 思考:目标平台没有 zlib,要先交叉编译它
M->>E: 行动:git clone zlib 并构建
E-->>M: 观察:exit=0
Note over M: 思考:依赖好了,指定 ZLIB_ROOT 重新配置
原论文里”思考”是模型输出的一段文字(格式类似 Thought: ... Action: ... Observation: ...),由程序解析出行动。现在的做法有两个变化:
- 行动部分用原生工具调用,不用自己解析文本格式,更可靠
- 思考部分:普通模型可以在调用工具的同时输出一段说明(
content字段);推理模型(reasoning model,比如 DeepSeek-R1 这类会先输出长推理过程的模型)的思考过程是单独的字段或隐藏的。产品上一般不需要把完整思考展示给用户
所以今天说”ReAct”,基本就是指 04 篇那种”调模型 → 工具调用 → 观察结果 → 再调模型”的循环,实验 1 就是 ReAct 的骨架。
ReAct 的局限:
| 局限 | 表现 | 缓解 |
|---|---|---|
| 每步都调模型 | 步骤多时慢、贵 | 先计划后执行;简单步骤交给代码 |
| 只看眼前一步 | 长任务里忘了全局目标、在局部绕圈 | 显式维护计划或待办清单;上下文里保留目标 |
| 错误会累积 | 前面一个错误判断,后面都建立在它上面 | 关键节点做验证;允许回退 |
| 容易过早结束 | 模型觉得差不多了就说完成 | 独立验收 |
4.8 先计划后执行(Plan-and-Execute)
思路:先让模型根据目标列出一份完整的步骤清单(planner,规划器),然后由执行器(executor)逐步执行。某一步失败时,把失败信息交回规划器重新规划(replan)。
来源上,Plan-and-Solve Prompting(Wang 等,2023)提出先制定计划把任务拆成子任务、再逐个解决,用来改进零样本思维链;LangChain 在 Plan-and-Execute Agents(2024-02)里把这类思路整理成了 Agent 架构。
flowchart TD
G[目标] --> P[规划器:列出步骤清单]
P --> Q[(待执行步骤)]
Q --> X[执行器:取一步执行]
X --> R{成功吗}
R -- 成功 --> D{还有步骤吗}
D -- 有 --> Q
D -- 没有 --> V[验收]
R -- 失败 --> RP{重新规划次数用完了吗}
RP -- 没用完 --> P2[规划器:带着失败信息重新规划]
P2 --> Q
RP -- 用完了 --> B([停止:blocked])
动手实验 2:规划器和执行器都用写死的函数模拟,只看控制流程。保存为 plan_execute.py。
def planner(goal, failed_step=None):
if failed_step is None:
return ["configure 主项目", "build 主项目", "检查产物架构"]
return ["交叉编译 zlib 并安装到 deps/", "configure 主项目并指定 ZLIB_ROOT", "build 主项目", "检查产物架构"]
def executor(step, state):
if step == "configure 主项目" and not state["zlib_ready"]:
return False, "Could NOT find ZLIB"
if step.startswith("交叉编译 zlib"):
state["zlib_ready"] = True
return True, "ok"
def plan_and_execute(goal, max_replans=2):
state = {"zlib_ready": False}
plan = planner(goal)
replans = 0
done = []
while plan:
step = plan.pop(0)
ok, output = executor(step, state)
print(f" {'✓' if ok else '✗'} {step} {output}")
if ok:
done.append(step)
continue
if replans == max_replans:
return "blocked", done
replans += 1
plan = planner(goal, failed_step=step)
print(f" 重新规划(第 {replans} 次): {plan}")
return "completed", done
status, done = plan_and_execute("把 libpng 编译到 aarch64")
print(status, len(done), "步完成")
实际输出:
✗ configure 主项目 Could NOT find ZLIB
重新规划(第 1 次): ['交叉编译 zlib 并安装到 deps/', 'configure 主项目并指定 ZLIB_ROOT', 'build 主项目', '检查产物架构']
✓ 交叉编译 zlib 并安装到 deps/ ok
✓ configure 主项目并指定 ZLIB_ROOT ok
✓ build 主项目 ok
✓ 检查产物架构 ok
completed 4 步完成
逐段讲。
def planner(goal, failed_step=None):failed_step=None是默认参数,调用时不传就是None。第一次规划不传,得到三步的初始计划;重新规划时传入失败的步骤,得到先编 zlib 的新计划。真实系统里这两处都是调模型executor(step, state)返回两个值return False, "...":Python 里用逗号可以一次返回多个值(实际上是一个元组),调用方用ok, output = executor(...)分别接住,这叫解包赋值state是一个字典,被执行器修改后,调用方也能看到变化。因为字典是可变对象,传进函数的是同一个对象,不是复制品while plan::列表不为空时一直循环。空列表在条件判断里当作假plan.pop(0):取出并删除列表第 0 个元素,也就是”取下一步”f"{'✓' if ok else '✗'}":花括号里写了一个条件表达式,A if 条件 else B,条件为真取 A 否则取 B- 失败时先检查重新规划次数,用完了返回
blocked;没用完就用新计划整个替换剩下的步骤
执行流程:初始计划第一步就失败 → 重新规划 → 新计划四步全部成功 → completed。
和 ReAct 对比:
| ReAct | 先计划后执行 | |
|---|---|---|
| 调大模型的次数 | 每一步都调 | 规划一次,重新规划时再调;执行步骤可以用小模型或代码 |
| 适合的任务 | 每步结果都可能改变方向,比如排查未知错误 | 步骤基本可预见,依赖关系清楚,比如数据处理流水线 |
| 可解释性 | 路径事后才知道 | 计划可以先给用户看、让人确认 |
| 风险 | 局部绕圈、缺全局视野 | 计划基于不完整信息;环境变化大时频繁重新规划 |
LangChain 那篇文章还介绍了两个变体,知道名字和思路即可:
- ReWOO:规划时用变量引用前面步骤的结果(比如”第 3 步用第 1 步的输出”),执行过程中不用再调规划模型
- LLMCompiler:把计划做成依赖图,没有依赖关系的步骤并行执行,速度最快,结构也最复杂
构建任务为什么我没用先计划后执行? 因为交叉编译会遇到什么错误根本无法预见,初始计划基本都是”配置、构建、检查”,每次失败都要重新规划,等于退化成了更慢的 ReAct。实际常用的是组合:顶层粗略规划(先编依赖、再编主项目),每个子任务内部用 ReAct。
4.9 反思(Reflection)和评估-优化循环
思路:做完或做错之后,让模型回头看结果和反馈,用文字总结问题,再改进。
代表工作是 Reflexion(Shinn 等,2023):Agent 在一次尝试失败后,根据任务反馈信号(比如单元测试结果)用文字反思哪里做错了,把反思内容存进一个”经历记忆”,下一次尝试时带上这些反思。它不更新模型权重,只靠语言反馈改进,在代码生成基准 HumanEval 上报告了明显提升。
flowchart LR
T[任务] --> A[Agent 尝试]
A --> F[外部反馈<br/>测试结果 / 报错 / 验收]
F --> OK{通过吗}
OK -- 通过 --> END([完成])
OK -- 不通过 --> R[模型反思:<br/>哪里错了,下次怎么做]
R --> MEM[(反思记录)]
MEM --> A
关键在于反馈信号的来源:
| 反馈来源 | 可靠性 | 例子 |
|---|---|---|
| 程序验证 | 高 | 编译是否通过、测试是否通过、JSON 能否解析 |
| 另一个模型评审 | 中,要校准 | 用模型检查回答是否引用了资料 |
| 同一个模型自己检查 | 低 | 让模型”检查一下你的答案对不对” |
没有外部信号的纯自我检查,模型经常把对的改错,或者什么都没发现就说”没问题”。所以面试时要强调:反思要接在真实反馈上。
Anthropic 2026 年 3 月的 Harness design for long-running application development 给了一个新近的例子:他们让 Claude 从一句话需求做出完整的网页应用,发现模型评价自己的作品时明显偏宽松,会自信地夸一个平庸的结果。解决办法是把”评估”拆成单独的 Agent,用 Playwright 真去点页面、按写好的标准打分,把具体问题反馈给负责写代码的 Agent。这就是上表第二行”另一个模型评审”,只是评审者能实际操作产品,反馈更接近程序验证。
我项目里没有显式的反思步骤,但有两处是同样的思路:
- 工具报错会原样回给模型,模型在下一轮根据报错调整,这是最朴素的”基于反馈改进”
- 知识库条目是从 160 次运行记录里提炼的”经验”,相当于把跨任务的反思沉淀下来(08 RAG 篇讲怎么提炼和审核)
4.10 怎么选:一张决策图
flowchart TD
S[新需求] --> Q1{一次模型调用<br/>加检索或例子能解决吗}
Q1 -- 能 --> A1[单次调用]
Q1 -- 不能 --> Q2{步骤和分支<br/>能事先写清楚吗}
Q2 -- 能 --> A2[工作流<br/>选合适的模式组合]
Q2 -- 不能 --> Q3{结果能自动验证吗}
Q3 -- 不能 --> A3[工作流 + 人工审核<br/>或缩小范围到能验证]
Q3 -- 能 --> Q4{步骤能大致预见吗}
Q4 -- 能 --> A4[先计划后执行<br/>失败时重新规划]
Q4 -- 不能 --> A5[ReAct 循环<br/>加预算和验收]
A4 --> Q5{子任务之间<br/>需要隔离上下文或并行吗}
A5 --> Q5
Q5 -- 需要 --> A6[考虑多 Agent]
Q5 -- 不需要 --> A7[单 Agent 就够]
Agent 的代价清单,决定用之前过一遍:
- 成本:多轮调用、每轮重发历史
- 延迟:用户要等几十秒到几分钟,需要流式展示进度
- 不确定:同一任务跑两次路径不同,我项目里同一个项目 3 次运行的步数标准差平均 2.5 到 3.4 步
- 难测试:路径不固定,要靠多次运行统计
- 安全:模型能决定调用什么,权限边界必须在代码里
外层结构要跟着模型换代做减法。 同一篇 Anthropic 文章里有一句值得记住的话:外层框架(文中叫 harness)里的每个组件,都对应一条”模型自己做不到”的假设。他们用 Opus 4.5 时需要把任务拆成一个个小阶段、上下文快满时要清空重来;换到 Opus 4.6 后把阶段拆分去掉,效果没有变差。所以换了更强的模型,要回头逐个去掉组件、用评测确认哪些还有用,而不是一直往上加。
4.11 人在回路(Human-in-the-loop)
有些步骤不能完全交给 Agent:删除数据、发邮件、付款、合并代码、上线。常见的做法是在循环里加”等待人工”状态:
sequenceDiagram
participant A as Agent 循环
participant S as 状态存储
participant H as 人
A->>A: 模型请求 send_email
A->>A: 程序判断:高风险操作
A->>S: 保存当前状态和待确认的调用
A->>H: 通知:请确认发送这封邮件
Note over A: 循环暂停,不占用进程
H->>A: 批准(或修改后批准 / 拒绝)
A->>S: 读回状态
A->>A: 执行或把拒绝原因回给模型
要点:
- 暂停期间可能过去几小时,状态必须持久化,不能只放在内存里
- 人可以批准、拒绝、修改参数后批准,拒绝时要把原因回给模型
- 12-Factor Agents 的第 7 条”通过工具调用联系人类”,就是把”问人”也做成一个工具,让模型在缺信息时主动请求
具体实现(LangGraph 的中断机制、检查点)在 07 状态与恢复篇。
第五部分 对照项目
手写版的整体结构(agent/build_agent.py):
flowchart TD
subgraph SG1["工作流部分:程序固定执行"]
P1[prepare<br/>克隆项目 / 拷贝工具链] --> P2[设置 CMAKE_TOOLCHAIN_FILE 环境变量]
P2 --> P3[survey<br/>扫描根目录和 CMake 选项]
end
P3 --> L
subgraph SG2["Agent 部分:模型决定"]
L[调模型] --> T{有工具调用}
T -- 有 --> C[check_and_run<br/>重复计数 / 执行 / 判断 ok]
C --> K{所有调用都重复超限<br/>且不止 2 种}
K -- 否 --> L2{到 40 步了吗}
L2 -- 没到 --> L
end
T -- 没有 --> V
K -- 是 --> V
L2 -- 到了 --> V
subgraph SG3["工作流部分:程序验收"]
V[verify<br/>重新构建 + 检查 ELF 架构]
end
V --> R([success / failed / max_steps / stuck])
LangGraph 版(agent/graph_agent.py)把同一个循环拆成三个节点:
flowchart LR
START([START]) --> agent[agent 节点<br/>调模型]
agent -- "after_agent:没有工具调用" --> verify[verify 节点<br/>独立验收]
agent -- "after_agent:有工具调用" --> tools[tools 节点<br/>执行 + 卡死判断]
tools -- "after_tools:卡死或到步数上限" --> verify
tools -- "after_tools:否则" --> agent
verify --> END([END])
| 本篇知识点 | 项目里的位置 | 做到了什么 | 没做到或可以改进的 |
|---|---|---|---|
| 工作流 + Agent 组合 | build_events 里的 prepare、survey、verify | 前置扫描和后置验收由程序固定执行 | 构建失败后自动检索知识库还没做成固定步骤 |
| 独立验收 | verify、scan_artifacts、find_build_dir | 重新构建并检查 ELF 架构;自己找构建目录 | 没有在目标平台运行测试 |
| 步数上限 | MAX_STEPS,默认 40 | 用评测数据从 25 调到 40 | 没有按难度分级的预算 |
| 卡死检测 | check_and_run、主循环末尾的 stuck 判断 | 同一调用超过 3 次拒绝执行;写文件后清零命令计数 | 只认完全相同的调用;状态变化只用”写文件”近似 |
| 停止原因 | status:agent_done / max_steps / stuck,验收后变成 success / failed | 能区分几类结局,记进 runs.db | 没有费用上限;整体超时只靠单个命令的超时 |
| 事件流 | build_events 是生成器,yield 各类事件 | 命令行和网页共用同一套事件 | — |
| 路由 | errors.py 的 8 类报错分类 | 用于统计和展示 | 没用来选择处理流程或模型 |
| 手写 vs 框架 | build_agent.py 循环 65 行,graph_agent.py 126 行 | 两版评测结果看不出差别 | — |
| 人在回路 | — | 没有 | 写文件、执行命令都不需要确认;评测场景下可以接受,给用户用时要加 |
建议阅读:agent/build_agent.py 的 build_events 函数(第 243 到 320 行附近),对照上面第一张图看。
对照开源实现:pi
pi 是一个开源的编程 Agent,用法和 Claude Code 类似,用 TypeScript 写。下面引用的源码都以 commit 7b4cfd6(2026-09-11)为准。
它的主循环是 packages/agent/src/agent-loop.ts 里的 runLoop,一共 118 行:
flowchart TD
A[取用户中途插的话] --> B{还有工具调用要接着做<br/>或者有插话}
B -- 有 --> C[把插话追加进消息<br/>调模型]
C --> D{stopReason 是<br/>error 或 aborted}
D -- 是 --> Z([结束])
D -- 否 --> E{返回了工具调用吗}
E -- 没有 --> G1[标记:不用接着做]
E -- 有 --> F{stopReason 是 length<br/>输出被截断了}
F -- 是 --> F1[一个都不执行<br/>每个回一条报错]
F -- 否 --> F2[执行,默认并行]
F1 --> G2[标记:接着做<br/>除非这批结果全都要求结束]
F2 --> G2
G1 --> H[再取一次插话]
G2 --> H
H --> B
B -- 都没有 --> I{有排队的追加消息吗}
I -- 有 --> B
I -- 没有 --> Z
和本篇 4.3 节的骨架、我的项目逐项对比:
| 本篇讲的 | pi 的做法 | 我的项目 |
|---|---|---|
| 步数上限 | 没有。agent 和 coding-agent 两个包里搜不到任何轮数上限 | 40 步 |
| 卡死检测 | 没有 | 同一调用超过 3 次拒绝执行 |
| 模型停下后验收 | 没有。模型不再调工具、也没有排队的消息,循环就结束 | 程序重新构建并检查 ELF 架构 |
| 什么时候停 | 模型不再调工具;请求出错或被中止(用户按 Esc);外部传入的 shouldStopAfterTurn 返回 true;一批工具结果全部带 terminate 标记 | 模型结束、到 40 步、卡死 |
| 人在回路 | 没有内置审批。提供 beforeToolCall 钩子,返回 { block: true, reason } 就不执行,把 reason 当作报错结果回给模型(types.ts) | 没有 |
| 运行中途插话 | 用户在 Agent 干活时按 Enter 输入的话进”插话队列”,等这一轮工具执行完、下一次调模型之前追加进去;按 Alt+Enter 进”追加队列”,等 Agent 本来要结束时才处理 | 无人值守,没有 |
| 输出被截断 | stopReason 是 length 时,这条回复里的工具调用全部不执行,每个回一条报错,意思是”参数可能被截断了,请重发完整参数”(L379-L404) | 没有检查 finish_reason |
| 一轮多个工具调用 | 默认并行;只要其中一个工具声明了必须串行(executionMode: "sequential"),整批改成串行 | 逐个串行 |
最值得讲的差别是前三行:pi 没有步数上限、卡死检测和验收,为什么还能用? 因为它是交互式工具,人就坐在终端前。模型绕圈子,人会按 Esc 中止;方向偏了,人直接插一句话纠正;做没做完,人自己看。本篇讲的那些程序兜底,本质上是在替”不在场的人”做判断。我的项目是无人值守的批量评测,一次跑十几个项目,没人盯着,所以这几层必须由程序来做。反过来,如果把我的 Agent 做成给人实时用的工具,步数上限可以放宽,插话和中止反而更重要。
截断时拒绝执行,是一个容易漏掉的细节。 流式输出时,工具参数是一段一段拼起来的 JSON。回复被 max_tokens 截断后,pi 会尽量把残缺的 JSON 修补成能解析的样子,结果可能是”格式合法、校验也能过,但内容少了一截”,比如写文件工具的内容只写了一半。源码注释专门说明了这个风险,所以干脆一个都不执行,让模型重发。
第六部分 追问清单
| 你刚讲完 | 下一个追问 | 回答方向 |
|---|---|---|
| Agent 和工作流的区别 | 你的项目为什么用 Agent | 报错种类无法枚举,但结果容易验证;前后固定步骤仍是工作流 |
| 五种工作流模式 | 你用过哪几种 | 提示链(扫描 → 循环 → 验收);路由思路(报错分类,未接入) |
| 循环骨架 | 模型一直不停怎么办 | 步数、时间、费用上限;卡死检测;两道防线的分工 |
| 卡死检测 | 怎么区分正常重试和打转 | 把状态变化算进去;我用写文件清零近似;局限是命令改变状态时判断不出 |
| 步数上限 | 设多少、怎么定的 | 25 → 40 的实验;嵌套任务;本质是为失败付多少钱 |
| 验收 | 验收怎么保证不被模型骗 | 验收是程序独立执行,不读模型输出;还发现过验收自身的 bug(写死 build 目录) |
| ReAct | 和思维链什么关系 | CoT 只推理不行动;ReAct 用真实观察纠正推理 |
| 先计划后执行 | 你的场景为什么不用 | 错误无法预见,计划频繁作废;可以顶层规划 + 子任务 ReAct |
| 反思 | 让模型自我检查靠谱吗 | 没有外部信号不靠谱;接编译结果、测试结果才有用 |
| 成本 | Agent 为什么贵、怎么省 | 每轮重发历史近似平方增长;缓存、压缩、简单步骤交给代码、小模型执行 |
| 人在回路 | 等人确认时进程怎么办 | 持久化状态,暂停不占进程,恢复后继续;07 篇 |
| 手写 vs LangGraph | 你为什么两版都写 | 手写看清原理;LangGraph 换来检查点和续跑;评测无差别 |
| 停止条件 | 开源编程 Agent 为什么不设步数上限 | pi 没有步数上限、卡死检测和验收:人在终端前,可以随时中止、插话;无人值守的批量任务才必须由程序兜底 |
| 工具调用 | 回复被 max_tokens 截断了,里面的工具调用怎么办 | 不执行,回报错让模型重发;截断的 JSON 可能被修补成合法但不完整的参数,pi 就是这么处理的 |
第七部分 闭卷自测
1. 程序固定先调检索、再让模型回答,这是 Agent 吗?
答案
不是,是工作流。路径由代码决定,模型没有决定”下一步做什么”。有工具调用不等于是 Agent。
2. 说出五种工作流模式,并各举一个例子。
答案
- 提示链:提取错误行 → 判断类型 → 生成建议
- 路由:按报错类型分给不同处理流程,或按难度选模型
- 并行:分段(同时检查依赖、编译选项、平台代码)或投票(同一问题问三次取多数)
- 编排者-执行者:模型看了仓库后动态决定要编哪些依赖,分给执行者
- 评估者-优化者:一个生成补丁,一个(最好是编译器)检查并反馈,循环到通过
3. 模型最后一轮没有调用工具,回复”编译成功”。程序应该怎么做?
答案
进入独立验收:程序自己检查构建结果和产物,通过才判成功。模型不再调工具只说明它停下了,不代表任务完成。
4. 实验 1 里,“正常修好”剧本第 2 步和第 4 步都是 run_build,参数完全一样,为什么没被判为重复?
答案
因为第 3 步 write_file 成功后,程序删除了所有 run_build 的重复计数。配置变了,再构建是正常验证。
5. 实验 1 的”一直换办法但没修对”为什么没被卡死检测抓到?靠什么停下的?
答案
每次写入的内容不同,调用参数都不一样,完全相同的重复检测判断不出;而且每次写文件又清零了构建计数。最后靠总步数上限停下(budget_exceeded)。所以卡死检测和总预算两道防线都要有。
6. 实验 1 在每步开头检查时间。如果某次 run_build 卡住一小时,会怎样?怎么改?
答案
循环卡在这次调用里,走不到下一次时间检查,整体超时失效。要把单次超时落实到具体调用上:命令用 subprocess.run(timeout=...),模型请求设 HTTP 超时,必要时结束整个进程组。
7. 每轮新增的内容量大致固定、每轮完整重发历史时,步数翻倍,累计输入 token 大约变成几倍?为什么?
答案
大约 4 倍。第 k 轮要发 k 份内容,N 轮累计约 N²/2 份,和步数的平方成正比。缓存、压缩会改变实际费用。
8. 生成器函数和普通函数有什么区别?Agent 为什么适合用生成器?
答案
普通函数 return 一次返回就结束;生成器每遇到 yield 交出一个值并暂停,调用方处理完再继续。Agent 运行时间长,调用方需要每做一步就拿到事件(打印、推送到网页),生成器天然适合”做一步交一个事件”。
9. ReAct 和思维链的区别是什么?
答案
思维链只让模型写出推理步骤,全部基于模型已有知识,推理用的事实错了就会一路错。ReAct 在推理中穿插真实行动和观察,用外部信息纠正推理、更新计划。
10. 什么任务适合先计划后执行?交叉编译为什么不太适合?
答案
步骤基本可预见、依赖关系清楚的任务适合,好处是少调大模型、计划可以先给人确认。交叉编译遇到什么错误无法预见,初始计划基本每次都会在第一步失败后作废,频繁重新规划等于变成更慢的 ReAct。可以用组合:顶层粗略规划,子任务内部用 ReAct。
11. 让模型”检查一下自己的答案”再修改,为什么不一定有效?怎样的反思更可靠?
答案
没有外部信号时,模型可能把对的改错,或者什么问题都发现不了。反思要接在真实反馈上,比如编译报错、测试结果、格式校验,Reflexion 就是基于任务反馈信号做文字反思。
12. 我项目里步数上限从 25 改到 40 后,libpng 和 re2 都通过了。能不能说”步数不够是困难项目失败的原因”?
答案
只能说是支持这个假设的证据,不能下定论。每边只跑了一次,排除不了随机性;而且当时 read_file 不能翻页,模型绕路消耗了一部分步数,工具缺陷和步数预算两个因素混在一起。
13. 开源编程 Agent pi 的主循环没有步数上限、没有卡死检测、模型停下后也不验收。为什么它能这样做?我的项目能不能照搬?
答案
pi 是交互式工具,人坐在终端前:模型绕圈子时人会按 Esc 中止,方向不对时人可以中途插话,做没做完人自己判断。这些兜底由人完成。我的项目是无人值守的批量评测,没人盯着,步数上限、卡死检测和独立验收都必须由程序来做,不能照搬。
延伸阅读
- Anthropic:Building effective agents(2024-12)— 工作流和 Agent 的定义、五种工作流模式、什么时候用 Agent,本篇 4.1、4.2 的主要来源
- ReAct 论文(Yao 等,ICLR 2023)— 推理和行动交替
- Chain-of-Thought 论文(Wei 等,2022)— 思维链
- LangChain:Plan-and-Execute Agents(2024-02)— 先计划后执行、ReWOO、LLMCompiler 的对比
- Plan-and-Solve 论文(Wang 等,2023)
- Reflexion 论文(Shinn 等,2023)— 基于反馈的文字反思
- Lilian Weng:LLM Powered Autonomous Agents(2023-06)— 规划、记忆、工具使用三部分的综述,早期论文脉络
- 12-Factor Agents — 第 7 条”通过工具调用联系人类”、第 8 条”自己掌控控制流”
- pi:agent-loop.ts(commit
7b4cfd6)— 一个真实编程 Agent 的主循环:两层循环、中途插话、截断时拒绝执行工具调用 - Anthropic:Harness design for long-running application development(2026-03)— 单独的评估 Agent;换了模型后哪些外层设计可以删
下一篇:06 上下文工程与记忆——循环跑久了,上下文会越来越长,怎么办。