从一句话到水密 STL:给科研工具装 LLM Agent 的安全架构实录

0 阅读10分钟

从一句话到水密 STL:给科研工具装 LLM Agent 的安全架构实录

上一篇《44 道门禁》(已发布)讲的是"如何让绿有精确语义"——验证方法论。这一篇讲另一面:当 LLM 成为工具的调用者,如何让它产出的每一个参数都可被信任。素材仍是 TPMS Explorer(三周期极小曲面设计平台)的真实工程实录:它的 Agent 链路从 2026-09-04 的纯数学 CLI 起步,到 09-12 完成自然语言直连闭环;闭环后至加固期又经两轮 Agent 面红队,留下六个代表性打穿(教学精选,非全集)。文中关键工程数字附复现命令(均在仓库根执行)。

一、问题:LLM 是最不可信的参数生产者

平台的能力终点是交付物:水密 STL、Abaqus INP、OpenFOAM 案例。在 Agent 出现之前,参数由人填——人会被 UI 钳制。接入 LLM 后,参数生产者变成了一个概率模型:它会自信地写出 --porosity 850,会把文件名写成 ../../etc/passwd,会幻觉出一个不存在的工具名,甚至会把参数槽位填成一个 JSON 字符串而不是对象。

传统思路是 prompt 工程:"请确保参数合法"。这个项目的立场更极端:prompt 里写的任何要求都不构成安全边界。LLM 的输出和用户输入同级对待——它是不可信输入源,不是合作者。这决定了整个架构:LLM 只负责填"意图槽位",从槽位到交付物之间的每一步都由确定性代码执行。

二、六阶段架构:信任边界逐层收窄

Agent 链路分六阶段(M0–M5),每层只信任下一层的结构化输出,绝不跳层:

M0 纯数学 CLI      tpms.mjs list/estimate —— 无文件写、无网络,selftest 起步 14 断言
M1 几何闭环        mesh 命令 —— 内建水密三硬门,非零即 exit 3 拒产 STL
M2 工具注册层      tools.schema.json —— 每个工具每个参数的 enum/min/max 硬边界
M3 LLM 接入        llm-provider(Provider 抽象)+ 拦截器(LLM 产出逐槽位校验拦截,越界拒绝)
M4 闭环驱动器      propose → 执行 → 读结构化门禁输出 → 有界修复 → 重跑
M5 场景模板        scenario —— 设计意图 → 解析预测 + 四件交付物 + 诚实边界声明

两个刻意的设计决策:

CLI 先于 LLM 存在。 M0/M1 完全不知道 LLM 的存在,它们是纯确定性工具。Agent 层是后来"挂"上去的调用者,不是骨架的一部分。这意味着整条链路随时可以脱离 LLM 独立运行(CI 里就是这么跑的),也意味着 LLM 层的任何故障都不污染几何内核。

schema 不是文档,是代码。 tools.schema.json 里每个数值参数都有 minimum/maximum,每个枚举参数都有封闭 enum,并且有一道独立门禁(schema_check,106 断言)逐项对拍"schema 声称的边界"与"CLI 实际执行的校验"——两份清单若漂移即红。schema 写得再漂亮,没有对拍门禁就是装饰品。

node tpms/agent/schema_check.mjs   # 契约对拍:schema 边界 vs CLI 校验,106 断言
node tpms/agent/selftest.mjs       # CLI 自检:50 断言

三、拦截器:LLM 产出永不直接执行

M3 的核心是 validateToolCalls 拦截器:LLM 返回的 tool call 逐槽位过 schema 校验拦截(越界拒绝,非钳制)——enum 不在封闭集、数值越 min/max、出现未知属性、调用未知工具、缺必填槽位,任一命中即结构化拒绝(exit 2),根本不会到达执行层。拦截器自身是一道离线确定性门禁(40 断言,含 ../x.stl 穿越写、裸 .. 读、Win 设备名大小写实测拦截),不依赖任何模型:

node tpms/agent/llm_provider_selftest.mjs   # Provider + 拦截器离线自检:40 断言(无 key 可跑,GUARD 37)

Provider 层做了抽象(OpenAICompat / Ollama / Mock 三实现),key 只经环境变量(TPMS_LLM_API_KEY),仓库零密钥。一个实战坑:GLM-5.x 这类推理模型思考时间远超常规 chat 模型,默认 provider 超时会假 FAIL——超时上限必须可配(实测 170s 档稳定),否则"模型太慢"会被误诊为"模型不会"。

四、红队实录:六次真实打穿

架构画出来都很美,漏洞全在缝里。独立红队对 Agent 面做了两轮系统攻击,最有教学价值的六个:

1. 路径槽位零校验 = 任意写(最高危)。 mesh --out 接受任意路径,../../foo.stl 直接穿越仓库根写文件。第一版补丁用正则拦截,结果正则首字符允许 .——整串 .. 照样放行。最终方案:文件名由已钳制白名单派生,用户可控路径面直接归零。

2. 崩溃被吞成"结构化拒绝"——语义污染比崩溃更险。 上述路径写入 EISDIR 崩溃后,错误处理层把它包装成了合法的"结构化不可达"输出。表面看错误处理得很优雅,实际是把"我坏了"伪装成"我拒绝了"——监控与回归都失去信号。该修的是崩溃,不是措辞。

3. 校验死代码。 拦截器里 isoGrad 渐变参数的校验函数写得很完整——但出口检查在校验之前 return,这段校验从未被执行。非法渐变参数静默透传照常交付。教训:校验代码存在 ≠ 校验在执行路径上,拦截器断言必须覆盖"非法输入确实被拦截"的行为,而不能只覆盖"校验函数通过单测"。

4. 多 schema 口径分裂。 闭环驱动器引入修复策略工具后,同一参数槽位存在两份口径:初始工具 schema 要求整数、修复工具 schema 允许小数。LLM 在链路里先后调用两个工具,第二跳必然被拦截——整链 exit 2 死锁。schema 不是一份文件,是一份契约的多处引用,口径必须单一来源。

5. in 操作符的原型链绕过。 用 key in obj 检查"未知属性"时,toString 这类原型链键永远为真——但真正的绕过来自 JSON.parse:它产生的自有属性 __proto__ 不会被 in 误判,反倒是某些手写合并逻辑会沿原型链读到不该读的键。判自有属性一律 Object.hasOwn。

6. JSON 分支 fail 曾 exit 0。 闭环驱动器解析门禁 JSON 输出的分支里,解析失败时错误路径忘了置非零退出码——门禁红着,驱动器绿着走。这是"假成功"家族的 Agent 变体,与第一篇讲的"空输出恒真断言"同根:失败路径本身需要断言。

六条的共同点:没有一条是 LLM"聪明到骗过了系统",全部是确定性代码自己的缝。LLM 只是放大器——它以极高的频率探索参数空间,把人手永远不会碰到的输入组合(小数槽位、双点路径、原型链键)在几分钟内全部踩一遍。从这个意义上说,LLM 是最好的模糊测试器,前提是你先把拦截器做对。

# 复现:拦截器行为级断言(含上述各形态的非法输入实测拦截)
node tpms/agent/llm_provider_selftest.mjs

五、真实模型回归:失败项轮换说明什么

拦截器对了,不代表模型好用。回归集从 34 条中英设计指令起步(现 37 条),覆盖参数类意图 100%(nl-agent 语义覆盖表对拍)。口径先钉死:这套回归是 --dry-run 的 tool-calling 槽位语义验收(工具选择+参数槽位),不在套件内落盘 STL;端到端交付证据另计(闭环 Mock 自检 6/6 + 真实模型抽测 2/2 收敛)。四档真实模型的终态(单轮观察,n=1):

