AI エージェントにビジュアライゼーション機能を追加する PoC

Amethyst AIにビジュアライゼーション機能を追加するPoCについて解説します。Vercel AI SDKのTool CallingとgenerateObjectを活用し、データ取得と可視化を分離した柔軟なアーキテクチャを実装しました。

この記事は JADE Advent Calendar 2025 の20日目の記事です。

昨日の記事は篠原さんの「社内向けの週次検索レポート『Weekly Ranking Update』の3年を振り返る」でした。


こんにちは、JADE でフロントエンドエンジニアをしている長谷川です。今年(2025年)の4月から入社し、主に弊社のプロダクト Amethyst のユーザが操作する画面の部分を作っています。

最近ドカハマりしている指原莉乃プロデュースアイドル「≠ME(ノットイコールミー)」さんの素晴らしさについてを書くか、直前まで迷いましたが、今回は現在開発している Amethyst AI のビジュアライゼーション機能の PoC(概念実証)について書いてみたいと思います。

アイドルの記事を楽しみにしていた皆さん、すみません。

今回試したコードは以下。Vercel の AI SDK をベースに開発しています。

記事執筆の締切が近づいていた PoC を高速で回したかったため、コードは100%バイブコーディングで書かれています。細かいバグが多々ありますし、実際のプロダクションに適用できるレベルまでブラッシュアップはできていません。しかし、この記事は100%人間の手によって書かれているのでご安心ください(?)。

ビジュアライゼーションとは?

百聞は一見にしかず、以下がイメージです。

Image from Gyazo

ChatGPT や Analytics Advisor にも導入されている機能で、テキストベースのチャットでありながら、視覚的にデータが表現されることで、体感としてもわかりやすく、気持ちが愉快になります。しかしながら、ビジュアライゼーションの価値は「飾る」ことではなく、比較・傾向・外れ値を目で確かめられる形にして、認知負荷を下げることにあるかと思います。 逆に言うと、「それっぽいグラフ」を出して満足してしまうと、チャットの誤読がさらに強化されることもあります。そのため、“安全に”をどのように設計するかが重要です。

想定ユースケース

PoC を進めるにあたり、ある程度複雑で柔軟なデータ構造をもつ題材がほしかったので、Google Analytics っぽいダミーデータを用意し、Google Analytics のドキュメントに示された Examples を想定会話として使うことにしました。Analytics Advisor を自分で作ってみよう、という感じですね。

会話例(リンク先の Examples を日本語化したもの)

テーマ 利用シーン クエリ例
ざっくり質問 サイト全体の調子をざっくり把握したいとき "うちのサイト、最近どう?"
"新規ユーザー獲得、今どんな調子?"
条件をきっちり指定 狙った指標や切り口、イベントに絞って、分析結果とビジュアライズを表示したいとき "直近30日間のアクティブユーザー推移を見せて"
"先週、オーガニック検索流入の購入数合計を出して"
"なぜ?"を聞く 急な増減の理由を切り分けて、下落やスパイクの要因を特定したいとき "8月1日に総売上が落ちたのはなぜ?"
"新規ユーザーが先月比で15%減ってるのは、何が原因?"
etc.

基盤となるチャット UI の準備

ではやっていきましょう。きっと Vercel がなんか用意してくれているでしょう、ということで Vercel の ai-chatbot リポジトリを参考に必要な機能をチェリーピックし、ついでにデバッグしやすいようにトークン使用量を表示したり、AI 向けの linter/formatter として Ultracite 入れたり、GAっぽいダミーデータを取得する API を用意したり、カバレッジ100%じゃなかったら怒られるようにするなどの設定を行いました。

これらの導入からテストまで、すべて AI (Codex)と対話しながら ExecPlan を作成して実施しています。AGENTS.mdPLANS.mdCodex Exec PlansCode Modernization の例 を参考にカスタマイズしたものを使っています(絶賛模索中)。GPT-5.1 は「これで仕事するのは無理だろ…」という感じでしたが、 5.2 は補助線さえ引いてやればまだ全然やっていけますね。日本語は苦手なようです。

おちゃめな我が家のAI。人格矯正に無駄なコンテキストを消費しているが、かわいさは開発モチベに寄与している。

ビジュアライゼーションの戦略立案

まずは Vercel AI SDK の公式ドキュメントを当たってみます。

