- 文档
- 教程
- 人工智能
- 大模型
- AI Agent
【免费下载链接】12-factor-agents
What are the principles we can use to build LLM-powered software that is actually good enough to put in the hands of production customers?
导读
在 12-Factor Agents 的架构体系中,"工具(Tools)"常被想象成需要复杂运行时和特殊协议支撑的魔法能力,但本篇文章要论证的核心观点恰恰相反:工具调用本质上只是 LLM 输出的结构化 JSON,由它触发你的确定性代码。理解这一点,你就能把"模型决策"与"应用执行"彻底解耦,不再依赖任何框架封装的函数调用魔法。读完本文,你将掌握如何用纯数据结构定义工具、如何用switch/if-else让确定性代码接管执行、以及如何将这一思想与 12-Factor Agents 的控制流原则 组合出真正生产可用的 Agent 循环。
本文以 12-factor-agents 仓库 中 content/factor-04-tools-are-structured-outputs.md 为骨架,并补充仓库内create-12-factor-agent模板的 BAML 定义、TypeScript 工具循环实现与测试用例作为源码级佐证。
一、核心论点:工具 = 结构化输出 + 确定性代码
12-Factor Agents 第四条原则指出:工具并不复杂,其内核只是"来自 LLM 的结构化输出,触发一段确定性代码"。这条原则在仓库中的官方配图 img/140-tools-are-just-structured-outputs.png 中得到了直观的呈现:
图:Tools are just structured outputs —— LLM 决策(结构化输出)与确定性代码执行之间的桥梁。
一旦接受了这个心智模型,很多框架给你强加的"工具注册""函数绑定""运行时解释器"等复杂度就不再必要。你只需要两样东西:
- 一组描述工具意图与参数的数据结构;
- 一段根据这些数据结构执行动作的确定性代码。
二、用纯数据结构定义工具:CreateIssue 与 SearchIssues 示例
原文档给出了一个直观的例子。假设你有两个工具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观察这段定义,你会发现其中没有任何"可调用函数"的成分——有的只是:
intent字段:一个常量字符串,用于标识工具身份;- 其余字段:执行该工具所需的全部参数(如
issue、query)。
这恰恰是"工具即结构化输出"的全部含义:模型不需要"调用"什么,它只是选择并填充一个 JSON 结构,剩下的事交给你的代码。
三、模式三步曲:输出 → 执行 → 回填
原文档将整个模式压缩为三个步骤:
- LLM 输出结构化 JSON(描述"下一步"该做什么);
- 确定性代码执行对应的动作(例如调用外部 API);
- 执行结果被捕获并回填到上下文窗口。
这个模式带来了一个干净且关键的分离:LLM 负责"决定做什么",而你的代码负责"怎么做"。原文档特别强调:即使 LLM "调用"了一个工具,也绝不意味着你必须每次都原封不动地去执行某个对应的函数——执行方式、校验逻辑、权限检查、是否转交人工,全部由你的确定性代码说了算。
如果你回忆上一章的 agent 循环,会发现它正是这个模式的落地。仓库模板中
packages/create-12-factor-agent/template/src/agent.ts的agentLoop与handleNextStep就是"输出 → 执行 → 回填"的完整实现(见下节)。
四、仓库源码佐证:BAML 中的工具定义与 DetermineNextStep
在仓库的create-12-factor-agent模板中,工具不是 JavaScript 函数,而是baml_src目录下的 BAML 类型定义。
4.1 计算器工具:四个纯数据类
packages/create-12-factor-agent/template/baml_src/tool_calculator.baml 定义了四个工具:
class AddTool { intent "add" a int | float b int | float } class SubtractTool { intent "subtract" a int | float b int | float } class MultiplyTool { intent "multiply" a int | float b int | float } class DivideTool { intent "divide" a int | float b int | float }注意这些类与CreateIssue的结构完全同构:一个常量intent("add"、"subtract"等)加上两个参数a、b。同时type CalculatorTools = AddTool | SubtractTool | MultiplyTool | DivideTool把四个类组合成一个"工具集合"的联合类型,供下游函数引用。
4.2 让模型输出"下一步":DetermineNextStep
packages/create-12-factor-agent/template/baml_src/agent.baml 中的DetermineNextStep函数,把"工具集合"作为返回值类型暴露给 LLM:
type HumanTools = ClarificationRequest | DoneForNow | RequestApprovalFromManager type CalculatorTools = AddTool | SubtractTool | MultiplyTool | DivideTool type CustomerSupportTools = ProcessRefund function DetermineNextStep( thread: string ) -> HumanTools | CalculatorTools | CustomerSupportTools { client "openai/gpt-4o" prompt #" {{ _.role("system") }} You are a helpful assistant that can help with tasks. {{ _.role("user") }} You are working on the following thread: {{ thread }} What should the next step be? {{ ctx.output_format }} Always think about what to do next first, like: - ... - ... - ... {...} // schema "# }这段代码直观体现了本原则的全部要点:
- 返回值是一个联合类型:模型输出的"下一步"要么是某个工具(
AddTool、SubtractTool、ProcessRefund…),要么是一个"人类动作"(请求澄清ClarificationRequest、完成DoneForNow、请求经理审批RequestApprovalFromManager)。工具与"非工具"被统一放在同一个结构化输出的类型空间里。 - prompt 中只要求"输出格式":
{{ ctx.output_format }}告诉模型"请按给定的 schema 输出",即把"调用工具"翻译成"输出一段匹配联合类型的 JSON"。这正是"工具就是结构化输出"在提示词层面的直接体现。 - 生成器配置见 packages/create-12-factor-agent/template/baml_src/generators.baml:
output_type "typescript"、output_dir "../",它负责把 BAML 类型生成为可直接import的 TypeScript 客户端代码(如模板代码中的import { AddTool, ... , b } from "../baml_client")。
工具循环的三个测试用例(见 agent.baml 中
test MathOperation、test LongMath、test MathOperationWithClarification)验证了:模型在can you multiply 3 and 4?时输出intent == "multiply";在多步运算后输出intent == "done_for_now";在输入含乱码时输出intent == "request_more_information"。也就是说,"工具调用"与"结束""澄清"在模型输出层面没有本质区别,都只是联合类型的一个分支——这是对"工具只是结构化输出"最有力的实证。
五、确定性代码执行:switch 分支接管一切
结构化输出产生之后,"如何执行"完全由你的确定性代码决定。原文档给出的执行骨架是:
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仓库模板用 TypeScript 实现了同一套逻辑。在 packages/create-12-factor-agent/template/src/agent.ts 的handleNextStep中,每个intent分支执行真实的计算,然后把结果作为tool_response事件回填进thread:
export async function handleNextStep(nextStep: CalculatorTool, thread: Thread): Promise<Thread> { let result: number; switch (nextStep.intent) { case "add": result = nextStep.a + nextStep.b; console.log("tool_response", result); thread.events.push({ "type": "tool_response", "data": result }); return thread; case "subtract": result = nextStep.a - nextStep.b; // ... 同样回填 thread case "multiply": result = nextStep.a * nextStep.b; // ... case "divide": result = nextStep.a / nextStep.b; // ... } }而agentLoop则把"模型输出下一步 → 确定性代码执行 → 结果回填 → 再问模型"串成循环:
export async function agentLoop(thread: Thread): Promise<Thread> { while (true) { const nextStep = await b.DetermineNextStep(thread.serializeForLLM()); console.log("nextStep", nextStep); thread.events.push({ "type": "tool_call", "data": nextStep }); switch (nextStep.intent) { case "done_for_now": case "request_more_information": case "request_approval_from_manager": // response to human, return the thread return thread; case "divide": // divide is scary, return it for human approval return thread; case "add": case "subtract": case "multiply": thread = await handleNextStep(nextStep, thread); } } }这段实现有三个值得注意的细节,它们全部印证了"你的代码控制怎么做"这一原则:
done_for_now、request_more_information、request_approval_from_manager也被当作"工具"统一处理——它们同样是结构化输出,只是"执行动作"是"把话术返回给用户/发起审批"而不是"调用外部 API";divide分支被单独拦截:出于安全考虑,"除法"这类有风险(除零、误导性结果)的操作不直接执行,而是把线程交还给外层循环走人工审批(见 src/cli.ts 中askHumanCLI的approveCLI,以及 src/server.ts 中hl.createFunctionCall的审批分支)——这证明模型输出的"工具调用"并不必然被原样执行,是否执行、如何执行完全由确定性代码掌控;- 事件回填:
tool_response被压回thread.events,而Thread.serializeForLLM()会把这些事件序列化成 LLM 可读的<>标签文本,供下一轮DetermineNextStep使用——完成"结果捕获并回填到上下文"这一环。
六、"下一步"不必是原子函数:释放控制流的灵活性
原文档特别提醒一个容易被忽略的事实:"下一步"不一定原子到"运行一个纯函数并返回结果"。当你把"工具调用"仅仅理解为"模型输出一段 JSON、描述确定性代码该做什么"时,你就解锁了大量灵活性:
- 一个
intent可以对应多步业务动作(组合多个 API 调用); - 一个
intent可以触发人工审批、异步等待、状态持久化; - 一个
intent甚至可以什么都不做,仅仅作为对话中的"语义标记"。
把这一思想与 Factor 8:Own Your Control Flow 组合起来看:工具调用的语义由你的switch分支决定,而不是由框架的函数注册表决定,因此控制流始终掌握在你的确定性代码手里。这正是 12-Factor Agents 反复强调的"去框架化、模块化"主张的核心——你不需要框架提供运行时,你只需要结构化的模型输出和一段你自己的switch。
七、关于 "plain prompting vs tool calling vs JSON mode" 的取舍
原文档也坦诚地指出:关于"纯提示词(plain prompting)"、"工具调用(tool calling)"与"JSON 模式(JSON mode)"各自的优劣与性能差异,业界已有大量讨论,本篇不展开深挖(相关外部资料可参考 Schema Aligned Parsing、Vellum 的函数调用与结构化输出对比、LlamaIndex 的 OpenAI JSON vs Function Calling 等)。这里只强调与本文主题相关的一个事实:
无论你采用哪种方式让模型产出结构化结果,其本质都是"让 LLM 输出一段可解析为数据结构的 JSON"。工具调用只是其中一种约束更强、更常见的形态;而"工具是结构化输出"这条原则,意味着你的执行代码不依赖任何特定厂商的工具调用协议——这为迁移模型、切换供应商、甚至多模型并存留下了空间。
八、在生产中如何落地这条原则
综合原文档与仓库实现,落地"工具即结构化输出"的实践清单如下:
- 用纯数据类型定义工具:每个工具 = 常量
intent+ 参数集合。在 BAML 中写作class XxxTool { intent "xxx"; ... },再组合成联合类型(如CalculatorTools)作为模型输出的"下一步"空间; - 让模型输出联合类型:
DetermineNextStep的返回值类型即工具集合的联合类型,prompt 中通过{{ ctx.output_format }}约束格式,由生成器(generators.baml)生成对应语言客户端; - 确定性代码接管执行:用
switch (nextStep.intent)或if/elif分派动作,执行结果以tool_response事件回填线程; - 为高风险工具设置拦截:像模板中
divide分支那样,把某些 intent 交给外层循环走人工审批(Factor 7:Contact Humans With Tools 会深入展开); - 把"非工具动作"也纳入同一类型空间:
done_for_now、request_more_information等人类交互动作与工具并列放在联合类型里,统一走同一套循环逻辑; - 保留对未知 intent 的处理:
else分支处理"模型输出了你没定义的工具"这一真实发生的边界情况,而不是让程序崩溃。
九、小结
12-Factor Agents 的第四条原则——Tools are just structured outputs——用一个极简的心智模型消解了工具调用的神秘感:
- LLM 只负责输出结构化 JSON,选择哪个"工具"、填哪些参数;
- 你的确定性代码负责执行,怎么执行、是否执行、执行后做什么,全部由
switch分支决定; - 执行结果回填上下文,驱动下一轮决策,形成稳定的 agent 循环。
从 agent.baml 的联合类型返回值和四个计算器工具类,到 agent.ts 的handleNextStep/agentLoop,仓库模板完整演示了这条原则从定义到执行的全部环节。它让"工具"回归本质:一段描述意图的数据,加上一段属于你的代码。把这一点与 Factor 3:Own Your Context Window(决定向模型暴露哪些上下文)以及 Factor 8:Own Your Control Flow(决定循环与分派逻辑)结合起来,你就掌握了构建生产级 LLM 应用的三个关键支点。
- 文档
- 教程
- 人工智能
- 大模型
- AI Agent
【免费下载链接】12-factor-agents
What are the principles we can use to build LLM-powered software that is actually good enough to put in the hands of production customers?
相关推荐
终极指南:12-Factor Agents与结构化输出工具调用的实战技巧
终极指南:12 Factor Agents与结构化输出工具调用的实战技巧 12 Factor Agents是一套构建生产级LLM应用的核心原则,旨在解决AI驱动
文档教程人工智能大模型AI Agent终极指南:12-Factor Agents与BAML集成实现结构化输出的完整教程
终极指南:12 Factor Agents与BAML集成实现结构化输出的完整教程 12 Factor Agents是一个模块化构建LLM应用的开源项目,确保生产
文档教程人工智能大模型AI AgentSemantic Kernel .NET 结构化输出(Structured Outputs)技术方案与实战指南
Semantic Kernel .NET 结构化输出(Structured Outputs)技术方案与实战指南 导读 本文基于 Semantic Kernel
人工智能大模型AI AgentAgent 框架多智能体RAG
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考