AI

FakeLLMとは|LangChainのテスト用ダミーLLMの使い方とStreaming・Callbacksの落とし穴

LangChain の FakeLLM は、API を叩かずにアプリのロジックだけをテストするためのダミーLLMです。あらかじめ渡した文字列を順番に返すだけで、推論もトークン課金も発生しません。ところがクラスが6つあり、どれを選ぶかで stream() のチャンク分割もコールバックの発火も変わります。特に FakeStreamingListLLM は1文字ずつ流れているように見えて on_llm_new_token が一度も呼ばれません。この記事では langchain-core 1.5.4 での実行結果をもとに、6クラスの使い分け、Streaming の挙動差、コールバックが沈黙する原因と回避策までを整理します。

まとめ

結論から示します。

  • LangChain の FakeLLM は langchain_core.language_models にあるテスト用のモックです。軽量な実用LLMではなく、推論は一切しません。
  • pip install fakellm で入るのは LangChain とは無関係の別パッケージ(OpenAI/Anthropic 互換のモックHTTPサーバー)です。from fakellm import FakeLLM は ImportError になります。
  • 単純な文字列応答なら FakeListLLM、文字単位の Streaming を見たいなら FakeStreamingListLLMコールバックまで検証したいなら FakeListChatModel を選びます。
  • FakeStreamingListLLMstream() 自体を上書きしているため on_llm_new_token が発火せず、on_llm_end が最初のチャンクより先に来ます。ストリーミングUIのコールバック検証には使えません。
  • ツール呼び出しは GenericFakeChatModelinvoke() なら再現できます。stream()content が空だと ValueError、非空だと例外を出さずに tool_calls だけが消えます。

以降で、各クラスの実測値と、テストが「通っているのに何も検証していない」状態に陥る条件を順に見ていきます。

FakeLLMの正体|LangChainのテスト用モックと同名PyPIパッケージの取り違え

FakeLLM という語は2つの別物を指します。この取り違えが、動かないサンプルコードが出回る原因になっています。

langchain-core 1.5.4 が公開する6クラスと選び分け

LangChain 本体の Fake 群は langchain_core.language_models にまとまっています。1.5.4 の __all__ は19要素あり、そのうち Fake 系は次の6クラスです。

クラス 種別 渡すもの stream()のチャンク on_llm_new_token
FakeListLLM LLM responses(文字列リスト) 応答まるごと1個 発火しない
FakeStreamingListLLM LLM responses(文字列リスト) 1文字ずつ 発火しない
FakeListChatModel チャットモデル responses(文字列リスト) 1文字ずつ 発火する
FakeMessagesListChatModel チャットモデル responses(BaseMessageリスト) 応答まるごと1個 発火しない
GenericFakeChatModel チャットモデル messages(イテレータ) 空白区切り 発火する
ParrotFakeChatModel チャットモデル 不要(入力を反射) 応答まるごと1個 発火しない

実務でまず押さえるべきは FakeListLLMFakeListChatModel の2つです。前者はチェーンの配線確認、後者はコールバックまで含めた検証に使います。残り4つは、文字単位の遅延再現(FakeStreamingListLLM)、ツール呼び出しやメッセージ属性の再現(GenericFakeChatModel)、入力の反射(ParrotFakeChatModel)といった目的が決まったときだけ出番があります。表の「応答まるごと1個」は _stream を実装していないクラスの既定動作で、stream() を呼んでも例外にはならず1チャンクだけ返ります。

なお fake_chat_models モジュールには FakeChatModel も実在しますが __all__ に載っていません。公開APIとして数えるのは上の6つです。

インポート元を間違えやすい点にも触れておきます。langchain_core.llmslangchain_core.chat_models はモジュールとして存在せず、いずれも ModuleNotFoundError です。旧世代の記事にある langchain_community.llms.fake は最新の langchain-community 0.4.2 にも同梱されていて今も動きますが、import すると DeprecationWarning: langchain-community is being sunset and is no longer actively maintained. が出ます。新規のコードは langchain_core.language_models から読んでください。LangChain 本体のパッケージ再編については LangChain v1.0とは|create_agent・ミドルウェア・langchain-classicを実装例で解説で整理しています。

pip install fakellm で入る別物|0.3.5のモックHTTPサーバー

