AI 輔助開發

護欄掛鉤:在 AI 代理造成破壞前將其阻止

AI 代理程式會執行真實的工具,而且偶爾會執行錯誤的工具。以下說明如何在執行層(而非事後)攔截破壞性行為。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

AI 程式碼代理人不僅僅是建議文字。它會執行工具:執行 shell 指令、寫入檔案、提交 (commit),有時還會部署。大部分時候,它是稱職的。偶爾,它會嘗試一些你絕不會核准的事情,而當你讀到紀錄時,指令早已執行完畢。一個寫著「小心」的提示詞並無法阻止這種情況,因為模型可以忽略它。能阻止它的是一個掛鉤 (hook):一段位於代理人工具路徑上的程式碼,它會檢查每一次的呼叫,並能在執行前拒絕它。這篇文章是關於這些掛鉤如何運作、該把它們放在哪裡,以及何時單靠提示詞就足夠了。

掛鉤所針對的失敗情況

值得部署的代理人,就是那些你允許它們接觸真實系統的代理人。那也正是它們危險之處。給模型一個 shell,它偶爾會使用一個在抽象層面沒問題、但在特定情境下卻是災難性的指令:

  • 一個針對錯誤目標運行的破壞性指令。在一個後來發現是符號連結的路徑上執行 rm -rf,或是在一個並非用完即棄的資料庫上執行 DROP
  • 提交了一個密鑰。代理人會暫存所有東西,而所有東西中也包含了你原本只想保留在本地的 .env 檔案。
  • 一個本應等待的部署。對模型來說,變更看起來已經完成了,所以它直接將其推送到生產環境。
  • 一個過早的「完成」。代理人宣告任務完成,但此時測試仍是紅燈,或檢查清單還有一半未勾選。

這些情況都不是源於一個壞的模型。它們源於一個有能力的模型,在資訊不完整的情況下採取行動,其速度之快,讓人類沒有空間在決策與行動之間進行干預。事後審查已經太遲了。指令已經執行了。

為何提示詞並非控制項

最直接的修復方法是將規則寫入系統提示詞中。「絕不執行破壞性指令。絕不提交密鑰。絕不未經批准即部署。」這有幫助,而且你仍應這麼做,但它並非一個控制項。它是一個成功機率很高的建議。

提示詞塑造了模型下一步行動的機率分佈。大多數時候,此分佈會偏向安全的途徑。但它仍然是一個分佈,而在數千次的工具呼叫中,尾部事件終會發生。模型誤讀了上下文,三千個 token 前的指令失去了權重,不安全的指令終究還是會出現。你無法審核一個機率。你無法證明提示詞在下一次呼叫中會保持有效。

鉤子(hook)在性質上是不同的。它不是給模型的建議,而是位於模型決策與實際效果之間的程式碼。模型可以隨心所欲地想要執行該指令。如果鉤子回傳一個拒絕訊息,該指令就不會執行。提示詞是引導。鉤子是強制執行。兩者做的是不同的工作,而當失誤的代價很高時,後者是唯一你真正可以依賴的。

Hook 的所在之處

代理程式執行環境揭露了一小組點,您可以在這些點上將自己的程式碼附加到工具執行路徑中。這些點的名稱在不同框架中會有所不同,但其形式是一致的。其中有兩點最為重要。

第一點在工具執行前觸發。執行環境會將工具名稱及其引數交給您的 hook,而您的 hook 會回傳一個決定:允許、允許但帶有修改,或拒絕並附上理由。這裡的「拒絕」意味著工具永遠不會執行,而代理程式會收到您的理由作為回饋。

第二點在代理程式嘗試結束其回合時觸發。執行環境會詢問您的 hook 是否允許停止。如果工作實際上尚未完成,您的 hook 可以阻止停止動作,並回傳一個繼續進行的指令。

一個前置工具掛鉤大致如下:

def pre_tool_use(tool_name, tool_input):
    if tool_name == "bash":
        cmd = tool_input["command"]
        if is_destructive(cmd):
            return deny(
                reason="This command can delete data. Use the "
                       "approved migration script, which takes a backup first."
            )
        if is_direct_deploy(cmd):
            return deny(
                reason="Direct deploys are blocked. Run ./scripts/deploy.sh, "
                       "which enforces the review gate."
            )
    return allow()

重要的部分不是模式匹配,而是回傳值。Hook 不會只是發出警告就置身事外。當它拒絕時,執行環境會捨棄該工具呼叫。代理程式的下一個觀察結果是理由字串,而不是指令輸出,因為該指令從未執行過。

三個實至名歸的掛鉤

並非每條規則都值得設置一個掛鉤。這三個涵蓋了傷害最大且誤報最少的失敗情況。

針對破壞性與特權指令的工具前防護。 根據指令的形式進行匹配,而非其背後的意圖。遞迴強制刪除、資料庫的 drop 和 truncate、對受保護分支的強制推送、直接呼叫部署工具。拒絕只是工作的一半。你回傳的原因應指明安全的替代方案,這樣代理程式才有其他地方可去,而不是重試同樣的事情。

針對未完成工作的停止掛鉤。 在讓代理程式結束其回合之前,檢查任務的客觀狀態。檢查清單是否已完全勾選?測試套件在上次執行時是否通過?若否,則阻止其停止。

def on_stop(session):
    if session.tests_last_status == "fail":
        return block(reason="Tests are failing. Fix them before finishing.")
    if session.checklist_has_open_items():
        return block(reason="Checklist still has open items. Complete them.")
    return allow_stop()

