编写支持 Markdown/PDF 等通用格式的文档解析插件,为智能体提供本地知识检索能力。
上一节课中,你完成了具备只读安全防护的 SQLite 数据库检索插件:通过 defineTool 注册了模型可调用的 SQL 查询工具,并借助 Harness 的 Trajectory 执行轨迹视图,逐一核对了多轮交互中 Tool Call 的参数、返回值时序与 Token 消耗流水。本节课延续同样的插件化思路,把检索对象从结构化数据库扩展到本地文档知识。直接将超长原始文件灌入模型上下文同样会令 Token 预算迅速耗尽,并容易丢失关键细节。你需要构建一个结构化文档解析与分块工具插件,将本地 Markdown 或长文本文件按段落进行清洗、分块(Chunking)和摘要截断,并通过 defineTool 将结构化提取接口暴露给 DeepSeek 模型。
本地解析文档属于经典的内容读取源(Source)。根据安全评估,未受信任的文档可能包含隐藏的提示注入指令或不可见 Unicode 字符。在编写文档解析插件时,必须对文件路径进行白名单边界限制(防止目录遍历),对文件大小设置硬上限(防止内存耗尽),并对文本进行规范化过滤,避免模型将文档中的指令误识别为系统操作指令。
编写结构化文档解析与分块插件
- 1
在插件源码目录下创建文档分块工具实现文件
src/doc-parser.ts,声明配置 Schema 并注入tools服务依赖。typescriptimport 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
实现安全路径解析与文本清洗分块逻辑,确保文件路径不越过基础目录且文本按段落平滑切分。
typescriptfunction 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
在 apply 函数中使用 ctx.tools.register 注册 parse_document 工具,声明参数校验与渲染格式。
typescriptexport 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
创建测试知识库目录与示例 Markdown 文件,并启动 Harness 验证工具挂载。
bashmkdir -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 扮演了智能体与底层文件系统之间的桥梁:
- 入参隔离与约束:通过
parameters定义了filePath和可选的chunkIndex,由框架自动进行类型校验与注入防护。 - 上下文预算控制:通过
chunkIndex参数支持渐进式(Lazy Fetch)文档读取。模型可以先获取分块总量与摘要,再按需检索特定片段,避免一次性载入大文档撑爆 Context Window。 - 可逆清理与热重载:
ctx.tools.register注册的效果被 Fiber 作用域追踪,当插件配置重载或卸载时,工具定义会自动从上下文注册表中注销,保证无脏状态残留。
遇到问题:模型调用 parse_document 提示执行异常,或返回乱码/拒绝访问错误。
可能原因
- 模型传入了包含 '..' 的相对路径试图读取上层敏感文件,触发了插件的安全路径前缀检测。
- 待解析文件包含非 UTF-8 编码字节流或文件体积超过了 maxFileSizeBytes 设定的阈值。
- 配置中的 baseDir 目录不存在,导致 fs.stat 或 readFile 抛出 ENOENT。
排查步骤
- 检查传入路径是否限制在 baseDir 内,确保相对路径不含跨目录指令。
- 在读取前确认文件编码为 UTF-8,或在配置中适当调整 maxFileSizeBytes 限制。
- 在插件初始化时或读取前添加目录就绪检查,确保 baseDir 路径存在。
自检:为什么在开发文档解析工具时,推荐返回结构化对象并在 output.render 中转换为文本,而不是直接在 execute 中返回拼接好的长字符串?
execute 返回的规范值(Canonical Value)符合 output.schema 定义,便于程序化调用、单元测试与 Trajectory 结构化日志追踪;output.render 则负责将该结构化对象按大模型最易理解的 Markdown/文本格式进行渲染,实现了数据存储与模型视图展示的解耦。