ai newsDi., 27. Okt.
EilmeldungAnthropic Says It Will Revolutionize Biology—Some Day

Google's crawler ran up my AI bill

Because I liked asks what you loved and why , then finds films, shows, books and games that match the why. Part 1 was how it works; part 2 was measuring it. This one is about money. Last week Anthropic charged my card $10, then $10 again, on days when almost nobody was using the site. My analytics

dev.toDienstag 20:19 · 27.10.7 Min. Lesezeit
Bild: dev.to

Weil ich es mochte, fragt ab, was du geliebt hast und warum, und findet dann Filme, Serien, Bücher und Games, die zum Warum passen. Teil 1 erklärte, wie es funktioniert; Teil 2 ging um die Messbarkeit.

Dieser Teil handelt von Geld. Letzte Woche hat Anthropic meine Karte mit 10 Dollar belastet, dann noch einmal mit 10 Dollar, an Tagen, an denen fast niemand die Seite genutzt hat. Mein Analytics zeigte eine Handvoll Suchanfragen. Meine Modellrechnung zeigte Tausende.

Die täglichen Kosten für Haiku hatten zuvor zwischen wenigen Cent und etwa 2 Dollar pro Tag gelegen. Dann rund 7 Dollar am 22. Oktober und rund 14 Dollar am 23. Oktober: zwei Drittel der monatlichen 34 Dollar an zwei Tagen. Niemand war gekommen. Etwas war gekommen.

Die Kurzfassung: Die URLs, die Kosten verursachten, waren meine eigene Schuld, und vier der fünf Korrekturen haben nur zur Hälfte funktioniert.

Eine Suche auf der Seite besteht aus ein paar Aufrufen an Claude Haiku: einer, um die Anfrage zu verstehen (welches Medium, welche Epoche, was die Person eigentlich geliebt hat), einer, um einen Satz zu schreiben, warum jeder Top-Treffer passt, und einer für Treffer in anderen Medien. Ergebnisse werden gecacht, daher ist eine Suche, die jemand schon ausgeführt hat, kostenlos. Eine neue Suche ist es nicht.

Jede Titelseite hat außerdem „Why Chips“: Gründe zum Antippen wie „Serien wie Breaking Bad: ein anständiger Mensch, der langsam schlecht wird“. Es waren einfache Links:

<a href= "/?q=shows+like+breaking+bad%3A+a+decent+man+slowly+turning+bad" > Enter fullscreen mode Exit fullscreen mode Jetzt die Rechnung. Etwa 13.850 Titelseiten, jeweils mehrere Chips, und jeder Chip eine eigene, noch nie ausgeführte Such-URL. Ich hatte Zehntausende ungecachte, bezahlte und crawlbare Links gebaut und jede Titelseite in meine Sitemap aufgenommen, damit Crawler sie schneller finden.

Die Crawler taten genau das, was Crawler tun. Jeder gefolgte Link war ein paar Haiku-Aufrufe.

Die Crawl-Statistiken der Google Search Console zeigten, wie Googlebot Such-URLs zu Tausenden abrief. Meine Suchseiten hatten bereits noindex, und das hilft hier nicht: Google muss die Seite abrufen, um das noindex zu sehen, und der Abruf ist genau der Teil, der Geld kostet.

// src/app/robots.ts disallow: [ // Jede neue Suche ist ein paar ungecachte Haiku-Aufrufe, und die // Why Chips der Titelseiten verlinken auf Zehntausende davon. Sie // tragen ohnehin noindex, für die Suche geht also nichts verloren. ' /*?q= ', //... ], Enter fullscreen mode Exit fullscreen mode Googlebot hält sich daran. Die Rechnung ging nicht auf null, denn nicht alles, was crawlt, ist Googlebot.

Was übrig blieb, kam in zwei Arten: ein Crawler auf AWS-IP-Adressen und später etwas auf rotierenden privaten IPs, das robots.txt komplett ignorierte.

Ich fügte Vercel-Firewall-Regeln hinzu (nach User Agent und nach AS-Nummer für den AWS-Traffic) und aktivierte dann Bot Protection, die alles herausfordert, was sich nicht wie ein Browser verhält. Jeder Schritt reduzierte den Traffic. Keiner brachte ihn auf null.

