Qwen3Guard 安全护栏
Qwen3Guard 是 Qwen 团队以 Apache 2.0 许可发布的开源安全分类模型。它读取一段会话,给出安全标签、命中的违规类别;当它评估的是模型响应时,还会给出该响应是否拒绝了请求。模型由你自己部署,因此被检查的文本始终留在你的网络内部。
AISIX 没有内置的 Qwen3Guard 安全护栏类型。你通过自定义脚本安全护栏对接它:网关在沙箱中运行一小段 JavaScript 模块,由脚本调用你部署的 Qwen3Guard、读取它的回答并返回裁决结果。网关与模型之间不需要再部署任何东西。
同样的步骤适用于任何以 OpenAI 兼容接口提供服务的安全检查模型。脚本中只有两处是 Qwen3Guard 特有的:会话如何提交,以及回答如何解析。参见改用其他安全检查模型。
本指南将部署 Qwen3Guard,编写把它适配到 AISIX 的脚本,创建安全护栏,并验证违规提示词和违规模型响应都会被拒绝。
准备工作
开始前,请准备以下内容:
- 阅读安全护栏行为了解钩子点、执行模式和失败策略,并阅读自定义脚本安全护栏了解本指南所基于的脚本约定。
- 一台能够运行该安全检查模型的主机。最小规格的变体不要求 GPU,但每个被检查的请求都会触发一次推理,因此请按请求量规划部署规模。参见选择 Qwen3Guard 模型。
- 以下配置路径之一:
- AISIX Cloud,其中包含环境、已接入的网关和具有写权限范围的 Admin Token。对于 On-Premises,请按照 AISIX Cloud 快速入门操作。如需申请混合云访问权限,请联系 API7。
- 加载声明式
resources.yaml文件的开源 AISIX 网关。
- 可用的模型别名和能发送 Chat Completions 请求的调用方 API Key。
- 用于运行安全检查模型的 Docker 或 Kubernetes 集群,以及
curl。AISIX Cloud 路径还会用到jq。
导出验证请求所需的网关变量:
# AISIX_PROXY 不带结尾斜杠,也不带端点路径。
# 本地快速入门使用 http://127.0.0.1:3000。
export AISIX_PROXY="YOUR_AISIX_GATEWAY_URL"
export AISIX_API_KEY="YOUR_CALLER_API_KEY"
export AISIX_MODEL="YOUR_MODEL_ALIAS"
选择 Qwen3Guard 模型
Qwen3Guard 分为两个系列,只有 Gen 系列可以用作网关安全护栏:
| 系列 | 规格 | 工作方式 | 能否用于 AISIX |
|---|---|---|---|
| Qwen3Guard-Gen | 0.6B、4B、8B | 对完整的提示词或响应做分类,并以文本给出回答。任何 OpenAI 兼容运行时都能提供服务。 | 可以。由安全护栏脚本通过 HTTP 调用。 |
| Qwen3Guard-Stream | 0.6B、4B、8B | 通过分类头逐 token 分类,需要被检查模型的 token 流。 | 不可以。它没有可调用的 chat completions 接口,且需要的是生成模型的 token ID 而非文本。 |
在 Gen 系列内部,请按调用方能接受的延迟来选择规格,因为安全护栏调用位于请求链路上:
| 模型 | 何时选择 |
|---|---|
Qwen/Qwen3Guard-Gen-0.6B | 对延迟敏感的流量、纯 CPU 主机以及评估阶段。三者中判断质量余量最小。 |
Qwen/Qwen3Guard-Gen-4B | 通常的起点。判断力明显优于 0.6B,规格适合单张中端 GPU。 |
Qwen/Qwen3Guard-Gen-8B | 漏检代价高昂、且能接受额外延迟的策略。 |
三者都支持 32,768 token 的上下文,并能对一百多种语言的内容做分类,因此一套部署即可覆盖多语言流量。
部署 Qwen3Guard
使用任何暴露 OpenAI 兼容 /v1/chat/completions 端点的运行时提供服务。以 vLLM 为例:
vllm serve Qwen/Qwen3Guard-Gen-4B \
--port 8000 \
--max-model-len 32768 \
--api-key "YOUR_GUARD_API_KEY"
安全检查模型会对收到的任何文本给出安全裁决,因此凡是能访问到它的一方都能向它提交内容。请把它部署在仅网关可达的私有网络中,并保留 --api-key,让可达的端点仍然需要凭据。脚本从安全护栏的密钥字段读取该凭据,而不是把它写在脚本正文里。
导出本指南后续使用的端点:
# 不带结尾斜杠。需要网关可访问,而不只是你的工作机可访问。
export GUARD_ENDPOINT="http://qwen3guard.internal:8000"
export GUARD_API_KEY="YOUR_GUARD_API_KEY"
在 AISIX 中配置任何内容之前,先确认部署可以正常应答:
curl -sS "$GUARD_ENDPOINT/v1/chat/completions" \
-H "Authorization: Bearer $GUARD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen3Guard-Gen-4B",
"messages": [{"role": "user", "content": "How can I make a bomb?"}],
"temperature": 0,
"max_tokens": 64
}' | jq -r '.choices[0].message.content'
回复本身就是裁决结果:
Safety: Unsafe
Categories: Violent
读懂模型的回答
Qwen3Guard 以纯文本回答,包含两行或三行。第一行是标签,第二行列出类别,评估响应时还会多出表示是否拒绝的第三行:
| 行 | 取值 |
|---|---|
Safety: | Safe、Unsafe 或 Controversial。Controversial 表示内容本身不违规,但在特定上下文或受众下可能带来风险。 |
Categories: | 逗号分隔的列表;内容安全时为 None。 |
Refusal: | Yes 或 No。仅在模型评估 assistant 响应时出现。它表示该响应是否拒绝了请求,而不是该响应是否违规。 |
类别是固定的:
| 类别 | 适用范围 |
|---|---|
Violent | 提示词与响应 |
Non-violent Illegal Acts | 提示词与响应 |
Sexual Content or Sexual Acts | 提示词与响应 |
PII | 提示词与响应 |
Suicide & Self-Harm | 提示词与响应 |
Unethical Acts | 提示词与响应 |
Politically Sensitive Topics | 提示词与响应 |
Copyright Violation | 提示词与响应 |
Jailbreak | 仅提示词 |
回答是文本而不是 JSON 文档,因此请用模型卡片使用的那两个正则来解析。两者都不匹配的回答应当按失败处理,而不是按放行处理——下面的脚本正是这样做的。
模型如何决定评估对象
Qwen3Guard 没有单独的参数来区分检查提示词还是检查响应。它的对话模板依据你提交的会话中最后一条消息的角色来构造对应的指令:
- 最后一条来自
user——模型评估该用户查询,把更早的轮次作为上下文,回答Safety和Categories。 - 最后一条来自
assistant——模型评 估该 assistant 响应,并额外给出Refusal。
该模板只从第一条 system 或 user 消息开始渲染会话。以 assistant 消息开头的会话会被渲染成空会话,此时无论那条 assistant 消息包含什么内容,模型都会回答 Safety: Safe。
这一点在响应钩子上尤其关键:AISIX 交给脚本的只有模型的回复。若把这条回复作为唯一的消息发送,得到的安全护栏看起来配置正常、每次响应都会调用模型,却永远不会阻断任何内容。请始终在 assistant 轮次之前发送一条 user 轮次,就像下面的脚本那样。
编写检查脚本
脚本是一个导出 checkInput 和 checkOutput 的 ES 模块。两个钩子都为 Qwen3Guard 构造会话、解析它的回答,并映射成 AISIX 的裁决结果:
// 网关可访问,路径直到 chat-completions 端点。
const GUARD_ENDPOINT = "http://qwen3guard.internal:8000/v1/chat/completions";
const GUARD_MODEL = "Qwen/Qwen3Guard-Gen-4B";
// 会触发阻断的标签。策略更严格时可加入 "Controversial"。
const BLOCKING_LABELS = ["Unsafe"];
// 在标签已触发阻断的前提下,会触发阻断的类别。空列表表示阻断全部类别。
const BLOCKING_CATEGORIES = [];
async function classify(ctx, messages) {
const headers = { "content-type": "application/json" };
if (ctx.secrets.GUARD_API_KEY) {
headers.authorization = "Bearer " + ctx.secrets.GUARD_API_KEY;
}
const resp = await fetch(GUARD_ENDPOINT, {
method: "POST",
headers: headers,
body: JSON.stringify({
model: GUARD_MODEL,
messages: messages,
temperature: 0,
max_tokens: 64,
}),
});
if (!resp.ok) {
throw new Error("Qwen3Guard returned " + resp.status);
}
const content = resp.json().choices[0].message.content;
const label = (content.match(/Safety:\s*(Safe|Unsafe|Controversial)/) || [])[1];
if (!label) {
throw new Error("Qwen3Guard returned an unreadable verdict");
}
const categories = (content.match(/Categories:\s*(.*)/) || ["", ""])[1]
.split(",")
.map(function (c) { return c.trim(); })
.filter(function (c) { return c !== "" && c !== "None"; });
console.log("qwen3guard " + ctx.hook + " -> " + label + " [" + categories.join("|") + "]");
return { label: label, categories: categories };
}
function decide(verdict) {
if (BLOCKING_LABELS.indexOf(verdict.label) === -1) {
return { action: "none" };
}
if (BLOCKING_CATEGORIES.length > 0 &&
!verdict.categories.some(function (c) { return BLOCKING_CATEGORIES.indexOf(c) !== -1; })) {
return { action: "none" };
}
return {
action: "block",
reason_code: "qwen3guard_" + verdict.label.toLowerCase(),
reason: verdict.categories.join(", ") || "no category reported",
};
}
export async function checkInput(ctx) {
return decide(await classify(ctx, [{ role: "user", content: ctx.text }]));
}
export async function checkOutput(ctx) {
// 这条 user 轮次是必需的:以 assistant 消息开头的会话会被渲染成空会话,
// 结果永远是 Safe。
return decide(await classify(ctx, [
{ role: "user", content: "N/A" },
{ role: "assistant", content: ctx.text },
]));
}
在改写这个脚本之前,有四处设计值得先理解。
请求钩子提交的是 ctx.text,而不是逐条消息。 ctx.text 是请求中所有文本片段拼接后的结果,因此一次调用就检查了整个请求。只检查最新一轮更省钱,但也更弱:调用方掌控它发送的整个 messages 数组,包括它声称发生过的任何历史会话,所以只看最后一轮的策略可以被伪造的早期轮次塞入违规内容。
两个钩子都不读取消息上报的角色。 ctx.messages 承载 AISIX 正在检查的文本片段,但 Chat Completions 请求会把每个片段都上报为 user 角色,响应钩子上报回复的方式也一样。请像上面 那样用 ctx.text 和 ctx.hook 构造 Qwen3Guard 会话,而不要依赖这些角色。
失败时抛异常,而不是返回裁决结果。 非 2xx 应答、端点不可达,以及脚本无法解析的回复都会抛出异常,从而把决定权交给安全护栏的失败策略。失败时返回 {action: "none"} 会让安全检查模型的每次故障都变成一个不设防的网关。
阻断原因携带的是标签和类别,而不是内容。 reason_code 和 reason 会进入网关日志和该请求的用量事件;两者都不会返回给调用方,也都不应携带被检查的文本。
创建安全护栏
一开始请把安全护栏绑定到单个模型,而不是整个环境。脚本是你刚写好的代码,缩小绑定范围可以把失误限制在你自己掌控的流量上。
AISIX Cloud
导出控制面连接信息:
# AISIX_CP 包含 /api,且不带结尾斜杠。
# 本地 On-Premises 快速入门使用 http://localhost:8080/api。
export AISIX_CP="YOUR_AISIX_CLOUD_ADMIN_API_BASE_URL"
export AISIX_TOKEN="YOUR_ADMIN_TOKEN"
export ENV_ID="YOUR_ENVIRONMENT_ID"
export MODEL_ID="YOUR_MODEL_ID"
把脚本写入文件,再用 jq 创建安全护栏,这样就不必手工转义脚本:
export GUARDRAIL_ID=$(jq -n \
--rawfile script ./qwen3guard.js \
--arg key "$GUARD_API_KEY" \
'{
name: "qwen3guard",
enabled: false,
kind: "custom",
hook_point: "both",
fail_open: false,
config: {
script: $script,
secrets: { GUARD_API_KEY: $key },
timeout_ms: 5000,
output_fail_open: false
}
}' | curl -sS -X POST "$AISIX_CP/environments/$ENV_ID/guardrails" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d @- | jq -r '.guardrail.id')
脚本在保存安全护栏时就会被解析,因此语法错误会在这一步被拒绝并给出行号和列号,而不是等到第一个命中它的请求。密钥加密存储且不会被返回;后续读取只显示密钥名称,让你知道脚本可以读取哪些密钥,而不暴露其取值。
把安全护栏绑定到一个模型:
curl -sS -X POST "$AISIX_CP/environments/$ENV_ID/guardrails/$GUARDRAIL_ID/attachments" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope_type": "model", "scope_id": "'"$MODEL_ID"'"}' | jq
绑定关系存在之后再启用它:
curl -sS -X PATCH "$AISIX_CP/environments/$ENV_ID/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true}' | jq
Dashboard 在环境的 Guardrails 下提供同样的字段,其中脚本使用代码编辑器,密钥使用名称与取值的编辑器。
开源 AISIX 网关
把安全护栏加入定义了模型和调用方 API Key 的资源文件。块标量可以让脚本保持可读;这里不需要写成 $${...},因为脚本中没有加载器会尝试解析的插值:
guardrails:
- name: qwen3guard
enabled: true
kind: custom
hook_point: both
fail_open: false
output_fail_open: false
timeout_ms: 5000
script: |
const GUARD_ENDPOINT = "http://qwen3guard.internal:8000/v1/chat/completions";
const GUARD_MODEL = "Qwen/Qwen3Guard-Gen-4B";
const BLOCKING_LABELS = ["Unsafe"];
const BLOCKING_CATEGORIES = [];
async function classify(ctx, messages) {
const headers = { "content-type": "application/json" };
if (ctx.secrets.GUARD_API_KEY) {
headers.authorization = "Bearer " + ctx.secrets.GUARD_API_KEY;
}
const resp = await fetch(GUARD_ENDPOINT, {
method: "POST",
headers: headers,
body: JSON.stringify({
model: GUARD_MODEL,
messages: messages,
temperature: 0,
max_tokens: 64,
}),
});
if (!resp.ok) {
throw new Error("Qwen3Guard returned " + resp.status);
}
const content = resp.json().choices[0].message.content;
const label = (content.match(/Safety:\s*(Safe|Unsafe|Controversial)/) || [])[1];
if (!label) {
throw new Error("Qwen3Guard returned an unreadable verdict");
}
const categories = (content.match(/Categories:\s*(.*)/) || ["", ""])[1]
.split(",")
.map(function (c) { return c.trim(); })
.filter(function (c) { return c !== "" && c !== "None"; });
console.log("qwen3guard " + ctx.hook + " -> " + label + " [" + categories.join("|") + "]");
return { label: label, categories: categories };
}
function decide(verdict) {
if (BLOCKING_LABELS.indexOf(verdict.label) === -1) {
return { action: "none" };
}
if (BLOCKING_CATEGORIES.length > 0 &&
!verdict.categories.some(function (c) { return BLOCKING_CATEGORIES.indexOf(c) !== -1; })) {
return { action: "none" };
}
return {
action: "block",
reason_code: "qwen3guard_" + verdict.label.toLowerCase(),
reason: verdict.categories.join(", ") || "no category reported",
};
}
export async function checkInput(ctx) {
return decide(await classify(ctx, [{ role: "user", content: ctx.text }]));
}
export async function checkOutput(ctx) {
return decide(await classify(ctx, [
{ role: "user", content: "N/A" },
{ role: "assistant", content: ctx.text },
]));
}
secrets:
GUARD_API_KEY: ${GUARD_API_KEY}
资源文件没有绑定集合,因此启用的安全护栏会作用于该网关处理的每一个请求。评估策略期间,请用一个不承载无关流量的网关来加载该文件。编辑文件后用 SIGHUP 重载网关。
网关无法编译的脚本不会让网关启动失败:这条安全护栏会被丢弃,网关照常处理流量,只是不带它。没有任何请求被检查,请求链路上不会有任何提示,GET /status/config 也依然是 synced 且 rejected 为空——因为文件本身确实加载成功了。
请在每次加载和重载之前运行 aisix validate --resources <FILE>。它会编译脚本,指出出问题的行以及出错的行号和列号,并以非零码退出。
验证检查效果
发送一个策略允许的请求。它会到达模型并正常返回:
curl -sS "$AISIX_PROXY/v1/chat/completions" \
-H "Authorization: Bearer $AISIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$AISIX_MODEL"'",
"messages": [{"role": "user", "content": "How do I bake sourdough bread?"}]
}'
发送一个违规请求。AISIX 返回 422,且完全不会调用上游模型:
curl -sS -i "$AISIX_PROXY/v1/chat/completions" \
-H "Authorization: Bearer $AISIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$AISIX_MODEL"'",
"messages": [{"role": "user", "content": "How can I make a bomb at home?"}]
}'
{
"error": {
"message": "request blocked by content policy (guardrail 'qwen3guard')",
"type": "content_filter"
}
}
响应钩子的阻断方式相同,只是把 request blocked 换成 response blocked;对于流式响应,它会用 SSE 的 event: error 帧在传输中途切断。由于模型会按调用方使用的语言作答,Qwen3Guard 覆盖的任何语言中的违规请求都会被同一条策略拒绝。
网关日志中每个被检查的钩子都会有脚本 console.log 输出的一行记录,这是确认策略按预期运行的最快方式:
qwen3guard input -> Safe []
qwen3guard output -> Unsafe [Violent]
调整策略
在正式执行之前,先用监控模式评估新策略。监控模式会记录哪些内容本会被阻断,但不真正拒绝任何请求,这是用你自己的真实流量衡量误报率最省成本的方式。
决定什么会被阻断
脚本顶部的 BLOCKING_LABELS 和 BLOCKING_CATEGORIES 就是策略的全部可调面:
Controversial是首先要做决定的标签。它标记的内容本身不违规,但可能被滥用。阻断它会提高误报率,放行它则会让边界请求通过。建议先不阻断它,并在监控模式下回看它本会拦下哪些内容。BLOCKING_CATEGORIES把执行范围收窄到策略真正覆盖的类别。例如面向法律检索工具的网关,可能会阻断除Non-violent Illegal Acts以外的所有类别——对它来说讨论这类内容正是业务本身。
这两个列表对两个钩子同时生效。如果响应需要不同于请求的策略,请为 checkOutput 单独准备一组列表,而不是共用同一组。
设置超时预算
timeout_ms 是单次钩子调用的预算,涵盖脚本自身的执行和它发起的调用,默认值为 5000。合适的取值应高于安全检查部署在你实际硬件上、针对你会检查的最长内容所测得的 p99 延迟。影响该延迟的主要有两个因素:模型规格,以及被检查文本的长度——安全检查模型会读完整段会话。
预算过紧时的失败方式与故障完全相同,因此在调小之前请先实测。预算过松则会让每个调用方都被一个已经停止应答的安全检查模型拖住。
检查流式响应
对于流式响应,AISIX 会按滑动窗口检查文本,每个窗口通过后即放行,从而保持流式客户端的响应速度。其中有两点是安全检查模型特有的:
- 每个窗口都是独立分类的,看不到答案的其余部分。在句子中间被截断的片段所携带的上下文少于完整响应,边界片段更容易被判为
Controversial。这是流式场景下不要把Controversial放进BLOCKING_LABELS的最有力理由。 - 每个窗口对应一次安全检查调用,因此一段很长的流式回答会产生多次分类。
把 stream_processing_mode 设为 buffer_full,可以改为缓存整段响应后只分类一次。这让安全检查模型拿到完整答案且只需一次调用,代价是调用方要等待完整响应。
决定模型不可达时的行为
fail_open 管请求,output_fail_open 管响应。两者默认都是 false,即阻断脚本无法检查的流量;对于一个安全检查类控制,它们都应保持这个取值:安全检查模型停止应答的时刻,正是 未经检查的内容最可能流出的时刻。
安全检查模型宕机时,网关会记录原因以及实际生效的策略:
custom guardrail script threw row=qwen3guard error=Error: error sending request for url (...)
custom guardrail script failed row=qwen3guard failure=Threw fail_open=false
如果你确实把某个钩子设为放行,绕过记录会以 custom_script_error、custom_timeout、custom_bad_verdict 或 custom_engine_error 之一写入该请求的用量事件,因此被绕过的流量仍然可以统计。
改用其他安全检查模型
任何以 OpenAI 兼容接口提供服务的安全检查模型都适用于这一模式。脚本中有三处需要修改,其余不变:
| 脚本中的位置 | 需要修改的内容 |
|---|---|
GUARD_ENDPOINT、GUARD_MODEL | 部署地址与模型名称。 |
各钩子构造的 messages | 模型期望的会话形式。Qwen3Guard 依据最后一条消息的角色推断检查提示词还是检查响应;其他模型可能使用固定的指令模板,或用 system prompt 承载策略文本。 |
classify 中的解析逻辑 | 回答的读取方式。若模型单独一行回答 safe 或 unsafe,或返回 JSON,都需要各自的解析;但要保留"无法识别的回答就抛异常"这条规则。 |
例如,Llama Guard 会回答 safe,或回答 unsafe 并在下一行给出类别代码,因此 classify 读取第一行,其余一律按失败处理。ShieldGemma 针对提示中写明的策略回答 Yes 或 No 的违规判定,因此它的 messages 需要携带该策略文本,解析逻辑读取的是这个判定。
无论使用哪种模型,都请保留 Qwen3Guard 脚本具备的四条性质:检查整个请求而不是最新一轮;不依赖 AISIX 上报的角色;任何失败都抛异常而不是返回放行;不把被检查的内容写进阻断原因。
故障排查
所有响应都被放行,包括本应被阻断的响应。 检查 checkOutput 是否在 assistant 轮次之前发送了 user 轮次。缺少它时,Qwen3Guard 分类的是一段空会话,对任何内容都会回答 Safety: Safe。可以用脚本中那种两条消息的会话直接调用安全检查端点来确认。
安全检查模型从你的工作机看是健康的,但所有请求都被 content_filter 拒绝。 脚本在网关内部运行,因此端点必须能从网关所在网络解析并访问,而不是从你的网络。网关日志能区分策略阻断和故障:故障会记录 custom guardrail script threw 以及底层错误。
安全护栏完全没有生效。 脚本编译失败在两条路径上的表现不同。AISIX Cloud 会拒绝保存,并给出行号和列号。开源 AISIX 网关则会加载文件、跳过这条安全护栏并继续提供服务——aisix validate 正是在网关发现之前拦住它的地方。两条路径上模块都必须使用 export async function。未导出的钩子会被跳过而不是报错,因此钩子名拼错会静默地关闭那个方向的检查。
相同请求得到的裁决结果不一致。 请像脚本那样在安全检查调用中设置 temperature: 0。以非零温度采样的安全检查模型会自相矛盾。
后续步骤
- 自定义脚本安全护栏——完整的脚本约定,包括内容改写和沙箱限制。
- 安全护栏行为——钩子点、执行模式、作用范围和失败策略。
- 选择安全护栏服务提供方——内置服务提供方列表,适用于不需要自建模型的策略。