乐于分享
好东西不私藏

AI 应用还在一本正经胡说?NVIDIA NIM 双层 Guardrails 把离谱答案直接拦掉

AI 应用还在一本正经胡说?NVIDIA NIM 双层 Guardrails 把离谱答案直接拦掉

原文地址:https://dev.to/torkian/add-guardrails-so-your-ai-app-doesnt-lie-a-two-layer-approach-with-nvidia-nim-3km

RAG 应用最怕的,不是模型不会答,而是它明明没有证据,答出来却又特别像真的。

更麻烦的是,很多 Demo 看着已经“会检索、会回答”了,但一到真实使用时就开始跑偏。问校园 GPU 实验室,它能一路扯到恋爱建议;上下文里明明只写了周一到周五,它还会顺手编出周六营业时间。

这里要拆开的,是一个很小、但很实用的 NVIDIA NIM Guardrails 做法,不用引入复杂框架,只额外加两层拦截。

一层放进 Prompt 里,先把模型能回答的范围限制住。另一层借助第二次 LLM 调用做 grounding check,检查答案里的事实是不是真的有检索上下文支持;如果不支持,就统一返回 fallback。

别再只靠 RAG 自觉:检索到了上下文,不等于答案就安全

很多人在做 RAG 时,注意力常会放在 chunk 切分、向量库、top-k 以及 embedding 模型这些方面。

这些内容当然都重要,但它们解决的其实只是一个问题:模型到底能看到什么。

它没有自动解决另一个问题:模型能不能乱写。

原文里的示例是一个 USC 校园助手。前两步已经完成了 NIM 调用,基于 embedding 的检索也已经处理好了。用户提出问题后,会先召回相关性最高的校园信息,再把这些内容交给模型来作答。

但这里有两个真实风险:

风险
具体表现
为什么 RAG 拦不住
话题越界
用户问“帮我写分手短信”,模型也开始写
检索器总会返回几个最像的 chunk,但不判断问题该不该答
听起来很合理的幻觉
上下文只有周一到周五,模型编出周六开放时间
模型会补全“常识感”,而不是只复述证据

guardrails 不能只停在表述层面。

“能答什么”和“答案有没有证据”,更适合分开设成两个关卡。

最终链路很短:检索、限定范围、二次验真、再输出

这套方案的链路可以压成四步:

text
User question
-> retrieve top-k context
-> scoped prompt: answer OR fallback
-> grounding check: verify answer against context
-> ship answer OR replace with fallback

它没有引入新服务,也没有上复杂规则引擎。 核心思路是:

层次
负责什么
Retrieval
决定模型能看到哪些上下文
Scoped Prompt
限定模型只回答指定范围
Grounding Check
检查答案是否被上下文直接支持
Fallback
不满足条件时统一拒答

很多内部助手、校园助手这类应用,还有客服问答、文档问答,采用这样的复杂度已经比较合适。它并不是最高级别的安全体系,但也足够把那种“会一本正经胡说”的 Demo,朝真正可用的工具再推进一大步。

先把 NIM 和最小 RAG 环境跑起来

采用的是 NVIDIA NIM 的 OpenAI-compatible API,所以在 Python 代码里就可以直接使用 openai 客户端。

如果前两篇里的 NIM 和 RAG 代码已经有了,这一步就可以直接跳过。没有的话,也可以先用下面这段搭一个最小环境:

python
%pip install -q openai numpy
import os, getpass
from openai import OpenAI
import numpy as np
if not os.getenv("NVIDIA_API_KEY"):
os.environ["NVIDIA_API_KEY"] = getpass.getpass("Paste your NVIDIA API key (starts with nvapi-): ")
client = OpenAI(
base_url="https://integrate.api.nvidia.com/v1",
api_key=os.environ["NVIDIA_API_KEY"],
)
MODEL = "meta/llama-3.1-8b-instruct"
EMBED_MODEL = "nvidia/nv-embedqa-e5-v5"
def ask(system_prompt, user_message):
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=0.3,
max_tokens=400,
)
return response.choices[0].message.content

这里有几个点别改错:

配置
作用
base_url
指向 NVIDIA NIM 的兼容 OpenAI API 入口
NVIDIA_API_KEY
放在环境变量里,不要硬编码进 Notebook
MODEL
用于聊天回答和二次验证
EMBED_MODEL
用于把知识库和问题转成向量

接下来准备一个很小的校园知识库:

python
knowledge_base = [
{
"title": "USC AI Club meeting",
"text": "The USC AI Club meets every Thursday at 5 PM in the engineering building, room 204.",
},
{
"title": "USC GPU lab hours",
"text": "The USC GPU computing lab is open Monday to Friday from 10 AM to 6 PM.",
},
{
"title": "NVIDIA Developer Program",
"text": "USC students can join the NVIDIA Developer Program for free.",
},
{
"title": "Next USC workshop",
"text": "The next USC AI Club workshop will cover Retrieval Augmented Generation (RAG).",
},
]

再补上 embedding 和检索函数:

python
def embed_texts(texts, input_type="passage"):
response = client.embeddings.create(
model=EMBED_MODEL,
input=texts,
extra_body={"input_type": input_type},
)
return [np.array(item.embedding, dtype=np.float32) for item in response.data]
def cosine_similarity(a, b):
denom = np.linalg.norm(a) * np.linalg.norm(b)
if denom == 0:
return 0.0
return float(np.dot(a, b) / denom)
def retrieve_context(question, k=3):
q_emb = embed_texts([question], input_type="query")[0]
scored = [(cosine_similarity(q_emb, item["embedding"]), item) for item in knowledge_base]
scored.sort(key=lambda p: p[0], reverse=True)
return "\n".join(f"- {item['text']}" for _, item in scored[:k])
for item, emb in zip(
knowledge_base,
embed_texts([item["text"] for item in knowledge_base], "passage"),
):
item["embedding"] = emb

到这里,RAG 已经先能跑了,不过 guardrails 暂时还没加上。

第一层:用 Scoped Prompt 把回答范围钉死

第一层 guardrail 的思路比较直接,就是先把模型的边界明确下来,再给它配一个固定的拒答语。

python
FALLBACK = "I don't have that information — check with the USC AI Club."
SCOPED_SYSTEM_PROMPT_TEMPLATE = """You are a USC campus assistant for AI Club,
GPU lab, NVIDIA program, workshop, office hour, robotics lab, and tutoring
questions only.
Rules:
- Answer ONLY using the CONTEXT below.
- If the user asks about anything outside this scope (e.g. weather, jokes,
personal advice, code generation, general world knowledge), reply with
exactly: "{fallback}"
- If the answer is not present in the context, reply with exactly: "{fallback}"
- Do not invent names, dates, room numbers, links, passwords, schedules,
policies, or instructions that are not in the context.
CONTEXT:
{context}
"""

它不只是“提示词技巧”,更像是在把三件很具体的事落到实处:

设计
作用
有限话题列表
告诉模型这个助手到底负责什么
固定 fallback
让拒答结果稳定,后续代码更好处理
明确禁止编造
把 room number、password、schedule 这类高风险字段列出来

固定 fallback 很重要。 如果每次拒答都换一种说法,后面的日志和指标,连同前端提示、自动化测试,处理时一般都会更麻烦。从工程实现来看,宁可让它显得稍微生硬一些,也不要每次都改得太随意。

第二层:再让一个 LLM 当“验货员”

Scoped Prompt 说到底也只是请求,模型还是可能不照着来。

第二层更适宜单独拆出来:把问题先连同检索上下文、模型答案,一并交给另一次 NIM 调用,让它只判断一个问题:

答案里的每个事实,是否都能被上下文直接支持? 代码如下:

python
def answer_is_grounded(question: str, context: str, answer: str) -> bool:
verdict = ask(
system_prompt=(
"You are a strict grounding verifier. Read the CONTEXT and the "
"ANSWER. Respond with only 'yes' or 'no'. Say 'yes' if every "
"factual claim in the ANSWER is directly supported by the CONTEXT. "
"Say 'no' otherwise — including if the ANSWER adds information not "
"in the CONTEXT, even if that information sounds plausible."
),
user_message=(
f"CONTEXT:\n{context}\n\n"
f"QUESTION:\n{question}\n\n"
f"ANSWER:\n{answer}\n\n"
"Is every factual claim in the ANSWER supported by the CONTEXT?"
),
)
return verdict.strip().lower().startswith("yes")

这段代码有几个小心机:

设计
为什么这么做
只输出 yes / no
解析简单,避免模型写一长段解释
不支持就判 no
宁可拒答,也不要放出编造信息
用同一个 ask()
不引入额外框架,便于放进现有 Demo

当然,这个 verifier 本身也仍然是 LLM,所以它同样会有出错的情况。

如果放到生产系统里,不能只依靠它。还得继续叠加按字段核对这类硬校验,像房间号、日期这类信息,以及链接、金额、电话、密码这些字段,都要看它们是否确实出现在上下文里。不过把它当成一个成本不高的验货环节来使用,已经能拦下不少那种“听起来很合理”的幻觉。

把两层合起来:ask_guarded() 才是真正对外接口

现在把检索、回答、验真、拒答串起来:

python
def ask_guarded(question: str) -> str:
context = retrieve_context(question)
system_prompt = SCOPED_SYSTEM_PROMPT_TEMPLATE.format(
fallback=FALLBACK,
context=context,
)
answer = ask(system_prompt, question)
if not answer_is_grounded(question, context, answer):
return FALLBACK
return answer

测试四个问题:

python
for question in [
"When does the USC AI Club meet?",
"Can you write my breakup text?",
"What is the wifi password?",
"What are the USC GPU lab Saturday hours?",
]:
print(f"Q: {question}")
print(f"A: {ask_guarded(question)}\n")

预期结果大概是:

问题
应该发生什么
AI Club 什么时候开会
上下文有答案,正常回答
能不能帮我写分手短信
越界,返回 fallback
WiFi 密码是什么
上下文没有,返回 fallback
GPU 实验室周六几点开
上下文只写了周一到周五,不让模型脑补

真正值得看的,是第四个问题。 很多模型会顺着给出“周六不开放”这类回答,也可能直接编出一个时间,这类说法只是听上去比较合理。可要是上下文里并没有写到,严格的 grounding check 通常就会把这类回答拦下来。

这种拒答方式对用户来说,可能会显得不够热情。可放到产品这一侧看,它反而更能体现出结果的可靠性。

成本不是零:每个问题多一次模型调用

这套方案很轻,但不是没有代价。 每个用户问题至少都会多出一次 verifier 调用。这个成本会直接增加,最好先把这一点考虑清楚:

成本项
影响
延迟
用户等待时间会增加
Token
上下文、问题、答案要再喂一次
费用
每次问答多一笔模型调用
误杀
verifier 过严时可能把可回答内容拦掉

我的建议是,不要一上来就全站启用。 先在高风险场景里用,比如:

场景
为什么适合加
校园/企业政策问答
时间、地点、权限、流程不能乱编
客服知识库
答错会触发投诉或售后成本
内部 IT 助手
密码、权限、系统操作不能瞎说
医疗/金融外围问答
即使不做诊断,也不能乱给确定建议
开发文档助手
API、版本、命令一错就会浪费人半天

如果只是做个闲聊助手,未必一定要卡得这么严;可一旦答案会直接影响用户操作,那这类成本,值得付出。

真正上线前,还要补这几块

原来的方案其实是有意做得比较简单。真要上到生产环境,还得补上五件事:

补强点
建议
结构化输出
verifier 返回 JSON,例如 {"grounded": true, "reason": "..."}
确定性规则
对链接、日期、金额、房间号、邮箱等字段做精确检查
日志审计
记录被拦截的问题、答案和上下文,方便改知识库
评测集
收集越界问题、缺证据问题、容易误杀问题
灰度开关
先对部分流量启用,观察误杀率和延迟

尤其是日志。 Guardrails 拦下来的内容,不只是“失败请求”。很多时候,这类情况不只会让知识库还缺哪些内容看出来,也能说明用户到底想问什么,还能反过来看出 Prompt 在某些边界上的表述还不够清楚。

fallback 不必只当作结束来看。它很多时候也在提醒,后面还需要去补知识库、修改 Prompt,或者对产品再做调整。

这套方法适合谁:别把它神化,也别低估它

NVIDIA NIM 这套示例更有价值的,并不是它选用了某个很高级的安全框架。 恰恰相反,它的价值在于足够朴素:

你已有的东西
加什么
一个 NIM chat 调用
scoped prompt
一个 embedding 检索函数
grounding verifier
一个固定 fallback
输出兜底
一个 Python 文件
可读、可调、可复制

它更适宜把 Demo 往可控工具的方向推进,不适合拿来当作已经解决全部 AI 安全问题的方案。

真要开展严格的生产安全,还得先把权限和数据边界纳入,再把提示词注入、工具调用审批、人工复核、模型版本变更以及评测回归这些项单列覆盖。当前的 AI 应用要是还只是“检索完就直接回答”,先补上这两层,比裸跑已经强很多了。

别把 Guardrails 当补丁:它应该站在应用入口

我更愿意把这套方案理解成一个基本工程习惯: 模型不是答案会直接出去的那个口子,真正作为出口的,是 guarded function。

对外暴露的也不是 ask(),而是 ask_guarded()。用户的问题要先经过检索、范围限定,还有证据检查。之后才可以出现在界面上。

这条边界要是先立起来了,后面再补规则,做评测、留日志,以及加上人工审核,都会顺一些。

不要等到 AI 应用已经开始胡说了,再临时补安全话术。很关键。RAG 应用从第一天开始,就应该先问自己一个问题:

这句话到底有没有证据? 如果没有,就别让它说。

   创作来源:个人观点仅供参考