yii.
← Writing
Flutter 技術 · · 13 min read · Read in English

你的 widget tree 已經是一份選項清單:Jev 在 Flutter 專案裡的位置

一個只會挑選項、不會寫字的模型,能幫 Flutter 做什麼、什麼時候不該用

寫過 Flutter E2E 測試的朋友,應該都遇過這種情況:UI 只是改了一個 widget key、換了一個按鈕文案,測試就整排紅掉,接下來花時間修的往往是測試腳本,而不是 app 本身的問題。

九月中 TypeSafe AI 發布了 Jev,它是一個專門做決策的模型。使用方式跟一般的 LLM 很不一樣,我們要先把可能的答案列成選項,它會從裡面挑一個,並告訴我們它有多確定,但它不會生成任何文字。

看到這個設計的時候,我第一個想到的就是 Flutter。畫面上每一個能點擊、能輸入的元件,本來就是一份有限的清單,如果把這份清單交給 Jev,它應該可以幫我們決定 E2E 測試的下一步要做什麼,而且 UI 改了名字,它也能從當下的畫面讀到新的名字。所以這幾週我把它接進 Flutter 的 E2E 測試,實際跑了好幾輪,這篇文章想跟大家分享過程和結果。

先說結論。在 E2E 測試裡,讓 Jev 負責挑每一步、LLM 只負責寫字,遇到 Jev 拿不定主意時再請 LLM 當裁判,是我這次量到最划算的組合:

  • 準 → 18/18,跟讓 LLM 全包一樣
  • 穩 → UI 改名後 12/12
  • 快 → 比 LLM 全包快 1.7 倍
  • 省 → 比 LLM 全包便宜 3.5 倍,而且沒有任何一次謊報

接下來會介紹 Jev 能幫 Flutter 做到什麼、它跟一般 LLM 的差別(跟我原本想的不太一樣)、兩者要怎麼搭配,以及哪些情境實測下來並不適合。所有數字都是在我自己的電腦上量測的。

讓 Jev 挑,讓 LLM 寫,再加裁判:準 18/18;穩,UI 改名後 12/12,手寫腳本 0/12;快 1.7 倍;省 3.5 倍

文章裡會用到幾個名詞,先簡單說明:

  • marionette → LeanCode 做的開源工具,讓外部程式可以讀取、操作正在執行的 Flutter app
  • E2E 測試 → 讓程式自動操作 app,走一遍真實使用者會走的流程
  • agent → 讓模型自己看畫面、決定下一步,而不是照著腳本執行

widget tree 跟 Jev 怎麼接起來#

先看整體架構。app、runner 和判定都在本機執行,會離開電腦的只有兩種請求:問 Jev 下一步要做什麼,以及請 LLM 寫字或當裁判。

系統架構:Flutter app 透過 marionette 被 runner 讀取;runner 讀畫面、壓縮成選項、在決策迴圈裡問 Jev 下一步、執行動作,跑完讀 app 的 store 判定。離開電腦的只有兩種請求:Jev 的決策,和 LLM 的寫字、當裁判

marionette 可以從執行中的 app 讀出每個可互動元件的 type、key、可見文字、啟用狀態和座標。我把它整理成一份清單:

@e1 TextField key=email_field = (empty)
@e2 TextField key=password_field = (empty)
@e3 CheckboxListTile key=remember_me
@e4 FilledButton key=signin_button
@e5 TextButton key=forgot_password_button

這份清單幾乎原封不動地變成送給 Jev 的 request:

body = {
    "model": "jev-latest",
    "state": state,                      # 畫面 + 任務 + 已經做過的動作
    "questions": {
        "nextAction": {
            "type": "choice",
            "instructions": "挑一個能推進 task 的動作",
            "criteria": {a.id: a.description for a in actions},
        },
        "taskComplete": {"type": "noul",
                         "instructions": "光看畫面,task 做完了嗎"},
    },
}

左邊是登入畫面的五個元件,右邊是同一畫面轉成的六個候選動作;Jev 回傳 fill_email_field,信心 0.99

