🔧 API 原樣轉成工具,後台畫面替客服人員做的事就落到模型身上。工具要照 Agent 的任務設計:模型判斷退哪幾件,金額由系統照規則算,做了什麼、為什麼被拒都寫進回傳。
昨天在 Day 13|多開幾個 Subagent,會比較快嗎?,我們看到 subagent 先換到的是乾淨的 context window,想再換到時間,分出去的工作要互不重疊。今天回到每個 Agent 手上的工具:名稱和參數都給了,模型為什麼還得自己猜?
快速回顧一下:Day 1 講過,Harness 是模型外面的控制系統:每一輪把工具定義跟著 request 送給模型,模型回一個工具呼叫,由它的 executor 執行,結果再放回下一輪。Day 4 的客服 Agent 就這樣處理退款:先查訂單,再提出退款。
假設團隊要讓它接手前台按鈕處理不了的退款,像是客戶一句話裡有的要退、有的要換,原本得轉給客服人員。客服人員用的後台畫面背後有一套 API,團隊用 FastMCP(能把 API 轉成 MCP server 的 Python 套件;MCP 是讓 Agent 接上外部工具的協定)把每支 API 轉成一支工具,先接到 Claude Code 試。客戶已經登入、從訂單頁進來。
王小明的訂單 5531:藍牙耳機 1,200 元、9/23 送達,耳機殼 380 元還沒出貨,用了「滿 1,500 折 150」的優惠券,實付 1,430 元。他說「耳機左耳沒聲音,耳機殼也還沒寄到,我不想等了」。這家店的規則跟 UNIQLO 台灣網路商店一樣:退掉之後留下的商品不到優惠券門檻,就從退款扣回折抵。耳機換貨、耳機殼取消,留下的耳機不到 1,500,耳機殼該退 380 − 150 = 230 元。客服人員在後台畫面勾耳機殼,畫面照這條規則算好 230,按一下就取消出貨、送出退款。
我用 Claude Code v2.1.280 當 Harness,模擬這個客服 Agent。後台 API 是我用 FastAPI 寫的假服務,查訂單、查物流、查退款紀錄、取消品項、建立退款、建立退貨、改收件地址共 7 支,用 FastMCP 轉成 MCP server 接上 Claude Code。Bash、Read、WebFetch 這些內建工具全部關掉,它只能用這 7 支;system prompt 寫客服的角色和三條退貨規則(沒出貨的可以直接取消退款、送達 7 天內的瑕疵品寄回退換、出貨 14 天沒到視為遺失),沒提優惠券,也不載入我自己的設定。每次開新的 session 送王小明那句話,跑了 5 次。
轉出來的工具裡,退款的 create_refund 要帶一個金額 amount。5 次的前半段都一樣:查訂單、查物流、查退款紀錄,再呼叫 cancel_line_item 取消耳機殼。接著就分開了:1 次自己決定把 150 元照價格比例分攤,耳機殼分到 150 × 380 ÷ 1,580 ≈ 36 元,呼叫 create_refund 退 344 元;另外 4 次停在已取消、還沒退款,跟王小明說金額確認後再告訴他,在備註裡請主管決定優惠券怎麼算。