PyPI には fakellm という名前のパッケージが実在します。ただし LangChain とは無関係です。2026年8月時点の最新は 0.3.5 で、説明は「A mock LLM server for testing. Drop-in replacement for OpenAI and Anthropic APIs.」。OpenAI/Anthropic の HTTP API を喋るモックサーバーを立て、YAML で応答を定義して使う CLI ツールです。

ここが厄介な点です。Python 3.10 以上の環境なら pip install fakellm成功します。エラーが出ないので、そのまま次の行を書いてしまいます。

# langchain-core と fakellm を同じ venv に入れた状態で実行
from fakellm import FakeLLM
# ImportError: cannot import name 'FakeLLM' from 'fakellm' (.../site-packages/fakellm/__init__.py)

インストールは通るのにインポートで落ちるため、原因を「バージョンが古い」「環境が壊れた」と誤診しがちです。LangChain の FakeLLM を使いたいなら、追加インストールは要りません。langchain-core に最初から入っています。

pip install langchain-core

逆に、HTTP レイヤごとモックしたい場合はこの PyPI 版が使えます。手順は3つです。

pip install fakellm
fakellm init     # fakellm.yaml を生成し、応答を定義する
fakellm serve    # http://127.0.0.1:9999 で待ち受ける

あとはクライアント側の base_url をこのアドレスへ向けるだけです。LangChain の Fake クラスがフレームワーク内部で応答を差し替えるのに対し、こちらはネットワーク境界で差し替えます。SDK の認証・リトライ・タイムアウト処理まで通したいときはこちらが向きます。

FakeListLLMの基本動作|responsesの巡回とinvokeの戻り値

FakeListLLMresponses に渡した文字列を先頭から順に返します。リストの末尾まで到達すると内部カウンタ i が 0 に戻り、先頭から巡回します。

from langchain_core.language_models import FakeListLLM

llm = FakeListLLM(responses=["A", "B"])
print([llm.invoke("x") for _ in range(4)])
# ['A', 'B', 'A', 'B']

この巡回仕様はテスト設計に影響します。応答が尽きたら例外を出す実装なら「LLMを想定回数だけ呼んだか」を副作用で検証できますが、FakeListLLM は静かに先頭へ戻ります。

では呼び出し回数はどう数えるか。llm.i を見る方法は、len(responses) を超えない範囲でしか使えません。カウンタ自身も巡回するからです。実測では responses が2件のとき、invoke を6回呼んでも i は 1, 0, 1, 0, 1, 0 と往復するだけでした。呼び出し回数が要素数を超えうるなら、コールバックハンドラで on_llm_start を数えてください。

コンストラクタ引数 sleep にも注意点があります。FakeListLLM にも定義されていますが、ソースの docstring に「Ignored by FakeListLLM, but used by sub-classes.」と明記されているとおり、このクラスでは無視されます。遅延を入れるなら FakeStreamingListLLMFakeListChatModel です。

Streaming時のチャンク分割|クラス別の粒度差

「Streaming に対応しているか」はクラスごとに答えが異なります。langchain-core 1.5.4 で同じ文字列を流したときの実測値が下記です。

同一入力で比較したチャンク数の実測

from langchain_core.language_models import (
    FakeListLLM, FakeStreamingListLLM, FakeListChatModel,
)

print(list(FakeListLLM(responses=["こんにちは世界"]).stream("q")))
# ['こんにちは世界']  ... 1チャンク

print(list(FakeStreamingListLLM(responses=["こんにちは世界"]).stream("q")))
# ['こ', 'ん', 'に', 'ち', 'は', '世', '界']  ... 7チャンク

print([c.content for c in FakeListChatModel(responses=["こんにちは世界"]).stream("q")])
# ['こ', 'ん', 'に', 'ち', 'は', '世', '界']  ... 7チャンク

FakeListLLM にも stream() はあります。ただし専用の _stream を持たないため、BaseLLM.streamtype(self)._stream == BaseLLM._stream という比較で未実装を検知し、invoke() の結果をそのまま1チャンクだけ yield します。チャンク境界の処理(バッファリング、途中でのUI更新、途中キャンセル)を検証したいテストで FakeListLLM を使うと、境界が1つしか無いので何も検証できません。

