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

Agent 反思与验证:Critic、Verifier、规则检查和停止边界

Agent 的核心循环不是“让模型多想几次”,而是让系统在每次行动后获得新的环境事实,判断当前状态是否仍然满足任务约束,并决定继续、修正、请求人工介入还是终止。

一个可执行的 Agent 通常包含四类能力:

  1. 规划:决定下一步做什么;
  2. 行动:调用工具改变或查询外部环境;
  3. 反思:解释刚才发生了什么、哪里可能有问题;
  4. 验证:依据独立证据判断结果是否满足要求。

其中,Critic 主要回答“这一步或这个候选结果有什么问题”,Verifier 主要回答“结果是否满足可判定条件”。二者都不是“再调用一次模型”这么简单。若没有清晰的检查对象、证据来源和停止边界,反思只会变成额外的文本生成,验证则可能只是模型对自己答案的重复确认。

Anthropic 将 Agent 描述为由模型动态决定流程和工具使用的系统,而不是沿固定代码路径执行的工作流;同时强调,Agent 在执行过程中需要从工具结果、代码执行等环境反馈中取得“ground truth”,并通过最大迭代次数等停止条件保持控制。(anthropic.com) OpenAI 的 Agents SDK 文档也将 Agent 定义为能够规划、调用工具、协作并保留状态以完成多步工作的应用,并区分了由应用自行控制循环的 Responses API 与由 SDK 管理 Agent 循环的 Agents SDK。(developers.openai.com)


一、先建立正确的对象模型

1. 什么是 Agent 反思

**反思(reflection)**是 Agent 对当前执行轨迹、最近一次行动或候选结果进行解释和诊断的过程。

它至少包含三个输入:

  • 当前任务目标;
  • 已发生的执行轨迹;
  • 最近获得的观察结果。

输出通常不是最终答案,而是结构化诊断:

{
  "status": "needs_revision",
  "findings": [
    {
      "type": "missing_evidence",
      "claim": "退款金额为 120 元",
      "evidence": "订单查询结果中未返回退款金额"
    }
  ],
  "next_action": "query_refund_detail",
  "confidence": 0.86
}

反思关注的是:

  • 当前路径是否偏离目标;
  • 哪个假设被观察结果否定;
  • 哪个步骤失败;
  • 是否遗漏必要条件;
  • 下一次行动应该如何调整。

因此,反思本质上是一个诊断与修正机制,而不是最终正确性的证明。

2. 什么是 Critic

Critic 是负责发现问题、提出反馈或指出改进方向的评审组件。

Critic 的典型问题是:

“这个计划、工具调用、代码补丁或答案有哪些缺陷?”

它可以评审:

  • 计划是否覆盖任务要求;
  • 工具选择是否合理;
  • 参数是否完整且类型正确;
  • 当前步骤是否重复;
  • 结果是否包含未经证实的推断;
  • 生成内容是否违反格式、安全或业务约束。

Critic 的输出可以是自然语言,也可以是结构化问题列表。但 Critic 的结论通常是“发现风险”或“建议修改”,不必承担最终放行责任。

例如:

{
  "verdict": "revise",
  "issues": [
    {
      "severity": "high",
      "location": "step_2",
      "reason": "查询使用了用户输入的模糊姓名,没有使用唯一用户 ID"
    }
  ],
  "suggested_fix": "先调用 resolve_user_id,再按 ID 查询订单"
}

Critic 可以是模型,也可以是程序:

  • LLM Critic:适合检查语义、完整性、推理漏洞;
  • 静态分析器:适合检查代码、SQL、JSON Schema;
  • 业务规则引擎:适合检查金额、权限、状态机;
  • 测试执行器:适合检查行为;
  • 人工审核:适合处理高风险和不可形式化的问题。

3. 什么是 Verifier

Verifier 是负责根据明确判定条件,对候选结果进行验收的组件。

Verifier 的典型问题是:

“这个结果是否满足验收条件?”

它的输入应尽量接近可验证事实,而不是只接收模型的一段解释。例如,对于“完成退款”这个任务,Verifier 不应仅检查 Agent 是否输出了“退款成功”,而应查询支付系统的真实状态:

{
  "order_id": "O1001",
  "refund_status": "SUCCEEDED",
  "refund_amount": 12000,
  "currency": "CNY",
  "provider_ref": "RF20260901001"
}

然后根据规范判断:

order_id 匹配
refund_status == SUCCEEDED
refund_amount == expected_amount
currency == CNY
provider_ref 存在

Verifier 更接近验收测试、后置条件检查或安全门。它的输出应该是:

{
  "passed": true,
  "checks": {
    "order_match": true,
    "status_succeeded": true,
    "amount_match": true,
    "currency_match": true,
    "provider_reference_present": true
  }
}

关键区别是:

组件 主要问题 输出含义 是否应直接决定放行
Critic 哪里可能有问题 反馈、缺陷、修正建议 通常不单独决定
Verifier 是否满足验收条件 通过、失败、未知 可以作为放行门
规则检查 是否违反硬约束 允许、拒绝、暂停 通常直接决定
测试执行器 行为是否符合预期 测试结果、日志、差异 可以作为证据
人工审核 人是否接受该风险 批准、拒绝、补充信息 可作为最终授权

