Qwen Function Calling实战:重构电商客服AI Agent

前言

在前两篇文章中,我们使用Python手写了一个ReAct电商客服Agent。旧版通过Action: 工具名: 参数约定模型输出,再由正则表达式提取工具名称和参数。这种方式便于理解Agent底层循环,但模型只要增加空格、改用中文冒号或输出额外说明,工具解析就可能失败。

本文基于同一个电商客服项目,使用Qwen的原生Function Calling能力重构工具调用层。新版不再从普通文本中寻找Action,而是通过message.tool_calls读取结构化工具请求,使用JSON Schema约束参数,并通过标准tool消息把执行结果返回模型。

本文重点分析Function Calling的真实调用链、工具Schema、ToolRegistry、消息结构和离线测试。RAG检索和SQL防幻觉将在后续文章单独展开,避免多个主题相互干扰。

项目效果展示

旧版ReAct核心循环。Function Calling不会取消这个循环,而是把Action和Observation升级为结构化协议。

一、旧版Action解析为什么需要重构

旧版模型按照Prompt约定输出:

text 复制代码
Thought: 我需要查询足球商品信息。
Action: query_by_product_name: 足球

Python使用正则表达式识别Action:

python 复制代码
action_re = re.compile(r"^Action: (\w+): (.*)$")

这种方案存在几个问题:

  • 工具行必须从行首开始。
  • 必须使用英文冒号。
  • 工具名和参数之间必须是冒号加空格。
  • 多个参数仍然只是一个字符串。
  • Python无法在调用前验证参数类型。
  • 模型可能输出不存在的工具名。
  • Prompt越复杂,格式越容易漂移。

例如下面几种输出对人类来说意思相同,但旧版正则不一定能识别:

text 复制代码
 Action: query_by_product_name: 足球
Action:query_by_product_name:足球
我决定调用query_by_product_name查询足球

Function Calling的改进点不是让模型"获得执行Python代码的权限",而是让模型按照接口协议返回结构化工具请求。

二、Function Calling到底是什么

Function Calling可以理解为模型与应用程序之间的一套结构化通信协议。

程序把工具说明发送给模型:

text 复制代码
工具名称
工具用途
参数名称
参数类型
必填参数
取值范围

模型根据用户问题返回:

json 复制代码
{
  "name": "search_products",
  "arguments": {
    "query": "足球",
    "top_k": 3
  }
}

随后Python程序完成:

text 复制代码
读取工具请求
→ 校验工具名
→ 解析JSON参数
→ 执行Python函数
→ 返回工具结果
→ 再次调用模型

必须记住:

模型只负责生成工具调用请求,真正的Python函数由应用程序执行。

模型既不能直接访问SQLite,也不能直接运行search_products()。如果程序没有注册或执行该工具,Function Calling不会自动产生业务结果。

三、新版项目目录

text 复制代码
function_calling_rag_agent/
│
├── main.py
├── agent.py
├── llm_client.py
├── config.py
├── database.py
│
├── tools/
│   ├── schemas.py
│   ├── registry.py
│   ├── product_tools.py
│   └── price_tools.py
│
├── rag/
│   ├── retriever.py
│   └── build_index.py
│
├── guardrails/
│   ├── evidence.py
│   └── validator.py
│
├── data/
│   ├── products.db
│   └── product_embeddings.json
│
└── tests/
    ├── test_agent_function_calling.py
    ├── test_retriever.py
    └── test_tools_and_guardrails.py

本文主要关注:

text 复制代码
main.py
agent.py
llm_client.py
tools/schemas.py
tools/registry.py
tests/test_agent_function_calling.py

RAG、数据库验证和回答校验虽然已经接入Agent,但将在后续文章详细分析。

四、百炼客户端初始化

完整代码

python 复制代码
import os

from openai import OpenAI

from config import SETTINGS


def create_client() -> OpenAI:
    return OpenAI(
        api_key=os.environ["DASHSCOPE_API_KEY"],
        base_url=SETTINGS.base_url,
    )

参数说明

参数 来源 作用
api_key 系统环境变量 百炼接口身份认证
base_url config.py OpenAI兼容接口地址

新版直接读取:

python 复制代码
os.environ["DASHSCOPE_API_KEY"]

项目不使用.env,也不依赖python-dotenv。如果变量不存在,Python会抛出KeyError。这是项目根据实际环境采用的配置方式,并不代表所有项目都必须这样设计。

调试提示

不要打印真实密钥。只需在调试器中判断:

python 复制代码
"DASHSCOPE_API_KEY" in os.environ

推荐断点放在return OpenAI(...)之前,观察SETTINGS.base_url是否正确。

五、使用JSON Schema定义工具

工具定义位于tools/schemas.py。下面以商品检索工具为例:

python 复制代码
TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "search_products",
            "description": (
                "根据名称、品牌、描述、规格或使用场景检索候选商品。"
                "结果只是候选,必须继续调用get_product_details做SQL验证。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "用户的商品需求或检索词"
                    },
                    "top_k": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 10,
                        "default": 3
                    }
                },
                "required": ["query"],
                "additionalProperties": False
            }
        }
    }
]

name的作用

name是模型返回工具请求时使用的唯一名称:

text 复制代码
search_products

它必须和ToolRegistry中的注册名称一致,否则程序会返回未知工具错误。

description的作用

description告诉模型什么时候应该使用工具。它不仅解释"能做什么",还明确指出检索结果只是候选,必须继续执行SQL验证。

工具描述会影响模型决策,但它不是安全边界。即使描述写得很严格,程序端仍然要检查参数和工具结果。

parameters的作用

parameters使用JSON Schema描述参数:

  • 参数整体是对象。
  • query必须是字符串。
  • top_k必须是整数。
  • top_k范围为1~10。
  • query是必填参数。
  • 不允许额外参数。

相比旧版字符串足球, 3,JSON参数更容易校验和扩展。

六、项目中的四个工具

新版定义了四个Function Calling工具:

工具 作用 是否返回权威事实
search_products RAG召回候选商品
get_product_details SQL查询商品详情
get_promotions SQL查询优惠政策
calculate_final_price 按数据库规则计算价格

这里故意把"检索候选"和"验证事实"拆成两个工具:

text 复制代码
search_products
→ 找到可能相关的商品ID

get_product_details
→ 根据商品ID查询权威记录

这套设计为后续防幻觉校验提供了基础。

七、Function Calling请求参数

Agent调用模型的核心代码:

python 复制代码
completion = self.client.chat.completions.create(
    model=SETTINGS.chat_model,
    messages=messages,
    tools=TOOL_SCHEMAS,
    tool_choice="auto",
    temperature=0.1,
)

model

SETTINGS.chat_model当前为:

text 复制代码
qwen-plus

messages

包含System Prompt、用户问题、模型工具请求和工具执行结果。

tools

TOOL_SCHEMAS发送给模型,让模型知道可以使用哪些函数及参数格式。

tool_choice

python 复制代码
tool_choice="auto"

表示由模型判断是调用工具还是直接回答。普通问候可以直接回复,具体商品事实则应根据System Prompt调用工具。

temperature

python 复制代码
temperature=0.1

客服和工具调用任务更重视稳定性,因此使用较低温度。温度较低不能保证参数永远正确,程序仍需进行校验。

八、message.tool_calls返回结构

模型需要工具时,不再返回:

text 复制代码
Action: search_products: 足球

而是在消息对象中返回tool_calls。逻辑结构类似:

json 复制代码
{
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "search_products",
        "arguments": "{\"query\":\"足球\",\"top_k\":3}"
      }
    }
  ]
}

需要注意,arguments通常是JSON字符串,不是已经解析好的Python字典。

三个关键字段分别是:

字段 作用
tool_call.id 标识本次工具调用
function.name 模型选择的工具名称
function.arguments JSON格式参数字符串

九、FunctionCallingAgent完整循环

核心代码

python 复制代码
class FunctionCallingAgent:
    def __init__(self, client, registry: ToolRegistry):
        self.client = client
        self.registry = registry

    def answer(self, question: str) -> str:
        messages: list[dict] = [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": question},
        ]
        evidence = EvidenceLedger()

        for _ in range(SETTINGS.max_iterations):
            completion = self.client.chat.completions.create(
                model=SETTINGS.chat_model,
                messages=messages,
                tools=TOOL_SCHEMAS,
                tool_choice="auto",
                temperature=0.1,
            )

            message = completion.choices[0].message
            messages.append(message.model_dump(exclude_none=True))

            if message.tool_calls:
                for tool_call in message.tool_calls:
                    result = self.registry.execute(
                        tool_call.function.name,
                        tool_call.function.arguments,
                    )

                    evidence.record(tool_call.function.name, result)

                    messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": tool_call.id,
                            "name": tool_call.function.name,
                            "content": json.dumps(
                                result,
                                ensure_ascii=False
                            ),
                        }
                    )
                continue

            answer = message.content or SAFE_FALLBACK
            _, safe_answer = validate_answer(
                question,
                answer,
                evidence
            )
            return safe_answer

        return "已达到最大工具调用次数,暂时无法生成可靠答案,请联系人工客服。"

