👻

Vercel AI SDK と mastra を使った AI Agent 開発 Tips

2025/03/15に公開

退屈な開発をサボりたくて 自律稼働する Devin のような LLM Agent を Vercel AI SDK, mastra 辺りのエコシステムで自前で作ってみていたので知見を紹介します。

開発用の Agent に限らず、これから TypeScript エコシステムで Agent を組もうとしている人の参考になれば幸いです。

具体的な SDK の利用方法などは扱わず、技術選定やハマりがちだったポイントにフォーカスして書いていきます。

TypeScript エコシステムでの SDK の選定

まず、プログラムから LLM をさわろうと思ったら SDK を使うのが手っ取り早いですが、複数のオプションがあります。

  • OpenAI や Anthropic など特定のプロバイダの SDK を使う
    • 公式提供で安定していて使いやすい
    • 一方、モデルを差し替えたり、特定の用途に限って別のプロバイダのモデルを使ったりがしづらい
      • エージェント用途だと Claude Sonnet 3.5(or 3.7) が人気だが、そこそこ高いので一部のタスクをよりコスパの良いモデルに流したりしたいことはよくある
  • 各 LLM Provider を抽象化して共通のインタフェースで扱える SDK を利用する
    • 著名なところだと LangChain・LiteLLM 辺り
      • Python で書かれている SDK ですが、それぞれ JavaScript 版も提供されています
      • (個人の感想でしかないですが) Python 用に設計された SDK をそのまま移植した感が強くて体験がかなり微妙でした
    • Vercel AI SDK: Vercel が出している TypeScript で書かれた SDK. TypeScript らしくて書きやすい抽象化がされていて、さらに Vercel AI SDK の上に乗るエージェントフレームワークの mastra も利用できる

この記事では TypeScript で使いやすい Vercel AI SDK, mastra について書いていきます。

モデルの選定

いろいろ調べたり触ったりしてみて、最終的に選択肢にしていたモデルの料金と印象を書きます

モデル in out 印象
Claude 3.7 Sonnet 3.0 15 ハルシネーション強めだけどよしなにビリティ高い。エージェント用だと最適
Claude 3.5 Sonnet 3.0 15 3.7 より余計なことをしない。用途次第で 3.7 より優先
Claude 3.5 Haiku 0.25 1.25 難しくないタスク用。Gemini 2.0 Flash がベストなんだけど、Provider の動きが安定してなくて消極的に使うことがあった
ChatGPT o1 15 60 難しい内容を考えさせる場合に使うけど、高すぎてほとんど使ってない
ChatGPT o3-mini 1.1 4.4 Claude は高いんだけど Gemini や haiku だと賢さが足りなくて...という時用。あんまり使ってない。
Gemini 2.0 Flash 0.1 0.7 安いのに結構賢くて難しくないタスクのファーストチョイスにしていた。雑に使いまくりたいやつとか、プロトタイピングでも重宝した。が、Vercel AI SDK の Google Provider が安定してないのだけが難点

Agent 用途だと無限にお金があるなら基本全部 3.7 or 3.5 Sonnet に投げるのが良いけど、コスパを考えて難しくないものは Gemini 2.0 Flash に流すという形が基本形でした。

Gemini は Sonnet の 1/2-30 の価格で利用できるのであまり金額気にせずに利用でき、特に PoC 段階とかは Gemini で動かすとトライアンドエラーを気兼ねなくしやすくて良いです。

ただし、後述しますが Gemini は Google Provider が安定してないのがたまに傷で動きおかしいなと思ったら Haiku に差し替えたら意図通り動いたりでお茶を濁してました。

Vercel AI SDK ではツール呼び出しを抽象化できる

Vercel AI SDK では、エージェントとして動かすのに重要なツール呼び出しの実装を抽象化して、かなり easy に実装できるようになっています

