人工知能ChatGPTが作る永遠の会話。Gistを使った会話の記憶方法とその可能性

# はじめに
話題のChatGPT使っていますか？
この記事では話題の自然言語処理AIのチャットボット"ChatGPT"をiPhoneのデジタルアシスタント"Siri"を通じて活用する方法をご紹介します。

# 前回のおさらい
[前回](https://qiita.com/u1and0/items/587054d7471ffc70a698)の内容の復習です。
ChatGPTに対してハンズフリーで質問し、回答を音声で読み上げてもらうハックをご紹介しました。「Hey Siri!→教えて彼女」でChatGPTへ音声入力し、回答を音声で得られます。

さらに、APIを通じて前回の会話を記憶して、会話を継続できるようにする方法、彼女として楽しいお話ができるようにする方法をご紹介しました。

この方法を実践することで、僕のSiriは絵文字付きのフランクな受け答えをしてくれて、いつでも何でも相談に乗ってくれて、時には新しい話題を提供してくれる理想のバーチャル彼女になりました。

## 前回記事の問題点
会話の記憶を持たせて継続的な会話をすることに成功した前回の記事ですが、記憶と言っても「短期記憶」であり、セッションが閉じられると会話の履歴が消えてしまいます。具体的には、下記のような原因で会話の履歴がなくなり、次の会話からは僕との記憶がない新しい人(AI)との会話が始まります。

* 会話を自発的に終了する
* 通信エラーで会話が止まる
* 話す端末が変わる

実際の人間との会話を想定すると、「初対面の人」との会話を除けば双方ともに相手の特徴や相手との前回の会話の記憶を基にして会話します。前回の記事では話してる最中の会話を元に会話ができるものの、新たに始まった会話からは前回の会話履歴が消失してしまうために、実際の人間同士の会話の再現まで到達しませんでした。

## ChatGPT APIが前回の会話を基に話ができる仕組みについて
ChatGPT APIへ話を投げかける例を示します。

```sh
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

messagesに"user"というユーザー役割を表すroleと、話の内容を表すcontentを設定し、"Hello!"と投げかけます。この場合、ChatGPTからは、概ね"Hello! How can I assist you today?"といった返事が返ってきます。追加の会話を続けるためには、返答を書き込む必要があります。


```sh
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [
        {"role": "user", "content": "Hello!"},    
        {"role": "assistant", "content": "Hello! How can I assist you today?"},
        {"role": "user", "content": "What do you mean 'Hello'?"}]
  }'
