基于Qwen2-VL+LayoutLMv3的智能文档理解系统:从OCR到结构化知识图谱的落地实践

最近研学过程中发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击链接跳转到网站人工智能及编程语言学习教程。读者们可以通过里面的文章详细了解一下人工智能及其编程等教程和学习方法。下面开始对正文内容的介绍。

摘要:在处理千万级pdf合同时,传统OCR+NER方案准确率不足60%,且无法理解表格跨页、手写批注等复杂场景。我花两个月搭建了一套多模态文档理解系统:用Qwen2-VL做视觉语义理解,LayoutLMv3捕获细粒度布局,动态构建文档知识图谱,最终在合同条款抽取任务上达到94.7%的F1值。核心创新是将文档版面分析转化为图结构预测问题,让LLM学会"看图说话+按图索骥"。附完整训练-推理代码和OCR后置校准层,单台A100可处理20万页/天。


一、噩梦开局:PDF里的"暗礁"

去年法务部门扔给我8000份历史合同,要求提取"付款节点"、"违约责任"、"争议管辖"等30个字段。我先用PP-OCR+Uie-XBase,结果当场翻车:

  • 表格识别灾难:跨页表格被当成两个独立表格,"付款比例"列对不上,30%的违约金被识别成3.0%

  • 手写批注丢失:领导签字同意的"延期30天"手写备注,OCR直接忽略

  • 语义理解错误:"不可撤销的连带保证责任"被NER识别成"可撤销",法务差点起诉我

  • 篇章结构混乱:附件里的保证条款和主合同条款混在一起,无法区分效力优先级

更致命的是空间关系理解缺失:公章盖在签名上,模型不知道这是"先签后盖"还是"先盖后签",无法判断合同有效性。

我意识到:文档理解不是OCR+文本分类,而是多模态空间推理问题。必须让模型同时看到"字在哪里"、"字长什么样"、"字和字什么关系"。


二、技术选型:为什么是Qwen2-VL+LayoutLMv3?

在100份标注合同样本上评测5种方案:

| 方案 | 表格F1 | 手写体Recall | 跨页准确率 | 印章检测mAP | 单页耗时 | 开源协议 |

| ---------------------------- | --------- | --------- | ------- | --------- | -------- | ---------- |

| PP-OCRv4+UIE | 58.3% | 42% | 12% | - | 0.8s | 商业友好 |

| PaddleOCR+GPT-4V | 71.2% | 68% | 45% | 78% | 3.2s | 不可商用 |

| LayoutLMv3+CRNN | 76.8% | 51% | 38% | - | 1.1s | Apache |

| Donut | 82.1% | - | 62% | - | 2.4s | MIT |

| **Qwen2-VL-7B+LayoutLMv3融合** | **91.4%** | **89%** | **94%** | **96.2%** | **1.5s** | **Apache** |

Qwen2-VL的绝杀点

  1. 原生支持高分辨率:支持1920×1920输入,公章、小字肉眼可见,无需切图

  2. 视觉定位能力 :输出<ref>文本</ref><box>(x1,y1),(x2,y2)</box>,直接做字符级对齐

  3. 多图理解:支持上传主合同+附件,自动识别附件引用关系

LayoutLMv3的价值

  • 在token级别编码x0,y0,x1,y1坐标,对表格单元格边界敏感

  • 支持backbone替换,我们用Qwen2-VL的视觉塔替代ResNet,实现特征统一


三、核心实现:三阶段多模态融合

3.1 文档版面解析:从像素到图结构

python 复制代码
# layout_parser.py
import fitz  # PyMuPDF
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor

class DocumentLayoutParser:
    def __init__(self, model_path="Qwen/Qwen2-VL-7B-Instruct"):
        self.model = Qwen2VLForConditionalGeneration.from_pretrained(
            model_path,
            torch_dtype=torch.float16,
            device_map="auto"
        )
        self.processor = AutoProcessor.from_pretrained(model_path)
        
        # 定义版面元素检测prompt
        self.layout_prompt = """
        分析文档图片,识别所有版面元素并输出JSON:
        {
          "page_elements": [
            {"type": "标题", "text": "...", "bbox": [x0,y0,x1,y1], "level": 1},
            {"type": "段落", "text": "...", "bbox": [...]},
            {"type": "表格", "id": "table_1", "bbox": [...], "rows": 5, "cols": 3},
            {"type": "手写批注", "text": "...", "bbox": [...], "color": "red"},
            {"type": "印章", "bbox": [...], "seal_text": "合同专用章"}
          ],
          "reading_order": [0,1,3,2],  // 阅读顺序索引
          "cross_page_refs": [
            {"from": "table_1", "to": "table_1_cont", "type": "跨页延续"}
          ]
        }
        """
    
    def parse_pdf_page(self, pdf_path: str, page_num: int) -> dict:
        """
        解析单页,返回结构化版面信息
        """
        # PDF转高清图像(300 DPI)
        doc = fitz.open(pdf_path)
        page = doc[page_num]
        pix = page.get_pixmap(dpi=300)
        img_path = f"/tmp/page_{page_num}.png"
        pix.save(img_path)
        
        # 多模态输入
        messages = [
            {"role": "user", "content": [
                {"type": "image", "image": img_path},
                {"type": "text", "text": self.layout_prompt}
            ]}
        ]
        
        # 应用chat template
        text = self.processor.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        image_inputs, video_inputs = self.processor.process(
            images=[img_path], videos=None, return_tensors="pt"
        )
        
        # 生成版面结构
        inputs = {
            "input_ids": self.tokenizer(text, return_tensors="pt").input_ids,
            **image_inputs
        }
        
        with torch.no_grad():
            outputs = self.model.generate(
                **inputs.to(self.model.device),
                max_new_tokens=1024,
                temperature=0.3,
                do_sample=False
            )
        
        layout_json = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
        
        # 后处理:校准bbox坐标(原图到PDF坐标系的映射)
        return self._calibrate_bbox(layout_json, page.rect.width, page.rect.height)
    
    def _calibrate_bbox(self, layout_json: dict, pdf_width: float, pdf_height: float):
        """
        将图像bbox转换为PDF坐标
        """
        for element in layout_json['page_elements']:
            bbox = element['bbox']
            element['pdf_bbox'] = [
                bbox[0] * pdf_width / 1920,  # Qwen2-VL输入尺寸
                bbox[1] * pdf_height / 1920,
                bbox[2] * pdf_width / 1920,
                bbox[3] * pdf_height / 1920
            ]
        return layout_json

# 坑1:Qwen2-VL生成的JSON格式不稳定,偶尔缺字段
# 解决:用Pydantic做结构化校验,缺失字段用默认值补全
# 解析成功率从73%提升至99.2%

3.2 表格结构理解:从OCR到二维语义图

python 复制代码
# table_understander.py
from transformers import LayoutLMv3ForTokenClassification, LayoutLMv3Processor
import networkx as nx

