12-factor-agents
https://github.com/humanlayer/12-factor-agents

传统agent的困境:
这种方法的问题在于:
- 控制流不可预测:完全依赖LLM决策
- 错误处理困难:缺乏结构化的异常处理
- 状态管理混乱:执行状态与业务状态混合
- 可观测性差:难以调试和监控

真正优秀的Agent不是"给LLM一堆工具让它自由发挥",而是大部分由确定性代码构成,在关键决策点巧妙地融入LLM能力。
那么Agent到底是什么?
- 提示- 告诉 LLM 如何操作,以及它有哪些可用的“工具”。提示的输出是一个 JSON 对象,它描述了工作流程的下一步(“工具调用”或“函数调用”)。
- switch 语句- 根据 LLM 返回的 JSON,决定如何处理它。
- 累积上下文- 存储已发生的步骤及其结果的列表。
- for 循环- 直到 LLM 发出某种“终端”工具调用(或纯文本响应),将 switch 语句的结果添加到上下文窗口并要求 LLM 选择下一步。

1 Natural Language to Tool Calls
**原则**:将自然语言输入转换为结构化的工具调用,而非直接文本输出。
// ❌ 错误方式 - 依赖文本解析
const response = await llm.complete("帮我发送邮件给张三")
// 需要解析:"我将为您发送邮件..."
// ✅ 正确方式 - 结构化输出
const toolCall = await llm.generateToolCall(prompt, tools)
// 返回:{ tool: "send_email", params: { to: "张三", ... } }
2 Own your prompts
**原则**:将提示词作为代码资产管理,而非隐藏在框架中。- 提示词应该版本化管理
- 支持A/B测试和灰度发布
- 提供清晰的提示词模板系统

