AI 破译古代亚述泥板的新尝试
展示现代 AI 在古代文物解码中的应用,四千年前的债务记录被现代算法破译,体现 AI 的跨域应用潜力。
展示现代 AI 在古代文物解码中的应用,四千年前的债务记录被现代算法破译,体现 AI 的跨域应用潜力。
四千年前,亚述商人在做人类一直在做的事情:追踪债务、催收款项、就合同争论。他们将这些记录压入粘土板。不是神圣文本,不是史诗。只是远古时代的办公室邮件等价物。
近23000块这样的泥板保存了下来。其中一半从未被翻译过——不是因为它们受损,而是因为地球上只有少数人能读懂古亚述语。
当 Deep Past Initiative 将这变成了一场 Kaggle 竞赛时,要求构建一个古亚述楔形文字的机器翻译系统——我立刻参加了。任务是:接收音译文本(楔形文字符号转换为拉丁字母)并产生英文翻译。
训练集?大约1500对。仅此而已。
作为参考,标准翻译模型的训练数据是数百万个句子对。即使是关于"低资源"语言的研究也使用数万个句子。我们只得到了1500份文档和一拍脑门。
所以问题很直接:当你几乎没有任何数据时,如何构建翻译模型?当一种语言没有现代分词器见过时?当每个专有名词和数字都很重要,因为这些是法律和财务记录时?
最初"微调一个模型来处理一些古代文本"的想法演变成了一个完整的 AI 管道:用于 OCR 扫描学术书籍的 Gemini 视觉、用于句子对齐和跨语言翻译的 LLM、作为字节级骨干不会被楔形文字卡住的 ByT5、用于高效 LoRA 训练的 Unsloth,以及用于在 Kaggle T4 上快速推理的 vLLM。结果让我们感到惊讶。
让我们从为什么明显的方法不起作用开始。
我尝试的第一件事是大家都会尝试的——用预训练的 LLM 做。Gemma、Qwen,一般的嫌疑人。用一些例子提示它,让它翻译。
老实说?输出乍一看相当不错。流畅的英文、合理的句子结构,感觉可能是对的。但"感觉对"在翻译古代法律文件时是很危险的。
问题是幻觉——而且不是微妙的那种。这些模型自信地填入商人、城市和商品的名字,这些在源文本中根本不存在。当音译说 A-šùr-i-dí 时,模型可能会输出一个完全不同的名字,听起来像是青铜时代的。当遇到不熟悉的贸易术语时,它会即兴创作。对于每个名字、每个数字、每个商品都是实际信息的文件来说——这不是一个次要的质量问题,这是整个问题。
那标准的编码器-解码器翻译模型呢?这里问题更根本:分词。现代分词器是在现代文本上训练的。阿卡德语音译是一个完全不同的宇宙——带连字符的音节序列如 a-na、全大写的苏美尔标志如 KÙ.BABBAR、花括号中的确定词如 {d} 和 {ki}、编码音位变体的下标数字如 il₅,以及如 <gap> 这样的间隙标记表示泥板的破损部分。
把这个输入标准分词器,它会在每个它没见过的字符处碎裂。曾在任何预训练语料库中出现过的专有名词会被无声地损坏。表示文本缺失的 <gap> 标记被当作噪音或特殊令牌处理。
所以:仅解码器模型产生幻觉,标准翻译模型无法正确地分词输入。什么实际上适合这个问题?
关于 Kaggle 竞赛最好的事情之一是社区。人们分享发现,在论坛中讨论方法,集体缩小什么行得通。早期,几个参与者都得出了同样的答案:ByT5。
ByT5 来自一篇 2021 年的 Google 研究论文——《走向没有令牌的未来:预训练的字节对字节模型》。这个想法简单而有点激进:完全跳过分词。与其将文本映射到学习的子词词汇,ByT5 直接在原始字节上操作。一个标准的 Transformer,最少的修改,只是一次处理一个字节。
为什么这对我们的问题重要?因为根据定义,每个字符都是有效的输入。无关紧要 A-mur-{d}UTU 曾在任何预训练语料库中出现——ByT5 不需要它。没有词汇缺失、没有碎裂的令牌、没有花括号或下标数字的特殊处理。模型就是看字节。
论文还展示了另一个结果证明很关键的东西:字节级模型对噪音的鲁棒性明显更强。当你的源文本来自 OCR 化的泥板,不同的学者和几十年间转录惯例不一致时——这种鲁棒性不是很好拥有的,它是一个要求。
架构:解决了。现在来了更难的问题——我们有了正确的模型,但远远没有足够的数据来训练它。
以 ByT5 作为架构,瓶颈完全转向了数据。竞赛主办方在一个公开讨论帖中非常清楚地阐明了挑战。
两件事一致地比其他任何东西都更会破坏翻译:
命名实体。个人名字、地点名字、神圣名字——它们在不同的版本中音译不一致,它们保持较早的拼写惯例,对模型来说完全不透明。实际上,许多其他方面合理的翻译失败了,因为一个名字被损坏、丢弃或幻觉了。主办方甚至准备了一个人名索引(策划的名字拼写列表)作为补充数据来帮助解决这个问题。
音译格式不一致。不同的语料库使用不同的惯例编码相同的文本。一个参与者在训练前将音调符号转换为 ASCII(š → sz、ú → u2)——一个合理的直觉,但评估数据期望音调符号。将 ṣ 折叠为 S₂ 或 š 折叠为 SZ 移除在阿卡德语中语义有意义的区别。规则很清楚:向评估集中使用的格式标准化,而不是相反。
除此之外,间隙处理很棘手。泥板的损坏部分用 <gap> 标记,但训练数据并不完美对齐——有时一个大的间隙出现在音译中但不在翻译中,迫使模型学习不对齐而不是翻译。如 <gap>-A-šùr(一个间隙附在一个词上)这样的边界情况需要被保留,不能盲目剥离。
主办方的结束点让我印象深刻:这些不是模型架构问题。它们是数据问题。而只有约1500个训练对,这些问题的每一个都更加严重,因为模型看到的学习例子太少了。
所以前进的路径很明显——找更多数据。
训练数据必须来自某处。竞赛主办方指明了道路——他们分享了 AKT 系列(安纳托利亚 Kültepe 文本)的扫描 PDF,这是来自土耳其 Kültepe 挖掘的古亚述泥板的多卷学术出版物。每卷都包含泥板的音译和翻译。完全是我们需要的域和格式。
问题是?这些学术书籍发表于1990年代到2020年代,由不同的作者、用不同的语言出版。AKT 1、2、4、9a 和 10 是土耳其语的。AKT 3 是德语的。每卷都有自己的布局、自己的标题惯例、自己标记泥板边缘和部分的方式。不同的字体、不同的编辑风格、不同年代的排版。
这不是你能用脚本解析的结构化数据。这些是实体书的扫描页面——有些清晰,有些不清晰——一个泥板的音译可能从一页开始,在下一页继续,学术评论坐在翻译文本右边,格式在卷之间的变化足以使任何东西都不能干净地泛化。
但在这些凌乱的 PDF 内部,正是我们渴望的东西:数百个额外的音译-翻译对,许多具有原始训练集没有的逐行对齐。
问题是我是否能足够可靠地提取它,以实际帮助模型——或者噪音是否会使事情变得更糟。这是 Gemini 的多模态能力发挥作用的地方——特别是它理解页面布局、区分音译块和评论、开箱即用处理多语言内容的能力。我决定构建管道。
构建这个管道本身就是一个小项目。每一步都解决了一个问题并揭示了下一个。
步骤1:PDF → 页面图像
最简单的步骤——将每个 PDF 页面渲染为一个编号的 PNG。这是唯一完全本地运行的部分。其他所有东西都通过 Gemini。
步骤2:页面图像 → 结构化 JSON
每个页面图像通过 Vertex AI 批处理 API 发送到 Gemini 的视觉模型。流程:构建一个请求的 JSONL(每个页面图像一个,引用 GCS URI),提交到 Vertex,解析预测回来。
先简单说明一下为什么使用批量推理:当你需要处理数百页内容,又不需要实时响应时,Batch API 显然是最佳选择。相比标准推理,它可以节省 50% 的费用,速率限制也宽松得多,而且服务会替你处理并行化和重试——通常会在 24 小时内完成。你只需提交一个任务,去做其他事情,之后再回来查看结果。对于这样的流水线——我要处理多本书,而每本书都有数百页——它既省钱,也让人省心。
请求的构造方式如下:
def build_request(gcs_uri: str, prompt_text: str) -> dict:
return {
"request": {
"contents": [{
"role": "user",
"parts": [
{"fileData": {"mimeType": "image/png", "fileUri": gcs_uri}},
{"text": prompt_text},
]
}],
"generationConfig": {
"responseMimeType": "application/json",
"temperature": 0.0,
"mediaResolution": "MEDIA_RESOLUTION_HIGH", # needed for diacritics
"thinkingConfig": {"thinkingLevel": "MEDIUM"},
},
}
}
我们使用了启用中等思考级别的 gemini-3.1-flash-lite-preview——推理步骤对于理解复杂的页面布局,以及正确判断一块泥板在哪里结束、下一块泥板又从哪里开始,帮助非常大。
通过 Vertex AI Batch API 提交:
client = genai.Client(vertexai=True, project=project, location=location)
job = client.batches.create(
model="gemini-3.1-flash-lite-preview",
src="gs://your-bucket/book/ocr_batch/requests.jsonl",
config=CreateBatchJobConfig(
dest="gs://your-bucket/book/ocr_batch/predictions/"
),
)
有一个坑早期让我吃了亏:返回的预测结果顺序是打乱的。你不能依赖输出中的行顺序——必须从每条预测结果对应的原始请求 URI 中提取页码:
def extract_page_num(pred: dict) -> int:
uri = pred["request"]["contents"][0]["parts"][0]["fileData"]["fileUri"]
m = re.search(r"page_(\d+)\.png", uri)
return int(m.group(1))
这其实算是一个特性——它迫使你从一开始就编写健壮的解析逻辑。
每一卷 AKT 都需要使用自己的提示词。不同的标题格式、不同的边缘标记(土耳其语卷使用 Ö.y.、Ak.;德语卷使用 Vs.、Rs.),以及不同的评注块约定。只要这里出错,你就可能把评注提取成译文,或者把两块泥板合并成一块。
步骤 3:JSON 页面 → 泥板 CSV
针对具体书籍编写的导出脚本会汇总每一页的 JSON,将其转换为扁平的 CSV——每块泥板占一行,并包含合并后的转写和译文字段。每一卷都需要自己的导出器,因为各卷的结构差异足以让通用导出器在不发出任何警告的情况下悄然出错。
把所有内容都导出到一个 HTML 文件中,然后真正打开查看。这时你才能发现实际存在的问题:误读的标题、混入译文字段的评注、续页导致的重复译文。无论做多少自动化测试,都无法替代人工检查输出结果。
针对具体书籍编写的清理脚本会应用质检过程中发现的修复措施——删除错误行、合并被跨页拆开的泥板,以及移除混入其中的评注。这些工作既不光鲜,又需要手动完成,但完全不可或缺。
步骤 6:句子分块与翻译
到了这里,事情又变得有趣起来。原始训练数据采用文档级结构——输入完整泥板,输出完整译文。但 AKT 书籍提供了更好的东西:逐行结构。每一行转写都有一个标记((Vs.1)、(2)、(Rs.14)),每个译文句子也会引用这些标记。
第二个 Gemini 批处理任务会同时处理两件事:根据标记将转写行与译文句子对齐,并把非英语内容(土耳其语或德语)翻译成英语。对于每块泥板,我都使用 TF-IDF 余弦相似度从官方训练集中检索最相似的示例,并将它们作为少样本上下文提供给模型。事实证明,这一点至关重要——不仅能提高翻译质量,还能让结果贴近主办方在措辞、风格和术语选择上的数据分布。模型不只是在进行翻译,它还在学习以竞赛数据所期望的方式进行翻译。
仍然采用相同的批处理模式——构建 JSONL、提交任务、解析顺序被打乱的预测结果。
步骤 7:规范化
大多数看不见的工作都发生在这里。竞赛测试集使用特定的字符格式,而书中的格式与之不一致。每一卷都有自己的 OCR 伪影和排版约定。
以下是规范化流程中的几个例子:
ḫ/Ḫ → h/H(测试集使用普通的 H)
Unicode 下标 → 普通数字(₄ → 4)
上标限定符 → 花括号格式(ᵈ → {d},ᵏⁱ → {ki})
OCR 伪影:KU.BABBAR → KÙ.BABBAR,ś → š,ş → ṣ
空缺去重:<gap> <gap> → <gap>,同时保留类似 <gap>-A-šùr 的连接形式
对于 ByT5 这样的字符级模型而言,这些并非表面上的修饰。训练集和测试集之间哪怕只有一个字符不匹配——例如 ḫ 与 h、₄ 与 4——人工审阅者可能根本看不出区别,但对于只学习过其中一种表示形式的模型来说,却可能是灾难性的。
最后一步会将规范化后的分块合并到主训练集中。从约 1,500 对数据开始,这条流水线使我们的可用训练数据大致实现了成倍增长——更重要的是,它还增加了句子级数据对,相比单纯的文档级翻译,为模型提供了粒度细得多的学习信号。
训练——ByT5 能带你走很远,但最终会停下来
扩充后的数据集准备就绪后,训练 ByT5 十分直接——使用 HuggingFace Transformers 进行标准的 seq2seq 编码器—解码器训练。没有特殊技巧,也没有奇特的调度器。模型很快就学会了其中的模式,并且在训练领域的泥板上展现出了令人惊讶的翻译效果。
但随后,排行榜分数开始讲述一个不同的故事。
在我们的案例中,Kaggle 的隐藏测试集似乎与训练数据存在不同的数据分布。我们最合理的猜测是:书籍不同、主题不同、译者风格不同,还出现了陌生的人名和地名。ByT5 在训练期间直接见过的内容上表现很好,但排行榜分数表明,它无法泛化到这些内容之外。
我们遇到了瓶颈。许多团队后来通过进一步挖掘 ByT5 获得了非常好的成绩——也许是用了更好的数据增强、更长时间的训练,以及更聪明的技巧。但在我们的配置下,提升已经停滞,因此我们决定探索另一个方向。
回归仅解码器模型——但这一次进行微调
故事在这里形成了一个闭环。此前,我们曾因为仅解码器 LLM 会产生幻觉而放弃它们。这一点仍然成立——对于开箱即用的模型确实如此。但微调会彻底改变局面。
其中的逻辑很简单:ByT5 和 Qwen 解决的是不同的问题。ByT5 非常适合处理转写本身——每个字符都很重要,而字节级建模使它能够处理奇特的正字法、变音符号、下标和限定符,而不必与分词器对抗。但当任务变成跨陌生泥板、不同译者风格和主题变化进行泛化时,Qwen3.5 拥有 ByT5 所不具备的能力:强大得多的预训练语言知识。
开箱即用时,这种优势毫无用处,因为它同时伴随着幻觉。微调改变了这一点。LoRA 让我们能够保留模型更广泛的语言能力,同时使其扎根于具体任务和数据集。我们没有再去提示一个通用模型,然后祈祷它能给出理想结果,而是在精心整理的示例上训练了一个轻量级适配器。再结合少样本提示来匹配主办方的翻译风格,微调后的 Qwen 成功应对了 ByT5 无法处理的数据分布变化。
使用 Unsloth 进行微调——让 LLM 变得负担得起
在深入介绍训练细节之前,先为从未微调过模型的读者做一个简单说明。
微调大语言模型最朴素的方法,是更新它的全部参数——数量高达数十亿。这需要强大的硬件、巨大的内存和高昂的成本。对于需要在有限 GPU 资源上快速迭代的 Kaggle 竞赛而言,这条路根本走不通。
这正是 LoRA(低秩适配)发挥作用的地方。你不必更新整个模型,而是冻结原始权重,只训练叠加在其上的一小组适配器矩阵。只需完整微调的一小部分成本,就能获得它的大部分收益。QLoRA 更进一步,将基础模型量化到 4 位精度,从而大幅降低内存占用——使原本绝无可能装入单张 GPU 的模型也能够进行微调。
在这个项目中,我们使用了 Unsloth,它让整个过程变得出乎意料地轻松。它负责处理 LoRA/QLoRA 配置,将训练优化到约 2 倍速度,同时减少约 70% 的显存占用,并且开箱即用地支持大量模型——其中就包括我们需要的 Qwen3.5。
训练本身使用 Unsloth 内置的 SFT 训练器进行 SFT(监督微调)。我们将数据结构化为聊天对话:系统提示设定专家亚述学家的角色、通过 TF-IDF 相似性检索的少样本示例,以及目标泥板作为最后的用户消息。模型仅从助手完成部分学习——实际的翻译。
# 每个训练样本如下所示
messages = [
{"role": "system", "content": "You are an expert Assyriologist..."},
# 通过相似性检索的少样本示例
{"role": "user", "content": "Translate: um-ma A-šùr-i-dí-ma ..."},
{"role": "assistant", "content": "Thus says Aššur-idī: ..."},
{"role": "user", "content": "Translate: um-ma Pu-šu-ki-in-ma ..."},
{"role": "assistant", "content": "Thus says Pūšu-kēn: ..."},
# 实际要翻译的泥板
{"role": "user", "content": "Translate: a-na A-lim {ki} ..."},
{"role": "assistant", "content": "To the City: ..."}, # 模型学习这部分
]
这里有一个重要细节:我们使用了仅完成部分的掩码。损失仅在助手的翻译令牌上计算——提示令牌(系统消息、少样本示例、用户消息)在训练期间被掩码掉。这意味着模型不会浪费容量学习预测输入;它完全专注于产生准确的翻译。
这意味着模型不仅学会了翻译——它学会了在上下文中翻译,通过相似的示例来锚定。相同的检索和提示结构将在推理时使用,因此模型训练方式和评估方式之间没有差距。
我们开始探索但时间不足的一个方向:在微调模型之上的强化学习。想法是使用 GRPO(组相对策略优化)和自定义奖励函数——结合竞争指标本身、音译和翻译之间的间隙对齐,以及长度平衡——来将模型推向 SFT 单独无法达到的水平。每个奖励都将针对监督训练无法直接解决的特定故障模式。我们在截止时间前没有完成,但这似乎是自然的下一步。
有了微调的模型,下一个挑战是在 Kaggle 竞赛的约束条件下实际运行它。这是一个代码竞赛——提交时无互联网访问,两个各有 16GB VRAM 的 T4 GPU,以及严格的时间限制。
对不熟悉 vLLM 的人的快速介绍:这是一个由加州大学伯克利分校开发的开源推理引擎,已成为高效服务 LLM 的首选。关键创新是 PagedAttention——它不是为每个序列的键值缓存预分配固定的内存块,而是动态地分页 KV 缓存,类似于操作系统管理虚拟内存的方式。这意味着你可以在更少的硬件上服务更大的模型。此外,你还可以获得连续批处理、优化的 CUDA 内核、张量并行和无缝的 HuggingFace 模型支持。
听起来很完美,对吧?理论上是的。但在实际中,我们遇到了问题。
Qwen3.5 在竞赛的最后几周发布。这个模型是全新的——vLLM 支持仍处于实验阶段且不稳定。此外,Kaggle 的 T4 GPU 的计算能力为 7.5,这意味着不支持 FlashAttention 2。我们不得不回退到 Triton 注意力后端,与环境兼容性问题搏斗,并解决提交时无法 pip 安装任何内容的问题——每个依赖都需要在你的数据集中预先打包。
让一个 9B 参数的模型在两个 T4 上加载、运行和生成翻译而不崩溃本身就是一个小项目。跨两个 GPU 的张量并行是不可协商的——模型根本无法适应单个卡。
llm = LLM(
model=MODEL_PATH,
dtype="float16",
max_model_len=16000,
gpu_memory_utilization=0.85,
enforce_eager=True, # T4 上没有 CUDA 图
tensor_parallel_size=2, # 跨两个 T4 分割
)
推理提示完全镜像训练设置——相同的系统提示、相同的 TF-IDF 少样本检索。对于每个测试泥板,我们从训练数据中检索 5 个最相似的示例,并将它们作为对话上下文包括在内:
prompts = [
build_messages(
transliteration=row["transliteration"],
few_shot_examples=retriever.top_k(row["transliteration"]),
)
for row in test_rows
]
outputs = llm.chat(prompts, sampling_params=sampling_params)
保持推理管道与训练相同——相同的提示结构、相同的检索、相同的风格锚定——意味着模型看到的正是它受训的那种输入。推理时没有分布偏移。
我们团队在 2500 多支队伍中获得了银牌。在竞赛的最后几天,OCR 提取管道仍在生成新数据——每批清理和规范化的泥板都将我们的分数推高。我们真的感觉再多两天就能获得金牌。这有点遗憾,但说实话?这段旅程比奖牌更有价值。
以下是我从这个项目中得到的启示:
Gemini 的批处理推理对非结构化数据来说是超能力。 我们用它将 1990 年代扫描的学术书籍——混乱的版面、多种语言、不一致的格式——转化为干净、结构化的训练数据。如果它能在土耳其语和德语 PDF 中处理 4000 年前的亚述泥板,它也将适用于你的用例。Vertex AI Batch API 使其在规模上既经济又无痛。
少样本检索仍然是容易获胜。 TF-IDF 字符 n-gram 相似性实现非常简单,使用检索的示例来锚定训练和推理都为我们提供了一致的改进,付出的努力最少。小的迭代,大的回报。
微调比你想象的更容易获得。 LoRA + Unsloth 意味着我们可以在 Kaggle 的免费 GPU 上训练一个 9B 参数的模型。你不需要一个集群。你需要好的数据和正确的工具。
vLLM 使部署变得实用。 即使在 Kaggle T4 这样受限的硬件上,使用全新的模型且无法访问互联网,我们也让 9B 模型以张量并行方式运行。生态系统正在快速成熟。
而更大的图景——那个以 g