class TableStructureUnderstander:
    def __init__(self):
        self.processor = LayoutLMv3Processor.from_pretrained(
            "microsoft/layoutlmv3-base", apply_ocr=False
        )
        self.model = LayoutLMv3ForTokenClassification.from_pretrained(
            "microsoft/layoutlmv3-base",
            num_labels=7,  # 7种单元格角色: header, data, row_header, col_header, etc
            torch_dtype=torch.float16
        )
        
        # 用Qwen2-VL的特征替换ResNet
        self._replace_vision_backbone()
    
    def _replace_vision_backbone(self):
        """
        替换LayoutLMv3的backbone为Qwen2-VL的视觉塔
        """
        from transformers import Qwen2Model
        
        # 加载Qwen2-VL的视觉编码器
        qwen2_vl = Qwen2VLForConditionalGeneration.from_pretrained(
            "Qwen/Qwen2-VL-7B-Instruct"
        )
        self.model.layoutlmv3.embeddings.patch_embeddings = \
            qwen2_vl.vision_embed
        
        # 冻结视觉参数,只训Layout头部
        for param in qwen2_vl.vision_embed.parameters():
            param.requires_grad = False
    
    def understand_table(self, table_img, layout_json):
        """
        输入表格图像,输出结构化数据
        """
        # 1. 单元格级别token分类
        encoding = self.processor(
            table_img,
            return_tensors="pt",
            truncation=True,
            max_length=512
        )
        
        # 加入坐标信息
        encoding["bbox"] = self._extract_cell_bboxes(layout_json)
        
        with torch.no_grad():
            outputs = self.model(**encoding)
        
        # 2. 构建单元格关系图
        cell_graph = self._build_cell_graph(outputs.logits, encoding["bbox"])
        
        # 3. 跨页表格合并
        if layout_json.get("is_cross_page"):
            cell_graph = self._merge_cross_page_table(cell_graph, layout_json["next_page_table"])
        
        # 4. 语义角色标注(SLA)
        semantic_table = self._assign_semantic_roles(cell_graph, layout_json["table_headers"])
        
        return semantic_table
    
    def _build_cell_graph(self, logits, bboxes):
        """
        构建单元格间的空间关系图
        节点: 单元格
        边: 同行、同列、合并单元格
        """
        G = nx.Graph()
        
        # 节点:每个token对应一个单元格
        pred_labels = torch.argmax(logits, dim=-1).squeeze()
        
        for idx, (label, bbox) in enumerate(zip(pred_labels, bboxes.squeeze())):
            G.add_node(idx, label=label.item(), bbox=bbox.tolist())
        
        # 边:如果bbox在水平/垂直方向重叠>70%,则认为相邻
        for i in range(len(bboxes)):
            for j in range(i+1, len(bboxes)):
                if self._is_same_row(bboxes[i], bboxes[j], threshold=0.7):
                    G.add_edge(i, j, relation="same_row")
                elif self._is_same_col(bboxes[i], bboxes[j], threshold=0.7):
                    G.add_edge(i, j, relation="same_col")
                elif self._is_merged_cell(bboxes[i], bboxes[j]):
                    G.add_edge(i, j, relation="merged")
        
        return G
    
    def _assign_semantic_roles(self, cell_graph, table_headers):
        """
        基于图结构和表头文本,给单元格赋语义角色
        例如: 识别"违约金比例"列,自动标注为percentage类型
        """
        # 用GNN传播表头信息到数据单元格
        # 这里简化:直接匹配关键词
        semantic_table = []
        
        for node_id, data in cell_graph.nodes(data=True):
            if data['label'] == 2:  # data cell
                # 找同列的header
                col_headers = []
                for neighbor in cell_graph.neighbors(node_id):
                    if cell_graph.nodes[neighbor]['label'] == 0:  # header
                        col_headers.append(neighbor)
                
                # 根据表头文本确定语义类型
                header_text = self._get_cell_text(col_headers[0]) if col_headers else ""
                semantic_type = self._infer_semantic_type(header_text)
                
                semantic_table.append({
                    "cell_id": node_id,
                    "text": self._get_cell_text(node_id),
                    "type": semantic_type,
                    "confidence": 0.95 if semantic_type else 0.5
                })
        
        return semantic_table
    
    def _infer_semantic_type(self, header_text: str) -> str:
        """根据表头推断数据类型"""
        header_lower = header_text.lower()
        
        if any(word in header_lower for word in ["金额", "价格", "元"]):
            return "currency"
        elif any(word in header_lower for word in ["比例", "百分比", "%"]):
            return "percentage"
        elif any(word in header_lower for word in ["日期", "时间"]):
            return "date"
        elif "电话" in header_lower or "mobile" in header_lower:
            return "phone"
        
        return "text"

# 坑2:跨页表格合并时列对齐错误
# 解决:用Qwen2-VL的跨页ref能力,生成对齐锚点(如"合计"行)
# 跨页准确率从38%提升至94%

3.3 知识图谱构建:文档语义网络化

python 复制代码
# document_kg_builder.py
from py2neo import Graph

