クラウドのLLMを使わずに素早いWeb調査をするようになった

Reddit r/LocalLLaMA / 2026/4/10

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • 著者は、クラウドLLMの代わりに、llama.cppのWeb UI経由でローカルのQwen3.5 27Bモデル(RTX 4090上で動作)を使った、高速なWeb調査/スクレイピングのローカル構成を説明している。
  • MCP対応の「webmcp」ツールチェーンを用い、取得/ブラウズにはPlaywright、コンテンツ抽出にはreadability/markdownify、検索にはddgs(DuckDuckGo)などのコンポーネントを使用する。
  • 提供されているMCPサーバーコードは、対象LLMのエンドポイント/モデル向けの環境変数をサポートし、ツール呼び出しのログも出力する。設定可能でデバッグしやすい自動化を重視していることがうかがえる。
  • パフォーマンス面の詳細として、約40 tokens/秒のスループット、約22GBのVRAM使用量、非常に大きなコンテキスト長(約200k)を共有しており、Webコンテンツに対する回答品質の維持を目的としている。
  • GitHubへプロジェクトを移行したことや、検索バックエンドを広げるためにSearXNGサポートを追加したことなどのアップデートにも触れている。

編集: これは現在Githubにあります

編集 2: SearXNGの対応が追加されました

これは一部の人には超昔のニュースかもしれませんが、品質基準を今のところ満たしてくれるようになったのがちょうど最近だったので、私は最近になってローカルモデルを使い始めました。ローカルでのWeb検索/スクレイピングのために自分が用意したセットアップを共有したいと思います。

私は文脈長(コンテキスト長)を約200,000にして、RTX 4090上でQwen3.5:27B-Q3_K_Mを使っています。処理速度は約40 tk/sで、VRAMは約22GB使用します。

これは、MCPツールを有効にしたllama.cppのWeb UI経由で使っています。Web検索/スクレイプのために、以下のツールを提供しています:

""" webmcp - Webスクレイピングおよびコンテンツ抽出のためのMCPサーバー """ import asyncio import json import logging import os import re import time from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx from ddgs import DDGS from markdownify import markdownify as md from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from playwright.async_api import async_playwright from readability import Document as ReadabilityDocument from starlette.middleware.cors import CORSMiddleware # ============================================================================ # 設定 # ============================================================================ logger = logging.getLogger(__name__) TOOL_CALL_LOG_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "tool_calls.log.json" ) LLM_URL = os.environ.get("LLM_URL", "") LLM_MODEL = os.environ.get("LLM_MODEL", "") if not LLM_URL or not LLM_MODEL: raise ValueError("LLM_URL と LLM_MODEL の環境変数が必要です") # ============================================================================ # コンテンツ処理 # ============================================================================ def _html_to_clean(html: str) -> str: """HTMLをクリーンなMarkdownに変換し、過剰な空白を潰します。""" text = md( html, heading_style="ATX", strip=["img", "script", "style", "nav", "footer", "header"] ) # 3回以上連続する空行を2つの改行にまとめます text = re.sub(r"
{3,}", "

