1. 为什么你需要掌握Qwen的FunctionCall?

如果你正在用大模型做点实际的东西,比如做个智能客服、数据分析助手,或者想让你写的程序能“上网查天气”、“查股票”,那你肯定绕不开一个核心功能:工具调用。简单说,就是让大模型不仅能“说”,还能“做”——它能根据你的指令,去调用你预先写好的函数,执行具体的任务,然后把结果拿回来,再组织成你能听懂的话告诉你。

Qwen(通义千问)在这方面做得相当不错,它提供了一套完整的FunctionCall机制。但说实话,我刚开始看官方文档和那个chat_template的时候,头也是大的。一堆XML标签、特殊的token、复杂的消息结构……感觉像在解谜。但别怕,这东西一旦搞明白了,你会发现它其实设计得很巧妙,用起来非常顺手。

这篇文章,我就想把我自己踩过的坑、试出来的最佳实践,用最直白的方式分享给你。我们不谈那些空洞的理论,就从一个完整的、可以跑起来的代码例子出发,一步步拆解:怎么定义函数、怎么构建对话、怎么解析模型的输出、怎么执行函数并把结果喂回给模型,最后形成一个能自动工作的循环。更重要的是,我们还会聊聊当对话轮次多了,上下文越来越长时,怎么优化性能,让你的应用既聪明又敏捷。

2. 第一步:彻底搞懂Qwen的对话模板

很多朋友卡在第一步:那个apply_chat_template到底干了啥?为什么我的消息一传进去,出来一堆奇怪的标签?我们来把它扒开看。

2.1 模板的核心逻辑:有工具和没工具,是两套剧本

Qwen的聊天模板是个“智能导演”。它看你提供的messages列表和可选的tools参数,来决定这场“戏”怎么演。

场景一:没有提供工具(普通聊天) 这时候模板很简单,就是按角色(system, user, assistant)把历史对话用<|im_start|><|im_end|>标签包起来,串成一个大字符串。这和你用其他聊天模型差不多。

场景二:提供了工具(开启FunctionCall) 这才是重头戏。模板会做一件关键的事:在系统提示(system prompt)里,插入完整的工具使用说明书。这个说明书包括:

  1. 工具列表:把所有你定义的函数,用JSON格式详细描述(名字、功能、参数要求),放在<tools>标签里。
  2. 调用格式:明确告诉模型:“你想调用工具时,必须严格按照<tool_call>标签包裹一个JSON对象来输出,JSON里要有namearguments。”

我们来看个具体的例子。假设我们定义了一个查天气的函数,然后用户问:“北京今天多少度?”

经过tokenizer.apply_chat_template处理后的文本,开头部分会是这样的:

<|im_start|>system
You are a helpful assistant.
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "get_weather", ...}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></toolcall> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
<|im_start|>user
北京今天多少度?<|im_end|>
<|im_start|>assistant

看到了吗?模型在生成回答之前,已经清晰地“知道”它手头有什么工具,以及调用工具的“标准格式”。这就像你给了助理一份工作手册和一张标准申请表。

2.2 消息历史的特殊处理:工具调用与结果也是对话的一部分

当对话进行到多轮,并且涉及工具调用时,模板对消息的处理就更精细了。它要区分几种特殊的消息角色:

  • assistant消息里包含tool_calls:这表示模型上一轮输出了工具调用。模板会把tool_calls列表里的每个调用,转换成<tool_call>标签包裹的JSON,拼接到assistant的内容后面。
  • tool消息:这表示函数执行后的返回结果。模板会把多个连续的tool消息的content(通常是JSON字符串),用<tool_response>标签包起来,合并成一个user角色的消息块。这是因为在Qwen的设计里,工具执行结果是以“用户告知”的形式反馈给模型的。

这个设计非常关键,它保证了整个工具调用的“对话历史”是完整、格式化的,模型能基于之前所有的操作和结果进行后续推理。

3. 手把手实战:构建一个天气查询机器人

光说不练假把式。我们用一个完整的、可运行的例子,把整个流程串起来。目标:做一个能回答今天和未来某天天气的智能助手。

3.1 定义你的“武器库”:函数与工具描述

首先,我们得准备几个函数,并按照Qwen要求的格式描述它们。

import json

# 1. 真正的函数实现
def get_current_weather(location: str, unit: str = "celsius"):
    """获取指定城市的当前天气。
    Args:
        location: 城市名,例如 "北京, 中国"。
        unit: 温度单位,默认为 "celsius"(摄氏度),可选 "fahrenheit"(华氏度)。
    """
    # 这里应该是调用真实天气API,我们模拟返回数据
    print(f"[模拟API调用] 查询{location}的当前天气,单位:{unit}")
    return {
        "location": location,
        "temperature": 22,
        "unit": unit,
        "condition": "晴朗",
        "humidity": 65
    }