重點在 criteria 這一行,它的內容就是從畫面讀出來的動作清單,格式是 {動作 id: 這個動作在做什麼},模型的回答只能是其中一個 key。

另外我很喜歡 questions 可以同時放好幾個問題的設計。上面的 request 一次問了兩件事,「下一步要點哪裡」是 choice 類型,「任務是不是完成了」是 noul 類型,會回傳 0 到 1 的判斷,一次往返就拿到兩個答案。「任務完成了嗎」這一題在後面很重要,整支測試會不會謊報成功,就看它的判斷。

當然,中間還是需要一層自己寫的程式碼,而且不算短。marionette 預設會回傳每個 widget 的所有屬性,包含 controller、decoration、colorSpace 等等,一個只有九個元素的登入畫面就有 5,488 個字元,大約 1,370 個 token。整理成上面的格式後只剩 337 個字元、約 85 個 token,減少了 94%。每個動作的描述也是由程式產生的。

一開始我以為用 Jev 至少可以省掉防止幻覺的處理,因為 LLM 可能會編出一個不存在的按鈕,而 Jev 只能回答清單裡的 key。實測之後發現這個優勢沒有想像中大,LLM 只要使用 structured output,把回答限制在同一份清單的 key(Gemini 的 responseSchema 支援 enum),在 749 次決策中一次都沒有選到不存在的動作,重試次數也是 0。所以防幻覺這部分,兩邊都可以交給型別系統處理。

Jev 真正不一樣的地方,是它回傳的那個機率。後面有一個實驗專門在看這件事,也是這次讓我最意外的結果。

同樣的模式在其他地方也看得到。GenUI 決定下一個畫面要用哪個元件時,也是從元件目錄裡挑一個,下一段我模擬了這個步驟來量測,並沒有接真的 GenUI。其他像客服單分類、CI 的 PR 風險分級也很類似,不過這兩個我還沒有實際跑過。我自己的判斷方式是,如果答案的範圍可以被列舉出來,這種接法大概就適用。

決定和產出分開做#

讓 LLM 決定 vs 讓 Jev 決定:限制輸出格式後兩邊都不會挑到不存在的元件;LLM 的信心在輸入錯時照樣報 1.0,Jev 的機率會掉下來

這一段的起點是 Abdallah Shaban 在 X 上分享的一段 demo,一個 Flutter 的 GenUI 畫面,每個元件都由 Jev 從元件目錄裡挑選,他提到感覺就像全部在本機執行一樣快。貼文底下有人問:「那元件裡的文字是從哪裡來的?」這一段我先把「快」拆開來量,至於文字從哪裡來,後面「讓 Jev 挑,讓 LLM 寫」那一段就是我的答案。以下是量測的結果,元件目錄和資料是我另外準備的。

我沒有接 Flutter 的 genui 套件,只模擬它挑選元件的步驟:自己寫一份元件目錄,讓模型輸出 GenUI 使用的 A2UI 格式,量測它回傳的內容和花費的時間。使用同一份目錄、十二個真實的對話,每個跑三次,比較三種做法:

  • 讓 Gemini 寫出完整的 UI 描述 JSON
  • 讓 Gemini 只回傳一個元件名稱
  • 讓 Jev 從同一份目錄挑一個

模擬 GenUI 挑元件:Gemini 寫完整 JSON 27/36、1,369 ms;Gemini 只回名字 30/36、770 ms;Jev 挑名字 25/36、225 ms

「只回傳名稱」這一組是我特別加進來的。少了它,「生成 vs 挑選」和「LLM vs 不會寫字的模型」這兩個變因會混在一起,比較的結果就說明不了什麼。

結果可以分成兩部分來看。讓模型一邊決定、一邊產生 JSON,要多花 600 毫秒,在這次的樣本中一致程度也比較低,75% 對 83%,把決定和產出綁在一起,兩邊都沒有好處。另一部分是同樣只挑一個名稱,Jev 比小模型快了 3.4 倍。

