刘胡子大叔头像

工具调用与知识库检索协同实现

AgentScope 2.0 客服智能体:工具调用与知识库检索协同实现

工具和 RAG 单独用都会了,合起来做一个客服,问题才真正开始。

这篇给的是一份能跑的单文件实现,同时讲清楚一件事:当工具和知识库对同一件事给出不同答案时,会发生什么。

代码在 WSL Ubuntu 24.04 / Python 3.12 实测通过。

一、环境

pip install agentscope httpx
export BAILIAN_API_KEY="sk-xxx"
export DEEPSEEK_API_KEY="sk-xxx"

嵌入用 text-embedding-v4(1024 维),对话用 deepseek-v4-flash,stream=False。

二、数据结构

六个产品,同时带结构化字段和非结构化描述。

PRODUCTS = [
    {"name": "玻尿酸保湿霜", "price": 268, "stock": 50, "skin": "干性",
     "desc": "深层补水,含玻尿酸,适合秋冬干皮"},
    {"name": "积雪草舒缓精华", "price": 398, "stock": 30, "skin": "敏感",
     "desc": "积雪草成分,舒缓泛红,修复屏障"},
    {"name": "净颜清透洁面乳", "price": 198, "stock": 80, "skin": "油性",
     "desc": "氨基酸洁面,控油不紧绷"},
    {"name": "美白淡斑精华", "price": 458, "stock": 20, "skin": "色斑",
     "desc": "烟酰胺提亮,淡化色斑暗沉"},
    {"name": "清爽控油凝露", "price": 238, "stock": 60, "skin": "油性",
     "desc": "控油收敛毛孔,适合夏天油皮"},
    {"name": "舒缓修护面膜", "price": 218, "stock": 45, "skin": "敏感",
     "desc": "镇静舒缓,敏感肌可用,晒后修护"},
]

注意:这个 demo 里工具和 RAG 读的是同一个 PRODUCTS。这一点很关键,第六节会专门讲。

三、工具:查结构化字段

class QueryProductTool(ToolBase):
    """查询敏辰产品的价格、库存、适合肤质(结构化数据)。"""

    name = "query_product"
    description = "查询敏辰产品的价格、库存、适合肤质。当用户问到具体产品的价格、有没有货时使用。"
    input_schema = {
        "type": "object",
        "properties": {
            "product_name": {"type": "string", "description": "产品名称或关键词"},
        },
        "required": ["product_name"],
    }
    is_concurrency_safe = True
    is_read_only = True

    async def check_permissions(self, tool_input, context) -> PermissionDecision:
        return PermissionDecision(behavior=PermissionBehavior.ALLOW, message="只读查询,放行。")

    async def call(self, product_name: str) -> ToolChunk:
        """精确匹配 → 模糊匹配 → 未找到。"""
        if product_name in [p["name"] for p in PRODUCTS]:
            p = next(x for x in PRODUCTS if x["name"] == product_name)
            result = f"{p['name']}:{p['price']}元,库存{p['stock']}件,适合{p['skin']}肤质"
        else:
            matches = [p for p in PRODUCTS if product_name in p["name"] or p["name"] in product_name]
            if matches:
                lines = [f"- {p['name']}:{p['price']}元,适合{p['skin']}" for p in matches]
                result = "相关产品:\n" + "\n".join(lines)
            else:
                result = f"未找到「{product_name}」"
        return ToolChunk(content=[TextBlock(text=result)])

is_read_only = True 不只是给框架看的标记。真实项目里用它可以做两件事:并发安全(同一个 Agent 可以并行调)、以及审计(只读操作不需要记变更日志)。

四、RAG:把描述拼成可检索的块

