Function Calling 实战:从零构建一个 AI Agent

10 阅读6分钟

一、什么是 Function Calling?

大模型本身是无状态的:你给它一段文字,它返回一段文字。但现实任务往往需要调用外部系统——查实时天气、查数据库、执行代码……

Function Calling(工具调用) 就是解决这个问题的机制:

  • 你在请求中描述一批"工具"(函数签名 + 参数 JSON Schema)
  • 模型根据用户的问题,决定是否调用工具,以及传什么参数
  • 你的代码真正执行这个函数,把结果塞回对话
  • 模型拿到结果后生成最终回复

关键点:模型只是"建议"调用,真正的执行权在你的代码里。这让整个链路既安全又可控。


二、工作流程全图

用户消息
    ↓
[你的代码] 组装 messages + tools → 发给 API
    ↓
[模型] 返回 finish_reason: "tool_calls"
    + tool_calls: [{id, name, arguments}]
    ↓
[你的代码] 解析 tool_calls → 执行对应函数
    ↓
把执行结果以 role: "tool" 追加到 messages
    ↓
[你的代码] 再次请求 API(带完整历史)
    ↓
[模型] 返回 finish_reason: "stop",生成最终回复

多工具调用时,模型可能在一次响应里同时请求调用多个工具(并行工具调用),这时候你需要把每个结果都追加进去,再发一次请求。


三、环境准备

pip install openai  # DeepSeek 兼容 openai 客户端
from openai import OpenAI

client = OpenAI(
    api_key="your-deepseek-api-key",
    base_url="https://api.deepseek.com/v1",
)

四、定义工具(tools)

工具用 JSON Schema 描述参数,放在 tools 数组里传给 API:

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的实时天气,返回温度和天气状况",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称,例如:北京、上海",
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "温度单位,默认摄氏度",
                    },
                },
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "执行数学计算,支持加减乘除和幂运算",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "数学表达式,例如:(3 + 5) * 2",
                    }
                },
                "required": ["expression"],
            },
        },
    },
]

五、实现工具函数

import json
import math

def get_weather(city: str, unit: str = "celsius") -> dict:
    """模拟天气查询(生产环境替换为真实 API)"""
    mock_data = {
        "北京": {"temp_c": 12, "condition": "晴"},
        "上海": {"temp_c": 18, "condition": "多云"},
        "广州": {"temp_c": 25, "condition": "小雨"},
    }
    data = mock_data.get(city, {"temp_c": 20, "condition": "未知"})
    temp = data["temp_c"]
    if unit == "fahrenheit":
        temp = temp * 9 / 5 + 32
    return {
        "city": city,
        "temperature": temp,
        "unit": unit,
        "condition": data["condition"],
    }


def calculate(expression: str) -> dict:
    """安全计算数学表达式"""
    try:
        # 只允许数字和基本运算符,避免代码注入
        allowed = set("0123456789+-*/().^ ")
        if not all(c in allowed for c in expression):
            return {"error": "不支持的字符"}
        # 把 ^ 转成 Python 的 **
        expr = expression.replace("^", "**")
        result = eval(expr, {"__builtins__": {}}, {"sqrt": math.sqrt, "pi": math.pi})
        return {"expression": expression, "result": result}
    except Exception as e:
        return {"error": str(e)}


# 工具注册表
TOOL_REGISTRY = {
    "get_weather": get_weather,
    "calculate": calculate,
}


def execute_tool(name: str, arguments_str: str) -> str:
    """执行工具并返回 JSON 字符串结果"""
    if name not in TOOL_REGISTRY:
        return json.dumps({"error": f"未知工具: {name}"})

    # 注意:arguments 是字符串,必须 json.loads!
    try:
        arguments = json.loads(arguments_str)
    except json.JSONDecodeError:
        return json.dumps({"error": "参数解析失败"})

    result = TOOL_REGISTRY[name](**arguments)
    return json.dumps(result, ensure_ascii=False)

六、完整 Agent 循环