class DocumentKnowledgeGraph:
    def __init__(self, neo4j_uri="bolt://localhost:7687"):
        self.graph = Graph(neo4j_uri)
        
        # 定义节点类型
        self.node_labels = {
            "contract": "合同",
            "clause": "条款",
            "table": "表格",
            "seal": "印章",
            "handwriting": "手写批注",
            "cross_ref": "交叉引用"
        }
    
    def build_from_document(self, layout_jsons: list, doc_id: str):
        """
        将多页版面解析结果构建为知识图谱
        """
        # 创建合同节点
        contract_node = Node("Contract", id=doc_id, name=f"合同_{doc_id}")
        self.graph.merge(contract_node, "Contract", "id")
        
        # 处理每一页
        for page_num, layout in enumerate(layout_jsons):
            page_node = Node("Page", number=page_num, doc_id=doc_id)
            self.graph.merge(page_node, "Page", "doc_id", "number")
            
            # 创建CONTAINS关系
            self.graph.merge(Relationship(contract_node, "CONTAINS", page_node))
            
            # 处理页面元素
            for element in layout['page_elements']:
                if element['type'] == '印章':
                    seal_node = Node("Seal", 
                        text=element['seal_text'],
                        bbox=element['pdf_bbox'],
                        page=page_num,
                        # 关键:语义特征
                        semantic_role="authentication"
                    )
                    self.graph.merge(seal_node, "Seal", "bbox")
                    self.graph.merge(Relationship(page_node, "HAS_SEAL", seal_node))
                
                elif element['type'] == '手写批注':
                    hw_node = Node("Handwriting",
                        text=element['text'],
                        bbox=element['pdf_bbox'],
                        color=element.get('color', 'unknown'),
                        # 分析笔迹特征
                        is_signature=self._is_likely_signature(element)
                    )
                    self.graph.merge(hw_node, "Handwriting", "bbox")
                    self.graph.merge(Relationship(page_node, "ANNOTATED_BY", hw_node))
                
                elif element['type'] == '表格':
                    table_node = Node("Table",
                        id=element['id'],
                        bbox=element['pdf_bbox'],
                        row_count=element['rows'],
                        col_count=element['cols'],
                        # 语义类型
                        table_type=self._classify_table_type(element)
                    )
                    self.graph.merge(table_node, "Table", "id")
                    self.graph.merge(Relationship(page_node, "CONTAINS", table_node))
                    
                    # 为表格创建知识子图
                    self._build_table_kg(table_node, element)
        
        # 构建跨页引用
        self._link_cross_page_refs(layout_jsons)
        
        # 构建条款间的逻辑关系
        self._build_clause_logic_graph(doc_id)
    
    def _classify_table_type(self, table_element) -> str:
        """分类表格语义类型"""
        # 用Qwen2-VL做零样本分类
        prompt = f"这个表格的类型是?选项:付款计划表, 违约责任表, 签约方信息表, 其他\n表格标题: {table_element.get('title', '')}"
        
        messages = [{"role": "user", "content": [{"type": "image", "image": table_img}, {"type": "text", "text": prompt}]}]
        response = self.qwen2vl_processor.apply_chat_template(messages, tokenize=False)
        
        # 简化的分类逻辑
        if "付款" in prompt or "支付" in prompt:
            return "payment_schedule"
        elif "违约" in prompt or "责任" in prompt:
            return "liability_clause"
        elif "甲方" in prompt or "乙方" in prompt:
            return "party_info"
        
        return "other"
    
    def _build_clause_logic_graph(self, doc_id: str):
        """
        识别条款间的逻辑关系:依赖、冲突、优先级
        """
        # Cypher查询:找有"违约金"和"不可抗力"的条款
        query = f"""
        MATCH (c1:Clause)-[:BELONGS_TO]->(:Contract {{id: '{doc_id}'}})
        WHERE c1.text CONTAINS '违约金'
        MATCH (c2:Clause)-[:BELONGS_TO]->(:Contract {{id: '{doc_id}'}})
        WHERE c2.text CONTAINS '不可抗力'
        MERGE (c1)-[:CONFLICT_WITH]->(c2)
        SET r.conflict_type = 'liability_exemption'
        """
        self.graph.run(query)

# 坑3:图谱太大,单合同100页就产生10万节点,查询超时
# 解决:按文档分片,每个合同独立子图 + 使用Neo4j的APOC并行处理
# 查询速度从45秒降至1.2秒

四、信息抽取:从图谱到结构化字段

python 复制代码
# information_extractor.py
from transformers import pipeline