Critic 发现问题,Verifier 判断条件,规则检查执行硬约束。


二、为什么“反思”不能替代“验证”

设任务目标为 GG,Agent 在时刻 tt 的内部状态为 sts_t,调用工具或执行动作 ata_t,环境返回观察结果 ot+1o_{t+1}

状态转移可以表示为:

st+1=T(st,at,ot+1)s_{t+1} = T(s_t, a_t, o_{t+1})

其中:

  • sts_t:当前任务状态,包括已知事实、未完成子目标、历史轨迹和风险状态;
  • ata_t:下一步行动,例如搜索、查询、写文件或提交变更;
  • ot+1o_{t+1}:工具或环境返回的结果;
  • TT:状态更新过程。

Agent 的计划器根据状态产生动作:

atπ(st,G)a_t \sim \pi(s_t, G)

其中 π\pi 是策略,GG 是目标。

验证器则判断当前状态是否满足完成条件:

V(st,G){pass,fail,unknown}V(s_t, G) \in \{\text{pass}, \text{fail}, \text{unknown}\}

这里必须允许 unknown。如果证据不足,不能把“无法判断”当成“通过”。

反思器可以产生诊断:

C(st,G)dtC(s_t, G) \rightarrow d_t

其中 dtd_t 可能包含缺陷、风险、原因和建议动作。但 CC 本身不等价于 VV

反例:模型自评的循环确认

用户要求:

“确认订单 O1001 已退款,并给出退款流水号。”

Agent 第一次调用支付工具失败,工具返回:

{
  "status": "TIMEOUT",
  "message": "request timed out"
}

模型随后生成:

“支付系统可能已处理成功,但响应超时。根据上下文判断退款应该已经完成。”

如果再让一个 Critic 评审这段话,它可能输出:

“该回答表达了不确定性,建议补充说明。”

即使 Critic 说“没有明显问题”,也不能证明退款成功。因为没有新的环境事实,信息集没有增加:

It+1=ItI_{t+1} = I_t

重新生成解释不会改变支付系统的真实状态,也不会增加退款流水号。此时正确动作应是:

  1. 使用幂等键重新查询退款状态;
  2. 若查询仍失败,进入 unknown
  3. 不向用户声称完成;
  4. 必要时转人工或等待异步回调。

反思可以指出“超时导致状态不确定”,但只有独立查询或可信事件记录才能验证结果。


三、四层检查模型:从硬约束到语义评审

生产 Agent 不应只配置一个“检查器”。更稳定的做法是将检查分成四层。

1. 结构检查

结构检查判断输出是否满足机器可解析的格式。

例如:

  • JSON 是否合法;
  • 必填字段是否存在;
  • 字段类型是否正确;
  • 枚举值是否合法;
  • 数组长度是否满足要求;
  • 工具参数是否符合 Schema。

结构检查通常不需要 LLM。

from dataclasses import dataclass
from typing import Any


@dataclass
class CheckResult:
    passed: bool
    reason: str


def check_refund_request(data: Any) -> CheckResult:
    if not isinstance(data, dict):
        return CheckResult(False, "必须是 JSON 对象")

    required = {"order_id", "amount", "currency", "idempotency_key"}
    missing = required - data.keys()
    if missing:
        return CheckResult(False, f"缺少字段: {sorted(missing)}")

    if not isinstance(data["order_id"], str) or not data["order_id"]:
        return CheckResult(False, "order_id 必须是非空字符串")

    if not isinstance(data["amount"], int) or data["amount"] <= 0:
        return CheckResult(False, "amount 必须是正整数,单位为分")

    if data["currency"] != "CNY":
        return CheckResult(False, "当前流程只允许 CNY")

    return CheckResult(True, "结构检查通过")

这里金额使用整数分,而不是浮点数。若直接使用 float,可能出现:

0.1 + 0.2 == 0.30000000000000004

这不是 Agent 特有问题,但 Agent 会把模型生成的金额、工具参数和业务状态串联起来,因而更容易把普通的数据表示错误扩大成实际扣款或退款错误。

2. 规则检查

规则检查判断是否违反明确业务约束。

例如:

def check_refund_policy(
    *,
    order_status: str,
    paid_amount: int,
    requested_amount: int,
    already_refunded: int,
    user_can_refund: bool,
) -> CheckResult:
    if not user_can_refund:
        return CheckResult(False, "用户无退款权限")

    if order_status not in {"PAID", "PARTIALLY_REFUNDED"}:
        return CheckResult(False, f"订单状态 {order_status} 不允许退款")

    remaining = paid_amount - already_refunded
    if requested_amount > remaining:
        return CheckResult(
            False,
            f"退款金额超过可退金额: requested={requested_amount}, remaining={remaining}"
        )

    return CheckResult(True, "业务规则检查通过")

规则检查的特点是:

  • 条件明确;
  • 结果可重复;
  • 不依赖模型的措辞;
  • 失败时通常不能由模型自行“解释通过”。

例如,订单实际已支付 100 元、已退款 80 元,本次请求退款 30 元。无论模型如何评价客户意图,规则结果都应是失败:

30>10080=2030 > 100 - 80 = 20

模型可以选择“请求人工处理”或“改为退款 20 元并询问用户”,但不能直接绕过规则。

3. 事实验证

