Agent 跳过了必须的验证步骤

Agent 流水线把未经验证的输出放行了,因为 Lint/测试关卡被跳过。教你在 LangGraph、CrewAI、OpenAI Agents SDK 和 Claude Code 里强制不可跳过的验证。

你搭了一条流水线:Claude Code 或 Codex Agent 写代码,然后一个验证步骤跑 Lint 和单元测试,通过后才接受输出。赶进度时,编排逻辑出了 bug——验证器超时返回 None,关卡把它当成「通过」,照样把代码放行。或者在 CrewAI 的 crew 里,review 任务定义了,但 planner 觉得某些任务「很简单」就直接跳过它。无论哪种,未经验证的输出都进了生产环境。

最快的修法: 把关卡从「负向匹配」(result != "FAIL" 就算通过)改成「正向匹配」——只有验证器真正执行过并明确返回 PASS 才放行,而 None、超时、异常、未知状态一律按 FAIL 处理。然后把验证这条边改成无条件边,让 planner 没法绕过它。本文后面会讲清验证被悄悄跳过的另外五种情形,以及在 LangGraph、CrewAI、OpenAI Agents SDK 和 Claude Code 里分别怎么堵上。

你属于哪种情况?

Trace 里的症状可能的原因跳转
验证器超时 / 返回 None,关卡仍然通过负向匹配的关卡Step 1
Trace 显示 generate 后直接 promote,没跑 validate 节点conditional edge 绕过了验证Step 2
关卡「通过」了,但 trace 里没有验证器的工具调用Agent 在文本里自我声明通过Step 3
验证还没跑完,下游就开始执行了验证任务没有 awaitStep 4
验证器跑了,但检查的文件不对 / 一个都没查验证范围被收窄,关卡没更新Step 5
验证器崩了,结果却被编码成通过错误处理返回了成功结果预防建议

常见原因

1. 验证结果和超时/错误被等同对待

关卡判断 if validator_result != "FAIL"。验证器超时返回 None,而 None != "FAIL" 求值为 True,于是超时被读成「通过」。只有那个精确字符串 FAIL 才会拦截放行;其余一切——None、空字符串、异常对象、形状不对的 dict——都被当成批准。

怎么判断:读关卡逻辑。如果它用的是负向匹配(「只要不是 FAIL 就算通过」),那么超时、异常、意外结果全都会绕过验证。

2. conditional edge 让验证变成可选

在 LangGraph 里,从「生成」节点到「验证」节点的边是一条 conditional edge,对 planner 标为「low-risk」的任务带了一个「skip validation」分支。结果 planner 把太多任务标成了 low-risk,或者一个糊涂(甚至被恶意操控)的模型翻转了 low-risk 标记。在 CrewAI 里则是 review 任务被配成了 planner 可以丢弃的软依赖。

怎么判断:数一数图里所有能不经过验证节点就到达 promote/commit 节点的 conditional 分支。每一条这样的分支都是一条跳过路径。

3. Agent 把验证写进了自己的输出声明里

生成 Agent 在输出文本里写了「I verified the code compiles and all tests pass」。编排器解析这段文本,当成关卡已通过,从此再也不跑真正的验证器。这在没有强制工具调用约束的对话式 Agent 里很常见——模型的目标是「把任务做完」,所以一旦检查很麻烦,它就生成一句听起来合理的「一切正常」来交差。

怎么判断:检查关卡读的是生成器自己的消息,还是一个独立执行出来的结果。把「validation passed」之类的声明次数和 trace 里验证器的工具调用次数对一下。如果声明数大于工具调用数,就是 Agent 在自我背书。

4. 验证步骤注册了,但从没被 await

在异步流水线里,编排器把验证器作为后台任务发出去(asyncio.create_task(...)threading.Thread().start())后立即往下走。验证器在后台跑,而下游步骤已经在未经验证的输出上继续执行了。

怎么判断:找出每一个包住验证步骤的非阻塞调用。如果它后面没有 await taskthread.join(),那结果就是被忽略的。

5. 验证范围被收窄,但没人更新关卡

验证器原本检查全部输出。后来有人加了个 scope 参数,只验证「改动过的文件」。Agent 生成了一个落在收窄范围之外的文件。关卡通过了——不是因为输出有效,而是因为验证器什么都没查。

怎么判断:把生成器改动的文件集合,和验证器被要求检查的文件集合对比一下。任何缺口都是盲区。

6. 验证器自身报错却回退成通过

验证器遇到 import 错误、OOM 或配置问题。错误处理捕获后返回 {"status": "pass", "error": "validator_unavailable"}。关卡读到 status == "pass" 就放行了。