一些框架提供了类似这样的“黑箱”方法:
agent = Agent(
role="...",
goal="...",
personality="...",
tools=[tool1, tool2, tool3]
)
task = Task(
instructions="...",
expected_output=OutputModel
)
result = agent.run(task)
这非常适合引入一些顶级的提示词工程来帮助你开始,但通常很难调整和/或逆向工程以将完全正确的标记输入到你的模型中。相反,你应该拥有自己的提示词,并将它们视为一级代码:
function DetermineNextStep(thread: string) -> DoneForNow | ListGitTags | DeployBackend | DeployFrontend | RequestMoreInformation {
prompt #"
{{ _.role("system") }}
You are a helpful assistant that manages deployments for frontend and backend systems.
You work diligently to ensure safe and successful deployments by following best practices
and proper deployment procedures.
Before deploying any system, you should check:
- The deployment environment (staging vs production)
- The correct tag/version to deploy
- The current system status
You can use tools like deploy_backend, deploy_frontend, and check_deployment_status
to manage deployments. For sensitive deployments, use request_approval to get
human verification.
Always think about what to do first, like:
- Check current deployment status
- Verify the deployment tag exists
- Request approval if needed
- Deploy to staging before production
- Monitor deployment progress
{{ _.role("user") }}
{{ thread }}
What should the next step be?
"#
}
(上述示例使用了 BAML 来生成提示词,但你可以使用任何你想要的提示词工程工具,甚至可以手动进行模板化)
function DetermineNextStep(thread: string) -> DoneForNow | ListGitTags | DeployBackend | DeployFrontend | RequestMoreInformation {
拥有自己的提示词的主要好处:
完全控制:精确编写你的代理所需的指令,无需依赖黑箱式的抽象层。
测试与评估:像对待其他代码一样,为你的提示词构建测试和评估。
迭代:根据实际表现快速修改提示词。
透明度:确切了解你的代理正在使用的指令。
角色黑客:利用支持用户/助手角色非标准使用的API——例如,现已废弃的OpenAI“补全”API的非聊天版本。
这包括一些所谓的“模型误导”技巧。记住:你的提示词是你的应用程序逻辑与大型语言模型(LLM)之间的主要接口。

3 Own your context window
**原则**:主动管理上下文内容,而非被动累积。一切都与上下文工程有关。LLM是无状态函数,它将输入转化为输出。为了获得最佳输出,你需要为它们提供最佳输入。
创造良好的环境意味着:
- 你给模特的提示和指示
- 您检索的任何文档或外部数据(例如 RAG)
- 任何过去的状态、工具调用、结果或其他历史记录
- 任何来自相关但独立的历史/对话的过去信息或事件(记忆)
- 关于输出什么类型的结构化数据的说明
关于上下文工程本指南旨在尽可能多地从当前的模型中获取价值。值得注意的是,以下内容并未提及:
• 对模型参数的调整,例如温度(temperature)、top_p、频率惩罚(frequency_penalty)、存在惩罚(presence_penalty)等。
• 训练你自己的补全或嵌入模型。
• 对现有模型进行微调。
再次强调,我不知道将上下文传递给 LLM 的最佳方法是什么,但我知道你希望拥有足够的灵活性来尝试一切。
xml格式:
拥有上下文窗口的主要好处:
- 信息密度:以最大化 LLM 理解的方式构建信息
- 错误处理:以有助于 LLM 恢复的格式包含错误信息。考虑在错误和失败的调用解决后将其从上下文窗口中隐藏。
- 安全性:控制传递给 LLM 的信息,过滤掉敏感数据
- 灵活性:根据你的使用情况调整格式
- 令牌效率:优化上下文格式以提高令牌效率和 LLM 理解
上下文包括:Prompt、说明、RAG 文档、历史记录、工具调用、记忆

4 Tools are just structured outputs
**原则**:将工具调用视为结构化数据生成,而非函数执行。工具不需要复杂。在本质上,它们只是从你的大型语言模型(LLM)中产生的结构化输出,这些输出触发确定性的代码。
工具定义应该:
- 明确输入输出schema
- 提供详细的描述和示例
- 支持参数验证

例如,假设你有两个工具:CreateIssue(创建问题)和SearchIssues(搜索问题)。要让一个大型语言模型(LLM)“使用多个工具中的一个”,实际上就是要求它输出我们可以解析为表示这些工具的对象的JSON格式数据。
class Issue:
title: str
description: str
team_id: str
assignee_id: str
class CreateIssue:
intent: "create_issue"
issue: Issue
class SearchIssues:
intent: "search_issues"
query: str
what_youre_looking_for: str
这个模式很简单:
- LLM 输出结构化的 JSON:
• LLM 生成一个结构化的 JSON 输出,这个 JSON 表示了需要执行的操作或调用的工具。
- 确定性的代码执行适当的动作:
• 你的代码解析这个 JSON 输出,并根据其内容执行相应的操作,例如调用外部 API。
- 捕获结果并反馈到上下文中:
• 执行操作的结果被捕获并反馈到上下文中,以便 LLM 在后续的决策中可以使用这些结果。这种模式在 LLM 的决策和应用程序的动作之间创建了一个清晰的分离。
LLM 决定要做什么,但你的代码控制如何去做。仅仅因为 LLM“调用了一个工具”,并不意味着你每次都要以相同的方式执行一个特定的对应函数。
if nextStep.intent == 'create_payment_link':
stripe.paymentlinks.create(nextStep.parameters)
return # or whatever you want, see below
elif nextStep.intent == 'wait_for_a_while':
# do something monadic idk
else: #... the model didn't call a tool we know about
# do something else
注:关于“纯文本提示”与“工具调用”以及“JSON 模式”的优势,以及各自的性能权衡,已经有很多讨论了。我们很快会链接一些相关资源,但在这里不会深入探讨。请参考以下内容:《Prompting vs JSON Mode vs Function Calling vs Constrained Generation vs SAP》,《When should I use function calling, structured outputs, or JSON mode?》,以及《OpenAI JSON vs Function Calling》。“下一步”可能并不像“运行一个纯函数并返回结果”那么简单。当你把“工具调用”看作是模型输出 JSON 来描述确定性代码应该执行什么操作时,你就能获得很大的灵活性。将这一点与第 8 个因素——掌控你的流程——结合起来。
5 Unify execution state and business state
**原则**:将Agent的执行状态与业务逻辑状态统一管理。即使在人工智能领域之外,许多基础设施系统也试图将“执行状态”与“业务状态”分离开来。对于人工智能应用程序来说,这可能涉及到复杂的抽象概念,用以跟踪诸如当前步骤、下一步骤、等待状态、重试次数等事项。这种分离可能会带来值得的复杂性,但对于你的使用场景来说,可能过于复杂了。
和往常一样,由你来决定什么最适合你的应用程序。但不要认为你必须将它们分开管理。
更明确地说:
• 执行状态:当前步骤、下一步骤、等待状态、重试次数等。
• 业务状态:到目前为止在代理工作流程中发生的事情(例如,OpenAI 消息列表、工具调用和结果列表等)。
如果可能的话,尽量简化——尽可能地将这些内容统一起来。

实际上,你可以设计你的应用程序,以便能够从上下文窗口中推断出所有的执行状态。在许多情况下,执行状态(当前步骤、等待状态等)只是关于到目前为止发生了什么的元数据。
你可能有一些东西不能放入上下文窗口,比如会话ID、密码上下文等,但你的目标应该是尽量减少这些内容。通过拥抱第3个因素,你可以控制实际进入LLM的内容。
这种方法有多个好处:
• 简单性:所有状态都有一个单一的真相来源。
• 序列化:线程可以轻松地进行序列化和反序列化。
• 调试:整个历史记录可以在一个地方看到。
• 灵活性:通过简单地添加新的事件类型,可以轻松添加新的状态。
• 恢复:只需加载线程,就可以从任何点恢复。
• 分支:可以通过将线程的某个子集复制到新的上下文/状态ID中,在任何点对线程进行分支。
• 人类接口和可观察性:将线程转换为人类可读的Markdown或丰富的Web应用程序UI非常简单。
6 Launch/Pause/Resume
**原则**:支持长时间运行任务的暂停和恢复。代理只是程序,我们对如何启动、查询、恢复和停止它们有相应的预期。

应该有一个简单的 API,方便用户、应用程序、管道以及其他代理来启动一个代理。当需要执行长时间运行的操作时,代理及其协调确定性代码应该能够暂停代理。像网络钩子(webhooks)这样的外部触发器应该能够让代理从它们停止的地方恢复执行,而无需与代理协调器进行深度集成。
7 Contact Humans with Tool Calls
**原则**:人机交互也应该通过结构化的工具调用实现。默认情况下,大型语言模型(LLM)的API依赖于一个基本的、高风险的令牌选择:我们是返回纯文本内容,还是返回结构化数据?

你在这个第一个标记的选择上赋予了很大的权重,在the weather in tokyo这个例子中,它是
“the”
但在 fetch_weather 这个例子中,它是一些特殊的标记,用来表示一个 JSON 对象的开始。
|JSON>
你可能会通过让 LLM 总是输出 json,并用一些自然语言标记(如 request_human_input 或 done_for_now)来表明它的意图(而不是一个“正式”的工具,如 check_weather_in_city),从而获得更好的结果。
同样,你可能不会从这当中获得任何性能提升,但你应该进行实验,并确保你可以自由地尝试一些奇怪的东西以获得最佳结果。
class Options:
urgency: Literal["low", "medium", "high"]
format: Literal["free_text", "yes_no", "multiple_choice"]
choices: List[str]
# Tool definition for human interaction
class RequestHumanInput:
intent: "request_human_input"
question: str
context: str
options: Options
# Example usage in the agent loop
if nextStep.intent == 'request_human_input':
thread.events.append({
type: 'human_input_requested',
data: nextStep
})
thread_id = await save_state(thread)
await notify_human(nextStep, thread_id)
return # Break loop and wait for response to come back with thread ID
else:
# ... other cases
稍后,你可能会从处理 Slack、电子邮件、短信或其他事件的系统中收到一个网络钩子(webhook)。
@app.post('/webhook')
def webhook(req: Request):
thread_id = req.body.threadId
thread = await load_state(thread_id)
thread.events.push({
type: 'response_from_human',
data: req.body
})
# ... simplified for brevity, you likely don't want to block the web worker here
next_step = await determine_next_step(thread_to_prompt(thread))
thread.events.append(next_step)
result = await handle_next_step(thread, next_step)
# todo - loop or break or whatever you want
return {"status": "ok"}
上述内容包含了第 5 个因素、第 8 个因素、第 3 个因素以及第 4 个因素,还有其他几个因素的模式。
如果我们使用第 3 个因素中的 XML 格式——掌控你的上下文窗口,那么在经过几次交互后,我们的上下文窗口可能看起来像这样:
(snipped for brevity)
<slack_message>
From: @alex
Channel: #deployments
Text: Can you deploy backend v1.2.3 to production?
Thread: []
</slack_message>
<request_human_input>
intent: "request_human_input"
question: "Would you like to proceed with deploying v1.2.3 to production?"
context: "This is a production deployment that will affect live users."
options: {
urgency: "high"
format: "yes_no"
}
</request_human_input>
<human_response>
response: "yes please proceed"
approved: true
timestamp: "2024-03-15T10:30:00Z"
user: "alex@company.com"
</human_response>
<deploy_backend>
intent: "deploy_backend"
tag: "v1.2.3"
environment: "production"
</deploy_backend>
<deploy_backend_result>
status: "success"
message: "Deployment v1.2.3 to production completed successfully."
timestamp: "2024-03-15T10:30:00Z"
</deploy_backend_result>
好处:
• 明确的指令:不同类型的人员接触工具允许 LLM 提供更具体的指令。
• 内部循环与外部循环:使代理工作流能够脱离传统的类似 ChatGPT 风格的界面,在这种界面中,控制流程和上下文初始化可能是代理->人类,而不是人类->代理(想想那些由定时任务或事件触发的代理)。
• 多人访问:能够轻松地跟踪和协调来自不同人类的输入,通过结构化事件进行协调。
• 多代理:简单的抽象可以轻松扩展以支持代理->代理的请求和响应。
• 持久性:结合第 6 个因素——使用简单 API 启动/暂停/恢复,这使得多人工作流变得持久、可靠且可内省。

8 Own Your Control Flow
**原则**:不要让LLM完全控制程序流程,而是在预定义的流程中让LLM做决策。做选择题,而不是做生成题。

构建适合你特定用例的自定义控制结构。具体来说,某些类型的工具调用可能是退出循环并等待来自人类或另一个长时间运行的任务(如训练管道)的响应的原因。你可能还想结合自定义实现:
• 工具调用结果的总结或缓存
• 结构化输出的 LLM 作为评判
• 上下文窗口压缩或其他内存管理
• 日志记录、跟踪和指标
• 客户端速率限制
• 持久化睡眠/暂停/“等待事件”
下面的例子展示了三种可能的控制流模式:
• request_clarification:模型请求更多信息,退出循环并等待人类的响应。
• fetch_git_tags:模型请求一个 git 标签列表,获取标签,追加到上下文窗口中,并直接传递回模型。
• deploy_backend:模型请求部署后端,这是一个高风险的操作,因此退出循环并等待人类批准。
def handle_next_step(thread: Thread):
while True:
next_step = await determine_next_step(thread_to_prompt(thread))
# inlined for clarity - in reality you could put
# this in a method, use exceptions for control flow, or whatever you want
if next_step.intent == 'request_clarification':
thread.events.append({
type: 'request_clarification',
data: nextStep,
})
await send_message_to_human(next_step)
await db.save_thread(thread)
# async step - break the loop, we'll get a webhook later
break
elif next_step.intent == 'fetch_open_issues':
thread.events.append({
type: 'fetch_open_issues',
data: next_step,
})
issues = await linear_client.issues()
thread.events.append({
type: 'fetch_open_issues_result',
data: issues,
})
# sync step - pass the new context to the LLM to determine the NEXT next step
continue
elif next_step.intent == 'create_issue':
thread.events.append({
type: 'create_issue',
data: next_step,
})
await request_human_approval(next_step)
await db.save_thread(thread)
# async step - break the loop, we'll get a webhook later
break
这种模式允许你根据需要中断和恢复代理的流程,从而创造出更自然的对话和工作流程。
举例来说——我对每一个AI框架提出的首要功能需求是,我们需要能够中断一个正在工作的代理,并在之后恢复工作,尤其是在选择工具的时刻和调用工具的时刻之间。
如果没有这种可恢复性/粒度,就无法在工具调用运行之前进行审查/批准,这意味着你只能被迫选择以下几种方式之一:
• 在等待长时间运行的任务完成时将任务暂停在内存中(想想while...sleep),如果进程被中断,就从头开始重新启动;
• 限制代理仅能进行低风险、低风险的调用,比如研究和总结;
• 给代理提供执行更大、更有用任务的权限,然后只能听天由命,希望它不会搞砸。
9 Compact Errors
**原则**:错误信息应该结构化并适合上下文窗口。
这点比较短,但值得一提。agent的优点之一是“自我修复”——对于短任务,LLM 可能会调用失败的工具。优秀的 LLM 能够很好地读取错误消息或堆栈跟踪,并确定在后续工具调用中需要更改的内容。
好处:
- 自我修复:LLM 可以读取错误消息并找出在后续工具调用中需要更改的内容
- 持久:即使一个工具调用失败,代理仍可继续运行
我确信你会发现,如果你这样做太多次,你的agent就会开始失控,并可能一遍又一遍地重复同样的错误。所以需要第10步。
thread = {"events": [initial_message]}
while True:
next_step = await determine_next_step(thread_to_prompt(thread))
thread["events"].append({
"type": next_step.intent,
"data": next_step,
})
try:
result = await handle_next_step(thread, next_step) # our switch statement
except Exception as e:
# if we get an error, we can add it to the context window and try again
thread["events"].append({
"type": 'error',
"data": format_error(e),
})
# loop, or do whatever else here to try to recover
你可能希望为特定的工具调用实现一个错误计数器(errorCounter),以限制对单一工具的尝试次数,比如大约3次,或者其他任何适用于你使用场景的逻辑。
consecutive_errors = 0
while True:
# ... existing code ...
try:
result = await handle_next_step(thread, next_step)
thread["events"].append({
"type": next_step.intent + '_result',
data: result,
})
# success! reset the error counter
consecutive_errors = 0
except Exception as e:
consecutive_errors += 1
if consecutive_errors < 3:
# do the loop and try again
thread["events"].append({
"type": 'error',
"data": format_error(e),
})
else:
# break the loop, reset parts of the context window, escalate to a human, or whatever else you want to do
break
}
}
10 Small, Focused Agents
**原则**:构建多个专门化的小Agent,而非一个万能大Agent。不要构建试图包揽一切的庞大代理,而应构建小巧、专注的代理,使其擅长做好一件事。代理只是更大、主要由确定性代码构成的系统中的一个构建模块。