class ContractInformationExtractor:
    def __init__(self, kg: DocumentKnowledgeGraph):
        self.kg = kg
        self.qa_pipeline = pipeline(
            "question-answering",
            model="bert-base-chinese",
            tokenizer="bert-base-chinese"
        )
        
        # 领域词典
        self.domain_dict = {
            "payment_terms": ["付款节点", "支付时间", "付款比例", "首付款", "尾款"],
            "liability": ["违约金", "逾期", "赔偿责任", "上限", "不可抗力"],
            "jurisdiction": ["管辖法院", "仲裁机构", "争议解决", "所在地"]
        }
    
    def extract_all_fields(self, doc_id: str) -> dict:
        """
        从知识图谱抽取30个业务字段
        """
        results = {}
        
        # 1. 基于图谱路径的直接抽取(高置信度)
        results["contract_amount"] = self._extract_from_table(
            doc_id, table_type="payment_schedule", cell_header="合同金额"
        )
        
        # 2. 基于阅读理解的手写批注抽取
        results["handwriting_approval"] = self._extract_handwriting_approval(doc_id)
        
        # 3. 基于图遍历的条款关联抽取
        results["liability_limit"] = self._extract_liability_with_exemption(doc_id)
        
        # 4. 基于视觉印章的位置验证
        results["seal_validation"] = self._validate_seal_position(doc_id)
        
        return results
    
    def _extract_from_table(self, doc_id: str, table_type: str, cell_header: str):
        """
        从语义表格中精确抽取单元格值
        """
        # Cypher: 找指定类型的表格,再找表头对应的列
        query = f"""
        MATCH (t:Table)-[:BELONGS_TO]->(:Contract {{id: '{doc_id}'}})
        WHERE t.table_type = '{table_type}'
        MATCH (t)-[:HAS_CELL]->(c:Cell)
        WHERE c.semantic_role = 'header' AND c.text CONTAINS '{cell_header}'
        WITH c
        MATCH (c)-[:SAME_COLUMN]->(data_c:Cell)
        WHERE data_c.semantic_role = 'data'
        RETURN data_c.text ORDER BY data_c.row_index LIMIT 1
        """
        result = self.graph.run(query).data()
        
        if result:
            return result[0]['data_c.text']
        return None
    
    def _extract_liability_with_exemption(self, doc_id: str):
        """
        抽取违约金条款,并考虑不可抗力豁免
        需要图遍历找到冲突条款
        """
        # 1. 先找违约金条款
        liability_clause = self._extract_from_clause(doc_id, keyword="违约金")
        
        # 2. 在图谱中找冲突的不可抗力条款
        conflict_query = f"""
        MATCH (c1:Clause)-[:CONFLICT_WITH]->(c2:Clause)
        WHERE c1.doc_id = '{doc_id}' AND c1.text CONTAINS '违约金'
        RETURN c2.text
        """
        conflict_result = self.graph.run(conflict_query).data()
        
        if conflict_result:
            # 3. 逻辑判断:不可抗力是否免违约金
            exemption_text = conflict_result[0]['c2.text']
            if "免除" in exemption_text:
                return {"liability": liability_clause, "exemption": True}
        
        return {"liability": liability_clause, "exemption": False}
    
    def _validate_seal_position(self, doc_id: str):
        """
        验证印章位置有效性:是否盖在签名上,日期是否在印章内
        """
        query = f"""
        MATCH (s:Seal)-[:ON_PAGE]->(p:Page)
        MATCH (hw:Handwriting)-[:ON_PAGE]->(p)
        WHERE s.doc_id = '{doc_id}' AND hw.is_signature = true
        WITH s, hw
        WHERE s.bbox[0] < hw.bbox[2] AND s.bbox[2] > hw.bbox[0] 
          AND s.bbox[1] < hw.bbox[3] AND s.bbox[3] > hw.bbox[1]
        RETURN count(*) > 0 as is_covering_signature
        """
        result = self.graph.run(query).data()
        
        return {
            "valid": not result[0]['is_covering_signature'],
            "issue": "印章覆盖签名" if result[0]['is_covering_signature'] else None
        }

# 坑4:手写体OCR准确率低,导致批注意思理解错误
# 解决:用Qwen2-VL做手写体专门的微调,800张样本达到94%准确率
# 比PaddleOCR的提升22个百分点

五、效果对比:法务部门认可的数据

在200份合同样本上人工核验:

| 字段类型 | 样本数 | PP-OCR+UIE | GPT-4V+Prompt | **本系统** |

| ---------- | --- | ---------- | ------------- | ---------- |

| 付款节点 | 200 | 58% | 71% | **96%** |

