iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI Security

一個 Agent 我查不動,一個我改得動:內部威脅偵測的 30 天系列 第 13 篇

問一次 Jev 只要 0.25 秒,塞進 hook 卻要 1.7 秒:我最後把模型移出了熱路徑

  • 分享至 

  • xImage
  •  

問一次 Jev,同一條連線從第二次開始,中位數 248 毫秒。同一個問題放進 hook 裡問,要 1.7 秒。

差的那 1.4 秒不是模型,是「每次都從零開始」。

這三天量到三件事:Cursor 會等 hook 跑完(Day 10);現在的 hook 已經讓每一步多花 1 到 2 秒(Day 11);同時跑的 hook 會把紀錄蓋掉(Day 12)。今天回到 Day 9 預告的那個問題:模型到底該放在哪裡。


模型本身有多快

先量最好的情況:同一個 client,連續問 20 次,每次間隔 0.2 秒。

  • 模型:jev-1.13.0
  • 題目:Noul「這次改動有沒有改變執行行為?」
  • state:一段合成的 billing.py 前後版本,後一版拿掉了 amount <= 0 的檢查
第 1 次(含建立連線)   763 ms
第 2~20 次            p50 248 ms   p90 308 ms   min 236 ms   max 352 ms
回的機率               0.94 ~ 0.95

模型本身不慢。248 毫秒,比 Cursor 叫起一次 hook 的那一段(約 0.7 秒)還快。


放進 hook,每次都是第一次

但 hook 用不到「第 2 次」。Cursor 每觸發一次 hook,就開一個新的 python 行程,跑完就結束。所以放在 hook 裡的模型呼叫,每一次都得從頭來:載入 SDK、讀 key、建立連線、再問。

我照這個條件,開三個全新的行程各問一次:

第 1 次   整個行程 5,393 ms(import 4,057 ms,呼叫 1,104 ms)
第 2 次   整個行程 1,669 ms(import   649 ms,呼叫   887 ms)
第 3 次   整個行程 1,669 ms(import   611 ms,呼叫   902 ms)

拆開看,import 約 0.6 秒,第一次呼叫約 0.9 秒。第一個行程特別慢,是因為檔案還沒進快取。

現在的 audit_edit.py 單獨跑一次是 117 毫秒(Day 11 量的)。在 hook 裡直接問 Jev,會變成 1.7 秒,是原來的 14 倍。Day 10 又量到 Cursor 會等 hook 跑完,所以多出來的 1.6 秒,會原封不動加在每一次改檔上。


要問幾次:Day 9 的判斷式,套在真實紀錄上

Day 9 我寫了一個升級判斷式,原則是「不要每筆都問模型,只問確定性層判不出來的」。只有三種情況才升級:

  • AST 解析不動(unparsable)
  • regex 喊了,AST 卻說沒有實質改動(l1_only)
  • AST 說動到行為,regex 卻沒喊(l1_silent)

Day 9 的文章只寫了 QUIET_KINDS、LOUD_KINDS 這兩個名字,沒寫定義。今天照 audit_edit.py 的分類補上:QUIET 是「只動註解、無結構變化」;LOUD 是「動到執行邏輯、動到函式或類別、移除斷言、改了斷言的預期值」。

然後原封不動套在真實紀錄上:9/16 到 9/24,206 筆改檔。

全部 206 筆                              要升級 163 筆(79%)
  解析不動(unparsable)                    122
  regex 沒喊、AST 說有動(l1_silent)        41
  不用升級                                  43
只看 .py 的 84 筆                        要升級  41 筆(49%)

紙上很省的判斷式,放到真實紀錄上,每 5 筆有 4 筆要問模型。

拆開看,這個數字大半是我自己造成的。

第一個問題:把「不適用」當成「判不出來」。 122 筆「解析不動」全是非 Python 檔:.md 63 筆、.txt 20 筆、.json 12 筆,還有 .html、.png、.csv。AST 本來就只看 Python,判斷式卻把它們全部當成疑難雜症往上送。改一條「非 Python 檔只在 regex 喊了才升級」,比例降到 36%。

第二個問題:AST 從來沒看過舊版本。 84 筆 .py 改檔,在 hook 裡全部叫 Write,而且舊內容全是空的。AST 等於拿新檔去跟空檔比,所以 84 筆裡有 69 筆被判成「動到函式或類別」。

真正帶著新舊字串的,是另一個事件 afterFileEdit:

afterFileEdit 的欄位:conversation_id, generation_id, model, file_path,
edits[{old_string, new_string}], session_id, hook_event_name, ...

我的 audit_edit.py 卻因為這個事件沒有 tool_name,把它當成「不是改檔工具」跳過了。這就是 Day 4 寫過的「接錯欄位」:那時候發現了,到今天還沒修。

所以真正的升級比例,修好之前我給不出來。今天的 79% 只說明一件事:判斷式照原樣不能用。


三種模式,把帳算出來

三個數字都有了,Day 9 預告的三種模式可以直接算:

模式 做法 每次改檔多等多久 代價
inline hook 裡直接問 Jev 約 +1.6 秒,冷的時候 +5 秒以上 每一步都慢,Agent 一定等
sample 只有要升級的那幾筆在 hook 裡問 升級比例 × 1.6 秒;判斷式照原樣是 79%,平均 +1.2 秒 判斷式不修,省不到多少
deferred hook 照舊只寫紀錄,另一支常駐程式讀紀錄、慢慢問 跟現在一樣 答案晚幾秒到;常駐程式要自己顧

我選 deferred,理由有四個:

  • Agent 一定等 hook。 hook 裡的每一毫秒,都直接加在每一步上。
  • 貴的不是模型,是每次都從零開始。 常駐程式只開一次連線,之後每筆就是 0.25 秒,而且不在熱路徑上。
  • Day 8 說過,機率不進結論,只進待看清單。 答案晚幾秒到,待看清單不會因此變錯。
  • 模型的答案只由一支程式寫。 不會多出一群同時 append 的分身,Day 12 那種互相蓋掉的事,在這一段不會發生。

sample 還是有用,只是不該拿來省延遲。它留在常駐程式裡,負責省錢、省人看的量,前提是先把判斷式修好。

常駐程式要做的事,我先列出來,還沒寫:

  1. 讀 audit-findings.jsonl 新增的那幾行。
  2. 用修好的判斷式,挑出要升級的那幾筆。
  3. 用同一個 client 問 Jev,答案寫進自己的檔,附上模型版號。照 Day 8 的紀律,寫 jev-1.13.0,不寫 jev-latest。

今天的成果,你可以直接拿去用

一、量你的模型呼叫在 hook 裡的真實成本。 不要只量「同一個 client 連問 20 次」,那是常駐程式的數字。hook 付的是新行程的價錢,import 也算在帳上:

import os
import time

t0 = time.perf_counter()
from typesafe_sdk import Noul, TypeSafeClient       # import 也算在 hook 的帳上
t_import = time.perf_counter()
with TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], model="jev-1.13.0") as client:
    client.system_one(
        state={"version_before": "def f(x):\n    if x <= 0:\n        raise ValueError\n    return x\n",
               "version_after": "def f(x):\n    return x\n"},
        questions={"changes_logic": Noul(instructions="Does this change alter what the code does at runtime?")},
    )
t_call = time.perf_counter()
print(f"import {(t_import - t0) * 1000:.0f} ms,第一次呼叫 {(t_call - t_import) * 1000:.0f} ms")

每次都用一個新的 python 行程跑,跑三次,第一次通常最慢。

二、把判斷式套在真實紀錄上算一次。 紙上推演的升級比例不算數。這是改過一條之後的版本:

def escalate_fixed(rec):
    """非 Python 檔只在 regex 喊了的時候才升級,不再一律算解析失敗。"""
    if not str(rec["path"]).lower().endswith(".py"):
        return "non_py_l1" if sum((rec.get("l1_regex") or {}).values()) > 0 else None
    return escalate_reason(rec)          # Day 9 那一版

三、檢查你的 audit hook 有沒有讀 afterFileEdit。 新舊字串在那裡,不在 postToolUse 的 Write 裡。


誠實欄

  • Jev 只量了一種題目、一種大小的 state、一個時段、一條網路。 state 大很多的時候會不會變慢,今天沒量。整篇一共呼叫了 23 次。
  • 冷啟動只有三次。 第一次的 5 秒多,混了檔案還沒進快取的成本。
  • 升級比例用的 QUIET/LOUD 是我這篇才補上的定義。 206 筆改檔裡,有不少是我寫文章的草稿(.md 63 筆);加上 Write 沒有舊內容的 bug,79% 不是真實的升級比例。
  • deferred 的「跟現在一樣」是推算。 前提是 hook 維持現狀;常駐程式還沒寫,也還沒量。
  • 答案晚到,代價是它不能拿來擋任何事。 這是刻意的。照 Day 8 的原則,機率只進待看清單。

明天換一個問題。Agent 交件時說「做完了」,真的做完了嗎?我給它 5 條待辦,其中 2 條不可能做到,看它怎麼勾。


今天對應的威脅: T3(藉 Agent 之手規避控制)。偵測放錯位置,只有兩種下場:拖慢每一步,最後被關掉;或是判斷式亂升級,把人要看的量灌爆,重要的那幾筆被淹掉。兩種都會讓事後舉不出證。

實驗與程式: 2026-09-24 跑。day10-lab/jev_latency.py(同一個 client 連問 20 次)、jev_cold.py(新行程的完整成本)、escalation_ratio.py(升級比例,以及改一條之後的比例)。資料是這個專案的 audit-findings.jsonl(206 筆改檔)。模型 jev-1.13.0。本篇沒有引用外部來源。


上一篇
同時寫同一個紀錄檔,1,000 筆少了兩成:我的稽核 hook 會蓋掉自己的證據
下一篇
回頭看 Day1:同一支會當掉的 hook,omp 擋下、Cursor 放行——那 11 天,其實是一個預設值
系列文
一個 Agent 我查不動,一個我改得動:內部威脅偵測的 30 天 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言