🧪

AI SDK と Workflow DevKit を眺め、Agent 抽象の行く末に思いを馳せる

に公開

LayerX AI エージェントブログリレー 52日目の記事です。

バクラク事業部 スタッフエンジニアの @izumin5210 です。
Platform Engineering 部 Enabling チームでいろいろなことをしています。

AI SDK 6 BetaToolLoopAgent class と Agent interface, Workflow DevKit(vercel/workflow) のサブパッケージである @workflow/aiDurableAgent と、Agent の抽象を表すための概念が3つ続けて登場しています。 本記事ではこれらの定義・実装や関連するコードを眺め、 AI SDK や TypeScript で同様のレベル感のライブラリにおける Agent 抽象の行く末を妄想して楽しみます。 特にオチはない予定です。

ToolLoopAgent class (AI SDK 6 Beta)

AI SDK v5 で experimental_Agent として導入され、 AI SDK 6 から ToolLoopAgent とリネームされ導入予定の class です。

  • メインの API は後述する Agent interface が持つ generate()stream() の2つ
  • いずれも messages or prompt を引数にとり、constructor 経由で渡した他のオプションとマージして generateText() あるいは streamText() をそのまま実行する
  • 従来のオプションに加え、以下2つをコンストラクタから与えることができる
    • callOptionsSchema: generate() および stream() 実行時に追加の引数を要求できる
    • prepareCalls: generateText / streamText に渡すオプションを制御可能

messages or prompt 以外のオプションをインスタンス化時に渡し、そのインスタンスを使い回すことで Agent の挙動をカプセル化する… というコンセプトなんでしょうか。

// https://github.com/vercel/ai/blob/bfff278/packages/ai/src/agent/tool-loop-agent.ts#L12-L110
export class ToolLoopAgent<
  CALL_OPTIONS = never,
  TOOLS extends ToolSet = {},
  OUTPUT extends Output = never,
> implements Agent<CALL_OPTIONS, TOOLS, OUTPUT>
{
  // ...

  private async prepareCall(
    options: AgentCallParameters<CALL_OPTIONS>,
  ): Promise</* ... */> {
    // ...
  }

  async generate({
    abortSignal,
    ...options
  }: AgentCallParameters<CALL_OPTIONS>): Promise<
    GenerateTextResult<TOOLS, OUTPUT>
  > {
    return generateText({
      ...(await this.prepareCall(options)),
      abortSignal,
    });
  }

  async stream({
    abortSignal,
    experimental_transform,
    ...options
  }: AgentStreamParameters<CALL_OPTIONS, TOOLS>): Promise<
    StreamTextResult<TOOLS, OUTPUT>
  > {
    return streamText({
      ...(await this.prepareCall(options)),
      abortSignal,
      experimental_transform,
    });
  }
}

実プロダクト開発では tracing をはじめとして Observability のための設定やフックなど、全ての Agent 処理にもれなく設定したいものがいくつかあるので、それらを設定した BaseAgent abstract class を作ってプロジェクト内で使いまわす… というのはしたくなるような気がします。

Agent class (AI SDK 6 Beta)

vercel/ai#9450experimental_AgentAgent interface と BaseAgent class(後の ToolLoopAgent)に分かれました。Pull Request の description によると拡張性の高い・カスタマイズされた Agent を実装できるようにするのが目的だそうです。

// https://github.com/vercel/ai/blob/bfff278/packages/ai/src/agent/agent.ts#L69-L110
export interface Agent<
  CALL_OPTIONS = never,
  TOOLS extends ToolSet = {},
  OUTPUT extends Output = never,
> {
  readonly version: 'agent-v1';
  readonly id: string | undefined;
  readonly tools: TOOLS;

  generate(
    options: AgentCallParameters<CALL_OPTIONS>,
  ): PromiseLike<GenerateTextResult<TOOLS, OUTPUT>>;

  stream(
    options: AgentStreamParameters<CALL_OPTIONS, TOOLS>,
  ): PromiseLike<StreamTextResult<TOOLS, OUTPUT>>;
}

AI SDK 6 Beta のリリースブログでは OrchsestratorAgent を実装するというユースケース例が紹介されています。

import { Agent } from 'ai';

// Build your own multi-agent orchestrator that delegates to specialists
class Orchestrator implements Agent {
  constructor(private subAgents: Record<string, Agent>) {
    /* Implementation */
  }
}

const orchestrator = new Orchestrator({
  subAgents: {
    // your subagents
  },
});

