写在前面
"我一开始只是想翻译 Markdown, 但在处理科研文献时,我发现'文本翻译'本身不是难点, 真正的难点是'如何在不破坏文档结构的前提下处理文本', 于是我引入了 AST 的概念,并基于 markdown-it 实现了一套结构保持的翻译流水线。"
文章目录
我之前一直在做一个工业级大模型文档处理流水线的活儿。
顾名思义就是用LLM去翻译一些外文的科研文献,并做智能总结、摘要等工作。
大家可以移步看文章:
「 LLM实战 - 企业 」构建工业级大模型文档处理流水线:解决截断、幻觉与延迟不确定性的端到端实践解决方案-CSDN博客
我在这篇文章里说的,构建这个流水线的一个基本流程就是:
PDF--->MinerU--->Md文档(英文)--->AI大模型--->摘要、翻译全文、简介等
在这个过程中,我们需要把markdwon的英文文档做全文翻译,如果直接让LLM去做全文翻译,会出现翻译被截断、翻译内容胡编乱造、不翻译的幻觉等问题村咋。于是我在上一个文章里提了一个新的思路,那就是按"行块"翻译。把英文的md文档的每一行拿出来翻译,翻译完再拼接回去。
局限性
其实你会发现,如果按照我之前的翻译markdwon文档的每一行,那这样做翻译出来的质量并不高。我们不是按照语义去翻译的,而是活生生的切割了全文的每一行,机械的翻译的,这样做并不好。
本文将提供一种新的思路去做翻译。本文将告诉大家我是怎么按照markdwon原文的语义去做拆分的,从而让大模型翻译的效果更完美,效果更佳!
AST抽象语法树
说实话,不建议大家直接去深究AST这个东西,你只要理解,这个AST可以帮你把markdwon文档里的行啊、文本啊、表格啊、标题啊这些东西标记出来,再不破坏这些结构的情况下,帮你直接去标记出来它们,你在做翻译的时候,直接把这部分标记的内容提出来翻译即可。这样就不会破坏原本的markdwon文本结构了。
如果不用AST抽象语法树,那你对markdwon的翻译工作很可能是破坏了原本的文本结构的,比如表格啊、引用啊,段落啊,结构被你破坏了,拿着破坏的结构去翻译,质量就不会高。
Markdown-it
有一个工业级的开源项目:Markdown-it。
项目地址:https://github.com/markdown-it/markdown-it.git
它就是利用AST的原理帮助你提取markdwon文本里的各个结构,进行提取渲染成html格式。渲染出来的html还能保证markdwon原文的结构不被破坏。
在这里我不会去进行html的转换,我只是拿他做AST的解析器。
tex
Markdown 文本
↓
markdown-it.parse()
↓
Token 流(AST)
我的目的是把我的markdwon文本丢给markdwon-it进行AST的解析,解析成为Token流。
被解析出来的Token列表如下:
tex
heading_open
inline("Experiment Results")
heading_close
paragraph_open
inline("The sensor shows high sensitivity.")
paragraph_close
在拿到AST结构以后,我们再进行结构和内容的拆分:
tex
Markdown 结构(AST)
+
可翻译文本单元(units)
我们把可翻译的文本单元再次拆分成最小语义单元:
python
units = [
{id: 0, text: "..."},
{id: 1, text: "..."}
]
这意味着:
- 每一段都有明确边界
- 每一段都可追踪
- 翻译失败不会污染全局
在完成最小语义级别的翻译之后,
Markdown 文档已经被拆解为一组 AST Tokens。
每个 Token 描述的不是文本,而是"结构语义"。
本阶段的任务,是根据 Token 的类型,将这些结构语义重新映射为 Markdown 语法,
并将已经翻译完成的 inline 内容按顺序回写,
最终重建出一篇完整、结构不变、内容已翻译的 Markdown 文档。
实际运用
接下来我说一下,我的源代码,我是怎么实际把markdwon文本进行AST转换,在从AST结构中提取出最小可翻译单元进行翻译,最后在进行回写的。
python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time : 2025/12/17 15:14
# @Author : linghu
# @File : md_ast_translator.py
# @Software: PyCharm
##AST 翻译器
from markdown_it import MarkdownIt
import re
class MarkdownASTTranslator:
def __init__(self):
self.md = MarkdownIt()
def extract_units(self, md_text: str):
"""
Markdown → 可翻译语义单元
将Markdown文本解析为AST,并提取可翻译的文本单元
返回值格式与Translator类的translate_blocks方法兼容
"""
print(f"[DEBUG] AST解析开始,文本长度: {len(md_text)}")
tokens = self.md.parse(md_text)
print(f"[DEBUG] AST解析完成,共{len(tokens)}个token")
units = []
uid = 0
last_open = None
for i, token in enumerate(tokens):
if token.type in ("heading_open", "paragraph_open", "blockquote_open", "list_item_open"):
# 支持更多类型的块级元素
last_open = token.type
print(f"[DEBUG] Token {i}: 打开块级元素 {token.type}")
elif token.type == "inline" and last_open and token.content.strip():
# 只提取非空的内联文本内容
print(f"[DEBUG] Token {i}: 找到可翻译文本 '{token.content[:50]}...' (类型: {last_open})")
units.append({
"id": uid,
"text": token.content, # 确保格式与Translator类期望的一致
"type": last_open.replace("_open", "") # 保留类型信息用于调试
})
uid += 1
elif token.type != "inline" and token.content:
print(f"[DEBUG] Token {i}: {token.type}, 内容: '{token.content[:20]}...'")
print(f"[DEBUG] 提取完成,共{len(units)}个可翻译单元")
return tokens, units
def apply_translation(self, tokens, translated_units):
"""
翻译结果 → AST 回写
将翻译后的文本单元应用回AST,并生成最终的Markdown文本
"""
# 创建翻译映射,使用id作为键
translated_map = {
item["id"]: item["text"] # 注意:Translator返回的字段是"text"而不是"translated"
for item in translated_units
}
uid = 0
last_open = None
for token in tokens:
if token.type in ("heading_open", "paragraph_open", "blockquote_open", "list_item_open"):
last_open = token.type
elif token.type == "inline" and last_open:
# 只处理非空的内联文本内容,与extract_units保持一致
if token.content.strip():
if uid in translated_map:
token.content = translated_map[uid]
uid += 1
# 渲染最终的Markdown文本
# 注意:使用markdown-it的render方法将AST转换为HTML,然后转换回Markdown
# 或者使用一个更简单的方法,直接从tokens重建Markdown
md_text = ""
current_level = 0
list_stack = []
for token in tokens:
if token.type == "heading_open":
level = int(token.tag[1])
current_level = level
md_text += "#" * level + " "
elif token.type == "heading_close":
md_text += "\n\n"
current_level = 0
elif token.type == "paragraph_open":
md_text += ""
elif token.type == "paragraph_close":
md_text += "\n\n"
elif token.type == "blockquote_open":
md_text += "> "
elif token.type == "blockquote_close":
md_text += "\n\n"
elif token.type == "list_item_open":
if token.level not in list_stack:
list_stack.append(token.level)
md_text += "- "
elif token.type == "list_item_close":
md_text += "\n"
if token.level in list_stack:
list_stack.remove(token.level)
elif token.type == "inline":
# 处理内联内容(包含翻译后的文本)
md_text += token.content
elif token.type == "hr":
md_text += "---\n\n"
elif token.type == "code_block":
md_text += "```\n" + token.content + "\n```\n\n"
elif token.type == "fence":
md_text += "```" + token.info + "\n" + token.content + "\n```\n\n"
elif token.type == "text" and token.content.strip():
md_text += token.content
# 移除多余的空行
md_text = re.sub(r'\n\s*\n', '\n\n', md_text)
return md_text.strip()
async def translate_markdown(self, md_text, translator):
"""
完整的Markdown翻译流程
1. 提取可翻译单元
2. 使用提供的translator进行翻译
3. 将翻译结果应用回AST
4. 返回翻译后的Markdown
"""
print(f"[AST翻译器] 开始翻译Markdown文本")
# 1. 解析AST并提取可翻译单元
tokens, units = self.extract_units(md_text)
print(f"[AST翻译器] 提取到 {len(units)} 个可翻译单元")
# 2. 使用提供的translator进行翻译(注意:这是一个异步方法)
print(f"[AST翻译器] 开始调用翻译服务")
translated_units = await translator.translate_blocks(units)
print(f"[AST翻译器] 翻译完成,共得到 {len(translated_units)} 个翻译结果")
# 打印一些翻译结果示例
if translated_units:
print(f"[DEBUG] 翻译结果示例 (id={translated_units[0]['id']}): '{translated_units[0]['text'][:50]}...'")
# 3. 将翻译结果应用回AST
print(f"[AST翻译器] 开始应用翻译结果到AST")
translated_md = self.apply_translation(tokens, translated_units)
print(f"[AST翻译器] 翻译完成,最终Markdown长度: {len(translated_md)}")
# 打印翻译后的Markdown前100个字符
print(f"[DEBUG] 翻译后的Markdown前100个字符: '{translated_md[:100]}...'")
return translated_md