def run_agent(user_message: str) -> str:
    """运行一个支持多轮工具调用的 Agent"""
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            tools=tools,
            tool_choice="auto",  # 让模型自行决定是否调用工具
        )

        choice = response.choices[0]
        message = choice.message

        # 把模型回复追加到历史(包括 tool_calls 信息)
        messages.append(message.model_dump(exclude_unset=True))

        # 如果模型决定停止,直接返回最终回复
        if choice.finish_reason == "stop":
            return message.content

        # 如果模型要调用工具
        if choice.finish_reason == "tool_calls":
            # 并行处理所有工具调用
            for tool_call in message.tool_calls:
                tool_name = tool_call.function.name
                tool_args = tool_call.function.arguments

                print(f"  [调用工具] {tool_name}({tool_args})")
                result = execute_tool(tool_name, tool_args)
                print(f"  [工具结果] {result}")

                # 将工具结果追加到消息历史
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,  # 必须和请求的 id 对应
                    "content": result,
                })
            # 循环继续,把结果交给模型处理
        else:
            # 其他 finish_reason(content_filter 等),中断
            break

    return message.content or ""

七、跑起来试试

if __name__ == "__main__":
    # 测试单工具调用
    print("=== 天气查询 ===")
    answer = run_agent("北京今天天气怎么样?")
    print(f"最终回答:{answer}\n")

    # 测试计算器
    print("=== 数学计算 ===")
    answer = run_agent("帮我算一下 (15 + 7) * 3 的结果")
    print(f"最终回答:{answer}\n")

    # 测试多工具并行
    print("=== 多工具并行 ===")
    answer = run_agent("上海和广州今天的天气分别是什么?顺便帮我算 2^10 是多少")
    print(f"最终回答:{answer}\n")

输出示例:

=== 天气查询 ===
  [调用工具] get_weather({"city": "北京"})
  [工具结果] {"city": "北京", "temperature": 12, "unit": "celsius", "condition": "晴"}
最终回答:北京今天天气晴,气温 12°C,穿件外套出门吧。

=== 多工具并行 ===
  [调用工具] get_weather({"city": "上海"})
  [调用工具] get_weather({"city": "广州"})
  [调用工具] calculate({"expression": "2^10"})
  [工具结果] {"city": "上海", "temperature": 18, ...}
  [工具结果] {"city": "广州", "temperature": 25, ...}
  [工具结果] {"expression": "2^10", "result": 1024}
最终回答:上海今天多云,18°C;广州小雨,25°C。另外,210 次方等于 1024

八、常见坑与解决

坑 1:arguments 是字符串,不是 dict

# 错误:直接当 dict 用
tool_call.function.arguments["city"]  # TypeError!

# 正确:先 json.loads
args = json.loads(tool_call.function.arguments)
args["city"]  # OK

模型返回的 arguments 永远是字符串,哪怕它长得像 dict。

坑 2:忘记追加 assistant 消息

工具结果必须在对应的 assistant 消息(带 tool_calls 字段)之后追加。如果顺序错了,API 会报 400 Bad Request

坑 3:tool_call_id 不匹配

每个 tool 消息的 tool_call_id 必须和请求里的 tool_call.id 严格对应,漏填或填错都会报错。

坑 4:tool_choice 参数

含义
"auto"模型自行决定(推荐默认值)
"none"禁止调用工具,强制文本回复
{"type": "function", "function": {"name": "xxx"}}强制调用指定工具

坑 5:无限循环

如果模型卡在工具调用循环里(极少见),加个最大轮次保护:

MAX_ROUNDS = 10
for _ in range(MAX_ROUNDS):
    ...

九、进阶:让 Agent 更健壮

超时保护:工具执行加超时,防止外部 API 挂起整个 Agent。

结构化错误:工具函数统一返回 {"ok": bool, "data": ..., "error": ...} 格式,让模型知道调用失败了并能优雅降级。

工具结果裁剪:外部 API 返回几千字的 JSON 时,在塞回消息前先裁剪到关键字段,避免撑爆 context window。

值得一提的是,不同厂商对 Function Calling 的格式实现存在细微差异(比如 tool_calls 的字段命名、并行调用的支持程度)。笔者开发的 TheRouter 网关会在适配层自动统一各家模型的工具调用格式,切换模型时上层代码无需修改。


总结

Function Calling 的核心就一句话:模型负责"想",代码负责"做"

整个流程:

  1. 定义 tools(JSON Schema 描述参数)
  2. 发送请求,检查 finish_reason == "tool_calls"
  3. 解析 tool_calls,注意 argumentsjson.loads
  4. 执行函数,把结果以 role: "tool" 追加到 messages
  5. 循环,直到 finish_reason == "stop"

掌握这个循环之后,你可以给模型接上数据库查询、文件读写、HTTP 请求……任何能写成函数的事情,都能成为模型的"手"。


代码已在 DeepSeek API 上验证可用。如有问题欢迎评论区交流。

作者:TheRouter 开发者,专注 AI 模型路由网关。项目主页:therouter.ai