LangChainで作ったプログラムをexe化して誰でも実行できるようにする【TypeScript編】

Featured image of the post

はじめに

TypeScript版のLangChainで作ったプログラムをexe化する方法を解説する😊

そもそも何のためにexe化するのか

LangChainで作った試作プログラムを非エンジニアの人に見てもらいたい😊

非エンジニアに環境構築させたくない

さっそく試作プログラムを見てもらおう!と思ったが、Node.jsを使っているためそのままフォルダを渡しても実行できない💦

かといって非エンジニアに「Node.jsの環境構築をしてね」というのは気が引ける💦

💡
ということで環境構築なしで動くようにするためexe化する

作るもの
  • 独自データ(名探偵コナンの映画「⿊鉄の⿂影」の内容)について答えてくれるプログラムをexe化する
  • 画面(GUI)は作らない → CLI
  • 質問内容はコマンドライン引数で指定する
Image in a image block

💡
今回は最低限の労力で済ませるため画面は作らない

結論

nexeを使えばexe化できる。

✅しかしLangChainで使用しているhnswlibのバグ(?)があり、exeファイル1つだけ配布しても実行できない。

✅「exeファイル」 + 「いくつかのフォルダ」 があれば実行できる。

必要なもの
  • Open AIのAPIキー
  • Node.jsの開発環境

今から何をexe化する?

以前に作ったサンプルプログラムをexe化する✅

🔗
サンプルプログラムの解説

🚫 ただいま記事作成中です💭

ディレクトリ構成

主要なファイルの構成を紹介(Node.jsのライブラリなどは省略)

📦TSexe (プロジェクトフォルダの名前はTSexeとした)
 ┣ 📂index            // 事前に作ったインデックス(前回の記事で解説済み)
 ┃ ┣ 📜args.json
 ┃ ┣ 📜docstore.json
 ┃ ┗ 📜hnswlib.index
 ┣ 📂src
 ┃ ┣ 📜index.ts       // exe化したいソース(前回の記事から一部修正。後述)
 ┣ 📜.env             // 環境変数用ファイル。OpneAIのAPIキーが書いてある。

exe化するソース

上記のソースから少しだけ変更しているので、ソースの全貌を記載する。

変更後のindex.ts

// モデル
import { ChatOpenAI } from "langchain/chat_models/openai";
// 埋め込み
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
// ベクトル検索エンジン
import { HNSWLib } from "langchain/vectorstores/hnswlib";
// チェーン
// import { RetrievalQAChain } from "langchain/chains";
import { ConversationalRetrievalQAChain } from "langchain/chains";
// メモリー
import { BufferMemory } from "langchain/memory";

// .envの読み込み
import fs from 'fs';
const envFilePath = '.env';   // .envファイルのパスを指定(ルートディレクトリにある場合)

// ----------------------------------------------------------------------
// .envファイルを同期的に読み込む関数
function readEnvFile(filePath: string): { [key: string]: string } {
  const data = fs.readFileSync(filePath, 'utf-8');
  const lines = data.split('\n');
  const envData: { [key: string]: string } = {};

  for (const line of lines) {
    const trimmedLine = line.trim(); // 先頭と末尾の空白を取り除く
    if (trimmedLine && !trimmedLine.startsWith('#')) { // 空行とコメント行をスキップ
      const [key, value] = trimmedLine.split('=');
      envData[key] = value.replace(/(^"|"$)/g, ''); // ダブルクオーテーションを取り除く
    }
  }

  return envData;
}


