资讯动态

零LLM合规引擎:确定性规则与CI拦截的工程实践

发布时间:2026/9/26 8:51:48 来源:尧图企业网站定制
1. 为什么我们给合规引擎上了零LLM的紧箍咒第一次听到合规引擎里一行LLM调用都没有这个说法很多同行的第一反应是都什么年代了还不用大模型但如果你真的做过欧盟AI法案EU AI Act相关的合规产品就会明白这个决定背后不是保守而是被现实反复教育出来的。我们这套引擎的核心任务很明确给定一个AI系统的基本信息用途、数据模态、部署场景、是否面向公众、是否用于生物识别、是否影响关键基础设施等输出它在EU AI Act下的风险等级判定、对应的义务清单、需要留存的文档、需要走的符合性评估路径以及一份可审计的判定依据。听起来像是个规则匹配问题对吧它本质上就是。而规则匹配这件事用LLM去做是给自己埋雷。原因有三层。第一层是可复现性。合规判定这种东西同一个输入今天判成高风险明天判成有限风险客户会直接质疑你的专业性监管方更不会接受一个模型今天心情不好的结论。LLM有温度参数、有版本漂移、有上下文长度导致的截断这些在聊天场景里无所谓在合规场景里是致命的。第二层是可审计性。EU AI Act明确要求高风险系统保留技术文档和日志如果判定逻辑藏在一个几十亿参数的权重里你没法向审计员解释为什么这条规则被触发。第三层是成本与延迟。合规检查往往要批量跑一个企业可能有几百个AI系统要过一遍每次调用大模型token账单和响应延迟都不可接受。所以我们的选择是把合规知识全部编码成确定性的规则、决策表和有限状态机LLM一个都不许进。更进一步我们用CI把这个约束焊死了——任何人往代码库里加LLM调用流水线直接红PR合不进去。下面我把这套东西从设计到落地完整拆一遍包括我们踩过的坑和那些看起来多余但救过命的细节。2. 合规引擎的整体架构与零LLM约束的设计逻辑2.1 引擎到底在算什么从AI系统描述到义务清单先把业务讲清楚不然技术选型都是空中楼阁。我们的输入是一个结构化的AI系统画像大概长这样系统名称、提供者角色provider/deployer/importer/distributor、预期用途、是否属于EU AI Act附件一列举的受监管产品、是否属于附件三的高风险类别比如生物识别、关键基础设施、教育评分、招聘筛选等、是否与自然人交互、是否生成合成内容、是否用于情绪识别。输出则是一棵判定树的结果风险等级不可接受/高/有限/最小、适用的条款编号、必须满足的义务条目、建议的符合性评估方式、以及每条结论对应的规则ID和法条引用。这套东西的本质是把法律文本翻译成可执行的判定逻辑。EU AI Act的条文有大量如果……则……除非……的结构还有不少交叉引用和例外条款。我们做的事情就是把这些结构拆成一条条独立的、可测试的规则每条规则有唯一ID、有前置条件、有结论、有法条出处。规则之间通过优先级和互斥关系组织最终形成一个确定性的判定流程。提示合规规则最怕的不是漏而是看起来对但边界错。所以我们给每条规则都配了正例和反例测试用例边界条件单独列出来这部分后面会细讲。2.2 为什么坚决不用LLM三个绕不过去的硬约束我在前面提了可复现、可审计、成本三点这里展开说因为这是整个架构的立足点。可复现性是合规产品的生命线。你想象一下客户拿着我们上个月出的报告去应对监管问询监管方问为什么这个系统被判为高风险客户回头找我们我们总不能说因为当时模型的temperature是0.7。确定性规则的好处是同样的输入无论跑多少次、在哪台机器上跑、隔多久跑结果完全一致。这一点在跨版本升级时尤其重要——我们可以明确告诉客户v2.3到v2.4只改了附件三第5条的判定阈值其他结论不变。可审计性直接对应法规要求。EU AI Act对高风险系统要求保留技术文档其中就包括风险管理系统的描述。如果判定逻辑是黑盒你没法写这份文档。而规则引擎的每一条结论都能追溯到具体的规则ID和法条编号审计员可以逐条核对。我们甚至把判定过程输出成一棵可展开的决策树客户能清楚看到因为满足了A、B、C三个条件所以触发了规则R-1042。成本与延迟是最现实的。批量合规扫描场景下一个中型企业客户可能有几百到上千个AI系统需要评估。如果每次判定都调LLM按每个系统平均2000 token算一千个系统就是两百万token成本不说光是排队等待就够呛。规则引擎跑一千个系统单机几秒钟的事。2.3 规则引擎的选型为什么是决策表加有限状态机确定了不用LLM接下来是选什么确定性方案。我们评估过三种纯代码if-else、规则引擎如Drools那类、决策表加状态机。纯if-else的问题是规则一多就变成意大利面条改一条规则要动一大片代码测试覆盖也难做。规则引擎功能强大但太重引入一套DSL和运行时团队学习成本高而且很多规则引擎的推理顺序不透明反而不利于审计。最后我们选了决策表加有限状态机的组合决策表负责条件到结论的映射状态机负责多步骤判定流程的编排。决策表的好处是它天然就是一张二维表行是规则列是条件单元格是条件取值最后一列是结论。这种结构业务人员也能看懂法务同事可以直接审阅规则表不需要读代码。状态机则用来处理那些有先后依赖的判定比如先判断是否属于不可接受风险如果是直接终止否则判断是否属于附件三高风险类别再判断是否有例外条款适用。每个状态是一个判定阶段转移条件是决策表的输出。2.4 CI拦截的设计让零LLM成为不可违反的约束光靠代码规范约束不住人。今天你写个注释说这里不要用LLM明天新来的同事为了赶进度就偷偷加了一个。所以我们把约束放进了CI而且是硬拦截。具体做法是在流水线里加一个静态检查步骤扫描代码库中所有可能引入LLM调用的痕迹。这个检查不是简单的关键词匹配而是分层的第一层扫依赖文件pyproject.toml、package.json、requirements.txt等看有没有引入已知的LLM SDK第二层扫源码看有没有对特定API端点的调用、有没有导入相关模块第三层扫配置和密钥看有没有API key相关的环境变量引用。任何一层命中流水线直接失败PR无法合并。注意这个检查必须放在CI的最前面最好在依赖安装之前就跑。否则等你装完一堆包再检查既浪费时间又可能因为依赖安装本身触发了某些网络行为。我们一开始放在测试之后结果有一次一个PR装了个带LLM依赖的包CI跑了十分钟才失败体验极差。3. 核心细节拆解规则编码、依赖管控与CI拦截实现3.1 把法条翻译成决策表一个高风险判定的完整例子拿附件三第1条生物识别来举例。法条大意是用于远程生物识别、生物特征分类、情绪识别的AI系统属于高风险除非是用于特定豁免场景比如医疗、防欺诈的某些限定用途。我们把它拆成决策表大概是这样的结构规则ID用途类别是否远程是否实时豁免场景结论R-101生物识别是是无高风险R-102生物识别是否无高风险R-103生物识别否-无有限风险R-104生物识别是-医疗诊断最小风险R-105情绪识别--工作场所高风险这张表看起来简单但每一条背后都有讲究。比如R-104的豁免法条里对医疗的定义很窄不是所有医疗相关都豁免必须是用于医疗诊断或治疗的特定用途。我们在实现时把豁免场景这一列做成了枚举每个枚举值对应法条里的一个具体表述避免业务人员自由发挥。决策表的实现我们用的是YAML描述加代码生成。规则表写在YAML里CI里有个步骤把它编译成Python的判定函数。这样做的好处是规则和代码分离法务改规则不用碰代码改完YAML跑一遍生成和测试就行。生成出来的函数是纯确定性的没有任何外部调用。3.2 依赖文件里的雷区pyproject.toml、package.json、requirements怎么管零LLM的第一道防线是依赖管控。因为绝大多数LLM调用都是通过SDK引入的只要依赖里没有这些SDK代码里想调也调不了。Python这边我们用的是pyproject.toml管理依赖锁文件是poetry.lock或uv.lock。CI里会检查pyproject.toml的依赖列表对照一份禁用包清单。这份清单不是写死的而是定期从几个来源更新已知的LLM SDK包名、常见的HTTP客户端里被滥用的这个要谨慎requests本身不能禁、以及内部维护的黑名单。检查逻辑是解析pyproject.toml的依赖声明逐个比对包名命中就报错。Node这边package.json的检查类似但要注意一个坑很多LLM功能是通过间接依赖引入的。比如你装了个A包A包依赖了B包B包才是真正的LLM SDK。所以光检查直接依赖不够还要检查锁文件里的完整依赖树。我们用的是npm ls或pnpm why来展开依赖树然后对整棵树做黑名单匹配。requirements.txt的情况稍微特殊因为它是扁平列表没有依赖树信息。我们的做法是如果项目用requirements.txtCI里会先跑pip install --dry-run或者pipdeptree来展开依赖再做检查。这里有个实操心得pip的依赖解析在不同版本下行为不一致我们遇到过preparing metadata (pyproject.toml) did not run successfully这类报错最后发现是某个间接依赖的构建元数据有问题。所以CI里跑依赖检查时要固定pip版本并且把构建隔离打开避免环境差异导致的误报。提示禁用包清单要定期更新但更新本身要走评审。我们吃过亏有一次自动更新把某个常用的HTTP库误加进黑名单导致整个CI挂了一天排查半天才发现是清单的问题。3.3 源码扫描怎么识别偷偷调用LLM的代码依赖管控能挡住大部分情况但挡不住有人用requests直接调API。所以第二层是源码扫描。我们的扫描规则分几类。第一类是导入检查扫描所有Python文件的import语句和JS文件的require/import匹配已知的LLM SDK模块名。第二类是端点检查扫描字符串字面量里有没有已知的LLM API域名或路径片段。第三类是模式检查比如有没有出现api_key加completion这种组合或者有没有构造特定的请求体结构。这里要特别小心误报。比如completion这个词在代码里很常见补全功能、任务完成状态都可能用到。所以我们用的是组合模式单个词不触发多个特征同时出现才报警。另外扫描要排除测试文件和文档否则测试用例里写个不要调用LLM的注释都可能被误伤。扫描工具我们用的是自研的AST解析加正则辅助。AST解析能准确拿到import和字符串字面量正则用来兜底一些动态构造的情况。整个扫描跑一遍中型代码库大概几秒钟放在CI里完全可接受。3.4 CI流水线的编排从pre-commit到merge request的完整链路CI拦截不是单点而是一条链路。我们的编排是这样的本地开发阶段pre-commit钩子里跑一个轻量版的检查只扫改动文件快速反馈。这一步是善意提醒不强制因为本地环境可能不完整。推到远端后CI流水线第一段就是零LLM检查包含依赖检查、源码扫描、配置检查三个并行任务。这三个任务都通过才进入依赖安装和测试阶段。任何一段失败流水线立即终止PR标记为不可合并。合并到主分支前还有一道全量扫描对整个代码库跑一遍完整检查防止有人通过分支合并绕过。这道扫描的结果会存档作为合规证据的一部分。GitLab CI的配置大概是这样的结构stages里第一个stage是compliance-check下面挂三个job每个job有独立的script和rules。我们用rules来控制只在特定文件变更时触发比如依赖文件变了才跑依赖检查源码变了才跑源码扫描节省时间。stages: - compliance-check - build - test zero-llm-deps: stage: compliance-check script: - python scripts/check_deps.py --manifest pyproject.toml --blocklist config/llm_blocklist.txt rules: - changes: - pyproject.toml - poetry.lock - package.json - pnpm-lock.yaml zero-llm-source: stage: compliance-check script: - python scripts/scan_source.py --root src/ --rules config/scan_rules.yaml rules: - changes: - src/**/*.py - src/**/*.ts注意CI job的镜像要固定版本不要用latest。我们有一次因为基础镜像更新导致扫描脚本依赖的某个库行为变了误报了一堆排查了半天。固定镜像tag是血泪教训。4. 实操过程从零搭一套可复现的零LLM合规引擎4.1 环境准备与项目骨架假设你现在要从零搭一套类似的引擎我按我们的实际路径给你捋一遍。首先是项目骨架Python项目用pyproject.toml管理依赖目录结构大概是src/engine放核心判定逻辑src/rules放规则YAMLsrc/generators放代码生成脚本scripts放CI用的检查脚本tests放测试用例config放黑名单和扫描规则。依赖方面核心运行时只需要极少的库YAML解析pyyaml或ruamel.yaml、数据校验pydantic、测试框架pytest。注意这里一个LLM相关的库都不能有。pydantic用来定义输入输出的schema保证判定函数的输入是结构化的、经过校验的。环境准备有个细节Python版本要固定我们用3.11因为3.12某些库的兼容性还在磨合。虚拟环境用venv或uv都行关键是CI和本地要一致。我们在pyproject.toml里声明requires-pythonCI里用对应的镜像避免版本漂移。4.2 规则表的编写与代码生成规则表用YAML写每条规则包含id、description、conditions、conclusion、legal_reference。conditions是一个条件列表每个条件有字段名、操作符、取值。conclusion是判定结果。legal_reference是法条出处用于审计追溯。写规则表有几个实操要点。第一条件字段名要和输入schema严格对应我们用一个schema文件统一管理生成脚本会校验。第二操作符要收敛我们只支持eq、neq、in、not_in、gt、lt这几种避免复杂的逻辑表达式。第三规则之间要有优先级用priority字段控制数字小的先匹配。第四每条规则必须有唯一id且id一旦分配不再复用即使规则被删除id也保留避免历史报告对不上。代码生成脚本读YAML输出一个Python模块里面是一个判定函数函数体是一串if-elif。生成出来的代码不手改改了也会被下次生成覆盖。生成脚本本身要有测试确保生成的代码和YAML语义一致。# 生成后的判定函数示意简化 def evaluate_bio_risk(input_data): if input_data.use_case biometric and input_data.remote and input_data.realtime: return {level: high, rule_id: R-101, ref: Annex III.1} if input_data.use_case biometric and input_data.remote and input_data.exemption medical: return {level: minimal, rule_id: R-104, ref: Annex III.1(a)} # ... 其余规则4.3 判定流程的状态机编排单条规则判定完还需要状态机来编排整个流程。状态机的状态包括初始、不可接受风险检查、附件三高风险检查、透明度义务检查、例外条款检查、完成。每个状态执行对应的决策表根据结果决定下一个状态或直接终止。状态机的实现我们用的是显式的状态转移表而不是用现成的状态机库。原因是显式表更容易审计每个转移条件都写在表里一眼能看全。状态转移表也是YAML描述和规则表一起生成代码。这里有个设计决策值得说为什么不用递归或循环来自动遍历所有规则因为合规判定有明确的优先级和短路逻辑比如一旦判定为不可接受风险后面的检查都不用做了。用状态机显式表达这种短路比让规则引擎自己推理更可控也更容易向审计员解释。4.4 CI检查脚本的落地与调试CI检查脚本是整个约束的执行者写的时候要兼顾准确性和性能。依赖检查脚本的核心逻辑是解析依赖文件展开依赖树匹配黑名单。源码扫描脚本的核心逻辑是AST遍历加字符串匹配。调试这类脚本有个技巧先在本地用一批已知的干净和脏样本跑确认没有误报和漏报再上CI。我们维护了一个测试仓库里面故意放了各种LLM调用的变体每次改扫描规则都跑一遍确保规则有效。性能方面源码扫描对大仓库可能慢我们做了增量扫描只扫改动的文件全量扫描放在合并前。增量扫描用git diff拿到改动文件列表只对这些文件跑AST。这样即使仓库很大日常开发的反馈也很快。提示扫描脚本的退出码要规范0表示通过非0表示失败且失败时要把命中的文件、行号、规则ID打印清楚。我们一开始只打印检查失败排查起来很痛苦后来改成详细输出效率高多了。5. 常见问题与排查技巧实录5.1 依赖检查的典型误报与漏报误报一间接依赖里的同名包。有些包名和LLM SDK重名但实际是别的用途。我们的做法是黑名单里不仅记包名还记包的特征比如作者、仓库地址匹配时双重校验。如果只按包名匹配误报率会很高。误报二构建元数据报错被当成检查失败。前面提到的preparing metadata (pyproject.toml) did not run successfully就是典型。这个报错是依赖安装阶段的问题不是检查逻辑的问题。我们的处理是把依赖安装和依赖检查分开检查只解析声明文件不实际安装避免被构建问题干扰。漏报一动态导入。有人用importlib动态导入模块AST静态分析抓不到。我们的兜底是运行时检查在测试阶段加一个钩子监控有没有对外的网络请求如果有非预期的外部调用测试失败。这个钩子只在测试环境开生产不开。漏报二通过子进程调用。有人可能用subprocess调外部命令来发请求。这个比较难静态检测我们的做法是扫描subprocess调用如果命令里包含可疑的URL或工具名报警人工复核。5.2 CI流水线卡住或超时的处理CI卡住最常见的原因是依赖安装慢或网络问题。我们的经验是CI里尽量用缓存把依赖安装的缓存目录挂上第二次跑就快很多。另外检查任务要设置超时比如5分钟超时直接失败避免流水线挂死。还有一个坑是并发。如果多个PR同时跑CI资源竞争可能导致超时。我们的做法是给检查任务设置较低的并发优先级或者用独立的runner跑检查不和构建测试抢资源。5.3 规则表更新后的回归测试规则表一改判定结果可能变必须跑回归测试。我们维护了一套黄金用例每个用例是一个输入加期望输出覆盖所有规则和边界。规则表更新后CI里自动跑黄金用例任何不一致都报错。黄金用例的维护有个技巧用例要按规则ID组织改哪条规则就跑哪组用例不用全量跑。另外用例的期望输出要包含规则ID和法条引用这样不仅验证结论还验证追溯信息。5.4 常见问题速查表问题现象可能原因排查方向解决方式CI在依赖检查阶段失败引入了LLM SDK或间接依赖看报错里的包名展开依赖树移除依赖或替换实现源码扫描误报组合模式过于宽松看命中的文件和行号调整扫描规则加白名单判定结果与预期不符规则优先级或条件写错看决策表对应行修正YAML重跑生成流水线超时依赖安装慢或并发高看各阶段耗时加缓存调超时隔离runner黄金用例失败规则更新未同步用例看失败的用例ID更新用例或修正规则5.5 几条踩坑换来的实操心得第一条约束要前置。零LLM检查放在CI最后等于没放。必须放在最前面让违规在最早的时间点暴露。第二条黑名单要可维护。写死在脚本里的黑名单迟早会过期。我们用独立的配置文件有版本管理有评审流程更新有记录。第三条扫描规则宁严勿松但要有申诉通道。误报会让人烦躁但漏报的代价更大。我们允许开发者对误报提申诉人工复核后加白名单白名单也有评审。第四条文档要跟上。新同事不知道为什么要零LLM就会觉得这是多余的束缚。我们在仓库根目录放了一份说明讲清楚背景和约束新人入职必读。6. 规则引擎的扩展与长期维护6.1 法条更新时怎么改规则EU AI Act的实施细则和指南还在陆续出台规则表肯定要更新。我们的流程是法务同事解读新规产出规则变更说明工程同事把变更翻译成YAML跑黄金用例和全量测试CI通过后合并。整个流程有记录每次变更对应一个版本号历史报告可以追溯到当时的规则版本。版本管理有个细节规则表的版本和引擎的版本要分开。引擎代码可能重构但规则语义不变规则可能更新但引擎不变。分开版本管理客户能清楚知道这次更新是规则变了还是引擎变了。6.2 多语言、多法域的扩展思路现在只做EU AI Act但客户可能还要看其他法域的要求。扩展的思路是把规则表按法域分目录判定流程按法域选择对应的规则集。状态机的编排逻辑可以复用只是加载不同的决策表。这样新增一个法域主要是写规则和测试引擎核心不用大改。多语言方面规则描述和法条引用要支持多语言输出报告也要能切换语言。这部分我们用i18n的方案规则YAML里存key翻译文件单独维护。6.3 性能优化批量判定的处理批量判定场景下性能瓶颈往往在输入校验和输出序列化不在判定逻辑本身。我们的优化是输入校验用pydantic的model_validate批量做输出用orjson序列化判定函数本身是纯计算很快。一千个系统的批量判定单机跑下来不到一秒。如果还要更快可以把判定函数编译成C扩展或者用numba但我们实测没必要Python够用。过早优化是浪费。6.4 审计追溯的实现细节审计追溯是这套引擎的核心价值之一。每条判定结果都带规则ID、法条引用、以及判定时的输入快照。输入快照要脱敏不能存敏感数据。我们存的是输入的哈希加结构化字段既能复现判定又不泄露原始数据。追溯信息还包含引擎版本和规则版本这样任何一份历史报告都能精确复现。客户拿报告去应对监管问询时我们能提供完整的判定链路。7. 关于零LLM这件事的一些个人看法做了这么久我越来越觉得零LLM不是反技术而是分场景。合规判定这种要求确定性、可审计、可复现的场景确定性规则就是比LLM合适。LLM擅长的是模糊匹配、自然语言理解、生成这些在合规场景里也有用武之地比如帮用户把自然语言的系统描述转成结构化输入或者把判定结果转成人话解释。但这些都应该放在引擎的外围核心判定必须是确定性的。我们现在的架构就是这样外围可以用LLM做输入解析和输出解释核心判定一行LLM都没有CI焊死。这样既享受了LLM的便利又保住了合规的底线。如果你也在做类似的产品我建议把这条边界划清楚并且用工程手段而不是口头约定守住它。口头约定在deadline面前一文不值CI的红灯才是真的。最后分享一个小技巧如果你的团队对零LLM有抵触别急着讲道理先让他们跑一次批量判定看看LLM方案的成本和延迟再让他们试着向审计员解释一次LLM的判定结果。很多时候现实比说服更有力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