nikkie-ftnextの日記

イベントレポートや読書メモを発信

openai-pythonライブラリで得たChat Completionのレスポンス全体をprintすると日本語が読めない事象の原因と対策

はじめに

公式さんもアドカレ参戦♪ 『かがみの孤城』円盤発売まであと7️⃣日 nikkieです。

先日のFunction callingのエントリで積んだ宿題に取り組みました。

目次

Function callingのエントリの宿題

「Function calling」を完全に理解しました。

この中で1つ宿題がありました。

レスポンスの日本語はencodeされていてそのままでは読めない出力になっているのが宿題事項です。

以下のレスポンスのcontentの部分ですね。
"\u3053"のように、Unicodeコードポイントでの表示です。
printすれば「こ」だと分かりますが、そのままでは私は読めません。

{
  "id": "chatcmpl-7SlBqmUItQTBDOQL18bwqZTT8f03z",
  "object": "chat.completion",
  "created": 1687088938,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "\u3053\u3093\u306b\u3061\u306f\uff01\u79c1\u306f\u540d\u524d\u3092\u6301\u3063\u3066\u3044\u307e\u305b\u3093\u3002\u79c1\u306fAI\u30a2\u30b7\u30b9\u30bf\u30f3\u30c8\u3067\u3059\u3002\u3069\u306e\u3088\u3046\u306b\u304a\u624b\u4f1d\u3044\u3067\u304d\u307e\u3059\u304b\uff1f"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 113,
    "completion_tokens": 40,
    "total_tokens": 153
  }
}

こちらについてこのエントリで現時点の解答をアウトプットします。

動作環境は先日のエントリ同様、以下です。

なぜコードポイントで表示される?

ステップ・バイ・ステップでみていきましょう。

APIからのレスポンスの型はOpenAIObjectです。
https://github.com/openai/openai-python/blob/v0.27.8/openai/openai_object.py

>>> import openai
>>> response = openai.ChatCompletion.create(model="gpt-3.5-turbo", messages=[{"role": "user", "content": "こんにちは。あなたの名前は?"}], temperature=0)
>>> type(response)
<class 'openai.openai_object.OpenAIObject'>

対話モードでresponseが指すOpenAIObjectの具体値を見ましょう。

>>> response
<OpenAIObject chat.completion id=chatcmpl-7Tqiupac9RL8tBIDb8EVjlpwWtTYE at 0x102c6d530> JSON: {
  "id": "chatcmpl-7Tqiupac9RL8tBIDb8EVjlpwWtTYE",
  "object": "chat.completion",
  "created": 1687348536,
  "model": "gpt-3.5-turbo-0301",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "\u3053\u3093\u306b\u3061\u306f\u3002\u79c1\u306fAI\u30a2\u30b7\u30b9\u30bf\u30f3\u30c8\u3067\u3059\u3002\u540d\u524d\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u4f55\u304b\u304a\u624b\u4f1d\u3044\u3067\u304d\u307e\u3059\u304b\uff1f"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 18,
    "completion_tokens": 32,
    "total_tokens": 50
  }
}

contentの値はコードポイントですね。

このとき、内部では特殊メソッド1__repr__が呼ばれています。
https://docs.python.org/ja/3/reference/datamodel.html#object.__repr__

OpenAIObject__repr__の実装は以下です。
https://github.com/openai/openai-python/blob/v0.27.8/openai/openai_object.py#L261-L277

__repr__を一部抜粋すると

        unicode_repr = "<%s at %s> JSON: %s" % (
            " ".join(ident_parts),
            hex(id(self)),
            str(self),
        )

        return unicode_repr

str関数の呼び出しがあり、これは内部で__str__メソッドを呼びます。
https://docs.python.org/ja/3/reference/datamodel.html#object.__str__

OpenAIObject__str__の実装は以下です。
https://github.com/openai/openai-python/blob/v0.27.8/openai/openai_object.py#L279-L281

    def __str__(self):
        obj = self.to_dict_recursive()
        return json.dumps(obj, indent=2)

OpenAIObjectインスタンス自身を再帰的に辞書に変換し、json.dumpsでフォーマットしたJSONに変換しているのですね!
https://docs.python.org/ja/3/library/json.html#json.dumps

(略) obj を JSON 形式の str オブジェクトに直列化します。

「なぜコードポイントで表示される?」の答えは__repr__で(__str__を経由して)json.dumpsを呼んでいるからです。

寄り道:OpenAIObjectはどう作られる?

openai.ChatCompletion.createOpenAIObjectが返されます。
https://github.com/openai/openai-python/blob/v0.27.8/openai/api_resources/chat_completion.py#L12-L30

親クラスEngineAPIResourcecreateメソッドが呼ばれています。
https://github.com/openai/openai-python/blob/v0.27.8/openai/api_resources/abstract/engine_api_resource.py#L127-L190

親クラスのcreateではいろいろやっていますが、util.convert_to_openai_objectOpenAIObjectが返されます。
https://github.com/openai/openai-python/blob/v0.27.8/openai/util.py#L101-L147

再帰OpenAIObjectを作っているという理解です。

>>> type(response["choices"][0])
<class 'openai.openai_object.OpenAIObject'>

コードポイントの代わりに、人が読める日本語の文字で表示するには

json.dumpsensure_ascii=Falseを渡します。
https://docs.python.org/ja/3/library/json.html#json.dump

ensure_ascii が (デフォルト値の) true の場合、出力では入力された全ての非 ASCII 文字はエスケープされていることが保証されています。ensure_ascii が false の場合、これらの文字はそのまま出力されます。

ライブラリの実装では、非ASCII文字がエスケープされ(てコードポイントになり)ます。
なので、非ASCII文字がコードポイントにならないように指定するわけです。

>>> import json
>>> print(json.dumps(response.to_dict_recursive(), indent=2, ensure_ascii=False))
{
  "id": "chatcmpl-7Tqiupac9RL8tBIDb8EVjlpwWtTYE",
  "object": "chat.completion",
  "created": 1687348536,
  "model": "gpt-3.5-turbo-0301",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは。私はAIアシスタントです。名前はありません。何かお手伝いできますか?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 18,
    "completion_tokens": 32,
    "total_tokens": 50
  }
}

contentが日本語になりました!🙌

終わりに

Chat Completionエンドポイントのレスポンス全体をprintしたとき、日本語がコードポイントになる事象がなぜ起こるか、どう対処するかを見てきました。
今の私が考えている解決策は、json.dumpsの呼び出しでensure_ascii=False指定です。
プルリクチャンス!

P.S. Python__repr____str__

今回は言語リファレンスのデータモデルを参照しましたが、もう少し平易な解説が『Effective Python 第2版』にあります。

項目75 出力のデバッグにはrepr文字列を使う