CLOVER🍀

That was when it all began.

RabbitMQのJavaScriptチュートリアルの「RPC」をTypeScriptで試す

これは、なにをしたくて書いたもの?

RabbitMQのチュートリアルをJavaScriptクライアント+TypeScriptでやっていこう、ということで。

今回は「RPC」を扱います。

RabbitMQ tutorial - Remote procedure call (RPC) — RabbitMQ

今回で、この一連のお題は最後にします。

RPC

「Work Queues」チュートリアルでは、ワークキューを使って時間のかかるタスクを複数のコンシューマー(ワーカー)に分散して
処理を行ってもらうものでした。

RabbitMQ tutorial - Work Queues — RabbitMQ

RabbitMQのJavaScriptチュートリアルの「Work Queues」をTypeScriptで試す - CLOVER🍀

今回は、ワーカー側で処理を実行してその結果を受け取るというパターンです。なので「RPC」ですね。

こんな感じで実現するようです。

  • 2つのキューを使用する
  • そのうちの片方は、RPCワーカー(サーバー)に対して、プロデューサー(クライアント)ごとに作成する匿名の排他的キュー
    • コールバックキュー
  • RPCリクエストを送る場合、クライアントはコールバックキューを指定するreply_to、どのリクエストに関するものなのかを特定するためのcorrelation_idの2つのプロパティを指定する
  • クライアントは、キュー(コールバックキューではない方)にリクエストを送る
  • RPCワーカーは、キューからリクエストを受け取るのを待ち、処理を終えるとreply_toで指定されたキューにレスポンスを返す
  • クライアントはコールバックキューでデータを待ち、受け取った場合はcorrelation_idを確認し、リクエストで送信した値と一致する場合はリクエストに対するレスポンスと見なす

なお、RPCのチュートリアルページでは、以下の点をRPCの注意事項として記載しています。

  • 呼び出している処理が実はローカルの関数呼び出しではなく、RPCであるかもしれないことに注意すること(特にプログラマーが気づいていない場合)
  • システムを複雑化し、デバッグをしづらくする
  • RPCの乱用は、ソフトウェアのシンプルさを失わせメンテナンスの難しいスパゲッティコードを生み出すかもしれない

関数呼び出し自体はプログラミングで一般的ですが、実はそれがリモート呼び出しだった場合はその事実が隠されやすいので、
注意しましょうということですね。

では、このあたりの動作をNode.js+TypeScriptで試してみます。

環境

今回の環境は、こちら。

$ sudo -u rabbitmq rabbitmqctl version
3.12.10

RabbitMQは、172.17.0.2で動作しているものとします。

ユーザーの作成。

$ sudo -u rabbitmq rabbitmqctl add_user kazuhira password
$ sudo -u rabbitmq rabbitmqctl set_permissions -p / kazuhira '.*' '.*' '.*'
$ sudo -u rabbitmq rabbitmqctl set_user_tags kazuhira monitoring

使用するNode.jsのバージョン。

$ node --version
v18.19.0


$ npm --version
10.2.3

準備

まずはNode.jsプロジェクトを作成。

$ npm init -y
$ npm i -D typescript
$ npm i -D @types/node@v18
$ npm i -D prettier
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest

確認は、テストコードで行います。

RabbitMQに接続するためのamqplibとその型定義をインストール。

$ npm i amqplib
$ npm i -D @types/amqplib

ソースコードを配置するディレクトリを作成。

$ mkdir src test

依存関係はこうなりました。

  "devDependencies": {
    "@types/amqplib": "^0.10.4",
    "@types/jest": "^29.5.10",
    "@types/node": "^18.19.1",
    "esbuild": "^0.19.8",
    "esbuild-jest": "^0.5.0",
    "jest": "^29.7.0",
    "prettier": "^3.1.0",
    "typescript": "^5.3.2"
  },
  "dependencies": {
    "amqplib": "^0.10.3"
  }

scripts。

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "jest",
    "format": "prettier --write src test"
  },

各種設定ファイル。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 120
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

RabbitMQのJavaScriptチュートリアルの「RPC」をTypeScriptとJestで試す

では、こちらをTypeScriptとJestで試していきます。

RabbitMQ tutorial - Remote procedure call (RPC) — RabbitMQ

チュートリアル内ではクライアントとサーバーをそれぞれ別プロセスで起動していますが、今回はテストコード内で表現して みます。

