every Tech Blog

株式会社エブリーのTech Blogです。

VercelのAI SDKを用いてストリーミング可能な動的UIを実現する

この記事は every Tech Blog Advent Calendar 2024 9 日目の記事です。

はじめに

こんにちは。DELISH KITCHEN開発部の村上です。

DELISH KITCHENでは、これまでの『レシピ動画アプリ』から『AI料理アシスタント』を目指すべく、これまで以上にAI領域に力を入れています。詳しくはこちらにも記載があるので、ぜひご覧ください。

AI/LLMでtoC向けサービスはどう変わるのか?『DELISH KITCHEN』は、「レシピ動画アプリ」から「AI料理アシスタント」へ

このAI活用は社内での業務改善にも進んでおり、直近でOpenAI APIを用いた社内システムの開発をする機会がありました。その中で今回はVercelのAI SDKを使う機会があったのでAI SDKを用いたストリーミング可能なUIをwebアプリケーション内で実現する方法を紹介します。

AI SDKとは

VercelのAI SDKはAI/LLMを用いたwebアプリケーション開発を支援するためのツールです。AI/LLMを用いた開発ではOpenAI, Claudeなど外部APIへの繋ぎこみやチャットUIの実装、チャット履歴の保存、ストリーミング機能、RAGの利用などの機能が求められたりしますが、これを全て自分で開発しようと思うと、たとえwebフレームワークを使っていてもかなり手間がかかってしまいます。AI SDKを利用することでこうした実装工数を削減し、より周辺の機能開発に時間を割くことができます。

現状AI SDKは以下の3つから構成されています。

  1. AI SDK Core

    • テキスト生成、構造化オブジェクトの生成、LLM(大規模言語モデル)を使用したツール呼び出しを行うためのプロバイダーに依存しない統一APIの提供
  2. AI SDK UI

    • チャットやその他ユーザーインターフェースを構築するためのツールの提供
  3. AI SDK RSC

    • React Server Components (RSC) を使用してユーザーインターフェースをストリーミングする機能。※現在は実験的な開発段階。

AI SDKは同じ開発元から出ているNext.jsはもちろんNuxtやSvelteなど他環境への対応もしていますが、AI SDK RSCはNext.jsのApp Routerだけをサポートしていたりするので、環境別で何が使えるかは公式を参照するのがおすすめです。

https://sdk.Vercel.ai/docs/getting-started/navigating-the-library#environment-compatibility

AI/LLMを用いたアプリケーションにおけるストリーミング機能

特にLLMを用いたwebアプリケーション開発をしていく場合、開発上ケアしておきたいポイントはいくつかありますが、その一つにストリーミング機能があります。

まず、単純な一問一答的なものを実現しようと思っても質問内容や回答によってはAPIでLLMを用いて回答を生成する段階でその出力までユーザーに待ち時間が発生してしまいます。さらに単純な1回のAPI呼び出しだけで終わればいいですが、多くの場合では複数のAPI呼び出しや処理のステップを経て、出力結果を作っていくのでその待ち時間はユーザー体験として無視できないものになってきます。

そこで身近なところであれば、ChatGPTでも全ての回答を生成し終わる前に回答の出力が段階的に行われ、ユーザーが体感する待ち時間を軽減していると思いますが、出力や処理の途中でもユーザーにフィードバックできるようなストリーミング機能が求められます。

AI SDKではAI SDK RSCの中でその機能をサポートしているので以降ではいくつかの種類に分けて機能を紹介していきます。

テキストの出力結果をストリーミングで表示する

Server

Server側ではまず createStreamableValue でServerからClientにストリーミングで送るためのデータの格納先を準備し、streamText を使ってOpenAI APIなどProviderからストリーミングされた出力結果で更新します。

'use server';

import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableValue } from 'ai/rsc';

export async function generate(input: string) {
  const stream = createStreamableValue('');

  (async () => {
    const { textStream } = streamText({
      model: openai('gpt-4o-mini'),
      prompt: input,
    });

    for await (const delta of textStream) {
      stream.update(delta);
    }

    stream.done();
  })();

  return { output: stream.value };
}

Client

Client側ではServer側が createStreamableValue で生成されたデータを readStreamableValue を用いることで簡単に読み取ることができるので受け取ったものを処理するhooksを定義します。

import { StreamableValue, readStreamableValue } from 'ai/rsc'
import { useEffect, useState } from 'react'

export const useStreamableText = (
  content: string | StreamableValue<string>
) => {
  const [rawContent, setRawContent] = useState(
    typeof content === 'string' ? content : ''
  )

  useEffect(() => {
    ;(async () => {
      if (typeof content === 'object') {
        let value = ''
        for await (const delta of readStreamableValue(content)) {
          if (typeof delta === 'string') {
            setRawContent((value = value + delta))
          }
        }
      }
    })()
  }, [content])

  return rawContent
}

表示するコンポーネント側ではServerからの結果を上記で定義した useStreambleText を使って表示するだけで簡単に実現できます。

'use client';

import { useState } from 'react';
import { generate } from '@/lib/actions';
import { useStreamableText } from '@/lib/hooks';
import { StreamableValue } from 'ai/rsc';

export default function QuestionAnswer() {
  const [answer, setAnswer] = useState<string | StreamableValue<string>>('');

  return (
    <div>
      <button
        onClick={async () => {
          const { output } = await generate('簡単に作れるお弁当レシピを教えてください。');
          setAnswer(output);
        }}
      >
        Ask
      </button>

      {answer && <AssistantMessage answer={answer} />}
    </div>
  );
}

export function AssistantMessage({
  answer,
}: {
  answer: string | StreamableValue<string>;
}) {
  const text = useStreamableText(content);

  return (
    <div>
      {text}
    </div>
  );
}

オブジェクトの出力結果をストリーミングで表示する

Server

Server側では、同じように createStreamableValue を利用するところは同じですが、ここではオブジェクト形式の出力に対応する streamObject を利用して、プロバイダーから逐次送信される構造化されたデータで更新します。AI SDKではOpenAI APIのStructured Outputsにも対応しているので、 structuredOutputs パラメーターで指定します。

'use server';

import { streamObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableValue } from 'ai/rsc';
import { z } from 'zod';

export async function generateObject(input: string) {
  const stream = createStreamableValue({ answer: '', quotation_links: [] });

  (async () => {
    const { objectStream } = streamObject({
      model: openai('gpt-4o-mini', {
        structuredOutputs: true,
      }),
      schema: z.object({
        answer: z.string(),
        quotation_links: z.array(
          z.object({
            title: z.string(),
            link: z.string(),
          })
        ),
      }),
      prompt: input,
    });

    for await (const delta of objectStream) {
      stream.update(delta);
    }

    stream.done();
  })();

  return { output: stream.value };
}

上記の例では、objectStream には常に { answer: '', quotation_links: [] } の形式が保たれた形で随時そのテキスト情報や配列要素が追加されていくので、特に複雑な加工処理をすることなく、Client側で利用可能な状態になります。

Client

Client側では、テキストをストリーミングする時と同じように readStreamableValue を使用してストリーミングされたオブジェクトを受け取り、動的に更新します。

import { StreamableValue, readStreamableValue } from 'ai/rsc';
import { useEffect, useState } from 'react';

type AnswerObject = {
  answer: string;
  quotation_links: { title: string; link: string }[];
};

export const useStreamableObject = (
  content: AnswerObject | StreamableValue<AnswerObject>
) => {
  const [rawContent, setRawContent] = useState<AnswerObject | null>(
    typeof content === 'object' && !('subscribe' in content)
      ? content
      : { answer: '', quotation_links: [] }
  );

  useEffect(() => {
    (async () => {
      if (typeof content === 'object' && 'subscribe' in content) {
        let value: AnswerObject | null = null;
        for await (const delta of readStreamableValue(content)) {
          if (typeof delta === 'object') {
            setRawContent((value = { ...value, ...delta }));
          }
        }
      }
    })();
  }, [content]);

  return rawContent;
};

定義した useStreamableObject を使用して、ストリーミングで受け取ったオブジェクトデータを表示します。

'use client';

import { useState } from 'react';
import { generateObject } from '@/lib/actions';
import { useStreamableObject } from '@/lib/hooks';
import { StreamableValue } from 'ai/rsc';

type AnswerObject = {
  answer: string;
  quotation_links: { title: string; link: string }[];
};

export default function ObjectDisplay() {
  const [answer, setAnswer] = useState<
    AnswerObject | StreamableValue<AnswerObject> | null
  >(null);

  return (
    <div>
      <button
        onClick={async () => {
          const { output } = await generateObject('環境問題に関する最新のレポートを教えてください。');
          setAnswer(output);
        }}
      >
        Ask
      </button>

      {answer && <AssistantObjectMessage answer={answer} />}
    </div>
  );
}

export function AssistantObjectMessage({
  answer,
}: {
  answer: AnswerObject | StreamableValue<AnswerObject>;
}) {
  const data = useStreamableObject(answer);

  return (
    <div>
      {data ? (
        <div>
          <p>{data.answer}</p>
          <ul>
            {data.quotation_links.map((item, index) => (
              <li key={index}>
                <a href={item.link} target="_blank" rel="noopener noreferrer">
                  {item.title}
                </a>
              </li>
            ))}
          </ul>
        </div>
      ) : (
        'Loading...'
      )}
    </div>
  );
}

オブジェクト形式のストリーミングのメリットは上記のようにそれぞれ異なる要素に対して個別のスタイリングや処理を行うことができる点です。今までのテキストでのストリーミングは表現方法が限定的になるか、やろうと思ってもテキスト情報を変換するような複雑な処理をしないといけず不安定になってしまいますが、オブジェクト形式で受け取れることによって、アプリケーションによって独自の見せ方が可能になり、自由度が上がりました。

処理に合わせてUI自体をストリーミングで表示する

Vercelではテキストやオブジェクトだけではなく、UI自体をストリーミングすることができます。この機能を使うことでテキストやオブジェクトだけではなく、LLMの回答結果をUIで表示することも可能になります。 今回は、AIアプリケーションでありがちな回答を出力するまで進捗を表示するUIを例に紹介します。

一番最初に利用した AssistantMessage と以下の WorkflowProgress コンポーネントをServerとClientでやりとりします。

進捗を表示するWorkflowProgressコンポーネント

export function WorkflowProgress({ workflowSteps }) {
  const completedSteps = workflowSteps.filter((step) => step.status === 'completed').length;
  const totalSteps = workflowSteps.length;
  const progressValue = (completedSteps / totalSteps) * 100;

  return (
    <div className="p-4 bg-gray-100 rounded-lg shadow">
      <h3 className="font-semibold text-lg">Progress</h3>
      <progress value={progressValue} max="100" className="w-full mb-2"></progress>
      <ul>
        {workflowSteps.map((step) => (
            <li key={step.id} className="mb-2">
            <strong>{step.name}:</strong> {step.status}
            </li>
        ))}
      </ul>
    </div>
  );
}

Server

サーバー側では createStreamableUI を利用して、ストリーミングするコンポーネントを追加、更新することができます。今回はAI SDKの機能紹介がメインのため、step毎の具体的な処理については省略します。

'use server';

import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableUI } from 'ai/rsc';
import { WorkflowProgress } from '@/components/WorkflowProgress';
import { AssistantMessage } from '@/components/AssistantMessage';

