「 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
相关推荐
程序员哈基耄3 小时前
一键生成专属形象照——AI智能相馆引领摄影新潮流
人工智能
DeeGLMath3 小时前
机器学习中回归训练的示例
人工智能·机器学习·回归
勇气要爆发3 小时前
【第二阶段—机器学习入门】第十五章:机器学习核心概念
人工智能·机器学习
山东小木3 小时前
A2UI:智能问数的界面构建策略
大数据·人工智能·jboltai·javaai·springboot ai·a2ui
认真学GIS3 小时前
逐3小时降水量!全国2421个气象站点1951-2024年逐3小时尺度长时间序列降水量(EXCEL格式)数据
人工智能·算法·机器学习
龙山云仓3 小时前
No098:黄道婆&AI:智能的工艺革新与技术传承
大数据·开发语言·人工智能·python·机器学习
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2025-12-20
人工智能·经验分享·深度学习·神经网络·产品运营
love530love3 小时前
Win11+RTX3090 亲测 · ComfyUI Hunyuan3D 全程实录 ②:nvdiffrast 源码编译实战(CUDA 13.1 零降级)
人工智能·windows·python·github·nvdiffrast
————A3 小时前
强化学习---->多臂老虎机问题
人工智能