跳到正文

目录

12-Factor Agents:把 LLM 应用从 Demo 拉进生产线的工程原则

目录

12-Factor Agents:把 LLM 应用从 Demo 拉进生产线的工程原则

核心判断

HumanLayer 联合创始人 Dex Horthy 访谈了至少 100 位 SaaS 构建者(多数是技术背景的创始人),得出一条规律:真正交付到生产的 LLM 软件,绝大多数是传统软件里嵌入 LLM 步骤的混合体,不是纯 Agent。

12-Factor Agents 回答的是三个问题:

  • 为什么 Agent 框架跑出来的 demo 停在 70-80% 后再也上不去?
  • 从 demo 到生产级之间,缺的是模型能力还是工程结构?
  • 不用框架、自己搭的话,先做哪一块?

仓库地址是 github.com/humanlayer/12-factor-agents,配套有视频讲解、Discord 社区、Dex 团队按这套方法维护的开源参考实现 got-agents/agents,以及一个仍在征集共建者的脚手架 create-12-factor-agent(截至本文发布尚未发布包)。团队一般拿它做两件事:审视现有实现里哪些环节失控,以及指导新项目从第一行代码开始的结构。

目录


Agent 首先是软件

软件可以看作有向图

CI/CD 流水线里的"步骤 A 完成后再跑步骤 B",就是 DAG(有向无环图)。过去二十年,Airflow、Prefect、Dagster、Inngest、Windmill 这些工具把 DAG 编排变成了一件可观测、可重试、可在 UI 上点两下就恢复的事——节点挂了能看到是哪个节点,跑崩了能从失败点重来,不用盯着终端手动补数据。

Agent Loop 的承诺与现实

Agent 范式的承诺很诱人:让 LLM 自己决定路径,扔掉 DAG。 工程师只提供"边"(可用的工具),模型在运行时决定走哪些"节点"。少写编排代码,LLM 说不定还能发现你没考虑到的解法,错误自动恢复。

但实际跑起来的 Agent Loop 包含三步:

# 简化版 Agent Loop
while not done:
    # 1. LLM 输出下一步要做什么
    next_step = await llm.decide(context)
    
    # 2. 确定性代码执行这个工具调用
    result = await execute(next_step)
    
    # 3. 执行结果追加进上下文窗口
    context.append(result)

# 重复,直到 LLM 判断"完成"

用 Python 完整写出来的话:

from dataclasses import dataclass, field
from typing import Literal, Any, Callable
import json
from openai import AsyncOpenAI

@dataclass
class NextStep:
    intent: Literal["tool_call", "done"]
    tool: str | None = None
    args: dict[str, Any] = field(default_factory=dict)
    final_answer: str | None = None

class LLMClient:
    """基于 OpenAI SDK 的 tool calling 实现。"""

    def __init__(self, model: str, schemas: dict[str, dict], tools: dict[str, Callable]) -> None:
        self.client = AsyncOpenAI()
        self.model = model
        self.schemas = schemas  # 每个工具的 JSON Schema
        self.tools = tools      # 工具名 -> 实际执行函数

    async def determine_next_step(self, context: list[dict]) -> NextStep:
        response = await self.client.chat.completions.create(
            model=self.model,
            messages=context,
            tools=[
                {"type": "function", "function": {"name": name, "parameters": schema}}
                for name, schema in self.schemas.items()
            ],
        )
        choice = response.choices[0].message
        if choice.tool_calls:
            call = choice.tool_calls[0]
            return NextStep(intent="tool_call", tool=call.function.name, args=json.loads(call.function.arguments or "{}"))
        return NextStep(intent="done", final_answer=choice.content)

async def execute_step(step: NextStep, tools: dict[str, Callable]) -> dict:
    """确定性代码执行工具调用。"""
    handler = tools.get(step.tool or "")
    if handler is None:
        return {"status": "error", "reason": f"unknown tool: {step.tool}"}
    return {"status": "executed", "result": handler(**step.args)}

async def agent_loop(context: list[dict], llm: LLMClient, tools: dict[str, Callable]) -> str:
    while True:
        next_step = await llm.determine_next_step(context)
        context.append({"role": "assistant", "tool_call": next_step.tool, "args": next_step.args})
        if next_step.intent == "done":
            return next_step.final_answer or ""
        result = await execute_step(next_step, tools)
        context.append({"role": "tool", "name": next_step.tool, "result": result})

这套循环裸跑的质量通常停在 70-80% 区间。“质量"指端到端任务成功率——demo 跑 10 次有 7-8 次能通,剩下 2-3 次要么走偏要么卡死。再往上提,只能靠对循环中每一步的工程控制,模型本身给不了更多。

从 70% 到生产级的典型翻车路径

Dex 在访谈中发现了一条反复出现的轨迹:用 LangChain 或 CrewAI 搭一个 Agent demo,跑出 70-80% 的成功率,觉得差不多了就推到真实客户场景,然后炸了——用户不接受"大多数时候对”。团队开始逆向工程框架内部注入的 prompt、flow、状态管理,发现框架替他们做的决策恰好是自己需要掌控的那部分,于是从零重写。

12-Factor Agents 做的事,是把重写时才会悟到的那些结构决策提前摆出来,让第一版实现就少走弯路。比如 Agent 在生产环境里莫名"卡住",排查半天,最后发现执行状态存在进程内存里,重启后全丢了——这类坑每条 Factor 都对应一个。


系统地图

