Eino AI 实战:解析 PDF 文件 & 实现 MCP Server

174 阅读5分钟

字数 1632,阅读大约需 9 分钟

前言

大家好,我是码一行。

在 AI 应用开发中,文档解析是一个常见的需求,尤其是 PDF 文档的解析。

Eino 作为 Go 语言编写的 LLM 应用开发框架,提供了强大的文档解析能力。

不知道怎么用的,请看历史文章:

本文会分为两大章节:

  1. 如何解析 PDF
  2. 如何封装成 MCP

详细介绍如何使用 Eino 框架实现 PDF 解析,并基于 Go 官方的 MCP(Model Context Protocol)库将其封装为 MCP 服务,方便本地调用和集成。

第一章:Eino 如何解析 PDF

依赖库如下:

  • github.com/cloudwego/eino-ext/components/document/parser/pdf
  • github.com/cloudwego/eino/components/document/parser
  • github.com/cloudwego/eino/schema

1. 实现原理

Eino 解析 PDF 的核心原理是:

  • 使用 Eino 内置的 PDF 解析组件读取 PDF 文件内容
  • 将解析的文本按照排版结构进行智能切割
  • 识别章节标题和内容分隔特征
  • 将内容组织成结构化的 Document 对象,便于后续处理

2. 实现方案

  1. 依赖选择:使用 github.com/cloudwego/eino-ext/components/document/parser/pdf 库进行基本 PDF 解析
  2. 排版切割:实现 splitByLayout 函数,根据章节标题特征进行智能切割
  3. 章节识别:识别常见的中文文档章节标题,如"教育经历"、"个人优势"、"专业技能"等
  4. 结果组织:将切割后的内容组织成多个 schema.Document 对象,每个对象对应一个章节

3. 代码实现

package parser

import (
        "context"
        "regexp"
        "strings"

        "os"

        "github.com/cloudwego/eino-ext/components/document/parser/pdf"
        "github.com/cloudwego/eino/components/document/parser"
        "github.com/cloudwego/eino/schema"
)

func ParserPdf(ctx context.Context, path string, options ...parser.Option) ([]*schema.Document, error) {
        parser, _ := pdf.NewPDFParser(ctx, &pdf.Config{
                ToPages: false, // 是否按页面分割文档
        })

        file, err := os.Open(path)
        if err != nil {
                return nil, err
        }

        defer file.Close()

        // 解析文档
        docs, err := parser.Parse(ctx, file, options...)
        if err != nil {
                return nil, err
        }

        // 如果解析结果为空,直接返回
        if len(docs) == 0 {
                return docs, nil
        }

        // 按照排版结构切割文档
        return splitByLayout(docs[0].Content), nil
}

// splitByLayout 按照排版结构切割文本,将内容分割为多个章节
func splitByLayout(content string) []*schema.Document {
        // 定义章节标题列表
        sectionTitles := []string{
                "教育经历", "个人优势", "专业技能", "工作经历", "项目经历",
                "教育背景", "个人简介", "专业能力", "实习经历", "工作经验",
                "项目经验", "获奖情况", "证书", "自我评价",
        }

        // 预处理:在章节标题前添加换行符,便于后续分割
        processedContent := content
        for _, title := range sectionTitles {
                processedContent = strings.ReplaceAll(processedContent, title, "\n"+title+"\n")
        }

        // 定义章节标题正则表达式,匹配可能有换行符的章节标题
        sectionRegex := regexp.MustCompile(`(?m)^\s*(教育经历|个人优势|专业技能|工作经历|项目经历|教育背景|个人简介|专业能力|实习经历|工作经验|项目经验|获奖情况|证书|自我评价)\s*$`)

        // 查找所有章节标题的位置
        matches := sectionRegex.FindAllStringIndex(processedContent, -1)
        if len(matches) == 0 {
                // 如果没有找到章节标题,返回原始内容
                return []*schema.Document{
                        {
                                Content:  content,
                                MetaData: map[string]any{"section": "完整内容"},
                        },
                }
        }

        var result []*schema.Document

        // 处理第一个章节之前的内容
        if matches[0][0] > 0 {
                preContent := strings.TrimSpace(processedContent[:matches[0][0]])
                if preContent != "" {
                        result = append(result, &schema.Document{
                                Content:  preContent,
                                MetaData: map[string]any{"section": "头部信息"},
                        })
                }
        }

        // 处理每个章节
        for i, match := range matches {
                sectionTitle := processedContent[match[0]:match[1]]
                var sectionContent string

                // 确定章节内容的结束位置
                if i < len(matches)-1 {
                        // 不是最后一个章节,结束位置是下一个章节的开始
                        sectionContent = strings.TrimSpace(processedContent[match[1]:matches[i+1][0]])
                } else {
                        // 最后一个章节,结束位置是文本末尾
                        sectionContent = strings.TrimSpace(processedContent[match[1]:])
                }

                // 添加章节到结果中
                result = append(result, &schema.Document{
                        Content:  sectionContent,
                        MetaData: map[string]any{"section": sectionTitle},
                })
        }

        return result
}

