テスト

FakeLLMとは?LangChainのテスト用モデルの使い方とcreate_agent・Streamingの落とし穴【1.7.3実測】

FakeLLMとは?LangChainのテスト用モデルの使い方とcreate_agent・Streamingの落とし穴【1.7.3実測】

FakeLLM は、LangChain のアプリを API キーなしでテストするためのダミーモデルの総称です。決めておいた応答を返すだけなので、推論もトークン課金も発生しません。ただし Python 版には FakeLLM という名前のクラスは無く、実体は langchain_core.language_models が公開する Fake 系の6クラスです。どれを選ぶかで stream() の分割もコールバックの発火も変わり、エージェントをストリーミングで流すとツール呼び出しが黙って消える組み合わせもあります。この記事では langchain-core 1.7.3(2026年10月29日公開、11月1日時点の PyPI 最新)と langchain 1.4.7 で実行した結果をもとに、クラスの選び方、create_agent のテスト、Streaming とコールバックの挙動差、1.7系で変わったツール引数のイベント、HTTP 層でモックする PyPI 版 fakellm との使い分けを整理します。

まとめ

  • Python の LangChain で FakeLLM と呼ばれるのは langchain_core.language_models の Fake 系6クラスです。名前どおりの FakeLLM クラスがあるのは JS 版の @langchain/core/utils/testing です。
  • pip install fakellm で入るのは LangChain と無関係のモックHTTPサーバー(0.3.5)です。インストールは成功し、from fakellm import FakeLLM で ImportError になります。
  • 文字列を返すだけなら FakeListLLM、コールバックやトークン単位の処理まで確かめるなら FakeListChatModel を選びます。FakeStreamingListLLM は1文字ずつ流れるのに on_llm_new_token が0回です。
  • create_agent のテストは、公式ドキュメントのとおり GenericFakeChatModel を継承して bind_tools を上書きすれば invoke() で通ります。同じモデルを stream_mode="messages" で流すと、content が空なら ValueError、非空ならツールを呼ばずに終わります。disable_streaming=True を付けるか、tool_call_chunks を流す _stream を自作します。
  • langchain-core 1.7.0 以降、stream_events(version="v3") のツール引数は「ここまでの全文」でなく「今回増えた断片」で届きます。最終値を .tool_calls.get() で見るテストは書き換え不要です。
  • SDK のリトライ、HTTP エラー、本物のクライアントの bind_tools まで通したいときは、PyPI 版 fakellm のサーバーに ChatOpenAI を向けます。会話状態がプロセスに残るので、テストごとに /_fakellm/reset を呼びます。

FakeLLMが指す3つのもの|Python・JS・PyPIの取り違え

FakeLLM という語は3つの別物を指します。取り違えると、動かないサンプルを写すことになります。LangChain 自体の位置づけは LangChainとは?LLMアプリ開発フレームワークの仕組み・できること・採用判断を解説 を先に読むと早いです。

Python版langchain-core 1.7.3の6クラスと選び分け

Python の Fake 群は langchain_core.language_models にまとまっています。1.7.3 の __all__ は19要素で、そのうち Fake 系が次の6クラスです。実装は fake.py(LLM 系2クラス)と fake_chat_models.py(チャットモデル系4クラス)にあります。

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

実務でまず使うのは FakeListLLM と FakeListChatModel、エージェントを試すときの GenericFakeChatModel の3つです。「応答まるごと1個」は _stream を実装していないクラスの既定動作で、stream() を呼んでも例外にならず1チャンクだけ返ります。FakeChatModel も fake_chat_models モジュールにありますが __all__ には載っていません。

インポート元にも注意が要ります。langchain_core.llms と langchain_core.chat_models はモジュールとして存在せず、どちらも ModuleNotFoundError です。古い記事にある langchain_community.llms.fake は langchain-community 0.4.2 で今も動きますが、import すると「langchain-community is being sunset」という DeprecationWarning が出ます。このパッケージは 2026年5月22日に Issue #674 で保守終了が告知され、0.4.2 が最後のリリースです。パッケージ再編の全体は LangChain v1とは|最新1.4の変更点とcreate_agent・ProviderStrategy・langchain-classic移行 で整理しています。

