Agent 工程体系 · 第 3/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 运行循环:观察、决策、行动、反馈、状态与终止

Agent 不是“一次调用模型并返回文本”,而是一个持续推进的运行过程:模型读取当前状态,提出决策;运行时执行决策中的动作;外部环境返回结果;运行时把结果写回状态;模型再基于更新后的状态继续判断,直到任务完成、需要人工介入,或触发某种终止条件。

OpenAI 对 Agent 运行循环的概括是:调用当前 Agent 的模型、检查模型输出、执行工具调用或切换到其他专员、在没有更多工具工作时返回结果。Anthropic 也将 Agent 描述为“LLM 根据环境反馈循环使用工具”的系统。两者共同指向一个核心事实:Agent 的可靠性主要取决于循环如何管理状态和边界,而不只是模型本身的回答质量。 (developers.openai.com)


一、先区分:工作流、模型调用和 Agent 运行循环

1. 一次模型调用不是 Agent

一次模型调用可以表示为:

y=M(x)y = M(x)

其中:

  • xx 是输入上下文;
  • MM 是模型;
  • yy 是模型输出。

如果输出只是最终文本,那么调用结束后系统就停止。这种方式适合分类、摘要、改写和简单问答。

但 Agent 的任务通常不能由一个输出直接完成。例如:

查询订单状态,如果订单已支付但未发货,则创建客服工单,并向用户说明工单编号。

这个任务至少涉及:

  1. 查询订单;
  2. 解释查询结果;
  3. 判断是否满足创建工单的条件;
  4. 调用创建工单工具;
  5. 读取工单创建结果;
  6. 组织最终回复。

模型无法凭空知道订单状态,也不能把“应该创建工单”直接当成“工单已经创建”。因此,Agent 必须在模型和环境之间多次往返。

2. 固定工作流和 Agent 的差异

固定工作流把步骤写在程序中:

查询订单 -> 判断状态 -> 创建工单 -> 返回结果

Agent 则可能让模型动态决定:

用户目标
  -> 模型判断需要查询订单
  -> 工具返回结果
  -> 模型判断还需要查物流
  -> 工具返回结果
  -> 模型决定创建工单
  -> 工具返回结果
  -> 模型输出最终答案

Anthropic 将前者称为 Workflow,将后者称为 Agent:Workflow 的路径由预先编写的代码决定,Agent 则由 LLM 动态决定处理过程和工具使用方式。对于步骤明确、成功路径稳定的任务,固定工作流更容易测试;对于无法提前确定步骤数量和工具顺序的开放任务,Agent 循环才有价值。代价是更高的延迟、费用和错误累积风险。 (anthropic.com)


二、运行循环的形式化模型

把一次 Agent 执行抽象成离散时间系统。

设:

  • StS_t:第 tt 个时刻的运行状态;
  • OtO_t:Agent 在当前状态中观察到的输入;
  • MM:模型决策函数;
  • AtA_t:模型产生的动作;
  • EE:外部环境或工具执行器;
  • RtR_t:动作执行结果;
  • UU:状态更新函数;
  • GG:终止判断函数。

一次循环可写成:

Ot=Observe(St)O_t = Observe(S_t)

At=M(Ot)A_t = M(O_t)

Rt=Act(At)R_t = Act(A_t)

St+1=U(St,At,Rt)S_{t+1} = U(S_t, A_t, R_t)

Stop(St+1)=G(St+1)Stop(S_{t+1}) = G(S_{t+1})

如果 G(St+1)=falseG(S_{t+1}) = false,继续下一轮;如果为真,则运行终止。

这里的“观察”不是一定要调用视觉模型或浏览器。它指的是:把当前运行所需的事实组织成模型可以消费的上下文。可能包括:

  • 用户目标;
  • 历史消息;
  • 上一轮模型输出;
  • 工具执行结果;
  • 当前任务进度;
  • 预算和截止时间;
  • 错误信息;
  • 等待人工审批的事件。

“行动”也不只意味着调用工具。模型可能产生三类不同结果:

  1. 最终回答;
  2. 一个或多个工具调用;
  3. 向其他 Agent 转交任务。

运行时必须先解释模型输出,再决定状态如何变化。不能把所有模型输出都当作文本,也不能把模型提出的工具调用当作工具已经成功执行。


三、循环状态不是聊天记录

1. 历史记录与运行状态的差异

聊天记录通常回答:

到目前为止,用户和模型说过什么?

运行状态还必须回答:

当前正在执行哪个动作?哪些动作已经成功?哪些动作失败?是否正在等待审批?剩余预算是多少?恢复时从哪里继续?

因此,一个更完整的状态可以表示为:

RunState {
    run_id: "run_123",
    status: "running",
    objective: "查询订单并在需要时创建工单",
    current_agent: "support_agent",
    messages: [...],
    facts: {
        order_id: "O-1001",
        payment_status: "paid",
        shipment_status: "not_shipped"
    },
    pending_actions: [],
    completed_actions: [...],
    failed_actions: [],
    step_count: 3,
    token_usage: {
        input: 4200,
        output: 900
    },
    deadline: "2026-09-01T10:30:00+08:00",
    termination_reason: null,
    version: 7
}

其中 messages 是模型上下文的一部分,但不是全部状态。factspending_actionscompleted_actions 和预算字段都可能影响下一次决策,却未必适合原样放进对话历史。

2. 状态必须能解释下一步

一个有效状态至少要满足:

NextActiont=f(St)NextAction_t = f(S_t)

也就是说,给定持久化后的 StS_t,系统应能重新判断下一步应该做什么,而不依赖只存在于某个进程内存里的变量。

反例是:

last_tool_result = result
# 进程重启后 last_tool_result 丢失

如果下一次模型调用需要依赖 last_tool_result,恢复后的 Agent 就无法区分:

  • 工具从未执行;
  • 工具已经执行但结果未写回;
  • 工具执行成功但进程在写回前崩溃;
  • 工具执行失败并且应该重试。

这四种情况对应完全不同的恢复动作。

3. 状态需要版本号

多个 Worker 或恢复任务可能同时操作同一个运行。状态应带有单调递增的版本号:

version = 7

写入时使用乐观并发控制:

UPDATE agent_runs
SET state_json = :new_state,
    version = version + 1
WHERE run_id = :run_id
  AND version = :expected_version;

如果更新行数为零,说明另一个执行者已经推进了状态,当前执行者不能盲目覆盖。否则可能出现:

  1. Worker A 执行工具;
  2. Worker B 从旧状态恢复;
  3. Worker B 覆盖 A 写入的工具结果;
  4. Agent 重复执行工具。

这不是模型错误,而是状态并发控制缺失。


四、事件和状态:循环如何被真正推进

状态描述“现在是什么”,事件描述“发生了什么”。

一个事件通常包含:

{
  "event_id": "evt_0007",
  "run_id": "run_123",
  "sequence": 7,
  "type": "tool.completed",
  "action_id": "act_0003",
  "tool_name": "create_ticket",
  "input": {
    "order_id": "O-1001",
    "reason": "paid_but_not_shipped"
  },
  "output": {
    "ticket_id": "T-9001"
  },
  "status": "succeeded",
  "occurred_at": "2026-09-01T10:02:11+08:00"
}

常见事件类型包括:

run.started
model.started
model.completed
tool.requested
tool.started
tool.completed
tool.failed
approval.requested
approval.accepted
approval.rejected
run.paused
run.cancel_requested
run.cancelled
run.completed
run.failed
run.terminated

事件和状态的关系可以写成:

St+1=Reduce(St,Eventt)S_{t+1} = Reduce(S_t, Event_t)

其中 Reduce 是确定性的状态折叠函数。只要事件顺序和内容不变,最终状态就应一致。

flowchart LR
    A[当前状态 S_t] --> B[观察 Observe]
    B --> C[模型回合 Model Turn]
    C --> D{模型输出类型}
    D -->|最终答案| E[完成状态]
    D -->|工具调用| F[行动 Action]
    D -->|需要审批| G[暂停状态]
    F --> H[工具回合 Tool Turn]
    H --> I[工具结果事件]
    I --> J[写回状态 Update]
    J --> A
    G --> K[审批事件]
    K --> J

关键路径是:

模型输出工具调用
    -> 记录 tool.requested
    -> 执行工具
    -> 记录 tool.completed 或 tool.failed
    -> 将结果写入模型上下文和业务状态
    -> 重新调用模型

如果省略最后一步,工具调用只是孤立的副作用,模型并不知道动作是否成功。


五、模型回合和工具回合不是同一件事

1. 模型回合

模型回合是运行时向模型提交上下文并接收模型输出的过程:

输入:用户目标 + 当前状态 + 历史事件
输出:最终文本、工具调用、转交或其他控制结果

