32
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Streamingに対応したFakeLLMを作ってLangChainアプリケーション開発を爆速にしよう

Last updated at Posted at 2024-06-17

LLMを使ったアプリ開発での課題

LangChain入門3ヶ月目のtubone24です。よろしくお願いします。

皆さん、LangChainLLMを使ったアプリケーション作ってますか?

LLMを使ったアプリケーションを開発しているとしばしばLLMのトークン使用料に悩まされて月末の請求に震え上がる日々を過ごすことがあるかと思います。

プロンプトを調整したり、LLMの出力を調整するだけでなく、ちょっとUIを直したり、LLMに関係ない機能を追加するときにも実装中・テストなどでたくさんLLMを叩いたりすることでしょう。

すると....

スクリーンショット 2024-06-17 23.04.18.png

「ぎゃーーーーーー!」

大変なことになってしまいます。LLM怖い。

また、コストだけでなく、一回あたりの実行時間もかかってしまうので、開発のイテレーションが上がらず、生産性爆下がりでこれまた困ってしまいます。

スクリーンショット_2024-06-17_23_16_24.png

「うーん!命短し恋せよ乙女なのに、1分近くも待てないよぉー!」

この課題を解決するべく、FakeLLMを使っていきましょう、というのが今回の記事のお話です。

FakeLLMとは

FakeLLMとは、その名の通り、LLMの出力を模擬した偽物のLLMのことです。
テストコードを書いたことがある人なら、LLMのMockという表現がしっくりくるかと思います。

実はFakeLLM自体は、LangChainが提供してるもの(FakeListLLM)もあるので、多くの場合はこちらで十分だと思います。

使い方は簡単で、今LLM Modelとして定義してる箇所をそっくりFakeLLMに置き換えてしまえばOKです。

from langchain.llms.fake import FakeListLLM

responses=[
    "あのイーハトーヴォのすきとおった風",
    "夏でも底に冷たさをもつ青いそら",
    "うつくしい森で飾られたモリーオ市"
    "郊外のぎらぎらひかる草の波"
]

llm = FakeListLLM(responses=responses)

chain = prompt | llm

response = chain.invoke({"input": "ポラーノの広場を朗読して"})

print(response.content)

> あのイーハトーヴォのすきとおった風

response = chain.invoke({"input": "続きを朗読して"})

print(response.content)

> 夏でも底に冷たさをもつ青いそら

とっても簡単ですね。

Callbacks(Streaming)と絡めたときの扱いにくさ

ここまでとても便利なツールとしてLangChain提供のFakeListLLMを紹介しましたが、残念ながら実用するにはあと少し足りないのです。

それは、Streaming処理をCallbackで対応してるアプリケーションだとStreaming処理が全く再現できないということです。

ChainやAgentを組んでるとき、invokeメソッドを実行してresponseを取るかと思うのですが、特にStreamlitなどを使う場合や、主処理とは別でLLMのStreamingを記録する場合など、Streamingの生成テキストはresponseとは別にCallbackで扱うことが多いと思います。

例として次のような実装があるとしましょう。

from typing import Text
from langchain.callbacks.base import BaseCallbackHandler
from langchain_community.chat_models import BedrockChat
import streamlit as st


# Streamlitのcontainerを受け取り、受け取ったcontainerに生成テキストを書き出す
class StreamHandler(BaseCallbackHandler):
    def __init__(self, container: st.container, init_text: Text = ""):
        self.init_text = init_text
        self.container = container

    def on_llm_new_token(self, token: Text, **kwargs) -> None:
        st.session_state.text += token
        self.container.markdown(st.session_state.text)



container = st.container()

stream_handler = StreamHandler(container)

model = BedrockChat(
    model_id="anthropic.claude-3-opus-20240229-v1:0",
    region_name="us-west-2",
    streaming=True,
    callbacks=[stream_handler],
    model_kwargs={"max_tokens": 4096},
    )

model.invoke({"input": "あのイーハトーヴォのすきとおった風の続きを出して"})

Bedrock Chat(LLM)からStreamlingで生成テキストができるたびに、StreamHandlerのon_llm_new_tokenが呼び出されるため、st.containerにテキストが記録されていきます。Streamlitを用いた実装でよくある方式だと思います。

test.gif

(Claude3 Opusのガードレールが優秀で、例の文章をそのまま出力しませんね...。)

これをFakeListLLMに置き換えてしまうと、Fake LLMからon_llm_new_tokenが呼び出されないので、Streamlingが一切動きません。

これだと、生成されたテキストがStreamingしながら表示されるようなLLMアプリケーション特有の動きが確認できないので不便極まりないです。

LangChainのLLM ModelはBaseModelがある

ちょっと脱線しますが、LangChainのChatModel(例えばBedrockChat)はBaseChatModelを継承して作られてます。

これにより、様々なモデル間の違いをうまく吸収し、共通のメソッドを実行することで出力を得ることができるので、実装が容易になってるのですがこれはFakeLLMにも活かせます。

CallbackでStreaming処理ができるように改造したFakeLLMを作ってあげることで対処できそうです。

ということで作ってみた

全体の実装は下記のGistを見ていただければと思いますが、次のようにBaseChatModelを継承してFakeLLMを作ります。

https://gist.github.com/tubone24/bc25e6f2f3ccb37e685fd0007cd36bfd#file-fake_list_chat_model_with_streaming-py

_streamがstreamingの処理を定義したメソッドなのですが、Callbackで渡されるCallbackManagerForLLMRunに対して直接on_llm_new_tokenを呼んであげるだけです。

一応、それっぽく、sleepを入れたりテキストをchankに分割して出力したりする処理も入れてます。

このあたりは、いくつかLangChainのModelソースコードを見てみるとヒントが散りばめられてます。

from langchain_core.language_models import BaseChatModel
from typing import List, Dict, Optional, Any, Iterator
from collections import defaultdict
from langchain_core.callbacks import (
    CallbackManagerForLLMRun,
)
from langchain_core.messages import (
    AIMessage,
    AIMessageChunk,
    BaseMessage,
)
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from time import sleep

class FakeListChatModelWithStreaming(BaseChatModel):

    model_id: str = "fake-chat-model-with-streaming"
    streaming: bool = True
    responses: List[str] = []
    sleep_time: int = 0

    (中略)

    def _stream(
            self,
            messages: List[BaseMessage],
            stop: Optional[List[str]] = None,
            run_manager: Optional[CallbackManagerForLLMRun] = None,
            **kwargs: Any,
    ) -> Iterator[ChatGenerationChunk]:

        for response in self.responses:
            for chunk in [char for char in response]:
                sleep(self.sleep_time)
                run_manager.on_llm_new_token(chunk)
            delta = response
            yield ChatGenerationChunk(message=AIMessageChunk(content=delta))


model = FakeListChatModelWithStreaming(
   sleap_time=0.01,
   callbacks=[stream_handler],
   responses=["あのイーハトーヴォのすきとおった風〜(省略)"]
   streaming=True)


model.invoke({"input": "あのイーハトーヴォのすきとおった風の続きを出して"})

test.gif

うまくいきましたね!

結論

Fake LLMを使いたい際、StreamingをCallbackを実装しているとうまくいかない問題がありましたが、LangChainの実装に従って新しいLLM Classを作ることで解決できます。

LangChainのコードはPythonを書いたことがある人なら、比較的読みやすいコードになっていると思いますので、ドキュメントに載ってないようなニッチなことも実装できる気がします。

Let's LangChain!

32
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?