12-Factor Agents:构建Agent应用的12个因素
12-factor-agents是一份观点 + 教材 + 可运行样例的合集。和OpenHarness对比:两者都反对“模型想做什么就直接执行什么”的粗糙 Agent loop,但前者更像一套判断 Agent 是否可靠的工程原则,后者更像一个把这些原则扩展到 Coding Agent 场景的完整运行时工程样板。
如果把 OpenHarness 看成一座功能完整的建筑,包含 CLI/TUI、工具注册、权限、MCP、压缩、记忆、插件、多 Agent、沙箱和 UI,那么 12-Factor Agents 更像检查这座建筑承重边界的尺子。它关心的不是“模块够不够多”,而是 prompt 是否可控、上下文是否由应用构造、工具调用是否只是结构化输出、状态是否可恢复、选择工具和执行工具之间能否暂停。换句话说,OpenHarness 回答“一个 Coding Agent Runtime 应该有哪些模块”,12-Factor Agents 回答“无论模块多少,哪些控制权不能交给黑盒”。
它的作者 Dex(HumanLayer 创始人)想回答一个很具体的问题:
怎样才能构建出可靠到能交付给生产客户的 LLM 应用?
这个问题背后是他反复见到的一个失败模式。很多团队想给产品加上 Agent 能力,于是抓一个流行框架,很快做到 70–80% 的体验,然后卡住——因为剩下的 20% 才是决定"能不能给真实用户用"的部分,而这部分往往要求你回过头去逆向框架的 prompt、控制流和状态管理,最后不得不推倒重来(README.md:153-161)。
作者由此得出一个和主流叙事相反的判断:真正跑在生产里、面向客户的好 Agent,大部分其实是普通软件,只在少数关键节点插入 LLM 决策,而不是"给个目标、塞一袋工具、循环到完成"那种模式(README.md:39-45)。
所以这个项目的名字致敬了经典的 12-Factor App。当年那份文档讲的是"云原生应用该守哪些工程约定";这份文档讲的是"构建可靠 LLM Agent 该守哪些工程约定"。这里的"12 个因素"不是 12 个参数,也不是必须全部打勾的清单,而是12 条设计原则——你可以只挑其中几条,塞进你现有的产品里。
仓库里到底有什么
打开仓库你会看到三样东西,它们分别对应"讲道理、给样例、教怎么搭":
- 理念内容:
content/目录下的 12 篇 factor 文章加一篇附录,是这个项目的主体和灵魂。 - 可运行样例:
packages/create-12-factor-agent/template/,一个用 TypeScript + BAML + Express + HumanLayer 写的小 Agent,能真的跑起来做计算、找人审批。 - 教学构建系统:
workshops/和packages/walkthroughgen/,把上面那个样例拆成一步步的 workshop,并用一个小工具从 YAML 生成教程文档和分章节代码。
需要说清楚的是:这个样例会真的执行任务,但任务本身是教学性的——一个计算器加上人类审批流程,不是通用的生产 Agent。它的价值在于用最小的 domain 把"机制"讲透,而不是覆盖真实业务的复杂度。
12 个因素,一句话一条
先把 12 条快速过一遍,建立全景,后面再讲它们如何在代码里落地:
- 自然语言转工具调用——把"帮我退 750 美元"翻译成结构化对象
{ intent: "refund", amount: 750 },再交给确定性代码处理。 - 掌控你的 prompt——prompt 是一等公民代码,要能测试、能版本化、能随时替换,别塞进框架黑盒。
- 掌控你的上下文窗口——喂给模型什么、按什么格式喂,由你决定,不被 SDK 的标准 message 历史绑死。
- 工具只是结构化输出——"调用工具"本质只是模型吐了段 JSON;是否执行、何时执行、怎么执行,全由你的代码说了算。
- 统一执行状态和业务状态——尽量用一条事件历史同时表达"发生了什么"和"进行到哪一步",别维护两套状态。
- 用简单 API 启动/暂停/恢复——Agent 是程序,就该能被启动、能暂停等待、能靠外部信号恢复。
- 用工具调用联系人类——"找人确认"也是一种结构化事件(
request_approval、done_for_now),和普通工具走同一条事件流。 - 掌控你的控制流——哪些工具直接执行、哪些要中断等审批,由你的代码决定,而不是框架的循环。
- 把错误压缩进上下文——出错时不要把原始堆栈无限塞回上下文,要压缩成模型能读懂、能恢复的形式。
- 小而专注的 Agent——别造无所不能的巨型 Agent;上下文一膨胀,模型就容易走偏。
- 从任何地方触发——Agent 不只从聊天框启动,也可以来自 Slack、邮件、cron、webhook、报警。
- 让 Agent 成为无状态 reducer——理想形态是"输入事件历史 → 输出下一步",状态外置,可恢复、可重放。
这 12 条并不是彼此独立的,它们其实收敛到同一条主线。
一条主线串起所有因素
如果只记一句话,那就是:把 LLM 关进一个很窄的职责里,其余的控制权全部留在你自己的代码手上。
顺着这条主线,12 个因素会自然分成几个层次:
- LLM 只负责一件事——看着"到目前为止发生了什么",输出"下一步该干什么"的结构化意图(factor 1、2、3、4)。
- 执行、暂停、恢复、联系人类,全是确定性代码的活儿(factor 6、7、8、9)。
- 状态用一条事件线程统一承载,既是上下文,也是业务历史,也是恢复锚点(factor 5、12)。
- 整个 Agent 保持小而专注,靠外层确定性外壳控制风险边界(factor 10、11)。
这条主线也解释了作者的产品立场:不是要你换一个更强的框架,而是要你想清楚——哪些控制权,无论如何都不能交出去。
样例代码如何印证这条主线
光讲原则容易空。这个项目的高明之处在于,它把原则压进了一个几十行的小 Agent 里,让你能一眼看到"原则长什么样"。
LLM 只决定,不执行。样例里那个被 LLM 调用的函数DetermineNextStep,返回值是一个联合类型:要么是计算工具(加减乘除),要么是联系人类的工具(要澄清、要审批、报告完成)(template/baml_src/agent.baml:26-32)。模型做的全部事情,就是根据当前线程输出其中一种意图。真正的执行权在 TypeScript 的agentLoop里:它把模型的输出记为一次tool_call,然后用 switch 分流——加减乘可以当场算,但divide(被当成"危险操作")和所有联系人类的意图,都会直接return出去交给外层处理(template/src/agent.ts:89-113)。
这正是 factor 4 和 factor 8 的核心:模型"选了"某个工具,不等于代码必须立刻、以同样方式执行它。能在"选择工具"和"真正调用"之间插一刀,正是审批、回滚、人类介入得以存在的前提。
一条事件线程,就是全部状态。样例里的Thread类只有一个核心字段:events数组(template/src/agent.ts:8-14)。它有个serializeForLLM()方法,把所有事件拼成一段 XML 风格的文本喂给模型——这就是 factor 3 说的"LLM 的输入,本质就是’发生了什么 + 下一步是什么’“。存储层也没有额外维护"当前第几步、在等什么、重试了几次"这类编排状态,而是直接把整条线程序列化成 JSON 落盘,顺便再写一份人类可读的 txt(template/src/state.ts:15-31)。这就是 factor 5 和 factor 12:状态是单一事实源,天然可序列化、可恢复、可分叉、可观察。你想知道 Agent 当时"看到了什么、选了什么、人怎么回的”,打开那个.threads/<id>.txt就行。
内循环负责推进,外循环负责等待。agentLoop是内循环——不停问模型下一步,能同步做的当场做,做不了的就退出来。外循环则有两个化身:命令行版本创建线程、跑内循环、用命令行提示接收人类的审批或补充,再把回复追加回线程(template/src/cli.ts:10-36);HTTP 版本则从 HumanLayer 的 webhook 事件里恢复线程,处理审批结果或人类回复,再继续跑内循环(template/src/server.ts:69-110)。这两者一起演示了 factor 6 和 factor 7:Agent 能用简单 API 暂停、靠 webhook 恢复,而"联系人类"本身就是事件流里的一环,靠把thread_id存进外部交互状态来实现跨请求的接力。
跟着样例跑一遍
理解这套东西最快的方式,是想象几个真实场景:
做一道多步计算。你在命令行敲npx tsx src/index.ts 'can you multiply 3 and 4, then divide by 2 and add 12'。整句话被当作一条user_input放进线程,模型开始一轮轮地选下一步:乘法当场算、结果追加回上下文、再问模型……直到它输出"完成"并给出最终答案。除法这一步会触发审批(见下)。全程线程被不断写进.threads/,留下完整轨迹。
除法需要人类点头。模型可以选择divide,但样例故意把它当成危险操作:内循环一选中它就退出,绝不先算。外循环转而请求人类审批——命令行里按回车批准,输入任何文字则视为拒绝并把反馈写回线程,让模型带着反馈重试(template/src/agent.ts:105-107,template/src/cli.ts:69-80)。这里的产品意义很关键:高风险操作不是靠"相信模型不会算错"来兜底,而是靠控制流边界来兜底。
用 webhook 恢复一个长流程。外部系统创建会话,HumanLayer 回调进来,server 建线程、跑内循环;如果最后一步需要人类(澄清或完成),就创建一个 human contact,如果是危险操作,就创建一个 function call,并把thread_id塞进 HumanLayer 的状态里(template/src/server.ts:112-149)。等人类在邮件或 Slack 里回复后,下一个 webhook 带着thread_id回来,server 找回原线程、追加回复、继续推进。整个过程跨越了多个 HTTP 请求,但对 Agent 来说只是"同一条线程被恢复了"。
维护教程本身。这是个元用例:维护者把教学步骤写成 YAML(每步含说明、文件复制、命令、预期输出),walkthroughgen读了之后生成 markdown、逐章节的工作目录和最终项目(packages/walkthroughgen/src/index.ts:75-145)。它不属于 Agent 运行时,而是保证"教程文字、示例代码、最终产物"三者不漂移的基础设施。
谁会用它,为什么值得读
它的目标读者,是那些正在把已有 SaaS 或内部系统"变得更 agentic"的技术创始人、AI 工程师和产品团队——正是 README 里描述的、走到 80% 就卡住的那群人。
它给这群人的价值,不是"又一个框架",而是一个和主流相反、却更贴近生产经验的判断框架:不要从"选哪个框架"开始,而要从"哪些控制权不能交出去"开始。而且这些原则不绑定任何技术栈——文档里 BAML 只是举例,factor 2 明确说你可以用任意 prompt 工具甚至手写模板(content/factor-02-own-your-prompts.md:69-75)。一句话概括它的定位:一套把 Agent 从黑盒框架拉回生产软件工程的原则和模板。
它的代价与边界
作为一份诚实的分析,也要说清楚它没做什么、哪里不能照搬:
- 样例不是生产就绪的。它缺鉴权、并发锁、幂等、webhook 签名校验、持久化数据库、可观测性和严格的错误恢复。它是用来讲机制的,不是用来上线的。
- 示例 domain 太窄。计算器足以说明"选择与执行分离",但真实业务工具的权限、回滚、审计和数据依赖复杂度,它一概没碰。
- 有教学草稿的痕迹。比如
server.ts里若干 async 的存储调用缺了await(template/src/server.ts:83,template/src/server.ts:116-119);BAML 里定义了ProcessRefund意图,但 TypeScript 侧的handleNextStep并没有覆盖它(template/baml_src/agent.baml:19-32,template/src/agent.ts:49-87)。当教材读没问题,当代码抄要小心。 - 文件命名有历史包袱。
content/下既有factor-2-own-your-prompts.md又有factor-02-own-your-prompts.md,前者只是指向后者的迁移指针。阅读时以 README 里 01–12 的链接为准。
如果从零重建,该怎么推进
顺着主线,重建这个样例的合理顺序是这样的——每一步都对应一个要解决的压力,而不只是"实现某个模块":
先让模型能输出结构化的下一步。压力是"自然语言不能直接驱动副作用"。此时只读用户输入、不写任何外部状态;不变量是模型输出必须落进 schema 联合类型。验收标准:输入"add 3 and 4",能得到
{ intent: "add", a: 3, b: 4 }。加一个确定性的工具分发器。压力是"模型负责选,但执行必须由代码掌控"。它负责追加
tool_call和tool_response;不变量是未知意图要 fail-fast,高风险意图不能自动执行。验收标准:多步计算能在线程里留下完整轨迹。把线程做成唯一的状态锚点。压力是"多轮、暂停、恢复都需要可序列化的历史"。events 是主状态,thread id 是外部引用;不变量是执行状态尽量从 events 推导,不另建隐式状态机。验收标准:存盘再加载,
serializeForLLM()的结果一致。拆开内循环和外循环。压力是"人类审批和长任务不能阻塞内存里的循环"。外部的联系/审批状态要携带 thread id;不变量是"选择工具"和"调用工具"之间必须能暂停。验收标准:除法先生成审批请求,批准后才执行。
接上 HTTP / webhook 触发。压力是"Agent 不该只能在命令行里活着"。从 webhook body 恢复线程、追加回复、继续内循环;不变量是所有恢复路径都要校验线程存在和事件类型合法。验收标准:
会话创建 → 审批完成 → 报告完成能跨请求走完。最后才做生产化补齐。压力是"教学 demo 扛不住真实客户数据"。把文件存储换成数据库,加上幂等键、锁、审计、trace、metrics;不变量是人类审批、权限校验、业务副作用和 LLM trace 要分层记录。验收标准:重复的 webhook 不会重复执行工具,并发回复不会互相覆盖线程,高风险工具都留有审批证据。
它给"如何分析一个 Agent"带来的启发
读完这个项目,最大的收获或许不是那 12 条本身,而是它提醒我们:分析一个 Agent,不能只数"有没有工具、有没有 memory、有没有 planner"。真正决定它能不能上生产的,是控制权的归属:
- prompt 是否可测试、可版本化、可替换;
- 上下文是应用主动构造的,还是被 SDK 的 message 历史绑死;
- "选择工具"和"调用工具"能不能分离;
- 状态能否从业务事件推导,而不是藏在框架运行时里;
- 人类是不是一种结构化事件,而不是聊天 UI 的特例;
- Agent 是否足够小,能靠确定性外壳把风险圈住。
换句话说,判断一个 Agent 系统是否可靠,可以看这样一组边界是否清晰:
LLM 决策边界 + 上下文构造权 + 工具执行权 + 状态归属 + 暂停/恢复粒度 + 人类 / 外部触发的接入方式 + 小 Agent 的范围控制如果一个系统在这些边界上不可见、不可插入、不可恢复,那么无论它的框架抽象多漂亮,都很难真正达到生产级可靠性。这,就是 12-Factor Agents 想让你记住的判断框架。
