2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HonoAdvent Calendar 2024

Day 14

HonoでChatGPTの構造化出力をストリーミングする技

Posted at

昨今、ChatGPTやその他のLLMを利用してアプリケーションを作成することが多くなってきました。

しかし、これらのLLMにはレスポンスを返すのが遅い、という欠点が共通して存在しています。単純なテキスト補完であれば、ストリーム機能を利用して徐々に表示させていくという方法を取ることができるでしょう。しかし、実際のアプリケーションではOpenAI APIのStructuredOutputを活用して複雑な構造をJSONで生成したり、出力したデータを永続化する必要があり、単純にテキストを返すだけでは済まないことが多いです。

この時、完全なJSONを一括で返そうとすると、ユーザーは処理完了まで長時間待たされてしまいます。

こうした問題を解消する方法として、部分的に受信したJSONの断片を文法的に正しく補完しながら返す関数を併用し、逐次オブジェクトを更新しながら描画する手法を考えました。

クライアントサイドでの体験を改善しつつ、サーバーサイドでも構造化したデータを取り扱うことができ、データベースなどへの永続化がしやすくなります。

百聞は一見に如かず

文章で書かれてもイメージ湧かないし何が嬉しいのかわかりませんね!!
比較のために下をご覧ください。

通常のJSONSchemaのレスポンスだとこのようになるのが、

本手法を採用することで、このようになめらかに表示できます。
とても嬉しいですね。

手法

  1. OpenAIのAPIで、StructuredOutputsから出力されるストリームを受け取る
  2. 適当でいいので、部分的に取得したJSONを補完する関数を用いて半端なJSONをパースできる形にする
  3. パースできるようになったJSONを表示する

実装

以下は実装の例です。

サーバーサイド

HonoのStreamで、OpenAIからのストリームを仲介してあげます。
少ないコード量で書くことができます。とても嬉しいですね。

import { Hono } from "hono";
import { stream } from "hono/streaming";
import OpenAI from "openai";
import { jsonSchema } from "./schemas"; // 中身は付録に追記

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});
app.post("/api/scoring_cool_names", async (c) => {
  const { name } = await c.req.json();
  return await stream(c, async (stream) => {
    const response = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      // ストリームで返すようにする
      stream: true,
      // JSON Schemaで型を指定
      response_format: {
        type: "json_schema",
        json_schema: {
          name: "ScoringCoolNames",
          schema: jsonSchema,
        },
      },
      messages: [
        {
          role: "system",
          content: `入力された名前のかっこよさを測定して、理由と提案を返します。
  また、よりかっこいい名前も同時に提案してください。`,
        },
        {
          role: "user",
          // ユーザーからの入力を渡す
          content: JSON.stringify({
            name: name.slice(0, 80),
          }),
        },
      ],
    });

    let leftover = "";
    for await (const chunk of response) {
      // 候補から、テキストだけ取り出す
      const choice = chunk.choices.at(0);
      if (!choice) {
        continue;
      }
      if (typeof choice.delta.content !== "string") {
        continue;
      }
      // choice.delta.contentにはJSONの断片が入るので、leftoverに追加(最終的に完全な JSONにするため)
      leftover += choice.delta.content;
      // JSONの断片をクライアントにストリームで渡す
      await stream.write(choice.delta.content);
    }
    // ここで完全なJSONが手に入るので、永続化するなりなんなりしてあげよう
    const json = JSON.parse(leftover);
    
  });
});

クライアント

参考実装

import React from "react";
import { Chart } from "./Chart";
import { completeJSONStream } from "../utils/completeJSON";

import type { CoolNaming } from "@guide/types";
// 再起的にPartialを適用する型
// (completeJSONStreamの戻り値は常に断片的なオブジェクトになるため)
type DeepPartial<T> = {
  [P in keyof T]?: DeepPartial<T[P]>;
};
export default () => {
  const [coolNaming, setCoolNaming] = React.useState<DeepPartial<CoolNaming>>();
  const handleSubmit: React.FormEventHandler = async (e) => {
    e.preventDefault();
    const form = e.currentTarget as HTMLFormElement;
    const formData = new FormData(form);
    const name = formData.get("name") as string;
    const res = await fetch("/api/scoring_cool_names", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ name }),
    });
    if (!res.ok || res.status >= 400) {
      throw res;
    }
    if (!res.body) {
      throw new Error("Response body is not readable");
    }
    // Streamを作って
    const reader = res.body.getReader();
    // 受け取るたびにJSONを補完してパース可能にしてくれる関数(実装は付録に追記)
    const it = completeJSONStream(reader) as AsyncGenerator<
      DeepPartial<CoolNaming>
    >;
    for await (const content of it) {
      // 補完してくれたコードはそのままぶちこんで良い
      setCoolNaming(content);
    }
  };
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <h2>名前のかっこよさ測定</h2>
        <input
          type="text"
          name="name"
          placeholder="名前を入れてください"
          defaultValue={"エターナルフォースブリザード"}
        />
        <br />
        <button type="submit">測定</button>
      </form>
      <hr />
      {coolNaming && (
        {/* グラフの描画 */}
        <Chart
          width={512}
          height={512}
          name={coolNaming.name}
          description={coolNaming.description}
          // 完全な形で手に入るわけではないので、完全な形になるように毎回整形
          data={Object.entries(coolNaming.score || {}).map(
            ([key, value]) => ({
              key,
              label: key,
              value: value?.score ?? 0,
              description: value?.reason ?? "",
            })
          )}
        ></Chart>
        )}
    </div>
  );
};