def get_weather_forecast(location: str, date: str, unit: str = "celsius"):
    """获取指定城市未来某天的天气预报。
    Args:
        location: 城市名。
        date: 日期,格式 "YYYY-MM-DD"。
        unit: 温度单位。
    """
    print(f"[模拟API调用] 查询{location}在{date}的天气预报,单位:{unit}")
    return {
        "location": location,
        "date": date,
        "temperature": 24,
        "unit": unit,
        "condition": "多云",
        "precipitation_chance": 30
    }

# 一个简单的函数映射器,方便后面根据名字找到函数
def get_function_by_name(name: str):
    function_map = {
        "get_current_weather": get_current_weather,
        "get_weather_forecast": get_weather_forecast,
    }
    return function_map.get(name)

# 2. 工具描述列表 (TOOLS)
# 这是给模型看的“说明书”,必须和上面的函数对应,描述要清晰准确。
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_current_weather",
            "description": "获取指定城市的当前天气情况。",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "城市和国家的名称,例如:'北京, 中国'。",
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "温度单位,默认为'celsius'(摄氏度)。",
                    }
                },
                "required": ["location"], # 必填参数
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather_forecast",
            "description": "获取指定城市未来某一天的天气预报。",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "城市和国家的名称,例如:'上海, 中国'。",
                    },
                    "date": {
                        "type": "string",
                        "description": "查询的日期,格式必须为 'YYYY-MM-DD'。",
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "温度单位,默认为'celsius'(摄氏度)。",
                    }
                },
                "required": ["location", "date"], # 这个函数需要日期
            },
        },
    }
]

这里有个关键点TOOLS列表里的descriptionparameters描述非常重要!模型就靠这些文字来理解什么时候该调用哪个函数,以及参数该怎么填。写得太模糊,模型可能调用错误或者参数解析不对。

3.2 组装对话并让模型思考

有了工具,我们就可以发起对话了。我们使用transformers库加载Qwen模型。

from transformers import AutoModelForCausalLM, AutoTokenizer

# 加载模型和分词器 (请替换为你自己的模型路径)
model_name = "Qwen/Qwen2.5-7B-Instruct" # 示例模型名,可以用本地路径
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map="auto" # 自动选择GPU或CPU
)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 构造初始对话
MESSAGES = [
    {"role": "system", "content": "你是一个有用的天气助手。请根据用户问题,调用合适的工具获取天气信息,并用友好、简洁的语言回答用户。当前日期是2024-10-27。"},
    {"role": "user", "content": "我想知道上海今天的天气怎么样?另外,下周二天气如何?"}
]

# 魔法发生在这里:应用聊天模板
# `tools=TOOLS` 是关键,告诉模板我们要开启工具调用。
# `add_generation_prompt=True` 会在最后加上<|im_start|>assistant,引导模型开始生成。
prompt_text = tokenizer.apply_chat_template(
    MESSAGES,
    tools=TOOLS,
    add_generation_prompt=True,
    tokenize=False # 我们先看看生成的文本是什么样
)
print("=== 模型接收到的完整提示 ===")
print(prompt_text[:1500]) # 打印前一部分看看

运行这段代码,你会看到生成的长字符串。它的结构就是我们之前分析的:开头是包含了工具说明的system提示,然后是用户的问题,最后是<|im_start|>assistant,等待模型接话。

3.3 解析模型的“决策”:提取工具调用

现在,我们把提示文本送给模型,让它生成回复。

# 将提示文本转换为模型可接受的输入格式
inputs = tokenizer(prompt_text, return_tensors="pt").to(model.device)

# 生成回复
outputs = model.generate(**inputs, max_new_tokens=512)
# 解码输出,并只取出模型新生成的部分(去掉我们输入的prompt)
full_output = tokenizer.batch_decode(outputs)[0]
model_raw_output = full_output[len(prompt_text):] # 剥离输入部分

print("\n=== 模型的原始输出 ===")
print(model_raw_output)

模型可能会输出类似这样的内容:

<tool_call>
{"name": "get_current_weather", "arguments": {"location": "上海, 中国", "unit": "celsius"}}
</tool_call>
<tool_call>
{"name": "get_weather_forecast", "arguments": {"location": "上海, 中国", "date": "2024-10-29", "unit": "celsius"}}
</tool_call><|im_end|>

完美!模型理解了我们的问题,并决定调用两个工具:一个查今天天气,一个查下周二(假设2024-10-29是下周二)的预报。它严格按照我们规定的<tool_call>格式输出了JSON。

接下来,我们需要写一个解析器,把这个输出从文本里“抠”出来,转换成我们程序里能用的数据结构。

import re