export async function generateWithSteps(input: string) {
  const workflowSteps = [
    { id: 'step1', name: '質問を解析中', status: 'in-progress', tasks: [] },
    { id: 'step2', name: '探索方法を検討', status: 'pending', tasks: [] },
    { id: 'step3', name: '関連データを取得', status: 'pending', tasks: [] },
  ];

  const displayUI = createStreamableUI(
    <WorkflowProgress workflowSteps={workflowSteps} />
  );

  (async () => {
    // Step 1: 質問を解析
    await new Promise((resolve) => setTimeout(resolve, 2000));
    workflowSteps[0].status = 'completed';
    workflowSteps[1].status = 'in-progress';
    displayUI.update(
      <WorkflowProgress workflowSteps={workflowSteps} />
    );

    // Step 2: 探索方法を決定
    await new Promise((resolve) => setTimeout(resolve, 2000));
    workflowSteps[1].status = 'completed';
    workflowSteps[2].status = 'in-progress';
    displayUI.update(
      <WorkflowProgress workflowSteps={workflowSteps} />
    );

    // Step 3: データを取得
    await new Promise((resolve) => setTimeout(resolve, 2000));
    workflowSteps[2].status = 'completed';
    displayUI.update(
      <WorkflowProgress workflowSteps={workflowSteps} />
    );

    // 結果を生成
    const { textStream } = streamText({
      model: openai('gpt-4o-mini'),
      prompt: input,
    });

    let generatedText = '';
    for await (const delta of textStream) {
      generatedText += delta;

      // 進捗状況のコンポーネントから回答結果のコンポーネントに変える
      displayUI.update(
        <AssistantMessage answer={generatedText} />
      );
    }

    displayUI.done();
  })();

  return { display: displayUI.value };
}

Client

クライアント側では、定義したアクションを呼び出し、その結果をそのまま表示することで簡単に動的なUIが作れます。実際の挙動はまず最初にProgressを表示し、回答結果がLLMから出力され始めるとProgressは非表示になり、回答が生成されていくような形になります。

'use client';

import { useState } from 'react';
import { generateWithSteps } from '@/lib/actions';

export default function DynamicProgressDisplay() {
  const [display, setDisplay] = useState<React.ReactNode | null>(null);

  return (
    <div>
      <button
        onClick={async () => {
          const { display } = await generateWithSteps('環境問題に関する最新のレポートを教えてください。');
          setDisplay(display);
        }}
        className="mb-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        Ask
      </button>

      {display}
    </div>
  );
}

最後に

今回はVercelのAI SDKを使って、いくつかの手法でストリーミング可能なUIをアプリケーション上で実装する方法を紹介しました。この他にも会話履歴の保存やその状態の管理を簡単に扱えるように豊富な機能が提供されています。AI SDK RSCはまだベータ的な位置付けですが、webでLLMを使ったアプリケーションをする際には比較的簡単にリッチなアプリケーションが作れるので、ぜひ社内ツールや簡単に動くものを作りたい場合にはVercelのAI SDKを使ってみてください。

冒頭でも紹介したようにDELISH KITCHENではこれまでの『レシピ動画アプリ』から『AI料理アシスタント』への変化を起こそうとしています。ぜひ、この取り組みに興味を持った方は一度カジュアル面談でお話しましょう!

corp.every.tv

初めて経験したLaravel、Pestを利用した単体テストで感じたこと

はじめに

この記事は every Tech Blog Advent Calendar 2024 の8日目の記事です。

こんにちは、リテールハブ開発部でバックエンドエンジニアをしています。
実はまだ転職して2ヶ月のため、まだまだわからないことだらけですが、
現在、Laravelを利用したAPI開発をしていて、その中でPestを利用した単体テストを行なっています。

前職のAPIテストは結合テストメインで行っていて、単体テストはほとんど行なっていなかったのですが、 検証データの準備や継続的なテストにおいて課題がありました。
今回LaravelもPestも初めて利用するのですが、今回行った単体テストを経験する中で、 これを利用すれば以前感じていた課題が解決できるかもしれないと思いました。

本ブログでは、課題解決できると思った点や実際に単体テストを経験して感じたことなどをお伝えできればと思っています。 同じ課題感をお持ちの方やテストに興味のある方はぜひ読んでいただけると嬉しいです。

今回はLaravel、Pestを例にしたお話にはなりますが、他のフレームワークでも同様のケースは多いのかなと思っています。

前職で行っていたAPIテストで感じていた課題

私が今まで行っていたAPIのテストは基本的にはDBにテストデータを投入し、
データと各パラメーターによって変わるAPI結果を検証する結合テストがメインでした。
できる限りのパターンを網羅し、各ロジックが検証できるようデータ準備をしていたりもしました。 その後はSeleniumなどを使用してテストパターンを登録して自動テストなども行なっていました。

しかし、以下のような課題がありました。

「データ整備の課題」

  • 検証用のデータをDBにあらかじめ入れないと検証できない
  • DBデータの最新化や別件の対応などで内容が変わったりと検証データの担保がしにくい
  • 検証データが不足していて意図した結果が得られていないケースがある
  • 外部要因による結果を利用するテストが行いにくい

「検証データの信頼性の課題」

  • 自動テストの実行でエラーが発生した場合、検証データによるものかロジックによるものかの特定に時間がかかる

上記課題は、DBデータを固定化したり、検証用DBを別途作ったりして回避しようとしていましたが、 やはりそれだけではデータパターンの担保や変更されないことの保証はうまくできていないことがありました。 特にデータ担当が別部署だったりするとなお難しい状況でした。

Laravel、Pestを利用したテストについて

LaravelやPest以外でも同じような機能はあると思いますが、
今回のテストでは、LaravelのModel、Factoryを利用して、コード記述によって任意のテストデータを作成(DB投入)し、 Pestを使って各テストケース毎に独立した検証ができるテストを経験しました。

簡単ではありますが、単体テストのメリットとデメリットを記載してみました。

メリット

  • メソッド単位で1つ1つ検証することができる
  • ケース毎に独立したテストができるため、環境やデータ状況に左右されない
  • Mockを利用して仮想的な結果を混ぜることで、データ整備だけでは難しいケースにも記述次第で対応できる

デメリット

  • テストケースが増えるほどコストも増加する
  • 機能仕様の変更などをすると単体テストにも影響が大きくあり変更負荷が高い

当然ではありますが、今回行ったような単体テストは、1ケースずつ細かく行うため工数はどうしてもかかるなと思いました。
様々なケースを考えながら記述もするので単純作業というわけでもなく、 そもそもテストコードが間違えていたら意味もないため、手早くこなすのも厳しそうです。 また、最終的にはAPIの機能検証も別途必要なため、複合して行う必要があります。

課題が解決できると思った点