https://ai-sdk.dev/docs/announcing-ai-sdk-6-beta#custom-agent-implementations

Agent interface の使い道を考えてみる

この Agent を利用する API は現状以下の3つの関数と1つの utility type です。

  • createAgentUIStream
  • createAgentUIStreamResponse
  • pipeAgentUIStreamResponse
  • InferAgentTools

上記はいずれも Agent の出力を stream でフロントエンド(useChat)に返すために使うのが主な用途でしょう。 Agent interface を維持していれば、その実態が単一 Agent ではなく複数の Agent を Orchestrate したものであっても、自然に stream に接続できるようになります。
「呼び出し元のための stream 処理」という presentation な知識と「Agent の orchestration」というメインロジックが、 Agent interface を界面にきれいに分離できるようになる… というのが現状 Agent interface を使うモチベーションの1つになりそうです。 Agent の構成を変えるたびに streaming してる API handler まで変更が必要になるのは手間なので、ここに境界を引けることは責務の分解という点でも重要です。

また、 具体の Agent に依存しないような仕組み・フレームワークを自前で作る際にもこの Agent interface は利用できるでしょう。 例えば Agent を Temporal など Durable Workflow の上で動かすための何らかのユーティリティが Agent を要求する… という使い方はありそうです。

…つらつら書いたけど、どちらもわりと一般的な interface の利用用途ですね。

一方で、generate()stream() はいずれも generateText()streamText() の返り値の型に依存しています。 特に後者の型 StreamTextResult は自分で作るのはあまり現実的ではないような気がします。 今のところ処理実態は streamText() あるいは ToolLoopAgent.prototype.stream() を使うことが前提となりそうです。 これは後述する DurableAgent の成熟で変わったりするのか… 個人的には注目ポイントの一つです。

DurableAgent (Workflow DevKit beta, @workflow/ai experimental)

DurableAgent は10月下旬に Vercel より公開された Workflow DevKit が提供する実装の1つで、これを利用することで Agent が簡単に Durable に実行できるというものです。

https://useworkflow.dev/docs/api-reference/workflow-ai/durable-agent

「Agent の Durable な実行」についてはブログリレー14日目で紹介しています。

https://zenn.dev/layerx/articles/b5f6cf6e47221e

今回は先にコード例を示します。

async function getWeather({ city }: { city: string }) {
  "use step";
  return `Weather in ${city} is sunny`;
}

async function myAgent() {
  "use workflow";

  const agent = new DurableAgent({
    model: 'anthropic/claude-haiku-4.5',
    system: 'You are a helpful weather assistant.',
    tools: {
      getWeather: {
        description: 'Get weather for a city',
        inputSchema: z.object({ city: z.string() }),
        execute: getWeather,
      },
    },
  });

  const writable = getWritable<UIMessageChunk>();

  await agent.stream({
    messages: [{ role: 'user', content: 'How is the weather in San Francisco?' }],
    writable,
  });
}

https://useworkflow.dev/docs/api-reference/workflow-ai/durable-agent

DurableAgent における Workflow / Step の使い分け

Workflow DevKit では Workflow(ステップの実行制御)と Step(実際の処理)をそれぞれ 'use workflow', 'use step' というディレクティブで制御しますが、DurableAgent を使えば Agent Loop の実装のうち決定論的に処理できない LLM 呼び出しの部分を内部的に Step として扱ってくれます。

