不用框架,100行Python从零实现AI Agent的Tool Calling

5 阅读1分钟

不用框架,100行Python从零实现AI Agent的Tool Calling

大多数Tool Calling教程一上来就是LangChain、LlamaIndex、CrewAI。框架确实省事,但黑盒太多——出了问题你根本不知道哪层报的错。

这篇文章不装任何第三方框架,只用Python标准库 + openai SDK,手写一个完整的Tool Calling循环。你可以直接复制代码跑,也可以拿去改成自己的Agent。

先搞清楚Tool Calling到底在干什么

传统的LLM调用是这样的:

用户提问 → 模型回答 → 结束

加了Tool Calling以后变成这样:

用户提问 → 模型判断需要调工具 → 返回工具调用指令
→ 你的代码执行工具 → 把结果喂回模型 → 模型生成最终回答

核心逻辑就一句话:模型不直接执行函数,它只是告诉你"我想调哪个函数、传什么参数",你来执行,再把结果塞回去。

OpenAI、Anthropic、通义千问、DeepSeek都支持这个流程,接口格式基本一样。下面用OpenAI兼容的API来写,换其他模型只需要改base_url

准备工作

需要的东西就两个:

pip install openai

一个支持Function Calling的模型API。这篇用DeepSeek做演示,因为便宜。你换成OpenAI、通义千问、Moonshot都行,代码一行不用改。

import os

# DeepSeek
os.environ["OPENAI_API_KEY"] = "你的key"
os.environ["OPENAI_BASE_URL"] = "https://api.deepseek.com"
MODEL = "deepseek-chat"

# 或者用OpenAI
# os.environ["OPENAI_API_KEY"] = "你的key"
# MODEL = "gpt-4o"

# 或者通义千问
# os.environ["OPENAI_API_KEY"] = "你的key"
# os.environ["OPENAI_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# MODEL = "qwen-plus"

第一步:定义工具

工具就是普通的Python函数。我们写三个日常够用的:查天气、算数学、查当前时间。

import json
import math
from datetime import datetime


def get_weather(city: str) -> str:
    """根据城市名查天气(模拟数据,实际项目替换成真实API)"""
    fake_data = {
        "北京": {"temp": 18, "condition": "晴", "humidity": 35},
        "上海": {"temp": 22, "condition": "多云", "humidity": 65},
        "深圳": {"temp": 27, "condition": "阵雨", "humidity": 80},
    }
    info = fake_data.get(city)
    if not info:
        return json.dumps({"error": f"没有{city}的天气数据"}, ensure_ascii=False)
    return json.dumps({
        "city": city,
        "temperature": info["temp"],
        "condition": info["condition"],
        "humidity": info["humidity"],
    }, ensure_ascii=False)


def calculate(expression: str) -> str:
    """计算数学表达式,支持基本运算和math模块函数"""
    allowed = {
        "abs": abs, "round": round,
        "sqrt": math.sqrt, "pow": math.pow,
        "sin": math.sin, "cos": math.cos,
        "pi": math.pi, "e": math.e,
    }
    try:
        result = eval(expression, {"__builtins__": {}}, allowed)
        return json.dumps({"expression": expression, "result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})


def get_current_time() -> str:
    """获取当前时间"""
    now = datetime.now()
    return json.dumps({
        "datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
        "weekday": ["周一","周二","周三","周四","周五","周六","周日"][now.weekday()],
    }, ensure_ascii=False)

函数写好了,接下来要把它们"翻译"成模型能理解的schema。

第二步:写工具描述(JSON Schema)

模型不会读你的Python代码,它靠的是你给的JSON描述来决定用哪个函数、传什么参数。

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的实时天气,包括温度、天气状况、湿度",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称,如北京、上海、深圳"
                    }
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "计算数学表达式,支持加减乘除、平方根、三角函数等",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "数学表达式,例如 2+3*4 或 sqrt(144)"
                    }
                },
                "required": ["expression"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_current_time",
            "description": "获取当前日期时间和星期几",
            "parameters": {
                "type": "object",
                "properties": {},
                "required": []
            }
        }
    },
]

description写得越清楚,模型判断的准确率越高。别偷懒写"获取数据"这种模糊描述。

第三步:构建函数注册表

我们需要一个映射,让代码根据模型返回的函数名找到对应的Python函数:

FUNCTION_MAP = {
    "get_weather": get_weather,
    "calculate": calculate,
    "get_current_time": get_current_time,
}

这个字典后面会用到。

第四步:核心循环——Agent主函数

到重点了。整个Tool Calling的核心是一个while循环:发请求 → 检查是否有工具调用 → 有就执行 → 把结果喂回去 → 再发请求。直到模型不再调工具为止。

from openai import OpenAI


