« ^ »

OpenAI APIを使う

所要時間: 約 21分

最近、たくさんの人がChatGPTをはじめとした、生成系AIのSaaSを利用している。しかしその機能は多岐に渡り、また使い方も様々だ。例えばChatGPTを使用する場合、素直にWeb UIを使用する方法がある。他にもOpenAI APIを利用する方法や、Webkitを用いてWeb UI全体を組み込むことも検討できるだろう。今回はOpenAI APIを使って、ChatGPTの機能を試す事にする。

実は以前、EmacsのELIZA実装であるdoctorをChatGPTに対応させた。あれからまだ1年も経っていないのだが、それに実装していたAPIはLegacy扱いとなり、新しいAPIが提供されていた。doctorの拡張を実装する際はdoctorに焦点を当てていたので、データの永続化や会話の継続性についてちゃんと考えていなかった。今回は新しいAPIの使い方に焦点を当て、また会話を継続する事やデータの永続化についても考えてみたい。

お金を払う準備をする

今、OpenAI APIは有料だ。登録した数ヶ月間だけ無料のクレジットを与えてくれるので、試したいだけであればその範囲内で使うのが良いだろう。私は期限が切れたので課金ユーザーになってしまった。有料で使用する場合、支払い方法としてクレジットカードを登録する必要があるので、クレジットカードを持っていないと詰む。

https://openai.com/pricing

モデルContextInput (per 1K tokens)Output (per 1K tokens)
GPT-48K$0.03$0.06
GPT-432K$0.06$0.12
GPT-3.5 Turbo4K$0.0015$0.002
GPT-3.5 Turbo16K$0.003$0.004

APIキーを発行する

APIの呼び出しには、HTTPヘッダのAuthorizationヘッダにBearerトークンとしてAPIキーを付けて送信する必要がある。そのAPIキーはOpenAIの管理画面から発行する事ができる1

Authorization: Bearer :API_KEY
HTTPヘッダのAuthorizationヘッダにBearerトークンとしてAPIキーを指定する例

質問する

ChatGPTの機能はCreate chat completion APIで使用できる2。入力になる文字列を含むJSONを送信すると、それに対する回答の文字列を含むJSONを返す。

実際にリクエストを送信してみよう。AIに「こんにちわ」を送信すると、その答えを返してくる。

:ORIGIN := openapi-api-origin
:API_TOKEN := openapi-api-key

POST :ORIGIN/v1/chat/completions
Content-Type: application/json
Authorization: Bearer :API_TOKEN

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "user",
      "content": "こんにちわ"
    }
  ]
}
{
  "id": "chatcmpl-7k2xt9c4d1LjllzPDSbL6rjuG1qEN",
  "object": "chat.completion",
  "created": 1691208841,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは!お元気ですか?何かお手伝いできることはありますか?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 10,
    "completion_tokens": 25,
    "total_tokens": 35
  }
}

// POST https://api.openai.com/v1/chat/completions
// HTTP/1.1 200 OK
// Date: Sat, 05 Aug 2023 04:14:02 GMT
// Content-Type: application/json
// Transfer-Encoding: chunked
// Connection: keep-alive
// access-control-allow-origin: *
// Cache-Control: no-cache, must-revalidate
// openai-model: gpt-3.5-turbo-0613
// openai-organization: user-XXXXXXXXXXXXXXXXXXXX
// openai-processing-ms: 923
// openai-version: 2020-10-01
// strict-transport-security: max-age=15724800; includeSubDomains
// x-ratelimit-limit-requests: 3500
// x-ratelimit-limit-tokens: 90000
// x-ratelimit-remaining-requests: 3499
// x-ratelimit-remaining-tokens: 89979
// x-ratelimit-reset-requests: 17ms
// x-ratelimit-reset-tokens: 14ms
// x-request-id: f6ffabf8c8d10a981c04f8f962bf2c94
// CF-Cache-Status: DYNAMIC
// Server: cloudflare
// CF-RAY: 7f1c35f82ef9e090-NRT
// alt-svc: h3=":443"; ma=86400
// Request duration: 1.640691s
「こんにちわ」という文字列を送信する例

送信する内容を挨拶ではなく、質問にするとその回答を返してくる。

:ORIGIN := openapi-api-origin
:API_TOKEN := openapi-api-key