不過我不會說 Jev 比較準,這一輪它反而少對了五題,25/36 對 30/36。答錯的集中在兩句話,「where is my refund」它選了訂單清單,「what’s in my cart」有兩次選了純文字回覆。十二句話各跑三次,實際上就是十二題,這個差距不足以判斷誰比較準,但也不能說兩者一樣準。可以確定的是速度,快了三倍多。

另外有一題三種做法都「答錯」,後來我覺得是我自己標錯了。使用者說「I got the wrong size」,三種做法都選了訂單清單,而不是退貨表單。仔細想想它們是對的,使用者沒有說是哪一張訂單,先給清單讓他選很合理。這類分歧我選擇列出來,而不是直接算成錯誤。

UI 改名之後,腳本和 agent 的差別#

先看一個最直接的例子。在 demo app 裡把 nav_orders 這個 key 改成 nav_orders_v14,手寫的測試腳本在第 4 步就找不到元件而失敗;同一條流程交給 agent 來跑,13 步全部走完。下面是它的 log:

step 4: Tap @e7, the NavigationDestination nav_orders_v14
step 7: Tap @e2, the FilledButton start_return_button_v14
step 8: Tap @e3, the FilledButton return_next_button_v14

沒有人告訴 agent 名字改了,它是從當下的畫面讀到新的 key,直接拿來使用。

左邊手寫腳本在第 4 步找不到 nav_orders 而失敗;右邊 Jev 讀到新名字 nav_orders_v14、start_return_button_v14,13 步走完

這些改動是我在 demo app 裡刻意加進去的。App 裡有一個開關,輸入一個數字(程式裡稱為 seed,同一個數字每次都會產生一模一樣的改動,讓腳本和 agent 面對同一組變化),它會改掉大約五分之二的 widget key,再換掉幾個按鈕文案、打亂列表順序,模擬團隊開發一週後 UI 常見的變動。

這個例子能證明的範圍需要說清楚。手寫測試把 UI 的實作細節寫死在程式裡,agent 則沒有,所以 UI 一改,腳本需要跟著修改,agent 不用。這點換成 LLM agent 一樣做得到,只是速度慢一點、成本高一點。我也實際量過:同樣四條流程、同一張預設值表,只把做決定的模型從 Jev 換成 gemini-3.1-flash-lite,兩邊都是 12/12,LLM 慢了 2.7 倍、貴了 4.7 倍。

把改動擴大到 4 種、每種跑 3 次,結果是這樣:

UI 改了之後:腳本 0/12,LLM 包辦 12/12,Jev 挑 LLM 寫 11/12,再加裁判 12/12;只有 Jev 6/12

腳本全部失敗在我的預期之內,比較意外的是「只有 Jev」只拿到 6/12。它在 seed 7 和 91 停了下來,它的選擇都是對的,停下來是因為這兩組剛好改到 email 欄位的名字,email_field 變成了 email_field_v17,但我手寫的預設值表還是用舊的 key 查值。查不到值,Jev 雖然點得到欄位卻沒辦法輸入,信心下降,最後停在門檻前。

也就是說,預設值表其實也是一種 selector,UI 改名時它一樣會失效,只是失效的方式比較安全。讓 LLM 看著當下畫面寫字的兩種做法都沒有這個問題,其中由 Jev 負責挑選的那一種快了 1.9 倍、便宜 4.3 倍。它少掉的那一次跟改名無關,是後面會提到的導航停下,加上裁判之後就補齊了(12/12)。

所以在這個例子裡,Jev 真正帶來的價值,是後面那張風險分級的門檻表,而不只是測試有通過。

還有一個原本想拿來當例子、後來我自己降級的情況。第一輪比較時,手寫的客服流程卡在 contact_us_button,這顆按鈕在 FAQ 清單的下方、超出畫面,所以點不到。agent 卻通過了,因為它在第五步於搜尋框輸入 refund,把清單篩短之後按鈕就出現了。這一步沒有寫在腳本裡,我自己也沒想到。不過 refund 這個字串來自我的預設值表,Jev 本身不會產生文字,它做的是「用搜尋框推進流程」這個決定,所以這比較算是 agent 的優點,並不是 Jev 特有的。

