🦜

LangChainやるならPythonよりTypeScriptの方がいんじゃね?

2023/12/10に公開

はじめに

LangChain を Python で動かしているソフトウェアエンジニアの方々に意見を聞いてみたいんですが、「LangChain やるなら Python より TypeScript の方がいんじゃね?」って思うことありません?ありますよね?・・・え?・・・・・・ない???・・・え?・・・あるでしょ?

ということで、LangChain の JS/TS 版の LangChain.js について書いていこうと思いますー。

TypeScript で書きたい想い

自分が Python の書き方よく分かってないってのが一番の理由だと思うんですが、LangChain 触ってると、TypeScript で書きたいなぁ、TypeScript で書きたいんよなぁ、TypeScript で書けたらいいのに・・・って思ったりしてます。Python の方が良いところもあるのは承知してるつもりなんですが、TypeScript で書けたら、もっとデバッグ楽なんだけどなー、書いてて気持ちいんだけどなー、実行中にエラーで止まらないだけどなーと思ったりしてます。

ということで(2 回目)、LangChain.js について調べたことを書いていきたいと思います。

前提

LangChain についてはある程度、知ってる前提で書こうと思います。
もし知らないかたいれば、この本すごくわかりやすかったのでおすすめです。
https://gihyo.jp/book/2023/978-4-297-13839-4

著者の方の Udemy もすごくわかりやすかったのでおすすめです。
https://www.udemy.com/course/langchain-apps/

公式ドキュメントも充実してるので見てみるといいと思います。
https://python.langchain.com/docs/get_started/introduction

また、kansai.ts ってコミュニティのイベントで発表した内容を元に加筆修正して書いてますー。
https://kansaits.connpass.com/event/299545/
https://speakerdeck.com/optimisuke/langchainyarunarapythonyoritypescriptnofang-gainziyane

ということで(3 回目)、次から、LangChain.js について詳しく書いていこうと思いますー。

LangChain.js?

LangChain.js は LangChain の JS/TS 版です。Python 版と同じく公式のコードです。
https://www.langchain.com/

ちなみに、Java とか Go とかも探したらあるけど、非公式っぽいです。なんで、公式は Python と TypeScript だけなんですかねー?

https://github.com/tmc/langchaingo
https://github.com/langchain4j/langchain4j

TypeScript といっても実行環境は色々あるんで調べてみると、結構色々な環境に対応してていい感じに動くことがわかります。

Supported Environments
LangChain is written in TypeScript and can be used in:

Node.js (ESM and CommonJS) - 18.x, 19.x, 20.x
Cloudflare Workers
Vercel / Next.js (Browser, Serverless and Edge functions)
Supabase Edge Functions
Browser
Deno
細かい話

TS/JS はここらがややこしくてあれなんですが、バックエンドで動かすのがメインな気がするので、とりあえず Node.js で動けばそれでいいきもします。ただ、昨今のいろんなところで JavaScript を動かしちゃう流れにのっていて、いろんなところで動きます。よく分かってないけど、エッジとかも。Vercel とか Cloudflare、Supabase の名前があるので、結構そこらへんにコントリビュートしてるんやろうなぁと思います。

Node.js で動くので、普通にバックエンドサーバーとしてデプロイできちゃいます。この後、少し説明する Vercel AI SDK を使ったら、ChatGPT みたいに文字がポコポコ出てくるストリームな API を簡単に作れるので、もうこれでいんじゃねって感じします。

https://sdk.vercel.ai/docs

ただ、どうしても Python の方が先に開発されてるっぽいので、新しい機能が後追いな印象です。ちなみに、Python の方だけ LangServe っていう FastAPI 使って REST API 作る機能があるみたいなんですが、TypeScript であれば Express でも Next.js でも NestJS でも好きなフレームワークで API 作れるので、気にしなくていいと思ってます。Web アプリは TypeScript/JavaScript のエコシステムの方が充実しているので、Python じゃなくて TypeScript でいいんじゃね感、強いです。

