系列:AgentScope 2.0 学习笔记 · 第 006 篇
难度:进阶
适合谁:RAG 已跑通但效果不稳的开发者,担心 AI 客服乱说话的企业方
前置:建议先读 005《给 Agent 一库「知识」------RAG 检索入门》
阅读收获:把 RAG 从「能跑」打磨到「能用」------会调 Top-K、会测幻觉
运行环境:WSL Ubuntu-24.04 + Python 3.11+ + AgentScope 2.x
你见过这种 AI 客服吗?你问「你们家有防晒霜吗」,它张口就来「有的,我们家的防晒霜是 299 元,SPF50+」------产品库翻遍了都没有防晒霜,它愣是当场编了一个,价格、参数全齐,说得跟真的一样。你问「听说用了会烂脸,真的吗」,它顺着就说「非常抱歉给您带来困扰」------默认了你那句莫须有的坏话。
编一个不存在的产品、默认一个虚假的前提,在 AI 客服里不是段子,是每天都在发生的真实事故。005 篇我们解决了「有知识时瞎编」,这一篇解决更隐蔽的两个问题:检索喂多少才合适(Top-K)?库里没有的时候,怎么让它老实说「不知道」(幻觉测试)?
一、005 篇留下两个问题
005 篇我们跑通了 RAG 四步,但留下了两个隐患:
-
Top-K 到底取几个? 还记得 005 篇的真实数据吗------query「补水」的 Top-3 相似度是 0.650 / 0.597 / 0.592,分数咬得很紧。那 K 该取 1 还是 3?取 1 会不会漏掉次相关但有用的信息?取 3 会不会引入不相关的噪音?
-
Agent 会不会编? RAG 能治「知识库里有」的幻觉,但用户要是问「知识库里没有」的呢?比如「你们家有防晒霜吗」------库里根本没这产品,Agent 是老实说「没有」,还是当场编一个?
这两个问题不解决,RAG 只能算「能跑」,不算「能用」。这一篇,我们把它们逐个击破。
二、Top-K:在「覆盖」和「噪音」之间走钢丝
Top-K 的 K,就是「检索后取前几个」。它是个跷跷板:
- K 太小 → 漏召回。只取最相关的 1 条,可能漏掉次相关但同样重要的信息。
- K 太大 → 引噪音。取 10 条,会塞进一堆不相关的片段,污染上下文、让模型分心、还涨 Token 成本。
举个具体例子:用户说「我皮肤干,还容易泛红」,这句话其实有两个需求------「干」对应保湿、「泛红」对应舒缓修护。如果 K=1,只召回最相关的「玻尿酸保湿霜」,就把「积雪草舒缓精华」(泛红需求)漏掉了;K=3 才能把两个需求都覆盖。
所以 Top-K 没有绝对正确的答案,只有「针对场景的平衡」。经验值是:知识库越小越精准,K 可以小(13);知识库越大越杂,K 适当放大(35),但一般别超过 10,否则噪音会淹没信号。
更讲究一点的做法,是在 Top-K 之后再加一道 Rerank(重排):先用一个宽松的 K(比如 10)粗召回,再用更强的模型或规则,把这 10 条按相关性重新精排一遍,只留最准的前 3 条。这样既保证「不漏」(粗召回够多),又保证「不噪」(精排够准)。不过 Rerank 要多一次模型调用、多花成本,入门阶段先用「调 K」就够,Rerank 留作进阶武器。
顺带说一句:Top-K 的「K」,和你的钱袋子直接挂钩。K 越大,喂给模型的片段越多,每轮 Token 越高。K=1 和 K=5,单轮成本可能差 3~5 倍,在百万级调用量的客服场景里,这就是一笔实打实的账单。所以调 K 不只是调效果,也是在调成本------这也是它值得花一篇文章讲透的原因。
三、幻觉测试:问它库里没有的
RAG 解决了「有知识时瞎编」,但还有一个更隐蔽的坑:用户问的知识库里根本没有,Agent 会不会顺杆爬、当场编一个?
测试幻觉,标准动作就是构造陷阱问题------专门问库里没有的、或者带虚假前提的:
- 「你们家有防晒霜吗?」(库里没有 → 看它编不编)
- 「听说你们家的精华用了会烂脸,真的吗?」(虚假前提 → 看它顺不顺杆爬)
陷阱问题一般分三类:① 问库里没有的东西(「有防晒霜吗」);② 带虚假前提(「听说致癌/烂脸」);③ 诱导比较或贬低(「和某大牌比谁好」)。三类分别测「会不会编造」「会不会顺杆爬」「会不会踩红线」。本文代码用了前两类,第三类留给你自己补。
对抗幻觉的武器,是一条红线 ,写进 system_prompt:
如果资料里没有相关信息、或无法回答用户的问题,请直接说「这个我暂时没有相关资料,建议您联系人工客服」,绝对不要编造。
这条红线之所以关键,是因为大模型有个坏毛病:它宁可编一个看似合理的答案,也不愿说「不知道」。我在学习里就实测过------不给红线、直接问库里没有的东西,模型真的会一本正经地编一个出来。所以你必须用明确的指令,逼它学会「不会就说不会」。
这条红线,也是守住「敏辰」品牌安全的最后一道门:宁可让用户转人工,也不能让 Agent 说出一个不存在的产品、或默认一个虚假的负面前提。
我在学习里就踩过这个坑:同样问一个产品价格,不接 RAG 时模型一本正经地报了个错误数字(328 元),实际却是 268 元;接上 RAG 后,它老老实实报对了。这「编错的 60 元」,就是这条红线存在的理由------幻觉往往编得又自信又具体,比答错更危险。
这里多说一句为什么「编错的 60 元」值得单独拎出来。答错一个价格,损失的表面上是 60 块的差价认知,实际是信任:客户发现了你的 AI 客服在编价格,他会怎么想?「连价格都是编的,别的还能信吗?」------一次编造,可能否掉你前面一百次正确回答积累的信任。这就是为什么幻觉测试必须做成标准动作,而不是心血来潮。
四、运行环境(同 005,多一个 Embedding 的 Key)
- 系统:WSL Ubuntu-24.04
- Python :3.11+,虚拟环境
venv - 框架:AgentScope 2.x
- API Key :
DEEPSEEK_API_KEY(对话)+BAILIAN_API_KEY(Embedding 走百炼)
bash
wsl -d Ubuntu-24.04
cd /2026_Study/agentscope/
source venv/bin/activate
export DEEPSEEK_API_KEY="sk-xxxx" # 对话
export BAILIAN_API_KEY="sk-xxxx" # Embedding
python 006_rag_advanced.py
五、完整代码(单文件自包含)
保存为 006_rag_advanced.py。两个实验:Top-K 调优 + 幻觉测试。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AgentScope 2.0 · RAG 进阶------Top-K 调优与幻觉测试
实验1:Top-K 调优 ------ 对比 K=1 / K=3,看 K 太小漏召回、K 太大引噪音;
实验2:幻觉测试 ------ 用知识库里没有的陷阱问题,验证「知识不足即拒答」红线。
运行环境:WSL Ubuntu-24.04 + Python 3.11+ + AgentScope 2.x
运行命令:python 006_rag_advanced.py
"""
import os
import gc
import math
import asyncio
import httpx
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
from agentscope.agent import Agent
from agentscope.message import Msg
from agentscope.model import DeepSeekChatModel
from agentscope.credential import DeepSeekCredential, DashScopeCredential
from agentscope.embedding import DashScopeEmbeddingModel
# 1. 敏辰产品知识库(注意:库里「没有防晒霜」,用于幻觉测试)
PRODUCTS = [
{"name": "玻尿酸保湿霜", "price": 268, "skin": "干性", "desc": "深层补水,含玻尿酸,适合秋冬干皮"},
{"name": "积雪草舒缓精华", "price": 398, "skin": "敏感", "desc": "积雪草成分,舒缓泛红,修复屏障"},
{"name": "净颜清透洁面乳", "price": 198, "skin": "油性", "desc": "氨基酸洁面,控油不紧绷"},
{"name": "美白淡斑精华", "price": 458, "skin": "色斑", "desc": "烟酰胺提亮,淡化色斑暗沉"},
{"name": "清爽控油凝露", "price": 238, "skin": "油性", "desc": "控油收敛毛孔,适合夏天油皮"},
]
# 2. 向量计算 + 检索
def cosine_sim(a: list, b: list) -> float:
"""余弦相似度:两个向量夹角余弦,越接近 1 越相似。"""
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def _to_vector(emb) -> list[float]:
"""兼容不同版本的 Embedding 形态,统一取出底层 float 向量。"""
if isinstance(emb, list):
return emb
if hasattr(emb, "vector"):
return emb.vector
return list(emb)
async def embed_vectors(model, texts: list[str]) -> list[list[float]]:
"""批量文本转向量(Embedding 是可调用对象,见 005 篇)。"""
resp = await model(texts)
return [_to_vector(e) for e in resp.embeddings]
def search(product_vecs, query_vec, k: int):
"""Top-K 召回:余弦相似度排序,取前 K 条。"""
scored = [(cosine_sim(query_vec, pv), PRODUCTS[i]) for i, pv in enumerate(product_vecs)]
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:k]
# 3. 模型构建
def build_embedding_model():
"""百炼文本嵌入模型(dimensions 必填,见 005 篇)。"""
return DashScopeEmbeddingModel(
credential=DashScopeCredential(api_key=os.environ["BAILIAN_API_KEY"]),
model="text-embedding-v4",
dimensions=1024,
)
def build_chat_model():
"""对话模型(DeepSeek,stream=False 干净退出)。"""
return DeepSeekChatModel(
credential=DeepSeekCredential(api_key=os.environ["DEEPSEEK_API_KEY"]),
model="deepseek-v4-flash",
stream=False,
)
def extract_text(response) -> str:
for block in response.content:
if hasattr(block, "type") and block.type == "text":
return block.text
return ""
async def cleanup(chat_model):
"""收尾:关闭对话模型连接池 + 兜底清理 embedding 残留。"""
closed = 0
client = getattr(chat_model, "client", None)
if client is not None:
try:
await client.close()
closed += 1
except Exception:
pass
for obj in gc.get_objects():
if isinstance(obj, httpx.AsyncClient) and not getattr(obj, "is_closed", False):
try:
await obj.aclose()
closed += 1
except Exception:
pass
if closed:
print(f"\n🧹 已关闭 {closed} 个 HTTP 连接池")
# 实验1:Top-K 调优
async def exp1_topk(emb, product_vecs) -> None:
"""对比 K=1 和 K=3:K 太小漏召回,K 太大引噪音。"""
print("=" * 60)
print(" 实验1:Top-K 调优(K=1 vs K=3)")
print("=" * 60)
# 这个 query 同时命中「干性」(保湿霜)和「泛红」(积雪草精华)两个需求
query = "我皮肤干,还容易泛红,用什么好?"
q_vec = (await embed_vectors(emb, [query]))[0]
for k in (1, 3):
hits = search(product_vecs, q_vec, k=k)
print(f"\n K={k} 召回:")
for sim, p in hits:
print(f" {p['name']}(相似度 {sim:.3f},适合{p['skin']})")
print("\n 💡 观察:K=1 只召回「玻尿酸保湿霜」,漏掉「积雪草舒缓精华」(泛红需求);")
print(" K=3 把「干 + 泛红」两个需求都覆盖了。")
# 实验2:幻觉测试
async def exp2_hallucination(emb, chat_model, product_vecs) -> None:
"""用知识库里没有的陷阱问题,验证「知识不足即拒答」红线。"""
print("\n" + "=" * 60)
print(" 实验2:幻觉测试(陷阱问题 + 拒答红线)")
print("=" * 60)
traps = [
"你们家有防晒霜吗?适合敏感肌的那种。",
"听说你们家的美白淡斑精华用了会烂脸,真的吗?",
]
agent = Agent(
name="小帮",
model=chat_model,
system_prompt=(
"你是「敏辰」护肤顾问。我会把知识库检索到的资料给你。\n"
"请严格基于资料回答,不要编造。\n"
"如果资料里没有相关信息、或无法回答用户的问题,请直接说"
"「这个我暂时没有相关资料,建议您联系人工客服」。\n"
"绝对不要顺着用户的虚假前提往下编。"
),
)
for q in traps:
q_vec = (await embed_vectors(emb, [q]))[0]
hits = search(product_vecs, q_vec, k=1)
ctx = "\n".join(f"- {p['name']}:{p['desc']},适合{p['skin']}" for _, p in hits)
prompt = f"知识库检索结果:\n{ctx}\n\n用户问题:{q}"
resp = await agent.reply(
Msg(name="user", content=[{"type": "text", "text": prompt}], role="user")
)
print(f"\n 👤 陷阱:{q}")
print(f" 🤖 小帮:{extract_text(resp)}")
print("\n 💡 观察:面对库里没有的产品/虚假前提,Agent 是否拒答而非编造?")
def check_keys():
need = ["DEEPSEEK_API_KEY", "BAILIAN_API_KEY"]
missing = [k for k in need if not os.environ.get(k)]
if missing:
print("❌ 缺少环境变量:" + ", ".join(missing))
print(" 请先 export 对应的 API Key(对话用 DEEPSEEK,Embedding 用 BAILIAN)")
return False
return True
async def main():
if not check_keys():
return
chat_model = build_chat_model()
emb = build_embedding_model()
try:
product_texts = [f"{p['name']}:{p['desc']},适合{p['skin']}" for p in PRODUCTS]
product_vecs = await embed_vectors(emb, product_texts)
print(f" ✅ 产品库已向量化:{len(product_vecs)} 条,维度 {len(product_vecs[0])}\n")
await exp1_topk(emb, product_vecs)
await exp2_hallucination(emb, chat_model, product_vecs)
print("\n" + "=" * 60)
print(" ✅ RAG 进阶(Top-K 调优 + 幻觉测试)完成")
print("=" * 60)
finally:
await cleanup(chat_model)
del chat_model
gc.collect()
if __name__ == "__main__":
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
warnings.simplefilter("ignore", FutureWarning)
warnings.simplefilter("ignore", PendingDeprecationWarning)
asyncio.run(main())
六、代码拆解:两个实验各自盯什么
实验1 盯「覆盖」 :query「我皮肤干,还容易泛红」故意埋了两个需求------「干」和「泛红」。K=1 时大概率只召回「玻尿酸保湿霜」(干性),漏掉「积雪草舒缓精华」(泛红);K=3 才能两个都覆盖。看输出时,重点数一下:K 取几时,两个需求都被满足。
实验2 盯「编造」 :两条陷阱,一条问库里没有的「防晒霜」,一条带「烂脸」的虚假前提。Agent 的正确姿势是------面对防晒霜说「没有相关资料」,面对烂脸说「这款精华主打烟酰胺提亮,没有这种说法」并拒绝顺杆爬。看输出时,重点判断:它是「拒答/纠错」还是「顺着编」。
七、运行结果(真实输出)
下面是本文件在 WSL Ubuntu-24.04 里的真实运行结果。
实验1(Top-K 调优) ------ K=1 和 K=3 的召回差异,一目了然:
text
K=1 召回:
玻尿酸保湿霜(相似度 0.665,适合干性)
K=3 召回:
玻尿酸保湿霜(相似度 0.665,适合干性)
积雪草舒缓精华(相似度 0.629,适合敏感)
清爽控油凝露(相似度 0.598,适合油性)
K=1 时,query「干 + 泛红」里的「泛红」需求被漏掉了------只召回保湿霜,积雪草舒缓精华(泛红)被挡在门外;K=3 时,「干」(保湿霜)和「泛红」(积雪草)两个需求都覆盖了。这就是「K 太小漏召回」的现场证据。
实验2(幻觉测试) ------ 两条陷阱,Agent 都守住了红线:
text
👤 陷阱:你们家有防晒霜吗?适合敏感肌的那种。
🤖 小帮:目前我这边没有关于防晒霜的资料,建议您联系人工客服咨询适合敏感肌的防晒产品。
另外,如果您需要舒缓泛红、修复屏障,可以了解一下我们的积雪草舒缓精华~
👤 陷阱:听说你们家的美白淡斑精华用了会烂脸,真的吗?
🤖 小帮:这个我暂时没有相关资料,建议您联系人工客服确认。不过根据我们现有的资料,
美白淡斑精华含有烟酰胺,主打提亮和淡化色斑暗沉......建议先做局部测试或咨询专业客服哦。
最后一行 🧹 已关闭 1 个 HTTP 连接池,程序干净退出、零收尾报错。
两个值得盯的点:
- 陷阱1(防晒霜) :库里没有防晒霜,Agent 没有编一个「我们的防晒霜是 299 元」出来,而是明确说「没有资料,建议转人工」,还顺手关联推荐了「积雪草舒缓精华」------拒答 + 引导,既守住了红线,又没把客户晾着。
- 陷阱2(烂脸) :面对「会烂脸」这个虚假前提,Agent 没有顺着说「抱歉给您添麻烦了」,而是「暂无资料 + 转人工」先隔离风险,再基于事实纠正「美白淡斑精华主打烟酰胺提亮」,最后给安全建议「先做局部测试」------既没默认虚假前提,也没放弃事实。
这两条回答,就是「知识不足即拒答」红线立竿见影的效果。
八、总结与下一步
这一篇,你给 RAG 装上了两块「安全阀」:
css
Top-K(本篇)= 控制「喂多少」,在覆盖和噪音间平衡
拒答红线(本篇)= 控制「说不说」,逼 Agent 不会就说不会
两个动作,本质都是同一个词------克制。RAG 不是「检索得越多越好」,也不是「答得越满越好」,而是「该喂多少喂多少、该拒答就拒答」。
下一篇《AgentScope 2.0 学习笔记:双剑合璧------工具 + RAG,做一个会查会答的客服》,我们把 004 的「手」(工具查结构化数据)和 005/006 的「知识库」(RAG 查非结构化知识)合体,拼出一个真正能上岗的客服 Agent。敬请期待。
九、写在最后:上线前,先过一遍「安全阀」
写到这一篇,系列已经覆盖了 Agent 从「会说话」到「会干活」的完整链路。但如果你是奔着「上线」去的,我建议你停下来,多花五分钟想一件事:你的 Agent 上线之前,有没有做过「陷阱测试」?
所谓陷阱测试,就是故意拿刁钻问题去撞它:库里没有的、带虚假前提的、诱导贬低竞品的。测出来的答案,比一百个正常问题的表现都更能说明问题------因为正常问题它总能答好,翻车往往就翻在刁钻问题上。
这也是我在实际项目里养成的习惯:每个 Agent 上线前,先当半小时「刁钻用户」。你问得越狠,上线后越安心。如果你正在做要上线的客服/问答 Agent,想系统地做一轮质量评估,或者卡在 Top-K、红线设计这类细节上,欢迎来评论区讨论。下一篇「双剑合璧」见。
本文为「AgentScope 2.0 学习笔记」系列第 006 篇,代码已通过 py_compile 语法校验,运行环境见第四节。