JS版@langchain/coreのFakeLLMクラス

名前どおりの FakeLLM クラスは JS 版にあります。npm の @langchain/core 1.3.2(2026年10月29日公開)の utils/testing は、FakeLLM・FakeStreamingLLM・FakeListChatModel・FakeChatModel など Fake で始まる部品をまとめて書き出しています。APIリファレンス にあるとおり、FakeLLM 固有の設定項目は response と thrownErrorString で、ほかに BaseLLMParams の共通設定も渡せます。Node 26.5.0 で実行した結果が次です。

import { FakeLLM } from "@langchain/core/utils/testing";

console.log(await new FakeLLM({}).invoke("こんにちは"));
// こんにちは(response を渡さないと入力をそのまま返す)
console.log(await new FakeLLM({ response: "固定の応答" }).invoke("こんにちは"));
// 固定の応答
await new FakeLLM({ thrownErrorString: "boom" }).invoke("x");
// Error: boom

Python の FakeListLLM はリストを巡回しますが、JS の FakeLLM は1つの応答を固定で返します。同じ名前の感覚で移植すると期待値がずれます。JS 版でエージェントを試す場合の fakeModel については LangChain.jsとは?npm導入からcreateAgent・構造化出力・会話メモリまで【v1.5対応】 で扱っています。

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

PyPI の fakellm は LangChain と無関係のパッケージです。最新は 0.3.5(2026年5月22日公開、Python 3.10 以上)で、説明は「A mock LLM server for testing. Drop-in replacement for OpenAI and Anthropic APIs.」。OpenAI と Anthropic の HTTP API を真似るサーバーを YAML の規則で動かします。

厄介なのは、インストールが通ることです。

pip install fakellm        # 成功する
python -c "from fakellm import FakeLLM"
# ImportError: cannot import name 'FakeLLM' from 'fakellm' (.../site-packages/fakellm/__init__.py)

エラーが import の行で出るため、「版が古い」「環境が壊れた」と誤診しがちです。このパッケージは CLI(init と serve)で起動するモックHTTPサーバーで、LangChain のモデルとして使う FakeLLM クラスは公開していません。LangChain の Fake を使うなら追加インストールは不要で、pip install langchain-core に最初から入っています。サーバーとしての使い方は後半の章で扱います。

FakeListLLMの基本動作|responsesの巡回とカウンタ

FakeListLLM は responses を先頭から順に返し、末尾まで行くと先頭へ戻ります。FakeListChatModel も同じ巡回です。

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.i も巡回します。responses が2件のとき、invoke を6回呼ぶたびに i を記録すると 1, 0, 1, 0, 1, 0 でした。呼び出し回数を数えるなら、コールバックで開始イベントを数えます(後述のとおり、チャットモデルでは数えるメソッドに注意が要ります)。

コンストラクタ引数 sleep は FakeListLLM にもありますが、ソースの docstring に「Ignored by FakeListLLM, but used by sub-classes.」とあるとおり、このクラスでは効きません。FakeListChatModel は invoke() と stream() の両方で待ち、FakeStreamingListLLM はチャンクの間だけ待ちます。タイムアウト処理を試すなら前者、進捗表示の描画間隔を見るなら後者です。

Fake を「テストダブル」の分類で言えば、決まった値を返すスタブに近く、呼び出しの記録は持ちません。用語をそろえたい場合は モックとは?スタブとの違い・テストダブル5分類と5言語の書き方を実行例で解説 が参考になります。

Streamingのチャンク分割|同じ入力でのクラス別の粒度

同じ文字列を流したときのチャンクは、クラスによって次のように変わります。

from langchain_core.language_models import (
    FakeListLLM, FakeStreamingListLLM, FakeListChatModel, GenericFakeChatModel,
)
from langchain_core.messages import AIMessage

