Pre-flight 检查被 Agent 跳过

Agent 跳过了启动前应该执行的检查,结果在几秒就能发现的问题上跑了几小时才失败。本文教你把 pre-flight 做成任何代码路径都绕不过去的强制门禁。

你部署了一个用 LangGraph 写的基础设施开通 agent,它本应在调用任何 Terraform 之前先验证 AWS 凭证、确认目标环境不是生产环境、再核对资源配额。在一个特别长的任务里,编排器的 pre-flight 步骤超时了,流水线的兜底逻辑把它标成「skipped (non-fatal)」就继续往下走。Terraform agent 接着执行,把该区域的 EC2 配额耗尽,留下一个开通了一半的环境,要花好几个小时才能清理干净。Pre-flight 检查只有在被无条件强制执行时才有价值。任何允许它被跳过的代码路径,迟早都会被触发。

**最快的修复:**把 pre-flight 写成一个「失败就抛异常」的函数(包括超时、以及「这个任务类型没有定义检查项」也要抛),然后在任务入口和重试入口的第一行无条件调用它。只要 pre-flight 在结构上还能返回一个调用方可以忽略的结果,它就是建议性的,而不是强制性的。下面的内容都是在加固这一个核心思想:按任务类型注册检查、用环境变量守卫开关、在工作流图层面强制、以及写 CI 测试。

你属于哪一种情况?

抓一次真实「跳过了检查」的运行记录,把现象对应到下面六种原因之一。绝大多数「pre-flight 被跳过」的事故都属于其中之一。