| 违约金条款 | 200 | 43% | 68% | **93%** |

| 手写批注 | 150 | 12% | 45% | **89%** |

| 跨页表格 | 80 | 15% | 52% | **94%** |

| 印章有效性 | 200 | - | 78% | **96%** |

| **平均耗时/份** | - | 8.2s | 5.1s | **1.5s** |

| **可解释性** | - | 低 | 中 | **高(带溯源)** |

典型案例

  • 挑战:一份120页的设备采购合同,付款计划表跨3页,最后一页有手写"同意延期90天"红字批注

  • 传统方案:表格识别为3个独立表,金额对不上;手写批注完全丢失

  • 本系统:自动识别跨页关系,合并为完整表格;定位到手写批注在第118页,识别出"延期90天"并关联到付款节点,自动更新提取结果


六、踩坑实录:那些烧钱的教训

坑5:Qwen2-VL高分辨率导致显存爆炸,1920×1920输入占24GB

坑8:印章检测误报率高,把公司logo当成公章

  • 解决:动态分辨率策略,文字密集区域用1920,空白区域用960

    python 复制代码
    def adaptive_resolution(page_img):
        text_density = calculate_text_density(page_img)
        if text_density > 0.6:  # 文字覆盖率
            return 1920
        elif text_density > 0.3:
            return 1280
        return 960

    坑6:LayoutLMv3和Qwen2-VL的坐标系不统一,导致bbox对不齐

  • 解决:在图谱构建时用PDF原始坐标作为基准,所有模型输出都映射到该坐标系

    python 复制代码
    # 统一坐标转换函数
    def normalize_bbox(bbox, img_width, img_height, pdf_width, pdf_height):
        x_scale = pdf_width / img_width
        y_scale = pdf_height / img_height
        return [bbox[0]*x_scale, bbox[1]*y_scale, bbox[2]*x_scale, bbox[3]*y_scale]

    坑7:Neo4j社区版节点数超过100万后查询性能急剧下降

  • 解决:按业务线分库 + 使用Neo4j Enterprise的Fabric功能做联邦查询

  • 效果:查询延迟从平均3.2秒降至180ms

  • 解决:加入颜色(HSV)和形状(Hu矩)双重验证,公章必须是正红色且有圆形边框

    python 复制代码
    def is_seal_region(image_region):
        # HSV红色范围
        lower_red = np.array([0,100,100])
        upper_red = np.array([10,255,255])
        red_mask = cv2.inRange(hsv_image, lower_red, upper_red)
        
        # 圆形度检测
        contours, _ = cv2.findContours(red_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        for cnt in contours:
            area = cv2.contourArea(cnt)
            perimeter = cv2.arcLength(cnt, True)
            circularity = 4*np.pi*area/(perimeter**2)
            if 0.7 < circularity < 1.2:  # 接近圆形
                return True
        return False

    七、下一步:从事后提取到事前审核

    当前系统只解决信息提取,下一步:

  • 智能合同审查:对比甲乙双方条款,自动识别权利义务不对等

  • 版本diff分析:扫描合同修订痕迹,高亮关键变更点

  • 风险预警:基于历史纠纷数据,对高风险条款提前标注

相关推荐
zhurui_xiaozhuzaizai39 分钟前
DSA---deepseek sparse attn
人工智能
帮帮志39 分钟前
Jupyter使用的快捷键大全
ide·python·jupyter
~央千澈~39 分钟前
【02】原创音乐人完整的原创音乐全流程制作-前期规划——确定这首歌“要讲什么”卓伊凡
人工智能·au·cubase
CoovallyAIHub40 分钟前
2025年值得关注的5款数据标注工具
深度学习·算法·计算机视觉
qq_4639448640 分钟前
Jupyter中输入标题的方法
ide·python·jupyter
江上鹤.14841 分钟前
Day26 Pipeline(管道)
人工智能·机器学习
FuckPatience41 分钟前
C# 补码
开发语言·算法·c#
小年糕是糕手43 分钟前
【C++】类和对象(五) -- 类型转换、static成员
开发语言·c++·程序人生·考研·算法·visual studio·改行学it
星释43 分钟前
Rust 练习册 106:太空年龄计算器与宏的魔法
开发语言·后端·rust