文章建议先实现完整的只读Agent链路,再以显式契约隔离规划与执行,并记录计划和实际变更。配套测试覆盖权限失败状态,可避免原型阶段将写操作散落在路由、提示词和工具实现中。
用户点击“总结这个 ticket,并提出修复方案”。你的 Agent 读取 ticket、调用 LLM,然后返回一份计划。这样的演示,任何团队都能在一周内做出来。接着,有人问:“它能不能顺便直接把问题修好?”——这时,最先出问题的往往不是模型,而是从“规划”到“执行”的交接层。因为在整个代码库里,从来没有明确规定:读取权限在哪里结束,写入权限又从哪里开始。
我曾经错误地实现过一次这条边界,所以现在我会严格按照一个顺序来构建:先做出一个可运行的只读垂直切片,同时将写入路径置于一份显式契约之后,并让现有切片从一开始就遵守这份契约。本文会结合代码,介绍这一实现顺序、我会测试的失败状态,以及如何低成本地迭代,而不至于烧掉生产环境的预算。
凭感觉写出来的 Agent,失败原因几乎从来都不是“模型太笨”,而是在原型开发过程中,写入能力被分散到了代码库的各个角落:
路由处理程序既调用 LLM,又直接执行 LLM 返回的任何内容。
工具定义以内联方式写在 prompt 字符串里,导致没有人能完整列举这个 Agent 究竟可以做什么。
系统没有持久化记录哪些内容只是计划、哪些内容已经实际执行,因此事故复盘变成了考古工作。
只读切片会迫使你尽早回答那些困难的问题——Agent 可以看到哪些数据?作为数据的“计划”应该长什么样?计划要存在哪里?——而且是在这些答案有机会伤害任何人之前。
关键在于:Agent 输出的是一个保存到存储系统中的计划对象,而不是一项操作。写入权限属于另一条独立的代码路径,只有在计划获得明确批准后,这条路径才会消费该计划。下面是它的结构,使用 TypeScript 编写,并通过 provider 接缝让模型可以随时替换:
// tools.ts — the ONLY place agent capabilities are enumerated
export interface AgentTool {
name: string;
authority: 'read' | 'write';
run: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
}
export const readOnlyTools: AgentTool[] = [
{
name: 'get_ticket',
authority: 'read',
run: async ({ id }, ctx) => ctx.db.ticket.findUniqueOrThrow({ where: { id: String(id) } }),
},
{
name: 'search_code',
authority: 'read',
run: async ({ query }, ctx) => ctx.search.query(String(query), { limit: 10 }),
},
];
// Write tools exist in the registry but are NEVER passed to the planner.
export const writeTools: AgentTool[] = [
{
name: 'create_branch',
authority: 'write',
run: async (args, ctx) => ctx.git.createBranch(String(args.name)),
},
];
planner 路由只接收 readOnlyTools。这听起来理所当然,但在我审查过的每一个有问题的实现中,完整工具列表都会以“临时方案”的名义被传到所有地方。
// planner.ts — provider seam + persisted plan
export interface PlanStep {
tool: string; // must exist in registry
args: Record<string, unknown>;
rationale: string;
}
export interface Plan {
id: string;
ticketId: string;
steps: PlanStep[];
status: 'proposed' | 'approved' | 'rejected' | 'applied' | 'failed';
createdBy: string; // actor provenance — carried through every layer
createdAt: string;
}
export async function proposePlan(
ticketId: string,
userId: string,
deps: { llm: LlmProvider; db: Db },
): Promise<Plan> {
const context = await gatherReadOnlyContext(ticketId, deps.db);
const raw = await deps.llm.complete({
system: PLANNER_SYSTEM_PROMPT,
messages: [{ role: 'user', content: JSON.stringify(context) }],
responseFormat: 'json',
});
const steps = validatePlanSteps(JSON.parse(raw), readOnlyTools.concat(writeTools));
// validatePlanSteps rejects unknown tools and malformed args BEFORE persistence
return deps.db.plan.create({
data: { ticketId, steps, status: 'proposed', createdBy: userId },
});
}
apply 路径是另一条独立路由,它拥有自己的授权检查和状态机,并且拒绝执行任何不处于 approved 状态的计划:
// apply.ts
export async function applyPlan(planId: string, userId: string, deps: Deps) {
const plan = await deps.db.plan.findUniqueOrThrow({ where: { id: planId } });
if (plan.createdBy === userId) {
throw new HttpError(403, 'SELF_APPROVAL_FORBIDDEN');
}
if (plan.status !== 'approved') {
throw new HttpError(409, `PLAN_NOT_APPLICABLE:${plan.status}`);
}
await deps.db.plan.update({ where: { id: planId }, data: { status: 'applied' } });
// execute steps against writeTools, with per-step audit rows
}
有三个设计决策值得解释:
验证发生在持久化之前。如果计划中包含未知的工具名称,应当在提交计划时返回 422,而不是等到执行时才返回 500。
状态转换就是契约。proposed → approved → applied 的转换规则应在数据库更新操作的 where 子句中强制执行,也就是使用乐观并发控制,而不只是依赖应用代码。这样一来,即使用户双击,也不会导致计划被重复执行。
默认禁止自行批准。触发计划的人不能同时成为批准计划的人。你的威胁模型可能有所不同,但无论如何,都应该把这个决策落实到代码中,而不是只写在某个 wiki 页面里。
只读切片是消耗 LLM 调用次数最多的阶段——你需要调整 prompt、尝试不同的工具 schema,并基于真实 ticket 运行评测。如果这些工作全部使用付费的生产环境 key,原型可能还没来得及被 bug 杀死,就先被财务部门叫停了。
披露声明:本文是 MonkeyCode 产品推广工作的一部分。
这正是我使用 MonkeyCode 免费模型访问能力的地方:它位于上面展示的同一个 LlmProvider 接口之后,因此在迭代 prompt 和计划验证逻辑时,planner 切片可以使用免费模型运行。只有等契约稳定之后,我才会把这个接缝指向付费 provider。免费服务器方案还可以承载这个切片自身的 API 和数据库,足以让一名开发者完整演练整个计划与执行流程——包括下文中的失败测试——之后才让任何操作接触真实基础设施。
这里有两个需要坦诚说明的注意事项:第一,我没有验证免费套餐在负载下的吞吐量或速率限制行为,因此不要根据它推断生产容量;第二,应当把任何免费套餐都视为临时资源——保留真正有效的 provider 接缝,才能让切换成本维持在接近于零的水平。如果你的切片需要有保证的延迟、较高的并发量或整个团队共同访问,那就跳过免费套餐,直接为真正需要的资源做好预算。
我测试的是跨层测试,而不是 SDK mock。每一行都包含一个请求、预期响应状态码,以及负责处理它的层:
第 7 行是所有人都会跳过的测试。由于 planner 只负责提出数据,而工具注册表又是封闭的,因此注入攻击最多只会退化成一份被人工拒绝的糟糕计划,而不会变成一项已经执行的操作。这种性质来自只读优先的架构,而不是来自 system prompt。
Human-in-the-loop 审批会增加延迟。如果你的使用场景属于高频、低风险的自动化,例如修复 lint 问题或格式化代码,那么完整的权限门控就有些过度了——更合适的做法是缩小写入工具的权限范围。
封闭式工具注册表意味着,每增加一种新能力,都需要修改代码并重新部署。从安全角度看,这是一个特性;从迭代速度来看,这也是一项成本。
这种模式无法解决计划质量问题。即使一份计划通过了验证并获得批准,它仍然可能是个坏主意。针对计划内容建立评测覆盖,是另一个独立的问题。
如果你是一名尚未拥有真实用户的独立开发者,那么完整状态机可能还为时过早——但只读工具注册表和持久化计划并不过早。可以先从这两项开始。
在一个注册表中集中列举所有工具,并为其标注 read 或 write;planner 只接收其中的只读子集。
计划在持久化之前,先根据工具注册表完成验证。
计划的状态转换应在数据库层强制执行,而不只是由应用代码保证。
执行者来源信息(createdBy)需要从路由一路传递到执行器和审计日志。
apply 路径需要独立于 proposal 路径执行审批和 authz 检查。
在 CI 中针对真实路由运行失败状态测试(即上文所述的表格),而不是测试 mock 路由。
将 LLM 放在 provider 接口之后,以便使用低成本或免费的算力进行迭代。
在你的 Agent 系统中,哪个层之间的交接最不稳定?你是否为它定义了具体的失败状态和响应状态码,还是仅仅寄希望于它不会出问题?欢迎在评论区分享你见过最糟糕的案例。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。