全部产品
Search
文档中心

向量检索服务 Milvus 版:通过阿里云Milvus的DATA_INSPECTION为AI问答构建内容安全护栏

更新时间:Aug 13, 2026

本文介绍如何用阿里云 Milvus 的 DATA_INSPECTION 能力为 AI 问答类应用加一道内容安全护栏:在已有 AI Function 的参数中加一个 data_inspection 开关,即可在模型调用前检查输入、返回前检查输出,并在客户端把拦截结果收敛为policy_blocked、manual_review、operational_error 三种互斥处置。

方案概述

大模型一旦对外提供服务,内容安全就从加分项变成上线门槛。只要有真实用户在一端输入 prompt、模型在另一端输出文本,就同时打开了两个风险敞口:

  • 输入侧(用户 → 模型):用户可能输入违法违规内容,或用越狱话术诱导模型突破安全边界。用户提交的帖子、评论、昵称等 UGC 内容,也需要先过一道安全门再落库。

  • 输出侧(模型 → 用户):即便输入看起来正常,模型也可能生成不合规内容,例如夸大承诺的营销文案、带有偏见的表述。文案生成、脚本创作这类让模型自由发挥的场景尤其明显。

两端都需要门禁,其业务价值很直接:内容安全是大模型应用备案与上线的硬性要求;机器先挡掉绝大多数明确违规内容后,人工只需复核少量模糊样本,审核团队从「全量看」变成「看疑难」;一条越狱成功的截图或一段不当言论都可能演变成公开舆情,护栏把风险挡在内容发出去之前。

传统方案要凑齐这套能力会遇到几个难点:

  1. 两端检查时机不同:输入检查必须在模型调用之前完成,输出检查必须在结果返回之前完成。一次业务调用横跨模型调用的前后两个时点。

  2. 要额外接内容安全服务并自己串联:典型做法是业务代码先调内容安全 API 检查输入、再调大模型、再调一次检查输出。三次远程调用、三套超时重试、三处鉴权。

  3. 拦截结果不好机读:命中安全策略时,服务端可能返回 HTTP 错误、可能返回非零业务码,也可能在 HTTP 200 里正常返回一段明确拒答的文本。只判 HTTP 状态码会漏掉 200 里的拒答,只判关键词又会被正常业务文本里的「风险」「拒绝」等词误伤。

  4. 误拦与漏拦的权衡:门收得太紧影响正常业务,太松则合规风险上升。护栏不能只有放行与拦截两态,还需要一条转人工复核的中间地带来吸收模糊样本。

  5. 日志合规:排查问题需要日志,但高风险原文与个人敏感信息一旦明文落进业务日志,日志系统本身就成了新的泄露面。

阿里云 Milvus 的做法是:在已有 AI Function(例如文本生成 ai_text_generate)的 params 中加一个 data_inspection 开关即可,取值只有三种:

取值

检查时机

典型场景

说明

input

模型调用前

智能客服、AI 助手接收用户 prompt;UGC 内容落库前

挡住越狱与违法违规输入。命中时模型不被调用,既省算力也更安全。

output

结果返回前

营销与活动文案发布、脚本生成

适用于输入可信、只担心模型生成不合规内容的场景。

both

前后各一次

高风险开放式对话、面向公众的自由问答

两端都不可信时的最强门禁,成本也最高。

护栏与模型调用在 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 里返回一段明确拒答的文本。因此客户端需要把观察到的信号统一收敛为三种互斥处置:

处置

含义

建议动作

policy_blocked

命中安全策略,护栏正常工作,属业务预期内的结果。

记录审计日志,向用户返回合规提示。不必告警运维。

manual_review

协议层无法判定,例如 HTTP 200 且结构正常但内容可疑。

投递到人工复核队列。「未抛错」不等于「安全通过」。

operational_error

真实的服务故障,如模型不可用、参数错误、网络失败。

告警值班并停止重试,不要无限重试。

# ==================== 三态处置分类 ====================
# 两条调用路径(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

✅

policy_blocked

模型名不存在

500

65535

❌

operational_error

缺少必填参数 texts

400

1100

❌

operational_error

正常输入

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')}")
# 输出通过安全检查 → 进入待发布队列。

实测放行结果:

模式

输入

输出

input

退款审核后多久到账?

亲,退款审核通过后,款项通常会在1-7个工作日内原路退回您的支付账户,具体到账时间取决于支付渠道的处理速度,请您留意查收哦。

output

为会员日活动生成一条不超过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"
}

方案价值

维度

接入护栏前

接入 DATA_INSPECTION 后

输入侧越狱与违规 prompt

依赖后置人工审核,处置滞后

模型调用前即阻断

输出侧不合规内容外发

事后发现、被动处置

返回前拦截,发不出去

人工审核量

全量人工过审

仅复核 manual_review 少量样本

系统数量

业务系统 + 外部内容安全服务,需串三次远程调用

1 套(Milvus,护栏随调用附带)

数据与凭据

原文流出到外部服务,鉴权分散

数据不出实例,凭据由 Provider 统一配置

把内容安全护栏收敛为一个开关之后,可以按场景灵活选择:

  • 智能客服问答:给文本生成加 input,挡住越狱与违规提问。

  • 营销文案与脚本生成:给文本生成加 output,发布前拦下不合规内容。

  • 面向公众的开放式 AI 助手:用 both 做两端双保险。

可延伸的方向:

  • 与 AI_PII_MASK 组合:先脱敏、再检查、再入日志,把敏感数据暴露面压到最低。

  • 覆盖更多任务:随着支持 data_inspection 的 AI Function 增多,同一套 input / output / both 心智可以平移到更多生成式场景。

  • 策略闭环:把 manual_review 样本沉淀成评测集,持续校准误拦与漏拦的平衡点,让护栏越用越准。