LangChain.js を推す 5 つの理由

改めて、ここで推しポイントも整理してみました。

  1. スクリプト言語で Web アプリ作るなら、どうせフロントエンドは TypeScript やし、バックエンドもそれでよくね?
  2. アプリ作るのはデータサイエンティストじゃないし、TypeScript でよくね?
  3. Python で型ヒント書くくらいなら、別に TypeScript で良くね?
  4. 中級者・上級者向けの Python 情報って検索するのつらくね?&TypeScript で検索すると必要な情報見つけやすくね?
  5. TypeScript ってなんかよくね?

最後、適当ですが、こんな気持ちです。Python の方がデータサイエンティストとかいろんなロールの人と一緒に作れたり、Jupyter Notebook で試行錯誤しながら作れたり、機械学習系のライブラリが充実してたり、良いところもあるとは思ってます。ただ、TypeScript の方が書いてて安心感あるんすよねー。Python は実行してからエラーで止まるのがほんと辛い・・・。動くか不安な気持ちで書き続けるの辛い。。。テスト駆動にしたり、型ヒント書いたり、良い解決方法はあるのかもしれないけど。

ある程度お気持ちを表明できたので、ここからは LangChain.js の使い方にうつっていきます。

LangChain.js 基礎編

まずは、シンプルにユーザー入力をプロンプトに埋め込んで、モデルに渡し、出力結果をパースするコードを見てみます。

import { ChatOpenAI } from "langchain/chat_models/openai";
import { ChatPromptTemplate } from "langchain/prompts";
import { StringOutputParser } from "langchain/schema/output_parser";

const prompt = ChatPromptTemplate.fromMessages([
  ["human", "Tell me a short joke about {topic}"],
]);
const model = new ChatOpenAI({});
const outputParser = new StringOutputParser();

const chain = prompt.pipe(model).pipe(outputParser);

const response = await chain.invoke({
  topic: "ice cream",
});
console.log(response);
/**
Why did the ice cream go to the gym?
Because it wanted to get a little "cone"ditioning!
 */

このコードはここらからコピペしたものです。
https://js.langchain.com/docs/expression_language/get_started
LangChain はドキュメントが充実していて、
構成はこんな感じになっていて、繋がっていく感じが"chain"っぽくなっています。

コードをみると、プロンプトとモデルとパーサーのインスタンスをそれぞれ作成して、pipeで繋いでchainを作って、最後にユーザー入力を想定した引数を渡して実行しています。とてもシンプルですが、LangChain の拡張性が高そうな雰囲気が醸し出されていると思います。

ちなみに、Python だと|を使って Linux のパイプっぽく処理を繋いでいけますが、TypeScript では、.pipeで繋いでいきます。パイプで繋ぐのも面白いと思いますが、論理和の演算子をオーバーロードするしてるのが黒魔術感あって、そこまでせんでもいいのにって思ったりします。使ってたら便利なのかもしれませんが。

最後のパース部分は、LLM の出力のテキスト部分を取り出してるだけですが、Json 等、いろんなパーサーがあるみたいです。

https://js.langchain.com/docs/modules/model_io/output_parsers/

LangChain.js 応用編

次に、LLM の良くある活用方法としての Retrieval-Augmented Generation (RAG)の構築方法についてみていきます。
RAG は、検索で拡張した生成ということで、質問文に関係する情報をデータベースから検索し、プロンプトの中に埋め込んで、LLM にいい感じの回答を作ってもらう仕組みのことです。とてもシンプルな方法ですが、LLM を再学習せずに、企業や個人のデータを元に文章を生成してもらうことができるようになります。OpenAI の GPTs を使うとドキュメントをアップロードして、その情報も踏まえて回答してくれるので、RAG と言えそうです。また、Microsoft Bing も web 上の検索結果を元に回答してくれるので立派な(ものすごく立派な)RAG です。

以下の例では、非常にシンプルなデータベースを準備して、質問文に似たデータをデータベースから検索し、結果をプロンプトにcontextとして入れ、回答を生成しています。