お題は加算にしましょう。以下をリクエストとレスポンスの型定義とします。

src/exchange.ts

export type Request = {
  a: number;
  b: number;
};

export type Response = {
  result: number;
};

クライアントで加算元の数を送信し、サーバーで加算してレスポンスとして返すものとします。

サーバー(コンシューマー)を作成する

最初にサーバーを作成します。RabbitMQではコンシューマーやワーカーと呼ばれる種類に相当します。

src/Server.ts

import { setTimeout } from 'node:timers/promises';
import amqp from 'amqplib';
import { Request, Response } from './exchange';
import { log } from './log';

export class Server {
  private name: string;
  private conn: amqp.Connection;
  private channel: amqp.Channel;
  private queue: string;

  constructor(name: string, conn: amqp.Connection, channel: amqp.Channel, queue: string) {
    this.name = name;
    this.conn = conn;
    this.channel = channel;
    this.queue = queue;
  }

  static async create(name: string, url: string, queue: string): Promise<Server> {
    const conn = await amqp.connect(url);
    const channel = await conn.createChannel();

    await channel.assertQueue(queue, { durable: false });
    channel.prefetch(1);

    return new Server(name, conn, channel, queue);
  }

  async start(): Promise<void> {
    log(` [${this.name}] [x] Start Server, Awaiting RPC requests`);

    await this.channel.consume(this.queue, async (message) => {
      if (message !== null) {
        log(` [${this.name}] sleep, 3 sec...`);

        await setTimeout(3 * 1000);

        const request: Request = JSON.parse(message?.content.toString('utf-8'));
        const response: Response = {
          result: request.a + request.b,
        };

        this.channel.sendToQueue(message?.properties.replyTo, Buffer.from(JSON.stringify(response)), {
          correlationId: message?.properties.correlationId,
        });

        this.channel.ack(message);
      }
    });
  }

  async close(): Promise<void> {
    await this.conn.close();
  }
}

キューを作成。

    await channel.assertQueue(queue, { durable: false });
    channel.prefetch(1);

Channel-oriented API reference / API reference / Channel / assertQueue

このキューが、サーバーとしてリクエストを受け取るためのキューになります。複数のサーバーでリクエストをできる限り均等に
処理するようにするには、Channel#prefetchを指定するとよいそうです。

Channel-oriented API reference / API reference / Channel / prefetch

そして、キューからメッセージを受信したら、処理を行ってChannel#sendToQueueでクライアントとの排他的キューにレスポンスを
送信します。

    await this.channel.consume(this.queue, async (message) => {
      if (message !== null) {
        log(` [${this.name}] sleep, 3 sec...`);

        await setTimeout(3 * 1000);

        const request: Request = JSON.parse(message?.content.toString('utf-8'));
        const response: Response = {
          result: request.a + request.b,
        };

        this.channel.sendToQueue(message?.properties.replyTo, Buffer.from(JSON.stringify(response)), {
          correlationId: message?.properties.correlationId,
        });

        this.channel.ack(message);
      }
    });

Channel-oriented API reference / API reference / Channel / sendToQueu

この部分ですね。

        this.channel.sendToQueue(message?.properties.replyTo, Buffer.from(JSON.stringify(response)), {
          correlationId: message?.properties.correlationId,
        });

送信先のキューは、リクエストのメッセージのプロパティに含まれるreplyToで指定します。また、レスポンスを返す時にプロパティに
correlationIdを含めます。こちらも、リクエストのメッセージのプロパティに含まれるcorrelationIdを指定します。

こうやって、correlationIdでリクエストとレスポンスを紐付け、経路はreplyToで決まるというわけですね。

処理を行う前には、スリープを入れるようにしました。

        log(` [${this.name}] sleep, 3 sec...`);

        await setTimeout(3 * 1000);

これで、重い処理を表現しています。チュートリアルではフィボナッチ数の計算をしていますが、それだとCPUを使い切るので
テストで複数サーバーを利用する際に都合が悪く(forkが必要になる)、スリープにすることにしました。

ちなみに、logというのはこういう定義です。

src/log.ts

export function log(message: string): void {
  console.log(`[${new Date().toISOString()}] ${message}`);
}

こちらはクライアントでも使います。

クライアント(プロデューサー)を作成する

次は、クライアントを作成します。RabbitMQではプロデューサーの役割ですね。

src/Client.ts

