生産環境の耐久性と高速評価を両立するAIワークフローのパターン
本文の状態
日本語全文を表示中
詳細モードで約23分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
InfoQ AI/ML
InfoQ AI/ML は、AI ワークフローの生産性耐久性と評価ループ速度という相反する要件を両立させるため、実行環境に依存しない純粋なビジネスロジックを記述し、ランタイムをプラグインとして接続するパターンを提案している。
AI深層分析を開く2026年8月6日 20:25
AI深層分析
キーポイント
相反する要件の対立構造
AI ワークフローは、クラッシュやデプロイに耐えるための耐久性(重いランタイム)と、LLM 出力品質をチェックするための高速な評価ループ(軽いランタイム)という、互いにトレードオフの関係にある二つのニーズを同時に満たす必要がある。
実行環境非依存のアーキテクチャ
ロジックを実行場所を認識しない純粋なビジネスロジックとして記述し、ランタイムをプラグインとして接続することで、評価用と本番用の両方で全く同じロジックが動作する仕組みを実現する。
バージョン不整合の排除
ロジックに単一のバージョンしか存在しないため、評価プロセスを経たコードが本番環境にデプロイされる際に、異なるバージョンへのドリフトによって生じるバグを根本的に防止する。
強制されたアーキテクチャ的制約
開発者の自律性に頼らず、非依存コードの記述を試みるとビルドが失敗するように設計し、正しい書き方を最も簡単な方法として強制する必要がある。
耐久性重視と評価ループの根本的な対立
耐久性のある実行はステップごとの永続化と再プレイを必要とするが、高速な評価ループはローカルで非永続的かつ仮想的な環境を要求する。このため、ワークフローエンジンを経由した評価や評価ハッチ経由の生産運用はそれぞれ欠陥を持つことになる。
重要な引用
The properties that buy durability are the ones that kill iteration speed.
When there is only one version of the logic, the version that goes through eval is guaranteed to match the one that ships.
Running your evals through a workflow engine is a category mismatch.
They are different runtimes solving different problems.
編集コメントを表示
編集コメント
本稿は、AI ワークフローの設計において長年指摘されてきたジレンマに対する明確な解決策を提示しており、実務レベルでの品質向上に直結する示唆に富む内容である。開発チームが評価と本番環境の乖離によるバグに悩まされる状況にある場合、このパターンに基づくリファクタリングは有効な戦略となるだろう。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
重要なポイント
AI ワークフローには、トレードオフの関係にある二つの要件が存在します。一つは本番環境での安定稼働であり、クラッシュやデプロイ、再起動に耐えるために各ステップを永続化・分散させる必要があります。もう一つは、LLM の出力品質を確認するための高速な試行錯誤ループです。しかし、耐久性を確保する仕組みが、この試行錯誤の速度を阻害してしまうのです。耐久性をもたらす特性こそが、イテレーションスピードを殺す要因となります。
両方の要件を満たすには、実行環境を意識しない純粋なビジネスロジックとしてワークフローを記述し、そこにランタイムを接続するアプローチが有効です。こうすれば、本番環境でも評価用(eval)環境でも、同じロジックが変更されることなく動作します。
ロジックのバージョンが一つに統一されれば、評価プロセスを経たものと本番リリースされたものが一致することが保証されます。この手法は、時間の経過とともにロジックのバージョンが乖離することで生じるバグのクラスを根本的に排除するものです。
実行環境に依存しないロジックを維持するには、開発者の自律性に頼ることはできません。アーキテクチャ自体が、「正しい書き方」が最も簡単な方法となるように設計される必要があります。誰かが非対応なコードを書いた場合、ビルドは必ず失敗するように仕組まれているべきです。
この分離にはコストも伴います。オーケストレーション層が各ランタイムのネイティブ機能に直接アクセスできなくなるため、新しい機能はすべて非対応レイヤーを介して接続する必要があります。この設計が真価を発揮するのは、プロジェクトが本番環境での信頼性と高速な評価の両方を本当に必要としている場合に限られます。
AI ワークフローとは、タスクを完了するために一連のステップが連鎖されたものであり、そのステップの一部またはすべてに大規模言語モデル(LLM)への呼び出しが含まれます。これらのステップを組み合わせるロジック(順序や分岐を含む)は、ここではワークフローのオーケストレーション・ロジックと呼ばれます。
こうしたワークフローには、過去 10 年以上にわたって長期間稼働する分散システムが備えてきたものと同じ生産環境での要件が求められます。デプロイやクラッシュからの回復、冪等性のあるリトライ、そして水平方向のスケーリングです。これら一連の問題は、すでにワークフロー・エンジンによって解決されています。
AI ワークフローを際立たせるのは、LLM ステップの出力品質が、プロンプトの微調整やモデルの変更ごとに drifting(ドリフト)する可能性がある点です。そのため、ラベル付きデータセットに対してワークフローを実行し、その出力にスコアを付けるオフライン評価(evals)によって品質を確認する必要があります。
そのためには、従来のワークフロー・エンジンが想定していなかったものが求められます。数百回も安価に再実行できる、高速な評価ループです。
この 2 つの要件は互いに相反する方向を向いています。生産環境での耐久性を求めるなら、重厚で永続的かつ分散型のランタイムが必要です。一方、評価の反復を重視するなら、数秒で再実行可能な軽量で一時的なプロセス内ループが求められます。現在の多くのスタックは、どちらか一方のランタイムを中心に構築されています。
耐久性を最優先したランタイム環境はテスト用環境を提供しますが、評価ループには沙箱(サンドボックス)、タスクキュー、テストサーバーが不要な場合でも、それらを構築する必要があります。この記事では、そのトレードオフを解消するパターンについて解説します。
このパターンは、Brex の AI ワークフロープラットフォームから生まれたものです。同プラットフォームは TypeScript で記述され、5 名のエンジニアチームによって維持されています。ワーカーは Brex の Kubernetes クラスター上で動作し、長時間実行されるエージェントの実行には、マネージド型の Temporal サービスである Temporal Cloud に接続しています。
エージェントは Vercel AI SDK を通じて LLM(大規模言語モデル)にアクセスし、これは内部の LLM ゲートウェイへルーティングされます。ゲートウェイではレート制限と認証が一元管理されています。一方、評価(Evals)は自社で開発したプラットフォーム上で実行されます。
生産環境の耐久性と高速なオフライン評価のトレードオフ
まず、ワークフローエンジンの機能について考えましょう。耐久性のある実行 では、次のステップを実行する前に各ステップの結果が永続化されることが必須です。プロセスがクラッシュしたり、再デプロイされたり、別のワーカーにスケジューリングされたりした場合でも、エンジンは履歴を再生して中断した場所から正確に再開します。状態は単一のプロセスよりも長く存続します。これは、数十回の LLM 呼び出しやツール実行を要する 深層研究エージェント のように、1 時間ほど稼働するシステムにおいてまさに必要な特性です。ポッドがリサイクルされたために 40 分間の作業が失われてしまうような事態は許容できません。
評価の反復処理における特徴を見てみましょう。プロンプトや分岐決定を調整する際、1 行を変更し、数百件のサンプルを含むデータセットを読み込んで集計スコアを確認したいはずです。このループはローカルでプロセス内で行われ、一時的なものです。クラスター全体にまたがって永続化したりスケジューリングされたりする必要はありません。実行を越えて残存するものも不要です。
依存関係をモックし、その出力を固定することで、LLM(実際に評価対象となる部分)だけが実行ごとに変わる唯一の要素であることを保証します。このループは十分に軽量で、1 時間に数百回実行可能です。
評価をワークフローエンジン「経由」で実行することは、カテゴリの不一致です。永続化、タスクキュー、ワーカー、再生セマンティクスといったものが自動的に付帯してしまいます。これは、あなたが求める緊密なループに逆らうオーバーヘッドとなります。逆に、本番環境を評価ハーン「経由」で実行すれば、1 時間続くエージェントが依存する耐久性の保証は得られません。
これらは異なるランタイムであり、異なる問題を解決しています。オーケストレーションはどちらにも縛られる必要はありません。しかし実際には、ほぼ例外なくそのようになっています。
ほとんどのスタックでどちらかを選ばされる
チームが特定のランタイムに縛られてしまう理由は、主要なツールが設計段階でオーケストレーションとランタイムを結合しているからです。LangGraph や Mastra といったエージェントフレームワークでは、オーケストレーションは独自の SDK で直接記述されます。制御フローはグラフのノードやエッジ、あるいはフレームワーク固有の DSL(ドメイン固有言語)として表現されるのです。オーケストレーションロジックとフレームワーク自体が同一のアーティファクトとなります。このロジックを検証するにはフレームワークを実行し、本番環境で提供するためにも同じくフレームワークを実行する必要があります。フレームワークに依存しない独立したオーケストレーションは存在しません。
具体的な例として、後ほど書き換える classifyBusinessAgent を Mastra のワークフローとして記述したものが以下です。
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 }) =>
// ...
});
// Ordering lives in Mastra's builder, not in plain control flow. The steps are
// Mastra objects, and the workflow only exists once committed to its engine.
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 が下す判断を継続的に調整する必要があります。これらを評価する際、各エージェントを別々の評価用ランタイムに再実装するのは単純な方法ですが、このアプローチでは同じロジックの複製が二つ存在することになり、複製同士がずれていくことで「評価環境と本番環境の乖離(eval-prod skew)」という問題が発生するリスクがあります。
このパターンはまさにその「評価環境と本番環境の乖離」という失敗モードを不可能にするために設計されており、ランタイム非依存のワークフローオーケストレーションを実装することで達成されます。これは、多くのフレームワークが前提としている「ランタイムとオーケストレーションは単一のアーティファクトである」という考え方を打破するものです。
ランタイム非依存のオーケストレーション
中核となるアプローチは、特定のランタイム向けにオーケストレーションを書くのをやめ、そのランタイムが満たすべきインターフェースに対して記述し直すことです。