模型回合的结果是“决策”,不是事实。模型可能说:

{
  "type": "tool_call",
  "name": "create_ticket",
  "arguments": {
    "order_id": "O-1001"
  }
}

这只代表模型请求调用工具,不代表工单已经创建。

2. 工具回合

工具回合是运行时执行动作并接收外部结果:

输入:工具名 + 结构化参数
输出:成功结果、业务拒绝、超时或异常

例如:

{
  "type": "tool_result",
  "name": "create_ticket",
  "status": "succeeded",
  "data": {
    "ticket_id": "T-9001"
  }
}

只有工具回合成功并且结果被写回,系统才获得新的环境事实。

3. 为什么必须严格区分

如果程序在模型刚产生工具调用后就直接返回:

model_output = call_model(state)
if model_output.type == "tool_call":
    return model_output

那么用户得到的可能只是:

我将为您创建工单。

但工单可能尚未创建,甚至参数校验已经失败。

正确的因果顺序是:

模型决定创建工单
    ≠ 工单已经创建

工具成功返回工单编号
    = 环境确认工单已经创建

这也是 Agent 必须从环境获得“ground truth”的原因:工具结果、代码执行结果或其他外部观测,才是判断进度的依据。 (anthropic.com)


六、动作结果必须写回:否则循环不会学习

1. 写回的两层含义

动作结果写回至少有两层:

写回对话上下文

把工具结果作为下一次模型输入的一部分:

assistant: 调用 create_ticket(order_id="O-1001")
tool: ticket_id="T-9001"

模型据此才能生成:

工单 T-9001 已创建。

写回业务状态

同时把可查询的事实写入结构化状态:

{
  "facts": {
    "ticket_id": "T-9001",
    "ticket_status": "created"
  },
  "completed_actions": [
    {
      "action_id": "act_0003",
      "type": "create_ticket",
      "result_ref": "evt_0007"
    }
  ]
}

只写聊天记录,不写结构化状态,会导致重复执行检测、审计和恢复困难;只写结构化状态,不写模型上下文,则下一轮模型无法看到完整因果链。

2. 结果写回的事务边界

工具调用和状态写回通常不在同一个数据库事务中,因为工具可能是外部 HTTP 服务。于是会出现经典故障窗口:

1. 调用支付工具成功
2. 进程在写回数据库前崩溃
3. 恢复后无法确认支付是否成功
4. Agent 重试支付

因此,具有副作用的工具需要幂等键:

idempotency_key = run_id + ":" + action_id

服务端收到同一个幂等键时,应返回第一次执行的结果,而不是再次产生副作用。

不能简单依赖:

if action_id in completed_actions:
    skip()

因为 completed_actions 可能在第一次调用成功后尚未持久化。更稳妥的设计是:

  1. 生成稳定的 action_id
  2. 在执行前持久化 tool.requested
  3. 向工具传递幂等键;
  4. 工具返回后持久化 tool.completed
  5. 恢复时根据事件判断动作处于“已完成”“未开始”还是“结果未知”。

“结果未知”不能自动等同于“失败”。


七、完整算例:查询订单并创建工单

设用户提出:

查询订单 O-1001。如果已经付款但还没有发货,就创建客服工单,并告诉我工单编号。

初始状态:

status = running
step_count = 0
facts = {}
pending_actions = []

第 1 轮:模型决定查询订单

模型观察到用户提供了订单号,但没有订单状态,于是产生:

{
  "type": "tool_call",
  "action_id": "act_0001",
  "tool": "get_order",
  "arguments": {
    "order_id": "O-1001"
  }
}

此时状态应先记录:

step_count = 1
pending_actions = ["act_0001"]

注意,pending_actions 表示“已请求但尚未得到结果”,不是“已成功”。

第 1 个工具回合:订单查询成功

工具返回:

{
  "order_id": "O-1001",
  "payment_status": "paid",
  "shipment_status": "not_shipped"
}

运行时写回:

facts.payment_status = paid
facts.shipment_status = not_shipped
pending_actions = []
completed_actions += act_0001

并把工具结果放入下一次模型上下文。

第 2 轮:模型根据事实决定创建工单

现在模型看到:

payment_status = paid
shipment_status = not_shipped

它判断条件成立,产生:

{
  "type": "tool_call",
  "action_id": "act_0002",
  "tool": "create_ticket",
  "arguments": {
    "order_id": "O-1001",
    "reason": "paid_but_not_shipped"
  }
}