POST :ORIGIN/v1/chat/completions
Content-Type: application/json
Authorization: Bearer :API_TOKEN

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "user",
      "content": "あなたは誰ですか?"
    }
  ]
}
{
  "id": "chatcmpl-7k31vw1eucJQJzPVcRt5SU9NkSIqI",
  "object": "chat.completion",
  "created": 1691209091,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "私はArtificial Intelligence搭載のAIで、OpenAIが提供するチャットボットです。どのようにお手伝いできますか?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 16,
    "completion_tokens": 44,
    "total_tokens": 60
  }
}

// POST https://api.openai.com/v1/chat/completions
// HTTP/1.1 200 OK
// Date: Sat, 05 Aug 2023 04:18:12 GMT
// Content-Type: application/json
// Transfer-Encoding: chunked
// Connection: keep-alive
// access-control-allow-origin: *
// Cache-Control: no-cache, must-revalidate
// openai-model: gpt-3.5-turbo-0613
// openai-organization: user-XXXXXXXXXXXXXXXXXXXX
// openai-processing-ms: 1536
// openai-version: 2020-10-01
// strict-transport-security: max-age=15724800; includeSubDomains
// x-ratelimit-limit-requests: 3500
// x-ratelimit-limit-tokens: 90000
// x-ratelimit-remaining-requests: 3499
// x-ratelimit-remaining-tokens: 89976
// x-ratelimit-reset-requests: 17ms
// x-ratelimit-reset-tokens: 16ms
// x-request-id: b32164d71f9a92428937690fd51abcc3
// CF-Cache-Status: DYNAMIC
// Server: cloudflare
// CF-RAY: 7f1c3c13d858e090-NRT
// alt-svc: h3=":443"; ma=86400
// Request duration: 2.032330s
質問に対して回答する例

会話を続ける

APIの呼び出し方法を確認したが、これでは単発の質問はできても、前回の質問の文脈を維持した回答は得られない。ChatGPTのWeb UIでは、前回の質問や回答の文脈を維持して、次回の回答が生成されるため、会話として成立する。APIでももちろんそれはできる。 messages に前回までの会話の内容を含める事で、会話の続きを行う事ができる。先のやり取りの続きとして会話を行うには次のリクエストを送信する。

:ORIGIN := openapi-api-origin
:API_TOKEN := openapi-api-key

POST :ORIGIN/v1/chat/completions
Content-Type: application/json
Authorization: Bearer :API_TOKEN

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "user",
      "content": "あなたは誰ですか?"
    },
    {
      "role": "assistant",
      "content": "私はArtificial Intelligence搭載のAIで、OpenAIが提供するチャットボットです。どのようにお手伝いできますか?"
    },
    {
      "role": "user",
      "content": "もっと詳しく教えてください。"
    }
  ]
}
{
  "id": "chatcmpl-7k3kTeiRj2ikcHAdMc6dCrkfPnQpo",
  "object": "chat.completion",
  "created": 1691211853,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "もちろんです。私はOpenAIが開発した、AI(人工知能)ベースの自然言語処理システムです。言語生成モデルであるGPT-3をベースにしており、広範で多様なトピックについての対話を行うことができます。\n\n質問に応じて情報を提供したり、アドバイスをしたり、文書を生成したりすることができます。さまざまな対話形式やトピックに対応しており、情報の検索や解釈、創造的な応答をすることができます。\n\nただし、私はプログラムされた言語モデルであり、個別の情報や経験を持っているわけではありません。そのため、絶対的な正確さや確信を持った意見を持つことはありません。また、セキュリティやプライバシーの理由から、個人情報や機密情報の提供はお控えください。\n\nご質問やお困りごとがあれば、お気軽にお尋ねください。私は精一杯お手伝いさせていただきます。"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 81,
    "completion_tokens": 362,
    "total_tokens": 443
  }
}

// POST https://api.openai.com/v1/chat/completions
// HTTP/1.1 200 OK
// Date: Sat, 05 Aug 2023 05:04:24 GMT
// Content-Type: application/json
// Transfer-Encoding: chunked
// Connection: keep-alive
// access-control-allow-origin: *
// Cache-Control: no-cache, must-revalidate
// openai-model: gpt-3.5-turbo-0613
// openai-organization: user-XXXXXXXXXXXXXXXXXXXX
// openai-processing-ms: 10739
// openai-version: 2020-10-01
// strict-transport-security: max-age=15724800; includeSubDomains
// x-ratelimit-limit-requests: 3500
// x-ratelimit-limit-tokens: 90000
// x-ratelimit-remaining-requests: 3499
// x-ratelimit-remaining-tokens: 89928
// x-ratelimit-reset-requests: 17ms
// x-ratelimit-reset-tokens: 48ms
// x-request-id: 29b7c0baaff38eaa2f244c53a945fbf2
// CF-Cache-Status: DYNAMIC
// Server: cloudflare
// CF-RAY: 7f1c7f841c2e8a84-NRT
// alt-svc: h3=":443"; ma=86400
// Request duration: 11.196523s
質問に対して回答する例

