开发结构化文档解析与分块工具插件

约 35 分钟

编写支持 Markdown/PDF 等通用格式的文档解析插件,为智能体提供本地知识检索能力。

上一节课中,你完成了具备只读安全防护的 SQLite 数据库检索插件:通过 defineTool 注册了模型可调用的 SQL 查询工具,并借助 Harness 的 Trajectory 执行轨迹视图,逐一核对了多轮交互中 Tool Call 的参数、返回值时序与 Token 消耗流水。本节课延续同样的插件化思路,把检索对象从结构化数据库扩展到本地文档知识。直接将超长原始文件灌入模型上下文同样会令 Token 预算迅速耗尽,并容易丢失关键细节。你需要构建一个结构化文档解析与分块工具插件,将本地 Markdown 或长文本文件按段落进行清洗、分块(Chunking)和摘要截断,并通过 defineTool 将结构化提取接口暴露给 DeepSeek 模型。

编写结构化文档解析与分块插件

  1. 1

    在插件源码目录下创建文档分块工具实现文件 src/doc-parser.ts,声明配置 Schema 并注入 tools 服务依赖。

    typescript
    import type { Context } from '@deepseek-ai/cordis'
    import { defineTool } from '@deepseek-ai/dsh-tools'
    import { Schema } from 'schemastery'
    import * as fs from 'node:fs/promises'
    import * as path from 'node:path'
    
    export const name = 'doc-parser'
    export const inject = ['tools']
    
    export interface Config {
      baseDir: string
      maxChunkSize: number
      maxFileSizeBytes: number
    }
    
    export const Config: Schema<Config> = Schema.object({
      baseDir: Schema.string().default('./docs').description('允许解析的安全工作目录'),
      maxChunkSize: Schema.number().default(1000).description('单分块最大字符数'),
      maxFileSizeBytes: Schema.number().default(1048576).description('允许读取的最大单文件字节数 (默认 1MB)'),
    })
  2. 2

    实现安全路径解析与文本清洗分块逻辑,确保文件路径不越过基础目录且文本按段落平滑切分。

    typescript
    function chunkMarkdown(content: string, chunkSize: number): string[] {
      const normalized = content.replace(/\r\n/g, '\n')
      const paragraphs = normalized.split(/\n\s*\n/)
      const chunks: string[] = []
      let currentChunk = ''
    
      for (const para of paragraphs) {
        const trimmed = para.trim()
        if (!trimmed) continue
        if ((currentChunk + '\n\n' + trimmed).length > chunkSize && currentChunk.length > 0) {
          chunks.push(currentChunk.trim())
          currentChunk = trimmed
        } else {
          currentChunk = currentChunk ? `${currentChunk}\n\n${trimmed}` : trimmed
        }
      }
      if (currentChunk.trim()) {
        chunks.push(currentChunk.trim())
      }
      return chunks
    }
  3. 3

    在 apply 函数中使用 ctx.tools.register 注册 parse_document 工具,声明参数校验与渲染格式。

    typescript
    export function apply(ctx: Context, config: Config) {
      ctx.tools.register(
        defineTool({
          name: 'parse_document',
          description: '读取并解析指定相对路径的 Markdown 文档,返回分块清洗后的段落列表。',
          parameters: {
            filePath: {
              type: 'string',
              required: true,
              description: '相对于 baseDir 的文档相对路径,如 readme.md',
            },
            chunkIndex: {
              type: 'number',
              required: false,
              description: '可选:指定读取的具体分块索引 (从 0 开始);若未指定则返回全部分块概览',
            },
          },
          output: {
            schema: {
              type: 'object',
              properties: {
                filePath: { type: 'string' },
                totalChunks: { type: 'number' },
                chunks: { type: 'array', items: { type: 'string' } },
              },
            },
            render(_args, value) {
              const header = `### 文档解析结果: ${value.filePath} (共 ${value.totalChunks} 块)\n`
              const body = value.chunks
                .map((c: string, i: number) => `#### [Chunk ${i + 1}/${value.totalChunks}]\n${c}`)
                .join('\n\n')
              return [{ type: 'text', text: header + body }]
            },
          },
          async execute(args) {
            const safeBase = path.resolve(process.cwd(), config.baseDir)
            const targetPath = path.resolve(safeBase, args.filePath)
    
            if (!targetPath.startsWith(safeBase)) {
              throw new Error(`安全限制:禁止访问目录之外的文件路径: ${args.filePath}`)
            }
    
            const stat = await fs.stat(targetPath)
            if (stat.size > config.maxFileSizeBytes) {
              throw new Error(`文件大小 (${stat.size} 字节) 超过允许上限 (${config.maxFileSizeBytes} 字节)`)
            }
    
            const rawContent = await fs.readFile(targetPath, 'utf-8')
            const allChunks = chunkMarkdown(rawContent, config.maxChunkSize)
    
            if (typeof args.chunkIndex === 'number') {
              const idx = Math.floor(args.chunkIndex)
              if (idx < 0 || idx >= allChunks.length) {
                throw new Error(`请求的分块索引 ${idx} 超出范围 (共 ${allChunks.length} 块)`)
              }
              return {
                filePath: args.filePath,
                totalChunks: allChunks.length,
                chunks: [allChunks[idx]],
              }
            }
    
            return {
              filePath: args.filePath,
              totalChunks: allChunks.length,
              chunks: allChunks,
            }
          },
        })
      )
    }
  4. 4

    创建测试知识库目录与示例 Markdown 文件,并启动 Harness 验证工具挂载。

    bash
    mkdir -p docs
    cat << 'EOF' > docs/sample.md
    # 知识库示例
    
    DeepSeek Harness 提供了强大的插件化扩展能力,一切皆为插件。
    
    ## 上下文控制
    文档分块能显著降低 Token 消耗,使智能体精准定位目标知识。
    EOF
    pnpm dsh web --patch ./scratch-plugin/cordis.yml

