别急着上框架:先用 Python 手搓一个可恢复的 AI Agent Loop
很多人第一次接触 AI Agent,打开教程看到的就是一串框架名:规划、工具调用、记忆、反思、工作流编排。模块越堆越多,代码越像一座小型平台,但真正要解决的问题可能只是"读取一份输入,调用一个工具,检查结果,再决定下一步"。
如果连最小循环都没有想明白,直接上框架只会把问题藏到更多抽象层里。本文不讨论某个框架的优劣,而是用 Python 写一个足够小、但具备工程边界的 Agent Loop:它能接收任务、选择工具、保存状态、处理失败,并在高风险动作前停下来等待验收。
一、Agent Loop到底比普通函数多了什么
普通函数通常是固定的:输入参数,执行步骤,返回结果。Agent Loop 的区别不在于"用了大模型"这件事,而在于下一步不是完全写死的。它会根据当前状态和工具结果,决定继续、重试、等待人工确认,或者结束。

可以把它抽象成下面这个循环:
text
读取任务
↓
整理当前状态
↓
选择一个允许使用的工具
↓
执行工具并记录结果
↓
判断结果是否满足验收条件
├─ 满足:结束
├─ 可恢复失败:重试或调整输入
├─ 高风险动作:等待人工确认
└─ 不可恢复:停止并报告
这里最重要的是"判断结果"。如果代码只是调用模型,再把模型返回的文字打印出来,那仍然是聊天接口,不是可恢复的任务循环。Agent 必须知道什么叫成功,也必须知道什么时候不能继续。
二、先定义工具契约,不要让模型直接碰任意函数
工具是 Agent 与外部世界的接口。最小实现里,工具至少应该有名称、输入参数、风险级别和执行函数。风险级别不是装饰字段,它决定这个工具能不能被自动调用。
python
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class ToolResult:
ok: bool
output: Any = None
error: str = ""
retryable: bool = False
@dataclass
class Tool:
name: str
description: str
risk: str # read / write / external
run: Callable[..., ToolResult]
例如,读取本地文本可以标记为 read,生成临时文件可以标记为 write,发送邮件或修改线上数据就应该标记为 external。在没有人工确认时,循环只能自动执行前两类工具。