Das ist die Grenze der Bot-Erkennung anhand dessen, was Bots sind. Ein Scraper auf rotierenden privaten IPs sieht exakt wie deine Nutzer aus, weil er dieselbe Art von Verbindung nutzt wie deine Nutzer.

(Außerdem habe ich mich entschieden, Bots nicht generell zu blockieren. Suchmaschinen bringen Besucher, und Link-Vorschauen auf X, Slack und WhatsApp kommen ebenfalls von Bots. Deine Route /api/og muss erreichbar bleiben, sonst wird jeder geteilte Link zu einer nackten URL.)

Als Nächstes eine Obergrenze: ein seitenweites Tageslimit für Modellaufrufe auf dem Suchpfad, gezählt in Redis.

// src/lib/daily-cap.ts export async function takeSearchCall (): Promise < void > { if (! redis ) return const key = `cap:search-llm: ${ new Date (). toISOString (). slice ( 0, 10 )} ` let used: number try { used = await Promise. race ([ redis. incr ( key ), new Promise < never > (( _, reject ) => setTimeout (() => reject ( new Error ( ' timeout ' )), 1 _000 )), ]) if ( used === 1 ) void redis. expire ( key, 60 * 60 * 48 ) } catch ( err ) { throw new DailyCapReached ( ' counter unavailable ' ) } if ( used > DAILY_CALLS ) throw new DailyCapReached ( ' cap reached ' ) } Enter fullscreen mode Exit fullscreen mode Jeder Modellaufruf auf dem Suchpfad greift zuerst auf dieses Kontingent zu. Wenn es auslöst, weicht der Aufrufer aus: einfache Textsuche statt verstandener Suche, Poster ohne den „Warum es passt“-Satz.

Die interessante Entscheidung steckt im Catch. Mein Rate Limiter pro IP versagt offen: Ist Redis nicht erreichbar, lassen wir Leute suchen, denn ein defekter Rate Limiter darf die Seite nicht lahmlegen. Das Limit versagt geschlossen: Ist der Zähler nicht lesbar, wird der Modellaufruf übersprungen. Eine Kostenobergrenze, die verschwindet, sobald Redis einen schlechten Tag hat, ist keine Obergrenze.

Und dann hatte Redis einen schlechten Tag. Am Launch-Tag hatte mein Upstash-Free-Tier sein monatliches Kommandolimit erreicht. Ein Grund war mein eigenes Debugging: Ich hatte bei all den Untersuchungen Zehntausende Cache-Keys durchsucht. Das Limit tat genau das, wofür ich es entworfen hatte. Es versagte geschlossen, und jede Suche fiel still auf einfache Textübereinstimmung zurück. Jemand suchte nach etwas wie The Sixth Sense, und The Sixth Sense war nicht in den Ergebnissen.

Der Fix, der wirklich funktionierte, kam daher, dass ich die Frage änderte. Nicht mehr „Von wem stammt diese Anfrage?“, was ich bereits verloren hatte, sondern „Hat gerade ein Mensch etwas getan?“

Eine Anfrage darf das Modell nur nach etwas Neuem fragen, wenn:

Andernfalls ist die Seite reiner Cache: Alles bereits Geschriebene wird vollständig angezeigt, und nichts Neues wird vom Modell angefragt.

export function newPass (): string | null { const key = passKey () if (! key ) return null const exp = String ( Math. floor ( Date. now () / 1000 ) + PASS_SECONDS ) return ` ${ exp }. ${ sign ( key, exp )} ` } export function validPass ( value: string | undefined ): boolean { const key = passKey () if (! key ||! value ) return false const [ exp, sig ] = value. split ( '. ' ) if (! exp ||! sig ||! /^ \d +$/. test ( exp ) || Number ( exp ) < Date. now () / 1000 ) return false const want = Buffer. from ( sign ( key, exp )) const got = Buffer. from ( sig ) return want. length === got. length && timingSafeEqual ( want, got ) } Enter fullscreen mode Exit fullscreen mode Keine Datenbank, keine Session-Tabelle. Die Signatur bedeutet, dass ein Client keinen Pass fälschen oder seine Gültigkeit verlängern kann. timingSafeEqual bedeutet, dass er auch nicht Byte für Byte raten kann.

