项目介绍这是一个开箱即用的财务报表抽取工具支持上传PDF/Excel格式的年报、审计报告自动提取资产负债表、利润表、现金流量表三大表结构化数据输出JSON或Excel。它能够处理跨页表格、合并单元格等复杂排版并支持结果溯源至原文页码。适用于投融资分析、财务校验及企业知识库建设。GitHub项目地址https://github.com/intsig-textin/xparse-sample-projects下面我们讨论实现方法。如果目标是从一份很长的财务报告里快速、稳定地提取三大表第一件事不是写Prompt而是先判断这个问题到底属于语义理解还是属于结构定位。财务三大表更接近后者核心问题往往是“哪一块是表题、哪一块是表格、哪几列是金额列”而不是“模型能不能理解财报”。一、先把目标定义清楚如果目标只是读懂财报一次性全文问答当然也可以但如果目标是做成结构化工具要求通常会变成下面这样从长 PDF 财报里快速定位资产负债表、利润表、现金流量表稳定提取“科目 金额列”把结果直接交给前端继续做同比、导出和后续分析在这种目标下重点不再是生成自然语言答案而是尽快、稳定地把三张表还原出来。二、架构应该怎么拆更适合这类问题的链路通常是PDF 财务报告 ↓ TextIn 文档解析 ↓ markdown detail ↓ 定位 table_title ↓ 查找后续表格块 ↓ 标准化列结构 ↓ 输出三大表 JSON这条链路里的职责边界很明确解析层负责把长财报转成结构化文档树规则层负责定位表题、关联表格块、识别金额列交付层负责把结果给前端做展示和导出这里的关键判断是如果结构信息已经足够好就不要强行把核心抽取写成LLM任务。三、先把解析层输入输出定义对真正调用的还是 TextIn 的二进制流接口POST https://api.textin.com/ai/service/v1/pdf_to_markdown代码里的请求方式如下headers { x-ti-app-id: TEXTIN_APP_ID, x-ti-secret-code: TEXTIN_SECRET_CODE, Content-Type: application/octet-stream, } params { parse_mode: auto, page_count: 200, dpi: 144, table_flavor: html, apply_document_tree: 1, markdown_details: 1, page_details: 1, apply_merge: 1, } resp await client.post( https://api.textin.com/ai/service/v1/pdf_to_markdown, headersheaders, paramsparams, contentfile_bytes, )这里同样要强调Body 是原始 PDF 二进制内容不是multipart/form-data这一层除了markdown还依赖detail里的结构化块信息可以把上游输出理解成{ code: 200, result: { markdown: ..., detail: [ {sub_type: table_title, text: 资产负债表, page_id: 12}, {type: table, rows: [[项目, 本期, 上期], [货币资金, 100, 80]]} ] } }如果做前后端分离通常会在本地后端包一层/api/parse-document给浏览器上传使用但上游解析协议本身仍然是“二进制流 结构化返回”。四、为什么这里不把核心抽取写成 Prompt很多人一上来会想既然前面很多文档抽取都可以用 Prompt财报是不是也可以直接让模型输出三大表当然可以试但这里不是最优解。原因很简单三大表提取首先是定位问题不是开放语义问题长财报对时效和稳定性要求很高如果解析层已经给出了表题和表格块规则通常比全文 Prompt 更直接、更快、更稳所以这里更合理的思路是让解析层提供结构让规则层消费结构。五、真正的输入契约是什么虽然这里没有抽取 Prompt但它一样有严格的输入契约。1. 输入不是全文语义而是detail这套实现真正依赖的是detail里的结构化块。规则层首先看的是块类型和顺序而不是整份财报文本的自然语言含义。最关键的锚点就是if item.get(sub_type) ! table_title: continue这说明规则层并不是在“读懂一段话”而是在找“结构上已经被标注成表题的块”。2. 表题定位之后再找后续表格块找到table_title之后代码会继续向后扫描寻找与之相邻的表格块for j in range(i 1, n): nxt detail_list[j] if isinstance(nxt, dict) and is_table_block(nxt): table_block nxt break这一步的含义很清楚先定位标题再关联表格而不是让模型在全文里自己猜哪一段属于哪张表。3. 表格块还要做输入适配不同文档里的表格块并不一定长成同一种结构所以代码专门兼容了三种来源rowscellshtml对应逻辑是def extract_table_matrix_from_block(item: dict) - list: if isinstance(item.get(rows), list) and item.get(rows): return item[rows] if isinstance(item.get(cells), list) and item.get(cells): return cells_to_matrix(item[cells]) html item.get(table_html) or item.get(html) or item.get(table)这一步其实就是这类工具的“输入适配层”。如果没有这一层后续列识别和表结构统一都会很脆弱。六、输出结构应该怎么定规则层最终要输出的不是原始表格块而是前端可直接消费的三大表结果。本地后端最终返回的是{ status: success, markdown: ..., tables: { balanceSheet: [], incomeStatement: [], cashFlow: [] } }每张表里再是一组已经过标准化的行例如[ { title: 资产负债表, page_id: [12], rows: [ [货币资金, 1000000, 800000], [应收账款, 500000, 420000] ] } ]这样设计的重点是后端先把结构问题解决掉前端不需要再理解原始detail后续做同比、导出、可视化时直接消费标准化后的tables七、为什么这里的规则比全文 Prompt 更合适如果硬要把这件事写成全文 Prompt通常会遇到几个问题长文档 token 成本高同一张表多次抽取结果可能波动模型对表格边界、列边界、续表边界的处理不一定稳定而这里的核心其实是TextIn智能文档解析本身就会对文档做结构化表题识别表格块关联列结构标准化数值列筛选这四件事都更接近结构化规则问题不是开放式生成问题。以上是基于规则与结构化解析实现财报三大表提取的一次实践。方案已上传GitHub欢迎大家在项目中与我们交流。如果你在实际处理财报表格时遇到其他复杂情况如多级表头、不规则合并单元格、跨页续表等也可以留言或私信交流探讨。