🎞️

Mastra×RAG×構造化ストリーミングの開発事例 - 先生検索AIエージェントの詳細解説

に公開

先日、オンライン家庭教師マナリンクにて、「先生検索エージェントが先生を探してくれる機能」をリリースしました🎉

まずは簡単なデモ動画をご覧ください⬇️

※画面は開発時点のもの/検索結果は一例です

この機能には以下のような特徴があります。

  • AIのレスポンスが 1文字ずつ表示(ストリーミング) され、ChatGPTライクなUXを実現
  • 検索者が自然言語で入力した科目や学年などの条件を、AIが理解して先生を探す
  • 先生を探す条件には科目や学年などの数値化できるデータだけでなく、「コミュニケーションが苦手」といった非構造化データも含めて検索できる
  • 検索結果には順位や推薦文、先生の情報が表示されるカードなど、単なるテキストではなく構造化されたリッチなUIが表示される。すなわち構造化されたデータ(JSON)のストリーミング表示ができている
  • 最終的な返答だけでなく、AIが様々なデータソースから検索している進捗がリアルタイムで表示され、体感時間を短縮
  • 検索結果を経て、さらに要望を投げるなどインタラクティブなやり取りが継続的に可能

これらの特徴を達成するために、大まかに言うと以下のような技術選定、検証、研究開発を行いました。

  • 単にLLMをAPIで呼び出すシンプルな方法ではなく、AIエージェントを用いることで臨機応変な対応を可能にした
  • AIエージェントフレームワークとしてMastraを選択し、ストリーミング含む多くの既存資産を活用した
  • ストリーミングを既存のNext.jsおよびReact Nativeアプリケーションおよびインフラ(AWS)上で動作させるための設定を行った
  • 非構造化データを検索するために、全先生のデータをコンテキストに入れるのではなく、EmbeddingおよびRAGを用いた類似度検索によって、検索精度を向上させつつコンテキストを浪費しないようにした
  • 臨機応変に動く代わりに意図しない動作をしがちなエージェントに対して、Tool、Schema、Instructionなどの設計によりできるかぎり安定した動作をするように調整した
  • Vercel AI SDKが提供するシンプルなフックuseChatを独自拡張したuseStructuredChatを用いて、クロスプラットフォームで安定した構造化データのストリーミングを実装した
  • 「先生検索エージェントにおける "精度"とはなにか? 」という問いに向き合い続け、最終的な事業面でのゴールにコミットできる水準に内部仕様含め調整した
  • AIエージェントの導入事例が弊社において過去に例がなかったため、エンジニアチーム内外においてそれぞれエージェントの価値や将来性含め説明し、開発工数の合理化やメンバーのアサイン、検証版リリース→本番リリースへの計画立案などを行った

本記事ではこれらのトピックの中から「AIエージェントにおいて構造化データのストリーミングを実装する具体的な方法」を軸に、AIエージェントがUXに及ぼす影響や検討事項の多さなどにも触れていきたいと思います。

なお"AIエージェントってそもそも何?"/"単にLLMをAPIで呼ぶのと何が違うの?"という方は以下の記事も参考にしてください🔖

https://zenn.dev/meijin/articles/ai-agent-design-tips

バージョン情報など

本記事は以下の環境における情報となりますので、参考にされる際はバージョンの違いによる技術情報の差にご注意ください。

  • 2025/06時点
  • Next.js 14系
  • @mastra/coreその他Mastra関連パッケージ 0.10系
  • Expo 52系

AIエージェントにおける構造化データのストリーミング

それでは早速、AIエージェント開発におけるUX設計のキーポイントとも言える構造化データのストリーミングについて解説します。

なぜ構造化ストリーミングが必要なのか

本題である「構造化オブジェクトのストリーミング」の技術実装の話題に入る前に、まず「なぜストリーミング自体が必要なのか?」、そして「なぜそれが構造化されている必要があるのか?」という2つのポイントを整理しておきましょう。この2つのニーズが掛け合わさることで、初めて「構造化ストリーミング」の真価が見えてきます。

ストリーミング表示が求められる背景

