•
4 min read

設計一條企業內的 Agentic AI 流程

Table of Contents

Dashboard 現在不香了嗎?為什麼是 agent ?

公司內部有一批很純的機上盒散在幾個實驗室,平常拿來驗 build、跑相容性測試。如果有問題,以前的流程很直接:工程師走過去,把機器接上,下 adb,翻 log,再回來講結論。另外根據 end users 最常回報給 platform team 的問題就是:「某個串流 app 在某台機器上打不開」。

這是我平常工作常常遇到的場景,我常常在想有沒有更好用的方式,可以快速定位到問題,我不用再去煩惱:到底是哪一台?它現在在不在線?上面跑的是哪個 build?我要看的資料到底在哪裡?或者煩惱如何聯絡 end user?如何翻譯復現步驟拿到我需要的 log 或者確認某些 critical path。

我們也做過 dashboard。但 dashboard 能回答的,基本上就是做 dashboard 的時候已經想到的問題:裝置在不在線、最近一次心跳是什麼時候、log 上傳到哪裡。這是根據預設的場景所定義的儀表板,是很有用,但針對剛剛上述的場景,沒辦法幫我快速定位 root cause。真的開始查問題之後,要問什麼通常事前列不完。你可能突然想知道「這個 app 到底註冊了哪些 scheme」,但 dashboard 上不會有這顆按鈕,因為當初設定這個 dashboard 根本不知道之後會需要這個。

所以我後來覺得 agent 在這些場景裡面真正有用的地方不是「會聊天」,而是會組合。

它可以先列裝置、挑一台在線的、撈那段時間的 log;發現 log 裡什麼都沒有之後,再換成呼叫端的 package 撈第二次。每一步其實都是我們經常會用到的查詢工具,但需要工程師把這些 tool 都串起來之後才有辦法找到解答。

正因為有這樣的需求,我決定開始打造讓我工作上可以更加方便的 Agent,讓它可以解放我的時間。

最開始的架構:三層 - 針對問題理解的過程

  使用者在群組裡提問
          │
          ▼
┌─────────────────────────────┐
   Chat surface                  LLM agent,同時是 MCP client
   (Slack bot)                   - tool 清單定期重新列舉
└─────────────┬───────────────┘
              │  帶著發問者的 identity
              ▼
┌─────────────────────────────┐
   Gateway                        認證 · caller attribution
                                  per-tool 時間預算
└─────────────┬───────────────┘
              │  換成 service identity
              ▼
┌─────────────────────────────┐
   Domain service                 tool 本體,全部 read-only (now)
└──────┬───────────────┬──────┘
       │               │
   排程快照         即時命令
       │               │
       ▼               ▼
  ┌─────────┐   ┌────────────┐
    物件儲存        Message
    log·截圖        broker
  └─────────┘   └──────┬─────┘
                       │
                 ┌─────┴─────┐
                 ▼           ▼
             常駐 agent   常駐 agent
                 │           │
               機上盒      機上盒

最開始我認為最方便的就是使用 Slack bot 當成聊天介面直接操作,背後連接到一個 LLM Agent,bot 手上會有一份 tool 清單,而且會定期重新列舉。

所以如果某天我們需要新增任意 tool,不需要頻繁的重啟 bot 就會自己出現。這件事看起來很小,但實際做下去我覺得很重要,因為後來確實為了達到某些需要的效果,我需要頻繁修改現有的 tool 或者新增不同的 tool。

中間那層是 gateway,最開始很容易就把它想成 proxy,但未來它有一個很重要的功用 - 進行 identity 交換的地方。目前必須老實說,我並沒有把這個 bot / gateway 做得太複雜,像是加入 token exchange / 加上 credential broker / 等等,目前的流程是:發問的人帶著自己的身分進來,gateway 驗完之後,往下游改用 service identity 呼叫。

在設計這個 Agent 的過程,我恰好閱讀了很多關於 Agent Identity 相關的資料,所以在中間特意把「這個 service account 現在是替誰做事」留下來作為以後 audit 之用。不然 audit log 事後翻起來,每一筆看起來都只是同一個機器人在動,無法回答它到底為誰做事情。

