ZenChAIne
記事一覧に戻る
【第2回・前編】Mastraのエージェント基盤でHTTP APIと利用量の記録を自作した理由

【第2回・前編】Mastraのエージェント基盤でHTTP APIと利用量の記録を自作した理由

ZenChAIne·
AIエージェントMastraTypeScriptHono

はじめに

開発中の4つ目の自社エージェント基盤で、最初の骨格ができました。 呼び出し元のアプリから HTTP で指示を受け取ると、API キーでアプリを確かめてから Mastra のエージェントでモデルを呼び、結果を返す構成で、モデル呼び出しごとのトークン数を Postgres に保存しています。

開発連載の第2回前編として、この構成にした理由を書きます。 第1回の【第1回】TypeScriptのエージェント基盤にMastraを選んだ理由では、利用量の記録を自作すると決めましたが、実装時には HTTP の入口を Mastra に任せるかも比べ直しました。

この記事のポイント

  • Mastra はサーバ内部で使い、呼び出し元のアプリとは HTTP API と、その入出力を定義した契約を共有します。
  • HTTP API は Hono で自作しました。今回の利用量記録の構成に Mastra の組み込みルートを足すと、記録先の ID を呼び出し元が指定できるためです。
  • モデル呼び出しごとの利用量を初日から保存します。記録の保存に失敗した場合は実行を止めず、保存用データをログに残す方針です。

外から使える範囲をどう絞ったか?

外部のアプリに公開するのは HTTP API と、その入出力を zod(型と入力検証を定義するライブラリ)のスキーマで記述した契約のパッケージです。 Mastra の型は外へ出さず、フロントエンドが import できる内部パッケージも、契約と、その契約を使って API を呼ぶクライアントの2つに限りました。

立ち上げ時は私一人で開発していて契約が頻繁に変わるため、Turborepo と pnpm のモノレポにサーバと、これから作る Next.js のフロントエンドを置き、契約の変更と双方の修正を1つの PR で済ませられる形にしました。 サーバ内部のモジュールを直接 import されると、その変更にフロントエンドも付き合うことになるので、許可したパッケージ以外の import は lint でエラーにします。 フロントエンドを作る前から、その配置先のディレクトリに規則を適用しています。

Mastra の開発が失速して入れ替えることになっても、HTTP API と契約を変えずに済むよう、依存する処理をサーバ内部に集めました。 Mastra に触れているのは、エージェントの組み立て、Run(1つの指示を受けて実行する単位)の実行、利用量の記録で、3つ目の基盤でパッケージを分けて保守した経験を引き継いでいます。

なぜ HTTP の入口を Mastra に任せなかったのか?

今回の構成では Mastra のサーバ機能を使っても Run の保存や応答の処理は自作する必要があり、組み込みルートを止める処理も増えるため、HTTP のルートは Hono で書き、その中から Mastra のエージェントを呼ぶ形にしました。 調べた Mastra 1.74.0 の MastraServer は、初期化時に /api/agents/<id>/generate などの組み込みルートを登録し、許可したものだけを公開する設定はありません。 ルートのグループごとに 404 を返すミドルウェアで止める方法はありますが、組み込みのパスは数百あるため、更新に伴う追加も含めて公開範囲を管理する必要があります。

利用量の記録との組み合わせにも問題がありました。 私たちの記録処理は、Run、アプリ、テナント(呼び出し元のアプリの顧客)、代理元(誰の代わりに実行したか)の ID を実行文脈から読みますが、組み込みルートは要求本文の値を、予約されたキー以外はその文脈に取り込みます。 この構成のまま公開すると、呼び出し元が他のアプリの Run の ID を指定して利用量の記録先を変えたり、必要な ID を渡さず記録を残さないままモデルを呼んだりできます。

Run の保存や SSE(処理中のイベントを順に返す応答)の形は、どちらを使っても自分で書きます。 いまの HTTP 処理は4つのファイルで約180行なので、MastraServer を採用して公開範囲を管理する負担を増やす理由はないと判断しました。

モデル呼び出しごとの利用量はどう残すか?

