使用Python從零搭建一個(gè)能用的AI Agent
上個(gè)月接了個(gè)私活,甲方要求做一個(gè)「智能客服助手」,能查訂單、能查物流、還能根據(jù)用戶問(wèn)題自動(dòng)判斷該調(diào)哪個(gè)工具。說(shuō)白了就是要一個(gè) AI Agent。
我一開(kāi)始想著,不就是大模型 + 函數(shù)調(diào)用嘛,兩天搞定。結(jié)果整整折騰了一周——工具調(diào)用的參數(shù)解析炸了、多輪對(duì)話的上下文丟了、Agent 陷入死循環(huán)瘋狂調(diào)同一個(gè)函數(shù)……
踩完這些坑之后,我把整個(gè)流程抽成了一套還算能復(fù)用的模板。今天把核心代碼和踩坑記錄都貼出來(lái),希望能幫你少走點(diǎn)彎路。
先說(shuō)結(jié)論
| 要點(diǎn) | 說(shuō)明 |
|---|---|
| 核心原理 | LLM 做決策大腦 + Tool Calling 做手腳 |
| 最小依賴 | openai SDK + 任何兼容 OpenAI 協(xié)議的 API |
| 關(guān)鍵難點(diǎn) | 工具描述的 prompt 工程、多輪上下文管理、循環(huán)調(diào)用兜底 |
| 代碼量 | 核心 Agent 循環(huán)不到 100 行 |
| 適用模型 | GPT-4o、Claude 3.5、Gemini Pro 等支持 function calling 的模型 |
什么是 AI Agent?別被概念唬住
圈子里關(guān)于 Agent 的定義吵了一年了,各種框架花里胡哨。但對(duì)我這種干活的人來(lái)說(shuō),Agent 的本質(zhì)就一句話:
讓大模型自己決定「下一步做什么」,而不是你在代碼里用 if-else 替它決定。
傳統(tǒng)的 LLM 應(yīng)用是這樣的:
用戶提問(wèn) → 你拼 prompt → 調(diào) LLM → 返回文本
Agent 的流程是這樣的:
用戶提問(wèn) → LLM 判斷要不要用工具 → 用哪個(gè)工具 → 執(zhí)行工具拿結(jié)果 → 把結(jié)果喂回 LLM → LLM 再判斷……直到它覺(jué)得可以回答了
核心就是一個(gè) ReAct 循環(huán)(Reasoning + Acting),模型自己推理、自己行動(dòng)、自己觀察結(jié)果、再推理。
好,概念到此為止,開(kāi)始寫代碼。
環(huán)境準(zhǔn)備
依賴極簡(jiǎn),就一個(gè) openai 的 SDK:
pip install openai
因?yàn)槲覀冇玫氖羌嫒?OpenAI 協(xié)議的接口,所以不管你背后調(diào)的是 GPT、Claude 還是 Gemini,代碼都一樣。我自己開(kāi)發(fā)的時(shí)候需要頻繁切模型對(duì)比效果,折騰了一圈發(fā)現(xiàn)最省事的方案是用聚合 API,改個(gè) base_url 就能切模型,不用管各家的鑒權(quán)差異。
from openai import OpenAI
client = OpenAI(
api_key="your-key",
base_url="https://api.ofox.ai/v1" # 聚合接口,一個(gè) Key 用所有模型
)
第一步:定義工具(Tools)
Agent 的「手腳」就是工具。你得先告訴大模型有哪些工具可用、每個(gè)工具接收什么參數(shù)。
我以那個(gè)客服場(chǎng)景為例,定義兩個(gè)工具——查訂單和查物流:
# 模擬的業(yè)務(wù)函數(shù)
def query_order(order_id: str) -> dict:
"""根據(jù)訂單號(hào)查詢訂單信息"""
# 實(shí)際項(xiàng)目里這里查數(shù)據(jù)庫(kù)
fake_db = {
"ORD001": {"order_id": "ORD001", "product": "機(jī)械鍵盤", "status": "已發(fā)貨", "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:
"""根據(jù)訂單號(hào)查詢物流信息"""
fake_logistics = {
"ORD001": {"carrier": "順豐", "tracking_no": "SF1234567890", "status": "在途中,預(yù)計(jì)明天到"},
}
return fake_logistics.get(order_id, {"error": f"訂單 {order_id} 暫無(wú)物流信息"})
然后把工具描述成 OpenAI function calling 要求的格式:
tools = [
{
"type": "function",
"function": {
"name": "query_order",
"description": "根據(jù)訂單號(hào)查詢訂單詳情,包括商品名、狀態(tài)、金額",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "訂單編號(hào),格式如 ORD001"
}
},
"required": ["order_id"]
}
}
},
{
"type": "function",
"function": {
"name": "query_logistics",
"description": "根據(jù)訂單號(hào)查詢物流狀態(tài),包括快遞公司、運(yùn)單號(hào)、當(dāng)前狀態(tài)",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "訂單編號(hào),格式如 ORD001"
}
},
"required": ["order_id"]
}
}
}
]
這里有個(gè)坑我必須提一下:description 寫得好不好,直接決定模型會(huì)不會(huì)正確地選工具。我一開(kāi)始 query_logistics 的描述寫的是「查詢物流」四個(gè)字,結(jié)果模型經(jīng)常把「我的訂單到哪了」這種問(wèn)題路由到 query_order 上去。后來(lái)我把描述改詳細(xì)了,加上「快遞公司、運(yùn)單號(hào)、當(dāng)前狀態(tài)」這些關(guān)鍵詞,準(zhǔn)確率一下就上來(lái)了。
第二步:搭建 Agent 主循環(huán)
這是整個(gè) Agent 的核心,也就是 ReAct 循環(huán)。邏輯很直白:
- 把用戶消息發(fā)給 LLM
- 如果 LLM 返回了 tool_calls,就執(zhí)行對(duì)應(yīng)的函數(shù)
- 把函數(shù)結(jié)果塞回消息列表,再發(fā)給 LLM
- 重復(fù),直到 LLM 不再調(diào)用工具,直接返回文本
import json
# 工具名 → 實(shí)際函數(shù)的映射
TOOL_MAP = {
"query_order": query_order,
"query_logistics": query_logistics,
}
SYSTEM_PROMPT = """你是一個(gè)電商客服助手。你可以幫用戶查詢訂單信息和物流狀態(tài)。
請(qǐng)用簡(jiǎn)潔友好的語(yǔ)氣回復(fù)。如果用戶沒(méi)有提供訂單號(hào),請(qǐng)先詢問(wèn)訂單號(hào)。"""
def run_agent(user_input: str, messages: list = None, max_turns: int = 5) -> str:
"""
運(yùn)行 Agent 主循環(huán)
max_turns: 最大工具調(diào)用輪次,防止死循環(huán)
"""
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", # 讓模型自己決定要不要調(diào)工具
)
msg = response.choices[0].message
messages.append(msg) # 把 assistant 的回復(fù)加入上下文
# 如果沒(méi)有工具調(diào)用,說(shuō)明模型準(zhǔn)備好直接回答了
if not msg.tool_calls:
return msg.content
# 執(zhí)行每個(gè)工具調(diào)用
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
print(f" [Agent] 調(diào)用工具: {func_name}({func_args})")
# 執(zhí)行函數(shù)
if func_name in TOOL_MAP:
result = TOOL_MAP[func_name](**func_args)
else:
result = {"error": f"未知工具: {func_name}"}
# 把工具執(zhí)行結(jié)果塞回消息列表
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False)
})
return "抱歉,我處理這個(gè)問(wèn)題遇到了困難,請(qǐng)聯(lián)系人工客服。"
注意最后那個(gè)兜底的 return——這就是 max_turns 的作用。我之前沒(méi)加這個(gè),測(cè)試的時(shí)候 Agent 對(duì)一個(gè)不存在的訂單號(hào)瘋狂調(diào) query_order,調(diào)了十幾次才超時(shí)報(bào)錯(cuò)。加個(gè)上限,超過(guò) 5 輪強(qiáng)制退出,返回一個(gè)友好的兜底話術(shù)。
第三步:跑起來(lái)看看效果
if __name__ == "__main__":
# 測(cè)試 1:正常查詢
print("=" * 50)
print("用戶:幫我看看 ORD001 到哪了")
print("Agent:", run_agent("幫我看看 ORD001 到哪了"))
print()
# 測(cè)試 2:需要先查訂單再查物流
print("=" * 50)
print("用戶:ORD001 買的什么?快遞到哪了?")
print("Agent:", run_agent("ORD001 買的什么?快遞到哪了?"))
print()
# 測(cè)試 3:缺少訂單號(hào)
print("=" * 50)
print("用戶:我想查一下我的快遞")
print("Agent:", run_agent("我想查一下我的快遞"))
實(shí)際運(yùn)行輸出大概長(zhǎng)這樣:
==================================================
用戶:幫我看看 ORD001 到哪了
[Agent] 調(diào)用工具: query_logistics({"order_id": "ORD001"})
Agent:您的訂單 ORD001 由順豐快遞承運(yùn),運(yùn)單號(hào) SF1234567890,目前在途中,預(yù)計(jì)明天到達(dá)。
==================================================
用戶:ORD001 買的什么?快遞到哪了?
[Agent] 調(diào)用工具: query_order({"order_id": "ORD001"})
[Agent] 調(diào)用工具: query_logistics({"order_id": "ORD001"})
Agent:您的訂單 ORD001 購(gòu)買的是機(jī)械鍵盤(399元),已發(fā)貨。快遞由順豐承運(yùn),運(yùn)單號(hào) SF1234567890,目前在途中,預(yù)計(jì)明天到。
==================================================
用戶:我想查一下我的快遞
Agent:好的,請(qǐng)?zhí)峁┮幌履挠唵尉幪?hào),我?guī)湍樵兾锪餍畔ⅰ?/p>
第二個(gè)測(cè)試案例是我覺(jué)得最能體現(xiàn) Agent 價(jià)值的——用戶一句話包含兩個(gè)意圖,模型自己判斷需要調(diào)兩個(gè)工具,并行調(diào)用(GPT-4o 支持一次返回多個(gè) tool_calls),然后把兩個(gè)結(jié)果整合成一段話回復(fù)。這種邏輯你用 if-else 寫,嵌套能寫到懷疑人生。
踩坑記錄
坑 1:tool_call 的 arguments 不一定是合法 JSON
是的你沒(méi)看錯(cuò)。模型偶爾會(huì)返回不合法的 JSON 字符串,尤其是一些小參數(shù)量的模型。我遇到過(guò)返回 {order_id: "ORD001"} 少引號(hào)的情況。
解決方案很暴力但有效:
try:
func_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
# 嘗試用 ast.literal_eval 兜底,再不行就報(bào)錯(cuò)
import ast
try:
func_args = ast.literal_eval(tool_call.function.arguments)
except:
result = {"error": "參數(shù)解析失敗"}
# 繼續(xù)把 error 喂給模型,讓它重試
坑 2:多輪對(duì)話的 messages 會(huì)越來(lái)越長(zhǎng)
每次工具調(diào)用的結(jié)果都要追加到 messages 列表里,聊幾輪之后 token 數(shù)蹭蹭漲。我那個(gè)客服場(chǎng)景用戶平均聊 8-10 輪,到后面經(jīng)常超 token 限制。
我的做法是加一個(gè)簡(jiǎn)單的滑動(dòng)窗口:
def trim_messages(messages: list, max_tokens: int = 8000) -> list:
"""保留 system prompt + 最近的消息"""
system_msg = messages[0] # system prompt 永遠(yuǎn)保留
recent = messages[1:]
# 粗略估算:1個(gè)中文字符約2個(gè)token
while len(json.dumps(recent, ensure_ascii=False)) > max_tokens * 2 and len(recent) > 2:
recent.pop(0)
return [system_msg] + recent
粗暴但管用。正經(jīng)生產(chǎn)環(huán)境可以用 tiktoken 精確計(jì)算 token 數(shù)。
坑 3:工具描述要站在「模型的視角」寫
這個(gè)我前面提過(guò)了,但值得再?gòu)?qiáng)調(diào)一下。你覺(jué)得理所當(dāng)然的信息,模型不一定知道。比如我有個(gè)工具叫 get_refund_policy,一開(kāi)始描述是「獲取退款政策」。結(jié)果用戶問(wèn)「買了 7 天了還能退嗎」,模型根本不會(huì)調(diào)這個(gè)工具——因?yàn)樵谒磥?lái)這是個(gè)關(guān)于時(shí)間的問(wèn)題,不是關(guān)于「政策」的問(wèn)題。
后來(lái)我改成:「獲取退款政策信息,當(dāng)用戶詢問(wèn)能否退款、退款條件、退款時(shí)限、退貨流程等問(wèn)題時(shí)使用」,一下就準(zhǔn)了。
寫工具描述的時(shí)候,想想用戶會(huì)怎么問(wèn),而不是這個(gè)函數(shù)在代碼里叫什么。
坑 4:模型幻覺(jué)——編造工具參數(shù)
用戶說(shuō)「幫我查一下訂單」沒(méi)給訂單號(hào),正常情況模型應(yīng)該反問(wèn)。但我遇到過(guò) GPT-3.5 直接編一個(gè)訂單號(hào) ORD12345 去調(diào)工具的情況。GPT-4o 和 Claude 3.5 好很多,基本不會(huì)出現(xiàn)。
如果你用的模型不夠強(qiáng),可以在 system prompt 里加一句硬約束:
重要:如果用戶沒(méi)有提供必要的參數(shù)信息,你必須先向用戶詢問(wèn),絕對(duì)不能自行編造參數(shù)。
往更完整的方向擴(kuò)展
上面這套代碼是一個(gè)最小可用的 Agent。實(shí)際項(xiàng)目你可能還需要:
- 記憶持久化:把 messages 存到 Redis/數(shù)據(jù)庫(kù),支持用戶下次繼續(xù)聊
- 流式輸出:
stream=True,不然用戶等 Agent 調(diào)完工具再回復(fù),體驗(yàn)很差 - 工具權(quán)限控制:不同用戶能用不同的工具
- 可觀測(cè)性:記錄每次 LLM 調(diào)用的 token 數(shù)、延遲、工具調(diào)用鏈路,方便排查問(wèn)題
這些我后續(xù)可能會(huì)單獨(dú)寫。今天這篇就聚焦在核心循環(huán)和踩坑上。
小結(jié)
AI Agent 聽(tīng)起來(lái)高大上,但拆開(kāi)了就三件事:定義工具、讓模型選工具、執(zhí)行工具把結(jié)果喂回去。核心循環(huán)的代碼量真的不多,難度主要在工程細(xì)節(jié)——參數(shù)解析、上下文管理、兜底策略、prompt 調(diào)優(yōu)。
如果你也想上手試試,建議別一開(kāi)始就上 LangChain 那種重框架,先用原生 SDK 把 Agent 循環(huán)跑通,理解每一步在干什么。等你真的覺(jué)得手寫吃力了,再引入框架也不遲。
以上就是使用Python從零搭建一個(gè)能用的AI Agent的詳細(xì)內(nèi)容,更多關(guān)于Python實(shí)現(xiàn)AI Agent的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Python3實(shí)現(xiàn)自定義比較排序/運(yùn)算符
這篇文章主要介紹了Python3實(shí)現(xiàn)自定義比較排序/運(yùn)算符,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-02-02
Python3實(shí)現(xiàn)爬取指定百度貼吧頁(yè)面并保存頁(yè)面數(shù)據(jù)生成本地文檔的方法
這篇文章主要介紹了Python3實(shí)現(xiàn)爬取指定百度貼吧頁(yè)面并保存頁(yè)面數(shù)據(jù)生成本地文檔的方法,涉及Python基于urllib模塊的頁(yè)面爬取與文件讀寫相關(guān)操作技巧,需要的朋友可以參考下2018-04-04
Python實(shí)現(xiàn)計(jì)算經(jīng)緯度坐標(biāo)點(diǎn)距離的方法詳解
地球表面兩點(diǎn)間的距離計(jì)算看似簡(jiǎn)單,實(shí)則涉及復(fù)雜的球面幾何,本文將用Python實(shí)現(xiàn)精確的球面距離計(jì)算,覆蓋從基礎(chǔ)公式到工程優(yōu)化的全流程,快跟隨小編一起學(xué)習(xí)一下吧2025-10-10
Python中easy_install 和 pip 的安裝及使用
本篇文章主要介紹了Python中easy_install 和 pip 的安裝及使用,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-06-06
如何理解python接口自動(dòng)化之logging日志模塊
代碼需要經(jīng)歷開(kāi)發(fā)、調(diào)試、審查、測(cè)試或者上線等不同階段,在“測(cè)試”時(shí),可能只想看警告和錯(cuò)誤信息,然而在“調(diào)試”時(shí),可能還想看到跟調(diào)試相關(guān)的信息。如果想打印出使用的模塊以及代碼運(yùn)行的時(shí)間,那么代碼很容易變得混亂。使用logging日志模塊,就能很容易地解決2021-06-06
YOLOv5車牌識(shí)別實(shí)戰(zhàn)教程(四)模型優(yōu)化與部署
這篇文章主要介紹了YOLOv5車牌識(shí)別實(shí)戰(zhàn)教程(四)模型優(yōu)化與部署,在這個(gè)教程中,我們將一步步教你如何使用YOLOv5進(jìn)行車牌識(shí)別,幫助你快速掌握YOLOv5車牌識(shí)別技能,需要的朋友可以參考下2023-04-04
Python實(shí)現(xiàn)復(fù)雜對(duì)象轉(zhuǎn)JSON的方法示例
這篇文章主要介紹了Python實(shí)現(xiàn)復(fù)雜對(duì)象轉(zhuǎn)JSON的方法,結(jié)合具體實(shí)例形式分析了Python針對(duì)json轉(zhuǎn)換的相關(guān)操作技巧,需要的朋友可以參考下2017-06-06

