会调用工具的 Agent 很容易演示,难的是让它知道什么时候必须停、哪些动作不能直接做,以及中断后从哪里继续。
如果这三个问题还没有确定答案,多加几个 Agent 只会把上下文、权限、成本和评测一起变复杂。本文先用一个框架无关的 JavaScript 骨架,把最大轮数、只读权限、审批暂停、检查点和取消放到模型之外。
这个循环可以演示能力,却没有回答几个生产问题:
- 模型一直重复调用同一个工具,谁来强制停止?
- 工具要发布文章、发消息或修改数据,谁来判断权限?
- 进程在第 6 轮中断,能否从最近一步继续?
- 最终答案正确,但中途访问了不该访问的数据,算不算成功?
- 用户点击取消后,模型流和正在执行的工具会不会一起停下?
这些问题不能靠一句“请谨慎使用工具”的 Prompt 解决。Prompt 可以影响模型行为,却不是确定性的权限系统、预算器或状态机。
先分清四层:模型、Loop、Runtime、Harness
为了避免把所有能力都叫作 Agent,可以先用四层理解:
| 层 | 主要职责 |
|---|---|
| 模型 | 根据当前上下文提出下一步动作或最终答案 |
| Agent Loop | 重复“模型决策—工具执行—状态更新—是否继续” |
| Runtime | 管理有状态执行、调度、持久化、暂停与恢复 |
| Harness | 围绕模型和运行时提供上下文、工具、权限、沙箱、反馈、观测与治理 |
这不是唯一的行业标准,而是一种实用的工程分层。LangChain 的官方产品概念也区分了 Framework、Runtime 与 Harness;OpenAI Agents SDK 的 Runner 文档则明确描述了 final_output、handoff、工具调用和 max_turns 组成的循环。它们说明的是各自产品实现,本文只提炼通用边界。
这里最重要的判断是:
模型负责提出动作,运行环境负责决定动作能不能执行、还能执行多久,以及失败后如何恢复。
一个最小可控 Loop 至少需要什么
第一版不必直接引入庞大的平台,但至少应有六类确定性控制。
1. 硬停止条件
停止条件不能只写在 Prompt 中,至少要覆盖:
- 得到符合输出契约的最终结果;
- 达到最大模型轮数、工具调用数、总时长或费用;
- 用户取消;
- 等待用户输入或人工审批;
- 命中安全策略;
- 出现不可重试错误;
- 连续无进展或重复调用。
需要先统一计量单位:一次完整任务是 run,一次模型调用是 turn,一次具体工具执行是 tool call。如果只写“最多 10 步”,并行工具调用或 handoff 后很容易统计失真。
2. 工具契约
每个工具至少声明:
- 输入、输出 Schema;
- 可读和可写范围;
- 是否有副作用;
- 风险等级与审批策略;
- 超时、重试和幂等策略;
- 返回给模型的结构化错误;
- 日志中需要脱敏的字段。
“搜索文档”和“删除文档”不应因为都叫文件工具而共享权限。授权应该围绕能力和影响,而不是工具名称。
3. 模型外权限
可以从一个简单分级开始:
| 风险 | 例子 | 默认策略 |
|---|---|---|
| 低 | 检索公开资料、读取允许目录 | 允许并记录 |
| 中 | 写草稿、创建临时产物、调用付费 API | 限额允许或询问 |
| 高 | 发布、发消息、修改正式数据 | 展示最终参数并审批 |
| 极高 | 删除、支付、扩大权限、任意命令 | 拒绝或进入专用隔离流程 |
审批时要展示最终执行对象和参数;批准后参数一旦变化,就要重新审批。OWASP 对提示注入的建议同样强调最小权限、外部内容隔离,以及对高影响动作加入人工介入。
4. 检查点与正式状态
不要把所有非完成情况都记成失败。最小状态可以包括:
running
completed
waiting_approval
waiting_input
failed
cancelled
budget_exceeded
检查点至少保存当前状态、配置版本、已经执行的工具、待审批动作和产物位置。写入、发送、创建等有副作用的动作还应带幂等键,恢复时先查询结果,避免重复执行。
5. 可取消与可恢复
取消信号要继续向下游传播到模型请求、工具进程和子任务,而不是只让页面上的按钮变灰。
恢复也不是“把整段聊天再发一次”。更可靠的事实来源是外部状态:任务目标、验收标准、当前步骤、配置快照、工具结果和产物索引。Anthropic 的长任务实践与 OpenAI 的 Harness Engineering 都把增量产物、可读环境和反馈回路放在重要位置。
6. 轨迹评测
只看最终答案会漏掉过程风险。至少同时看三层:
- 结果:任务是否完成,事实是否正确;
- 轨迹:工具选择、参数、顺序、重复调用和错误恢复是否合理;
- 系统:延迟、成本、失败率、审批次数和恢复能力。
一个答案碰巧正确,但调用了越权工具或成本失控的运行,不能算可靠。
一个不依赖框架的最小 JavaScript 骨架
下面的代码不是完整生产 Runtime,而是把最容易遗漏的控制点放在同一个可执行骨架里。decide 可以替换成任意模型适配器,工具只暴露明确的 risk 和 execute。
const TERMINAL = new Set([
"completed",
"waiting_approval",
"waiting_input",
"failed",
"cancelled",
"budget_exceeded",
]);
export async function runAgent({
goal,
decide,
tools,
checkpoint,
signal = new AbortController().signal,
limits = { maxTurns: 8, maxToolCalls: 12, maxMs: 30_000 },
}) {
const state = {
runId: crypto.randomUUID(),
status: "running",
goal,
turn: 0,
toolCalls: 0,
startedAt: Date.now(),
events: [],
};
const finish = async (status, extra = {}) => {
Object.assign(state, extra, { status, finishedAt: Date.now() });
await checkpoint(structuredClone(state));
return state;
};
await checkpoint(structuredClone(state));
try {
while (!TERMINAL.has(state.status)) {
if (signal.aborted) return await finish("cancelled");
if (Date.now() - state.startedAt >= limits.maxMs) {
return await finish("budget_exceeded", { reason: "max_time" });
}
if (state.turn >= limits.maxTurns) {
return await finish("budget_exceeded", { reason: "max_turns" });
}
state.turn += 1;
const action = await decide({
goal,
state: structuredClone(state),
signal,
});
if (action.type === "final") {
if (typeof action.output !== "string" || action.output.length === 0) {
throw new Error("final output does not match schema");
}
return await finish("completed", { output: action.output });
}
if (action.type !== "tool") {
throw new Error(`unsupported action: ${action.type}`);
}
const tool = tools[action.name];
if (!tool) {
state.events.push({
type: "tool_rejected",
turn: state.turn,
name: action.name,
reason: "tool_not_found",
});
await checkpoint(structuredClone(state));
continue;
}
if (tool.risk !== "read") {
return await finish("waiting_approval", {
pendingAction: {
name: action.name,
input: action.input,
risk: tool.risk,
},
});
}
if (state.toolCalls >= limits.maxToolCalls) {
return await finish("budget_exceeded", {
reason: "max_tool_calls",
});
}
state.toolCalls += 1;
const result = await tool.execute(action.input, { signal });
state.events.push({
type: "tool_result",
turn: state.turn,
name: action.name,
status: result.status,
});
await checkpoint(structuredClone(state));
}
return state;
} catch (error) {
return await finish("failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}
这段代码有意不做三件事:
- 不允许模型直接改变
risk; - 不在恢复时自动重放副作用动作;
- 不把异常堆栈和工具原文全部塞回模型上下文。
真实项目还要补上参数 Schema 校验、单次调用超时、有限重试、幂等键、持久化存储、敏感字段脱敏、重复调用检测,以及经过校准的评测集。
第一版为什么通常不需要多 Agent
Anthropic 的工程建议区分了由代码预设路径的 Workflow 和由模型动态决定过程的 Agent,并建议从简单、可组合的模式开始。
多 Agent 会额外引入:
- 上下文如何传递;
- 不同 Agent 的工具权限如何隔离;
- handoff 后谁对最终结果负责;
- 费用和轮数如何跨 Agent 统计;
- 循环嵌套后如何停止和恢复;
- 评测到底针对单个角色还是完整轨迹。
如果一个单 Agent 加三个只读工具都无法稳定通过真实问题集,增加角色数量通常只是放大不确定性。只有在角色隔离、独立权限或并行研究确实带来价值时,再引入 handoff 或子 Agent。
最小落地顺序
如果现在有一个“能调用工具”的 Demo,可以按这个顺序补齐:
- 写清任务、成功标准和失败状态;
- 给所有工具补 Schema、副作用与风险等级;
- 在模型外加入最大 turn、工具数、时长和取消;
- 高影响动作改为
waiting_approval,不直接执行; - 每个有意义步骤后写检查点;
- 为结果、轨迹、成本和安全建立同一套回归样本;
- 稳定后再决定是否需要专用 Runtime、多 Agent 或平台化。
真正的工程分水岭,不是模型能不能调用更多工具,而是系统能否明确回答:
这次运行做了什么、为什么能做、何时必须停、失败后从哪里继续。
事实、验证与边界说明
- 官方事实:OpenAI Agents SDK 的 Runner 循环、
max_turns行为;LangChain 文档中的 Framework / Runtime / Harness 产品分类;Anthropic 对 Workflow 与 Agent 的区分;OWASP 对提示注入、最小权限和人工介入的建议。 - 工程归纳:本文的四层模型、风险分级、最小状态集和落地顺序,是对多份官方资料的综合,不是统一行业规范。
- 代码验证:示例为框架无关骨架,已使用 Node.js 22.16.0 完成语法校验;未连接真实模型和生产工具。
- 个人实践边界:本文不声称已经在“水獭比特技术知识助手”或其他生产项目中完成部署、压测和长期运行。
- 待验证项:不同 Runtime 的恢复语义、复杂审批流、并行工具和多 Agent 成本,需要在具体项目中重新验证。