别急着上框架:先用 Python 手搓一个可恢复的 AI Agent Loop

别急着上框架:先用 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 函数代替模型,等状态机和验收逻辑稳定后,再接入大模型。这样调试时能区分"循环设计错误"和"模型规划错误",不会一上来就面对一团不可复现的行为。

七点五、别忘了幂等、超时和可观测性

一个循环能跑通,只说明演示路径成立;要让它在真实环境里反复运行,还要处理三个经常被忽略的问题。

第一个是幂等性。任务可能在工具已经完成之后才发生进程中断,如果恢复逻辑不检查上一次的结果,就会再次写文件、再次发送请求,甚至重复创建外部记录。每个有副作用的工具都应该有一个稳定的任务标识,执行前先查询同一个标识是否已经完成。不能保证幂等的工具,至少要放在人工确认之后。

第二个是超时。模型调用、网络请求、浏览器操作和本地测试的耗时分布不同,不能只在最外层设置一个很大的总超时。每个工具都要有自己的截止时间,超时后记录阶段、参数摘要和已经产生的副作用。这样下一次恢复时,才能判断是重新执行、查询结果,还是直接进入人工处理。

第三个是可观测性。日志不应该只记录"成功"或"失败",还要记录任务编号、阶段编号、工具名、开始时间、结束时间、重试次数和验收结果。参数里可能包含密钥或个人数据,记录时要做脱敏;但如果为了安全把所有参数都删掉,调试又会失去必要上下文。比较稳妥的做法是记录参数结构和摘要,不记录秘密值。

这三个问题都不需要框架才能解决。先把它们写进前面的 StateStoreToolResult,等任务规模真的增长后,再迁移到数据库或队列系统,迁移成本会小很多。测试时可以分别覆盖成功、超时、权限拒绝、重复恢复和验收失败五条路径;只有成功路径通过,不能说明循环可靠、可回归、稳定。

八、什么时候才值得上框架

当你需要并行任务、复杂工具注册、长时间运行、人工审批、分布式状态、可观测性和多 Agent 协作时,框架会带来明显收益。但框架解决的是编排和基础设施问题,不会自动替你定义权限、验收和回滚。

如果当前任务只有一个输入、一个工具和一个结果,先写一个几十行的循环更划算。先证明状态、失败和验收逻辑,再决定哪些部分值得抽象。否则很容易出现"框架已经搭好,任务仍然无法可靠完成"的尴尬。

我自己的判断标准很简单:如果删掉模型,只保留工具契约、状态事件和验收器,流程仍然能被人理解和测试,说明基础打稳了;如果所有行为都藏在一个巨大 Prompt 里,换一次模型就全线失效,说明还没有真正工程化。

最后给一份落地检查表

开始写 Agent 前,先确认这几件事:任务是否能拆成明确步骤;每个工具是否有窄输入和风险级别;状态是否能在进程退出后恢复;失败是否区分可重试和不可重试;每一步是否有可执行的验收条件;高风险动作是否会停下来;外部操作后是否能从目标系统回读;超过最大步骤后是否会明确报告,而不是继续循环。

这套方法不依赖某个特定模型,也不要求一开始就部署复杂平台。它的价值在于把 Agent 从"会聊天的黑盒"变成"有状态、有权限、有验收、有边界的程序"。等这些基础问题解决之后,再接入更大的模型或更重的框架,收益才不会被隐藏的流程错误吃掉。

你现在最想用 Agent 自动化哪一类任务?如果已经遇到过循环失控、重复执行或结果无法验收的问题,也欢迎在评论区分享具体场景。觉得这份最小实现有用,可以收藏下来,准备接入框架前先逐项检查一遍。

相关推荐
程序员差不多先生2 小时前
政企智能化落地核心挑战与轻量化工程解决方案——基于Agent与存量系统融合场景
人工智能·deepseek-v4-pro·kimi-k3·glm-5.3·qwen3.7-max
zhangzeyuaaa2 小时前
AI Agent 执行引擎选型指南:PowerShell / Bash 和 Python,边界与最佳实践
人工智能·python·bash
2501_930472442 小时前
从物理机到 CVM 全流程落地:部署脚本的 8 个云化改造点(附代码对比)
开发语言·人工智能·架构·腾讯云·perl
棣廷2 小时前
初识OpenCV——人脸检测与识别实战
人工智能·opencv·计算机视觉
byte轻骑兵2 小时前
【BlueZ 】util 模块:通用工具函数,源码中高频复用的基础组件
linux·人工智能·bluez·电脑蓝牙·嵌入式蓝牙
Cicada1282 小时前
分享我一直在用的一套 AI 记忆系统方案
大数据·数据库·人工智能
桃西西呀2 小时前
买房怕买贵?我把真实成交价丢进决策树,看它到底按什么给房子定价
人工智能·机器学习·llm
jimmyleeee2 小时前
大模型安全之十:LLM无界消耗(Unbounded Consumption)
人工智能·安全
m0_462605222 小时前
大模型实战营week6
人工智能