この手法のTips

レスポンスが逐次処理できるようにJSONSchemaを工夫する

いつ完全な形で返ってくるのかわからないため、レスポンスは常に Partial<{}> のような形式になります。
LLMからのレスポンスは、JSONを頭から返してくれるので、終端記号みたいなやつを入れて完成したかどうかを判断してあげると、全て揃ってから表示したい場合に楽です。

// そのオブジェクトの終端を示す記号とか入れとくといい。
// (ここでは 'ok':1が出来上がったタイミングで完成とみなす)
{ type:'a', text:'a', ok: 1}

実際の用途

こちらのウェブサイトでは、部分的に上記のテクニックを採用しています。
よろしければ一度ご覧ください。

最後に

これで、ユーザーを待たせることなく、スムーズに出力を実現できるようになりました。

Honoの最大の強みは、頻繁に利用される機能が十分にテストされた高速なコードとして提供されている点にあると考えています。他の方法でも同様の実現は可能ですが、これほど簡単に実装できたのは、Honoの優れた設計のおかげです。

Hono、本当にありがとう🔥🔥🔥🔥

付録

半端なJSONを補完する関数の実装

jsonrepairなどのライブラリを使ってもいいと思います。
今回は簡単に実装してみました。

// JSONの特殊文字を閉じるための対応関係
const closeSymbols: {
  [key: string]: string;
} = {
  "{": "}",
  "[": "]",
  '"': '"',
};
const jsonSymbols = ["{", "}", "[", "]"] as const;
const JSONSymbolCollector = (target: string) => {
  const collected: string[] = [];
  const strings = [...target]; // サロゲートペアなどを考慮して分割
  for (let i = 0, iz = strings.length; i < iz; i++) {
    // ただし、""内の文字列はカウントしない
    if (strings[i] === '"') {
      collected.push('"');
      let j = i + 1;

      while (j < iz && strings[j] !== '"') {
        // エスケープされた"はスキップ
        if (strings[j] === "\\" && strings[j + 1] === '"') {
          j++;
        }
        j++;
      }
      i = j;
      const closed = strings[j] === '"';
      if (closed) {
        collected.push('"');
      }
      continue;
    }
    for (const symbol of jsonSymbols) {
      if (strings[i] === symbol) {
        collected.push(symbol);
      }
    }
  }
  return collected;
};

const dropClosedItems = (source: string[]): string[] => {
  let target = source.join("");
  const starts = Object.keys(closeSymbols);
  for (let i = 0; i < target.length; i++) {
    if (starts.includes(target[i])) {
      if (target[i + 1] === closeSymbols[target[i]]) {
        target = target.slice(0, i) + target.slice(i + 2);
        i = -1;
      }
    }
  }
  return target.split("");
};
// JSON.parseは遅いので、簡易的な文法チェックを行う
export const fastParseJSON = (json: string) => {
  // 先頭と末尾は一致しているはず
  const first = json[0];
  const last = json[json.length - 1];
  if (!(closeSymbols[first] && closeSymbols[first] === last)) {
    throw new SyntaxError(`Expected property name or '${last}' in JSON`);
  }
  // 個数を数えて一致しているか確認
  const collects = dropClosedItems(JSONSymbolCollector(json));
  if (collects.length > 0) {
    throw new SyntaxError("Unclosed symbol in JSON");
  }
  // オリジナルのJSON.parseで他のエラーが出ないか確認
  return JSON.parse(json);
};
export function tryCompleteJson<T extends any>(
  input: string
): {
  object: T;
  fixed: boolean;
} {
  // 一旦素のままparseできるか試す
  try {
    return {
      object: fastParseJSON(input.trim()),
      fixed: false,
    };
  } catch (e) {
    // 文法エラー以外はそのままthrow
    if (!(e instanceof SyntaxError)) {
      throw e;
    }
  }

  let dest = input.trim();

  // 閉じていないオブジェクトを閉じる
  const collections = JSONSymbolCollector(dest);
  const openedSymbols = dropClosedItems(collections);
  for (let i = openedSymbols.length - 1; i >= 0; i--) {
    dest += closeSymbols[openedSymbols[i]];
  }
  try {
    // OKかどうか最終確認
    // ただし、一部ケース(例: { "test": }) はこれでparse成功しないはず。
    const parsed = fastParseJSON(dest);

    return {
      object: parsed,
      fixed: true,
    };
  } catch (e) {
    // まだダメな場合、特定のケースを検出する

    // 一意な解釈が出来ないケース例:
    // "{ "test": " まではきているが、終端しても何の値か不明な場合、あるいはコロンで終わっている
    // コロンで終わっている場合は、後続の値が不明なため、一意な補完不可
    const trimmed = dest.trim();
    if (trimmed.endsWith(":")) {
      // 何の値を補完していいか分からないのでエラー
      throw new SyntaxError("Expected value in JSON");
    }
    // 上記以外で依然としてparseできない場合は、
    // 不正な書式のため一意な解釈が不可能と判断
    throw new SyntaxError("Unexpected token in JSON");
  }
}

