说明:文中的服务是作者自己做的(LinkDigest,把小红书、抖音链接读成文字的接口)。数字来自 2026-11-06 当晚的线上日志。

背景

我们有一个接口:一组小红书笔记读完之后,用一个便宜的 flash 模型写一份「对比报告」(共同主题、开头钩子、读者顾虑、空白点……),报告语言可以指定,比如 translate_to: "ja"。系统提示里写得很清楚:Reply with JSON, in Japanese.

事故

当晚给日本的客户做 8 个城市的公开报告。前 3 批里有 2 批,模型写出来的 summary 和每条要点全是中文。 原因不难猜:输入的笔记是中文,便宜模型被素材的语言「带跑了」,提示词里那句 in Japanese 被无视。

第一反应通常是改提示词:加粗、写三遍、放到最前面。我们的规矩是:一个线上模型两次违反同一条指令,就不再换措辞,改成三步:代码检查 → 一次重问 → 确定性的兜底。

一、代码检查:看文字本身是什么语言

不需要语言识别模型,看「字形」就够了:

  • 日文:必须出现假名(平假名、片假名)。中日文共用汉字,只看汉字分不出来;
  • 韩文:看谚文;
  • 中文:有汉字,而且几乎没有假名;
  • 英文等:拉丁字母明显多于汉字。
import re

_CJK = re.compile(r"[㐀-鿿]")
_LATIN = re.compile(r"[A-Za-z]")
_KANA = re.compile(r"[぀-ゟ゠-ヿ]")
_HANGUL = re.compile(r"[가-힣]")


def written_in(language: str, texts: list[str]) -> bool:
    text = " ".join(t for t in texts if t)
    if not text.strip():
        return True  # 没有可判断的内容,不拦
    code = language.split("-")[0].lower()
    cjk, kana = len(_CJK.findall(text)), len(_KANA.findall(text))
    if code == "ja":
        return kana >= 3
    if code == "ko":
        return len(_HANGUL.findall(text)) >= 3
    if code == "zh":
        return cjk >= 1 and kana < 3
    return len(_LATIN.findall(text)) >= 4 * cjk

只检查模型自己写的「散文」(summary、每条要点的 text、无法判断的说明),不检查引文:引文按设计要保留笔记原文的中文,它们是证据。

二、带规则重问一次

检查不过,就把规则写得更直白,再问一次(只问一次):

LANGUAGE RULE: the notes are in Chinese; the reader is not. Every `summary`, `text` and `missing`
string MUST be written in Japanese — describe and summarise in Japanese, never copy the notes'
sentences. Only each example's `quote` keeps the note's original words.

三、翻译兜底:只翻散文,证据由代码拷回

重问还是中文,就走一次「翻译」调用:把 JSON 交给模型,只翻 summary / text / missing。返回之后,由代码把 note 编号、count、quote 从原始结果拷回去——不相信模型会「原样保留」。

def translate_prose(data: dict, language: str) -> dict:
    raw = call_model(json.dumps(data, ensure_ascii=False),
                     system=TRANSLATE_PROMPT.format(language=language),
                     max_tokens=12000)
    out = json.loads(raw)
    for name in SECTIONS:
        src = data.get(name) or []
        for i, point in enumerate(out.get(name) or []):
            if i < len(src):
                point["examples"] = src[i].get("examples") or []  # 证据原样拷回
                point["count"] = src[i].get("count", point.get("count"))
    return out

四、第二个坑:重问本身跑飞了

修完上线,大阪那一批(5 篇笔记)的重问请求一路写到了 48,000 token 的输出上限,约 7 分钟后报「输出被截断」,整批的对比报告丢了——连第一次已经生成好的中文版也一起没了。

修法两条:

  1. 重问和翻译这两步单独设 max_tokens=12000。一份对比报告几千 token 就够,模型跑飞时一分钟内失败,而不是等到 48k;
  2. 每一步都允许失败:失败就保留上一步的结果;如果最后仍然不是目标语言,把结果的 language 标成它实际的语言(标题也随之用中文),而不是谎称这是一份日文报告。
data = parse(call_model(content, system=system))
if translate_to and not written_in(lang, prose(data)):
    try:
        again = parse(call_model(content, system=system + RULE, max_tokens=12000))
        if written_in(lang, prose(again)) or len(prose(again)) >= len(prose(data)):
            data = again
    except Exception:
        pass  # 第一份结果保留
    if not written_in(lang, prose(data)):
        try:
            data = translate_prose(data, lang)
        except Exception:
            pass
    if not written_in(lang, prose(data)):
        lang = language_of(notes)  # 如实标注实际语言

五、结果

修好后把 8 个城市重新跑了一遍:7 份报告的散文都是日文(每份 139–317 个假名),引文保持中文原文、逐条和笔记核对;东京药妆那一份因为搜索只返回 1 条笔记,被我们自己拿掉了。测试里用假模型覆盖三条路径:一次就对(不重问)、重问后对、重问和翻译都失败时保留第一份并如实标注语言。

六、可以带走的几条

  • 便宜模型 + 外语素材,「用 X 语言回答」经常失效。别指望提示词,做代码检查;
  • 判断中文还是日文看假名,不要看汉字;
  • 兜底翻译时,证据字段由代码拷回,不让模型碰;
  • 每个重试步骤都要有自己的 token 上限,失败时保留已有结果;
  • 结果的语言如实标注,宁可是一份标明「中文」的报告,也不要一份挂着「日文」标题的中文报告。
Logo

更多推荐