selmertsxの素振り日記

ひたすら日々の素振り内容を書き続けるだけの日記

「ChatGPT/LangChainによるチャットシステム構築 」という書籍が素晴らしかったのでNode.jsでも書いてみた

はじめに

「ChatGPT/LangChainによるチャットシステム構築」 という本が素晴らしかったので、ちゃんと身につけるために Python だけじゃなくて Node.js でも動かしてみました。同じことをやろうとした人のために、ここにそのときの記録を残します。特に callbacksやmemoryについて、詳細に記載しようと思います。

書籍の説明につながるようなことはできる限り書きません!めっちゃ良書なので、ご興味持っていただけた方は購入してもらえますと 🙏

5章まではPython固有のToolを利用しており、6章の中身は7章とかなり近いところがあるので、7章のプログラムだけここに記載します。LangChainの学習に注力したいので、Serverelss Frameworkに関連するコードは省略しました。また、Momentoや @slack/bolt に関する説明はしません。

プログラムの全体像

今回作成したプログラムの全体像は下記のとおりです。

src
├── handler.ts
├── history.ts
└── index.ts
// index.ts
import bolt from "@slack/bolt";
import { ChatOpenAI } from "langchain/chat_models/openai";
import SlackBotCallbackHandler from "./handler";
import { createHistory } from "./history";

const { App } = bolt;

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
  port: 3000,
});

function CreateLLM(handler: SlackBotCallbackHandler) {
  return new ChatOpenAI({
    modelName: "gpt-4-1106-preview",
    temperature: 0,
    streaming: true,
    callbacks: [handler],
  });
}

app.event("app_mention", async ({ event, say }) => {
  const ts = event.thread_ts || event.ts;
  const botTypingMessage = await say({ thread_ts: ts, text: "Typing..." });
  if (!botTypingMessage.ts) return;

  /* create LLM Settings */
  const handler = new SlackBotCallbackHandler({
    channel: event.channel,
    ts: botTypingMessage.ts,
    app: app,
  });
  const llm = CreateLLM(handler);

  /* reply & add history */
  const histroy = await createHistory(event.channel);
  const userMessage = event.text.replace(/<@.*>/, "");
  await histroy.addUserMessage(userMessage);
  const aiMessage = await llm.predict(event.text.replace(/<@.*>/, ""));
  await histroy.addAIChatMessage(aiMessage);
});

(async () => {
  await app.start();
})();
// handler.ts
import { BaseCallbackHandler } from "langchain/callbacks";

type Props = {
  channel: string;
  ts: string;
  app: any;
};

export default class SlackBotCallbackHandler extends BaseCallbackHandler {
  name = "SlackBotCallbackHandler";

  private channel: string;
  private ts: string;
  private message: string = "";
  private lastTime: number = 0;
  private app: any;

  constructor({ channel, ts, app }: Props) {
    super();
    this.channel = channel;
    this.ts = ts;
    this.app = app;
  }

  handleLLMNewToken(token: string) {
    this.message = this.message + token;

    const currentTime = new Date().getTime();
    if (currentTime - this.lastTime > 2000 && token) {
      this.lastTime = currentTime;
      this.app.client.chat.update({
        channel: this.channel,
        ts: this.ts,
        text: this.message,
      });
    }
  }

  handleLLMEnd() {
    this.app.client.chat.update({
      channel: this.channel,
      ts: this.ts,
      text: this.message,
    });
  }
}
// history.ts
import { MomentoChatMessageHistory } from "langchain/stores/message/momento";
import {
  CacheClient,
  Configurations,
  CredentialProvider,
} from "@gomomento/sdk";

export function createHistory(sessionId: string) {
  return MomentoChatMessageHistory.fromProps({
    sessionId,
    cacheName: process.env.MOMENTO_CACHE as string,
    client: new CacheClient({
      configuration: Configurations.Laptop.v1(),
      credentialProvider: CredentialProvider.fromEnvironmentVariable({
        environmentVariableName: "MOMENTO_AUTH_TOKEN",
      }),
      defaultTtlSeconds: 60 * 60,
    }),
  });
}

LangChainの学習要素

7章において、callbacks と memory の2つの要素が出てきたので、この2点に注力して詳細を追っていこうと思います。

Callbacks

https://js.langchain.com/docs/modules/callbacks/

LangChainでは、Chain, LLM, Tool, Agent などのコンポーネントに対して Callbackを設定することができます。一番シンプルな使い方は下記のようになります。