print(list(FakeListLLM(responses=["こんにちは世界"]).stream("q")))
# ['こんにちは世界']
print(list(FakeStreamingListLLM(responses=["こんにちは世界"]).stream("q")))
# ['こ', 'ん', 'に', 'ち', 'は', '世', '界']
print([c.content for c in FakeListChatModel(responses=["こんにちは世界"]).stream("q")])
# ['こ', 'ん', 'に', 'ち', 'は', '世', '界']
g = GenericFakeChatModel(messages=iter([AIMessage(content="hello fake world")]))
print([c.content for c in g.stream("q")])
# ['hello', ' ', 'fake', ' ', 'world']

FakeListLLM は _stream を持たないため、基底クラスが type(self)._stream == BaseLLM._stream で未実装を検知し、invoke() の結果を1チャンクで返します。そのため、複数チャンクをまたぐバッファリングや、受信途中のキャンセルを検証する用途には向きません。

GenericFakeChatModel は re.split(r"(\s)", content) で分けるので、空白も1チャンクとして残ります。日本語は空白を含まないため「こんにちは世界」は1チャンクのままでした。日本語 UI の逐次描画を確かめるなら FakeListChatModel を使います。

Callbacksの落とし穴|FakeStreamingListLLMでトークン通知が0回

FakeStreamingListLLM は1文字ずつ返すのに、on_llm_new_token が一度も呼ばれません。同じハンドラを2つのクラスに付けて、イベントの順序を記録しました。

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

EV = []

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

for c in FakeStreamingListLLM(responses=["abc"]).stream("q", config={"callbacks": [H()]}):
    EV.append(f"yield({c})")
print(EV)
# ['start', '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)
# ['start', 'token(a)', 'yield(a)', 'token(b)', 'yield(b)', 'token(c)', 'yield(c)', 'end']

FakeStreamingListLLM ではトークン通知が0回で、しかも end が最初のチャンクより先に来ています。原因はソースにあります。このクラスは _stream ではなく Runnable の stream() と astream() そのものを上書きし、先に self.invoke(input, config) を完走させてから、返った文字列を1文字ずつ yield しています。チャンクを刻むループがコールバックマネージャの外にあるため、トークンのイベントが流れません。FakeListChatModel は _stream を実装しており、チャットモデルの基底クラスが各チャンクで on_llm_new_token を呼ぶので、実モデルと同じ順序になります。

WebSocket 配信、トークン数カウンタ、監査ログのように、コールバックでトークンを受けるコードのテストに FakeStreamingListLLM を使ってはいけません。トークン通知の回数や副作用をアサートしていないテストでは、対象のハンドラが呼ばれなくても合格してしまいます。トレースをコールバックで外部へ送る構成も同じ影響を受けます。送信の設定は LangSmithとは?トレース送信の設定・評価の実行・料金とリージョンの選び方【2026年版】 にまとめています。

開始イベントにも差があります。上の FakeListChatModel で on_llm_start が記録されたのは、ハンドラが on_chat_model_start を実装していないときの代替です。同じハンドラに on_chat_model_start を足すと先頭がそちらに置き換わり、on_llm_start は記録されなくなりました。on_llm_start で呼び出し回数を数えているテストは、ハンドラにメソッドを1つ足しただけで0回になります。

create_agentのテスト|bind_toolsの上書きとstreamで消えるツール呼び出し

Fake 系チャットモデルは bind_tools() を実装しておらず、FakeListChatModel(responses=["ok"]).bind_tools([]) は NotImplementedError です。create_agent はツールを渡すと bind_tools() を呼ぶので、そのままではエージェントを組めません。

公式のFakeToolCallingModelでinvokeを検証する手順

LangChain の公式ドキュメント Unit testing は、GenericFakeChatModel を継承して bind_tools が自分自身を返すようにする方法を案内しています。1ターン目でツール呼び出し、2ターン目で最終回答を返すように並べます。

from langchain.agents import create_agent
from langchain.messages import AIMessage, HumanMessage, ToolCall
from langchain.tools import tool
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel


class FakeToolCallingModel(GenericFakeChatModel):
    def bind_tools(self, tools, **kwargs):
        return self


CALLS = []

@tool
def get_weather(city: str) -> str:
    """Get the weather for a city."""
    CALLS.append(city)
    return f"晴れ ({city})"


def script():
    return iter([
        AIMessage(content="", tool_calls=[
            ToolCall(name="get_weather", args={"city": "Tokyo"}, id="call_1")]),
        AIMessage(content="東京は晴れです。"),
    ])