上記だけでは、やはり工数の問題などで優先度を下げて対応してしまいそうではありますが、 今回自分の中で非常に使えそうと思ったのは、

  • 指定のテーブルに任意のデータをコード記述により固定で入れられ、検証したいデータパターンを柔軟に記述することができる点
  • 簡単にテストデータを作る仕組みがあり、複数データ作成の手間も省ける点
  • 1テストケース毎に他のデータの影響を与えず独立して実行することができる点

かなと思いました。

簡単な例ですが、以下の要件に合わせたPestのサンプルコードがあります。

  • getListメソッドのテストを行いたい
  • APIリクエストからのパラメーターはidのみ
  • status引数の値によって分岐があり結果が変わる
  • statusはAPIのリクエストからは指定できず、外部要因によって決定される

コードの記述のみでデータ投入、メソッド実行、結果比較を行うことができます。

Pest記述のサンプルコード

<?php

namespace Tests\Unit\Sample;

// ここにTest前の処理を記述
beforeEach(function () {
    $this->sample = new Sample();
});

// ここにTest後の処理を記述
afterEach(function () {
    
});

// 1テストケースずつ以下の形式で作成していく
it('指定IDとステータスがokの場合一覧を検索できること。', function () { 
    // 検索したいパラメーター
    $id = 1;
    $status= 'ok'; // statusはAPIのパラメータからは指定できない

    // 任意のテーブルにデータを入れる機能
    // この記述によりデータがテストケース毎に固定できる。
    SampleTable::factory()->create(
        [
            'id' => 1, // primary key
            'status' => 'ok',
            'name' => 'サンプル1',
        ]
    );
    SampleTable::factory()->create(
        [
            'id' => 2, // primary key
            'status' => 'ok',
            'name' => 'サンプル2',
        ]
    );

    // id, statusから一覧を取得するメソッドのテスト
    $result = $this->sample->getList($id, $status);

    // 返却内容は以下の記述でチェックできます。

    // 結果が何件返ってきているかをチェック
    expect($result->count())->toBe(2);

    // 結果のnameが正しいかチェック
    expect($result[0]['name'])->toBe('サンプル1'); 
    expect($result[1]['name'])->toBe('サンプル2');     
});

//  2ケース目
it('指定IDは合っていてもステータスがok以外のためヒットしないこと。', function () { 
    // 検索したいパラメーター
    $id = 1;
    $status= 'test'; // statusはAPIのパラメータからは指定できない

    // 任意のテーブルにデータを入れる機能
    SampleTable::factory()->create(
        [
            'id' => 1,  // primary key
            'status' => 'ok',
            'name' => 'サンプル1',
        ]
    );
    SampleTable::factory()->create(
        [
            'id' => 2, // primary key
            'status' => 'ok',
            'name' => 'サンプル2',
        ]
    );

    // id, statusから一覧を取得するメソッドのテスト
    $result = $this->sample->getList($id, $status);

    // 返却内容は以下の記述でチェックできます。

    // 結果が何件返ってきているかをチェック
    expect($result->count())->toBe(0);
});
<?php
class Sample
{ 
    public function getList(int $id, string $status): Collection
    {
        $query = SampleTable::query()
            ->where('id', $id);
        
        if ($status == 'ok') {
            $query->where('status', $status);
        } else {
            $query->where('status', 'ng');
        }

        return $query->get();
    }
}

上記、2つのテストケースがありますが、

  • 1つ目はid=1, status=okでデータが取得できるケース
  • 2つ目はid=1, status=testでデータが取得できないケース

どちらのケースもid=1、id=2と入れていますが、各テストは独立しているのでデータの重複や状態を考慮する必要がなく、 自由なデータを入れることができます。
また、サンプル内の「status」はAPIパラメータ指定ではなく、何らかの条件で決まる場合、

  • 外部APIの結果などの別要因で決まる値
  • 他のデータの組み合わせで決まる値

上記のようなケースでも単体テストでは値を固定してそれぞれのパターンを簡単に試すこともできます。

前職で行っていたテストではこういった外部要因で決まった値によって変わるテストが、
データ調整や外部APIの結果を無理やり変えたりと大変苦労していました。

また、サンプルでは非常にシンプルなテストですが、

  • 複数使用したテーブルやテーブルの項目が増える場合
  • いろんな条件によって検索する値が変わる場合
  • 通常は起こり得ないデータを入れないとテストできない場合

など複雑化していくケースの場合、

  • あらかじめ検証したいデータを投入しておく
  • API検証時に各パラメーターのパターンを変えて試す

だけではどうしても漏れが起きてしまったり、複雑なケースのデータ投入の限界や他のテストケースに影響を与えないデータ考慮などが必要になってきてしまいます。
また、もしエラーが発生した場合にそれが検証データによるものか、ロジックによるものかの判断もより難しくなってきます。

これが1テストケースごとで独立したデータ、テストであれば、

  • 難しいデータパターンもコードに1度書いてしまえば、同じ検証、同じ結果が担保できる
  • 各ケースが様々な記述をしても、他のケースに影響を与えない
     (データパターンも自由に変更可能)
  • ある時からエラーが発生するケースを検知した場合でもデータによるものではなくロジックによるものと判断しやすい

このメリットを利用することができれば、今まで課題だった問題が解決できそうと思ったところです。

実際に使ってみて感じたところ

ただ今回のような単体テスト実行は、前職のプロジェクトでは一時的に導入はしたものの、
前述の通り、

  • 対応コストが多く掛かってしまう
  • 機能変更した場合の修正コストも都度必要
  • 通常のプログラムとは別の記述方法が必要

が、やはり大きく影響し、最初は導入しても徐々に対応しなくなってしまうケースがほとんどでした・・・。

しかし、今までのようにすべてやらなくなるのではなく、
一部だけでも取り入れる方法はメリット部分を大きく活かせそうかなと思いました。

この一部だけというのが、また別の課題も生みそうではありますが、

  • 検証しにくいデータパターンだが重要なケース
  • データの変化が起きやすく安定した検証がしにくいケース
  • 一部の重要なロジックだけでも固定データと合わせて動作保証を担保したいケース
  • よく利用されるもの、共通のケース
    など