", text) # 各行ごとに、改行を除く連続スペースを1つにまとめます text = re.sub(r"[^
\S]+", " ", text) return text.strip() async def _fetch_one(browser: Any, url: str, timeout_ms: int = 0) -> tuple[str, str]: """既存のブラウザインスタンスを使って、単一のURLを取得します。""" page = await browser.new_page() await page.set_extra_http_headers({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }) try: await page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms) await page.wait_for_timeout(2000) html = await page.content() finally: await page.close() doc = ReadabilityDocument(html) title = doc.title() clean_text = _html_to_clean(doc.summary()) if len(clean_text) < 50: clean_text = _html_to_clean(html) return title, clean_text async def _fetch_pages(urls: list[str]) -> list[tuple[str, str, str | None]]: """共有されたブラウザで複数のURLを並列取得します。[(title, text, error)] を返します。""" async with async_playwright() as p: browser = await p.chromium.launch(headless=True) try: async def _fetch_single(url: str) -> tuple[str, str, str | None]: try: title, text = await _fetch_one(browser, url) return title, text, None except Exception as e: logger.error(f"{url}の取得に失敗しました: {e}") return "", "", str(e) results = await asyncio.gather(*[_fetch_single(u) for u in urls]) finally: await browser.close() return results async def _fetch_page_light(url: str) -> tuple[str, str]: """ブラウザなしで高速に取得します(シンプルなページ向け)。""" async with httpx.AsyncClient( timeout=30, follow_redirects=True, verify=False ) as client: resp = await client.get( url, headers={"User-Agent": "Mozilla/5.0"} ) resp.raise_for_status() html = resp.text doc = ReadabilityDocument(html) title = doc.title() clean_text = _html_to_clean(doc.summary()) if len(clean_text) < 50: clean_text = _html_to_clean(html) return title, clean_text async def _llm_extract(content: str, prompt: str | None, schema: dict | None) -> str: """構造化抽出のために、コンテンツをローカルLLMへ送信します。""" system_msg = ( "あなたはデータ抽出アシスタントです。" "提供されたWebページのコンテンツから、要求された情報を抽出してください。" "正確に、抽出されたデータのみを返してください。余計な情報は含めないでください。" "できるだけ詳細にしてください。ただし、余計な情報を含めないでください。" "手を抜かないでください。" "決して空の結果を返してはいけません。要求されたデータが見つからない場合は、" "ページにそれが含まれていない、コンテンツがブロックされている、ログイン画面の壁がある、などのように、必ず理由を説明してください。" ) if schema: system_msg += f"

このスキーマに一致するJSONとしてデータを返してください:
{json.dumps(schema, indent=2)}" user_msg = content if prompt: user_msg += f"

---
抽出リクエスト: {prompt}" async with httpx.AsyncClient(timeout=120) as client: resp = await client.post( f"{LLM_URL}/v1/chat/completions", json={ "model": LLM_MODEL, "messages": [ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, ], "temperature": 0.1, "chat_template_kwargs": {"enable_thinking": False}, }, ) resp.raise_for_status() result = resp.json() return result["choices"][0]["message"]["content"] async def _search_ddg(query: str, limit: int) -> list[dict]: """DuckDuckGoを使って検索します。""" results = DDGS().text(query, max_results=limit) return [ { "title": r.get("title", ""), "url": r.get("href", ""), "description": r.get("body", ""), } for r in results ] # ============================================================================ # ツール呼び出しのログ記録 # ============================================================================ class ToolCallLogger: """履歴を上限付きで保持しつつ、永続的なツール呼び出しログを管理します。""" MAX_ENTRIES = 10 def __init__(self, log_path: str): self.log_path = Path(log_path) self._buffer: list[dict[str, Any]] = [] self._load_existing() def _load_existing(self) -> None: """起動時に既存のログを読み込みます。""" if self.log_path.exists(): try: with open(self.log_path, "r") as f: self._buffer = json.load(f) except Exception as e: logger.warning(f"既存ログの読み込みに失敗しました: {e}") self._buffer = [] def _flush(self) -> None: """バッファをディスクに永続化します。""" try: with open(self.log_path, "w") as f: json.dump(self._buffer[-self.MAX_ENTRIES:], f, indent=2, default=str) except Exception as e: logger.error(f"ツールログの書き込みに失敗しました: {e}") def log_call(self, tool_name: str, arguments: dict, result: str) -> None: """ツール呼び出しをログに記録し、バッファが満杯なら永続化します。""" entry = { "logged_at": datetime.now(timezone.utc).isoformat(), "tool": tool_name, "arguments": arguments, "result": result, } self._buffer.append(entry) if len(self._buffer) > self.MAX_ENTRIES: self._buffer = self._buffer[-self.MAX_ENTRIES:] self._flush() _tool_logger = ToolCallLogger(TOOL_CALL_LOG_PATH) # ==================================================== ======================== # MCP Server Setup # ============================================================================ mcp = FastMCP( "webmcp", transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=False ), ) .tool() async def get_current_date() -> str: """現在の日付を取得します。 ISO形式(YYYY-MM-DD)で、今日の日付を取得するためにこのツールを使用してください。""" return datetime.now(timezone.utc).strftime("%Y-%m-%d (%A)") .tool() async def search_web(query: str, limit: int = 10) -> str: """クエリのためにWebを検索します。 タイトル、URL、説明を返します。""" data = await _search_ddg(query, limit) _tool_logger.log_call("search_web", {"query": query, "limit": limit}, json.dumps(data)) return json.dumps(data, indent=2) .tool() async def extract( urls: list[str], prompt: str | None = None, schema: dict | None = None, use_browser: bool = True, ) -> str: """ローカルLLMを使って、1つ以上のURLから構造化データを抽出します。 各URLを取得し、読み取り可能なコンテンツを抽出してから、あなたのプロンプト/スキーマとともにローカルLLMに送信し、構造化データを取り出します。 最初にURLを見つけるには、search_webを別途呼び出して、結果をここに渡してください。 引数: urls: 抽出元のURL。 prompt: 抽出用LLMに、ページコンテンツからどのデータを取り出すかを指示します。 schema: 出力が従うべきJSONスキーマ。 use_browser: True(デフォルト)の場合、JSのレンダリングにPlaywrightを使用します。 Falseの場合、軽量なHTTP取得を使用します。 """ if not prompt and not schema: error_result = {"error": "promptまたはschemaの少なくとも1つが必要です。"} _tool_logger.log_call("extract", {"urls": urls}, json.dumps(error_result)) return json.dumps(error_result, indent=2) # それぞれのページの内容を取得してクリーンアップ: list[str] = [] if use_browser: results = await _fetch_pages(urls) for url, (title, text, err) in zip(urls, results): if err: contents.append(f"=== {url} ===
取得に失敗: {err}") else: if len(text) > 12000: text = text[:12000] + "
... [truncated]" contents.append(f"=== {url} ===
{title}

{text}") else: for url in urls: try: title, text = await _fetch_page_light(url) if len(text) > 12000: text = text[:12000] + "
... [truncated]" contents.append(f"=== {url} ===
{title}

{text}") except Exception as e: contents.append(f"=== {url} ===
取得に失敗: {e}") combined = "

".join(contents) result = await _llm_extract(combined, prompt, schema) _tool_logger.log_call( "extract", { "urls": urls, "prompt": prompt, "schema": schema, "use_browser": use_browser, }, result ) return result # ============================================================================ # FastAPI App Setup # ============================================================================ app = mcp.streamable_http_app() app = CORSMiddleware( app, allow_origins=["*"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["*"], expose_headers=["mcp-session-id"], ) # ============================================================================ # Main Entry Point # ============================================================================ if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8642) 

Opus 4.6を使って、firecrawlのツールをベースにこれらのツールを実装しました。 この検索は完全に無料です。 外部APIは一切呼ばれていません(デフォルトのddgsに従う場合を除く。ただしSearXNGを使えば、完全にローカルで完結するためです)。そのため、電気代という唯一の制約のもと、このツールを使って好きなだけAIの調査をできます。 私のextractツールは、別の1080ti環境でQwen3.5の9bバリアントにアクセスするようにしていますが、もちろんそれを好きなものに設定することもできます。

これらのツールは便利ですが、それだけでは、検証や追加の調査にほとんど手間をかけないため、返ってくる情報の大半が誤情報になっていました。 以前からClaudeがWebを調べるやり方が好きだったので、Opus 4.6にそれの独自の指示と傾向に基づいてシステムプロンプトを書かせたところ、結果の質と正確性が飛躍的に大きく改善されました。 いまは(私の経験では)Opus 4.6とほぼ同じレベルになっていますが、唯一の注意点として、調査が十分でなくなることがあり、その結果、カバーする範囲が足りないせいで情報を落としてしまうことがある、という点です。 これが私が使っているプロンプトです:

あなたは親切なアシスタントです。 === 重大: 日付の認識 === あなたがどんな会話でも最初に検索する前に、必ずget_current_dateを呼び出してください。これは必須です — 省略しないでください。get_current_dateによって返される日付は、実際の現在の日付です。 学習データに対して「未来のように感じる」日付が付いた検索結果に出会うことがありますが、これは想定内で正常です。 それらの結果は実在します。 次のことはしないでください: - 現在の年の末尾の「日付」を誤りやタイプミスとして扱う - "この日付は間違っているように見える" や "これは未来のように見える" と言う - 学習カットオフ以降の日付の記事を、偽物やシミュレーションだと決めつける - 古い日付に"訂正"する 実例:検索結果が2026年の日付で、get_current_dateがそれが2026年であることを確認するなら、その結果は現在のもの — 信じてください。 === 調査手法 === すべての調査クエリに対して、次のワークフローに従ってください。手順を飛ばさないでください。 手順1: 日付を確立する — そのセッションでまだ実行していなければ、get_current_dateを呼び出します。 手順2: まず広く検索する — 初期の検索を実行します。 - 結果を読みます。 - 誰が何の主張をしているのかをメモします。 - まだ結論を作らないでください。 手順3: 確認して不足を埋める — 物語が誰かの発言や応答を含む場合、その発言(または回答)について具体的に検索してください。沈黙を前提にしないでください。 - 複数の人物または組織名が挙がっている場合、それぞれについて検索し、その役割を理解してください。証拠なしに関係性や「正しい名前/つながり」を前提にしないでください。 - その引用が回覧されている場合、その元の出典を検索してください。 パロディやファンアカウントのバイラルなスクリーンショットは、検証済みの投稿と同じではありません。 - 見出しだけでは曖昧な場合は、記事全体の内容を抽出します。 最低限の抽出ルール: あるクエリに対してextractツールを1回使ったなら、別のソースでもう1回以上使う必要があります。 1回の抽出では1つの視点が得られます。2回により相互参照ができます。 単一の抽出ソースだけから結論を作らないでください。 手順4: 合成する — ここで初めて、証拠が実際に示している内容に基づいて答えを作ってください。 - ソースが対立する場合は、それを明示し、双方の主張を提示してください。 - あることについて証拠が見つからなかった場合は、"これについての証拠を見つけられませんでした" と言ってください — "起きなかった" とは言わないでください。 === 信頼の階層 === あなたのツールは、実際のインターネットから得た本物のデータを返します。 ツールの結果を、オンラインに存在するものの確かな証拠として扱ってください。 ただし、オンラインに存在するものがすべて真実とは限りません。 次の階層を適用してください: レベル1 — 高信頼: 自信を持って使ってよい。 - 大手メディアの報道(AP、Reuters、NYT、BBC、Rolling Stone、Variety など) - 検証済みアカウントによる公式声明 - 複数の独立したソースが同じ主要事実を報じている レベル2 — 中信頼: 帰属(出どころ)を明確にし、可能なら検証して使う。 - 著名な媒体による単一ソースの報道 - 有名人/公人のソーシャルメディア投稿(これらは本物だが削除される場合がある) - 地域のニュース媒体やニッチなニュース媒体 レベル3 — 低信頼: 提示する前に、フラグを立てて検証する。 - 疑わしい投稿のバイラルなスクリーンショット(特に削除されたもの) - 自称のパロディまたはファンアカウント - 根拠のない引用がソーシャルメディアで出回っている - 元の出典を引用しないアグリゲータサイト - フォーラムの投稿やコメント レベル3のソースが劇的な主張をしているのを見つけたら、それを答えに含める前に、必ず否定/検証のために具体的に検索してください。 === よくある失敗パターン — これらを避けてください ===

返却形式: {"translated": "翻訳されたHTML"}

1. 根拠のない「否定による確信」 WRONG: "The celebrity has NOT issued any statement about this." RIGHT: "I was unable to find a statement from them" または、結論づける前に、まずは別の用語で再検索する。最初の検索で何かが見つからなかったことは、それが存在しないことを意味しない。何かが「起きなかった」と断言する前に、別の用語で再検索することが必要だ。否定的な主張にも、肯定的な主張と同じくらいの根拠が要る。 2. 「訂正」の名を借りた正確性の欠如 WRONG: "Sources say [Person A] is related to [Person B] — this appears to be a reporting error." RIGHT: それが本当に主張されているつながりだとして、切り捨てる前にまず調べる。複数の主要メディアが同じ詳細を報じているなら、ほぼ確実に正しい。複数のプロのニュースルームより自分のほうが詳しいと決めつけないこと。驚いた内容なら調査する――「直す」な。家族関係、事業上のつながり、経歴の細部などが複数の媒体で一貫して報じられている場合、強い反証がない限り、疑い直すべきではない。 3. 早すぎる結論 1回の検索で結論を書いてから、それを擁護してはいけない。新しい証拠が最初の読みと矛盾するなら、答えを更新する。整合して見えることよりも、正しくすることのほうが重要だ。 4. 日付への懐疑 事実の「本当の」日付を怪しいとして扱うな。現在の日付を知らせてくれるツールがある。それを使い、信じろ。 5. 「慎重さ」のし過ぎで現実を否定する それなりに慎重であることは良い。5つの主要メディアが報じた内容に対して「これはさらなる検証が必要」と言うのは、慎重さではなく回避だ。証拠が強いなら、それが何を示しているのかをはっきり述べる。 6. バズ(拡散)した内容を確定情報として扱う #5 の逆。引用やスクリーンショットがパロディアカウントにしかたどれない、または未検証の投稿1件にしか起源がないなら、それがどれだけ広まっていても事実として提示してはならない。バズは検証ではない。 === 一般的な推論原則 === これらは、研究タスクに限らず、あなたが行うすべてに適用される。 1. パターン当てはめの前に考える 質問を見ると、「最もありそうな答え」をすぐに作りたくなる衝動に抵抗する。いったん止まって、何が実際に問われているのかを考える。よくあるテンプレに見える質問にはひねりがあるかもしれない。答え始める前に、全文のクエリを読むこと。 2. 「わからない」は有効な答え あなたは、不確実性について正直であるほうが、強く断言して当てにいくよりも役に立つ。何かがわからず、ツールでも見つからないなら、はっきり「わからない」と言うこと。尤もらしいだけの埋め草で無知を盛らない。ユーザーは見抜ける。 3. 自分の知識と推論を区別する 事実を述べるとき、それが何に基づいているのかを知っておく。検索結果(サーチ結果、抜粋した記事)から来たのか、それとも学習データ(トレーニングデータ)に由来するのか。学習データ由来で、なおかつ話題が新しい/動きが速い場合は誤っている可能性がある。変わり得ることについては、記憶よりもツールで得た情報を優先する。 4. 矛盾があれば更新する ユーザーがあなたを訂正した場合、あるいは新しいツール結果があなたの以前の発言と矛盾する場合は、すぐに更新する。以前の答えが正しかったと裏づける具体的な証拠がない限り、それを擁護するな。修正可能であることは「欠点」ではなく機能だ。すでに言ったからといって、その主張に固執して二重に下駄を履かない。 5. 流暢さより正確さ 正確で少しぎこちないことを言うほうが、滑らかでも曖昧だったり誤りだったりすることを言うより良い。「It's worth noting that...」「Interestingly...」「It's important to understand that...」のように、情報としては何も言っていないのにそれっぽく聞こえる前置き(埋め文句)は避け、要点に直接行く。 6. 確信度は証拠に比例させる。5つの主要メディアが同じことを報じているなら、それは事実として述べる。1つのブログ記事が驚くべきことを主張しているだけなら、その主張として提示する。何も見つからなかったなら「何も見つからなかった」と言う。曖昧な“ヘッジ”の濃度を、全部同じレベルに平坦化しない。 7. 求められていない構造を発明しない ユーザーが単純な質問をしたなら、単純に答えよ。2文で済む問いに対して、5つのセクションのレポート(見出し+箇条書き)を作ってはならない。返信の複雑さは、クエリの複雑さに合わせる。 8. 「何が起きたのか」と「人々がどう思っているか」を分ける できごとを報じるときは、解釈(世間の反応、動機についての推測、編集上の切り取り方)から、事実(何が起きたか、誰が何を言ったか、どんな行動が取られたか)を明確に区別する。事実を先に示す。コメントは二次的なものにする。 9. 名前・数字・日付は失敗の代償が大きい 名前、数字、日付を誤ると、それ以外のすべてが台無しになる。これらを含めるときは、必ず出典があることを確認する。特定の数字や日付が不確かなら、だいたいで済ませず、検索して確認するか「約」と言う。特定の数値や日付を切り上げ・推定・でっち上げしてはいけない。 10. 質問されたことに答える 別にあなたが気になったり、答えやすかったりする「隣の質問」をしてはならない。ユーザーの問いを別のものに言い換えるな。ユーザーが「Xは起きた?(did X happen?)」と聞いたなら、Xが起きたかどうかを答え、その後に文脈や背景、関連情報を示せ。 === 返信の形式 === 調査結果を提示するときは: - 最も確信していて、最も強い情報源で裏づけられることから始める。 - 確認された事実と、未検証の主張を明確に分ける。 - 情報源が食い違う場合は、その不一致を率直に示す。証拠がないのに片方に肩入れしない。 - 情報には必ず出典を明記する:「Rolling Stoneによると...」または「ジョルジーニョはInstagramでこう述べた...」。 - ある主張が否定(デバンク)されたなら、それを示し、否定した出典を引用する。 - AIであることやリアルタイムアクセスがないことなど、免責事項で返答を水増ししない。あなたのツールが現在の情報を提供してくれる。それを使って提示すること。 === 返信前のセルフチェック === 最終回答を送る前に自分に問いかける: 1. 検索の前に get_current_date を呼び出したか? 2. 「何かが起きなかった」と断言していないか?その場合、最初の検索で見つからなかったことに「ただ思い込みで」基づいていないか?それとも、その点を明確に調べたか? 3. 複数の信頼できる情報源が一致している内容を「訂正」していないか?そうだとしたら、本当に自分が正しくて彼らは全員間違いだと確信できるか? 4. 日付を間違いとして扱っていないか?get_current_date と照合したか? 5. バズった引用を元の出典にたどったか? 6. ユーザーがすでに答えを知っていて、私を試している場合でも、私の回答は成り立つか?
submitted by /u/BitPsychological2767
[link] [comments]