运行记录里的现象可能的原因对应修复
Pre-flight 跑了,记录了失败,但任务仍继续执行结果被当成建议性(if passed: log()Step 1
Pre-flight 记录了超时,然后任务继续了except TimeoutError 里设了 passed=TrueStep 1
系统提示里有这个检查,但部分运行的工具调用记录里没有只写在 prompt 里,没在代码里强制Step 4
只在 staging 或某些机器上被跳过那里设了 FAST_MODE / SKIP_PREFLIGHT 开关Step 3
新任务类型完全没有任何检查注册表没为新类型更新Step 2
Pre-flight「跑了」,但失败是在执行开始后才记录的create_task 触发后没有 awaitStep 1

常见原因

1. Pre-flight 结果在编排逻辑里被当成可选的

编排代码写的是 preflight_result = run_preflight(task),但只用 if preflight_result.passed: log_success() 来消费结果。当 preflight_result.passedFalse、或者 preflight_resultNone(超时或出错)时,它从不阻断执行。任务总是继续往下走。

怎么判断:找到消费 pre-flight 结果的代码。如果不管结果如何(包括 None、出错、False)执行都照样继续,这个检查就是建议性的,而不是强制性的。

2. Pre-flight 超时,超时被当成「可以跳过」处理

Pre-flight 调用了一个很慢的外部校验服务。30 秒后检查超时,异常处理里捕获了 TimeoutError,并为了「稳妥起见、不阻塞用户」把 preflight_result = PreflightResult(passed=True, skipped=True)。这是反的:超时应该阻断,而不是放行。一个你没能跑完的检查,就是一个没有通过的检查。

怎么判断:找到 pre-flight 步骤的 except TimeoutError(或等价的)处理分支。如果它设了 passed=True 或直接落到执行流程,它就把超时变成了自动批准。

3. Pre-flight 写在 prompt 里,但代码里没有强制

系统提示写着「做任何事之前,永远先检查 X、Y、Z」。Agent 有时会跳过 Z 这一步,因为它判断「Z 跟这个任务无关」。基于 prompt 的 pre-flight 不可靠:模型总能给自己找到跳过某一步的理由。

怎么判断:把 prompt 里列出的 pre-flight 步骤,和每次运行开头实际发出的工具调用对比。如果 Z 出现在 prompt 里、却在部分运行的工具调用记录里缺席,那它就只在 prompt 里、没有被代码强制。

4. 某些模式下 pre-flight 被硬编码跳过

为了开发提速加了一个「快速模式」开关:if fast_mode: skip_preflight()。这个开关通过环境变量设置,有人带着 FAST_MODE=true 部署到了 staging,却没意识到 pre-flight 被关掉了。这个开关后来也一直没清理。

怎么判断:在代码库里搜 skip_preflightFAST_MODEbypass_checks 之类。如果有任何开关能关掉 pre-flight,检查它是否可能在非开发环境里被设上。

5. 新增了任务类型,但 pre-flight 没为它更新

原来的 pre-flight 检查的是「AWS 凭证是否有效」和「环境是否非生产」。流水线新增了一个任务类型(数据库迁移)。数据库任务需要额外的 pre-flight 检查(备份存在、迁移幂等、回滚方案就绪),但没人为新任务类型更新 pre-flight。

怎么判断:列出流水线处理的每一种任务类型,以及各自需要的 pre-flight 检查。任何没有完整 pre-flight 规格的任务类型,都是检查不足的。

6. Pre-flight 跑了,但 Agent 没等结果

在异步流水线里,asyncio.create_task(run_preflight(task)) 把检查丢到后台。主流程立刻继续往下执行,没有 await。Pre-flight 检查确实在跑、也可能会失败,但等到失败被记录下来时,执行早已经开始了。

怎么判断:看 pre-flight 是被 await 的(await run_preflight(task)),还是作为后台任务触发的。后台跑的 pre-flight 等于没有 pre-flight。

最短修复路径

Step 1:把 pre-flight 做成会阻断的门禁,而不是建议性的步骤

class PreflightError(Exception):
    """当某个必需的 pre-flight 检查失败时抛出。执行必须停止。"""

def require_preflight(task: dict) -> None:
    """
    必须在任何执行动作之前调用。任何失败都会抛 PreflightError。
    永远不返回 None。永远不吞掉异常。
    """
    checks = get_required_checks(task["type"])
    results = []
    for check in checks:
        try:
            result = check.run(task, timeout=30)
        except TimeoutError:
            raise PreflightError(
                f"Pre-flight check '{check.name}' timed out — execution blocked. "
                "Fix the check or resolve the underlying connectivity issue."
            )
        except Exception as e:
            raise PreflightError(
                f"Pre-flight check '{check.name}' raised an error: {e}"
            ) from e
        if not result.passed:
            raise PreflightError(
                f"Pre-flight check '{check.name}' failed: {result.reason}"
            )
        results.append(result)
    logger.info("All %d pre-flight checks passed for task %s", len(results), task["id"])

Step 2:用注册表按任务类型定义必需的检查项

PREFLIGHT_REGISTRY: dict[str, list[PreflightCheck]] = {
    "terraform_provision": [
        AWSCredentialsCheck(),
        NonProductionEnvironmentCheck(),
        ResourceQuotaCheck(min_remaining={"ec2": 10, "vpc": 2}),
        TerraformValidateCheck(),
    ],
    "database_migration": [
        DatabaseConnectionCheck(),
        BackupExistsCheck(max_age_hours=24),
        MigrationIdempotencyCheck(),
        RollbackPlanCheck(),
    ],
    "code_deploy": [
        GitBranchCheck(allowed_branches=["main", "release/*"]),
        TestSuitePassCheck(),
        SecretScanCheck(),
    ],
}

def get_required_checks(task_type: str) -> list[PreflightCheck]:
    if task_type not in PREFLIGHT_REGISTRY:
        raise ValueError(
            f"No pre-flight checks defined for task type '{task_type}'. "
            "Add an entry to PREFLIGHT_REGISTRY before adding new task types."
        )
    return PREFLIGHT_REGISTRY[task_type]

新任务类型如果没注册,这里会直接报错(强制你显式注册 pre-flight),所以你不可能忘记加检查项。

Step 3:移除所有「跳过」开关,或把它们限制在本地模式

import os

def require_preflight_with_env_guard(task: dict) -> None:
    if os.environ.get("SKIP_PREFLIGHT") == "true":
        env = os.environ.get("ENVIRONMENT", "unknown")
        if env not in ("local", "test"):
            raise ConfigurationError(
                "SKIP_PREFLIGHT=true is not allowed in non-local environments. "
                f"Current environment: {env}"
            )
        logger.warning("SKIP_PREFLIGHT=true — only allowed in local/test environments")
        return
    require_preflight(task)

在 staging 和生产环境里硬性阻断这个跳过开关。

Step 4:在工作流图的定义层面强制 pre-flight,而不只在代码里

在 LangGraph v1.0(2025 年 10 月起为稳定版)里,set_entry_point() 已弃用,改为用 START 哨兵节点 显式连边。把入口连成 preflight 是从起点唯一可达的节点,失败则路由到 END

from langgraph.graph import StateGraph, START, END

graph = StateGraph(State)
graph.add_node("preflight", preflight_node)
graph.add_node("execute", execute_node)

# preflight 是从 START 唯一可达的节点
graph.add_edge(START, "preflight")
# 根据 preflight 结果分支;绝不让 START 直接到达 execute
graph.add_conditional_edges(
    "preflight",
    lambda s: "execute" if s["preflight_passed"] else "abort",
    {"execute": "execute", "abort": END},
)

preflight_node 在任何失败或超时时把 preflight_passed=False(或直接抛异常)。因为没有从 STARTexecute 的边,所以没有任何路径能在不经过 preflight 的情况下到达执行。

如果用 Temporal,把 pre-flight 做成一个会快速失败的独立 activity。截至 2026 年 6 月,maximum_attempts=1 表示只尝试一次、不重试;但更干净的做法是在 activity 内部抛一个不可重试的 ApplicationError,让编排器立刻停下,而不是在一个根本无法恢复的前提条件上把整个重试预算耗完:

from datetime import timedelta
from temporalio import activity, workflow
from temporalio.common import RetryPolicy
from temporalio.exceptions import ApplicationError

@activity.defn
async def run_preflight_activity(task: dict) -> None:
    failures = run_required_checks(task)  # 你的检查执行器:抛异常或收集失败项
    if failures:
        # non_retryable:缺失的备份、过期的 token 不会因为重试就自己好了
        raise ApplicationError(
            f"Pre-flight failed: {failures}", type="PreflightFailed", non_retryable=True
        )

@workflow.defn
class ProvisionWorkflow:
    @workflow.run
    async def run(self, task: dict):
        # 必需的第一个 activity;由 start_to_close_timeout 限定时长
        await workflow.execute_activity(
            run_preflight_activity,
            task,
            retry_policy=RetryPolicy(maximum_attempts=1),
            start_to_close_timeout=timedelta(seconds=60),
        )
        await workflow.execute_activity(
            execute_activity, task, start_to_close_timeout=timedelta(minutes=30)
        )

Step 5:为每个 pre-flight 检查写一个 CI 测试

def test_preflight_blocks_on_missing_backup():
    task = {"type": "database_migration", "db": "prod_replica"}
    # 模拟最近 24 小时内没有备份
    with patch("checks.backup_check.get_latest_backup", return_value=None):
        with pytest.raises(PreflightError, match="BackupExistsCheck failed"):
            require_preflight(task)

def test_preflight_blocks_on_timeout():
    task = {"type": "terraform_provision", "env": "staging"}
    with patch("checks.aws_check.validate_credentials", side_effect=TimeoutError):
        with pytest.raises(PreflightError, match="timed out"):
            require_preflight(task)

如何确认已经修好

在你信任这个门禁之前,跑下面三项检查:

  1. **负向测试能通过。**主动制造一个真实的失败场景(吊销 token、删掉备份、指向生产环境),确认任务在任何会产生副作用的工具调用 之前 就中止了。execute 步骤的日志根本不应该出现。
  2. **没有残留的绕过路径。**在仓库里 grep skip_preflightFAST_MODESKIP_PREFLIGHTbypassif .*preflight。每一处命中要么被移除,要么被一个硬性阻断非本地使用的环境检查守住。
  3. **重试路径也被覆盖。**对一个原本能通过、但现在前提条件被破坏的运行触发重试。重试必须重新跑 pre-flight 并中止,而不是基于过期的结果直接恢复。

现在在运行日志里,每一次被阻断的运行都应该看到带着失败检查名和原因的 PreflightError(或不可重试的 PreflightFailed),每一次成功的运行都应该看到一行 all N checks passed。如果某次运行进入了执行、却两行都没有,那就说明还有代码路径在绕过这个门禁。

预防建议

  • 把 pre-flight 实现成一个会在任何失败时抛异常的强制阻断函数;它绝不应返回一个可以被忽略的结果。
  • 按任务类型注册必需的 pre-flight 检查;新任务类型缺失注册时应该大声报错,而不是悄悄通过。
  • 绝不允许在非本地环境里使用 SKIP_PREFLIGHT 之类开关;用显式的环境检查守住。
  • 把 pre-flight 设为工作流图里无条件的第一个节点;移除任何能在不经过 pre-flight 的情况下到达执行的路径。
  • 把 pre-flight 超时当作阻断性失败,而不是放行。一个你没能跑完的检查,就是一个没有通过的检查。
  • 写 CI 测试,验证每个 pre-flight 检查在它要拦截的每一种失败场景下都能阻断执行。
  • 每当流水线新增任务类型或新能力时,复查并更新 pre-flight 检查。
  • 为每一次运行记录带时间戳和结果的 pre-flight 日志;审计需要确认检查确实跑过了,而不只是「通过了」。

常见问答 (FAQ)

Q: 怎么避免 pre-flight 太严,把正常工作也挡掉? A: 让每个检查只在「确实会阻碍安全执行」的条件下失败,而不是在「只是不够理想」的条件下失败。「24 小时内没有备份」会挡住迁移;「备份是 6 小时前的(在 24 小时上限内)」则不会。阈值要根据真实事故来标定,而不是理论上的极值。

Q: Pre-flight 检查应该重试吗? A: 针对外部资源的检查(API 可达性、凭证有效性)可以带短延迟重试一次。针对逻辑前提的检查(备份存在、环境非生产)不应该重试:如果条件没满足,重试也不会改变它。快速失败,让人来修复这个前提条件。

Q: 怎么把 pre-flight 的结果传给主 Agent,让它不用重新推导一遍? A: 把 pre-flight 结果放进交给执行 Agent 的 handoff 上下文里。执行 Agent 从上下文读到「backup verified at 2026-05-25T14:30:00Z, quota remaining: 15 EC2 instances」,就能在推理时直接引用,而不必重新检查。

Q: 如果某个 pre-flight 检查需要 LLM 推理(比如「这次迁移安全吗?」)怎么办? A: 基于 LLM 的 pre-flight 检查适合做定性判断,但要给它配一个确定性的兜底。「这次迁移安全吗?」要拆成:LLM 评审 + --dry-run 执行 + schema diff 对比。绝不要让一个会阻断生产工作的门禁只依赖 LLM 的判断。

Q: 我的编排器本来就会重试失败的 activity,为什么 pre-flight 失败还是要几分钟才暴露? A: 因为默认的重试策略会一直重试。一个前提条件失败(token 过期、备份缺失)永远不会因为重试就成功,于是它会在拖垮整个重试预算之后才让运行失败。把 pre-flight 失败标记为终止性的:在 Temporal 里抛 ApplicationError(..., non_retryable=True) 或设 maximum_attempts=1;在 LangGraph 里把失败的 preflight 节点直接路由到 END。不可重试的机制可参考 Temporal 的 retry policy 文档

相关阅读

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