図 1. ポータブルなコアとそのアダプター。出典:著者作成。
オーケストレーションと、その Steps インターフェースはランタイムを問わず同一です。変化するのは注入されるプラグインと、その背後にあるランタイムのみです。
このパターンの完全な実行可能バージョンは、リポジトリで利用可能です。ここでは、本番環境と評価用ランタイムの両方に接続された `ClassifyBusinessAgent` という実行可能なエージェントが用意されています。src/ フォルダは 3 つの観点から分割されており、そのうち agents/ フォルダ(リンク)のみが、エージェント作成者が直接触れる箇所です。各エージェントごとにフォルダが用意され、そこにはオーケストレーションと、それを実現する具体的なステップが記述されています。
platform/ フォルダには、エージェントの定義に用いるプリミティブと、上記画像にある 2 つのランタイムアダプターが格納されています。一方、bin/ フォルダには、本番環境用のワーカーや評価ループといったエントリーポイントが含まれています。
この上位レベルの分割こそが、本パターンを裏付ける根拠となります。エージェントを追加する際は、`agents/` ディレクトリのみでコードを書けば十分です。`platform/` 側に変更は不要であり、ランタイムが新しいエージェントについて何かを学習する必要もありません。
具体的には、エージェントのオーケストレーションは単なる関数です。その依存関係は、エージェントの有意味な操作名を定義する型付きの Steps インターフェースのみです。ランタイム固有のものは何もインポートしません。Temporal も評価フレームワークも、Node.js の組み込み機能も使用しません。
// The contract the orchestration depends on. Nothing here is runtime-aware.
export interface ClassifyBusinessSteps {
enrichWithWebData(website: string): Promise<string>;
classify(businessName: string, webContext: string): Promise<string>;
}
// No workflow engine or eval framework imports.
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` を参照)。
本番環境では、プラグインは実際のサービスにアクセスします。評価(evals)の段階では、プラグインはフェイクデータを返します。オーケストレーション側はこの違いを区別できませんが、これがまさに私たちが目指す特性です。
オーケストレーションの移植性を保つ
移植性は無料ではありません。誰かが便利なショートカットに手を出した瞬間に崩壊しないよう、強制して維持する必要があります。特に重要なルールは以下の 2 つです。
- オーケストレーション内で非決定性の処理を隠さない 壁時計からの読み取りや乱数の生成、直接の I/O は禁止します。非決定性が必要な場合は必ず Steps メソッドとして実装し、ランタイムが制御するポイントにします。
オーケストレーションには Node.js やランタイム固有の API を含めない
オーケストレーションと *Steps* インターフェースは、プロセスモデルに依存するものを一切インポートしてはいけません。HTTP クライアントやファイル/CSV パーサーなど Node.js 専用のモジュールは *StepsImpl* の中に配置し、オーケストレーションやインターフェースには決して含めないようにします。
これらのルールにより、同じオーケストレーションを本番環境と評価(evals)の両方で安全に再実行できるようになります。ポータブルな形状が最も抵抗の少ない道となるよう設計しています。例えば `defineAgentHandle` 関数は、作業対象として *steps* と *input* のみを提供するため、エージェントを記述する自然な書き方自体がすでに正しい形になっています。
本番用と評価用のアダプター
オーケストレーションがインターフェースに対する単一の関数に縮約されたことで、ランタイムアダプターの役割は「*Steps* を提供してその関数を呼び出す」ことに単純化されます。この負荷を担うのは主に 2 つのアダプターです。1 つは本番環境での耐久性を保証する Temporal、もう 1 つは評価用としてプロセス内で動作するアダプターです。
Temporal アダプターについて
Temporal はコードを 2 つの世界に分割します。*activities*(ワークフローのステップ)は通常の Node.js プロセスで実行され、I/O を行うことも可能ですが、*workflow* コードは決定論的なサンドボックス内で動作し、外部世界に影響を与える手段として *activity* をディスパッチする唯一の方法しか持ちません。私たちのアダプターはこのパターンをこの分割構造にきれいにマッピングします。各 *Steps* メソッドが 1 つの *activity* となり、オーケストレーションはサンドボックス内で実行されます。

図 2. タイムアダプターのシーケンス図。出典:著者作成
サンドボックス内での steps.foo(...) の呼び出しは、ワーカー上で永続的なアクティビティとして処理されます。各エージェント用のプロキシが agentName プレフィックスを再付加するため、オーケストレーション側は実行環境(ランタイム)を意識する必要がありません。
ワーカー側は通常の Node プロセスとして動作します。各エージェントの具体的なステップを実際のプラグインでインスタンス化し、すべてのメソッドを Temporal アクティビティとして登録します。これらは単一の辞書にフラット化され、エージェント名を接頭辞として付与された名前(例:classify_business_enrichWithWebData)の下に格納されます。この仕組みにより、同じメソッド名を定義する複数のエージェントが衝突することはありません。
ワーカーの完全なソースコードは `worker.ts` にあります。
サンドボックスで動作するのはオーケストレーション側です。ここでは StepsImpl をインポートしないため、Node モジュールが間接的に読み込まれることはありません。これは前述のビルド時の強制措置です。もしオーケストレーションが誤って Node 専用モジュールに依存してしまった場合、このバンドルはビルドに失敗します。
オーケストレーションコードの完全なソースコードは `workflows.ts` にあります。
Temporal アダプターは、エージェントのオーケストレーション関数に対して Temporal アクティビティを提供し、これを Steps インターフェースの実装として機能させます。オーケストレーションがメソッドを呼び出すたびに *Proxy* がアクセスを捕捉し、エージェント名を先頭に付加します。これにより、通常のメソッドがワーカーに登録された接頭辞付きのアクティビティにマッピングされます(例:steps.enrichWithWebData は classify_business_enrichWithWebData に変換される)。
一度 `agentWorkflow` がクライアントによって起動されると、オーケストレーション内のすべての steps.foo(...) 呼び出しは、リトライ、タイムアウト、再デプロイ時の再生機能を備えた Temporal アクティビティとして配信されます。このオーケストレーションコードがその背後で何が起こっているかを意識しているわけではありません。
これが、長時間稼働するエージェントを堅牢化するレイヤーです。同システムは1日に約100回実行され、各回の処理には20〜60分を要します。その間には数十回のLLM呼び出しやツール呼び出しが連続して行われます。
従来の構成では、実行中のどこかでポッドのリサイクル、デプロイ、またはタイムアウトが発生すると、すべての処理がリセットされ、約4%のタスクは完了しませんでした。
現在は、ワーカーが実行中に停止しても、Temporal が履歴を再生して最後の完了ステップから再開します。その結果、直近の数ヶ月間、完了率は99.9%を維持しています。
The Eval Adapter
評価用アダプターは劇的に小型化されています。サンドボックスやワーカー、タスクキューも不要です。同じオーケストレーションをプロセス内で実行し、プラグインが実際のサービスにアクセスする代わりにフェイクデータを返す *Steps* インスタンスと連携します。
ご自身で確認するには `run-eval.ts` をご覧ください。
// Runs orchestration in-process with a real Steps instance, but with plugins
// that return fixture data. Same orchestration as production — byte-for-byte —
// only the runtime underneath differs.
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 calls stay real — they are what we are evaluating.
};
return handle.orchestration(new StepsClass(plugins), input);
}runEval は単に (input) => Promise<output> という関数なので、あらゆる評価プラットフォームがこれをブラックボックスとしてラップできます。Braintrust を使用して評価を実行する例については、`braintrust-eval.ts` を参照してください。
Braintrust は単なる一例に過ぎず、オーケストレーション側が評価フレームワークやランタイムについて何も知らないことを示すためのものです。Laminar や社内ツール、あるいは単純なスクリプトであっても、同じように接続可能です。このアプローチにより、私たちは評価プラットフォームに対して低コストで実験を行えます。
成果とコスト
このアーキテクチャも、ソフトウェアの他の設計と同様にトレードオフを伴います。
得られた成果
- 評価と本番環境の乖離が構造的に防止される。評価するオーケストレーションがそのまま出荷されるため、評価用に調整したブランチが、本番環境のものとは無意識に異なるという事態は起こりません。
ランタイムの選択が反転可能になった。実行には Temporal や Restate を、評価には Braintrust や Laminar、LangSmith を選べる。これは書き換えではなくアダプターの差し替えに過ぎない。エージェントの動作に影響を与えずに、評価プラットフォームを複数回変更した事例もある。
ランタイムの複雑さは一度きりのプラットフォームコストで済んだ。開発者は *Steps* インターフェースに対してプレーンな TypeScript を書くだけでよく、エージェントをリリースするために Temporal の決定性ルールや評価 SDK を習得する必要はない。新しい貢献者をオンボーディングする際も、ランタイムそのものではなくインターフェースに慣れさせるだけだ。
信頼性とビジネスへの影響は確実に向上した。一時的なインフラ障害を吸収できる Temporal アダプターのおかげで、長時間実行される処理の成功率は約 96% から 99.9% に引き上がった。ビジネス面では、このプラットフォーム上で構築されたエージェントがオンボーディング申請の半数以上で自動判断を下せるようになっている。
私たちが手放したもの
- ランタイムネイティブなプリミティブへの直接アクセス。オーケストレーションは Temporal のシグナル、クエリ、タイマーを直接呼び出せない。評価用ランタイムにはそれらの概念が存在しないからだ。ランタイムネイティブな機能はすべてインターフェースの背後でモデル化する必要があり、ネイティブ API に比べると必ずしも洗練された抽象化になるとは限らない。
- すべてのランタイム機能は間接的な経路を通じて追加・伝播される。新しい機能を公開するには、まずインターフェースに設計し、すべてのアダプターで実装する必要がある。新機能の追加は単なる一行コードの変更ではなく、プラットフォーム全体への改修となる。
標準のビジュアルツールがすぐに使える
フレームワークに組み込まれたグラフ可視化やステップデバッガは、通常「そのモデルに合わせてコードを書くこと」を前提としています。一方、オーケストレーションをインターフェース上の単純な関数として実装すれば、独自のツールを開発しない限り、これらの機能を手放すことになります。
ワークフローが数個を超え、本番環境での耐久性要件や、本格的な評価(eval)プラクティスが必要となる場合にこそ、このパターンは真価を発揮します。規制された判断を下すエージェント群を運用するプラットフォームにおいて、「ランタイムをインターフェース背後のプラグインとして扱う」という設計決定が、本番環境の信頼性と評価の速度という相反する要素を対立させないための鍵となっています。
著者について
Mateus Moury
Mateus Moury は、大規模分散システムや高スループットプラットフォームのアーキテクチャ設計に10年の経験を持つシニアソフトウェアエンジニアです。現在、Brex でエージェントプラットフォームを率いており、KYC(本人確認)、不正検知、与信審査、営業といった主要な金融ワークフローにおいて、本番環境で稼働する AI エージェントをデプロイするために必要なオーケストレーション、評価、注釈付けのインフラを主導しています。過去には Google や Incognia での勤務経験もあります。
Show moreShow less
原文を表示
要点
- AI workflows have two needs that trade off directly. Running reliably in production requires persisting and distributing every step so it survives crashes, deploys, and restarts. But that same machinery is what makes runs too heavy for the fast, throwaway loop you need to check an LLM's output quality. The properties that buy durability are the ones that kill iteration speed.
- You can serve both needs by writing the workflow as pure business logic that doesn’t know where it runs, then plugging in the runtime, so the exact same logic runs unchanged in production and evals.
- When there is only one version of the logic, the version that goes through eval is guaranteed to match the one that ships. This approach removes a whole class of bugs caused by different versions of the logic drifting apart over time.
- Keeping the logic agnostic of where it runs can’t rely on developer discipline. The architecture must make the right way the easiest way to write a workflow. Whenever someone writes non-agnostic code, the build must fail.
- The decoupling isn’t free. The orchestration loses direct access to each runtime’s native features. Every new capability has to be wired through the agnostic layer. This design only pays off when a project genuinely needs both production reliability and fast evaluation.
An AI workflow is a sequence of steps chained together to complete a task, where one or more of those steps consists of calls to a large language model (LLM). The logic that combines these steps (their ordering and branching) is hereby referred to as the workflow’s orchestration logic.
These workflows carry the same production requirements any long-running distributed system has had for a decade: They need to survive deploys and crashes, retry idempotently, and scale horizontally. Workflow engines solved this class of problem years ago.
What sets AI workflows apart is that an LLM step’s output quality can drift with every prompt tweak or model change, so it has to be checked with evals, offline runs of the workflow against a labeled dataset to score its outputs.
That, in turn, demands something the classic workflow engine wasn't designed for: a fast evaluation loop that is cheap enough to rerun hundreds of times.
These two requirements pull in opposite directions. Production durability wants a heavyweight, persistent, distributed runtime. Eval iteration wants a lightweight, ephemeral, in-process loop you can rerun in seconds. Most stacks are built around one of these runtimes.
Durability-first runtimes do offer test environments, but they need to stand up sandboxes, task queues, and test servers for an evaluation loop that needs none of it. This article describes the pattern we used to remove that trade-off.
The pattern comes out of Brex's AI workflow platform. The platform is written in TypeScript and maintained by a team of five engineers. Its workers run on Brex's Kubernetes cluster and connect to Temporal Cloud, the managed Temporal offering, to execute long-running agents.
Agents reach LLMs through the Vercel AI SDK, which routes to an internal LLM Gateway that centralizes rate limiting and authentication. Evals run on our own in-house platform.
The Trade-Off Between Production Durability and Fast Offline Evals
Let’s start with workflow engine features. Durable execution requires that the result of every step is persisted before the next step runs. If the process crashes, is redeployed, or is rescheduled onto a different worker, the engine replays history and resumes exactly where it left off. State outlives any single process. That is precisely what you want for a deep research agent that runs for an hour across dozens of LLM calls and tool invocations: You cannot afford to lose forty minutes of work because a pod was recycled.
Now look at eval iteration features. You are tuning a prompt or a branching decision. You want to change one line, load a dataset of a few hundred examples, and see aggregate scores. The loop is local, in-process, and ephemeral. Nothing should be persisted or scheduled across a cluster. Nothing should survive the run. You want to mock the dependencies so that their outputs are held fixed, isolating the LLM (the piece you’re actually evaluating) as the only thing that varies between runs. The loop stays cheap enough to run hundreds of times an hour.
Running your evals *through* a workflow engine is a category mismatch. You inherit persistence, task queues, workers, and replay semantics. This is overhead that fights the tight loop you need. Conversely, running production *through* an eval harness gives you none of the durability guarantees on which your hour-long agent depends.
They are different runtimes solving different problems. Your orchestration should not have to marry either one. In practice, though, it almost always does.
Most Stacks Force You to Pick One
The reason teams end up married to a runtime is that mainstream tools couple orchestration to the runtime by design. Agent frameworks such as LangGraph and Mastra express orchestration directly in their own SDKs. Your control flow becomes graph nodes and edges, or the framework’s DSL. The orchestration logic and the framework are the same artifact. To evaluate that logic, you run the framework; to serve it, you run the framework. There is no orchestration that exists independently of the framework.
As a concrete example, here is the classifyBusinessAgent (the same one we’ll rewrite later) as a Mastra workflow:
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 }) =>
// ...
});
// Ordering lives in Mastra's builder, not in plain control flow. The steps are
// Mastra objects, and the workflow only exists once committed to its engine.
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();Everything here is Mastra. Nothing runs until .commit() hands the graph to Mastra’s engine. To evaluate this logic, you stand up that same engine.
Workflow engines such as Temporal go the other way, allowing you to write orchestration in a general-purpose language, but constraining how you write it. Temporal workflow code must be deterministic, so you cannot call Date.now() or perform I/O directly inside the orchestration. All that has to be pushed into the workflow steps. There are payload size limits on what crosses the workflow boundary. The constraints exist to ensure replayability, but their existence demands that the orchestration is written *against* the engine’s rules.
Brex's onboarding agents run in production on Temporal, but need their LLM-driven decisions tuned continuously. The naive way to evaluate them is to reimplement each agent in a separate eval runtime, but that approach leaves two copies of the same logic, which opens the door to eval-prod skew, because the copies can drift.
Eval-prod skew is the failure mode this pattern is designed to make impossible and is achieved by implementing runtime-agnostic workflow orchestration, which breaks most frameworks’ assumption that runtime and orchestration are a single artifact.
Runtime-agnostic Orchestration
The core move is to stop writing orchestration *for* a runtime and start writing it against an interface that the runtime satisfies.

Figure 1. The portable core and its adapters. Source: created by the author.
The orchestration and its Steps interface are identical across runtimes; only the injected plugins and the runtime underneath change.
A complete, runnable version of the pattern is available in the repository. It holds a runnable agent named ClassifyBusinessAgent that is wired to production and eval runtimes. Its src/ folder is split in three ways. The agents/ folder is the only one an agent author touches, with one folder per agent holding the orchestration and the concrete steps that implement it. The platform/ folder holds the primitives an agent is defined against, plus the two runtime adapters from the image above. Finally, bin/ holds the entry points, such as the production worker and the eval loop.
This top-level split is what backs up the argument behind the pattern. Adding an agent requires writing in agents/ only. The platform/ does not change and neither runtime learns anything about the new agent.
Concretely, an agent’s orchestration is a plain function. Its only dependency is a typed Steps interface that names the agent’s meaningful operations. Nothing runtime-specific is imported: no Temporal, no eval framework, and no Node.js built-ins.
// The contract the orchestration depends on. Nothing here is runtime-aware.
export interface ClassifyBusinessSteps {
enrichWithWebData(website: string): Promise<string>;
classify(businessName: string, webContext: string): Promise<string>;
}
// No workflow engine or eval framework imports.
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);
},
});The orchestration reads like business logic: Enrich, then classify. It has no opinion on whether enrichWithWebData is a Temporal activity dispatched to a worker or an in-process call that returns fixture data. A developer writing a new agent never touches a runtime.
The side effects live in a concrete Steps implementation. This is where real work happens. Here it receives dependencies, such as the web scraper and the LLM client, via injection (see ClassifyBusinessStepsImpl).
In production, the plugins hit real services. In evals, the plugins return fixtures. The orchestration cannot tell the difference, which is exactly the property we want.
Keeping Orchestration Portable
Portability is not free; it has to be enforced or it erodes the first time someone reaches for a convenient shortcut. Two rules matter most:
- No hidden non-determinism in orchestration
No wall-clock reads, no random values, no direct I/O. Anything non-deterministic is a Steps method, so it becomes a point where the runtime takes control.
- No Node.js or runtime-specific APIs in orchestration
The orchestration and the Steps interface must import nothing that ties them to a process model. Node-only modules (HTTP clients, file/CSV parsers) belong in StepsImpl, never in the orchestration or the interface.
These rules allow the same orchestration to replay safely in production and evals. We make the portable shape the path of least resistance: The function, defineAgentHandle, gives you nothing but *steps* and *input* to work with, so the natural way to write an agent is already correct.
Production and Eval Adapters
With orchestration reduced to a function over an interface, a runtime adapter is just "supply a *Steps* and call the function". Two adapters carry the load: Temporal for production durability and an in-process adapter for evals.
The Temporal Adapter
Temporal splits code into two worlds: *activities* (workflow steps), which run in a normal Node.js process and may do I/O, and *workflow* code, which runs in a deterministic sandbox, whose only way to affect the outside world is to dispatch an activity. Our adapter cleanly maps the pattern onto that split: Each *Steps* method becomes an activity and the orchestration runs inside the sandbox.

Figure 2. The Temporal adapter sequence diagram. Source: created by the author.
A steps.foo(...) call in the sandbox is dispatched as a durable activity on the worker. The per-agent Proxy readds the agentName prefix, so the orchestration stays unaware of the runtime.
The worker side runs in a regular Node process. It instantiates each agent’s concrete Steps with the real plugins and registers every method as a Temporal activity flattened into a single dictionary under names prefixed by the agent (classify_business_enrichWithWebData, for example), so that two agents defining the same method name never collide. The full source code for the worker is in worker.ts.
The orchestration side is what runs in the sandbox. It never imports StepsImpl, so nothing here transitively pulls in Node modules. This is the build-time enforcement mentioned earlier. If the orchestration accidentally depended on a Node-only module, this bundle would fail to build. The full source code for the orchestration code is in workflows.ts.
The Temporal adapter supplies an agent’s orchestration function with Temporal activities as the implementation of the Steps interface. Each time the orchestration calls a method, a *Proxy* intercepts the access and prepends the agent name, mapping the plain method to the prefixed activity the worker registered (so steps.enrichWithWebData maps to classify_business_enrichWithWebData).
Once a client starts agentWorkflow, every steps.foo(...) call inside the orchestration is dispatched as a Temporal activity with retries, timeouts, and replay on redeploy. The orchestration code is completely unaware that any of it is happening.
This is the layer that hardened our long-running agents. They run about one hundred times a day and take twenty to sixty minutes each, spanning dozens of LLM and tool calls. Under the old setup, a pod recycle, deploy, or timeout anywhere in the run would wipe it all and nearly four percent never completed. Now, when a worker dies mid-run, Temporal replays history and resumes at the last completed step; completion has held at 99.9 percent over the last few months.
The Eval Adapter
The eval adapter is dramatically smaller. There is no sandbox, no worker, and no task queue. It runs the *same* orchestration in-process with a *Steps* instance whose plugins return fixtures instead of hitting real services. Check it yourself at run-eval.ts.
// Runs orchestration in-process with a real Steps instance, but with plugins
// that return fixture data. Same orchestration as production — byte-for-byte —
// only the runtime underneath differs.
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 calls stay real — they are what we are evaluating.
};
return handle.orchestration(new StepsClass(plugins), input);
}Because runEval is just an (input) => Promise<output> function, any eval platform can wrap it as a black box. See braintrust-eval.ts for an example of using Braintrust to run evals.
Braintrust is just an example to illustrate that nothing about the orchestration knows about the eval framework or runtime. Laminar, an internal tool or a plain script, plugs in the same way. This approach lets us experiment cheaply with eval platforms.
Payoffs and Costs
This architecture, like any other in software, comes with trade-offs.
What We Gained
- No eval-prod skew, by construction.
The orchestration you evaluate is the orchestration you ship, so a branch-tuned-in eval cannot silently differ from the one in production.
- Runtime choices became reversible.
Temporal or Restate for execution, Braintrust or Laminar or LangSmith for evals. These are adapter swaps, not rewrites. We changed eval platforms more than once without agents noticing.
- Runtime complexity became a one-time platform cost.
Developers write plain TypeScript against a Steps interface. They do not learn Temporal’s determinism rules or an eval SDK to ship an agent. Onboarding a new contributor is onboarding to an interface, not to a runtime.
- Reliability and business impact followed.
By absorbing transient infrastructure failures, the Temporal adapter raised long-running execution success from about ninety-six percent to 99.9 percent. On the business side, agents built on this platform now produce an automated decision for more than half of onboarding applications.
What We Gave Up
- Direct access to runtime-native primitives.
Orchestration cannot call Temporal signals, queries, or timers directly, because those do not exist in the eval runtime. Anything runtime-native has to be modeled behind the interface, which sometimes forces a less elegant abstraction than the native API.
- Every runtime feature must be added through indirection and propagated.
Exposing a new capability requires designing it into the interface and implementing it across all adapters. New features are platform-wide changes, not one-liners.
- Visual tooling out of the box.
Framework-native graph visualizers and step debuggers assume you wrote to their model. When your orchestration is plain functions over an interface, you give that up unless you build your own.
The pattern earns its keep when you have more than a couple of workflows, real production-durability requirements, and a serious eval practice. For a platform hosting a fleet of agents against regulated decisions, treating the runtime as a plugin behind an interface has been the design decision that lets production reliability and eval velocity stop fighting each other.
About the Author
Mateus Moury
Mateus Moury is a Staff Software Engineer with ten years of experience architecting large-scale distributed systems and high-throughput platforms. He currently leads the Agents Platform at Brex, driving the orchestration, evaluation, and annotation infrastructure required to deploy production AI agents across core financial workflows, including KYC, fraud, underwriting, and sales. He has previously worked at Google and Incognia.
Show moreShow less
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み