拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

12-Factor Agents 第四原则:工具调用本质上是结构化输出(Tools Are Just Structured Outputs)

12-Factor Agents 第四原则:工具调用本质上是结构化输出(Tools Are Just Structured Outputs)
  • 文档
  • 教程
  • 人工智能
  • 大模型
  • 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?

项目地址:https://gitcode.com/GitHub_Trending/12/12-factor-agents
点击查看免费下载

导读

在 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 决策(结构化输出)与确定性代码执行之间的桥梁。

一旦接受了这个心智模型,很多框架给你强加的"工具注册""函数绑定""运行时解释器"等复杂度就不再必要。你只需要两样东西:

  1. 一组描述工具意图与参数的数据结构;
  2. 一段根据这些数据结构执行动作的确定性代码。

二、用纯数据结构定义工具: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 结构,剩下的事交给你的代码。

三、模式三步曲:输出 → 执行 → 回填

原文档将整个模式压缩为三个步骤:

  1. LLM 输出结构化 JSON(描述"下一步"该做什么);
  2. 确定性代码执行对应的动作(例如调用外部 API);
  3. 执行结果被捕获并回填到上下文窗口。

这个模式带来了一个干净且关键的分离: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); } } }

这段实现有三个值得注意的细节,它们全部印证了"你的代码控制怎么做"这一原则:

  1. done_for_now、request_more_information、request_approval_from_manager也被当作"工具"统一处理——它们同样是结构化输出,只是"执行动作"是"把话术返回给用户/发起审批"而不是"调用外部 API";
  2. divide分支被单独拦截:出于安全考虑,"除法"这类有风险(除零、误导性结果)的操作不直接执行,而是把线程交还给外层循环走人工审批(见 src/cli.ts 中askHumanCLI的approveCLI,以及 src/server.ts 中hl.createFunctionCall的审批分支)——这证明模型输出的"工具调用"并不必然被原样执行,是否执行、如何执行完全由确定性代码掌控;
  3. 事件回填: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"。工具调用只是其中一种约束更强、更常见的形态;而"工具是结构化输出"这条原则,意味着你的执行代码不依赖任何特定厂商的工具调用协议——这为迁移模型、切换供应商、甚至多模型并存留下了空间。

八、在生产中如何落地这条原则

综合原文档与仓库实现,落地"工具即结构化输出"的实践清单如下:

  1. 用纯数据类型定义工具:每个工具 = 常量intent+ 参数集合。在 BAML 中写作class XxxTool { intent "xxx"; ... },再组合成联合类型(如CalculatorTools)作为模型输出的"下一步"空间;
  2. 让模型输出联合类型:DetermineNextStep的返回值类型即工具集合的联合类型,prompt 中通过{{ ctx.output_format }}约束格式,由生成器(generators.baml)生成对应语言客户端;
  3. 确定性代码接管执行:用switch (nextStep.intent)或if/elif分派动作,执行结果以tool_response事件回填线程;
  4. 为高风险工具设置拦截:像模板中divide分支那样,把某些 intent 交给外层循环走人工审批(Factor 7:Contact Humans With Tools 会深入展开);
  5. 把"非工具动作"也纳入同一类型空间:done_for_now、request_more_information等人类交互动作与工具并列放在联合类型里,统一走同一套循环逻辑;
  6. 保留对未知 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?

项目地址:https://gitcode.com/GitHub_Trending/12/12-factor-agents
点击查看免费下载

相关推荐

上一篇:英雄联盟智能辅助工具League Toolkit:如何轻松提升你的游戏体验与胜率
下一篇:Inter字体终极指南:5分钟掌握现代数字界面排版艺术

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

返回列表