从零到一:构建基于大语言模型的多格式文档理解系统 — 技术难点与解决方案全记录

项目名称:表易智融(原"智联文档" / FilesReadSystem)
项目背景:第十七届中国大学生服务外包创新创业大赛 A23 赛题——《基于大语言模型的文档理解与多源数据融合》
开发周期:2026年2月 ~ 2026年5月(约3个月)
技术栈:FastAPI + React + TypeScript + MongoDB + MySQL + Redis + Celery + FAISS + 多LLM集成


一、项目概述

"表易智融"是一个基于大语言模型(LLM)的智能文档处理系统,核心能力包括:

  1. 多格式文档解析:支持 Excel (.xlsx/.xls)、Word (.docx)、Markdown (.md)、纯文本 (.txt) 四种格式

  2. AI 驱动的智能分析:文档摘要、关键信息提取、图表数据生成

  3. 自然语言指令交互:用户用自然语言描述需求,系统自动完成文档编辑、格式转换、模板填写

  4. 模板智能填充:从非结构化源文档中自动提取数据,填入 Word/Excel 模板表格

  5. RAG 检索增强:基于向量检索的语义搜索,辅助 LLM 精准定位信息

本文将从实际的 Git 提交记录出发,全面复盘项目开发过程中遇到的技术难点、踩过的坑、以及最终解决方案


二、基础设施与架构挑战

2.1 多数据库协同的复杂性

问题描述:系统需要同时使用三种数据库:MySQL(结构化 Excel 数据)、MongoDB(非结构化文档)、Redis(缓存/任务队列)。三者的连接管理、初始化顺序、健康检查逻辑各不相同。

遇到的坑

  • MySQL 数据库自动创建失败:当数据库不存在时,SQLAlchemy.create_engine 必须在连接字符串中指定数据库名,但又无法在连接时动态创建

  • MongoDB 连接超时无合理错误提示

  • Redis 连接健康检查返回假阳性(连接池存在但实际不可用)

解决方案

# 1. MySQL: 先创建无数据库的临时连接,执行 CREATE DATABASE,再建立正式连接
# 2. MongoDB: 使用 motor 异步驱动,所有操作统一为 async/await 模式
# 3. Redis: 健康检查从简单连接验证改为实际执行 PING 命令
# 4. 所有数据库的健康检查改为执行实际操作(SELECT 1, ping(), PING)而非仅检查连接对象

相关提交4bdc3f9, 7c88da9, 5fca4eb, 8e1ddb8

2.2 Docker 全栈部署的环境配置

问题描述:项目最终需要支持 Docker 一键部署,涉及 5 个服务(前端 Nginx、后端 FastAPI、MongoDB、MySQL、Redis)的编排,以及开发环境与生产环境的配置差异。

解决方案

# docker-compose.yml 核心结构
services:
  frontend:  # React + Nginx 反向代理
  backend:   # FastAPI + Celery Worker
  mongodb:   # 非结构化数据
  mysql:     # 结构化数据
  redis:     # 缓存 + 消息队列

