LLM 產品目錄翻譯管線的第一版通常能運作,但仍會出錯。它會重新翻譯沒人動過的欄位,也會輕易發佈一則描述,裡面包含模型改寫電壓、刪除佔位符,或是把 SKU 變得更好看。
我曾建立 LLM 管線,用來為國際受眾翻譯與調整產品目錄。翻譯品質從來不是讓我徹夜難眠的問題。以下兩項機制才是把示範腳本轉變成可對應真實目錄的關鍵:一個能捕捉所有可能改變輸出的輸入的快取金鑰,以及把模型回應視為不可信、直到驗證安全為止的驗證器。
工作單位是欄位,而非產品
如果把產品當作單一區塊來翻譯,任何屬性的編輯都會讓整個區塊失效,而且無法分辨模型究竟改了輸出中的哪一部分。更糟的是,你將無法說「標題已審核,描述尚未審核」。
因此管線以 (product_id, field, target_lang) 三元組為單位運作。每個三元組在進行任何其他操作前都會先正規化,因為爬蟲產生的不穩定空白與編碼差異,否則會讓語義相同但實際未變更的文字產生快取未命中。
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()
Enter fullscreen mode Exit fullscreen mode
正規化並非只是表面功夫。目錄來源可能輸出不間斷空格、結尾定位字元,以及每次爬取都不同的混合 Unicode 形式。若沒有正規化,你的成本將有很大一部分花在重新翻譯那些位元不同、但語義相同的文字。
快取金鑰必須包含所有可能改變輸出的因素
我最常看到的錯誤,就是只根據來源文字來建立快取金鑰。之後若有人編輯系統提示,或是模型版本更新,整個目錄就會混雜兩代輸出,卻無法分辨它們。
所有參與產生字串的元素都應納入金鑰:
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()
Enter fullscreen mode Exit fullscreen mode
分隔符號的重要性超乎想像:用普通逗號或空格串接,會讓兩個不同的元組碰撞出相同的金鑰。請使用輸入中不可能出現的字元。
將金鑰與翻譯一起儲存,並把模型與提示版本各自存成欄位。當你更新提示時,就能用查詢而非猜測,回答唯一重要的營運問題——哪些資料列已過期,以及刷新它們的成本是多少。提示變更將成為可分段執行的遷移(依語言或類別),而非整個目錄的全面重翻。
防護機制:從來源擷取不變量,並在輸出上驗證
翻譯模型是文字產生器,而非具備保證的轉換器。它偶爾會本地化小數點、轉換未被要求轉換的單位,或是因為句子讀起來較順而刪除樣板佔位符。這些都不會丟出例外,卻會出現在你的目錄中,最後變成規格錯誤的客服單。
解決方法不是更好的提示,而是從來源取出不變量並檢查它們是否存續的驗證器。
PLACEHOLDER = re.compile(r'\{[a-z_]+\}')
HTML_TAG = re.compile(r'</?[a-z][a-z0-9]*[^>]*>', re.IGNORECASE)
NUMBER = re.compile(r'\d+(?:[.,]\d+)?')
class InvariantError(Exception):
pass
def numbers(text: str) -> list[str]:
return [n.replace(',', '.') for n in NUMBER.findall(text)]
def check_invariants(source: str, translated: str) -> None:
src_ph = sorted(PLACEHOLDER.findall(source))
out_ph = sorted(PLACEHOLDER.findall(translated))
if src_ph != out_ph:
raise InvariantError(f'placeholders changed: {src_ph} -> {out_ph}')
src_tags = sorted(t.lower() for t in HTML_TAG.findall(source))
out_tags = sorted(t.lower() for t in HTML_TAG.findall(translated))
if src_tags != out_tags:
raise InvariantError('markup changed')
src_nums = sorted(numbers(source))
out_nums = sorted(numbers(translated))
if src_nums != out_nums:
raise InvariantError(f'numbers changed: {src_nums} -> {out_nums}')
Enter fullscreen mode Exit fullscreen mode
刻意比較排序後的多重集合而非序列:不同語言間詞序確實會改變,但數值不會。在比較前先把小數點正規化,是因為翻譯成使用逗號的小地區的模型會把 1.5 改成 1,5,這是呈現決策而非數值改變——你希望由格式化程式而非模型決定這件事。
這個驗證器刻意保持輕量且機械化。它不評斷翻譯品質,只證明模型沒有悄悄改變具有商業或法律意義的內容。
失敗政策才是真正的設計決策
驗證失敗時,你有三個選項,如何選擇就是需要立場的部分。
def translate_field(client, source, field, lang, model, glossary_version):
key = cache_key(source, field, lang, model, glossary_version)
cached = store.get(key)
if cached is not None:
return cached
violation = None
for _ in range(2):
out = client.translate(
source=source,
field=field,
target_lang=lang,
model=model,
correction=violation,
)
try:
check_invariants(source, out)
except InvariantError as exc:
violation = str(exc)
continue
store.put(key, out, status='ok')
return out
store.put(key, source, status='needs_review', reason=violation)
return source
Enter fullscreen mode Exit fullscreen mode
重試一次,且重試時會把具體的違規內容帶回模型——而不是泛泛的「再試一次」。指出被打破的限制,才能讓第二次嘗試與第一次有實質差異;單純重試大多只會重複相同的失敗。
之後就停止。退回來源字串並標記該列。將未翻譯的英文文字發佈到本地化目錄,是可見且誠實的降級;發佈規格被改寫的翻譯則是不可見的降級,而目錄資料中的不可見失敗,其行為與支付歸屬中的不可見失敗完全相同:直到有人投訴前都不會被發現,屆時你已無法知道它錯了多久,或影響了多少列。
needs_review 佇列才是這裡的交付成果,而非可有可無。它是唯一能觀察管線真實錯誤率的地方。
我會做什麼不同的改變
我會從一開始就把詞彙表——品牌名稱、不可翻譯的產品用語、單位拼寫——當作版本化的資料表,而不是直接貼在提示中。一旦它存在於提示裡,每次詞彙表編輯都會悄悄讓整個快取失效,而且你無法針對實際改變的詞彙進行分段刷新。
我也不會再讓自由形式的佔位符進入可翻譯文字。Regex 比對 {price} 在有人寫出巢狀大括號或相鄰貨幣符號的樣板之前都還能用。在翻譯前先把字串拆成結構化區段,翻譯後再組裝回來,能消除一整類驗證器假陰性——代價是渲染器變得更複雜。
更廣泛的觀點是:資料管線中的 LLM 是放在原本確定性元件位置的非確定性元件。以上所有做法,都是你會圍繞任何不可靠依賴所建構的東西——冪等金鑰、邊界不變量檢查、明確的失敗狀態。模型是新的,圍繞它的工程實踐卻不是。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.