从零搭建一个最小 AI Agent:Python 完整示例 + AI调用机制详解
「Agent」是 LLM 应用从「能聊天」到「能干活」的关键一跃。本文先用 Python + OpenAI 兼容接口实现一个最小可运行的 Agent,再详细拆解 AI 是如何知道 Agent 提供了哪些工具、如何决定调用、又如何把结果回流到对话。
一、什么是 Agent?30 秒理解
Agent = LLM + 工具 + 循环
- LLM —— 大脑,负责理解、推理、决策
- 工具(Tools) —— 手脚,负责实际干活(查数据库、调 API、跑代码)
- 循环(Loop) —— 持续「思考 → 行动 → 观察」直到任务完成
经典 ReAct 模式(Reasoning + Acting):
循环开始
├─ Thought: LLM 想一步
├─ Action: LLM 决定调哪个工具
├─ Observation: 工具执行结果回传
└─ 判断: 任务完成?没完成就回循环开头
循环结束 → 最终回答
与「单次 function calling」的区别:
- function calling —— 一轮就结束(LLM 调一次工具,拿到结果,直接答)
- Agent —— 多轮循环(LLM 可以连续调多个工具,中间还能自我修正)
二、最小可运行 Agent 示例
2.1 环境准备
pip install openai
本文使用 OpenAI 兼容接口(支持 OpenAI / DeepSeek / 通义 / 智谱 / 本地 vLLM 等)。
2.2 完整代码(agent.py)
# -*- coding: utf-8 -*-
"""
最小 Agent 示例
- 2 个工具:加法、天气查询
- ReAct 循环:LLM 决策 → 执行工具 → 结果回流 → 直到 LLM 不再要工具
"""
import sys
import json
from openai import OpenAI
sys.stdout.reconfigure(encoding="utf-8")
# ============ 1. 初始化 LLM 客户端 ============
client = OpenAI(
base_url="https://newapi-ai.xxx.com/v1", # 替换成你的 OpenAI 兼容端点
api_key="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
)
MODEL = "glm-5.2-local"
# ============ 2. 定义工具(实际可执行的 Python 函数)============
def add(a: float, b: float) -> str:
"""两个数字相加,返回它们的和。
Args:
a: 第一个数字
b: 第二个数字
"""
return f"{a} + {b} = {a + b}"
def get_weather(city: str) -> str:
"""查询某个城市的当前天气(模拟数据)。
Args:
city: 城市名称,如 "北京"
"""
mock = {
"北京": "晴,18℃",
"上海": "多云,22℃",
"深圳": "雷阵雨,28℃",
}
return mock.get(city, f"未收录 {city} 的天气,默认晴 20℃")
# 工具名 → 实际函数的映射表
TOOL_FUNCTIONS = {
"add": add,
"get_weather": get_weather,
}
# ============ 3. 工具的 JSON Schema(给 LLM 看的「菜单」)============
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "add",
"description": "两个数字相加,返回它们的和。",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number", "description": "第一个数字"},
"b": {"type": "number", "description": "第二个数字"},
},
"required": ["a", "b"],
},
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询某个城市的当前天气(模拟数据)。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称,如 \"北京\""},
},
"required": ["city"],
},
},
},
]
# ============ 4. Agent 主循环(ReAct)============
def run_agent(user_query: str, max_steps: int = 10) -> str:
"""运行 Agent 循环,直到 LLM 不再请求工具或达到最大步数"""
messages = [
{"role": "system", "content": "你是一个有用的助手,可以调用工具回答问题。"},
{"role": "user", "content": user_query},
]
for step in range(1, max_steps + 1):
print(f"\n────── Agent Step {step} ──────")
# 4.1 把对话历史 + 工具 schema 一起发给 LLM
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS_SCHEMA, # 关键:注入工具清单
tool_choice="auto", # auto:让 LLM 自己决定调不调工具
)
msg = response.choices[0].message
# 4.2 把 LLM 这轮的回答加回对话历史
messages.append(msg)
# 4.3 如果 LLM 没要求调工具 → 任务完成,直接返回
if not msg.tool_calls:
print(f"[最终回答] {msg.content}")
return msg.content
# 4.4 LLM 要求调工具 → 逐个执行
for tool_call in msg.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
print(f"[调用工具] {name}({args})")
# 真正执行 Python 函数
result = TOOL_FUNCTIONS[name](**args)
print(f"[工具返回] {result}")
# 4.5 把执行结果作为 role=tool 的消息回传给 LLM
messages.append({
"role": "tool",
"tool_call_id": tool_call.id, # 关键:把结果和请求对应起来
"content": result,
})
return "达到最大步数,任务未完成"
# ============ 5. 跑起来 ============
if __name__ == "__main__":
# 单工具调用
run_agent("北京天气怎么样?")
# 多工具调用 + 推理(需要循环)
run_agent("北京和上海哪个温度更高?高多少?")
2.3 跑一遍看输出
输入:北京和上海哪个温度更高?高多少?
────── Agent Step 1 ──────
[调用工具] get_weather({"city": "北京"})
[工具返回] 晴,18℃
────── Agent Step 2 ──────
[调用工具] get_weather({"city": "上海"})
[工具返回] 多云,22℃
────── Agent Step 3 ──────
[最终回答] 上海温度更高,比北京高 4℃(上海 22℃,北京 18℃)。
注意 Step 1 和 Step 2 —— LLM 没有一次性把两个工具都调完,而是分两轮:第一轮调北京,第二轮调上海。这是因为某些模型在 tool_choice="auto" 下默认每轮只调一个工具。多轮循环正是 Agent 区别于单次 function calling 的价值。
三、代码结构剖析
整个 Agent 由 5 部分组成:
┌──────────────────────────────────────────┐
│ 1. LLM Client │ ← OpenAI 兼容接口
├──────────────────────────────────────────┤
│ 2. Tools(Python 函数) │ ← 真正干活的代码
├──────────────────────────────────────────┤
│ 3. Tools Schema(JSON Schema) │ ← 给 LLM 看的「菜单」
├──────────────────────────────────────────┤
│ 4. Agent Loop(ReAct) │ ← 思考-行动-观察循环
├──────────────────────────────────────────┤
│ 5. Entrypoint │ ← 跑起来
└──────────────────────────────────────────┘
核心是第 3 和第 4 部分:
- 第 3 部分回答「AI 怎么知道有哪些工具」
- 第 4 部分回答「AI 怎么调、结果怎么回流」
四、AI 是如何知道 Agent 提供了哪些方法的?
这是本文的核心问题。拆成 3 个阶段:
阶段 1: 工具注入(把工具菜单交给 LLM)
→ 阶段 2: LLM 决策(读菜单,决定要不要用、用哪个)
→ 阶段 3: 结果回流(执行结果以 role=tool 回传)
4.1 阶段一:工具注入 —— tools 字段
Agent 调 LLM 时,把 TOOLS_SCHEMA 放进 tools 参数:
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS_SCHEMA, # ← 工具清单在这里
tool_choice="auto",
)
LLM 实际收到的请求长这样(简化):
{
"model": "glm-5.2-local",
"messages": [
{"role": "system", "content": "你是一个有用的助手..."},
{"role": "user", "content": "北京天气怎么样?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "add",
"description": "两个数字相加,返回它们的和。",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number", "description": "第一个数字"},
"b": {"type": "number", "description": "第二个数字"}
},
"required": ["a", "b"]
}
}
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询某个城市的当前天气(模拟数据)。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称,如 \"北京\""}
},
"required": ["city"]
}
}
}
],
"tool_choice": "auto"
}
LLM 看到这份菜单后,知道了 3 件事:
| 字段 | LLM 据此知道什么 |
|---|---|
name |
工具叫什么 → 决定要不要选它 |
description |
工具干嘛用的 → 判断是否匹配用户意图 |
parameters (JSON Schema) |
要传什么参数、什么类型、哪些必填 → 决定怎么调 |
关键认知:LLM 不知道你的 Python 函数长什么样,它只看到这份 JSON Schema。Schema 写得越清晰,LLM 调用越准;Schema 模糊,LLM 就乱调或瞎传参数。
4.2 阶段二:LLM 决策 —— 输出 tool_calls
LLM 内部经过推理,如果觉得需要调工具,返回的 message 里会带 tool_calls 字段:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
]
}
注意几个细节:
content可能为 null —— LLM 决定调工具时,不一定说话(部分模型会同时输出"思考过程",content 就不为空)arguments是字符串化的 JSON —— 不是 dict,需要json.loads()解析id是这次调用的唯一标识 —— 回传结果时要用它把结果和请求对应起来- 可能同时调多个工具 ——
tool_calls是数组,LLM 可以一次要多个工具的结果(并行调用)
4.3 阶段三:结果回流 —— role=tool
Agent 执行完 Python 函数,把结果以 role=tool 的消息塞回 messages:
messages.append({
"role": "tool",
"tool_call_id": tool_call.id, # ← 用 id 对应上 Step 2 的请求
"content": result,
})
为什么 tool_call_id 这么重要?
设想 LLM 一次调了 3 个工具(call_1、call_2、call_3),你执行完回传 3 条 role=tool 消息。LLM 怎么知道哪条结果对应哪个工具?靠 tool_call_id。少了这个字段,API 会直接报错。
回传后,下一轮调 LLM 时,它看到的对话历史变成:
[
{"role": "system", "content": "你是一个有用的助手..."},
{"role": "user", "content": "北京天气怎么样?"},
{"role": "assistant", "content": null, "tool_calls": [{"id": "call_abc123", ...}]},
{"role": "tool", "tool_call_id": "call_abc123", "content": "晴,18℃"}
]
LLM 读到工具返回的「晴,18℃」,再生成最终回答:「北京现在是晴天,气温 18℃。」
五、完整时序图
用户 Agent Code LLM 工具函数
│ │ │ │
│ "北京天气?" │ │ │
│──────────────────>│ │ │
│ │ 1. messages + tools schema │
│ │───────────────────>│ │
│ │ │ │
│ │ 2. LLM 推理 → 决定调 get_weather │
│ │<───────────────────│ │
│ │ tool_calls: [get_weather("北京")] │
│ │ │ │
│ │ 3. 解析 arguments,调 Python 函数 │
│ │──────────────────────────────────────>│ get_weather()
│ │<──────────────────────────────────────│ "晴,18℃"
│ │ │ │
│ │ 4. 把结果作为 role=tool 回传 │
│ │───────────────────>│ │
│ │ │ │
│ │ 5. LLM 生成最终回答 │
│ │<───────────────────│ │
│ │ "北京晴天 18℃" │ │
│ │ │ │
│ "北京晴天 18℃" │ │ │
│<──────────────────│ │ │
六、关键设计点
6.1 tool_choice 的 3 种取值
tool_choice="auto" # LLM 自己决定调不调(推荐)
tool_choice="none" # 强制不调(用于纯对话场景)
tool_choice="required" # 强制必须调一个(确保 LLM 行动)
tool_choice={"type": "function", "function": {"name": "add"}} # 指定调某个
6.2 Schema 是 LLM 唯一依据
LLM 完全不知道你的 Python 函数实现,它只看 schema。所以:
description要写清「这个工具干嘛的」「什么时候该用」「不该用的时候怎么办」- 参数
description要写清楚含义、单位、格式(如"时间戳,毫秒"而不是"时间") required要准确,该必填的别漏
6.3 错误处理:工具执行失败也要回传
错误的做法:工具抛异常就 raise,Agent 崩了。
正确的做法:工具失败时,把错误信息作为 content 回传,让 LLM 自己决定怎么办:
try:
result = TOOL_FUNCTIONS[name](**args)
except Exception as e:
result = f"工具执行失败:{type(e).__name__}: {e}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result, # 失败信息也回流
})
LLM 看到「工具执行失败」后,可能会:
- 换个参数重试
- 换另一个工具
- 直接告诉用户「这个我做不了」
这就是 Agent 的自我修正能力,前提是错误信息要回流。
6.4 工具粒度
| 太粗 | 太细 | 适中 |
|---|---|---|
run_anything(query) —— LLM 不会用 |
add_one(x) —— 上下文爆掉 |
add(a, b) / get_weather(city) |
经验:一个工具做一件事,工具之间可组合。
6.5 最大步数兜底
for step in range(1, max_steps + 1):
...
LLM 偶尔会陷入「调用 → 失败 → 再调用」的死循环,设 max_steps=10 兜底,防止 token 烧爆。
七、MCP 与 Agent 的关系
很多人混淆 MCP 和 Agent,这里厘清一下:
| 维度 | MCP | Agent |
|---|---|---|
| 是什么 | 协议(JSON-RPC) | 应用模式(LLM + 工具 + 循环) |
| 解决什么 | 工具的标准化接入 | 多步推理 + 工具调用 |
| 关系 | 提供 tools 的来源 | 消费 tools 的主体 |
打个比方:
- MCP = USB 标准 —— 任何设备按 USB 协议做接口,任何电脑都能用
- Agent = 用 USB 接了一堆设备的电脑 —— 真正干活的是电脑
Agent 的工具不一定来自 MCP,可以直接在代码里定义(本文示例就是)。但 MCP 让 Agent 能动态接入任何符合协议的工具服务器,扩展性强得多。
八、调试与验证
8.1 打印每一轮的 messages
最直接的调试方式:
for step in range(1, max_steps + 1):
response = client.chat.completions.create(...)
msg = response.choices[0].message
messages.append(msg)
print(f"[Step {step}] messages:")
print(json.dumps(messages, ensure_ascii=False, indent=2))
...
看 messages 演化,就能看清 Agent 的「思考轨迹」。
8.2 用 response.usage 监控 token
print(f"[Step {step}] tokens: {response.usage.total_tokens}")
如果某轮 token 突增,可能是工具返回了超长内容,该截断或总结。
8.3 工具执行日志
每次调用工具时打印 name(args) → result,方便定位「工具对不对」「参数对不对」「结果格式对不对」。
九、常见坑
坑 1:Schema 里 description 写得太简短
# 反例
"description": "查询天气"
# 正例
"description": "查询某个城市的当前天气。返回格式:'天气描述,温度℃。仅支持中国大陆主要城市,不支持海外。"
LLM 只看 description 判断用不用,描述不清就会乱调或不调。
坑 2:arguments 忘了 json.loads
# 反例:arguments 是字符串,直接 **args 会报错
result = TOOL_FUNCTIONS[name](**tool_call.function.arguments)
# 正例:先解析成 dict
args = json.loads(tool_call.function.arguments)
result = TOOL_FUNCTIONS[name](**args)
坑 3:漏传 tool_call_id
# 反例:不带 id,API 直接报错
messages.append({"role": "tool", "content": result})
# 正例
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
坑 4:忘记把 msg 加回 messages
# 反例:msg.tool_calls 用了,但没把 msg 加进 messages,LLM 下一轮会困惑
for tool_call in msg.tool_calls:
...
messages.append({"role": "tool", ...})
# 正例:先把 assistant 消息加进去,再加 tool 结果
messages.append(msg)
for tool_call in msg.tool_calls:
...
坑 5:工具返回 dict / 对象
# 反例:返回 dict,LLM 收到 [object Object] 之类
def get_weather(city): return {"temp": 18, "weather": "晴"}
# 正例:返回字符串,信息明确
def get_weather(city): return "晴,18℃"
如果非要返回结构化数据,json.dumps(ensure_ascii=False) 一下。
坑 6:LLM 不调工具直接编答案
可能原因:
tools没传 / schema 写错description不清楚,LLM 觉得自己知道答案tool_choice设成了"none"
排查:打印完整请求,看 tools 字段有没有正确传过去。
十、进阶:从 30 行 Agent 到生产级 Agent
| 进阶项 | 价值 |
|---|---|
| 多工具管理 | 用注册器自动生成 schema,避免手写(类似 FastAPI) |
| 工具权限控制 | 危险工具要人工确认(如删数据库) |
| 持久化记忆 | 把对话历史存向量库,跨会话记忆 |
| RAG 注入 | 把检索到的文档作为 role=user 上下文 |
| 并行工具调用 | LLM 一次要多个工具时,asyncio.gather 并行执行 |
| 流式输出 | 用 stream=True 实时看到 LLM 思考过程 |
| 多 Agent 协作 | Agent 调 Agent(子任务委派) |
| MCP 接入 | 工具来源从「代码里硬编码」升级为「动态加载 MCP Server」 |
成熟框架:
- LangChain / LangGraph —— 全家桶,生态完整但偏重
- LlamaIndex —— RAG 向 Agent 扩展
- AutoGen —— 微软出品,多 Agent 协作
- OpenAI Assistants API —— 托管式 Agent
- Anthropic Claude Agent SDK —— 官方新框架
十一、总结
一个最小 Agent 的核心只有 3 件事:
- 把工具清单注入 LLM 上下文(
tools字段 + JSON Schema) - LLM 决策后输出
tool_calls(name+arguments) - Agent 执行后以
role=tool回流结果(带tool_call_id)
AI 知道 Agent 有什么工具、怎么调,本质是看你写的 JSON Schema —— name 是工具名,description 是说明,parameters 是入参契约。Schema 是 LLM 唯一能看到的工具文档,写得越清晰,Agent 行为越可靠。
Agent 的「智能」不在循环本身,而在 LLM 看 Schema 后能不能选对工具、传对参数、用好结果。这个能力的下限由 LLM 决定,但上限由你写 Schema 的认真程度决定。
十二、参考
- OpenAI Function Calling 文档:https://platform.openai.com/docs/guides/function-calling
- ReAct 论文:https://arxiv.org/abs/2210.03629
- LangChain Agent 概念:https://python.langchain.com/docs/concepts/agents/
- MCP 与 Agent:https://modelcontextprotocol.io
更多推荐


所有评论(0)