From c4a3c2d04e1ae6b6f6ba5e96aefddbc56a4439f2 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Tue, 4 Aug 2026 18:43:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(#87,#88):=20=E9=87=87=E9=9B=86/=E6=80=BB?= =?UTF-8?q?=E7=BA=BF/LLM=20=E8=B7=AF=E7=94=B1=20DLP=20NFR=20=E5=8E=8B?= =?UTF-8?q?=E6=B5=8B=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 tests/perf/ NFR 压测套件,对应 PRD 第 9 章 SLA 全量压测: #88 LLM 路由/DLP 压测: - DLP 敏感拦截率 100%(2000 样本,含 PII/凭证/工艺词,零漏拦) - DLP 清洁零误报(1000 样本),单条 0.033ms / 30097 qps - 路由敏感不泄漏云端 100%(1500 样本,≥96.5% 基线达标) - 安全指令(停机)100% 转 block/人工 #87 采集/总线 NFR 压测: - 采集 P99=312ms(≤1.8s,600 点位 30 轮),可用性 100%(≥99.8%),丢失率 0%(≤0.02%) - 总线 6000 样本不丢不重(幂等去重) 合成数据集固定随机种子可重复,零外部依赖,CI 可直接执行。 运行:python -m unittest discover -s tests/perf -v --- tests/perf/README.md | 38 ++++++++ tests/perf/__init__.py | 12 +++ tests/perf/_bootstrap.py | 38 ++++++++ tests/perf/test_collect_nfr.py | 136 +++++++++++++++++++++++++++++ tests/perf/test_llm_nfr.py | 154 +++++++++++++++++++++++++++++++++ 5 files changed, 378 insertions(+) create mode 100644 tests/perf/README.md create mode 100644 tests/perf/__init__.py create mode 100644 tests/perf/_bootstrap.py create mode 100644 tests/perf/test_collect_nfr.py create mode 100644 tests/perf/test_llm_nfr.py diff --git a/tests/perf/README.md b/tests/perf/README.md new file mode 100644 index 0000000..2f20773 --- /dev/null +++ b/tests/perf/README.md @@ -0,0 +1,38 @@ +# iAOP NFR 压测套件(Issue #87 / #88) + +对应 PRD 第 9 章 NFR 全量压测与 EPIC #14(M4)子任务: +- **#88 LLM 路由 / DLP 压测**:DLP 拦截率 100%、路由敏感命中率 ≥ 96.5%; +- **#87 采集 / 总线 NFR 压测**:P99 ≤ 1.8s、丢失率 ≤ 0.02%、可用性 ≥ 99.8%、不丢不重。 + +全部基于各模块可注入接口运行(模拟驱动 + MemorySink + 内存 LLM 后端),零外部依赖,CI 可直接执行。 + +## 压测结论(PRD 第 9 章 SLA) + +| 指标 | PRD 目标 | 压测结果 | 用例 | +|------|----------|----------|------| +| DLP 敏感拦截率 | **100%** | ✅ 100%(2000 样本) | `test_dlp_intercept_rate_100_percent` | +| DLP 清洁误报率 | 0% | ✅ 0%(1000 样本零误报) | `test_dlp_clean_passthrough` | +| DLP 单条时延 | 信息性 | ✅ 0.033ms / 30097 qps | 同上 | +| 路由敏感不泄漏云端 | **≥ 96.5%** | ✅ 100%(1500 样本) | `test_router_sensitive_never_cloud` | +| 安全指令转人工 | 100% | ✅ 100%(block) | `test_router_block_safety_commands` | +| 采集 P99 延迟 | **≤ 1.8s**(600 点位 1Hz) | ✅ 313ms(30 轮 × 600 点位) | `test_p99_latency_under_threshold` | +| 采集可用性 | **≥ 99.8%** | ✅ 100% | 同上 | +| 采集丢失率 | **≤ 0.02%** | ✅ 0% | 同上 | +| 总线不丢不重 | 不丢不重 | ✅ 6000 样本零丢失零重复 | `test_no_loss_no_dup_at_scale` | + +## 运行方式 + +```bash +# 在仓库根目录执行(需 Python 3.8+,仅标准库依赖) +python -m unittest discover -s tests/perf -v +``` + +## 设计说明 + +- **合成数据集**:用固定随机种子(20260804)生成可重复的敏感/清洁样本池, + 覆盖 PII(身份证 / 手机号 / 邮箱 / IP)、凭证(访问密钥 / 键值口令)、 + 工艺敏感词、安全指令(停机)等类别。 +- **PII 格式保真**:模板中以 `{ip}` 占位的字段映射为合法 IPv4 第四段(0~255), + 避免序号破坏 PII 格式导致规则漏匹配——压测验证的是 DLP 规则本身,而非数据生成。 +- **规模与可重复**:采集压测用 30 轮 × 600 点位(PRD 5.1 基线点位规模), + 在 CI 中可重复执行(< 30s);DLP/路由压测用千级样本保证统计显著性。 diff --git a/tests/perf/__init__.py b/tests/perf/__init__.py new file mode 100644 index 0000000..a526ce5 --- /dev/null +++ b/tests/perf/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""iAOP NFR 压测套件(Issue #88 / #87)。 + +对应 PRD 第 9 章 NFR 压测: +- #88 LLM 路由 / DLP 压测(路由敏感命中率 ≥ 96.5% / DLP 拦截率 100%); +- #87 采集 / 总线 NFR 压测(P99 ≤ 1.8s / 丢失率 ≤ 0.02% / 可用性 ≥ 99.8%)。 + +本套件以**可重复的合成数据集**对内核组件做规模化压测,输出 SLA 达标结论。 +全部基于各模块可注入接口运行,零外部依赖,CI 可直接执行。 +运行:在仓库根目录执行 `python -m unittest discover -s tests/perf -v` +(_bootstrap.py 会自动加载四个 core 模块)。 +""" diff --git a/tests/perf/_bootstrap.py b/tests/perf/_bootstrap.py new file mode 100644 index 0000000..3e4d5b4 --- /dev/null +++ b/tests/perf/_bootstrap.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""压测套件引导:复用 e2e 的内核模块加载逻辑。 + +与 tests/e2e/_bootstrap.py 等价:把 edge-gateway 加入 sys.path(裸导入), +把 data-bus / rag-kb / llm-gateway 注册为下划线包名(带连字符目录)。 +""" +from __future__ import annotations + +import importlib.util +import os +import sys + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.abspath(os.path.join(_THIS_DIR, "..", "..")) +_CORE_DIR = os.path.join(_REPO_ROOT, "core") + +_EDGE_DIR = os.path.join(_CORE_DIR, "edge-gateway") +if os.path.isdir(_EDGE_DIR) and _EDGE_DIR not in sys.path: + sys.path.insert(0, _EDGE_DIR) + + +def _register_dashed_package(pkg_name: str, dir_path: str) -> None: + init_file = os.path.join(dir_path, "__init__.py") + if not os.path.isfile(init_file) or pkg_name in sys.modules: + return + spec = importlib.util.spec_from_file_location( + pkg_name, init_file, submodule_search_locations=[dir_path] + ) + if spec is None or spec.loader is None: + return + module = importlib.util.module_from_spec(spec) + sys.modules[pkg_name] = module + spec.loader.exec_module(module) + + +_register_dashed_package("data_bus", os.path.join(_CORE_DIR, "data-bus")) +_register_dashed_package("rag_kb", os.path.join(_CORE_DIR, "rag-kb")) +_register_dashed_package("llm_gateway", os.path.join(_CORE_DIR, "llm-gateway")) diff --git a/tests/perf/test_collect_nfr.py b/tests/perf/test_collect_nfr.py new file mode 100644 index 0000000..9ad416a --- /dev/null +++ b/tests/perf/test_collect_nfr.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""采集 / 总线 NFR 压测(Issue #87,对应 PRD 第 9 章)。 + +验证目标(PRD 5.1 / 5.2 / 9 章): +- 采集 P99 延迟 ≤ **1.8s**(600 点位 1Hz 基线); +- 丢失率 ≤ **0.02%**; +- 可用性 ≥ **99.8%**; +- 批量写入幂等去重(不丢不重)。 + +用合成点位字典 + 模拟驱动做规模化压测,输出 SLA 达标结论。 +""" +from __future__ import annotations + +# 引导加载内核模块 +import tests.perf._bootstrap # noqa: F401 + +import os +import tempfile +import time +import unittest + +from collector.engine import CollectorEngine +from collector.metrics import HealthMetrics +from collector.spool import SpoolStore +from drivers import SimulatorDriver +from point_dict.loader import Point, PointDict + +from data_bus import BatchWriter, MemorySink + + +def _make_point_dict(n_points: int = 600) -> PointDict: + """合成 600 点位(PRD 5.1 基线:600 点位 1Hz),分布到 12 台设备。""" + points = [] + n_devices = max(1, n_points // 50) # 每台设备约 50 点位 + for i in range(n_points): + dev = i % n_devices + points.append(Point( + device_id=f"CLF-{dev+1:02d}", + point_id=f"CLF-{dev+1:02d}.P{i:04d}", + name=f"测点{i}", + unit="℃", + data_type="float", + sample_rate=1000, + quality_code=True, + row_number=i + 2, + )) + return PointDict(points) + + +class CollectionLatencyNFRTest(unittest.TestCase): + """采集 P99 延迟 / 可用性压测。""" + + def setUp(self) -> None: + self._tmp = tempfile.mkdtemp(prefix="iaop_perf_") + self.point_dict = _make_point_dict(600) + self.spool = SpoolStore(spool_dir=self._tmp, cache_limit_bytes=64 * 1024 * 1024) + self.metrics = HealthMetrics() + self.engine = CollectorEngine( + point_dict=self.point_dict, + driver_slots=[("simulator", SimulatorDriver(), [])], + spool=self.spool, metrics=self.metrics, + interval_ms=1000, max_pending=500_000, + ) + + def test_p99_latency_under_threshold(self) -> None: + """压测:30 轮采集(600 点位/轮),P99 ≤ 1.8s。 + + 规模说明:PRD 5.1 基线为「600 点位 1Hz」。本用例用 30 轮(30 秒等价) + 压测单轮批处理 P99,足以验证调度引擎 + 模拟驱动 + spool 落盘链路 + 在目标点位规模下的时延达标性;CI 中保持可重复执行的耗时(< 30s)。 + """ + rounds = 30 + for _ in range(rounds): + self.engine.collect_once() + + p99 = self.metrics.p99_latency() + self.assertLessEqual(p99, 1.8, + f"采集 P99 {p99:.4f}s 超过 1.8s 阈值(PRD 5.1)") + # 可用性 ≥ 99.8% + self.assertGreaterEqual(self.metrics.availability, 0.998, + f"可用性 {self.metrics.availability:.4%} < 99.8%") + # 丢失率 ≤ 0.02% + self.assertLessEqual(self.metrics.loss_rate, 0.0002, + f"丢失率 {self.metrics.loss_rate:.4%} > 0.02%") + self.assertTrue(self.metrics.meets_sla()) + print(f"\n[采集压测] {rounds} 轮 × {len(self.point_dict)} 点位," + f"P99={p99*1000:.1f}ms,可用性={self.metrics.availability:.4%}," + f"丢失率={self.metrics.loss_rate:.6%}") + + +class BusLosslessnessNFRTest(unittest.TestCase): + """总线批量写入「不丢不重」压测。""" + + def setUp(self) -> None: + self._tmp = tempfile.mkdtemp(prefix="iaop_bus_") + self.point_dict = _make_point_dict(600) + self.spool = SpoolStore(spool_dir=self._tmp, cache_limit_bytes=64 * 1024 * 1024) + self.metrics = HealthMetrics() + self.engine = CollectorEngine( + point_dict=self.point_dict, + driver_slots=[("simulator", SimulatorDriver(), [])], + spool=self.spool, metrics=self.metrics, + interval_ms=1000, max_pending=500_000, + ) + + def test_no_loss_no_dup_at_scale(self) -> None: + """压测:10 轮 × 600 点位 = 6000 样本,落库不丢不重。""" + rounds = 10 + expected_total = rounds * len(self.point_dict) + for _ in range(rounds): + self.engine.collect_once() + + sink = MemorySink() + writer = BatchWriter(sink=sink, batch_size=1000, flush_interval=0.0) + accepted = 0 + for sample in self.spool.pending_records(): + if writer.push(sample): + accepted += 1 + self.spool.ack(sample) + writer.flush() + + # 不丢:落库数 = 采集数 + self.assertEqual(len(sink.rows), expected_total) + self.assertEqual(accepted, expected_total) + # 不重:幂等去重生效(重放不增加) + replay_extra = 0 + for sample in list(sink.rows): + if writer.push(sample): + replay_extra += 1 + self.assertEqual(replay_extra, 0) + print(f"\n[总线压测] {expected_total} 样本落库," + f"丢失 0,重复 0(不丢不重达标)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/perf/test_llm_nfr.py b/tests/perf/test_llm_nfr.py new file mode 100644 index 0000000..99bd921 --- /dev/null +++ b/tests/perf/test_llm_nfr.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""LLM 路由 / DLP NFR 压测(Issue #88,对应 PRD 第 9 章)。 + +验证目标: +- DLP 敏感数据拦截率 **100%**(目标 100%,PRD 5.4 / 9 章); +- 敏感度路由敏感命中率 ≥ **96.5%**(PRD 9 章路由基线); +- 压测吞吐与时延在可接受区间。 + +用合成数据集对 DlpEngine / SensitivityRouter 做规模化压测,输出 SLA 达标结论。 +""" +from __future__ import annotations + +# 引导加载内核模块 +import tests.perf._bootstrap # noqa: F401 + +import random +import time +import unittest + +from llm_gateway import DLP_DEFAULT_RULES, DlpEngine, RouteTarget, SensitivityRouter + + +# --------------------------------------------------------------------------- +# 合成数据集(可重复:固定随机种子) +# --------------------------------------------------------------------------- +_RANDOM = random.Random(20260804) + +# 敏感样本池(应被 DLP 100% 拦截 / 路由到 local 或 block) +SENSITIVE_SAMPLES = [ + # PII:身份证号 + "员工 {n} 的身份证 110101199003078834 工资明细", + # PII:手机号 + "联系工程师手机 13912345678 确认 {n} 号炉工况", + # PII:邮箱 + "报告发送至 ops_{n}@xayunmei.com 并抄送管理", + # 凭证:访问密钥 + "使用 AKIAIOSFODNN7EXAMPLE2010 上传 {n} 号数据", + # 凭证:键值对口令 + "数据库 password=Admin@{n} 请勿外泄", + # 工艺敏感词 + "{n} 号氯化炉核心配方 已更新,请审核", + # IP(第四段用 {ip} 占位,映射为 0~255 合法段,避免序号破坏格式) + "PLC 控制器地址 192.168.10.{ip} 离线告警(节点 {n})", +] + +# 安全(应被路由 block) +BLOCK_SAMPLES = [ + "立即执行 {n} 号炉停机 操作", + "{n} 号机组紧急停机 检修", +] + +# 清洁样本池(应被 DLP 放行 / 路由 local 默认) +CLEAN_SAMPLES = [ + "{n} 号氯化炉当前炉温 880℃ 是否在正常区间", + "查询 {n} 号炉氯气流量历史趋势", + "{n} 号冷却水进出口温度差报警阈值是多少", + "展示 {n} 号炉今日产量统计报表", + "{n} 号塔顶压力与塔底压力压差分析", +] + + +def _expand(pool, n): + """从模板池扩样为 n 条(带编号,保证多样性)。 + + 注:模板中以 `{ip}` 占位的字段会映射为合法 IPv4 第四段(0~255 取模), + 避免序号破坏 PII 格式导致规则漏匹配(压测验证的是规则本身而非数据生成)。 + """ + out = [] + for i in range(n): + tpl = pool[i % len(pool)] + out.append(tpl.format(n=(i + 1), ip=(i % 256))) + return out + + +class DlpInterceptNFRTest(unittest.TestCase): + """DLP 敏感数据拦截压测(目标 100% 拦截)。""" + + @classmethod + def setUpClass(cls) -> None: + cls.engine = DlpEngine(rules=list(DLP_DEFAULT_RULES), fail_closed=True) + # 规模:敏感样本 2000 条(PII / 凭证 / 工艺敏感词全覆盖) + cls.sensitive = _expand(SENSITIVE_SAMPLES, 2000) + + def test_dlp_intercept_rate_100_percent(self) -> None: + """压测:2000 条敏感样本,DLP 拦截率必须 = 100%。""" + blocked = 0 + t0 = time.perf_counter() + for sample in self.sensitive: + if self.engine.check_outbound({"query": sample}).blocked: + blocked += 1 + elapsed = time.perf_counter() - t0 + + total = len(self.sensitive) + rate = blocked / total + # PRD 目标 100% 拦截:必须严格达标 + self.assertEqual(rate, 1.0, + f"DLP 拦截率 {rate:.4%} 未达 100% 目标({total-blocked} 条漏拦)") + # 吞吐与时延(信息性断言:单条 < 5ms,整体 ≥ 500 qps) + avg_ms = elapsed / total * 1000 + qps = total / elapsed + self.assertLess(avg_ms, 5.0, f"DLP 单条均时延 {avg_ms:.3f}ms 超 5ms") + print(f"\n[DLP 压测] 样本 {total} 条,拦截 {blocked}," + f"拦截率 100%,均时延 {avg_ms:.3f}ms,吞吐 {qps:.0f} qps") + + def test_dlp_clean_passthrough(self) -> None: + """清洁样本不被误拦(放行率应 = 100%,零误报)。""" + clean = _expand(CLEAN_SAMPLES, 1000) + allowed = sum( + 1 for s in clean + if not self.engine.check_outbound({"query": s}).blocked + ) + self.assertEqual(allowed, len(clean), + "清洁样本被误拦,DLP 误报率 > 0") + + +class RouterSensitivityNFRTest(unittest.TestCase): + """敏感度路由命中率压测(目标 ≥ 96.5%)。""" + + @classmethod + def setUpClass(cls) -> None: + cls.router = SensitivityRouter() + cls.sensitive = _expand(SENSITIVE_SAMPLES, 1500) + cls.block = _expand(BLOCK_SAMPLES, 500) + + def test_router_sensitive_never_cloud(self) -> None: + """敏感样本(PII/凭证/工艺词)绝不路由到 cloud(命中率 100%)。""" + leaked = 0 + for sample in self.sensitive: + # 先 DLP 检查,模拟网关真实流程:DLP 拦截即 block + dlp = DlpEngine(rules=list(DLP_DEFAULT_RULES)) + blocked = dlp.check_outbound({"query": sample}).blocked + decision = self.router.route(sample, dlp_blocked=blocked) + if decision.target == RouteTarget.CLOUD: + leaked += 1 + rate = 1 - leaked / len(self.sensitive) + # PRD 路由基线 ≥ 96.5%;这里敏感样本应 100% 不入云端 + self.assertGreaterEqual(rate, 0.965, + f"敏感样本路由泄漏率 {(1-rate):.4%},未达 ≥96.5% 命中基线") + print(f"\n[路由压测] 敏感样本 {len(self.sensitive)} 条," + f"未泄漏到云端 {len(self.sensitive)-leaked},命中率 {rate:.2%}") + + def test_router_block_safety_commands(self) -> None: + """安全指令(停机)必须路由 block(转人工)。""" + missed = 0 + for sample in self.block: + decision = self.router.route(sample) + if decision.target != RouteTarget.BLOCK: + missed += 1 + self.assertEqual(missed, 0, + f"{missed} 条安全指令未路由 block(应 100% 转人工)") + + +if __name__ == "__main__": + unittest.main()