12 条正式原则加 1 条荣誉提及分布在三层上。这个分层对应的是概念分类,不是执行流水线——遇到问题先定位层次,再找对应 Factor。

三层各自管的事:

层次管什么核心工程问题
输入输出层 (F1, F3, F4, F9, F13)LLM 看到什么、输出什么上下文格式怎么设计?工具契约怎么定义?错误怎么反馈?
控制层 (F2, F8, F10, F12)谁来决定下一步prompt 所有权归谁?while loop 归谁写?Agent 粒度多大?
运行层 (F5, F6, F7, F11)Agent 怎么在生产环境活下来状态怎么持久化?人怎么介入?触发源有哪些?

怎么用这个地图:Agent 在生产环境里停了就再也恢复不了→运行层的问题(F5/F6)。LLM 总是调错工具→输入输出层的问题(F1/F4)。控制流逻辑散在 prompt 里到处都是→控制层的问题(F8/F12)。

一次真实任务穿过系统长什么样

下面用一个完整任务流把 12 条原则串起来。读每个 Factor 时,可以回到这张图确认"这条管的是哪个环节"。

场景:用户通过 Slack 消息要求部署后端服务的最新版本到生产环境。

用户消息到达
[F11] 从 Slack webhook 触发,与用户在对话中相遇
[F13] 预取:拉取 Git tags、最近部署记录、变更日志
[F3] 构建上下文窗口:Slack 消息 + 预取数据打包为 XML 事件
[F1] LLM 输出结构化决策:{ "intent": "tool_call", "tool": "list_git_tags" }
[F4] 确定性代码执行 `git tag --list`,不是 LLM 在执行
[F9] 如果 `git tag` 失败 → 错误摘要压入上下文,LLM 决定重试或报告
[F8] 控制流归软件:LLM 选择工具,但 if/else / 循环由代码管理
[F7] 部署前需审批 → Agent 发 Slack "确认部署 v1.2.3 到生产?"
[F6] 等待审批时持久化状态、释放资源
[F7] 用户回复 "yes" → Agent 恢复执行
[F5] 部署过程中,执行状态和业务状态同一数据源追踪
[F2] 整个流程中,prompt 完全由团队自己的代码显式构建
[F10] 部署 Agent 只做部署,报告 Agent 只做报告,互不干扰
[F12] 整个 Agent 可作为纯函数回放:(状态, 事件) → 新状态

这个任务流贯穿了全部 12 条正式原则加 1 条荣誉提及(预取)。


逐条拆解

Factor 1:自然语言 → 工具调用

用户说了一句人话,Agent 把它转成一次函数调用。

用户说:

“create a payment link for $750 to Terri for sponsoring the february AI tinkerers meetup”

Agent 应该输出:

{
    "type": "function",
    "function": {
        "name": "create_payment_link",
        "parameters": {
            "amount": 750,
            "customer": "cust_128934ddasf9",
            "product": "prod_8675309",
            "price": "prc_09874329fds",
            "quantity": 1,
            "memo": "Hey Terri - see below for the payment link for the february AI tinkerers meetup"
        }
    }
}

之后代码接手:

next_step = await llm.determine_next_step(
    [
        {"role": "user", "content": "create a payment link for $750 to Terri "
         "for sponsoring the february AI tinkerers meetup"}
    ]
)

if next_step.tool == 'create_payment_link':
    stripe.paymentlinks.create(**next_step.args)
    return

参数里的 customerproductprice 这些 ID 怎么来的?LLM 不会凭空生成。要么靠 Factor 13(预取)提前加载到上下文,要么靠 Factor 3(上下文工程)让 LLM 有足够信息做实体映射。Factor 1 的输出质量,取决于上游喂进去了什么。

模型在参数选择上反复出错时,先查上下文里有没有足够的候选数据,别急着调 temperature 或换模型。


Factor 2:提示词是自己的资产

提示词要能 git grep 到、能在 code review 里被讨论、能单独跑回归测试——把它当代码管,而不是当配置项藏在框架底层。

大多数框架会在你不知道的情况下注入 system prompt——角色设定、行为约束、工具使用规则。三个后果:

  • 行为不稳定:框架升级后,注入的 prompt 变了,Agent 行为跟着变——而你根本不知道哪条规则变了。
  • 难以复现:同样的用户输入,因为框架内部状态不同,输出不同。
  • 调试困难:出问题时,你不知道这条指令来自你自己写的 prompt,还是框架在背后加进去的。

做法:用 Git 管理 prompt 文件,在代码里显式构建完整的 prompt 字符串。 哪怕底层 LLM 调用仍然用了某个框架,至少 prompt 的完整内容你能一键定位到。

排查 Agent 行为问题时,能不能在 5 分钟内定位到"哪段代码构建了这条 prompt"?做不到的话,prompt 就不算是你的资产。

如果一段 system prompt 超过 50 行,考虑把其中可独立验证的约束——比如"工具选择规则"和"输出格式规范"——拆成可 diff、可评分的小段。这跟常规软件里拆巨型函数是同一个道理。


Factor 3:掌控上下文窗口

上下文窗口决定了 Agent 能看到什么。LLM 只看得到塞进窗口的内容,怎么组织这些内容,直接决定了 Agent 的决策质量。

标准消息格式的隐性成本。 大多数 LLM 客户端默认用标准消息格式:

[
    {"role": "system", "content": "You are a helpful assistant..."},
    {"role": "user", "content": "Can you deploy the backend?"},
    {
        "role": "assistant",
        "content": null,
        "tool_calls": [{"id": "1", "name": "list_git_tags", "arguments": "{}"}]
    },
    {
        "role": "tool",
        "name": "list_git_tags",
        "content": "{\"tags\": [{\"name\": \"v1.2.3\", \"commit\": \"abc123\"}]}",
        "tool_call_id": "1"
    }
]

这套格式有两个实际问题。第一,每个消息块有固定的元数据开销(role、tool_call_id 等),长对话中 token 消耗可观。第二,role: tool 的语义是给模型看的,不直观反映"这是哪一步、产生了什么数据"——对工程师不够可读,对模型来说也不一定是最优的注意力分配。

自定义事件格式。 12-Factor Agents 建议用 XML 风格的自定义格式,把每一步建模为事件:

from dataclasses import dataclass
from typing import Any
import yaml

@dataclass
class Event:
    type: str   # 事件类型:工具名,或 "slack_message" 这类来源标记
    data: Any

@dataclass
class Thread:
    events: list[Event]

def event_to_prompt(event: Event) -> str:
    data = event.data if isinstance(event.data, str) else yaml.safe_dump(
        event.data, sort_keys=True, allow_unicode=True
    )
    return f"<{event.type}>\n{data}\n</{event.type}>"

def thread_to_prompt(thread: Thread) -> str:
    return "\n\n".join(event_to_prompt(e) for e in thread.events)

转换后的上下文窗口:

<slack_message>
 From: @alex
 Channel: #deployments
 Text: Can you deploy the latest backend to production?
</slack_message>

<list_git_tags_result>
 tags:
 - name: "v1.2.3"
 commit: "abc123"
 - name: "v1.2.2"
 commit: "def456"
</list_git_tags_result>

what's the next step?

XML 标签直接标记了信息边界,模型更容易定位相关内容,降低"中间丢失"(lost in the middle)效应。对工程师来说,事件结构本身就是调试信息——直接看到每一步的类型和数据,不需要在 JSON 嵌套里翻。

RAG、跨会话记忆、Schema 对齐解析(BAML 等工具)这些技术各管一段数据供给,但有一条前提不变:上下文的构建方式必须完全由你控制,否则你永远没法系统性地优化 token 效率和注意力分配。

每次做上下文裁剪时,先删掉"已被后续步骤取代的信息"。比如第 3 步生成了部署报告,那第 1 步的原始 build log 大概率不再需要留在窗口里。上下文工程的关键是"这一轮 LLM 真正需要看到什么",不是看能塞多少 token。


Factor 4:工具调用就是结构化输出

Function Calling、Structured Outputs(JSON Mode)、约束解码——这三件事解决的是同一个问题:让 LLM 输出特定格式的结构化数据。Agent 只是其中一种用法,不是专属特性。

有一个常见的误解需要澄清:给 LLM 挂一个工具,只是在要求它以某种格式输出数据;真正执行工具的是你自己的代码,LLM 只负责决定"调哪个、参数填什么"。

工具设计要按 API 设计的标准做——参数类型、校验逻辑、错误返回格式,这些和普通软件工程里的 API 设计没有区别。Agent 行为不稳定时,第一步排查的应该是"工具定义是否清晰"和"上下文里是否有足够信息让 LLM 做参数选择",而不是去调 temperature。

常见反模式:给 LLM 挂了 20 个工具,每个工具的参数描述含糊,然后怪模型不够聪明。人类工程师拿到 20 个文档不全的 API,也未必能在一轮对话里准确选出该调哪个、参数该怎么填。

工具返回值的格式同样影响 Agent 质量。返回一个 5KB 的 JSON blob 不如返回一个被精心裁剪的结构体——只包含 LLM 做下一步决策需要的字段。工具设计是双向的:输入参数设计 + 输出格式设计,缺一不可。


Factor 5:执行状态与业务状态统一

Agent 跑起来之后,会自然长出两套状态。一套是执行历史——哪些步骤完成了、当前在哪一步;另一套是业务状态——订单状态、用户资料、审批结果。大多数实现会在不知不觉中把它们分开维护:执行状态塞在 Agent 进程的内存里,业务状态存在数据库里。

分开维护的后患:Agent 重启后执行状态丢失,但数据库里显示"订单已创建,后续步骤没执行完"。这种不一致是 Bug 的温床。

正确的做法是把执行状态也当成业务状态的一部分。用单一数据源——PostgreSQL 一行、Redis 一个 key、一条事件溯源日志——同时描述"任务当前在哪里"和"业务当前是什么状态"。这是 Factor 6(暂停 / 恢复)的技术前提:一个序列化不完整的 Agent 根本没法恢复。

设想一个典型事故:执行状态存在内存里,业务状态存在订单表里。进程重启后执行状态全丢,而订单表显示"退款已受理"——Agent 很可能把退款再执行一遍。把 agent_tasks 表和 orders 表放进同一个事务,Agent 每完成一步就同时更新两边的状态,这类不一致才堵得住。

具体实现上,一种常见模式是在业务数据库里加一张 agent_tasks 表,字段包含 idstatuscurrent_stepevents_json(Factor 3 的 Thread)和 business_entity_id(关联业务实体)。Agent 重启时从这张表读取最后的状态就能继续。


Factor 6:启动 / 暂停 / 恢复