這個掛鉤能阻止過早地宣告「完成」。當客觀信號顯示情況並非如此時,代理程式不能宣告勝利,因為宣告本身就是一個會被此掛鉤攔截的工具呼叫。

提交前的密鑰掃描。 攔截提交動作並檢查暫存區的內容。如果集合中包含 .env 檔、私鑰、credentials.json 檔,或符合已知金鑰模式的一行,則拒絕提交。

def pre_tool_use(tool_name, tool_input):
    if is_commit(tool_name, tool_input):
        for path in staged_files():
            if looks_like_secret(path) or contains_key_material(path):
                return deny(
                    reason=f"{path} looks like a secret. Unstage it and add "
                           f"it to .gitignore before committing."
                )
    return allow()

git 歷史紀錄中的密鑰移除成本高昂,且一旦推送就必須視為已外洩。這個掛鉤的成本低廉,但它所預防的失敗代價卻非如此。

讓掛鉤保持實用性的設計原則

一些規則能區分有幫助的護欄與變成噪音的護欄。

預設關閉 (Fail closed)。 當掛鉤無法判斷某個動作是否安全時,它應該阻擋而非允許。一個被阻擋的安全動作只會讓你多一次重試和得到更清晰的指示。一個被允許的不安全動作則會讓你付出一次事故的代價。這種不對稱性正是掛鉤存在的全部理由,所以讓模糊不清的情況倒向安全的一邊。

讓拒絕訊息具有可操作性。 一個只回傳「拒絕」而沒有其他資訊的掛鉤,會讓代理程式去猜測,而它通常會猜測一個與同樣錯誤指令極為相似的變體。一個說明原因並指出核准路徑的掛鉤,能將阻擋轉變為重新導向。代理程式讀取原因,採用被核准的路徑,任務便能繼續。原因字串不是日誌記錄,而是代理程式的下一個輸入。

專注於高成本的動作。 你新增的每條規則都是一條需要維護的規則,並且有可能阻擋合法的操作。將這份預算花在錯誤是不可逆或代價高昂的地方:生產環境資料、部署、憑證,任何你無法復原的事物。不要對普通的編輯和讀取設限。過度阻擋會訓練操作員不信任護欄,並扼殺代理程式的吞吐量,這就違背了執行代理程式的初衷。

根據形式進行匹配,並接受一些偽陽性。 你無法列舉出所有危險的指令。根據結構性信號進行匹配:遞迴強制旗標、drop 動詞、受保護的分支名稱、形似機密的檔案名稱。你偶爾會攔截到一些無害的東西。當另一種選擇是錯過真正的危險時,這是一個正確的權衡,只要拒絕訊息能解釋得足夠清楚以便快速釐清即可。

您所接受的權衡取捨

掛鉤並非免費,而假裝它們免費,最終只會導致一個無人信任的護欄。

它們是程式碼,所以需要維護。一個脆弱的正規表示式,每三次執行就阻擋一次合法指令,會被惱怒的操作員停用,而一個被停用的護欄什麼也保護不了。隨著專案的變更,規則會變得過時。每個掛鉤都是一個有其自身錯誤的小程式,而護欄中的錯誤比沒有護欄更糟,因為您曾指望著它。

誤報的實際成本不僅僅是重試。每一次錯誤的阻擋都會侵蝕對整個系統的信任。一旦超過某個門檻,人們就會開始繞過護欄,在掛鉤不會觸發的模式下執行代理程式,這會讓您回到您試圖擺脫的未受保護狀態。規則集也會產生複合效應:十個掛鉤相互作用,而一個掛鉤所允許的動作,可能會為另一個本應捕捉的問題埋下伏筆。

坦白地說,這是在成本與風險之間做出的權衡。成本是維護和偶爾的錯誤阻擋。風險是破壞性指令、洩漏的秘密、未經審查的部署。在低風險的沙箱中,風險很小,掛鉤就成了額外開銷。面對生產環境的資料、線上部署和真實的憑證時,風險佔了主導地位,而掛鉤在第一次觸發並攔截了本會導致事故的指令時,就已經值回票價了。

提示詞就已足夠的時機

並非每條規則都需要 hook,而將每條規則都視為 hook 本身就是一種失敗。使用 hook 的時機是當三件事同時成立時:該操作成本高昂或不可逆、不安全的情況可從工具呼叫 (tool call) 及其參數中識別出來,而且你需要規則每次都成立,而非只是通常成立。破壞性指令、秘密提交,以及未經審查的部署都符合這種情況。

當風險不高、當偶爾的失誤能在審查中以低成本捕獲,或者當規則是關於風格和判斷,而非程式碼中可以匹配的明確界線時,單純的提示詞指令就是正確的工具。「偏好小型提交」是個提示詞。「絕不對 main 分支進行 force-push」是個 hook。如果你無法將檢查寫成一個函式,該函式檢視工具呼叫 (tool call) 並回傳允許 (allow) 或拒絕 (deny),那麼它可能就屬於提示詞的範疇,因為一個必須猜測意圖的 hook 會因誤報(false-positive)而最終被關閉。

這區分並非提示詞與 hook 的對立,而是引導與保證的對比。使用提示詞來引導代理人 (agent) 在其日常處理的廣泛而模糊的任務空間中,採用良好的預設行為。使用 hooks 來劃定幾條絕不能跨越的硬性界線,並在行動實際發生的層級使其無法被跨越,而不僅僅是在模型決定嘗試的層級進行勸阻。