圖:耳機換貨,耳機殼不想等了 → create_refund 要模型填金額 → 該退 230 元,算法在後台畫面裡 → 5 次都先取消耳機殼的出貨 → 1 次自己照比例退了 344 元 → 4 次停在已取消、還沒退款。這是示意,實作要照自己的工具和環境調整。
Claude 沒亂來,規則沒給,它多半停下來問人。卡住的是工具:create_refund 要一個金額,這個金額怎麼算原本寫在後台畫面裡,轉成工具時沒有跟過來。結果是多退 114 元,或是訂單卡在一半,等客服人員補上畫面一按就有的數字。
下面先看模型拿到的定義、換成 MCP 有沒有差,再看金額該誰算、回傳和錯誤怎麼寫,接著比較 MCP 和 CLI,最後看怎麼驗證。
一支工具交給模型的,主要是三個欄位:名稱、描述,和參數的 JSON Schema(用 JSON 寫出每個欄位的型別、哪些必填、允許哪些值)。MCP server 交出來的也是這幾個,MCP 規格另外定義了給人看的 title 和描述回傳格式的 outputSchema。
這些定義最後變成模型讀的文字。Claude 的工具文件寫,帶著 tools 呼叫 API 時,API 會拿工具定義、工具設定和你的 system prompt 組出一段專門的 system prompt。名稱和描述寫了什麼,模型就讀到什麼,沒寫的它只能猜。開頭那支 create_refund,Claude Code 送給模型的是這樣(名字前面的 mcp__backoffice__ 下一節講):
{
"name": "mcp__backoffice__create_refund",
"description": "建立退款,退回原付款方式。amount 為退款金額(新台幣)。",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "title": "Order Id"},
"amount": {"type": "integer", "title": "Amount"},
"reason": {"type": "string", "title": "Reason"},
"line_item_ids": {"type": "array", "items": {"type": "string"},
"title": "Line Item Ids", "default": []}
},
"required": ["order_id", "amount", "reason"]
}
}
這套 API 是用 FastAPI(Python 寫 API 的框架)寫的,描述是程式裡那行說明,title 是 FastAPI 照欄位名稱產生的。同一頁文件的建議把描述排在第一條:「Provide extremely detailed descriptions. This is by far the most important factor in tool performance.」每支工具至少寫三到四句,講它做什麼、什麼時候該用和不該用、每個參數的意思,還有限制。這支是寫給看 API 文件的工程師的,單位寫了;會真的退錢、送出不能撤回、取消出貨在另一支,還有金額該怎麼算,都沒寫。
schema 擋得住的是格式。Claude API 和 OpenAI 都有 strict 模式:工具定義設 strict: true,模型產生參數時就被限制在 schema 允許的範圍內,Claude 文件寫的保證是「Tool input strictly follows the input_schema」,OpenAI 的 function calling 文件也建議一律打開。
開頭那幾次呼叫,開了 strict 一樣會送出去。344 和 230 都是合法的整數,schema 分不出哪個照了店裡的規則;該退多少要看這張訂單還留下什麼,本來就寫不進 schema。這要靠工具怎麼設計參數、描述和回傳,讓模型不用猜。
MCP 規定的是 Harness 跟工具那一端怎麼溝通:Harness 連上 MCP server,先送 tools/list 拿工具清單;模型要用某支工具時,再送 tools/call 請 server 執行。清單裡的名稱、描述和 schema,Harness 會轉成自己的工具定義,跟內建工具放進同一張工具表。要看的是轉完之後,模型那一側還分不分得出來。
我直接看 Claude Code v2.1.280 送出去的 request。Claude Code 的 request 要送去哪裡,由環境變數 ANTHROPIC_BASE_URL 決定,預設是 Anthropic 的伺服器。
我請 Claude Code 用 Python 內建的 http.server 寫了一支不到 30 行的轉發程式,在本機的 8789 port 等著;啟動 Claude Code 時設 ANTHROPIC_BASE_URL=http://127.0.0.1:8789,它送出的每個 request 就先到這支程式。程式把 request 的內容原封不動存成一個 JSON 檔,header 和內容照原樣轉給 api.anthropic.com,拿到回應後也存一份,再交回 Claude Code。
存下來的 request 裡,tools 這一欄就是這一輪送給模型的工具清單;回應開頭的 usage 是 API 算的 input token 數。接上開頭那個 MCP server(名字叫 backoffice,裡面是 FastMCP 轉出來的 7 支),叫 Claude 只回一個 ok。每次都加 --setting-sources local,不載入我自己的 CLAUDE.md 和 hook。
Claude Code 的 MCP 文件寫,ANTHROPIC_BASE_URL 指到非官方的位址時,會關掉 tool search(先只給工具名字、用到再載入定義,Day 15 細講)。所以我用 ENABLE_TOOL_SEARCH 明確設定開和關各跑一次,再各跑一次不接 MCP server 的當對照。
tool search 關掉時,request 的 tools 裡有 34 支:7 支 MCP 工具,加上 27 支內建工具。不接 MCP server 時內建工具只有 24 支,多出來的 3 支是讀 MCP server 提供的 resources 用的。內建的 WebFetch 跟 MCP 的 create_refund 放在一起看:
request 的 tools(tool search 關掉)
{"name": "WebFetch",
"description": "Fetches a URL, converts the page to markdown, ...",
"input_schema": {...}}
{"name": "mcp__backoffice__create_refund",
"description": "建立退款,退回原付款方式。amount 為退款金額(新台幣)。",
"input_schema": {"type": "object", "properties": {"order_id": ..., "amount": ...}, ...}}
兩支都只有 name、description、input_schema 三個欄位。MCP 留下的痕跡,只剩名字前面的 mcp__backoffice__。
打開 tool search,tools 剩 12 支,WebFetch 和 7 支 MCP 工具都不在裡面。它們的名字排在同一份清單,附在訊息裡,告訴模型這些要先用 ToolSearch 載入:
request 裡延後載入的清單(tool search 開著,最後九行)
WebFetch
WebSearch
mcp__backoffice__cancel_line_item
mcp__backoffice__create_refund
mcp__backoffice__create_return
mcp__backoffice__get_order
mcp__backoffice__list_refunds
mcp__backoffice__list_shipments
mcp__backoffice__update_shipping_address
我再叫 Claude 呼叫一次 ToolSearch,同時點名 WebFetch 和 mcp__backoffice__create_refund。回來兩個 tool_reference,下一個 request 的 tools 就多了這兩支,欄位一樣,都多一個 defer_loading: true。
token 也對得上。API 回報的 input token,tool search 關掉時,接這個 MCP server 比不接多 1,845 個,其中 7 支工具的定義,Claude Code 的 /context 估約 874 個,其餘大多是那 3 支讀 resources 的工具;打開之後只多 142 個,是這 10 個名字。整個 request 從 32,261 降到 15,574,省下的大多是內建工具的定義。
Codex 的原始碼也是這樣接的(照 commit 44fe510 讀)。每一輪組工具時,先放內建工具,再把 MCP 工具加進同一張工具表;每支 MCP 工具包成一個 handler,跟內建工具實作同一套介面,交出名稱、給模型的定義和搜尋用的資訊。模型和 API 端都支援工具搜尋時,MCP 工具預設標成延後載入;表裡只要有延後、能被搜尋的工具,不管是哪裡來的,就多放一支搜尋工具讓模型去找。
Claude 的 Agent SDK 更直接。自訂工具的文件要你用 createSdkMcpServer 把自己寫的函式包成 MCP server,這個 server「runs in-process inside your application, not as a separate process」,工具名稱一樣是 mcp__{server_name}__{tool_name}。
SDK 的 tool search 文件也寫,延後載入「applies to all registered tools, whether they come from remote MCP servers or custom SDK MCP servers」。在 Claude Code 這一套裡,自己寫的工具本來就是 MCP 工具。
MCP 管的是模型看不到的那一半:工具可以在同一個 process、另一個 process 或另一台機器上跑,遠端 server 需要授權時要處理登入,server 也能在執行中通知工具清單變了。Harness 自己分得出哪支是 MCP 來的。Claude Code 的 hook 收到 MCP 工具時多一欄 mcp_server,文件建議判斷信不信任時看裡面的 source(這個 server 是外掛、SDK 還是哪一層設定加進來的),不要看名字前綴;Codex 替外掛帶來的 MCP 工具設了上限,說明只留前 1000 bytes。
所以常聽到的「MCP 很吃 Context」,要看 Harness 怎麼載入。Claude Code 和 Codex 開著工具搜尋時,MCP 工具預設延後載入,跟可延後的內建工具一起省;沒有延後載入的 Harness,每一輪都送整份定義,換成自己寫的工具一樣吃。延後載入管的只有定義,工具回傳的內容照樣進 Context。延後載入怎麼運作、還有哪些代價,Day 15 再看。
開頭那 5 次會卡住,問題不在 MCP。
FastMCP 的 FastAPI 整合文件寫,每支 endpoint 預設轉成一支工具。同一頁也提醒:「LLMs achieve significantly better performance with well-designed and curated MCP servers than with auto-converted OpenAPI servers」,建議這樣轉只拿來「bootstrapping and prototyping, not for mirroring your API to LLM clients」。
Anthropic 工具設計文章也點過這種包法:「A common error we've observed is tools that merely wrap existing software functionality or API endpoints」。
轉的時候少掉的,是後台畫面替客服人員做的事。這套 API 本來是給畫面的程式呼叫的:客服人員勾品項,畫面照優惠券規則算出金額,再依序呼叫 cancel_line_item 和 create_refund。規則寫在畫面的程式裡,API 只收一個算好的數字。轉成工具之後,模型拿到的是這兩支底層的 API,「該退多少」在哪支工具的定義或回傳裡都找不到。Claude 的內部備註也寫了:規則沒說優惠券怎麼算,要主管決定。
這條規則真的有店在用。UNIQLO 台灣的退貨說明寫:「若退貨後,該筆訂單『保留的商品總金額』未達優惠券使用門檻,系統將從退款金額中扣除該優惠券的折抵金額」。同一頁還有兩條:原本免運的訂單,退貨後留下的商品不到 1,500 元,要從退款扣 80 元運費;同一筆訂單第二次以後的退貨,每次再扣 80 元。一家店的退款規則通常不只一條。
改法有兩種。一種是把規則寫進 system prompt 或工具描述,讓模型自己算。我把上面那條加進 system prompt,同一句話再跑 5 次,5 次都算出 230,也都先把金額講給王小明聽,等他回覆才動手。這張單,寫進 prompt 就解了。
另一種是讓模型不用算:Agent 在這一步要判斷的是「王小明要退哪幾件」,工具就收品項,金額由後端算,取消出貨一起做。前台的退貨按鈕本來就這樣,客戶勾要退哪幾件,送出去的是品項,不是金額。Shopify 的訂單 API 有一個 suggestedRefund,照客戶退回的品項、運費和關稅算出建議的退款金額,折扣和稅都拆好;τ-bench(Sierra 做的客服 Agent 評測環境)的退貨工具,參數是訂單編號、品項編號和退款方式,沒有金額欄位。
我會選第二種。規則已經是後台畫面的程式,prompt 再寫一份文字版,行銷改規則、加一條運費,兩份都得改;模型算出的 230 送出去之前,也沒有東西驗得了它對不對,要驗就得把規則再寫一次。做法是把畫面裡算金額那段搬到後端,畫面和工具呼叫同一份。Shopify 做安全掃描的 Agent Harness 也是讓腳本管固定的格式、Agent 只填內容,理由是「Deterministic scripts mean fewer malformed outputs and parseable results that you can verify.」
金額還會影響王小明要不要取消:知道只退 230,他可能寧可等耳機殼。所以我拆成兩支,quote_refund 只試算、不動錢,像 Shopify 的 suggestedRefund 先算給人看;refund_items 才真的退。改前是 FastMCP 從 API 轉出來的兩支,描述各只有一句:
cancel_line_item(order_id, line_item_id)取消尚未出貨的品項,倉庫不再出貨。
create_refund(order_id, amount, reason, line_item_ids)建立退款,退回原付款方式。amount 為退款金額(新台幣)。
改後照客服 Agent 的任務寫,兩支都只收 order_id 和 line_item_ids,參數說明寫「填 get_order 回傳的 line_item_id,例如 ["li_2"]」:
quote_refund(order_id, line_item_ids)試算:客戶退掉這幾個品項、其他留著,會拿回多少錢。只試算,不會退款也不會取消出貨。金額由系統照公司的退款規則算,包括優惠券:退掉之後留下的商品未達優惠券門檻,會從退款扣回折抵金額。回傳金額和算法,請照實告訴客戶,客戶同意後再用 refund_items(還沒出貨的)或 create_return(已送達的)。
refund_items(order_id, line_item_ids)退掉一張訂單裡還沒出貨的品項:取消出貨,錢退回客戶原本的付款方式,送出後不能撤回。金額由系統照退款規則算(跟 quote_refund 的結果一樣),不用填。呼叫前先用 quote_refund 跟客戶說明金額,客戶同意再送。已送達的品項要寄回,改用 create_return;已經取消或退過的品項會被拒絕,錯誤訊息會附上次的日期和金額。
要是有工具真的得讓模型填金額,例如客服主動給的補償,單位和上限就寫進參數名稱和說明。格式慣例光靠描述還說不清的話,Claude API 可以在工具定義裡放 input_examples,附上填好的參數範例,Anthropic 建議每支一到五組。Anthropic 在 2025 年 11 月的文章裡說,JSON Schema 描述得了型別和必填,描述不了「什麼時候填哪個選填欄位、API 預期什麼慣例」;他們內部測試加上範例之後,複雜參數的正確率從 72% 提高到 90%。
寫在描述裡的「客戶同意再送」,是請模型這樣做,它照不照做沒有保證。Claude Code 的權限文件提到寫在 CLAUDE.md 的指引時說「This shapes what Claude tries but doesn't enforce a boundary」,我覺得放到工具描述也一樣。
改後那 5 次,Claude 都等王小明回覆才退,但上線時我不會只靠這句描述:quote_refund 回一個試算編號,refund_items 只收這個編號,Harness 在客戶的畫面上跳出「確認退款 230 元」的按鈕,客戶按下去才執行。這樣退出去的一定是客戶看過的那筆,品項和金額都不會跑掉。確認和核准怎麼接進 Harness,Day 20 接著講。
客戶和訂單也不交給模型挑。客戶編號由 Harness 從登入身分帶給工具,模型的參數裡沒有這一欄;get_order、refund_items 只查得到這位客戶的訂單,模型填了別人的訂單編號,拿到的也只是「找不到」。王小明是從訂單 5531 的頁面進來的,上線時訂單編號也可以由 Harness 帶入;這次測試為了跟改前對照,才保留 order_id 參數。要是工具開放用姓名搜全部客戶,同名那位客戶的電話和訂單就會出現在這場對話裡。身分怎麼帶、權限怎麼切,Day 22 談。
往這個方向收,模型要判斷的只剩從王小明那句話讀得出來的事:哪件要退、哪件要換。系統查得到的、按鈕確認得了的,都交給 Harness 和後端。
參數名稱也要分得清。那篇文章的例子是「instead of a parameter named user, try a parameter named user_id」。get_order 回的品項欄位叫 line_item_id,refund_items 的參數就叫 line_item_ids,模型照抄就對得上,不會把品項編號填進 order_id。
程式替模型算錢,紀錄就要兩邊都留:模型選的是 li_2,系統算出 230 元,照的是哪條規則。之後對帳,才分得出是模型選錯品項,還是算錢的程式錯。
成功的回傳也要把做了什麼講出來。改前 create_refund 回的是 {"refund_id": "rf_8801", "amount": 344, "status": "succeeded", ...},344 是模型自己填的,回傳只是照抄。改後的 refund_items 回:
已取消耳機殼 ×1的出貨,退款 NT$230 退回原付款方式(信用卡末四碼 4242),退款編號 rf_8801。
算法:售價 380;退掉之後留下的藍牙耳機 1,200 元,未達優惠券「滿 1,500 折 150」的門檻,扣回折抵 150。
取消了沒、退了多少、怎麼算的,都寫在回傳裡。改後跑的 5 次,Claude 跟王小明解釋 230 時,講的都是試算和回傳裡這套算法:380 扣回 150,因為留下的耳機不到 1,500。
Day 3 講過,模型的下一步,很大一部分是被上一個工具結果決定的;Harness 想引導模型,最直接的辦法就是決定工具結果怎麼寫。錯誤訊息最用得上這一點:模型剛做錯一步,下一輪讀到的錯誤訊息,決定它是改對參數、換一支工具、回頭問客戶,還是把同樣的東西再送一次。
Claude Code 自己的 Edit 工具是現成的例子。Claude Code 的工具文件寫,Edit 拿 old_string 去檔案裡做完全比對的替換,old_string 在檔案裡要剛好出現一次。我在 v2.1.280 故意讓它出現兩次,回來的是:
Found 2 matches of the string to replace, but replace_all is false. To replace all
occurrences, set replace_all to true. To replace only one occurrence, please provide
more context to uniquely identify the instance.
這則訊息講了哪裡不對(出現兩處),也給了兩條路(設 replace_all,或多帶一點上下文)。模型收到之後,有東西可以改。
我也翻了自己 Claude Code 的紀錄檔(~/.claude/projects/<專案>/<session>.jsonl),把內容是「File has not been read yet. Read it first before writing to it.」的工具錯誤都找出來:Edit 和 Write 一共被擋了 42 次,其中 34 次,下一個工具呼叫就是 Read 同一個檔案。這是不同版本、不同模型混在一起算的。文件也寫,這條「先讀再改」現在只對 Opus 4.6、Haiku 4.5 和更舊的模型一定要求;較新的模型,只要讀那個檔案不用另外跳權限確認,就能不讀直接改。
沒有東西可以回的時候也一樣。指令跑完什麼都沒印,Claude Code 交給模型的是「(Bash completed with no output)」,我的紀錄檔裡有 370 筆。SWE-agent 的論文也這樣做,回「Your command ran successfully and did not produce any output」,他們的說法是讓回饋更清楚。一個空字串,模型分不出是成功了沒輸出,還是出了什麼事。
錯誤要放在模型看得到的位置。Claude API 的 tool_result 可以標 is_error: true,文件的提示是「Instead of generic errors like "failed", include what went wrong and what Claude should try next」。
MCP 規格從 2025-11-25 那版開始寫明:參數驗證失敗(格式錯、數值超出範圍)和業務規則錯誤,都放在工具結果裡、標 isError: true,client 要交給模型,因為這種錯誤「contain actionable feedback that language models can use to self-correct and retry with adjusted parameters」。只有請求本身格式壞掉,才走協定層的錯誤。
回到王小明這張單。從他開口到 Claude 按下取消,倉庫可能已經把耳機殼撿貨裝箱,購物網站常見的「已進入出貨流程,無法取消」就是這種情況。我把耳機殼改成這個狀態,直接呼叫兩邊的工具。改前的 cancel_line_item,是 FastMCP 把 API 的 HTTP 錯誤原樣轉過來:
Error calling tool 'cancel_line_item': HTTP error 409: Conflict - {'detail': 'line item already fulfilled'}
原因有寫,是寫給看 log 的工程師的;模型看不出錢退了沒、該跟客戶說什麼、改用哪支工具。改後的 refund_items 回:
is_error: true
耳機殼(li_2)已經進入出貨流程,取消不了,也還沒退款。請跟客戶說明:收到後用 create_return 退貨,
倉庫收到後退款,金額可以先用 quote_refund 試算。
SWE-agent 把這件事做進了工具裡。它的 edit 指令改完會跑語法檢查,會產生語法錯誤的修改直接退回,再告訴模型錯在哪、這次修改原本會變成什麼樣子。用 GPT-4 Turbo 跑 SWE-bench Lite(300 個真實的 GitHub issue,看 Agent 能不能修好),拿掉這個檢查,解題率從 18.0% 掉到 15.0%。
錯誤訊息寫得再清楚,也要先決定這個錯該不該交給模型。Day 1 提過,有些已知的錯誤可以直接寫進 executor 處理,會付款的操作則要先查清楚第一次有沒有生效。放到退款工具,我會這樣分:
| 狀況 | 誰處理 | 模型收到什麼 |
|---|---|---|
| 狀態不對:已進入出貨流程、已經退過、已送達要寄回 | 模型換做法,或回頭跟客戶說明 | is_error,現在的狀態、上次做了什麼、可以改走哪條路 |
| 參數不對:品項不在這張訂單、格式不對 | 模型改參數,或回頭問客戶 | is_error,哪個欄位、限制是什麼、這次填了什麼 |
| 找不到:訂單編號不存在 | 模型重新查 | is_error,查了什麼、該用哪支工具查 |
| 請求沒送出去:連不上後端、被限流 | executor 在次數上限內自己 retry | 都失敗才回,說明試了幾次、要等多久 |
| 沒權限、要主管核准 | 停下來交給人 | 缺哪個權限或核准,不叫它換參數再試 |
| 送出後 timeout,不知道退了沒 | 不能直接重送 | 結果未知,要先查證 |
後兩列不是模型改參數能解決的。沒權限時叫它「修正後再試」,它可能會換別的工具繞過去,Day 15 和 Day 20 會接著講;送出後 timeout 的退款可能已經生效,Day 18 專門處理。
同一支退款工具,團隊也可以不包 MCP,寫成一支命令列工具(CLI),讓 Claude Code 用內建的 Bash 工具(在 terminal 跑指令)去執行。這樣模型手上沒有 refund_items 這支工具,只有 Bash,退款要自己組一串指令。Claude Code 的最佳實踐文件就偏向這邊:「CLI tools are the most context-efficient way to interact with external services」,沒看過的指令,叫 Claude 先讀 --help 再用。改後的 refund_items,兩種做法交給模型的字並排是這樣:
包成 MCP:模型讀到的工具定義
name refund_items
description 退掉一張訂單裡還沒出貨的品項:取消出貨,錢退回客戶原本的付款方式,……
order_id string
line_item_ids array of string
做成 CLI:模型跑 refund items --help 讀到的
usage: refund items [-h] --order ORDER --items ITEMS [ITEMS ...]
退掉還沒出貨的品項:取消出貨,退回原付款方式。金額由系統照退款規則算,不用填。
optional arguments:
-h, --help show this help message and exit
--order ORDER 訂單編號,例如 5531
--items ITEMS [ITEMS ...]
品項編號,例如 li_2
說明要寫多少,兩邊都放得下。反過來,改前那支 create_refund 做成 CLI,一樣要帶 --amount,金額一樣是模型在算,換形式不會變。指令失敗時,Claude Code 交給模型的是「Exit code 1」加上 stderr,一樣標 is_error: true,錯誤訊息寫得清不清楚,換成 CLI 也一樣要顧。兩種形式的欄位對得起來:
| 模型要知道的 | 包成 MCP | 做成 CLI |
|---|---|---|
| 有哪些工具 | 工具清單裡的名稱 | 子指令,refund --help 列得出來 |
| 做什麼、什麼時候用 | description |
--help 開頭那段說明 |
| 參數怎麼填 | inputSchema 和欄位說明 |
參數和 --help 裡的說明,型別由參數解析檢查 |
| 做了什麼 | 工具結果的內容 | stdout;要給程式讀就加 --json |
| 出錯了 | isError: true 加錯誤訊息 |
exit code 不是 0,加 stderr |
Context 上兩種做法差得不多。前面看過,MCP 工具延後載入之後只剩名字,開頭那個 server 加起來 142 個 token。Claude Code 講怎麼省花費的文件說 CLI 還是比較省,因為「they don't add any per-tool listing」,連名字都不用放;--help 用到才讀,git、gh 這種常見的指令,模型本來就會用,連讀都不用。
實際量過的差距不大。Mario Zechner(開源 coding agent pi 的作者)寫了一個工具,讓 Agent 操作 terminal 裡的程式,像 debugger、Python REPL。這個工具他做成 MCP 和 CLI 兩版,讓 Claude Code 跑三種任務、每種 10 次:兩版成功率都是 100%,MCP 版總時間少 23%、費用少 2.5%。
他的結論是「The problem with many MCPs isn't the protocol - it's that they're badly designed wrappers that dump unnecessary JSON everywhere」,跟前面 FastMCP 和 Anthropic 提醒的「原樣包 API」是同一件事。他也寫了兩邊各適合什麼:沒有內建 shell 的 client 只能用 MCP,要維持狀態的工具做成 MCP 比較好寫;從頭寫新工具、使用者本來就有 shell,做一支好的 CLI 就好,輸出還能 pipe 給別的指令先篩過。
寫程式這一塊,SWE-agent 的團隊兩邊都走過。同一篇論文用 GPT-4 Turbo 跑 SWE-bench Lite,給模型一套專門設計的看檔、搜尋、編輯指令,解題率 18.0%;只給一個 shell 是 11.0%。
一年後他們做了 mini-SWE-agent,除了 bash 沒有別的工具,理由是「as LMs have become more capable, a lot of this is not needed at all to build a useful agent」;README 寫它在另一組題目 SWE-bench Verified 拿到 74% 以上。README 沒寫用哪個模型,題目也不同,兩組數字不能直接比;只給 bash 能行得通,是因為寫程式的環境本來就有 git、grep 和測試指令。
退款就不一樣了。只給 bash、讓 Claude 用 curl 直接打後端的 API,前面加的照規則算金額、試算和退款分開、取消出貨一起做、看得懂的錯誤訊息,都沒地方放。做成 CLI 的話,這些就寫在 refund 這支指令裡,跟寫在 MCP server 裡的是同一份程式。
放到 Harness 這一層看,兩種做法還差在工具執行前,Harness 拿到的是什麼。退款送出前,Harness 常要再查一次,例如這張訂單是不是登入的客戶的、後端算出的金額有沒有超過要主管核准的門檻(Day 20、23 會接上)。Claude Code 做這件事的地方是 PreToolUse hook:每次工具執行前先跑的一支腳本,讀得到這次的工具名稱和參數,可以放行、擋下,或跳出確認畫面問人。我在 v2.1.280 讓 Claude 用兩種形式各退一次耳機殼,hook 收到的是:
包成 MCP
tool_name mcp__refund__refund_items
tool_input {"order_id": "5531", "line_item_ids": ["li_2"]}
做成 CLI
tool_name Bash
tool_input {"command": "./refund items --order 5531 --items li_2 2>&1",
"description": "Refund li_2 on order 5531 via CLI"}
MCP 那邊,訂單和品項各是一個欄位,hook 直接讀。CLI 那邊只有一串指令,hook 得自己從字串裡拆出 --order 和 --items;模型換個寫法,拆法就要跟著補。這次退款那個呼叫在後面多接了 2>&1,前一個呼叫還把 ls -la; 和 ./refund --help 串在同一行送出。Claude Code 的權限文件對 Bash 也這樣提醒:「Bash permission patterns that try to constrain command arguments are fragile」。
這個差別出在專用工具和 Bash,跟 MCP 無關。Claude Code 內建的 Write,hook 收到的也是 file_path、content 兩個欄位;寫在自己 Harness 裡的專用工具,執行前拿到的一樣是模型填好的參數。
所以我的判斷是:寫程式、查 CI 這種已經有好用指令的,直接讓模型用 CLI。退款這種帶規則的動作,規則要包進工具,MCP 和 CLI 都放得下;差在 Harness 送出前要不要再查一次。開頭的團隊最後要讓客服 Agent 自己退款,Claude Code 只是先拿來試。客服 Agent 是跑在後端的服務,我不會為了用 CLI 給它一個能跑任意指令的 shell;送出前要查訂單、接核准,拿到欄位也比拿到一串指令好查。所以我會做成專用工具。
專用工具要包成 MCP server,還是直接寫在客服 Agent 的程式裡,前面看過,模型拿到的是同一種工具定義,差的只有名字前綴。要給好幾個 Agent 共用,或像開頭那樣先接到 Claude Code 試,包成 MCP server 比較方便。
MCP 這兩年被批評的,除了占 Context(前面看過,要看 Harness 怎麼載入),還有登入和資安。規格到 2025-03-26 那版才加上以 OAuth 2.1 為基礎的授權;第三方 server 給的工具說明會直接進模型讀的內容,Invariant Labs 示範過在一支加法工具的說明裡藏指令,讓 Agent 讀出使用者的 SSH 私鑰、當成參數送出去。這兩件事換成 CLI 也躲不掉:CLI 一樣要登入,模型一樣會讀到第三方寫的 --help 和指令輸出。Agent 的身分和權限 Day 22 談,外部內容帶偏模型 Day 24 談。
改描述、改回傳,都是在改模型讀到的內容,效果要拿任務量。Anthropic 那篇的流程是先做原型在本機試,再跑一輪完整的評測,之後和 Agent 一起反覆改;除了成功率,也建議收每次工具呼叫和整個任務的時間、工具呼叫次數、token 用量和工具錯誤。他們還說可以把評測的紀錄整份貼進 Claude Code,讓它幫忙改工具;Claude Sonnet 3.5 在 SWE-bench Verified 拿到當時最好的成績,就是在精確調整工具描述之後。
小地方也量得出差別。SWE-agent 同一組實驗裡,看檔案時一次顯示 100 行,解題率 18.0%;只顯示 30 行是 14.3%,整個檔案丟進去是 12.7%。這些是 2024 年 GPT-4 Turbo 在程式任務上的數字,換到退款工具不能照搬,能照搬的是量法:同一批任務、同一個模型,只換工具介面。
開頭那張單我就是這樣比的,同一句話各跑 5 次:改前 1 次退 344、4 次停在已取消沒退款;規則寫進 prompt,5 次都說 230;改後 5 次都先試算、跟王小明講 230,他回覆之後都退 230、開換貨單。5 次只看得出方向。要測得完整,同一批任務還要放已送達要寄回的、已經退過的、進入出貨流程的、沒用優惠券的,數退對金額的次數、is_error 的次數、轉人工的次數,和每張單用了幾次工具呼叫。中間換了模型或 system prompt,成功率變了也說不準是哪個改動造成的。
少了哪一樣,模型或看紀錄的人就有一件事得用猜的:
| 欄位 | 這個情境的值 | 誰讀、拿來做什麼 |
|---|---|---|
| name/description | quote_refund 只試算;refund_items 會真的退錢、送出不能撤回;金額由系統算;先試算、客戶同意再送 |
模型選工具時讀 |
| 參數 | order_id、line_item_ids,填 get_order 回傳的編號;沒有金額 |
模型填參數時讀 |
| 客戶身分 | 登入的客戶編號,由 Harness 帶進工具 | 工具只查、只退這位客戶的訂單,模型不用填 |
| 金額規則 | 跟後台畫面同一份:退掉之後留下的商品未達優惠券門檻,扣回折抵 | 試算和退款時由後端算,模型不用算 |
| 呼叫紀錄 | 模型選的品項、算出的金額、用了哪條規則 | 事後對帳的人 |
| 成功回傳 | 取消了哪件、退了多少、怎麼算的、退款編號 | 模型回覆客戶前讀 |
| 錯誤回傳 | is_error,加上哪裡不對、現在的狀態、下一步 |
模型決定換做法還是轉人工 |
下面是放在 adapter(包在後端 API 外面、把模型的參數轉成 API 呼叫的那層程式)裡的 pseudocode。tool_error() 回一個標了 is_error 的工具結果;refund_quote() 是從後台畫面搬到後端的那段算金額程式,quote_refund 也呼叫它。已退過、已出貨的檢查後端還會再做一次,adapter 先查是為了把錯誤講清楚。
def refund_items(args, customer_id, backend, log):
# customer_id 由 Harness 從登入身分帶進來,模型的參數裡沒有這一欄
order = backend.get_order(args["order_id"], customer_id) # 只查得到這位客戶的訂單
if order is None:
return tool_error(f"這位客戶沒有訂單 {args['order_id']}。order_id 要填 get_order 回傳的訂單編號。")
items = [order.item(i) for i in args["line_item_ids"]]
if None in items:
return tool_error(f"訂單 {order.id} 沒有這個品項。line_item_ids 要填 get_order 回傳的 line_item_id。")
for item in items:
if item.status == "delivered":
return tool_error(f"{item.name}已送達,要客戶寄回,請改用 create_return。")
if item.status == "cancelled":
last = backend.last_refund(order.id, item.id)
return tool_error(f"{item.name} {last.date} 已經取消並退款 {money(last.amount)}"
f"(退款編號 {last.id}),不能再退。請跟客戶說明日期和金額。")
if item.status != "unfulfilled":
return tool_error(f"{item.name}已經進入出貨流程,取消不了,也還沒退款。"
"請客戶收到後用 create_return 退貨。")
quote = backend.refund_quote(order, items) # 跟後台畫面同一份規則,含優惠券扣回
log(model_args=args, amount=quote.amount, rule=quote.why)
backend.cancel_line_items(order.id, [i.id for i in items])
refund = backend.create_refund(order.id, quote.amount, [i.id for i in items]) # retry、核准和去重不在這裡
return tool_ok(f"已取消{names(items)}的出貨,退款 {money(quote.amount)} 退回原付款方式,"
f"退款編號 {refund.id}。算法:{quote.why}。")
改後實際跑出來的是這樣:
| 誰呼叫 | 什麼時候 | 結果 |
|---|---|---|
| Claude | 王小明說「耳機左耳沒聲音,耳機殼也還沒寄到,我不想等了」 | 查完訂單,用 quote_refund 試算只退耳機殼(NT$230)和兩件都退(NT$1,430),有 1 次多算了只退耳機;5 次都把兩種選擇和金額講給他聽,問耳機要換還是退,沒有先動手 |
| Claude | 王小明回「耳機換貨,耳機殼還是取消」 | refund_items 只帶 ["li_2"],系統算出 NT$230、取消出貨;create_return 開換貨單;5 次都照回傳告訴他退了 230、換貨單編號 |
| adapter | refund_items 執行時 |
紀錄寫下模型選的 li_2、算出的 230,和「留下的藍牙耳機 1,200 元未達門檻、扣回 150」這條規則 |