def test_agent_invoke():
    CALLS.clear()
    agent = create_agent(FakeToolCallingModel(messages=script()), tools=[get_weather])
    result = agent.invoke({"messages": [HumanMessage("東京の天気は?")]})
    assert CALLS == ["Tokyo"]
    assert [type(m).__name__ for m in result["messages"]] == [
        "HumanMessage", "AIMessage", "ToolMessage", "AIMessage"]
    assert result["messages"][-1].text == "東京は晴れです。"

langchain 1.4.7 でこのテストは合格し、stream_mode="updates" で流しても model → tools → model の順に進みました。継承せずに GenericFakeChatModel をそのまま渡すと NotImplementedError で止まります。

messagesモードのstreamで起きる2つの失敗

チャット画面へトークンを流すアプリは、エージェントを stream_mode="messages" で回します。上のモデルをそのまま使うと、ここで結果が変わりました。

agent = create_agent(FakeToolCallingModel(messages=script()), tools=[get_weather])
for msg, meta in agent.stream({"messages": [HumanMessage("東京の天気は?")]},
                              stream_mode="messages"):
    print(meta["langgraph_node"], repr(msg.content))
# ValueError: No generations found in stream.

# 1ターン目の content を "確認します" にした場合
# model '確認します'      ← これで終わる。CALLS は [] のまま

messages モードではモデルノードが stream 経由で呼ばれ、GenericFakeChatModel の _stream が使われます。この _stream は content と additional_kwargs しか見ず、tool_calls フィールドを流しません。content が空ならチャンクが1つも出ずに ValueError になり、content があればテキストだけが流れて、ツール呼び出しの無い応答としてエージェントが終了します。2つ目は例外が出ないので、最終メッセージの中身まで確かめていないテストは気づかずに通ります。

単体のモデルで見ても同じです。tool_calls を持つ AIMessage を GenericFakeChatModel で stream() すると、content が空なら「ValueError: No generation chunks were returned」、content が「ok done」なら3つのチャンクがどれも tool_calls == [] で流れました。invoke() では tool_calls がそのまま残ります。

回避策|disable_streaming=Trueか自作の_stream

回避策は2つあり、どちらも langchain 1.4.7 で messages モードのままツールが1回呼ばれ、最終回答まで進みました。

1つ目は FakeToolCallingModel(messages=script(), disable_streaming=True) とする方法です。BaseChatModel の disable_streaming が True だと stream 経由でも invoke に切り替わるので、tool_calls が残ります。代わりにモデルノードの出力は1メッセージずつで届き、トークン単位の描画は確かめられません。

2つ目は、tool_calls を tool_call_chunks に変えて流す _stream を書く方法です。ツールの実行と、最終回答が1文字ずつのチャンクで届くことを1本のテストで確かめられます。

import json
from langchain_core.messages import AIMessageChunk
from langchain_core.outputs import ChatGenerationChunk


class StreamingToolFake(GenericFakeChatModel):
    """tool_calls を tool_call_chunks に変えて流すテスト用モデル"""

    def bind_tools(self, tools, **kwargs):
        return self

    def _stream(self, messages, stop=None, run_manager=None, **kwargs):
        msg = next(self.messages)
        if isinstance(msg, str):
            msg = AIMessage(content=msg)
        for i, tc in enumerate(msg.tool_calls):
            yield ChatGenerationChunk(message=AIMessageChunk(
                content="",
                tool_call_chunks=[{"name": tc["name"], "id": tc["id"],
                                   "args": json.dumps(tc["args"]), "index": i}],
            ))
        for ch in msg.content:
            yield ChatGenerationChunk(message=AIMessageChunk(content=ch))


def test_agent_stream_messages():
    CALLS.clear()
    agent = create_agent(StreamingToolFake(messages=script()), tools=[get_weather])
    tokens = [
        m.content
        for m, meta in agent.stream({"messages": [HumanMessage("東京の天気は?")]},
                                    stream_mode="messages")
        if meta["langgraph_node"] == "model" and isinstance(m.content, str) and m.content
    ]
    assert CALLS == ["Tokyo"]
    assert tokens == list("東京は晴れです。")   # 1文字ずつ8チャンクで届く