エージェントに登録した processor(Mastra がモデル呼び出しの前後に実行する処理)が、呼び出しごとのトークン数を読み、Postgres の usage_events に1行ずつ保存します。 社外のアプリがつながったときに原価をアプリやテナントごとに分け、請求の根拠を示すための記録なので、初日から実装しました。

processor の processLLMResponse が呼ばれたら、今回の出力にある finish イベントから入力・出力のトークン数を取り出します。 実行文脈の Run、アプリ、テナント、代理元と、実際に呼んだプロバイダ・モデルの名前を合わせて保存しますが、Mastra 1.74 ではこのメソッドを呼ぶ登録先が inputProcessors であり、outputProcessors に置くと呼ばれません。

記録の保存に失敗した場合は、その例外を processor 内で捕まえ、Run の実行を続ける方針にしました。 モデル呼び出しと記録の書き込みを別の失敗として扱い、保存できなかった行の内容はログに残して、後から再登録するための材料を確保しています。

モデル呼び出し自体が失敗したときは、トークン数が null の行を残します。 2026年10月6日の確認では失敗した11件の Run すべてにこの行があり、利用量が分かった呼び出しと区別しつつ、呼び出そうとした記録として保存することにしました。

同じ確認で、成功したすべての Run の保存値が、プロバイダの返した利用量の値と一致しています。 その時点の既定モデルは Claude Haiku 4.5 でしたが、いまは Haiku 5.5 に替えており、変更後も記録にプロバイダとモデルが残ることを確かめています。

TypeScript をビルドしない構成で何を選んだか

lint とフォーマットは Biome、テストは Vitest、ORM は Drizzle を選びました。 基盤のサーバは Node.js 24 で TypeScript を直接実行し、2026年7月に正式版になった TypeScript 7 で始めたため、その前提で使えるものを比べています。

typescript-eslint は、TypeScript 7.0 に入っていないコンパイラ API で構文を読むため使えません。 Biome は TypeScript のコンパイラに依存せず、内部パッケージの import 制限も Biome 2.2 のパターン指定で書けました。

ORM に Drizzle を選んだのは、TypeScript のテーブル定義を、クエリの型とマイグレーション SQL の生成に共用できるためです。 定義を1か所にまとめ、クエリ用のコードを別途生成する工程も持たずに済むので、TypeScript をビルドせずに動かす今回の構成に合わせられます。

コンテナでは、契約のパッケージを .ts のまま使うために、ワークスペースの配置を保ちました。 Node.js は node_modules 内の .ts を実行しないので、pnpm deploy でパッケージをそこへ複製すると起動に失敗しますが、元のディレクトリを指すシンボリックリンクを残せば、その配置を変えずに使えます。

よくある質問

HTTP サーバも Mastra に任せる方が楽ではないか?

今回の構成では、Run の保存や応答の処理が残り、組み込みルートを止める実装も増えます。 既製機能があることだけで決めず、利用量の記録と公開範囲を管理するために必要な処理まで比べ、Hono で書く方を選びました。

保存に失敗した利用量はどう扱うか?

Run の実行は続け、保存できなかった行の内容をログに残します。 データベースへの記録が欠けたままにせず、再登録に必要な情報を残す設計です。

TypeScript 7 で ESLint は使えるか?

typescript-eslint が TypeScript 7.0 では動かないため、そのままでは使えません。 互換パッケージで ESLint にだけ TypeScript 6 の API を渡す方法もありますが、今回は2つの版を抱えるのを避けて Biome にしました。

まとめ

Mastra にエージェントのループとモデルの呼び分けを任せ、契約、HTTP API、利用量の記録を自分のコードで持つ骨格ができました。 HTTP API と契約を共有する形にして、アプリ側がサーバ内部の変更に付き合う範囲を抑えています。

次回の後編では、サブスクの長命トークンでモデルを呼ぶ方法、依存の更新への追随、Cloud Run へのデプロイなど、本番に載せるまでの話を書きます。 基盤の構成や自作する範囲の判断は、ZenChAIne の技術顧問・開発支援でも支援しています。

参考ソース