import { ChatOpenAI } from "langchain/chat_models/openai";
import { HNSWLib } from "langchain/vectorstores/hnswlib";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { PromptTemplate } from "langchain/prompts";
import {
  RunnableSequence,
  RunnablePassthrough,
} from "langchain/schema/runnable";
import { StringOutputParser } from "langchain/schema/output_parser";
import { formatDocumentsAsString } from "langchain/util/document";

const model = new ChatOpenAI({});

const vectorStore = await HNSWLib.fromTexts(
  ["mitochondria is the powerhouse of the cell"],
  [{ id: 1 }],
  new OpenAIEmbeddings()
);
const retriever = vectorStore.asRetriever();

const prompt =
  PromptTemplate.fromTemplate(`Answer the question based only on the following context:
{context}

Question: {question}`);

const chain = RunnableSequence.from([
  {
    context: retriever.pipe(formatDocumentsAsString),
    question: new RunnablePassthrough(),
  },
  prompt,
  model,
  new StringOutputParser(),
]);

const result = await chain.invoke("What is the powerhouse of the cell?");

console.log(result);

/*
  "The powerhouse of the cell is the mitochondria."
*/

これも公式ドキュメントのコードをコピペしたものです。
https://js.langchain.com/docs/expression_language/cookbook/retrieval

先ほどのコードより長いですが、雰囲気は同じです。
最初に、モデル・ベクターストア(データベース)・プロンプトあたりのインスタンスを作ります。また、ベクターストアから検索をしてくれるretrieverを作ります。chain は、pipe()ではなく、RunnableSequence.from()を使って作っています。まず、chain 呼び出し時の入力文字列をretrieverに入れて検索し、得られた複数の結果を String に変換してくれるformatDocumentsAsStringに渡し、プロンプトのcontextに埋め込みます。また、プロンプトのquestionのところには、chain の入力文字列をそのまま入れています。new RunnablePassthrough()の部分が、少し無駄に見えるかもしれませんが、そのまま入力するためにはこういうふうに書くらしいです。こんな感じで chain の最初に複数の入力があったら全てに chain 呼び出し時の入力が渡されるみたいです。そのあとは先ほどと同じようにプロンプトをモデルに渡し、パーサーで String 型にします。最後に、chain.invoke()で入力を渡して実行します。
retriever も prompt も model も String をインプットとして緩やかに繋がっていて、うまいことなっててすごいなーという印象です。

それぞれの動きについては下記 api ドキュメントに色々書かれているので、必要に応じてコードと一緒に真面目に読むのがいいかなと思います。例えば、以下のサイトをみると、VectorStoreRetriever はデフォルトでkという検索個数のパラメータが 4 だから、retriever の出力は 4 つの検索結果が含まれた配列で、formatDocumentsAsStringが 4 つの検索結果を改行で区切って一つの String にしてそうだなぁとかいうのが分かったりします。

https://api.js.langchain.com/classes/vectorstores_base.VectorStoreRetriever.html
https://api.js.langchain.com/functions/util_document.formatDocumentsAsString.html

Next.js + LangChain.js

次に、LangChain.js をもう少し知るために、テンプレートを紹介したいと思います。Next.js な上に、Vercel の SDK 使ったり、Vercel のプラットフォーム上にデプロイしていたり、Vercel の人が作ってる感ありますが、シンプルかつ実践的でいい感じです。中身は全部で 5 種類のチャットボットがあって、RAG パターンや Agent と呼ばれる LLM と api 呼び出しを連携させるパターンも試せます。

https://github.com/langchain-ai/langchain-nextjs-template

まずは、下記リンクから実際に動かしてみるのがお勧めです。Retrieval (RAG) の部分は下記リンクからは動かないので自分で色々設定して動かす必要あります。
https://langchain-nextjs-template.vercel.app/

コードも見てみると、一番シンプルなチャットボットの API は以下のようになっています。