输入和返回值

输入:

text 复制代码
question: str

返回:

text 复制代码
经过证据校验的中文客服答案

调用流程

text 复制代码
answer(question)
↓
创建System和User消息
↓
调用qwen-plus
↓
检查message.tool_calls
↓
有工具请求:执行工具并追加tool消息
↓
continue进入下一轮
↓
没有工具请求:校验最终回答
↓
返回安全答案

十、为什么要保存模型原始消息

python 复制代码
messages.append(message.model_dump(exclude_none=True))

工具请求消息不仅包含工具名称,还包含tool_call.id。下一步返回工具结果时,模型需要通过这个ID知道结果属于哪次调用。

如果只保存模型的文本内容而丢弃tool_calls,后续标准tool消息将无法正确关联。

exclude_none=True用于排除值为None的字段,减少无效消息内容。

十一、标准tool消息

执行工具后,项目追加:

python 复制代码
messages.append(
    {
        "role": "tool",
        "tool_call_id": tool_call.id,
        "name": tool_call.function.name,
        "content": json.dumps(result, ensure_ascii=False),
    }
)

role

text 复制代码
tool

明确表示这条消息来自外部工具,不是用户的新问题。

tool_call_id

必须与模型请求中的tool_call.id一致,用于建立请求和结果的对应关系。

name

记录本次执行的工具名称,便于调试和理解消息历史。

content

工具结果被转换为JSON字符串。ensure_ascii=False可以保留中文,而不是转换为Unicode转义序列。

旧版使用:

python 复制代码
query = f"Observation: {observation}"

新版使用标准tool角色,消息语义更加明确。

十二、ToolRegistry工具注册器

完整代码

python 复制代码
import json
from collections.abc import Callable

from tools.price_tools import calculate_final_price
from tools.product_tools import (
    get_product_details,
    get_promotions,
    search_products,
)


class ToolRegistry:
    def __init__(self, retriever):
        self._tools: dict[str, Callable[..., dict]] = {
            "search_products": (
                lambda **arguments: search_products(
                    retriever,
                    **arguments
                )
            ),
            "get_product_details": get_product_details,
            "get_promotions": get_promotions,
            "calculate_final_price": calculate_final_price,
        }

    def execute(self, name: str, arguments_json: str) -> dict:
        if name not in self._tools:
            return {
                "status": "tool_error",
                "message": f"未知工具:{name}"
            }

        try:
            arguments = json.loads(arguments_json or "{}")
            return self._tools[name](**arguments)
        except (TypeError, ValueError, json.JSONDecodeError) as error:
            return {
                "status": "tool_error",
                "message": str(error)
            }

为什么使用注册表

模型返回的是工具名称字符串:

text 复制代码
get_product_details

注册表把字符串映射为真实Python函数:

python 复制代码
self._tools[name](**arguments)

程序只允许调用字典中明确注册的函数。模型即使生成其他名称,也只能得到tool_error,不会自动执行任意Python代码。

为什么search_products使用lambda

search_products()除了模型参数,还需要程序初始化时创建的retriever对象。lambda把这个对象提前绑定:

python 复制代码
"search_products": lambda **arguments: search_products(
    retriever,
    **arguments
)

模型只需传入querytop_k,不需要了解检索器对象。

参数解析

python 复制代码
arguments = json.loads(arguments_json or "{}")

将JSON字符串转换为Python字典,再使用:

python 复制代码
self._tools[name](**arguments)

例如:

python 复制代码
{"query": "足球", "top_k": 3}

会展开为:

python 复制代码
search_products(query="足球", top_k=3)

十三、新版main.py为什么很短

完整代码

python 复制代码
from agent import FunctionCallingAgent
from llm_client import create_client, embed_texts
from rag.retriever import HybridProductRetriever
from tools.registry import ToolRegistry


def main() -> None:
    client = create_client()
    retriever = HybridProductRetriever(
        embedder=lambda texts: embed_texts(client, texts)
    )
    agent = FunctionCallingAgent(
        client=client,
        registry=ToolRegistry(retriever)
    )

    print("Function Calling + RAG 电商客服已启动,输入'退出'结束。")

    while True:
        question = input("\n请输入问题:").strip()
        if question.lower() in {"退出", "exit", "quit"}:
            break
        if question:
            print(f"\n客服回复:{agent.answer(question)}")


