尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用Cordis构建DeepSeek Harness:插件化架构实现PDF文档分析

用Cordis构建DeepSeek Harness:插件化架构实现PDF文档分析 过去两年里调用大模型 API 的门槛已经被压到了极限。拿一个 DeepSeek API Key二三十行代码就能让模型回答你的问题、总结一封邮件、写一段 SQL。但几乎所有团队在迈过这一步之后都会撞上同一堵墙单次调用很容易把模型能力稳定地编排到业务流里却非常难。你需要考虑上下文管理、工具接入、文档解析、错误重试、权限控制还要让这些能力可以被不同团队复用。于是“脚本”开始膨胀成一座难以维护的屎山。这个场景里最值得研究的不是模型本身而是围绕模型构建的那一层“控制框架”。业界把这类框架叫做Harness它的核心工作是把模型能力装进一个受控的、可编排的执行环境。最近热门的 DeepSeek Harness、Codex Harness 等社区项目虽然名字各不相同本质上都在解决同一件事如何让大模型在工程化的轨道上稳定工作。那为什么偏偏要提 Cordis因为 Harness 真正落地时最关键的架构决策就是插件化。而 Cordis 这套基于 Node.js 的插件框架把插件的生命周期、依赖注入、配置校验都标准化了非常适合用来理解“模型工具链该怎么组织”。这篇文章会从 Harness 和插件化架构的概念讲起然后带你在本地用 Cordis 搭一个能处理 PDF 文档的 DeepSeek Harness 最小骨架包括完整的代码、运行方式和排错清单。1. 这篇文章真正要解决的问题围绕 DeepSeek 的讨论大部分集中在模型精度和 API 价格上。但在实际工程中真正影响交付效率的往往是另一层问题模型周围那些“非模型”的东西怎么做。具体来说有三个层面的问题第一层是功能问题。你拿到 API Key 之后可能会遇到这些场景需要让模型读取一份 PDF 合同并抽取关键字段需要让模型在回答时自动检索本地文档需要把模型接入企业微信、钉钉或者自己的 Web 后台。每一个场景都不只是“调用 API”这么简单背后都涉及文档解析、工作流编排、事件分发。第二层是架构问题。如果每次都从零开始写调用脚本三个功能就会产生三套没有复用的代码。今天要接 a 模型明天可能还要接 b 模型今天处理 PDF明天可能处理 Word、Excel。没有插件化架构支撑的时候这些需求会迅速拖垮一个项目。第三层是心态问题。很多开发者以为自己缺的是“更好的模型”但更普遍的情况是手里已经有一个足够好的模型缺的是工程化组织能力。本文想传递的判断是DeepSeek Harness 未必是一个严格意义上的官方产品而是社区对“围绕 DeepSeek 构建的控制框架/开发工具链”这类方案的统称。理解这个思路之后你可以用 Cordis 自己搭一套。这篇文章最适合四类读者正在用 DeepSeek 或类似模型做业务集成的后端开发者。想给团队搭建统一 AI 工具链但不知道从何下手的架构师。对 Cordis 插件框架感兴趣想找一个真实应用场景来练手的 Node.js 开发者。做过“Python 提取 PDF 图片 调用大模型分析”这类单点脚本但想升级成可维护系统的开发者。读完之后你会理解 Harness 和插件化架构的关系并且能跟着示例代码在本地跑通一个“PDF 文档文本提取 DeepSeek 自动分析”的最小系统。2. 理清概念DeepSeek Harness 到底是什么“Harness”原本在工程领域指“控制装置”“线束”到了 AI 工程语境里它被借用过来表示包裹在模型之外负责控制、调度、观测模型行为的那一层框架。一个模型 API 只是“发动机”而 Harness 是整辆车的底盘、变速箱和仪表盘。它解决的是几类很实际的问题配置管理模型名、温度、上下文长度、API Key 放在哪里。任务编排一次分析任务可能要先读文件、再清洗文本、再调用模型、最后格式化输出。插件扩展通过插件接入新的工具如 PDF 解析器、数据库、搜索接口而不改动主流程。生命周期管理插件何时加载、何时销毁、插件之间的依赖如何建立。可观测性调用日志、耗时、Token 消耗、失败重试。开源社区里有不少项目名字里带 Harness比如 OpenAI Codex 的 Codex Harness、社区里的 DeepSeek Harness、Hermes 等。需要说明的是这些名字并不统一有的指官方发布的工作台有的只是开发者自己封装的 CLI 工具。纠结名称没有意义关键是抓住它们共通的工程模式通过一个可扩展的框架把模型能力接入具体业务流程。DeepSeek 本身提供了 API也支持 OpenAI 兼容格式所以社区围绕它做的 Harness 方案往往可以互相借鉴。下面用一个简单的对比说明为什么需要 Harness 这一层维度裸脚本调用 APIHarness 框架配置管理散落在代码和环境变量里统一 schema 校验与配置注入工具集成每个新工具重新写一遍胶水代码通过插件标准化集成依赖关系靠手动调整调用顺序框架负责依赖注入和启动顺序错误处理每个脚本各写各的统一的重试与错误出口可复用性基本无法跨项目复用插件可发布、可共享、可替换实现从裸脚本到 Harness转变的其实是开发视角从“实现一个 prompt 调用”变成“设计一个可持续生长的工具系统”。这并不需要一步到位但你的代码结构应该朝这个方向走。3. 为什么插件化架构是 Harness 的底座如果你只做一个调用脚本不需要插件化。但当你开始构建 Harness 时插件化几乎是必然选择原因有三个。第一个原因是边界清晰。没有插件化时PDF 解析代码、模型调用代码、日志代码会相互交叠最后形成一个无人敢动的“核心模块”。插件化之后每个能力被封装成独立模块接口明确新人接手时不需要理解全部细节。第二个原因是交付节奏。企业里对 AI 能力的需求是持续变化的。今天要支持 PDF明天要支持 Word后天可能要把模型结果写入指定数据库。如果主流程是插件化的新增一个能力只需要写一个新插件主流程完全不用改动。第三个原因是团队协作。插件化架构允许不同小组负责不同插件。A 组负责 DeepSeek 服务封装B 组负责文档解析C 组负责业务流程编排只要插件之间的 service 接口约定好开发可以并行推进。在 Node.js 生态里Cordis 就是为这种架构而生的插件框架。它由 Koishi 社区积累而来核心思路是一切能力都是插件插件通过 Context 交互插件之间通过 Service 通信。你不需要重复设计插件加载器、依赖注入、生命周期钩子这些底层设施Cordis 已经把它们标准化了。使用 Cordis 构建 DeepSeek Harness 的另一个好处是易于测试和替换。框架层面提供了一套容器管理能力默认配置可以是本地模型也可以在测试环境中替换成 mock 服务而业务插件本身不需要感知这些变化。4. Cordis 核心模型与插件生命周期在动手写代码之前先理解 Cordis 的几个核心概念。Context上下文插件运行时的容器。每个插件在启动时会收到一个ctx对象通过它注册事件、访问服务、加载子插件。在 Cordis 应用里最外层的Context实例就是整个应用的容器。Plugin插件一个带有apply(ctx, config)函数的模块。当插件被加载时Cordis 会调用这个函数并把配置对象传进去。插件可以注册事件监听器、调用其他服务、加载更多子插件。Service服务跨插件共享的能力。比如你写了一个DeepSeekService其他插件就可以通过ctx.deepseek来调用它而不需要自己保存 API Key 或重复实现请求逻辑。生命周期插件从注册到销毁会经历初始化、启动、停止等阶段。Cordis 会保证依赖插件的顺序如果你声明的inject数组里列出了deepseekCordis 会确保在加载你的插件之前deepseek服务已经可用。下面是一个最小 Cordis 插件定义。这个插件没有实际业务逻辑只用来演示插件的基本结构// src/plugins/minimal.ts import { Context, Schema } from cordis export const name minimal export interface Config { message: string } export const Config: SchemaConfig Schema.object({ message: Schema.string().default(hello cordis), }) export function apply(ctx: Context, config: Config) { ctx.on(ready, () { console.log([minimal] plugin loaded: ${config.message}) }) // 插件销毁时清理资源 ctx.on(dispose, () { console.log([minimal] plugin disposed) }) }这个插件做了三件事定义名字、声明配置结构、注册事件。ctx.on(ready)表示应用准备好后执行ctx.on(dispose)是插件卸载时的清理钩子。在 Cordis 中ctx.on和ctx.emit是框架内置的事件机制这部分 API 非常稳定可以放心使用。再来看一个 Service 的定义。Service 的意义是让插件之间通过接口通信而不是互相依赖具体实现// src/plugins/hello-service.ts import { Context, Service } from cordis class HelloService extends Service { constructor(ctx: Context) { super(ctx, hello) } sayHello(name: string) { return Hello, ${name}! } } export const name hello-service export function apply(ctx: Context) { ctx.provide(hello, HelloService) }其他插件只要声明inject: [hello]就能在运行时通过ctx.hello拿到这个服务实例。这种“面向接口编程”的做法正是插件化架构的基石。5. 搭建 DeepSeek Harness环境与项目骨架现在开始搭建一个真实的 DeepSeek Harness 最小骨架。它会包含三个部分DeepSeek 服务插件负责调用 DeepSeek API支持重试和错误处理。PDF 提取插件调用 Python 脚本解析 PDF 文本和图片。分析编排插件读取 PDF 内容通过 DeepSeek 生成分析结果。5.1 环境准备建议准备以下环境Node.js 18 以上示例代码会用到原生fetchNode 18 起无需额外引入请求库。pnpm用于初始化项目和安装依赖这不是硬性要求npm/yarn 也可以。Python 3.8 以上用于 PDF 解析需要安装PyMuPDF推荐或类似库。一个 DeepSeek API Key在对应平台注册后获取建议先确认账户额度充足。node -v pnpm -v python3 --version pip3 install PyMuPDF版本细节以你本机环境为准本文重点演示通用思路。5.2 初始化项目mkdir deepseek-harness cd deepseek-harness pnpm init pnpm add cordis typescript tsxtsx用来在开发环境直接运行 TypeScript 文件。如果你的项目里已经配置了其他 TypeScript 执行方案可以跳过。5.3 目录结构建议按下面的结构组织代码deepseek-harness ├── package.json ├── tsconfig.json ├── .env ├── scripts │ └── extract_pdf.py ├── src │ ├── index.ts │ └── plugins │ ├── deepseek.ts │ ├── pdf-extractor.ts │ └── pdf-analyzer.ts └── docs └── sample.pdf这个目录结构的核心思想是脚本层scripts负责和外部工具打交道插件层src/plugins负责业务编排入口文件src/index.ts负责组装。5.4 基础配置创建tsconfig.json{ compilerOptions: { target: ES2022, module: CommonJS, moduleResolution: Node, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: dist }, include: [src] }创建.env文件注意生产环境不应把真实 Key 提交到仓库DEEPSEEK_API_KEYsk-your-key DEEPSEEK_MODELdeepseek-chat DEEPSEEK_BASE_URLhttps://api.deepseek.compackage.json 里的 scripts 可以这样配置{ scripts: { dev: tsx src/index.ts, build: tsc, start: node dist/index.js } }6. 实现核心插件代码这一章是文章的核心会给出四个文件的完整实现。代码以 Cordis 3.x 的 API 为参考如果你的版本有差异请以官方文档为准。6.1 DeepSeek 服务插件src/plugins/deepseek.tsimport { Context, Schema, Service } from cordis export const name deepseek export interface Config { apiKey: string model: string baseUrl: string maxRetries: number timeout: number } export const Config: SchemaConfig Schema.object({ apiKey: Schema.string().required().description(DeepSeek API Key), model: Schema.string().default(deepseek-chat).description(模型名称), baseUrl: Schema.string().default(https://api.deepseek.com).description(接口地址), maxRetries: Schema.number().default(3).description(最大重试次数), timeout: Schema.number().default(30000).description(请求超时时间毫秒), }) class DeepSeekService extends Service { constructor(ctx: Context, private config: Config) { super(ctx, deepseek) } async chat(messages: Array{ role: string; content: string }) { let lastError: Error | null null for (let attempt 0; attempt this.config.maxRetries; attempt) { try { const controller new AbortController() const timer setTimeout(() controller.abort(), this.config.timeout) const res await fetch(${this.config.baseUrl}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.config.apiKey}, }, body: JSON.stringify({ model: this.config.model, messages, }), signal: controller.signal, }) clearTimeout(timer) if (!res.ok) { throw new Error(DeepSeek API error: ${res.status} ${await res.text()}) } const data await res.json() return data?.choices?.[0]?.message?.content ?? } catch (err) { lastError err instanceof Error ? err : new Error(String(err)) console.warn([deepseek] attempt ${attempt 1} failed: ${lastError.message}) await new Promise((resolve) setTimeout(resolve, 300 * (attempt 1))) } } throw lastError } } export function apply(ctx: Context, config: Config) { ctx.provide(deepseek, DeepSeekService, config) }这个文件的关键点在于Schema负责配置校验apiKey是必填项。DeepSeekService继承Service封装了纯调用逻辑。调用格式参考 OpenAI 兼容接口DeepSeek 也支持这个格式所以代码里的baseUrl和model都是可配置的。重试逻辑里使用了简单的指数退避避免一次 API 抖动导致整个任务失败。Cordis 版本不同时ctx.provide的签名可能有差异建议以文档为准。如果你不想依赖这个 API也可以把DeepSeekService直接作为工具类导出然后由其他插件实例化使用。6.2 PDF 提取脚本scripts/extract_pdf.pyimport sys import json import fitz # PyMuPDF def extract_pdf(path: str) - dict: doc fitz.open(path) texts [] images [] for page_index in range(len(doc)): page doc[page_index] texts.append(page.get_text()) for img_index, img in enumerate(page.get_images(fullTrue)): xref img[0] pix fitz.Pixmap(doc, xref) if pix.n - pix.alpha 4: pix fitz.Pixmap(pix, 0) output_path f{path}_page{page_index 1}_img{img_index 1}.png pix.save(output_path) images.append(output_path) return { text: \n.join(texts).strip(), images: images, } if __name__ __main__: result extract_pdf(sys.argv[1]) print(json.dumps(result, ensure_asciiFalse))这个脚本的作用是把 PDF 转为结构化 JSON 输出。文本部分通过page.get_text()获取图片部分通过page.get_images()枚举并导出为 PNG 文件。如果你的 PDF 是扫描件文本可能为空那就需要先用 OCR 处理这里不展开。6.3 PDF 提取器插件src/plugins/pdf-extractor.tsimport { execFile } from node:child_process import { promisify } from node:util import { Context, Schema } from cordis export const name pdf-extractor export interface Config { pythonPath: string scriptPath: string } export const Config: SchemaConfig Schema.object({ pythonPath: Schema.string().default(python3).description(Python 可执行文件路径), scriptPath: Schema.string().default(./scripts/extract_pdf.py).description(PDF 提取脚本路径), }) const run promisify(execFile) export interface ExtractResult { text: string images: string[] } export function apply(ctx: Context, config: Config) { ctx.provide(pdfExtractor, { extract: async (pdfPath: string): PromiseExtractResult { const { stdout } await run(config.pythonPath, [ config.scriptPath, pdfPath, ]) return JSON.parse(stdout) }, }) }为什么 PDF 提取要单独做成一个插件因为 PDF 解析不是一个稳定可靠的操作它依赖于外部 Python 环境而且不同 PDF 的编码格式差异很大。把它隔离在独立插件里后续可以单独替换成其他实现比如基于 WASM 的纯 Node 方案或者调用云服务不会影响上层业务。6.4 PDF 分析编排插件src/plugins/pdf-analyzer.tsimport { Context, Schema } from cordis export const name pdf-analyzer export const inject [deepseek, pdfExtractor] export interface Config { prompt: string } export const Config: SchemaConfig Schema.object({ prompt: Schema.string().default(请总结这份 PDF 文档的核心内容输出 Markdown 格式。), }) export function apply(ctx: Context, config: Config) { ctx.on(pdf/analyze, async (pdfPath: string) { // 1. 提取 PDF 文本 const { text, images } await ctx.pdfExtractor.extract(pdfPath) if (!text) { console.warn([pdf-analyzer] PDF 中没有可用的文本可能是扫描件。) } // 2. 调用 DeepSeek 分析 const answer await ctx.deepseek.chat([ { role: system, content: 你是一个文档分析助手擅长从文档中提取关键结论。, }, { role: user, content: ${config.prompt}\n\n文档内容如下\n${text.slice(0, 12000)}, }, ]) // 3. 输出结果 console.log(分析结果\n) console.log(answer) }) }在插件声明里inject数组让 Cordis 知道当前插件依赖deepseek和pdfExtractor两个服务框架会确保它们在当前插件启动前可用。为了避免一次请求携带过多 token这里对文本做了截断处理text.slice(0, 12000)。实际项目中你需要根据使用的模型上下文长度来调整这个字节数也可以通过摘要、分块等策略处理超长文档。6.5 主入口src/index.tsimport { Context } from cordis import * as deepseek from ./plugins/deepseek import * as pdfExtractor from ./plugins/pdf-extractor import * as pdfAnalyzer from ./plugins/pdf-analyzer async function main() { const app new Context() app.plugin(deepseek, { apiKey: process.env.DEEPSEEK_API_KEY, model: process.env.DEEPSEEK_MODEL || deepseek-chat, baseUrl: process.env.DEEPSEEK_BASE_URL || https://api.deepseek.com, }) app.plugin(pdfExtractor, {}) app.plugin(pdfAnalyzer, { prompt: 请从 PDF 中提取最重要的三个结论并用中文列表输出。, }) const filePath process.argv[2] || ./docs/sample.pdf // 触发 PDF 分析事件 app.emit(pdf/analyze, filePath) // 保持进程存活等待异步任务完成 await new Promise((resolve) setTimeout(resolve, 30000)) } main().catch((err) { console.error(err) process.exit(1) })这段代码的核心是组装。主入口不关心 PDF 怎么解析、API 怎么调用只负责注册插件并触发事件。后续如果要增加“Word 支持”只需要新增一个word-extractor插件然后把pdf-analyzer中依赖的接口抽象好即可。7. 运行与效果验证把一份测试 PDF 放到docs目录下然后执行pnpm dev ./docs/sample.pdf如果你没有处理.env文件用下面方式先加载环境变量export DEEPSEEK_API_KEYsk-your-key pnpm dev ./docs/sample.pdf如果代码正确、依赖完整控制台会输出类似这样的日志[deepseek] plugin loaded [pdf-analyzer] PDF 中没有可用的文本可能是扫描件。 分析结果 1. 文档主要描述了项目背景和市场痛点。 2. 方案的核心创新在于使用插件化架构组织 AI 工具链。 3. 实施路径分为三个阶段验证、试点、推广。如果看到这行输出说明整个链路已经跑通PDF 被成功读取文本被传给了 DeepSeek API模型的回答被打印了出来。如果运行失败第一步先看错误信息里有没有DeepSeek API error字样。如果有说明代码本身没问题问题出在 API Key、网络连通性或账户额度上。如果错误来自 Python 脚本比如ModuleNotFoundError: No module named fitz说明 PyMuPDF 没有安装成功。8. 常见问题与排查思路问题现象可能原因排查方式解决方案启动时提示apiKey is required环境变量未加载或配置传入错误确认.env文件是否生效打印process.env.DEEPSEEK_API_KEY显式传参或使用 dotenv 加载环境变量调用 API 时报401API Key 无效或已过期检查控制台里的 Key 是否赋值完整重新生成 Key确认账户余额请求超时网络不通或模型响应慢查看timeout配置尝试请求官方示例增大 timeout或检查代理设置PDF 提取结果为空文件是扫描件没有文本层用 PDF 阅读器查看是否可复制文字接入 OCR 工具或使用云 OCR 服务ModuleNotFoundError: No module named fitzPython 缺少 PyMuPDF运行pip3 install PyMuPDF安装依赖后重试pnpm dev卡住没有输出依赖安装不完整或异步事件未触发查看是否输出了plugin loaded日志检查app.emit是否被正确调用ctx.pdfExtractor在编译时类型报错Service 类型未声明到 Context 上查看 Cordis 的 Service 类型扩展方式在declare module cordis中补充接口声明关于社区里一些 DeepSeek Harness 相关脚本在安装 Web 界面时卡住的问题比如网络热词中提到的“卡在 pnpm dsh web”大概率是前端构建依赖下载失败或 pnpm 版本不兼容。排查思路是先清空.pnpm-store缓存再检查 pnpm 版本最后确认网络能访问 npm registry。不要盲目重装。9. 工程化最佳实践示例代码可以用作原型验证但离生产环境还有距离。以下是几个最值得注意的点。9.1 密钥管理绝对不要把 API Key 写进代码仓库。示例里直接读取process.env.DEEPSEEK_API_KEY这是正确方向。生产环境建议使用专门的密钥管理服务并把 Key 的权限限制为“仅能调用模型 API”不要使用超管范围。9.2 错误处理与重试模型 API 不是 100% 可靠的。网络抖动、限流、模型加载过慢都会导致失败。生产环境必须在服务层统一处理重试、退避和熔断而不是在业务代码里到处写 try/catch。9.3 日志与可观测性每次模型调用都应该记录请求 ID、模型名、输入输出 token 数、耗时、成功/失败标记。这样当用户反馈“回答质量变差”或“耗时变长”时你才能快速定位是模型参数变化还是调用链路出了问题。9.4 插件粒度插件不是越小越好。一个切得太碎的插件系统会和没有插件一样令人痛苦。合理的粒度是每个插件负责一种“能力类型”比如“文档解析能力”“模型调用能力”“结果输出能力”。不要把一个 PDF 解析拆成五个插件。9.5 异步编排示例中通过setTimeout保住进程这只是演示。真实项目应该使用队列或事件驱动机制PDF 上传后触发消息后台 Worker 执行提取和分析最后把结果写回数据库或通知用户。9.6 内容截断与上下文管理大模型输入长度有限制。处理长文档时必须做分块或摘要。分块策略可以是按字符数切割也可以按段落语义切割后者效果更好但实现成本更高。示例中的text.slice(0, 12000)只是兜底方案不适合直接搬到线上。9.7 安全边界如果你开放了文件上传解析能力要注意 PDF 本身可能携带恶意脚本或超链接。拆分成独立进程执行解析脚本限制文件大小并对上传文件做类型校验都是必须的动作。10. 总结与后续学习方向这篇文章想表达的核心观点是DeepSeek 这类模型的价值不只在模型本身而在于你能否为它构建一个可控、可扩展、可观测的 Harness 环境。插件化架构是这套环境的底座Cordis 把它做到了 Node.js 生态里可直接落地的程度。通过文中的示例你已经跑通了一条完整的链路用 Cordis 插件框架组织插件、用 Python 脚本解析 PDF、用 DeepSeek API 完成内容分析。这个最小骨架可以直接作为起点扩展出更多能力。接下来可以继续深入的方向包括把事件触发改成 HTTP 接口让文档分析能力以服务形式对外暴露。引入消息队列让长文档分析任务异步化执行。把 DeepSeek 服务插件的接口抽象成通用模型网关同时支持接其他兼容 OpenAI 格式的模型。研究 Cordis 内置的配置热更新、插件热加载能力提升系统运维体验。如果你的团队正准备把大模型接入业务建议不要从零写一遍调用脚本而是先花半小时梳理出“模型能力、文档能力、编排能力”的边界再决定要不要用插件化架构来承载它。架构选型的成本远低于后期重构的成本。
返回列表