Translating a product catalog with an LLM: cache keys and guard rails A developer built an LLM pipeline for translating product catalogs, focusing on cache keys that capture every input affecting output and validators that treat model responses as untrusted. The pipeline operates on (product_id, field, target_lang) triples with normalization to avoid cache misses, and uses a SHA-256 cache key incorporating source text, field, target language, model, prompt version, and glossary version. Validators check that invariants like numbers, placeholders, and HTML tags survive translation. The first version of an LLM catalog translation pipeline usually works and is still wrong. It re-translates fields nobody touched, and it happily publishes a description where the model rewrote a voltage, dropped a placeholder, or turned a SKU into something that looks nicer. I have built LLM pipelines that translate and adapt product catalogs for international audiences. Translation quality was never the part that kept me up. The two mechanisms below are what turn a demo script into something you can point at a live catalog: a cache key that captures every input that can change the output, and validators that treat the model's response as untrusted until proven safe. If you translate a product as one blob, any edit to any attribute invalidates the whole thing, and you cannot tell which part of the output the model changed. Worse, you lose the ability to say "the title is reviewed, the description is not". So the pipeline operates on product id, field, target lang triples. Each one is normalized before anything else happens, because unstable whitespace and encoding differences from a scraper will otherwise produce cache misses for text that did not actually change. python import hashlib import re import unicodedata WHITESPACE = re.compile r'\s+' def normalize text: str - str: text = unicodedata.normalize 'NFC', text text = text.replace '\u00a0', ' ' return WHITESPACE.sub ' ', text .strip That normalization is not cosmetic. Catalog sources emit non-breaking spaces, trailing tabs and mixed Unicode forms that change from crawl to crawl. Without it, a large share of your spend goes to re-translating text that is byte-different and semantically identical. The mistake I have seen most often is keying the cache on source text alone. Then someone edits the system prompt, or the model version is bumped, and the catalog becomes a mix of two generations of output with no way to tell them apart. Everything that participates in producing the string belongs in the key: python PROMPT VERSION = 'catalog-v4' def cache key source: str, field: str, target lang: str, model: str, glossary version: str, - str: payload = '\u241f'.join normalize source , field, target lang, model, PROMPT VERSION, glossary version, return hashlib.sha256 payload.encode 'utf-8' .hexdigest The separator matters more than it looks: joining with a plain comma or space lets two different tuples collide into the same key. Use a character that cannot appear in the inputs. Store the key next to the translation, along with the model and prompt version as their own columns. When you bump the prompt, you can then answer the only question that matters operationally — which rows are stale, and what does it cost to refresh them — with a query instead of a guess. A prompt change becomes a migration you can run in slices, per language or per category, rather than a full re-translation of the catalog. A translation model is a text generator, not a transformer with guarantees. It will occasionally localize a decimal separator, convert units it was not asked to convert, or drop a templating placeholder because the sentence reads better without it. None of these throw an exception. They land in your catalog and surface later as a support ticket about a wrong specification. The fix is not a better prompt. It is a validator that pulls the invariants out of the source and checks they survived. PLACEHOLDER = re.compile r'\{ a-z +\}' HTML TAG = re.compile r'