if __name__ == "__main__":
    main()

新版main.py只负责:

  1. 创建模型客户端。
  2. 创建检索器。
  3. 创建工具注册器。
  4. 创建Agent。
  5. 接收用户输入。
  6. 调用agent.answer(question)

旧版中的正则解析、工具执行和循环控制都移动到了职责更明确的模块。

代码变短不是唯一目标。更重要的是,FunctionCallingAgent可以被命令行、FastAPI或桌面界面重复使用,而不需要复制整段工具调度逻辑。

十四、Function Calling消息变化示例

用户输入:

text 复制代码
你们有足球吗?

初始消息:

python 复制代码
[
    {"role": "system", "content": "你是基于证据回答的电商客服"},
    {"role": "user", "content": "你们有足球吗?"}
]

模型请求检索工具后:

python 复制代码
{
    "role": "assistant",
    "tool_calls": [
        {
            "id": "call_1",
            "type": "function",
            "function": {
                "name": "search_products",
                "arguments": "{\"query\":\"足球\",\"top_k\":3}"
            }
        }
    ]
}

工具执行后追加:

python 复制代码
{
    "role": "tool",
    "tool_call_id": "call_1",
    "name": "search_products",
    "content": "{\"status\":\"candidates_found\",\"candidates\":[...]}"
}

模型看到候选商品后,继续请求get_product_details。因此,一条用户问题仍然可能包含多轮模型请求。

十五、离线模拟Function Calling测试

真实接口测试会产生网络请求和API消耗。项目使用假客户端模拟三轮响应,验证Agent循环是否正确处理标准工具消息。

测试流程

text 复制代码
第1轮模型响应
→ search_products

第2轮模型响应
→ get_product_details

第3轮模型响应
→ 最终中文答案

核心断言:

python 复制代码
self.assertIn("商品ID:001", answer)
self.assertEqual(len(completions.calls), 3)

tool_messages = [
    message
    for message in final_messages
    if message["role"] == "tool"
]

self.assertEqual(
    [message["tool_call_id"] for message in tool_messages],
    ["call-1", "call-2"]
)

这项测试验证:

  • Agent确实进行了三轮模型调用。
  • 工具结果使用tool角色。
  • 两次工具结果保留正确的tool_call_id
  • 最终答案引用了经过验证的商品ID。

它不验证Qwen模型质量,也不验证网络接口,只验证Python端Function Calling循环。

十六、断点调试方法

建议依次设置断点:

  1. completion = self.client.chat.completions.create(...)
  2. message = completion.choices[0].message
  3. if message.tool_calls:
  4. result = self.registry.execute(...)
  5. messages.append({"role": "tool", ...})
  6. answer = message.content or SAFE_FALLBACK

重点观察:

text 复制代码
messages
message.content
message.tool_calls
tool_call.id
tool_call.function.name
tool_call.function.arguments
result
evidence

当模型没有调用预期工具时,先检查:

  • System Prompt是否明确要求调用工具。
  • 工具description是否清楚。
  • JSON Schema是否正确。
  • 用户问题是否确实涉及商品事实。
  • 模型是否直接返回了message.content

十七、常见问题与解决方法

1. message.tool_calls为空

原因:模型判断可以直接回答,工具描述不清楚,或当前模型不支持相应工具调用协议。

解决:检查System Prompt、tools参数和模型能力;打印message.model_dump()查看完整响应。

2. arguments不是Python字典

原因:function.arguments通常是JSON字符串。

解决:使用json.loads()解析,不要直接执行字符串。

3. JSON参数解析失败

原因:模型返回无效JSON或参数为空。

解决:捕获json.JSONDecodeError,把错误作为工具结果返回模型。

4. 模型调用不存在的工具

原因:模型生成的名称和注册表不一致。

解决:注册表执行前检查name in self._tools,禁止动态导入未知函数。

5. 工具参数名称不匹配

原因:Schema使用product_ids,Python函数却使用其他参数名。

解决:保持Schema和函数签名一致,并添加离线工具调用测试。

6. 缺少tool_call_id报错

原因:标准tool消息无法关联模型发起的工具请求。

解决:原样使用tool_call.id,不要自己重新生成。

7. 工具执行后模型仍重复调用

原因:工具结果含义不清楚,或者没有把模型工具请求和工具结果完整加入messages

解决:检查最后两条消息,确认assistant工具请求和tool结果都存在。

