引言:AI 代理的信任悖论
AI 代理有一个根本问题:LLM 自己生成输出,又自己判断"做完了"——这就像让学生自己出题自己批改。
主流桌面 AI 代理的解法是:让同一个 Agent 负责拆解、执行、判断完成,在关键动作前弹出确认让用户 approve 或 reject。这个模型简洁、直觉、有效——对于文档撰写、日程管理、邮件回复这类任务,"看起来对"通常就够用了。
但当任务涉及代码生成、构建、测试时,"看起来完成了"和"真的能跑"之间可能有巨大的鸿沟。Agent 自己 ls 了一下说"文件已创建",但实际上目录路径是错的;Agent 自己 cat 了一下说"代码正确",但缺少关键 import。用户即使审批也只是在看 Agent 自己提供的摘要——如果自述本身不可靠,用户的审批就建立在沙子上。
我在开发一个桌面 AI 工作台(ai-workbench,开源),基于 Electron + React,本地优先,支持任意 OpenAI 兼容模型。面对验证问题,我走了另一条路:把规划、执行、验证拆成三个独立角色,验证者明确不采信执行者的自述,并且拥有一双独立的眼睛去取证。
这篇文章拆解这套架构在"信任、验证、安全、可审计"四个维度的具体设计,以及这些设计如何体现在代码里。
第一章 PSE 三角色分离
ai-workbench 的工作流引擎(main/workflow.js)把任务生命周期拆成三个角色,每个角色有独立的 system prompt,甚至可以用不同的模型配置:
// main/workflow.js — 角色凭证解析
const roleCreds = (role) => {
const id = models && typeof models === 'object' ? models[role] : undefined;
const configs = (settings && Array.isArray(settings.modelConfigs) && settings.modelConfigs) || [];
if (id) {
const cfg = configs.find((c) => c.id === id);
if (cfg) return { baseURL: cfg.baseURL, apiKey: cfg.apiKey, model: cfg.model };
}
// 回退:主模型
return { baseURL: settings.baseURL, apiKey: settings.apiKey, model: settings.model };
};
三个角色的职责在 main/prompts.js 中严格界定:
- Planner:只做拆解,输出带验收标准(AC)的 JSON 计划,不执行任何命令
- Specialist:只执行当前这一步,产出交付物,可以运行 shell 命令但结果作为证据回传
- Evaluator:独立验证,明确被要求不采信执行者自述
Evaluator 的 system prompt 开宗明义:
// main/prompts.js
const EVALUATOR_SYS = `你是一位独立评审工程师(Evaluator),负责验证 Specialist 的交付物是否满足该步骤的验收标准(ac)。
关键原则:证据驱动、不采信执行者自述。
- 你必须从交付物原文中引用证据(摘录关键句子/片段)来证明每条 ac 是否满足,而不是听信 Specialist "已完成" 的声明。
- 若本步骤提供了【命令执行结果】一节,应优先以真实输出为证据:依赖运行结果的 ac 可据「退出码」与「标准输出/标准错误」直接判 PASS/FAIL。
...`;
"不采信执行者自述"这一句话是整个架构的分水岭。单代理模型里,Agent 自己说"完成了"就进入用户审批环节;这里 Evaluator 会先问:证据在哪?
角色分离的意义不只是"多一个人检查"。更关键的是:不同角色用不同的 system prompt,甚至可以用不同的模型——Planner 适合用推理强的模型做拆解,Specialist 可以用快的模型做执行,Evaluator 需要一个"挑刺"心态的 prompt 来严格审查。这种分离让每个角色只关注自己的职责,不会被"我刚才写的应该没问题"的确认偏差影响判断。
如果 Evaluator 判定 PARTIAL(核心完成但有缺口),会把 feedback 注入下一轮 Specialist 重试。如果判定 FAIL 或 BLOCKED,触发 fail-fast 跳过依赖该步骤的后续步骤,避免级联空跑:
// main/workflow.js — fail-fast 机制
if (blockedBy.length) {
const verdict = {
verdict: 'SKIPPED',
feedback: `被 fail-fast 跳过:前置步骤 ${blockedBy.map((b) => `"${b.title}"(${b.verdict})`).join('、')} 已失败/受阻,本步骤不可能成功,不再执行以节省时间。`,
};
// ...跳过本步骤
}
第二章 给验证者一双独立的眼睛
Evaluator 的独立取证机制
这是 ai-workbench 最独特的设计。Evaluator 不仅要审查 Specialist 的交付物文本,还可以要求框架替它实跑一批只读命令来确认产物是否真实存在。
在 EVALUATOR_SYS 的输出格式中,有一个 verify 字段:
// main/prompts.js — Evaluator 输出格式
{
"verdict": "PASS" | "PARTIAL" | "FAIL" | "BLOCKED",
"acResults": [
{ "ac": "验收标准原文", "status": "PASS" | "PARTIAL" | "FAIL", "evidence": "从交付物或命令执行结果摘录的证据" }
],
"feedback": "若未完全通过,说明缺了什么;若通过则为空字符串",
"verify": ["可选:你希望框架代为执行的【只读】取证命令,例如 find/ls/cat/grep"]
}
工作流引擎收到 verify 字段后,会实际执行这些只读命令,把真实输出回贴给 Evaluator 做第二轮判定:
// main/workflow.js — 两阶段独立取证
if (Array.isArray(parsed.value.verify) && parsed.value.verify.length) {
on({ type: 'phase', phase: roleKey, message: `独立取证(${parsed.value.verify.length} 条只读命令)` });
for (const vc of parsed.value.verify.slice(0, 8)) {
const vr = await runReadOnlyCommand(String(vc || ''), projectDir, { timeoutMs: 30000, signal });
verifications.push({ command: vr.command, exitCode: vr.exitCode, stdout: vr.stdout, stderr: vr.stderr, blocked: vr.exitCode === 'FORBIDDEN' });
}
// 用取证结果做第二轮判定
const second = await chatStream({ ...base, messages: [
{ role: 'system', content: sysPrompt },
{ role: 'user', content: buildEvaluatorUser(task, step, specialistText, projCtx, executions, verifications, ...) },
]});
}
这意味着 Evaluator 不是在"看" Specialist 的自述,而是在"查"。Specialist 说"已创建 src/App.js",Evaluator 可以提交 find . -name App.js 去验证;Specialist 说"构建成功",Evaluator 可以提交 ls dist/ 去确认产物是否真实存在。
两阶段判定的设计让 Evaluator 先基于交付物文本做初步判断,如果发现"声称已创建但无法从文本确认"的情况,就主动提出取证命令,拿到真实输出后再做最终判定。这比单纯审查文本可靠得多——文件系统不会撒谎,退出码不会美化。
只读取证的安全契约
Evaluator 提交的取证命令不是直接交给 shell 执行的——它走的是一条比 Specialist 的受控执行更严格的安全通道。main/executor.js 中的 runReadOnlyCommand 有一套独立的白名单和禁令:
// main/executor.js — 只读取证白名单
const READONLY_ALLOWED = [
'ls', 'find', 'cat', 'grep', 'rg', 'head', 'tail', 'wc', 'stat', 'test', 'file',
'readlink', 'pwd', 'echo', 'printenv', 'realpath', 'du', 'tree',
'sort', 'uniq', 'cut', 'tr', 'basename', 'dirname',
// 框架只读健康检查命令
'cd', 'php', 'composer', 'mvn', 'gradle', 'ng', 'node', 'java', 'python', 'python3',
'go', 'cargo', 'ruby', 'bundle', 'dotnet', 'rails', 'pytest', 'tsc', 'jest', 'yarn', 'pnpm',
];
const READONLY_DESTRUCTIVE = [
'rm', 'mv', 'cp', 'dd', 'mkfs', 'chmod', 'chown', 'chgrp', 'touch', 'ln', 'mkdir',
'rmdir', 'git', 'npm', 'npx', 'curl', 'wget', 'kill', 'pkill', 'tee', 'shutdown',
'reboot', 'eval', 'exec', 'source', 'xargs', 'sudo', 'su', 'passwd',
];
const READONLY_FORBIDDEN_RE = /(>>?|\$\(|`)/; // 禁止重定向、命令替换、反引号
readOnlyReason 函数会逐项检查:首词是否在白名单内、整条命令是否含破坏性令牌(覆盖管道后续和链式命令)、是否含重定向或命令替换。任何违例都返回 exitCode='FORBIDDEN' 并附带可读原因,绝不执行。
这套机制的核心思想是:只读命令不会造成任何破坏,所以不需要用户确认——但前提是框架能可靠地判断一条命令确实"只读"。白名单 + 破坏性令牌黑名单 + 重定向/命令替换禁令三重校验,确保 Evaluator 的取证命令安全可控。
第三章 分层执行安全:从一刀切到四级模型
大部分 AI 代理的执行安全是一刀切的:重要动作前弹出确认,用户 approve 或 reject。简单清晰,但 ls 和 rm -rf / 需要同样的确认力度,既浪费用户注意力,又没有对真正危险的命令做特殊防护。
ai-workbench 把执行安全分成四个层级,在 main/executor.js 中实现:
第一层:FORBIDDEN — 直接拒绝,不弹确认
// main/executor.js
const FORBIDDEN_PATTERNS = [
{ re: /\bsudo\s+[\w-]/, why: 'sudo 需要在终端交互输入密码(UI 收不到提示,只会挂到超时)...' },
{ re: /\bsu\s+(-|\w)/, why: 'su 切换用户是交互式提权,禁止。' },
{ re: /\bpasswd\b/, why: 'passwd 是交互式命令,禁止。' },
];
sudo/su/passwd 这类交互式提权命令直接拒绝执行,不弹确认框。因为在 Electron UI 环境中没有控制终端,密码提示不会出现,只会挂死到超时。
第二层:DANGEROUS — 永远强制单独确认
const DANGEROUS_PATTERNS = [
/\brm\s+-rf?\s+\//, // rm -rf /
/\brm\s+-rf?\s+~/, // rm -rf ~
/\bmkfs\b/,
/\bdd\b\s+if=/,
/:\s*\(\)\s*\{/, // fork bomb
/\bchmod\s+-R?\s+0/, // chmod -R 0...
/\bshutdown\b/,
/\breboot\b/,
/>\s*\/dev\/(sd|hd|nvme)/, // 直写块设备
/\bcurl\b[^\n|]*\|\s*(sh|bash)/, // curl ... | sh
];
命中危险模式的命令一律强制单独确认,即使用户之前勾选了"记住本项目"也不豁免。
第三层:normal — 首次确认后可"记住"
同一项目目录在本次运行内首次执行需用户确认;用户勾选"记住"后,本项目后续非危险命令免确认:
let allowed = isDirApproved(projectDir) && !dangerous;
if (!allowed) {
const approved = await requestApproval({ emit: on, dir: projectDir, command: cmd, dangerous });
if (!approved) { executions.push({ command: cmd, denied: true }); continue; }
}
第四层:只读白名单 — 无需用户确认
Evaluator 的 verify 取证命令通过 runReadOnlyCommand 执行,走只读白名单校验,不需要用户确认但禁止任何写操作。
这四层模型的核心思想是:确认的成本应该与风险成正比。ls 不需要确认,rm -rf / 永远需要确认,sudo 根本不应该出现在自动生成的命令里。这比一刀切的 binary 审批更精细,也减少了用户因为确认疲劳而盲目点 approve 的风险。
第四章 环境感知:预防优于纠正
如果没有工具链预检,Agent 可能规划出一个需要 PHP 的任务,但本机没装 PHP,直到执行时才发现——浪费了规划和执行的成本。
ai-workbench 在工作流启动时先做一次性环境探测。main/capabilities.js 用 command -v(只读、无副作用)探测常用工具链:
// main/capabilities.js
const TOOLS = [
'node', 'npm', 'npx', 'python3', 'python', 'uv',
'php', 'composer', 'laravel', 'java', 'mvn', 'gradle',
'ng', 'go', 'cargo', 'ruby', 'docker', 'git',
];
function detectCapabilities() {
const caps = {};
for (const t of TOOLS) caps[t] = has(t);
caps._stacks = {
phpLaravel: !!(caps.php && caps.composer && caps.laravel),
nodeFrontend: !!(caps.node && caps.npm),
angularCli: !!(caps.node && caps.npm && caps.ng),
javaSpring: !!(caps.java && (caps.mvn || caps.gradle)),
pythonFastapi: !!(caps.python3 && caps.uv),
};
return caps;
}
探测结果注入 Planner 的提示词,明确告知哪些工具链可用、哪些缺失:
// main/workflow.js — 注入 Planner
s += `\n【运行环境工具链探测结果(本机真实状态,请严格遵守)】
可用工具链(command 存在):${avail || '(无)'}
缺失工具链:${missing || '(无)'}
技术栈就绪度:
${stackLines}
要求:
- 只能规划【可用工具链】能真正执行的任务;不要假定缺失工具已存在。`;
Planner 如果发现必需的工具链缺失,要么改用已可用的替代技术栈,要么在第一个 step 安排"安装依赖"步骤。这比让 Specialist 执行到一半才发现 command not found 要高效得多。
第五章 路径一致性:防止 LLM 重命名跑偏
这是一个实战中发现的真实问题。LLM 在多步骤任务中,如果在步骤 1 创建了 demo-spring-boot 目录,到步骤 4 重试时可能会"忘记"之前用的名字,另起一个 spring-boot-demo。这会导致验证命令在错误目录执行,Evaluator 误判 FAIL。
ai-workbench 用 inferProjectDir 从步骤 1 的 mkdir -p/cat > 命令中推断工程根目录名,然后注入后续所有步骤和重试的提示词:
// main/workflow.js
if (i === 0) taskProjectDir = inferProjectDir(executions) || taskProjectDir;
// 注入 Specialist 提示词
s += `
R${ruleN}.【强制复用工程根目录】本任务在步骤1已确立工程根目录「${taskProjectDir}」,你必须【严格且唯一复用】此根目录:所有命令的相对路径都基于它。
- 若本任务是【多组件 / 全栈】任务,每个组件都必须在「${taskProjectDir}」下的【各自子目录】中创建,绝不在工程容器根层另建平级兄弟目录。
验收路径依赖此根目录名,换名或建兄弟目录会导致产物路径不符被 Evaluator 判 FAIL。`;
同时还维护一个 createdDirs 集合,跨步骤累积真实创建的目录,注入后续步骤和 Evaluator 取证命令,强制使用完整路径、禁止裸名或缩短。
这种问题在文档场景中不太会遇到(文档不会因为路径名不同而"不存在"),但在软件工程场景中是高频陷阱——目录名是验证路径的基础,一旦跑偏,后续所有基于路径的检查都会失效。
第六章 证据归档:让运行可审计
AI 代理的运行过程往往是黑盒——出了问题不知道哪一步出了错,成功了也不知道为什么成功。ai-workbench 把每次运行的结构化证据落盘归档,所有退出路径——成功、用户中止、出错——都会归档:
// main/runlog.js
function saveRunLog(data) {
const dir = logsDir();
const ts = started.toISOString().replace(/[:.]/g, '-');
const base = `${ts}-${slugify(data && data.task)}`;
const jsonPath = path.join(dir, base + '.json');
const mdPath = path.join(dir, base + '.md');
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2), 'utf-8');
fs.writeFileSync(mdPath, buildRunMarkdown(data), 'utf-8');
return { jsonPath, mdPath };
}
JSON 文件包含完整的运行记录:runId、时间戳、任务类型、项目目录、Planner 计划、每一步的 Specialist 交付物、Evaluator 判定与证据、命令执行结果、工具链探测结果、整体结论。MD 文件是人读版,可以直接打开查看。
工作流引擎在 final(成功完成)、aborted(用户中止)、error(异常退出)三个分支都调用 persistRun:
// main/workflow.js
// 成功归档
persistRun();
// 用户中止
persistRun({ aborted: true });
// 异常退出
persistRun({ error: msg });
这意味着即使一次运行中途崩溃,你也能从日志中看到:Planner 规划了什么、每一步 Specialist 产出了什么、Evaluator 判定了什么、哪些命令被执行了、退出码和输出是什么。证据链完整、可追溯。
结语:从"信任自己"到"不信任执行者"
单代理自验证模型简洁高效——Agent 自己拆解、执行、判断完成,用户在关键节点兜底。对于"看起来对就够了"的场景,这完全够用。
但当任务涉及"构建是否成功""测试是否通过""路由是否注册生效"这类需要运行才能验证的场景时,自验证的可靠性会打折。Agent 的自述可能不可靠,用户的审批可能流于形式,而"看起来完成了"和"真的能跑"之间的鸿沟,只有在独立取证时才会暴露。
ai-workbench 的设计哲学很简单:不信任执行者。给验证者独立的角色、独立的 prompt、甚至独立的取证终端。让证据来自文件系统和退出码,而不是来自执行者的自我评价。这套机制更重、更复杂,但在软件工程场景中,真实的运行证据比任何自述都可靠。
源码导航
main/workflow.js– PSE 工作流主进程编排:Planner→Specialist→Evaluator 闭环、两阶段独立取证、fail-fast、路径一致性注入main/prompts.js– 三角色 system prompt(Planner/Specialist/Evaluator/Reviewer)、任务类型画像、Specialist 执行规则main/executor.js– 受控命令执行引擎:四级安全模型(FORBIDDEN/DANGEROUS/normal/只读白名单)、只读取证执行器main/capabilities.js– 运行环境工具链探测:command -v 预检 + 技术栈就绪度派生main/runlog.js– 运行证据归档:结构化 JSON + 人读 MD 双文件落盘main/keychain.js– macOS 钥匙串封装:API Key 加密存储、多账户支持main/project.js– 项目上下文采集:只读目录树 + 关键文件摘要main/llm.js– 大模型流式调用封装:统一代理/重试/日志electron.js– 主进程入口:IPC 通道注册、工作流事件转发preload.js– 渲染进程桥接:contextBridge 安全暴露 API
发表回复