asyncio.gatherでLLMパイプラインに「タダで」分類機能を追加する
はじめに
RAGシステムを運用していると、検索精度を上げるために「ユーザーの問い合わせを分類したい」という要望が出てきます。対象ソフトウェアの特定、問題カテゴリの分類、エスカレーション先の決定——これらの情報があれば、検索結果の並び替えやプロンプトへのコンテキスト注入が可能になります。
しかし、分類のためにLLMを追加で呼び出すとレイテンシが増えます。ユーザーの体感速度を犠牲にしてまで分類する価値があるのか——この判断に悩むケースは多いのではないでしょうか。
本記事では、Pythonのasyncio.gather()を使ってKB検索のI/O待ち時間の裏側で軽量LLM分類を並列実行し、レイテンシ増加ゼロで分類機能を追加するパターンを紹介します。サービスデスクAIの開発で実際に採用した手法です。
前提・環境
- Python 3.12 + FastAPI
- Amazon Bedrock(Claude Haiku 4.5 / Claude Sonnet 4.5)
- OpenSearch Serverless(ベクトル検索)
- Anthropic Python SDK(
anthropic[bedrock])
パイプライン全体像
まず、記事の対象となるRAGパイプラインの全体構成を示します。

ポイントは、Haiku呼び出し2つ(クエリ生成 + 問題分類)とOpenSearch検索の依存関係です。
- クエリ生成と問題分類は互いに独立(入力がどちらもユーザーの生テキスト)
- OpenSearchはクエリ生成の結果に依存
- 最終回答は全ての結果に依存
この依存グラフから、並列化できる箇所が見えてきます。
逐次実行だとどうなるか
素朴に逐次実行すると、こうなります。
# 逐次実行(素朴な実装)
kb_query = await generate_kb_query(messages) # ~100ms
problem_ctx = await classify_problem(raw_query) # ~100ms
chunks = await retrieve_from_kb(kb_query, top_k=5) # ~500ms
# 合計: ~700ms(回答生成前のオーバーヘッド)
分類のために100msが加算されます。たかが100msですが、レスポンスタイムに敏感なチャットUIでは体感に影響します。
asyncio.gatherで並列化する
asyncio.gather()を使えば、独立した非同期関数を同時に実行できます。
初回ターン:クエリ生成と分類を並列化
初回ターンでは、ユーザーの生テキストからクエリ生成と分類を同時に実行します。
async def _chat_stream(request: ChatRequest) -> AsyncGenerator[str]:
is_first_turn = not any(m.role == "assistant" for m in request.messages)
if is_first_turn:
# Haiku 2並列: クエリ生成 + 問題分類
kb_query, problem_ctx = await asyncio.gather(
generate_kb_query(request.messages),
classify_problem(request.messages[0].content),
)
# KB検索はクエリ生成の結果に依存するため、ここで逐次実行
chunks = await retrieve_from_kb(kb_query, top_k=request.top_k)
# ※ 以降、chunksとproblem_ctxを使ってstreaming回答を生成(yield)
asyncio.gather()は渡されたコルーチンを同時にスケジュールし、全てが完了するまで待ちます。戻り値は引数と同じ順序のリストです。
2ターン目以降:KB検索と分類を並列化
2ターン目以降は、クエリ生成に会話コンテキスト(確認質問への回答)が必要なため、クエリ生成は先に実行します。代わりに、KB検索と分類を並列化します。
else:
# 2ターン目: クエリ生成は会話コンテキストが必要なため先に実行
kb_query = await generate_kb_query(request.messages)
# KB検索(I/Oバウンド)と分類(CPUバウンド)を並列化
chunks, problem_ctx = await asyncio.gather(
retrieve_from_kb(kb_query, top_k=request.top_k),
classify_problem(kb_query),
)
ターンによって並列化の組み合わせを変えているのがポイントです。依存関係を正確に把握し、その時点で独立している処理だけをgatherするのが原則です。
なぜ「タダ」なのか
このパターンが有効な理由は、ボトルネックの非対称性にあります。
| 処理 | モデル | 典型的なレイテンシ | 性質 |
|---|---|---|---|
| クエリ生成 | Haiku | ~100ms | LLM推論(軽量) |
| 問題分類 | Haiku | ~100ms | LLM推論(軽量) |
| KB検索 | OpenSearch | ~500ms | ネットワークI/O |
| 回答生成 | Sonnet | ~2000ms | LLM推論(重量) |
asyncio.gather()で並列実行した場合、最も遅い処理が全体のレイテンシを決定します。