关键措施:

  • 通过 .env.example 统一管理环境变量

  • 服务启动依赖链管理(depends_on + healthcheck

  • 文件路径区分开发/生产环境(本地路径 vs Docker 挂载路径)

  • Celery Worker 作为独立容器运行

相关提交d2e3c2d


三、文档解析的深水区

3.1 Excel 解析:pandas 不是万能的

这是项目中遇到问题最多、迭代次数最多的模块。

问题 1:特殊格式 Excel 文件 pandas 无法解析

现象:某些 Excel 文件(包含非标准 XML 元素、自定义命名空间等)用 pandas.read_excel() 打开时报错或返回空数据。

解决:实现 XML 回退解析机制 (XML Fallback Parser)

# xlsx_parser.py 核心逻辑
try:
    # 方法1: 标准 pandas 解析
    xls_file = pd.ExcelFile(file_path)
    sheet_names = xls_file.sheet_names
except Exception:
    # 方法2: 直接从 ZIP 内 XML 提取
    sheet_names = self._extract_sheet_names_from_xml(file_path)
    data = self._read_excel_sheet_xml(file_path)

具体实现:

  • _extract_sheet_names_from_xml(): 解析 xl/workbook.xml 提取工作表名

  • _read_excel_sheet_xml(): 直接解析 xl/worksheets/sheetN.xml 提取单元格数据

  • 支持多种命名空间http://schemas.openxmlformats.org/spreadsheetml/2006/main 等),使用通配符匹配

相关提交3b82103, 7c19e49

问题 2:XML 命名空间兼容性

现象:不同来源的 Excel 文件使用不同的 XML 命名空间声明方式,导致元素查找失败。

解决:多命名空间同时匹配 + 通配符 fallback

# 尝试多种命名空间前缀
for ns_prefix in ['', 's:', 'ss:', 'x:', 'main:']:
    tag = f'{{{ns}}}{element_name}'
    result = root.findall(f'.//{tag}')

相关提交3b82103

问题 3:英文表头 Excel 读取失败

现象:包含英文列名的 Excel 文件,在某些命名空间配置下无法正确识别表头。

解决:增强 Excel 解析器,支持多种命名空间和路径格式的组合查找。

相关提交496b965

问题 4:浮点数匹配不一致

现象:从 Excel 读取的数字,与使用时的浮点数进行匹配时出现精度不一致问题(如 3.0 != 3.0000000000001)。

解决:在数据类型检测逻辑中优化浮点数范围验证,超出合理精度范围的数值标记为 TEXT 类型处理。

# 改进的类型检测
def _infer_column_type(values):
    # 移除空值后才进行类型推断
    # 整数:检查是否在 INT 范围内
    # 浮点:检查精度在合理范围
    # 超出范围 → TEXT

相关提交718f864, 1a54d40

问题 5:MySQL 列名编码问题

现象:Excel 列名包含中文字符,直接用作 MySQL 列名时报编码错误或创建失败。

解决:实现列名清理逻辑,支持 UTF-8 编码的中文字符,同时处理唯一列名生成(防止重名列),并切换到 pymysql 直接插入方式提升性能。

相关提交ec47595

问题 6:MySQL “id” 保留字冲突

现象:使用 id 作为列名时,SQLAlchemy 生成的 SQL 语法错误。

解决:识别并转义 MySQL 保留字列名。

相关提交41e5eaa

3.2 Word 文档解析

问题 1:python-docx 的兼容性局限

现象python-docx 处理某些复杂格式的 .docx 文件时,表格提取异常或遗漏。

解决:采用 zipfile + 直接 XML 读取 的方式兜底:

# docx_parser.py - 绕过 python-docx 直接读取 XML
import zipfile
from xml.etree import ElementTree as ET

with zipfile.ZipFile(file_path) as z:
    with z.open('word/document.xml') as f:
        tree = ET.parse(f)
        # 直接解析 Word 的 XML 结构

相关提交be30283

问题 2:Word 模板导出文件损坏

现象:使用 python-docx 修改模板后保存,导出的 .docx 文件无法正常打开。

解决:改用临时文件方式处理输出,确保文件完整写入后再返回。

相关提交ecad9cc

3.3 Markdown 和文本解析

编码检测挑战:TXT 文件编码不定(GBK、UTF-8、UTF-8-BOM 等),直接读取中文乱码。

解决:引入 chardet 库进行自动编码检测,支持从 GBK/GB2312/UTF-8 等多种编码中自动选择。

相关提交5bcad4a


四、LLM 集成:最艰难的攻坚战

4.1 LLM 服务商切换的阵痛

项目经历了多次 LLM 服务商变更:

MiniMax → 智谱AI (GLM-4) → DeepSeek

每次切换都面临:

  • API 格式差异(请求体结构、响应字段名不同)

  • Token 限制差异(不同模型的最大 token 数差异巨大)

  • 输出风格差异(JSON 格式稳定性、中文理解能力)

  • 流式输出协议差异

最终方案:抽象 LLM 服务层,统一不同供应商的调用接口。

# llm_service.py 核心设计
class LLMService:
    def __init__(self):
        self.api_key = settings.LLM_API_KEY
        self.base_url = settings.LLM_BASE_URL
        self.model_name = settings.LLM_MODEL_NAME
        # 通过 .env 配置即可切换,无需修改代码

    async def chat(self, messages, temperature=0.3, max_tokens=4000):
        # 统一的调用接口
    async def chat_stream(self, messages):
        # 统一的流式调用接口

相关提交6ec45b7, 5241f68

4.2 max_tokens 的血泪教训

这是项目中反复调整次数最多的参数,经历了经典的"矫枉过正"循环:

初始值: 500   → "不够用,LLM 返回被截断"
修改为: 50000 → "DeepSeek API 报错,超过限制"
修改为: 4000  → "正常运行"
修改为: 5000  → "又报错"
改回:   4000  → "最终稳定值"

教训

  • 不同 LLM 的 max_tokens 上限不同,需要逐一测试

  • DeepSeek 的上限远低于预期,约 4000-8000 tokens

  • 参数过大不仅会报错,还可能导致响应变慢和费用增加

  • 最佳实践:针对具体 API 文档逐模型测试边界值,而非猜一个"够大"的值

相关提交df35105, 2c2ab56, 496b965, 78417c898

4.3 LLM 输出 JSON 解析:与"不听话"的 AI 斗争

这是项目的核心痛点之一。LLM 返回的 JSON 经常处于不可解析状态:

问题表现

// 情况1: Markdown 代码块包裹
```json
{"field": "value"}

// 情况2: JSON 被截断(token 限制导致)
{“field”: “value”, “another”: "val

// 情况3: 末尾多余逗号
{“field”: “value”,}

// 情况4: 非 JSON 噪音混入
根据文档内容,提取结果如下:
{“field”: “value”}
以上为提取结果。


#### 解决方案:多层 JSON 修复策略

```python
# template_fill_service.py 中的 _fix_json 方法

def _fix_json(self, text: str) -> str:
    """多层防御性 JSON 修复"""

    # Step 1: 清理 Markdown 代码块标记
    text = re.sub(r'```json\s*', '', text)
    text = re.sub(r'```\s*', '', text)

    # Step 2: 提取 JSON 对象(找第一个 { 到最后一个 })
    start = text.find('{')
    end = text.rfind('}')
    if start != -1 and end != -1:
        text = text[start:end+1]

    # Step 3: 移除末尾多余逗号
    text = re.sub(r',\s*}', '}', text)
    text = re.sub(r',\s*]', ']', text)

    # Step 4: 尝试配对括号(处理截断)
    open_count = text.count('{') - text.count('}')
    if open_count > 0:
        text += '}' * open_count

    return text

终极保险:正则兜底提取

当 JSON 完全无法修复时,使用 正则表达式直接从文本提取值

def _extract_values_by_regex(self, text: str, field_name: str) -> List[str]:
    """从损坏的文本中用正则兜底提取"""
    # 匹配 "field_name": "value" 模式
    patterns = [
        rf'"{field_name}"\s*:\s*"([^"]*)"',
        rf'"{field_name}"\s*:\s*(\d+\.?\d*)',
    ]
    for pattern in patterns:
        matches = re.findall(pattern, text, re.IGNORECASE)
        if matches:
            return list(matches)
    return []

相关提交df35105, 2c2ab56, d5df5b8, a9dc0d8

4.4 Prompt 工程的持续优化

核心挑战:Prompt 既要引导 LLM 生成正确格式,又要保持提取准确率。

经过多轮迭代总结的经验:

策略

效果

严格按表头提取,只返回相关数据

✅ 减少无关输出

每个值必须带标注(年份/地区/分类)

✅ 提高可追溯性

不返回大段来源说明

✅ 减少 JSON 噪音

保留原始数值、单位和百分号格式

✅ 提高准确性

Few-shot 示例引导

✅ 稳定输出格式

精简字段到 5-7 个代表性字段

✅ 减少 token 消耗

相关提交a9dc0d8, ecc0c79, 7f67fa89


五、RAG 向量检索:从理想到现实

5.1 嵌入模型的安装困境

问题sentence-transformers 依赖的底层库(torchonnxruntime 等)在 Windows 上安装困难,且模型下载需要特定网络环境。

解决方案

  1. 添加 try-catch 优雅降级,嵌入模型加载失败时使用简化模式而非崩溃

  2. 提供 EMBEDDING_MODEL 配置项,支持灵活切换

  3. 默认使用轻量模型 all-MiniLM-L6-v2

try:
    from sentence_transformers import SentenceTransformer
    self.embedding_model = SentenceTransformer(settings.EMBEDDING_MODEL)
except Exception as e:
    logger.warning(f"嵌入模型加载失败,使用简化模式: {e}")
    self._disabled = True  # 优雅降级

5.2 RAG 服务的临时禁用决策

背景:在比赛准备阶段,嵌入模型在多个环境下加载不稳定,影响了核心的模板填写功能。

决策:临时禁用 RAG 功能,改用直接文件读取 + LLM 理解 的方式完成数据提取。关键考虑:

RAG 禁用 vs 保留的权衡:
- RAG 优势:语义检索精度高,支持跨文档对比
- RAG 劣势:模型加载不稳定,增加系统复杂度
- 直接读取优势:简单可靠,不需要额外依赖
- 直接读取劣势:无法做语义级检索

结论:比赛场景下,文档数量有限→直接读取更可靠

相关提交44d389a

5.3 混合检索的实现

当 RAG 服务正常运行时,系统支持 BM25 + 向量检索混合融合

class RAGService:
    def hybrid_search(self, query: str, top_k: int = 5):
        """混合检索:BM25 关键词 + FAISS 向量"""
        # 1. BM25 关键词检索
        bm25_results = self.bm25.search(query, top_k=top_k*2)

        # 2. FAISS 向量检索
        vector_results = self.faiss_search(query, top_k=top_k*2)

        # 3. 融合排序(RRF - Reciprocal Rank Fusion)
        fused = self._rrf_fusion(bm25_results, vector_results)
        return fused[:top_k]

相关提交ecad9cc


六、智能填表:核心业务的演进

6.1 从"一条路"到"双轨制"

初始方案:所有字段都通过 LLM 从文档文本中提取。

问题:对于 Excel 等已有结构化数据(rows/columns)的文档,LLM 提取既不准确又慢。

最终方案:结构化优先 + LLM 兜底

字段提取流程:
1. 检查 source_docs 是否有 structured_data.rows
2. 有 → 直接从 rows 中按列名匹配提取(模糊匹配 + 包含关系)
3. 无 → 使用 LLM 从文本中提取
4. LLM 提取失败 → 正则表达式兜底
async def _extract_field_value(self, field_name, source_docs):
    # 优先:结构化数据直接提取
    for doc in source_docs:
        if doc.structured_data.get("rows"):
            values = self._extract_from_rows(
                doc.structured_data["rows"],
                doc.structured_data["columns"],
                field_name
            )
            if values:
                return values, "结构化数据直接提取", 1.0

    # 其次:LLM 文本理解
    result = await self.llm.extract_field_from_text(field_name, text)

    # 兜底:正则表达式
    if not result:
        result = self._extract_by_regex(field_name, text)

相关提交2c2ab56, df35105, 7f67fa89

6.2 多值字段的处理

需求:一个模板字段可能对应多个数据值(如"提取文档中的医院数量"→多个医院的统计结果)。

解决

@dataclass
class FillResult:
    field: str
    values: List[Any] = []  # 支持多值数组
    value: Any = ""         # 向后兼容(第一个值)
    warning: str = None     # 多值检测提示

前端展示时,多值情况显示黄色警告提示,清晰列出所有提取到的值。

相关提交a9dc0d8

6.3 智能意图解析

实现了支持以下意图的自然语言解析器:

意图类型

示例指令

功能

fill

“把数据填入模板”

执行模板填写

summarize

“帮我总结这个文档”

文档摘要

compare

“对比A和B两个文档”

文档比较

transform

“把Word转成Markdown”

格式转换

edit

“润色这段内容”

文档编辑

extract

“提取文档中的医院数量”

字段提取

关键创新:支持对话历史感知,基于 MongoDB 中存储的对话上下文延续用户意图。AI 助手能够记住之前的对话内容,实现多轮交互下的意图延续和理解。

相关提交e5d4724, ecad9cc

6.4 格式转换与 PDF 生成

实现了四种格式互转:docx ↔ xlsx ↔ md ↔ txt ↔ pdf

特别是 PDF 转换的流水线设计:

任意格式 → Markdown(中间格式)→ PDF

这样保证输出一致性,避免了每种格式都要单独写 PDF 渲染逻辑。

中文支持:使用 simhei.ttf 字体文件解决中文 PDF 乱码问题。DOCX 解析使用 zipfile 直接读取 XML,避免 python-docx 的兼容性问题。

相关提交be30283


七、前端架构的演化

7.1 状态管理重构

问题TemplateFill 页面状态分散在多个组件和 hooks 中,导致数据流难以追踪和调试。

解决:创建 TemplateFillContext,将模板填写相关的所有状态集中管理。

重构前:
TemplateFill.tsx (300+ 行状态管理)
  ├── FileUploader (独立状态)
  ├── FieldEditor (独立状态)
  └── ResultPreview (独立状态)

重构后:
TemplateFillContext (全局状态)
TemplateFill.tsx (轻量化,仅 UI 编排)
  ├── FileUploader (消费 Context)
  ├── FieldEditor (消费 Context)
  └── ResultPreview (消费 Context)

相关提交7f67fa89

7.2 FastAPI + JSON 请求体的经典问题

现象:后端接口定义使用普通函数参数,FastAPI 将其解析为查询参数而非请求体,导致 JSON 数据无法接收。

# ❌ 错误写法 - FastAPI 当作 Query 参数处理
@router.post("/statistics")
async def generate_statistics(excel_data: Dict[str, Any]):
    ...

# ✅ 正确写法 - 使用 Pydantic 模型
class StatisticsRequest(BaseModel):
    excel_data: Dict[str, Any]

@router.post("/statistics")
async def generate_statistics(request: StatisticsRequest):
    ...

相关提交:开发日志 2026-03-16

7.3 导入路径漂移

问题:重构时将 AuthContextsrc/context/ 移动到 src/contexts/,导致多处导入路径失效。

解决:系统性更新所有引用路径(App.tsx, RouteGuard.tsx 等),并删除旧文件避免混淆。

相关提交5fca4eb

7.4 并行文件上传

需求:用户需要一次上传多个文件,并能看到每个文件的上传状态。

解决:实现并行多文件上传功能,前端显示上传文件列表,支持多次追加上传。配合后端批量接口,提升了用户操作效率。

相关提交8f66c23


八、异步任务系统的设计

8.1 任务状态双写机制

问题:Celery 的任务状态存储在 Redis 中,但 Redis 有内存淘汰策略,长时间任务状态可能丢失。

解决:实现 Redis + MongoDB 双写

async def update_task_status(task_id: str, status: str, **kwargs):
    """任务状态双写"""
    # 1. 写入 Redis(快速查询,支持过期)
    await redis.set(f"task:{task_id}", json.dumps({
        "status": status, **kwargs
    }), ex=3600)

    # 2. 写入 MongoDB(持久化,支持历史查询)
    await mongodb.tasks.update_one(
        {"task_id": task_id},
        {"$set": {"status": status, **kwargs}},
        upsert=True
    )

8.2 任务过期处理

当 Redis 中的任务状态过期后,不再返回 404,而是返回"已完成"状态:

@router.get("/tasks/{task_id}")
async def get_task_status(task_id: str):
    task_data = await redis.get(f"task:{task_id}")
    if not task_data:
        # Redis 中已清除 = 大概率已完成
        return {"status": "completed"}
    return json.loads(task_data)

8.3 任务历史记录

基于 MongoDB 的任务集合,实现了完整的任务历史记录管理:CRUD 操作、列表查询、进度跟踪。前端提供任务历史页面,用户可以查看、删除历史任务记录。

相关提交858b594, 0dbf74d, ddf3007


九、团队协作与 Git 管理

9.1 多人协作的合并冲突

项目有至少 4 位贡献者(KiriAky, dj, tl, zzz),在开发高峰期频繁出现合并冲突。

冲突高发区域

  • template_fill_service.py — 核心填写逻辑

  • llm_service.py — LLM 调用参数

  • 前端 API 接口定义

典型冲突场景

一方优化了字段提取逻辑(增加了 RAG 加速)
另一方也修改了同一方法(增加了 Word 文档支持)
→ 合并时需要仔细对比,保留双方改进

解决经验

  1. 功能模块物理隔离:不同开发者负责不同的 Service 文件

  2. 及时 Pull + Rebase:每天早上先拉最新代码

  3. 合并前 review:关键文件合并前通过代码审查

相关提交df35105, 8e713be, 38b0c7e

9.2 提交规范与追溯

项目后期形成了规范的提交格式:

feat(module): 简短描述
- 详细变更点1
- 详细变更点2

fix(module): 问题描述
- 修复内容

这极大方便了后续的问题追溯和博客撰写。


十、关键经验总结

10.1 技术层面的七大教训

序号

教训

具体体现

1

不要轻信 LLM 的 JSON 输出

必须实现多层容错解析:清理代码块→修复截断→正则兜底

2

pandas 不是万能的

复杂 Excel 需要 XML 回退解析,准备多套解析策略

3

参数调优要找边界而非猜大值

max_tokens 从 500→50000→4000 的反复横跳

4

健康检查要真刀真枪

不能只检查连接对象存在,要执行实际操作(SELECT 1, PING)

5

结构化优先于 LLM

能直接提取的数据绝不调用 LLM,更快更准

6

嵌入模型要有降级方案

RAG 功能不能成为系统可用性的单点故障

7

不同环境的文件路径差异

本地开发 vs Docker 容器路径完全不同,需在配置中显式说明

10.2 架构设计的经验

  1. 分层清晰:API 层 → Service 层 → Parser 层 → Database 层,职责分明

  2. 依赖注入:通过 settings 对象统一管理配置,方便切换环境

  3. 工厂模式ParserFactory 根据文件扩展名自动选择解析器

  4. 异步优先:所有 I/O 密集型操作(数据库、LLM 调用)使用 async/await

  5. 双轨制提取:结构化数据直接提取 + LLM 文本提取,兼顾速度与覆盖面

10.3 项目管理经验

  1. MVP 先行:先完成 Excel 解析+统计图表(最小闭环),再逐步扩展

  2. 比赛导向:根据比赛评分规则(准确率优先于速度)调整技术方案

  3. 技术栈克制:不追新求异,选择成熟稳定的技术组合

  4. 文档先行开发路径.md比赛备赛规划.md 等文档帮助团队对齐目标

  5. Docker 化收尾:最后阶段的容器化部署极大简化了环境搭建


十一、技术栈全景图


十二、项目数据一览

基于 Git 统计:

  • 总提交数:约 60+ 个有意义提交

  • 开发周期:2026年2月 ~ 2026年5月(约3个月)

  • 代码变更:218 files changed, 44,332+ insertions

  • 后端服务:15+ 个 Service 类

  • API 端点:20+ 个接口

  • 前端页面:7+ 个主要页面

  • 支持格式:4 种输入(xlsx, docx, md, txt)+ PDF 导出

  • 数据库:3 种(MySQL, MongoDB, Redis)

  • LLM 供应商:3 家(MiniMax, 智谱AI, DeepSeek)

  • 后端语言:Python 3.12(FastAPI + Celery)

  • 前端语言:TypeScript(React 18 + Vite)


后记

"表易智融"项目从零开始,在三个月内完成了一个功能完备的多格式文档理解与智能填表系统。整个过程充满了技术挑战:LLM 输出的不确定性、Excel 格式的多样性、RAG 模型部署的复杂性、多数据库协同的细节陷阱、多人协作的合并冲突……

但也正是这些挑战,让团队在实战中积累了宝贵的经验。从最初的简单 Excel 解析到最终的自然语言驱动的智能文档平台,每一步迭代都在解决真实的问题。

最重要的感悟:在 LLM 应用开发中,不要试图让 AI 包办一切。合理的策略是——能用代码精确处理的,绝不让 AI 猜;AI 的输出,永远要有容错和兜底机制。 正是这种"结构化优先 + LLM 增强 + 容错兜底"的组合策略,让系统在准确性和鲁棒性之间找到了平衡点。

希望这篇博客能帮助到正在构建类似 LLM 应用系统的开发者——少走一些我们走过的弯路。

📎 项目仓库FilesReadSysteam
🏆 赛题:第十七届中国大学生服务外包创新创业大赛 A23 选题


博客最后更新:2026年6月8日
基于 60+ 个 Git 提交记录、项目文档及源码分析撰写

おとといは兎を見たの、昨日は鹿、今日はあなた