中文 ▾

Chinese LLM API翻译

获取 API 密钥

更新于

通过聊天 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。这就是全部设置。

获取 API 密钥