GenericFakeChatModelの分割規則|空白を保持する分割

GenericFakeChatModel だけは分割規則が違います。ソースでは re.split(r"(\s)", content) を使っており、空白文字自体も独立したチャンクとして残ります。

from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import AIMessage

g = GenericFakeChatModel(messages=iter([AIMessage(content="hello fake world")]))
print([c.content for c in g.stream("q")])
# ['hello', ' ', 'fake', ' ', 'world']  ... 5チャンク

英文なら実際のトークン単位に近い挙動になります。ただし日本語は空白を含まないため分割されず1チャンクのままです。日本語UIで逐次描画の見た目を確認したい場合、このクラスは適していません。文字単位で流すなら FakeListChatModel を選びます。

Callbacksが発火しない落とし穴|FakeStreamingListLLMでon_llm_new_tokenが0回

ここが FakeLLM で最も事故が起きる箇所です。FakeStreamingListLLM は1文字ずつチャンクを返すのに、コールバックハンドラの on_llm_new_token一度も呼ばれません。同じハンドラを2つのクラスに付けて、イベントの発生順を記録した結果を並べます。

from langchain_core.language_models import FakeStreamingListLLM, FakeListChatModel
from langchain_core.callbacks import BaseCallbackHandler

EV = []

class H(BaseCallbackHandler):
    def on_llm_start(self, *a, **k): EV.append("on_llm_start")
    def on_llm_new_token(self, token, **k): EV.append(f"on_llm_new_token({token})")
    def on_llm_end(self, *a, **k): EV.append("on_llm_end")

EV.clear()
for c in FakeStreamingListLLM(responses=["abc"]).stream("q", config={"callbacks": [H()]}):
    EV.append(f"yield({c})")
print(EV)
# ['on_llm_start', 'on_llm_end', 'yield(a)', 'yield(b)', 'yield(c)']

EV.clear()
for c in FakeListChatModel(responses=["abc"]).stream("q", config={"callbacks": [H()]}):
    EV.append(f"yield({c.content})")
print(EV)
# ['on_llm_start', 'on_llm_new_token(a)', 'yield(a)', 'on_llm_new_token(b)', 'yield(b)',
#  'on_llm_new_token(c)', 'yield(c)', 'on_llm_end']

違いは2点あります。FakeStreamingListLLM では on_llm_new_token が0回。そして on_llm_end最初のチャンクより先に記録されています。実際のモデルなら最後に来るイベントが、先頭に来ています。

原因はソースを見ると明確です。langchain_core/language_models/fake.pyFakeStreamingListLLM は、_stream を実装するのではなく Runnable の stream()astream() そのものを override しています。中身は self.invoke(input, config) を先に完走させ、返ってきた文字列を後から1文字ずつ yield するだけです。

def stream(self, input, config=None, *, stop=None, **kwargs):
    result = self.invoke(input, config)   # ここで完結し on_llm_end まで発火
    for i_c, c in enumerate(result):
        if self.sleep is not None:
            time.sleep(self.sleep)
        if self.error_on_chunk_number is not None and i_c == self.error_on_chunk_number:
            raise FakeListLLMError
        yield c

チャンクを刻んでいるループはコールバックマネージャの外側にあります。だからトークンイベントが流れません。一方 FakeListChatModel_stream を実装しており、チャットモデルの基底クラスが各チャンクで run_manager.on_llm_new_token を呼ぶため、実モデルと同じ順序になります。

結論として、コールバック経由でトークンを受けるコード(WebSocket配信、ストリーミングUI、トークン数カウンタ、監査ログ)のテストに FakeStreamingListLLM を使うべきではありません。テストはグリーンになりますが、ハンドラは一度も呼ばれていないので実質ノーテストです。この用途では FakeListChatModel、または英語圏のトークン境界を再現したい場合のみ GenericFakeChatModel に置き換えてください。FakeStreamingListLLM が向くのは、コールバックを介さずジェネレータを直接消費する処理の検証と、sleep でチャンク間の遅延を再現したいケースに限られます。

ツール呼び出しテストの適用範囲|GenericFakeChatModelがtool_callsを落とす2条件

エージェントのテストでは、モデルにツール呼び出しを返させたい場面があります。GenericFakeChatModelAIMessage をそのまま渡せるので一見使えそうですが、invoke()stream() で対応範囲が分かれます。