import amqp from 'amqplib';
import { Request, Response } from './exchange';
import { log } from './log';
import { randomUUID } from 'crypto';

export class Client {
  private conn: amqp.Connection;
  private channel: amqp.Channel;
  private queue: string;

  constructor(conn: amqp.Connection, channel: amqp.Channel, queue: string) {
    this.conn = conn;
    this.channel = channel;
    this.queue = queue;
  }

  static async create(url: string, queue: string): Promise<Client> {
    const conn = await amqp.connect(url);
    const channel = await conn.createChannel();

    return new Client(conn, channel, queue);
  }

  async send(request: Request): Promise<Response> {
    const q = await this.channel.assertQueue('', { exclusive: true });

    const correlationId = randomUUID();

    return new Promise(async (resolve, reject) => {
      try {
        await this.channel.consume(q.queue, async (message) => {
          if (message?.properties.correlationId === correlationId) {
            const response: Response = JSON.parse(message.content.toString('utf-8'));
            resolve(response);
          }
        });

        this.channel.sendToQueue(this.queue, Buffer.from(JSON.stringify(request)), {
          correlationId: correlationId,
          replyTo: q.queue,
        });

        log(`send message, correlationId = ${correlationId}`);
      } catch (e) {
        reject(e);
      }
    });
  }

  async close(): Promise<void> {
    await this.conn.close();
  }
}

こちらで、排他的な匿名キューを作成しています。

    const q = await this.channel.assertQueue('', { exclusive: true });

Channel-oriented API reference / API reference / Channel / assertQueue

Channel#assertQueueの第1引数を空文字にすることで、空文字列('')で指定しているところがポイントで、
こうするとamq.gen-JzTY20BRgKO-HjmUJj0wLgのようなランダムな名前でキューが作成されます。 またexclusiveをtrueに
しているので、これは排他的な一時キューとなり、接続がクローズされると削除されるものになります。

correlationIdはUUIDにしました。

    const correlationId = randomUUID();

レスポンスを受信している箇所。レスポンスのメッセージのプロパティに含まれるcorrelationIdが、自分で作成したものと同じであれば
自分のレスポンスだと見なすようにしています。

        await this.channel.consume(q.queue, async (message) => {
          if (message?.properties.correlationId === correlationId) {
            const response: Response = JSON.parse(message.content.toString('utf-8'));
            resolve(response);
          }
        });

リクエストを送信している部分。

        this.channel.sendToQueue(this.queue, Buffer.from(JSON.stringify(request)), {
          correlationId: corrationId,
          replyTo: q.queue,
        });

プロパティとして、correlationIdに作成したUUIDを、replyToに一時キューの名前を渡しています。

これで、クライアントとサーバーの準備は完了です。

テストコードで確認する

確認は、テストコードで行います。

test/rpc.test.ts

import { Client } from '../src/Client';
import { Server } from '../src/Server';
import { Request, Response } from '../src/exchange';

test('rpc test', async () => {
  const url = 'amqp://kazuhira:password@172.17.0.2:5672';
  const queue = 'rpc_queue';

  const server1 = await Server.create('server1', url, queue);
  const server2 = await Server.create('server2', url, queue);

  const client = await Client.create(url, queue);

  try {
    await server1.start();
    await server2.start();

    const request1: Request = {
      a: 5,
      b: 8,
    };
    const request2: Request = {
      a: 2,
      b: 3,
    };

    const promise1 = client.send(request1);
    const promise2 = client.send(request2);

    const response1: Response = await promise1;
    const response2: Response = await promise2;

    expect(response1).toStrictEqual({ result: 13 });
    expect(response2).toStrictEqual({ result: 5 });
  } finally {
    await client.close();
    await server1.close();
    await server2.close();
  }
});

サーバーのインスタンスを2つ、クライアントのインスタンスをひとつ作成。

  const server1 = await Server.create('server1', url, queue);
  const server2 = await Server.create('server2', url, queue);

  const client = await Client.create(url, queue);

サーバー起動後、クライアントから2つメッセージを送信して結果を確認します。

    await server1.start();
    await server2.start();

    const request1: Request = {
      a: 5,
      b: 8,
    };
    const request2: Request = {
      a: 2,
      b: 3,
    };

    const promise1 = client.send(request1);
    const promise2 = client.send(request2);

    const response1: Response = await promise1;
    const response2: Response = await promise2;

    expect(response1).toStrictEqual({ result: 13 });
    expect(response2).toStrictEqual({ result: 5 });