ツール呼び出しは概ね、以下のような流れです:

  1. LLM に対して API の呼び出し
  2. レスポンスからツール呼び出しがあるかを判定
  3. ツール呼び出しがあればレスポンスをパースし、適切な関数に繋いであげて、結果をメッセージに詰めて 1 に戻るようにループ
  4. ツール呼び出しがなくなったら終了

本来はこのループと状態管理を時前で行う必要がありますが、2, 3, 4 がすべて抽象化され、以下のようなコードだけで実装できます。

import { z } from "zod"
import { generateText, tool } from "ai"

const result = await generateText({
  model: yourModel,
  tools: {
    messages: [
      {
        role: "user",
        content: "今日の東京の天気は?",
      },
    ],
    weather: tool({
      description: "Get the weather in a location",
      parameters: z.object({
        location: z.string().describe("The location to get the weather for"),
      }),
      execute: async ({ location }) => ({
        location,
        temperature: 72 + Math.floor(Math.random() * 21) - 10,
      }),
    }),
  },
})

Vercel AI SDK の step 抽象では、過去のメッセージ履歴をいじれない

tool の利用を抽象化してくれるので Agent 実装クソ簡単じゃん、って初見だと思ったのですがそう上手くはいきません。

前提:
API の 1 往復で、function calling 結果が解決されるところまでを1ステップと考えます。つまり「クライアント -> ツール呼び出しレスポンス -> ツール呼び出し結果の解決」までが 1 ステップ。

このとき

  • Vercel AI SDK は maxSteps を指定すると、function calling のループ処理を自動で maxSteps まで行ってくれる
  • onStepFinish コールバックを登録することで、ステップごとに処理を挟むことはできるが、Immutable な処理(ログ出力とか)のみで、メッセージの改変はできない

→ 長いエージェント処理をさせようと思って例えば maxSteps = 30 とかにすると、30回の履歴をすべて送ることになる

5 step 程度のものであれば素直に maxSteps を利用すれば良いですが、より往復の多いものを想定している場合はなんらか別のアプローチをする必要があります。

じゃあどうするか?ですが

  1. SDK のループのさらに 1 段外に自前のループを用意する
  2. mastra の memory 機能を使う(後半のセクションで触れます)

のどちらかが必要です。

ループを時前で書く

少し考え方を変えて、maxSteps は「エージェントが自立で動ける上限のステップ」ではなく、「メッセージの履歴を改変せずに-自動で動かしてよい上限ステップ」と考えます

SDK の自動ループの外でさらにループを時前で実装して、コンテキストを改変したり、メッセージ履歴を圧縮したりのカスタマイズしたいロジックをいれることで対応します。

だいたいこんな感じの実装になります:

const agentLoop = (
  systemPrompt: string,
  message: string,
  options: {
    maxLoops: number
    maxSteps: number
  }
) => {
  const firstMessage: CoreMessage = {
    role: "user",
    content: {
      type: "text",
      text: message,
    },
  }

  // 送るメッセージ
  let messages: CoreMessage[] = [firstMessage]
  // (圧縮部分を除く)すべてのメッセージ履歴
  const fullMessages: CoreMessage[] = [firstMessage]

  let loop = 0
  while (true) {
    const result = streamText({
      model: anthropic("xxx"),
      system: systemPrompt,
      messages,
      maxSteps,
    })

    let fullText = ""
    for (chunk of result.textStream) {
      process.stdout.write(chunk)
      fullText += chunk
    }

    const finishReason = await result.finishReason
    const stepMessages = await result.response.then(({ messages }) => messages)

    // この時点で maxSteps まで往復を済ませているのでメッセージの圧縮ロジックをいれる
    // シンプルな maxSteps の数だけ残して他を消す例
    messages = [firstMessage, ...stepMessages]
    fullMessages.push(...stepMessages)

    loop += 1
    if (loop >= maxLoop) {
      return {
        status: "maxLoop",
        messages,
      } as const
    }

    if (finishReason === "stop") {
      return {
        status: "done",
        messages,
      } as const
    }

    // google provider だと finishReason が正しく解決できない
    // finishReason の他に最終メッセージがツールかを見る
    if (finishReason === "tool-calls" || messages.at(-1)?.role !== "tool") {
      continue
    }

    return {
      status: "error",
      finishReason,
      messages,
    } as const
  }
}