from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import AIMessage

msg = AIMessage(
    content="",
    tool_calls=[{"name": "get_weather", "args": {"city": "Tokyo"}, "id": "call_1"}],
)

# invoke なら tool_calls はそのまま保持される
print(GenericFakeChatModel(messages=iter([msg])).invoke("q").tool_calls)
# [{'name': 'get_weather', 'args': {'city': 'Tokyo'}, 'id': 'call_1', 'type': 'tool_call'}]

# stream は例外
list(GenericFakeChatModel(messages=iter([msg])).stream("q"))
# ValueError: No generation chunks were returned

_stream の実装が見ているのは contentadditional_kwargs の2つだけで、現行の tool_calls フィールドは対象外です。content が空文字だとチャンクが1つも生成されず、チャットモデルの基底クラスが「返すものが無い」と判断して ValueError を投げます。

より危険なのは content に何か入れた場合です。例外は出ません。そのかわり tool_calls が黙って消えます。

msg = AIMessage(
    content="ok done",
    tool_calls=[{"name": "get_weather", "args": {"city": "Tokyo"}, "id": "call_1"}],
)
for c in GenericFakeChatModel(messages=iter([msg])).stream("q"):
    print(repr(c.content), c.tool_calls, c.tool_call_chunks)
# 'ok'   [] []
# ' '    [] []
# 'done' [] []

チャンクは3つ流れ、どれも tool_calls は空リストです。テストは通ります。しかしツール呼び出しは一度も検証されていません。この記事の前半で扱ったコールバックの落とし穴と同じ構図が、ここでも起きます。

ストリーミングでツール呼び出しを再現するには、旧形式の additional_kwargsfunction_call を入れます。引数文字列はカンマで分割されて流れます。

msg = AIMessage(
    content="",
    additional_kwargs={
        "function_call": {"name": "get_weather", "arguments": '{"city":"Tokyo","unit":"c"}'}
    },
)
for c in GenericFakeChatModel(messages=iter([msg])).stream("q"):
    print(c.additional_kwargs)
# {'function_call': {'name': 'get_weather'}}
# {'function_call': {'arguments': '{"city":"Tokyo"'}}
# {'function_call': {'arguments': ','}}
# {'function_call': {'arguments': '"unit":"c"}'}}
# {}

もう1つの制約として、Fake 系チャットモデルは bind_tools() に対応していません。FakeListChatModel(responses=["ok"]).bind_tools([])NotImplementedError になります。ツールスキーマのバインドまで含めた経路を検証したいなら、Fake ではなく HTTP レベルのモック(前述の PyPI 版 fakellm のようなモックサーバー)か、実モデルを使った統合テストに切り替える判断が要ります。グラフ構造でのエージェント制御を検証したい場合は LangChainとLangGraphの違い|v1.0で逆転した関係と使い分けもあわせて確認してください。

pytestへの組み込み|エラー注入・遅延・Embeddingのモック

Fake を使う目的の多くは、正常系ではなく異常系の再現です。実モデルでは狙って起こせない状況を、パラメータで作れます。

error_on_chunk_numberによるチャンク途中での中断

error_on_chunk_number は指定したインデックスのチャンクで例外を送出します。このフィールドを持つのは FakeStreamingListLLMFakeListChatModel の2クラスだけで、送出される例外はそれぞれ FakeListLLMErrorFakeListChatModelError です。

import pytest
from langchain_core.language_models import FakeListChatModel
from langchain_core.language_models.fake_chat_models import FakeListChatModelError

def test_stream_interrupted_midway():
    model = FakeListChatModel(responses=["abcdef"], error_on_chunk_number=3)
    received = []
    with pytest.raises(FakeListChatModelError):
        for chunk in model.stream("q"):
            received.append(chunk.content)
    # 3チャンク受信後に中断される
    assert received == ["a", "b", "c"]

sleepの適用範囲|クラスで違う遅延の入り方

sleep は待ち時間を挟みますが、どこに挟まるかがクラスで違います。FakeListChatModel_call_stream の両方に適用するため invoke() でも遅延します。FakeStreamingListLLM はチャンク間だけです。タイムアウト処理を試すなら前者、プログレス表示の描画間隔を見るなら後者を選びます。値は 0.01 秒程度に抑えないとテスト時間が伸びます。responses を複数用意しておけば、リトライ時に別の応答を返す挙動も巡回で再現できます。