圖:試算和退款分成兩支 → 工具收品項,不收金額 → 後端照後台畫面的規則算出 230 → 回傳寫退了多少、怎麼算的 → 錯誤說哪裡不對、下一步做什麼 → 同一張單改前改後各跑 5 次。這是示意,實作要照自己的工具和環境調整。
假設你已經做到 Day 13:旁支的工作交給 subagent,主對話只收整理過的報告。接下來要讓每個 Agent 手上的工具好用一點。形式先定下來:Agent 有 shell、要接的服務也有好用的 CLI,就直接用 CLI;Agent 沒有 shell,或送出前要在 Harness 這層查參數,就做成專用工具,包成 MCP server 或寫在 Agent 的程式裡都可以。下面幾步兩種形式都適用,我會照這個順序:
第一步,先從紀錄找出最常讓模型卡住的那支工具。 標了 is_error 的結果、同一份參數換寫法連送好幾次、模型在回覆裡寫「規則沒寫」「要主管決定」的地方、做到一半就停下來的地方,都是線索。還沒有紀錄的話,照 Anthropic 那篇的做法,先包成 MCP server、用 claude mcp add 接到 Claude Code 跑幾個真實任務。後端已經有 FastAPI 或 OpenAPI 文件的,可以像開頭那樣用 FastMCP 轉一份出來試,很快就看得到模型卡在哪;那份拿來找問題,不直接上線。
第二步,補那支工具的描述、參數和回傳。 描述寫它做什麼、什麼時候用、呼叫前要先確認什麼;參數照 Agent 要做的事設計,系統算得出來的金額不要讓模型填,後台畫面替人算好的東西搬到後端;會動到錢的,先給一支只試算的;成功的回傳講清楚實際做了什麼、怎麼算的。
第三步,改錯誤訊息,分清誰處理。 給模型的錯誤說哪裡不對、下一步做什麼;沒送出去的暫時故障由 executor 在上限內 retry;會付款的操作,送出後結果不明就不要自動重送。
第四步,同一批任務改前改後各跑幾次。 比做對的次數、錯誤次數和呼叫次數,確認改動真的有幫上忙。
工具只有幾支、模型已經很少卡住的話,做到第二步就夠了。第三、四步是給會產生外部效果、錯了代價高的工具,退款就是一例。
後面還會往這裡加:Day 15 看工具一多之後,定義什麼時候進 Context、名字又像的時候怎麼找到對的那支,Day 16 處理回傳只有一部分,Day 18 處理送出後不知道成功沒有,Day 20 到 23 把權限和核准接上去。
回到開頭那張單,可以試著問自己:create_refund 回了 succeeded,送出去的 JSON 也合法,為什麼王小明多拿了 114 元?開了 strict 模式擋得住嗎?把優惠券規則寫進 system prompt,5 次都算對了,那還要改工具嗎?
第一題,schema 只知道 344 是合法的整數,strict 模式保證的也只是參數符合 schema,所以擋不住。344 是模型照價格比例分攤折扣算的,這家店的規則是扣回整張優惠券;規則在後台畫面裡,工具又要它填金額,它只好自己挑一種算法。要改的是工具要模型填什麼:退款收品項,金額由後端照同一份規則算,回傳把怎麼算的講出來。
第二題,只看這張單可以不改。我還是會改:規則已經寫成後台畫面的程式,prompt 再寫一份,改規則時兩份都要動,運費、第二次退貨這些規則也得一條條抄進去;模型算的數字送出去之前,也沒有東西驗得了。改前那 4 次停在已取消、沒退款,也是因為取消和退款是兩支工具,要模型自己湊成一套。
模型只看得到工具定義和回傳。API 原樣轉成工具,後台畫面替人做的事就落到模型身上;工具照 Agent 的任務設計,模型判斷退哪幾件,金額由系統算,做了什麼、為什麼被拒寫進回傳,模型才不用猜。
今天只看一支工具。接下來還有另一個問題:Agent 接上 CRM、郵件、監控這些系統之後,手上有兩百多支工具,光定義就占掉一大塊 Context,好幾支又都能「通知客戶」,模型當下該看到哪些?
tools/list 拿工具清單、tools/call 呼叫工具,server 宣告 listChanged、client 也訂閱了清單變動的話,清單變了會發通知;工具定義的欄位(name、title、description、inputSchema、outputSchema、annotations),和協定錯誤、工具執行錯誤的分法。input_examples 的用法和限制。strict: true 保證工具參數符合 input_schema,保證範圍只到 schema。ANTHROPIC_BASE_URL 指到非官方的位址時改成關閉,用 ENABLE_TOOL_SEARCH 明確設定可以蓋過。createSdkMcpServer/create_sdk_mcp_server 包成在同一個 process 裡跑的 MCP server,工具名稱是 mcp__{server_name}__{tool_name};tool search 預設開著,這些工具也會延後載入。tool_name 和 tool_input,可以回 allow、deny 或 ask;MCP 工具的名稱是 mcp__<server>__<tool>,Bash 的 tool_input 是整串 command;MCP 工具的輸入多一欄 mcp_server(v2.1.274 起),判斷信不信任要看裡面的 source,不要看名字前綴。claude mcp add 接到 Claude Code 試工具;原樣包 API 端點的常見錯誤;參數從 user 改名 user_id;錯誤回應要講具體的修正方式;原型、評測、和 Agent 一起改的流程,要收的指標,把紀錄貼進 Claude Code 改工具;Sonnet 3.5 在調整工具描述後拿到 SWE-bench Verified 當時最好的成績。order_id、item_ids、payment_method_id,沒有金額,描述要求 Agent 先說明、取得使用者明確同意再送出。old_string 做完全比對、要剛好出現一次;先讀再改只對 Opus 4.6、Haiku 4.5 和更舊的模型一定要求,v2.1.208 之前所有模型都要先讀。is_error: true 回傳工具錯誤,錯誤訊息要寫出哪裡不對和接下來該試什麼。gh,沒學過的 CLI 可以叫它先讀 --help。gh、aws 這類 CLI 不會替每支工具加一段清單,還是比 MCP server 省 Context。curl 連 GitHub 的規則,參數順序、https、轉址和變數都對不上;寫在 CLAUDE.md 的指引只影響 Claude 會怎麼做,擋不住。~/.cursor/mcp.json 和 SSH 私鑰、當成參數送出去的示範。