このように messages に、これまでのやり取りを含める。この時の含めるデータの量は課金対象となるため、どれぐらいの量のメッセージを含めるのか、検討する必要がある。

モデルを選ぶ

リクエストの model に、どのモデルを使用するのか指定できる。現在は以下のモデルを指定できる3

  • gpt-3.5-turbo
  • gpt-3.5-turbo-0613
  • gpt-3.5-turbo-16k
  • gpt-3.5-turbo-16k-0613
  • gpt-4
  • gpt-4-0613
  • gpt-4-32k
  • gpt-4-32k-0613

GPT-3.5系のモデルに関する特徴について、ChatGPTに問い合わせた結果を参考までに貼っておく。

gpt-3.5-turboは、自然言語処理のモデルであり、OpenAIが開発した最新の言語モデルです。以下にそれぞれの特徴を説明します。

  1. gpt-3.5-turbo: gpt-3.5-turboは、以前のgpt-3.0モデルをより高速かつ効果的にしたものです。より多くのタスクでの使用をサポートしており、文章の生成、文章の質問応答、要約、翻訳など多様な応用が可能です。
  2. gpt-3.5-turbo-3010: gpt-3.5-turbo-3010は、事前にトレーニングされた新しいモデルです。従来のモデルよりも多くの文脈を持ち、長い文脈に基づいたより良い応答を生成することができます。
  3. gpt-3.5-turbo-16k-0613: gpt-3.5-turbo-16k-0613は、16,000トークン(トークンは文字、単語、句読点などのテキストの部分を表す単位)までの長い文章に対応できるモデルです。これにより、より詳細な文章を生成することができます。
  4. gpt-3.5-turbo-16k: gpt-3.5-turbo-16kは、16,000トークンまでの長い文章に対応できるモデルです。文章の生成、文章の要約、文章の編集など、さまざまなタスクに対応しています。
  5. gpt-3.5-turbo-0613: gpt-3.5-turbo-0613は、以前のモデルよりも優れた文脈理解能力を持つモデルです。より正確な質問応答や文章生成が可能であり、パラフレーズやテキストの詳細な指示にも適しています。

これらのモデルは、自然な文章の生成や複雑なタスクの解決に優れたパフォーマンスを発揮します。また、APIを使用して他のアプリケーションやサービスに組み込むこともできます。

ロールを指定する

Chat Completion APIでは、messagesに含めるデータの中にroleを含める事ができる。これは、そのメッセージがどのような役割を果しているのかを表す事ができる。

role用途
systemシステムが発したメッセージ。会話の文脈を限定するために使用できる。英語の事なら何でも聞いてね。
userユーザーが発したメッセージ。どうすれば上達しますか?
functionモデルに直接提供されるコードのようなテキストとして使用される。東北弁で答える。
assistantAIが発したメッセージ。過去の会話の続きをするために使用できる。上達したいときは、まずは努力と練習が大切だべさ。

functionの例だけ適切ではないかもしれないが、良い例が思い浮かばなかった。

:ORIGIN := openapi-api-origin
:API_TOKEN := openapi-api-key

POST :ORIGIN/v1/chat/completions
Content-Type: application/json
Authorization: Bearer :API_TOKEN

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {"role": "system", "content": "英語の事なら何でも聞いてね。"},
    {"role": "user", "content": "どうすれば上達しますか?"},
    {"role": "function", "content": "東北弁で答える。", "name": "tohoku"}
  ]
}
{
  "id": "chatcmpl-7k2SZ2eoDaZXBcRVojqIJWwCmLhv4",
  "object": "chat.completion",
  "created": 1691206899,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "上達したいときは、まずは努力と練習が大切だべさ。英語の勉強は毎日コツコツやることが必要だべさ。文法勉強やリスニングの練習や、英会話の練習など、様々な方法を試してみるといいだべさ。また、英語を使う機会を増やすことも大切だべさ。英会話のグループに参加したり、外国人の友達と交流することで、実際に英語を使う機会を得ることができるだべさ。そして、自分の目標を持ち続けることも大事だべさ。上達するためには、目標に向かって努力し続けることが不可欠だべさ。少しずつでも前進することを楽しんで、上達を目指すことが大切だべさ。"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 55,
    "completion_tokens": 301,
    "total_tokens": 356
  }
}

