3
4
Qiita×Findy記事投稿キャンペーン 「自分のエンジニアとしてのキャリアを振り返ろう!」

ChatGPTのチャットの返答をfetchでStream受信しようとしたらSyntaxError: Unterminated string in JSONが出たことと解決法

Posted at

エラーに遭遇した状況

openAIのchatGPTにAPIで質問を投げて、取得した回答を表示するということをしようと思いました。
chatのapiにはstreamというオプションがあります。これを使うと回答全体の構築を待たずに先頭から流してくれるようになります。
以下の記事を参考にこれを試してみようと思いました。

202403011716.gif

やったこと

まずフロントのページを用意しました。
スクリーンショット 2024-03-01 160213.png

送信ボタンを押すと、ローカルのExpressサーバに質問を送り、サーバからopenAIのchatAPIにリクエストします。openAIのモジュールを使うとちょっと簡単にできるようなのですが、streamAPIの勉強も兼ねてあえてfetchでリクエストすることにしました。

上の記事を参考にして以下のようにpostのendpointを作成しました。

const chatCompletion_model = "gpt-3.5-turbo-0125";
const chatCompletion_url = "https://api.openai.com/v1/chat/completions";

router.post("/chat/", async (req, res) => {
  const question = req.body["question"];
  const completion = await fetch(chatCompletion_url, {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${api_key}`,
    },
    method: "POST",
    body: JSON.stringify({
      messages: [{ role: "user", content: question }],
      model: chatCompletion_model,
      stream: true,
    }),
  });

  const reader = completion.body?.getReader();

  const decoder = new TextDecoder("utf-8");
  try {
    // この read で再起的にメッセージを待機して取得します
    const read = async () => {
      const { done, value } = await reader.read();
      if (done) return reader.releaseLock();

      const chunk = decoder.decode(value, { stream: true });
      // この chunk には以下のようなデータ格納されている。複数格納されることもある。
      // data: { ... }
      // これは Event stream format と呼ばれる形式
      // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
      console.log(chunk);

      const jsons = chunk
        // 複数格納されていることもあるため split する
        .split("data:")
        // data を json parse する
        // [DONE] は最後の行にくる
        .map((data) => {
          const trimData = data.trim();
          if (trimData === "") return undefined;
          if (trimData === "[DONE]") return undefined;
          return JSON.parse(data.trim());
        })
        .filter((data) => data);

      // jsonsから回答部分を抽出してstreamでresに返す
      jsons.forEach((json) => {
        const delta = json.choices[0].delta.content;
        if (!delta) return;
        res.write(delta);
      });
      return read();
    };
    await read();
  } catch (e) {
    console.error(e);
  }
  // ReadableStream を最後は解放する
  reader.releaseLock();

  res.end();
});

で、「こんにちは」というメッセージを送ると、SyntaxError: Unterminated string in JSON at position 157というエラーに遭遇しました。

原因と解決法

このエラーはJSON.parse()で生じており、stringをJSONとして解釈しようとしたときに解釈不能だったということを表しています。
で、実際にstringを見てみると、
data: {"id":"hoge","object":"chat.completion.chunk","created":0000,"model":"gpt-3.5-turbo-0125","system_fingerprint":"ho
みたいな感じで、jsonが途中で途切れていることが分かりました。

上の記事のコメントでは複数のdataが同時に流れてくる可能性については触れられていますが、このように不完全なデータが流れてくることは想定されていません。ちなみに次のchunkはこの続きのjsonを含んでおり、合わせると完全なjsonを構成できました。つまり、不完全なdataのときは次のdataを待ち、結合させて完全なデータになったらparseのステップに進むということをすればよいでしょう。

そのように変更しました。

router.post("/chat/", async (req, res) => {
  const question = req.body["question"];
  const completion = await fetch(chatCompletion_url, {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${api_key}`,
    },
    method: "POST",
    body: JSON.stringify({
      messages: [{ role: "user", content: question }],
      model: chatCompletion_model,
      stream: true,
    }),
  });

  const reader = completion.body?.getReader();

  const decoder = new TextDecoder("utf-8");
  try {
    // この read で再起的にメッセージを待機して取得します
    const read = async () => {
      var chunk = "";
      var finished = false;

      while (true) {
        const { done, value } = await reader.read();
        // 読み込んだデータをchunkにつなげる
        chunk += decoder.decode(value, { stream: true });

        if (done) {
          finished = true;
          break;
        }
        // jsonの切れ目の最後の文字は必ず改行コードになる。そうなってないなら次のデータも読み込む
        if (chunk.endsWith("\n")) break;
      }
      // この chunk には以下のようなデータ格納されている。複数格納されることもある。
      // data: { ... }
      // これは Event stream format と呼ばれる形式
      // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
      console.log(chunk);

      const jsons = chunk
        // 複数格納されていることもあるため split する
        .split("data:")
        // data を json parse する
        // [DONE] は最後の行にくる
        .map((data) => {
          const trimData = data.trim();
          if (trimData === "") return undefined;
          if (trimData === "[DONE]") return undefined;
          return JSON.parse(data.trim());
        })
        .filter((data) => data);

      // jsonsから回答部分を抽出してstreamでresに返す
      jsons.forEach((json) => {
        const delta = json.choices[0].delta.content;
        if (!delta) return;
        res.write(delta);
      });

      if (finished) return reader.releaseLock();
      return read();
    };
    await read();
  } catch (e) {
    console.error(e);
  }
  // ReadableStream を最後は解放する
  reader.releaseLock();

  res.end();
});

