让 Python 编码代理从“凭感觉”走向可验收:任务边界、审查循环与证据门禁

2026-09-14 23 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

让编码代理生成一个 diff 并不困难,困难的是回答另一个问题:这个 diff 为什么值得保留?Agentic Engineering 的重点不只是让模型调用工具、修改文件和运行测试,而是把任务约束、审查循环与可复查证据组合成一套工程流程。

如果系统只能说“代理认为已经完成”,团队得到的仍然是感觉;如果系统能展示改了哪些文件、执行了哪些检查、结果是什么、哪些风险尚未覆盖,才开始接近可验收的软件变更。

把开放式需求压缩成有边界的任务

“优化解析器”“修复登录问题”都不是适合直接交给代理的任务。它们没有说明修改范围、验收条件,也没有定义代理应该在什么情况下停止。

一个可执行的任务契约至少应包含:

  • 目标:需要改变的具体行为。
  • 允许范围:可以读取和修改哪些目录或文件。
  • 禁止事项:不能改公开 API、依赖版本、数据库结构或安全配置等。
  • 验收命令:测试、静态检查、格式检查和构建命令。
  • 停止条件:连续失败、需求冲突或超出修改预算时,不再盲目重试。
  • 交付证据:diff、测试输出、风险说明以及仍未解决的问题。

可以把任务描述成机器和人都容易检查的 YAML。下面只是一个可改造的实践示例,并不依赖特定代理框架:

task:
  objective: "让 parse_amount 在收到空字符串时返回 None"
  allowed_paths:
    - src/payments/parser.py
    - tests/test_parser.py
  forbidden:
    - "修改 parse_amount 的参数列表"
    - "增加第三方依赖"
  acceptance:
    - "python -m compileall -q src tests"
    - "python -m unittest discover -s tests -p 'test_*.py'"
  limits:
    max_changed_files: 2
    max_review_rounds: 3
  evidence:
    - changed_files
    - diff_check
    - test_output
    - unresolved_risks

这里的边界不是为了妨碍代理,而是为了减少搜索空间。代理一旦发现必须修改第三个文件才能完成任务,就应暂停并申请扩大范围,而不是悄悄越界。

审查循环不是“再问模型一次”

有效的循环通常包含四步:生成变更、执行检查、审查证据、针对失败进行修订。每一轮都应该产生新的可观察结果,而不是让模型反复声明“现在应该没问题了”。

一个实用的循环可以表示为:

  1. 代理读取任务契约并提出最小修改计划。
  2. 代理编辑允许范围内的文件。
  3. 外部工具执行测试、静态检查和 diff 检查。
  4. 审查器根据真实输出决定接受、要求修订或升级给人工。

审查器最好不要只复用生成阶段的自然语言结论。即使仍由模型参与审查,也应把 Git diff、测试日志和任务契约作为输入,并由独立脚本执行确定性检查。

循环还必须有预算。连续三次出现相同测试失败、测试本身疑似不稳定、需要访问密钥,或修改范围不断扩大时,正确动作通常是停止并交给人处理,而不是无限重试。

用 Python 给 diff 加一道可执行门禁

下面的脚本展示了一种最小证据门禁。假设项目使用 Git,源码位于 src/,测试位于 tests/,并采用标准库 unittest。它会检查变更是否越界,运行编译与测试,并把结果写入 evidence.json

将代码保存为 review_gate.py

from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path


def output(command: list[str]) -> str:
    return subprocess.check_output(command, text=True).strip()


def run_check(name: str, command: list[str]) -> dict:
    completed = subprocess.run(
        command,
        text=True,
        capture_output=True,
        check=False,
    )
    return {
        'name': name,
        'command': command,
        'returncode': completed.returncode,
        'stdout': completed.stdout,
        'stderr': completed.stderr,
    }