Boxtide 客服分頁截圖:FAQ 清單佔滿畫面,Contact us 按鈕在畫面外;agent 在搜尋框打 refund 讓清單變短,按鈕才出現

這張預設值表後來也成為純 Jev 做法的瓶頸,上面的 6/12 就是它造成的,後面會說明我怎麼處理。

為什麼決策交給 Jev:依風險分級的門檻#

這一段我一開始做錯了。原本設定了一個全域門檻,confidence 低於 0.55 就停下來,避免亂猜,結果模型明明選對了下一步,卻一直被擋下來。有一次它正確選擇「先輸入密碼再按登入」,confidence 是 0.45,就被擋掉了。

後來回去看官方文件,裡面寫得很清楚:

low confidence on a Choice often means none of the options are a clear winner over the others

Choice 的 confidence 代表的是機率分布有多平均,也就是選項之間競爭有多激烈,跟答案正不正確是兩回事。當兩個動作都能推進流程時,低 confidence 反而是誠實的回答,把它擋下來才是錯的。

改成依照動作的風險設定不同的門檻之後,問題就解決了:

CONFIDENCE_FLOOR = {
    "scroll": 0.20,   # 捲回去就好
    "fill":   0.30,   # 打字可以重打
    "tap":    0.35,   # 導航可以回上一頁
    "submit": 0.70,   # 會寫進去、使用者會看到
    "finish": 0.60,   # 判錯會讓整個測試在騙人
}

捲動錯了捲回來就好,但如果錯誤判斷「已經完成」,整支測試給出的結果就是錯的,這兩種情況不應該使用同一個門檻。

這張表要成立,前提是機率本身有意義,也就是 confidence 0.7 的決策出錯的比例,真的比 0.3 的低。TypeSafe 表示他們的機率是校準過的,但這點我沒有自己驗證,所以不把它當成結論。要驗證需要先有標準答案,而這個任務沒有。我能分享的是實際使用的結果,改成風險分級之後 24 次全部通過,而原本全域 0.55 的門檻會把正確的決策擋掉。這比不上一條校準曲線,但至少是我實際觀察到的。

同樣錯誤的資料,只有一邊會謊報#

後來我補做了一個實驗,想看看這個機率在出錯的時候到底有沒有幫助。

任務是「回報訂單 5198 到貨損壞」,我刻意不更新預設值表,表裡還是另一條流程的資料:訂單 5213、內容是「包裹四天沒動」。接著讓 Jev 和 gemini-3.1-flash-lite 各跑三次,兩邊拿到的畫面、候選動作和預設值表完全相同,只換做決定的模型。判定時也不看 agent 自己回報什麼,而是跑完之後直接讀取 app 的 store,確認客服單有沒有送出、內容是什麼。

同一張過期的資料:LLM 送出錯的客服單並回報做完了;Jev 信心掉到 0.30 停下,沒有送出任何東西

LLM 那邊三次都回報成功,但三次都是錯的,它送出了錯誤的客服單,還回報完成。Jev 那邊三次都沒有完成,但也沒有寫入任何錯誤的資料。另一個任務「新增一個標籤為 Home 的地址」也是一樣,預設值表裡寫的是 Office,LLM 照表存了一筆 Office 地址並回報成功,三次都是如此;Jev 照表填入 Office 之後,信心下降到 0.28 到 0.34,於是停了下來。

Jev 的機率反映出輸入的內容跟任務對不上,LLM 自己回報的信心則沒有。這是那張風險分級表第一次有實驗數據支持,而不只是「實際用起來都通過」。

