跳轉至

AI 工具呼叫是怎麼運作的

AI 回答的時候,如果遇到自己不確定的內部術語、文件或資料,可以「呼叫工具」——請後端幫忙查一份資料、把查到的內容讀進對話裡再繼續回答,而不是憑空捏造答案。這頁說明 afterthread 裡工具呼叫實際上是怎麼運作的:一次呼叫經過哪些步驟、有哪些安全與資源邊界、工具是怎麼被 AI 自己「安裝」出來的,以及除錯時能看到什麼紀錄。

先說在前面:這整套機制是手工寫的一個迴圈,沒有用 LangChain 之類的 agent 框架。這不是「還沒空導入框架」,而是評估過後的決定——原因見本頁最後一節為什麼不用 LangChain

一次工具呼叫的生命週期

這個 app 沒有聊天視窗。已安裝的工具只會在你做「快速捕捉」「AI 補齊」「AI 進度更新」三個 AI 動作之一時被呼叫(KB 網頁安裝器內部也跑同一套工具迴圈,但用的是另一組「元工具」,見下方「工具是怎麼安裝出來的」)。

  1. 送出已啟用工具的規格:呼叫 AI 之前,後端先問「現在有哪些工具是已安裝且已啟用的」,把每個工具的名稱、說明、參數格式組成 AI 看得懂的規格,跟系統提示、使用者內容一起送出去。如果沒有安裝任何工具(或全部停用),就完全不帶這個規格——提示內容跟「這個 app 從來沒有工具功能」時一模一樣,不會因為工具子系統存在就多花一分 token。
  2. 模型回覆 tool_calls:模型看到工具規格後,如果判斷需要查資料,會回一份「我要呼叫哪個工具、參數是什麼」的清單,而不是直接回答。這是 OpenAI API 的標準協定,不是這個 app 自創的格式。
  3. 後端執行每個工具:對每一個呼叫,後端把參數(一段 JSON 文字)解析成物件——解析失敗就直接回一個錯誤字串當結果,不會讓格式錯誤的參數傳進工具本身;接著啟動一個全新的子行程執行這個工具,參數從子行程的標準輸入餵進去,標準輸出的內容就是結果。有逾時與輸出長度上限(見下一節),不管工具本身是成功、失敗還是逾時,後端一律把結果包成一段文字回饋——工具的例外絕不會直接炸到使用者面前。
  4. 結果附回對話,再問模型:每個工具呼叫的結果接在模型剛剛那則訊息後面,一起送回去再問模型一次。模型這時看得到查詢結果,可以決定「資料夠了,直接回答」或「還要再查別的」。
  5. 重複,直到給出最終答案:這個循環會一直進行,直到模型不再要求呼叫工具、或輪數用完、或對話大小超過預算、或時間快用完。這時後端會停止再帶工具規格,並多送一則提醒訊息逼模型直接給出最終答案,而不是無限迴圈下去。最終答案必須是嚴格符合預先講好格式的一個 JSON 物件。

下面是這個生命週期的示意圖(以查一個叫 search_kb 的工具為例):

sequenceDiagram
    participant U as 使用者(前端)
    participant B as 後端
    participant L as LLM
    participant T as 工具子行程(search_kb)

    U->>B: 觸發 AI 動作(例如快速捕捉)
    B->>L: 系統提示 + 已啟用工具規格 + 使用者內容
    L-->>B: tool_calls:呼叫 search_kb({"query": "..."})
    B->>T: 啟動子行程,參數經 stdin 傳入
    T-->>B: stdout 結果(可能被逾時/輸出上限截斷、已遮蔽已知秘密)
    B->>L: 附上工具結果,再次詢問
    L-->>B: 最終答案(嚴格 JSON)或再要求呼叫工具(回到上一步)
    B-->>U: 顯示結果

安全與資源邊界