事实验证判断系统状态是否已经达到目标。

假设目标是:

G={订单为已退款, 退款金额=12000, 币种=CNY}G = \{\text{订单为已退款},\ \text{退款金额}=12000,\ \text{币种}=\text{CNY}\}

则 Verifier 执行:

def verify_refund(
    *,
    order_id: str,
    expected_amount: int,
    payment_record: dict,
) -> CheckResult:
    if payment_record.get("order_id") != order_id:
        return CheckResult(False, "支付记录中的订单号不匹配")

    if payment_record.get("status") != "SUCCEEDED":
        return CheckResult(
            False,
            f"退款状态不是 SUCCEEDED: {payment_record.get('status')}"
        )

    if payment_record.get("amount") != expected_amount:
        return CheckResult(False, "退款金额不匹配")

    if payment_record.get("currency") != "CNY":
        return CheckResult(False, "退款币种不匹配")

    if not payment_record.get("provider_ref"):
        return CheckResult(False, "缺少支付方流水号")

    return CheckResult(True, "退款结果验证通过")

这里的 payment_record 应来自支付系统或可信事件存储,而不是来自 Agent 自己刚刚生成的文字。

4. 语义 Critic

语义 Critic 适合处理难以完全形式化的问题:

  • 方案是否遗漏重要风险;
  • 解释是否与证据矛盾;
  • 代码修改是否可能破坏原有行为;
  • 搜索结果是否真的支持结论;
  • 文档是否满足隐含的受众和范围要求。

但语义 Critic 的输出应被当作证据之一,而不是无条件的真值来源。

例如,代码 Agent 修改了数据库迁移文件,可以并行运行:

  • SQL 语法检查;
  • 单元测试;
  • 数据库迁移演练;
  • LLM Critic 代码审查;
  • 权限和破坏性操作规则检查。

Anthropic 将并行化描述为把独立子任务同时执行并聚合结果,也将 evaluator-optimizer 描述为“生成—评估—反馈—再生成”的循环;这些模式适用于把不同检查维度分开,而不是让同一个模型在一次调用中既生成又自我放行。(anthropic.com)


四、反思与验证应该检查什么

Agent 轨迹不能只记录最终文本。每个步骤都应形成可检查的事件。

{
  "run_id": "run_123",
  "step": 4,
  "state_before": {
    "goal": "查询订单并执行退款",
    "known_order_id": "O1001",
    "refund_status": "UNKNOWN"
  },
  "action": {
    "kind": "tool_call",
    "tool": "get_refund_status",
    "arguments": {
      "order_id": "O1001",
      "idempotency_key": "refund-O1001"
    }
  },
  "observation": {
    "status": "SUCCEEDED",
    "amount": 12000,
    "currency": "CNY",
    "provider_ref": "RF20260901001"
  },
  "checks": [
    {
      "name": "schema",
      "result": "pass"
    },
    {
      "name": "refund_postcondition",
      "result": "pass"
    }
  ],
  "state_after": {
    "refund_status": "SUCCEEDED",
    "refund_amount": 12000
  }
}

每一步至少需要区分以下对象:

1. 目标状态

目标状态是任务完成时必须满足的条件,而不是一句自然语言描述。

例如:

任务:把文件 report.csv 上传到对象存储,并返回可访问地址。

完成条件:
1. 文件存在;
2. 文件内容校验和与本地文件一致;
3. 对象存储返回成功;
4. 对象键为 reports/report.csv;
5. 访问权限符合任务要求;
6. 返回的 URL 能通过 HEAD 请求获得 200。

如果只把目标写成“上传文件”,Agent 可能在本地生成了上传请求就停止,即使远端对象不存在。

2. 前置条件

前置条件决定动作是否允许开始。

上传动作前:
- 文件路径已确认;
- 文件大小小于限制;
- 用户具有目标桶的写权限;
- 对象键通过路径安全检查;
- 未检测到同名对象,或已获得覆盖授权。

3. 后置条件

后置条件决定动作是否成功。

上传动作后:
- 服务端返回成功;
- 服务端对象大小等于本地大小;
- 服务端校验和一致;
- 读取或 HEAD 验证成功。

4. 不变量

不变量是执行过程中必须始终保持的条件。

例如支付流程:

- 不得在未确认用户身份时使用账户余额;
- 不得发送金额为负数或零的支付请求;
- 同一业务幂等键不得对应两个不同金额;
- 失败重试不得产生重复扣款。

不变量通常应放在规则检查层,而不是依赖 Critic 在事后提醒。


五、一个完整的 Agent 验证循环

下面给出一个框架无关的状态机。它适合解释 Responses API 自行控制循环的方式,也适合作为 Agents SDK 等运行时的内部抽象。OpenAI 文档明确区分了两种模式:Responses API 由应用接收函数调用、执行工具并再次调用模型;Agents SDK 则由 Runner 管理工具循环、切换 Agent,并在完成或等待审批时停止。(developers.openai.com)

