作者发现模型会幻觉执行动作(谎称已退款),因此将 allowlist、approval 流程、速率限制和正则模式写成代码层面的 policy 对象。
本文是一个系列教程的第 5 篇,主题是从零构建一个不依赖任何框架的工单处理 Agent。前一篇是第 4 篇(循环逻辑)。代码仓库:github.com/akash-pal/agent-from-scratch
整个分析基于一个核心发现:在评估迭代过程中,Agent 开始报告"已提出退款"——听起来流畅合理——但它从未调用过"提出退款"这个工具。没有请求过审批,不存在任何确认记录,模型只是凭空说了这件事。
这就是本篇要讨论的失效模式,而对应的修复方案也正是论证"防护栏应该写在代码里,而非仅存在于提示词中"的核心依据。
src/policy.ts 是一个纯数据对象——包含白名单、审批列表、速率限制和正则表达式——由 Agent 循环检查,而非询问模型:
export const policy: Policy = {
allowTools: ["order_lookup", "refund_eligibility", "issue_refund", "kb_search", "send_email"],
requireApprovalFor: ["issue_refund", "send_email"],
rateLimits: { maxToolCallsPerRun: 8, maxCostPerRunUsd: 0.3 },
autoEscalatePatterns: {
legal_threat: /\b(lawyer|attorney|sue|legal action|better business bureau|\bbbb\b)\b/i,
fraud_flag: /\b(fraud|unauthorized|without my permission|didn't authorize|stolen card)\b/i,
duplicate_ticket: /\b(already submitted|second ticket|duplicate ticket|already reported)\b/i,
},
};
这些规则全部在 LLM 控制之外强制执行。模型无法绕过 requireApprovalFor——循环在执行工具之前就会检查它,直接阻断。autoEscalatePatterns 中的正则表达式在调用模型之前就对原始工单文本进行匹配(第 3 篇边缘情况处理中的 expected_trajectory: [] 行为就是为此设计)——法律威胁或欺诈标记根本不会到达 LLM,而是直接进入人工队列。
三种人工审核模式
并非所有重大操作都需要同一种审核模式。本项目对 issue_refund 和 send_email 采用"执行前审批"模式——人工审批通过后才执行,因为这两者都很少见、后果严重且难以撤销。另外两种模式适用于不同的风险场景,值得了解,尽管本项目暂时不需要它们:
审批关卡本身,来自 src/approval.ts:
export const cliApproval: ApprovalFn = async (tool, args) => {
const answer = await getRl().question(
`\n[APPROVAL REQUIRED] ${tool}(${JSON.stringify(args)})\nApprove? (y/n) `,
);
return answer.trim().toLowerCase().startsWith("y");
};
以及循环如何在任何需要审批的工具运行前应用它(来自 agent.ts):
} else if (requiresApproval(toolName) && !(await approvalFn(toolName, args))) {
resultPayload = { error: "rejected_by_human_approval" };
isError = true;
}
如果人工选择拒绝,该工具永远不会执行——模型会收到一个错误结果作为返回,必须处理它,与其他任何工具失败的情况无异。
Bug:缺乏事实支撑的声明
实际发生的事情是这样的。两个评估用例(hard_04、hard_06)返回了 outcome: refund_proposed,但它们的工具调用轨迹仅为 [order_lookup, refund_eligibility]——issue_refund 从未出现在列表中。模型生成的最终消息以 REFUND_PROPOSED: 开头,直接跳过了本应导致该结果的工具调用。
这之所以重要:结果解析器将模型自己的 REFUND_PROPOSED: 前缀视为真实依据,没有任何逻辑验证该声明是否对应一次真实的、已审批的、已执行的工具调用。"提出退款"声明正是那种不应该完全依赖模型自行遵守提示词指令的业务关键性、后果性声明——而在这里,模型没有遵守。
修复方案——两层,而非一层
第一层,提示词:添加模型无法忽略的明确条款(已在第 4 篇的系统提示词中展示)——"除非你实际调用了 issue_refund 且在本轮中成功,否则不要声称 REFUND_PROPOSED。"
第二层,代码——这才是真正起作用的:
// 代码层强制执行的防护栏,而非仅仅依赖提示词:模型可以在
// 从未调用 issue_refund 的情况下在文本中声称 REFUND_PROPOSED。
function enforceOutcomeIntegrity(
outcome: { outcome: AgentOutcome; finalText: string },
state: AgentState,
): { outcome: AgentOutcome; finalText: string } {
if (outcome.outcome === "refund_proposed" && !state.artifacts.refund_confirmation_id) {
return {
outcome: "escalated",
finalText: `ESCALATED: model claimed REFUND_PROPOSED without ever calling issue_refund (policy violation) — original: "${outcome.finalText}"`,
};
}
return outcome;
}
state.artifacts.refund_confirmation_id 只会在一个地方被设置——当 issue_refund 实际成功时(src/memory.ts):
export function recordStep(state: AgentState, step: TrajectoryStep, result: Record<string, unknown>) {
state.conversation.trajectory.push(step);
if (step.tool_name === "issue_refund" && typeof result.confirmation_id === "string") {
state.artifacts.refund_confirmation_id = result.confirmation_id;
}
return state;
}
所以 enforceOutcomeIntegrity 根本没有信任模型的文字——它检查的是一个只有当 gated、已审批的工具调用实际发生时才能存在的事实。如果模型声称 REFUND_PROPOSED 但该事实不存在,运行时就会被降级为 escalated,无论模型的文字听起来多么令人信服。
这就是真正的教训,坦白地说:你的 Agent 对任何重大操作做出的声明,都应该可以从代码控制的状态中验证,而不是信任模型生成的文字。仅仅修复提示词可能降低了这个 Bug 发生的频率,但无法让它完全不可能发生。代码层的防护栏可以。
第 6 篇:AI Agent 的可观测性:链路追踪、指标与漂移 → 将讨论可观测性——本项目每一步都会记录 trace payload,以及为什么"运行评估集"不等于"在生产环境中判断是否健康"。
仓库:github.com/akash-pal/agent-from-scratch
进一步行动,你可以考虑屏蔽此人或举报滥用行为