原仓库里这条叫 “Launch/Pause/Resume with simple APIs”。Agent 暂停和恢复在生产环境里是基本要求——只要 Agent 需要等待人工审批、外部 webhook 回调、或长耗时异步操作,没有这套机制就只能让进程空转占着资源。“启动"同样重要:Agent 的入口要能从任意事件触发(见 Factor 11),而不是硬编码成"用户发了一条消息”。

典型场景:

  • 部署 Agent 发起了部署请求,需要等 CI/CD 系统返回结果
  • 客服 Agent 遇到退款超过阈值,需要主管审批
  • 服务器重启或进程崩溃,Agent 需要从上次中断的地方继续

实现手段上,把 Agent 的执行状态序列化到持久化存储,通过简单 API 做 save/load。Factor 5(统一状态)让这件事变成可能——如果执行状态和业务状态是同一套 schema,序列化和恢复就只是一次数据库读写。

容易被忽略的细节:恢复时上下文窗口的重建。 暂停时只存了"执行到第 3 步"这个元信息,却没有存前 3 步产生的完整 Thread 事件列表,恢复后 LLM 看到的是一个残缺的对话历史。正确做法是把整个 Thread(Factor 3 的事件列表)作为状态的一部分持久化。

恢复逻辑的伪代码:

def resume_agent(task_id: str) -> str:
    task = db.get_task(task_id)
    if task is None:
        raise TaskNotFound(task_id)

    state = State.from_dict(task.state_snapshot)
    thread = Thread(events=task.events)

    return run_agent_loop(state, thread)

关键约束:task.state_snapshottask.events 必须在同一个事务里写入,否则恢复后的状态和事件列表可能不匹配。


Factor 7:用工具调用联系人类

Agent 卡住时无非两种结局:乱猜一个答案,或者卡死不动。Factor 7 给的是第三种选择——主动找人帮忙,而且用跟调用任何工具完全相同的机制。

{
    "type": "function",
    "function": {
        "name": "contact_human",
        "parameters": {
            "reason": "退款金额 $5,000 超出客服自主审批上限 $1,000",
            "context": {
                "order_id": "ord_12345",
                "customer_tier": "enterprise",
                "refund_requested": 5000
            },
            "channel": "slack",
            "target": "#ops-escalations"
        }
    }
}

Agent 通过同一套工具调用机制与人类交互,不需要为"人机协作"单独开一条异常处理分支。从代码角度看,contact_humancreate_payment_link 是同一类东西——Agent 发出一个函数调用,代码去执行。区别只在前者的"执行"是发一条 Slack 消息然后阻塞等待回复。

工程细节:contact_human 的回复格式同样需要结构化。与其让人类打一段自然语言塞回上下文,不如给审批人几个按钮选项——“批准 / 拒绝 / 需要更多信息”——每个选项映射到标准化的回复结构。这样 LLM 在恢复执行时看到的是结构化的审批结果,而不是一段需要再次解析的自由文本。


Factor 8:控制流归软件

控制流——“什么时候做什么、什么条件下跳转、什么情况下终止”——写在代码里,不归 LLM。LLM 只负责单步决策:选哪个工具、填什么参数。

LLM 应用的架构应该长这样:系统骨架由代码定义(DAG、状态机、if/else),模型在骨架约束下执行具体步骤——这段文字怎么写、这几个参数填什么。

注意 Factor 8 并没有禁止 LLM 参与决策。LLM 当然可以决定"下一步调用哪个工具、参数是什么"(那是 Factor 1 的范围)。Factor 8 划的是一条更硬的边界:决定什么时候停下来、什么时候重试、什么时候升级到人工处理的逻辑,写在代码里,不写在 prompt 里

在 while loop 的外层用代码控制终止条件:

MAX_ITERATIONS = 25
iteration = 0

while iteration < MAX_ITERATIONS:
    next_step = await llm.determine_next_step(context)

    if next_step.intent == "done":
        return next_step.final_answer

    if next_step.tool in CRITICAL_TOOLS:
        require_human_approval(next_step)

    result = await execute(next_step)
    context.append(result)
    iteration += 1

raise MaxIterationsExceeded(f"Agent did not finish within {MAX_ITERATIONS} steps")

到了 Factor 8,DAG 换了一种形式回到 Agent 架构里:工作流引擎编排 Agent 节点,Agent 节点内部调用 LLM。这一次,节点是 LLM 调用,边是代码逻辑。


Factor 9:把错误压进上下文窗口

传统的异常处理是:出错 → 抛异常 → 中断 → 等人来修。这套模式在 Agent 场景下代价很高——每次中断,之前积累的上下文、状态和进度全得从头来。

另一种做法是把错误压缩进上下文,让 loop 继续,由模型决定如何处理。

压缩方式是把原始错误转换为结构化摘要,而不是把整个 stack trace 塞进 context window:

{
    "error_type": "api_rate_limit",
    "source_tool": "fetch_github_issues",
    "retry_after_seconds": 60,
    "affected_parameters": {"repo": "org/repo", "since": "2026-05-01"},
    "suggested_action": "retry_after_wait"
}

LLM 拿到这条摘要后可以做多种决策:等待后重试、换一个 API endpoint、跳过这步用已有数据继续、升级到人工处理。LLM 拿到了选择权,可恢复的错误不再直接中断整条 loop。

