AI 在重构时可能悄悄为重试逻辑添加额外工具调用(如退款接口),单元测试只验证返回值不监控出站调用面,建议用 JSON 契约文件白名单化所有合法工具。
一个支付 Worker 坐在周二的合并队列里。一个 Agent 在夜里重写了重试辅助函数。本地单元测试依然通过,且非常安静。随后生产环境的追踪记录显示了一个新的出站工具名称。
这个辅助函数做的不仅仅是重试失败的扣款。它还在未经审查的情况下调用了退款端点。没有任何测试密切关注出站调用表面。绿灯意味着函数返回了。它并不意味着网络请求保持关闭。
Agent 的修补以这种安静的模式失败。它们在重构中添加了一个有用的工具。对本地返回值的断言错过了额外的调用。俱乐部一夜之间多了一扇侧门。保镖仍然只守着正门的绳子。
把工具表面当作一份打印的来宾名单。名单上的名字可以进入房间。一个新名字会在门口被拦下,无需争辩。额外的 JSON 字段算作新名字。人类在审查中拥有这份名单。Agent 不能编辑那个文件。
这篇文章提出了一个合并门禁,不是一篇回忆录。没有声称生产环境的计时或通过率。代码是一个带标签的、可运行的草图。在信任 CI 之前,先在笔记本电脑上执行它。
在服务代码旁边提交一份 JSON 契约。该文件命名了每个允许的工具。它用封闭模式固定参数形状。它还固定了调用者可能抛出的错误类。
{
"tools": {
"charges.create": {
"type": "object",
"required": ["amount_cents", "currency", "idempotency_key"],
"additionalProperties": false,
"properties": {
"amount_cents": {"type": "integer", "minimum": 1},
"currency": {"enum": ["usd", "eur"]},
"idempotency_key": {"type": "string", "minLength": 16}
}
},
"charges.get": {
"type": "object",
"required": ["charge_id"],
"additionalProperties": false,
"properties": {
"charge_id": {"type": "string", "pattern": "^ch_"}
}
}
},
"errors": ["Timeout", "Conflict", "Unavailable"]
}
Agent 可以在 src/ 下修补 Python。它不能在那条路径中修补 tool_surface.json。审查者将表面编辑视为产品变更。这种分离使契约与人类保持一致。
在测试运行前包装 HTTP 或工具客户端。记录工具名称、参数和错误类。为每次调用写入一行 JSON。在你已经拥有的表征测试中执行此操作。不要等待生产流量来注意漂移。
# trace_client.py
import json
from pathlib import Path
class TracingClient:
def __init__(self, inner, sink: Path):
self.inner = inner
self.sink = sink
def call(self, name: str, args: dict):
record = {"name": name, "args": args, "error": None}
try:
return self.inner.call(name, args)
except Exception as exc:
record["error"] = type(exc).__name__
raise
finally:
with self.sink.open("a") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
将表征套件指向 TracingClient。将接收器保存在临时路径上。在表征运行通过后复制接收器。将每一行与已提交表面文件进行差异比较。新工具名称会使门禁失败。新参数键会使门禁失败。未知错误类会使门禁失败。
门禁测试看起来应该几乎无聊。无聊的检查在审查中经受住 Agent 的魅力。诗意的断言会被重写以保持绿色。模式检查不会讨价还价。
# test_tool_surface.py
import json
from pathlib import Path
import jsonschema
SURFACE = json.loads(Path("tool_surface.json").read_text())
def load_trace(path: Path):
rows = []
for line in path.read_text().splitlines():
if line.strip():
rows.append(json.loads(line))
return rows
def test_trace_stays_inside_surface(tmp_path):
sink = tmp_path / "trace.jsonl"
# Proposal: call your real characterization suite here.
run_characterization(sink)
rows = load_trace(sink)
assert len(rows) >= 100, "empty traces cannot pass the surface gate"
for row in rows:
assert row["name"] in SURFACE["tools"], row
jsonschema.validate(row["args"], SURFACE["tools"][row["name"]])
if row["error"] is not None:
assert row["error"] in SURFACE["errors"], row
一个封闭的列表仍然不是一个正确的列表。有效字段可以转移错误的资金。在表面保持后,向已知工具投掷垃圾。客户端必须在线上之前拒绝垃圾。它不能打开一个新工具来提供帮助。
# test_tool_properties.py
import json
from pathlib import Path
from hypothesis import given, strategies as st
SURFACE = json.loads(Path("tool_surface.json").read_text())
NAMES = list(SURFACE["tools"])
class StrictClient:
def __init__(self, inner, surface):
self.inner = inner
self.surface = surface
def call(self, name, args):
if name not in self.surface["tools"]:
raise ValueError("unknown tool")
schema = self.surface["tools"][name]
extra = set(args) - set(schema.get("properties", {}))
if extra:
raise ValueError(f"extra fields: {extra}")
return self.inner(name, args)
@given(
name=st.sampled_from(NAMES),
extra=st.dictionaries(st.text(min_size=1, max_size=8), st.integers(), min_size=1),
)
def test_unknown_fields_never_hit_the_wire(name, extra):
sent = []
def fake_call(tool_name, args):
sent.append((tool_name, args))
return {"ok": True}
client = StrictClient(inner=fake_call, surface=SURFACE)
try:
client.call(name, {"_probe": True, **extra})
except ValueError:
assert sent == []
return
raise AssertionError("strict client must reject extra fields")
Hypothesis 在这里的使用是一个带标签的提案。将策略调整到已提交的架构。不要让 Agent 拥有策略模块。应用与 tool_surface.json 相同的审查分离。
不稳定的测试仍然在繁忙的夜晚困扰着 Agent 合并。一个失败的 flake 看起来像是扩展。一个通过的 flake 可能隐藏一个新工具。这些测试必须离开合并投票。将它们的 nodeid 移入隔离文件。CI 在每次 Agent 修补时读取该文件。隔离的名称不能使任务失败。它们也不能使任务通过。
# conftest.py
from pathlib import Path
import pytest
FROZEN = {
line.strip()
for line in Path("flake_quarantine.txt").read_text().splitlines()
if line.strip() and not line.startswith("#")
}
def pytest_collection_modifyitems(config, items):
for item in items:
if item.nodeid in FROZEN:
item.add_marker(pytest.mark.skip(reason="flake quarantined; no merge vote"))
# flake_quarantine.txt
# nodeids only. Agent patches may not edit this file.
tests/test_retry.py::test_eventual_success
隔离是一个流程冻结,不是一个愿望。人类在一个安静的分支上复现 flake。然后测试返回投票或消亡。Agent 不会与那个列表讨价还价。规则就是控制。
首先运行表征测试,然后是表面门禁。在这两个保持绿色后运行属性检查。始终保持隔离的 nodeid 被跳过。
python -m pytest tests/characterization -q --trace-sink=/tmp/trace.jsonl
python -m pytest tests/test_tool_surface.py -q
python -m pytest tests/test_tool_properties.py -q
python -m pytest tests --strict-markers -q
当 tool_surface.json 与 Agent 代码一起移动时,使构建失败。一个小的 git 检查强制执行人类分离。审查者仍然可以一起更改两个文件。他们在一个单独的人类提交中执行此操作。
changed=$(git diff --name-only origin/main)
echo "$changed" | grep -q 'tool_surface.json'
code_moved=$?
echo "$changed" | grep -qE '^(src/|app/)'
src_moved=$?
if [ "$code_moved" -eq 0 ] && [ "$src_moved" -eq 0 ]; then
echo "surface and service code moved together" >&2
exit 1
fi
一些团队在合并日缺乏空闲的 CI 分钟。测试工具是 pytest、JSON 和一个模式库。它可以在笔记本电脑上与 diff 一起运行。当笔记本电脑忙碌时,它也可以在免费的远程服务器上运行。MonkeyCode 提供免费模型访问和免费服务器选项。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。仅使用免费模型为属性文件生成额外的无效 payload。永远不要使用它来编辑 tool_surface.json。永远不要使用它来编辑 flake_quarantine.txt。服务器应该运行你本地运行的相同命令。如果这两个文件在作业中发生变化,则拒绝该作业。
封闭的工具表面不是语义安全。Agent 仍然可以传递错误的金额。模式允许任何大于零的整数。幂等键可以匹配形状但仍然冲突。属性检查在门口捕获额外字段。它们不会捕获有效字段中的业务谎言。
高变动率的公共 API 会经常触发这个门禁。这种痛苦是一个信号,不是缺陷。在人类拥有的变更中更新表面。不要将 additionalProperties 设置为 true 来让 Agent 沉默。
有目的地为研究 Agent 跳过这种方法。那些 Agent 必须探索未列出的工具。封闭的来宾名单会阻止那项工作。不要将其作为支付中的唯一控制。添加账本、限制和人工审查。当无法收集追踪时不要安装门禁。没有追踪的门禁只是 theater。
如果没有调用工具,空的表征仍然会通过。将模式差异与最小追踪计数配对。一百次调用只是一个起始提案。在复制之前根据你自己的套件进行衡量。不要将那个数字当作法律。
修补可以在辅助函数中保持聪明。门禁列表故意保持无聊。无聊的列表是合并队列真正能够执行的。