import { NextRequest, NextResponse } from "next/server";
import { Message as VercelChatMessage, StreamingTextResponse } from "ai";

import { ChatOpenAI } from "langchain/chat_models/openai";
import { BytesOutputParser } from "langchain/schema/output_parser";
import { PromptTemplate } from "langchain/prompts";

export const runtime = "edge";

const formatMessage = (message: VercelChatMessage) => {
  return `${message.role}: ${message.content}`;
};

const TEMPLATE = `You are a pirate named Patchy. All responses must be extremely verbose and in pirate dialect.

Current conversation:
{chat_history}

User: {input}
AI:`;

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const messages = body.messages ?? [];
    const formattedPreviousMessages = messages.slice(0, -1).map(formatMessage);
    const currentMessageContent = messages[messages.length - 1].content;
    const prompt = PromptTemplate.fromTemplate(TEMPLATE);
    const model = new ChatOpenAI({
      temperature: 0.8,
    });
    const outputParser = new BytesOutputParser();

    const chain = prompt.pipe(model).pipe(outputParser);

    const stream = await chain.stream({
      chat_history: formattedPreviousMessages.join("\n"),
      input: currentMessageContent,
    });

    return new StreamingTextResponse(stream);
  } catch (e: any) {
    return NextResponse.json({ error: e.message }, { status: 500 });
  }
}

POST されてくる body の中のmessagesに現在のユーザー入力と過去のやり取りが含まれているので、それを分離する部分が少し複雑ですが、他は今までと同じような感じでとてもシンプルになっています。ここでは、Vercel AI SDK を使ってストリーム処理できるように、stream()で呼び出して、最後に Vercel AI SDK のStreamingTextResponse()に入れて返しています。Vercel AI SDK については後ほど説明します。
また、try catchしてエラーハンドリングしてたりします。
他のコードやフロントエンドのコードもいい感じなので、読んでみるといいと思います。

Stream 処理 (Vercel AI SDK)

上記テンプレートに出てきた Vercel AI SDK についても少し説明したいと思います。
https://sdk.vercel.ai/docs

一番の特徴は、LLM の API のレスポンスを stream でフロントエンドにむっちゃ簡単に渡せるところです。

https://sdk.vercel.ai/docs/concepts/streaming

コードみるのが早いかなと思うので、ドキュメントに書かれたコードを紹介します。
まずは、バックエンド(edgeにしてますが)がこんな感じです。

import { NextRequest } from "next/server";
import { Message as VercelChatMessage, StreamingTextResponse } from "ai";

import { ChatOpenAI } from "langchain/chat_models/openai";
import { BytesOutputParser } from "langchain/schema/output_parser";
import { PromptTemplate } from "langchain/prompts";

export const runtime = "edge";

const formatMessage = (message: VercelChatMessage) => {
  return `${message.role}: ${message.content}`;
};

const TEMPLATE = `You are a pirate named Patchy. All responses must be extremely verbose and in pirate dialect.
 
Current conversation:
{chat_history}
 
User: {input}
AI:`;

export async function POST(req: NextRequest) {
  const body = await req.json();
  const messages = body.messages ?? [];
  const formattedPreviousMessages = messages.slice(0, -1).map(formatMessage);
  const currentMessageContent = messages[messages.length - 1].content;

  const prompt = PromptTemplate.fromTemplate(TEMPLATE);

  const model = new ChatOpenAI({
    temperature: 0.8,
  });

  const outputParser = new BytesOutputParser();

  const chain = prompt.pipe(model).pipe(outputParser);

  const stream = await chain.stream({
    chat_history: formattedPreviousMessages.join("\n"),
    input: currentMessageContent,
  });

  return new StreamingTextResponse(stream);
}

先ほど、紹介した Next.js + LangChain.js のテンプレートとほぼ同じなのであれですが、最後にchain.stream()をよんで、それをnew StreamingTextResponse()に入れて返しているだけです。

次に、フロントエンドはこんな感じです。

"use client";