def retrieve(product_vecs, query_vec, k: int = 2):
    """RAG 检索:返回最相关的 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]

块的内容由 name + desc 拼成。注意这里没有拼价格——这是有意的,第六节解释原因。

五、组装:工具在 toolkit,知识在 prompt

这是整个实现最关键的一行决定。

async def build_customer_agent(chat_model, context: str):
    toolkit = Toolkit()
    toolkit.register_tool_function(QueryProductTool())

    agent = Agent(
        name="小帮",
        model=chat_model,
        toolkit=toolkit,
        system_prompt=(
            "你是「敏辰」的护肤顾问小帮。\n"
            "你可以使用工具 query_product 查询产品的价格、库存、肤质。\n"
            "当用户问到具体产品的价格、有没有货时,请先调用工具查询。\n"
            "如果用户的问题在本轮参考资料里没有答案,礼貌说明并建议转人工。"
        ),
    )
    return agent

context 是检索结果,拼进每一轮的 user 消息里,不是放在 system 里。

为什么?因为检索结果是这一轮的事,不是这个人的设定。放 system 里会导致两个后果:多轮对话时旧知识一直挂着,以及缓存失效(system 变了整段前缀缓存就废了)。

缓存这一点值得多说两句。主流对话 API 都支持前缀缓存:如果这次请求的前 N 个 token 和上次完全一致,这部分按缓存价计费,通常便宜一个数量级。

system 消息永远在最前面,是缓存命中率最高的一段。如果每次检索都把新内容塞进去,system 每次都变,缓存全废——一次请求的输入成本可能翻五到十倍。

而 user 消息在末尾,前面那大段 system 保持不变,缓存照常命中。

具体到代码:

# 每轮都做:检索,然后拼进 user 消息
qv = (await embed_vectors(emb, [question]))[0]
ctx = "\n".join(f"- {p['name']}|{p['desc']}" for _, p in retrieve(product_vecs, qv, k=2))

msg = Msg("user", f"参考资料:\n{ctx}\n\n用户问题:{question}", role="user")

system_prompt 从头到尾一个字没变。检索命中与否、命中几条,都不影响它。

还有一个附带好处:多轮对话时,历史 user 消息里带着当时检索到的内容,模型能回溯"上一轮我说的是哪个产品",而不是被最新一次检索覆盖。

六、三类问题,三种打法

跑三类问题验证分工是否正确。

Case 1,问价格 → 走工具:

👤 用户:积雪草舒缓精华多少钱?有货吗?
🤖 小帮:为您查询到积雪草舒缓精华的信息:
💰 价格:398元   📦 库存:有货(30件)   🧴 适合肤质:敏感肤质

Case 2,问建议 → 走知识库:

👤 用户:我皮肤容易泛红,有什么能修复屏障的?
🤖 小帮:根据您的需求,我为您推荐积雪草舒缓精华:含有积雪草成分,能舒缓泛红、
修复皮肤屏障,非常适合敏感肤质……还可以搭配舒缓修护面膜,主打镇静舒缓。

Case 3,两者都要 → 接力:

👤 用户:我皮肤干还起皮,帮我推荐个产品,顺便说下价格。
🤖 小帮:玻尿酸保湿霜 💰 价格:268元 📦 库存:有货(50件)🧴 适合干性肤质。
这款保湿霜含玻尿酸,能深层补水、缓解干燥起皮,非常适合您目前的情况。

三条都对。但请仔细看 Case 3 的输出结构:

前半句是工具的原样返回,后半句是知识库的描述。模型把两段不同来源的内容拼成了一句话。

这个拼接做得太自然了,用户完全看不出这是两个数据源。

平时这是优点——体验连贯。但当两个数据源矛盾时,这种"自然拼接"会变成灾难。这就是第六节的实验。

七、复现数据漂移

真实系统里,价格在数据库,产品说明在文档。两份独立维护,一定会不一致。

把 demo 改成模拟真实状态:

# 数据库里已经改价(运营改的)
PRODUCTS = [
    {"name": "玻尿酸保湿霜", "price": 258, "stock": 50, "skin": "干性",
     "desc": "深层补水,含玻尿酸,适合秋冬干皮"},
    # ...
]

# 知识库文档是三个月前写的,价格没跟上
DOC_CHUNKS = [
    "玻尿酸保湿霜,268 元,含玻尿酸,深层补水,适合秋冬干皮",
    "积雪草舒缓精华,398 元,积雪草成分,舒缓泛红,修复屏障",
]

工具查数据库返回 258,知识库检索命中的文档说 268。

问同一句话,看输出。

判断标准只有一条:回答里出现了几个价格。

出现一个(258 或 268),说明它选了。出现两个,说明它在如实汇报矛盾。

实测里第二种很常见,典型形态是这样:

玻尿酸保湿霜是 258 元。另外根据我们的产品资料,这款售价 268 元,深层补水,适合秋冬干皮。

这句话里每个字都是真的。两个数字都真实存在于系统里。模型没有幻觉,它只是把两份矛盾的数据都念了一遍。

但客户看到这句话的反应不会是"这个 AI 很诚实",而是"这家店自己都不知道卖多少钱"。

这比编造一个价格更难处理。 编造你能一眼识破,念出两个真实数字,你只会觉得这家公司管理混乱。

八、修法:把重叠区物理删掉

我先试的是软方案:在提示词里加一句"价格以工具结果为准,忽略资料中的价格"。

能缓解,但不稳。因为模型看到两个数字时,倾向于都提一下——从信息完整性角度看,它的做法其实没错。

有效的是硬方案:知识库文档里不留任何价格。

# 改完的知识库:只留解释性内容
DOC_CHUNKS = [
    "玻尿酸保湿霜,含玻尿酸,深层补水,适合秋冬干皮",
    "积雪草舒缓精华,积雪草成分,舒缓泛红,修复屏障",
]

三条规则,可以照着执行:

规则一:工具能精确返回的字段,知识库一个都不许有。

价格、库存、发货时间、优惠券面额、会员折扣。全部从文档里删干净。

规则二:会变的都不进知识库。

"这个成分是烟酰胺"不会变,"这款 458 元"明天就可能变。知识库的更新成本高(重新切分、重新向量化、重新验召回),只放半年内不会动的内容。

规则三:同一件事只能有一个入口。

问价格永远走工具,问功效永远走知识库。不存在"两边都能答"的灰色地带。这是前两条的结果。

九、重叠率:一个可量化的检查

怎么知道有没有切干净?算一个数。

拿 50 条真实用户问话,一条条跑,数有多少条同时触发了工具调用和知识库命中。

async def overlap_rate(queries, agent, emb, product_vecs):
    """统计同时命中工具和知识库的问话占比。"""
    both = 0
    for q in queries:
        qv = (await embed_vectors(emb, [q]))[0]
        hit_rag = retrieve(product_vecs, qv, k=2)[0][0] > 0.55   # 阈值按你的库调
        hit_tool = any(p["name"] in q for p in PRODUCTS)          # 简易判断
        if hit_rag and hit_tool:
            both += 1
    return both / len(queries)

判断标准:

重叠率含义处理
< 10%正常这部分是"既要查又要答"的复合问题,本该两边都用
10% ~ 30%可疑逐条看,多半是文档里混进了数字
> 30%知识库成了数据库的复印件先删数据,别调提示词

我第一次算是 42%。其中一部分是文档里带价格,更大一部分是文档写成了宣传页——规格、活动力度、赠品全塞进去了。

删完降到 8%。

这个指标的好处是不依赖主观判断。不用争论"这条该不该进知识库",跑一遍就有答案。

十、为什么 RAG 不做成工具

这是个容易做错的决定。

既然工具能让模型自己判断"什么时候查订单",为什么不让它判断"什么时候查知识库"?代码更统一,都是 toolkit 里的一个 item。

我试过,会翻车。但原因不是很多人说的"模型有时候忘了查"。

真实原因是决策点从 1 个变成 2 个,错误面跟着放大。

只放工具的时候,模型只有一个二值决策:这次要不要调工具。错的情况有 1 种。

把 RAG 也做成工具,就有两个二值决策:要不要检索、要不要调工具。四种组合,其中 2 到 3 种是错的(该检索没检索、该调工具去检索、两个都不做)。

而"该不该检索"恰恰是最不该交给模型判断的——用户问任何问题,你都希望它有知识储备。检索应该是无条件发生的。

所以固定成这样:

  • RAG 在 Agent 外部,每次强制注入。 确定性。
  • 工具在 Agent 内部,由模型决定。 概率性。

一个无条件,一个有条件的。把无条件的那个做成可选项,等于用确定性换概率。

十一、常见报错

报错原因处理
KeyError: 'BAILIAN_API_KEY'环境变量没导出重新 export,或确认 shell 会话
工具永远不被调用description 没写使用场景补"当用户问到……时使用"
工具被频繁误调描述太宽泛加上"不查询……"的边界
回答里出现两个价格知识库文档带价格删文档里的价格,见第八节
多轮对话后知识串味检索结果放进了 system移到 user 消息
Unclosed client session连接池没关末尾调 cleanup()
工具返回"未找到"但产品存在名称不完全匹配检查模糊匹配分支

十二、接口速查

类 / 方法关键参数作用
ToolBasename、description、input_schema自定义工具的基类
ToolBase.call()按 input_schema 定义工具实际执行逻辑
is_read_onlyTrue / False并发安全 + 审计依据
Toolkitregister_tool_function()注册工具
Agenttoolkit、system_prompt组装 Agent
retrieve()kRAG 检索,见 006 篇

十三、排查顺序

做完这套,我建议的排查顺序是这样的:

第一步,看数据是不是同源。 工具和 RAG 读同一张表的话,你还没这个问题,但迟早会遇到——因为知识库迟早要接文档。

第二步,算重叠率。 超过 30% 先删数据。

第三步,看知识库里有没有会变的字段。 价格、库存、活动。有就删。

第四步,才看提示词。 提示词能缓解症状,治不了根因。因为模型看到两份矛盾的数据时,"都告诉你"是它的默认行为,而且从它的角度看这是对的。

下一篇把单个 Agent 拆成一队,讲意图路由和职责边界怎么划。


代码已通过 py_compile 语法校验。运行环境:WSL Ubuntu 24.04 / Python 3.12 / agentscope 2.x。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/sangev5/article/details/169445452

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--