def parse_tool_calls_from_assistant(content: str):
    """
    从模型assistant的输出中解析出工具调用。
    返回一个字典,包含角色、文本内容(如果有)和工具调用列表。
    """
    tool_calls = []
    # 使用正则表达式找到所有<tool_call>...</tool_call>块
    pattern = r"<tool_call>\n(.+?)\n</tool_call>"
    for match in re.finditer(pattern, content, re.DOTALL):
        try:
            func_call = json.loads(match.group(1))
            # 确保参数是字典,有时模型可能输出字符串形式的arguments
            if isinstance(func_call.get("arguments"), str):
                func_call["arguments"] = json.loads(func_call["arguments"])
            tool_calls.append({
                "type": "function",
                "function": func_call
            })
        except json.JSONDecodeError as e:
            print(f"警告:解析工具调用JSON失败。内容:{match.group(1)},错误:{e}")
            continue

    # 除了工具调用,模型可能还生成了一些文本内容(比如“我来帮你查一下”)
    # 我们需要把这部分文本也保留下来。
    text_content = re.sub(pattern, "", content).strip()
    # 移除末尾可能存在的<|im_end|>
    text_content = re.sub(r"<\|im_end\|>$", "", text_content).strip()

    message = {"role": "assistant"}
    if text_content:
        message["content"] = text_content
    if tool_calls:
        message["tool_calls"] = tool_calls

    return message

# 解析我们刚才得到的模型输出
assistant_message = parse_tool_calls_from_assistant(model_raw_output)
print("\n=== 解析后的助手消息 ===")
print(json.dumps(assistant_message, indent=2, ensure_ascii=False))

这个函数会输出一个结构化的字典,包含了tool_calls列表。这样,我们就成功地把模型的“意图”转换成了程序可以执行的指令。

3.4 执行函数并更新对话历史

解析出工具调用后,下一步就是真正去执行这些函数,并把结果放回对话历史中,让模型知道“任务已完成”。

def execute_tool_calls_and_append(messages_list, assistant_msg):
    """
    执行助手消息中的工具调用,并将结果以tool角色消息的形式追加到对话历史中。
    """
    # 首先,把包含tool_calls的助手消息添加到历史
    messages_list.append(assistant_msg)

    # 检查并执行工具调用
    if "tool_calls" in assistant_msg:
        for tool_call in assistant_msg["tool_calls"]:
            func_info = tool_call["function"]
            func_name = func_info["name"]
            func_args = func_info["arguments"]

            print(f"\n[执行] 调用函数: {func_name}, 参数: {func_args}")

            # 找到对应的函数并执行
            func = get_function_by_name(func_name)
            if func:
                try:
                    result = func(**func_args)
                    # 将结果转换为JSON字符串
                    result_str = json.dumps(result, ensure_ascii=False)
                except Exception as e:
                    result_str = json.dumps({"error": f"函数执行失败: {str(e)}"}, ensure_ascii=False)
            else:
                result_str = json.dumps({"error": f"未找到函数: {func_name}"}, ensure_ascii=False)

            # 将执行结果作为一条tool消息添加
            messages_list.append({
                "role": "tool",
                "name": func_name, # 记录是哪个函数的返回结果
                "content": result_str,
            })
            print(f"[结果] {func_name}: {result_str}")

# 将解析出的助手消息加入历史,并执行工具
execute_tool_calls_and_append(MESSAGES, assistant_message)

print("\n=== 更新后的对话历史 ===")
for msg in MESSAGES:
    print(f"{msg['role']}: {msg.get('content', msg.get('tool_calls', 'N/A'))[:100]}...")

现在,MESSAGES列表里已经包含了工具执行的结果。它看起来像这样:

system: 你是一个有用的天气助手...
user: 我想知道上海今天的天气怎么样?...
assistant: {'tool_calls': [...]}
tool: {"location": "上海, 中国", "temperature": 22...
tool: {"location": "上海, 中国", "date": "2024-10-29"...

3.5 循环与最终回答:让模型“消化”结果并生成回复

最后一步,我们把包含了工具执行结果的完整对话历史,再次送给模型,让它基于这些“事实”来组织最终的自然语言回答。

# 再次应用模板,这次历史里包含了tool消息
second_prompt = tokenizer.apply_chat_template(
    MESSAGES,
    tools=TOOLS, # 工具描述仍然需要,因为模板需要它来格式化历史
    add_generation_prompt=True,
    tokenize=False
)

# 生成最终回答
second_inputs = tokenizer(second_prompt, return_tensors="pt").to(model.device)
second_outputs = model.generate(**second_inputs, max_new_tokens=512)
final_answer = tokenizer.batch_decode(second_outputs)[0][len(second_prompt):]

print("\n=== 模型的最终回答 ===")
print(final_answer)

这次,模型接收到的提示里,包含了它自己之前发出的工具调用指令,以及这两个工具返回的具体数据。因此,它能够生成像这样的回答:

根据查询结果,上海今天的天气晴朗,气温22摄氏度,湿度65%。至于下周二(2024-10-29),天气预报显示为多云天气,气温大约24摄氏度,有30%的降水概率。建议您根据天气情况安排出行。

至此,一个完整的“用户提问 -> 模型决定调用工具 -> 程序执行工具 -> 模型基于结果生成回答”的闭环就完成了!

4. 性能优化与实战避坑指南

上面的流程跑通后,你会发现一个现实问题:每轮工具调用都会把<tool_call><tool_response>的完整内容追加到对话历史里。对话三五轮还好,如果是一个复杂的多步任务,上下文会飞速膨胀,导致后续生成速度变慢、成本增高,甚至可能超过模型的最大上下文长度限制。

4.1 精简对话历史:只保留精华

一个最直接的优化思路是:在把历史喂给模型进行下一轮生成前,对历史消息进行压缩或摘要。对于FunctionCall场景,我们可以尝试这些策略:

  1. 移除旧的工具描述:在后续轮次中,如果工具集没有变化,可以考虑在构造prompt时不再包含完整的<tools>...</tools>定义,或者只包含一次。但要注意,Qwen的模板可能依赖于此格式,一种更安全的方式是保留系统提示中的工具定义,但通过其他方式减少其影响。
  2. 摘要工具调用和结果:对于已经处理完的、非当前步骤必须的早期工具调用和结果,可以用一两句话总结其核心结论,替换掉冗长的JSON。例如,将{“temperature”: 22, “condition”: “晴朗”}总结为“当前天气晴朗,22度”,然后替换原来的tool消息。
  3. 使用外部记忆:对于超长对话,可以考虑引入向量数据库等外部记忆体。只将最近几轮对话和高度相关的历史摘要作为上下文传给模型,其他历史存储在外部,按需检索。

实操建议:对于大多数应用,一个简单有效的方法是apply_chat_template之前,手动清理MESSAGES列表。比如,只保留最近3轮“问答对”(user+assistant),以及最近一次的工具调用结果。你需要测试哪种历史裁剪方式对你的任务效果影响最小。

4.2 错误处理与鲁棒性提升

在实际使用中,模型输出可能不完美,你的代码需要足够健壮。

  • 模型不调用工具:用户的问题可能不需要工具,或者模型觉得信息不足。你的代码需要能处理没有<tool_call>标签的普通回复,并正常将内容返回给用户。
  • 模型调用格式错误:JSON解析失败、参数缺失或类型不对。我们的parse_tool_calls_from_assistant函数已经有了基本的try-catch。你还可以增加更严格的参数校验,并在解析失败时,构造一个特殊的tool消息(如{"error": "模型输出格式有误"})返回给模型,让它有机会纠正自己。
  • 函数执行失败:网络超时、API限流、参数无效等。一定要在execute_tool_calls_and_append函数里捕获所有异常,并将明确的错误信息(而非堆栈跟踪)以JSON格式返回给模型,让它能向用户解释问题。

4.3 并行调用与流式处理

如果模型一次返回多个<tool_call>,且这些工具调用之间没有依赖关系(比如同时查询北京和上海的天气),那么并行执行它们可以显著降低整体延迟。你可以使用asyncioconcurrent.futures来并发地执行这些函数。

另外,对于生成最终答案这一步,如果回答较长,可以考虑使用流式输出(streaming),让用户能更快地看到部分结果,提升体验。transformers库的generate函数支持streamer参数来实现这一点。

5. 进阶技巧:构建更复杂的智能体

掌握了基础流程后,你可以玩出更多花样:

  • 动态工具管理:不是一开始就把所有工具定义好。可以根据对话状态,动态地向TOOLS列表里添加或移除工具。例如,用户提到“订机票”时,才把航班查询工具加入上下文。
  • 多轮工具链:模型可以根据第一个工具的结果,决定调用第二个工具。比如,用户问“帮我找一家评分高的川菜馆,然后查查去那里的路线”。这需要你的循环逻辑能处理多次“模型生成->执行工具->再生成”的过程。
  • 与ReAct等范式结合:你可以引导模型按照“思考(Thought)-行动(Action)-观察(Observation)”的ReAct格式来输出,将<tool_call>作为其“行动”的一部分。这能让模型的推理过程更可控、更可靠。

我自己在项目中把这些技巧都用上了。最开始也是被复杂的模板和来回的消息组装搞得头疼,但一旦理顺了这个“模型-工具-程序”三方协作的流程,开发效率就高了很多。记住,核心就是把模型当作一个会写JSON的决策者,你的程序则是忠实的执行者和上下文管理者。两者配合好了,就能做出非常强大的AI应用。

更多推荐