AI 学习笔记(二十八):LLM policy-as-code enforcement 最小实践

上一篇我们把 formalize / sunset / convergence 讲清楚了。
但线上治理往前走一步,马上会撞到一个更现实的问题:

规则都写在文档里,事故却还是按老路发生。

常见场景很熟:

  1. 某次模型切换明明要求补评估报告,PR 还是直接合了
  2. 某条高风险工具白名单明明只允许单租户灰度,发布时却被带进全局配置
  3. 某个成本上限明明写在 runbook 里,批任务一扩容,账单还是先飞出去

这时候真正缺的,是把制度变成机器会执行的东西。

这篇继续沿着生产运维主线往下推,聊一件很多团队都拖得太久的事:

把 LLM 治理规则写成 policy as code,让提交、发布、运行时都能按同一套条件做决策。

1. formalize 之后,为什么系统还是容易失控

很多团队做到 formalize 这一步,就以为治理已经落地了。
实际没有。

因为 formalize 解决的是“规则有没有被说清楚”,不是“规则有没有被执行”。

只要下面三种情况还在,治理就还是半成品:

  1. 审批条件藏在文档和聊天记录里,机器看不见
  2. 发布闸门和运行时只看技术状态,不看治理状态
  3. 例外单、评估报告、成本阈值、权限边界分散在不同系统,没有统一输入

人肉检查在小团队、低频发布时还能勉强撑住。
一旦进入多模型、多环境、多团队并行,问题会变得很直接:

  1. 同一条规则今天拦得住,明天拦不住
  2. 值班同学知道应该卡住,但没有硬阻断点
  3. 事后复盘能指出制度缺口,事前链路却没有任何自动保护

我更愿意把这类问题叫“治理断路”。
规则在纸面成立,执行链路里却断了。

2. 什么东西该优先写成 policy

别一上来就想把所有治理都塞进策略引擎。
那样十有八九会做成一个没人愿意维护的平台。

更稳的做法,是只挑高频、可结构化、会直接影响风险面的规则先写。

我建议 LLM 系统先抓下面四类:

1) 变更准入条件

  • 哪类模型切换必须补 eval
  • 哪类 prompt guard 变更必须二次审批
  • 哪类工具权限扩容不能直接合并

2) 发布闸门条件

  • 是否存在未过期但未获批的例外
  • 是否存在核心评估指标回退超过阈值
  • 是否存在成本增幅、延迟增幅、错误率增幅超线

3) 运行时执行条件

  • 哪类租户允许调用高风险工具
  • 哪些环境允许联网、写库、外发消息
  • 哪些 fallback 路由只能在事故窗口打开

4) 追责与证据条件

  • owner 是否明确
  • waiver / exception 是否存在且未过期
  • 发布记录、审批记录、评估记录是否齐全

这里有个判断标准很好用:

只要某条规则一旦失守,就可能把风险直接带进生产,它就值得优先写成 policy。

3. 不要先选引擎,先把输入和输出钉死

很多人一提 policy as code,脑子里马上跳到 OPACedarGatekeeper
这些工具当然有用,但真正常见的失败点不在引擎,而在输入太乱。

你至少要先把三件事说清楚:

  1. 输入是什么
    是一次 PR?一次发布申请?一次运行时调用?还是一次例外续期?
  2. 输出是什么
    allow / deny,还是 allow with obligations
  3. 证据在哪里
    owner、评估报告、例外单、成本预算、环境标签,分别从哪来?

如果这三件事没钉住,再强的策略引擎也会退化成“帮你执行模糊规则”。

下面给一个最小数据模型示例。
字段不需要一开始就很全,关键是让策略输入尽量结构化、少靠隐含上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
type Stage = 'pull_request' | 'release' | 'runtime';
type Scope = 'tenant' | 'workspace' | 'global';

interface EvalRegression {
metric: string;
delta: number;
}

interface ChangeRequest {
id: string;
stage: Stage;
scope: Scope;
model: string;
touchesToolPolicy: boolean;
touchesPromptGuard: boolean;
estimatedDailyCostDeltaUsd: number;
evalRegressions: EvalRegression[];
owner: string | null;
approvedExceptionId: string | null;
}

interface PolicyDecision {
allow: boolean;
reasons: string[];
obligations: string[];
}

function evaluatePolicy(input: ChangeRequest): PolicyDecision {
const reasons: string[] = [];
const obligations: string[] = [];

if (input.scope === 'global' && !input.owner) {
reasons.push('global change requires explicit owner');
}

if ((input.touchesToolPolicy || input.touchesPromptGuard) && !input.approvedExceptionId) {
reasons.push('high-risk change requires approved exception or waiver');
}

if (input.estimatedDailyCostDeltaUsd > 500) {
obligations.push('attach cost review before promotion');
}

if (input.evalRegressions.some((item) => item.delta < -0.03)) {
reasons.push('eval regression exceeds threshold');
}

return {
allow: reasons.length === 0,
reasons,
obligations,
};
}

这个模型有三个工程好处:

  1. 条件显式,出了问题能回放
  2. denyallow with obligations 能分开
  3. 输入结构稳定后,后面换引擎成本没那么高