状态变化:

step_count = 2
pending_actions = ["act_0002"]

第 2 个工具回合:工单创建成功

工具返回:

{
  "ticket_id": "T-9001",
  "status": "created"
}

状态变化:

facts.ticket_id = "T-9001"
facts.ticket_status = "created"
pending_actions = []
completed_actions = ["act_0001", "act_0002"]

第 3 轮:模型输出最终答案

模型产生文本:

订单 O-1001 已付款但尚未发货。我已创建客服工单,编号为 T-9001。

运行时此时才可以将状态置为:

status = completed
termination_reason = final_output

完整轨迹是:

run.started
model.completed(tool_call=get_order)
tool.requested(act_0001)
tool.completed(act_0001)
model.completed(tool_call=create_ticket)
tool.requested(act_0002)
tool.completed(act_0002)
model.completed(final_answer)
run.completed

这个例子说明,Agent 的“步数”不能只按模型调用次数计算,也不能只按工具调用次数计算。系统必须明确自己的计数单位。常见做法是把一次“模型决策加随后处理”称为一个 Agent step,而把模型请求、工具执行、审批等待分别记录为事件。


八、终止条件:完成不是唯一出口

终止条件是判断运行是否可以停止的规则。可以把它表示为:

Terminate(S)=Success(S)Failed(S)Cancelled(S)BudgetExceeded(S)DeadlineExceeded(S)LoopDetected(S)Terminate(S) = Success(S) \lor Failed(S) \lor Cancelled(S) \lor BudgetExceeded(S) \lor DeadlineExceeded(S) \lor LoopDetected(S)

1. 成功终止

成功终止通常需要同时满足:

  1. 模型产生最终答案;
  2. 当前没有待执行工具调用;
  3. 必要的业务不变量成立;
  4. 结果已经持久化。

不能仅凭“模型没有调用工具”判断成功。例如模型可能输出普通文本:

订单已经退款。

但系统没有任何退款工具事件,说明这只是未经验证的声明。

可以定义业务级成功条件:

Success(S) =
    final_output_exists
    AND pending_actions == []
    AND required_fact("refund_id")
    AND refund_event.status == "succeeded"

2. 最大步数

最大步数是最基本的安全阀:

step_countmax_stepsterminatestep\_count \geq max\_steps \Rightarrow terminate

它能防止模型因误解任务而无限循环,但不能保证任务质量。最大步数过小,任务可能在最后一个必要动作前被截断;过大,则会增加延迟和费用。

达到上限时,不应伪装成成功,而应记录:

status = terminated
termination_reason = max_steps
partial_result = current_state

然后由上层决定是:

  • 返回部分结果;
  • 转人工;
  • 允许用户继续;
  • 使用更强模型重试;
  • 进入补偿流程。

3. Deadline

Deadline 是绝对时间边界,而不是“再执行几步”。设:

nowdeadlineterminatenow \geq deadline \Rightarrow terminate

它解决的是单次工具调用很慢、模型服务排队或网络阻塞的问题。每次执行模型和工具前都应检查剩余时间,并把剩余时间传递给下游。

例如:

总截止时间:10:30:00
当前时间:10:29:52
HTTP 工具超时:10 秒

此时不能再无条件发起一个 10 秒请求。运行时应把超时时间限制为剩余预算,例如 7 秒,并在超时后进入明确的失败或暂停状态。

4. Token 和费用预算

Token 预算控制模型上下文和输出规模;费用预算控制实际资源消耗。两者相关但不等价:

  • 上下文很长,Token 可能很多,但调用模型价格可能较低;
  • 工具调用次数不多,但单个外部服务可能产生高费用;
  • 重试会增加 Token 和工具费用;
  • 并行调用会降低墙钟时间,却可能增加总成本。

可以用累计预算表示:

costtotal=icost(modeli)+jcost(toolj)cost_{total} = \sum_i cost(model_i) + \sum_j cost(tool_j)

当:

costtotal+estimated_next_cost>cost_budgetcost_{total} + estimated\_next\_cost > cost\_budget

运行时应停止继续扩展任务,而不是让模型自行决定是否“值得继续”。

5. 循环检测

最大步数只能限制长度,不能识别“重复做同一件事”。循环检测需要定义动作指纹:

fingerprint(a)=hash(tool_name,normalized(arguments),relevant_state)fingerprint(a) = hash(tool\_name, normalized(arguments), relevant\_state)

例如以下动作可能具有相同指纹:

{"tool": "search", "arguments": {"query": "退款规则"}}
{"tool": "search", "arguments": {"query": "退款规则"}}

如果连续多轮出现相同动作,且环境状态没有变化,则说明循环没有产生新信息。

但不能把所有重复都视为错误。例如轮询任务可能合理地重复查询:

查询任务状态 -> 等待 5 秒 -> 再次查询任务状态

此时应把“等待时间”“任务状态变化”和“最大轮询次数”纳入判定条件。循环检测的核心不是动作是否重复,而是:

重复动作是否带来了新的可观察状态,或者是否有明确的单调进展。


九、失败、暂停和取消必须分开

1. 失败

失败表示系统无法按当前路径完成运行,例如:

  • 模型调用失败;
  • 工具参数校验失败;
  • 工具返回业务错误;
  • 工具超时;
  • 状态持久化失败;
  • 触发最大步数;
  • 触发预算限制。

失败还应区分可重试和不可重试:

可重试:
- 网络超时
- 临时限流
- 下游 503

不可重试:
- 参数非法
- 权限不足
- 订单不存在
- 业务规则拒绝

模型输出的错误信息也不能直接作为重试依据。重试策略应由运行时根据错误类别决定。

2. 暂停

暂停表示运行没有失败,只是等待外部事件:

  • 人工审批;
  • 用户补充信息;
  • 异步工具回调;
  • 定时等待;
  • 配额恢复。

暂停状态应保存继续执行所需的快照:

status = paused
pause_reason = approval_required
pending_actions = ["act_0005"]
state_snapshot = "..."

审批通过后,应从原状态恢复,而不是发起一条新的用户消息。否则可能产生:

  • 步数重新计数;
  • 工具调用丢失;
  • 对话历史重复;
  • 服务端 continuation ID 不一致。

OpenAI Agents SDK 当前文档也明确区分了暂停运行和新回合:审批或取消后继续执行时,应使用保存的状态恢复,而不是把它当成新的用户轮次。 (developers.openai.com)

3. 取消

取消表示有人或某个系统主动要求停止:

用户点击停止
任务超时
租户撤销授权
服务关闭

取消不是异常,也不等于工具失败。运行时至少需要三个阶段:

running
  -> cancel_requested
  -> cancelled

cancel_requested 表示系统已经收到取消请求,但当前工具可能尚未安全停止。只有确认当前动作不再继续,或者已经进入可接受的终态后,才能写成 cancelled

对于有副作用的工具,取消尤其复杂:

1. 用户请求取消
2. create_payment 已经发送到支付服务
3. 本地还没有收到响应

此时不能因为本地取消就断言支付没有发生。正确处理通常是:

  1. 记录取消请求;
  2. 等待工具返回或通过幂等查询确认;
  3. 将结果标记为成功、失败或未知;
  4. 必要时执行补偿动作。

十、可持久化执行轨迹:把运行变成可恢复事实

1. 轨迹不是日志

普通日志主要服务于排查:

calling tool create_ticket

可持久化执行轨迹则必须支持:

  • 恢复;
  • 审计;
  • 重放;
  • 去重;
  • 统计;
  • 解释为什么终止;
  • 判断副作用是否已经发生。

因此轨迹中的事件不能只记录一句字符串,而应包含结构化输入、输出、状态和关联 ID。

2. 推荐的最小轨迹结构

{
  "run_id": "run_123",
  "sequence": 12,
  "event_id": "evt_0012",
  "type": "tool.completed",
  "action_id": "act_0007",
  "tool_name": "create_ticket",
  "request": {
    "order_id": "O-1001",
    "reason": "paid_but_not_shipped"
  },
  "response": {
    "ticket_id": "T-9001"
  },
  "status": "succeeded",
  "attempt": 1,
  "started_at": "2026-09-01T10:02:10+08:00",
  "completed_at": "2026-09-01T10:02:11+08:00",
  "idempotency_key": "run_123:act_0007"
}

必须特别保存:

  • run_id:一次运行的身份;
  • sequence:事件顺序;
  • action_id:动作身份;
  • attempt:第几次尝试;
  • status:请求、执行中、成功、失败或未知;
  • idempotency_key:副作用去重依据;
  • started_atcompleted_at:时延和 Deadline 诊断依据。