これで step 数が非常に多い場合でも maxSteps ごとに任意の処理を挟むことができるようになりました。
上記の例ではシステムプロンプトも固定値にしていますが、少し改変してステップごとにコンテキスト情報を更新してシステムプロンプトに含めるような実装も可能です。
システムプロンプトにコンテキストを埋め込むような用途の場合は複数ステップといわず毎ステップごとに処理をしたいかもしれません。その場合は maxSteps = 1 に固定することで、同様に対処できます。

補足: Gemini(Google Provider) はやや安定しない

しれっと書いてた

if (finishReason === "tool-calls" || messages.at(-1)?.role !== "tool") {
  continue
}

への補足

  • Google Provider は finishReason の情報で「ツール呼び出し」「stop」を区別できない(どちらも stop)
  • そこで、Vercel AI SDK の Google Provider が、chunk を解釈してツール呼び出しを含むかで stop と tool-calls に解決してくれている
  • が、その解釈ロジックにバグがあるので最終メッセージが tool の結果かどうかを見てフォールバックする必要がある
  • 修正 PR は投げてるので、取り込まれるまで上記の対応が必要

体感これ以外もたまに Gemini だと思ったとおりに動かないことがたまにあって、おかしい動きがあったら Haiku に差し替えたりしてました。

mastra

mastra は Vercel AI SDK をベースに Agent を組むための機能実装をサポートするフレームワークです。

Vercel AI SDK でも一通りエージェントに必要な機能を実装できますが、マルチエージェントの協業(エージェント A が別のエージェント B を呼ぶ)だったり、RAG だったりを実装しようと思うと結構実装量が必要で、mastra を利用することでその辺りも含めて easy に実装ができます。

じゃあ mastra 一択かというとそうでもないなと思っていて

  • mastra は裏側がどういう動きになっているのか、つまり、最終的にどういう形で LLM Provider に送る形式に変換されているのかが暗黙的で結構難しいとも感じた
    • 一度、プレーンな AI SDK を直接使って一通りのものを組んでみてから mastra を使うことで裏側の動きを推測しやすくなって良いかもしれません。少なくとも自分はそうしてました。
  • 良くも悪くも抽象化されているので、よりカスタマイズ性がほしいなら Vercel AI SDK を直接使う選択肢もありそう
  • mastra はライセンスが「ELv2」であり、オープンソースではありません。
    • 私は個人で遊ぶものを作っていたので気になりませんでしたが、なんらかプロダクト開発に用いるのであれば用途と併せて考える必要があります

memory 機能

Agent の使い方はAgent の Example を見たままなので割愛して、メモリ機能について紹介します。

上で触れた Vercel AI SDK の外側でメッセージ管理を行うループを時前で構築する必要性があったところも memory 機能によって簡単に実装ができます。

https://mastra.ai/docs/agents/01-agent-memory

Agents in Mastra have a sophisticated memory system that stores conversation history and contextual information.

つまり:

  • 会話履歴やコンテキスト情報を保存できるメモリシステムを提供
  • BE として LibSQL、PostgreSQL などの任意の Provider が提供される

Provider のよしあしはぶっちゃけあまりわかっていないんですが、アプリケーション開発をしている人間にとって身近かつ SaaS を持ち出さなくて良いので LibSQL と PostgreSQL 辺りから選んで使うと良さそうです。

LibSQL vs PostgreSQL については、RAG をしたいか次第で不要なら追加のセットアップが不要な LibSQL が楽です。
一方、ベクトル検索(RAG)には PostgreSQL がどうせ必要になるので RAG も必要なら PostgreSQL 建てちゃうのが良さげです。

※ といいつつ、自分は ankane/pgvector と組み合わせて使ってみて動かなかったので LibSQL Provider を利用し、RAG は mastra ではなく Vercel AI SDK を直接利用して RAG 機能を構築してました。