確認。

$ npm test

実行時のログはこちら。

> rpc@1.0.0 test
> jest

  console.log
    [2023-12-02T15:23:32.980Z]  [server1] [x] Start Server, Awaiting RPC requests

      at log (src/log.ts:24:11)

  console.log
    [2023-12-02T15:23:33.035Z]  [server2] [x] Start Server, Awaiting RPC requests

      at log (src/log.ts:24:11)

  console.log
    [2023-12-02T15:23:33.058Z] send message, correlationId = 69a5198e-10c4-4e47-86ed-16087f36a4e6

      at log (src/log.ts:24:11)

  console.log
    [2023-12-02T15:23:33.083Z] send message, correlationId = ff2aedcf-ca35-45c6-abaf-715c8b434198

      at log (src/log.ts:24:11)

  console.log
    [2023-12-02T15:23:33.090Z]  [server1] sleep, 3 sec...

      at log (src/log.ts:24:11)

  console.log
    [2023-12-02T15:23:33.125Z]  [server2] sleep, 3 sec...

      at log (src/log.ts:24:11)

 PASS  test/rpc.test.ts
  ✓ rpc test (3396 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.725 s, estimated 6 s
Ran all test suites.

サーバーはリクエストを受け付けてから3秒スリープするのですが、全体の実行時間は4秒弱なので、処理自体は複数のサーバーインスタンスで
行えています。

それはログからもわかりますね。

  console.log
    [2023-12-02T15:23:33.090Z]  [server1] sleep, 3 sec...

      at log (src/log.ts:24:11)

  console.log
    [2023-12-02T15:23:33.125Z]  [server2] sleep, 3 sec...

      at log (src/log.ts:24:11)

メッセージごとに異なるサーバーインスタンスが受信していることも確認できました。

OKですね。

おわりに

RabbitMQのチュートリアルの「RPC」を試してみました。

今までと少し毛色が違うのでなかなか興味深かったのと、少し考え方が広がる構成かなと思いますね。

今回で、Node.js(TypeScript/JavaScript)でRabbitMQのチュートリアルをなぞっていくのは最後にしたいと思います。
始めてから半年くらいかかっていますが、いったん完了(?)ですね。

OpenAI Python APIライブラリーからllama-cpp-pythonで立てたOpenAI API互換のサーバーへアクセスしてみる

これは、なにをしたくて書いたもの?

前に、llama-cpp-pythonを使って、OpenAI API互換のサーバーを立てるということをやってみました。

llama-cpp-pythonで、OpenAI API互換のサーバーを試す - CLOVER🍀

この時はcurlでアクセスして確認してみましたが、今度はOpenAIのPython APIライブラリーを使ってみたいと思います。

OpenAI Python APIライブラリー

OpenAI Python APIライブラリーのGitHubリポジトリーはこちら。

GitHub - openai/openai-python: The official Python library for the OpenAI API

現時点でのバージョンは1.3.7のようです。

ドキュメントはこちら。

Welcome to the OpenAI developer platform

どういうものか、少し見てみましょう。

OpenAI Pythonライブラリーは、OpenAIのREST APIに簡単にアクセスできるようにするものだそうです。リクエストとレスポンスの
型定義が含まれており、バックエンドにはhttpxというライブラリーが使われているそうです。

The OpenAI Python library provides convenient access to the OpenAI REST API from any Python 3.7+ application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by httpx.

OpenAI Python API library

型定義は、こちらのOpenAI APIのOpenAPI定義が元になっています。

GitHub - openai/openai-openapi: OpenAPI specification for the OpenAI API

使い方は、簡単にこちらにまとまっています。

OpenAI Python API library / Usage

非同期での利用、ストリーミング、ページネーション、ファイルアップロード、エラーハンドリング、リトライ、タイムアウトなどが
記述されています。

ちなみに、Node.js(TypeScript/JavaScript)のライブラリーもあるようです。

GitHub - openai/openai-node: The official Node.js / Typescript library for the OpenAI API

その他の言語のについては、コミュニティベースのものが紹介されています。

Guides / Libraries

ドキュメントも見てみましょう。

まずはこちらで用語を押さえるのがよさそうです。

Introduction

主要な概念は以下のようです。

  • テキスト生成モデル(Text generation models)
  • アシスタント(Assistants)
    • エンティティと呼ばれるもののこと
    • OpenAI APIの場合は、GPT-4などのLLMを使用してユーザーに代わってタスクの実行が可能
    • 通常、モデルのコンテキストウィンドウ内に埋め込まれた情報に基づいて動作する
    • 詳細はAssistants API
  • 埋め込み(Embeddings)
    • コンテンツの意味を保持することを目的としたデータのベクトル表現
    • 類似のデータのチャンクには、近いEmbeddingsが含まれる傾向にある
    • 詳細はEmbeddings
  • トークン(Tokens)
    • テキスト生成モデルおよび埋め込みにおける処理単位で、文字列が分解されたもの
    • 1単語が1トークンになるわけではない
    • Tokenizerで確認可能
    • テキスト生成モデルの場合、プロンプトと出力がモデルの最大コンテキスト長を超えてはならない
    • (トークンを出力しない)埋め込みの場合、入力はモデルの最大コンテキスト長より短くなくてはならない
    • 各テキスト生成モデル、埋め込みの最大コンテキスト長はModelsで確認可能

Introduction / Key concepts

今回は、まずはQuickstartを見ながらやってみたいと思います。

Developer quickstart

アクセスする先は、OpenAI APIではなくllama-cpp-pythonによるOpenAI API互換のサーバーとします。

環境

今回の環境は、こちら。

$ python3 -V
Python 3.10.12

llama-cpp-python。

$ pip3 freeze | grep llama_cpp_python
llama_cpp_python==0.2.20

モデルはこちらを使います。

TheBloke/Llama-2-7B-Chat-GGUF · Hugging Face

起動。

$ python3 -m llama_cpp.server --model llama-2-7b-chat.Q4_K_M.gguf

OpenAI Python APIライブラリーをインストールして試してみる

まずは、OpenAI Python APIライブラリーをインストールします。

セットアップは、こちらに従います。

Developer quickstart / Step 1: Setup Python

$ python3 -m venv venv
$ . venv/bin/activate
$ pip3 install openai

インストールしたOpenAI Python APIライブラリーのバージョン。

$ pip3 freeze | grep openai
openai==1.3.7

こちらに従って、プログラムを作成。

Developer quickstart / Step 3: Sending your first API request

quickstart.py

import time
from openai import OpenAI

start_time = time.perf_counter()

client = OpenAI(base_url = "http://localhost:8000/v1", api_key = "dummy-api-key")

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "system", "content": "You are a poetic assistant, skilled in explaining complex programming concepts with creative flair."},
        {"role": "user", "content": "Compose a poem that explains the concept of recursion in programming."} 
    ]
)