3. 事件日志与当前快照

只保存所有事件,恢复时逐条重放,审计能力强,但长任务恢复慢;只保存当前快照,恢复快,但难以解释中间发生了什么。

工程上常用组合:

事件轨迹:完整事实
状态快照:快速恢复
快照版本:对应最后一个已折叠事件

例如:

snapshot.last_sequence = 100
events = 101, 102, 103, ...

恢复时先加载快照,再重放 101 之后的事件。

4. 一个最小的本地执行器

下面的代码不依赖 LLM 服务,使用假的模型和工具演示核心语义。它展示了三个关键点:

  1. 模型输出工具调用时,不能立即完成;
  2. 工具结果必须写回历史和状态;
  3. 只有模型输出最终答案时,运行才成功结束。
from dataclasses import dataclass, field
from typing import Any


@dataclass
class State:
    messages: list[dict[str, Any]] = field(default_factory=list)
    facts: dict[str, Any] = field(default_factory=dict)
    completed_actions: set[str] = field(default_factory=set)
    step_count: int = 0
    status: str = "running"


def fake_model(state: State) -> dict[str, Any]:
    """根据当前状态模拟模型决策。"""
    if "order" not in state.facts:
        return {
            "type": "tool_call",
            "action_id": "get_order:O-1001",
            "name": "get_order",
            "arguments": {"order_id": "O-1001"},
        }

    order = state.facts["order"]
    if (
        order["payment_status"] == "paid"
        and order["shipment_status"] == "not_shipped"
        and "ticket" not in state.facts
    ):
        return {
            "type": "tool_call",
            "action_id": "create_ticket:O-1001",
            "name": "create_ticket",
            "arguments": {
                "order_id": "O-1001",
                "reason": "paid_but_not_shipped",
            },
        }

    return {
        "type": "final",
        "text": f"工单已创建,编号为 {state.facts['ticket']['ticket_id']}。",
    }


def fake_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
    """根据工具名模拟外部环境。"""
    if name == "get_order":
        return {
            "order_id": arguments["order_id"],
            "payment_status": "paid",
            "shipment_status": "not_shipped",
        }

    if name == "create_ticket":
        return {
            "ticket_id": "T-9001",
            "status": "created",
        }

    raise ValueError(f"unknown tool: {name}")


def run_agent(max_steps: int = 5) -> State:
    state = State()
    state.messages.append({
        "role": "user",
        "content": "查询订单 O-1001,必要时创建客服工单。",
    })

    while state.status == "running":
        if state.step_count >= max_steps:
            state.status = "terminated:max_steps"
            break

        state.step_count += 1
        decision = fake_model(state)

        if decision["type"] == "final":
            state.messages.append({
                "role": "assistant",
                "content": decision["text"],
            })
            state.status = "completed"
            break

        action_id = decision["action_id"]

        if action_id in state.completed_actions:
            # 已经成功执行过的动作不能再次产生副作用
            state.status = "failed:duplicate_action"
            break

        state.messages.append({
            "role": "assistant",
            "tool_call": decision,
        })

        try:
            result = fake_tool(decision["name"], decision["arguments"])
        except Exception as exc:
            state.messages.append({
                "role": "tool",
                "name": decision["name"],
                "error": str(exc),
            })
            state.status = "failed:tool_error"
            break

        state.messages.append({
            "role": "tool",
            "name": decision["name"],
            "content": result,
        })
        state.completed_actions.add(action_id)

        if decision["name"] == "get_order":
            state.facts["order"] = result
        elif decision["name"] == "create_ticket":
            state.facts["ticket"] = result

    return state


result = run_agent()
print(result.status)
print(result.facts)
print(result.messages[-1])

预期输出类似:

completed
{
    'order': {
        'order_id': 'O-1001',
        'payment_status': 'paid',
        'shipment_status': 'not_shipped'
    },
    'ticket': {
        'ticket_id': 'T-9001',
        'status': 'created'
    }
}
{'role': 'assistant', 'content': '工单已创建,编号为 T-9001。'}

这个示例中的 fake_model 不是生产模型接口,而是为了让循环可确定地运行。真实系统还需要在每个事件落盘后更新快照,并处理进程崩溃、重复投递、超时和并发恢复。


十一、流式输出不等于运行已经完成

流式输出只是把模型生成过程或运行事件逐步交给调用方消费,并不改变 Agent 的核心循环。

用户可能已经看到:

我正在查询订单……

