Agent 看起来能自主规划、调用工具、完成任务,但在程序内部,它究竟如何运转?

本文选取 OpenAI Agents Python SDK 的 Runner 作为研究对象,依据官方文档和调研时读取的 main 分支源码,拆解它的核心循环。本文属于文档与源码调研,不包含实际模型调用测试;引用的 main 分支及在线文档会持续更新,内部函数和文件布局可能随版本变化。

先给出结论:

Agent Loop 本质上是一个由模型参与决策、由程序控制执行的状态机。模型提出下一步行动,运行时负责校验、执行、记录结果,并决定继续、结束或暂停。

一、为什么需要一个 Loop?

普通模型调用通常是:

用户输入 → 模型 → 输出

但复杂任务需要接触外部环境。例如:

查一下订单的物流信息,如果超时了,帮我申请退款。

模型第一次响应时,并不知道订单状态。完整过程可能是:

用户提出任务
    ↓
模型请求查询订单
    ↓
程序执行查询,得到订单状态
    ↓
模型根据查询结果,请求申请退款
    ↓
程序要求用户批准
    ↓
批准后执行退款
    ↓
模型根据执行结果,给出最终回复

工具的真实结果,会改变模型下一步的决策。 这种“行动—反馈—再决策”的闭环,就是 Agent Loop 的核心。

它不要求模型显式输出思考过程,也不要求使用多个 Agent。一个模型加几个工具,就可以构成这样的循环。

二、源码如何组织?

在这个 SDK 中,两个核心对象的职责不同:

  • Agent 是配置:描述指令、模型、工具、交接目标和输出类型。
  • Runner 是运行时:管理循环、状态、工具执行和结束条件。

本次源码中的关键位置如下:

文件 主要职责
src/agents/run.py Runner 入口,以及 AgentRunner 外层循环
src/agents/run_internal/run_loop.py 单轮编排、模型调用和流式运行
src/agents/run_internal/turn_resolution.py 处理模型响应、组织工具执行、判断下一步
src/agents/run_internal/run_steps.py 定义单轮结果和状态转换类型

核心调用关系可以概括为:

Runner.run()
    └── AgentRunner.run()
          └── _run_impl()
                └── 反复执行单轮
                      ├── 准备模型输入
                      ├── 调用模型
                      ├── 分类响应、处理工具和交接
                      └── 返回 SingleStepResult

最值得注意的是,SingleStepResult.next_step 不是随意的字符串,而是明确的类型:

NextStepRunAgain
NextStepFinalOutput
NextStepHandoff
NextStepInterruption

它们分别代表:继续、完成、交接、暂停。

把“单轮处理”与“外层状态转换”分开,是这个实现的关键结构。外层循环不必理解每一种工具的具体执行细节,只需要理解下一步状态。

源码入口:run.py、run_steps.py。

三、一轮循环具体做什么?

1. 组装模型输入

通常需要:

当前 Agent 的指令
+ 对话历史
+ 本次运行已经生成的消息
+ 工具调用及其结果
+ 可用工具的描述和参数结构
+ 期望的输出结构

模型看到的是工具的名称、用途和参数约束,而不是直接获得执行 Python 函数的能力。

例如,一个工具的描述大致是:

{
  "name": "get_order",
  "description": "查询订单状态",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {"type": "string"}
    },
    "required": ["order_id"]
  }
}

2. 模型返回结构化响应

响应不一定只是文本,还可能包含工具调用:

{
  "type": "function_call",
  "call_id": "call_123",
  "name": "get_order",
  "arguments": "{\"order_id\":\"A1001\"}"
}

这里有一个重要边界:

模型输出工具调用请求,不等于工具已经执行。

3. 运行时执行工具

对于应用侧执行的函数工具,运行时需要找到对应实现,并处理参数、审批以及配置的工具检查逻辑,然后真正调用函数。

工具结果会关联原来的调用:

{
  "type": "function_call_output",
  "call_id": "call_123",
  "output": "{\"status\":\"delayed\",\"refund_allowed\":true}"
}

call_id 用于把结果与请求对应起来。同一轮有多个工具调用时,这尤其重要。

工具也不一定都由本地运行时执行。源码区分了需要应用侧处理的工具,以及已经在服务端执行的 hosted tools,不能把所有工具响应都再次执行一遍。

4. 更新历史,决定下一步

概念上:

下一轮历史
= 原有历史
+ 模型输出的工具调用
+ 工具实际返回的结果

模型下一轮才有依据说:“订单确实超时,可以申请退款。”

这不是模型凭空拥有了新知识,而是程序把环境反馈变成了下一次模型输入。

源码参考:run_loop.py、turn_resolution.py。

四、循环什么时候结束?

完整逻辑更像一个状态机,而不是没有边界的 while True:

准备上下文,调用模型
    ↓
分类响应、处理工具与审批
    ↓
判定下一步
    ├── 继续:回填结果,进入下一轮
    ├── 最终输出:输出检查后返回
    ├── 交接:切换当前 Agent,进入下一轮
    └── 暂停:返回待审批状态,决策后恢复

正常结束

常见情况是:

  • 没有待处理的工具调用或交接;
  • 获得了可作为最终答案的输出;
  • 如果配置了结构化输出类型,还需要通过对应校验。

此外,SDK 允许配置 tool_use_behavior:

配置 行为
run_llm_again 工具结果回传模型,继续循环
stop_on_first_tool 直接采用工具结果作为最终输出
stop_at_tool_names 指定工具产生结果后结束
自定义函数 由业务代码决定工具结果是否足以结束

因此,工具执行后不一定还要再调用一次模型。

达到轮数上限

max_turns 限制的是模型调用轮次,不是单个工具的执行次数。

