以下是本次课程的正文:
在当今大模型快速发展的时代,与AI相关的新技术、新算法以及新产品层出不穷,几乎每天都能看到新的突破和应用。然而,很多前沿技术和动态的介绍通常以英文形式呈现,这给科研人员和技术工程师带来了一定的挑战。对于从事一线研究和开发的人员来说,掌握最新动态至关重要。那么,如何高效获取这些信息呢?一般来说,主要有两个常见渠道:
第一种
是通过关注国内的自媒体平台或技术领域的大V了解动态。这种方式具有一定的便利性和本地化的优势,但也存在一定的滞后性。此外,由于内容是经过二次解读,可能带有发布者的个人理解和观点,未必能完整传递信息的“原汁原味”。
第二种
是直接阅读英文原文资料,比如论文、技术文档或博客。然而,这种方式对阅读者的英文水平要求较高。对于一些人来说,面对长篇英文文档可能感到吃力,尤其是在快速获取关键信息的场景中,难免会有理解障碍。
那么,该如何更好地解决这些问题?值得庆幸的是,随着大模型技术的不断发展,我们已经有了新的工具和方法。例如,大模型可以帮助我们对英文内容进行高效总结,从而快速了解其核心信息(上一章节就有相关案例)。如果需要深入精读,我们可能需要完整阅读长篇原始文档。目前,市场上已经有一些工具(如搜狗翻译)能够将PDF文档全文翻译成中文,尽管通常需要付费。
为了解决这一痛点,本节将带大家实现一个基于大模型和智能体技术的小工具——
翻译助手
。通过这个案例,我们不仅可以实践智能体技术,还能开发出一个真正实用的工具:自动将英文PDF翻译为中文PDF的助手。
具体而言,本章将从
整体业务流程
、
核心模块设计
以及
工作流实现
三个方面详细讲解翻译助手的开发。由于翻译助手的实现流程相对明确,我们仍然采用上一章节提到的
LangGraph 框架
。不过,本章的重点将是如何高效处理PDF文件,这涉及多个与计算机视觉和PDF处理相关的工具和库。考虑到篇幅和聚焦问题的需要,本章不会逐一详解这些工具的使用方式,读者可根据需求自行查阅和学习。
通过本章的学习,你不仅可以更深入地了解智能体技术的实践方法,还能掌握一套实用的解决方案,为科研和技术开发工作提供有力支持。
实现翻译助手的流程也不复杂,只需要5步就可以实现:从原始PDF中抽取对应的英文、从原始PDF中抽取相关的图片、抽取的文本进行翻译、生成带翻译后文本的新PDF、在新PDF中插入图片,具体流程及依赖关系见下面图25-1,我们在下一小节会相信讲解各个步骤的细节和代码实现。
上面小节已经给出了翻译助手的整体流程和各个步骤之间依赖关系,本节我们详细讲解每个步骤的实现方案。
抽取文本模块是从原始PDF中抽取出文字部分(也包括表格中的文字,表格部分要想处理的更好,读者可以尝试单独处理,本章没有针对表格进行特殊处理)。抽取后会返回一个嵌套的JSON list,第一层list的元素是PDF的某一页的内容,第二层嵌套list里面的dict是这一页的某一段的内容(下面JSON就是PDF的第一页)。
[
[
{
"page_num": 1,
"text": "OpenAI o1 System Card",
"top": 113,
"bottom": 130,
"x0": 210.437,
"x1": 384.83416662
},
{
"page_num": 1,
"text": "OpenAI",
"top": 160,
"bottom": 170,
"x0": 280.346,
"x1": 314.9311659
},
{
"page_num": 1,
"text": "December 5, 2024",
"top": 193,
"bottom": 205,
"x0": 252.412,
"x1": 343.26793344
},
{
"page_num": 1,
"text": "1 Introduction",
"top": 248,
"bottom": 263,
"x0": 70.86600000000001,
"x1": 181.25427052000003
},
{
"page_num": 1,
"text"
: "The o1 model series is trained with large-scale reinforcement learning to reason using chain of thought. These advanced reasoning capabilities provide new avenues for improving the safety and robustness of our models. In particular, our models can reason about our safety policies in context when responding to potentially unsafe prompts. This leads to state-of-the-art performance on certain benchmarks for risks such as generating illicit advice, choosing stereotyped responses, and succumbing to known jailbreaks. Training models to incorporate a chain of thought before answering has the potential to unlock substantial benefits, while also increasing potential risks that stem from heightened intelligence. Our results underscore the need for building robust alignment methods, extensively stress-testing their efficacy, and maintaining meticulous risk management protocols. This report outlines the safety work carried out for the OpenAI o1 and OpenAI o1-mini models, including safety evaluations, external red teaming, and Preparedness Framework evaluations.",
"top": 284,
"bottom": 444,
"x0": 70.476,
"x1": 525.9228737591997
},
{
"page_num": 1,
"text": "2 Model data and training",
"top": 478,
"bottom": 492,
"x0": 70.866,
"x1": 264.6329503
},
{
"page_num": 1,
"text": "The o1 large language model family is trained with reinforcement learning to perform complex reasoning. o1 thinks before it answers—it can produce a long chain of thought before responding to the user. OpenAI o1 is the next model in this series (previously OpenAI o1-preview), while OpenAI o1-mini is a faster version of this model that is particularly effective at coding. Through training, the models learn to refine their thinking process, try different strategies, and recognize their mistakes. Reasoning allows o1 models to follow specific guidelines and model policies we’ve set, helping them act in line with our safety expectations. This means they are better at providing helpful answers and resisting attempts to bypass safety rules, to avoid producing unsafe or inappropriate content.",
"top": 514,
"bottom": 633,
"x0": 70.476,
"x1": 524.7064330704997
},
{
"page_num": 1,
"text": "The two models were pre-trained on diverse datasets, including a mix of publicly available data, proprietary data accessed through partnerships, and custom datasets developed in-house, which collectively contribute to the models’ robust reasoning and conversational capabilities.",
"top": 647,
"bottom": 685,
"x0": 70.476,
"x1": 525.9197562634197
},
{
"page_num": 1,
"text": "Select Public Data: Both models were trained on a variety of publicly available datasets, including web data and open-source datasets. Key components include reasoning data and scientific literature. This ensures that the models are well-versed in both general knowledge and technical topics, enhancing their ability to perform complex reasoning tasks.",
"top": 712,
"bottom": 762,
"x0": 70.866,
"x1": 525.9128251106
},
{
"page_num": 1,
"text": "1",
"top": 790,
"bottom": 801,
"x0": 294.926,
"x1": 300.35000451999997
}
]
]
针对某一段的内容结构,下面的JSON对各个字段进行了详细说明。之所以生成的时候保留top、bottom、x0、x1这些距离字段,主要是方便在后面绘制的时候,知道将翻译的中文放置到新PDF的哪一页的哪个位置上。
{
"page_num": 1,
"text": "OpenAI o1 System Card",
"top": 113,
"bottom": 130,
"x0": 210.437,
"x1": 384.83416662
}
下面代码就是抽取文本的代码,
extract_text_by_paragraph是核心的函数,它会调用get_paragraph_lines_info函数来生成上面JSON中的核心字段信息。下面这段代码对应我们github仓库中的llm_agent_abc/pdf_translate/utils/extract_text_from_pdf.py。
import pdfplumber
def get_paragraph_lines_info(paragraph_lines: list[dict]) -> dict:
"""
提取段落的相关信息:合并文本,获取段落边界框(top, bottom, x0, x1)。
:param paragraph_lines:
[
{
"text": "Given the lack of standardized datasets specific to chip design, we adopted the ChipNeMo [90]",
"top": 194,
"bottom": 205,
"x0": 72.0,
"x1": 540.0045790943997
},
...
]
:return:
{
"text": "Combined text from all lines",
"top": 194,
"bottom": 246,
"x0": 72.0,
"x1": 540.0045790943997
}
"""
for line in paragraph_lines:
if 'bottom' not in line:
line['bottom'] = line.get('top', 0)
text_list = [line['text'] for line in paragraph_lines]
text = " ".join(text_list)
top = paragraph_lines[0]['top']
bottom = paragraph_lines[-1]['bottom']
x0_list = [line['x0'] for line in paragraph_lines]
x1_list = [line['x1'] for line in paragraph_lines]
x0 = min(x0_list)
x1 = max(x1_list)
return {
"text": text,
"top": top,
"bottom": bottom,
"x0"
: x0,
"x1": x1
}
def extract_text_by_paragraph(pdf_path, page_width_threshold=0.85, line_width_difference_ratio=0.05, tolerance=3):
"""
基于行的分段和合并策略,按段落提取 PDF 内容。
Args:
pdf_path (str): PDF 文件路径。
page_width_threshold (float): 页面宽度占比阈值(0~1),超过该比例的行视为段落。
Returns:
list: 包含每页段落的列表,每个段落包含文字及排版信息。
:param line_width_difference_ratio:
"""
extracted_data = []
with pdfplumber.open(pdf_path) as pdf:
for page_num, page in enumerate(pdf.pages):
page_width = page.width
lines = []
for word_info in page.extract_words():
top = round(word_info["top"])
bottom = round(word_info["bottom"])
if not lines or abs(lines[-1]["top"] - top) > tolerance:
lines.append({
"text": word_info["text"],
"top": top,
"bottom": bottom,
"x0": word_info["x0"],
"x1": word_info["x1"],
})
else:
lines[-1]["text"] += f" {word_info['text']}"
lines[-1]["x0"] = min(lines[-1]["x0"], word_info["x0"])
lines[-1]["x1"] = max(lines[-1]["x1"], word_info["x1"])
for word_info in page.extract_words():
if page.find_tables():
for table in page.find_tables():
if table.bbox[1] <= word_info["top"] <= table.bbox[3]:
break
else:
top = round(word_info["top"])
if not lines or lines[-1]["top"] != top:
lines.append({
"text": word_info["text"],
"top": top,
"x0": word_info["x0"],
"x1": word_info["x1"]
})
else:
lines[-1]["text"] += f" {word_info['text']}"
paragraphs = []
paragraph_lines = []
for i, line in enumerate(lines):
is_last_line = i == len(lines) - 1
line_width_ratio = line["x1"] / page_width
if i == 0:
paragraph_lines.append(line)
if (
not is_last_line
and abs(lines[i + 1]["x1"] - line["x1"]) / line["x1"] > line_width_difference_ratio
):
paragraph = {"page_num": page_num + 1}
paragraph.update(get_paragraph_lines_info(paragraph_lines))
paragraphs.append(paragraph)
paragraph_lines = []
continue
elif (
i > 0 and
not is_last_line
):
if (line_width_ratio > page_width_threshold
and abs(lines[i - 1]["x1"] - line["x1"]) / line["x1"] > line_width_difference_ratio):
paragraph_lines = [line]
if (
not is_last_line
and abs(lines[i + 1]["x1"] - line["x1"]) / line["x1"] > line_width_difference_ratio
):
paragraph = {"page_num": page_num + 1}
paragraph.update(get_paragraph_lines_info(paragraph_lines))
paragraphs.append(paragraph)
paragraph_lines = []
continue
elif (
not is_last_line
and line_width_ratio > page_width_threshold
and abs(lines[i - 1]["x1"] - line["x1"]) / line["x1"] < line_width_difference_ratio
):
paragraph_lines.append(line)
else:
paragraph_lines.append(line)
paragraph = {"page_num": page_num + 1}
paragraph.update(get_paragraph_lines_info(paragraph_lines))
paragraphs.append(paragraph)
paragraph_lines = []
elif is_last_line:
paragraph_lines = [line]
paragraph = {"page_num": page_num + 1}
paragraph.update(get_paragraph_lines_info(paragraph_lines))
paragraphs.append(paragraph)
extracted_data.append(paragraphs)
return
extracted_data
第二个核心模块是从原始PDF中抽取图片,将这些抽取的图片保存到一个固定的目录下来,同时还生成图片位置描述的JSON文件,具体如下:
[
{
"page_num": 4,
"image_index": 1,
"path": "./pdf_translate/output/images/page_4_img_1.png",
"image_ext": "png",
"width": 3747,
"height": 1520,
"colorspace": 3,
"bbox": [
93.54499816894531,
471.3414306640625,
501.7056884765625,
636.9150390625
]
},
{
"page_num": 12,
"image_index": 1,
"path": "./pdf_translate/output/images/page_12_img_1.png",
"image_ext": "png",
"width": 1782,
"height": 1016,
"colorspace": 3,
"bbox": [
114.41100311279297,
241.2252197265625,
477.2440490722656,
448.093017578125
]
}
]
上面JSON对应每个图片的说明,具体每个字段的意思已经做了说明。下面是实现图片抽取的代码(对应github仓库中的
llm_agent_abc/pdf_translate/utils/extract_images_from_pdf.py
),里面还有更细致的说明。我们是将图片原样保存的,并没有对图片中的英文进行翻译,有兴趣的读者可以自行利用一些OCR工具进行探索。由于图片可能是各种风格的,里面文字的排列也可能非常多样,要实现一个效果好的图片自动翻译的组件还是非常困难的。
import fitz
import os
"""
针对抽取的图片中的字段进行说明。针对下面这个案例说明:
{
"page_num": 4,
"image_index": 1,
"path": "./pdf_translate/output/images/page_4_img_1.png",
"image_ext": "png",
"width": 3747,
"height": 1520,
"colorspace": 3,
"bbox": [
93.54499816894531,
471.3414306640625,
501.7056884765625,
636.9150390625
]
}
1. page_num:
• 含义: 图片所在的 PDF 页码(从 1 开始计数)。
• 单位: 无单位,整数值。
• 例子: "page_num": 4 表示该图片位于 PDF 文件的第 4 页。
2. image_index:
• 含义: 该图片在页面中的索引。每页上的图片都有一个索引值,用于标识该页中的图片顺序。
• 单位: 无单位,整数值。
• 例子: "image_index": 1 表示这是该页面上的第一张图片。
3. path:
• 含义: 提取的图片文件保存路径。该路径指向提取并保存的图片文件。
• 单位: 无单位,字符串表示文件路径。
• 例子: "path": "./pdf_translate/output/images/page_4_img_1.png" 表示该图片保存在 ./pdf_translate/output/images/ 目录下,文件名为 page_4_img_1.png。
4. image_ext:
• 含义: 提取图片的文件扩展名(即图片的格式)。如 png、jpg、jpeg 等。
• 单位: 无单位,字符串表示文件扩展名。
• 例子: "image_ext": "png" 表示该图片被保存为 PNG 格式。
5. width:
• 含义: 图片的宽度,单位是 像素 (px)。这是提取图片的实际分辨率,表示图片的水平像素数量。
• 单位: 像素(px)。
• 例子: "width": 3747 表示图片的宽度为 3747 像素。
6. height:
• 含义: 图片的高度,单位是 像素 (px)。这是提取图片的实际分辨率,表示图片的垂直像素数量。
• 单位: 像素(px)。
• 例子: "height": 1520 表示图片的高度为 1520 像素。
7. colorspace:
• 含义: 图片的颜色空间。不同的颜色空间编码方式定义了图片颜色的表现方式。
• 1 表示 灰度(Grayscale),通常是单色图像。
• 3 表示 RGB 颜色空间(Red, Green, Blue),常见于彩色图片。
• 其他值也代表不同的颜色空间(如 CMYK)。
• 单位: 无单位,整数值,代表颜色空间的编码方式。
• 例子: "colorspace": 3 表示该图片使用 RGB 颜色空间。
8. bbox:
• 含义: 图片在 PDF 页面中的边界框(bounding box),定义了图片在页面中的位置和尺寸。
• [x0, y0, x1, y1]:x0, y0 表示图片的左下角坐标,x1, y1 表示图片的右上角坐标。
• 这些值是基于 PDF 页面坐标系,单位是 点 (pt),PDF 的标准单位。1 点 = 1/72 英寸。
• 这个边界框定义了图片在页面中的位置和图片的尺寸。图片的显示区域由此边界框决定。
• 单位: 点(pt),PDF 的标准坐标单位。
• 例子:
"bbox": [
93.54499816894531,
471.3414306640625,
501.7056884765625,
636.9150390625
]
• 93.54499816894531 是图片左下角的 x 坐标。
• 471.3414306640625 是图片左下角的 y 坐标。
• 501.7056884765625 是图片右上角的 x 坐标。
• 636.9150390625 是图片右上角的 y 坐标。
单位说明
• 像素 (px):
• 用于 width 和 height 字段,表示图片的分辨率。像素是图像显示的基本单位,定义了图像的精度和清晰度。
• 点 (pt):
• 用于 bbox 字段,表示图片在 PDF 页面中的坐标位置。PDF 坐标系统以点为单位,1 点等于 1/72 英寸。bbox 提供了图片在 PDF 页面中的具体位置和大小。
"""
def extract_images(pdf_path, output_dir):
"""
从 PDF 提取嵌入图片,并返回正确的边界框 (bbox)。
Args:
pdf_path (str): PDF 文件路径。
output_dir (str): 保存图片的目录。
Returns:
list: 包含图片路径、分辨率、颜色空间和位置的元数据列表。
"""
os.makedirs(output_dir, exist_ok=True)
image_metadata = []
pdf_document = fitz.open(pdf_path)
for page_num in range(len(pdf_document)):
page = pdf_document[page_num]
images = page.get_images(full=True)
for img_index, img in enumerate(images):
xref = img[0]
base_image = pdf_document.extract_image(xref)
image_bytes = base_image["image"]
image_ext = base_image["ext"]
bbox = None
for img_info in page.get_images(full=True):
if img_info[0] == xref:
bbox = page.get_image_bbox(img_info)
image_filename = f"page_{page_num + 1}_img_{img_index + 1}.{image_ext}"
image_path = os.path.join(output_dir, image_filename)
with open(image_path, "wb") as image_file:
image_file.write(image_bytes)
image_metadata.append({
"page_num": page_num + 1,
"image_index": img_index + 1,
"path": image_path,
"image_ext": image_ext,
"width": base_image["width"],
"height": base_image["height"],
"colorspace": base_image["colorspace"],
"bbox": list(bbox) if bbox else None,
})
pdf_document.close()
return image_metadata
有了前面的抽取文本,这一模块就是将文本翻译为对应的英文。本章我们借助DeepSeek的大模型API来实现,具体是通过prompt来让大模型针对英文进行逐段翻译的。实现过程比较简单,下面是具体的代码(对应github仓库中的
llm_agent_abc/pdf_translate/utils/translate_pages_and_paragraphs.py
)。
import sys
import json
from tqdm import tqdm
sys.path.append('./')
from openai import OpenAI
from pdf_translate.configs.model_config import DEEPSEEK_API_KEY, DEEPSEEK_MODEL, DEEPSEEK_BASE_URL
client = OpenAI(
api_key=DEEPSEEK_API_KEY,
base_url=DEEPSEEK_BASE_URL,
)
def generate_en_2_zh_translate(content):
"""
使用 GPT 生成吸引眼球的中文标题。
:param content: 英文的文章内容
:return: 生成的中文标题
"""
prompt = (
"你是一名专业的翻译,擅长将英文文档翻译成中文。"
"以下是给你翻译的英文原文:\n"
f"{content}\n"
"\n请将上面英文翻译成语句通顺、结构合理、符合中国人表达习惯的中文。注意,如果是阿拉伯数字请不用翻译为中文,英文名、链接等也不用翻译。"
)
response = client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[
{"role": "system", "content": "你是一名专业的英文翻译,你擅长将英文翻译为中文。"},
{"role": "user", "content": prompt},
]
)
message_content = response.choices[0].message.content.strip()
return message_content
def translate_all_pages(text_with_layout, output_path):
"""
主函数:翻译整个文档。
"""
translated_data = []
for page_data in tqdm(text_with_layout, total=len(text_with_layout), desc=f"translate pages", unit="pages"):
page_translated = []
for paragraph in tqdm(page_data, total=len(page_data), desc=f"translate paragraphs", unit="paragraph"):
original_text = paragraph["text"]
translated_text = generate_en_2_zh_translate(original_text)
paragraph["text"] = translated_text
page_translated.append(paragraph)
translated_data.append(page_translated)
with open(output_path, 'w', encoding='utf-8') as file:
file.write(json.dumps(translated_data, indent=4, ensure_ascii=False))
有了前面的准备工作,现在开始就可以绘制翻译后的PDF了,我们先开始绘制PDF的文字部分。这就要用到前面提到的提取的文本的位置信息了。由于是英文翻译为中文,中文字体跟英文单词还是不一样的,因此在绘制的过程中需要确定字体大小、边距、段落等信息,下面代码(对应github仓库中的
llm_agent_abc/pdf_translate/utils/draw_text_on_pdf.py
)中的
generate_pdf_with_text是核心函数,会借助2个辅助函数来逐页绘制PDF。
顺便提一下,我们绘制的过程是保障新PDF的每一页对应老PDF的每一页,这个做法虽然简单,但是不是大多数时候都好用。要做到利用AI技术重新排版内容,让排版美观,还是有非常多的工程细节需要实现,这超出了我们课程的范围,本章不详细讲解。