まず、なぜAIの応答をストリーミングで表示することが一般的になってきたのでしょうか。主な理由は2つ考えられます。

  1. UXの向上:体感待ち時間の削減

    AIによる生成は、どれだけ高速化されたといっても、ある程度の時間がかかります。ユーザーにとって、その処理が「AIにしかできない、絶対に代替不可能なもの」であれば、おそらく何秒でも待てるでしょう。しかし、「自分でも頑張ればできそう」あるいは「他のツールでもなんとかなりそう」と感じるようなユースケースでは、10秒という時間は非常に長く感じられます。

    ここでストリーミング表示が活きてきます。処理が完了するのを待たずに、生成された部分から順次表示していくことで、ユーザーは「待たされている」という感覚を抱きにくくなります。

  2. ユーザーの期待値の変化

    もう一つの大きな理由として、ユーザーの期待値が変化している点が挙げられます。ChatGPTをはじめ、ClaudeやGeminiといった主要なAIサービスは、軒並みストリーミング表示を標準機能として提供しています。

    これは、たとえばLINEが普及したことで、チャットUIがコミュニケーションのスタンダードになった状況と似ています。チャットUIは、リアルタイムな送受信や吹き出し形式の表示など、実装には手間がかかる部分も多いですが、一度ユーザーがその利便性を知ってしまうと、それが「当たり前」の基準になります。AIのストリーミング表示も同様で、多くのユーザーがこれを体験した結果、「AIの応答はストリーミングされるもの」という認識が広まりつつあるのではないでしょうか。そのため、開発者側もこの期待に応える必要性が高まっていると考えられます。

なぜ「構造化されたデータ」が必要なのか

次に、AIの出力がなぜプレーンテキストだけでは不十分で、構造化されたデータ(例えばJSON形式)で扱えると良いのか、その理由について考えてみましょう。

端的に言うと、AIの出力を単なるテキストではなく、意味のあるデータとして扱いたいというニーズがあるためです。これにより、アプリケーションの表現力や連携の可能性が格段に向上します。

具体的なメリットとしては、以下のような点が挙げられます。

  1. リッチなUI表現とインタラクションの実現

    構造化されたデータ(例えばJSON)をAIが出力できれば、そのデータをReactなどのUIコンポーネントに直接マッピングできます。例えば、レストランのおすすめ情報をAIに尋ねるケースを考えてみましょう。

    • プレーンテキストの場合: レストラン名と説明文、URLなどがテキストで返ってくるだけかもしれません。
    • 構造化データの場合: レストラン名、評価、画像URL、予約ページへのリンク、空席確認ボタンといった情報が構造化されて返ってくれば、それを基にリッチなレストランカードコンポーネントを表示できます。ユーザーは情報を確認しやすく、予約や空席確認といった次のアクションにもスムーズに移れます。

    エンジニアにとってはAIの応答がテキストであることは自然かもしれませんが、ユーザーから見れば、AIは既存の検索結果一覧や詳細画面の代替手段として機能することが期待されます。そのため、プレーンテキストのみの簡素な表示は、むしろユーザーの不満につながる可能性すらあります。ユーザーが求めるのは、多くの場合、構造化された情報に基づいたリッチなUIなのです。

  2. アプリケーション内での柔軟な処理連携

    AIからの出力が構造化されていれば、その後の処理も格段に行いやすくなります。例えば、AIが出力したJSONのある特定のキーの値に基づいて、SWRのようなライブラリを使い、追加のデータを非同期でフェッチする、といった連携も可能です。

このように、AIの出力を構造化することで、ユーザー体験の向上、そしてアプリケーションの機能拡張の両面で大きなメリットが生まれます。プレーンテキストのやり取りだけでは実現が難しい、より高度で使いやすいAIアプリケーションを構築するためには、構造化データという選択肢を常に持っておくことが重要と言えるでしょう。

構造化データのストリーミング実装方針の選択肢

さて、構造化データのストリーミングの価値を説明したところで、実装する際の選択肢について簡単に解説します。

1. useChatを単に用いる

MastraではVercel AI SDKを内部的に利用している(というかクライアント側はVercel AI SDKを使うことを前提にしている)ため、Vercel AI SDKのフックuseChatをそのまま使うことができます。

https://mastra.ai/ja/docs/frameworks/ai-sdk

https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat

具体的なUI側の実装とAPI Routeの実装例は以下ドキュメントにもあります

https://ai-sdk.dev/docs/ai-sdk-ui/chatbot