Rendering User Interfaces with Language Models

getWeather という Tool Calling を定義し、レスポンスを文字列ではなく UI として描画するだけ、という単純な方法です。実装で悩むことが少なく確実ですが、表示したいデータパターンの数だけ tool を作成する必要がありそうです。現在の Amethyst AI のアーキテクチャとしては、すでに GraphQL の API とそれをラップした tool の資産がたくさんあり、ユースケースが増えるほど tool 側の拡張が増えます。ここに「UI 制御に関する処理」まで混ぜ込むと、責務が混ざってスケールしづらい(= 大改修になりそう)という懸念がありました。

次に参考にしたのは Gemini API の公式ドキュメントです。

Gemini API with Vercel AI SDK Example

なんかやりたいことのイメージに近い感じがする…!

ざっくり理解するに、1回目の Tool Calling で必要なデータを取得し、そのレスポンスを2回目の呼び出しで prompt の文字列に乗せて渡す。LLM はその入力を元に Schema として定義されたチャートやテーブルの構造に合わせてデータを作ってくれる、というやり方です。これであれば「データ取得は既存 tool のまま」「可視化は Spec(契約)を中心に別レイヤで行う」という分離が達成され、柔軟なビジュアライゼーション層を疎結合につなげてやることができそうです。

ビジュアライゼーションアーキテクチャ

設計判断として、重要な“原則”は次の 3 つです。

  1. 必要なときだけ tool を呼ぶ(LLM が「呼ぶ/呼ばない」を決める)
  2. 数値は tool output を唯一の根拠にする(LLM たちは計算ができない!)
  3. tool output を UI が描ける契約(Spec)に変換し、カードとして描画する

この 3 点を押さえると、「可視化を出す/出さない」「既存 API 資産の再利用」を、なるべくシンプルに成立させられます。とはいえ 2. は結構むずかしく、LLM が勝手に算出などをしないようにチューニングが必要な領域であるように思われます。集計が必要なら集計 tool を用意し参照させる、などが必要です。1. もビジュアライゼーションの度にデータを取得して履歴に保持していくと、すぐにコンテキストが溢れてしまう懸念があります。なかなか考えることはありそうですね。

シーケンスは以下の通りです。

ユーザーが質問すると、UI から /api/chat にメッセージが送信され、サーバー側で LLM のストリーミング応答が始まります。

マルチステップの流れ

1 ターンの中で ga4_runReport → viz_chart_bar のように複数ステップが発生することがあります(発生しないターンもあります)。UI は message.parts(text parts / tool parts)を順番に描画し、tool 実行中はローディングカードなどの「途中状態」を表示します。マルチステップについても Vercel AI SDK にドキュメントがあるので、参照しておくとよいでしょう。

Multistep Interfaces

ここで重要なのは、アプリ側で「次はこの tool を呼ぶ」という状態管理を組まないことです。データ取得系 tool(ga4_* )と描画系 tool(viz_*) を定義しておき、LLM が必要な順番で呼び出すように祈ります(この辺の調整が一番難しいところですね)。一方で、決定論的で明示的な制御フローが必要な場合には、WorkflowsSequential Generations を使う方法もあります。どちらが好ましいかは、プロダクトの性質や代表ユースケースで変わってくるかと思います。描画プランの生成とかを前段に挟んでもいいのかもしれない。

tool 実行の流れ

LLM とサーバーは次のように動きます。

  1. LLM が「回答に必要な根拠データが足りない」と判断したら ga4_runReport を呼ぶ
  2. サーバーが tool を実行し、その output(根拠データ)を LLM に返す
  3. 同時に、同一ターン内で後続の可視化に再利用できるよう、サーバーは output を reportStore に保存する(同一ターン内で複数回増えうる)
  4. LLM が「次はUIに落とし込める」と判断したら viz_* を呼ぶ(例: viz_table, viz_chart_line など)
  5. viz_* は fetch を行わず、前回までの会話履歴も参照せず、同一ターン内の reportStore と短い命令のみを参照し、入力→出力を1往復で終えるワンショットで generateObject を実行し、1枚分のカードデータ(chart/table)を返す

これにより、取得データを可視化する際に「不要なコンテキストの混入」と「不要なコンテキストの汚染」を防いでいます。可視化ツールはユーザと LLM との会話に関与せず、描画に専念してもらおう、というわけです。