边界判断:错误该不该压进上下文,看的是"LLM 重新决策能不能改变结果"。API 限流、临时超时、数据格式不匹配——这些 LLM 有可能通过换策略来绕过,压进去让它自己判断。API key 失效、数据库连接断了——重试不会改变结果,走传统异常抛出。区分这两类的逻辑本身就是一段代码:凡是 error_typeRECOVERABLE_ERRORS 集合里的进上下文,其余抛出。


Factor 10:小而专注的 Agent

一个 Agent 同时做"处理支付 + 回答用户问题 + 写报告",每个环节都会做得更差。单一职责原则在 Agent 场景下有额外的硬理由。

把"一个大 Agent 做 10 件事"和"5 个小 Agent 各做 2 件事"放在一起对比,差异是具体的:

  • 上下文长度:大 Agent 要把 10 个场景的说明塞进同一个 system prompt,小 Agent 每个只装载自己那 2 个场景的说明。
  • 工具数量:大 Agent 挂 20+ 个工具,每轮要在 20 个候选里做选择;小 Agent 每个 5 个以内。
  • 测试难度:大 Agent 改一处 prompt 可能影响 10 个场景的回归;小 Agent 改一个只跑它自己的测试套件。
  • 故障半径:大 Agent 一个幻觉可能把支付逻辑带偏;小 Agent 支付出问题不会污染报告。

这四条加起来,决定了大 Agent 在 70% 之后每提升 1% 都要付出更高的边际成本。每多一个场景,回归测试就要多覆盖一条路径,prompt 调整的影响面也跟着扩大。

LLM 的注意力是有限资源。system prompt 越长、工具列表越长、上下文越复杂,模型在每件事上分配的注意力就越少。一个"万能 Agent"的 system prompt 可能写满 3 页,覆盖 8 种业务场景,挂 30 个工具——就算注意力能均匀分配,8 个场景平摊下来每个也只分到 1/8,任何边缘情况都可能触发跳场景的幻觉。

拆分时可以参考三个方向:

  • 按功能域拆:支付 Agent、客服 Agent、报告 Agent、部署 Agent,各自有独立的 prompt 和工具集
  • 每个 Agent 有明确的输入 / 输出契约:进什么格式的事件,出什么格式的结果
  • 通过消息总线或共享状态协调:Agent 之间不直接调用,避免形成不可追踪的依赖链

拆得够细的话,每个 Agent 的 system prompt 可以控制在半页以内,工具列表不超过 5 个。这个量级下模型注意力更集中,出问题时一看就知道是哪个 Agent 的哪段 prompt。

什么时候该拆?发现自己正在往某个 Agent 的 system prompt 里加"如果用户问的是 X 类问题,则……“这种场景分叉逻辑时。场景分叉不应该用 prompt 里的 if-else 来处理,应该由路由层(一段代码)根据用户意图把请求分发到对应的 Agent。


Factor 11:从任意地方触发,在用户所在的地方相遇

Agent 应该能从 webhook、cron 定时任务、用户消息、API 调用、CI/CD 事件等任意入口触发,把结果推送到用户已经在用的渠道里——Slack、邮件、IDE、Dashboard——不需要用户专门打开一个 Agent 界面。

这层设计有工程层面的约束:Agent 的入口和出口必须解耦。 入口逻辑不应该假设"用户一定会通过 HTTP POST 发 JSON”,出口逻辑也不应该假设"用户当前一定在 Web 页面前等着"。今天从 Slack 触发、输出到 Slack;下个月加一个 cron 触发、输出到邮件——Agent 核心逻辑不感知这些变化。做法是定义两个接口——Trigger(带 sourcepayload)和 Delivery(带 channelrecipient)——Agent 内核只依赖这两个接口。


Factor 12:把 Agent 做成无状态 reducer

把 Agent 建模为无状态 reducer——(状态, 事件) → 新状态

from dataclasses import dataclass, field
from typing import Any, Callable, Protocol

@dataclass
class Event:
    """上下文窗口中的一个事件,对应 Factor 3 的自定义事件格式。"""
    type: str
    data: Any = None

@dataclass
class State:
    """Agent 的可序列化状态,同时承载执行状态与业务状态(Factor 5)。"""
    context: list[Event] = field(default_factory=list)
    done: bool = False
    final_answer: str | None = None

    def append(self, event: Event) -> "State":
        return State(context=self.context + [event], done=self.done, final_answer=self.final_answer)

    def mark_done(self, final_answer: str) -> "State":
        return State(context=self.context, done=True, final_answer=final_answer)

class LLMClientProtocol(Protocol):
    """LLM 客户端协议:入参是 Factor 3 的事件列表,实现内部负责转成 prompt,返回结构化决策。"""

    async def determine_next_step(self, context: list[Event]) -> Any: ...

async def agent_step(
    state: State,
    event: Event,
    llm: LLMClientProtocol,
    tools: dict[str, Callable],
) -> State:
    """单步 reducer:吸收一个外部事件,决策一次,返回新 State。"""
    state = state.append(event)
    next_step = await llm.determine_next_step(state.context)
    if next_step.intent == "tool_call":
        handler = tools.get(next_step.tool or "")
        result = handler(**next_step.args) if handler else {"error": f"unknown tool: {next_step.tool}"}
        return state.append(Event(type="tool_result", data=result))
    if next_step.intent == "done":
        return state.mark_done(final_answer=next_step.final_answer or "")
    return state

