「 LLM实战 - 企业 」基于 markdown-it AST 的 Markdown 文献翻译实现详解

写在前面

"我一开始只是想翻译 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
相关推荐
ASF1231415sd19 小时前
【基于YOLOv10n-CSP-PTB的大豆花朵检测与识别系统详解】
人工智能·yolo·目标跟踪
水如烟20 小时前
孤能子视角:“意识“的阶段性回顾,“感质“假说
人工智能
Carl_奕然20 小时前
【数据挖掘】数据挖掘必会技能之:A/B测试
人工智能·python·数据挖掘·数据分析
旅途中的宽~20 小时前
《European Radiology》:2024血管瘤分割—基于MRI T1序列的分割算法
人工智能·计算机视觉·mri·sci一区top·血管瘤·t1
岁月宁静20 小时前
当 AI 越来越“聪明”,人类真正的护城河是什么:智商、意识与认知主权
人工智能
CareyWYR20 小时前
每周AI论文速递(260105-260109)
人工智能
智能相对论20 小时前
CES深度观察丨智能清洁的四大关键词:变形、出户、体验以及生态协同
大数据·人工智能
齐齐大魔王20 小时前
Pascal VOC 数据集
人工智能·深度学习·数据集·voc
程途拾光15820 小时前
幻觉抑制:检索增强生成(RAG)的优化方向
人工智能
野豹商业评论20 小时前
千问发力:“AI家教”开始抢教培生意?
人工智能