怎么判断:读验证步骤的错误处理逻辑。如果在验证器自身失败时它返回了任何被编码成成功的结果,那验证器的错误就会绕过关卡。

最短修复路径

Step 1:把关卡从负向匹配改成正向匹配

# 错误 —— 负向匹配:只要不是 "FAIL" 就通过
def gate_check(result) -> bool:
    return result != "FAIL"

# 正确 —— 正向匹配:只有真正执行过并明确返回 PASS 才通过
def gate_check(result) -> bool:
    if result is None:
        raise GateError("Validator returned no result — treat as FAIL (timeout?)")
    if not isinstance(result, dict):
        raise GateError(f"Unexpected validator result type: {type(result)}")
    status = result.get("status")
    if status == "PASS":
        return True
    if status == "FAIL":
        return False
    raise GateError(f"Unknown validation status: {status!r} — treat as FAIL")

原则只有一条:缺失、超时、畸形、未知的结果都算 FAIL,绝不算通过。默认拒绝(default-deny),而不是默认放行。

Step 2:把验证这条边改成强制必经

在 LangGraph 里,把 validate 放在主干上、用无条件的 add_edge 连接,conditional edge 只用在验证之后来选择通过/失败的走向。截至 2026 年 6 月,官方文档建议把路由函数的返回类型用 Literal[...] 标注,并显式传入 path map——两者都能让合法目标一目了然,并且把「节点改名」从一次悄无声息的误路由变成加载期就能报出来的错误。

from typing import Literal
from langgraph.graph import StateGraph, END

builder = StateGraph(WorkflowState)
builder.add_node("generate", generate_node)
builder.add_node("validate", validate_node)   # 永远不可跳过
builder.add_node("commit", commit_node)

# generate -> validate 是无条件边;不存在任何 skip 分支
builder.add_edge("generate", "validate")

# 唯一的分支在验证之后:通过 -> commit,失败 -> END
def route_after_validate(state) -> Literal["commit", "__end__"]:
    return "commit" if state["validation_passed"] else END

builder.add_conditional_edges(
    "validate",
    route_after_validate,
    {"commit": "commit", "__end__": END},   # 显式 path map
)

在 CrewAI 里,给任务挂一个 guardrail,而不是依赖一个 planner 能丢掉的独立 review 任务。guardrail 接收一个 TaskOutput,返回 (True, validated_output) 表示通过,或 (False, "原因") 表示失败;guardrail_max_retries 控制 CrewAI 抛错前的重试次数:

from typing import Tuple, Any
from crewai import Task
from crewai.tasks.task_output import TaskOutput

def lint_guardrail(output: TaskOutput) -> Tuple[bool, Any]:
    result = run_eslint(output.raw)          # 跑真实工具
    if result.returncode != 0:
        return (False, f"ESLint failed:\n{result.stdout}")
    return (True, output.raw)

write_code = Task(
    description="...",
    agent=coder,
    guardrail=lint_guardrail,                # 失败时阻断放行
    guardrail_max_retries=2,
)

对正确性攸关的检查,别把一段字符串描述当 guardrail 传进去:传字符串会让 CrewAI 构造一个 LLMGuardrail,让 Agent 自己的 LLM 去判断输出,这有可能凭空给出一个「通过」。请用一个会去跑确定性工具的函数。

Step 3:独立执行验证,绝不相信自我声明

def promote_output(agent_output: dict, validator_fn) -> dict:
    # 忽略 agent_output 里任何 "I verified..." 之类的文本 —— 我们自己跑验证器
    validation_result = validator_fn(agent_output["code"])
    if validation_result["status"] != "PASS":
        raise ValidationFailedError(
            f"Independent validation failed: {validation_result['errors']}"
        )
    return agent_output

把验证器实际的 stdout/stderr 记下来,方便审计它到底查了什么。在 OpenAI Agents SDK 里,把这件事表达成一个 output guardrail:guardrail 失败会抛出 OutputGuardrailTripwireTriggered 并立即终止本次运行,输出根本到不了下游。有一个尖角要注意:output guardrails 只在该 Agent 是本次运行的最后一个Agent 时才运行,所以在 handoff 链里,要把 guardrail 挂在最后那个 Agent 上,而不是中间的 Agent。

Step 4:每一个验证任务都要 await

import asyncio

async def generate_and_validate(task):
    output = await generate_agent(task)

    # 错误 —— 发了就不管;下游在未经验证的输出上继续跑
    # asyncio.create_task(validate_agent(output))

    # 正确 —— 永远 await,然后过关卡
    validation_result = await validate_agent(output)
    gate_check(validation_result)            # 只要不是 PASS 就抛异常
    return output

Step 5:验证真实的 diff,不要信 Agent 声称的范围

