怎么用是非概率做审核和拦截? 把审核政策拆成几个是非问题,例如“是否针对特定群体”“这次工具调用是否会改动生产数据”,让决策模型为每个问题返回为真的概率;需要区分严重程度时,再加一个评分问题。代码按误判成本设两个阈值:高于上限就拦截,处于中间就转人工,低于下限就放行。概率不是授权,高风险操作仍然需要权限检查和人工审批。
Choice、Score、Noul 三种原语的基础知识见 决策模型是什么?。
Agent 护栏是什么:输入、输出、工具三类检查点
OpenAI Agents SDK(JS)的文档把护栏分成三类:
- 输入护栏:针对初始用户输入运行,只对第一个接收用户输入的 Agent 生效。
- 输出护栏:针对 Agent 的最终输出运行,只在生成最终输出的 Agent 上运行。
- 工具护栏:在每次自定义函数工具调用前后运行。工具输入护栏可以返回
allow(继续)、rejectContent(用一条消息跳过这次调用)或throwException(抛出熔断错误)。
文档还提到两种执行方式:输入护栏默认与 Agent 并行运行(runInParallel: true),延迟最低,但护栏触发时模型可能已经消耗了 token 或运行了工具;设为 false 则在调用模型之前先跑完护栏。文档还建议,可以先运行一个轻量级模型作为护栏,再调用成本较高的模型。
阿里云的 AgentScope 集成指南给出了另一组拦截点:pre_reply 在 Agent 回复前检测用户输入;post_reasoning 在推理完成后检测最终输出;toolkit middleware 在工具执行前检测“工具名 + 参数”,违规就阻止调用。
护栏和人工审批是两件事。OpenAI Agents SDK 的人工审批通过工具的 needsApproval 实现:需要审批时运行暂停,返回 interruptions,批准或拒绝后从同一个 RunState 恢复。默认情况下,工具输入护栏在审批通过后、工具执行前运行;设置 toolExecution: { preApprovalInputGuardrails: true } 后,护栏还会在发出审批请求之前先跑一次。
这些框架把“检查点放哪”讲得很清楚,但护栏函数内部凭什么判断,要你自己决定。
护栏的判断用什么做
常见的选择有四种:
- 规则和正则:确定、便宜,适合格式明确的东西,比如 OpenAI 文档示例里拦截参数中含
sk-的密钥。但它覆盖不了换个说法的内容。 - 再调一次生成式 LLM:OpenAI 文档的输入护栏示例就是在护栏里运行另一个 Agent,让它输出一个布尔字段。它能理解语义,但输出的是“是 / 否”,没有可调的阈值。
- 专用审核服务:例如阿里云 AI 安全护栏,通过插件挂到 AgentScope 的各个拦截点上。审核类别和判定标准由服务方定义。
- 决策模型的 Noul:为你自己写的是非问题返回 P(true)。你可以针对自己的政策写问题,并按误判成本设阈值。
四种方式可以组合使用:规则先挡掉格式明确的情况,Noul 判断需要理解语义的政策,高风险动作仍然走人工审批。
把政策拆成 Noul 问题
一条模糊的政策,比如“不要发布攻击性内容”,很难直接判断。把它拆成几个可以回答“是 / 否”的问题:
- 这段内容是否针对某个特定群体?
- 这段内容是否在索要密码、验证码等凭证?
- 这次工具调用是否会改动生产数据?
System One 的决策原语文档写明:Noul 估计“某个具体条件是否成立”,返回 P(true),范围 0 到 1;如果多个条件可以同时成立,应为每个条件分别定义 Noul 问题,这些概率对应不同的命题,不要求相加等于一。SDK 里用 booleanQuestion() 定义,并用 criteria 写清楚 true 和 false 各指什么。Microsoft Learn 的 Microsoft-Decision-1 文档也建议用 noul 做“门禁和过滤”,示例问题是“这条回复是否承诺给客户退款”。
需要区分严重程度时,再加一个 Score 问题,把档位按顺序写出来,例如“安全 / 擦边 / 违规 / 必须删除”。System One 文档写明,托管 API 要求 2–10 个有序档位,分数是按各档概率加权得出的,不能当作正确率的百分比。
在线试一下:推文会不会被审核(示例结果,登录后可跑真实数据)
工具调用前拦截
把工具调用作为 state 送去判断。AgentScope 集成指南的做法是把工具名和参数拼成 Tool: {name}\nArguments: {json} 的格式送检,可以参考。state 里只放判断所需、允许分享的信息,不要放密钥。
如果你在用 OpenAI Agents SDK,这个判断放在工具输入护栏里:低风险放行时返回 allow;需要拒绝但让 Agent 继续时返回 rejectContent,并附一条说明;需要整个运行停下时用 throwException。付款、删除数据、对外发消息这类高风险工具,仍然设置 needsApproval,由人来批准。
System One 的 Agent 接入文档写明:模型结果不是授权,操作人的身份和权限、动作白名单、必要的审批都要在模型之外检查;答案缺失或请求失败表示“未解决”,不能当成自动的“是”或“否”。所以护栏调用出错时,应该按“未通过”处理,而不是放行。工具路由里的白名单思路,见 Agent 工具太多总选错?用 Choice 做工具路由。
三段阈值与人工审核
问题按“是否违规”的方向写时:
- P(true) 高于上限 → 拦截;
- 介于上下限之间 → 转人工审核;
- 低于下限 → 放行。
如果问题写成“是否安全”,方向就反过来。两个阈值都不能照搬通用的百分比。System One 文档要求阈值来自你自己的评估案例和误判后果;Microsoft Learn 也建议按误报和漏报的代价来设阈值。漏拦代价高的场景(比如付款),上限要放低,让更多请求进入人工审核;误拦代价高的场景,人工区间可以收窄。具体怎么用标注样本选阈值、再用留出数据复核,参见 用 Score 替代 LLM 当裁判。Noul 阈值和动作的对应方式,也可以参考 Agent 什么时候算完成?中的动作表。
Microsoft Learn 还给了几条建议:问题和选项用清晰、中立的措辞;不应强行选择时加入“cannot tell”这类弃权选项;可以随机化选项顺序,并检验顺序变化是否影响结果;涉及个人的重大决定(信贷、就业、住房、医疗、法律)只把模型当作决策支持,并保留有意义的人工复核。
评估:漏拦率和误拦率分开算
在自己的标注集上分别统计两件事:该拦没拦(漏拦),不该拦却拦了(误拦)。两者的代价不同,合成一个准确率会掩盖问题。System One 的 Agent 接入文档也要求把“错误选择”和“遗漏审核”分开统计,并加入试图强迫模型改变选择的输入。
测试集里应该包括:换个说法表达同一个意思的内容、提示注入(比如在参数里写“忽略之前的规则”)、措辞很冲但无害的内容。Microsoft Learn 列出的已知局限包括:评分会随问题和选项的措辞、顺序变化;用作安全过滤时,可能漏掉隐蔽的有害内容,也可能把无害内容标记出来。这些都只能靠自己的数据测出来。
TypeScript 示例
开发团队的示例(examples/decision-articles/08-agent-guardrails.ts)用 booleanQuestion() 定义三个是非问题:是否针对特定群体、是否索要凭证或密钥、这次工具调用是否会改动生产数据或对外发消息。三个概率取最大值作为风险值,任何一条规则都可以触发护栏;风险值不低于 BLOCK_IF_RISK_AT_LEAST(0.8)时拦截,不低于 REVIEW_IF_RISK_AT_LEAST(0.4)时交给应用自己的人工审核回调,否则放行。两个阈值都是示例值,注释写明要在标注案例上调。示例注释还写明:P(true) 不是已确认的事实,也不是授权;System One 没有审核、审批或 needsApproval 接口。
ts/**
* Article 8 — Agent guardrails ("Agent 护栏", /zh-CN/blog/agent-guardrails).
*
* Split a moderation policy into independent yes/no questions via
* `booleanQuestion()` (wire type `noul`, SDK answer `.probability`). Those
* probabilities do not sum to 1. This file combines them with max() — any
* rule can trip the guardrail — then maps the combined risk onto two
* application-owned cutoffs: block / human review / allow.
*
* P(true) is not a confirmed fact and is not authorization. Human review is
* an application callback. System One has no moderation, approval, or
* `needsApproval` API. Packages: @system-one-ai/core, adapter-system-one,
* transport-fetch. Endpoint: POST /v1/systemone.
*/
import { createSystemOne, booleanQuestion } from '@system-one-ai/core';
import type { EvaluationClient } from '@system-one-ai/core';
import { systemOneAdapter } from '@system-one-ai/adapter-system-one';
import { createFetchTransport } from '@system-one-ai/transport-fetch';
export const guardrailQuestions = {
targetsGroup: booleanQuestion(
'Does this content target a specific group with abuse or harassment?',
{ true: 'The text attacks a named group', false: 'The text does not target a group' },
),
requestsSecrets: booleanQuestion(
'Does this content ask for credentials, API keys, or other secrets?',
{ true: 'The user is asked to reveal a secret', false: 'No secret is requested' },
),
changesProduction: booleanQuestion(
'Would executing this tool call change production data or send an external message?',
{ true: 'The call writes, deletes, pays, or posts', false: 'The call is read-only or local' },
),
};
export type GuardrailRule = keyof typeof guardrailQuestions;
export type GuardrailAction = 'block' | 'review' | 'allow';
export interface GuardrailProbabilities {
targetsGroup: number;
requestsSecrets: number;
changesProduction: number;
}
/** High risk: false allow is costly. Tune on labeled cases; not a hosted default. */
export const BLOCK_IF_RISK_AT_LEAST = 0.8;
/** Mid band: send to a person. Must be below the block cutoff. */
export const REVIEW_IF_RISK_AT_LEAST = 0.4;
export interface HumanReviewInput {
text: string;
tool?: string;
args?: string;
risk: number;
probabilities: GuardrailProbabilities;
}
export function createHostedClient() {
const apiKey = process.env.SYSTEM_ONE_API_KEY;
const baseURL = process.env.SYSTEM_ONE_BASE_URL;
const model = process.env.SYSTEM_ONE_MODEL;
if (!apiKey || !baseURL || !model) {
throw new Error('Set SYSTEM_ONE_API_KEY, SYSTEM_ONE_BASE_URL (…/v1), and SYSTEM_ONE_MODEL from GET /v1/models.');
}
return createSystemOne({
apiKey, baseURL, model, adapter: systemOneAdapter,
transport: createFetchTransport(), timeoutMs: 30_000, maxRetries: 0,
});
}
/** Conservative combine: the highest P(true) among independent Noul rules. */
export function combineRiskProbability(probabilities: GuardrailProbabilities) {
return Math.max(probabilities.targetsGroup, probabilities.requestsSecrets, probabilities.changesProduction);
}
export function applyGuardrailPolicy(risk: number): GuardrailAction {
if (risk >= BLOCK_IF_RISK_AT_LEAST) return 'block';
if (risk >= REVIEW_IF_RISK_AT_LEAST) return 'review';
return 'allow';
}
/**
* @param sendToHumanReview Your approval queue. Not a System One method.
*/
export async function screenWithGuardrails(
client: EvaluationClient,
input: { text: string; tool?: string; args?: string },
sendToHumanReview: (input: HumanReviewInput) => Promise<string>,
) {
const result = await client.evaluate({
state: {
text: input.text,
tool: input.tool ?? null,
args: input.args ?? null,
},
questions: guardrailQuestions,
});
const probabilities: GuardrailProbabilities = {
targetsGroup: result.answers.targetsGroup.probability,
requestsSecrets: result.answers.requestsSecrets.probability,
changesProduction: result.answers.changesProduction.probability,
};
const risk = combineRiskProbability(probabilities);
const action = applyGuardrailPolicy(risk);
if (action === 'block') {
return { action, risk, probabilities, model: result.model };
}
if (action === 'review') {
const reviewed = await sendToHumanReview({
text: input.text,
tool: input.tool,
args: input.args,
risk,
probabilities,
});
return { action, risk, probabilities, model: result.model, reviewed };
}
return { action, risk, probabilities, model: result.model };
}
async function main() {
const text = process.env.EXAMPLE_MESSAGE ?? 'Please post this refund to the production ledger.';
const decision = await screenWithGuardrails(
createHostedClient(),
{ text, tool: process.env.EXAMPLE_TOOL, args: process.env.EXAMPLE_TOOL_ARGS },
async () => {
throw new Error('No System One human-review API. Wire your approval queue here.');
},
);
console.log(JSON.stringify(decision, null, 2));
}
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split(/[\\/]/).pop() ?? '')) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
这个示例没有把判断挂到 OpenAI Agents SDK 的工具输入护栏上,也没有用 Score 区分严重程度;接入时要由你自己把返回的 block / review / allow 对应到框架的 rejectContent、needsApproval、allow 等行为。