import { useChat } from "ai/react";

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();

  return (
    <div className="mx-auto w-full max-w-md py-24 flex flex-col stretch">
      {messages.map((m) => (
        <div key={m.id}>
          {m.role === "user" ? "User: " : "AI: "}
          {m.content}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <label>
          Say something...
          <input
            className="fixed w-full max-w-md bottom-0 border border-gray-300 rounded mb-8 shadow-xl p-2"
            value={input}
            onChange={handleInputChange}
          />
        </label>
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

useChat()を使うだけで、とってもシンプルで stream を意識しないで書けます。
ユーザーからのinputhandleSubmitで送って、LLM を含むバックエンドからのレスポンスをmessageとして受け取って、含まれるrole情報を元に、表示を切り替えている感じです。すごい。シンプル。ドキュメントも充実しているし、色々できそうで素晴らしいっすね。

中身を少し

最後に、LangChain.js の中身を見てみようと思います。

Model

こんな感じで、LLM モデルがいい感じに抽象化されているので、モデルを置き換えても chain で繋ぐ他のもの(プロンプトやパーサー等)は変更せずに動いたりします。

const model = new ChatOpenAI({
  modelName: "gpt-3.5-turbo",
});
const model = new ChatOllama({
  model: "llama2",
});

各モデルのクラスはこんな感じになっていて、BaseChatModelを extends したものになってます。中身は API 呼んでるだけですが、いい感じにラップされて置き換えやすくなってます。すごい。

export declare class ChatOpenAI<CallOptions extends ChatOpenAICallOptions = ChatOpenAICallOptions> extends BaseChatModel<CallOptions> implements OpenAIChatInput, AzureOpenAIInput
export declare class ChatOllama extends SimpleChatModel<OllamaCallOptions> implements OllamaInput

export declare abstract class SimpleChatModel<CallOptions extends BaseChatModelCallOptions = BaseChatModelCallOptions> extends BaseChatModel<CallOptions>

Runnable

そして、コードを見ているとやたら出てくるRunnableおよびRunnableLikeが各コンポーネントをチェーン状に繋いでいくためのポイントっぽいです。標準インターフェイスっていうのかな?まだ、雰囲気しか分かってませんが、どのコンポーネントも同じようなメソッドがあったり同じように呼び出せたりするのはRunnableです。また、どうもRunnableは Java 書く人には馴染み深いものっぽいです。Java 書かないので良くわかりませんが。。。自分でカスタムチェーンを作りたい時は、ここらをもう少し理解すればできるはずです。

https://js.langchain.com/docs/expression_language/interface
https://api.js.langchain.com/classes/schema_runnable.Runnable.html

export declare abstract class Runnable<RunInput = any, RunOutput = any, CallOptions extends RunnableConfig = RunnableConfig> extends Serializable

最後の最後に、chain の作り方ですが、今まで軽く触れたように、RunnableSequence.from()pipe()の二種類あります。どちらを使っても良いのですが、個人的には.pipe()を使った方が繋いでる感あるのと入力と出力の方向性が見えるので、いいなーと思ってます。

const answerChain = RunnableSequence.from([
  {
    context: RunnableSequence.from([(input) => input.question, retrievalChain]),
    chat_history: (input) => input.chat_history,
    question: (input) => input.question,
  },
  answerPrompt,
  model,
]);

const chain = prompt.pipe(model).pipe(outputParser);

おわりに

LangChain.js への想いと、使い方、Next.js や Vercel AI SDK との連携の仕方、LangChain.js の中身についてちょっとずつ整理してみました。読んでいただきありがとうございました。Python 版の LangChain だけでなく LangChain.js ユーザーが増えればいいなと思います。と言いつつ、自分も LangChain.js に少し入門した程度なので、もう少し真面目に使ってみたいなと思ってます。(もう少し Python わかるようになったら Python でいんじゃね?って思ってる可能性もありますが。)ということで、ではでは、ごきげんよう。

GitHubで編集を提案

Discussion