4. 三个 enforcement 点里,发布闸门最先做

很多团队第一反应是把策略塞进运行时。
我不太建议一开始这么干。

原因很简单:运行时最敏感、回滚最难、误伤成本最高。
真正适合起步的,通常是这三个层次。

层 A:PR / 配置准入

这层适合拦:

  1. 缺 owner 的高影响变更
  2. 没有评估附件的模型替换
  3. 越过模板约束的工具权限扩容

它的价值在于把明显不合规的东西挡在 merge 之前。
但它不适合承接所有业务判断,因为很多信息要到发布前才完整。

层 B:发布闸门

这是我最建议优先落地的地方。

因为发布时你通常已经拿得到比较完整的上下文:

  1. 本轮变更范围
  2. 评估结果
  3. 是否存在活动例外
  4. 成本和容量预算
  5. 目标环境与租户范围

这时 policy 的输出也最容易转成硬动作:

  1. 允许发布
  2. 阻断发布
  3. 允许灰度但要求人工确认

层 C:运行时调用

运行时策略很有价值,但应该晚一点做。
它更适合处理:

  1. 工具调用白名单
  2. 网络、文件、外发通道限制
  3. 特定租户或环境的高风险能力开关

别把还没在发布阶段说清楚的规则,直接扔给运行时兜底。
那样多半会把事故从“错误上线”换成“线上随机拒绝”。

5. 真正有用的 policy,不只会说 deny

只会 allow / deny 的策略很快会逼团队绕过系统。
因为现实发布并不是非黑即白。

我更推荐把决策拆成三类:

  1. allow
  2. deny
  3. allow with obligations

第三类特别关键。
例如下面这些情况,直接一刀切并不划算:

  1. 成本涨幅超阈值,但只是灰度到 5% 租户
  2. eval 有轻微回退,但已补人工确认和回滚计划
  3. 高风险工具要开放给指定 workspace,且例外单仍在有效期

这时更合理的动作是:

  1. 要求补成本评审
  2. 要求缩小发布范围
  3. 要求补充事故回退 owner
  4. 要求写入到期时间和复盘任务

换句话说,policy 不只是“卡住一切”,它还要能把剩余风险翻译成明确义务

6. 一周最小落地顺序

如果你现在系统里还没有成熟策略引擎,也不用把事情搞太大。
一周内做出第一版完全够用。

Day 1

盘点三类高风险变更:

  1. 模型切换
  2. 工具权限扩容
  3. prompt guard / fallback 路由变更

Day 2

给这三类变更定义统一输入结构:

  1. owner
  2. scope
  3. eval 结果
  4. 成本变化
  5. 例外单 ID

Day 3

先在 CI 或发布脚本里跑 dry-run,只输出决策,不阻断。

Day 4

把两三条最确定的规则改成硬阻断:

  1. global change 无 owner
  2. 高风险变更无有效例外
  3. 核心评估指标跌破红线

Day 5

allow with obligations 接进发布面板、发布记录或工单系统。
别让“允许但有条件”停在控制台日志里。

Day 6 - Day 7

复盘一轮误判和漏判,再决定要不要引入专门策略引擎。

这样做的好处很实际:

  1. 先把规则跑起来
  2. 先积累真实输入样本
  3. 再决定规则写在 TypeScript、Rego 还是 Cedar 里

7. 三个常见误区

误区 A:把 policy 写成新的审批系统

策略代码负责做条件判断,不负责承载全部流程编排。
审批、工单、告警、审计可以接进来,但别把 policy engine 本身做成大一统平台。

误区 B:策略只看配置,不看风险上下文

同样一条模型变更,影响 1 个租户和影响全站,不该用同一条规则直接拍板。
scope、owner、例外状态、回滚能力,这些上下文必须一起进输入。

误区 C:只拦上线,不看运行时漂移

你把发布拦住了,不代表系统以后不会漂。
高风险工具、代理配置、预算阈值、例外有效期,还是要做周期性校验和 runtime spot check。

8. 冷思考:policy as code 的价值,在于把执行做一致

很多团队一听到 policy as code,会本能觉得这件事只会让发布更慢。
我倒觉得,真正的价值不在“更严”,而在“更一致”。

同一条规则:

  1. 不该今天靠资深同学拦住,明天换个值班人就放过去
  2. 不该在 A 团队靠经验执行,在 B 团队靠运气执行
  3. 不该每次事故后都说“其实文档里写过”

只要系统已经进入多团队、多环境、多模型并行,治理最怕的不是某一条规则太弱,
而是同一条规则在不同链路里表现不一致。

policy as code 干的,就是把这种不一致压掉。

总结

做到 formalize 之后,下一步该做的是把关键规则拉进执行链路。

这件事我会建议从三条最小原则开始:

  1. 先把输入和输出结构化
  2. 先把发布闸门做成第一落点
  3. 先支持 allow with obligations,别只会一刀切

当模型切换、工具权限、例外状态、成本阈值都能被同一套 policy 解释时,
治理才算真的从“会开会”走到“会执行”。

参考资料

本文永久链接: https://www.mulianju.com/learning-notes/ai-learning-notes-llm-policy-as-code-enforcement-minimal-practice/