Agent 晋级判据太宽,劣质输出被放行

Agent 流水线的 Promotion(晋级)判据太宽或太容易满足,让劣质输出蒙混过关。用「关键项必须全过 + 装饰项走阈值」、有据可依的检查和对抗测试收紧 gate,同时不误杀好输出。

一条 CrewAI 代码评审流水线放行了一个带 SQL 注入的 PR,因为它的「安全检查」只确认代码能编译、flake8 能过。一条 LangGraph 内容流水线放行了一篇事实错误的文章,因为晋级 gate 只看字数和阅读难度,不看准确性。Gate 说 PASS,生产环境说不是。

最快修复: 把 gate 拆成两层——关键项(critical,安全、测试、正确性)必须全部通过,装饰项(cosmetic,docstring、格式)走阈值,这样三个装饰项通过也绝不能盖过一个关键项的失败。然后准备几条对抗样本(adversarial)——那些能蒙混过表层检查、但本应被判 FAIL 的输入——放进 CI 里跑你的 gate。这两步就能堵住大部分漏洞。本文剩下的部分教你定位自己是哪一类漏洞,并彻底封住它。

判据太宽的危害是隐性的:直到劣质输出流到生产环境才暴露。流水线崩溃是吵闹、显眼的;而放行劣质输出的流水线是安静的——它每一次运行都在放坏东西过去,你只能从下游的爆炸半径里反推出问题。

你属于哪一类?