最下面是 domain service。它有兩條路拿到裝置資料:排程快照和即時命令通道 (via MQTT)。快照便宜、可回溯、但有延遲;即時通道精確,代價是需要裝置在線,而且要處理逾時。

我沒有讓 bot 直接打 domain service,主要也是因為 tool 一定會愈長愈多,而且最後不會只有裝置這一種。

gateway 這層一旦有了,之後接第二個 domain,不管是監控、CI 還是發版,本質上都只是再多掛一個 source。

定義 Tool description 介面

我一開始寫 tool 的 mental model 就是用寫 function 邏輯來進行:名字、參數、回傳值,最後 description 再補一句「這個 tool 是做什麼的」。

後來發現這樣不太夠,原因是因為真正讀 description 的是模型。它要決定的不只是「現在該不該 call 這個 tool」,還包括 拿到這個答案之後,它到底可以推論到什麼程度。

舉個例來說:有個 tool 可以回傳指定裝置上指定的 app log,它的 description 是這樣敘述:

當查詢一個 app 啟動失敗的錯誤,需要知道呼叫端的 process。所以拿目標 app 的 package name 來問無法得到正確的結果,這種情況要改用呼叫端的 process 的 package name 再問一次,不要認為「目標 app 底下沒有錯誤」就是「沒有啟動失敗」。

我自己後來是把這東西當成推論規則,不只是文件。

寫進去之後,模型在回答使用者的時候真的會把這個邊界帶出去,而後就會看到 bot 在 Slack 上直接跟 engineer 說:「空的 handler 清單不代表裝置回答了而且沒人接這條 URI」,然後自己解釋為什麼。

所以我現在寫 tool description 有一個很簡單的順序:

先寫這個答案不能證明什麼,再寫它能證明什麼。

前者反而比較重要,因為那才是模型最容易自己補完的地方。

「這個 tool 回傳心跳」幾乎沒什麼用;「心跳只代表機器有在回報,不代表 CPU 或記憶體正常,因為我們根本沒收集那些」才真的有用。

如何驗證模型的答案 ?

但把規則寫進 description 只是宣告而已。模型到底有沒有照著讀、照著推論,還是得靠回歸測試。

我替這個 agent 加上一套 eval,每個 case 是一個 JSON,可以用來測試紀錄 agent 的回答品質:

incident        哪天、問了什麼、bot 答錯了什麼
ground_truth    驗證日期 + 驗證對象(實際的 tool contract 字串)
prompt          原樣重現那次提問
mention[]       必須出現的 regex,附 why
forbid[]        不准出現的 regex,附 why
tools_require[] 必須呼叫到的 tool
judge[]         regex 判不動的,交給 LLM 判

我認為比較重要的是 why 欄位。它不只解釋這條規則在防什麼,還解釋這條規則的邊界在哪。有一條 forbid 的 why 大概會是這樣:

它刻意不比對單純的 “no errors”。「我們沒有存任何 error」是誠實的陳述,而無法分出它和「沒有找到 error」的差別,這個 judgement 交給下面的 judge 問題負責。

還有一條記錄了測試自己被改過:

放寬過兩次。tool contract 用的字是 “presence”,而一個幾乎逐字重述正確答案、只是換成「最近有回報」的回答被判成沒抓到證據。

這種做法我以前在其他測試框架裡比較少看到。

一般的測試會記「期望值是什麼」。這個 eval tool 可以記錄「這個期望值以前寫錯過、為什麼錯、後來怎麼改」都一起留下來。

我覺得這對 agent 特別重要,因為你測的不是一個 deterministic 的函式回傳值,而是一段自然語言到底有沒有講出該講的東西。有個稍微麻煩的點是,這個「什麼叫該講的東西」都有可能被我們自己寫歪掉。

讓 agent 碰硬體的 guardrail

一旦 tool 真的會碰到機器,guardrail 就不是要不要的問題了而是如何設計並套用的問題。

最後根據我最常遇到場景,我設計了以下的規則:

Read-only before agent identity is ready。 任何會改變狀態的動作都要求呼叫者是人,service 帳號目前不給過。授權因此是 per-action 而不是 per-endpoint。

不開 generic shell passthrough。 底層的命令通道本身可以跑任意字串,也包含重開機,危險的 write operation 包含刪除資料。在還沒完善我們的 authority policy 之前,只允許 read-only operation。所以每個 tool 自己組命令,呼叫者只能給識別碼和參數。