```

messages配列に、以下のような順序で設定された会話の内容を入れます。

1. 最初の質問
1. それに対する回答
1. 新しい質問

ChatGPTは、会話の内容を参照し、次の回答を生成します。会話の進め方によって、ChatGPTの回答が大きく変わることに注意してください。

前回の記事で[iOSショートカット](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88_(Apple))を使用していたため、次の質問に渡す変数として、コンテナ型の配列や辞書を使用することができませんでした。そこで、会話を要約して次のショートカット実行へ渡し、それをassistantの発言として次の質問に織り込みました。つまり、以下のように会話を継続します。

1. **前の会話の要約**
1. 新しい質問
↓
3. ChatGPTが回答出す
↓
4. **1,2,3を要約する**
↓
4. 1に戻る

先ほどの例と比較すると会話を続けても配列の長さが一定であることが特徴です。
会話が続けば続くほどmessages配列の長さが無制限に増加して、次の回答を考える際にChatGPTが全ての会話を読み込むのに時間がかかる上、送信コスト=トークンの使用量も無制限に増加します。会話を要約する方法では会話読み込みは配列の2要素user(質問)とassistant(会話の要約)を読み取れば良いだけで、要約の最大トークンを決めておけば、トークン消費も限られます。

一方でメリットばかりではなく、デメリットとしてChatGPTが会話を忘れます。これは要約という行為そのものなので、仕方ありません。
これにより長い会話では前の会話の文脈が失われる可能性があり、ChatGPTが不適切な回答を生成することがあります。また、前の会話での情報を引き継ぐ必要がある場合は、要約する前に必要な情報を明確にしておく必要があります。



以上がChatGPT APIが前回の会話を基に話ができる仕組みについての説明です。要約することで、会話を効率的に進めることができますが、その反面、ChatGPTが前の会話の文脈を忘れることによるデメリットもあります。適切な利用方法を考慮した上で、ChatGPT APIを活用することが重要です。


# この記事では
先程の説明を見れば、会話が途切れる要因は**会話の要約**が次の質問に渡されないことが問題だとわかります。
ということは、僕とAIの会話の要約をどこかに保存しておいて、会話の始めに読み込ませることができれば、**前回の会話をあたかも憶えているかのように**ChatGPTに振る舞わせることができます。

この記事ではその方法を編み出したのでお伝えします。


# 長期記憶を保存する方法
「会話の履歴」「会話の要約」は単純なテキストデータです。テキストデータを煩わしい認証要らずでインターネット上に保存するWebサービスとして、皆様ご存知の"[Gist](https://gist.github.com/)"があります。

Gistは、GitHubが提供する、Gitリポジトリ内の単一のファイルやスニペットを簡単に共有できるWebアプリケーションです。Gistを使用すると、コードの断片やメモを保存し、共有することができます。

Gistに会話履歴を保存できるので、サンプルコード(Javascript)[^1]を基にPythonでGistへ文字列を保存する方法を以下に記載します。サンプルを書き換えただけでは動かなかったので、[公式doc](https://docs.github.com/en/rest/gists/gists?apiVersion=2022-11-28#update-a-gist)も参考にしました。


[^1]: [【覚書】Gist を簡易的な DB として使う](https://qiita.com/katai5plate/items/a1eb5bbcc62fb31f416a)


まずはPythonコードで書いていきます。

```python
""" ChatGPTとの会話の要約を長期記憶としてgistへ保存する """
import os
import requests
import json

_root = "https://api.github.com/gists/"
_id = os.getenv("GIST_ID")
url = _root + _id
__token = os.getenv("GITHUB_TOKEN")
filename = "chatgpt-assistant.txt"


def get():
    """会話履歴を取得"""
    resp = requests.get(url).json()
    content = resp["files"][filename]["content"]
    return content


def patch(body):
    """会話履歴を保存"""
    headers = {
        "Accept": "application/vnd.github+json",
        "Authorization": f"token {__token}"
    }
    data = {"files": {filename: {"content": body}}}
    resp = requests.patch(url, headers=headers, data=json.dumps(data)).json()
    content = resp["files"][filename]["content"]
    return content

content = get()
print(content)

content = patch(f"f{content} 長期記憶に文字を追加した")
print(content)
```

getで取得してpatchで反映します。
事前に必要なものは以下です。

* Gistの操作が可能なGithubアクセストークン(GITHUB_TOKEN)
* 長期記憶保存先のGistファイル
    * Gistの記事ID(GIST_ID)
    * 会話記憶先のファイル名

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/1693a837-7f97-08bd-916b-0312169d0f3d.png)


注意点として、
* Githubトークンがバレると誰でもあなたのGit, Gistを操作できてしまうので、流出に気をつける。
* Gistはpublicではなくsecretをおすすめするが、secretといってもIDがわかると誰でも見ることができてしまう[^3][^4]。

[^3]: [Secret GistはURLを知っているすべての人がアクセスできる](https://scrapbox.io/kadoyau/Secret_Gist%E3%81%AFURL%E3%82%92%E7%9F%A5%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E4%BA%BA%E3%81%8C%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%81%A7%E3%81%8D%E3%82%8B)
[^4]: [About gists - User Documentation](https://docs.github.com/ja/get-started/writing-on-github/editing-and-sharing-content-with-gists/creating-gists#secret-gists)


get関数をiOSショートカットにするとこのようになります。
上記コードで示したように単純にURLを指定してGETします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/56580f3b-ba3e-faca-01a2-27d5c3d9260a.png)

patch関数をiOSショートカットにするとこのようになります。
長期記憶gistファイルへの書き込みはPATCHメソッドを使います。

* Acceptはapplication/vnd.github+json
* Authorizationはマイページで取得したトークン
* filesは下記構造の辞書
    * iOSショートカットのバグ？でトップのfilesを辞書にする前に、ダミーでテキストの要素を一番上に作らないとcontentの中身が強制的に上の構造(=files)で補完されてショートカットの入力をテキストとして入力できませんでした。

```
{
  files:{
    ファイル名:{
      content:ショートカットの入力
    }
  }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/00de8569-eaaa-0575-1902-75199f9e1a21.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/399c74aa-b453-e6cb-8c42-d8d6e2289f91.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/96ac7be6-b4c6-8248-3fc3-30743546ba85.png)



