使用Python从零搭建一个能用的AI Agent
上个月接了个私活,甲方要求做一个「智能客服助手」,能查订单、能查物流、还能根据用户问题自动判断该调哪个工具。说白了就是要一个 AI Agent。
我一开始想着,不就是大模型 + 函数调用嘛,两天搞定。结果整整折腾了一周——工具调用的参数解析炸了、多轮对话的上下文丢了、Agent 陷入死循环疯狂调同一个函数……
踩完这些坑之后,我把整个流程抽成了一套还算能复用的模板。今天把核心代码和踩坑记录都贴出来,希望能帮你少走点弯路。
先说结论
| 要点 | 说明 |
|---|---|
| 核心原理 | LLM 做决策大脑 + Tool Calling 做手脚 |
| 最小依赖 | openai SDK + 任何兼容 OpenAI 协议的 API |
| 关键难点 | 工具描述的 prompt 工程、多轮上下文管理、循环调用兜底 |
| 代码量 | 核心 Agent 循环不到 100 行 |
| 适用模型 | GPT-4o、Claude 3.5、Gemini Pro 等支持 function calling 的模型 |
什么是 AI Agent?别被概念唬住
圈子里关于 Agent 的定义吵了一年了,各种框架花里胡哨。但对我这种干活的人来说,Agent 的本质就一句话:
让大模型自己决定「下一步做什么」,而不是你在代码里用 if-else 替它决定。
传统的 LLM 应用是这样的:
用户提问 → 你拼 prompt → 调 LLM → 返回文本
Agent 的流程是这样的:
用户提问 → LLM 判断要不要用工具 → 用哪个工具 → 执行工具拿结果 → 把结果喂回 LLM → LLM 再判断……直到它觉得可以回答了
核心就是一个 ReAct 循环(Reasoning + Acting),模型自己推理、自己行动、自己观察结果、再推理。
好,概念到此为止,开始写代码。
环境准备
依赖极简,就一个 openai 的 SDK:
pip install openai
因为我们用的是兼容 OpenAI 协议的接口,所以不管你背后调的是 GPT、Claude 还是 Gemini,代码都一样。我自己开发的时候需要频繁切模型对比效果,折腾了一圈发现最省事的方案是用聚合 API,改个 base_url 就能切模型,不用管各家的鉴权差异。
from openai import OpenAI
client = OpenAI(
api_key="your-key",
base_url="https://api.ofox.ai/v1" # 聚合接口,一个 Key 用所有模型
)
第一步:定义工具(Tools)
Agent 的「手脚」就是工具。你得先告诉大模型有哪些工具可用、每个工具接收什么参数。
我以那个客服场景为例,定义两个工具——查订单和查物流:
# 模拟的业务函数
def query_order(order_id: str) -> dict:
"""根据订单号查询订单信息"""
# 实际项目里这里查数据库
fake_db = {
"ORD001": {"order_id": "ORD001", "product": "机械键盘", "status": "已发货", "amount": 399},
"ORD002": {"order_id": "ORD002", "product": "显示器支架", "status": "待付款", "amount": 89},
}
return fake_db.get(order_id, {"error": f"订单 {order_id} 不存在"})
def query_logistics(order_id: str) -> dict:
"""根据订单号查询物流信息"""
fake_logistics = {
"ORD001": {"carrier": "顺丰", "tracking_no": "SF1234567890", "status": "在途中,预计明天到"},
}
return fake_logistics.get(order_id, {"error": f"订单 {order_id} 暂无物流信息"})
然后把工具描述成 OpenAI function calling 要求的格式:
tools = [
{
"type": "function",
"function": {
"name": "query_order",
"description": "根据订单号查询订单详情,包括商品名、状态、金额",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单编号,格式如 ORD001"
}
},
"required": ["order_id"]
}
}
},
{
"type": "function",
"function": {
"name": "query_logistics",
"description": "根据订单号查询物流状态,包括快递公司、运单号、当前状态",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单编号,格式如 ORD001"
}
},
"required": ["order_id"]
}
}
}
]
这里有个坑我必须提一下:description 写得好不好,直接决定模型会不会正确地选工具。我一开始 query_logistics 的描述写的是「查询物流」四个字,结果模型经常把「我的订单到哪了」这种问题路由到 query_order 上去。后来我把描述改详细了,加上「快递公司、运单号、当前状态」这些关键词,准确率一下就上来了。
第二步:搭建 Agent 主循环
这是整个 Agent 的核心,也就是 ReAct 循环。逻辑很直白:
- 把用户消息发给 LLM
- 如果 LLM 返回了 tool_calls,就执行对应的函数
- 把函数结果塞回消息列表,再发给 LLM
- 重复,直到 LLM 不再调用工具,直接返回文本
import json
# 工具名 → 实际函数的映射
TOOL_MAP = {
"query_order": query_order,
"query_logistics": query_logistics,
}
SYSTEM_PROMPT = """你是一个电商客服助手。你可以帮用户查询订单信息和物流状态。
请用简洁友好的语气回复。如果用户没有提供订单号,请先询问订单号。"""
def run_agent(user_input: str, messages: list = None, max_turns: int = 5) -> str:
"""
运行 Agent 主循环
max_turns: 最大工具调用轮次,防止死循环
"""
if messages is None:
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.append({"role": "user", "content": user_input})
for turn in range(max_turns):
response = client.chat.completions.create(
model="gpt-4o", # 换成 claude-3.5-sonnet 等也行
messages=messages,
tools=tools,
tool_choice="auto", # 让模型自己决定要不要调工具
)
msg = response.choices[0].message
messages.append(msg) # 把 assistant 的回复加入上下文
# 如果没有工具调用,说明模型准备好直接回答了
if not msg.tool_calls:
return msg.content
# 执行每个工具调用
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
print(f" [Agent] 调用工具: {func_name}({func_args})")
# 执行函数
if func_name in TOOL_MAP:
result = TOOL_MAP[func_name](**func_args)
else:
result = {"error": f"未知工具: {func_name}"}
# 把工具执行结果塞回消息列表
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False)
})
return "抱歉,我处理这个问题遇到了困难,请联系人工客服。"
注意最后那个兜底的 return——这就是 max_turns 的作用。我之前没加这个,测试的时候 Agent 对一个不存在的订单号疯狂调 query_order,调了十几次才超时报错。加个上限,超过 5 轮强制退出,返回一个友好的兜底话术。
第三步:跑起来看看效果
if __name__ == "__main__":
# 测试 1:正常查询
print("=" * 50)
print("用户:帮我看看 ORD001 到哪了")
print("Agent:", run_agent("帮我看看 ORD001 到哪了"))
print()
# 测试 2:需要先查订单再查物流
print("=" * 50)
print("用户:ORD001 买的什么?快递到哪了?")
print("Agent:", run_agent("ORD001 买的什么?快递到哪了?"))
print()
# 测试 3:缺少订单号
print("=" * 50)
print("用户:我想查一下我的快递")
print("Agent:", run_agent("我想查一下我的快递"))
实际运行输出大概长这样:
==================================================
用户:帮我看看 ORD001 到哪了
[Agent] 调用工具: query_logistics({"order_id": "ORD001"})
Agent:您的订单 ORD001 由顺丰快递承运,运单号 SF1234567890,目前在途中,预计明天到达。
==================================================
用户:ORD001 买的什么?快递到哪了?
[Agent] 调用工具: query_order({"order_id": "ORD001"})
[Agent] 调用工具: query_logistics({"order_id": "ORD001"})
Agent:您的订单 ORD001 购买的是机械键盘(399元),已发货。快递由顺丰承运,运单号 SF1234567890,目前在途中,预计明天到。
==================================================
用户:我想查一下我的快递
Agent:好的,请提供一下您的订单编号,我帮您查询物流信息。
第二个测试案例是我觉得最能体现 Agent 价值的——用户一句话包含两个意图,模型自己判断需要调两个工具,并行调用(GPT-4o 支持一次返回多个 tool_calls),然后把两个结果整合成一段话回复。这种逻辑你用 if-else 写,嵌套能写到怀疑人生。
踩坑记录
坑 1:tool_call 的 arguments 不一定是合法 JSON
是的你没看错。模型偶尔会返回不合法的 JSON 字符串,尤其是一些小参数量的模型。我遇到过返回 {order_id: "ORD001"} 少引号的情况。
解决方案很暴力但有效:
try:
func_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
# 尝试用 ast.literal_eval 兜底,再不行就报错
import ast
try:
func_args = ast.literal_eval(tool_call.function.arguments)
except:
result = {"error": "参数解析失败"}
# 继续把 error 喂给模型,让它重试
坑 2:多轮对话的 messages 会越来越长
每次工具调用的结果都要追加到 messages 列表里,聊几轮之后 token 数蹭蹭涨。我那个客服场景用户平均聊 8-10 轮,到后面经常超 token 限制。
我的做法是加一个简单的滑动窗口:
def trim_messages(messages: list, max_tokens: int = 8000) -> list:
"""保留 system prompt + 最近的消息"""
system_msg = messages[0] # system prompt 永远保留
recent = messages[1:]
# 粗略估算:1个中文字符约2个token
while len(json.dumps(recent, ensure_ascii=False)) > max_tokens * 2 and len(recent) > 2:
recent.pop(0)
return [system_msg] + recent
粗暴但管用。正经生产环境可以用 tiktoken 精确计算 token 数。
坑 3:工具描述要站在「模型的视角」写
这个我前面提过了,但值得再强调一下。你觉得理所当然的信息,模型不一定知道。比如我有个工具叫 get_refund_policy,一开始描述是「获取退款政策」。结果用户问「买了 7 天了还能退吗」,模型根本不会调这个工具——因为在它看来这是个关于时间的问题,不是关于「政策」的问题。
后来我改成:「获取退款政策信息,当用户询问能否退款、退款条件、退款时限、退货流程等问题时使用」,一下就准了。
写工具描述的时候,想想用户会怎么问,而不是这个函数在代码里叫什么。
坑 4:模型幻觉——编造工具参数
用户说「帮我查一下订单」没给订单号,正常情况模型应该反问。但我遇到过 GPT-3.5 直接编一个订单号 ORD12345 去调工具的情况。GPT-4o 和 Claude 3.5 好很多,基本不会出现。
如果你用的模型不够强,可以在 system prompt 里加一句硬约束:
重要:如果用户没有提供必要的参数信息,你必须先向用户询问,绝对不能自行编造参数。
往更完整的方向扩展
上面这套代码是一个最小可用的 Agent。实际项目你可能还需要:
- 记忆持久化:把 messages 存到 Redis/数据库,支持用户下次继续聊
- 流式输出:
stream=True,不然用户等 Agent 调完工具再回复,体验很差 - 工具权限控制:不同用户能用不同的工具
- 可观测性:记录每次 LLM 调用的 token 数、延迟、工具调用链路,方便排查问题
这些我后续可能会单独写。今天这篇就聚焦在核心循环和踩坑上。
小结
AI Agent 听起来高大上,但拆开了就三件事:定义工具、让模型选工具、执行工具把结果喂回去。核心循环的代码量真的不多,难度主要在工程细节——参数解析、上下文管理、兜底策略、prompt 调优。
如果你也想上手试试,建议别一开始就上 LangChain 那种重框架,先用原生 SDK 把 Agent 循环跑通,理解每一步在干什么。等你真的觉得手写吃力了,再引入框架也不迟。
以上就是使用Python从零搭建一个能用的AI Agent的详细内容,更多关于Python实现AI Agent的资料请关注脚本之家其它相关文章!
相关文章
Python sklearn CountVectorizer使用详解
这篇文章主要介绍了Python_sklearn_CountVectorizer使用详解,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下2023-03-03
使用Python的Treq on Twisted来进行HTTP压力测试
这篇文章主要介绍了使用Python的Treq on Twisted来进行HTTP压力测试,基于Python中的Twisted框架,需要的朋友可以参考下2015-04-04
在vscode使用jupyter notebook出现bug及解决
这篇文章主要介绍了在vscode使用jupyter notebook出现bug及解决,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教2024-06-06


最新评论