// ----------------------------------------------------------------------
// 実行
export const runLlm = async () => {
  // APIキー読み込み
  try {
    // .envファイルを読み込む
    const envData = readEnvFile(envFilePath);
    const apiKey = envData['OPENAI_API_KEY'];

    // APIキーを環境変数にセットする
    process.env.OPENAI_API_KEY = apiKey;

  } catch (error) {
    console.error('.envファイルの読み込みに失敗しました:', error);
  }


  // 作成済みのインデックスを読み込む
  const vectorStore = await HNSWLib.load(
   path.join(__dirname, 'index'),                 // indexフォルダ
    new OpenAIEmbeddings()
  );
  // モデル
  const model = new ChatOpenAI({});
  // チェーン
  const chain = ConversationalRetrievalQAChain.fromLLM(
    model,
    vectorStore.asRetriever(),
    {
      qaChainOptions: {type: "stuff"},
      memory: new BufferMemory({
        memoryKey: "chat_history",
        inputKey: "question",
        outputKey: "text",
        returnMessages: true,
      }),
    // verbose: true,
    }

  );
  // 質問する
  const res = await chain.call({
    question: process.argv[2],    // コマンドライン引数から質問文を取得
  });
  
	// 回答を表示する
  console.log( res['text'] );
  
};

runLlm();

変更点1:APIキーの読み込み

APIキーの読み込み処理を変更した!

前回作ったソースはdotenvでOpenAIのAPIキーを読み込んでいたが、exe化すると動かなかった💦

対処方法として.envファイルからAPIキーを読み込む処理を自作した。

✅変更前

src/index.ts

require("dotenv").config();

✅変更後

src/index.ts

// .envファイルを同期的に読み込む関数
function readEnvFile(filePath: string): { [key: string]: string } {
  const data = fs.readFileSync(filePath, 'utf-8');
  const lines = data.split('\n');
  const envData: { [key: string]: string } = {};

  for (const line of lines) {
    const trimmedLine = line.trim(); // 先頭と末尾の空白を取り除く
    if (trimmedLine && !trimmedLine.startsWith('#')) { // 空行とコメント行をスキップ
      const [key, value] = trimmedLine.split('=');
      envData[key] = value.replace(/(^"|"$)/g, ''); // ダブルクオーテーションを取り除く
    }
  }

  return envData;
}

...
...
...

// APIキー読み込み
try {
  // .envファイルを読み込む
  const envData = readEnvFile(envFilePath);
  const apiKey = envData['OPENAI_API_KEY'];

  // APIキーを環境変数にセットする
  process.env.OPENAI_API_KEY = apiKey;

} catch (error) {
  console.error('.envファイルの読み込みに失敗しました:', error);
}

変更点2:質問をコマンドライン引数から取得

コマンドライン引数で任意の質問ができるようにした!

✅変更前

src/index.ts

// 質問する
const res = await chain.call({
  query: "ピンガはどんなキャラクターですか?",
});

✅変更後

src/index.ts

// 質問する
const res = await chain.call({
  question: process.argv[2],    // コマンドライン引数から質問文を取得
});

トランスパイルしておく

先程のソースはTypeScriptなので、トランスパイルしてJavaScriptファイルを生成しておく。

以下のコマンドを実行する✅

tsc

📦TSexe
 ┣ 📂dist
 ┃ ┣ 📜index.js        // index.tsをトランスパイルしたファイルができる
 ┣ 📂index
 ┣ 📜args.json
 ┃ ┣ 📜docstore.json
 ┃ ┗ 📜hnswlib.index
 ┣ 📂src
 ┃ ┣ 📜index.ts
 ┣ 📜.env

💡
TypeScriptの設定によってはindex.jsの出力先が異なるので以降の説明は適宜読み替えてください

exe化する

exe化に使うツール

どうやってexe化するのか調べるとNode.jsの「pkg」や「nexe」を使う方法が出てきた。

「pkg」は試したがうまくいかなかったので不採用❌

「nexe」でexe化に成功したのでこちらを紹介する⭕️

nexeをインストール

公式の説明のとおり以下のコマンドでインストールする。

npm install nexe -g

【もしエラーが出たらsudoを付ける】
sudo npm install nexe -g

Windowsの場合追加作業が必要

