[控场AI]
· 10 分钟阅读· 5,183 字

本地AI工具Moonlight:用开源模型审查竞业条款,让副业不踩坑

本地AI工具Moonlight:用开源模型审查竞业条款,让副业不踩坑

开源工具Moonlight用本地小模型审查雇佣合同,只摘抄不判断,让副业启动前先看清竞业条款。

Moonlight 是开发者 Arpan Patra 构建的开源本地 AI 工具,目标是帮人在启动副业前审查雇佣合同中的竞业限制、知识产权归属和披露义务。它完全本地运行(Node.js + Express + Ollama + gemma3:4b),并用 airgap 脚本拦截 socket、TLS 和 DNS 调用,实测 7 次出站连接全部指向本地回环地址。架构上检索靠关键词打分,模型只负责逐字摘抄,每条引文都要通过原文子串校验,不通过就标注为「已定位,未读取」。14 条标注条款的评估显示 gemma3:4b 分类准确率仅 50%,但摘抄准确率 100%,作者据此删掉模型判断、改用确定性规则打标记。更小的 gemma3:1b 在第一条测试条款中就删掉了「未经公司事先书面同意」,被原文校验拦下。工具明确不提供法律判断。

本地AI工具Moonlight:用开源模型审查竞业条款,让副业不踩坑

很多开发者想开始副业时,第一道坎不是写代码,而是翻出那份早已遗忘的雇佣合同。竞业限制、知识产权归属、披露义务这些条款,可能直接决定你能否合法地出售自己创造的东西。开发者Arpan Patra为此构建了开源工具Moonlight:一个完全在本地运行的AI助手,扫描合同、提取相关条款、标注值得注意的原文措辞。

从「已经启动副业」往前退一步

据DEV社区的Hacktoberfest挑战赛记录,本周近百个参赛项目中,票据分类、发票提取、Etsy文案生成等工具层出不穷,但绝大多数都默认你已经开始了副业。Moonlight反其道而行,瞄准的是启动前的那一步:你的雇佣协议到底允许你做什么?

这个定位有其现实基础。多数有全职工作的人签合同时只是草草翻过,之后再也没有打开过。而合同中的知识产权条款可能写着:员工在受雇期间构思、创造或开发的所有作品,无论是否在工作时间内,无论是否使用公司资源,无论是否与公司当前业务相关,均归公司独家所有。这类措辞一旦被忽略,副业成果的归属就会成为隐患。

Moonlight不做法律判断。它只做三件事:找出可能相关的条款、引用原始措辞、提示值得追问的问题。这种边界是刻意设计的,也直接影响了后续的技术决策。

本地优先:合同不该离开你的电脑

Moonlight最核心的设计选择是完全本地运行。没有托管演示,没有云端API。原因很直接:雇佣合同包含薪资、雇主信息、通知期和具体义务,这些内容一旦上传到第三方服务,就构成隐私风险。

技术栈上,项目使用Node.js、Express和Ollama,跑的是gemma3:4b模型,只有一个应用依赖。启动只需三条命令:拉取模型、启动Ollama服务、npm install && npm start。

为了验证「本地」不只是口号,作者写了src/airgap.js,在应用加载前拦截socket.connect、tls.connect和dns.lookup调用,然后跑一次完整审查,检测是否有非回环地址的连接。实测结果:7次出站连接全部指向127.0.0.1:11434,DNS查询为0。

Moonlight审查合同条款9.2的界面

截图展示了Moonlight读取合同第9.2条的结果:原文句子下方挂着四个标记,分别对应「超出工作时间」「可能覆盖无关工作」「使用个人设备」「所有权转移」,每个标记都能追溯到触发它的具体措辞。

Ollama 是一个开源的本地大模型运行时,它把模型权重、推理引擎和简单的 HTTP API 打包在一起,让开发者用一条命令就能在消费级硬件上跑起 Llama、Gemma、Mistral 等开放权重模型。Moonlight 依赖 Ollama 提供的 /api/generate 接口向 gemma3:4b 发请求,默认监听 127.0.0.1:11434。

之所以强调这一点,是因为「本地」是否成立取决于推理请求究竟发往哪里。Ollama 默认只绑定回环地址,不会把提示词转发到云端;但某些客户端库在配置缺失时会退回到公共端点。Moonlight 的 airgap 脚本正是在验证这一层:它拦截 Node 的 socket、TLS 和 DNS 调用,确认所有连接都落在 127.0.0.1:11434,而不是某个远程推理服务。换句话说,Ollama 提供了本地的推理能力,airgap 提供了对该能力的可验证约束,两者缺一不可。

