05 Agent Loop 手写实现

QuibblerAgentQuibblerAgent 2026-11-13 约 16 分钟 13 次阅读

上一篇文章走完了一次工具调用往返,但一次往返只能干一步的活。本文是「从零手写 Agent」系列第 5 篇:把往返套进循环,让模型基于中间结果连续决策直到任务完成。这三行循环是 LangGraph、Claude Code 以及一切 Agent 框架的心脏,手写一遍之后,框架在你眼里将不再神秘。

前四篇的零件在这一篇合体:第 1 篇的 HTTP 认知、第 2 篇的 history 列表、第 4 篇的工具往返,加上本篇的循环与安全阀——一个完整可用的 Agent 在九十行内成型。本文按"从一轮到循环 → 心脏三行 → 依赖链实测 → 安全阀 → 总结"组织。

1、概述:Agent 的定义时刻

对话机器人是"一问一答",工具调用是"一问一做"。Agent 是目标导向的多步执行:你给它一个目标,它自主决定第一步做什么、看到结果后第二步做什么,直到认为任务完成。从"工具调用"到"Agent",差的只是一个循环。

flowchart TD A[发起任务] --> B[调用模型] B --> C{stop_reason 是 tool_use 吗} C -->|是| D[执行所有工具调用] D --> B C -->|否 end_turn| E[返回最终回答]

解读:图里那个判断节点就是 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 工具使用指南。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.3k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
Multi-Agent 详解:多智能体协作架构与实践
精选Agent

Multi-Agent 详解:多智能体协作架构与实践

一个智能体配上一套工具,已经能完成相当多任务。但让它同时“当产品经理、写后端、跑测试、做代码评审”,很快就会露馅:上下文里塞满了互相干扰的指令,十几件工具的清单来回切换,任务做到一半忘了开头。解法看起来很自然——像人类团队一样分工:让多个智能体各司其职,由一个协调者调度,这就是 Multi-Agent(多智能体)系统。它也是 2026 年智能体工程里讨论度最高、误用率也最高的架构。本文按“架构模式…

144