Windows環境では以下の手順が必要。

  1. Power Shellを管理者権限で起動し、以下の4つのコマンドを実行する。
    Set-ExecutionPolicy Unrestricted -Force
    iex ((New-Object System.Net.WebClient).DownloadString('https://boxstarter.org/bootstrapper.ps1'))
    get-boxstarter -Force
    Install-BoxstarterPackage https://raw.githubusercontent.com/nodejs/node/master/tools/bootstrap/windows_boxstarter -DisableReboots

  2. Visual Studio2019をインストールし、C++デスクトップアプリ開発をインストールする。
    Image in a image block

  3. 再びPower Shellに戻り以下のコマンドを実行する。
    npm config edit

  4. すると.npmrcが開くので最下行に以下を追記すれば作業完了!
    msvs_version=2019
    python=python3.8

参考サイト

exe化したいJavaScriptファイルを移動

exe化したいindex.jsをルートディレクトリに移動する。

📦TSexe
 ┣ 📂dist
 ┃ ┣ 📜index.js       // 移動元
 ┣ 📂index
 ┃ ┣ 📜args.json
 ┃ ┣ 📜docstore.json
 ┃ ┗ 📜hnswlib.index
 ┣ 📂src
 ┃ ┣ 📜index.ts
 ┣ 📜.env
 ┣ 📜index.js         // 移動先

nexeを使ってexe化

✅以下のコマンドでindex.jsをexe化できる。

nexe ./index.js --build --verbose --python python3 -r .env -r index

これでルートディレクトリにTSexe.exe(フォルダ名と同じ名前のexe)が生成される😊

(Macだと拡張子なしのTSexeというファイル名になる)

📦TSexe
 ┣ 📂dist
 ┃ ┣ 📜index.js
 ┣ 📂index
 ┣ 📜args.json
 ┃ ┣ 📜docstore.json
 ┃ ┗ 📜hnswlib.index
 ┣ 📂src
 ┃ ┣ 📜index.ts
 ┣ 📜.env
 ┣ 📜index.js
 ┣ 📜TSexe.exe           // exeができる😊

【補足】コマンドのオプションの解説
オプション解説
./index.jsルートディレクトリにあるindex.jsをexe化する。
--build
--python python3
Node.jsのバージョン16以降を使う場合に必要(?)
--verbose詳細なログを出力
-r .env.envをexeに含める。
-r indexindexをexeに含める。

exeを配布する

早速作ったexeを非エンジニアに配布して使ってもらう😊

✅実行してもらうには「exeファイル」と一緒に「node_modules」と「index」を配布する必要がある

「node_modules」と「index」は元のソースにあるものをコピペしてくるだけでOK!

💡
exeファイル単体だと動かなかったので注意!
詳細は後で説明する。

配布するフォルダ

📦myApp(任意の名前)
 ┣ 📜TSexe.exe
 ┣ 📂node_modules         // exeと同階層に置いておく。以下の3フォルダ以外は削除してOK。
 ┃ ┣ 📂bindings
 ┃ ┣ 📂file-uri-to-path
 ┃ ┗ 📂hnswlib-node
 ┣ 📂index                // exeと同階層に置いておく
 ┃ ┣ 📜args.json
 ┃ ┣ 📜docstore.json
 ┃ ┗ 📜hnswlib.index

exeを実行してみる

3パターンの実行方法を紹介する😊

Windowsのコマンドプロンプトの場合
フォルダ構成

場所:C:\hoge\conan

必要なファイル:「exe」と「実行に必要なもの(indexとnode_modules)」

Image in a image block

手順
  1. コマンドプロンプトを開く。

  2. exeがあるディレクトリに移動。
    cd ディレクトリのパス
    
    【例】
    cd C:\hoge\conan

  3. exeをコマンドライン引数付きで実行する。
    exeの名前 映画の内容を50文字以内に要約して
    
    【例】
    TSexe.exe 映画の内容を50文字以内に要約して
    Image in a image block

macのターミナルの場合
フォルダ構成

場所:/Users/ユーザー名/hoge/conan

必要なファイル:「exe」と「実行に必要なもの(indexとnode_modules)」

Image in a image block

