假设用户对客服 Agent 说:
shell
请帮我取消订单 123。
模型很快返回一段结构化结果:
json
{
"name": "cancel_order",
"arguments": {
"orderId": 123
}
}
日志里已经出现 cancel_order,看起来 Agent 做出了正确决定。可此时查询订单系统,订单 123 仍然是"待发货"。
这不是模型调用失败。恰恰相反,模型已经完成了它在这一轮中的工作:根据用户请求和工具描述,生成一个结构化的 Tool Call。问题在于,提出动作和执行动作不是一回事。
接下来还有一串没有发生的事情:
- 谁确认订单 123 属于当前用户?
- 谁校验订单是否仍可取消?
- 谁判断这项操作是否需要人工审批?
- 谁真正调用订单服务?
- 如果订单已经发货或服务超时,谁处理失败?
- 工具返回结果后,谁决定回复用户还是继续调用其他工具?
Tool Calling 只建立了模型与应用之间的协议接口。要把这个调用请求变成已经发生的业务动作,应用必须接住模型输出,执行工具,再把真实结果送回模型。模型若继续产生新的 Tool Call,这个过程还要重复。
上一篇讨论了为什么模型能力不等于企业生产力。本篇沿着其中的"任务闭环层"继续向内拆:一次模型调用,究竟如何变成一段可以执行、暂停、恢复和结束的 Agent Run?
一、Tool Calling 只完成了"提出动作"
1. 模型返回的是结构化决策
向模型声明工具时,应用通常会提供工具名称、用途说明和参数结构。模型根据当前输入判断是否使用工具;如果需要,它返回工具名称与参数,而不是直接进入订单数据库执行 SQL。
以取消订单为例,模型看到的可以是这样一份工具定义:
typescript
const cancelOrder = {
name: "cancel_order",
description: "取消尚未发货且属于当前用户的订单",
parameters: {
type: "object",
properties: {
orderId: { type: "number" }
},
required: ["orderId"]
}
};
工具描述让模型知道"有哪些动作可以选择",参数 Schema 约束它"应该用什么格式提出动作"。模型返回的 name 与 arguments 仅仅是一份结构化请求;真正的函数调用必须由应用侧(Client Tool)接管并执行。OpenAI 与 Anthropic 的 Tool Calling 文档都把这个边界写得很清楚:模型产生调用,应用执行代码,再将 Tool Result 返回模型。^1^^2^
模型在一轮中也不一定只返回 Tool Call。根据 API 和应用约定,它还可能返回最终文本、结构化业务结果,或者多个工具调用。Runtime 必须检查实际输出,而不能假设每次响应都是可以直接展示给用户的答案。
2. 应用把请求变成真实动作
一个 Tool Call 从模型出来后,至少要经过四类处理:
| 参与者 | 输入 | 负责事项 | 输出 | 不负责事项 |
|---|---|---|---|---|
| Model | 当前 Context、工具定义 | 生成最终回答或下一步结构化调用 | Final Output / Tool Call | 不直接保证业务动作成功 |
| Runtime | Model Output、Run State | 解析、校验、选择分支、维护循环 | 执行指令或 Run Result | 不替代业务系统完成动作 |
| Policy / Approval | Tool Call、用户与风险信息 | 判断允许、拒绝或暂停审批 | Policy Decision | 不生成业务结果 |
| Tool Executor | 已通过校验的参数 | 调用订单服务、数据库或外部 API | Tool Result | 不决定完整任务是否结束 |
回到订单案例。Runtime 首先要确认 cancel_order 是已注册工具,orderId 符合参数 Schema;Policy 再结合当前用户、订单归属与动作风险判断是否放行;Tool Executor 最后才向订单服务发出取消请求。
这个边界也解释了为什么不能简单地说"模型会操作数据库"。对于 Client Tool,动作由用户应用执行;对于部分 Hosted Tool 或 Server Tool,则由模型服务提供方的运行时执行。执行位置可以不同,但都不是"模型权重本身直接连接外部系统"。^2^
3. 一次完整往返包含两次模型交互
Client Tool 的典型过程不是一次请求,而是五个步骤:^1^
第一轮 Model Call 产生动作请求;应用执行工具;第二轮 Model Call 读取 Tool Result,才生成"订单已取消"的最终答复。如果工具返回"订单已经发货",模型也应基于这个事实调整回答,而不是继续宣称取消成功。
所以,接入 Tool Calling 不自动等于得到一个 Agent。 它只是让模型能够用结构化格式提出动作。应用还需要执行动作、回传结果,并处理模型可能继续提出的下一个动作。
二、从一次往返到 Agent Run,中间多了一个循环
1. 一次 Model Call 不等于一次 Agent Run
Model Call 的边界很清楚:应用组装 Context,调用模型,取得一次输出。Agent Run 的边界则更长:从收到任务开始,经历若干次模型调用、工具执行与状态更新,直到运行完成、暂停、失败或被取消。
OpenAI Agents SDK 把一个 Run 描述为持续到"真实停止点"的循环:调用模型,检查输出;有 Tool Call 就执行后继续,有 Handoff 就切换处理者,没有后续工具工作且得到最终回答时才返回结果。^3^ Anthropic 对 Agent 的概括更直接:LLM 根据环境反馈在循环中使用工具。^4^
因此,一次 Run 里可能发生:
text
Model Call #1 → 查询订单
Tool Result → 订单存在,尚未发货
Model Call #2 → 取消订单
Tool Result → 需要人工审批
Pause → 等待负责人批准
Resume → 执行取消
Model Call #3 → 生成最终答复
Complete
如果只记录最后一条文本,我们会丢掉真正决定任务结果的过程:模型为什么调用某个工具、工具返回什么、动作是否经过审批、运行从哪里恢复。
2. 最小循环是"决策---执行---反馈---更新"
把具体 SDK 的类名拿掉,最小 Agent loop 只有两个反复发生的核心步骤:
- Model Call:模型根据当前 Context 返回 Final Output 或 Tool Call。
- Tool Execution:应用执行工具,把 Tool Result 交给下一轮。
Runtime 负责两者之间的分支、状态与边界:
这里刻意不用"模型思考了什么"解释循环。工程上真正能够记录和复现的是 Model Request、Model Response、Tool Call、Tool Result、State Update、Approval 和 Error。模型内部如何形成输出,不影响 Runtime 对这些公开事件的处理。
3. 环境反馈让下一步建立在事实上
模型产生 cancel_order 时,只能说明它根据已有 Context 判断取消动作可能合适。真实环境可能返回完全不同的结果:
- 订单取消成功;
- 订单已经发货,不能取消;
- 订单不属于当前用户;
- 订单服务暂时不可用;
- 动作超过自动处理额度,需要人工审批。
这些结果会改变下一步路径。取消成功后可以生成确认信息;已经发货时要解释限制或转入退货流程;服务超时可能进入受控重试;需要审批时则保存现场并暂停。
Agent 的动态性不是来自"模型可以自由发挥",而是来自下一步路径由模型输出和环境反馈共同决定 。环境结果为模型提供 ground truth,Runtime 则保证这个结果以正确格式进入下一轮。^4^
4. Planning 可以存在,但不是固定方框
复杂任务可能需要显式计划。例如,一个采购 Agent 可以先输出待办列表,再逐项查询库存、比较供应商并发起审批。计划也可以保存在 State 中,执行后更新完成状态。
但在取消订单这类短任务里,模型完全可以每轮根据当前 State 与 Tool Result 选择下一步,不必先调用独立 Planner。现有主流实现也没有共同要求每个 Agent 都必须配置一个 Planning 组件:有的把它视为模型行为,有的用结构化计划实现,有的通过 Orchestrator 或 Graph 显式编排。
更准确的说法是:Planning 是一种可以显式化的运行行为或编排模式,不是最小 Agent 必须拥有的独立组件。 如果系统展示计划,应展示模型明确输出的计划或应用记录,而不是把生成的解释当作模型真实内部推理的完整披露。
三、循环为什么离不开 State?
循环解决了"结果回来后继续调用"的流程问题,但它无法回答两个更关键的工程问题:每一轮应该带上哪些信息? 以及 中断后如何从断点原样恢复?
若每次都把完整历史、所有工具结果塞给模型,Context 会越来越长,敏感信息也可能被不必要地暴露。若什么都不保存,下一轮又会失去任务进度。要拆解这对矛盾,需要区分 Context、State、Session / Thread 和 Memory。下面是本文采用的工作定义,不是某一家厂商的统一术语体系。
1. Context 是本轮模型真正看见的信息
Context 是一次 Model Call 实际收到的信息集合,通常包括 Instructions、经过选择的消息、可用工具定义、与当前决策有关的 State,以及按需检索出的资料。
它不是数据库中所有可用数据,也不等于完整会话历史。Context Window 有容量限制,更重要的是,混入大量无关信息会稀释当前任务真正需要的信号。Context Engineering 的工作,就是决定每一轮把哪些信息交给模型。^5^
OpenAI Agents SDK 也明确区分 Conversation History 与 Run Context:前者会进入模型,后者可以只供应用代码和工具使用。^6^ 当前用户的数据库连接、鉴权对象和内部日志句柄属于 Runtime 依赖,没有必要因为它们"与运行有关"就发送给模型。
2. State 保存任务事实与恢复位置
State 是应用维护的任务事实与运行进度。取消订单时,它可以包含:
typescript
interface AgentState {
runId: string;
status: "running" | "paused" | "completed" | "failed" | "cancelled";
turnCount: number;
events: Array<ModelEvent | ToolEvent>;
pendingApproval?: {
call: ToolCall;
decision?: "approved" | "rejected";
};
lastError?: string;
}
State 中既有可能进入下一轮 Context 的信息,例如最近一次 Tool Result;也有只供 Runtime 使用的信息,例如重试次数、审批决定和内部错误。State 是运行拥有的数据,Context 是本轮选择给模型看的数据。
当 cancel_order 等待审批时,应用应保存 Pending Tool Call、当前轮次和已有事件。审批完成后从这份 State 恢复同一次 Run,而不是把"批准了"伪装成一个全新的用户问题。官方 Agent Runtime 对审批流程也采用"中断并返回可恢复 State"的模式。^7^^8^
3. Session / Thread 组织连续运行
Session 或 Thread 更接近一个组织容器或定位标识:它把多次调用、消息历史和 Checkpoint 归到同一条连续交互中。不同框架的具体语义并不相同,不能把 OpenAI Session 与 LangGraph Thread 当成完全相同的 API。
可以用一个简单关系理解:
| 名词 | 作用 |
|---|---|
| State | 是某个时刻保存了什么 |
| Checkpoint | 是 State 在特定步骤的快照 |
| Session / Thread | 帮助 Runtime 找到属于同一连续交互的历史与快照 |
LangGraph 通过 Checkpointer 保存 Thread 内的 Graph State,用于对话延续、人工介入和故障恢复;OpenAI Agents SDK 则可以通过 Session、Conversation ID 或 Response ID 延续不同类型的会话状态。^3^^9^
4. Memory 保存未来可能再用的信息
Memory 通常指跨步骤或跨会话保留、并在未来按需取回的信息。例如:
- "用户偏好短信通知"可以进入长期 Memory;
- "用户常用收货地址"可以在授权后跨会话读取;
- "订单 123 正等待取消审批"则应属于当前 Run State。
信息被写入 Memory,不代表模型下一轮自动知道它。外部 Memory 必须经过检索、筛选并放入当前 Context,才能直接影响本轮输出。Anthropic 对 Agent Context 的讨论,以及 LangGraph 对 Checkpointer 与 Store 的区分,都体现了这一点:前者处理当前 Thread 的状态连续性,后者保存跨 Thread 的应用数据。^5^^9^
四者的关系可以画成:
| 概念 | 保存什么 | 典型生命周期 | 是否直接对模型可见 | 订单案例 |
|---|---|---|---|---|
| Context | 本轮推理所需的选定信息 | 一次 Model Call | 是 | 当前请求、可用工具、最近 Tool Result |
| State | 当前任务事实、进度和控制信息 | 一次 Run,可持久化恢复 | 按需选择 | 待审批调用、轮次、执行结果 |
| Session / Thread | 连续交互的历史与状态定位 | 多次调用或多个 Run | 其中部分可进入 Context | 同一客服会话或任务线程 |
| Memory | 未来可能复用的偏好、事实或经验 | 跨 Run / 跨 Session | 否,需取回后进入 Context | 用户通知偏好 |
这个区分的价值不在术语本身,而在数据边界:哪些信息必须让模型看到,哪些只应由 Runtime 保管,哪些需要跨会话保存。
四、谁决定继续、暂停与失败?
模型可以返回 Final Output,也可以建议下一步动作,但 Agent Run 的生命周期不能只交给模型决定。应用还要处理审批、错误、预算、超时和用户取消。
本文用 Continue、Complete、Pause、Fail 和 Cancel 描述这些状态。它们是便于解释 Runtime 的工程归纳,不是所有 SDK 共同采用的标准枚举。
| 状态 | 典型触发条件 | 是否终态 | 是否可恢复 | 订单案例 |
|---|---|---|---|---|
| Continue | 获得 Tool Result,需要再次调用模型 | 否 | 是 | 查询订单成功,继续判断能否取消 |
| Complete | 得到最终输出,且没有后续工具工作 | 是 | 通常不再继续 | 取消成功并生成答复 |
| Pause | 等待审批、用户信息或外部事件 | 否 | 是 | 等待负责人批准取消 |
| Fail | 不可恢复错误、校验拦截或达到限制 | 是 | 视补偿策略而定 | 参数无效、重试耗尽 |
| Cancel | 用户或系统主动终止 | 是 | 通常需显式重开 | 用户撤回取消请求 |
1. Complete:运行结束不自动等于业务成功
在最小循环里,模型返回 Final Output 且没有更多 Tool Call,可以作为 Run 的停止点。^3^ 但"模型不再调用工具"和"业务目标已经成功"不是永远等价。
假如订单服务返回"已经发货,无法取消",模型可以正确解释原因并结束 Run。此时运行本身正常完成,取消订单这一业务目标却没有达成。生产系统仍需要独立的任务结果或业务校验,不能只用 finalOutput !== null 统计成功率。
2. Pause:需要外部决定时保存现场
取消、退款、发布、删除等有副作用的动作,常常不能由模型输出直接触发。Runtime 可以让模型继续提出动作,但在执行前根据 Policy 暂停。
暂停时应返回待处理事项和可恢复 State。审批人批准或拒绝后,应用从同一份 State 继续:批准则执行原 Tool Call;拒绝则把拒绝结果记录为 Tool Result,让模型决定如何回复用户。这个过程中不需要重新让模型生成一次取消请求,也不应把 Pause 当成运行失败。^7^^8^
3. Fail:失败处理不能藏在无限重试里
Agent loop 会放大错误处理的重要性,因为每一轮都有新的失败入口:
- 模型输出无法解析;
- 工具名称不存在或参数不合法;
- Guardrail 阻止输入、输出或动作;
- Tool Executor 超时;
- 外部服务返回业务错误;
- 运行达到最大轮次、重试次数或预算。
maxTurns 不是性能优化,而是一条基本控制边界。没有它,模型和工具可能在相同结果之间反复往返。工具失败也不能一律自动重试:查询类动作通常可以安全重试,取消订单等有副作用的动作必须先设计幂等键和结果核验,否则一次网络超时后的自动重试,可能因缺乏幂等键而造成重复执行(如重复扣款或重复取消)。
4. Cancel:应用必须保留强制停止权
用户撤回请求、上游连接断开、运行超时或预算耗尽时,Runtime 都需要能够终止循环。模型可以生成"任务已经完成",也可以建议停止,但应用仍应保留独立的取消信号。
这也是"Agent 自主执行"的边界:自主意味着模型可以在授权范围内动态选择下一步,不意味着 Runtime 放弃控制。运行时掌握审批、资源限制、超时和取消,才能让模型决策进入一个可管理的系统。
五、用最小代码复原一个 Agent Runtime
前面分别拆开了 Tool Calling、执行循环、State 和生命周期。把它们放回同一段代码,可以更清楚地看到 Agent SDK 的 run() 隐藏了什么。
下面的 TypeScript 示例不绑定具体模型或框架。callModel、executeTool、saveState 和 approvalPolicy 由外部注入,循环只负责编排它们。为突出主线,本示例假定每轮单次 Tool Call;实际生产环境需额外扩展支持并行多工具调用与结果聚合。
typescript
type RunStatus =
| "running"
| "paused"
| "completed"
| "failed"
| "cancelled";
type ToolCall = {
type: "tool_call";
callId: string;
name: string;
arguments: Record<string, unknown>;
};
type ModelDecision =
| { type: "final"; content: string }
| ToolCall;
type AgentEvent =
| { type: "user"; content: string }
| { type: "model"; decision: ModelDecision }
| { type: "tool"; callId: string; result: unknown };
type PendingApproval = {
call: ToolCall;
decision?: "approved" | "rejected";
};
type AgentState = {
runId: string;
status: RunStatus;
turnCount: number;
events: AgentEvent[];
pendingApproval?: PendingApproval;
finalOutput?: string;
lastError?: string;
};
type RuntimeDependencies = {
callModel: (events: AgentEvent[]) => Promise<ModelDecision>;
executeTool: (call: ToolCall) => Promise<unknown>;
requiresApproval: (call: ToolCall) => boolean;
saveState: (state: AgentState) => Promise<void>;
signal?: AbortSignal;
maxTurns: number;
};
async function appendToolResult(
state: AgentState,
call: ToolCall,
result: unknown
) {
state.events.push({
type: "tool",
callId: call.callId,
result
});
}
async function runAgent(
state: AgentState,
deps: RuntimeDependencies
): Promise<AgentState> {
state.status = "running";
try {
while (state.turnCount < deps.maxTurns) {
if (deps.signal?.aborted) {
state.status = "cancelled";
await deps.saveState(state);
return state;
}
// 恢复时先处理暂停中的原 Tool Call,避免让模型重复生成。
if (state.pendingApproval) {
const pending = state.pendingApproval;
if (!pending.decision) {
state.status = "paused";
await deps.saveState(state);
return state;
}
if (pending.decision === "rejected") {
await appendToolResult(state, pending.call, {
ok: false,
reason: "rejected_by_reviewer"
});
} else {
const result = await deps.executeTool(pending.call);
await appendToolResult(state, pending.call, result);
}
state.pendingApproval = undefined;
await deps.saveState(state);
continue; // 工具结果已回填,重新进入循环调用模型生成后续回复
}
state.turnCount += 1;
const decision = await deps.callModel(state.events);
state.events.push({ type: "model", decision });
if (decision.type === "final") {
state.finalOutput = decision.content;
state.status = "completed";
await deps.saveState(state);
return state;
}
if (deps.requiresApproval(decision)) {
state.pendingApproval = { call: decision };
state.status = "paused";
await deps.saveState(state);
return state;
}
const result = await deps.executeTool(decision);
await appendToolResult(state, decision, result);
await deps.saveState(state);
}
state.status = "failed";
state.lastError = `maxTurns exceeded: ${deps.maxTurns}`;
await deps.saveState(state);
return state;
} catch (error) {
state.status = "failed";
state.lastError =
error instanceof Error ? error.message : "unknown runtime error";
await deps.saveState(state);
return state;
}
}
async function review(
state: AgentState,
decision: "approved" | "rejected",
deps: RuntimeDependencies
) {
if (!state.pendingApproval) {
throw new Error("No pending approval");
}
state.pendingApproval.decision = decision;
await deps.saveState(state);
return runAgent(state, deps);
}
这段代码没有实现具体模型与订单服务,却保留了最小 Runtime 的关键机制:
ModelDecision把 Final Output 与 Tool Call 变成显式分支。while循环让 Tool Result 能够进入下一轮 Model Call。AgentState保存事件、轮次、审批点与运行状态。pendingApproval让 Run 从同一 Tool Call 恢复,避免重新生成动作。maxTurns、AbortSignal和catch提供失败与取消出口。
为了突出主线,示例省略了 Tool Registry、Schema Validation、幂等键、分布式锁、超时重试、Checkpoint 版本和 Trace。这些不是可有可无的细节,而是生产化 Runtime 需要继续补齐的能力;本篇只证明它们应该接入执行循环的哪个位置。
OpenAI Agents SDK、LangChain / LangGraph 等框架会替我们封装模型适配、Tool Dispatch、循环、Session、Checkpoint、审批或 Trace 的一部分。框架降低了实现成本,但不会消除这些机制。出现重复调用、状态丢失、越权执行或无法恢复时,排查仍然要回到几个基本问题:
- 模型实际返回了什么?
- Runtime 选择了哪个分支?
- 工具是否真正执行,结果是否正确回填?
- State 在哪一步更新和持久化?
- Run 为什么继续、暂停或结束?
理解循环不是为了重复造一个 Agent SDK,而是为了知道框架在替我们承担什么,以及系统出错时应该从哪里找证据。
总结:Agent 的核心不是"拥有工具",而是"运行得起来"
回到订单 123。模型返回 cancel_order 时,只提出了动作;Runtime 还要校验参数与权限,在风险边界前暂停审批,调用订单服务,并把真实结果交给下一轮。State 保存这段过程,让 Run 可以暂停和恢复;最大轮次、错误处理和取消信号则防止它无限运行或越过系统边界。
由此可以给出本文的工作定义:
Agent 是一个由 Runtime 组织的应用级执行系统:模型根据当前 Context 产生下一步决策,应用执行动作并更新 State,再依据环境反馈持续推进,直到任务完成、暂停、失败或取消。
这一定义是根据多家官方运行机制做出的工程归纳,不是行业唯一标准。它强调的也不是组件数量,而是四件事能否连起来:模型决策、外部执行、状态更新和边界控制。
这篇文章可以先带走三个判断:
- Tool Calling 只是协议接口,不是完整 Agent。
- Agent loop 的核心,是 Model Call 与 Tool Execution 根据环境反馈反复推进。
- State 与 Runtime 边界决定这个循环能否受控、可恢复地运行。
下一篇会在这个最小执行循环之上,继续讨论 Skill、Workflow、MCP 等能力如何接入,又该如何编排。
Footnotes
-
OpenAI, "Function calling", platform.openai.com/api/docs/gu... (official-doc) ↩ ↩2
-
Anthropic, "Tool use with Claude", docs.anthropic.com/en/docs/bui... (official-doc) ↩ ↩2
-
OpenAI, "Running agents", platform.openai.com/api/docs/gu... (official-doc) ↩ ↩2 ↩3
-
Anthropic, "Building effective agents", www.anthropic.com/engineering... (official-blog) ↩ ↩2
-
Anthropic, "Effective context engineering for AI agents", www.anthropic.com/engineering... (official-blog) ↩ ↩2
-
OpenAI, "Agent definitions", platform.openai.com/api/docs/gu... (official-doc) ↩
-
OpenAI, "Results and state", platform.openai.com/api/docs/gu... (official-doc) ↩ ↩2
-
OpenAI, "Guardrails and human review", platform.openai.com/api/docs/gu... (official-doc) ↩ ↩2
-
LangChain, "LangGraph persistence", docs.langchain.com/oss/python/... (official-doc) ↩ ↩2