AI 编程工具正在从代码补全转向能够读取仓库、修改文件、执行命令的智能体。能力边界扩大后,评价方法却常常停留在“看看生成的代码是否像样”:开发者浏览一遍差异,运行少量命令,然后凭印象决定是否接受。
这种方式有三个明显问题。
第一,任务描述通常不具备可验证性。“优化登录逻辑”“修复偶发错误”没有明确输入、输出和禁止事项,智能体很容易交付一份看似合理、实际偏离目标的修改。
第二,执行环境可能被污染。智能体在开发者当前工作区中安装依赖、改配置或生成文件,失败后很难区分哪些变化来自原任务,哪些属于尝试过程。
第三,评价结果不可复现。换一个人、换一次上下文或换一个模型,判断标准可能发生变化。即使测试通过,也不能自动证明没有越权修改、敏感信息泄漏或无关重构。
更稳妥的工程思路是:把编程智能体视为一个不完全可信的自动化执行器。它可以提出和实施修改,但最终是否接收,应由任务契约、确定性测试、静态约束和人工审查共同决定。
核心原理:把开放式生成转成封闭式验收
一条可审计的智能体任务可以拆成五层:
- 任务契约:定义允许修改的路径、必须满足的行为、禁止行为和验证命令。
- 隔离工作区:每次任务使用独立分支或 Git worktree,避免污染主工作区。
- 受限执行:为命令设置超时、权限和环境变量白名单,不把本机全部凭据暴露给进程。
- 确定性门禁:优先使用单元测试、类型检查、格式检查和仓库规则判断结果。
- 证据归档:保存提交差异、命令退出码和日志,模型评价只能作为补充信息,不能替代测试。
这里最关键的区别是“生成”和“裁决”分离。智能体负责生成候选变更,验收器负责裁决。即便两者使用不同模型,也不能据此假定裁决天然客观;凡是能写成程序断言的要求,都应由程序完成判断。
第一步:把需求写成机器可检查的契约
下面使用 JSON,避免额外引入配置解析依赖。创建 tasks/fix-parser.json:
{
"id": "fix-parser-empty-input",
"prompt": "修复 parse_items 在空输入时抛出异常的问题,并补充测试。不要修改公开函数签名。",
"allowed_paths": [
"src/parser.py",
"tests/test_parser.py"
],
"required_commands": [
["python", "-m", "pytest", "tests/test_parser.py", "-q"],
["python", "-m", "compileall", "-q", "src"]
],
"timeout_seconds": 300
}
allowed_paths 不是提示词装饰,而是验收规则。任务完成后,只要差异中出现其他文件,流水线就应失败并要求人工处理。对于依赖锁文件、快照或代码生成结果确实需要联动修改的项目,应事先把相关路径加入白名单,而不是在任务结束后临时放宽标准。
任务还应尽量使用行为语言。例如,“空字符串返回空列表,且不改变公开函数签名”比“正确处理空输入”更容易落成测试。无法自动验证的风格偏好,可以放入人工审查项,但不宜伪装成确定性结论。
第二步:创建一次性隔离工作区
在仓库根目录执行:
git worktree add -b agent/fix-parser-empty-input \
../worktrees/fix-parser-empty-input HEAD
cd ../worktrees/fix-parser-empty-input
git worktree 共享对象数据库,但拥有独立工作目录和索引,适合并行处理多个候选任务。开始执行前应确认基线本身可通过测试;否则任务后的失败可能来自既有缺陷。
不要默认复制 .env、云平台凭据或生产配置。若任务只需读代码和运行本地测试,就不应获得模型密钥、数据库写权限或部署权限。必须访问外部服务时,优先使用专用测试账号、只读权限和短期凭据。
第三步:实现最小验收器
下面的脚本接受任务文件和智能体命令。它执行智能体后检查修改路径,再运行契约中的验证命令。智能体具体采用何种 CLI 不属于验收器的职责。
创建 tools/run_agent_task.py:
from __future__ import annotations
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
def run(argv: list[str], timeout: int, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
argv,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
env=env,
check=False,
)
def changed_paths(timeout: int, env: dict[str, str]) -> set[str]:
result = run(["git", "status", "--porcelain"], timeout, env)
if result.returncode != 0:
raise RuntimeError(result.stdout)
paths = set()
for line in result.stdout.splitlines():
raw = line[3:]
# 重命名记录形如 old -> new,验收新旧路径。
paths.update(part.strip() for part in raw.split(" -> "))
return paths
def main() -> int:
if len(sys.argv) != 3:
print("usage: run_agent_task.py TASK_JSON AGENT_COMMAND")
return 2
task = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
timeout = int(task.get("timeout_seconds", 300))
allowed = set(task["allowed_paths"])
# 只传递基础运行环境;按任务显式增加其他变量。
env = {
"PATH": os.environ.get("PATH", ""),
"HOME": os.environ.get("HOME", ""),
"LANG": os.environ.get("LANG", "C.UTF-8"),
}
agent_argv = shlex.split(sys.argv[2]) + [task["prompt"]]
agent_result = run(agent_argv, timeout, env)
print("=== agent output ===")
print(agent_result.stdout)
if agent_result.returncode != 0:
return 10
unexpected = changed_paths(timeout, env) - allowed
if unexpected:
print("unexpected changed paths:", sorted(unexpected))
return 20
for command in task["required_commands"]:
result = run(command, timeout, env)
print(f"=== {' '.join(command)} ===")
print(result.stdout)
if result.returncode != 0:
return 30
diff = run(["git", "diff", "--check"], timeout, env)
print(diff.stdout)
return 0 if diff.returncode == 0 else 40
if __name__ == "__main__":
raise SystemExit(main())
运行方式如下,其中 your-agent-cli 只是占位符,需替换为实际工具提供的非交互命令:
python tools/run_agent_task.py \
tasks/fix-parser.json \
"your-agent-cli --non-interactive"
脚本返回非零退出码时,不应自动合并。还要注意,git status --porcelain 的路径解析足以支持常见仓库,但包含换行符等特殊文件名的仓库应改用 -z 输出并按 NUL 字节解析。
第四步:增加针对越权和退化的测试
验收器自身也需要测试。至少覆盖以下场景:智能体命令失败、执行超时、修改白名单外文件、测试命令失败、生成空白错误以及全部门禁通过。
业务仓库中的测试则应围绕任务行为编写。例如:
from src.parser import parse_items
def test_empty_string_returns_empty_list():
assert parse_items("") == []
def test_whitespace_returns_empty_list():
assert parse_items(" ") == []
def test_existing_behavior_is_preserved():
assert parse_items("a,b") == ["a", "b"]
只写修复场景可能掩盖回归,因此要保留至少一个原有行为断言。若公开接口稳定性重要,还可使用 inspect.signature 固化签名;但签名测试也会限制未来合理演进,应只在契约明确要求时使用。
第五步:把模型复核放在非阻塞位置
部分任务包含命名、可读性或需求覆盖度等难以完全编码的判断,可以把差异发送给另一个模型生成审查清单。不过模型结论具有非确定性,也可能遗漏问题,适合作为人工审查材料,不适合作为唯一合并条件。
接入模型时应把供应商差异收敛到适配层,并从环境变量读取密钥:
export MODEL_BASE_URL="https://example.invalid/v1"
export MODEL_API_KEY="从密钥管理系统注入"
export MODEL_NAME="按供应商文档填写"
如果团队需要评估中转接口,可查阅 HaerAPI 的当前文档;只有在文档明确支持所需协议、模型和调用参数时,才能把对应地址填入 MODEL_BASE_URL,不能根据接口名称自行推定兼容性。
发送审查材料前,应过滤 .env、访问令牌、客户数据、私有依赖地址及其他敏感内容。企业代码能否传给外部接口取决于合同、数据处理地域、留存策略和内部制度,技术上能够调用不代表合规上允许调用。
第六步:在 CI 中保留证据
本地验收通过后,可在 CI 中重复执行测试并保存补丁:
name: agent-change-review
on:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install -r requirements-dev.txt
- run: python -m pytest -q
- run: git diff --check origin/${
{
github.base_ref }}...HEAD
示例中的 Action 主版本和 Python 版本只是配置样例,实际项目应依据运行时约束选择并固定依赖。涉及供应链控制时,可以进一步固定第三方 Action 的提交摘要。CI 权限应遵循最小授权原则;仅做检查的作业通常不需要写入仓库或访问部署密钥。
常见问题
测试全部通过,是否可以自动合并?
不能一概而论。测试只能证明已覆盖断言成立,不能证明需求完整、没有安全缺陷或没有未覆盖回归。低风险、规则稳定的机械修改可以在充分审计后提高自动化程度;权限、支付、数据删除和基础设施变更仍应保留人工审批。
为什么不让模型直接判断另一个模型的答案?
模型复核适合发现候选问题,但两个模型可能共享相似盲区,评价还会受到提示词和上下文截断影响。编译器、测试和静态分析提供更稳定的证据,应先执行这些工具,再让模型解释剩余风险。
白名单会不会限制智能体修复必要文件?
会,这正是任务契约需要迭代的原因。发现必须修改新文件时,应暂停任务、更新契约并重新执行,而不是允许智能体无限扩张范围。这样可以留下范围变更记录。
如何防止智能体修改测试来掩盖错误?
把关键验收测试放在智能体不可写的目录,或由 CI 在任务结束后注入隐藏测试。隐藏测试不应取代公开契约,否则智能体无法获得足够反馈;两者分别用于表达需求和防止针对样例投机。
超时后进程一定会完全退出吗?
不一定。示例脚本对直接子进程设置超时,但某些工具可能派生子进程。生产实现应创建独立进程组,并在超时时终止整个进程组;容器化执行还可以提供更明确的 CPU、内存、网络和文件系统边界。
总结
编程智能体的工程价值,不应只用生成速度或演示效果衡量。更重要的是能否把一次开放式修改转成可描述、可隔离、可验证、可回滚的候选变更。
落地时可以从最小闭环开始:用 JSON 写清任务契约,用 Git worktree 隔离工作区,用 Python 验收器限制修改范围并运行测试,再由 CI 重复验证和保存证据。模型复核可以补充人工审查,但确定性门禁、最小权限和敏感数据控制仍是最终边界。这样即使更换智能体或模型,团队保留下来的也不是某个工具的使用习惯,而是一套可迁移的研发控制机制。