默认行为是在超过上限时抛出 MaxTurnsExceeded,而不是把当前结果自动当成成功。SDK 也提供相应错误处理配置。

暂停,不是完成

需要审批时,会产生 NextStepInterruption。这表示运行暂时交还控制权,并不是任务已经完成。

错误退出

模型请求失败、输出格式不合法或 guardrail 触发,都可能导致异常。部分错误可以配置处理策略,但不是所有错误都会自动交给模型重试。

这里最容易被忽略的一点是:

循环正常结束,不等于任务真的完成。

“模型不再调用工具”和“退款已经到账”是两种不同的判定。后者需要业务系统的事实或验收逻辑。

官方说明:Running agents。

五、用伪代码还原核心设计

下面是便于理解的概念性伪代码,不是 SDK 源码的逐行复刻,也不是可以直接运行的完整实现:

async def run_agent(agent, input, max_turns):
    state = initialize_or_restore(agent, input)

    while True:
        if state.has_pending_interruption:
            # 先恢复尚未完成的工具处理,而不是重新请求模型
            step = await resume_interrupted_step(state)
        else:
            if state.turns >= max_turns:
                raise MaxTurnsExceeded()

            request = build_model_input(state)
            response = await call_model(request)
            state.turns += 1

            # 分类响应、处理工具、审批、交接、输出类型校验等
            step = await resolve_response(state, response)

        merge_step_items(state, step)

        if step.kind == "interruption":
            return paused_result(state, step.approvals)

        if step.kind == "handoff":
            state.agent = step.new_agent
            continue

        if step.kind == "run_again":
            continue

        if step.kind == "final":
            await check_output_guardrails(step.output)
            await persist_accepted_result(state)
            return completed_result(state, step.output)

这一设计的价值是:模型如何生成内容,与运行时如何安全推进流程,彼此解耦。

实际源码还要处理流式事件、历史过滤、持久化失败、资源清理和恢复一致性,因此远比这段伪代码复杂。

六、“记忆”和“暂停恢复”靠什么实现?

这里要区分三类状态:

状态 内容 作用
模型上下文 指令、历史、工具结果 让模型知道之前发生了什么
本地应用上下文 用户身份、数据库客户端、业务依赖等 供工具和回调使用,不自动发送给模型
运行状态 当前 Agent、轮次、已生成条目、待审批调用等 支持暂停和恢复

对话记忆不是模型自动记住

应用可以手动维护输入历史,也可以使用 SDK 的 session,或使用服务端会话机制。

模型能够延续任务,是因为之前的信息被纳入下一次输入或关联到服务端会话,而不是 Python 进程中的变量天然对它可见。

本地上下文不自动发送给模型,也不代表它适合保存所有秘密:如果后续要序列化运行状态,还必须检查序列化内容及其存储权限。

暂停恢复需要保存“执行到哪里”

SDK 的审批流程大致是:

工具需要审批
    ↓
返回 result.interruptions
    ↓
result.to_state()
    ↓
记录 approve / reject 决策
    ↓
Runner.run(original_agent, state)
    ↓
继续尚未完成的执行

恢复不只是把聊天记录重放一次。运行时还需要区分:哪些调用已经完成,哪些仍待审批,哪些结果已经写入历史。

但保存运行状态不自动保证外部副作用 exactly-once。

例如退款成功后,程序在记录结果前崩溃,仅靠聊天历史无法消除重复退款风险。生产系统仍需要业务幂等键、操作记录或执行结果查询。

官方说明:Context management、Human-in-the-loop。

七、真正的工程难点在哪里?

下面是基于上述机制的工程建议,不代表 SDK 默认替应用完成了全部工作。

难点 为什么难 建议
循环不收敛 模型可能不断重复相似调用 设置轮数、费用、时间预算,加无进展检测
工具权限 参数来自模型,不应天然可信 在执行端做鉴权、参数约束和必要审批
重试副作用 响应丢失不代表操作没执行 对写操作设计幂等性,必要时先查执行状态
工具并发 同轮请求不代表业务上相互独立 独立查询可并发,共享资源写入谨慎串行
上下文增长 工具输出不断堆积 裁剪、摘要、检索,并保留调用与结果配对
成功判定 模型可能过早宣称完成 以测试、数据库状态或业务规则独立验收
可观测性 只看最终答案无法定位失败环节 记录轮次、调用 ID、耗时、错误和状态转换

工具返回内容也应当被视为外部数据,而不是新的高优先级指令。网页、文档或日志中出现“忽略之前规则”之类文本,不应该改变执行端的授权边界。

还有两个常见误区:

  • 流式输出不等于另一套智能机制。 它主要改变事件交付方式;工具参数必须在完整且验证后才能用于执行。
  • Handoff 不等于多 Agent 并行。 在这个实现中,handoff 主要是切换当前 Agent,再继续循环。

总结

可以把一个 Agent 拆成四部分:

Agent = 模型决策 + 工具执行 + 状态管理 + 控制策略

其中:

  • 模型提出下一步做什么;
  • 工具负责读取或改变外部环境;
  • 状态保存已经发生的事情;
  • Loop 依据规则继续、切换、暂停或结束。

写一个演示版 Agent Loop,几十行就够;把它做成可靠系统,难点主要在模型之外:权限、错误恢复、状态一致性、资源预算和结果验收。

参考资料

  1. OpenAI Agents Python SDK
  2. 官方文档:Running agents
  3. 运行入口源码:run.py
  4. 单轮编排源码:run_loop.py
  5. 响应处理源码:turn_resolution.py
  6. 状态类型源码:run_steps.py
  7. 官方文档:Context management
  8. 官方文档:Human-in-the-loop