flowchart TD
    A[接收任务] --> B[解析目标与验收条件]
    B --> C[初始化状态与预算]
    C --> D[计划下一步]
    D --> E{动作前规则检查}
    E -->|拒绝| X[安全拒绝或请求人工]
    E -->|允许| F[执行工具或生成候选结果]
    F --> G[记录观察结果]
    G --> H[结构检查与事实验证]
    H -->|通过| I{完成条件满足?}
    H -->|失败| J[Critic 诊断]
    H -->|未知| K[补充证据或人工介入]
    J --> L[更新状态]
    L --> M{是否仍有预算且可修正?}
    M -->|是| D
    M -->|否| N[失败终止]
    I -->|是| O[输出结果]
    I -->|否| D
    K --> M

关键路径如下:

  1. 接收任务:不要立即调用工具,先确定任务边界;
  2. 解析验收条件:把自然语言目标转成可检查的条件;
  3. 动作前检查:阻止危险、越权或参数错误的动作;
  4. 执行动作:工具调用可能成功、失败、超时或返回部分结果;
  5. 记录观察:工具结果必须进入轨迹;
  6. 执行验证:根据后置条件判断 pass/fail/unknown
  7. 反思修正:失败时定位原因,而不是只要求模型重新回答;
  8. 判断停止:成功、失败、未知、预算耗尽或需人工时都可以终止。

六、反思的三个粒度

1. 步骤级反思

步骤级反思只检查最近一次动作。

输入:

动作:调用 get_order(order_id="O1001")
观察:返回订单 O1002

诊断:

工具返回的订单 ID 与请求参数不一致,不能将其用于当前任务。

步骤级反思响应快,适合每一步执行后运行。它的缺点是看不到全局目标,可能无法发现“每一步都看似正确,但整体漏掉了关键子目标”。

2. 阶段级反思

阶段级反思检查一个子任务是否完成。

例如:

阶段:准备退款
要求:
- 已确认订单属于当前用户;
- 已确认订单状态可退款;
- 已确认可退金额;
- 已取得用户对退款金额的授权。

即使单步查询都成功,如果没有用户授权,阶段级 Verifier 仍应返回失败或等待。

3. 任务级反思

任务级反思检查整个轨迹是否达成目标,并判断是否应该继续。

例如,研究 Agent 的任务是:

“比较三种数据库方案,并给出有证据支持的建议。”

任务级检查不只是看最后文字是否流畅,而要检查:

  • 三种方案是否都覆盖;
  • 每个关键结论是否有来源;
  • 来源是否真的支持结论;
  • 是否混淆了事实和推测;
  • 是否说明了适用边界;
  • 是否给出基于用户约束的选择理由。

任务级 Critic 适合发现遗漏,任务级 Verifier 则应依据覆盖率、证据绑定和格式约束进行验收。


七、Critic 的反馈必须能改变状态

低质量的 Critic 通常只输出:

“回答还可以更严谨。”

这种反馈不能驱动 Agent。好的 Critic 必须把问题映射到状态中的可修正项。

{
  "issue_id": "evidence-003",
  "category": "unsupported_claim",
  "severity": "high",
  "claim": "方案 A 在高并发场景下成本最低",
  "missing": "没有成本模型、请求规模和计费依据",
  "repair": {
    "type": "collect_evidence",
    "queries": [
      "方案 A 计费规则",
      "方案 B 计费规则",
      "目标请求规模下的成本估算"
    ]
  },
  "blocks_completion": true
}

其中:

  • category 决定使用什么修复策略;
  • severity 决定是否阻塞完成;
  • repair.type 决定下一步动作;
  • blocks_completion 决定停止门是否允许通过。

可以把修复策略写成有限集合:

missing_information -> 查询或询问用户
bad_parameter       -> 重建参数并重新校验
tool_failure        -> 重试、切换工具或降级
contradiction       -> 回溯并重新确认事实
policy_violation    -> 拒绝或人工审批
insufficient_budget -> 提前终止并报告未完成原因

如果 Critic 只提供自然语言建议而没有结构化动作类型,运行时就无法稳定决定下一步,最终会退化成“模型自己读自己的批评”。


八、Verifier 必须有独立证据

验证的强度取决于证据与被验证对象之间的独立性。

可以把证据分成三类:

1. 环境事实

由外部系统产生:

  • 数据库查询结果;
  • API 返回状态;
  • 文件哈希;
  • 测试执行结果;
  • 编译器输出;
  • 浏览器实际页面状态;
  • 支付服务回调。

这类证据通常最有价值。

2. 派生事实

由程序根据环境事实计算得到:

本地文件 SHA-256 == 远端对象 SHA-256

派生事实的可信度取决于计算逻辑是否正确、输入是否完整。

3. 模型陈述

由模型生成:

“根据这些结果,退款应该已经完成。”

模型陈述可以帮助解释证据,但不能在高风险场景中替代环境事实。

反例:同源证据

以下流程看似有两层校验:

  1. Generator 生成答案;
  2. Critic 读取答案并判断“是否正确”。

但如果 Critic 没有原始文档、工具结果或数据库状态,它看到的仍然只是 Generator 的输出。两者共享同一个错误来源,不能形成真正独立的验证。

更可靠的流程是:

原始数据/工具结果
        ├── Generator 生成答案
        └── Verifier 根据原始数据检查答案

而不是:

Generator 生成答案
        └── Critic 只审查答案文本

在实际系统中,Critic 仍然有价值,但它更适合发现解释层和推理层的问题;Verifier 负责把答案重新绑定到原始证据。


