上一篇文章走完了一次工具调用往返,但一次往返只能干一步的活。本文是「从零手写 Agent」系列第 5 篇:把往返套进循环,让模型基于中间结果连续决策直到任务完成。这三行循环是 LangGraph、Claude Code 以及一切 Agent 框架的心脏,手写一遍之后,框架在你眼里将不再神秘。
前四篇的零件在这一篇合体:第 1 篇的 HTTP 认知、第 2 篇的 history 列表、第 4 篇的工具往返,加上本篇的循环与安全阀——一个完整可用的 Agent 在九十行内成型。本文按"从一轮到循环 → 心脏三行 → 依赖链实测 → 安全阀 → 总结"组织。
1、概述:Agent 的定义时刻
对话机器人是"一问一答",工具调用是"一问一做"。Agent 是目标导向的多步执行:你给它一个目标,它自主决定第一步做什么、看到结果后第二步做什么,直到认为任务完成。从"工具调用"到"Agent",差的只是一个循环。
解读:图里那个判断节点就是 Agent 与普通程序的分界线。模型在循环里获得的关键能力是基于中间结果做下一步决策——这是它区别于"流程编排"的本质:步骤不是你写死的,是它自己看着办。
把循环里的角色分工说破,其实是一台三人戏:模型是决策者,只回答"下一步干什么";循环是调度器,只负责把决策送去执行、把结果送回决策;工具是执行者,对全局目标一无所知。你在这台戏里的角色是编剧——写好说明书与安全阀,然后退场。"自主性"也容易被神化,准确的表述只有一句:步骤序列在运行前不可完全确定。你定死的只有循环边界与工具清单,每一步选什么、何时收工,都是模型运行中看着中间结果定的。
2、心脏三行
循环本体浓缩成一个 Agent 类的核心方法,关键部分如下:
def run(self, user_input: str) -> str:
self.history.append({"role": "user", "content": user_input})
for turn in range(1, self.max_turns + 1):
response = self.client.messages.create(
model=self.model, max_tokens=2048,
tools=self.tools_spec, messages=self.history,
)
self.history.append({"role": "assistant",
"content": [b.model_dump() for b in response.content]})
tool_calls = [b for b in response.content
if b.type == "tool_use"] # 捞出调用块
if response.stop_reason != "tool_use": # 心脏判断
return "".join(b.text for b in response.content
if b.type == "text")
results = [self._execute(call) for call in tool_calls]
self.history.append({"role": "user", "content": results})
return "已达最大轮数,强制收工"
解读:run 方法的骨架就是上一篇文章的往返加了循环包装。两个容易踩的点:助手消息要先于判断入历史,保证配对完整;循环体最后一行把结果追加进历史后回到循环顶部重新请求模型,让模型看到结果决定下一步。tool_calls 一行从回复里捞出 tool_use 块交给执行器,是骨架图里省不掉的胶水。配上一个 history 列表属性,这个类同时实现了系列第 2 篇的对话记忆——Agent 与聊天机器人共享同一个数据结构。
2.1、构造与工具注册
run 之外的零件同样朴素。构造与注册的实现如下:
from anthropic import Anthropic
class Agent:
def __init__(self, model: str, max_turns: int = 10):
self.client = Anthropic()
self.model = model
self.max_turns = max_turns # 安全阀一:轮数上限
self.history = [] # 第 2 篇的对话记忆原样搬来
self.tools_spec = [] # 说明书列表(发给模型)
self.tool_impl = {} # 名字到真函数的路由表(留在本地)
def register(self, spec: dict, func) -> None:
"""说明书与真函数成对注册"""
self.tools_spec.append(spec)
self.tool_impl[spec["name"]] = func
解读:两份名单一明一暗——tools_spec 是给模型看的菜单,每次请求随 messages 一起发出去;tool_impl 是厨房的私账,永远不出你的进程。register 强制成对注册,杜绝"菜单上有、厨房没有"的悬空调用;第 4 篇里的 TOOL_IMPL 字典,在这里升级成了方法。
2.2、容错执行器
_execute 是第 4 篇执行段的方法化,外加未注册兜底。实现如下:
def _execute(self, call) -> dict:
"""执行单个调用块,任何异常都转成文字结果"""
func = self.tool_impl.get(call.name)
try:
if func is None:
raise KeyError(f"未注册的工具: {call.name}")
result = func(**call.input) # 模型填的参数直接解包
except Exception as e:
result = f"工具执行出错: {e}" # 第 4 篇:错误也是数据
return {"type": "tool_result",
"tool_use_id": call.id, # 按 id 对号入座
"content": str(result)}
解读:异常在这里被拦下并转成文字,循环永不因单个工具崩溃;模型看到"工具执行出错"会自行调整策略,看到"未注册的工具"则不会再点同一道菜。第 6 篇会把这条容错路径升级成完整的设计原则——"失败要大声"。
2.3、五件套总装图
至此 Agent 的全部零件到齐,各自的落点如下:
| 零件 | 落在类的哪里 | 来自哪一篇 |
|---|---|---|
| 模型 | self.client 与 self.model |
第 1 篇 |
| 记忆 | self.history |
第 2 篇 |
| 工具 | tools_spec 与 tool_impl |
第 4 篇 |
| 循环 | run 的 for 与心脏判断 |
本篇 |
| 安全阀 | max_turns 与 max_tokens |
本篇第 4 节 |
九十行代码,五个零件,没有一行需要框架。这张对照表也是后续篇目的地图:第 6 篇换零件(真实工具),第 7 篇治零件(记忆压缩),第 8 篇复制零件(子 Agent 派工)。
3、依赖链实测
循环的价值在"依赖链"任务上最直观:第二步的参数依赖第一步的结果。给模型的任务是"先精确计算 1234 乘 567,再把结果除以 13,最后告诉我今天星期几"。跑起来只需要三行。示例如下:
agent = Agent(model=MODEL, max_turns=10)
agent.register(CALC_SPEC, calculate) # 说明书与真函数成对注册
agent.register(WEEKDAY_SPEC, get_weekday)
answer = agent.run("先精确计算 1234 乘 567,再把结果除以 13,"
"最后告诉我今天星期几")
真实运行轨迹如下:
第 1 轮:模型点了 2 道菜
calculate(1234 × 567) → 699678
get_weekday() → 星期四
第 2 轮:模型点了 1 道菜
calculate(699678 ÷ 13) → 53821.38
第 3 轮:模型收工,一句话总结
解读:第 1 轮里乘法与星期几无依赖,模型并行点了两道菜;但除法必须等乘法结果出来才能填参,所以它自动排到了第 2 轮——模型自己规划了并行与串行,你写的只是循环。这个任务单轮往返永远做不完,这正是 Agent 存在的理由。
单轮为什么永远做不完?因为除法的被除数在第一步执行前不存在于任何地方——它诞生于模型第 1 轮的回复,得跑完第 1 轮才拿得到。依赖链因此是 Agent 的试金石:凡是能拆成"先问 A、再用 A 的答案问 B"的任务,就是循环的地盘。
3.1、自发验证的涌现
更有意思的是第二次实测:追问"把刚才除法的结果四舍五入",模型没有重算乘除,而是直接从历史里取出 53821.38,并自发设计了一套验证——分别计算 53821 乘 13 与 53822 乘 13,比较哪个更接近原值。验证轮的轨迹如下(口径:同一次会话的连续追问,提示词无任何"验证"字样):
第 1 轮:模型点了 2 道菜
calculate(53821 × 13) → 699673
calculate(53822 × 13) → 699686
第 2 轮:模型比较 5 与 8 的距离,答 53821
没有写入任何提示词的验证行为自然涌现,这是循环加记忆组合出的高级行为。拆开看有两个来源:从历史取数而不重算,是第 2 篇记忆的复利;夹逼验证,是模型把"四舍五入"理解成可检验命题后的自发动作。你一行验证逻辑都没写,Agent 替你做了——这类涌现行为没法靠堆提示词强行复现,能做的是把循环与记忆这两块土壤备好。顺带一提,它也提醒了读轨迹日志的价值:涌现的东西,只在复盘完整历史时才看得见。
4、安全阀
把"继续干活"的决定权交给模型,就必须防它不收工。真实会发生:提示词含糊时模型反复调用同一工具打转,没有循环边界的话程序会无限烧钱。
第一道阀是 max_turns:轮数用完强制收工,哪怕任务没完成。第二道阀是 max_tokens:管住每轮的嘴长,防止思考加调用块超预算被截断。两道阀防的是两种死法,互相不可替代。此外生产系统还会加总花费上限、单工具调用次数上限,原理相同。
打转不是理论风险,是一次真实运行。含糊提示词下的轨迹如下(口径:提示词为"帮我看看 1234 和 567",max_turns=10,一次运行):
第 1 轮:calculate(1234 × 567) → 699678
第 2 轮:calculate(1234 × 567) → 699678 ← 同一道菜
第 3 轮:calculate(1234 × 567) → 699678 ← 还是它
第 4 轮:模型收工,报了个 699678 了事
解读:模型连点三次同一道菜,既没消化结果也没推进目标。修复不在代码而在提示词——把"帮我看看"改成"算出乘积并直接报答案"后一次通过。安全阀买保险,提示词定生死:阀决定最坏情况赔多少,清晰的完成态定义决定打转会不会发生。
4.1、两道阀的分工
两道阀拦的是不同的死法,分工如下:
| 阀 | 拦住什么 | 拦不住什么 |
|---|---|---|
max_turns |
无限轮数打转 | 单轮内的超长输出 |
max_tokens |
单轮输出超预算 | 轮数多而每轮短的消耗 |
两列"拦不住"正是对方的地盘,所以缺一不可。给个起手经验值:教学与轻任务 max_turns 取 10,生产任务取 20 到 50 并配总花费上限;max_tokens 每轮 2048 起步,涉及长文或代码生成时再放开。调参顺序也有讲究:先按任务难度定 max_turns,再按输出形态定 max_tokens,最后在真实跑量里补总花费上限——顺序反了容易把阀拧得过紧,任务没跑完就被掐断。
上线一个没有安全阀的 while 循环,等于把钱包递给一个不知疲倦的执行者。
5、总结
Agent 与工具调用的距离是一个循环:stop_reason 等于 tool_use 就继续执行并回传,否则收工;依赖链任务证明循环让模型能规划多步执行,安全阀保证它跑不飞。至此一个最小可用的 Agent 已经在手:模型、工具、循环、记忆、安全阀,五件套约九十行代码。对于动手派,建议在 run 的循环体里加两行打印——当前轮数与 stop_reason,然后故意用含糊提示词触发一次打转,亲眼看安全阀把循环拦下来;这份体感比读十篇框架文档都扎实。手写完这一遍,再看 LangGraph 文档里的图式编排与 Claude Code 文档里的 Agent 设计,你会发现它们扩张的是同一副骨架。下一篇给它装上文件与代码执行工具,并画出安全边界——那才是生产与玩具的分界线。循环与工具的完整文档见 Anthropic 工具使用指南。