もちろん現在のプロジェクトで実施しているようなできる限り網羅するのが良いと思っていますが、 それぞれのプロジェクトの状況によっては、上記のような必要な部分だけに今回のようなテストコードのコストをかけるだけでも大きな効果があるのではないかと思いました。

最後に

抽象的な表現でわかりにくい点もあったかと思いますが、
データパターン検証、安定した検証に今回のような単体テストも有効そうであることは伝わりましたでしょうか。

今回のような単体テスト方法はしばらく触れてきていなかったのもあり、大変勉強になりました。

今後のテスト検証の際にぜひ少しでも参考にしていただければ幸いです。
最後までお読みいただき、ありがとうございました。

A/Bテスト自動レポーティングによるビジネスサイドの意思決定支援

はじめに

この記事はevery Tech Blog Advent Calendar 2024の7日目の記事です。

エブリーでデータサイエンティストをしている山西です。
今回は、A/Bテスト結果のレポーティングを自動化した事例をご紹介します。
ビジネスサイドが抱く「統計学的なとっつきにくさ」を解消し、結果を解釈しやすく伝えるための試みです。

図1: 結果のレポーティングの雰囲気(評価指標に対して、ダッシュボード上で結果を確認できる)

※ 本記事はランダム化比較実験や統計的仮説検定の基礎知識を前提としています。これらの知見をビジネスに還元する取り組み事例として、何かしらご参考になれば幸いです。

以下、経緯を順に説明していきます。

背景

私たちが運営するレシピ動画サービス『DELISH KITCHEN』では、日々の機能改善にA/Bテスト基盤※1を活用しています。
これは、
1. ユーザー展開の準備(control群、test群への割り当て)
2. 観察指標のデータ集計
3. 統計的仮説検定(観察指標の「test群とcontrol群の差」を検定)
4. 結果のダッシュボード可視化(BIツールRedashをインターフェースとし、日次バッチ更新)
を一気通貫で行う仕組みです。

これまで数年にわたり活用実績を積み重ねており、現在では社内の複数事業部で利用されています。

  • アプリ内機能の開発・改善
  • 機械学習アルゴリズムの性能検証
  • 広告やランディングページのデザイン改善

など、その用途は多岐にわたります※2

こうして、ビジネスサイドがデータドリブンに仮説検証を試みる文化が着実に根付いてきました。

※1 A/Bテスト基盤の詳細については、以下の記事をご覧ください。 tech.every.tv

※2 参考までに、直近1年の実施回数は50回でした。A/Bテストの実験成熟度モデル:Fabijan et al. (2017)では、年間のテスト実施回数で成熟度を簡易的に見積もるアイデアが提唱されています。これにならえば、ちょうどWalk Phase(年に50回以下)からRun Phase(年に250回以下)の境界にあたり、大規模なA/Bテスト推進組織への道がひらけた状態ともいえます。

課題

一方で、

  • ビジネスサイドに結果を正しく解釈してもらうこと
  • そのために適切な実験のデザインをすること

に関しては、一定の課題感が残りました。

以下にその事例をAs-Is、To-Be※3の体裁で整理します。

As-Is(実際にあった例) To-Be(目指したい状態)
・有意差や信頼区間を考慮せず、指標の結果値だけで判断する
・誤差幅と有意性を考慮して結果を解釈できるようにしたい
・有意差が出ていないことを「効果が無かった」と断定してしまう
・有意差がない場合は「差があったとは言えない」と判断できるようにしたい
・有意差と効果量を混同し、「有意だからビジネスインパクトが大きいだろう」と解釈してしまう
・有意性と効果量を区別し、それぞれ正しく考察できるようにしたい
・「有意差が出ていないから、出るまで期間を伸ばそう」と判断してしまう(p-hacking)
・都合の良い結果を導く危険性を共有し、事前の実験デザインを遵守できるようにしたい
・結果を見ながら元の仮説を書き換える(HARKing)
・仮説が不明瞭なまま検証を進めようとする。
・A/Bテストは仮説検証の手段であることを共有し、後から仮説を変える危険性を伝えたい
・大きな変更によるネガティブ影響を恐れ、展開率を必要以上に抑える
・結果を早く見たいので期間を短く設定する
・検出力を確保するため、適切なサンプルサイズと実験期間を設定できるようにしたい

※3 To-Beの部分が全く実践できていないわけではありませんが、共通認識として推し進める段階には至っていない現状をAs-Isと対比して示しています。

発生要因

前提知識のばらつき

これらの問題の主な原因は、結果を解釈する人々の前提知識にばらつきがあることだと考えられます。
統計的仮説検定の結果は本来、有意差や信頼区間の意味を理解しつつ、適切に解釈する必要があります。
しかし、専門知識を必ずしも有していない人々にその解釈を委ねると、「事実が示す以上の解釈」が生じる可能性があります。
その結果、「数字の一人歩き」や「データに基づかない意思決定」といった問題が発生しやすくなり、意思決定のリスクが増大してしまいます。

「ビジネスの関心事」と「統計的な正しさ」とのギャップ

また、時には統計的仮説検定としての理想的な実験デザインが完遂できないことがあります。
先述した「サンプルサイズ不足の状態でA/Bテストを進めてしまう」ケースがその一例です。

ビジネスサイドは収益最大化のため、時には短期間でPDCAを回す判断を行いたい場合もあります。
一方、観察指標によってはサンプルサイズの確保に時間を要する場合があります。
そうなると、「サンプルサイズ確保のために数週間、数ヶ月かけて仮説検定の正しさを立証する」ことよりも、「1施策を1〜2週間で実施し、不確実性を認めつつ結果を判断したい」ことに興味が向く場合もあります。
これはこれで一つの尊重すべき視点である※4一方、統計的視点を薄め、感覚と経験則に頼る傾向を強めてしまうことになります。
これでは、A/Bテストの意義が薄れてしまいます。

※4「1施策の結果考察の確からしさを犠牲にする」策が本当にKPIの最大化に寄与するか否かは、別途定量的に分析してみないとわからないことだと思います。が、本記事の範疇を越えるため、ここでの深入りは避けます。

それをサポートするのがデータサイエンティストの役割では?