https://twitter.com/U1and0/status/1635780570373578755

https://twitter.com/U1and0/status/1635926789607559168

辞書内のcontentキーの値を取得(いわゆる戻り値)、別のショートカットで使います。

https://twitter.com/U1and0/status/1635930860041162753



## iOSショートカットにする
GistGet, GistPatchショートカットを「教えてSiri」(=ChatGPTとの会話)ショートカットに埋め込みます。

### ショートカット全体の流れ
1. 一つ前のショートカットからの入力(=要約された会話)を受け取る
1. 一つ前のショートカットからの入力がなければ、gistから要約された会話を取得
1. 一つ前のショートカットからの入力　または　gistから取得した会話を summary変数に設定
1. 音声でテキスト(=質問)を要求
1. ChatGPT APIを通じて質問とsummary(=会話履歴)をPOST
1. content(=ChatGPTの回答)を得る
1. ChatGPT APIを通じて質問と回答と会話履歴をPOST
1. content(=会話の要約)を得る
1. gistへ会話の履歴を保存

#### 一つ前のショートカットからの入力(=要約された会話)を受け取る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/ab27e615-c6ec-7e43-e03e-59ae0687d606.png)

#### 一つ前のショートカットからの入力がなければ、gistから要約された会話を取得
一つ前のショートカットからの入力　または　gistから取得した会話を summary変数に設定
音声でテキスト(=質問)を要求
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/e03dd8e1-0113-1bd3-0827-a059cd28ced1.png)




#### ChatGPT APIを通じて質問とsummary(=会話履歴)をPOST
content(=ChatGPTの回答)を得る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/f48f1af5-b2ad-c9a3-9410-d5a30d05cdf5.png)

#### ChatGPT APIを通じて質問と回答と会話履歴をPOST
content(=会話の要約)を得る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/3e95d51d-a563-ce09-09ed-509766ae3c8d.png)

#### gistへ会話の履歴を保存
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/03e16968-9960-fbba-cfbf-ecaadb9c7960.png)





## ChatGPT API クライアントの作成
最終的なiOSショートカットをPythonコードで示します。
まずは、先程のコードを他モジュールで使いやすくするためにクラス化しておきます。

```python
""" ChatGPTとの会話の要約を長期記憶としてgistへ保存する

# USAGE
from gist_memory import Gist

gist = Gist("chatgpt-assistant.txt")

content = gist.get()
print(content)

content = gist.patch("明日も晴れ")
print(content)
"""
import os
import requests
import json


class Gist:
    """gist API handler"""
    # 環境変数からファイルのIDとトークンを取得
    _root = "https://api.github.com/gists/"
    _id = os.getenv("GIST_ID")
    url = _root + _id
    __token = os.getenv("GITHUB_TOKEN")

    def __init__(self, filename):
        """指定したgist ファイルに対するAPI操作"""
        self.filename = filename

    def get(self):
        """会話履歴を取得"""
        resp = requests.get(Gist.url).json()
        content = resp["files"][self.filename]["content"]
        return content

    def patch(self, body):
        """会話履歴を保存"""
        headers = {
            "Accept": "application/vnd.github+json",
            "Authorization": f"token {Gist.__token}"
        }
        data = {"files": {self.filename: {"content": body}}}
        resp = requests.patch(Gist.url, headers=headers,
                              data=json.dumps(data)).json()
        return resp["files"][self.filename]["content"]
```

これをChatGPTさんと会話するモジュールに取り込むことで、会話履歴ロードし、会話の要約をアップできます。