2ターン目では、クエリ生成(100ms)の後にKB検索と分類を並列実行します。分類のレイテンシが完全にKB検索の裏に隠れるため、分類を追加してもしなくても合計レイテンシは変わりません。逐次実行の700msに対し、どちらのパターンも600msで完了します。
分類関数の実装
分類関数classify_problem()の実装を見てみましょう。
from pydantic import BaseModel, Field
class ProblemClassification(BaseModel):
"""問題ドメインとカテゴリの分類結果"""
software: str | None = Field(
None,
description="対象ソフトウェア名(例: Exchange Online, SAP)"
)
category: str | None = Field(
None,
description=(
"問題カテゴリ: 認証・ログイン / 権限・アクセス / "
"インストール・更新 / パフォーマンス / データ・ファイル / "
"設定・構成 / その他"
),
)
Pydanticモデルで分類結果のスキーマを定義します。FieldのdescriptionはそのままLLMへのtool useスキーマとして使われます。
async def classify_problem(query: str) -> ProblemClassification:
"""問題ドメインとカテゴリを分類する。
asyncio.gatherでKB検索と並列実行されるため、レイテンシ増加はゼロ。
"""
tool_schema = ProblemClassification.model_json_schema()
try:
response = await _client.messages.create(
model=settings.bedrock_haiku_model_id, # Haiku 4.5
messages=[{"role": "user", "content": query}],
system=(
"サービスデスクの問い合わせを分析し、"
"対象ソフトウェアと問題カテゴリを特定してください。"
"不明な場合はnullを返してください。"
),
max_tokens=100,
temperature=0.0,
tools=[{
"name": "classify_problem",
"description": "問い合わせの対象ソフトウェアと問題カテゴリを分類する",
"input_schema": tool_schema,
}],
tool_choice={"type": "tool", "name": "classify_problem"},
)
for block in response.content:
if block.type == "tool_use" and block.name == "classify_problem":
return ProblemClassification(**block.input)
return ProblemClassification(software=None, category=None)
except Exception:
logger.warning("classify_problem failed silently", exc_info=True)
return ProblemClassification(software=None, category=None)
設計上の重要なポイントが3つあります。
1. tool useで構造化出力を保証
tool_choice={"type": "tool", "name": "classify_problem"}を指定することで、LLMは必ず指定したスキーマに従ったJSONを返します。json.loads()でパースしてバリデーションするアプローチと違い、マークダウンフェンスの混入やJSONの途中切断といった問題が起きません。
2. 失敗してもメインフローを止めない
分類はあくまで「あると便利」な補助情報です。例外が発生した場合はデフォルト値(software=None, category=None)を返し、メインのRAGフローは通常通り継続します。asyncio.gather()のデフォルト動作では1つのコルーチンが例外を投げると全体が失敗しますが、この実装では関数内部でexceptしているため、gatherが中断されることはありません。
3. Haiku + 短いmax_tokens
分類は2フィールドの抽出なので、max_tokens=100で十分です。軽量モデル(Haiku)× 短い出力で、レイテンシとコストの両方を最小化しています。
分類結果の活用:ドメインコンテキスト注入
分類しただけでは意味がありません。結果をメインLLMのプロンプトに注入することで初めて価値が生まれます。
def _build_domain_context(
ticket_sw_map: dict[int, str],
problem_ctx: ProblemClassification,
) -> str:
"""ドメインコンテキストを構築してSonnetのプロンプトに注入する"""
# KBチャンクから抽出したソフトウェア情報を優先
sw_counts = Counter(ticket_sw_map.values())
kb_software = [sw for sw, _ in sw_counts.most_common(2)]
systems = kb_software or (
[problem_ctx.software] if problem_ctx.software else []
)
return (
f"\n\n## ドメインコンテキスト(システム自動分析)\n"
f"- 対象システム: {'、'.join(systems) or '不明'}\n"
f"- 問題カテゴリ: {problem_ctx.category or '不明'}"
)
このドメインコンテキストがSonnet(回答生成モデル)のプロンプトに入ることで、以下の改善が得られました。
- 冗長な確認質問の削減: 「Exchange Onlineにログインできない」→ Sonnetはシステムを聞き返さず、エラーコードなど具体的な症状を質問する
- カテゴリに応じた質問の最適化: 「認証・ログイン」カテゴリなら「エラーコード」を、「権限・アクセス」カテゴリなら「どの操作で失敗するか」を聞く
- エスカレーション先の自動決定: ソフトウェア名からシステム担当者を検索し、回答に含める
KBチャンクからの証拠を優先する
もう一つ実装上のこだわりがあります。ソフトウェア名の決定には2つのソースがあり、信頼度が異なります。
# KBチャンクから正規表現でソフトウェア名を抽出(実データに基づく)
ticket_sw_map = _extract_software_from_chunks(chunks)
# Haiku分類(クエリテキストのみから推論)
_ctx_sw = problem_ctx.software
# KB証拠を優先、なければHaiku分類にフォールバック
_kb_sw = [
sw for sw, _ in Counter(ticket_sw_map.values()).most_common()
if sw not in _GENERIC_SW
]
dominant_sw = _kb_sw[0] if _kb_sw else _ctx_sw
KBチャンクから抽出したソフトウェア名は実際のチケットデータに基づく証拠です。一方、Haiku分類はクエリテキストだけからの推論なので、誤分類のリスクがあります。そのため、KB証拠 → Haiku分類 → Noneの優先順位でフォールバックします。
asyncio.gatherを使う際の注意点
ブロッキング関数はto_threadで包む
KB検索にboto3やopensearch-pyのような同期ライブラリを使う場合、asyncio.to_thread()でラップしないとイベントループをブロックします。
async def retrieve_from_kb(query: str, top_k: int = 5) -> list[dict]:
def _blocking() -> dict:
client = get_opensearch_client()
return client.search(index=index_name, body=search_body)
# 同期関数をスレッドプールで実行し、イベントループをブロックしない
response = await asyncio.to_thread(_blocking)
return _parse_response(response)
これを忘れると、asyncio.gather()で並列化しているつもりでも、ブロッキング関数が他のコルーチンを止めてしまい、並列化の効果がなくなります。
エラーハンドリングの戦略を決める
asyncio.gather()にはreturn_exceptionsパラメータがあります。
# デフォルト: 1つでも例外が出たらgather全体が例外を投げる
results = await asyncio.gather(task_a(), task_b())
# return_exceptions=True: 例外もresultsに含める
results = await asyncio.gather(task_a(), task_b(), return_exceptions=True)
今回の実装では、classify_problem()内部でexceptしてデフォルト値を返すようにしました。この方が呼び出し側のコードがシンプルになり、型安全です。return_exceptions=Trueを使うと戻り値がException | Tのunion型になり、isinstanceチェックが必要になります。
補足: JavaScriptのPromise.allとの対応
asyncio.gather()はJavaScriptのPromise.all()とほぼ同じ概念です。Node.jsで同様のパターンを実装する場合の対応表を示します。
// Python: await asyncio.gather(task_a(), task_b())
const [kbQuery, problemCtx] = await Promise.all([
generateKbQuery(messages),
classifyProblem(rawQuery),
])
asyncio.gather() |
Promise.all() |
|
|---|---|---|
| 基本動作 | 全コルーチンを並行実行し、全完了を待つ | 全Promiseを並行実行し、全完了を待つ |
| 戻り値 | 引数順のリスト | 引数順の配列 |
| エラー時 | 1つ失敗で全体が例外(デフォルト) | 1つ失敗で全体がreject |
| エラー収集 | return_exceptions=True |
Promise.allSettled()を使う |
| 並行モデル | シングルスレッド・イベントループ | シングルスレッド・イベントループ |
本記事の「I/Oバウンドな処理の裏に軽量LLM呼び出しを隠す」パターンは、Node.jsでもそのまま適用できます。ボトルネックがネットワークI/Oにある限り、言語を問わず同じ原理で「タダ」の処理時間を得られます。
まとめ
asyncio.gather()でI/Oバウンドな処理の裏側に軽量LLM呼び出しを隠すことで、レイテンシ増加ゼロで分類機能を追加できる- 並列化の判断基準は依存グラフ: 互いに独立した処理だけをgatherする
- 分類関数は失敗してもメインフローを止めない設計にする(補助情報だから)
- 同期ライブラリは
asyncio.to_thread()でラップしないと並列化が機能しない - 分類結果はプロンプトへのコンテキスト注入で活用し、確認質問の品質向上やエスカレーション先の自動決定につなげる
このパターンは「分類」に限らず、メイン処理のI/O待ち時間の裏でできる軽量な前処理全般に適用できます。要約の生成、言語の検出、トーンの分析——ボトルネックがI/Oにある限り、asyncio.gather()で「タダ」の処理時間を手に入れられます。