但此时:

  • 模型回合可能尚未结束;
  • 工具调用可能尚未执行;
  • 工具结果可能尚未写回;
  • 运行可能随后失败;
  • 最终答案可能尚未生成。

因此,流式系统应区分:

可展示事件
运行完成事件
运行失败事件
运行暂停事件

不能在收到第一段文本时就把任务标记为成功。OpenAI 的运行文档也强调,流式运行需要等待完成后再把结果视为稳定;如果运行因审批或取消中断,应基于保存的状态继续。 (developers.openai.com)


十二、状态机视角:把隐藏控制流显式化

一个生产级 Agent 至少可以抽象为以下状态:

created
  -> running
  -> waiting_tool
  -> running
  -> waiting_approval
  -> running
  -> completed

running
  -> failed
  -> cancelled
  -> terminated

事件触发转移:

当前状态 事件 下一状态
created run.started running
running tool.requested waiting_tool
waiting_tool tool.completed running
waiting_tool tool.failed failedrunning
running approval.requested waiting_approval
waiting_approval approval.accepted running
waiting_approval approval.rejected cancelledfailed
running model.completed(final) completed
任意非终态 cancel_requested cancelled
running budget_exceeded terminated

状态机的价值不在于画图,而在于阻止非法转移。例如:

completed -> running

通常是非法的;如果用户想继续对话,应创建新的 turn,或者使用明确的 continuation 机制,而不是修改已完成运行的历史。


十三、常见错误及其表现

错误一:把模型的计划当成执行结果

表现:

模型说“已发送邮件”,但没有 send_email 成功事件。

诊断方法是检查业务副作用是否有对应的工具完成事件,而不是检查最终文本。

错误二:工具失败后继续假装成功

表现:

工具返回 403
模型仍输出“工单创建成功”

运行时应把工具失败作为模型可见的反馈,或者直接终止;不能让最终输出绕过工具事实。

错误三:重试没有幂等性

表现:

用户收到两封邮件;
支付服务出现两笔扣款;
同一个订单创建多个客服工单。

诊断时要对照 action_idattempt 和工具侧幂等键,判断是模型重复请求、Worker 重复消费,还是超时后的未知结果重试。

错误四:把暂停当成失败

表现:

等待人工审批的任务被标记为 failed;
用户审批后系统重新创建了一次任务。

暂停应保存可恢复状态,审批结果应作为事件追加,而不是重新构造一条新的用户输入。OpenAI Agents SDK 将中断结果建模为 interruptionsstate,其中状态快照用于后续恢复。 (developers.openai.com)

错误五:只保存最终答案

表现:

最终答案说“任务失败”,但无法知道失败在模型调用、工具参数、网络还是业务规则。

最终答案是用户界面数据,不是完整运行轨迹。生产系统通常还需要保存工具调用、交接、守卫、原始模型响应和用量等更细粒度记录,以支持审计和调试。 (developers.openai.com)


十四、实现取舍:简单循环与复杂编排

最小 Agent 循环可以只有一个 while

while not should_stop(state):
    decision = model(state)
    result = execute(decision)
    state = update(state, result)

但生产环境通常还需要加入:

  • 事件持久化;
  • 状态版本控制;
  • 工具幂等;
  • 超时和取消;
  • 重试分类;
  • 人工审批;
  • Token 和费用计量;
  • 循环检测;
  • 轨迹查询;
  • 运行恢复。

这些机制不应一开始就隐藏在复杂框架之后。Anthropic 建议优先使用简单、可组合的实现,并理解框架底层的提示词、模型响应和工具调用,否则抽象层会增加调试困难。 (anthropic.com)

OpenAI Agents SDK 当前文档也把运行循环、状态延续、审批暂停、流式消费和结果快照分别作为运行时概念:结果不仅包含最终输出,还可能包含历史、最后一个 Agent、响应延续标识、待审批项和可恢复状态。 (developers.openai.com)

真正需要明确的不是“是否使用框架”,而是下面这条控制边界:

模型负责提出决策
运行时负责解释决策
工具负责改变外部环境
事件系统负责记录事实
状态机负责决定是否继续
终止器负责阻止无界运行

当这几种职责混在一起时,系统会把“模型说了什么”“工具做了什么”“状态记住了什么”和“任务是否完成”混为一谈。Agent 运行循环的核心,就是把这些因果关系逐步分离,再通过事件和状态重新连接起来。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。