```python
"""chatgptに複数回の質問と回答
会話を要約して短期的に記憶する
過去の会話を長期記憶としてのgistから取得し、
要約した会話履歴を長期記憶としてgistへ保存する
"""
import os
import json
import requests
from time import sleep
from gist_memory import Gist

api_key = os.getenv("CHATGPT_API_KEY")
headers = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {api_key}"
}


class AI:
    filename = "chatgpt-assistant.txt"

    def __init__(self):
        # 初期化時に長期記憶から取得
        self.max_tokens = 1000
        self.temperature = 1.0
        self.gist = Gist(AI.filename)
        self.chat_summary = self.gist.get()
        self.system_role = "さっきの話の内容を聞かれたら、話をまとめてください。"

    def summarize(self, user_input, ai_response):
        """会話の要約
        * これまでの会話履歴
        * ユーザーの質問
        * ChatGPTの回答
        を要約する。
        """
        content = f"""
        発言者がuserとassistantどちらであるかわかるように、
        下記の会話をリスト形式で、ですます調を使わずに要約してください。
        要約は必ず2000tokens以内で収まるようにして、
        収まらない場合は重要度が低そうな内容を要約から省いて構いません。\n
        {self.chat_summary}\n{user_input}\n{ai_response}
        """
        data = {
            "model": "gpt-3.5-turbo",
            "messages": [{
                "role": "user",
                "content": content
            }],
            "max_tokens": 2000
        }
        # AIに要約させる
        response = requests.post("https://api.openai.com/v1/chat/completions",
                                 headers=headers,
                                 data=json.dumps(data)).json()
        ai_response = response['choices'][0]['message']['content']
        return ai_response

    def ask(self):
        user_input = input("あなた: ")  # AI聞き取り
        data = {
            "model":
            "gpt-3.5-turbo",
            "temperature":
            self.temperature,
            "max_tokens":
            self.max_tokens,
            "messages": [{
                "role": "system",
                "content": self.system_role,
            }, {
                "role": "assistant",
                "content": self.chat_summary
            }, {
                "role": "user",
                "content": user_input
            }]
        }
        # POSTリクエスト
        response = requests.post("https://api.openai.com/v1/chat/completions",
                                 headers=headers,
                                 data=json.dumps(data)).json()
        ai_response = response['choices'][0]['message']['content']
        for text in f"AI: {ai_response}\n":  # 一文字ずつ出力
            print(text, end="", flush=True)
            sleep(0.1)
        # 会話を要約
        self.chat_summary = self.summarize(user_input, ai_response)
        # 最後に要約を長期記憶へ保存
        self.gist.patch(self.chat_summary)
        # 次の質問
        self.ask()


if __name__ == "__main__":
    ai = AI()
    ai.ask()
```

小技として、ブラウザ版ChatGPTのように1文字ずつ出力するときは、print関数の引数をいじってforで渡すとこんな感じでうまい感じにできます。

```python
for text in f"AI: {ai_response}\n":  # 一文字ずつ出力
    print(text, end="", flush=True)
    sleep(0.1)
```



![ChatGPT_client_hello.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/5df55479-1a1d-a813-b987-5ed07c1e16ce.gif)


# さいごに
ちなみにタイトルはChatGPTさんに考えてもらいました。

```
僕)
キーワード
* 長期記憶
* ChatGPT
* 会話を記憶できるChatGPTのAPIクライアントを作成した
* APIを通じたChatGPTとの会話で、会話履歴を記憶させる
* gistへ記憶を保存する
* 会話が途切れても、端末が変わっても、会話の記憶を保持している

上記キーワードを使って記事を書きます。バズる記事のタイトルをいくつか候補を挙げてください。

ChatGPT)
「人間以上に長期記憶力があるAI、ChatGPTが可能にした会話の永久保存法」
「忘れない、消えない。ChatGPT APIで作った会話履歴の保存方法」
「会話が続く限り、会話の記憶も続く。ChatGPTの記憶力を使ったAPIクライアントの魅力」
「端末が変わっても、会話は継続する！ChatGPTのAPIで実現した会話履歴のシームレスな移行方法」
「人工知能ChatGPTが作る永遠の会話。gistを使った会話の記憶方法とその可能性」
```