家庭内の複数マシンでLLM推論を分散処理する自作基盤「Coordinator」シリーズの第19回。今回は新機能の話ではなく、障害解析の話です。「Ubuntuでpingコマンドが見つかりません」という、ローカルLLMなら一行で答えられる質問が、なぜか全部Web検索に飛ばされて「検索結果が見つかりませんでした」と回答ゼロになる――しかも調べていくと、自作したルーティング基盤のどこにもバグがなかった、という事件です。
この記事で解決すること
- OpenWebUI + 自作Pipe Function構成で、簡単な質問が誤ってWeb検索ルーティングされる原因の特定
- 「検索結果が見つかりませんでした」「Coordinatorへの接続がタイムアウトしました」の真因と対処
- Web検索が失敗しても回答ゼロにしない、ローカルLLMフォールバックの実装
- キーワード方式のWeb検索自動判定における誤爆抑制(ネガティブキーワード)の設計
結論を先に言うと、犯人はOpenWebUI本体の組み込み機能でした。自作部分(ルーティングAPI・検索Worker・SearXNG)はすべて無実。フロントエンドが裏で何を送っているかを疑え、という教訓の記録です。
症状: 簡単な質問が全部Web検索行きになる
OpenWebUIから次の質問を投げます。
Ubuntuでpingコマンドが見つかりません。何をインストールすればいいですか?
普通のLLMなら sudo apt install iputils-ping で終わる質問です。ところが結果はこうなりました。
📡 ルーティング情報
- ドメイン: code
- モデル: web_search
- Worker: web-worker
- キュー: tasks:web
⏳ 処理中...
検索結果が見つかりませんでした。
本来なら code ドメインとしてGPU WorkerのローカルLLMに振られるはずが、Web検索Workerに飛ばされ、しかもその検索が0件。正解できる質問が「検索失敗→回答ゼロ」になる、ユーザー体験的に最悪のパターンです。
切り分け①: ルーティングの分類器は無実だった
Coordinatorは「最新」「今日」「天気」などのキーワードでWeb検索の要否を判定しています(過去記事参照)。この質問にはどのキーワードも含まれていないはず――確認のため、Coordinatorのプレビューエンドポイントに素の質問を直接投げてみます。
curl -sG http://localhost:8000/route/preview \
--data-urlencode "prompt=Ubuntuでpingコマンドが見つかりません。何をインストールすればいいですか?"
→ domain: general / auto_web_search: False
分類器は正しく「Web検索不要」と判定していました。同じテキストで判定が変わることはあり得ません。つまり――OpenWebUI経由で /route に届いているpromptは、私が入力した文面と違う。
切り分け②: psqlでpromptの実体を見る
幸い、Coordinatorは受け取ったpromptをPostgreSQLのtasksテーブルにそのまま保存しています。Workerに実際に渡った文字列を直接確認できます。
SELECT created_at, length(prompt) AS len, left(prompt, 400)
FROM tasks WHERE model='web_search'
ORDER BY created_at DESC LIMIT 3;
結果は衝撃でした。入力した質問は50文字程度なのに、len は2333〜5053。中身は日本語の質問ではなく、こんな英文テンプレートだったのです。
### Task:
Analyze the chat history to determine the necessity of generating
search queries, in the given language. By default, **prioritize
generating 1-3 broad and relevant search queries** ...
真因: OpenWebUIの組み込み機能が3層の事故を起こしていた
このテンプレートはOpenWebUI本体の組み込みWeb検索機能のものです。画面に出ていた「1 件のソースを取得」という表示も、自作Pipeではなくこの機能のものでした。整理すると、事故は3層構造になっていました。
第1層: 内部タスクのprompt汚染。 OpenWebUIの組み込みWeb検索は、検索クエリ生成や回答合成のための英文テンプレートを「現在選択中のモデル」に投げます。選択中のモデルは自作Pipe=Coordinatorなので、テンプレートがそのまま /route に流れ込みます。テンプレート内の “Today’s date” などがWeb検索キーワードに、”json” “api” などがcodeドメインのキーワードにヒットし、誤ルーティングが完成します。
第2層: 検索0件の正体。 5000文字の英文テンプレート全文がそのままSearXNGの検索クエリとして投げられていました。そんな検索が0件なのは当然で、「検索結果が見つかりませんでした」はSearXNGの障害ではありませんでした。
第3層: タイムアウトの連鎖。 組み込みWeb検索を無効化すると、今度は「Coordinatorへの接続がタイムアウトしました」が出るようになりました。調べると、OpenWebUIはタイトル自動生成・タグ自動生成も選択中モデル経由で投げており、この内部タスク(タイトル1件に53秒)がCoordinator同居のOllamaを占有。ドメイン分類LLMと競合して /route の応答がPipeのハードコードタイムアウト15秒を超えていたのです。実測では、分類の所要時間は単発2.7秒に対し競合時19.3秒。裏ではタスク投入に成功しているのに、画面はエラーというちぐはぐが起きていました。
対処1: OpenWebUIの内部タスクを止める
管理者設定から以下を無効化します。検索はCoordinator側の責務として設計済みなので、フロントエンドで二重に持つ理由がありません。
- Web検索(組み込み)→ オフ
- タイトル自動生成 → オフ
- タグ自動生成 → オフ
1.2B〜3BモデルのCPU推論53秒をチャットのタイトルに払うのは割に合わない、という判断でもあります。なお、これらの設定はOpenWebUIのアップデートで復活する可能性があるため、tasksテーブルに「### Task:」で始まる英文promptが現れたら再発のシグナルとして覚えておきます。
対処2: Pipeのタイムアウトを設定可能にする(v6.3)
ドメイン分類がCPU LLMを使う以上、混雑時に15秒を超えるのは構造的な問題です。Pipe Functionの /route タイムアウトをハードコードからValves(OpenWebUIの管理画面から調整できる設定値)に変更し、既定60秒にしました。
ROUTE_TIMEOUT: float = Field(
default=60.0,
description="/route 呼び出しのタイムアウト(秒)。ドメイン分類LLMが"
"AI-Core上の他推論と競合すると15秒超になり得るため余裕を持たせる",
)
# 呼び出し側
timeout=aiohttp.ClientTimeout(total=self.valves.ROUTE_TIMEOUT),
あわせてタイムアウト時のメッセージも「接続不可」と「混雑」を区別できる文言に変えました。エラーメッセージが原因の切り分けを助けるかどうかは、運用してみると効きます。
対処3: 検索失敗時のローカルLLMフォールバック(worker_web.py v2.1)
今回の調査で一番痛かったのは「正解できる質問 → 検索へ → 検索失敗 → 回答ゼロ」という流れです。真因は潰しましたが、SearXNGの障害や上流エンジンのブロックなど、検索0件は今後も起こり得ます。そこでWeb検索Workerに、検索結果が空のときローカルLLMの知識のみで回答するフォールバックを実装しました。
def process_web_search(task: dict):
...
results = search(prompt)
if results:
response = summarize(prompt, results)
else:
log.warning(f"[web_search] no results, falling back to local LLM.")
response = answer_without_search(prompt)
update_task(task_id, "done", response)
設計上のポイントは、Coordinatorの /route への再投入をあえて採用しなかったことです。再投入すると新しいtask_idが発行され、フロントエンドがポーリングしている元のtask_idに結果が届きません。さらに、プロンプトが再びWeb検索キーワードにヒットして無限ループする危険もあります。Worker内で同一task_idのまま完結させるのが安全です。
フォールバック回答には必ず注記を付けます。
※ Web検索が利用できなかったため、ローカルLLMの知識のみで回答しています。
最新情報は反映されていない可能性があります。
この注記は飾りではありません。障害注入テスト(SearXNGコンテナを意図的に停止)で天気を聞いたところ、ローカルLLMは「最高気温は27.7度、最低気温は20.2度です」ともっともらしい数字を創作しました。小型モデルの知識だけで答えるモードには、捏造リスクの明示が不可欠です。
対処4: 誤爆抑制キーワードの復元と、ドキュメント管理の教訓
調査の過程でもうひとつ発見がありました。以前のバージョンで実装したはずの「Web検索誤爆抑制(ネガティブキーワード)」が、配備コードにも引き継ぎドキュメントのソースコード章にも存在しなかったのです。その後の改修時に、古いソースをベースに編集して変更が消える「先祖返り」が起きていたと推定されます。
復元した判定ロジックはシンプルです。ポジティブキーワード(今日・最新・天気など)にヒットしても、コード・実装・エラー・学習・説明依頼系の語が含まれていれば抑制します。
def classify_web_search(prompt: str) -> bool:
prompt_lower = prompt.lower()
pos = next((kw for kw in WEB_SEARCH_KEYWORDS if kw in prompt_lower), None)
if pos is None:
return False
neg = next((kw for kw in WEB_SEARCH_NEGATIVE_KEYWORDS if kw in prompt_lower), None)
if neg is not None:
log.info(f"[web_search_classify] pos={pos!r} suppressed by neg={neg!r}")
return False
log.info(f"[web_search_classify] keyword={pos!r} → web_search")
return True
14ケースのユニットテストで検証しました。「今日の柏市の天気は?」はWeb検索へ、「今日学んだPythonコードの実装を復習したい」は抑制、そして「最新ニュースを教えて」は誤抑制しない(だから「教えて」はネガティブに入れない)――この境界線の設計が肝です。あわせて判定ログをINFOレベルに上げ、journalctlからどのキーワードでヒット・抑制されたかが直接見えるようにしました。今回のような調査が次は数分で終わるはずです。
検証: 障害注入テストまで通して完了
最終確認は3本です。
- 通常ルート: 「Ubuntuでping〜」→ ローカルLLMに自動ルーティングされ、インストール手順を回答(このとき初めてクラスタに参加したばかりのMacBook Air M1が実タスクを処理しました)
- Web検索ルート: 「今日の柏市の天気は?」→ SearXNG経由で実況を回答。SearXNGが最初から健全だったことも確認
- フォールバック: SearXNGを
docker stopして同じ質問 → 注記付きのローカル回答。docker start後は注記なしの検索回答に復帰
今回の教訓
- フロントエンドは「ユーザーの入力だけ」を流してくるとは限らない。 OpenWebUIの内部タスク(検索クエリ生成・タイトル生成・タグ生成)はすべて選択中モデル経由で流れてくる。自作基盤を後ろに繋ぐなら、まず内部タスクの行き先を確認する
- promptの実体をDBで見るのが最短の切り分け。 「分類器がおかしい」と思い込んで分類器を直していたら、永遠に解決しなかった
- 検索失敗で回答ゼロにしない。 フォールバックは安価に実装でき、効果は大きい。ただし捏造リスクの注記とセットで
- 引き継ぎドキュメントのソースコード章は「動いているコードの写し」であることが生命線。 乖離すると、改修のたびに過去の修正が静かに消える
次回は持ち越しになっているRAG検索品質の改善(Markdownの意味境界を尊重するチャンク分割)に取り組む予定です。シリーズの全記事一覧はまとめページからどうぞ。