手順
  1. ターミナルを開く。

  2. exeがあるディレクトリに移動。
    cd ディレクトリのパス
    
    【例】
    /Users/ユーザー名/hoge/conan

  3. exeをコマンドライン引数付きで実行する。
    ./実行ファイルの名前 質問
    
    【例】
    ./TSexe 映画の内容を50文字以内に要約して
    Image in a image block

VSCodeのターミナルの場合
フォルダ構成

場所:C:\hoge\conan

必要なファイル:「exe」と「実行に必要なもの(indexとnode_modules)」

Image in a image block

手順
  1. VSCodeを開く。
  2. exeがあるディレクトリをドラッグアンドドロップ
    Image in a image block

  3. exeをコマンドライン引数付きで実行する。
    ./実行ファイルの名前 質問
    
    【例】
    ./TSexe 映画の内容を50文字以内に要約して
    Image in a image block

注意点

セキュリティは非考慮

社内での利用を想定しているのでセキュリティは考慮していない。

⚠️
OpenAIのAPIキーが見られる恐れがある

コマンドでの操作

今回は最低限の労力で配布したかったので画面は作っていない。

⚠️
画面がほしい場合はexeを呼び出すGUIを作ったり、そもそもexe化ではなくelectronなどでデスクトップアプリ化してもいいかも

トラブルシューティング

hnswlibを使うとエラーになる

exeを実行するとhnswlibがインポートできないエラーが発生した。

Error: Could not import hnswlib-node. Please install hnswlib-node as a dependency with, e.g. `npm install -S hnswlib-node`.

原因

hnswlibは存在しているはずなのにエラーが出ていて原因が分からない💦

対処方法

試行錯誤した結果、node_modules内の「bindings」「file-uri-to-path」「hnswlib-node」の3つがあればエラーにならず実行できることが判明した!

Image in a image block

とりあえずexeと同じ階層にnode_modulesを置くことで対応した。

 ┣ 📜TSexe.exe
 ┣ 📂node_modules         // exeと同階層に置いておく。以下の3フォルダ以外は削除してOK。
 ┃ ┣ 📂bindings
 ┃ ┣ 📂file-uri-to-path
 ┃ ┗ 📂hnswlib-node

インデックスが見つからない

exeを実行すると同じ階層に「index」フォルダがあるのに、フォルダ存在しないというエラーが発生した。(Macのときだけエラーが出た。環境の問題…?)

Error while listing files: [Error: ENOENT: no such file or directory, scandir '/Users/xxxx/yyyy/index'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'scandir',
  path: '/Users/xxxx/yyyy/index'
}

原因

exe化するときにオプション-r indexを付けてindexフォルダを含めたはずなのにindexが見つからず原因が分からない💦

【補足】exe化のログにもindexフォルダが含まれている💭
✔ Including file: .env
✔ Including file: index/args.json
✔ Including file: index/docstore.json
✔ Including file: index/hnswlib.index
✔ Included 4 file(s)

ログのとおりならexeにindexが含まれているはずなのにindexが見つからない原因が不明💦

もしかするとnexeの仕様でフォルダは-rオプションで含められない…?と推測したが真偽は不明💭

対処方法

とりあえずexeと同じ階層にindexフォルダを置くことで対応した。

 ┣ 📜TSexe.exe
 ┣ 📂index         // exeと同階層に置いておく
 ┃ ┣ 📜args.json
 ┃ ┣ 📜docstore.json
 ┃ ┗ 📜hnswlib.index

異なるOSで実行できない

公式によるとexe化するときオプション--targetを付ければ異なるOS用の実行ファイルが作れるはず。

例:Windows環境でLinux用の実行ファイルを作る

しかしWindows環境で--target linux-x64を付けてもLinuxでは使えなかった😢

原因

--buildを付けると他のOSをターゲットに指定できないらしい😢

参考:https://github.com/nexe/nexe/issues/948

対処方法

現状直接の対処はなさそう…

どうしてもLinuxで実行したい場合は、面倒だがLinux環境でビルドする必要がある。

「Linuxが入ったPCでビルド」or「Linuxの仮想環境を作る」などの対応が必要そう(未検証)