elapsed_time = time.perf_counter() - start_time

print(completion)

print()

print(f"id = {completion.id}")
print(f"model = {completion.model}")

print("choices:")
for choice in completion.choices:
    print(f"  {choice}")

print(f"usage = {completion.usage}")

print()

print(f"elapsed time = {elapsed_time:.3f} sec")

OpenAIのコンストラクタには通常引数は不要(APIキーは環境変数で設定する)なようですが、今回はbase_urlの指定もあるので
引数で指定。base_urlには、llama-cpp-pythonにアクセスするURLを設定します。

client = OpenAI(base_url = "http://localhost:8000/v1", api_key = "dummy-api-key")

ここはQuickstartの内容と同じです。

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "system", "content": "You are a poetic assistant, skilled in explaining complex programming concepts with creative flair."},
        {"role": "user", "content": "Compose a poem that explains the concept of recursion in programming."} 
    ]
)

このあたりは、レスポンスを標準出力に書き出しているのですが、全体や各要素を少し細かくして表示しています。
あと、時間がけっこうかかるので、処理時間も表示するようにしました。

elapsed_time = time.perf_counter() - start_time

print(completion)

print()

print(f"id = {completion.id}")
print(f"model = {completion.model}")

print("choices:")
for choice in completion.choices:
    print(f"  {choice}")

print(f"usage = {completion.usage}")

print()

print(f"elapsed time = {elapsed_time:.3f} sec")

確認。

$ python3 quickstart.py

結果。