「こうした問題を防ぐためには、データサイエンティストがサポートすべきでは?」という指摘はもっともです。
しかし、実際の運用においては、いくつかの課題が浮き彫りになっています。

運用規模の拡大

A/Bテスト基盤の導入初期は、データサイエンティストとビジネスサイドが密に連携して結果を解釈していました。
しかし、運用規模が多くの部署に拡大するにつれ、データチームが全施策に関与することが難しくなっています。

データ解釈の視点の啓蒙活動の限界

ビジネスサイドへデータ解釈の際の心構えを啓蒙することも有効な解決策ですが、それだけでは限界があります。
学習を促す側・される側双方に一定のコストがかかるうえ、個々人の学習意欲や、担当者交代による知識の断絶といった属人性の課題があります。

全社的な見解の統一の必要性

実務者間で解釈を共有しても、他の利害関係者がダッシュボードを見た際に、「数字の一人歩き」や「誤解」が再燃することがあります。
特に、これが意思決定の上層部との間で起こると、認識のズレが意思決定を揺るがす原因になり得ます。

課題解決のための方向性

ここまで挙げてきたように、「誰でも気軽にA/Bテストを推進し、結果をダッシュボードで観察できること」の弊害が見え始めました。
一方で、「データドリブンな仮説検証を全社的に試みようとする文化の良い点」は引き続き維持したいところです。

また、ビジネスのスピード感を優先するがために「科学的な正しさ」の比重を下げなければいけない場合も、「その不確実性によって起き得るリスク」を意思決定者が認知し、公平に判断してもらう状態を目指したいです。

このような経緯から、「統計的仮説検定のデータ解釈をもっと良い感じに共通認識化させたい」という機運が高まることとなりました。

解決策: ダッシュボードからレポーティングへの昇華

これらの課題間の解決策として「言葉で解釈を手助けする」レポートをダッシュボードに追加することにしました。

コンセプトは「記述的なダッシュボードから、言葉によるレポーティングへの昇華」です。

これまでビジネスサイドとA/Bテストの結果を振り返るやりとりの中で「事実の整理としてのレポートはある程度パターン化できる」という気づきから、実装する運びとなりました。

以下に、結果の説明文の生成イメージを紹介していきます。
有意性の有無や、観測値(testとcontrolの指標の差)のプラス、マイナスに応じて、動的に生成内容を切り替えるようにしています※5

※5: 今回の主題ではないため詳しくは触れませんが、Redash上でPythonを実行する機構を用いて、各種統計的検定結果を動的に取得、埋め込む形でレポートを構築しました。

例1: 有意に結果がプラスとなったケース

図2:有意に結果がプラスとなった場合のレポーティング

例2: 有意に結果がマイナスとなったケース

図3:有意に結果がマイナスとなった場合のレポーティング

例1、例2では、「有意性と実際の効果の量を区別し、それぞれ正しく考察できるようにしたい。」というTo-Beを意識しています。

例3: 有意差が観察されなかったケース

図4: 有意差が観察されなかった場合のレポーティング

「誤差幅と有意性を考慮して結果を解釈できるようにしたい」
「検出力を確保するため、適切なサンプルサイズと実験期間を設定できるようにしたい」
というTo-Beを踏まえた内容が含まれています。

こだわり

  • ビジネスサイドにとって理解しやすい言葉を意識する(専門用語を過度に使用せず、統計独特の言い回しを適宜言い換える)
  • 言外の解釈に発展させないようにする(「信頼区間を95%正しい」と誤認させない、「有意差がないことは、効果がなかったことを必ずしも意味しない」など)

などの工夫と共に、慎重に言葉を選びました。

また、例3で挙げたように、理想的な実験デザインが完遂できなかったとしても、その不確実性やリスクを事前に告知する工夫を説明文に施しました。
意思決定者が、ビジネス視点とデータ解釈の視点を公平に判断できる状態を期待しています。

最後に

A/Bテストの運用における実務での気づきから、「自動レポーティング」という新たなアプローチを開拓した事例をご紹介しました。

本記事執筆時点では、これから運用を始める段階です。
自動レポーティングの導入により、統計的な観点を伴う解釈を関係者間で共有し、データ解釈における視座の向上を期待しています。

今後も、データドリブンに施策推進を行う社内文化の醸成と、その質の向上を図っていきたいと考えています。

全社的にSSH辞めるためには

全社的にSSH辞めるためには

この記事は every Tech Blog Advent Calendar 2024 の 6 日目の記事です。

はじめに

エブリーTIMELINE開発部の内原です。

全社的にSSHの利用を中止することができたので、そのような意思決定をすることに至った経緯や、その後の状況について紹介します。

なお前提として、下記記事はAWSに限定した内容となっています。

エブリーではGCP(GCE)も一部のサービスで利用しているのですが、GCEについては下記で説明する問題の影響がなかったため対象外としています。

SSH利用を中止したい理由

以下のような理由から、運用的にいろいろ辛い部分があったためです。

脆弱性対応で疲弊する

一般的にSSHサーバとしてOpenSSHが用いられることが多いと思いますが、このソフトウェアには時折セキュリティ脆弱性の問題が見つかることがあります。この脆弱性については放置できないケースも多いので、その都度工数が発生します。

今年だと CVE-2024-6387 の問題がありました。

共有アカウントにおけるセキュリティリスク

キーペアを用いてEC2にログインするケースなど共有アカウントでログインする運用では、退職者であってもログインできてしまうリスクがあります。

また共有アカウントの運用では、監査の観点でも誰がなにをしたかについても追跡が難しくなります。

個別アカウントでの運用は大変

かといって、ユーザ個別のアカウント運用を行うのはわりと面倒です。

手動で管理するのは当然として、なんらか外部サービスと連携してアカウント管理を自動化するアプローチであっても、面倒なことには変わりありません。

セキュリティグループ運用が面倒

SSHを使うためにはSSHポート番号(22番)を開放する必要がありますが、この管理方法についても考慮すべきことが多いです。

  • ポートは全体開放(0.0.0.0/0)するか?
  • 全体開放しないならどういう運用で開放するか?
    • 管理コンソールで担当者が直接更新するか? なんらかIaCツールを用いるか?
    • IPアドレスが頻繁に変わる場合はどうするか?

