LangChain Google-Suche ersetzen: Custom Search wird eingestellt

GoogleSearchAPIWrapper vor dem Ende von Google Custom Search am 1. Januar 2027 ersetzen: ein getestetes LangChain-Tool, das Titel, Link und Snippet beibehält.

GoogleSearchAPIWrapper und die darauf aufbauenden google_search-Tools rufen die Google Custom Search JSON API auf, die Google für Neukunden geschlossen hat und am 1. Januar 2027 beendet. Der Wrapper kann das nicht umgehen, die Lösung ist also, das Backend zu tauschen. Unten steht ein LangChain-Tool mit 79 Zeilen, ausgeführt am 19. Oktober 2026, das dieselben Tool-Namen und dieselben Felder title, link und snippet behält, damit bestehende Chains und Agent-Prompts weiterlaufen. Eine Suche kostet 5 FastAF-Credits.

Was genau hört in LangChain am 1. Januar 2027 auf zu funktionieren?

Die Übersicht zur Custom Search JSON API von Google sagt, die API „is closed to new customers” und bestehende Kunden „have until January 1, 2027 to transition to an alternative solution” (geprüft am 19. Oktober 2026). Die einzige genannte Alternative, Vertex AI Search, wird als „a favorable alternative for searching up to 50 domains” beschrieben, das ist Seitensuche, keine Websuche.

In langchain-google-community 5.1.1 (heute in einem frischen venv installiert, mit langchain 1.4.6 und langchain-core 1.6.12) baut GoogleSearchAPIWrapper aus GOOGLE_API_KEY und einer GOOGLE_CSE_ID einen customsearch-v1-Client, und jeder Aufruf läuft über cse().list(). Die ältere Kopie in langchain_community ist bereits als deprecated markiert. LangChains Seite zu Google Search dokumentiert den Wrapper weiterhin ohne Hinweis auf die Abschaltung, und der Tool-Index listet Dutzende weiterer Such-Integrationen. Das Verhalten nach dem Stichtag konnte ich nicht testen, dieser Teil ist also Googles Aussage, nicht meine Messung.

Das größere Bild findest du unter Googles offizielle Search API vs. SERP APIs und Websuche für KI-Agenten nach Bing. Dieser Beitrag behandelt nur den Austausch in LangChain.

Wie ersetze ich GoogleSearchAPIWrapper durch ein Drop-in-Tool?

Der alte Wrapper hat zwei Aufrufe, auf die Chains angewiesen sind: run(query), das Snippets zu einem String verbindet, und results(query, num_results, search_params), das Dicts mit title, link und snippet liefert, bei leerem Ergebnis [{"Result": "No good Google Search Result was found"}]. Darauf sitzen die Tools google_search und google_search_results_json. Dieses Modul bildet alles nach, synchron und asynchron, auf Basis des /v1/serp-Endpoints:

import os, time, httpx
from langchain_core.tools import StructuredTool, ToolException

BASE = "https://api.fastaf.co/v1"
NOTHING = [{"Result": "No good Google Search Result was found"}]
COST = 5  # credits per search

class FastAFSearch:
    """Same surface as GoogleSearchAPIWrapper: run(), results(), arun(), aresults()."""

    def __init__(self, k=10, max_credits=None, ttl=300, **defaults):
        self.k, self.max_credits, self.ttl, self.defaults = k, max_credits, ttl, defaults
        self.spent, self._cache = 0, {}
        self._headers = {"Authorization": f"Bearer {os.environ['FASTAF_API_KEY']}"}

    def _hit(self, key):
        hit = self._cache.get(key)
        return hit[1] if hit and time.time() - hit[0] < self.ttl else None

    def _params(self, query, extra):
        if self.max_credits is not None and self.spent + COST > self.max_credits:
            raise ToolException(f"Search budget of {self.max_credits} credits is used up.")
        self.spent += COST  # reserve before the request, so parallel async calls cannot both pass
        return {"query": query, **self.defaults, **(extra or {})}

    def _parse(self, key, resp, num):
        # An API error is never charged: give the reservation back. A 200 reports its real cost.
        self.spent += (int(resp.headers["X-FastAF-Credits-Used"]) if resp.status_code == 200 else 0) - COST
        if resp.status_code != 200:
            raise ToolException(f"Search failed: HTTP {resp.status_code}")
        out = [{"title": r["title"], "link": r["link"],
                "snippet": r.get("inline_snippet") or r.get("snippet", "")}
               for r in resp.json()["organic_results"][:min(num, 10)]]
        self._cache[key] = (time.time(), out or NOTHING)
        return out or NOTHING

    def results(self, query, num_results, search_params=None):
        key = (query, num_results, str(search_params))
        if (hit := self._hit(key)) is not None:
            return hit
        resp = httpx.get(f"{BASE}/serp", params=self._params(query, search_params),
                         headers=self._headers, timeout=60)
        return self._parse(key, resp, num_results)

    async def aresults(self, query, num_results, search_params=None):
        key = (query, num_results, str(search_params))
        if (hit := self._hit(key)) is not None:
            return hit
        async with httpx.AsyncClient(timeout=60) as c:
            resp = await c.get(f"{BASE}/serp", params=self._params(query, search_params),
                               headers=self._headers)
        return self._parse(key, resp, num_results)

    @staticmethod
    def _join(rs):
        return " ".join(r["snippet"] for r in rs if "snippet" in r) or NOTHING[0]["Result"]

    def run(self, query: str) -> str:
        return self._join(self.results(query, self.k))

    async def arun(self, query: str) -> str:
        return self._join(await self.aresults(query, self.k))