しかしこのように単純に用いた場合は出力がただのテキストになってしまいます。
仮にJSON形式で返して、とプロンプトで指示したとしても、レスポンスは途中で{ "hoge": tのように途中で半端な瞬間があり、フロントエンドでJSONパース可能になるまで待機してしまうと、それは結局最後の閉じ括弧が出力されるまで待機することとイコールとなってしまい、ストリーミングの意味がなくなります。

もちろん、AIがただのテキストを出力している場合でも、独自のルールを作り文字列操作することで仮想的に構造化データとして扱うことは可能です。

私は開発検証中には、AIの出力テキストに{{teacherId:123456789}}といったテキストが含まれている場合は、Markdown RendererライブラリがReactの<TeacherCard teacherId={123456789} />といったコンポーネントをレンダリングするようにしていました。

ただこの手法の欠点としては、上手にUIへの出力描画を実装しないと、パース前の文字列{{teacなどが画面上に一瞬見える瞬間が生じたり、結果として構造化データのストリーミングよりも外部要件に制約をかけてしまったりするため、可能であれば構造化データのストリーミングを実装するほうが良いと思います。

2. useObjectを用いる

非エージェント下における構造化データのストリーミング手法として最有力なフックがuseObjectです。

https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-object

このフックでは以下のサンプルコードのように、フック呼び出し時にZod Schemaを指定し、その通りの結果を得ることができます。

import { mastra } from "@/src/mastra";

export async function POST(req: Request) {
  const body = await req.json();
  const myAgent = mastra.getAgent("weatherAgent");
  const stream = await myAgent.stream(body, {
    output: z.object({
      weather: z.string(),
    }),
  });

  return stream.toTextStreamResponse();
}
import { experimental_useObject as useObject } from '@ai-sdk/react';

export default function Page() {
  const { object, submit } = useObject({
    api: '/api/use-object',
    schema: z.object({
      weather: z.string(),
    }),
  });

  return (
    <div>
      <button onClick={() => submit('example input')}>Generate</button>
      {object?.weather && <p>{object.weather}</p>}
    </div>
  );
}

ただしこちらは「非エージェント下で」と前置きしたとおり、エージェント呼び出しにおいて用いると、生成途中のTool呼び出しがスキーマに合致しないためレスポンスとして得られず、ユーザーは最終出力結果しか見ることができませんでした。

3. 構造化データのストリーミングを実現するために検証したこと

上記の現状を踏まえ、検証当時、いくつかのアプローチで構造化データのストリーミング実現可能性を検証し、最悪の場合はプレーンテキストを独自Parserする方針でセーフティネットを引いていました。

  • Mastra側でstreamObject相当の挙動をさせる

その他、主には以下のディスカッションとIssueでのやり取りを見ることで、実現性を調査しました。

https://github.com/vercel/ai/discussions/3323

https://github.com/vercel/ai/issues/4277

その結果として、useObjectの内部でも使われている、Vercel AI SDKのUtilsとして提供されているparsePartialJson関数を用いることで、自前でツール呼び出し含め構造化データをストリーミングできるカスタムフックを開発できました。

4.カスタムフックの開発

完成したフックが以下のとおりです。

色々後続のUIで扱いやすいように型変換などもかけているのですが実際構造化データのストリーミング観点で重要なのは以下の処理です。

        parts: message.parts.map((part) => {
          if (part.type === 'text') {
            const parsedMessage = parsePartialJson(part.text);
            if (['repaired-parse', 'successful-parse'].includes(parsedMessage.state)) {
              return {
                type: 'output',
                structuredData: parsedMessage.value as PartialSchema,
              };
            }

知ってみるとなんてことはないのですが、ストリーミングされてくる部分的なJSONを専用の関数で毎回復元してしまえばいいという話です。

部分的なJSONをとりあえず情報量を欠損させずに完全なJSONにすることは理論上は可能で、たとえば

  • { "hoge": "f -> { "hoge": "f" } : ダブルクオーテーションと中括弧が対になるように補完するとOK
  • { "array": [1, 2, -> { "array": [1, 2] } : 配列の閉じ括弧を補完するとOK
  • { "nested": { "foo": "bar -> { "nested": { "foo": "bar" } } : ネストされたオブジェクトの閉じ括弧とダブルクオーテーションを補完するとOK

のように、自前で実装しろと言われると心が折れそうな範疇ではありつつ、理論的にはアルゴリズム次第で欠損部分を復元することは可能なのです。

厳密に言うと下記のモジュールで実装されています。変態的ですね(褒めてる)。

https://github.com/vercel/ai/blob/05b832469072a9741fbc880484cf41351b5ff94f/packages/ui-utils/src/fix-json.test.ts

しかしこの手法だと生成途中でもJSONになるとはいえ任意のプロパティが欠損したJSONになることが違いないため、フックの戻り値は DeepPartial<z.infer<TSchema>> としており、利用側でUndefined対策は引くように型で案内しています。

また、useObjectのセクションで懸案となっていたTool呼び出しの進捗表示については、Mastraのエージェント呼び出し時のPropsとして、outputではなくexperimental_outputを指定することで、Tool呼び出しはそれとして返してくれて、最終的なAIの回答をスキーマを守った生成にしてくれます。

    const result = await mastra.getAgent('manalinkAgent').stream(messages, {
      experimental_output: ResponseSchema,
    });
    return result.toDataStreamResponse();

https://mastra.ai/ja/reference/agents/stream#options-オプション

あとは、UI上でデータを表示するときに、DeepPartialであることを考慮して実装します(これが地味に面倒なのですが)。

{messages.map((message) => (
  <div key={message.id}>
    <div>{message.role === 'user' ? 'ユーザー' : 'AI'}</div>
    {message.parts.map((part, i) => {
      switch (part.type) {
        case 'output':
          const value = part.structuredData;
          if (!value?.teachers) return null;

          return (
            <div>
              {value.teachers
                .filter((teacher) => teacher?.id)
                .map((teacher) => (
                  <div key={teacher.id}>
                    <h3>
                      {teacher.ranking && <span>{teacher.ranking}</span>}
                      <span>{teacher.name}</span>
                    </h3>
                    {teacher.ranking && (
                      <p>理由:{teacher.rankReason}</p>
                    )}
                    <p>{teacher.recommendation}</p>
                    <TeacherCard
                      id={teacher.id}
                      subjectIds={teacher.subjectIds}
                      gradeId={teacher.gradeId}
                    />
                  </div>
                ))}

              {value.notFoundReasonMessage && (
                <div>
                  <p>検索結果が見つかりませんでした</p>
                  <p>{value.notFoundReasonMessage}</p>
                </div>
              )}
            </div>
          );

        case 'text':
          if (message.role === 'user') {
            return <p>{part.text}</p>;
          }
          return null;

        case 'tool-invocation':
          return (
            <p key={`${message.id}-${i}`}>
              {getToolDisplayName(part.toolInvocation.toolName)}
              {loading && <span>...</span>}
            </p>
          );

        default:
          return null;
      }
    })}
  </div>
))}

詳細は割愛しますが、部分的なJSONが一瞬生成されている時がある、というのは結構面倒な状況なので、ちゃんとやるならAI用とは別にレンダリング用のZod Schemaを作って、そのSchemaに対してSafeParseしてから表示すると安定するかもしれません。このへんは実際ストリーミング中にUIがどう出ていると体験的に望ましいかという点との折り合いかと思います。弊社は自分がデザインも兼ねているので意思決定がスムーズでした。

余談:AIエージェント時代のユーザー体験設計は誰がいつやるの?

余談ですが、AIエージェント前提のユーザー体験がこれまでのWebサービスのユーザー体験設計からアンラーニングしないといけない度合いが高すぎて、少なくとも国内著名サービスによって浸透してくるまではあまり分業しすぎないほうが開発しやすいんじゃないかなと思いました。

ちなみに、私は個人開発で穴埋めテストを作成するツールを開発しています。このツールでは、WYSIWYGエディターに対してAIがテストを自動生成する機能を実装しており、こちらも同様に構造化データの生成を行っています。本ツールではエディタに一瞬でもInvalidなJSONが挿入されるとエラーになるため、エディターにデータを投入する前にパース処理を挟み、AIレイヤーだけでなくプログラムレイヤーでもデータの整合性チェックを行う仕組みとしています。求められるデータの堅牢性によっては、このような多重チェックの対策が必要になる場合もあるでしょう。

https://www.test-maker.app/

補足:ツール呼び出しの表示

補足として、冒頭のGIF画像でお見せしたように、今回開発した先生検索エージェントは検索途中のツールの呼び出し状況も随時表示することで体感時間を短くしています。

実現方法は非常にシンプルで、ストリーミングで返ってくるパーツの中にツール呼び出しを示すパートも含まれているため、愚直に関数を書いて、そのツール呼び出しのオブジェクトからツール名や最終的に返された値を取得することで、呼び出されたツールの概要や、その結果何件データがヒットしたかをユーザーにインタラクティブに表示できます。

この実装に関しては、オブジェクトの形式の仕様が明文化されていなかったため、型定義を読んだり、実際に生成されたデータをブラウザの開発ツールからコピペして分析するといった、やや泥臭い作業でさっさと終わらせました。

case 'tool-invocation':
  return (
    <p key={`${message.id}-${i}`}>
      {getToolDisplayName(part.toolInvocation.toolName)}
      {getToolResultDataCount(part.toolInvocation.result)}
      {loading && <span>...</span>}
    </p>
  );

構造化データのストリーミングをやってよかったこと

さて、面倒な実装も多々ありながらも構造化データのストリーミングを実現できてよかったことを以下にまとめます。

  • データの種類を色々出せたのと、種類ごとにUIデザインを当てられた
    • 順位表示は太字で大きくしよう、AIの推薦文は細字で小さくしようといった見た目のメリハリを効かせられた
  • 回答以外に気の利いたメッセージを場合によっては出力できた
    • 検索条件が厳しくて先生が推薦できなかった場合にのみ出力するプロパティnotFoundReasonMessageをスキーマで指定することで、Agent側が検索結果を用意できなかった際の専用メッセージを、専用のデザインとともに出力・表示できた
  • スキーマの型とDescribeである程度要望を表現でき、Agent本体のプロンプトがスリムになった
    • Agentへのプロンプトは業務のみに集中させて、データ形式や数やバリデーションをSchemaに逃がすことができ、スキーマを加筆するだけで出力がある程度変わるようになりました

ストリーミングの実現にあたっての技術的な障壁や検討事項

続いて、ストリーミングの実現にあたっての技術的な障壁や検討事項について解説します。

アプリケーションサイドの障壁

Next.js App Router以下のAPI Routeを使うことが必須

前提としてMastraを独立したインフラではなくNext.jsのAPI Routeに統合するうえでの話ですが、App Router以下に配置するのが必須となります。

マナリンクはまだPages Routerですが、理論上はPages Router以下のコンポーネントからApp RouterのAPI Routeを呼ぶことはできるので、ややこしいディレクトリ構造になるだけで、何事もなく終わりました。

Expo 52のfetch関数が必須

React Nativeアプリ(Expo)においてはExpo52で追加されたFetch関数の利用が必須です。

https://tech.fusic.co.jp/posts/expo-streaming-fetch-api/

https://docs.expo.dev/versions/latest/sdk/expo/#expofetch-api

HTTPレイヤーの障壁

ストリーミングレスポンスの構築と読み取り

こちらについてはMastraおよびVercel AI SDKがすべてWrapしてくれているので、ほとんど気にすることはありませんでした。

自前でChunked Responseを構築するのは頑張ったらできるとは思いますが、以下のコードなどを読むと自前でやるのはしんどいなと思っちゃいますね。

https://github.com/vercel/ai/blob/05b832469072a9741fbc880484cf41351b5ff94f/packages/ai/core/generate-text/stream-text.ts#L1629

ローカル環境の構築

マナリンクではローカル環境としてNginxを最前に置いているので、専用の設定が必要でした。同様のことがプロダクトによっては他のインフラ環境で必要になるかもしれませんね。

    location /api/ {
        proxy_pass {Next.js起動しているホストとポート};
        # その他の設定(ヘッダーなど)
        proxy_buffering off; # バッファリングを無効化。これが重要
    }

また、これも弊社特有かと思いますがReact NativeアプリからNext.jsのAPI Routeへ疎通させる工夫も多少必要でした。この辺含め、インフラ関係が面倒になってきたらVercelなどに独立で立ててしまおうかと思いました(言うてる間に移行しちゃうかもしれませんが)。

https://mastra.ai/ja/docs/deployment/deployment


他にもステージング環境以降のネットワーク構築ではいくつか落とし穴があったのですが、これらについては具体的に書けない点も多いので割愛します。1つ言えることとしては、初めてストリーミングやAIエージェントを実装するときは、検証環境へ早めにデプロイしてみると良いと思います。

AIエージェントの非機能要件(パフォーマンス、監視など)の落としどころ

AIエージェントの動作速度について

ストリーミングするとはいえ、ツール呼び出し含むAIエージェントのレスポンス速度は速いに越したことがないでしょう。特にマナリンクのようなtoCサービスでは離脱率にも影響しかねないため、重要な論点です。

私が実際に陥った最適化の観点として、Tool呼び出し時のInput Schemaの文字数を多くしない方が良い、というものがありました。

https://x.com/Meijin_garden/status/1925160245627953484

考えてみれば当然なのですが、ToolのInputの値もLLMが生成しているものなので、文字長が長くなるとそのぶん全体の生成時間に影響します。

メモリを活用するか、全体設計を工夫することで、ToolのInputが無駄に大きくならないようにしましょう。

また、同様の理屈で、RAGを用いて先生の持つ非構造化データを検索できるようにしているのですが、多種多様なデータソースから適切な先生をヒットさせるためには、HyDEと呼ばれる手法が必要だと考えました。

https://zenn.dev/khisa/articles/cc2ff969d4f2b8

しかし、HyDEにあまりにも真面目に向き合った場合、データソースごとに検索クエリを生成させることになり、多様なデータソースから検索するほどクエリ生成に時間がかかることになります。今回の実装では、究極の精度を求めるのが原理的に困難である(先生と保護者のマッチングが目的であるため)ことから、RAGの手法に過度にこだわらずにある程度速度を優先させるようにプロンプトを調整しました。

ロギング

エージェントの内部挙動において、ツールの呼び出しは重要なパーツですが監視しにくいため改善や問題の特定に時間がかかりやすいです。

Otelとの連携もサポートされているのでOtelガチ勢であればそちらがいいと思いますが、弊社はOtel未導入なので、普通にJSONログを標準出力に流してCloudWatchでクエリできたら、まずは初歩の初歩の分析は可能と考えました。

結論として、PinoベースのLoggerが提供されているので、JSON形式のログを吐き出させることが可能です。デフォルトだとPrettyされているので注意してください。
開発時と本番環境適用時で、記録するログレベルを分けると便利かと思います。

https://x.com/Meijin_garden/status/1928357822531604906

export const mastraLogger = new PinoLogger({
  name: 'Mastra',
  level:
    process.env.NODE_ENV === 'production' ? 'info' : (process.env.AI_AGENT_LOG_LEVEL as LogLevel | undefined) ?? 'info',
  // デフォルトでJSONがOFFられているようなので指定
  // https://github.com/mastra-ai/mastra/blob/e8d2aff1bd8e39966de13a05d4be8dfacfd0a31b/packages/loggers/src/pino.ts
  overrideDefaultTransports: true,
});

ツールの設計と責務

続いて、AIエージェントの開発において必要不可欠なツールの設計と責務の考え方について解説します。

構造化データと非構造化データの検索

今回は先生を検索するツールを作ったのですが、まず考えるべきこととしては、検索と一口に言っても「構造化されたデータで絞り込む」のと「非構造化されたデータで絞り込む」のを2つに区別することです。

構造化されたデータとは、例えば「科目は英語ができる先生がいい」とか「高校2年生の娘に対応した先生がいい」といった、もともとマナリンクでも持っているデータを指定して検索するようなケースです。このようなデータは、具体的にはデータベース上に科目のIDや学年のIDとともに紐付いています。そのため、AIエージェントがLLMの力を使って科目の日本語をIDに変換してくれれば、あとは既存のシステムに乗せて適切な先生をフィルタすることが可能です。

一方で、例えば「コミュニケーションが苦手」であるとか「厳しい先生がいい」、「宿題をたくさん出してくれる先生がいい」といった非構造化された要望の場合は、先生が発信しているブログや自己紹介、普段の指導スタイルといった情報をサービス内から収集してヒットさせる必要があります。こちらは技術的に言えば、EmbeddingとVector検索によって実現が可能です(可能です、とは言いましたが実際はドメイン知識も加味して"この文字列とこの文字列は類似度が高い"と言い切ることはかなり困難なので、topKを多めに指定して最後は結局生データをコンテキストある程度浪費させつつAIに食わせるといった折衷案を検討、採用しました)。

構造化データとベクトル検索の兼ね合い

ただし、それより重要なのは、構造化されたデータを使って機械的にフィルタリングした結果と、ベクトル検索によって得られた類似度スコアとの兼ね合いをどう判断して、AIが最終的に先生を推薦するのかという設計です。

例えば、ベクトル検索の方でいかに類似度が高いとしても、ユーザーが英語ができる先生を要望しているのに数学の先生を推薦してはいけません。また、英語ができる先生といったフィルタリングが当たっているにもかかわらず、「厳しい先生がいい」と言っているのに優しい先生ばかり出てきてもいけません。

プリミティブなツール提供アプローチの問題点

この要件に対して、例えばプリミティブなツールをたくさんAIに提供して最終的な判断を任せるといった、全てをLLMに任せるような判断をすることももちろん可能で、最初はそこから検討すべきです。

しかし、少なくとも私が実験を繰り返した感覚では、プリミティブなツールをたくさん提供するというアプローチでは、どうしても「英語の先生と言っているなら最低限英語ができる先生であってほしい」といった必須的なフィルターと、「優しい先生がいい」といったアナログ的なフィルターの使い分けが完璧にできず、結果的にハルシネーションと言っても差し支えがないような嘘の結果を出してくるケースが散見されました。

機械的処理とLLM処理の使い分け

そこで最終的に落ち着いているのは、ツールの数そのものはそんなに多くないのですが、ツールの中である程度機械的なフィルタリングとベクトル検索でのフィルタリングを同時にやってしまうといった方法です。

個人的な考え方としては、機械的なフィルタリングはLLMに頼らない旧来のフィルタリングと同様です。機械的なフィルタリングを求めているにもかかわらず、そこの判断をLLMに任せてしまうと、ハルシネーションのリスクを負ってしまうことになります。そのため、要件的に機械的にやりたいと決まっているものは、できるだけツールの中で自身がプログラムで実装する方が確実だと考えています。

一方で、ベクトル検索のための検索クエリ生成や、検索結果の類似度スコアを見てどのように最終的に判断するか、また得られた先生の情報からどんな推薦文をユーザーに見せるか、どの順番で先生を提示するかといったLLMライクな仕事のところは積極的に任せていくということが大事だと考えています。

ツール設計におけるアジャイルなアプローチ

ここまでの話を統合すると、ツールをどのように設計したらいいかを最初から言い切るのは難しく、とにかく作ってみてAIに渡してどんな動きをするか見ていくのが個人的には確実な方法だと思っています。

プロジェクトを進めるにあたっては、「とにかくツールを作るフェーズ」「そのツールを組み合わせるフェーズ」「最終的にできたツールをエージェントに食わせてみるフェーズ」というふうに切り分けて進めていきました。また、ツールの完成度を一旦度外視して早くエージェントと統合し、どんな動きをしてくるか観察してみるといった、ある意味全体を通してアジャイルのような進め方が重要だと考えました。

ついついやってしまうのは、特定のツールを開発し始めると普段のエンジニアリングをしている自分が顔を出してきて、ツールを作りこむだけで数日溶かしてしまったりすることです。しかし、最終的にはAIが使いこなしてくれてなんぼなので、インプット・アウトプットのスキーマ設計も含め、ツールはとにかく軽く作ってエージェントに渡してみるということを徹底するととても良いと思います。

AIエージェント開発のスケジュール管理や工数

最後に、AIエージェント開発を行うにあたってのスケジュール管理や工数について、自分たちの経験から述べていきたいと思います。

プロジェクトの前提と位置づけ

前提として、オンライン家庭教師マナリンクはあくまでオンライン家庭教師の最適なマッチングや教育の場を提供することが目的であり、AIエージェントはその手段の一つでした。そのため、エージェントを導入することは必須ではなく、類似の要件が満たせればそれでも良いという状況でした。

しかし今回、先生をAIが探せるようにしたい、従来の検索ではなく構造化されていないデータで検索したいという要望を達成するにあたっては、AIエージェントが適切と判断し開発を進めました。

開発工数と担当分担

結論として、全体の工数は2人月で完成しています。担当したエンジニアは私とWebエンジニアの2名で開発しました。

Webエンジニアの担当領域:

  • 先生に関連する様々なテキストデータのベクトル化
  • チャンクの検証
  • ベクトル検索のツールとしてMastraのAIエージェントが使えるかという初期プロトタイプ開発・研究
    (ですので、本記事からはEmbeddingやRAGの詳細は省いています。きっと当人がそのうち記事化してくれるはずです(圧))

私の担当領域:

  • その他全部(雑)

スケジュール管理で重要視したポイント

AIエージェント以外の選択肢を残す戦略

スケジュール管理において私が重要視したのは、AIエージェントでなくても実現できるオプションをギリギリまで残すことです。

というのも、社内的にエージェントの導入事例がなく、最初に先生検索をAIで実現したいという話があったときに、私がAIエージェントが望ましいのではないかと提案したとき、エンジニア・非エンジニア問わず理解があまり得られない状態でした。

一方で、「CTOがエージェントがいいと言うなら、ぜひやってみよう」「AIはどんどんWebアプリケーションの体験を変えていくだろうから、最新の取り組みを進めていこう」というスタンスとサポートはいただいている状態だったので、開発は開始できました。

しかし逆に言えば、本当はエージェントが適切ではないのに、私の一存でエージェントで突っ切ってしまったときに、誰もそれを止められないことも意味します。そのため、クリティカルシンキングと言いますか、自分の状態を批判的に見たときに、エージェントじゃなくても実現できるオプションを残すように動いていくことを考えました。

具体的な進め方

最初にWebエンジニアの方に担当してもらったのがベクトル化の検証でした。これをRAGとして抽象化するならば、最終的にエージェントという形ではなくても、テキストから類似度の高いものをクエリするという形で、シンプルなLLM呼び出しによって最終的な機能が実現できるオプションも残るため、そこに特化してやってもらうという形を取りました。

一方で私は、以前からMastraの開発を個人でよく検証していたので、それを実際に自社のサービスにプロトタイプとして軽く組み込んでみて、「こんな風にストリーミングできそう」「こんな風にユーザー体験が作れそう」といった大枠の部分を先に見せていきました。ツールは張りぼてでまず作ってみて、精度や出てくる先生は多少的外れでも、最終的な見栄えはこのようになるということを社内に見せていく動きをしました。

エージェントの価値を伝える取り組み

オプションがいろいろ残るような技術検証から開始しつつ、自分以外にもエージェントの価値を伝えていくことがプロジェクト全体にとって非常に重要だと考えました。私自身の動き方としては、エージェントの凄さや、エージェントがあると普通にLLMを呼ぶのと何が違うのかを視覚的に、また言葉で表現していくことを重視しました。

また、実際に本番環境にいる先生でどのような推薦がされるか知りたいという要望も出てくるので、カナリアリリースとして、社内の人間だけアクセスできる形で先んじてエージェントをリリースして実際に触ってもらうといった形も取りました。

エンジニアの肌感覚を育てる取り組み

エンジニアはCursorといったIDEで常にコーディングエージェントを触っています。このコーディングエージェントをよく観察したり、MCPサーバーを自分で作ってみるといったことを日頃からやっていれば、エージェントを作ることはどんどん肌感覚として身についてくるものだと思っています。そのため、社内で勉強会や案内といった活動も並行で行いました。

これからエージェントを開発してみたいという人も、まずは手元のコーディングエージェントとしっかり向き合ってみたり、MCPサーバーを自分で作ってみて反応を見てみることが大事だと考えています。

フォークしたものではありますが、私自身もLinearのMCPを作って、実際に業務で使うという期間を2〜3週間ほど取ってみて、エージェントとそれが呼び出すツールの関係性を体で覚えていくといったことを行いました。
https://github.com/TeXmeijin/linear-enhanced-mcp

まとめ

今回AIエージェントを開発してみて、ここ数ヶ月の開発で少なくともお世話になっているCursorのようなCoding Agentを他の領域に適用したものが自分の手でできていくことは非常に面白い体験でした。これを通してCoding Agentに対する理解度もより深まったと感じています。

また、ただエージェントを作るだけではなく、構造化データをストリーミングで出力するというユーザー体験にも配慮した仕組みも作ることができました。これからも要件次第ではありますが、このような新しいユーザー体験を積極的に検討して開発していきたいと思います。

「自分たちはこんなAIエージェントを作っています」「こんなエージェントを作ってみたいんだけど悩み中」といったAIエージェント開発談義したいという方、いらっしゃいましたら、ぜひ連絡お願いします🙆‍♂️

https://x.com/Meijin_garden

https://pitta.me/matches/bPsYBZYHezsJ

マナリンク Tech Blog

Discussion