ChatCompletion(id='chatcmpl-376a3b53-d3cf-48f0-a27f-1082be8ca755', choices=[Choice(finish_reason='stop', index=0, message=ChatCompletionMessage(content="  Recursion, oh gentle programmer's delight,\nA loop within a loop, a function's recursive might.\nIt's like a tree, you see, with branches so bright,\nEach one calling itself, until the morning light.\n\nIn recursion's embrace, a program unfolds,\nWith each iteration, a new tale to unfold.\nA solution's sought, with logic so neat,\nAnd in its heart, the loop can't be beat.\n\nIt starts with base, a seed so small and fine,\nAnd grows with each call, like vines entwining.\nThe function's name is echoed through the land,\nAs it loops and loops, a programmer's hand.\n\nWith each recursion, more complexity gained,\nThe program unfolds its secrets unrestrained.\nA dance of code, a symphony divine,\nRecursion weaves its magic, a programming shrine.\n\nSo here's to recursion, a tool so grand,\nThat makes our programs grow, in this digital land.\nWith it, we craft, with it we play,\nAnd bring our ideas to life each day.\n\nNow go forth, dear programmer, and code with cheer,\nFor recursion is the key that sets your spirit free.", role='assistant', function_call=None, tool_calls=None))], created=1701510436, model='gpt-3.5-turbo', object='chat.completion', system_fingerprint=None, usage=CompletionUsage(completion_tokens=279, prompt_tokens=53, total_tokens=332))

id = chatcmpl-376a3b53-d3cf-48f0-a27f-1082be8ca755
model = gpt-3.5-turbo
choices:
  Choice(finish_reason='stop', index=0, message=ChatCompletionMessage(content="  Recursion, oh gentle programmer's delight,\nA loop within a loop, a function's recursive might.\nIt's like a tree, you see, with branches so bright,\nEach one calling itself, until the morning light.\n\nIn recursion's embrace, a program unfolds,\nWith each iteration, a new tale to unfold.\nA solution's sought, with logic so neat,\nAnd in its heart, the loop can't be beat.\n\nIt starts with base, a seed so small and fine,\nAnd grows with each call, like vines entwining.\nThe function's name is echoed through the land,\nAs it loops and loops, a programmer's hand.\n\nWith each recursion, more complexity gained,\nThe program unfolds its secrets unrestrained.\nA dance of code, a symphony divine,\nRecursion weaves its magic, a programming shrine.\n\nSo here's to recursion, a tool so grand,\nThat makes our programs grow, in this digital land.\nWith it, we craft, with it we play,\nAnd bring our ideas to life each day.\n\nNow go forth, dear programmer, and code with cheer,\nFor recursion is the key that sets your spirit free.", role='assistant', function_call=None, tool_calls=None))
usage = CompletionUsage(completion_tokens=279, prompt_tokens=53, total_tokens=332)

elapsed time = 67.885 sec

1分を超えましたね…。

ちょっと長いので、質問を前回のエントリーと同じものにしてみましょう。

llama-cpp-pythonで、OpenAI API互換のサーバーを試す - CLOVER🍀

quickstart2.py

import time
from openai import OpenAI

start_time = time.perf_counter()

client = OpenAI(base_url = "http://localhost:8000/v1", api_key = "dummy-api-key")

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "user", "content": "Could you introduce yourself?"} 
    ]
)

elapsed_time = time.perf_counter() - start_time

print(completion)

print()

print(f"id = {completion.id}")
print(f"model = {completion.model}")

print("choices:")
for choice in completion.choices:
    print(f"  {choice}")

print(f"usage = {completion.usage}")

print()

print(f"elapsed time = {elapsed_time:.3f} sec")

変わったのはここですね。

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "user", "content": "Could you introduce yourself?"} 
    ]
)

確認。

$ python3 quickstart2.py
ChatCompletion(id='chatcmpl-037f969d-8a31-4324-995a-1a878e901bd1', choices=[Choice(finish_reason='stop', index=0, message=ChatCompletionMessage(content="  Hello! I'm LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. My primary function is to assist users with their inquiries and provide information on a wide range of topics. I'm here to help you with any questions or tasks you may have, so feel free to ask me anything! ", role='assistant', function_call=None, tool_calls=None))], created=1701510862, model='gpt-3.5-turbo', object='chat.completion', system_fingerprint=None, usage=CompletionUsage(completion_tokens=80, prompt_tokens=16, total_tokens=96))