无状态 reducer 让四件事变成可能:

  • 可测试:给定相同的 State、Event 和 LLM 返回,输出总是相同——不依赖 Agent 内部是否有"记忆"。可以为关键路径写标准单元测试:构造一个 State(含特定上下文),输入一个 Event,断言输出的 State 是正确的。
  • 可回放:存下所有事件后,在任何时间点重放,重现当时的决策过程。排查生产事故时逐帧回放每一步的输入输出,靠代码还原 Agent 当时做了什么,而不是靠猜。
  • 可 Fork:同一个状态可以 fork 出多个并行执行路径。比如同时尝试两种不同的工具选择策略,比较结果后再决定走哪条路。在代码生成 Agent 尝试多种实现方案的场景里尤其有用。
  • 可调试:整个执行轨迹是确定性的。每一步的输入输出都可以看,不存在"Agent 脑子里在想什么"的模糊空间。

Factor 8 把 while loop 的终止条件、重试次数、人工升级写在 agent_loop 函数体里;Factor 12 把每一步的状态变更建模为 agent_step(state, event) -> new_state 的纯函数调用。两者落地后,Agent 的执行轨迹变成一串可序列化的 State 对象,每一步的输入输出都能在日志里查到——LLM 的职责收窄到单步选工具、填参数,控制权回到代码手里。

测试策略推荐三层:

  1. 单元层:给 agent_step 传入固定的 StateEvent,断言返回的 State 正确。这里要 mock LLM 的返回。
  2. 集成层:跑一遍完整的事件序列,用真实 LLM 调用,断言最终状态和关键中间状态符合预期。
  3. 回放层:把生产环境采集的事件序列灌入 agent_step,对比重放结果和实际结果。有偏差,说明代码逻辑有非确定性因素需要排查。

Factor 13(荣誉提及):预取上下文

在用户发出请求之前,就预取所有可能需要的上下文。不等用户点击,提前把可能需要的数据拉进上下文窗口。

回到 Factor 1 的例子:LLM 凭什么知道 customer_idcust_128934ddasf9?因为当用户开始打开支付表单时,后台 Agent 已经预取了该用户的 Stripe customer ID、常用 product ID、历史支付偏好。用户提交的那一刻,Agent 的上下文窗口里已经有了完整信息,不需要额外轮次去查。

在需要严格响应时间的场景里(用户期望秒级回复),没有预取意味着 Agent Loop 至少要多跑 2-3 轮去查数据,每轮都有网络延迟和推理延迟叠加。这属于基础设施投入,不是可选项。

预取的内容可以按用户会话的上下文来判断:用户打开了哪个页面、最近的操作是什么、历史偏好是什么。把这套逻辑写成一段代码,放在 Agent Loop 启动之前执行,而不是让 LLM 在 Loop 内部决定"我还需要查什么数据"。


如何落地

采用路线图

团队从 0 构建 AI 产品时,建议按下面的顺序推进:

优先级时机先做理由
P0第一周Factor 2(Own your prompts)+ Factor 3(Own your context window)决定后续所有优化的自由度。prompt 不是你的资产,你连调都调不了;上下文格式不受控,所有 token 优化都是白做。
P1第二周Factor 1(NL→Tool Calls)+ Factor 4(Tools = Structured Outputs)把 LLM 怎么输出结构化数据这件事定下来。所有工具调用的基础。
P2第三周Factor 8(Own control flow)+ Factor 12(Stateless reducer)画出控制流骨架,把 Agent 建模为 reducer。此后每条路径都能写单元测试和回放测试。
P3第四周Factor 5(Unify state)+ Factor 6(Launch/Pause/Resume)让 Agent 在真实环境中活下来——重启不丢状态、等待后能恢复。
P4按需Factor 7(Contact humans)、Factor 9(Compact errors)、Factor 10(Small agents)、Factor 11(Trigger anywhere)、Factor 13(Pre-fetch)这些是从 90% 做到 99% 的原则。早期不必全上,但每条都对应一类踩坑场景。

团队在已有产品里嵌入 AI 能力(而不是从零做 Agent)时,顺序要倒过来:先从 Factor 11(Trigger anywhere)和 Factor 7(Contact humans)入手。原因是现有产品已经有触发源和用户渠道,而且人机协作的边界通常是首先要厘清的问题——这两条不先定下来,后面所有工程结构都会被"什么时候该问人"这种悬而未决的问题拖着走。

什么时候这套原则边际收益递减

  • 原型 / Demo 阶段:目标是快速验证想法,用 LangChain 或直接调 API 足够。这套原则的收益在需要稳定交付给用户时才显现。
  • 模型能力远超任务复杂度时:LLM 只做文本分类或结构化提取,没有多步循环,大部分原则用不上。
  • 纯研究 / 探索性项目:目标是看看模型能做到什么,做可靠产品不在议程上,工程约束反而是负担。

这套原则不覆盖什么

先划一条定位线:12-Factor Agents 不是行业标准,也不是必须照搬的框架,而是一份可以逐条对照的架构清单。它不提供运行时,只给出判断标准;作者 Dex 明确划分了几个话题边界:

  • MCP(Model Context Protocol):不讨论。MCP 是工具发现和调用的协议层,12-Factor Agents 是 Agent 工程的设计层——可以在这套原则上实现 MCP 客户端,但原则本身不绑定任何协议。
  • 框架对比:不涉及 LangChain vs LangGraph vs CrewAI 的横向评测。它告诉你好框架为什么好,但不帮你选框架。
  • 模型训练 / Fine-tuning:假设使用现有模型,聚焦工程层面的优化。

与 12-Factor App 的呼应