裝置端的 agent 大致會長成這樣:

const fullCommand = `${adbPath} ${deviceArg} shell ${command}`;
await execAsync(fullCommand);   // child_process.exec

我一開始看到這段其實沒多想,直到真的去追一條命令從我這邊送出去之後到底經過了什麼。

exec 會把整行字丟給 host 上的 /bin/sh -c。等 adb shell 收到剩下的參數,它會用空白把那些參數接回成一個字串,再交給裝置上的 shell 跑一次。

所以同一個字串其實被展開了兩次:

我組出來的命令字串
   │
   ▼
host 的 /bin/sh -c       # 第一次展開,吃掉一層引號
   │
   ▼ argv 丟給 adb
adb shell                # 把剩下的參數接回一個字串
   │
   ▼
裝置上的 shell            # 第二次展開

我原本把 URI 包在單引號裡,就覺得這件事處理完了。問題是那層單引號在 host 那邊就被吃掉,裝置真正收到的是沒有引號的版本。

後來我寫了一個假的 adb 腳本,把裝置端實際收到什麼直接印出來,差別就很清楚:

舊寫法 → DEVICE SHELL RECEIVES: ... -d myapp://a$(echo PWNED) ...
新寫法 → DEVICE SHELL RECEIVES: ... -d 'myapp://aPWNED' ...

第二行反而更值得看。整條命令多包一層雙引號之後,單引號確實活到裝置端了,但 $(...) 已經在 host 那層被展開掉,因為 $ 在雙引號裡照樣會代換。所以光是多包一層並不夠。

最後的做法是兩件事一起做。包裝的部分:整條命令用雙引號包給 host,內層單引號留給裝置。然後是拒絕,這五個字元直接不收:

字元問題原因
"host 的雙引號直接把外層引號關掉
\host 的雙引號跳脫出去
反引號host 的雙引號在雙引號裡仍然會被代換
$host 的雙引號同上,$(...) 和 $VAR 都會展開
'裝置的單引號把內層引號關掉

其他像 ; & | ( ) * ? # 和空白,在兩層引號裡面都是死的,所以照樣放行。真實的 deeplink 本來就會帶這些,全擋掉的話這個 tool 等於不能用。

而針對含 $(reboot) 的 URI 進去,的測試解果相當不錯:

Refused, nothing was sent to the device.
uri may not contain a quote, a backslash, a backtick, a dollar sign,
a newline or a control character.

還沒做完的

現在: 

  內容服務 ──  送出連結 ──▶ 裝置
      ▲                    │
      │                    │ tool 讀得到
   讀不到                   ▼
      └───────────────  「這個 app 接受什麼格式」


補上之後: 

  內容服務 ──┐
            ├──▶ 對帳(排程)──▶ 不一致就報 ──▶ Slack
  裝置快照 ──┘

內容端那半邊。 現在這些 tool 回答得了「這台裝置上的 app 接受什麼格式」,回答不了「我們到底送了什麼過去」。後者在裝置上結構性地拿不到,因為平台寫 log 的時候會把 URI 的路徑遮掉,只留 scheme 和 host。

對帳。 兩半湊齊之後,才有辦法在使用者回報之前主動發現漂移。現在仍然是被動的,等人回報了我們才去查。

即時探測 vs 排程快照。 目前碰裝置的 tool 全是即時的,需要裝置在線。另一條路是讓常駐 agent 定期上傳一份清單,tool 只讀快照。兩者互補,我傾向都要:快照回答日常問題,即時探測當 ground truth。

目前如果我要知道某台機器上的 agent 到底是哪個版本、支援哪些命令,還得去問人。這其實就已經代表系統少了一塊 observable state。

它應該自己回報,就像裝置會回報 build 一樣。

目前這套系統真的跑進 production 的時間還很短,實際的效果如何,能節省多少時間還有待觀察。目前可能還有一些 issue 我還沒發現,像是哪些 description 寫得還不夠、哪些 tool 模型老是選錯、哪些 tool 根本沒人用,現在的 usage 都還不夠多。

這些只能繼續等真的使用量進來再看。