SSH利用を中止した後の代替手段

上記のようにSSHを利用し続けることは無視できないリスクがあると考えたため、SSHの利用を全社的に中止することにしました。

ただそうは言っても、現状の運用でSSHを利用しているケースも存在していたため、代替手段を用意する必要がありました。

EC2へのログインにSession Managerを利用する

AWS Systems Manager Session Managerを利用することでSSHの代替を行うことができます。 Session ManagerはEC2インスタンスに対してSSHの代替となるリモートシェルを提供するサービスです。

最近のEC2インスタンスならば通常SSM Agentは起動していますが、数年以上前に作成したインスタンスの場合はSSM Agentが起動していないことがあるため、その場合はSSM Agentを手動でインストールする必要があります。

また、インスタンスIAMロールには AmazonSSMManagedInstanceCore ポリシーがアタッチされている必要があります。

EC2インスタンスを利用しないアプローチ

もしくは、踏み台用のEC2インスタンスを用いるのではなく、ECS Fargate Taskを都度起動するアプローチを採ることも可能です。以前にその対応を行った記事がありますので、参考にしてください。

RDS踏み台サーバをよく見かけるECS Fargate+PortForward+Adhocな機構に変更する

実際のSSH利用例と代替手段

EC2インスタンスへのログインを行なっているケース

EC2インスタンスにログインしてなんらかシェル操作を行なっているようなケースです。その場合は以下のようなコマンドでリモートシェルを利用することができます。

対応前

$ ssh -i path/to/key.pem $ec2_user@$ec2_host
sh-5.2$

対応後

$ aws --profile $profile ssm start-session --target $instance_id
Starting session with SessionId: foo.bar@nrcazkfv3a6gkcmdmihy7i4pbq
sh-5.2$

ローカル環境からのRDSへの接続用Proxyとして利用しているケース

RDSのインスタンスはVPC内に存在するため直接接続することができないので、SSH Port Forwardingを利用してリモート接続するようなケースです。

例えば以下のようなコマンドでローカル環境からmysqlサーバに接続することができます。

対応前

$ ssh -L 3306:$remote_db_host:3306 $ec2_user@$ec2_host
$ mysql -h 127.0.0.1 -u$db_user -p $db_name
Enter password:
mysql>

対応後

このようなケースについても、AWS Systems Manager Session Managerのポート転送を利用することで代替することができます。

$ aws --profile $profile ssm start-session --target $instance_id \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["YOUR-REMOTE-DB-HOST"],"portNumber":["3306"],"localPortNumber":["3306"]}'
$ mysql -h 127.0.0.1 -u$db_user -p $db_name
Enter password:
mysql>

全社の状況把握と方針策定

状況把握

まずは全社で利用している全EC2インスタンスのリストを作成し、それぞれのインスタンス利用状況を可視化することにしました。

その際は以下のようなコマンドで一覧化したものをスプレッドシートに書き出し、担当部署を割り当てて部署ごとに利用状況を記載してもらいました。

$ aws ec2 describe-instances | jq -r '.Reservations[] as $r | $r.Instances[] | select(.State.Name!="terminated") | [$r.OwnerId, .InstanceId, (.Tags // [] | from_entries.Name // "NoName"), (.SecurityGroups[0].GroupName // "NoName"), .LaunchTime, .InstanceType, .State.Name] | @tsv'

上記コマンドによって以下のような出力を得られます。

AccountName OwnerId InstanceId TagName SecurityGroupName LaunchTime InstanceType State.Name
************ i-***************** INSTANCE-NAME SECURITY-NAME YYYY-MM-DDThh:mm:ss+00:00 INSTANCE-TYPE running

対象としたインスタンス数は全社で70個ほどで、これを担当する複数の部署に割り当てました。

なお、部署によってはインフラ構成が大きく異なっているケースもあり、管轄する個数にはだいぶ偏りがある状態でした。(ちなみに自分が所属しているTIMELINE開発部では該当するインスタンスは存在しませんでした)

方針策定

各部署では以下のいずれかの方針を選択してもらうことにしました。

インスタンスの削除

stopping状態のままになっているインスタンスやすでに利用していないインスタンスなど、削除しても問題ないインスタンスについては削除することにします。

SSHポート番号閉鎖

本来はSSHサーバ自体を停止するのが望ましいのですが、EC2の機構上動作しているインスタンスからSSHを無効化するのが難しかったため、SSHポート番号の閉鎖で対応することにしました。

ポート番号が塞がれていれば事実上外部からSSHで攻撃されるリスクは考慮しなくてよくなると考えたためです。

その後の状況

最初にリストを作成してから1ヶ月半ほどで、全部署での対応が完了しました。

前述の通り部署によって対象個数に偏りがあったため最終的にはそれなりの時間がかかることになりましたが、各部署のご協力あって無事完了させることができました。

上記対応を行った結果、現在は全社的にSSHの利用が中止され、セキュリティ上のリスクは大幅に軽減されました。

また、今後新たにEC2インスタンスを起動する場合にも同様の対処が行われるよう、全社的な運用ルールを別途定める予定です。

まとめ

SSHの利用を中止することで、セキュリティ上のリスクを軽減することができました。またSSHのアカウント管理に関する煩雑さもなくなり、運用コストの削減にもつながりました。さらに運用ルールを定めて、今後ともにセキュリティを維持していくことが重要と考えています。

以上、全社的にSSHの利用を中止するために行った取り組みについて紹介しました。

ISUCONに向けて勉強したこと

この記事は every Tech Blog Advent Calendar 2024 5 日目の記事です。

はじめに

こんにちは、DELISH KITCHEN 開発部でソフトウェアエンジニアをしている24新卒の新谷です。

今回は12/8開催のISUCON14に向けて、ISUCON初参加の私が勉強したことについてまとめていきます。

また、everyはISUポンサーとして協賛しており、詳しくは以下をご覧ください。

tech.every.tv

初参加に向けたざっくりの戦略