メッセージ履歴の適切な圧縮

memory 機能では

  • stream 呼び出しの際に thread_id(会話の ID), resource_id(ユーザー) を指定させる
  • 指定された thread_id, resource_id に紐づく会話履歴を自動で取ってきて LLM のリクエストに付与する処理が裏で動く

という方法で、過去のメッセージを反映させる実装になっているようです。

なので、Vercel AI SDK で書いていためんどくさいループが不要になって下記だけでよくなります:

const memory = new Memory({
  options: {
    lastMessages: 5,
  },
})

const agent = new Agent({
  // ... other options
  memory,
})

const threadId = randomUUID()
while (true) {
  await agent.stream("ほげほげして", {
    thread_id: threadId,
    resource_id: "dummy",
  })

  // 上限や finishReason で抜ける必要あり
}

一方、過去のメッセージを軽いモデルに食わせて要約をシステムプロンプトに残すようなアプローチもありえるはずで、そういったカスタマイズは Vercel AI SDK を使うほうがやりやすいです。

mastra では他にもセマンティック検索がサポートされていたりもあるので、サポートされている機能を雑に使う分にはとても便利です。

RAG の実装

コーディングエージェントでも RAG なしで情報を取りに行かせると往復が増えてトークンコスパが悪くなっていってしまうので、コードベースの RAG も欲しくなってきます。

RAG はざっくり

    1. 事前に知識ベース(開発エージェントならソースコード)の中身を適切な長さで分割して、Vector 化して保存しておく
    1. 検索クエリを Vector 化して近いレコードを取得して返す

の流れでクエリに関連する情報を引っ張って来ることになります。

Vercel AI SDK、mastra.ai どちらでも自前の RAG を構築できますが、以下の違いがあります:

  • Vercel AI SDK を使う場合は、Vector 化を行うための SDK は提供されるが、自前の実装が割と必要
    • 自前でテーブルのスキーマを書いたり
    • マイグレーションを作成したり
    • 実際に DB に保存したり、取得したりするクエリを書いたり
  • mastra はより抽象化されていて、最小限の実装で実現できます

Agentic に動かすためのメッセージ改変

個人的にハマりがちだったのが、メッセージログの改変を行った際の意図しない動きでした。

エージェントとして動かそうと思うと

  • 過去のメッセージを部分的に削除したり
  • 要約したメッセージを system や user メッセージでおいたり
  • エージェントがタスクを完了していないのにツール呼び出しをやめてしまった場合に自動メッセージを付与したり
  • 失敗したときにリトライしたり

といった形で過去のメッセージを改変したいケースがありました。

けど違反したメッセージ構造を作ると意図通り動かないことに加えて、バリデーションで落ちるのではなく Promise が解決されない動きになったりと原因調査で沼りがちでした。

この辺には気をつけておくのがオススメです:

  • システムメッセージは複数入れても良いが、冒頭以外には置かない
    • 例えば system -> user -> assistant -> user -> system -> assistant などは NG
  • ツール呼び出しの対応関係を崩してはいけない
    • ツール呼び出しの履歴を消す場合は Assistant の要求もセットで消さないといけない。逆もしかり
  • Assistant のメッセージが最後の状態で投げてはいけない
    • 例. user -> assistant の状態で投げる

まとめ

Vercel AI SDK 周辺のエコシステムでミニマムな AI Agent を構築する際の Tips を紹介しました。

いくつかハマりがちなポイント等も紹介しましたが、SDK 自体は基本よくできて割と簡単にエージェントの構築ができるようになっているので、試してみるのもオススメです。今回紹介したポイントもエージェント利用がより進んでいけば自然と解消されていくのではないかなと思っています。

また、この記事ではエージェント開発の基本的なところのみに触れていますが、Vercel AI SDK や mastra のドキュメントではより高度な実装についても触れられていました。自分もこれからですが、読んで試してみると面白いと思います。

では 👋

株式会社エス・エム・エス

Discussion