與工程實踐)
1. 從能跑就行到跑得明白為什么要手寫一個 Agent市面上關(guān)于 Agent 的文章十篇里有八篇在講概念——什么自主規(guī)劃、工具調(diào)用、記憶機(jī)制配一張花里胡哨的架構(gòu)圖看完之后你依然不知道一個 Agent 從零到一到底該怎么落地。我自己也經(jīng)歷過這個階段看了大量資料腦子里全是名詞真到動手的時候連一個最小的可運行循環(huán)都寫不出來。后來我換了個思路不再去追那些大而全的框架而是找一個結(jié)構(gòu)足夠清晰、代碼量足夠小的參考實現(xiàn)把它拆開、讀懂、再自己重寫一遍。pi-agent 就是這樣一個適合剝開看的對象——它沒有堆砌太多抽象層核心邏輯用幾百行就能說清楚。我照著它的思路用 Python 從零實現(xiàn)了一個能跑的小 Agent整個過程踩了不少坑也積累了一些文檔里不會寫的經(jīng)驗。這篇文章要做的就是把這段經(jīng)歷完整地攤開。我會講清楚一個 Agent 的最小閉環(huán)由哪幾塊組成、每一塊為什么這樣設(shè)計、pi-agent 在哪些地方做了取舍、我自己實現(xiàn)時遇到了什么問題。目標(biāo)讀者是那些已經(jīng)了解大模型基本調(diào)用、想進(jìn)一步搞明白 Agent 內(nèi)部運轉(zhuǎn)機(jī)制的開發(fā)者。你不需要有 Agent 開發(fā)經(jīng)驗但至少要能看懂 Python 代碼和 API 調(diào)用的基本寫法。需要提前說明的是我這里的小 Agent指的是一個具備任務(wù)理解、工具調(diào)用、結(jié)果觀察、循環(huán)推進(jìn)這四件事的最小系統(tǒng)。它不追求生產(chǎn)級的健壯性但每一個環(huán)節(jié)都是真實可用的你可以基于它繼續(xù)往上加?xùn)|西。下面進(jìn)入正題。2. 一個 Agent 的最小閉環(huán)到底由哪幾塊拼成2.1 把 Agent 拆成大腦 手腳 記憶三個部件很多人一上來就想搞一個全能 Agent結(jié)果代碼寫到一半就亂了。我的建議是先做減法把 Agent 拆成三個最基礎(chǔ)的部件每個部件只干一件事。大腦負(fù)責(zé)決策也就是根據(jù)當(dāng)前的任務(wù)狀態(tài)決定下一步該做什么。在實現(xiàn)上大腦就是一次大模型調(diào)用輸入是當(dāng)前的上下文輸出是一個結(jié)構(gòu)化的動作指令。這個動作可能是調(diào)用某個工具也可能是直接給出最終答案。手腳負(fù)責(zé)執(zhí)行也就是真正去干活的部分。工具可以是讀文件、發(fā)請求、算數(shù)學(xué)、查數(shù)據(jù)庫任何你能用代碼封裝成函數(shù)的東西都可以。手腳本身沒有智能它只負(fù)責(zé)忠實地執(zhí)行大腦下達(dá)的指令然后把結(jié)果返回。記憶負(fù)責(zé)記錄也就是維護(hù)整個任務(wù)的上下文。它要記住用戶最初說了什么、大腦之前做過哪些決策、每個工具返回了什么結(jié)果。記憶的質(zhì)量直接決定了 Agent 能不能在多輪循環(huán)中保持連貫。這三塊拼起來就形成了一個閉環(huán)大腦讀記憶 → 做出決策 → 手腳執(zhí)行 → 結(jié)果寫回記憶 → 大腦再讀記憶。循環(huán)往復(fù)直到大腦決定任務(wù)完成。pi-agent 的結(jié)構(gòu)基本就是這個思路只不過它在記憶管理上做了一些工程化的處理比如對歷史消息做截斷、對工具結(jié)果做摘要。這些后面會細(xì)講。2.2 為什么是循環(huán)而不是一次性調(diào)用這里有個容易被忽略的關(guān)鍵點Agent 和普通的一問一答最大的區(qū)別就在于循環(huán)。普通的大模型調(diào)用是單輪的——你問一句它答一句結(jié)束。但 Agent 面對的任務(wù)往往是多步的比如幫我查一下這個目錄下所有日志文件里出現(xiàn)次數(shù)最多的錯誤碼這就至少需要三步列目錄、讀文件、統(tǒng)計。你沒法用一次調(diào)用完成因為中間結(jié)果需要反饋給模型讓它決定下一步。所以 Agent 的核心是一個while循環(huán)每一輪都做四件事把當(dāng)前記憶喂給大腦、解析大腦的輸出、執(zhí)行對應(yīng)的動作、把結(jié)果追加到記憶里。循環(huán)的終止條件通常有兩個大腦明確表示任務(wù)完成或者達(dá)到了預(yù)設(shè)的最大輪數(shù)上限。注意最大輪數(shù)上限這個保護(hù)機(jī)制一定要加。我早期版本沒加結(jié)果有一次模型陷入死循環(huán)反復(fù)調(diào)用同一個工具白白燒了一堆 token。后來我固定設(shè)成 10 輪超過就強(qiáng)制中斷并返回當(dāng)前狀態(tài)。2.3 pi-agent 在結(jié)構(gòu)上的取舍少一層抽象多一分可控我對比過好幾個 Agent 實現(xiàn)pi-agent 最讓我舒服的地方是它沒有過度抽象。有些框架會引入 Planner、Executor、Memory Manager、Tool Registry 一大堆類每個類之間還有復(fù)雜的依賴關(guān)系讀起來像在解謎。pi-agent 則相對扁平核心邏輯集中在一個循環(huán)函數(shù)里工具用字典注冊記憶就是一個列表。這種設(shè)計的代價是擴(kuò)展性稍弱——比如你想加一個復(fù)雜的多 Agent 協(xié)作機(jī)制就得自己動手改不少地方。但好處是可控每一輪發(fā)生了什么、模型看到了什么、工具返回了什么你都能一眼看穿。對于學(xué)習(xí)和中小型項目來說這種透明性比擴(kuò)展性更重要。我自己的實現(xiàn)基本沿用了這個思路只在兩個地方做了調(diào)整一是把工具注冊改成了裝飾器寫法加工具更方便二是給記憶加了一個簡單的滑動窗口防止上下文無限增長。這兩點后面會展開。3. 大腦部分怎么讓模型穩(wěn)定輸出可執(zhí)行的動作3.1 用結(jié)構(gòu)化輸出約束模型而不是靠求它讓模型輸出可解析的動作指令是 Agent 開發(fā)里第一個真正的難點。早期我試過在提示詞里寫請以 JSON 格式輸出你的決策結(jié)果模型十次里有三次會加一句好的以下是我的決策然后才給 JSON解析直接崩。后來我總結(jié)出兩條經(jīng)驗。第一條是用 API 原生的結(jié)構(gòu)化輸出能力。現(xiàn)在主流的大模型接口基本都支持指定返回格式比如傳入一個 JSON Schema模型會保證輸出符合這個 schema。這比在提示詞里求它靠譜得多。第二條是即使有結(jié)構(gòu)化輸出也要做防御性解析。因為網(wǎng)絡(luò)抖動、模型版本差異等原因偶爾還是會有意外。我的做法是包一層 try-except解析失敗就把原始輸出記下來然后給模型發(fā)一條你的上一條輸出格式不對請重新輸出的糾正消息讓它重試一次。實測下來加上這層兜底之后解析成功率基本能到 99% 以上。下面是我用的動作 schema簡化版長這樣{ type: object, properties: { thought: {type: string}, action: {type: string, enum: [call_tool, finish]}, tool_name: {type: string}, tool_input: {type: object}, final_answer: {type: string} }, required: [thought, action] }thought字段是讓模型先想一想再行動這個字段不參與執(zhí)行但對提升決策質(zhì)量有明顯幫助。action只有兩個取值要么調(diào)工具要么結(jié)束。這種二選一的設(shè)計比讓模型自由發(fā)揮要穩(wěn)定得多。3.2 提示詞里必須寫清楚的幾件事結(jié)構(gòu)化輸出解決了格式問題但內(nèi)容問題還得靠提示詞。我踩過的坑是模型不知道該用哪個工具或者明明有工具卻自己瞎編答案。提示詞里必須寫清楚的東西有這么幾樣。第一是工具清單每個工具的名字、用途、參數(shù)格式都要列出來最好給一個調(diào)用示例。第二是決策原則比如如果任務(wù)需要外部信息優(yōu)先調(diào)用工具而不是憑記憶回答。第三是終止條件明確告訴它什么情況下應(yīng)該輸出finish。我用的系統(tǒng)提示詞大致是這樣的結(jié)構(gòu)你是一個任務(wù)執(zhí)行助手。你可以使用以下工具 - read_file(path): 讀取指定路徑的文件內(nèi)容 - list_dir(path): 列出目錄下的文件 - run_python(code): 執(zhí)行一段 Python 代碼并返回結(jié)果 決策原則 1. 需要外部信息時必須調(diào)用工具獲取不要憑猜測回答 2. 每次只做一個動作 3. 任務(wù)完成后用 finish 動作給出最終答案 請以指定的 JSON 格式輸出你的決策。這段提示詞看起來簡單但每一條都是踩坑之后加上的。特別是每次只做一個動作這條早期沒寫的時候模型會一次性輸出好幾個工具調(diào)用導(dǎo)致執(zhí)行邏輯變得很復(fù)雜。3.3 溫度參數(shù)和模型選擇對穩(wěn)定性的影響還有一個容易被忽略的細(xì)節(jié)是溫度參數(shù)。Agent 的大腦需要的是穩(wěn)定和可預(yù)測不是創(chuàng)意。我一開始用了默認(rèn)溫度結(jié)果同樣的輸入模型有時候調(diào)工具、有時候直接回答行為很不一致。后來把溫度調(diào)到 0 或者接近 0穩(wěn)定性明顯提升。模型選擇上我的經(jīng)驗是決策環(huán)節(jié)用能力強(qiáng)的模型執(zhí)行環(huán)節(jié)用便宜快的模型。因為決策需要理解復(fù)雜任務(wù)、選擇合適工具對模型能力要求高而執(zhí)行往往只是把參數(shù)填進(jìn)函數(shù)簡單模型就夠。這種搭配能在保證效果的同時控制成本。不過這里有個權(quán)衡如果決策和執(zhí)行用的是同一個模型上下文可以完全共享實現(xiàn)簡單如果分開就要考慮兩邊上下文怎么同步。我自己的小 Agent 為了簡單用的是同一個模型但在提示詞里區(qū)分了決策模式和總結(jié)模式。4. 手腳部分工具注冊與執(zhí)行的安全邊界4.1 工具不是越多越好而是越正交越好新手容易犯的一個錯誤是拼命加工具覺得工具越多 Agent 越強(qiáng)。實際上工具太多會帶來兩個問題一是提示詞變長模型選擇困難二是工具之間功能重疊模型不知道該用哪個。我的原則是工具要正交也就是每個工具負(fù)責(zé)一個獨立的、不重疊的能力。比如read_file和list_dir是兩個正交的工具一個讀內(nèi)容一個列目錄但如果你再加一個read_text_file就和read_file重疊了只會讓模型困惑。pi-agent 的工具集也很克制核心就幾個。我自己的實現(xiàn)里工具數(shù)量控制在 5 個以內(nèi)每個都有明確的邊界。如果發(fā)現(xiàn)某個任務(wù)需要新能力我會先想清楚它能不能用現(xiàn)有工具組合出來實在不行才加新工具。4.2 用裝飾器注冊工具讓加工具變成一件順手的事工具注冊的寫法直接影響開發(fā)體驗。我見過用一個大字典手動維護(hù)的加一個工具要改好幾個地方容易漏。后來我改成了裝飾器寫法TOOLS {} def tool(name, description, parameters): def decorator(func): TOOLS[name] { func: func, description: description, parameters: parameters } return func return decorator tool( nameread_file, description讀取指定路徑的文件內(nèi)容, parameters{path: string, 文件路徑} ) def read_file(path): with open(path, r, encodingutf-8) as f: return f.read()這樣加工具只需要在函數(shù)上加一個裝飾器工具的描述和參數(shù)定義就在函數(shù)旁邊維護(hù)起來一目了然。而且TOOLS字典可以直接序列化成提示詞里的工具清單不用手動同步。4.3 執(zhí)行工具時必須做的三件事校驗、限時、兜底工具執(zhí)行是 Agent 里最容易出問題的環(huán)節(jié)因為它是真正和外部世界交互的地方。我總結(jié)了三條必須做的防護(hù)。第一是參數(shù)校驗。模型生成的參數(shù)不一定合法比如路徑不存在、參數(shù)類型不對。執(zhí)行前一定要校驗校驗失敗就返回一個清晰的錯誤信息給模型讓它自己糾正。我見過直接open(path)結(jié)果拋異常把整個循環(huán)搞崩的寫法這是大忌。第二是超時控制。有些工具可能卡住比如網(wǎng)絡(luò)請求、大文件讀取。如果不加超時整個 Agent 就掛在那里了。我的做法是給每個工具執(zhí)行包一層超時超過閾值就中斷并返回超時錯誤。第三是異常兜底。任何工具執(zhí)行都要包在 try-except 里把異常轉(zhuǎn)成字符串返回給模型而不是讓異常往上拋。因為模型看到錯誤信息后往往能自己調(diào)整策略比如換個路徑重試。def execute_tool(name, params): if name not in TOOLS: return f錯誤工具 {name} 不存在 try: result TOOLS[name][func](**params) return str(result)[:2000] # 截斷防止結(jié)果過長 except Exception as e: return f工具執(zhí)行出錯{str(e)}注意那個[:2000]截斷這也是踩坑得來的。有一次工具返回了一個幾萬字的文件內(nèi)容直接塞進(jìn)上下文下一輪調(diào)用就超了 token 限制。截斷雖然會丟信息但比整個流程崩掉強(qiáng)。5. 記憶部分上下文管理才是 Agent 的隱形戰(zhàn)場5.1 記憶不是全都塞進(jìn)去而是該記的記該丟的丟很多人以為記憶就是把所有歷史消息都拼起來發(fā)給模型簡單粗暴。但實際跑起來你會發(fā)現(xiàn)上下文增長得非??鞄纵喼缶涂赡艹瞿P偷拇翱谙拗贫?token 成本也扛不住。記憶管理的核心是取舍。哪些信息必須保留我的經(jīng)驗是三類用戶最初的任務(wù)描述、最近幾輪的決策和結(jié)果、以及任何被標(biāo)記為關(guān)鍵的中間結(jié)論。哪些可以丟重復(fù)的工具調(diào)用結(jié)果、冗長的原始數(shù)據(jù)、已經(jīng)消化過的中間過程。pi-agent 在這一點上的處理比較務(wù)實它會對歷史做一定程度的壓縮。我自己的實現(xiàn)用了一個簡單的滑動窗口保留最前面的系統(tǒng)提示和用戶任務(wù)保留最近 N 輪的消息中間的老消息如果太多就做摘要。5.2 滑動窗口 摘要一個夠用又不復(fù)雜的方案滑動窗口的邏輯很直白維護(hù)一個消息列表當(dāng)長度超過閾值時把中間部分的消息合并成一條摘要消息。摘要可以用模型生成也可以用簡單的規(guī)則拼接。def manage_memory(messages, max_len20): if len(messages) max_len: return messages # 保留頭部系統(tǒng)提示 用戶任務(wù) head messages[:2] # 保留尾部最近的消息 tail messages[-(max_len - 3):] # 中間部分做摘要 middle messages[2:-(max_len - 3)] summary summarize(middle) return head [{role: system, content: f歷史摘要{summary}}] tail這個方案不完美比如摘要可能丟信息但勝在簡單可控。我實測下來對于大多數(shù)中小型任務(wù)這個策略足夠用了。如果你的任務(wù)特別復(fù)雜可以考慮更精細(xì)的記憶方案比如向量檢索但那是另一個話題了。5.3 一個容易被忽略的細(xì)節(jié)工具結(jié)果的格式統(tǒng)一記憶里存的東西格式要統(tǒng)一否則模型讀起來會混亂。我早期犯的錯是工具結(jié)果有時候是字符串、有時候是字典、有時候是列表模型看到之后經(jīng)常理解錯。后來我定了個規(guī)矩所有工具結(jié)果在寫入記憶前統(tǒng)一轉(zhuǎn)成帶前綴的字符串。比如工具 read_file 返回內(nèi)容。這樣模型一眼就能看出這是哪個工具的結(jié)果不會和用戶消息混淆。這個小小的格式統(tǒng)一對提升多輪連貫性幫助很大。6. 把循環(huán)跑起來主流程的組裝與調(diào)試6.1 主循環(huán)的骨架其實就二十行前面幾塊都準(zhǔn)備好之后主循環(huán)本身反而很簡單。核心邏輯就是不斷重復(fù)決策-執(zhí)行-記錄這個過程def run_agent(task, max_turns10): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task} ] for turn in range(max_turns): messages manage_memory(messages) decision call_llm(messages) if decision[action] finish: return decision[final_answer] result execute_tool(decision[tool_name], decision[tool_input]) messages.append({role: assistant, content: str(decision)}) messages.append({role: user, content: f工具結(jié)果{result}}) return 達(dá)到最大輪數(shù)任務(wù)未完成這二十行就是 Agent 的心臟。看起來簡單但每一行背后都有前面講的那些設(shè)計考量。我建議你先把這二十行跑通再往上加?xùn)|西。6.2 調(diào)試 Agent 的正確姿勢把每一輪都打出來Agent 調(diào)試和普通程序調(diào)試不一樣因為它的行為有隨機(jī)性出錯的時候你很難一眼看出是哪一步的問題。我的做法是把每一輪的完整信息都打印出來模型看到了什么、輸出了什么決策、工具返回了什么。def call_llm(messages): print( * 50) print(當(dāng)前上下文) for m in messages: print(f[{m[role]}] {m[content][:200]}) response client.chat(...) print(模型決策, response) return parse(response)這個日志看起來啰嗦但排查問題時非常有用。我遇到過好幾次Agent 行為詭異的情況都是靠這個日志定位到根因的——有時候是提示詞有歧義有時候是工具返回格式不對有時候是記憶里混進(jìn)了臟數(shù)據(jù)。6.3 常見故障與對應(yīng)排查方向跑起來之后你會遇到各種問題。我把最常見的幾類整理成一張表方便對照排查現(xiàn)象可能原因排查方向模型不調(diào)工具直接編答案提示詞沒強(qiáng)調(diào)必須用工具檢查系統(tǒng)提示詞的決策原則反復(fù)調(diào)用同一個工具工具結(jié)果沒讓模型滿意或陷入循環(huán)看工具返回是否清晰加輪數(shù)上限解析 JSON 失敗模型輸出格式不穩(wěn)定用結(jié)構(gòu)化輸出 重試兜底上下文超限記憶沒做壓縮檢查滑動窗口和截斷邏輯工具執(zhí)行報錯參數(shù)不合法或環(huán)境問題看參數(shù)校驗和異常兜底這張表里的每一條我都是真金白銀踩出來的。特別是反復(fù)調(diào)用同一個工具這條早期沒加輪數(shù)上限的時候模型能在一個工具上死磕十幾輪token 嘩嘩地?zé)?. 從能跑到好用幾個提升 Agent 質(zhì)量的經(jīng)驗7.1 給模型思考的空間但別讓它想太多前面提到 schema 里有個thought字段這個設(shè)計很關(guān)鍵。讓模型在行動前先輸出一段思考能顯著提升決策質(zhì)量。但要注意思考不能太長否則會擠占上下文也會拖慢速度。我的做法是在提示詞里限制思考控制在兩句話以內(nèi)。這個thought還有個額外好處調(diào)試的時候你能直接看到模型為什么做這個決策比只看動作要清楚得多。很多時候 Agent 行為不對看它的 thought 就能明白問題出在哪。7.2 工具描述要寫得像給新人的說明書工具描述的質(zhì)量直接影響模型能不能正確使用工具。我見過寫得很敷衍的比如read_file: 讀文件模型看了根本不知道參數(shù)該怎么填。好的工具描述應(yīng)該像給新人的說明書說清楚這個工具干什么、參數(shù)是什么格式、有沒有注意事項。比如read_file的描述我會寫成讀取指定路徑的文本文件內(nèi)容。參數(shù) path 是文件的完整路徑必須是字符串。如果文件不存在會返回錯誤信息。這樣模型一看就知道怎么用出錯率大大降低。7.3 別追求一步到位先讓最小閉環(huán)跑通最后一條也是最重要的一條經(jīng)驗不要一上來就追求完美。我見過太多人一開始就想搞一個支持多 Agent 協(xié)作、帶向量記憶、能自我反思的復(fù)雜系統(tǒng)結(jié)果卡在第一步就放棄了。正確的做法是先讓最小閉環(huán)跑通——哪怕它只能讀文件、只能跑十輪、只能處理簡單任務(wù)。跑通之后你自然會發(fā)現(xiàn)哪里不夠用然后有針對性地加?xùn)|西。我自己的 Agent 就是從只能讀文件開始的后來才慢慢加了執(zhí)行代碼、目錄列舉這些能力。每一步都是被真實需求推著走的而不是憑空設(shè)計出來的。這個思路其實也是 pi-agent 給我的啟發(fā)它的結(jié)構(gòu)之所以清晰就是因為每一塊都只解決一個具體問題沒有為了看起來強(qiáng)大而堆砌功能。剝開它、理解它、再自己實現(xiàn)一遍這個過程本身就是最好的學(xué)習(xí)。如果你也照著這個思路走一遍我保證你對 Agent 的理解會比看十篇概念文章都深。跑通之后你可以試著加一個新工具或者改一改記憶策略觀察 Agent 行為的變化——這種改一點、看效果的循環(huán)才是真正把知識變成自己的過程。