どちらを選ぶかは、テストしたいものが「ツールの分岐」か「画面への逐次描画」かで決まります。分岐だけなら1行で済む disable_streaming を選び、描画の順序まで仕様に含むなら自作の _stream を選びます。ToolNode 側のエラー処理やループ上限は LangGraphのTool Calling実装|ToolNode・create_agent・エラー処理とループ上限【2026年10月】、LangChain と LangGraph の関係は LangChainとLangGraphの違いと使い分け|v1.0以降の関係をコードで確認【2026年10月】 を参照してください。

langchain-core 1.7のtool-call-delta|テストの移行手順

2026年10月22日公開の langchain-core 1.7.0 のリリースノート には「Tool-call args now stream as tool-call-delta slices instead of block-delta snapshots of the args so far.」とあります。ツール引数の途中経過が「ここまでの全文」から「今回増えた断片」へ変わりました。同じノートは、1.6.13 から ChatModelStream.tool_calls を反復したときの値も断片になったことを記しています。影響を受けるのは stream_events(version="v3") が返す ChatModelStream(定義は chat_model_stream.py)を読むコードで、version の既定は 1.7.3 でも "v2" です。従来の stream() や v2 のイベントだけを読むテストは対象外です。

v3イベントの形と.outputの注意点

from langchain_core.language_models import FakeListChatModel

stream = FakeListChatModel(responses=["abc"]).stream_events("q", version="v3")
for ev in stream:
    print(ev)
# {'event': 'message-start', 'role': 'ai', 'id': 'lc_run--...'}
# {'event': 'content-block-start', 'index': 0, 'content': {'type': 'text', 'text': ''}}
# {'event': 'content-block-delta', 'index': 0, 'delta': {'type': 'text-delta', 'text': 'a'}}
# {'event': 'content-block-delta', 'index': 0, 'delta': {'type': 'text-delta', 'text': 'b'}}
# {'event': 'content-block-delta', 'index': 0, 'delta': {'type': 'text-delta', 'text': 'c'}}
# {'event': 'content-block-finish', 'index': 0, 'content': {'type': 'text', 'text': 'abc'}}
# {'event': 'message-finish'}
print(stream.output.content)   # [{'type': 'text', 'text': 'abc', 'index': 0}]
print(str(stream.text))        # abc

stream.output.content は文字列ではなくコンテンツブロックのリストです。全文を比べるアサーションは str(stream.text) == "abc" と書くほうが壊れにくくなります。

1.6.12と1.7.3のツール引数イベントの比較

既存の6クラスはどれもツール引数を tool_call_chunks として流さないため、v3 の tool-call-delta を再現するには _stream で断片を返すモデルを書きます。引数 {"city":"Tokyo"} を3つに分けて流し、content-block-delta を並べた結果です。

