1. 为什么要在 Node.js 里做本地 AI 前置预处理1.1 一个被大多数人忽略的工程现实本地 AI 部署这件事网上教程一抓一大把从 Ollama 到各种推理框架装完之后跑个对话 demo 确实很爽。但真正把它接到办公场景里问题马上就来了一份 80 页的 Word 合同、一份带几十个 Sheet 的 Excel 台账、一份扫描件 PDF你直接丢给本地模型要么上下文爆了要么推理慢到让人想砸键盘要么模型把关键条款和无关的页眉页脚混在一起理解输出一塌糊涂。我在实际项目里踩过这个坑。最开始的想法很朴素——本地模型不是支持长上下文吗那就整篇塞进去。结果一台 32GB 内存的机器处理一份 60 页的文档光预处理加推理就跑了将近四分钟而且模型对文档中段的条款召回率明显下降。后来才想明白本地 AI 的瓶颈从来不是模型本身有多强而是喂给它的东西有没有被整理过。这就是“前置预处理模块”存在的意义。它不负责推理它负责在推理之前把一份乱七八糟的办公文档拆成模型能高效消化的、带结构标记的、大小合适的分片并且用一套硬规则先做一轮筛选和调度把明显不需要模型介入的部分直接处理掉。Node.js 在这个环节里扮演的角色非常合适——它的流式处理能力、异步 IO 模型、以及和办公文档解析库的生态契合度让它成为做这层“预处理中间件”的顺手工具。1.2 这个模块到底解决什么问题说人话就是三件事。第一文档分片。一份办公文档不是一坨纯文本它有标题层级、有表格、有列表、有页眉页脚、有批注。直接按字数切会把一个完整的条款切成两半模型看到上半句看不到下半句。所以分片要按语义边界来切同时控制每个分片的 token 量在一个合理区间。第二L0 自然语言硬规则调度。这个名字听起来唬人其实逻辑很简单在模型介入之前先用一套确定性的规则去判断“这个分片需不需要模型处理”。比如一份文档里 70% 的内容是标准模板条款那这部分用规则匹配就能提取关键信息根本不需要消耗推理资源。只有那些规则覆盖不到的、语义模糊的、需要理解上下文的部分才交给模型。这就是 L0 层的价值——用最低的成本过滤掉大部分工作量。第三调度。多个文档、多个分片、多个处理任务之间怎么排队、怎么分配优先级、怎么在资源有限的情况下保证吞吐这是调度层要解决的问题。Node.js 的事件循环和异步任务队列在这里能发挥很大作用但也有很多坑。1.3 适合谁来参考如果你正在做本地 AI 的办公场景落地比如合同审查、文档摘要、知识库构建、报表分析并且已经过了“跑通 demo”的阶段开始面对真实文档的复杂性那这套思路对你有直接参考价值。如果你只是想让本地模型聊聊天那这个模块对你来说可能过重了。另外这篇文章假设你对 Node.js 的基本异步编程模型有了解不需要精通但至少知道 Promise 和事件循环是怎么回事。2. 整体架构设计与方案选型思路2.1 为什么是 Node.js 而不是 Python这个问题我被问过很多次。本地 AI 生态确实 Python 更丰富推理框架、模型加载、tokenizer 基本都是 Python 优先。但前置预处理这一层Node.js 有几个实打实的优势。办公文档解析这块Node.js 的库成熟度不差。处理 docx 有 mammoth处理 xlsx 有 SheetJS处理 PDF 有 pdf-parse 和 pdfjs-dist。这些库的 API 设计都比较干净流式读取的支持也在逐步完善。更重要的是Node.js 的异步 IO 模型天然适合“读文件、解析、分片、排队、调度”这种 IO 密集加轻计算的场景。你不需要开多线程单线程事件循环就能把 IO 等待时间利用起来。另一个现实考量是部署。很多办公系统的后端本身就是 Node.js 写的把预处理模块做成一个 Node.js 服务或者库直接嵌进去比再起一个 Python 服务做进程间通信要省事得多。少一个进程少一层序列化少一堆运维问题。当然如果你的推理层是 Python 的那预处理模块用 Node.js 写完通过 HTTP 或者消息队列把分片结果传给推理服务这个边界是清晰的不会造成架构上的别扭。2.2 分片策略的核心考量分片这件事最怕的就是“一刀切”。我见过有人直接按 500 字切结果一个表格被切成三段模型看到第一段以为在讲 A 产品看到第三段以为在讲 B 产品实际上它们是同一个表格里的不同列。我的做法是按文档结构树来分片。先把文档解析成一棵结构树标题是节点段落是叶子表格是特殊节点。然后从根节点开始遍历遇到一个节点的内容量超过阈值就往下拆遇到一个节点的内容量很小就和相邻的兄弟节点合并。这样切出来的分片每个都是语义完整的。阈值怎么定这取决于你用的本地模型的上下文窗口。假设模型支持 8K token那单个分片控制在 1K 到 2K token 之间比较合适。留出空间给系统提示词和模型输出。token 和字符的换算中文大概 1 token 对应 1.5 到 2 个汉字英文大概 1 token 对应 4 个字符。这个换算不是精确的但用来做分片大小估算足够了。还有一个细节分片之间要有重叠。相邻分片重叠 10% 到 15% 的内容防止一个完整的语义单元刚好被切在边界上。这个重叠不是简单地把尾部复制到下一个分片的头部而是要在结构树的层面上做保证重叠的部分也是语义完整的。2.3 L0 硬规则层的设计逻辑L0 层的核心思想是用确定性规则替代不确定性推理。办公文档里大量内容是高度结构化的比如“甲方XXX 公司”“合同编号XXX”“签署日期XXXX年XX月XX日”这些用正则就能提取根本不需要模型。但 L0 层不只是做信息提取它还要做分片路由。具体来说每个分片经过 L0 层时会经过一系列规则判断这个分片是否只包含标准模板条款如果是直接标记为“规则可处理”不进入模型队列。这个分片是否包含需要语义理解的内容比如“双方协商一致同意……”这种需要判断意图的表述标记为“需模型处理”。这个分片是否包含敏感信息比如身份证号、银行账号标记为“需脱敏后处理”。这个分片的优先级是什么比如合同中的“违约责任”条款比“通知送达”条款优先级高。这些规则用自然语言来描述但实现上是确定性的代码逻辑。所谓“自然语言硬规则”指的是规则的表达形式接近自然语言方便业务人员理解和维护但执行时是硬编码的判断分支不涉及模型推理。2.4 调度层的设计取舍调度层要解决的核心问题是当有多个文档、每个文档有多个分片、每个分片有不同的优先级和处理方式时怎么安排处理顺序才能既保证高优先级任务及时完成又不让低优先级任务饿死。我试过几种方案。最简单的就是 FIFO 队列谁先来谁先处理。问题是高优先级的合同审查任务会被低优先级的文档摘要任务堵住。后来改成优先级队列高优先级先出队。但又出现了低优先级任务永远排不上的情况。最后的方案是加权公平队列。每个优先级分配一个权重调度器按权重比例从各优先级队列中取任务。比如高优先级权重 5中优先级权重 3低优先级权重 2那每轮调度取 5 个高优先级、3 个中优先级、2 个低优先级。这样既保证了高优先级任务的吞吐又不会让低优先级任务饿死。Node.js 里实现这个用不着什么复杂的调度库。一个数组加一个轮询指针就能搞定。关键是任务的状态管理要清晰待处理、处理中、已完成、失败重试。每个状态转换都要有日志不然出了问题根本查不到。3. 核心细节解析与实操要点3.1 办公文档解析的坑与技巧先说 docx。mammoth 这个库能把 docx 转成 HTML然后你再从 HTML 里提取结构。但 mammoth 有个问题它对表格的处理不够细合并单元格的信息会丢失。如果你的文档里有复杂的合并单元格表格mammoth 转出来的 HTML 会丢掉 rowspan 和 colspan 的语义。我的做法是直接用 docx 的 XML 解析。docx 本质上是个 zip 包里面 word/document.xml 就是正文内容。用 adm-zip 解压然后用 fast-xml-parser 解析 XML能拿到最原始的结构信息包括表格的合并单元格、段落样式、标题级别。代价是代码复杂度上去了但可控性最强。再说 xlsx。SheetJS 是首选但要注意它的流式读取模式。如果你的 Excel 文件很大比如几万行用默认的读取方式会把整个文件加载到内存里。用 stream 模式可以逐行读但 API 会复杂一些。另外Excel 里的公式单元格SheetJS 默认读的是公式的计算结果如果你需要公式本身要设置 cellFormula 选项。PDF 是最麻烦的。pdf-parse 能提取文本但提取出来的文本丢失了版面信息。一个两栏排版的 PDF提取出来可能是左栏第一行、右栏第一行、左栏第二行、右栏第二行这样交错。对于这种情况我的建议是如果 PDF 是扫描件先走 OCR如果是电子版 PDF尽量用 pdfjs-dist 拿到文本的坐标信息然后按坐标做版面重建。这个工作量不小但如果你的场景里 PDF 占比高值得投入。3.2 分片算法的实现细节分片算法的核心是一个递归的结构树遍历。我用一个简化的例子来说明。假设文档结构树是这样的根节点下有章节 A 和章节 B。章节 A 下有段落 A1、A2、A3章节 B 下有段落 B1、B2。每个段落的 token 量已知。算法从根节点开始计算当前节点的总 token 量。如果小于阈值下限就把这个节点整体作为一个分片。如果大于阈值上限就递归处理子节点。如果在上下限之间也作为一个分片。但这里有个问题章节 A 的总 token 量可能刚好在上下限之间但章节 A 内部的段落 A1 和 A2 是紧密相关的A3 是独立的。如果整体作为一个分片A3 的内容会和 A1、A2 混在一起。所以还需要一个语义相关性判断。这个判断可以用简单的规则做比如相邻段落的标题层级相同、或者相邻段落之间有指代词“上述”“该”等就认为它们相关。分片的元数据也很重要。每个分片要记录来源文档 ID、在文档中的位置章节路径、分片类型正文、表格、列表、预估 token 量、L0 规则处理结果。这些元数据在后续调度和模型处理时都会用到。3.3 L0 规则引擎的实现方式L0 规则引擎不需要什么重型框架。一个规则数组每个规则是一个对象包含匹配条件和处理动作。匹配条件可以是正则、关键词列表、或者自定义函数。处理动作可以是标记、提取、路由。比如一条规则长这样{ name: 合同编号提取, match: (chunk) /合同编号[:]\s*([A-Z0-9-])/.test(chunk.text), action: (chunk) { const match chunk.text.match(/合同编号[:]\s*([A-Z0-9-])/); chunk.metadata.contractNo match[1]; chunk.route rule-only; return chunk; } }规则按优先级排序一个分片依次经过所有规则直到某条规则把它标记为“规则可处理”或者“需模型处理”。如果所有规则都没匹配上默认走模型处理。这里有个经验规则不要写得太细。我一开始写了上百条规则想把所有能想到的情况都覆盖。结果维护成本极高而且规则之间经常冲突。后来精简到二十多条核心规则覆盖了 80% 的常见情况剩下的 20% 交给模型整体效果反而更好。3.4 调度器的状态管理调度器的状态管理是容易出 bug 的地方。一个任务从入队到完成中间可能经历待处理、处理中、等待重试、已完成、失败。每个状态转换都要有明确的触发条件和超时机制。我用一个 Map 来存所有任务的状态key 是任务 IDvalue 是任务对象。任务对象里包含状态、优先级、重试次数、创建时间、开始处理时间、完成时间。调度循环每隔一段时间扫描这个 Map找出待处理的任务按加权公平队列的规则取出来执行。超时机制很重要。一个任务如果处理时间超过预期要标记为超时然后决定是重试还是失败。重试次数要有上限不然一个坏任务会一直占用资源。我的设置是最多重试 3 次每次重试间隔翻倍。还有一个细节任务的处理结果要持久化。不能只存在内存里不然进程重启就全丢了。我用 SQLite 存任务状态和结果轻量不需要额外部署数据库服务。Node.js 的 better-sqlite3 库是同步 API在调度循环里用起来很顺手不用担心异步竞争。4. 实操过程与核心环节实现4.1 环境准备与依赖安装Node.js 版本建议用 18 LTS 或更高。我实测下来 18.20.4 和 20.x 都稳定22.x 也没问题。安装步骤不赘述官网下载安装包一路下一步就行。装完之后用node -v确认版本。项目初始化mkdir doc-preprocess cd doc-preprocess npm init -y npm install mammoth adm-zip fast-xml-parser xlsx pdf-parse better-sqlite3如果你要用 pdfjs-dist 做 PDF 版面重建再装一个npm install pdfjs-dist注意 pdfjs-dist 在 Node.js 环境下需要一些额外的配置主要是 canvas 相关的依赖。如果你不需要渲染 PDF 页面只是提取文本和坐标可以跳过 canvas 的安装。4.2 文档解析模块的实现先写一个统一的文档解析入口根据文件扩展名分发到不同的解析器。const path require(path); const parseDocx require(./parsers/docx); const parseXlsx require(./parsers/xlsx); const parsePdf require(./parsers/pdf); async function parseDocument(filePath) { const ext path.extname(filePath).toLowerCase(); switch (ext) { case .docx: return await parseDocx(filePath); case .xlsx: case .xls: return await parseXlsx(filePath); case .pdf: return await parsePdf(filePath); default: throw new Error(不支持的文件格式: ${ext}); } }docx 解析器的核心逻辑const AdmZip require(adm-zip); const { XMLParser } require(fast-xml-parser); async function parseDocx(filePath) { const zip new AdmZip(filePath); const documentXml zip.readAsText(word/document.xml); const parser new XMLParser({ ignoreAttributes: false, attributeNamePrefix: _ }); const doc parser.parse(documentXml); const body doc[w:document][w:body]; const structure []; // 遍历 body 中的段落和表格 const elements Array.isArray(body[w:p]) ? body[w:p] : [body[w:p]]; for (const el of elements) { if (el[w:p]) { structure.push(parseParagraph(el)); } else if (el[w:tbl]) { structure.push(parseTable(el)); } } return { type: docx, structure }; }这里省略了 parseParagraph 和 parseTable 的具体实现核心是从 XML 节点里提取文本内容、样式信息标题级别、表格的行列数据。标题级别从w:pStyle的w:val属性里读比如Heading1对应一级标题。4.3 分片算法的代码实现分片算法的输入是解析后的结构树输出是分片数组。const TOKEN_MIN 500; const TOKEN_MAX 2000; const OVERLAP_RATIO 0.12; function chunkDocument(structure, docId) { const chunks []; let currentChunk { text: , metadata: { docId, path: [] } }; function flushChunk() { if (currentChunk.text.trim().length 0) { currentChunk.tokenCount estimateTokens(currentChunk.text); chunks.push({ ...currentChunk }); } currentChunk { text: , metadata: { docId, path: [] } }; } function traverse(node, path) { const nodeText extractText(node); const nodeTokens estimateTokens(nodeText); if (nodeTokens TOKEN_MIN) { // 小节点尝试合并到当前分片 if (estimateTokens(currentChunk.text) nodeTokens TOKEN_MAX) { flushChunk(); } currentChunk.text nodeText \n; currentChunk.metadata.path path; } else if (nodeTokens TOKEN_MAX) { // 大节点递归拆分 flushChunk(); if (node.children) { for (const child of node.children) { traverse(child, [...path, child.title]); } } else { // 叶子节点但内容过大按句子边界强制切分 const sentences splitBySentence(nodeText); let buffer ; for (const sentence of sentences) { if (estimateTokens(buffer sentence) TOKEN_MAX) { currentChunk.text buffer; flushChunk(); buffer sentence; } else { buffer sentence; } } if (buffer) { currentChunk.text buffer; flushChunk(); } } } else { // 节点大小适中直接作为一个分片 flushChunk(); currentChunk.text nodeText; currentChunk.metadata.path path; flushChunk(); } } traverse(structure, []); flushChunk(); // 添加分片间重叠 return addOverlap(chunks, OVERLAP_RATIO); }estimateTokens 函数用简单的字符数估算中文字符数除以 1.5英文字符数除以 4然后相加。这个估算不精确但用于分片大小控制足够了。addOverlap 函数负责在相邻分片之间添加重叠内容。我的做法是把前一个分片的最后 10% 到 15% 的内容追加到后一个分片的开头同时在后一个分片的元数据里标记重叠部分的来源。4.4 L0 规则引擎的配置与执行规则引擎的配置我放在一个单独的 JSON 文件里方便修改而不需要改代码。{ rules: [ { name: 标准模板条款识别, priority: 1, matchType: keyword, keywords: [双方确认, 兹证明, 特此声明], minMatchCount: 2, action: route, routeTo: rule-only }, { name: 敏感信息检测, priority: 2, matchType: regex, pattern: \\d{17}[\\dXx]|\\d{16}, action: mark, markAs: sensitive }, { name: 高优先级条款, priority: 3, matchType: keyword, keywords: [违约责任, 赔偿, 争议解决, 保密义务], action: priority, priorityLevel: high } ] }执行引擎按优先级顺序遍历规则对每个分片依次应用。匹配成功的规则会修改分片的元数据或路由标记。所有规则执行完毕后分片带着这些标记进入调度队列。这里有个实操心得规则的优先级顺序很重要。敏感信息检测应该放在最前面因为不管这个分片最终走规则还是走模型敏感信息都需要先处理。高优先级条款识别可以放在后面因为它只影响调度顺序不影响处理方式。4.5 加权公平队列调度器的实现调度器的核心是一个按优先级分组的队列加上一个轮询指针。class WeightedFairScheduler { constructor(weights) { this.queues { high: [], medium: [], low: [] }; this.weights weights; // { high: 5, medium: 3, low: 2 } this.currentPriority high; this.currentCount 0; } enqueue(task) { const priority task.priority || medium; this.queues[priority].push(task); } dequeue() { const priorities [high, medium, low]; for (let i 0; i priorities.length; i) { const p priorities[i]; if (this.queues[p].length 0 this.currentCount this.weights[p]) { this.currentCount; return this.queues[p].shift(); } } // 一轮结束重置计数 this.currentCount 0; // 再试一次 for (const p of priorities) { if (this.queues[p].length 0) { this.currentCount 1; return this.queues[p].shift(); } } return null; } }这个实现有个小问题如果高优先级队列一直有任务currentCount 会一直增加直到达到权重上限然后轮到中优先级。但如果高优先级队列空了currentCount 不会重置会导致中优先级的任务被少处理。所以需要在 dequeue 的时候判断如果某个优先级的队列为空就跳过它不消耗它的权重配额。调度循环用 setInterval 或者递归的 setTimeout 来驱动。我倾向于用 setTimeout因为可以动态调整间隔。当队列为空时间隔拉长到 1 秒当队列有任务时间隔缩短到 10 毫秒。4.6 与本地模型推理层的对接预处理模块的输出是分片数组每个分片带有文本内容、元数据、路由标记。对于标记为“rule-only”的分片直接走规则提取逻辑不调用模型。对于标记为“需模型处理”的分片通过 HTTP 请求发给本地推理服务。请求体大概长这样{ prompt: 请分析以下文档分片提取关键信息\n\n{chunkText}, max_tokens: 500, temperature: 0.1, metadata: { docId: xxx, chunkId: yyy, path: [合同, 违约责任] } }temperature 设低一点因为预处理阶段需要的是确定性输出不是创意生成。max_tokens 根据分片大小和任务类型调整一般 300 到 800 之间。推理服务的响应要带上分片 ID方便回填结果。如果推理失败根据错误类型决定是重试还是标记失败。网络超时重试模型返回格式错误也重试但重试次数不超过 3 次。5. 常见问题与排查技巧实录5.1 文档解析阶段的典型问题问题一docx 解析出来中文乱码。这个通常是编码问题。docx 的 XML 默认是 UTF-8但如果你用 readAsText 的时候没有指定编码某些情况下会按系统默认编码解析。解决方法是zip.readAsText(word/document.xml, utf8)显式指定 UTF-8。问题二xlsx 大文件内存溢出。SheetJS 默认把整个工作簿加载到内存。一个 50MB 的 xlsx 文件加载后可能占用几百 MB 内存。解决方法是启用流式读取模式用XLSX.stream.to_json逐行处理。但流式模式不支持公式计算如果你的文件里有公式且需要计算结果得先让 Excel 另存为值。问题三PDF 提取的文本顺序错乱。前面提过多栏排版的 PDF 提取出来是交错的。一个折中方案是先用 pdf-parse 提取文本然后用简单的启发式规则判断是否存在多栏。比如如果连续几行的文本长度都差不多且行尾没有标点可能是多栏。检测到多栏后尝试按 x 坐标分组。这个方案不完美但比不做处理好。5.2 分片阶段的常见坑坑一分片大小不均匀。按结构树分片遇到一个超大的表格可能一个分片就 5000 token而相邻的段落分片只有 200 token。解决方法是给表格单独设置阈值表格分片可以适当大一些因为表格内容的结构化程度高模型处理起来效率更高。但也不能太大超过模型上下文窗口就废了。坑二重叠内容导致重复处理。分片重叠是为了保证语义完整但如果重叠部分刚好包含了一个高优先级条款这个条款会被处理两次。解决方法是在分片的元数据里标记重叠部分的来源分片 ID调度器检测到重复时跳过。或者更简单重叠部分只用于模型理解的上下文不参与信息提取。坑三分片元数据丢失。分片在队列里流转的时候如果不小心把元数据弄丢了后续就没法追溯这个分片来自哪个文档的哪个章节。我的做法是分片对象一旦创建就冻结元数据部分只允许修改处理状态相关的字段。5.3 调度阶段的排查经验症状任务积压队列越来越长。先看是不是某个分片处理超时了占着处理中的位置不放。检查超时机制有没有生效。再看是不是推理服务响应太慢导致调度器一直在等。如果是推理服务的问题考虑加一个并发上限不要一次性把太多分片发给推理服务。症状高优先级任务没有优先处理。检查加权公平队列的权重配置和 currentCount 的重置逻辑。我遇到过一次是因为 currentCount 在队列为空时没有正确重置导致高优先级的权重配额被浪费了。症状进程重启后任务状态丢失。这是没有做持久化。用 SQLite 存任务状态每次状态转换都写库。写入频率高的话可以用 WAL 模式提升性能。better-sqlite3 默认就是 WAL 模式不用额外配置。5.4 常见问题速查表问题现象可能原因排查方法解决方案文档解析乱码编码未指定检查 readAsText 参数显式指定 utf8大文件内存溢出全量加载监控内存曲线启用流式读取分片大小不均表格未单独处理统计分片 token 分布表格单独设阈值任务积压超时未生效检查任务状态时间戳修复超时逻辑高优先级未优先权重计数错误打印调度日志修复 currentCount 重置重启丢状态未持久化检查存储层接入 SQLite推理超时并发过高查看推理服务负载加并发上限敏感信息泄露规则未匹配检查正则覆盖补充规则并前置5.5 几个我踩过的坑和对应的技巧第一个坑是规则引擎的规则冲突。两条规则同时匹配一个分片一条说走规则处理一条说走模型处理结果分片的路由标记被覆盖了。解决方法是给规则加优先级高优先级的规则先执行执行后如果分片已经被标记为终态rule-only 或 model-only后续规则跳过路由修改只做信息补充。第二个坑是调度器的饥饿问题。低优先级任务在加权公平队列里理论上不会被饿死但如果高优先级任务持续不断涌入低优先级任务的等待时间会非常长。我的做法是给每个任务加一个等待时间阈值超过阈值后自动提升优先级。这个阈值设成 30 秒比较合适再长用户就该投诉了。第三个坑是分片重叠导致 token 浪费。12% 的重叠比例听起来不多但如果分片本身只有 500 token重叠部分就是 60 token十个分片就浪费了 600 token 的推理量。后来我把重叠策略改成“只在语义边界处重叠”具体来说只有当前分片的最后一个段落和后一个分片的第一个段落属于同一个章节时才添加重叠。这样重叠比例降到了 5% 左右效果没打折扣。6. 性能调优与扩展方向6.1 预处理阶段的性能瓶颈在哪里实测下来整个预处理流程里最耗时的环节是 PDF 解析尤其是带版面重建的 PDF 解析。一份 100 页的 PDF纯文本提取大概 2 到 3 秒加上版面重建可能到 8 到 10 秒。docx 和 xlsx 的解析通常在 1 秒以内。分片算法的耗时可以忽略不计纯 CPU 计算几毫秒的事。L0 规则引擎的耗时取决于规则数量和正则复杂度二十多条规则跑一遍大概 1 到 2 毫秒。调度器的开销主要在状态持久化上每次写 SQLite 大概 0.5 到 1 毫秒。所以优化重点在 PDF 解析。如果 PDF 占比高可以考虑把 PDF 解析做成独立的 worker 线程不阻塞主线程的事件循环。Node.js 的 worker_threads 模块用起来不复杂把解析逻辑包在一个 worker 文件里主线程通过消息传递拿结果。6.2 调度层的扩展思路当前的调度器是单进程的适合单机部署。如果文档量很大需要多机调度可以考虑把调度器改成一个独立服务预处理节点作为 worker 注册到调度器调度器负责任务分发。这个架构和常见的任务队列系统类似但我们的调度逻辑更定制化用现成的队列系统反而要写很多适配代码。另一个扩展方向是动态权重调整。当前的权重是静态配置的高优先级永远是 5低优先级永远是 2。可以根据队列长度动态调整权重高优先级队列积压时临时提高它的权重低优先级队列快空了降低它的权重。这样能更灵活地应对突发流量。6.3 和本地模型推理的协同优化预处理模块和推理模块之间的边界不是固定的。有些工作放在预处理做更划算有些放在推理做更合适。我的经验是确定性越强的工作越应该放在预处理。比如格式转换、字段提取、去重这些用代码做又快又准。需要语义理解的工作放在推理比如意图判断、情感分析、摘要生成。还有一个协同优化的点是批量推理。如果多个分片来自同一个文档的同一个章节可以把它们合并成一个请求发给推理服务减少请求次数。但合并后的大小不能超过模型上下文窗口。这个合并逻辑可以放在调度器里调度器出队时检查相邻分片是否来自同一章节如果是就合并。6.4 监控与日志的实操建议预处理模块的日志要分级别。ERROR 级别记录解析失败、推理失败、持久化失败。WARN 级别记录规则冲突、任务超时、重试。INFO 级别记录任务入队、出队、完成。DEBUG 级别记录分片详情、规则匹配详情。日志格式用 JSON方便后续用日志分析工具处理。每条日志带上 traceId从文档入队到所有分片处理完成整个链路用同一个 traceId 串起来。出了问题拿 traceId 一搜整条链路清清楚楚。监控指标方面我关注这几个文档解析成功率、分片平均大小、规则命中率、任务平均等待时间、任务平均处理时间、推理服务调用成功率。这些指标用简单的计数器就能统计定期输出到日志或者推送到监控系统。6.5 一个实际案例的完整数据拿一份 45 页的采购合同来跑。文档解析耗时 1.2 秒生成 38 个分片平均每个分片 850 token。L0 规则命中 22 个分片其中 15 个标记为 rule-only7 个标记为需脱敏。剩余 16 个分片进入模型队列。调度器按加权公平队列处理高优先级分片 6 个中优先级 7 个低优先级 3 个。推理服务并发上限设为 4总处理耗时 18 秒。如果全部走模型38 个分片大概需要 45 秒。L0 规则过滤掉了 40% 的分片整体耗时降低了 60%。这个数据不是最优的但能说明 L0 层的价值。规则覆盖得越好模型负担越轻整体吞吐越高。规则和模型之间是一个此消彼长的关系找到平衡点很重要。6.6 后续可以继续深挖的方向一个是分片语义边界的自动学习。当前的分片边界依赖结构树和固定阈值如果能让系统从历史处理记录中学习什么样的分片边界效果最好自动调整阈值和重叠策略那就更省心了。这个可以用简单的统计方法做不需要上机器学习。另一个是规则引擎的可视化配置。当前规则是 JSON 配置业务人员改起来还是有点门槛。如果做一个简单的 Web 界面让业务人员能可视化地添加、修改、测试规则那 L0 层的维护成本会大幅降低。还有一个是多文档关联处理。当前每个文档独立处理但实际场景里一份合同可能关联着附件、补充协议、审批单。这些文档之间的分片需要关联分析。这个复杂度就上去了需要设计跨文档的分片关联机制。我在实际项目里最大的体会是本地 AI 的落地模型只是其中一环前置预处理的质量直接决定了最终效果的上限。一份文档你喂给模型的方式对不对比模型本身强不强更重要。Node.js 在这个环节里不是最耀眼的技术但它足够稳、足够灵活、足够贴近工程现实。把分片做细把规则做准把调度做稳剩下的交给模型效果不会差。