熟悉 Heroku 在 2011 年提出的 12-Factor App 的话,会看到命名上的致敬。两套原则在状态外置、依赖显式、配置可控这几件事上走的是同一条路:

12-Factor App12-Factor Agents共通逻辑
代码库一份,多次部署Factor 12:Agent 是无状态 reducer状态外置,计算逻辑无状态
依赖显式声明Factor 2:Own your prompts输入资产显式追踪,不隐式依赖外部
配置存储在环境变量Factor 3:Own your context window运行时数据注入方式可控
进程无状态且不共享Factor 5:统一状态 + Factor 10:小 Agent独立、可替换、状态外部化

2011 年 Heroku 提出 12-Factor App 的时候,配置外置、依赖显式、进程无状态这些做法还不是共识。后来它们成了云原生的默认前提——照着做的人确实少踩了坑。12-Factor Agents 在 LLM 应用上做的是同一件事:把 prompt 构建位置、上下文格式、控制流归属、状态持久化这些原本被框架藏起来的决策,逐条摆到桌面上,交给团队自己掌控。


常见翻车现场

翻车 1:把 Agent Loop 当成系统架构

“我们用了 Agent,所以不需要设计工作流。”

Agent Loop 只解决"模型怎么调工具"这一层的问题。业务流程该怎么编排,还得另外设计。把它当应用架构用,等于把 if/else 全交给概率模型——出问题时你不知道是该修 prompt 还是该修代码。

翻车 2:工具挂太多

设想一个 Agent 挂了 15 个工具,system prompt 写了 2 页。用户问"我的订单状态是什么",Agent 先调了知识库搜索、又调了情感分析、最后才想起查订单。

工具多不等于能力强。每多一个工具都在稀释 LLM 的注意力预算。控制在 5 个以内,超出就拆 Agent。

翻车 3:上下文窗口当垃圾桶

“反正模型支持 128K,全塞进去。”

上下文窗口越大,模型的注意力越容易被无关信息分散。“中间丢失”(lost in the middle)是已知现象:LLM 对窗口中间位置的文本关注度显著低于开头和结尾。上下文工程的核心是"优先保留什么、大胆丢弃什么",不是把 128K token 全部塞满。

翻车 4:状态只存在内存里

Agent 的所有进度存在一个 Python 进程的局部变量里。进程重启后用户问"上次的任务怎么样了",Agent 说"什么任务?"

这是 Factor 5 和 Factor 6 要解决的问题——但多数团队只有经历过生产事故才会意识到。

翻车 5:指望模型升级来解决架构问题

“GPT-5 出来之后这些问题自然就没了。”

模型升级会提高单步决策的准确率,但状态管理、控制流归属、人机协作边界这些架构问题跟模型智商无关。更聪明的模型让单步决策更准,但 while loop 的上限、暂停恢复机制、状态持久化——这些都还是得自己做。


常见问题

Q1:12-Factor Agents 和 LangChain / LangGraph / CrewAI 是什么关系?

12-Factor Agents 给的是一份设计清单,团队拿着它审视自己的实现缺哪一块;它不提供任何运行时,也不替你做框架选型。你可以用这些原则审视 LangChain 实现——比如检查 prompt 是否被框架隐式注入(Factor 2)、控制流是否归代码(Factor 8)。如果框架挡住了某条原则的落地,那就是该考虑换框架或自己搭的信号。

Q2:12 条正式原则加 1 条荣誉提及必须全做完才能上线吗?

不必。采用路线图里 P0-P3 是从 0 到能稳定交付的最小集合,P4 是从 90% 做到 99% 的增量。原型阶段甚至可以全部跳过——原则的收益在需要稳定交付给真实用户时才显现。

Q3:已经用了框架,怎么迁移?

不要一次性重写。先做 Factor 2(把 prompt 从框架里抠出来,用 Git 管理)和 Factor 3(把上下文格式从框架默认改为自定义事件格式)。这两步不动业务逻辑,但能让你看清当前实现里哪些行为是框架注入的、哪些是你自己写的。看清之后,再决定哪些环节需要按 Factor 8 / Factor 12 重构。

Q4:Agent Loop 上限设多少合适?

取决于任务复杂度和单步成本。一个值得参考的事实:got-agents/agents 里的两个参考实现(部署示例 deploybot-ts、客服示例 linear-assistant-ts)都没有设步数上限,主循环是无界的 while(true),靠"意图"退出——done_for_nowrequest_more_information、需要人工审批的写操作,这些分支会让循环停下来等待外部输入。这套写法成立的前提,是每条路径都有明确的停机分支。如果你的 Agent 会自主连续执行、停机路径不明显,就在代码里加显式上限(本文 Factor 8 的示意取 25 步),超限后转入人工处理或保存状态暂停(Factor 6),而不是把上限拉到 100。

Q5:无状态 reducer 怎么处理需要调用真实 LLM 的场景?

agent_step 函数内部仍然调用 LLM,但 LLM 调用的输入完全由传入的 State 决定,输出被封装成新的 State 返回。测试时 mock 掉 LLM 调用,就能验证 reducer 逻辑本身是否正确。集成测试和回放测试再覆盖真实 LLM 调用的部分。

Q6:自定义事件格式和标准消息格式能不能混用?

技术上可以,但不建议。混用会让上下文工程失去一致性——你既要在 JSON 嵌套里翻工具调用结果,又要在 XML 标签里找用户消息。Factor 3 推荐的是把所有上下文统一建模为事件,包括用户消息、工具调用、工具返回、错误摘要。统一格式后,裁剪、压缩、回放逻辑都只针对一种数据结构。