回 1.6.12 block-deltaのargs 1.7.3 tool-call-deltaのargs
1 {"city": {"city":
2 {"city":"Tokyo" "Tokyo"
3 {"city":"Tokyo"} }

1.6.12 の block-delta は fields に type・id・name・args を毎回丸ごと持ち、args は回ごとに伸びる全文でした。1.7.3 の tool-call-delta は args だけを持ち、id と name は最初の content-block-start にだけ載ります。content-block-finish で確定する {'city': 'Tokyo'} は両方の版で同じです。次のテストを両方の版で実行すると、1つ目は 1.7.3 で合格し 1.6.12 で AssertionError、2つ目は両方で合格しました。

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessageChunk
from langchain_core.outputs import ChatGenerationChunk


class ToolChunkFake(BaseChatModel):
    """tool_call_chunks を3分割で流すテスト専用モデル"""

    @property
    def _llm_type(self):
        return "tool-chunk-fake"

    def _generate(self, messages, stop=None, run_manager=None, **kwargs):
        raise NotImplementedError

    def _stream(self, messages, stop=None, run_manager=None, **kwargs):
        for i, part in enumerate(['{"city":', '"Tokyo"', '}']):
            yield ChatGenerationChunk(message=AIMessageChunk(
                content="",
                tool_call_chunks=[{
                    "name": "get_weather" if i == 0 else None,
                    "id": "call_1" if i == 0 else None,
                    "args": part,
                    "index": 0,
                }],
            ))


def test_tool_call_delta_slices():
    stream = ToolChunkFake().stream_events("q", version="v3")
    slices = [
        ev["delta"]["args"]
        for ev in stream
        if ev["event"] == "content-block-delta"
        and ev["delta"]["type"] == "tool-call-delta"
    ]
    assert slices == ['{"city":', '"Tokyo"', '}']
    assert stream.output.tool_calls[0]["args"] == {"city": "Tokyo"}


def test_tool_calls_final():
    stream = ToolChunkFake().stream_events("q", version="v3")
    calls = stream.tool_calls.get()
    assert calls[0]["name"] == "get_weather"
    assert calls[0]["args"] == {"city": "Tokyo"}

1.7系への移行で書き換えるテストと変更不要なテスト

  • 生イベントの delta["type"] == "block-delta" からツール引数を読んでいたテストは、"tool-call-delta" を拾って delta["args"] を連結する形に変える。
  • 反復した stream.tool_calls の最後の要素を全文とみなしていたテストは、1.6.13 以降は最後の断片 } しか取れないため、.tool_calls.get() に置き換える。
  • 途中経過を毎回上書きで描画していた UI は、断片を連結するバッファを持たせる。上書きのままだと、1.7系では最後の } だけが画面に残る。

最終値だけを確かめたいなら、最初から .tool_calls.get() と .output で書いておけば版をまたいでも壊れません。旧挙動に留めたい場合、リリースノートは langchain-core<1.7 への固定を案内しています。

pytestへの組み込み|fixtureのスコープ・エラー注入・Embedding

fixture や parametrize の書き方そのものは pytestの使い方と書き方|fixture・parametrize・docstringの扱いまで【9.2対応】 にまとめています。ここでは Fake 固有の注意に絞ります。

fixtureはfunctionスコープで作る理由

GenericFakeChatModel はイテレータから応答を取り出すので、尽きると戻りません。応答を使い切ったモデルをもう一度呼ぶと、メッセージの無い StopIteration が上がりました。fixture を module や session スコープにすると、先に走ったテストが応答を使い切り、後のテストだけが原因の分かりにくい形で落ちます。公式ドキュメントも既定の function スコープを勧めています。FakeListChatModel は巡回するので尽きませんが、カウンタ i を共有するため、スコープを広げると後のテストは前のテストが進めた位置の応答から始まります。

import pytest

@pytest.fixture   # 既定の function スコープ=テストごとに新しいモデル
def weather_model():
    return FakeToolCallingModel(messages=script())

error_on_chunk_numberによる途中中断

error_on_chunk_number は、指定した番号のチャンクで例外を送出します。このフィールドを持つのは FakeStreamingListLLM と FakeListChatModel の2クラスで、例外はそれぞれ FakeListLLMError と FakeListChatModelError です。

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)
    assert received == ["a", "b", "c"]   # 3チャンク受け取ったあとで中断

Embedding用Fakeとnumpyの依存

RAG 構成では Embedding API の課金も残ります。langchain_core.embeddings には FakeEmbeddings(毎回ランダム)と DeterministicFakeEmbedding(テキストから決まるベクトル)があり、同じテキストから再現可能なベクトルを得るテストには後者を使います。ただし、テキストの意味的な類似度は再現しません。

from langchain_core.embeddings import DeterministicFakeEmbedding

emb = DeterministicFakeEmbedding(size=4)
assert emb.embed_query("langchain") == emb.embed_query("langchain")

langchain-core 1.7.3 の依存11項目に numpy は含まれていません。embeddings/fake.py は numpy の import 失敗を contextlib.suppress(ImportError) で握りつぶすため、numpy の無い環境では import が通り、embed_query() を呼んだ時点で NameError: name 'np' is not defined になります。テスト用の依存に numpy を明記してください。ベクトル化を挟まない検索で済ませるなら、LangChain BM25Retrieverの使い方|日本語対応・スコア取得・ハイブリッド検索【v1対応】 の BM25 も選択肢です。

