前言
在前两篇文章中,我们使用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
)
模型只需传入query和top_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只负责:
- 创建模型客户端。
- 创建检索器。
- 创建工具注册器。
- 创建Agent。
- 接收用户输入。
- 调用
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循环。
十六、断点调试方法
建议依次设置断点:
completion = self.client.chat.completions.create(...)message = completion.choices[0].messageif message.tool_calls:result = self.registry.execute(...)messages.append({"role": "tool", ...})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验证和最终回答校验。
十九、工程优化建议
- 使用数据模型进一步验证JSON参数。
- 为每个工具定义明确的成功、失败和未找到状态。
- 给数据库和网络异常设置分类错误码。
- 记录每轮模型耗时、工具耗时和工具名称。
- 对连续重复工具调用设置额外终止条件。
- 限制工具返回字段,避免消息历史过长。
- 为工具Schema编写自动化一致性测试。
- 将会话历史按用户隔离。
- 对高风险工具增加权限和人工确认。
- 不要把模型参数直接拼接为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从商品库中召回候选记录。