Mastraで作ったAIサービスを実運用する上で困った点
はじめに
Mastraを使うと、簡単にLLMをつなげたWorkflowや各種Toolsを連携したAgentを作る事ができます。ドキュメントも充実しており、ドキュメントを参照するMCPサーバも提供されているので、簡単にそれっぽいものが作れてします。最高!
一方で、動作させるだけではなくデプロイメント、テスト、LLMの出力の品質など、サービスを実運用する上では困った事がたくさんあったので、今回はその内容と自分が行った対策についてまとめようと思います!
成果物
本題の説明をわかりやすくするために、まずはMastraで作った成果物を共有します。サービスは以下のようなLINE BOTで2つの機能があります。1がMastraのWorkflow、2がAgentの機能を使って作られてます。
1)毎朝その日の市場状況をレポートする | 2)レポートに対して質問する |
---|---|
![]() |
![]() |
1が主な機能で、情報の収集から取捨選択、そして最終的な記事の作成から表現が中立的なポジションに基づいているかチェックする等をステップという単位で実装しひとつなぎにしています。出力からその様子を伺える部分もありますが、Workflowには以下のような特徴があります。
- 昨日のレポートの事を覚えている
- 会話も覚えている
- CPIやPPI等新しいトピックを検索してきている
- 引用文献を[1],[2]...のようにアノテーションできている
動作はこちらかシュッと試せるので、気になった方は使ってみてください。
余談ですが、株で大儲けしようとかいう訳ではなく、以下のような思いがあり、かつLLMと相性が良さそうだったので作ってみました。今のところうまくワークしています。
- この手の情報は直接ネットから収集すると結構煽り(e.g.暴落煽り)やポジショントークが入ったりすることもあるので、中立的な視点で情報を得たかった
- 経済用語は難しいものも多いのでシュッと質問できる環境で情報収集したい
前提
- 書き手はTypeScript、Node、Cloudflare初心者
- mastraは0.10.1を使用
- ローカルの開発環境はmacOS
本題
では、そんな初心者が作る上で困った点や行った対応についてまとめます。プロエンジニアからすると、ツッコミどころ満載な点もあると思うので、ぜひコメントで教えて下さい。
mastra build
の成果物がCloudflareで実行可能な形式にならない
MastraにはDeployerという機能があり、Deployerを介してデプロイ先の設定をするとmastra build
の実行時にそのデプロイ先にあったいい感じのコードが出力されます。
ただ、私の場合はそのいい感じのコードが全くワークせず、wranglerで動作確認すると以下のようなエラーが発生していました。素直にエラーを読むと、出力結果に含まれるjs実装において、本来グローバルスコープで宣言できないものが、宣言されてしまっているようです。
[ERROR] service core:user:XXX: Uncaught Error: Disallowed operation called within global scope. Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and generating random values are not allowed within global scope. To fix this error, perform this operation within a handler. https://developers.cloudflare.com/workers/runtime-apis/handlers/
at null.<anonymous> (node-internal:crypto_random:184:19) in randomUUID
at null.<anonymous> (index.js:30729:24) in map
at null.<anonymous> (index.js:31819:22)
✘ [ERROR] The Workers runtime failed to start. There is likely additional logging output above.
ナーバスな対応ですが、以下のようなpatchをdeploy前に実行する事でエラーを回避しています。知識がないのでゴリ押しで乗り切ってます。
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const targetFilePath = path.join(__dirname, '..', '.mastra', 'output', 'mastra.mjs');
const indexFilePath = path.join(__dirname, '..', '.mastra', 'output', 'index.mjs');
const patchMarkerStart = '// === Cloudflare Workers Compatibility Patch START ===';
const patchMarkerEnd = '// === Cloudflare Workers Compatibility Patch END ===';
const patchCode = `
${patchMarkerStart}
// Cloudflare Workers Compatibility Patch
(function() {
let stepCounter = 0;
// createStepID function for mapping IDs
globalThis.createStepID = function() {
return \`step-\${stepCounter++}\`;
};
// UpstashStore deferred initialization
globalThis._upstashInitialized = false;
globalThis._originalUpstashStore = null;
globalThis.createDeferredUpstashStore = function(config) {
const store = new UpstashStore(config);
const originalInit = store.init.bind(store);
store.init = async function() {
if (!globalThis._upstashInitialized) {
console.log('[CloudflarePatch] Deferring UpstashStore.init() - will initialize in request context');
return Promise.resolve(true);
}
return originalInit();
};
store._deferredInit = async function() {
if (!globalThis._upstashInitialized) {
console.log('[CloudflarePatch] Executing deferred UpstashStore.init() in request context');
await originalInit();
globalThis._upstashInitialized = true;
}
};
globalThis._originalUpstashStore = store;
return store;
};
globalThis.initUpstashStore = async function() {
if (globalThis._originalUpstashStore?._deferredInit) {
await globalThis._originalUpstashStore._deferredInit();
}
};
console.log('[CloudflarePatch] Cloudflare Workers compatibility patch applied');
})();
${patchMarkerEnd}
`;
async function patchMastraFile() {
try {
let originalContent = await fs.readFile(targetFilePath, 'utf-8');
// 既にパッチが挿入されているか確認 (冪等性の確保)
if (originalContent.includes(patchMarkerStart)) {
console.log(`[PatchInjector] Patch already injected in mastra.mjs. Skipping.`);
return true;
}
// パッチを先頭に追加
let newContent = patchCode + originalContent;
// mapping_${randomUUID()} を mapping_${createStepID()} に置換
newContent = newContent.replace(/mapping_\$\{randomUUID\(\)\}/g, 'mapping_${createStepID()}');
// const storage = new UpstashStore({ の行を見つけて置換
const storagePattern = /const storage = new UpstashStore\(\{[\s\S]*?\}\);/;
const storageMatch = newContent.match(storagePattern);
if (storageMatch) {
const originalStorageDecl = storageMatch[0];
// { url: ..., token: ... } の部分を抽出
const configMatch = originalStorageDecl.match(/new UpstashStore\((\{[\s\S]*?\})\);/);
if (configMatch) {
const config = configMatch[1];
const newStorageDecl = `const storage = globalThis.createDeferredUpstashStore(${config});`;
newContent = newContent.replace(originalStorageDecl, newStorageDecl);
console.log(`[PatchInjector] Replaced UpstashStore initialization with deferred version`);
}
}
await fs.writeFile(targetFilePath, newContent, 'utf-8');
console.log(`[PatchInjector] Patched mastra.mjs for deferred UpstashStore initialization`);
return true;
} catch (error) {
console.error(`[PatchInjector] Error patching mastra.mjs:`, error);
return false;
}
}
async function patchIndexFile() {
try {
let originalContent = await fs.readFile(indexFilePath, 'utf-8');
// 既にパッチが挿入されているか確認
if (originalContent.includes('await globalThis.initUpstashStore();')) {
console.log(`[PatchInjector] Index.mjs already patched. Skipping.`);
return true;
}
// _virtual__entry のfetchハンドラーをラップ
const newContent = originalContent.replace(
/var _virtual__entry = {\s*fetch: async \(request, env, context\) => {/,
`var _virtual__entry = {
fetch: async (request, env, context) => {
// Initialize UpstashStore in request context for Cloudflare Workers
await globalThis.initUpstashStore();`
);
await fs.writeFile(indexFilePath, newContent, 'utf-8');
console.log(`[PatchInjector] Patched index.mjs fetch handler for deferred initialization`);
return true;
} catch (error) {
console.error(`[PatchInjector] Error patching index.mjs:`, error);
return false;
}
}
async function injectPatch() {
console.log(`[PatchInjector] Starting Cloudflare Workers compatibility patch...`);
const mastraPatchSuccess = await patchMastraFile();
const indexPatchSuccess = await patchIndexFile();
if (mastraPatchSuccess && indexPatchSuccess) {
console.log(`[PatchInjector] ✅ Comprehensive Cloudflare Workers patch completed successfully`);
console.log(`[PatchInjector] - ✅ Replaced mapping_\${randomUUID()} with mapping_\${createStepID()}`);
console.log(`[PatchInjector] - ✅ Added deferred UpstashStore initialization`);
console.log(`[PatchInjector] - ✅ Modified fetch handler for proper request context`);
} else {
console.error(`[PatchInjector] ❌ Some patches failed to apply`);
process.exit(1);
}
}
injectPatch();
起票したら私の環境設定が悪い説を指摘されたので、おま環かしれません。余力があればシンプルなプロジェクトで再現しないか見てみようと思います。
wrangler.jsonに環境変数がすべて埋め込まれる
mastra build
すると.envにある環境変数が全てwrangler.jsonのvarに埋め込まれます。.envにLLMのシークレットキーを入れてる人も多いと思うので、手元でテストするときは予めcursorなどのAI系エディタがwrangler.jsonを見れないように設定する事をおすすめします。
テストの書き方がわからない
ドキュメントを見てもテストの書き方があまりわからず、少しつまずきました。あってるかわかりませんが、自分は以下のような感じでテストを書きました。
ワークフローのテスト:
// mock
mockGenerateText.mockResolvedValueOnce({ text: JSON.stringify([mockNewsData[0]]) })
const workflow = mastra.getWorkflow("myWorkflow");
const run = workflow.createRun();
const result = await run.start({ inputData: {} });
expect(result.status).toBe("success");
if (result.status === "success") {
expect(result.result).toEqual({});
}
ステップのテスト:
const formattedNews = "おはようございます!📈\n\n【テストニュース】";
mockGenerateText.mockResolvedValueOnce({ text: formattedNews });
mockDailyNewsService.getArticle.mockResolvedValue("昨日の記事");
const mockExecutionContext = {
inputData: [mockNewsData[0]],
mastra: mastra,
runtimeContext: {},
getInitData: vi.fn(),
getStepResult: vi.fn(),
workflowContext: {},
[Symbol.for("mastra.emitter")]: {},
} as any;
const result = await myStep.execute(mockExecutionContext);
expect(result).toBe(formattedNews + "\nAIは間違える事があります。詳細は引用文献をご確認ください。");
ワークフローもstepと同様に直接インスタンスを参照できるんですが、mastraインスタンスを介して参照するのが主なユースケースなのでそのようにテストしています。
1週間で1万円くらい課金されたと思いかなり焦った
モデルにはGeminiを使っており、Google AI Studioから使用料を確認できます。どうせそんなにコストがかからないだろうと思ってあまり見てたんですが、たまたま見る機会があり表示されている70という数値に驚きました。ドルなら1万超えです。3時間くらいアセアせした結果単位が円な事に気づき助かりましたが、ちゃんとコストを管理したほうが良いかなと思い、トラック出来るようにしました。Mastraを使う場合はvervelのaiパッケージを使う事になると思いますが、aiパッケージのgenerateTextはメソッドはLLMからのレスポンスだけではなく、入出力のトークンを数えてレスポンスしてくれます。これをログすれば使用料金をトラックできます。
const executeGenerateText = async (model: any, prompt: string, context?: string) => {
console.log(`=== Generate Text Request ${context ? `(${context})` : ''} ===`);
console.log(`Prompt:\n${prompt}`);
console.log(`=== End Prompt ===`);
const res = await generateText({
model: model,
prompt: prompt,
});
console.log(`=== Generate Text Response ${context ? `(${context})` : ''} ===`);
console.log(`Output:\n${res.text}`);
console.log(`Usage - Prompt tokens: ${res.usage?.promptTokens}`);
console.log(`Usage - Completion tokens: ${res.usage?.completionTokens}`);
console.log(`Usage - Total tokens: ${res.usage?.totalTokens}`);
console.log(`=== End Response ===`);
return res;
};
AgentにLINEのユーザを識別させた上で会話履歴を覚えさせたい
// TODO
LLMに引用([1], [2]のような)をつけさせるのが非常に難しい
// TODO
LLMが嘘のURLを送ってくる
// TODO
おわりに
deployerのような便利そうな仕組みが実装されていますが、意外と手こずってしまいまいした。しかし、issueを作るとすぐにコメントが返ってきて、コミュニティの力強さや頼もしさを感じました。Mastraに興味を持った方は、意外と簡単に実装できるのでぜひトライしてみてください。↓を触ってみると出来ることがなんとなくわかると思います。
Discussion