FakeEmbeddingsとDeterministicFakeEmbedding|numpy必須の注意

LLM だけモックしても、RAG 構成では Embedding API の課金が残ります。langchain_core.embeddings__all__ には FakeEmbeddingsDeterministicFakeEmbedding があり、前者は毎回ランダム、後者はテキストのハッシュから決定的なベクトルを返します。類似度の期待値をアサートするテストでは後者が必須です。

from langchain_core.embeddings import DeterministicFakeEmbedding

emb = DeterministicFakeEmbedding(size=4)
assert emb.embed_query("langchain") == emb.embed_query("langchain")  # 何度呼んでも同じ

ここに1つ罠があります。langchain-core 1.5.4 の依存に numpy は含まれておらず、fake.py 側も numpy の import 失敗を握りつぶす書き方をしています。そのため numpy を入れていない環境では、import は通るのに embed_query() を呼んだ瞬間 NameError: name 'np' is not defined で落ちます。pip install numpy を明示してください。検索側のモックについては LangChain BM25Retrieverの使い方|インストール・日本語対応・ハイブリッド検索で扱っている BM25 のようにベクトル化不要の Retriever を使う手もあります。

なお、これらは Python 版に固有の実装です。LangChain.jsとは|JavaScript/TypeScriptでLLM・エージェントを作る導入と使い方で扱う JS 版はクラス構成が異なるため、同じ名前で読み替えないでください。

よくある質問

FakeLLMとは何ですか?

LangChain が提供するテスト用のダミーLLMです。langchain-core 1.5.4 では langchain_core.language_models にある FakeListLLM などのクラス群を指し、渡した文字列を順番に返すだけで推論は行いません。API キーもネットワーク接続も不要で、課金もレート制限も発生しないため、チェーンやエージェントの配線・分岐・例外処理を単体テストで確かめる用途に使います。実運用のモデルとして推論品質を出すものではない点が、軽量な実用LLMとの最大の違いです。

LangChainのFakeLLMはどこからインポートしますか?

from langchain_core.language_models import FakeListLLM です。langchain-core に同梱されているため追加インストールは不要です。langchain_core.llmslangchain_core.chat_models というモジュールは存在せず ModuleNotFoundError になります。langchain_community.llms.fake も 0.4.2 時点で動きますが、パッケージ自体が sunset 宣言済みで DeprecationWarning が出ます。pip install fakellm は LangChain と無関係の別パッケージなので実行しないでください。

FakeListLLMとFakeStreamingListLLMはどちらを使うべきですか?

応答文字列の内容だけを検証するなら FakeListLLM で十分です。stream() は応答まるごと1チャンクを返します。チャンク境界の処理を確かめたい場合は FakeStreamingListLLM が1文字ずつ返しますが、on_llm_new_token が発火しないという制約があります。コールバック経由でトークンを受けるコードをテストするなら、どちらでもなく FakeListChatModel を選んでください。

Streamingでcallbacksが呼ばれないのはなぜですか?

FakeStreamingListLLM を使っている場合、実装が Runnable の stream() 自体を override しており、内部で invoke() を完走させてから結果を1文字ずつ yield しているためです。チャンクを刻むループがコールバックマネージャの外にあるので on_llm_new_token が流れず、on_llm_end は最初のチャンクより先に発火します。_stream を実装している FakeListChatModel に置き換えると、実モデルと同じ順序でイベントが発生します。

FakeLLMでツール呼び出し(tool calling)はテストできますか?

部分的に可能です。GenericFakeChatModeltool_calls を設定した AIMessage を渡せば invoke() では保持されます。ただし stream()tool_calls を参照しないため、content が空だと ValueError: No generation chunks were returnedcontent が非空だと例外を出さずに tool_calls だけが空リストになります。ストリーミングで流したい場合は additional_kwargsfunction_call 形式を使います。また Fake 系は bind_tools() 非対応(NotImplementedError)なので、バインド経路まで検証するなら別の手段が必要です。

関連記事

資料請求

RELATED POSTS 関連記事