python
def read_text(path: str) -> ToolResult:
try:
with open(path, "r", encoding="utf-8") as f:
return ToolResult(ok=True, output=f.read())
except FileNotFoundError:
return ToolResult(ok=False, error=f"文件不存在: {path}", retryable=False)
except OSError as exc:
return ToolResult(ok=False, error=str(exc), retryable=True)
TOOLS = {
"read_text": Tool(
name="read_text",
description="读取指定文本文件",
risk="read",
run=read_text,
),
}
不要把 eval、任意 shell 命令或一个没有参数约束的万能函数直接注册成工具。那样看起来灵活,实际上等于把执行权限交给了模型生成的字符串。工具契约越窄,错误越容易定位,审计也越容易进行。
三、状态是可恢复性的核心
没有状态记录,Agent 一旦超时或进程退出,只能从头再来。状态不需要一开始就放进复杂数据库,先用 JSON 文件把每一步保存下来就够了。关键是记录任务输入、当前阶段、工具名称、参数摘要、结果和尝试次数。
python
import json
from pathlib import Path
from datetime import datetime, timezone
class StateStore:
def __init__(self, path: str):
self.path = Path(path)
self.data = {
"status": "created",
"step": 0,
"events": [],
}
if self.path.exists():
self.data = json.loads(self.path.read_text(encoding="utf-8"))
def append(self, event: dict) -> None:
event["time"] = datetime.now(timezone.utc).isoformat()
self.data["events"].append(event)
self.path.write_text(
json.dumps(self.data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def set_status(self, status: str) -> None:
self.data["status"] = status
self.path.write_text(
json.dumps(self.data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
生产环境里可以把事件写入数据库或追加日志,但数据结构最好保持类似。尤其要避免只保存"模型说了什么",还要保存"实际调用了什么工具"和"工具返回了什么"。这两类信息不一致时,后者才是判断真实行为的依据。
恢复逻辑也很简单:启动时读取最后一个事件。如果上一步已经成功,就跳过它;如果上一步状态是可重试失败,就从该步骤继续;如果上一步是等待确认,就不要偷偷自动往下跑。恢复不是重放全部动作,而是从最后一个可信检查点继续。
四、让模型负责选择,不让模型负责越权
为了演示,假设模型已经返回了一个结构化动作:
python
@dataclass
class Action:
tool: str
args: dict
reason: str
真正接入模型时,应该使用结构化输出或严格解析,而不是从自然语言里猜工具名。拿到动作后,循环自己完成三次检查:工具是否存在,参数是否符合契约,风险是否被当前策略允许。
python
def execute_action(action: Action, tools: dict[str, Tool], state: StateStore):
tool = tools.get(action.tool)
if tool is None:
return ToolResult(ok=False, error=f"未知工具: {action.tool}")
if tool.risk == "external":
state.append({
"type": "approval_required",
"tool": tool.name,
"reason": action.reason,
})
return ToolResult(ok=False, error="需要人工确认后才能执行")
state.append({
"type": "tool_started",
"tool": tool.name,
"args": action.args,
})
result = tool.run(**action.args)
state.append({
"type": "tool_finished",
"tool": tool.name,
"ok": result.ok,
"error": result.error,
})
return result
注意这里没有把"模型认为应该执行"当成"系统允许执行"。模型可以提出动作,策略层决定动作是否被允许。这一层解耦之后,换模型不会直接改变权限边界。
五、重试不是无脑重复
最容易写错的重试是:捕获异常,睡几秒,再把同一个函数调用三遍。对网络暂时失败,这种方式可能有效;对参数错误、权限不足和文件不存在,重复调用只会浪费时间,甚至产生更多副作用。
工具结果应该明确标记 retryable。循环还要限制最大尝试次数,并把每次失败写进状态。
python
def run_with_retry(action, tools, state, max_attempts=3):
for attempt in range(1, max_attempts + 1):
state.append({
"type": "attempt",
"number": attempt,
"tool": action.tool,
})
result = execute_action(action, tools, state)
if result.ok:
return result
if not result.retryable:
break
state.set_status("failed")
return result
如果重试需要改变输入,就不要把它藏在函数内部。让下一轮规划明确看到错误,例如"文件不存在"和"接口超时"是两个完全不同的分支。前者应该修正路径或停止,后者才可能重新请求。

六、验收条件必须和任务一起定义
"生成报告"不是验收条件,"生成包含标题、来源、结论和失败说明的 Markdown 文件,并且文件可以重新打开"才是。没有验收条件,Agent 永远可以用一段总结宣称自己完成了。
可以给每类任务定义一个纯函数验收器:
python
def verify_report(path: str) -> ToolResult:
p = Path(path)
if not p.is_file():
return ToolResult(ok=False, error="报告文件不存在")
text = p.read_text(encoding="utf-8")
required = ["标题", "来源", "结论"]
missing = [item for item in required if item not in text]
if missing:
return ToolResult(
ok=False,
error=f"缺少字段: {', '.join(missing)}",
retryable=True,
)
return ToolResult(ok=True, output={"chars": len(text)})
验收器最好独立于生成器。生成器说自己写完了,不等于验收器认为结果合格;这正是把"生产"和"判断"解耦。代码、报告、视频和外部发布都应该有自己的验收层。
涉及外部系统时,还要增加回读。接口返回请求成功,只代表请求被接受;最终记录是否存在、内容是否完整、状态是否正确,需要再次从目标系统读取。否则你验证的只是自己的调用,不是任务结果。
七、一个完整的最小循环
把前面的模块串起来,主循环可以保持很短:
python
def agent_loop(task, planner, tools, state, max_steps=8):
state.append({"type": "task_created", "task": task})
for step in range(max_steps):
state.data["step"] = step
state.set_status("planning")
action = planner(task, state.data)
if action is None:
state.set_status("completed")
return {"ok": True, "reason": "planner_finished"}
result = run_with_retry(action, tools, state)
if result.ok:
continue
if "人工确认" in result.error:
state.set_status("waiting_approval")
return {"ok": False, "status": "waiting_approval"}
state.set_status("failed")
return {"ok": False, "error": result.error}
state.set_status("failed")
return {"ok": False, "error": "超过最大步骤数"}
这里的 planner 可以先用普通 Python 函数代替模型,等状态机和验收逻辑稳定后,再接入大模型。这样调试时能区分"循环设计错误"和"模型规划错误",不会一上来就面对一团不可复现的行为。
七点五、别忘了幂等、超时和可观测性
一个循环能跑通,只说明演示路径成立;要让它在真实环境里反复运行,还要处理三个经常被忽略的问题。
第一个是幂等性。任务可能在工具已经完成之后才发生进程中断,如果恢复逻辑不检查上一次的结果,就会再次写文件、再次发送请求,甚至重复创建外部记录。每个有副作用的工具都应该有一个稳定的任务标识,执行前先查询同一个标识是否已经完成。不能保证幂等的工具,至少要放在人工确认之后。
第二个是超时。模型调用、网络请求、浏览器操作和本地测试的耗时分布不同,不能只在最外层设置一个很大的总超时。每个工具都要有自己的截止时间,超时后记录阶段、参数摘要和已经产生的副作用。这样下一次恢复时,才能判断是重新执行、查询结果,还是直接进入人工处理。
第三个是可观测性。日志不应该只记录"成功"或"失败",还要记录任务编号、阶段编号、工具名、开始时间、结束时间、重试次数和验收结果。参数里可能包含密钥或个人数据,记录时要做脱敏;但如果为了安全把所有参数都删掉,调试又会失去必要上下文。比较稳妥的做法是记录参数结构和摘要,不记录秘密值。
这三个问题都不需要框架才能解决。先把它们写进前面的 StateStore 和 ToolResult,等任务规模真的增长后,再迁移到数据库或队列系统,迁移成本会小很多。测试时可以分别覆盖成功、超时、权限拒绝、重复恢复和验收失败五条路径;只有成功路径通过,不能说明循环可靠、可回归、稳定。
八、什么时候才值得上框架
当你需要并行任务、复杂工具注册、长时间运行、人工审批、分布式状态、可观测性和多 Agent 协作时,框架会带来明显收益。但框架解决的是编排和基础设施问题,不会自动替你定义权限、验收和回滚。
如果当前任务只有一个输入、一个工具和一个结果,先写一个几十行的循环更划算。先证明状态、失败和验收逻辑,再决定哪些部分值得抽象。否则很容易出现"框架已经搭好,任务仍然无法可靠完成"的尴尬。
我自己的判断标准很简单:如果删掉模型,只保留工具契约、状态事件和验收器,流程仍然能被人理解和测试,说明基础打稳了;如果所有行为都藏在一个巨大 Prompt 里,换一次模型就全线失效,说明还没有真正工程化。
最后给一份落地检查表
开始写 Agent 前,先确认这几件事:任务是否能拆成明确步骤;每个工具是否有窄输入和风险级别;状态是否能在进程退出后恢复;失败是否区分可重试和不可重试;每一步是否有可执行的验收条件;高风险动作是否会停下来;外部操作后是否能从目标系统回读;超过最大步骤后是否会明确报告,而不是继续循环。
这套方法不依赖某个特定模型,也不要求一开始就部署复杂平台。它的价值在于把 Agent 从"会聊天的黑盒"变成"有状态、有权限、有验收、有边界的程序"。等这些基础问题解决之后,再接入更大的模型或更重的框架,收益才不会被隐藏的流程错误吃掉。
你现在最想用 Agent 自动化哪一类任务?如果已经遇到过循环失控、重复执行或结果无法验收的问题,也欢迎在评论区分享具体场景。觉得这份最小实现有用,可以收藏下来,准备接入框架前先逐项检查一遍。