你应该看到

Harness Web 界面正常加载,提问解析 docs/sample.md 时,模型能够自动调用 parse_document 并输出分块格式化文本。

在上述实现中,defineTool 扮演了智能体与底层文件系统之间的桥梁:

  1. 入参隔离与约束:通过 parameters 定义了 filePath 和可选的 chunkIndex,由框架自动进行类型校验与注入防护。
  2. 上下文预算控制:通过 chunkIndex 参数支持渐进式(Lazy Fetch)文档读取。模型可以先获取分块总量与摘要,再按需检索特定片段,避免一次性载入大文档撑爆 Context Window。
  3. 可逆清理与热重载:ctx.tools.register 注册的效果被 Fiber 作用域追踪,当插件配置重载或卸载时,工具定义会自动从上下文注册表中注销,保证无脏状态残留。

遇到问题:模型调用 parse_document 提示执行异常,或返回乱码/拒绝访问错误。

可能原因

  • 模型传入了包含 '..' 的相对路径试图读取上层敏感文件,触发了插件的安全路径前缀检测。
  • 待解析文件包含非 UTF-8 编码字节流或文件体积超过了 maxFileSizeBytes 设定的阈值。
  • 配置中的 baseDir 目录不存在,导致 fs.stat 或 readFile 抛出 ENOENT。

排查步骤

  1. 检查传入路径是否限制在 baseDir 内,确保相对路径不含跨目录指令。
  2. 在读取前确认文件编码为 UTF-8,或在配置中适当调整 maxFileSizeBytes 限制。
  3. 在插件初始化时或读取前添加目录就绪检查,确保 baseDir 路径存在。
自检:为什么在开发文档解析工具时,推荐返回结构化对象并在 output.render 中转换为文本,而不是直接在 execute 中返回拼接好的长字符串?

execute 返回的规范值(Canonical Value)符合 output.schema 定义,便于程序化调用、单元测试与 Trajectory 结构化日志追踪;output.render 则负责将该结构化对象按大模型最易理解的 Markdown/文本格式进行渲染,实现了数据存储与模型视图展示的解耦。