def fastaf_search_tools(api: FastAFSearch, num_results: int = 4):
    """Two tools with the names and descriptions of GoogleSearchRun / GoogleSearchResults."""
    def as_json(query: str) -> str:
        return str(api.results(query, num_results))
    async def aas_json(query: str) -> str:
        return str(await api.aresults(query, num_results))
    desc = ("A wrapper around Google Search. Useful for when you need to answer questions "
            "about current events. Input should be a search query.")
    return [
        StructuredTool.from_function(func=api.run, coroutine=api.arun, name="google_search",
                                     description=desc, handle_tool_error=True),
        StructuredTool.from_function(func=as_json, coroutine=aas_json,
                                     name="google_search_results_json",
                                     description=desc + " Output is a JSON array of the query results",
                                     handle_tool_error=True),
    ]

Setz es dort ein, wo du das alte Paar benutzt hast:

from fastaf_search_tools import FastAFSearch, fastaf_search_tools

api = FastAFSearch(country="us", max_credits=50)
tools = fastaf_search_tools(api, num_results=5)   # drop into create_agent(model, tools=tools)

Ein Detail ist gemessen, nicht angenommen. Über 20 verschiedene Abfragen (192 Ergebnisse, 100 Credits, 19. Oktober 2026) trugen 38 Ergebnisse (20 %) ein inline_snippet. Bei 36 davon enthielt das Feld snippet nur Sitelink-Beschriftungen wie „More results from www.reddit.com”, und die eigentliche Beschreibung stand in inline_snippet. Das Modul bevorzugt inline_snippet, so sah auch das alte snippet aus. Jede Abfrage lieferte 9 oder 10 organische Ergebnisse.

Was habe ich tatsächlich ausgeführt, und was hat es gekostet?

In dieser Umgebung hatte ich keinen LLM-API-Key, es ist also kein echtes Modell beteiligt. Ich habe die Tools direkt ausgeführt (invoke und ainvoke) und dann in einer create_agent-Schleife, gesteuert von einem geskripteten Fake-Chatmodell, das drei Tool-Aufrufe abspielt. Das belegt die Verdrahtung in der LangChain-Runtime, nicht das Verhalten eines Modells.

Gegen https://api.fastaf.co/v1 am 19. Oktober 2026:

Lauf Latenz Credits Ergebnis
google_search_results_json, sync 3,20 s 5 5 Ergebnisse, Schlüssel link, snippet, title
Dieselbe Abfrage nochmal 0,0002 s 0 aus dem Cache geliefert
google_search, async 4,74 s 5 1.642 Zeichen verbundene Snippets
Geskripteter Agent, 3 Tool-Aufrufe, Budget 10 5,8 s 10 dritter Aufruf mit einer Meldung abgelehnt

Frühere Läufe desselben Codes in Produktion brauchten 6,9 und 8,1 s pro Suche, plane also mit etwa 3 bis 8 Sekunden, nicht mit Millisekunden. Im Lauf mit 20 Abfragen bei Nebenläufigkeit 4 lag der Median bei 4,0 s und das Maximum bei 12,4 s. Fehlgeschlagene Requests werden nicht berechnet. Die Budgetprüfung wurde nach diesen Läufen verschärft (sie reserviert die Kosten jetzt vor dem Request) und nur gegen einen Stub erneut geprüft: Mit max_credits=6 lassen zwei parallele aresults-Aufrufe einen durch und lehnen den anderen ab.