def is_allowed(path: str, prefixes: list[str]) -> bool:
    for prefix in prefixes:
        clean = prefix.rstrip('/')
        if path == clean or path.startswith(clean + '/'):
            return True
    return False


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument('--base', default='HEAD')
    parser.add_argument('--allow', action='append', required=True)
    args = parser.parse_args()

    tracked = output(
        ['git', 'diff', '--name-only', args.base, '--']
    ).splitlines()
    untracked = output(
        ['git', 'ls-files', '--others', '--exclude-standard']
    ).splitlines()
    changed_files = sorted(set(tracked + untracked))
    unexpected = [
        path for path in changed_files
        if not is_allowed(path, args.allow)
    ]

    checks = [
        run_check(
            'diff-check',
            ['git', 'diff', '--check', args.base, '--'],
        ),
        run_check(
            'compile',
            [sys.executable, '-m', 'compileall', '-q', 'src', 'tests'],
        ),
        run_check(
            'unit-tests',
            [
                sys.executable,
                '-m',
                'unittest',
                'discover',
                '-s',
                'tests',
                '-p',
                'test_*.py',
            ],
        ),
    ]

    report = {
        'base': args.base,
        'changed_files': changed_files,
        'unexpected_files': unexpected,
        'scope_ok': bool(changed_files) and not unexpected,
        'checks': checks,
    }
    Path('evidence.json').write_text(
        json.dumps(report, indent=2, ensure_ascii=False),
        encoding='utf-8',
    )

    passed = report['scope_ok'] and all(
        check['returncode'] == 0 for check in checks
    )
    print(json.dumps(report, indent=2, ensure_ascii=False))
    return 0 if passed else 1


if __name__ == '__main__':
    raise SystemExit(main())

在 Git 工作区中运行:

python review_gate.py \
  --base HEAD \
  --allow src/ \
  --allow tests/

命令返回码为 0,表示范围检查和配置的命令都通过;非零返回码应阻止自动合并。evidence.json 可以作为 CI 构件保存,供代码审查者查看。

实际项目可以继续增加 ruff checkmypypytest、覆盖率阈值、依赖漏洞扫描和构建验证。例如使用 pytest 的项目可把单元测试命令替换为:

run_check(
    'pytest',
    [sys.executable, '-m', 'pytest', '-q', '--maxfail=1'],
)

要注意,这个脚本只是门禁示例,不是完整沙箱。它运行的是仓库中的代码,因此不应直接在持有生产密钥或云管理权限的环境中执行不受信任的代理变更。

什么才算“足以保留”的证据

测试通过很重要,但它只证明已执行的测试没有发现问题,不等于实现一定正确。更可靠的证据通常分为几层:

  • 范围证据:变更文件都在任务允许范围内,diff 大小符合预算。
  • 结构证据:代码能编译,格式、类型和静态检查通过。
  • 行为证据:新增测试能够先暴露旧行为,再验证新行为。
  • 回归证据:相关测试集和必要的集成测试通过。
  • 审查证据:变更说明能把每一处修改映射回任务目标。
  • 风险证据:未覆盖的平台、数据规模、并发条件和外部依赖被明确记录。

证据也要防止“自证”。如果代理修改了实现,同时删除失败测试、降低覆盖率阈值或放宽断言,测试变绿反而可能是危险信号。因此,验收过程应单独检查测试文件、配置文件和依赖清单的变化。

四个自测问题

可以用下面的问题检查流程是否仍停留在“凭感觉”:

  1. 代理完成了功能,但修改了允许范围之外的 CI 配置,能否接受?
  2. 所有测试通过,但没有针对新行为增加测试,这份证据是否充分?
  3. 同一个代理在没有工具输出的情况下说“代码看起来正确”,这算审查吗?
  4. 代理连续重试三次仍得到相同错误,应该继续增加轮次还是升级给人工?

较稳妥的答案分别是:拒绝或重新授权范围;通常不充分;不算独立证据;停止循环并升级。真正的判断还应结合变更风险,例如文档修正与支付逻辑修改显然不能使用同一套门槛。

落地时从窄任务开始

引入代理式开发时,不必一开始就允许代理跨仓库重构。更稳健的顺序是:

  • 选择可快速验证、影响面小的任务。
  • 明确允许路径、验收命令和最大循环次数。
  • 在隔离容器或低权限 CI 中运行代理生成的代码。
  • 把 diff、命令、退出码和日志作为构件保存。
  • 对认证、支付、权限和数据迁移等高风险变更保留人工批准。
  • 定期分析失败案例,并把经验转化为新的确定性检查。

Agentic Engineering 的成熟度,不取决于代理能连续工作多久,而取决于团队能否说明每个变更为何满足任务、经过了哪些检查,以及还存在哪些不确定性。从感觉走向证据,才是让 Python 编码代理进入真实工程流程的关键一步。


相关推荐