与运行时无关的 AI 工作流:一种兼顾生产环境稳定性和快速评估迭代的模式
AI 工作流是一系列为完成某项任务而串联起来的步骤,其中一个或多个步骤包含对大语言模型(LLM)的调用。在此,我们将这些步骤组合在一起的逻辑(包括步骤的顺序和分支)称为工作流的协调逻辑。
对于这些工作流的生产要求,与过去十年间任何长期运行的分布式系统相同:它们需要能够经受住部署和崩溃的考验,能够以幂等的方式重试,并实现水平扩展。工作流引擎早在多年前就已经解决了这一类问题。
AI 工作流的独特之处在于,LLM 步骤的输出质量可能会随着每次提示词微调或模型变更而发生漂移。因此,必须通过评估(即在标注数据集上离线运行工作流并对输出进行评分)来对其进行验证。
反过来,这又要求经典工作流引擎具备其设计时未曾考虑的功能:一个成本足够低、能够重复运行数百次的快速评估循环。
这两项要求是相互矛盾的。生产环境的持久性需要一个重量级、分布式的持久化运行时环境;而评估迭代则需要一个轻量级、可在进程内运行的短暂循环,而且要能够在几秒钟内重新运行。大多数技术栈都是围绕其中一种运行时环境构建的。
虽然持久性优先的运行时确实提供了测试环境,但它们需要为一个根本不需要这些组件的评估循环搭建沙箱、任务队列和测试服务器。本文介绍了我们用来消除这种权衡的模式。
该模式源自 Brex 的 AI 工作流平台。该平台采用 TypeScript 编写,由五名工程师组成的团队负责维护。其工作进程运行在 Brex 的 Kubernetes 集群上,并连接到托管型 Temporal 服务 Temporal Cloud 来执行长期运行的代理。
代理通过 Vercel AI SDK 访问大语言模型(LLM)。该 SDK 会将请求路由至内部 LLM 网关,该网关集中管理速率限制和身份验证。评估则在我们的自建平台上运行。
生产环境稳定性与快速离线评估之间的权衡
让我们先从工作流引擎的功能说起。持久化执行要求在执行下一步之前,必须将每一步的结果持久化存储。如果流程发生崩溃、被重新部署,或者被重新调度到另一个工作节点上,引擎会回放历史记录,并从中断处精确地继续执行。状态的存续时间超过任何单个进程。这正是人们对一个深度研究代理的期望——该代理需运行一小时,期间会调用数十次大语言模型(LLM)并调用各类工具:绝不能因为一个 Pod 被回收而损失四十分钟的工作成果。
现在来看评估迭代功能。你正在调整提示词或分支决策。你希望修改一行代码,加载包含几百个示例的数据集,并查看汇总分数。这个循环是局部的,在进程内,而且非常短暂。它不应该在集群范围内持久化或调度任何内容。任何内容都不应该在运行结束后继续存活。你需要模拟依赖项,使其输出保持稳定,从而将大语言模型(你实际正在评估的部分)隔离成不同运行之间唯一会变化的因素。这样,该循环的执行成本就会变得足够低,每小时可以运行数百次。
将评估代码通过工作流引擎运行会导致类别不匹配。你会继承持久化、任务队列、工作进程以及重放语义。这些开销与你所需要的紧凑循环相悖。反之,若将生产环境代码通过评估框架运行,则无法提供你那个运行一小时的代理所依赖的任何持久性保证。
它们是为解决不同问题而存在的不同的运行时。你的编排不应是被迫二选其一。但在实际中,情况几乎总是如此。
大多数技术栈都强迫你选择其一
团队最终会与某个运行时“捆绑”在一起,这是因为在主流工具的设计中,编排与运行时是紧耦合的。诸如 LangGraph 和 Mastra 之类的代理框架,会在其自身的 SDK 中直接表达编排逻辑。你的控制流会转化为图中的节点和边,或者转化为框架提供的领域特定语言(DSL)。编排逻辑与框架本身是同一个组件。要评估该逻辑,就需要运行框架;要部署该逻辑,同样需要运行框架。不存在独立于框架而存在的编排。
以下是一个具体的示例,展示了一个 Mastra 工作流 classifyBusinessAgent(即我们稍后将重写的那个):
复制代码import { createWorkflow, createStep } from "@mastra/core/workflows";import { z } from "zod";const enrichWithWebData = createStep({id: "enrich-with-web-data",inputSchema: z.object({ businessName: z.string(), website: z.string() }),outputSchema: z.object({ businessName: z.string(), webContext: z.string() }),execute: async ({ inputData }) => {// ...},});const classify = createStep({id: "classify",inputSchema: z.object({ businessName: z.string(), webContext: z.string() }),outputSchema: z.object({ category: z.string() }),execute: async ({ inputData }) =>// ...});// 排序逻辑存在于 Mastra 的构建器而非普通的控制流中。// 各个步骤是 Mastra 对象,且工作流只有在提交到其引擎后才会生效。export const classifyBusinessWorkflow = createWorkflow({id: "classify_business",inputSchema: z.object({ businessName: z.string(), website: z.string() }),outputSchema: z.object({ category: z.string() }),}).then(enrichWithWebData).then(classify).commit();
这里的一切都由 Mastra 掌控。在 .commit() 将图结构传递给 Mastra 的引擎之前,没有任何操作会执行。要评估这一逻辑,就需要启动该引擎。
像 Temporal 这样的工作流引擎则采取了相反的做法:它允许你使用通用语言编写编排代码,但会对编写方式施加限制。Temporal 的工作流代码必须是确定性的,因此你无法在编排代码内部直接调用 Date.now() 或执行 I/O 操作。所有这些操作都必须推入工作流步骤中。而且,跨工作流边界的数据对有效载荷的大小有限制。这些约束的存在是为了确保可重放性,但它们的存在也要求编排代码必须遵循引擎的规则。
Brex 的开户代理运行在 Temporal 上的生产环境中,但其基于大语言模型(LLM)的决策需要持续调整。对于这些代理的评估,有一种简单粗暴的方法是在单独的评估运行时中重新实现每个代理,但这种方法会导致同一个逻辑存在两份副本,从而可能引发评估环境与生产环境之间的偏差,因为这两份副本可能会产生漂移。
评估与生产环境偏差正是该模式希望杜绝的故障模式。它实现了与运行时无关的工作流编排,打破了大多数框架所做的假设:运行时与编排是一个组件。
运行时无关的编排
核心举措是:不再为某个运行时编写编排代码,而是开始针对该运行时所遵循的接口规范来编写编排代码。

图 1 :可移植内核及其适配器(图片由作者制作)
该编排及其 Steps 接口在各个运行时环境中完全一致;仅注入的插件和底层的运行时会发生变化。
这个存储库中提供了该模式的完整可运行版本。其中包含一个名为 ClassifyBusinessAgent 的可运行代理,已经连接了生产环境和评估环境运行时。其 src/ 文件夹分成了三个部分。agents/ 文件夹是代理创建者唯一需要触及的部分,每个代理对应一个文件夹,其中包含编排以及实现该编排的具体步骤。platform/ 文件夹包含代理定义所依赖的基元,以及上图中提到的两个运行时适配器。最后,bin/ 文件夹包含入口点,例如生产环境工作进程和评估循环。
正是这种划分支撑了该模式得以诞生的观点。添加一个代理只需在 agents/ 目录下编写代码即可。platform/ 目录保持不变,两个运行时也不会获取关于新代理的任何信息。
具体来说,代理的编排是一个普通的函数。其唯一的依赖是类型化的 Steps 接口,其中列出了代理所有有意义的操作。它不会导入任何与运行时相关的内容:既不导入 Temporal,也不导入 eval 框架,更不导入 Node.js 内置函数。
复制代码// 该协调机制所依赖的契约。此处没有任何内容涉及运行时。export interface ClassifyBusinessSteps {enrichWithWebData(website: string): Promise<string>;classify(businessName: string, webContext: string): Promise<string>;}// 不会导入工作流引擎或评估框架。export const classifyBusinessAgent = defineAgentHandle({name: "classify_business",description: "Classifies a business given its name and website.",orchestration: async (steps: ClassifyBusinessSteps,input: { businessName: string; website: string },) => {const webContext = await steps.enrichWithWebData(input.website);return steps.classify(input.businessName, webContext);},});
该编排逻辑读起来就像业务逻辑:先丰富数据,再进行分类。它并不关心 enrichWithWebData 是分派给工作进程的 Temporal 活动,还是返回测试数据的进程内调用。编写新代理的开发人员永远不会接触运行时。
副作用存在于具体的 Steps 实现中。这里才是实际执行操作的地方。它通过依赖注入接收诸如 Web 爬虫和 LLM 客户端等依赖项(参见 ClassifyBusinessStepsImpl)。
在生产环境中,插件会调用真实的服务;而在评估环境中,插件则返回测试数据。编排机制不区分这两种情况,而这正是我们所期望的特性。
确保编排的可移植性
可移植性并非理所当然,必须强制执行,否则一旦有人为了图方便而走捷径,这种特性就会被侵蚀。以下两条规则最为重要:
编排中不得存在隐蔽的非确定性
不得读取墙钟时间、不得使用随机值、不得进行直接 I/O 操作。任何非确定性的内容都应该作为 Steps 方法来处理,这样它就成为运行时接管控制的切入点。
编排中不得使用 Node.js 或运行时特有的 API
编排和 Steps 接口绝不能导入任何会将其与特定进程模型绑定的内容。仅限 Node.js 使用的模块(HTTP 客户端、文件/CSV 解析器)应位于 StepsImpl 中,绝不能出现在编排或接口中。
这些规则可以确保同一个编排方案可以在生产环境和评估环境中安全地重放。我们将可移植的结构设计为阻力最小的路径:defineAgentHandle 函数仅提供步骤和供处理的输入,因此编写代理的方式本身就可以保证其正确性。
生产环境和评估环境适配器
随着编排被简化为实现接口的一个函数,运行时适配器的作用仅为“提供 Steps 实现并调用该函数”。有两个适配器承担了这一任务:用于生产环境持久化的 Temporal 适配器,以及用于评估的进程内适配器。
Temporal 适配器
Temporal 将代码划分为两部分:活动(工作流步骤),在普通的 Node.js 进程中运行并可能执行 I/O 操作;工作流代码,在确定性沙箱中运行,其影响外部世界的唯一方式是分派一个活动。我们的适配器将该模式清晰地映射到了这种划分上:每个 Steps 方法都成为一个活动,而协调逻辑则在沙箱内部运行。

图 2:Temporal 适配器序列图(图片由作者制作)
在沙箱中对 steps.foo(...) 的调用会被作为持久化活动分派到工作进程上。每个代理的 Proxy 会重新添加 agentName 前缀,因此,编排层对运行时环境一无所知。
工作进程端在普通的 Node 进程中运行。它使用真实的插件实例化每个代理的具体 Steps,并将每个方法注册为 Temporal 活动。它们会被扁平化为一个以代理名称为前缀的字典(例如 classify_business_enrichWithWebData),从而确保两个具有相同方法名的代理永远不会发生冲突。工作进程的完整源代码位于 worker.ts 中。
编排端是在沙箱中运行的部分。它从未导入 StepsImpl,因此这里没有任何东西会间接引入 Node 模块。这就是前面提到的构建时强制检查机制。如果编排端意外依赖了仅限 Node 的模块,那么该打包文件将无法构建。编排代码的完整源代码位于 workflows.ts 中。
作为 Steps 接口的实现,Temporal 适配器提供带 Temporal 活动的代理编排函数。每次编排层调用一个方法时, Proxy 都会拦截该访问并在方法名前添加代理名称,将普通方法映射到工作进程已注册的带前缀的活动(因此 steps.enrichWithWebData 会映射到 classify_business_enrichWithWebData)。
客户端启动 agentWorkflow 后,编排层内部的每次 steps.foo(...) 调用都会作为 Temporal 活动进行分发,并支持在出现重试、超时以及重新部署时进行重放。编排代码完全察觉不到这些操作的发生。
正是这一层强化了我们的长期运行代理。它们每天运行约一百次,每次耗时 20 到 60 分钟,涉及数十次 LLM 和工具调用。在旧的架构下,运行过程中任何环节的 Pod 回收、重新部署或超时都会导致整个流程被清零,近 4% 的任务永远无法完成。现在,当工作节点在运行中途宕机时,Temporal 会回放历史记录并从最后完成的步骤继续执行;过去几个月里,任务完成率一直保持在 99.9%。
Eval 适配器
eval 适配器要小得多。它没有沙箱、没有工作进程,也没有任务队列。它通过一个 Steps 实例在进程内运行相同的编排。它的插件返回的是测试数据,而不是调用真实的服务。感兴趣的话,可以自行查看 run-eval.ts 文件。
复制代码// 使用真实的 Steps 实例在进程内运行编排,但使用返回测试数据集的插件// 与生产环境完全相同的编排,每个字节都一样// 仅底层运行时环境不同export async function runEval<Input, Output, Steps>(handle: AgentHandle<Input, Output, Steps>,StepsClass: new (plugins: Plugins) => Steps,input: Input,fixtures: Record<string, string>,llm: Llm,): Promise<Output> {const plugins: Plugins = {webScraper: new MockWebScraper(fixtures),llm, // LLM 调用是真的,这是要评估的内容};return handle.orchestration(new StepsClass(plugins), input);}
由于 runEval 只是一个 (input) => Promise 函数,所以任何 eval 平台都可以将其作为黑盒进行封装。有关使用 Braintrust 运行 eval 的示例,请参阅 braintrust-eval.ts。
Braintrust 仅是一个示例,旨在说明协调机制完全不涉及 eval 框架或运行时。无论是 Laminar 、内部工具还是普通脚本,其集成方式都完全相同。这种方法使我们能够以较低的成本对 eval 平台进行实验。
收获与付出
与软件领域的其他架构一样,这种架构也存在权衡取舍。
收获
从设计上讲,评估版与生产版之间不存在偏差。
你要评估的编排方案就是你最终发布的编排方案,因此,经过分支调优的评估版不可能在不知不觉中与生产环境中的版本产生差异。
运行时选择变得可逆。
执行时可以选用 Temporal 或 Restate ,评估时可以选用 Braintrust、Laminar 或 LangSmith 。这些都只是适配器的替换,而非重写。我们曾多次更换评估平台,而代理对此毫无察觉。
运行时复杂性已经成为一次性的平台成本。
开发者只需要针对 Steps 接口编写纯 TypeScript 代码,不需要学习 Temporal 的确定性规则或评估 SDK 即可发布代理。新贡献者加入时只需要适配一个接口,而非一个运行时。
可靠性与业务影响随之提升。
通过吸收瞬时基础设施故障,Temporal 适配器将长期运行的成功率从约 96% 提升至 99.9%。在业务层面,基于该平台构建的代理现在能为超过一半的开户申请生成自动化决策。
付出
直接访问运行时原生基本操作。
编排层无法直接调用 Temporal 的信号、查询或定时器,因为这些在 eval 运行时中并不存在。任何运行时原生功能都必须通过接口进行建模,有时这会导致抽象方式不如原生 API 优雅。
每个运行时功能都必须通过间接方式添加并进行传播。
要暴露一项新功能,需要将其设计到接口中,并在所有适配器中实现。新功能是全平台范围的变更,而非一行代码就能完成。
开箱即用的可视化工具。
在默认情况下,框架原生的图可视化工具和步骤调试器会假设你是根据其模型编写的代码。当你的编排层仅是实现了接口的普通函数时,除非自行构建,否则将无法使用这些工具。
当你拥有不止一两个工作流、真正的生产级可靠性要求以及严谨的评估流程时,这种模式才能真正发挥其价值。对于一个托管着大量代理并且需要遵循受监管决策的平台而言,将运行时视为接口后面的插件,这样的设计决策可以使生产可靠性与评估速度不再相互冲突。