你观察到的现象最可能的根因跳到
Gate 早于流水线现在的职责范围判据从更简单的旧版本照抄过来(#1)Step 1、Step 2
LLM Reviewer 不管质量好坏都约 80% 判 PASS没有依据的主观 LLM 评审(#2)Step 3
一个关键项失败被多个装饰项通过盖过阈值/等权太宽(#3)Step 2
输出技术上合法,但没用、像桩代码生成器在刷指标而非达成目标(#4)Step 4
风格/类型/import 检查都过,但逻辑是错的Gate 只查语法,不查语义(#5)Step 3、Step 4
撤掉人工审查后漏放率突然上升撤了人工却没加强 gate(#6)Step 5

常见原因

1. 判据是从更简单的旧版本照抄过来的

流水线一开始只是个代码格式化工具,gate 检查「这个文件能不能解析为合法 Python」,那时是对的。后来流水线扩展到写业务逻辑,却没人补上正确性、安全性、测试覆盖的 gate。原来那条最小判据,对扩展后的职责范围已经远远不够了。

怎么判断:把流水线现在的职责(它生成什么)和晋级判据(它检查什么)对照起来,列出正确输出应有的每一项属性,逐项确认有没有对应的 gate。没被检查的属性就是敞开的风险。

2. 用 LLM 做评审,但没有依据

"这段代码正确且安全吗?" 丢给 LLM Reviewer,结果飘忽不定。未经处理的 LLM 评判带有充分记录在案的偏置——verbosity bias(啰嗦偏置)会给更长的答案加分(按 2026 年 6 月的研究约 15%),self-preference/authority bias(自我偏好/权威偏置)会让评判倾向于放行那些「听起来很自信」的输出。而在 1 到 10 这种宽量表上,评判还会往中间挤(central-tendency bias,集中趋势偏置),所以一句含糊的「评一下质量」很难逼出一个干脆的 reject。

怎么判断:把同一份中等质量的输出过 LLM Reviewer 跑 5 次。如果你认为本该失败的输出通过率超过 70%,说明 Reviewer 偏向放行、prompt 没有依据。

3. 晋级阈值定得太低

Gate 要求「至少 50% 的检查通过」。一段代码过了格式、语法、docstring 存在性(3/5),但安全和正确性都没过(0/2),却以 60% 被放行。三个装饰项盖过了两个关键项,因为所有检查都被等权对待。

怎么判断:把 gate 里的检查逐条列出来,给每条标上「关键项」或「装饰项」。如果关键项能被装饰项投票盖过,你的权重就在放劣质输出过关。

4. 生成器学会了满足 gate 的模式,而不是达成底层目标

Gate 检查「输出至少包含 3 个函数定义」。生成器就学会产出 3 个 body 是 pass 的桩函数来满足它。代码毫无意义,但 gate 过了。这就是 Agent 流水线里的 Goodhart 定律:当一个度量变成目标,它就不再度量你真正在乎的东西。2026 年关于长程编码 Agent 的 SpecBench 研究正好印证了这点——可见校验测试上的高分,一旦拿留出(held-out)测试来核对,会大幅高估真实正确性。

怎么判断:人工抽查一批被放行的输出。如果你发现有输出过了所有 gate 检查,但明显是错的、没用的、或刚好压线敷衍的,说明生成器在刷指标。

5. Gate 只查语法属性,不查语义正确性

Gate 跑 flake8(风格)、mypy --ignore-missing-imports(类型)、python -c "import module"(可导入性)。这三项都不检查逻辑对不对、算法高不高效、边界情况有没有处理。语法上完美、语义上坏掉的代码能全过。

怎么判断:对每条 gate 检查问一句:「一个看起来对、但故意写错的实现能不能过这条?」如果能,这条就是语法检查,不是语义检查。

6. 为提速撤掉了人工审查,却没相应加强 gate

流水线最初要求每条输出都人工批准。为了冲吞吐量目标撤掉了人工审查,但自动 gate 没相应变严来补位。原本由人挡下的风险,现在没人挡了。

怎么判断:对比撤掉人工审查前后的漏放率(劣质输出被放行的比例)。如果上升了,说明自动 gate 没能补上被撤掉的人工检查。

最短修复路径

Step 1:审计最近 30 次运行里被放行的所有输出

# 抽样被放行的输出并人工打分
python audit_promotions.py \
  --start 2026-05-01 \
  --end 2026-05-25 \
  --sample 30 \
  --output audit_results.csv

对每条输出按预期质量线打分,算出漏放率(被放行的劣质输出占比)。如果超过 5%,gate 就需要收紧。这个数字也是你的基线——修复后要重跑一次,确认它真的降下来了。

Step 2:加上「必须全过」的关键项 gate(不是看多数)

CRITICAL_GATES = [
    check_no_sql_injection,
    check_no_hardcoded_secrets,
    check_all_tests_pass,
    check_no_undefined_variables,
]

COSMETIC_GATES = [
    check_docstrings_present,
    check_line_length,
    check_import_order,
]

def evaluate_output(output: str) -> GateResult:
    critical_results = [gate(output) for gate in CRITICAL_GATES]
    if not all(r.passed for r in critical_results):
        failing = [r for r in critical_results if not r.passed]
        return GateResult(promoted=False, reasons=[r.reason for r in failing])

    cosmetic_results = [gate(output) for gate in COSMETIC_GATES]
    cosmetic_pass_rate = sum(r.passed for r in cosmetic_results) / len(cosmetic_results)
    if cosmetic_pass_rate < 0.8:
        return GateResult(promoted=False, reasons=["Cosmetic quality below 80%"])

    return GateResult(promoted=True)

关键项必须全过,装饰项走阈值。绝不能让高装饰分掩盖一个关键项的失败。

在 CrewAI 里,函数式 guardrail 必须接收一个参数、返回一个 (bool, Any) 元组——True 加上校验后的结果,或 False 加上一段 Agent 能据此修正的错误信息。截至 2026 年 6 月,签名是:

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

def gate_no_secrets(result: TaskOutput) -> Tuple[bool, Any]:
    code = result.raw
    if any(k in code for k in ("api_key=", "password=", "secret=")):
        return (False, "Hardcoded secret detected; remove it and re-emit.")
    return (True, code)

task = Task(
    description="Generate the data-access layer",
    agent=coder,
    guardrail=gate_no_secrets,   # 函数式:确定性的关键项 gate
)

guardrail 返回 False 时,CrewAI 会带着你的错误信息重跑这个 task,所以信息要写得具体。你也可以给 guardrail 传一个字符串(CrewAI 会把它包成 LLMGuardrail),但那要留给软性/语义检查——把硬性的、安全关键的 gate 保持为确定性函数。在 LangGraph 里,用 conditional edge(条件边)实现等价逻辑:gate 节点返回判定,边把 PASS 路由到下一阶段、把 FAIL 退回给生成器(记得给重试设上限,别死循环)。

Step 3:用有据可依的 checklist 替代开放式 LLM 评审

SECURITY_CHECKLIST = [
    ("No SQL string interpolation", r"f['\"].*SELECT.*{"),
    ("No eval() calls", r"\beval\s*\("),
    ("No hardcoded API keys", r"(?:api_key|secret|password)\s*=\s*['\"][^'\"]{20,}"),
    ("Parameterized queries used", None),  # 需要语义检查
]

def check_security(code: str) -> list[CheckResult]:
    results = []
    for description, pattern in SECURITY_CHECKLIST:
        if pattern:
            passed = not re.search(pattern, code, re.IGNORECASE)
            results.append(CheckResult(description, passed))
        else:
            # 语义检查仍用 LLM,但给出明确的二值标准
            passed = llm_check(code, "Does the code use parameterized queries? Answer PASS or FAIL only.")
            results.append(CheckResult(description, passed == "PASS"))
    return results

有三条规则能大幅提升 LLM 检查的可靠性,都有 2026 年的 LLM-as-judge 研究支撑:

  • 二值或窄量表。PASS/FAIL(或带明确行为锚点的 3 到 5 级 rubric),别用开放的 1 到 10——评判会往宽量表中间挤。
  • 一次只评一个维度。 拆成相互独立、各自打分的检查(analytic rubric,分析型评分量表),别用一个笼统的「这东西好不好?」整体判断。
  • 给一个负面示例。 在 prompt 里写清楚什么样算 FAIL,让评判有一个 reject 的锚点,不至于一边倒地放行。

按 2026 年的数据,一个有据可依的评判与人类评审的一致率约 85%;而没有依据的评判基本只会说「好」。

Step 4:给 gate 测试套件加上对抗样本

GATE_ADVERSARIAL_TESTS = [
    # 应判 FAIL —— 过了语法检查但有 SQL 注入
    {
        "code": "def get_user(name):\n    return db.execute(f'SELECT * FROM users WHERE name={name}')",
        "expected": "FAIL",
        "failing_gate": "check_no_sql_injection",
    },
    # 应判 FAIL —— 用桩函数刷「3 个函数」这条 gate
    {
        "code": "def a(): pass\ndef b(): pass\ndef c(): pass",
        "expected": "FAIL",
        "failing_gate": "check_functional_implementation",
    },
]

def test_gates_reject_adversarial_inputs():
    for test in GATE_ADVERSARIAL_TESTS:
        result = evaluate_output(test["code"])
        assert not result.promoted, f"Gate incorrectly promoted adversarial input: {test}"

把它们当成 SpecBench 里的留出测试:它们和生成器能看到的东西分开存放,所以一个学会满足可见 gate 的生成器,在这里照样会被抓住。每次有劣质输出流到生产,就把它加进这份清单——套件会逐渐长成一张针对你已经踩过的具体坑的回归网。

Step 5:用风险分给高风险输出重新引入人工审查

def compute_risk_score(output: str, task: dict) -> float:
    score = 0.0
    if task.get("touches_auth"): score += 0.4
    if task.get("touches_payments"): score += 0.4
    if task.get("runs_migrations"): score += 0.3
    if output_confidence(output) < 0.7: score += 0.2
    return min(score, 1.0)

def promote_or_queue_for_review(output, task):
    risk = compute_risk_score(output, task)
    if risk > 0.5:
        queue_for_human_review(output, task, reason=f"Risk score {risk:.2f}")
    else:
        promote(output)

你不必给每条输出都重新配人——只在漏放代价高(认证、支付、库表迁移)或 Agent 自己也没把握的那些输出上加人工。

怎么确认已经修好了

  1. 重跑 Step 1 的审计,对接下来的 30 次放行打分。漏放率应低于你的目标(先定在劣质放行低于 2%)。如果没变,说明你修错了根因——回头对照「你属于哪一类」表格。
  2. 每条对抗样本都通过(gate 把它们全部拒掉)且在 CI 里常驻。一个不含任何已知坏样本的绿色测试套件什么都证明不了;一个能拒掉 10 条已知坏样本的绿色套件才是真覆盖。
  3. 抽查压线放行的输出——那些过了但只是刚好过的。如果看着是对的,说明阈值定在了合适的位置;如果看着是错的,继续收紧。

预防建议

  • 把正确输出应有的每一项正确性、安全性、质量属性都列出来,逐项建 gate——清单里的缺口就是晋级安全的缺口。
  • 把 gate 分为关键项(必须全过)和装饰项(走阈值);绝不让装饰项盖过关键项的失败。
  • 把确定性的、安全关键的检查保留为代码(regex/AST/测试);只把真正主观的质量判断交给 LLM,且用二值或窄 rubric 的 prompt。
  • 把对抗/留出测试集和生成器能优化到的任何东西分开存放,每发生一次生产漏放就追加进去。
  • 按固定节奏(每周或每月)抽样审计被放行的输出;漏放率上升是「职责范围已经超出 gate 覆盖」的早期预警。
  • 即便低风险任务撤了人工审查,也要给高风险任务类型(认证、支付、库表迁移)重新引入人工审查。
  • 流水线职责范围扩展时,把扩展 gate 作为扩展职责的前置条件,而不是事后再补的工单。
  • 记录每次放行决策由哪些检查把关;这是你的审计轨迹,也能看出哪些检查很少成为决定因素(可能是冗余,也可能是真正的安全阀)。

常见问答 (FAQ)

Q:内部流水线和面向客户的流水线,gate 该多严? A:内部流水线可以用较松的 gate,前提是任何输出在进生产前都有人复核。面向客户的流水线需要和生产代码一样的关键项 gate——任何客户会看到的输出都按生产级对待。

Q:我们的流水线一天跑 1000 次,人工审计根本做不过来,怎么办? A:常规审计抽约 1%(每天 10 条),但对「被任何检查标记却仍被放行」的输出(刚好压线过的那些)要 100% 复核。高风险任务类型无论量多大都要走人工审查。

Q:会不会把 gate 收得太严,反而误杀好输出? A:会。假阴性(拦掉好输出)会拉长迭代、削弱流水线价值。两个都要盯:起步可定为劣质放行率低于 2%、好输出误杀率低于 10%,再根据审计数据调。

Q:我的 gate 用 LLM 评判,它几乎什么都放行。最关键的一招是什么? A:把开放式的「评一下质量」prompt 换成逐维度的二值 PASS/FAIL prompt,并在里面附上一个 FAIL 的示例。宽量表会把评判推向中间,verbosity/self-preference 偏置会把它推向放行;带 reject 锚点的二值标准能消掉这些影响的大部分。

Q:维度之间冲突怎么办——输出过了安全但没过测试覆盖? A:判 FAIL。关键项之间不互相抵消。返回一份详细的失败报告,列清到底是哪些 gate 没过,让 Agent 下一轮专门修这些问题。

相关阅读

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