viz_* tool の実装イメージ

ツールの実装は、概ね以下のようになります。

import type { LanguageModel } from "ai";
import { generateObject } from "ai";
import { z } from "zod";

// 共通(tool args は instruction のみ)
export const vizInstructionInputSchema = z
  .object({
    instruction: z
      .string()
      .min(1)
      .max(2000)
      .describe("Instruction-only tool input. Do NOT paste GA4 JSON here."),
  })
  .strict();

// reportStore は「同一ターン内の ga4_runReport outputs」を保持
type Ga4RunReportStore = {
  list(): Array<{ toolCallId: string; query: unknown | null; response: unknown }>;
};

type VizOneShotContext = {
  renderModel: LanguageModel; // viz one-shot に使うモデル(通常はメインと同じ)
  reportStore: Ga4RunReportStore;
  locale: string;
};

function buildVizOneShotPrompt(args: {
  toolName: string;
  instruction: string;
  locale: string;
  snapshots: unknown;
}) {
  return [
    `You are a data-to-visualization transformer.`,
    `Produce ONE ${args.toolName} output object that matches the JSON schema.`,
    `Use ONLY the GA4 report snapshots below as your factual data source.`,
    ``,
    `locale: ${JSON.stringify(args.locale)}`,
    `instruction: ${JSON.stringify(args.instruction)}`,
    ``,
    `GA4 report snapshots (turn-local):`,
    JSON.stringify(args.snapshots, null, 2),
  ].join("\n");
}

async function generateVizObject<T>(args: {
  context: VizOneShotContext;
  toolName: string;
  instruction: string;
  schema: z.ZodType<T>;
}): Promise<T> {
  const snapshots = args.context.reportStore.list();
  if (snapshots.length === 0) {
    throw new Error("No GA4 report snapshots available in this turn. Call ga4_runReport first.");
  }

  const prompt = buildVizOneShotPrompt({
    toolName: args.toolName,
    instruction: args.instruction,
    locale: args.context.locale,
    snapshots,
  });

  // 重要: messages を渡さず、prompt + schema の one-shot で構造化する
  const { object } = await generateObject({
    model: args.context.renderModel,
    schema: args.schema,
    prompt,
  });

  return object;
}

// tool 定義(例: viz_table)
export function createVizTableTool(context: VizOneShotContext) {
  const vizTableOutputSchema = z
    .object({
      title: z.string().min(1).max(120).optional(),
      timeRange: z
        .object({ start: z.string().min(1), end: z.string().min(1) })
        .strict()
        .optional(),

      // TanStack Table 対応:
      columns: z
        .array(
          z
            .object({
              key: z.string().min(1).describe("TanStack Table accessorKey"),
              header: z.string().min(1).max(120).optional().describe("Table header label"),
            })
            .strict()
        )
        .min(1)
        .max(20),

      rows: z
        .array(z.record(z.string().min(1), z.union([z.string(), z.number()])))
        .min(1)
        .max(50),

      totalRows: z.number().int().positive().optional(),
    })
    .strict();

  return {
    description: "Create exactly ONE table card output.",
    inputSchema: vizInstructionInputSchema,
    execute: async ({ instruction }: { instruction: string }) => {
      return await generateVizObject({
        context,
        toolName: "viz_table",
        instruction,
        schema: vizTableOutputSchema,
      });
    },
  } as const;
}

最後に

本当は LLM 回答の確からしさを検証するために Promptfoo も試してみたかったんですが、ちょっとそこまで到達しませんでした(汗)。

今回試した PoC のコードは何も考慮せずそのまま Amethyst に適用できる、という単純なものではありません。プロダクトのコンテキストに合わせて改善すべき箇所がいくつもあることでしょう(エラーハンドリングとか、多言語対応とかもね…)。しかしながら、AI エージェントの発達で今回のような PoC を「とりあえずやってみる」ということへのハードルはかなり下がったのではないでしょうか。

さて、アイドルの話ですが、≠ME の推しメンは「永田詩央里」さんで、好きな楽曲は『夏が来たからです。マジでいい曲しかない、全部好き。いつもありがとうございます、お仕事がんばりますね。

明日は、千賀さんが「LoLかデータ系の話」を書いてくれるようです。酒とLoLは人の本性を映す鏡と言われています。

おたのしみに!