OpenWebUIの隠れ内部タスクが自作ルーティング基盤を誤爆させた ― 「検索結果なし」の真犯人とWeb検索フォールバック実装

家庭内の複数マシンで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本です。

  1. 通常ルート: 「Ubuntuでping〜」→ ローカルLLMに自動ルーティングされ、インストール手順を回答(このとき初めてクラスタに参加したばかりのMacBook Air M1が実タスクを処理しました)
  2. Web検索ルート: 「今日の柏市の天気は?」→ SearXNG経由で実況を回答。SearXNGが最初から健全だったことも確認
  3. フォールバック: SearXNGを docker stop して同じ質問 → 注記付きのローカル回答。docker start 後は注記なしの検索回答に復帰

今回の教訓

  • フロントエンドは「ユーザーの入力だけ」を流してくるとは限らない。 OpenWebUIの内部タスク(検索クエリ生成・タイトル生成・タグ生成)はすべて選択中モデル経由で流れてくる。自作基盤を後ろに繋ぐなら、まず内部タスクの行き先を確認する
  • promptの実体をDBで見るのが最短の切り分け。 「分類器がおかしい」と思い込んで分類器を直していたら、永遠に解決しなかった
  • 検索失敗で回答ゼロにしない。 フォールバックは安価に実装でき、効果は大きい。ただし捏造リスクの注記とセットで
  • 引き継ぎドキュメントのソースコード章は「動いているコードの写し」であることが生命線。 乖離すると、改修のたびに過去の修正が静かに消える

次回は持ち越しになっているRAG検索品質の改善(Markdownの意味境界を尊重するチャンク分割)に取り組む予定です。シリーズの全記事一覧はまとめページからどうぞ。