這個實驗的範圍也要說明清楚。temperature 設為 0,三次重複不算三個獨立樣本,比較像是同一個情境看了三遍;而且只有兩個任務、一個 app,並不是完整的校準曲線。不過方向很明確,一個會謊報成功的測試比失敗的測試更危險,而這裡剛好就是兩者會不會謊報的差別。

如果要在正式專案使用這套方式,我會建議先花半天時間,把執行過的決策依 confidence 分組,人工標記對錯,看看曲線長什麼樣子。這也是我下一步要補的部分。

我過去開發工具的方式剛好相反,通常是讓模型自由決定,再在後面加上檢查來避免幻覺。把選項在程式碼裡定義好、模型只負責挑選、再依風險設定門檻,是另一種做法,而且門檻是一個可以調整的數字,不是一段 prompt。

這次換了新版首頁重新測試,剛好遇到一個實際的例子。新首頁有兩條同樣正確的路可以到訂單頁,底部的 Orders 分頁,以及「Your orders」捷徑。Jev 在這一步的信心落在 0.34 到 0.53 之間,機率分散在兩個都正確的選項上。退貨流程走到這一步的 27 次決策中,有 3 次剛好是 0.34,低於點擊的門檻 0.35,所以停了下來。它沒有選錯,也沒有謊報,只是如實反映兩條路都可以。這也是門檻要依風險分級的原因,導航可以回到上一頁,門檻可以設低一點,或者讓畫面只保留一條路。我沒有為了讓數字好看去調整門檻,所以沒有裁判的版本是 17/18 和 11/12,就是這樣來的,至於怎麼補上這兩次,會在「拿不定主意時,請 LLM 當裁判」說明。

Boxtide 首頁截圖,框出底部的 Orders 分頁和「Your orders」捷徑,兩條路都通到訂單頁,所以 Jev 在這一步的信心分散在兩者之間

順著這個方向,我還遇到一個更基本的問題。有一次看到 confidence 只有 0.40,我第一個反應是這個模型不夠強,查了之後才發現,首頁有八個沒有設定 key 的 InkWell 和 GestureDetector,選項清單裡出現了八個 tap_gesturedetector_x_x_x_x。選項本身不清楚,模型的信心自然也高不起來。

讓 Jev 挑,讓 LLM 寫#

前面也看到了純 Jev 做法的弱點,它不會寫字,預設值表沒有的內容它就輸入不了。兩個新任務它都是 0/6,雖然每一次都安全地停了下來。

所以我把兩件事拆開處理:每一步要選哪個動作交給 Jev,遇到新的輸入欄位時,才呼叫 gemini-3.1-flash-lite 寫一次文字。登入一次、表單一次,一次完整執行平均只呼叫 LLM 2.7 次,其餘的步驟都由 Jev 處理。

一步的流程如下,Jev 和 LLM 只在各自負責的地方出現:

agent 的一步:讀畫面、組選項,遇到新欄位才叫 LLM 寫字,Jev 從選項裡挑一個並附機率,程式按動作的風險檢查門檻,信心不夠時可回頭的步驟問 LLM 當裁判、送出和判定就停下,最後執行動作再回到第一步

搭配起來實際跑的樣子如下。任務只有一句話「登入後回報訂單 5198 到貨損壞」,黑底標籤是 Jev 挑的動作,框線標籤是 LLM 看著畫面寫進去的文字,最後一格是 app 裡實際收到的客服單:

一次實際的跑:Jev 挑了登入、客服、聯絡我們、送出等動作,LLM 寫了帳號、訂單號碼 5198 和問題描述,10 步、7.0 秒、$0.0008,app 收到訂單 5198 的客服單

同一個任務「回報訂單 5198 到貨損壞」,四種做法各跑一次:

同一個任務四種做法:LLM 包辦 14.3 秒、只有 Jev 4.8 秒停下、旗艦 LLM 49.0 秒、Jev 挑 LLM 寫 8.9 秒,三種送出正確的客服單

拿不定主意時,請 LLM 當裁判#