Wie werden die alten Parameter auf das neue Tool abgebildet?

GoogleSearchAPIWrapper Dieses Tool
GOOGLE_API_KEY + GOOGLE_CSE_ID nur FASTAF_API_KEY, keine Engine-ID
k, num_results gleiche Namen; auf 10 pro Aufruf begrenzt, wie Googles num
search_params={"gl": "de"} {"country": "de"}
{"hl": "de"} {"lang": "de"}
{"lr": ...}, {"cr": ...}, {"safe": "active"} gleiche Namen
{"start": 11} (ab 1 gezählt) {"page": 1} (ab null gezählt; Seite 1 lieferte andere Ergebnisse als Seite 0)
siterestrict=True, siteSearch site:example.com in die Abfrage schreiben (getestet)
exactTerms, excludeTerms Anführungszeichen und -term in der Abfrage
dateRestrict="w1" {"tbs": "qdr:w"}, ein Google-Filterstring (wird akzeptiert; die Datumsfilterung habe ich nicht geprüft)
Felder image, thumbnail werden nicht geliefert
searchType="image" von diesem Endpoint nicht abgedeckt

Der Unterschied im Verhalten wiegt schwerer. Custom Search hat eine Engine abgefragt, die du selbst konfiguriert hast. Dieses Tool liefert, was google.com für die Abfrage zurückgab, die Rankings weichen also von deinen alten Ergebnissen ab. Prüf jedes Eval-Set neu, das auf bestimmte URLs festgelegt war. Der Parameter results der API verkleinerte in meinen Tests die Seitengröße nicht (results=3 und results=20 lieferten beide 9), das Modul schneidet deshalb clientseitig zu.

Wie cache ich und begrenze die Ausgaben pro Lauf?

Beides steckt im Modul. ttl cacht nach Abfrage, Anzahl und Parametern, ein Agent, der eine Suche wiederholt, zahlt also nur einmal. max_credits reserviert die 5 Credits einer Suche vor dem Request, tauscht sie gegen den Header X-FastAF-Credits-Used der Antwort (ein API-Fehler gibt sie zurück) und wirft ToolException, sobald die nächste Suche nicht mehr passen würde; mit handle_tool_error=True erhält der Agent „Search budget of 10 credits is used up.” als normales Tool-Ergebnis und kann darüber nachdenken, wie in der Tabelle oben. Der Wrapper hält keinen Zustand über Prozesse hinweg, und der Cache liegt im Arbeitsspeicher.

Das Guthaben des Accounts ist die äußere Grenze: Ein aufgebrauchtes Guthaben liefert 402 und stoppt alles. Einem KI-Agenten Live-Websuche geben, mit hartem Ausgabenlimit behandelt die Ebenen drumherum, und SERP APIs für RAG zeigt, was du mit den Ergebnissen machst.

Kann ich statt eines eigenen Tools MCP nutzen?

Ja. FastAF betreibt einen gehosteten MCP-Server mit einem Tool search_web. Ich habe ihn mit langchain-mcp-adapters 0.3.2 geladen (MultiServerMCPClient, Transport streamable_http, Bearer-Header), bekam alle 15 Tools, und search_web lieferte 3 Ergebnisse für 5 Credits in 4,9 s. Zwei Haken: Es liefert url statt link, und es reicht snippet unverändert durch, du bekommst also die oben beschriebenen Sitelink-Beschriftungen. Für bestehende Chains, die link lesen, behalte das eigene Tool. Für einen neuen Agenten, der auch Seiten öffnen soll, nimm MCP.

Die Kurzfassung

Google Custom Search endet am 1. Januar 2027, und GoogleSearchAPIWrapper geht damit. Kopier das Modul, behalte die Tool-Namen, prüf deine rankingabhängigen Tests und setz max_credits, damit ein Agent in einer Schleife keine Rechnung auftürmt. Eine Suche kostet 5 Credits, je nach Paket 1,33 $ bis 2,50 $ pro 1.000, und das Free-Kontingent sind 500 Credits, etwa 100 Suchen. Hol dir einen kostenlosen Key und probier es mit deinen eigenen Abfragen aus.