这里的关键洞察是关于大型语言模型(LLM)的局限性:
任务越大越复杂,所需的步骤就越多,这意味着上下文窗口会更长。随着上下文的增长,LLM 更容易迷失方向或失去焦点。通过让代理专注于特定领域,步骤数保持在3到10步,最多20步,我们可以使上下文窗口保持在可管理的范围内,同时保持 LLM 的高性能。
随着上下文的增长,LLM 更容易迷失方向或失去焦点。构建小巧、专注的代理的好处包括:
• 可管理的上下文:较小的上下文窗口意味着更好的 LLM 性能。
• 明确的职责:每个代理都有明确的范围和目的。
• 更高的可靠性:在复杂的工作流程中迷失方向的可能性更小。
• 更简单的测试:更容易测试和验证特定的功能。
• 更有效的调试:当问题出现时,更容易识别和修复问题。
如果 LLM 变得更聪明呢?
如果 LLM 足够聪明,能够处理 100 步以上的工作流程,我们还需要这样做吗?
简而言之,答案是肯定的。随着代理和 LLM 的改进,它们可能会自然地扩展到能够处理更长的上下文窗口。这意味着能够处理更大有向无环图(DAG)的更多部分。这种小巧、专注的方法确保你今天就能取得成果,同时也为你逐步扩大代理范围做好了准备,因为 LLM 的上下文窗口变得更加可靠。(如果你曾经重构过大型确定性代码库,你现在可能会点头表示认同)。
11 Trigger from Anywhere
**原则**:Agent应该能从多种渠道触发,满足用户在不同场景的需求。
支持的触发方式:
- API调用
- Webhook
- 定时任务
- 消息队列
- 用户界面
好处:
• 贴近用户需求:这有助于你构建感觉像真人的 AI 应用程序,或者至少像数字同事一样。
• 外部循环代理:使代理能够被非人类触发,例如事件、定时任务、故障等。它们可能工作 5 分钟、20 分钟、90 分钟,但当它们到达一个关键点时,可以联系人类寻求帮助、反馈或批准。
• 高风险工具:如果你能够快速地让各种人类参与进来,你就可以让代理访问更高风险的操作,比如发送外部邮件、更新生产数据等。保持明确的标准可以让你对执行更大更好任务的代理进行审计,并对其充满信心。
12 Stateless Reducer
**原则**:Agent的核心逻辑应该是纯函数,便于测试和水平扩展。

实践建议
渐进式采用
不要一次性重写整个系统,而是逐步引入这些原则:- 从Factor 1开始:先实现结构化输出
- 管理提示词:将提示词从代码中分离
- 优化上下文管理:实现智能的上下文选择
- 添加状态管理:统一业务和执行状态
- 增强控制流:预定义关键流程步骤
工具选择
推荐的技术栈:- 结构化输出:OpenAI Function Calling、Anthropic Tool Use
- 提示词管理:BAML、LangSmith
- 状态管理:Redux模式、状态机
- 监控观测:LangSmith、Weights & Biases
质量保证
+ **单元测试**:对每个Factor进行测试 + **集成测试**:测试完整的Agent流程 + **A/B测试**:比较不同实现方案 + **监控告警**:实时监控Agent性能总结
12-Factor Agents提供了一套经过实践验证的原则,帮助开发者构建真正可用的LLM应用。关键在于:- 不要让LLM控制一切:在结构化的框架内使用LLM能力
- 拥有核心组件:主动管理提示词、上下文和控制流
- 渐进式优化:从简单开始,逐步完善
- 重视工程实践:测试、监控、版本管理一样不能少

浙公网安备 33010602011771号