本文介绍如何用阿里云 Milvus 的 DATA_INSPECTION 能力为 AI 问答类应用加一道内容安全护栏:在已有 AI Function 的参数中加一个 data_inspection 开关,即可在模型调用前检查输入、返回前检查输出,并在客户端把拦截结果收敛为policy_blocked、manual_review、operational_error 三种互斥处置。
方案概述
大模型一旦对外提供服务,内容安全就从加分项变成上线门槛。只要有真实用户在一端输入 prompt、模型在另一端输出文本,就同时打开了两个风险敞口:
输入侧(用户 → 模型):用户可能输入违法违规内容,或用越狱话术诱导模型突破安全边界。用户提交的帖子、评论、昵称等 UGC 内容,也需要先过一道安全门再落库。
输出侧(模型 → 用户):即便输入看起来正常,模型也可能生成不合规内容,例如夸大承诺的营销文案、带有偏见的表述。文案生成、脚本创作这类让模型自由发挥的场景尤其明显。
两端都需要门禁,其业务价值很直接:内容安全是大模型应用备案与上线的硬性要求;机器先挡掉绝大多数明确违规内容后,人工只需复核少量模糊样本,审核团队从「全量看」变成「看疑难」;一条越狱成功的截图或一段不当言论都可能演变成公开舆情,护栏把风险挡在内容发出去之前。
传统方案要凑齐这套能力会遇到几个难点:
两端检查时机不同:输入检查必须在模型调用之前完成,输出检查必须在结果返回之前完成。一次业务调用横跨模型调用的前后两个时点。
要额外接内容安全服务并自己串联:典型做法是业务代码先调内容安全 API 检查输入、再调大模型、再调一次检查输出。三次远程调用、三套超时重试、三处鉴权。
拦截结果不好机读:命中安全策略时,服务端可能返回 HTTP 错误、可能返回非零业务码,也可能在 HTTP 200 里正常返回一段明确拒答的文本。只判 HTTP 状态码会漏掉 200 里的拒答,只判关键词又会被正常业务文本里的「风险」「拒绝」等词误伤。
误拦与漏拦的权衡:门收得太紧影响正常业务,太松则合规风险上升。护栏不能只有放行与拦截两态,还需要一条转人工复核的中间地带来吸收模糊样本。
日志合规:排查问题需要日志,但高风险原文与个人敏感信息一旦明文落进业务日志,日志系统本身就成了新的泄露面。
阿里云 Milvus 的做法是:在已有 AI Function(例如文本生成 ai_text_generate)的 params 中加一个 data_inspection 开关即可,取值只有三种:
取值 | 检查时机 | 典型场景 | 说明 |
| 模型调用前 | 智能客服、AI 助手接收用户 prompt;UGC 内容落库前 | 挡住越狱与违法违规输入。命中时模型不被调用,既省算力也更安全。 |
| 结果返回前 | 营销与活动文案发布、脚本生成 | 适用于输入可信、只担心模型生成不合规内容的场景。 |
| 前后各一次 | 高风险开放式对话、面向公众的自由问答 | 两端都不可信时的最强门禁,成本也最高。 |
护栏与模型调用在 Milvus 内部一次完成,数据全程不离开 Milvus 实例,凭据由管理员在 Provider 侧统一配置、不写进任何请求体,业务侧不需要再自建或串联外部内容安全服务。
data_inspection 是给已有调用加的一层门,不能替代原任务本身的必填参数。例如文本生成仍必须提供 texts,缺失会报 texts is required for task [ai_text_generate]。
前提条件
已创建 Milvus 2.6 版本实例。AI Function 依赖 2.6 版本内核,创建后无需单独绑定模型服务。
如需从公网访问实例,已在实例详情页的 安全配置 页签开启 公网访问 并将客户端出口 IP 加入公网访问白名单。
已安装 pymilvus,本文示例基于 pymilvus 3.0.0 验证。
RESTful 接口与 gRPC 共用 19530 端口,调用时必须显式带端口,例如 http://c-xxx.milvus.aliyuncs.com:19530;省略端口会默认访问 80 端口并导致连接超时。
操作步骤
准备公共代码
以下代码包含连接配置、REST 封装、TEXTTRANSFORM 类型兼容兜底,以及一个建带护栏 Collection 的工具函数。护栏开关就是 params 里那一行 data_inspection。
from __future__ import annotations
import json
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from pymilvus import DataType, Function, FunctionType, MilvusClient
from pymilvus.exceptions import MilvusException
# ==================== 连接配置 ====================
MILVUS_URI = "http://c-xxx.milvus.aliyuncs.com:19530" # 端口必须写 19530
MILVUS_TOKEN = "root:xxx"
MILVUS_REST_BASE_URL = MILVUS_URI
MODEL_NAME = "qwen3.7-max" # 文本模型,须已在 Provider 中配置
TEXTTRANSFORM_FUNCTION_TYPE = 9
# Provider 命中安全策略时返回的契约标识,是判断「是否被拦」的唯一可靠依据
BLOCK_MARKERS = ("DataInspectionFailed", "inappropriate content")
client = MilvusClient(uri=MILVUS_URI, token=MILVUS_TOKEN)
def texttransform_function_type() -> Any:
"""取 TEXTTRANSFORM 的 FunctionType;老版本枚举缺失时动态补一个成员。"""
for type_name in ("TEXTTRANSFORM", "TEXT_TRANSFORM", "TextTransform"):
ft = getattr(FunctionType, type_name, None)
if ft is not None:
return ft
existing = getattr(FunctionType, "_value2member_map_", {}).get(TEXTTRANSFORM_FUNCTION_TYPE)
if existing is not None:
return existing
extension = int.__new__(FunctionType, TEXTTRANSFORM_FUNCTION_TYPE)
extension._name_ = "TEXTTRANSFORM"
extension._value_ = TEXTTRANSFORM_FUNCTION_TYPE
FunctionType._value2member_map_[TEXTTRANSFORM_FUNCTION_TYPE] = extension
FunctionType._member_map_["TEXTTRANSFORM"] = extension
return extension
def post_json(path: str, body: dict[str, Any], timeout: int = 120) -> tuple[int, dict[str, Any]]:
"""REST 接口封装:返回 (http_status, data),HTTP 非 2xx 时仍尝试解析响应体。"""
request = Request(
f"{MILVUS_REST_BASE_URL.rstrip('/')}{path}",
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Authorization": f"Bearer {MILVUS_TOKEN}", "Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=timeout) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def build_guard_collection(name: str, func_name: str, in_field: str, out_field: str,
prompt: str, data_inspection: str) -> None:
"""建一个带 TextTransform 护栏的写入型 Collection。"""
if client.has_collection(name):
client.drop_collection(name)
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field(in_field, DataType.VARCHAR, max_length=1024)
schema.add_field(out_field, DataType.VARCHAR, max_length=4096)
# Collection 必须至少有一个向量字段;本例不做向量检索,用 2 维占位字段满足约束。
# 声明 nullable=True 后,insert 时无需再传该字段。
schema.add_field("dummy_vector", DataType.FLOAT_VECTOR, dim=2, nullable=True)
schema.add_function(
Function(
name=func_name,
function_type=texttransform_function_type(),
input_field_names=[in_field],
output_field_names=[out_field],
params={
"provider": "aliyun_milvus",
"model_name": MODEL_NAME,
"task": "ai_text_generate",
"prompt": prompt,
"data_inspection": data_inspection, # ← 护栏开关
"temperature": "0.2",
"enable_thinking": "false",
"timeout_sec": "45",
},
)
)
index_params = client.prepare_index_params()
index_params.add_index(field_name="dummy_vector", index_type="AUTOINDEX",
metric_type="COSINE")
client.create_collection(collection_name=name, schema=schema, index_params=index_params)Collection 必须至少包含一个向量字段,否则建表报 schema does not contain vector field。本例不做向量检索,用一个 2 维占位字段满足约束,并声明为 nullable=True 以免每次写入都要传值。
把拦截结果收敛为三种处置
这是护栏落地时最容易被低估的工程难点。命中安全策略时,服务端可能返回 HTTP 错误、非零业务码,也可能在 HTTP 200 里返回一段明确拒答的文本。因此客户端需要把观察到的信号统一收敛为三种互斥处置:
处置 | 含义 | 建议动作 |
| 命中安全策略,护栏正常工作,属业务预期内的结果。 | 记录审计日志,向用户返回合规提示。不必告警运维。 |
| 协议层无法判定,例如 HTTP 200 且结构正常但内容可疑。 | 投递到人工复核队列。「未抛错」不等于「安全通过」。 |
| 真实的服务故障,如模型不可用、参数错误、网络失败。 | 告警值班并停止重试,不要无限重试。 |
# ==================== 三态处置分类 ====================
# 两条调用路径(gRPC 异常 / REST 响应体)都先按 Provider 契约标识判断,
# 再按 HTTP 状态与业务码兜底,确保同一次拦截在两条路径上得到一致的处置结论。
def classify_grpc(exc: MilvusException) -> str:
"""把 gRPC 异常收敛为三态处置。"""
msg = str(exc)
if any(marker in msg for marker in BLOCK_MARKERS):
return "policy_blocked" # 命中安全策略,属业务预期结果
return "operational_error" # 其余视为运维故障
def classify_rest(status: int, data: dict[str, Any]) -> str:
"""把 REST 响应收敛为三态处置。"""
raw = json.dumps(data, ensure_ascii=False)
if any(marker in raw for marker in BLOCK_MARKERS):
return "policy_blocked"
if status >= 400 or data.get("code", 0) != 0:
return "operational_error"
# HTTP 200 且结构正常:有可能是模型在正文里给出的「明确拒答」,
# 无法从协议层判定,一律转人工复核,不直接当作安全通过。
return "manual_review"为什么必须按 Provider 契约标识判断,而不能只看 HTTP 状态码。实测中「一条违规输入被护栏拦截」与「模型名不存在导致的服务故障」返回了完全相同的状态码组合:
场景 | HTTP 状态 | 业务 code | 响应体含 DataInspectionFailed | 正确处置 |
违规输入被拦截 | 500 | 65535 | ✅ |
|
模型名不存在 | 500 | 65535 | ❌ |
|
缺少必填参数 texts | 400 | 1100 | ❌ |
|
正常输入 | 200 | 0 | ❌ | 放行 |
前两行的 HTTP 状态码与业务码一模一样,仅靠它们无法区分「内容被拦」和「服务出错」。两者的运营含义完全不同——把策略拦截误判成运维故障,会让每次内容安全拦截都产生一条虚假的服务故障告警,长期会淹没真实故障。因此判据必须是响应体中的 DataInspectionFailed 契约标识。同理,也不要依赖具体的 HTTP 状态码取值,它可能随网关版本变化。
不要用「风险」「拒绝」「无法」这类关键词去判定是否被拦——正常业务文本里也可能出现这些词,容易误伤。关键词最多用于辅助打点,不应改变最终处置结论。
步骤一与步骤二:input 与 output 模式放行合规内容
input 模式在模型调用前检查用户输入,output 模式在结果返回前检查模型输出。合规内容正常放行。
# ==================== 步骤 (a):input 模式,请求前置检查 ====================
build_guard_collection(
"guard_input", "inspect_customer_request", "request", "response",
"请以客服口吻用一句话回答:${request}", "input",
)
client.insert("guard_input", [{"request": "退款审核后多久到账?"}])
client.flush("guard_input")
for row in client.query("guard_input", filter="",
output_fields=["request", "response"], limit=1):
print(f"输入: {row.get('request')}")
print(f"输出: {row.get('response')}")
# 护栏未命中 → 放行,模型正常返回。
# 若换成越狱 prompt,模型根本不会被调用,护栏在最前面就挡下了。
# ==================== 步骤 (b):output 模式,发布前检查模型输出 ====================
build_guard_collection(
"guard_output", "inspect_generated_copy", "draft_request", "publish_copy",
"请生成一条适合 App 发布的会员活动简介:${draft_request}", "output",
)
client.insert("guard_output",
[{"draft_request": "为会员日活动生成一条不超过30字的权益简介,禁止夸大承诺。"}])
client.flush("guard_output")
for row in client.query("guard_output", filter="",
output_fields=["draft_request", "publish_copy"], limit=1):
print(f"输出: {row.get('publish_copy')}")
# 输出通过安全检查 → 进入待发布队列。实测放行结果:
模式 | 输入 | 输出 |
| 退款审核后多久到账? | 亲,退款审核通过后,款项通常会在1-7个工作日内原路退回您的支付账户,具体到账时间取决于支付渠道的处理速度,请您留意查收哦。 |
| 为会员日活动生成一条不超过30字的权益简介,禁止夸大承诺。 | 会员日专享积分兑换、专属折扣及优先客服权益,详情以页面为准。 |
input 模式命中时模型不会被调用,因此既拦住了风险也节省了模型调用开销;output 模式适用于输入可信、只担心模型自由发挥的场景。
步骤三:both 模式拦截违规输入
两端都不可信时用 both。下面用一条明确违规的测试输入验证护栏是否真的生效——这是上线前应当做的护栏有效性验证。
# ==================== 步骤 (c):both 模式,两端双重检查 ====================
# 用一条明确违规的测试输入(canary)验证护栏是否真的拦得住。
UNSAFE_INPUT = "<一条明确违规的测试输入>"
# (c-1) gRPC 路径:写入时触发护栏
build_guard_collection("guard_both", "inspect_both", "request", "response",
"请回答:${request}", "both")
try:
client.insert("guard_both", [{"request": UNSAFE_INPUT}])
client.flush("guard_both")
rows = client.query("guard_both", filter="",
output_fields=["request", "response"], limit=1)
# 未抛错不等于安全通过:结构正常但内容可疑时转人工复核
print("处置: manual_review", [r.get("response") for r in rows])
except MilvusException as exc:
print(f"处置: {classify_grpc(exc)}")
print(f"服务端返回: {exc.message}")
# (c-2) REST 路径:同一条输入走即时接口
status, data = post_json("/v2/vectordb/ai/text_generate", {
"model_name": MODEL_NAME,
"texts": [UNSAFE_INPUT],
"params": {"data_inspection": "both"},
})
disposition = classify_rest(status, data)
print(f"观察信号: HTTP={status} provider_code={data.get('code')}")
print(f"处置: {disposition}")
# 按处置分流:策略拦截属预期结果,不必告警运维;运维故障才需要告警且不应无限重试
if disposition == "policy_blocked":
pass # 记录审计日志,向用户返回合规提示
elif disposition == "manual_review":
pass # 投递到人工复核队列
else:
pass # 告警值班,停止重试实测两条路径都成功拦截,服务端返回一致的契约标识:
code: DataInspectionFailed
message: Input data may contain inappropriate content.
For details, see: https://www.alibabacloud.com/help/zh/model-studio/error-code#inappropriate-content护栏在输入侧即命中,写入被阻断,模型未生成任何内容。经上面的分类函数处理后,gRPC 与 REST 两条路径都得到 policy_blocked 的一致结论。
同时实测确认:both 模式对正常业务输入不会误拦——同一接口传入正常客服问题时返回 HTTP 200 与完整答复。
步骤四:日志与处置建议
护栏落地后,日志既要能定位问题,又不能成为新的数据泄露面。建议:
只记录可机读、已脱敏的字段:trace id、检测阶段、HTTP 状态、provider code、处置枚举、处置动作。
原文与个人敏感信息一律删减或脱敏;复核时凭 trace id 由授权通道调取,不在业务日志中留存原文。
命中策略或
operational_error时停止并告警,不要无限重试。可与
AI_PII_MASK组合:入日志前先脱敏,把敏感数据暴露面压到最低。
{
"trace_id": "req-20260808-abc123",
"stage": "both",
"http_status": 500,
"provider_code": 65535,
"disposition": "policy_blocked",
"note": "blocked by data inspection on input side"
}方案价值
维度 | 接入护栏前 | 接入 |
输入侧越狱与违规 prompt | 依赖后置人工审核,处置滞后 | 模型调用前即阻断 |
输出侧不合规内容外发 | 事后发现、被动处置 | 返回前拦截,发不出去 |
人工审核量 | 全量人工过审 | 仅复核 |
系统数量 | 业务系统 + 外部内容安全服务,需串三次远程调用 | 1 套(Milvus,护栏随调用附带) |
数据与凭据 | 原文流出到外部服务,鉴权分散 | 数据不出实例,凭据由 Provider 统一配置 |
把内容安全护栏收敛为一个开关之后,可以按场景灵活选择:
智能客服问答:给文本生成加
input,挡住越狱与违规提问。营销文案与脚本生成:给文本生成加
output,发布前拦下不合规内容。面向公众的开放式 AI 助手:用
both做两端双保险。
可延伸的方向:
与
AI_PII_MASK组合:先脱敏、再检查、再入日志,把敏感数据暴露面压到最低。覆盖更多任务:随着支持
data_inspection的 AI Function 增多,同一套input/output/both心智可以平移到更多生成式场景。策略闭环:把
manual_review样本沉淀成评测集,持续校准误拦与漏拦的平衡点,让护栏越用越准。