每一條都先講「防什麼」,再講「怎麼做」。

  • 單回覆工具數上限(16 / 64):一則模型回覆理論上可以同時要求呼叫好幾個工具,但一個故障或被誤導的模型也可能一次要求幾千個——這防的是「上游洪水」。真的會執行的只有前 16 個,超過的每一個都會收到「too many tool calls」的拒絕訊息(仍然回一個結果,因為 API 規定每個 tool_calls 都要有對應回覆,否則下一輪的請求會被拒絕)。如果整則回覆的 tool_calls 數量超過 64 個,代表這則回覆本身已經不正常,後端連處理都不處理,直接判定為上游錯誤。
  • tool_calls 大小上限(約 256 KiB):光數量夠少還不保證安全——64 個呼叫還是可能夾帶巨大的參數內容。後端會把一則回覆裡所有 tool_calls 的內容加總字元數(不是精確的 UTF-8 位元組數,所以全中文之類的非 ASCII 內容實際位元組可能略高於此),超過 256 KiB 就跟數量超標一樣判定為上游錯誤。
  • 對話 token 預算:每一輪最多可能疊加 16 個工具結果,跑滿輪數的話,實際送給模型的對話會越滾越大。這個預算是「即時送出」這條路徑的總量煞車——量到的是 token,但程式碼裡實際比較的是字元數,兩者之間用一個動態學到的「字元↔token 比值」換算:每次模型回覆帶回它自己數的 token 數,後端就拿「這次送出多少字元」配「模型說這是多少 token」湊成一組觀察值,滾動平均出目前的比值(這套機制與安裝與啟動提到的 prompt 預算共用)。對話換算後一旦超過預算,後端就不再帶工具、直接收斂成最後一次回答。
  • 整體 deadline,以及「時間不夠就不啟動新工具」:整個互動共用同一個時間預算。這裡有一個誠實的技術限制:工具子行程是丟到一個背景執行緒池裡跑的,這個執行緒不會在時間到的時候被強制打斷(Python 的非同步逾時機制只能取消「正在等待」的程式碼,沒辦法打斷一個已經在執行中、佔用執行緒的子行程)。因此後端的作法是:一旦剩餘時間不超過 1 秒,就不再啟動任何新工具呼叫,直接回一個「deadline reached」的結果——最壞情況只會是「deadline 加上一個已經在跑的工具的逾時」,而不是加上一整輪工具的逾時。
  • 子行程從零建構環境:工具子行程的環境變數是從一份很短的允許清單(PATH/HOME/LANG/LC_ALL/TMPDIR)重新組出來的,從來不是把後端行程的整個環境變數複製過去——後端自己的 API key 因此結構性地不存在於子行程裡。要誠實講清楚這個防線的邊界:這防的是「意外」外洩,不是「對抗式」隔離——同一作業系統使用者底下的子行程理論上還是能讀到父行程的環境變數(/proc/<pid>/environ)。真正的信任邊界是「只安裝你信任的 OpenAPI 文件跟指示」,環境隔離只是多一層防線。
  • 輸出遮蔽:工具的輸出、或安裝過程中的輸出,理論上可能不小心印出一個已知的機密值(後端自己的 API key、任何已安裝工具 .env 裡的值、或安裝表單秘密欄位正在使用的值)。在這段文字進入對話、被模型看到之前,後端會先把所有已知機密值換成固定的遮蔽標記(畫面上會看到類似「•••[秘密已遮蔽]•••」的樣子)。兩個界線要誠實講清楚:(1) 只遮長度 6 字元以上的值——太短的值不遮(會把一般文字也切碎),安裝表單的秘密欄位同樣要求至少 6 字元,所以極短的秘密目前無法被安全遮蔽、也不建議用;(2) 送進對話給模型看的那條路徑是 fail-closed(遮蔽若出錯就讓該步驟失敗,不會把原值放出去);但存進 AI 日誌的那條是刻意 fail-open(紀錄器是純觀測者,遮蔽萬一出錯時寧可存下未遮蔽的原文,也不讓「記錄」這件事反過來弄壞真正的 LLM 呼叫)——所以日誌的遮蔽是常態保證,不是絕對保證。
  • strict JSON + 一次修正重試:最終答案要求是「剛好一個 JSON 物件」,格式在系統提示裡講得很清楚。如果模型的回覆解析失敗、或不符合預先講好的格式,後端不會直接放棄,而是把模型自己剛剛的錯誤回覆、加上「哪裡錯了」的說明,重新問一次——但只有這一次修正的機會;第二次仍失敗就回一個固定、不含任何解析細節的錯誤,讓呼叫端知道這次 AI 動作失敗了。

工具是怎麼安裝出來的

「安裝新工具」做的事情類似 Claude Code 幫你建一個 skill:後端啟動另一個獨立的 AI 對話,帶著四個「元工具」(meta-tool,工具本身也是工具,但是給 AI 用來「建造」工具的):

  • write_file / read_file / list_dir:讀寫檔案,但路徑被強制限制在一個暫存工作目錄裡,不能碰到目錄外的任何東西。
  • run_shell:一個真正的 bash shell,以後端服務自己的權限執行,只是預設從暫存目錄開始(工作慣例,不是圍籬)。這是刻意的設計:AI 要能真的用 curl/python3 呼叫目標 API、跑測試、看錯誤、修正,才建得出一個真正能動的工具。

這個安裝 session 有比一般 AI 動作大得多的預算,因為「寫檔 → 用 run_shell 測試 → 看錯誤修正 → 再測試」這個循環要跑到能力所及的最好結果,通常得花上好幾輪、好幾分鐘。