前面提到新首頁那一步,Jev 在兩條都正確的路之間拿不定主意,有 3 次停在門檻前。為了處理這個情況,我沒有調整門檻,而是另外加了第七種做法。當 Jev 在可以回頭的步驟(點擊、捲動、輸入)信心不足時,不直接停下來,而是請 flash-lite 從同一份選項中選一個;送出和判定完成這兩種情況則照樣停下,因為這時候信心低,通常代表輸入的資料本身有問題。這一批另外跑了六個任務加上四種 UI 改動,共 30 次,全部完成、沒有謊報,每次成本 $0.00098,跟沒有裁判的 $0.00094 幾乎相同。裁判只被呼叫了 1 次,就是改名後的首頁、信心 0.33 那一步,它選了 Orders 分頁,流程順利走完。30 次裡只遇到一次平手,所以 30/30 有一部分是運氣,但方向很清楚,遇到平手時多問一次,比直接停下來划算。

七種做法的比較#

六個任務(原本四條流程加上兩個新任務),每種做法各跑三次,一樣讀取 store 來判定:

七種做法的總表:Jev 挑 LLM 寫 17/18、8.6 秒,加上裁判 18/18;LLM 決定謊報 6 次

第六種做法,也就是沒有裁判的搭配版,是 17/18,純 LLM 18/18,少的那一次就是前面提到的導航停下,沒有寫入錯誤的資料;加上裁判的第七種做法補齊了,18/18。換來的是快 1.7 倍、便宜 3.6 倍。UI 改名之後,搭配版 11/12、純 LLM 12/12,搭配版快 1.9 倍、便宜 4.3 倍,手寫腳本則是 0/12。跟旗艦模型相比,搭配版快 5.8 倍、便宜 57 倍,而且更準確。Pro 是 14/18,客服流程三次都進到訂單 5213 的物流追蹤頁,沒有到客服分頁,最後自己回報無法完成。在這種一步一步往前推進的流程裡,推理開得越多只是越慢、越貴,準確率並沒有跟著提升。

搭配版的第一版漏了一次,是我自己的 prompt 寫錯了。「新增一個標籤為 Office 的地址」這個任務沒有提到街道,我跟寫字的模型說「任務不需要的欄位留空」,結果它把必填的地址欄位也留空了。Jev 在儲存時信心只有 0.29,停了下來,沒有寫入錯誤的資料。加上「必填但任務沒提到的欄位,請填一個合理的值」之後就沒有再發生,後面的數字都是修正後的版本。

手寫腳本依然最快,前提是它是正確的;新任務則完全沒有腳本,還是需要有人去寫。

這樣的分工跟前面 GenUI 那一段的結論一樣,寫字交給會寫字的模型,而且只在需要的時候呼叫;每一步挑哪個、任務是否完成,交給會如實告訴你它有多不確定的模型;當它拿不定主意,而且這一步還可以回頭時,再請會寫字的模型幫忙挑一次。

導入前提:Key 的命名品質#

這一段是 Flutter 特有的部分,也是我沒有預期到的。

agent 的動作描述是從 key 的名稱產生的。Key('start_return_button') 會變成「Tap the FilledButton start_return_button」,模型可以理解這是什麼。反過來說,如果 key 命名很隨意,或者根本沒有 key,agent 只能拿可見文字去比對,文案一改就會失敗,又變得跟手寫腳本一樣脆弱。所以 key 命名得好不好,會直接影響模型拿到的選項好不好理解。

另外提醒一個細節,避免大家跟我一樣浪費一輪測試。只修改 key、沒有修改 Semantics identifier,等於沒有改,工具會 fallback 到 identifier。我第一次跑改名實驗時完全沒有效果,找了很久才發現原因。

什麼時候值得用:先拆解總耗時#

判斷一個場景適不適合用 Jev,我的方式是先把總耗時拆開,看模型呼叫佔了多少比例。

以這個專案來說,結論對 Jev 不太有利。一次退貨流程需要 12.8 秒,拆開來看,Jev 做了 18 次決策,共 4.4 秒(35%);189 次傳輸,1.9 秒(15%);剩下約 6.5 秒(51%)都在等 app 本身的動畫與載入。