Q7:Agent 在生产环境里"卡住"了,怎么排查?

按层次定位。先看运行层:进程是否还在、是否在等外部回调(Factor 6 的暂停状态有没有正确写入)。再看控制层:是否触发了 MAX_ITERATIONS 上限但没正确抛出(Factor 8)。最后看输入输出层:上下文窗口是否被错误摘要塞爆(Factor 9),或工具返回值过大导致 LLM 注意力分散(Factor 4)。常见根因是 Factor 5 没做好——执行状态没落库,进程重启后状态丢失,Agent 不知道自己停在哪一步。

Q8:LLM 输出的工具调用参数不符合 schema,怎么办?

不要在 prompt 里反复强调格式。工具定义层用 JSON Schema 或 Pydantic 做严格校验,参数不合规直接返回结构化错误(Factor 9 模式)让 LLM 重试。这步解决的是"不让错误参数通过"。然后回到 Factor 3 检查上下文里是否给了 LLM 足够的候选值——如果 LLM 看不到 customer_id,它只能猜,猜错不是模型的问题。

Q9:多 Agent 协作时,状态怎么传递?

不要让 Agent 之间直接共享内存。Factor 10 要求 Agent 之间通过消息总线或共享状态协调,Factor 5 要求状态统一持久化。具体做法:每个 Agent 完成自己的 reducer 步骤后,把输出事件写入共享的 agent_tasks 表(或消息队列),下一个 Agent 从队列里取事件作为自己的输入。这样每个 Agent 仍然是无状态 reducer(Factor 12),整体协作链路可回放、可调试。

Q10:迁移到 12-Factor Agents 时,怎么衡量收益?

看三个基线指标:端到端任务成功率(从 70% 提升到 90%+ 通常需要 P0-P3 全部落地);平均调试时间(能在 5 分钟内定位 prompt 构建位置,而非在框架代码里翻找);生产事故恢复时间(Factor 5 + Factor 6 落地后,进程重启不再等于任务丢失)。三项指标都没动的话,回到自检清单逐条核对。


自检清单

Agent 正准备上线时,逐条过一遍:

  • 能在 5 分钟内定位到任意一条 prompt 在代码里的构建位置?(Factor 2,答否→回读 Factor 2
  • 上下文窗口的格式完全由你自己的代码控制,不依赖框架的隐藏注入?(Factor 3,答否→回读 Factor 3
  • 进程重启后,Agent 能从上次中断的地方继续吗?(Factor 5 + Factor 6,答否→回读 Factor 5Factor 6
  • 每个 Agent 的 system prompt 能控制在半页以内、工具不超过 5 个吗?(Factor 10,答否→回读 Factor 10
  • 控制流逻辑(终止条件、重试次数、升级人类)是写在确定性代码里,不是写在 prompt 里?(Factor 8,答否→回读 Factor 8
  • 给定相同的事件序列,Agent 能复现相同的决策路径吗?(Factor 12,答否→回读 Factor 12
  • 遇到可恢复错误时,Agent 是自己决策怎么处理,还是直接中断?(Factor 9,答否→回读 Factor 9
  • 需要人工介入时,是否通过统一的工具调用机制而不是硬编码的异常分支?(Factor 7,答否→回读 Factor 7

以上有超过 2 个答案为"否"的话,回到对应的 Factor 先修,再推进其他功能。


12-Factor Agents 快速参考卡

#名称一句话属于哪层
1自然语言 → 工具调用LLM 输出结构化决策,代码执行工具输入输出层
2提示词是自己的资产prompt 进 Git,不靠框架隐式注入控制层
3掌控上下文窗口自定义事件格式替代标准消息格式输入输出层
4工具调用 = 结构化输出工具设计像 API 设计一样严肃输入输出层
5执行状态与业务状态统一一张表同时描述"任务在哪"和"业务是什么"运行层
6启动 / 暂停 / 恢复状态可序列化,API 可 save/load运行层
7用工具调用联系人类contact_humancreate_payment_link 是同一类东西运行层
8控制流归软件while loop 的终止条件写在代码里控制层
9把错误压进上下文可恢复错误摘要进上下文,LLM 决定怎么处理输入输出层
10小而专注的 Agentsystem prompt 半页以内,工具 5 个以内控制层
11从任意地方触发入口解耦,Trigger + Delivery 两个接口运行层
12Agent 是无状态 reducer(状态, 事件) → 新状态,可测试可回放控制层
13预取上下文(荣誉提及)用户发请求之前就拉好数据输入输出层

采用顺序:Factor 2 + 3(P0)→ Factor 1 + 4(P1)→ Factor 8 + 12(P2)→ Factor 5 + 6(P3)→ 其余按需(P4)

三层系统地图

输入输出层 (F1,F3,F4,F9,F13)  ← LLM 看到什么、输出什么
控制层     (F2,F8,F10,F12)    ← 谁决定下一步
运行层     (F5,F6,F7,F11)     ← 在生产环境怎么活下来

项目资源

配套资料:

README 还在号召社区共建 npx/uvx create-12-factor-agent 脚手架,截至本文发布,npm 和 PyPI 上都还没有这个包,暂时跑不了。

项目地址github.com/humanlayer/12-factor-agents 内容许可:CC BY-SA 4.0 | 代码许可:Apache 2.0


参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。