// POST https://api.openai.com/v1/chat/completions
// HTTP/1.1 200 OK
// Date: Sat, 05 Aug 2023 03:41:49 GMT
// Content-Type: application/json
// Transfer-Encoding: chunked
// Connection: keep-alive
// access-control-allow-origin: *
// Cache-Control: no-cache, must-revalidate
// openai-model: gpt-3.5-turbo-0613
// openai-organization: user-XXXXXXXXXXXXXXXXXXXX
// openai-processing-ms: 9736
// openai-version: 2020-109-01
// strict-transport-security: max-age=15724800; includeSubDomains
// x-ratelimit-limit-requests: 3500
// x-ratelimit-limit-tokens: 90000
// x-ratelimit-remaining-requests: 3499
// x-ratelimit-remaining-tokens: 89955
// x-ratelimit-reset-requests: 17ms
// x-ratelimit-reset-tokens: 30ms
// x-request-id: b19c66f684baa04156b8ce57aad34607
// CF-Cache-Status: DYNAMIC
// Server: cloudflare
// CF-RAY: 7f1c0692cee880fb-NRT
// alt-svc: h3=":443"; ma=86400
// Request duration: 10.236672s

履歴を残す

会話の引き継ぎをするためには、履歴を残しておく必要がある。どこにどのような形式で残しておくのがよいかを考える事にした。使用環境はEmacs上なのでEmacs Lispの変数として保持したり、バッファにデータを残す事もできる。ただしその場合は、Emacsを終了すると共にデータも消えてしまう。そのため対話履歴は、ファイルとして書き出す方が良さそうだ。

では書き出すデータの形式は、どのような形式が良いだろう。選択肢としては、他のアプリケーションから扱いやすいJSON形式か、パースが簡単でLispとの相性が良さそうなS式ぐらいだろうか。また、その履歴が一体何の対話履歴なのか、何時のものなのかについても情報を持たせた方が良さそうだ。

ここでは先頭1行目にsystemのメッセージとして、対話のテーマとなる内容のJSONを挿入し、それ以降は通常の対話履歴としてJSONを保持するようにする。複数のJSONを改行で区切るNDJSON形式とする。そうすれば、jqなどで処理する事もできる。

{"role": "system", "content": "英語の事なら何でも聞いてね。"}
{"role": "user", "content": "どうすれば上達しますか?"}
{"role": "function", "content": "東北弁で答える。", "name": "tohoku"}
対話履歴の例

そのような前提で、各種処理を分解してみた。

;;; chatgpt.el --- OpenAI ChatGPT -*- lexical-binding: t; -*-

;; Copyright (C) 2023-2023  TakesxiSiximada

;; Author: TakesxiSximada <[email protected]>
;; Maintainer: TakesxiSximada <[email protected]>
;; Version: 0.1
;; Package-Requires: ((emacs "28.1") (plz))
;; Keywords: OpenAI, ChatGPT

;; This file is part of GNU Emacs.

;;; License:

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Code:

;;;; Requirements
(require 'plz)

;;;; Variables
(defvar chatgpt-current-request-body nil
  "OpenAI API request body")

(defvar chatgpt-current-response nil
  "OpenAI API response")

(defvar openapi-received-response-hook nil
  "Hook called when OpenAI API response is received")

(defcustom chatgpt-current-history-file "chat.log"
  "Chat history file path")

(defvar openapi-api-origin "https://api.openai.com"
  "OpenAI API Origin")

(defcustom openapi-api-key nil
  "OpenAI API Key")

;;;; Functions

;;;;; Public

(defun chatgpt-insert-question (question role name)
  "対話履歴の末尾に入力文字列を挿入する。"
  (with-temp-buffer
    (insert "\n")
    (insert
     (json-encode
      (if (string-equal role "function")
	  `((content . ,question)
	    (role . ,role)
	    (name . ,name))
	`((content . ,question)
	  (role . ,role)))))
    (append-to-file (point-min) (point-max)
		    chatgpt-current-history-file)))

(defun chatgpt-sync-chat-log ()
  "結果を対話履歴に反映する"
  (interactive)
  (with-temp-buffer
    (insert "\n")
    (insert
     (json-encode
      `((role . "assistant")
	(content . ,(completing-read
		     "[ChatGPT] "
		     (mapcar (lambda (elm)
			       (cdr (assoc 'content (cdr (assoc 'message elm)))))
			     (cdr (assoc 'choices
					 (json-read-from-string chatgpt-current-response)))))))))
    (append-to-file (point-min) (point-max) chatgpt-current-history-file)))

(defun chatgpt-create-current-request-body ()
  "リクエストを作成"
  (interactive)
  (setq chatgpt-current-request-body
	(progn
	  (shell-command (format "jq -s . %s" chatgpt-current-history-file))
	  (json-encode `(("model" . "gpt-3.5-turbo")
			 ("messages" . ,(with-current-buffer (get-buffer "*Shell Command Output*")
					  (goto-char 1)
					  (json-read-array))))))))

(defun chatgpt-send-request ()
  "APIを送信する"
  (interactive)
  (plz 'post (concat openapi-api-origin "/v1/chat/completions")
    :headers
    `(("Content-Type" . "application/json")
      ("Authorization" . ,(format "Bearer %s" openapi-api-key)))
    :body chatgpt-current-request-body
    :then (lambda (d)
	    (setq chatgpt-current-response d)
	    (princ "Got a openai api response.")
	    (run-hooks 'openapi-received-response-hook))
    :else (lambda (d) (princ d))))

(defun chatgpt-insert-result ()
  "現在位置に結果を挿入"
  (interactive)
  (insert (shell-command-to-string
	   (format "jq -s -r '.[-1].content' %s"
		   chatgpt-current-history-file))))


(defun chatgpt-display-result ()
  (interactive)
  (switch-to-buffer-other-window (get-buffer-create "*ChatGPT*"))
  (chatgpt-sync-chat-log)
  (chatgpt-insert-result))

(add-hook 'openapi-received-response-hook #'chatgpt-display-result)

(defun chatgpt (&optional question role name)
  (interactive
   (let* ((_question
	   (cond
	    ((bound-and-true-p rectangle-mark-mode)  ;; 矩形選択時は矩形領域を使用
	     (extract-rectangle (region-beginning) (region-end))
	     )
	    ((region-active-p)  ;; 領域選択時は領域を使用
	     (buffer-substring (region-beginning) (region-end))
	     )
	    (t  ;; 矩形選択でも領域選択でもない場合は、ミニバッファから入力する
	     (read-from-minibuffer "Question: ")
	     )))
	  (_role (completing-read "Role: " '("user" "function")))
	  (_name (when (string-equal _role "function")
		   (read-from-minibuffer "name: " "emacs"))))
     (list _question _role _name)))
  (chatgpt-insert-question question role name)
  (chatgpt-create-current-request-body)
  (chatgpt-send-request)
  )

(provide 'chatgpt)
;;; chatgpt.el ends here

履歴は以下のような形で保存される。

{"role": "system", "content": "Emacsについて"}
{"content":"このコードをレビューしてください。","role":"user"}
{"content":"\n(defun chatpgt-insert-question (&optional question role name)\n  \"対話履歴の末尾に入力文字列を挿入する。\"\n  (interactive\n   (let* ((_question (cond\n\t\t      ((bound-and-true-p rectangle-mark-mode)  ;; 矩形選択時は矩形領域を使用\n\t\t       (extract-rectangle (region-beginning) (region-end))\n\t\t       )\n\t\t      ((region-active-p)  ;; 領域選択時は領域を使用\n\t\t       (buffer-substring (region-beginning) (region-end))\n\t\t       )\n\t\t      (t  ;; 矩形選択でも領域選択でもない場合は、ミニバッファから入力する\n\t\t       (read-from-minibuffer \"Question: \")\n\t\t       )))\n\t  (_role (completing-read \"Role: \" '(\"user\" \"function\")))\n\t  (_name (when (string-equal _role \"function\")\n\t\t   (read-from-minibuffer \"name: \" \"emacs\"))))\n     (list _question _role _name)))\n  (with-temp-buffer\n    (insert \"\\n\")\n    (insert\n     (json-encode\n      (if (string-equal role \"function\")\n\t  `((content . ,question)\n\t    (role . ,role)\n\t    (name . ,name))\n\t`((content . ,question)\n\t  (role . ,role)))))\n    (append-to-file (point-min) (point-max) chatpgt-current-history-file)))","role":"user"}
{"role":"assistant","content":"このコードは一般的なEmacs Lispの関数のようです。以下では、コードの構造や意図を確認するために関数を一部抜粋して説明します。\n\n```lisp\n(defun chatpgt-insert-question (&optional question role name)\n  ...\n)\n```\n\nこの関数は`chatpgt-insert-question`という名前で定義されています。`&optional`キーワードを使用して、引数`question`、`role`、`name`をオプションとして指定できるようになっています。\n\n```lisp\n(interactive\n  ...\n)\n```\n\n`interactive`フォームは、この関数をインタラクティブに実行することができるようにします。具体的には、ミニバッファやリージョンの内容を取得し、引数に渡すための入力プロンプトを表示します。\n\n```lisp\n(let* (_question\n        ...\n)\n```\n\n`let*`フォームを使用して、ローカルな変数を束縛します。この場合、`_question`、`_role`、`_name`という変数が定義されます。\n\n```lisp\n(with-temp-buffer\n  ...\n)\n```\n\n`with-temp-buffer`フォームは、一時的なバッファを作成し、その中で一連の操作を実行します。これにより、一時的なバッファでの作業が完了すると、バッファは自動的に破棄されます。\n\n```lisp\n(append-to-file (point-min) (point-max) chatpgt-current-history-file)\n```\n\n`append-to-file`関数は、指定された範囲のバッファの内容をファイルに追記します。この場合、`point-min`と`point-max`は一時的なバッファの範囲を指定し、`chatpgt-current-history-file`は書き込むファイルのパスを表します。\n\n以上が抜粋したコードの主な構造です。レビューの詳細な内容については、コードの残りの部分や関連するコンテキストが必要です。また、関数がどのように使われるかや目的がどのようなものかも重要な情報です。"}

履歴に新しい文を含める

ここまでくると、OpenAI APIの話よりEmacsの話、もっと言うと私が実装するEmacs Lispの話になる。対話履歴をベースにリクエストを作成するのだから、次回の問い合わせは対話履歴の末尾に内容を挿入できればよい。対話履歴の挿入は幾つかのパターンが想定できる。

1つ目は単純に文字列の入力を待ち、ユーザーが入力した文字列をそのままNLJSONとして挿入する。2つ目はリージョンを選択し、そのリージョンの範囲をNLJSONとして挿入する。また、それら2つの方法について、roleに何を設定するのかという事も制御したい。

roleで選択可能なのは userfunction のいずれかで、 function の場合は name を別途指定する必要がある。この name の位置付けが今一つピンと来ない。

name は次のように説明されている。

https://platform.openai.com/docs/api-reference/chat/create

name string Optional

The name of the author of this message. name is required if role is function, and it should be the name of the function whose response is in the content. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters.

このメッセージの作成者の名前。ロールが関数の場合、名前は必須であり、応答がコンテンツに含まれる関数の名前である必要があります。 a ~ z、A ~ Z、0 ~ 9、アンダースコアを含めることができ、最大長は 64 文字です。

rolefunction とする場合は、モデルに直接渡される文字列であり、例えばコードスニペットのようなものだ。リージョンを選択し挿入されるような文字列は、直接引き渡された方が適切かもしれない。いずれにしても通常ではあまり使用しないものではある。

基本的には role には user をデフォルトとして completing-read として入力し、 function として入力した場合は、更に name を入力させるインターフェースを提供する。そのデフォルト値はとりあえず emacs という文字列を使う事にした。

Server Sent Eventsを使って結果をストリーミングで受け取る

ここまでは1問1答として、質問をリクエストとして送信し、回答をレスポンスとして受け取ってきた。回答が生成しきるまでは、レスポンスは返って来ないため、回答が生成できるまでの時間を待つ事になる。

ChatGPTのようなUIでは、回答の全てを生成しきる前に、生成できた分だけの文字が出力される。これは、OpenAI APIのレスポンスの受取方に違いがある。

OpenAI APIは結果を分割し、できた分毎に受け取る事ができる。これを streaming と言う。 OpenAI APIstreaming は、 Server Sent Event によって実現している。

リクエストのBODYに渡すJSONの stream 属性に true を指定する。


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": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "こんにちわ"
      }
    ],
    "stream": true
  }'

この時APIは、イベントを返せるようになったタイミングで徐々に返す。

data: {"id":"chatcmpl-8tTAxb74ugBRWYgIlFMQZ6Qy04Ytn","object":"chat.completion.chunk","created":1708230883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTAxb74ugBRWYgIlFMQZ6Qy04Ytn","object":"chat.completion.chunk","created":1708230883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"content":"こんにちは"},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTAxb74ugBRWYgIlFMQZ6Qy04Ytn","object":"chat.completion.chunk","created":1708230883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTAxb74ugBRWYgIlFMQZ6Qy04Ytn","object":"chat.completion.chunk","created":1708230883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"content":"お"},"logprobs":null,"finish_reason":null}]}

この形式は Server Sent Events と呼ばれる。まず data:eveうなnt: という記述で始まり、その後データを記述する。 dataevent はイベント種別であり、情報の種類を表す。

/v1/chat/completions を確認した範囲では data しか送られて来なかった。

メッセージの途中のデータは、 finish_reasonnull が設定されている。回答が分岐する事もあり、その場合 choices に複数の配列が、設定されるようだ。今回の場合、分岐はあまり考慮せず choices の先頭を採用する事にする。

{
  "id": "chatcmpl-8tTAxb74ugBRWYgIlFMQZ6Qy04Ytn",
  "object": "chat.completion.chunk",
  "created": 1708230883,
  "model": "gpt-3.5-turbo-0125",
  "system_fingerprint": "fp_69829325d0",
  "choices": [
    {
      "index": 0,
      "delta": {
        "content": "ください"
      },
      "logprobs": null,
      "finish_reason": null
    }
  ]
}
途中のデータ

メッセージの最後のデータには、 finish_reasonstop が設定されていた。ドキュメントを確認すると、以下の値を返却される可能性があるようだ4

finish_reason意味
"stop"モデルが自然な停止ポイントまたは提供された停止シーケンスに達した。
"length"リクエストで指定されたトークンの最大数に達した
"content_filter"コンテンツ フィルターのフラグによりコンテンツが省略された
"tool_calls"ツールの呼び出しによる停止
"function_call"モデルによる関数の呼び出しによる停止
null続きがある。

回答が分岐する事もあり、その場合 choices に複数の配列が、設定されるようだ。今回の場合、分岐はあまり考慮せず choices の先頭を採用する事にする。

{
  "id": "chatcmpl-8tTAxb74ugBRWYgIlFMQZ6Qy04Ytn",
  "object": "chat.completion.chunk",
  "created": 1708230883,
  "model": "gpt-3.5-turbo-0125",
  "system_fingerprint": "fp_69829325d0",
  "choices": [
    {
      "index": 0,
      "delta": {},
      "logprobs": null,
      "finish_reason": "stop"
    }
  ]
}
最後のデータ

生成できるメッセージを全て返却すると、APIは接続を切断する。

:OPENAI_API_KEY :=

POST https://api.openai.com/v1/chat/completions
Content-Type: application/json
Authorization: Bearer :OPENAI_API_KEY

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "system",
      "content": "You are a helpful assistant."
    },
    {
      "role": "user",
      "content": "Emacsについて教えてください"
    }
  ],
  "stream": true
}
data: {"id":"chatcmpl-8tTtbCmY4VGOqfLAj0vA4nMBtaXU3","object":"chat.completion.chunk","created":1708233651,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTtbCmY4VGOqfLAj0vA4nMBtaXU3","object":"chat.completion.chunk","created":1708233651,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"content":"Em"},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTtbCmY4VGOqfLAj0vA4nMBtaXU3","object":"chat.completion.chunk","created":1708233651,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"content":"ます"},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTtbCmY4VGOqfLAj0vA4nMBtaXU3","object":"chat.completion.chunk","created":1708233651,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{"content":"。"},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-8tTtbCmY4VGOqfLAj0vA4nMBtaXU3","object":"chat.completion.chunk","created":1708233651,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_69829325d0","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}

data: [DONE]


// POST https://api.openai.com/v1/chat/completions
// HTTP/1.1 200 OK
// Date: Sun, 18 Feb 2024 05:20:51 GMT
// Content-Type: text/event-stream
// Transfer-Encoding: chunked
// Connection: keep-alive

会話を続けるためには、過去の会話ごと送信する必要がある。

これを見ると、sedやjqやtrを使う事でパースできそうだ。ただ、トラブルシューティングはしんどそうだ。

cat request.json  | curl https://api.openai.com/v1/chat/completions -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $OPENAI_API_KEY" -d @- | tee api.log | sed -e 's/^data: //g' | jq -r '.choices[0].delta.content' | tr -d '\n'
例えばこんな感じ

Server Sent Eventsを処理してOpenAI APIを利用するEmacs Lisp

Server Sent Eventsを処理してOpenAI APIを利用するEmacs Lispを実装した。

openai.el

(setq openai-api-key nil)
(setq openai-chat-result-buffer-name "*ChatGPT Result*")

(setq openai-chat-request-buffer-name "*ChatGPT Request*")
(setq openai-chat-response-buffer-name "*ChatGPT Response*")
(setq openai-chat-response-error-buffer-name "*ChatGPT Response Error*")

(setq openai-chat-send-request-process nil)

(setq openai-chat-chars-q nil)

(setq openai-chat-chars-timer nil)

(defun openai-chat-sync-results ()
  (while openai-chat-chars-q
    (with-current-buffer (get-buffer-create openai-chat-result-buffer-name)
      (goto-char (point-max))
      (let ((ch (car openai-chat-chars-q)))
	(if (string-equal "" ch)
	    (insert "\n")
	  (insert ch)))
      (end-of-buffer)
      (pop-to-buffer (current-buffer))
      (setq openai-chat-chars-q (cdr openai-chat-chars-q)))))

(defun openai-chat-chars-start-timer ()
  (interactive)
  (unless openai-chat-chars-timer
    (setq openai-chat-chars-timer (run-with-idle-timer 1 t #'openai-chat-sync-results))))

(defun openai-chat-chars-cancel-timer ()
  (interactive)
  (cancel-timer openai-chat-chars-timer)
  (setq openai-chat-chars-timer nil))

(require 'json)
 

(defun openai-chat-create-request (sentence)
  (interactive "sSentence: ")
  
  (with-current-buffer (get-buffer-create openai-chat-result-buffer-name)
    (goto-char (point-max))
    (insert "\n\n------------------------------------------------\n")
    (insert "ME: ")
    (insert sentence)
    (insert "\n------------------------------------------------\n")
    (insert "AI: ")
    )
    
  (with-current-buffer (get-buffer-create openai-chat-request-buffer-name)
    (erase-buffer)
    (insert 
     (format "{ 
    \"model\": \"gpt-3.5-turbo\",
    \"messages\": [
	{
            \"role\": \"system\",
            \"content\": \"You are a helpful assistant.\"
	},
	{
            \"role\": \"user\",
            \"content\": %s
	}
    ],
    \"stream\": true
}" (json-encode-string sentence)))))

(defun openai-chat-parse-response ()
  (interactive)
  (with-current-buffer openai-chat-response-buffer-name
    (save-excursion
      (goto-char (point-min))
      (while (re-search-forward "^data: \\(.*\\)

" nil t)
	(if-let ((txt (buffer-substring-no-properties
		       (match-beginning 1) (match-end 1))))
	    (progn
	      (delete-region (point-min) (match-end 1))
	      (if-let ((content
			(cdr (assoc 'content
				    (cdr (assoc 'delta
						(seq-first (cdr (assoc 'choices
								       (json-read-from-string txt))))))))))
		  (setq openai-chat-chars-q (append openai-chat-chars-q (list content))))))))))
	  

(defun openai-chat-send-request ()
  (interactive)
  (if-let ((req-body (with-current-buffer openai-chat-request-buffer-name (buffer-string))))
      (progn
	(setq openai-chat-send-request-process
	      (make-process
	       :name "*OpenAI Chat*"
	       :buffer openai-chat-response-buffer-name
	       :command `("curl" "https://api.openai.com/v1/chat/completions"		       
 	      		  "-X" "POST"							       
 	      		  "-H" "Content-Type: application/json"			       
 	      		  "-H" ,(format "Authorization: Bearer %s" openai-api-key)
 	      		  "-d" "@-")							       
 	       :connection-type 'pipe							       
 	       :coding 'utf-8
	       :filter (lambda (process output)
			 (with-current-buffer (process-buffer process)
			   (goto-char (point-max))
			   (insert output)
			   (openai-chat-parse-response)
			   (openai-chat-sync-results)))
	       :sentinel (lambda (process event)
			   (openai-chat-parse-response)
			   (openai-chat-sync-results))
 	       :stderr openai-chat-response-error-buffer-name))
	
	(process-send-string openai-chat-send-request-process req-body)
	(process-send-eof openai-chat-send-request-process))))
			     
;;;

(defun openai-chat-question (question)
  (interactive (list (read-string-from-buffer "sQuestion" "")))
  (openai-chat-create-request question)
  (openai-chat-send-request)) 
  


(provide 'openai)