// 公式より抜粋
import { OpenAI } from "langchain/llms/openai";
const model = new OpenAI({ maxTokens: 25, streaming: true });
const response = await model.call("Tell me a joke.", {
  callbacks: [{handleLLMNewToken(token: string){ console.log({ token })}},
});

上記のケースでは、LLMが新しい token を受け取ったときに console.log({token}) の処理を実行します。この機能を使えばリアルタイムに出力を表示したり、様々なlogを仕込んだりすることができるでしょう。今回のケースでは、下記のように新しい token を取得した際にメッセージを更新しています。

// 簡単のために一部省略
  handleLLMNewToken(token: string) {
    this.message = this.message + token;
    this.app.client.chat.update({
      channel: this.channel,
      ts: this.ts,
      text: this.message,
    });
  }

LangChain本体でのCallbackの利用

CallbackはLangChain本体でも使われています。例えば verboseオプションなどはConsoleCallbackHandlerを仕込んでいます。

// https://github.com/langchain-ai/langchainjs/blob/main/langchain-core/src/callbacks/manager.ts#L483
if (verboseEnabled || tracingEnabled) {
  if (!callbackManager) {
    callbackManager = new CallbackManager();
  }
  if (
    verboseEnabled &&
    !callbackManager.handlers.some(
      (handler) => handler.name === ConsoleCallbackHandler.prototype.name
    )
  ) {
    const consoleHandler = new ConsoleCallbackHandler();
    callbackManager.addHandler(consoleHandler, true);
  }

ConsoleCallbackHandlerのプログラムを一部抜粋したものがこちらです。console.logにて入力した情報などを出力している処理が見て取れます。

// https://github.com/langchain-ai/langchainjs/blob/main/langchain-core/src/tracers/console.ts#L154
onLLMStart(run: Run) {
  const crumbs = this.getBreadcrumbs(run);
  const inputs =
    "prompts" in run.inputs
      ? { prompts: (run.inputs.prompts as string[]).map((p) => p.trim()) }
      : run.inputs;
  console.log(
    `${wrap(
      color.green,
      "[llm/start]"
    )} [${crumbs}] Entering LLM run with input: ${tryJsonStringify(
      inputs,
      "[inputs]"
    )}`
  );
}

Pythonの方は様々なcallbackのintegrationsがありそうですが、Node.js の方はあんまりなそう?(勘違いだったらすみません!)なので、足りなければ基本的に自作する必要がありそうです。

Memory

https://js.langchain.com/docs/modules/memory/

LangChainのMemoryは、過去の会話を覚えておくためのモジュールです。

公式の一番シンプルな例を持ってくると下記のようになります。

import { ChatMessageHistory } from "langchain/memory";
const history = new ChatMessageHistory();
await history.addUserMessage("Hi!");
await history.addAIChatMessage("What's up?");
const messages = await history.getMessages();

今回のコードでは下記のように利用しています。

// history.ts
import { MomentoChatMessageHistory } from "langchain/stores/message/momento";
import {
  CacheClient,
  Configurations,
  CredentialProvider,
} from "@gomomento/sdk";

export function createHistory(sessionId: string) {
  return MomentoChatMessageHistory.fromProps({
    sessionId,
    cacheName: process.env.MOMENTO_CACHE as string,
    client: new CacheClient({
      configuration: Configurations.Laptop.v1(),
      credentialProvider: CredentialProvider.fromEnvironmentVariable({
        environmentVariableName: "MOMENTO_AUTH_TOKEN",
      }),
      defaultTtlSeconds: 60 * 60,
    }),
  });
}

7章においては、LLMの回答の生成にhistoryが活用されていないため、どういったアウトカムにつながるのかがイメージができないと思います。ぱっと、公式ドキュメントを読んだ感想としては、Prompt, Memory, History の役割は、RDBMSにおけるクエリー、クエリエンジン、テーブルっぽいという印象を受けました。そのあたりの詳細は8章にて、ちゃんと調査してまとめようと思います。

LangChainjsのライブラリにおけるhistory実装の仕組み

ライブラリを読んでみたところ実装は下記のようになっていました。 少し前のプログラムでclientにmomentoのCacheClientを渡していたので、 LangChainjsにおいてはmomentoのうすーいwrapperのような役割のみとなっていることが見て取れると思います。

// https://github.com/langchain-ai/langchainjs/blob/main/langchain/src/stores/message/momento.ts
  /**
   * Adds a message to the cache.
   * @param message The BaseMessage instance to add to the cache.
   * @returns A Promise that resolves when the message has been added.
   */
  public async addMessage(message: BaseMessage): Promise<void> {
    const messageToAdd = JSON.stringify(
      mapChatMessagesToStoredMessages([message])[0]
    );

    const pushResponse = await this.client.listPushBack(
      this.cacheName,
      this.sessionId,
      messageToAdd,
      { ttl: this.sessionTtl }
    );
    if (pushResponse instanceof CacheListPushBack.Success) {
      // pass
    } else if (pushResponse instanceof CacheListPushBack.Error) {
      throw pushResponse.innerException();
    } else {
      throw new Error(`Unknown response type: ${pushResponse.toString()}`);
    }
  }