export const completeJSONStream = async function* (
  reader: ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>,
  maxWait = 100
) {
  const decoder = new TextDecoder();
  let leftover = "";
  let lastTime = Date.now();
  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      break;
    }
    if (!value) {
      continue;
    }
    const decoded = decoder.decode(value, { stream: true });
    leftover += decoded;
    const now = Date.now();
    if (now - lastTime < maxWait) {
      continue;
    }
    lastTime = now;

    try {
      yield tryCompleteJson(leftover).object;
    } catch (e) {
      if (e instanceof SyntaxError) {
        continue;
      }
      throw e;
    }
  }
  // 完了したら残りを返す
  yield tryCompleteJson(leftover).object;
};


型定義とJSONSchema

export type DeepPartial<T> = {
  [P in keyof T]?: DeepPartial<T[P]>;
};
export type CoolNameScoreWithReason = {
  score: number;
  reason: string;
};
export type CoolNameScore = {
  phoneticAppeal: CoolNameScoreWithReason;
  visualAesthetics: CoolNameScoreWithReason;
  semanticImpression: CoolNameScoreWithReason;
  uniquenessFactor: CoolNameScoreWithReason;
  culturalRelevance: CoolNameScoreWithReason;
  statusAssociation: CoolNameScoreWithReason;
};
export type CoolNaming = {
  name: string;
  description: string;
  score: CoolNameScore;
  suggestions: {
    name: string;
    description: string;
    score: CoolNameScore;
  }[];
};
// ajvのJSONSchemaTypeを使うと、型安全にJSON Schemaをかけて嬉しい
import type { JSONSchemaType } from "ajv";
const coolNameScoreWithReasonSchema: JSONSchemaType<CoolNameScoreWithReason> = {
  type: "object",
  properties: {
    score: {
      type: "integer",
      description: "この名前のかっこよさのスコア。0~100点",
      minimum: 0,
      maximum: 100,
    },
    reason: {
      type: "string",
      description: "この名前がかっこいい理由。一言で。",
    },
  },
  required: ["score", "reason"],
};
const coolNameScoreSchema: JSONSchemaType<CoolNameScore> = {
  type: "object",
  properties: {
    phoneticAppeal: {
      ...coolNameScoreWithReasonSchema,
      description: "音韻的魅力。0~100点",
    },
    visualAesthetics: {
      ...coolNameScoreWithReasonSchema,
      description: "視覚的デザイン。0~100点",
    },
    semanticImpression: {
      ...coolNameScoreWithReasonSchema,
      description: "意味やイメージ。0~100点",
    },
    uniquenessFactor: {
      ...coolNameScoreWithReasonSchema,
      description: "独自性・珍しさ。0~100点",
    },
    culturalRelevance: {
      ...coolNameScoreWithReasonSchema,
      description: "文化的・時代的コンテキスト。 0~100点",
    },
    statusAssociation: {
      ...coolNameScoreWithReasonSchema,
      description: "社会的ステータス・ブランド. 0~100点",
    },
  },
  required: [
    "phoneticAppeal",
    "visualAesthetics",
    "semanticImpression",
    "uniquenessFactor",
    "culturalRelevance",
    "statusAssociation",
  ],
};
const jsonSchema: JSONSchemaType<CoolNaming> = {
  type: "object",
  properties: {
    name: {
      type: "string",
      description: "かっこいい名前",
    },
    description: {
      type: "string",
      description: "なぜこの名前がかっこいいかの説明。簡単に。",
    },
    score: coolNameScoreSchema,
    suggestions: {
      type: "array",
      description: "もっとかっこいい名前の提案。2~3個ほど",
      items: {
        type: "object",
        properties: {
          name: {
            type: "string",
            description: "かっこいい名前",
          },
          description: {
            type: "string",
            description: "なぜこの名前がかっこいいかの説明。簡単に。",
          },
          score: coolNameScoreSchema,
        },
        required: ["name", "description", "score"],
      },
    },
  },
  additionalProperties: false,
  required: ["name", "description", "score", "suggestions"],
};
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?