更新于
通过聊天 API 进行中英翻译和本地化
使用聊天模型翻译 UI 字符串和文档很容易演示,但难以投产。失败很常见:占位符被重命名、术语表中的术语被翻译成三种不同的方式、JSON 回复包裹在正文中。本指南将翻译视为一个管道,在每个阶段都进行检查,使用 OpenAI 兼容的聊天补全接口。
分阶段思考,而不是使用一个提示词
生产环境的翻译工作有四个阶段:准备字符串、调用模型、验证结果、合并回原文。大多数质量问题源于跳过了验证阶段。本地化工程师绝不会在不运行占位符检查器的情况下发布翻译文件,当翻译者是模型时,同样的纪律也适用。
接口为 https://api.chinesellmapi.com/v1/chat/completions,模型 id 为 uncensored,使用 Bearer 认证。它支持双向翻译(英文到中文,以及中文到英文),以下示例使用英文源字符串翻译为简体中文。在系统提示词中交换方向即可进行反向翻译。
请注意共享的 100,000-token 上下文窗口、默认的 max_tokens 值 2,048(长文档可将其调高至 32,000),以及 8 MB 的请求体限制。这些限制对 UI 字符串不构成问题,但对完整文档至关重要。
注入术语表
品牌名称、产品名词和 legally sensitive terms 需要固定的呈现方式。最经济的机制是在系统提示词中添加一个术语表块,列出源术语和所需的目标术语。明确规则:在任何源术语的变体中,逐字使用术语表。保持术语表简短,因为每一行都会作为输入在每次调用中计费;几十条条目属正常,数千条则不然。
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.chinesellmapi.com/v1", api_key=os.environ["API_KEY"])
GLOSSARY = {
"workspace": "工作区",
"pull request": "合并请求",
"seat": "席位",
"billing cycle": "计费周期",
}
def build_system(glossary, target="Simplified Chinese"):
rows = "\n".join(f"- {src} => {dst}" for src, dst in glossary.items())
return (
f"You are a software localization translator. Translate English UI strings into {target}.\n"
"Rules:\n"
"1. Use the glossary below verbatim whenever the source term appears, in any inflection.\n"
"2. Copy placeholders such as {name}, %s, %d and {{count}} exactly, character for character.\n"
"3. Copy HTML tags and attributes exactly; translate only the text between tags.\n"
"4. Output the translation only, with no notes.\n\n"
"Glossary:\n" + rows
)
def translate(text):
r = client.chat.completions.create(
model="uncensored",
messages=[
{"role": "system", "content": build_system(GLOSSARY)},
{"role": "user", "content": text},
],
temperature=0.2,
max_tokens=400,
)
return r.choices[0].message.content.strip()
print(translate("Invite {name} to the workspace before the next billing cycle."))
如果术语表较大,请针对每个请求进行过滤:仅包含源术语出现在批次中的条目。对小写文本进行简单的子字符串测试即可满足第一版需求,并保持提示词简短。将 temperature 设置为 0.2 左右;在 UI 文案中,创造性变化属于缺陷。
还要提前确定目标语言的风格指南:正式或随意的称呼、中文字符和嵌入的拉丁字母或数字之间是否加空格,以及使用哪种标点集。将这些决定放在同一个系统提示词中,以便每个批次都继承它们。
保持占位符和标记完整
占位符会以可预测的方式出错:模型会翻译变量名、丢弃末尾的 %s,或在花括号内添加空格。提示词规则可以减少这些错误,但无法消除,因此需在代码中验证。使用正则表达式从源文本和目标文本中提取占位符和标签,然后将其作为排序后的列表进行比较。顺序在不同语言之间可能会合法地发生变化,因此应作为多重集(multisets)而非序列进行比较。
import re
PLACEHOLDER = re.compile(r"\{\{?\w+\}?\}|%[sd]|</?[a-zA-Z][^>]*>")
def placeholders_ok(source: str, target: str) -> bool:
# Same multiset of placeholders and tags, regardless of order.
return sorted(PLACEHOLDER.findall(source)) == sorted(PLACEHOLDER.findall(target))
src = 'You have <b>{count}</b> unread messages in %s.'
bad = '你在 %s 中有 <b>{数量}</b> 条未读消息。'
good = '你在 %s 中有 <b>{count}</b> 条未读消息。'
print(placeholders_ok(src, bad), placeholders_ok(src, good)) # False True
当检查失败时,重试一次,在提示词中引用失败的配对,例如用户消息指出之前的输出更改了占位符并需要纠正。如果仍然失败,请标记该字符串供人工审核,而不是循环重试。富文本需要额外小心:优先翻译文本节点并自己重建标记,因为这样可以由构造保证标签不会损坏。
通过指令实现结构化输出
在一个请求中对字符串进行批处理需要机器可读的回复。API 接受标准的聊天补全字段;此处的结构化输出来自清晰的指令,而非强制模式的开关,因此将契约写入提示词,包含 id 以便重新对齐结果,并防御性地进行解析。模型有时会将 JSON 包裹在代码围栏中或添加友好的句子,因此在解析前剥离围栏,并将解析错误视为可重试事件。
import json
import os
import re
from openai import OpenAI
client = OpenAI(base_url="https://api.chinesellmapi.com/v1", api_key=os.environ["API_KEY"])
SYSTEM = (
"Translate each item from English to Simplified Chinese. "
"Reply with a JSON object only, no code fences, no commentary. "
'Shape: {"items": [{"id": <number>, "zh": "<translation>"}]}. '
"Keep the same ids, keep every placeholder and HTML tag unchanged."
)
def translate_batch(strings):
payload = [{"id": i, "en": s} for i, s in enumerate(strings)]
r = client.chat.completions.create(
model="uncensored",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.2,
max_tokens=2000,
)
raw = r.choices[0].message.content.strip()
raw = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.M).strip() # tolerate stray fences
data = json.loads(raw)
by_id = {item["id"]: item["zh"] for item in data["items"]}
return [by_id[i] for i in range(len(strings))]
print(translate_batch(["Save changes", "Delete {count} files?", "Welcome back, <b>{name}</b>"]))
解析后始终进行验证:ID 必须匹配,计数必须匹配,每个项目必须通过上一节的占位符检查。每次请求 50 个字符串是短 UI 文本的合理起始批次;仅在验证通过率保持较高时增加它。
在速率限制下进行批量翻译
每个密钥限制为每分钟 300 个请求。一个包含 50,000 个字符串的本地化任务,每个请求处理 20 个字符串,共需 2,500 个请求,如果均匀分配,可在十分钟内完成。下面的脚本将用于进行中调用(in-flight calls)的信号量与基于锁的限速器结合,并在收到 429 和 503 时退避。与验证失败不同,这两个错误是瞬态的:429 表示请放慢速度,503 且带有 upstream_busy 表示请在几秒后重试。402 表示余额已耗尽,重试无法解决。
import asyncio
import os
import time
from openai import AsyncOpenAI, APIStatusError
client = AsyncOpenAI(
base_url="https://api.chinesellmapi.com/v1",
api_key=os.environ["API_KEY"],
max_retries=0,
timeout=90,
)
RATE = 240 / 60 # requests per second, below the 300/min cap
gate = asyncio.Lock()
next_slot = 0.0
slots = asyncio.Semaphore(6)
async def pace():
global next_slot
async with gate:
now = time.monotonic()
if next_slot > now:
await asyncio.sleep(next_slot - now)
next_slot = max(now, next_slot) + 1 / RATE
async def call(batch, attempt=0):
async with slots:
await pace()
try:
r = await client.chat.completions.create(
model="uncensored",
messages=[
{"role": "system", "content": "Translate to Simplified Chinese. Keep placeholders unchanged. One line per input line."},
{"role": "user", "content": "\n".join(batch)},
],
max_tokens=1500,
temperature=0.2,
)
return r.choices[0].message.content.splitlines()
except APIStatusError as e:
if e.status_code in (429, 503) and attempt < 4:
await asyncio.sleep(2 ** attempt + 1) # 1s, 3s, 5s, 9s
return await call(batch, attempt + 1)
raise
async def run(all_strings, size=20):
chunks = [all_strings[i:i + size] for i in range(0, len(all_strings), size)]
results = await asyncio.gather(*(call(c) for c in chunks))
return [line for part in results for line in part]
if __name__ == "__main__":
strings = [f"Item {n} was updated by {{user}}" for n in range(60)]
out = asyncio.run(run(strings))
print(len(out), out[0])
此版本为了简洁按行拆分,这对于单行字符串是可以的;对于任何多行内容,请使用上一节中的 JSON 变体,以便字符串内的换行符不会被误认为是字符串边界。
反向方向:中文源文本
将中文翻译成英文有其自身的失败模式。中文自由地省略主语和复数,因此模型必须猜测;给它上下文。像 "已发送" 这样的字符串可能是 "Sent"、"Has been sent" 或 "You sent it",具体取决于它是标签按钮、状态徽章还是提示框。解决方法是在每个字符串旁边添加上下文字段,由你的开发人员提供并在 JSON 批次中传递:字符串出现的位置、其最大长度以及它是标签还是句子。
明确测量长度限制。英文 UI 文本通常比中文原文长,而中文目标文本则相反,字符数较少但在屏幕上更宽,因为每个字符都是全宽。当按钮或表头有硬限制时,在提示词中声明字符预算,并在收到响应后在代码中验证。
对于包含人名和地名的中文源文档,提前指定罗马化约定,例如不带声调的汉语拼音,并将重复出现的名称添加到术语表中。如果没有这样做,同一个人可能会在单个文档中以两种拼写出现,这正是审核员首先注意到的不一致之处。
合并前的抽样和审核
自动化检查可以捕获结构错误,但不能捕获误译。增加轻量级的人工步骤:对每个批次抽取固定百分比,例如每二十个字符串,加上所有需要重试的字符串,发送给双语审核员。跟踪每个批次的审核员编辑率。如果它上升,收紧提示词、减小批次大小,或为正在纠正的术语添加术语表条目。
将提示词、术语表版本和批次设置与每个输出文件一起保留。当术语表中的术语发生变化时,你可以只重新翻译包含该术语的字符串,而不是重新运行整个语料库。源字符串加术语表版本的简单内容哈希可作为缓存键,这意味着未更改的字符串在下次运行时不会产生任何费用。
最后,保留一组棘手的字符串回归测试集:包含嵌套占位符、复数形式、嵌入的 HTML 和长复合名词的字符串。每当更改提示词时运行它,并在推出更改之前并排比较输出。
估算本地化运行的成本
明确假设:30,000 个源字符串,每个包含 12 个英文单词,每批翻译 20 个。每个字符串大约需要 20 个输入 token,每个请求需要 300 个 token 的固定系统提示词,每个字符串大约需要 35 个输出 token。这将产生 1,500 个请求,约 1.05 million 输入 token 和约 1.05 million 输出 token。按每百万输入 token $0.25 和每百万输出 token $1.00 计算,运行成本约为 $0.26 加上 $1.05,即约 $1.31。将其视为数量级估计,并将假设替换为你自己的 usage 日志中的数字。
试用额度为 $0.50,有效期七天,足以在几千个字符串的样本上验证管道。继续使用 app quickstart 进行脚本控制和 UTF-8 处理,或查看 pricing page 获取当前费率。
问答
API 是否支持双向翻译?
支持。中文到英文和英文到中文都通过同一个聊天接口工作;你在系统提示词中选择方向。
如何防止模型更改 {name} 等占位符?
在提示词中声明规则,然后在代码中通过比较源文本和目标文本中的占位符列表来验证,不匹配时重试或标记。
有 JSON 模式开关吗?
结构化输出通过指令获取。在提示词中指定确切格式,去除多余代码围栏,然后解析并验证结果。
运行大批量任务的速度有多快?
每个密钥每分钟允许 300 次请求。均匀分配请求,在收到 429 或 503 响应时退避。
只差一张表单,即可获得密钥
创建账户,复制密钥,更改 base URL。这就是全部设置。