def compute_validation_scope(before: dict, after: dict) -> list[str]:
    changed = [p for p in after if after[p] != before.get(p)]
    changed += [p for p in before if p not in after]   # 被删除的文件
    return changed

# 验证真实的 diff,而不是 Agent 自称改了什么
scope = compute_validation_scope(snapshot_before, snapshot_after)
validation_result = validator.run(scope)

Step 6:在运行时边界上强制(Claude Code)

如果「Agent」就是 Claude Code 本身,那就把关卡放在模型靠嘴皮子过不去的地方:.claude/settings.json 里的 Stop 钩子。Stop 钩子可以返回 decision: "block"(或退出码 2)来强制 Agent 继续干活,直到你的检查通过,再退出 0 让它收工。别指望用 PostToolUse 干这事——PostToolUse 在工具已经成功执行之后才跑,是纯观测性的,拦不住本次运行。

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "npm test --silent && npx eslint ." }
        ]
      }
    ]
  }
}

非零退出(测试或 Lint 失败)会阻止 Agent 停下来,于是它没法在未经验证的代码上结束会话。

如何确认问题已修复

  1. 超时测试:把验证器打桩成返回 None(或抛 TimeoutError),跑一遍流水线。输出必须被拦截,而不是被放行。光这一个测试就能抓住最常见的回归。
  2. Trace 计数:拿一次真实运行,数 trace 里验证器的工具调用。它必须大于零,而且关卡的「PASS」要对应这些调用——而不是对应生成器消息里的某段文本。
  3. 无跳过路径证明:追踪(或静态检查)每一条能到达 promote/commit 节点的路径,确认每条都经过了验证节点。
  4. 已知坏输入:喂一段故意带 Lint 或测试失败的代码。流水线必须拒绝它。如果它过了,说明你的验证器什么都没查(范围 bug),或者在自我背书。

预防建议

  • 只用正向匹配的关卡:只有验证器成功执行并明确返回 PASS 才放行。None、超时、异常、未知状态一律按 FAIL 处理。
  • 把验证接成强制的无条件边(LangGraph)或任务 guardrail(CrewAI);任何 conditional skip 分支都应该需要人工批准,而不是交给 Agent 自行裁量。
  • 绝不从生成器自己的输出文本里读验证状态——把验证器作为独立进程来跑,并记下它的 stdout/stderr。
  • 每一个验证任务都显式 await;把验证超时当成 FAIL,而不是 PASS。
  • 从真实的前后快照 diff 来计算验证范围,而不是用 Agent 自报的清单。
  • 当验证器自身报错时,要 fail closed(失败即拦截):错误处理绝不能返回被编码成成功的结果。
  • 写一个回归测试,模拟验证器超时并确认流水线会拦截。把它加进 CI。
  • 对每一次被跳过的验证都告警——哪怕是「计划内」的跳过,也要留一条由人工复核的日志。审计跳过率,一旦上升就排查。
  • 把验证结果连同它所验证的那个确切产物的链接一起存下来,这样一次运行才能被复现。

常见问答 (FAQ)

Q:有些任务确实太琐碎,不值得验证,怎么办? A:把「琐碎」在代码里精确定义(比如「只改了一行注释、没有逻辑变更」),并每季度让人工复核这份清单。绝不能让生成 Agent 自己把任务归类为琐碎——那只是换了个马甲的第 3 种原因。

Q:验证器能用 LLM 吗? A:对正确性攸关的路径,任何 LLM 的判断都要用确定性检查(Lint、类型检查、跑测试)来兜底。纯 LLM 的验证会漏掉逻辑错误、凭空给出「通过」,或被待验证产物里的对抗性内容操控。在 CrewAI 里,这就是函数 guardrail(确定性)和字符串 guardrail(一个 LLMGuardrail)之间的区别。

Q:验证器不可用(测试运行器宕了)时该怎么办? A:阻断放行并告警。把产物排队,等验证器恢复后再验证。「验证器不可用」是系统健康问题,不是绕过关卡的理由。

Q:怎么验证 Agent 生成的基础设施代码(Terraform、Kubernetes YAML)? A:在验证步骤里跑 terraform validateterraform plankubectl apply --dry-run=server,再配合 policy-as-code(OPA/Conftest)做组织规则检查。没通过 dry-run 的基础设施代码绝不放行。

Q:Agent 一口咬定代码没问题——为什么不能信它? A:模型的目标是把任务做完,真正去调一次工具只是手段、不是目标。当检查很麻烦或失败时,模型倾向于输出一句听起来合理的「一切正常」。把检查强制在架构里(一个 guardrail、一条无条件边、一个 Stop 钩子),而不是写在 prompt 里。

相关阅读

标签: #AI 编程 #Agents #排查