㊙️

OpenAIのAPIとの結合テストをaxiosのinterceptorを活用していい感じにする

2023/05/28に公開

背景あるいは宣伝

現在、私は個人開発でGPT-4を使用した水平思考クイズアプリを作成中です。現在、Beta版が公開されています。

https://iesona.com/

今回はその開発で直面した問題とその解決策について書きます。

TL;DR

OpenAIのAPIとの結合テスト時にaxiosのinterceptorを利用してリクエストをキャッシュし、CIの際にモックとして使用することで開発体験を向上させました。

技術スタック

  • Next.js(App Routerには移行できていない)
  • ほぼほぼT3 Stack
  • OpenAPIへの接続にはopenaiパッケージ(以後、openai)を利用

やりたいこと

OpenAIのAPI(以後、OpenAI)とアプリケーションロジックの結合テストを書きたいと考えています。
具体的なモチベーションとしては、「ユーザーからの入力を基に既存のプロンプトと組み合わせてAPIに問い合わせる」というユースケースの実装について、以下の要求がありました。

  • Web画面を経由せずに実装・検証したい
  • CIでロジックのリグレッションを検知したい

問題

しかしながら、OpenAIとの繋ぎこみの結合テストは以下の3つの問題点から運用の難易度が高いと感じています。

  • APIのレスポンスが遅い/不安定
  • リクエストごとに料金が発生する
  • 毎回同じ結果が返ってこない(temperatureを0にしていない場合)

かといって、これらの問題を避けるためにOpenAIをモックにするとアプリケーションがOpenAIと適切に連携して動作しているか検証することができません。

一般的には、このような問題に対処するために結合テストではなく、OpenAIとアプリケーションロジックを個々にテストするというアプローチを取ると思います。しかし、それではテスト対象の境界が増えてコストが上がってしまい個人開発でやってる身としては少々重いため、他のアプローチを模索しました。

解決策

OpenAIのレスポンスをキャッシュして再利用可能にする機構を作り、そのキャッシュをファイルとして保存し(保存したファイルは.gitignoreしないでリポジトリにコミットします)、以下の2つのモードを切り替えることでCIとローカル開発での両方のニーズを結合テストだけで満たすようにしました。

  • 試行錯誤が必要な場面では、実際にOpenAIを叩き、アプリケーションと結合させる。
  • CIなどでリグレッションを検知したい場合は、事前に保存したキャッシュをモックとして返させてロジックだけを検証する。

実装方針

axiosにはもともとリクエスト・レスポンスにinterceptorを追加する機能が備わっています。
openaiはクライアントをnewする際にaxiosのインスタンスを受け取るため、そのインスタンスにinterceptorを適用することでOpenAIへのリクエスト・レスポンスをインターセプトできます。

今回はこの機能を使って実装していきます。

実装

具体的な実装は以下の通りです。
(ちなみに、このライブラリの名前をAxios Cache Interceptor for OpenAI Testingで、略してaciotと名付けました。)

export type CacheMode = "cacheOnly" | "requestIfNoCacheHit";

export class AciotCore {
	private readonly cache: AxiosResponseCache;
	constructor(config: {
		namespace: string;
		cacheBasePath: string;
	}) {
		this.cache = new AxiosResponseCache(
			FsCache({
				basePath: config.cacheBasePath,
				ns: config.namespace,
			}),
		);
	}
	public applyInterceptors(axios: AxiosInstance, cacheMode: CacheMode) {
		const useCache = async (rejected: unknown) => {
			if (rejected instanceof ShouldUseCacheError) {
				return {
					data: rejected.cache,
				};
			}
			throw rejected;
		};
        switch (cacheMode) {
            case "cacheOnly":
                axios.interceptors.request.use(async (config) => {
                    const cache = await this.cache.get(config);
                    if (cache) {
                        throw new ShouldUseCacheError(cache);
                    }
                    throw new Error("Cache not found");
                });
                axios.interceptors.response.use(() => {}, useCache);
                break;
        
            case "requestIfNoCacheHit":
                axios.interceptors.request.use(async (config) => {
                    const cache = await this.cache.get(config);
                    if (cache) {
                        throw new ShouldUseCacheError(cache);
                    }
                    return config;
                });
                axios.interceptors.response.use((res) => {
                    if (res.data) {
                        this.cache.set(res.config, res.data);
                    }
                    return res;
                }, useCache);
        }
	}
}

ここでは、axiosのinterceptorを使ってリクエストを事前にハンドリングしています。
requestIfNoCacheHitモード(ローカル開発で使用)の場合、デフォルトではリクエスト時には何もせず、レスポンスをキャッシュに保存します。キャッシュがヒットした場合はキャッシュを返します。
cacheOnlyモード(CIで使用)の場合、キャッシュヒットした場合はキャッシュを返し、キャッシュがヒットしない場合はエラーを投げます。

OpenAI及びアプリケーションへの適用方法

先述した通り、openaiクライアントはaxiosのインスタンスを受け取るため、このインスタンスにinterceptorを適用します。あとはopenaiクライアントのインスタンスを実行時注入(本番時にこのキャッシュの機構は使わないため)できるようにしておくだけでほとんど実装を変えずにこの機構を導入できました。

openaiForTest.ts

const axios = Axios.create();
const aciot = new AciotCore({
    namespace: /* テストの名前空間 */   ,
    cacheBasePath: /* キャッシュするディレクトリ */
});

aciot.applyInterceptors(axios, process.env.CI ? "cacheOnly" : "requestIfNoCacheHit");

return new OpenAIApi(
    new Configuration({
        apiKey: process.env.OPENAI_API_KEY,
    }),
    undefined,
    axios,
);

キャッシュの仕組みは、リクエストのURLとbodyをJSONシリアライズしたものをキーとして、レスポンスのbodyをファイルとして保存します。以下、実装を簡易的にしたものです。

class AxiosResponseCache {
	constructor(private readonly cache: FileSystemCache) {}
	private static configToKey(config: AxiosRequestConfig): string {
		return JSON.stringify({
			url: config.url,
			body: config.data,
		});
	}
	public get(configForKey: AxiosRequestConfig): Promise<unknown> {
		const key = AxiosResponseCache.configToKey(configForKey);
		return this.cache.get(key);
	}
	public async set(configForKey: AxiosRequestConfig, value: unknown) {
		const key = AxiosResponseCache.configToKey(configForKey);
		await this.cache.set(key, value);
	}
}

実際に使っているところ

細かい点は https://github.com/eatski/yesonor を確認ください。

問題点

今見えている問題点は以下です。

  • embeddings APIの取得結果が数十kbになってしまうため、いつかリポジトリが重くなりすぎて破綻しそう
  • キャッシュファイルが溜まっていくので使われていないファイルを掃除する仕組みが必要

Q&A

Q. OpenAI以外にもこの解決策は適用できる?

A. できると思います。ただし、「本番のAPIを使って試行錯誤したい」ケースがあまりない(大抵の場合、ドキュメントを読んで一度試せば何が返ってくるかわかる)ため、このケースは特にニーズとしてマッチしていたと言えます。

Q. axiosのinterceptorではなく、mswを使っても同様のことができる?

A. おそらく可能ですが、mswでリクエストをpassthroughしつつ、そのレスポンスを参照することが現状できませんでした。そのためmswのハンドラー内でハンドリングしたリクエストを再現する必要があり、それが難しくて断念しています。

Discussion