def run_agent(user_input: str, max_rounds: int = 5) -> str:
    client = OpenAI()
    messages = [
        {"role": "system", "content": "你是一个实用助手,可以查天气、做计算、查时间。回答要简洁。"},
        {"role": "user", "content": user_input},
    ]

    for round_num in range(max_rounds):
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )

        msg = response.choices[0].message

        # 没有工具调用 → 模型直接给了答案
        if not msg.tool_calls:
            return msg.content

        # 有工具调用 → 逐个执行
        messages.append(msg)  # 先把assistant消息加进去

        for tool_call in msg.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)

            print(f"  [调用工具] {func_name}({func_args})")

            # 执行函数
            func = FUNCTION_MAP.get(func_name)
            if func:
                result = func(**func_args)
            else:
                result = json.dumps({"error": f"未知函数: {func_name}"})

            # 把执行结果作为tool消息加进去
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })

        # 下一轮循环会再次发请求,模型拿到工具结果后生成回答

    return "超过最大轮次,Agent停止"

52行代码,这就是一个完整的Agent了。

跑起来看看

if __name__ == "__main__":
    # 测试1:单工具调用
    print("=" * 50)
    print("问题:北京天气怎么样?")
    answer = run_agent("北京今天天气怎么样?")
    print(f"回答:{answer}\n")

    # 测试2:计算
    print("=" * 50)
    print("问题:144的平方根加上圆周率等于多少?")
    answer = run_agent("帮我算一下144的平方根加上圆周率等于多少?")
    print(f"回答:{answer}\n")

    # 测试3:多工具串联
    print("=" * 50)
    print("问题:现在几点了?北京和深圳的天气分别怎么样?")
    answer = run_agent("现在几点了?顺便帮我看看北京和深圳的天气")
    print(f"回答:{answer}\n")

运行输出大概长这样:

==================================================
问题:北京天气怎么样?
  [调用工具] get_weather({"city": "北京"})
回答:北京现在18°C,晴天,湿度35%,挺适合出门的。

==================================================
问题:144的平方根加上圆周率等于多少?
  [调用工具] calculate({"expression": "sqrt(144) + pi"})
回答:144的平方根是12,加上π约3.14,结果约15.14。

==================================================
问题:现在几点了?北京和深圳的天气分别怎么样?
  [调用工具] get_current_time({})
  [调用工具] get_weather({"city": "北京"})
  [调用工具] get_weather({"city": "深圳"})
回答:现在是2026324日下午2点,周二。北京18°C晴天,深圳27°C有阵雨,出门带伞。

第三个测试能看到模型一次调了三个工具。这就是并行Tool Calling,DeepSeek和GPT-4o都支持。

加个实用功能:动态注册工具

上面的代码是写死的三个函数。实际项目里你肯定想动态加工具,可以用装饰器封装一下:

from typing import Callable

# 全局注册表
_tool_registry: dict[str, dict] = {}
_func_registry: dict[str, Callable] = {}


def tool(name: str, description: str, parameters: dict):
    """装饰器:注册一个函数为Agent工具"""
    def decorator(func: Callable):
        _tool_registry[name] = {
            "type": "function",
            "function": {
                "name": name,
                "description": description,
                "parameters": parameters,
            }
        }
        _func_registry[name] = func
        return func
    return decorator


# 用法
@tool(
    name="search_docs",
    description="在文档库中搜索相关内容",
    parameters={
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "搜索关键词"},
            "top_k": {"type": "integer", "description": "返回结果数量", "default": 3},
        },
        "required": ["query"],
    },
)
def search_docs(query: str, top_k: int = 3) -> str:
    # 你的搜索逻辑
    return json.dumps({"results": [f"文档{i}: 关于{query}的内容" for i in range(top_k)]})

这样你只需要加@tool装饰器,函数自动注册成Agent工具。

错误处理:生产环境必须加

上面的代码为了简洁省略了错误处理。生产环境你至少要加这几个:

def safe_call(func_name: str, func_args: dict) -> str:
    """安全调用工具函数"""
    func = _func_registry.get(func_name)
    if not func:
        return json.dumps({"error": f"未注册的工具: {func_name}"})

    try:
        result = func(**func_args)
    except TypeError as e:
        # 参数不匹配
        return json.dumps({"error": f"参数错误: {e}"})
    except Exception as e:
        # 函数内部报错
        return json.dumps({"error": f"执行失败: {type(e).__name__}: {e}"})

    # 确保返回字符串
    if not isinstance(result, str):
        result = json.dumps(result, ensure_ascii=False, default=str)

    return result

另外,json.loads(tool_call.function.arguments)也可能报错——模型偶尔会返回格式不太对的JSON。加个try-except:

try:
    func_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
    func_args = {}  # 降级处理

一些实际踩过的坑

1. description写得太简略,模型分不清该调哪个工具。