把模型決策的 4.4 秒拿掉,整支測試從 12.8 秒只降到 8.4 秒,快 1.53 倍

模型只佔三成多,代表就算 Jev 的速度快到零秒,整支測試也只會快一倍半左右,照上圖的拆解是 1.53 倍。其餘的時間都花在 Flutter 的頁面轉場、等待畫面穩定,以及步驟數量本身,跟模型的速度無關。

我一開始調了一整輪參數,倍數一直出不來,就是因為我優化的部分只佔三成。應該先拆解時間,再決定要調什麼,這點我是做完才想到的。

我自己的判斷方式大概是這樣:

  • 模型佔八成以上 → 倍數會直接反映在體感上,很值得換
  • 模型只佔三成 → 上限大約一倍半,換了也不太有感
  • 介於中間 → 自己算一下

比較適合的是一次呼叫就結束的判斷,例如分類、路由、評分、篩選,或是擔任其他模型的 verifier。模型只是迴圈中一小步的工作就不太適合,像是驅動 UI、爬網頁,或任何需要等待外部系統回應的事情。

單次呼叫的任務,倍數確實存在#

為了驗證這一點,我另外做了一個單次呼叫的實驗,用在每日技術日報的選題上。輸入一則新聞,回傳三個型別化的判斷:要不要收錄、屬於哪一類、技術深度多少。素材是 20 則真實的 Hacker News 項目,透過 API 取得,三種模型各跑 40 次。

選題判斷三層:Jev 277 ms、$0.000025;flash-lite 967 ms、$0.000110;Pro 5,390 ms、364 thinking tokens、$0.005552

跟會推理的旗艦模型相比,Jev 快了 19.5 倍、便宜 220 倍。差距主要來自那 364 個 thinking token,旗艦模型最後只輸出 26 個字的答案,但在這之前先寫了 364 個字的思考草稿,這些草稿使用者看不到,時間和費用卻都要付。

代價是一致率。同樣 20 則新聞,Jev 跟旗艦模型在「要不要收錄」上一致 14/20、「屬於哪一類」一致 13/20,兩題同時一致的只有 9/20。這兩邊的答案都不是標準答案,「資深工程師會不會想看這則新聞」本來就是主觀判斷,兩個人來判斷也不會完全相同,所以我只能談一致率,沒辦法談準確率。

比較完整的說法是,快 19.5 倍、便宜 220 倍,答案有三分之一會不同,至於哪一邊比較對,這個實驗沒辦法判斷。

在 E2E 測試裡得到了什麼#

驅動 UI 不是追求倍數的場景,那 Jev 放在 E2E 測試裡到底帶來了什麼?先看數字,四條流程各跑三次,24 次全部通過:

四條流程都是手寫腳本比較快:退貨 4.2 對 12.8 秒、加購物車 2.9 對 4.4 秒、開客服單 3.6 對 5.6 秒、新增地址 3.9 對 5.7 秒

四條流程都是腳本比較快。腳本已經知道每一步的答案,agent 每一步都需要詢問模型,只要腳本是正確的,它一定比較快,這點調整參數也改變不了。

「只要腳本是正確的」這個前提,我自己就遇過一次。我把商品頁的「加入購物車」和「看購物車」按鈕移到畫面底部的固定列之後,加入購物車的腳本每次都慢了 6 秒多。原因是腳本裡還留著兩個 scroll_to 步驟,按鈕已經在畫面上,捲動不會產生任何變化,runner 就一直等到逾時。腳本跟 UI 對不上的時候,結果不一定是失敗,有時候只是默默變慢,測試看起來還是綠燈。

所以在 E2E 裡,Jev 帶來的好處在速度以外的兩件事。第一是前面改名的例子,UI 改了腳本會壞,agent 不會,這在實際專案裡幾乎每週都會發生。第二是成本低到幾乎可以忽略,一次完整執行平均 $0.0007,大約一萬五千個 input token;每個 PR 跑四條流程約 $0.0026,一天二十個 PR 是五分錢,一個月大約一塊半美金。每次決策的延遲中位數在 221 到 236 毫秒之間,而且是從台灣連到美西。

