AgentScope 2.0 学习笔记:RAG 进阶——Top-K 调优与幻觉测试(让 Agent 敢说「不知道」)

系列: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 四步,但留下了两个隐患:

  1. Top-K 到底取几个? 还记得 005 篇的真实数据吗------query「补水」的 Top-3 相似度是 0.650 / 0.597 / 0.592,分数咬得很紧。那 K 该取 1 还是 3?取 1 会不会漏掉次相关但有用的信息?取 3 会不会引入不相关的噪音?

  2. 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 KeyDEEPSEEK_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. 陷阱1(防晒霜) :库里没有防晒霜,Agent 没有编一个「我们的防晒霜是 299 元」出来,而是明确说「没有资料,建议转人工」,还顺手关联推荐了「积雪草舒缓精华」------拒答 + 引导,既守住了红线,又没把客户晾着
  2. 陷阱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 语法校验,运行环境见第四节。

相关推荐
yume_sibai1 小时前
07-Rust 异步编程完全指南(async/await + Tokio + Future + 并发原语 + 异步流)
开发语言·后端·rust
2601_953988071 小时前
Ricon组态 - 让数据可视化如此简单
运维·后端·物联网·数学建模·前端框架·c4前端
jsl_jsl_jsl1 小时前
操作系统速记
后端
geovindu1 小时前
java: Strategy Pattern
java·开发语言·后端·设计模式·策略模式·行为模式
garmin Chen1 小时前
MySQL精简面试题
数据库·后端·mysql·面试
中趴菜1 小时前
robots.txt 规则误拦排查实战指南
后端
落木萧萧8251 小时前
Wrapper、Criteria、Specification:查询能不能写得好维护一点
数据库·后端·架构
步行cgn1 小时前
构造注入详解:Spring 依赖注入的首选方式
后端
吃饱了得干活1 小时前
Spring Boot + Redis 分布式锁:一个订单重复提交引发的六次迭代
java·redis·后端