今回参加したチームは、日本CTO協会の新卒合同研修で知り合った新卒メンバーで出場しました。 (日本CTO協会の新卒合同研修についてのブログはこちら

全員がISUCON初参加ということで、それぞれ役割を決めて、それに向けて勉強を進めました。 そのうち私は、DB周りのインデックス担当ということで、DBのインデックスの張り方を中心に勉強しました。

また、役割はあるもののチーム全員で共通して勉強したこととして、以下があります。

  • Go言語
  • N+1の対策
  • オンメモリキャッシュのやり方
  • JOINなどのSQL構文をスラスラ読める&書けるようにする
  • 過去問を解く

特にN+1の解消に関しては、ISUCONでは頻出するパターンのため、JOINして解決するのかIN句で解決するのかキャッシュで回避するのかなど、事前にかなり話し合いました。 それぞれ詳しく勉強したことについては、以下で紹介していきます。

DBのインデックスについて

なぜインデックスの勉強が必要か

インデックス・ショットガンと呼ばれるアンチパターンがあるように、無闇にインデックスを張るとパフォーマンスが悪化することがあります。 特に、INSERTやUPDATEが多いテーブルは、書き込みのオーバーヘッドが大きくなるため、インデックスを張る際には注意が必要です。

MySQLのインデックス

ISUCONでは、DBにMySQLを使用することが多いため、MySQLのインデックスについて勉強しました。

以下の記事は、インデックスの基礎を学ぶのに参考になりました。

  • こちらはInnoDBにおけるインデックスの基礎知識を学べる他、インデックスを張るときのよくある間違いについても解説されています。 techlife.cookpad.com

  • こちらは、MySQLのクエリーライフサイクルやUsing filesort, Using whereが何をしているのかをトランプを例に解説されています。 www.slideshare.net

上記を勉強するとEXPLAINの結果の意味がわかるようになるのと、インデックスを張るときの注意点がわかるようになります。

Go言語

こちらに関しては私は普段から業務でGoを書いているので特段勉強はしませんでした。 ただ、ISUCONではDBを操作する際にsqlxを使うことが多いため、sqlxの使い方については事前に勉強しました。

全てのメソッドは覚えませんでしたが、以下についてはスラスラ書けるようにしました。

  • 1行を取得するときのGet
  if err := db.Get(&user, "SELECT * FROM users WHERE id = ?", id); err != nil {
    return err
  }
  • 複数行を取得するときのSelect
  if err := db.Select(&users, "SELECT * FROM users WHERE age = ?", age); err != nil {
    return err
  }
  • In句などを使うときのIn
  query, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", ids)
  if err != nil {
    return err
  }
  query = db.Rebind(query)
  if err := db.Select(&users, query, args...); err != nil {
    return err
  }
  • Bulk InsertもできるNamedExec
  _, err := db.NamedExec("INSERT INTO users (name, age) VALUES (:name, :age)", users)
  if err != nil {
    return err
  }

N+1の対策

N+1に関しては、大きく分けて以下の解決方法があると考えています。

  • JOINしてN個のクエリを1つにまとめる
  • IN句を使用してN個のクエリを1つにまとめる
  • クエリで取得しているデータをキャッシュする

基本的にJOINをする方が効率的ですが、実装が大変です。また、キャッシュは実装は簡単ですが、書き込みや更新があるデータに関しては注意が必要です。

そこで私たちのチームでは以下の方針で決めていました。

  • 基本的にはJOINを使う方針
  • 書き込みや更新がないデータは、キャッシュで実装する
  • 1対多の関係にあるデータは、IN句を使う
  • 書き込みや更新があるが、ユースケース的に書き込み処理などが少ないデータは、キャッシュで実装する

また、上記以外に、アプリケーション側でデータを絞っているにも関わらず、LIMIT句をつけていない場合は優先してLIMIT句をつけることも重要です。N+1自体の解消にはなりませんが、これによってDB負荷のボトルネックが改善され、点数が伸びることもあります。

オンメモリキャッシュのやり方

N+1の解消などにおいて、オンメモリキャッシュを使う場合は、どのように実装するのかチームで決めていました。 まず、書き込みや更新がないデータに関してはMap型で事前にキャッシュするようにしていました。

ただ、書き込みや更新があるデータに関しては、スレッドセーフなキャッシュを実現する必要があります。 そこで、私たちのチームでは、ISUCON用に開発されたcatatsuy/cacheを使うことにしました。

github.com

当初は、syncのMapを使うことを検討していましたが、以下の理由からcacheを使うことにしました。

  • Genericsを使っているため、型キャストが不要
  • キャッシュの有効期限を簡単に設定できる
  • パフォーマンスもSync.Mapとほぼ変わらない

他にも理由はありましたが、主に上記の理由からcacheを使うことにしました。

JOINなどのSQL構文をスラスラ読める&書けるようにする

N+1の解消において、基本的にはJOINを使う方針となったので、チーム全員がJOINに対して慣れる必要がありました。 また、そもそもISUCONではサブクエリを使った複雑なクエリやORDER BY句を使ったクエリなども出題されるため、SQLをスラスラ読めるようにすることが重要だと考えました。

SQLは以下のネットの問題集を使って勉強しましたが、他にも問題集などはあるため正直なんでもいいと思います。

SQL練習問題 | TECH PROjin

普段ORMを使っていたりすると、意識してSQLを書かなかったりすることもあると思うので、良い勉強になりました。

過去問を解く

過去問に関しては、時間的に全て解くことは難しかったため、直近の問題を何度も解くことにしました。

  • ISUCON13
  • ISUCON12の予選
  • ISUCON11予選
  • private-isu(過去問ではないが)

解説などを見ながら解いたりして、ボトルネックの特定のやり方や、どのようなアプローチで解いているのかを理解しました。 また練習中は、Copilotを切ったりコピペをしないようにしていましたが、これが意外と練習になりました。

まとめ

以上がISUCON14に向けて勉強したことです。 本番どうなるかは分かりませんが、練習してきたことを活かして、全力で取り組みたいと思います。

また、DBなどはISUCONのために勉強しましたが、普段の業務でも使える知識が多かったです。 そのため、ISUCONに活かすだけでなく、普段の業務でも活かせる部分は積極的に取り入れていきたいと思います。