第二章:Eino 如何封装 MCP

依赖库如下:

  • github.com/modelcontextprotocol/go-sdk
  • github.com/gin-gonic/gin

1. 实现原理

Eino 封装 MCP 的核心原理是:

  • 注册 PDF 解析工具到 MCP 服务
  • 处理 MCP 请求,调用相应的工具实现
  • 返回符合 MCP 协议的响应

2. 实现方案

  1. 依赖引入:引入 Go 官方 MCP 库
  2. 服务实现:实现 MCP 服务的核心接口
  3. 工具注册:将 PDF 解析工具注册到 MCP 服务
  4. HTTP 服务:使用 Gin 框架暴露 MCP 服务端点

3. 代码实现

package parser

import (
        "context"
        "log"
        "net/http"

        "github.com/gin-gonic/gin"
        "github.com/modelcontextprotocol/go-sdk/mcp"
)

func init() {
        serverMCP = mcp.NewServer(&mcp.Implementation{
                Name:    "ParserPDF",
                Version: "1.0.0",
        }, nil)

        // 初始化时注册 ParserPDF 工具
        CreateTool("ParserPDF", "解析 PDF 文件,支持本地路径和 Web URL")
}

func parserPdf(ctx context.Context, req *mcp.CallToolRequest, input McpPdfPrams) (*mcp.CallToolResult, McpPdfResp, error) {
        data, err := ParserPdf(ctx, input.Path)
        if err != nil {
                return nil, McpPdfResp{}, err
        }

        if len(data) == 0 {
                return &mcp.CallToolResult{}, McpPdfResp{Content: ""}, nil
        }

        return &mcp.CallToolResult{}, McpPdfResp{Content: data[0].Content}, nil
}

func CreateTool(name, desc string) {
        mcp.AddTool(serverMCP, &mcp.Tool{
                Name:        name,
                Description: desc,
        }, parserPdf)
}

func Run(ctx context.Context) error {
        // 启动一个新的Goroutine来运行MCP服务器(使用StdioTransport)
        go func() {
                if err := serverMCP.Run(ctx, &mcp.StdioTransport{}); err != nil {
                        log.Printf("MCP服务器运行失败: %v\n", err)
                }
        }()

        // 创建HTTP服务器以支持外部Agent调用
        r := gin.Default()

        // 设置外部调用接口
        r.POST("/api/parser/pdf", func(c *gin.Context) {
                var req struct {
                        Path string `json:"path"`
                }

                if err := c.ShouldBindJSON(&req); err != nil {
                        c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误: " + err.Error()})
                        return
                }

                // 直接调用PDF解析函数
                data, err := ParserPdf(c.Request.Context(), req.Path)
                if err != nil {
                        c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF解析失败: " + err.Error()})
                        return
                }

                c.JSON(http.StatusOK, gin.H{
                        "status": "success",
                        "data":   data,
                })
        })

        // 提供MCP工具信息接口
        r.GET("/api/mcp/info", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{
                        "name":        "ParserPDF",
                        "version":     "1.0.0",
                        "description": "解析PDF文件,支持本地路径和Web URL",
                })
        })

        // 启动HTTP服务器,监听7777端口
        log.Printf("MCP外部调用服务器启动,监听端口7777")
        return r.Run(":7777")
}

结语

这里并没有 Eino 实现的 MCP Server,根据最新的官方文档,并没有发现可以实现 MCP Server,只是可以 MCP Client 的方式调用外部或内部的 MCP Server。目前等待官方的最新消息。

通过本文的实践,我们可以看到:

  1. Eino 框架提供了强大的文档解析能力,能够方便地实现 PDF 文档的解析和排版切割。
  2. 基于 Go 官方的 MCP 库,可以快速实现标准化的 MCP 服务,提高服务的兼容性和互操作性。
  3. MCP 协议为 AI 应用提供了标准化的工具调用方式,方便不同组件之间的集成。

这种实现方式具有良好的扩展性和可维护性,可以方便地集成到各种 AI 应用中,满足不同场景下的文档解析需求。

随着 AI 应用的不断发展,基于 MCP 协议的工具调用将在更多场景中得到应用,为 AI 应用提供更强大的能力扩展。

参考文献