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,几十行就够;把它做成可靠系统,难点主要在模型之外:权限、错误恢复、状态一致性、资源预算和结果验收。