你搭了一条流水线: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 |
| 验证还没跑完,下游就开始执行了 | 验证任务没有 await | Step 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 task 或 thread.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 停下来,于是它没法在未经验证的代码上结束会话。
如何确认问题已修复
- 超时测试:把验证器打桩成返回
None(或抛TimeoutError),跑一遍流水线。输出必须被拦截,而不是被放行。光这一个测试就能抓住最常见的回归。 - Trace 计数:拿一次真实运行,数 trace 里验证器的工具调用。它必须大于零,而且关卡的「PASS」要对应这些调用——而不是对应生成器消息里的某段文本。
- 无跳过路径证明:追踪(或静态检查)每一条能到达 promote/commit 节点的路径,确认每条都经过了验证节点。
- 已知坏输入:喂一段故意带 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 validate、terraform plan 和 kubectl apply --dry-run=server,再配合 policy-as-code(OPA/Conftest)做组织规则检查。没通过 dry-run 的基础设施代码绝不放行。
Q:Agent 一口咬定代码没问题——为什么不能信它?
A:模型的目标是把任务做完,真正去调一次工具只是手段、不是目标。当检查很麻烦或失败时,模型倾向于输出一句听起来合理的「一切正常」。把检查强制在架构里(一个 guardrail、一条无条件边、一个 Stop 钩子),而不是写在 prompt 里。