id = chatcmpl-037f969d-8a31-4324-995a-1a878e901bd1
model = gpt-3.5-turbo
choices:
  Choice(finish_reason='stop', index=0, message=ChatCompletionMessage(content="  Hello! I'm LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. My primary function is to assist users with their inquiries and provide information on a wide range of topics. I'm here to help you with any questions or tasks you may have, so feel free to ask me anything! ", role='assistant', function_call=None, tool_calls=None))
usage = CompletionUsage(completion_tokens=80, prompt_tokens=16, total_tokens=96)

elapsed time = 20.823 sec

だいぶ短くなりました。

テキスト生成モデルのAPIをもう少し見てみる

role

いきなりこんな感じで使ってみましたが、これだと意味がよくわかりませんね。

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "system", "content": "You are a poetic assistant, skilled in explaining complex programming concepts with creative flair."},
        {"role": "user", "content": "Compose a poem that explains the concept of recursion in programming."} 
    ]
)

こちらでもう少し追ってみましょう。

Text generation models

今回使っているのは、Chat Completions APIです。

Text generation models / Chat Completions API

チャットモデルは、メッセージをリストとして受け取り、モデルが生成したメッセージを出力として返します。

Chat models take a list of messages as input and return a model-generated message as output.

APIリファレンスとしてはこちらですね。

OpenAI / API reference / ENDPOINTS / Chat

実際に呼び出しているのはこちらのAPIです。

OpenAI / API reference / ENDPOINTS / Chat / Create chat completion

modelには使用するモデルを指定します。今回はgpt-3.5-turboを指定しましたが、llama-cpp-pythonでは意味のないパラメーターです。
モデルの一覧はModelsに書かれています。

messagesには、roleとcontentを含めた辞書を渡します。

roleは役割を表すもので、以下が指定できます。

  • system … アシスタントの動作を設定する。性格やどのように動作するかの指示をする
  • user … アシスタントが応答するためのリクエストやコメント
  • assistant … アシスタント(通常はOpenAI、今回はllama-cpp-python)によるメッセージ

また、ツールの呼び出しに使うfunctionもあるようです。

どうしてassistantがあるのか?ですが、チャットを行っている際にOpenAI(今回はllama-cpp-python)は会話の内容を覚えている
わけではなく、一連の会話をすべて送信することでそれまでの内容を理解しているようです。
なので、チャットとして続ける場合にはassistantにはアシスタントが返してきたメッセージが入ることになります。

今回はローカルのllama-cpp-pythonを使っているので、Quickstartのサンプルをそのまま使うと2つのメッセージが入るので、それで
顕著に遅くなっている気がしますね…。

レスポンスをJSONにする

以下のように、モデルをgpt-3.5-turbo-1106として(OpenAIの場合)、response_format = { "type": "json_object" }と指定すると
レスポンスのchoices.message.contentの中身がJSONになるようです。

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo-1106",
    response_format = { "type": "json_object" },
    messages=[
        {"role": "user", "content": "Could you introduce yourself?"}
    ]
)

Text generation models / JSON mode

なのですが、llama-cpp-pythonではこの設定を入れると応答が返ってこなくなりました…。

複数のメッセージを返す

以下のようにnに2以上の値を指定すると、メッセージが複数返ってくるようになるらしいです(デフォルトは1)。

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "user", "content": "Could you introduce yourself?"}
    ],
    n = 2
)

返ってくるメッセージを多くすると、その分トークンも使うことになるので注意が必要です。
なのですが、llama-cpp-pythonでは2以上の値を指定しても返ってくるメッセージはひとつでした…。

再現可能なレスポンスにする

Chat Completion APIはデフォルトで非決定的で、結果はリクエストごとに異なる可能性があります。
これをできるだけ押さえるには、temperatureを0にします。

completion = client.chat.completions.create(
    model = "gpt-3.5-turbo",
    messages=[
        {"role": "user", "content": "Could you introduce yourself?"} 
    ],
    temperature = 0
)

Text generation models / Reproducible outputs

temperatureには0から2までの値を指定可能(少数可)で、値が小さいほど結果が確定的になり、値が大きいほどランダムになります。

このパラメーターはllama-cpp-pythonでも機能しました。

おわりに

llama-cpp-pythonで立てたOpenAI API互換のサーバーに対して、OpenAI Python APIライブラリーからアクセスしてみました。

遅いとか、効かないパラメーターがあるといった話はあるのですが、それでも動作はしてくれるので良いかなと。
どちらかというと、こうやってAPIを追いかけつつ概念などを学ぶことにも意味があるかなと思います。