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 队列才是真正的交付物,而非可选项。它是唯一能观测流程真实错误率的地方。
我会做出的不同选择
我会从第一天起就把术语表——品牌名称、不得翻译的产品术语、单位拼写——作为版本化数据表,而不是粘贴到提示词中的文本。一旦它存在于提示词中,每次术语表修改都会悄无声息地使整个缓存失效,而且你无法按实际变更的术语分片刷新。
我还会停止在可翻译文本中允许自由形式的占位符。基于正则匹配 {price} 直到有人写出带嵌套大括号或货币符号紧邻占位符的模板。把字符串在翻译前拆分为结构化片段,翻译后再重新组装,可消除一整类验证器假阴性——代价是渲染器更复杂。
更广泛的观点:在数据管道中使用 LLM 相当于把一个非确定性组件放在你之前使用的确定性组件的位置。以上所有内容都是你围绕任何不可靠依赖会构建的东西——幂等键、边界上的不变性检查、显式的失败状态。模型是新的,围绕它的工程实践却不是。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.