无约束的编码Agent会自行选择技术栈,建议在项目开始前用JSON锁定运行时语言、依赖边界、数据存储、Agent可编辑路径等四项核心约束。
Take the position, then enforce it
一个不受约束的 coding agent 会自己发明一套技术栈。这项发明,下周就会成为你的生产债务。你应该把这项发明当作不被信任的输出来对待。
你早已知道 agents 会填满每一个缺失的事实。它们会选择流行的默认值,而不是你真实的约束条件。那些默认值看起来很有帮助,直到第一次部署。
这不是风格偏好问题。这是所有权问题。你拥有运行时、数据和爆炸半径。Agent 只拥有那道围栏内部的一份草稿。别的都不是。
Agents invent architecture when you leave gaps
你留下一个空仓库和一个模糊的 prompt。Agent 选了一个框架、一个队列和一个数据库。那些选择只是披着能力外衣的猜测。
你根本没有审查过任何设计文档。你只是审查了一份已经假设了技术栈的 diff。这就是今年悄然产生brownfield混乱的方式。
目前流行的 agent demo 仍然在隐藏这种所有权的代价。它们庆祝一个跑起来的 endpoint,而不是一个持久的外形。demo 的能量消退之后,那个外形就由你来继承了。
不要再问 agent 你应该在什么上面构建了。让它在一个锁定的外形内实现一个行为。如果它无法在锁内工作,那说明 prompt 写错了。
Freeze a shape, not a feeling
架构在这里意味着四个锁定、可测试的事实:
把这些写进一个 gate 能解析的文件里。不要只把契约存在聊天记录里。把契约放到 Git 里,这样检查才能运行。如果没有被检查,外形就会移动。
下面是一个你可以复制的最小外形锁。你可以把它当作一个提案,而不是一个经过衡量的标准。把匹配你真实仓库的值固定下来。
{
"runtime": {
"language": "python",
"version": "3.12",
"entry": "src/app.py"
},
"boundaries": {
"forbidden_packages": ["django", "celery", "redis", "mongodb"]
},
"data": {
"store": "sqlite",
"migrations": "alembic"
},
"agent": {
"may_edit": ["src/services/", "tests/"],
"must_not_edit": ["shape.lock.json", "scripts/", "deploy/", ".github/"]
}
}
有意保持这个文件平淡无奇。平淡比幻灯片更容易测试。如果需要新的依赖,先编辑这个锁。
A gate that fails closed
你需要一个检查器,在违规时 exit 非零。警告不会停止 agent 循环。Agents 会忽略黄色文字。它们尊重 exit codes。
#!/usr/bin/env python3
"""Fail if touched files violate shape.lock.json.
Proposal: run locally and in CI. This script is a gate,
not a production security scanner.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCK = json.loads((ROOT / "shape.lock.json").read_text())
FORBIDDEN = set(LOCK["boundaries"]["forbidden_packages"])
MUST_NOT_EDIT = LOCK["agent"]["must_not_edit"]
def scan_imports(path: Path) -> list[str]:
hits: list[str] = []
text = path.read_text(encoding="utf-8", errors="ignore")
for pkg in FORBIDDEN:
if f"import {pkg}" in text or f"from {pkg}" in text:
hits.append(f"{path}: forbidden import {pkg}")
return hits
def main() -> int:
errors: list[str] = []
for raw in sys.argv[1:]:
rel = raw.replace("\\", "/")
for blocked in MUST_NOT_EDIT:
prefix = blocked.rstrip("/") + "/"
if rel == blocked or rel.startswith(prefix):
errors.append(f"{rel}: must not edit {blocked}")
path = ROOT / raw
if path.suffix == ".py" and path.exists():
errors.extend(scan_imports(path))
if errors:
print("SHAPE GATE FAILED")
print("\n".join(errors))
return 1
print("SHAPE GATE OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
在 agent 触碰的每个路径上运行它:
python scripts/assert_shape.py src/services/billing.py tests/test_billing.py
echo $?
当出现禁止的 import 时你希望得到 1。当只有 service 文件被修改时你希望得到 0。这个二元结果就是整个控制循环。
把同样的命令接入 Makefile target。不要藏在 chat plugin 后面。让失败在本地、快速、平淡地发生。
TOUCHED ?= src/services/billing.py tests/test_billing.py
assert-shape:
python scripts/assert_shape.py $(TOUCHED)
test:
python -m pytest tests/ -q
promote: assert-shape test
@echo "shape and tests passed; now read the service diff"
How a real failure should look
假设你只要求了发票总额。Agent "好心"地加了一个 Redis 缓存。你的 gate 应该长这样,然后停下来:
SHAPE GATE FAILED
src/services/billing.py: forbidden import redis
不要和模型开始设计辩论。Reset the tree 并收紧 prompt。在下一条指令里指明那个禁止的包。
Implement invoice totals in src/services/billing.py.
Do not add caches, queues, or new listeners.
Do not edit shape.lock.json, scripts/, or deploy/.
Add tests under tests/. Stop after pytest passes.
如果它两次 gate 失败,你就停止这个 session。重复的外形违规是一个 prompt bug。它们不是放松锁的理由。
Use a free sandbox. Do not promote from it.
Coding agent 仍然需要一台机器来运行。你的笔记本是一个糟糕的隔离边界。一次性的服务器是一个更好的草稿板。
MonkeyCode 可以为第一轮托管那个草稿板。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。这个开源项目目前提供免费模型访问和免费服务器选项。把两者都当作实验室,而不是 staging,在规划容量之前到项目页面核实实际限制。
本文不会指名模型或引用配额。那些数字会变。你的 lockfile 不应该变。只把合成的 fixtures 复制到那个机器上。
把 secrets 远离 sandbox。把生产数据远离 sandbox。Clone the lockfile、测试和一个假数据集。
一个实用的循环看起来是这样的:
如果第 4 步失败,丢弃服务器端的那棵树。你不和破碎的外形谈判。你 reset 那个 box 并缩小允许的路径。
Decision table: what the agent may touch
把下面这张表打印出来放在 prompt 旁边。Agent 应该看到每一个硬性的"不准"。你在 merge 之前也应该看到它。
如果一个任务需要"不准"单元格,停止 agent。用人类拥有的 change 去做那部分工作。然后只为实现部分重新打开 sandbox。
Tests that encode the opinion
不要只测试业务逻辑。测试架构没有移动。这些测试应该有意写得简短:
# tests/test_shape_policy.py
import ast
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCK = json.loads((ROOT / "shape.lock.json").read_text())
def test_lockfile_is_committed():
assert (ROOT / "shape.lock.json").is_file()
def test_entry_point_exists():
assert (ROOT / LOCK["runtime"]["entry"]).is_file()
def test_no_forbidden_imports_in_src():
forbidden = set(LOCK["boundaries"]["forbidden_packages"])
hits = []
for path in (ROOT / "src").rglob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root = alias.name.split(".")[0]
if root in forbidden:
hits.append(f"{path}: import {alias.name}")
if isinstance(node, ast.ImportFrom) and node.module:
root = node.module.split(".")[0]
if root in forbidden:
hits.append(f"{path}: from {node.module}")
assert hits == [], hits
在你使用的每台主机上运行同样的测试:
python -m pytest tests/test_shape_policy.py tests/ -q
如果 sandbox 无法运行 pytest,跳过那台主机。没有测试的 agent 是一个故事生成器。你不会把故事 ship 到 main。
Cheap generation invites extra architecture
当推理免费时,重试也感觉是免费的。额外的重试会带来额外的架构磨损。模型会在第三轮加上 Redis。
把这些重试花在行为上,而不是技术栈的磨损上。不要因为 prompt 卡住了就替换 SQLite。不要为了速度引入第二个 web 框架。
这是那个观点,不再柔软地重述。零成本的生成不是零成本的所有权。Gate 是你把这两个成本分开的方式。
你不是反 agent。你是反漂移。Agents 在填充你已经圈好范围的文件时很快。在选择范围本身时很鲁莽。
这个工作流会让想要魔法的人感到烦恼。它也会漏掉巧妙躲避策略的行为。字符串和 AST import 扫描不是 sandbox。
它不能证明运行时隔离。它不能证明负载、隐私或正确性。它只证明约定的外形没有漂移。
如果没有人拥有这个 lockfile,就不要用它。无人拥有的 lockfile 会变成被忽略的民俗。民俗不会让 CI 失败,所以漂移会回来。
不要把它用于受监管的生产切割。免费的共享服务器是错误的信任区域。把客户数据放在任何社区 box 之外。
不要把它作为你唯一的 review 步骤。人类仍然逐行阅读 service diff。Gate 只读你冻结的骨架。
What you should do on the next agent session
在你打开循环之前先写 lockfile。把 assert_shape.py 接入 pre-push hook。给 agent 一个目录,而不是一个产品授权。
如果你需要一个用完即弃的机器来做这个循环,带有免费模型访问的一次性服务器就足够做这个实验了。保持 lock。丢弃 box。