不用框架,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": "深圳"})
回答:现在是2026年3月24日下午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调用
框架做的事情就是把这些方向打包好了。但你先手写一遍再去用框架,遇到问题至少知道哪层出的错。