< button type = "button" onClick = { () => startTransition ( async () => { await ensureSearchPass () router. push ( href ) }) } > Enter fullscreen mode Exit fullscreen mode Für einen Menschen hat sich nichts geändert: Chip antippen, Suche erhalten. Für alles, was nur Links folgt, existieren die Suchen nicht mehr.

Ein Bot, der eine Such-URL direkt lädt, bekommt jetzt eine „Ready when you are“-Seite mit einem Show-picks-Button, und das kostet nichts. Bereits gecachte Suchen werden weiterhin vollständig gerendert, sodass die Seiten, die Suchmaschinen indexieren, nichts verlieren.

Ist das eine Festung? Nein. Jemand könnte einen Scraper für diese Seite schreiben, der zuerst die Server Action aufruft. Aber das erhöht die Hürde von „eine URL abrufen“ zu „die Seite wie ein Mensch bedienen“, und das Tageslimit steht weiterhin als Rückfallebene.

Das Ergebnis: Es ging am 24. Oktober live. Der 25. Oktober kostete Cent. Die Tage danach zähle ich nicht, denn da war Redis ohne Kommandos und das Limit blockierte alles, sie beweisen also nichts. Ich werde diesen Beitrag mit einer sauberen Woche aktualisieren.

Die Ironie dieses Beitrags: Alles, was hier schiefging, habe ich selbst entworfen, einschließlich der cleveren Chip-Links, die das Produkt für Menschen besser und für Crawler zu einem Festmahl machten.

Die Seite ist Because I liked: Beschreibe, was du geliebt hast und warum, und erhalte Filme, Serien, Bücher und Games, passend zum Warum. Probier eine Suche. Du bist ein Mensch, also darf mich diese hier Geld kosten 🙂

Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.

Original (Englisch) anzeigen

Because I liked asks what you loved and why, then finds films, shows, books and games that match the why. Part 1 was how it works; part 2 was measuring it.

This one is about money. Last week Anthropic charged my card $10, then $10 again, on days when almost nobody was using the site. My analytics showed a handful of searches. My model bill showed thousands.

Daily Haiku cost had been cents to about $2 a day. Then ~$7 on Oct 22 and ~$14 on Oct 23: two-thirds of the month's $34 in two days. Nobody had come. Something had.

The short version: the URLs that cost money were my own fault, and four of the five fixes only half worked.

A search on the site is a few calls to Claude Haiku: one to understand the query (what medium, what era, what the person actually loved), one to write a line on why each top pick fits, one for picks in other media. Results are cached, so a search someone already ran is free. A new search is not.

Every title page also has "why chips": one-tap reasons like "shows like Breaking Bad: a decent man slowly turning bad". They were plain links:

<a href= "/?q=shows+like+breaking+bad%3A+a+decent+man+slowly+turning+bad" > Enter fullscreen mode Exit fullscreen mode Now do the maths. About 13,850 title pages, several chips each, every chip a distinct search URL that nobody had run yet. I had built tens of thousands of uncached, paid, crawlable links, and listed every title page in my sitemap so crawlers would find them faster.

Crawlers did exactly what crawlers do. Each link they followed was a few Haiku calls.

Google Search Console's crawl stats showed Googlebot fetching search URLs by the thousand. My search pages already carried noindex, and that doesn't help here: Google has to fetch the page to see the noindex, and the fetch is the part that costs money.

// src/app/robots.ts disallow: [ // Each new search is a few uncached Haiku calls, and the title pages' // why chips link to tens of thousands of them. They carry noindex // anyway, so nothing is lost in search. ' /*?q= ', //... ], Enter fullscreen mode Exit fullscreen mode Googlebot respects it. The bill didn't go to zero, because not everything that crawls is Googlebot.

What was left came in two kinds: a crawler on AWS IP addresses, and later something on rotating home IPs that ignored robots.txt entirely.

I added Vercel firewall rules (by user agent, and by AS number for the AWS traffic), then turned on Bot Protection, which challenges anything that doesn't behave like a browser. Each step cut the traffic. None got it to zero.