把模型的工作压到最小

Moonlight的架构可以用一句话概括:检索靠规则,模型只负责摘抄,每一条摘抄都必须回原文验证。模型从头到尾看不到完整合同,也不决定哪些条款重要。

关键词检索先筛掉大部分噪音

合同先按条款编号切分,并用七类关键词打分:外部工作、知识产权转让、排他性、竞业限制、禁止招揽、保密义务、披露义务。一条款必须包含锚点短语才能进入候选,否则像「shall not」「in writing」这样的通用措辞会匹配到大量无关内容。示例中,28条合同被缩减为12条候选。

锚点短语(anchor phrase)在这里指某类条款几乎必然出现的特征性措辞,例如知识产权转让条款中的 "hereby assigns"、竞业限制中的 "shall not engage"、披露义务中的 "shall disclose"。它的作用不是理解语义,而是用极低的成本把明显无关的条款排除掉。

这与通用关键词匹配的区别在于精度取向:普通关键词只要出现单个词就命中,锚点短语要求一个短语整体出现,从而过滤掉 "shall not" "in writing" 这类在法律文本中高频但无区分度的表达。代价是召回率——用非常规措辞改写的条款可能因为缺少标准锚点而被漏掉。Moonlight 把这一取舍公开写在界面上(找不到不等于不存在),让用户知道检索结果的边界在哪里。对本地小模型而言,这种前置过滤还有一层工程意义:进入模型上下文的条款数量从 28 条降到 12 条,直接减少了小模型在长文本上「输出到一半就停止」的概率。

摘抄必须通过原文校验

模型的任务只有一个:从条款中复制出关键句子。系统随后检查返回的引文是否真的出现在原文中——归一化空白后做字符串比对。如果引文不在原文里,这次读取作废,但条款本身仍然展示,并标注为「已定位,未读取」。

这个校验针对的是法律文本中最危险的错误:一段听起来合理的转述,可能悄悄改变了条款含义,而阅读者未必能察觉某个关键限定条件已经消失。

原文校验(verbatim verification)是检索增强生成中「归因」思路的一种严格变体。常见做法是让模型附带引用来源,再由程序检查引用是否真实存在;Moonlight 把这一步推到极致——模型唯一被允许的输出就是从条款中逐字复制句子,任何不在原文中的字符串都视为读取失败。

具体的比对方式是:对模型返回的引文和合同原文分别做空白归一化(把连续空格、换行折叠为单个空格),再做子串匹配。它不处理同义词替换、语序调整或标点差异之外的改写,因此一旦模型「润色」了句子就会被拦下。这个设计针对的是法律文本中最危险的失败模式:一段读起来通顺的转述,可能悄悄丢掉「未经公司事先书面同意」这类限定条件,把「可以申请许可」变成「绝对禁止」。校验的代价是召回率——模型明明读对了、只是多写了一个字,也会被判为未读取;但对这类工具而言,宁可不展示,也不能展示一个改了意思的句子。

一次评估让作者删掉了模型的判断

初版Moonlight让gemma3:4b对每个条款做三分类:是否适用于工作之外,回答yes、unclear或no。测试的8个条款全部返回unclear。改写提示词、加入两个示例后,同一个模型对同样8个条款全部返回yes。

模型没变,合同没变,只有措辞变了。这种输出不是判断,更像硬币落在提示词倾斜的方向。作者没有继续凭直觉调提示词,而是建了一个小型评估集:14条带标签的条款,6条确实延伸到工作之外,5条不能,3条本身就有歧义。

gemma3:4b的结果指向两个相反方向:

  • 分类准确率50%,14条中有13条被判为yes,连年假条款都可能被标记为影响副业收入
  • 原文摘抄准确率100%,14条引文全部逐字匹配,没有转述或编造

于是分类功能被移除,摘抄功能保留。取而代之的是src/flags.js,用模式匹配识别具体属性并引用触发短语:要求书面同意而非直接禁止、雇佣结束后仍然有效、超出工作时间、可能覆盖无关工作、所有权转移等。标记仍然可能出错,但错误是可见的——用户可以检查具体短语并自行判断。

评估还暴露了另一个值得注意的细节:在这组小样本中,没有出现「把有约束力的条款判为无害」的错误(yes→no为0)。对这类工具而言,漏掉限制性条款的代价可能比多让用户看一眼更大。

