12-Factor Agents 第六因子:用简单 API 实现 Agent 的启动、暂停与恢复(Launch/Pause/Resume)
12-Factor Agents 第六因子:用简单 API 实现 Agent 的启动、暂停与恢复(Launch/Pause/Resume)
导读:12-Factor Agents 的核心主张是"Agent 本质上就是程序",因此它应当像任何可靠的后端服务一样,能被用户、应用、流水线和其他 Agent 通过简单 API 启动、查询、暂停与恢复。本篇围绕第六因子展开:先阐明"简单 API 启动、长任务暂停、Webhook 恢复"三条准则及其与第五因子(统一执行状态与业务状态)、第八因子(掌控控制流)的关系,再以仓库 workshops/2025-05/final 中的可运行示例为佐证,展示一套完整可落地的启动 / 查询 / 恢复 REST API 与工具选择与执行之间的暂停点设计。读完你将掌握如何把 Agent 从"一次性会话脚本"升级为可中断、可恢复、可被外部事件唤醒的生产级程序。
Agent 也是程序:启动、查询、暂停、恢复是基本盘
原文开篇即点明立场:Agents are just programs。我们对一个程序天然有这些预期——如何启动它、如何查询它的状态、如何让它暂停、如何让它恢复,以及如何彻底停止它。如果把这些预期从普通程序扩展到 LLM 驱动的 Agent,就会得出第六因子的全部内涵:
- Launch(启动):用户、应用、流水线(pipeline)以及其他 Agent,都应该能通过一个简单 API 启动一个 Agent。
- Pause(暂停):Agent 及其外围的确定性编排代码,应当在需要等待某个长时运行操作(long-running operation)时把 Agent 暂停下来,而不是在内存里空转等待。
- Resume(恢复):Webhook 之类的外部触发器应当能够让 Agent 从它上次停下的地方继续,而不需要对 Agent 编排器做深度集成。
这三条准则与 factor 5 - unify execution state and business state(统一执行状态与业务状态)和 factor 8 - own your control flow(掌控自己的控制流)紧密相关,但第六因子强调:即使不实现另外两个因子,启动 / 暂停 / 恢复本身也可以独立落地。
原文还特别提示了一个常见误区:
很多 AI 编排器(AI orchestrator)确实支持 pause 和 resume,但恰恰不能在"工具被选中之后、工具被执行之前"这个瞬间暂停。参见 factor 7 - contact humans with tool calls 和 factor 11 - trigger from anywhere, meet users where they are。
这个"工具选择与工具执行之间"的间隙,正是人工审批、长时间任务等待(如训练流水线)、跨渠道唤醒等场景的落点,也是本因子在实战中最有价值的切入点。
仓库佐证:三个简单 API 即可完成启动、查询、恢复
12-Factor Agents 仓库的 workshop 完整工程位于 workshops/2025-05/final,其中 server.ts 把第六因子的三条准则压缩成了三个 REST 端点,是"简单 API"最直接的实现示范:
| 准则 | 端点 | 作用 |
|---|---|---|
| Launch | POST /thread |
用一条用户消息创建新 Thread,启动 Agent 循环 |
| Query | GET /thread/:id |
查询某个 Thread 的当前状态 |
| Resume | POST /thread/:id/response |
推送人类响应 / 审批结果,恢复被暂停的 Agent |
Launch:一个 POST 建 Thread 并跑 Agent 循环
// workshops/2025-05/final/src/server.ts
app.post('/thread', async (req, res) => {
const thread = new Thread([{
type: "user_input",
data: req.body.message
}]);
const threadId = store.create(thread);
const newThread = await agentLoop(thread);
store.update(threadId, newThread);
const lastEvent = newThread.events[newThread.events.length - 1];
// 若 Agent 因需要人类响应而退出循环,把 response_url 带给客户端,
// 客户端之后即可用该 URL 把新消息推回 Thread 实现恢复
lastEvent.data.response_url = `/thread/${threadId}/response`;
res.json({
thread_id: threadId,
...newThread
});
});
注意这里的关键设计:agentLoop 返回后,服务端把 response_url 附加到最后一个事件上返回给调用方。调用方无需理解任何编排细节,只要记住这个 URL,就能在未来把响应推回 Thread——这正是原文"用户、应用、流水线和其他 Agent 都能用简单 API 启动 Agent"的体现。
Pause:在工具选择与执行之间退出循环
Agent 什么时候应该"暂停"?答案在 agent.ts 的 agentLoop 中非常清晰:
// workshops/2025-05/final/src/agent.ts
export async function agentLoop(thread: Thread): Promise<Thread> {
while (true) {
const nextStep = await b.DetermineNextStep(thread.serializeForLLM());
thread.events.push({ "type": "tool_call", "data": nextStep });
switch (nextStep.intent) {
case "done_for_now":
case "request_more_information":
// 需要回复人类,直接返回 Thread,退出循环
return thread;
case "divide":
// divide 是高危操作,返回 Thread 等待人工审批
return thread;
case "add":
case "subtract":
case "multiply":
thread = await handleNextStep(nextStep, thread);
}
}
}
三种暂停情形一目了然:
done_for_now/request_more_information:Agent 决定本轮结束、或需要向人类澄清信息——在工具执行之前退出循环;divide:模型已经"选中"了 divide 工具,但它是高危操作,在调用handleNextStep之前就把 Thread 交还出去等待审批。
这正是原文所说的"编排器通常不能在工具选择与工具执行之间暂停"的反例——通过自己掌控循环,这个暂停点完全落在我们手里。配套的 agent.baml 用结构化输出定义了 HumanTools = ClarificationRequest | DoneForNow 两类"与人类交互"的工具,模型通过 intent 字段表达暂停意图,与确定性代码中的 switch 一一对应。
暂停之后如何判断"该等响应还是该等审批"?Thread 提供了两个谓词(agent.ts):
awaitingHumanResponse(): boolean {
const lastEvent = this.events[this.events.length - 1];
return ['request_more_information', 'done_for_now'].includes(lastEvent.data.intent);
}
awaitingHumanApproval(): boolean {
const lastEvent = this.events[this.events.length - 1];
return lastEvent.data.intent === 'divide';
}
Resume:POST 恢复端点,按暂停类型分流
恢复端点 server.ts 读取 Thread,根据 awaitingHumanResponse / awaitingHumanApproval 决定如何处理 payload,随后重新进入 agentLoop 继续跑下去:
app.post('/thread/:id/response', async (req, res) => {
let thread = store.get(req.params.id);
if (thread.awaitingHumanResponse() && body.type === 'response') {
// 人类澄清响应,直接作为事件追加
thread.events.push({ type: "human_response", data: body.response });
} else if (thread.awaitingHumanApproval() && body.type === 'approval' && !body.approved) {
// 审批被拒,把反馈作为 tool_response 交还给模型
thread.events.push({ type: "tool_response", data: `user denied the operation with feedback: "${body.comment}"` });
} else if (thread.awaitingHumanApproval() && body.type === 'approval' && body.approved) {
// 审批通过,此刻才真正执行被选中的工具
await handleNextStep(lastEvent.data, thread);
} else {
// 类型不匹配,返回 400 与当前等待状态便于调用方调试
}
// 恢复循环,直到再次出现需要暂停的事件
const newThread = await agentLoop(thread);
store.update(req.params.id, newThread);
// 再次把 response_url 交给客户端,形成可持续的"暂停-恢复"循环
lastEvent.data.response_url = `/thread/${req.params.id}/response`;
res.json(newThread);
});
可以看到,"恢复"不是重新从零开始,而是把外部事件(人类响应或审批结果)作为新事件追加到已有 Thread 上,再进入同一套 agentLoop。这要求 Agent 的全部状态都沉淀在 Thread 事件列表里——这正是第五因子"统一执行状态与业务状态"的收益:state.ts 的 ThreadStore 只是内存 Map + randomUUID,注释明确说明可替换为 redis、sqlite、postgres 等任何存储。只要能取出 Thread,就能在任何进程、任何时间点恢复。
同一模式的两种变体:同步阻塞 vs 异步 Webhook
- CLI 变体(同步阻塞):cli.ts 在没有
HUMANLAYER_API_KEY时用readline阻塞等待人类输入,通过HUMANLAYER_API_KEY时则通过邮件渠道fetchHumanResponse/fetchHumanApproval同步等待回复。适合单次交互场景,但进程一旦中断就得重跑。 - Server 变体(异步恢复):如上方
POST /thread/:id/response,是真正的生产模式——暂停时把 Thread 持久化、返回response_url,未来由任意外部触发器(HTTP Webhook、Slack/邮件回调等)唤醒恢复。这正是原文强调的"外部触发器应能让 Agent 从上次停下的地方继续,而无需与编排器深度集成"。
为什么要强调"简单 API":把 Agent 变成可组合的组件
原文列举了四类启动方:用户、应用、流水线、其他 Agent。当启动、暂停、恢复都收敛为几个简单 HTTP 端点时:
- 应用集成:前端/移动端只需
POST /thread拿到thread_id,再通过response_url把用户后续输入推回去,无需理解 LLM 循环; - 流水线集成:Airflow 式的 DAG 编排器可以把
POST /thread当作普通任务调用,长任务暂停期间释放资源,Webhook 回来再继续; - Agent 间组合:多 Agent 工作流中,一个 Agent 可以启动另一个 Agent 并等待其
response_url回调,形成 Agent→Agent 请求/响应; - 可观测与可审计:Thread 事件列表天然记录了完整历史,配合 factor 5 的统一状态思想,任何时刻都能知道 Agent 停在哪个事件上、在等谁。
与之配套,第八因子 进一步展示了在 handle_next_step 中混合"同步步骤(continue)"与"异步步骤(break 等待 Webhook)"的完整控制流代码,第六因子与它互相成就:第六因子解决"能暂停能恢复"的 API 形态,第八因子解决"在哪里暂停、暂停后做什么"的循环控制。
落地建议与注意事项
- 暂停点是核心竞争力:优先把暂停点放在"工具选择之后、工具执行之前",这样高风险工具(如部署、删除、转账)可以在执行前接受人工审批;否则只能退化为"内存中 sleep 等待、进程挂了从头重跑"、"只敢给 Agent 低风险只读工具"或"直接放开高危权限赌它不犯错"三种糟糕选择(详见 factor 8)。
- 状态全部进 Thread:恢复的前提是"能从任意点载入 Thread 继续跑",这意味着不要依赖进程内存中的零散状态,把当前步骤、等待状态等都作为事件沉淀在 Thread 中(factor 5)。
- 持久化选择自由:
ThreadStore只是内存演示,生产环境按需替换为 Redis / SQLite / Postgres,接口仅create/get/update三个方法,替换成本极低。 - 与第七、十一因子衔接:人类交互内容(澄清问题、审批选项、邮件/Slack 多渠道触达)详见 factor 7 - contact humans with tool calls;"让 Agent 在任何渠道被触发、在用户所在的地方与用户相遇"见 factor 11 - trigger from anywhere。
回到本源:Agent 就是程序。给程序配上 launch / query / pause / resume 这四个简单 API,它才谈得上被生产客户放心使用——这正是第六因子在 12-Factor Agents 中的位置,也是它独立于第五、第八因子也能单独落地的原因。