以前 agent 測試很難放進 CI,主要是因為又慢又貴。現在慢的部分還在,大約慢一倍半到三倍,但成本已經不是問題了。

怎麼開始使用#

整理下來,在各種 agent 的做法裡,搭配版加上裁判是最划算的,準確度跟 LLM 全包一樣,速度和成本好很多,也沒有謊報。

Jev 現在已經不需要排隊了。我使用的是 TypeSafe 官方的 API key,到 console.typesafe.ai 用 Google 或 email 登入,在 Settings → Keys 建立一把就能使用。建議優先使用官方的 key,直連速度最快,文中的延遲數字都是從台灣直連美西量到的。如果沒辦法使用官方的 key,也可以透過 OpenRouter 呼叫 Jev,不過多經過一層轉發會慢很多,我實測透過 OpenRouter 每次呼叫需要 1.3 到 2.2 秒,直連 TypeSafe 只要 0.36 秒左右。

如果是我,會這樣開始:

  • 先用在 UI 常改的流程、手寫腳本常壞的地方,以及還沒有人寫腳本的新流程。這些是 agent 比腳本好用的地方,UI 改名之後,腳本 0/12,搭配版加裁判 12/12。穩定的關鍵流程可以先不動,只要腳本正確,它依然最快(3.8 秒)而且不用費用,兩者並存就好。
  • 每一步的選擇和「是否完成」交給 Jev,門檻依動作的風險設定,送出 0.70、判定完成 0.60。
  • 遇到新的輸入欄位才呼叫便宜的 LLM 寫字,並提醒它「必填但任務沒提到的欄位,請填一個合理的值」;點擊、捲動這類可以回頭的步驟,如果 Jev 信心不足,再讓同一個 LLM 當裁判選一次。
  • 判定時直接讀取 app 的狀態確認結果,不要只相信 agent 回報的 pass,前面那三次錯誤的成功回報就是這樣抓到的。

成本大約是一次完整執行 $0.001,一天二十個 PR、每個跑四條流程,約 8 美分。

另外也有幾個需要注意的地方。過程中我遇到十三個問題,有一半跟工具本身有關。hot restart 會替換 isolate,常駐連線會抓著一棵已經失效的樹;畫面外的元素不會出現在清單裡,而不是被標記為不可見;SnackBar 會讓下方的按鈕直接從 widget tree 消失,直到它自己關閉。還有,在 macOS 上 app 視窗被其他視窗完全蓋住時會停止繪製,agent 會讀到空白的畫面,每一種做法都會在同一個地方失敗,很容易誤以為是模型的問題。

樣本的範圍也要說明,一個 app、每種情況跑三次、temperature 0。如果要拿這些結果說服團隊,建議先在自己的 app 上跑一輪。

總結#

這次實驗讓我比較確定的是,Flutter 的 widget tree 跟 Jev 這種只做選擇的模型很適合放在一起,再搭配 LLM 寫字、在拿不定主意時當裁判,可以同時顧到準確、穩定、速度和成本。它沒有讓 E2E 測試比腳本更快,但讓測試更能承受 UI 的改動,也不會謊報成功。

程式碼已經開源,包含十三個畫面的 demo app、常駐 websocket 的 runner,以及產生文章中這些圖表的比較腳本(主要的實驗在 runner/arms.py,每次執行完的 store 快照也都在裡面):https://github.com/chyiiiiiiiiiiii/flutter-jev

我下一步會先把 Jev 接進日報選題,因為它剛好是一次呼叫就結束的類型,輸入一則新聞、回傳三個判斷,沒有迴圈,也不需要等待其他系統。照上面的數字,這部分的成本可以降低兩個數量級,有結果再跟大家分享。

參考與相關資源#