模型通过备注
glm-4-flash(经济档)33/37弱项=合法意图未命中正确调用;拦截器保证其失败形态不产生非法执行(≠能力达标)
glm-4.635/37
glm-5.3(满血)36/37失败项随轮次漂移
glm-5.3-flash37/37C3 缓解后多轮 n≥2 全绿;缓解前单轮 37/37 与复测 36/37 并存——勿称确定性

关键观察是"失败项轮换":满血模型这次挂的指令,直跑一次就过——仓库记录指向 temp=0 下服务端负载/单条级方差(不是简单归因"推理随机性"),且 n=1 不足以区分方差与管线缺陷。所以验收语义是三层而不是一层:拦截器保证"错的不执行"(确定性),回归保证"好的能通过"(统计性),对抗指令保证"坏的进不来"(四模型零非法执行=平台拒绝/模型拒绝/模型改发合法值三形态;拦截器动作由离线 40 断言单独证明)。把统计性验收当成确定性保证来宣传,是 Agent 评测最常见的造假姿势。

# 复现:真实模型回归(需 TPMS_LLM_API_KEY + TPMS_LLM_BASE_URL,key 不入库)
# bash: TPMS_LLM_TIMEOUT_MS=170000 node tpms/agent/llm_regression.mjs
# PowerShell: $env:TPMS_LLM_TIMEOUT_MS=170000; node tpms/agent/llm_regression.mjs

六、闭环驱动器:LLM 只选策略,应用与验收全确定性

最后一层是让 Agent 自己收敛:tpms-driver.mjs 注入一个(可能带缺陷的)设计方案 → 执行 → 读 verify 的结构化输出 → LLM 在有界动作集里选修复策略(换族/降周期数/换容器/参数修正,enum 封闭)→ 确定性应用 → 重跑。

分工刻意不对称:梯内修复由确定性梯完成(比如孔隙率偏差超阈时的分辨率提升,根本没有 LLM 什么事);LLM 只接管"梯外"的策略选择。验收是注入故意缺陷后 ≤5 轮自动收敛全绿,全程无人工干预;LLM 的每个修复决策同样过拦截器校验(越界拒绝),轮数耗尽 exit 4、结构化不可达 exit 3、拦截器拒绝 exit 2——退出码本身是契约。

两个真实收敛案例(先钉契约:梯内保真修复 vs 梯外换族=需求变更,须结构化告知,不得把换族成功冒充原意图达成):fcks 曲面 R96 超出验证梯 → 换族 gyroid R128 两轮几何可产(原族未达标,属改构型;同族可行解 fcks R120 实际存在,策略应优先同族);gprime k=6 周期超标定域 → 降 periods 5 失败 → 换族 diamond 三轮可产(同族 periods 2 可修——离线验收用例即此路径)。"失败后换个方向再来"恰恰是 LLM 擅长的部分——但每个决策的执行和验收,依然是确定性代码说了算;「收敛」在文中指几何可产,不等于意图保真。

七、收束:三条可复用的铁律

  1. LLM 产出 = 用户输入。 它值得被 schema 校验、被拦截器逐槽位拦截(越界拒绝)、被确定性代码执行——唯独不值得被信任。prompt 不是安全边界。
  2. 结构化拒绝优于自然语言劝导。 拒绝要有退出码、有结构化原因、有下一步建议;"请在 prompt 里要求模型守规矩"没有任何一条回归能保护你。
  3. 每个功能宣称必须指认一条跑过的命令。 拦截器存在 ≠ 拦截在执行路径上;校验函数通过单测 ≠ 非法输入真的被拦。这条纪律贯穿两篇文章,也是它作为方法论的自证:文中离线断言计数(成文时 106·49·33;现轮 106/50/40)来自三条命令实测、随批次演进;模型回归表另出 llm_regression.mjs(需 API key)。

项目地址:github.com/zhaoliuxin9…(在线入口见 README)。上一篇:《44 道门禁:在 LLM 时代交付"真的能用"的科研工具》(掘金/知乎已发布)。