我之前写过一个Agent有"搜索文档"和"搜索网页"两个工具,description都写成"搜索相关信息",模型隔三差五调错。把description改成"在本地知识库中搜索公司内部文档"和"通过搜索引擎搜索互联网公开信息"就好了。

2. 工具返回的数据量太大,模型处理不了。

有个同事把数据库查询直接返回了2000条记录的JSON,结果超了context window。工具返回的数据要控制量——做分页、只返回前N条、或者做摘要后再返回。

3. max_rounds设太大,死循环烧token。

模型有时候会反复调同一个工具(参数还一模一样),特别是遇到工具返回错误的时候。设个上限,5-10轮就够了。

4. tool_choice别写"required"除非你确定。

tool_choice="auto"让模型自己决定要不要调工具。"required"强制每次都调,有些简单问题模型不需要调工具就能回答,强制调反而多此一举。

完整代码

把上面的代码整合在一起,去掉注释大概100行出头:

#!/usr/bin/env python3
"""最小化AI Agent:Tool Calling完整实现"""

import json
import math
import os
from datetime import datetime
from openai import OpenAI

os.environ.setdefault("OPENAI_BASE_URL", "https://api.deepseek.com")
MODEL = os.environ.get("AGENT_MODEL", "deepseek-chat")

# ---- 工具函数 ----

def get_weather(city: str) -> str:
    fake_data = {
        "北京": {"temp": 18, "condition": "晴", "humidity": 35},
        "上海": {"temp": 22, "condition": "多云", "humidity": 65},
        "深圳": {"temp": 27, "condition": "阵雨", "humidity": 80},
    }
    info = fake_data.get(city)
    if not info:
        return json.dumps({"error": f"没有{city}的天气数据"}, ensure_ascii=False)
    return json.dumps({"city": city, **info}, ensure_ascii=False)

def calculate(expression: str) -> str:
    allowed = {"abs": abs, "round": round, "sqrt": math.sqrt,
               "pow": math.pow, "sin": math.sin, "cos": math.cos,
               "pi": math.pi, "e": math.e}
    try:
        result = eval(expression, {"__builtins__": {}}, allowed)
        return json.dumps({"expression": expression, "result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})

def get_current_time() -> str:
    now = datetime.now()
    weekdays = ["周一","周二","周三","周四","周五","周六","周日"]
    return json.dumps({"datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
                       "weekday": weekdays[now.weekday()]}, ensure_ascii=False)

# ---- 工具注册 ----

TOOLS = [
    {"type": "function", "function": {"name": "get_weather",
     "description": "查询指定城市的实时天气", "parameters": {
         "type": "object", "properties": {"city": {"type": "string",
         "description": "城市名"}}, "required": ["city"]}}},
    {"type": "function", "function": {"name": "calculate",
     "description": "计算数学表达式", "parameters": {
         "type": "object", "properties": {"expression": {"type": "string",
         "description": "数学表达式"}}, "required": ["expression"]}}},
    {"type": "function", "function": {"name": "get_current_time",
     "description": "获取当前日期时间", "parameters": {
         "type": "object", "properties": {}, "required": []}}},
]

FUNC_MAP = {"get_weather": get_weather, "calculate": calculate,
            "get_current_time": get_current_time}

# ---- Agent核心 ----

def run_agent(user_input: str, max_rounds: int = 5) -> str:
    client = OpenAI()
    messages = [
        {"role": "system", "content": "你是一个实用助手。回答简洁。"},
        {"role": "user", "content": user_input},
    ]
    for _ in range(max_rounds):
        resp = client.chat.completions.create(
            model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto")
        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content
        messages.append(msg)
        for tc in msg.tool_calls:
            name = tc.function.name
            try:
                args = json.loads(tc.function.arguments)
            except json.JSONDecodeError:
                args = {}
            func = FUNC_MAP.get(name)
            result = func(**args) if func else json.dumps({"error": "未知函数"})
            print(f"  [工具] {name}({args}) → {result[:80]}")
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
    return "超过最大轮次"

if __name__ == "__main__":
    print(run_agent("北京天气怎么样?"))
    print(run_agent("帮我算sqrt(144) + 3.14"))
    print(run_agent("现在几点?顺便查下上海天气"))

复制保存为agent.py,设好OPENAI_API_KEY,直接python3 agent.py就能跑。

从这里往哪走

这100行代码是Agent的骨架。往上搭功能的方向:

  • 接真实API(天气、搜索、数据库查询),替换掉fake_data
  • 加对话记忆,把messages持久化到文件或Redis
  • 用装饰器实现工具自动注册,前面已经给了代码
  • 加流式输出(streaming),用户体验好很多
  • 接MCP协议,让你的工具能被其他Agent调用

框架做的事情就是把这些方向打包好了。但你先手写一遍再去用框架,遇到问题至少知道哪层出的错。