こうすると...
20240301.gif
ちゃんと動きました!!

コード

フロントページ

<!DOCTYPE html>

<body>
	<form id="form">
		<textarea cols="80" rows="4" id="question"></textarea>
		<input type="submit" value="送信">
	</form>
	<div>
		<textarea cols="80" rows="4" readonly=true id="answer"></textarea>
	</div>



	<script src="/js/sample.js"></script>
</body>

このページのjs

post_url = "http://localhost:8080/post_question/chat";

document.getElementById("form").addEventListener("submit", (event) => {
  event.preventDefault();
  const question = document.getElementById("question").value;

  const body = { question: question };
  document.getElementById("answer").value = "";

  fetch(post_url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
    .then((response) => {
      if (!response.ok) {
        console.error("response.ok:", response.ok);
        console.error("esponse.status:", response.status);
        console.error("esponse.statusText:", response.statusText);
        throw new Error(response.statusText);
      }
      const reader = response.body.getReader();
      const decoder = new TextDecoder("utf-8");
      try {
        const read = async () => {
          const { done, value } = await reader.read();
          const chunk = decoder.decode(value, { stream: true });
          console.log(chunk);

          document.getElementById("answer").value += chunk;

          if (done) return reader.releaseLock();
          return read();
        };
        read();
      } catch (e) {
        console.error(e);
      }
    })
    .catch((error) => {
      console.error(error);
    });
});

expressによるローカルサーバー

import express from "express";
import path from "path";
import bodyParser from "body-parser";
import { fileURLToPath } from "url";

const app = express();
const router = express.Router();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

router.use(bodyParser.urlencoded({ extended: true }));
router.use(bodyParser.json());

app.use("/post_question/", router);

app.listen(8080, () => {
  console.log("Running at Port 8080...");
});

const api_key = "hoge"; // openaiのapi_keyを貼る
const chatCompletion_model = "gpt-3.5-turbo-0125";
const chatCompletion_url = "https://api.openai.com/v1/chat/completions";

router.post("/chat/", async (req, res) => {
  const question = req.body["question"];
  const completion = await fetch(chatCompletion_url, {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${api_key}`,
    },
    method: "POST",
    body: JSON.stringify({
      messages: [{ role: "user", content: question }],
      model: chatCompletion_model,
      stream: true,
    }),
  });

  const reader = completion.body?.getReader();

  const decoder = new TextDecoder("utf-8");
  try {
    // この read で再起的にメッセージを待機して取得します
    const read = async () => {
      var chunk = "";
      var finished = false;

      while (true) {
        const { done, value } = await reader.read();
        // 読み込んだデータをchunkにつなげる
        chunk += decoder.decode(value, { stream: true });

        if (done) {
          finished = true;
          break;
        }
        // jsonの切れ目の最後の文字は必ず改行コードになる。そうなってないなら次のデータも読み込む
        if (chunk.endsWith("\n")) break;
      }
      // この chunk には以下のようなデータ格納されている。複数格納されることもある。
      // data: { ... }
      // これは Event stream format と呼ばれる形式
      // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
      console.log(chunk);

      const jsons = chunk
        // 複数格納されていることもあるため split する
        .split("data:")
        // data を json parse する
        // [DONE] は最後の行にくる
        .map((data) => {
          const trimData = data.trim();
          if (trimData === "") return undefined;
          if (trimData === "[DONE]") return undefined;
          return JSON.parse(data.trim());
        })
        .filter((data) => data);

      // jsonsから回答部分を抽出してstreamでresに返す
      jsons.forEach((json) => {
        const delta = json.choices[0].delta.content;
        if (!delta) return;
        res.write(delta);
      });

      if (finished) return reader.releaseLock();
      return read();
    };
    await read();
  } catch (e) {
    console.error(e);
  }
  // ReadableStream を最後は解放する
  reader.releaseLock();

  res.end();
});

app.use(express.static(path.join(__dirname, "public")));

最後に

もとにした記事をちょっと変えただけで動きました。元記事の著者のhimanushi、ありがとうございました。

初心者なので変なコードを書いてるかもしれません。そういう点はぜひ教えていただきたいです。質問なども受け付けています。

3
4
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
3
4