8. Function Calling进入无限循环

原因:模型始终调用工具但不生成最终答案。

解决:保留max_iterations,记录每轮工具名称并分析重复原因。

9. 普通问候也调用商品工具

原因:System Prompt对工具使用条件规定过于宽泛。

解决:明确普通问题可以直接回答,商品事实问题才需要工具。

10. 低温度仍然传错参数

原因:温度只影响随机性,不能替代参数校验。

解决:保留JSON Schema、函数签名检查和业务范围验证。

11. ToolRegistry捕获不到异常

原因:当前只捕获部分参数和JSON异常,工具内部还可能出现数据库或网络异常。

解决:根据业务增加受控异常处理和日志,但不要用空except隐藏错误。

12. 真实接口测试通过但离线测试失败

原因:假消息对象结构与SDK对象不一致。

解决:让FakeMessage实现代码实际使用的属性和model_dump()方法。

十八、旧版与新版对比

对比项 旧版ReAct 新版Function Calling
工具说明 System Prompt自然语言 JSON Schema
工具请求 Action:文本 message.tool_calls
参数 普通字符串 JSON字符串
解析方式 正则和字符串拆分 SDK结构化字段
工具结果 Observation:用户消息 标准tool消息
请求关联 无调用ID tool_call_id
工具注册 tools字典 ToolRegistry
主循环 集中在main.py 封装在agent.answer()
参数约束 主要依赖Prompt JSON Schema加程序校验
测试方式 难以模拟文本变化 可模拟结构化工具消息

Function Calling提高了协议稳定性,但并不等于业务数据一定正确。模型仍可能选错工具、传错商品ID或忽略优惠条件,因此后续还需要RAG检索、SQL验证和最终回答校验。

十九、工程优化建议

  1. 使用数据模型进一步验证JSON参数。
  2. 为每个工具定义明确的成功、失败和未找到状态。
  3. 给数据库和网络异常设置分类错误码。
  4. 记录每轮模型耗时、工具耗时和工具名称。
  5. 对连续重复工具调用设置额外终止条件。
  6. 限制工具返回字段,避免消息历史过长。
  7. 为工具Schema编写自动化一致性测试。
  8. 将会话历史按用户隔离。
  9. 对高风险工具增加权限和人工确认。
  10. 不要把模型参数直接拼接为SQL语句或系统命令。

这些建议属于后续工程化方向,当前项目主要完成Function Calling、RAG、SQL复核和基础证据校验。

二十、总结

本文将旧版电商客服Agent从Action文本协议重构为Qwen原生Function Calling。新版通过JSON Schema向模型描述工具,使用message.tool_calls读取结构化请求,通过ToolRegistry解析参数并执行Python函数,再使用带有tool_call_id的标准tool消息返回结果。

重构后,main.py不再承担正则解析和工具调度,只负责创建客户端、检索器、注册器和Agent。真正的多轮循环被封装到FunctionCallingAgent.answer()中,因此相同Agent可以更方便地接入命令行、Web接口或桌面界面。

Function Calling解决的是工具通信格式问题,并不能自动保证商品事实正确。下一篇将继续分析Embedding、余弦相似度和关键词评分,说明项目如何通过混合RAG从商品库中召回候选记录。

相关推荐
蜜桃味女焊匠人1 小时前
焊接机器人对比人工焊接,工厂该怎么选?
人工智能·经验分享·其他·机器人
0566461 小时前
agent学习Day23——文件上传与 Embedding 语义检索
python·学习·embedding
leisoo80971 小时前
筹码分布数据分析实战:用Python构建主力建仓成本分析系统
python·数据挖掘·数据分析
2601_955759411 小时前
企业 Claude API 权限治理常见问题解答
人工智能
淼澄研学1 小时前
PyTorch 2.0 核心机制解析与5个实操方法
人工智能·pytorch·python
智购科技智能售货柜1 小时前
自动售货机商品识别YOLO模型训练实战:从6万张图片到98%识别率的完整复盘~YH
运维·服务器·数据库·人工智能·redis·物联网·yolo
_codemonster1 小时前
手语识别及翻译项目实战系列--认识CSL2018数据集
人工智能·深度学习
ACP广源盛139246256731 小时前
2026 PCIe互连芯片@ACP#国产替代格局解析:芯动科技领跑高端交换芯片赛道
大数据·网络·数据库·人工智能·分布式·嵌入式硬件
DevNo1 小时前
2026年校园AI课堂系统应用观察与选型思路
人工智能