九、停止边界:什么时候必须结束

**停止边界(stopping boundary)**是 Agent 不能继续自主行动,必须结束、暂停、失败或转人工的条件集合。

停止不是只有一种状态。至少应区分:

SUCCEEDED       已满足完成条件
FAILED          已确认无法完成或违反约束
UNKNOWN         证据不足,无法确认成功或失败
NEEDS_APPROVAL  需要人工授权
BUDGET_EXHAUSTED预算耗尽
CANCELLED       被用户或系统取消

1. 成功停止

成功停止必须满足后置条件:

V(st,G)=passV(s_t, G) = \text{pass}

并且没有未处理的高严重性问题:

cCt,c.blocks_completion=false\forall c \in C_t,\quad c.\text{blocks\_completion} = \text{false}

例如,“发送邮件”不能仅以邮件 API 返回 202 Accepted 为成功,除非业务定义就是“请求已被邮件服务接受”。如果任务要求“邮件已送达”,则还需要邮件服务的投递状态或其他可接受证据。

2. 失败停止

失败停止适用于:

  • 明确违反业务规则;
  • 权限不足;
  • 目标不存在;
  • 工具明确返回不可恢复错误;
  • 修复次数已达到上限;
  • 继续执行会增加风险。

失败停止不代表系统没有任何输出。应报告:

- 已完成的部分;
- 未完成的目标;
- 失败原因;
- 已尝试的动作;
- 是否可能安全重试;
- 需要用户提供什么信息。

3. 未知停止

UNKNOWN 是最容易被错误处理的状态。

例如:

动作:创建退款
结果:客户端超时
支付系统:无法查询

此时不能选择:

超时 => 失败 => 立即重试创建

因为第一次请求可能已经成功,立即重试可能造成重复退款。

更安全的流程是:

超时
  -> 按幂等键查询原操作
  -> 查询成功且状态 SUCCEEDED:成功停止
  -> 查询成功且状态 FAILED:可按策略重试
  -> 仍无法查询:UNKNOWN,暂停或人工介入

4. 预算停止

Agent 至少应有以下预算:

max_steps          最大动作次数
max_retries        单个动作最大重试次数
max_wall_time      最大运行时间
max_tokens         最大模型调用预算
max_tool_cost      工具调用成本上限
max_same_state     同一状态重复次数

这些预算不是为了让 Agent “尽量跑满”,而是为了给不可预测的循环设置硬边界。Anthropic 明确建议在 Agent 中使用最大迭代次数等停止条件,并指出自主性会带来更高成本和错误累积风险。(anthropic.com)


十、如何检测偏航、循环和无效进展

仅限制总步骤数还不够。Agent 可能在预算内重复做无效动作。

1. 状态哈希

把与任务相关的状态规范化后计算哈希:

import hashlib
import json