HTTP層でモックするfakellmサーバー|Fakeクラスで届かない範囲

LangChain の Fake はフレームワークの内側で応答を差し替えるので、SDK の HTTP 処理、リトライ、プロバイダ実装の bind_tools は通りません。そこまで含めたいときは PyPI 版 fakellm のサーバーを立て、本物の ChatOpenAI の接続先をそこへ向けます。規則は YAML で書きます。

# fakellm.yaml
version: 1
defaults:
  fallback: deterministic_echo
rules:
  - name: rate_limit
    when:
      header.x-test-scenario: rate_limit
    respond:
      status: 429
      error: "Rate limit exceeded (mock)"
  - name: answer_after_tool
    when:
      tool_result_contains: "晴れ"
    respond:
      content: "東京は晴れです。"
  - name: call_weather
    when:
      messages_contain: "天気"
    respond:
      tool_calls:
        - name: get_weather
          arguments: {city: Tokyo}
fakellm serve --port 9999   # 既定は http://127.0.0.1:9999
import httpx
import openai
from langchain_openai import ChatOpenAI

BASE = "http://127.0.0.1:9999/v1"
model = ChatOpenAI(model="gpt-4o-mini", base_url=BASE, api_key="dummy")
agent = create_agent(model, tools=[get_weather])   # get_weather は前の章と同じ
print(agent.invoke({"messages": [HumanMessage("東京の天気は?")]})["messages"][-1].text)
# 東京は晴れです。

bad = ChatOpenAI(model="gpt-4o-mini", base_url=BASE, api_key="dummy", max_retries=2,
                 default_headers={"x-test-scenario": "rate_limit"})
try:
    bad.invoke("hello")
except openai.RateLimitError as e:
    print(type(e).__name__)   # OpenAIRateLimitError(サーバーには429が3回届く)

httpx.post("http://127.0.0.1:9999/_fakellm/reset")   # テストの合間に会話状態を消す

langchain-openai 1.7.3 と openai 3.42.0 で実行すると、エージェントは get_weather を1回呼んで最終回答を返しました。bind_tools したモデルを単体で stream() すると、ツール引数は4つの断片({"ci・ty":・ "To・kyo"})で届き、足し合わせると {'city': 'Tokyo'} になりました。max_retries=2 の 429 は、初回と再試行2回の計3回がサーバーのアクセスログに記録されたあとで RateLimitError になりました。リトライ回数の設定が効いているかを、課金なしで確かめられます。

落とし穴は会話状態です。fakellm は最初のユーザー発話から会話 ID を作り、受け取ったツール結果をプロセスの中に覚えています。同じ質問のテストを1つのサーバーで2回続けると、1回目の「晴れ」というツール結果が同じ会話 ID に残っているため、2回目は1ターン目から tool_result_contains の規則に一致し、ツールを呼ばずに「東京は晴れです。」が返りました。/_fakellm/reset に POST すると解消します。テストごとに呼ぶか、X-Fakellm-Conversation-Id ヘッダでテストごとに会話を分けてください。サーバーの状態はワーカーをまたいで共有されないため、--workers は既定の1のまま使います。モックサーバー方式の選び方と、本物の API 仕様からずれていく「契約ドリフト」への備えは APIモッキングとは?モックサーバーの方式選定と契約ドリフト対策を実装目線で解説 で扱っています。

FakeLLMを採用する場面と見送る場面|テストの層ごとの判断基準

確かめたいこと 選ぶ手段 理由
チェーンの配線・分岐 FakeListLLM 応答順が決まり課金なし
トークン単位のコールバック FakeListChatModel 実モデルと同じ発火順
途中切断・遅延 FakeListChatModel 中断位置と待ち時間を指定
エージェントのツール分岐 GenericFake継承+bind_tools上書き 公式の手順でinvokeが通る
エージェントの逐次描画 tool_call_chunksを流す自作_stream messagesモードでもツールが残る
ツール引数の途中経過(v3) 自作の_streamモデル 既存Fakeは断片を流さない
SDKのリトライ・HTTPエラー fakellmサーバー 本物のクライアントを通る
応答の質・ツール選択の正しさ 実モデルでの評価 固定応答では測れない