AI 判定自己建好、測過、能動了以後,後端才會驗證這個暫存目錄:跑跟已安裝工具一模一樣的檢查(tool.json 格式、名稱規則、參數 schema 大小),再加上只有安裝時才做的更嚴格檢查,其中一條是「檔案不得內嵌秘密值」——如果 AI 不小心(或被誤導)把一個已知的機密值原封不動寫進某個檔案的前約 64 KiB裡,安裝會被直接拒絕,而不是遮蔽了事(因為這代表產出的工具本身結構有問題,遮蔽只會讓模型建出的 schema 悄悄跟原本不一樣)。每個檔案只掃前約 64 KiB 是務實的界線:最關鍵的 tool.json(會被列在工具清單裡、也會進未來每次的工具規格)本來就遠小於這個大小、完整涵蓋;埋在超大實作檔 64 KiB 之後的秘密則是已知的殘餘界線。驗證通過後,暫存目錄才會被搬進正式的工具目錄。

安裝表單秘密欄位的完整操作方式,見 KB 工具安裝指南;這裡要誠實補充一個邊界:秘密值確實存在於 run_shell 的環境變數裡,而 run_shell 是模型能下指令的地方——它若刻意把值分段、編碼後輸出再重組,遮蔽器(只比對原值與其尾端片段)未必攔得住。系統提示禁止這麼做,但那是行為指示,不是強制邊界。最終仍回到同一個信任模型:只安裝你信任的 OpenAPI 文件與指示。

AI 日誌怎麼配合除錯

「AI 日誌」頁記錄每一次 AI 互動——不管是快速捕捉、AI 補齊、AI 進度更新,還是工具安裝的建置過程——而且是記錄到每一個 attempt(每一輪工具呼叫、每一次修正重試都是獨立的一個 attempt),可以攤開來看每一輪實際送出的內容、模型的回覆、有沒有出錯。日誌只保留最近一定筆數的互動紀錄,存在記憶體裡的環狀緩衝區,後端程序重啟就會清空;只有另外設定了日誌檔案路徑,才會額外把完整紀錄落地成檔案。

幾個影響你看到什麼的規則:

  • 每一則存進紀錄的文字,都會先做已知機密遮蔽(跟上面工具輸出的遮蔽是同一套邏輯、同一個遮蔽標記,但此處是 fail-open——遮蔽萬一出錯時紀錄器寧可存原文也不弄壞 LLM 呼叫),再檢查過編碼安全性,最後按字元數上限截斷(預設每則 20 萬字元)——超過的部分會被標記截斷,不是無聲消失。
  • 工具呼叫的參數,在合成的「這輪呼叫了哪些工具」摘要裡只保留前約 200 個字元的預覽。要留意:日誌本身只存這段 200 字元預覽——紀錄器只保留每則訊息的角色與內容,不保存完整的 tool_calls 結構,所以日誌裡看不到完整參數(完整參數只存在於當下實際送給模型的對話裡,不在事後可翻閱的紀錄裡)。
  • 一次互動如果跑了很多輪、累積下來的內容超過同一個字元上限,比較舊的訊息會被摺疊成一則「較早 N 則訊息已省略以控制紀錄大小」的合成訊息,而不是讓單筆紀錄無限脹大。
  • 工具安裝的結果,只要 AI 建置階段真的開始跑了,就會附上一個直接跳到 AI 日誌並展開該筆紀錄的連結(deep link),失敗時這通常是最主要的除錯入口。

為什麼不用 LangChain

現在這個手寫迴圈裡的每一條護欄——上面「安全與資源邊界」列的那些——都是針對這個 app 的具體限制量身打造的:多大的輸出算洪水、多久算逾時、哪些值算機密、時間不夠時該怎麼收斂。這些規則框架不會天生就懂,換成框架之後,一樣得在框架自己的擴充點(例如 middleware 掛勾)重新刻一遍——程式碼行數不會變少,只是搬了個地方寫,還多了一層框架本身的抽象要學、要信任。

其中有一條規則甚至跟主流框架的預設行為直接衝突:「時間不夠就不啟動新工具」這件事,前提是同一輪裡的多個工具呼叫要依序一個一個啟動、每啟動一個都檢查一次剩餘時間。但主流 agent 框架對同一則回覆裡的多個工具呼叫,預設是並行派發出去的——要保留這條規則,就得換掉框架內建的執行路徑,自己重寫一個依序執行的節點,等於把現有邏輯原封不動塞進框架的殼子裡,並沒有真的變簡單。

框架真正擅長的地方——同時支援好幾家不同 AI 供應商、可以中途存檔恢復、人在迴圈中審核、逐步驟串流輸出——這個 app 一條都用不到:這裡永遠只對一個固定的 OpenAI-compatible 端點、一個固定模型講話,沒有多供應商熱插拔的需求,也沒有需要跨行程恢復的長任務。

結論是:把現成的、看得懂、改得動的安全邏輯換成框架的不透明封裝,並不會讓事情變得「更好更簡潔」。如果以後真的出現第二個協定不同的 AI 供應商,或者需要專門的可觀測性工具,這個結論值得重新評估,但目前不成立。