def state_hash(state: dict) -> str:
    payload = json.dumps(
        state,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()

如果连续多次出现相同状态:

s_4 == s_5 == s_6

但 Agent 仍然执行相同动作,可以判定为循环风险。

2. 动作重复检测

def action_signature(action: dict) -> str:
    return json.dumps(
        action,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )


def is_repeating(history: list[dict], action: dict, window: int = 3) -> bool:
    signature = action_signature(action)
    recent = history[-window:]
    return sum(action_signature(item) == signature for item in recent) >= 2

但“重复动作”不一定错误。例如轮询异步任务可能需要多次调用。因此必须结合:

  • 状态是否发生变化;
  • 轮询间隔是否符合要求;
  • 服务端是否返回 RUNNING
  • 是否已达到轮询上限。

3. 进展函数

定义一个单调进展指标:

P(st)=已完成且验证通过的目标条件数量P(s_t) = \text{已完成且验证通过的目标条件数量}

如果:

P(st+1)P(st)P(s_{t+1}) \le P(s_t)

持续多次成立,并且没有获得新证据,那么继续执行通常没有价值。

例如一个包含五个子目标的任务:

初始 P = 0
查询用户身份并验证 P = 1
查询订单并验证 P = 2
检查退款资格并验证 P = 3
退款请求超时,P = 3
重新生成相同退款请求,P = 3
再次重新生成相同退款请求,P = 3

后两次没有增加信息,也没有改变状态。系统应进入未知或人工审批,而不是继续循环。

4. 偏航检测

偏航不一定是工具失败,也可能是目标逐渐改变。

原目标:

比较方案 A、B、C 的部署成本

轨迹却变成:

搜索方案 A 的历史背景
阅读方案 A 的社区讨论
比较方案 A 和 D 的功能

每一步都有局部合理性,但与验收条件无关。可以让 Critic 定期输出:

{
  "goal_alignment": 0.31,
  "completed_requirements": ["A 的成本来源"],
  "missing_requirements": ["B 的成本", "C 的成本", "统一规模假设"],
  "off_track_actions": ["历史背景", "社区讨论"],
  "recommendation": "停止扩展背景,转向统一成本口径"
}

偏航检测的核心不是判断文本“像不像相关内容”,而是把当前动作与未完成验收条件关联起来。


十一、重试不是反思,反思也不是重试

工具失败后,Agent 常见的错误是无条件重试。

应先区分错误类型:

错误 示例 处理
参数错误 缺少 order_id 修正参数后重试
鉴权错误 401、权限不足 不重复重试,刷新凭证或人工处理
限流 429 按策略等待并限制次数
暂时性网络错误 连接断开 指数退避重试
超时且操作有副作用 创建订单超时 先查询幂等状态
明确业务拒绝 余额不足 不重试同一请求
响应格式错误 无法解析 JSON 检查工具契约或切换解析路径

一个安全的重试函数应把“是否可以重试”作为显式决策:

RETRYABLE = {
    "TIMEOUT_READ_ONLY",
    "NETWORK_TRANSIENT",
    "RATE_LIMITED",
}


def retry_decision(error_code: str, *, has_side_effect: bool) -> str:
    if error_code == "TIMEOUT" and has_side_effect:
        return "QUERY_IDEMPOTENT_RESULT_FIRST"

    if error_code in RETRYABLE:
        return "RETRY_WITH_BACKOFF"

    return "STOP"

反思用于解释失败原因,重试策略用于执行恢复动作。将二者混在一个提示词里,容易出现模型把不可重试错误描述成“再试一次可能成功”。


十二、一个可运行的最小验证循环

下面的代码不依赖特定 Agent SDK,使用一个虚构的订单服务模拟“查询—执行—验证—停止”。它展示的是运行时边界,而不是某个厂商 API 的固定接口。

from dataclasses import dataclass, field
from enum import Enum
from typing import Any


class RunStatus(str, Enum):
    RUNNING = "RUNNING"
    SUCCEEDED = "SUCCEEDED"
    FAILED = "FAILED"
    UNKNOWN = "UNKNOWN"
    NEEDS_APPROVAL = "NEEDS_APPROVAL"


@dataclass
class AgentState:
    order_id: str
    expected_refund: int
    refund_status: str = "UNKNOWN"
    provider_ref: str | None = None
    steps: int = 0
    retries: int = 0
    findings: list[str] = field(default_factory=list)


class MockPaymentService:
    def __init__(self):
        self.refunds: dict[str, dict[str, Any]] = {}

    def create_refund(
        self,
        order_id: str,
        amount: int,
        idempotency_key: str,
    ) -> dict[str, Any]:
        if idempotency_key in self.refunds:
            return self.refunds[idempotency_key]

        result = {
            "order_id": order_id,
            "status": "SUCCEEDED",
            "amount": amount,
            "currency": "CNY",
            "provider_ref": f"RF-{order_id}",
        }
        self.refunds[idempotency_key] = result
        return result

    def get_refund_status(self, idempotency_key: str) -> dict[str, Any] | None:
        return self.refunds.get(idempotency_key)


def verify_postcondition(
    state: AgentState,
    record: dict[str, Any] | None,
) -> tuple[bool, str]:
    if record is None:
        return False, "没有找到退款记录"

    if record.get("order_id") != state.order_id:
        return False, "订单号不匹配"

    if record.get("status") != "SUCCEEDED":
        return False, f"退款未成功: {record.get('status')}"

    if record.get("amount") != state.expected_refund:
        return False, "退款金额不匹配"

    if record.get("currency") != "CNY":
        return False, "币种不匹配"

    if not record.get("provider_ref"):
        return False, "缺少支付方流水号"

    return True, "后置条件全部满足"


def run_refund_agent(
    service: MockPaymentService,
    *,
    order_id: str,
    amount: int,
    max_steps: int = 5,
) -> tuple[RunStatus, AgentState]:
    state = AgentState(
        order_id=order_id,
        expected_refund=amount,
    )

    if amount <= 0:
        state.findings.append("退款金额必须为正整数")
        return RunStatus.FAILED, state

    idempotency_key = f"refund:{order_id}"

    while state.steps < max_steps:
        state.steps += 1

        # 动作前规则检查
        if state.expected_refund > 100_000:
            state.findings.append("超过单次自动退款上限,需要人工审批")
            return RunStatus.NEEDS_APPROVAL, state

        # 先查询已有结果,避免超时后重复产生副作用
        existing = service.get_refund_status(idempotency_key)
        if existing is not None:
            ok, reason = verify_postcondition(state, existing)
            if ok:
                state.refund_status = "SUCCEEDED"
                state.provider_ref = existing["provider_ref"]
                return RunStatus.SUCCEEDED, state

            state.findings.append(reason)
            return RunStatus.FAILED, state

        # 执行动作
        result = service.create_refund(
            order_id=state.order_id,
            amount=state.expected_refund,
            idempotency_key=idempotency_key,
        )

        # 观察后的独立验证
        ok, reason = verify_postcondition(state, result)
        if ok:
            state.refund_status = "SUCCEEDED"
            state.provider_ref = result["provider_ref"]
            return RunStatus.SUCCEEDED, state

        state.findings.append(reason)

        # 没有可修复证据时停止,而不是盲目循环
        if state.steps >= 2:
            return RunStatus.UNKNOWN, state

    state.findings.append("达到最大步骤数")
    return RunStatus.UNKNOWN, state


if __name__ == "__main__":
    service = MockPaymentService()
    status, state = run_refund_agent(
        service,
        order_id="O1001",
        amount=12000,
    )

    print(status.value)
    print(state)

预期输出类似:

SUCCEEDED
AgentState(
    order_id='O1001',
    expected_refund=12000,
    refund_status='SUCCEEDED',
    provider_ref='RF-O1001',
    steps=1,
    retries=0,
    findings=[]
)

这个示例的关键不是退款逻辑,而是执行顺序:

  1. 先检查参数;
  2. 再检查高风险规则;
  3. 先查询幂等结果;
  4. 没有结果时才执行有副作用的操作;
  5. 执行后依据返回记录验证后置条件;
  6. 通过才返回成功;
  7. 无法确认时返回 UNKNOWN,而不是伪造失败或成功。

真实系统还需要处理网络超时、服务端异步状态、幂等键持久化和人工审批恢复。OpenAI 的 Agents SDK 文档将 guardrails、human review、结果状态和可恢复运行状态列为 Agent 运行时能力,说明验证不仅是提示词问题,也属于运行生命周期和状态管理的一部分。(developers.openai.com)


十三、并发验证:什么时候可以并行,什么时候不能

验证任务可以并行,但前提是检查之间没有冲突,并且它们读取的是同一个稳定快照。

可以并行的情况

例如代码变更评审:

同一版本代码
 ├── 编译检查
 ├── 单元测试
 ├── 安全扫描
 ├── 依赖许可证检查
 └── LLM Critic

这些任务主要是读操作,结果可以最后聚合:

{
  "compile": "pass",
  "unit_test": "pass",
  "security_scan": "fail",
  "license_check": "pass",
  "critic": "warning"
}

聚合器应按规则决定最终状态:

安全扫描失败 => FAILED
单元测试失败 => FAILED
Critic 警告   => 可修复但不一定阻塞

不应直接并行的情况

如果多个动作会修改同一资源,就必须考虑竞态:

Agent A:退款 100 元
Agent B:退款 100 元

即使两个 Agent 都通过了各自的动作前检查,也可能同时看到“可退余额为 100 元”,最终产生重复操作。

解决方式包括:

  • 数据库事务;
  • 分布式锁;
  • 幂等键;
  • 版本号或乐观锁;
  • 服务端原子状态迁移;
  • 让验证与提交在同一可信边界内完成。

形式上,若资源版本为 vv,动作提交时要求:

vcurrent=vobservedv_{\text{current}} = v_{\text{observed}}

提交成功后版本递增:

vnew=vobserved+1v_{\text{new}} = v_{\text{observed}} + 1

如果版本不一致,说明观察已过期,Agent 必须重新读取,而不是继续使用旧判断。

并发 Critic 的边界

多个 Critic 投票不等于多个独立事实源。如果所有 Critic 都只读取同一份错误答案,投票只能增加“共识感”,不能增加真实性。

并发适合增加视角,不适合替代环境验证。


十四、常见误解及其失败表现

误解一:让模型“再检查一遍”就是 Verifier

失败表现:

第一次:退款成功。
第二次:我检查后确认退款成功。

第二次没有访问支付状态,也没有检查流水号,属于自我复述。

修正方式:Verifier 必须绑定可追溯证据和明确后置条件。

误解二:Critic 说有问题,就一定要重新规划

失败表现:

  • Critic 提醒“可以进一步优化”;
  • Agent 不断重写已经通过的结果;
  • 步骤和成本持续增加;
  • 结果没有可测量改善。

修正方式:为问题设置严重级别和阻塞属性:

info     不阻塞
warning  可选修复
error    必须修复
critical 必须停止或人工处理

误解三:验证通过就可以继续执行所有动作

验证通常只对某个对象、某个阶段或某个版本成立。

例如:

已验证订单 O1001 可退款

并不代表:

可以直接退款任意金额

验证范围必须包含:

  • 资源标识;
  • 版本或时间;
  • 金额和币种;
  • 用户和权限;
  • 证据来源;
  • 有效期。

误解四:最大步数越大,任务成功率越高

更大的步数预算有时可以提高完成率,但也会增加:

  • 错误累积;
  • 重复调用;
  • 成本;
  • 延迟;
  • 对外部系统的副作用;
  • 偏航后无法回收的风险。

Agent 的动作数量不是质量指标。更重要的是每一步是否产生新证据、是否推进已验证目标。

误解五:所有失败都应该自动修复

某些失败是不可修复的:

用户没有权限
付款超过风控上限
资源已永久删除
策略明确禁止自动执行

此时继续反思只会制造更多解释。正确的结果是明确失败或请求人工,而不是让模型寻找绕过规则的路径。


十五、生产诊断:从终态反查轨迹

当用户反馈“Agent 说完成了,但实际上没有完成”,应按以下顺序诊断:

1. 检查成功条件是否存在

如果系统没有明确写出“什么叫完成”,就无法判断是 Agent 错了,还是产品定义含糊。

2. 检查成功是否由模型文本决定

危险代码通常类似:

if "成功" in model_output:
    return SUCCESS

成功状态必须来自结构化验证,而不是关键词。

3. 检查工具结果是否被保留

轨迹中应能看到:

tool_call
tool_result
verification
state_transition
final_output

如果只有最终回答,没有工具原始结果,就无法判断 Agent 是否错误理解了观察。

4. 检查验证器使用的输入

常见错误包括:

  • Verifier 读取了模型总结,而不是原始工具结果;
  • 查询的是缓存旧数据;
  • 查询资源 ID 与动作资源 ID 不一致;
  • 忽略了币种、版本、租户或权限;
  • PENDING 当作 SUCCEEDED
  • 把空结果当作“没有问题”。

5. 检查 unknown 是否被错误转换

建议在日志中保留三值结果:

PASS
FAIL
UNKNOWN

不要在适配层直接把 UNKNOWN 映射成 FAILPASS。不同业务对未知状态的处理不同:

  • 只读搜索:可以返回部分结果;
  • 发送邮件:可能允许报告“已提交”;
  • 扣款退款:通常需要暂停或人工;
  • 文件删除:通常不能在状态未知时重复执行。

6. 检查是否发生了状态竞态

若验证在另一个服务或线程中执行,需要记录:

observed_version
verified_version
committed_version

否则可能出现:

读取可退余额 = 100
另一个请求已退款 80
Agent 仍按旧结果退款 100

十六、如何选择反思和验证的放置位置

并非每一步都需要完整的 LLM Critic。验证应尽量靠近被验证的动作。

工具调用前

适合放:

  • 参数 Schema;
  • 权限检查;
  • 业务规则;
  • 风险等级;
  • 人工审批门。

工具调用后

适合放:

  • 返回结构检查;
  • 状态码处理;
  • 后置条件;
  • 幂等状态查询;
  • 数据一致性检查。

阶段结束时

适合放:

  • 子目标覆盖检查;
  • 缺失信息诊断;
  • 证据完整性检查;
  • 是否进入下一阶段。

最终输出前

适合放:

  • 输出格式;
  • 敏感信息检查;
  • 证据绑定;
  • 用户问题是否全部回答;
  • 是否仍包含未经确认的推断。

Anthropic 建议优先使用能够清晰拆分的工作流,并在中间步骤加入程序化 gate;只有当任务步骤难以预先确定、需要模型动态决定行动时,才更适合使用自主 Agent。(anthropic.com) 这意味着验证位置也应遵循同一原则:能写成确定规则的检查,不要交给自由生成的 Critic。


十七、一个实用的完成判定公式

可以把一次运行的完成条件写成:

Complete(st)=G(st)R(st)E(st)B(st)\text{Complete}(s_t) = G(s_t) \land R(s_t) \land E(s_t) \land B(s_t)

其中:

  • G(st)G(s_t):目标条件全部满足;
  • R(st)R(s_t):没有违反硬规则;
  • E(st)E(s_t):关键结论有足够证据;
  • B(st)B(s_t):没有超出预算、权限和风险边界。

展开为:

G(st)=i=1ngi(st)G(s_t) = \bigwedge_{i=1}^{n} g_i(s_t)

表示 nn 个目标条件必须全部成立。

R(st)=¬j=1mrj(st)R(s_t) = \neg \bigvee_{j=1}^{m} r_j(s_t)

表示 mm 个禁止规则中不能有任何一条成立。

E(st)=k=1qek(st)E(s_t) = \bigwedge_{k=1}^{q} e_k(s_t)

表示每个关键声明都必须绑定有效证据。

如果某个条件无法判断:

gi(st)=unknowng_i(s_t) = \text{unknown}

则整体不应直接判定为成功。系统需要根据业务策略选择:

补充查询
请求用户确认
暂停等待异步结果
转人工
报告未完成

这套形式化表达的价值在于,它迫使工程师回答:

  • 哪些条件是目标;
  • 哪些条件是禁止规则;
  • 哪些条件需要外部证据;
  • 哪些预算会阻止继续;
  • 哪些未知状态可以接受。

如果这些问题没有答案,Agent 的“完成”就只是模型生成的一种语气。


十八、反思与验证的工程边界

反思适合解决:

  • 任务是否偏航;
  • 计划哪里不完整;
  • 哪个假设需要重新检查;
  • 下一步应收集什么信息;
  • 失败是否可能恢复。

验证适合解决:

  • 结果是否满足明确条件;
  • 工具结果是否与请求一致;
  • 系统状态是否真正改变;
  • 代码、数据和输出是否通过测试;
  • 是否允许进入下一阶段。

规则检查适合解决:

  • 是否越权;
  • 是否超过金额、范围或次数限制;
  • 是否违反安全策略;
  • 是否允许调用某个工具;
  • 是否需要人工审批。

停止边界适合解决:

  • 已成功;
  • 已失败;
  • 状态未知;
  • 预算耗尽;
  • 发生循环;
  • 风险需要人工决策。

把这四类机制混成一个提示词,通常会得到一个“会解释、但不一定会停;会批评、但不一定会验证;会重试、但不一定知道副作用”的 Agent。

一个可靠的 Agent 应当让状态转换由证据驱动:

动作产生观察
观察更新状态
状态触发规则与验证
验证决定是否完成
Critic 只负责诊断可修复问题
预算与风险决定最终边界

最终,Agent 的成熟度不体现在它能否生成更长的反思文本,而体现在它是否能够明确地区分:

我认为完成了
系统证明完成了
系统无法确认是否完成
系统禁止继续完成

这四句话对应四种完全不同的运行结果。只有后面三种能够被状态、证据、规则和停止边界可靠地管理,Agent 才真正具备工程上的可控性。


系列导航与关联阅读

官方资料

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