That's the limit of identifying bots by what they are. A scraper on rotating home IPs looks exactly like your users, because it's using the same kind of connection your users have.

(I also decided not to block bots in general. Search engines bring visitors, and link previews on X, Slack and WhatsApp come from bots too. Your /api/og route has to stay reachable or every shared link turns into a bare URL.)

Next, a ceiling: a site-wide daily cap on search-path model calls, counted in Redis.

// src/lib/daily-cap.ts export async function takeSearchCall (): Promise < void > { if (! redis ) return const key = `cap:search-llm: ${ new Date (). toISOString (). slice ( 0, 10 )} ` let used: number try { used = await Promise. race ([ redis. incr ( key ), new Promise < never > (( _, reject ) => setTimeout (() => reject ( new Error ( ' timeout ' )), 1 _000 )), ]) if ( used === 1 ) void redis. expire ( key, 60 * 60 * 48 ) } catch ( err ) { throw new DailyCapReached ( ' counter unavailable ' ) } if ( used > DAILY_CALLS ) throw new DailyCapReached ( ' cap reached ' ) } Enter fullscreen mode Exit fullscreen mode Every model call on the search path takes from this allowance first. When it throws, the caller falls back: plain text search instead of understood search, posters without the "why it fits" line.

The interesting decision is in the catch. My per-IP rate limiter fails open: if Redis is down, let people search, because a broken rate limiter shouldn't take the site down. The cap fails closed: if the counter can't be read, skip the model call. A cost ceiling that disappears whenever Redis has a bad day isn't a ceiling.

And then Redis had a bad day. On launch day, my Upstash free tier hit its monthly command limit. Part of the reason was my own debugging: I'd scanned tens of thousands of cache keys while investigating all this. The cap did exactly what I designed it to do. It failed closed, and every search quietly fell back to plain text matching. Someone searched for something like The Sixth Sense and The Sixth Sense wasn't in the results.

The fix that actually worked came from changing the question. Not "who is this request from?", which I'd already lost, but "did a person just do something?"

A request may ask the model for something new only if:

Otherwise the page is cache-only: anything already written is shown in full, and nothing new is asked of the model.

export function newPass (): string | null { const key = passKey () if (! key ) return null const exp = String ( Math. floor ( Date. now () / 1000 ) + PASS_SECONDS ) return ` ${ exp }. ${ sign ( key, exp )} ` } export function validPass ( value: string | undefined ): boolean { const key = passKey () if (! key ||! value ) return false const [ exp, sig ] = value. split ( '. ' ) if (! exp ||! sig ||! /^ \d +$/. test ( exp ) || Number ( exp ) < Date. now () / 1000 ) return false const want = Buffer. from ( sign ( key, exp )) const got = Buffer. from ( sig ) return want. length === got. length && timingSafeEqual ( want, got ) } Enter fullscreen mode Exit fullscreen mode No database, no session table. The signature means a client can't forge a pass or extend its expiry. timingSafeEqual means it can't guess one byte by byte either.

< button type = "button" onClick = { () => startTransition ( async () => { await ensureSearchPass () router. push ( href ) }) } > Enter fullscreen mode Exit fullscreen mode To a person, nothing changed: tap a chip, get a search. To anything that only follows links, the searches no longer exist.

A bot that loads a search URL directly now gets a "Ready when you are" page with a Show picks button, and it costs nothing. Searches that are already cached still render in full, so the pages search engines index lose nothing.

Is it a fortress? No. Someone could write a scraper for this site that calls the Server Action first. But that raises the bar from "fetch a URL" to "drive the page like a person", and the daily cap is still there as a backstop.

The result: it shipped on Oct 24. Oct 25 cost cents. I'm not counting the days after, because that's when Redis was out of commands and the cap was blocking everything, so they prove nothing. I'll update this post with a clean week.

The irony of this post: everything that went wrong here, I designed myself, including the clever chip links that made the product better for people and a feast for crawlers.

The site is Because I liked: describe what you loved and why, and get films, shows, books and games matched on the why. Try a search. You're a person, so this one's allowed to cost me money 🙂

Quelle: dev.to (dev.to). Original öffnen