Fake を使ってよいのは、確かめたいのがアプリ側のロジック(プロンプトの組み立て、分岐、例外処理、画面への受け渡し)で、モデルの応答内容そのものを評価しない場合です。この条件なら高速・無料・決定的なので、CI に毎回載せられます。

見送るべきなのは、モデルが正しいツールを選ぶか、回答が妥当かを測りたい場面です。Fake は書いたとおりの応答を返すだけなので、そこでは何も測れません。この段階は実モデルを使った評価に切り替えます。指標の設計は AIエージェント評価とは?軌跡・ツール呼び出し・タスク達成を測る指標と実装の進め方【2026年版】 で解説しています。

Fake のテストが通っても、本番のチャット画面でツール実行や逐次表示が動くことまでは保証できません。本番で動かないときに、この記事の範囲で疑う順番は、invoke でしか試していない GenericFakeChatModel、コールバックが沈黙する FakeStreamingListLLM、1.7系の断片を想定していない描画処理の3つです。ストリーミング応答のチャットUIをテスト設計まで含めて組みたい場合は、一創の AIチャットボット開発 で要件整理から実装まで対応しています。フロント側を Next.js で組む構成なら Vercel AI SDKの使い方|AI SDK 7でNext.jsチャットとLangChain連携を実装 の受け渡し例も参考になります。

よくある質問

FakeLLMとは何ですか?

LangChain のアプリを API キーなしでテストするためのダミーモデルです。Python では langchain-core 1.7.3 の langchain_core.language_models にある FakeListLLM・FakeListChatModel・GenericFakeChatModel などの6クラスを指し、JS では @langchain/core/utils/testing の FakeLLM クラスを指します。どちらも推論はせず、渡した応答を返すだけなので課金もレート制限もありません。PyPI の fakellm は別物のモックHTTPサーバーです。

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

Python は from langchain_core.language_models import FakeListLLM です。langchain-core に同梱されているので追加インストールは要りません。langchain_core.llms や langchain_core.chat_models は存在せず ModuleNotFoundError になります。langchain_community.llms.fake も動きますが、保守終了の DeprecationWarning が出ます。from fakellm import FakeLLM は ImportError です。

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

応答の文字列だけを確かめるなら FakeListLLM で足ります。stream() は応答まるごと1チャンクを返します。チャンクの境界を確かめるなら FakeStreamingListLLM が1文字ずつ返しますが、on_llm_new_token が発火しません。コールバックでトークンを受けるコードのテストには、どちらでもなく FakeListChatModel を使ってください。

FakeLLMでcreate_agentのツール呼び出しをテストできますか?

できます。GenericFakeChatModel を継承して bind_tools が自分自身を返すようにし、tool_calls を持つ AIMessage と最終回答を順に渡すと、invoke() でツールが呼ばれます。ただし stream_mode="messages" で流すと tool_calls が消え、content が空なら ValueError、非空ならツールを呼ばずに終わります。その場合は disable_streaming=True を付けるか、tool_call_chunks を流す _stream を書きます。

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

FakeStreamingListLLM を使っている場合、このクラスが Runnable の stream() 自体を上書きし、invoke() を完走させてから結果を1文字ずつ返しているためです。チャンクを刻む処理がコールバックマネージャの外にあるので on_llm_new_token が流れず、on_llm_end が最初のチャンクより先に来ます。_stream を実装している FakeListChatModel に替えると、実モデルと同じ順序になります。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  2. 2026.10.31 テックブログ 松屋の不正アクセス:237万件の対象確認と窓口、漏れたパスワードの守り方
  3. 2026.11.02 テックブログ サントリーの不正アクセスで約10万人分漏えい|M365のファイル大量ダウンロードを監査ログで調べる手順
  4. 2026.11.01 コラム インボイスの経過措置とは?70%控除の期間と計算方法・帳簿の書き方・1億円制限【2026年10月改正対応】
  5. 2026.03.17 コラム 所得金額調整控除とは?年収850万円超で最大15万円、対象者と計算式・申告書の書き方【令和8年分】

RELATED POSTS 関連記事

目次