小样本评估集是这里最值得借鉴的工程方法。作者没有凭直觉反复调提示词,而是手工标注了 14 条条款(6 条确实延伸到工作之外、5 条不能、3 条本身有歧义),把「这个模型靠不靠谱」变成一个可重复测量的数字。样本量虽小,但足以暴露方向性问题:分类准确率 50%、14 条里 13 条判 yes,说明模型输出几乎不随条款内容变化,更像被提示词的整体倾向带着走。

评估集同时承担了回归测试的角色。切换模型(从 4b 到 1b)、修改提示词、调整 flags 规则之后,跑同一组条款就能看出行为是否退化。它也为「移除分类功能」这一决策提供了依据——不是因为分类偶尔出错,而是因为它在两个相反方向上同时失败(对年假条款也判 yes),说明这个能力在 4b 规模上根本不成立。把评估结果和失败样本公开写进文章,则让读者能判断结论的适用范围,而不是只接受一个「效果好」的断言。

小模型暴露了更危险的失败

因为模型在本地运行,切换模型只是改一个环境变量。gemma3:1b体积815MB,对比gemma3:4b的3.3GB,速度快15%,但同一评估集上的表现是:分类准确率43%,原文摘抄准确率0%。

失败分为两种。长条款上,模型输出一个键就停止——请求五个键,只返回{"governs_side_income":"yes"},11个token后以done_reason: stop结束。由于没有引文可验证,Moonlight将其标注为「已定位,未读取」,展示原文,不伪装成成功读取。

短条款上的失败更隐蔽。模型给出了一条听起来通顺的引文:「员工不得从事任何其他雇佣、咨询、贸易或业务,无论是否有报酬。」但合同原文是:「员工不得,未经公司事先书面同意,从事任何其他雇佣、咨询、贸易或业务。」模型删掉了「未经公司事先书面同意」这七个字。

这一删改把「可以申请许可」变成了「绝对禁止」。读到错误版本的人可能直接放弃创业。原文校验拦截了这次输出,因为生成的句子根本不在文档中。作者特别指出,这不是为了论证规则而虚构的例子——用小模型测试的第一条条款就出现了这个错误。

开放权重让这种行为变得可检查:换模型、跑同一组测试、观察失败模式,而不是只依赖模型卡或公开基准。

代码审查还发现了比分词更严重的问题

在要求代理扫描代码库查找bug时,四轮检查没有发现内存泄漏,但暴露了三个更严重的问题。

第一,条款编号正则在标点处是可选的,导致任何以数字开头的换行都被当作新条款。示例中,一条关于工时的条款被切成三段,其中一段71字符的片段低于40字符下限,被整个丢弃。对一个承诺「我找到了条款」的工具来说,展示一条缺失了最终义务的条款是最糟糕的失败。

第二,「未找到某类条款」的提示是在候选数量上限应用之后计算的。上限为4时报告缺失三类,上限为100时只报告缺失一类——界面会对实际存在的条款说「本文档中任何地方都找不到」。

第三,模型返回裸null而非对象时,会抛出TypeError,从readOne逃逸、展开整个review(),12条条款的结果因一次格式错误全部丢失。

三个问题连同另外四个一起修复后,56个测试全部通过,airgap检查仍然确认零外部网络访问。作者还改了一处细节:展示的引文现在直接使用文档自身的字符,而不是模型返回的字符串——因为验证时会归一化大小写,模型用全大写回答时,展示出来会像是原文逐字引用。

它不是律师,也没打算成为律师

Moonlight明确拒绝给出法律判断。如果模型自行生成结论,stripVerdicts()会将其移除。界面上的「0 verdicts given」是应用行为的计数,不是营销口号。

检索基于关键词,用非常规措辞写成的条款可能被遗漏。找不到不等于不存在——这一点在界面上有明确说明。正确用法是:当作第一遍阅读辅助,核对引文,需要法律意见时咨询专业人士。

作者自己用一份真实合同测试过,找出了三条此前略过的条款,其中「需要许可而非直接禁止」的标记最改变他对措辞的理解。仓库中的示例合同是虚构的,仅用于跑通代码。

项目采用MIT协议开源,地址在GitHub的imarpanpatra/moonlight。在4GB显存的RTX 3050上即可运行gemma3:4b。整个项目围绕一个简单的想法:当文档敏感时,模型应该跑在持有文档的那台机器上。

#Moonlight#本地AI合同审查#gemma3#竞业限制条款#开源AI工具#Ollama本地模型#雇佣合同扫描
分享:

相关推荐