// https://github.com/vercel/workflow/blob/71d1757d0fd5f6971dfff6fc1b5601c3cfef6d53/packages/ai/src/agent/do-stream-step.ts#L20-L45
export async function doStreamStep(
  // ...
) {
  'use step';

  // ...

  const result = await model.doStream({
    prompt: conversationPrompt,
    tools,
  });

Tool の実行については実装者に委ねられており、先ほどのコード例は 'use step'; が付いているため Step として扱われることになります。

この Tool 実行の扱いが実装者に委ねられている、というのは非常に嬉しいポイントです。 ツール実行も単なる外部 API 呼び出しであれば Step でいいのですが、例えば Agent as Tool 的に別 Agent を呼び出したいケースでは自明ではありません。 Tool 呼び出しを含む Agent Loop のロジックが generateText() / streamText() 内にべったり実装されているため Activity(Step) と Workflow の分離が難しく、 Temporal 上での実現は大変です。しかし、 Workflow DevKit ではなぜかいい感じになってくれています。

DurableAgent の内部実装

ではこれがどう実現されているか、改めて DurableAgent のコードを見てみましょう。

// https://github.com/vercel/workflow/blob/71d1757/packages/ai/src/agent/durable-agent.ts#L94-L261
export class DurableAgent {
  constructor(options: DurableAgentOptions) {
    this.model = options.model;
    this.tools = options.tools ?? {};
    this.system = options.system;
  }

  generate() {
    throw new Error('Not implemented');
  }

  async stream(options: DurableAgentStreamOptions) {
    const prompt = await standardizePrompt({ /* ... */ });

    const modelPrompt = await convertToLanguageModelPrompt({ /* ... */ });

    const iterator = streamTextIterator({ /* ... */ });

    let result = await iterator.next();
    while (!result.done) {
      const toolCalls = result.value;
      const toolResults = await Promise.all(
        toolCalls.map(
          (toolCall): Promise<LanguageModelV2ToolResultPart> =>
            executeTool(toolCall, this.tools)
        )
      );
      result = await iterator.next(toolResults);
    }

    const sendFinish = options.sendFinish ?? true;
    const preventClose = options.preventClose ?? false;

    // Only call closeStream if there's something to do
    if (sendFinish || !preventClose) {
      await closeStream(options.writable, preventClose, sendFinish);
    }

    // The iterator returns the final conversation prompt (LanguageModelV2Prompt)
    // which is compatible with ModelMessage[]
    const messages = result.value as ModelMessage[];

    return { messages };
  }
}

なんと、 streamText() などは使わず独自実装の streamTextIterator() を呼び出していることがわかります。 ここからコードを読み進めていっても基本的には全て独自実装になっており、 AI SDK に依存しているのはモデル呼び出し部分(LanguageModelV2)くらいにです。 Agent Loop はおおむね実装し直しているということになります。びっくり。

この内部実装について DurableAgent が experimental ということもあり、まだまだ変化していくでしょう。 もしかしたら DurableAgent 実装の過程で generateText() / streamText() の内部実装が整理され、 AI SDK 利用者が Agent Loop をより柔軟にカスタマイズできるようなフックや低レベル API が整備されるかもしれません。 そうなると Temporal に乗せるときも便利なんだけどなあ…。

DurableAgent は AI SDK 6 の Agent interface を実装していない

DurableAgent の stream()async stream(options: DurableAgentStreamOptions): Promise<ModelMessage[]> というシグネチャになっています。 なんと、先ほど紹介した Agent が実装されていません。 まず stream() の責務もちょっと違っています。

  • Agent interface の stream()
    • メッセージストリームを AsyncIterable で返し、その用途は呼び出し側に一任される
    • ストリームは UIMessageChunk のほか、string などでも取り出し可能
  • DurableAgent class の stream()
    • 引数に WritableStream を受け取り、それを UIMessageChunk を書き込んでしまう

ということで、「UIMessageChunk を書き込む」ところまでは規定されてしまっています。 step から workflow に直接 AsyncIterable を返すのが難しいから…か? なので、ある Agent の出力を別の Agent に流すときは Workflow DevKit が提供する stream の機構を通すことになりそうです。 そもそも API の共通化が難しいんですね。

逆に、Agent のポータビリティを高めるために Agent interface が Workflow DevKit でも利用できるかたちに変化したりできればまた楽しいんですけどね。 なんかうまい API ないかなあ。

おわりに

Workflow DevKit の登場で AI SDK 側により低レベルなレイヤの API 化のモチベーションが発生してそうなのが個人的な注目ポイントでした。 現状 Workflow DevKit が Vercel 上での利用が前提になっている中で、AI SDK 側の低レベル API が充実すれば Temporal など別の Durable Workflow 基盤上で AI SDK を扱いやすくなります。 一方で、 Workflow DevKit はここで紹介しなかった Stream をはじめとした AI Agent 時代の Durable Workflow を作りに来てる感もあり、非常に楽しみです。

ただのライブラリ・フレームワーク設計オタクが API を眺めてニヤニヤするだけの記事になってしまいましたが、この3つの class あるいは interface を眺めるだけでもまだまだ Agent は奥が深く、抽象化を推し進めるためにはまだまだ先が見えない状態なんだなあというのが伝わってくる感じがします。 あれだけ AI Agent に投資してる Vercel ですら手探りなので、やはりまだまだ遊びの余地が大きい領域ではありそうです。

LayerX

Discussion