「用 AI 提升業務效率」已經成為理所當然的時代,不過,你是否也有過下面這些經驗呢?
而且一旦開始在團隊裡使用,還會冒出其他不安。
筆者自己也多次感受到「AI 萬能論,其實也沒那麼萬能」的場景。一路追查原因後,常常會發現,比起模型本身的性能,更關鍵的是 「把什麼放進了上下文」以及「連到了哪裡」這件事是看不見的。
因此,我正在個人開發 moeca,目標是把從多代理的任務管理、執行、稽核到上下文最佳化,全部濃縮在一個桌面應用程式裡完成。本文將介紹它的整體樣貌,以及我特別用力投入的兩個設計重點(安全性與上下文最佳化)。
相關素材已公開在這裡。
本應用程式目前仍在個人開發中。主要功能已經可以完整運作,但細節仍持續調整。
moeca 是一個現在仍持續個人開發中的桌面應用程式,用來安全地「管理、執行、回顧」多個 AI 代理。
形態桌面應用程式(Tauri + React + TypeScript)後端Go sidecar(gateway / hostagent / sandbox / registry proxy / RAG indexer)代理執行環境Docker 沙箱(每次執行都丟棄)畫面Delivery / Daily / Terminal / Knowledge / Audit / Settings一句話概括這個概念,就是這樣:
不信任代理。相對地,由我們完全決定代理能在哪裡運作,以及它能看見哪些上下文。
先不多說,先放上 6 個畫面在 27 秒內跑完的畫面。

依序走的是 Delivery(審核)→ Daily(定期執行)→ Audit(記錄)→ Knowledge(知識圖譜)→ Settings。畫面中顯示的是實際資料:索引了 4 個虛構服務的 90 份資料,並讓代理執行了 1 次任務之後的狀態。
任務分成兩種。
Delivery — 產出程式碼成果物的任務。透過 inbox(起單) → working(等待判斷) → done(完成) 的看板,依照 repository 分別排列。

一張卡片代表一個任務。卡片上會顯示負責的代理、差異行數與 CI 狀態。

上方是 CI 閘門。在通過之前無法核准。最下方的 A2A 記錄,會同時顯示這個差異是從哪些代理之間的互動中產生的。
Daily — 定期執行型任務。透過日曆/圖庫,列出生成的成果物(文字、圖片、影片、音訊)。


成果物圖庫。文字、圖片、音訊、影片會依類型以不同顏色標示,並可當場播放或顯示。
在 Settings → Agents 裡建立 代理樣板。共有 3 種形式,而且都可以儲存後直接執行。

樣板執行方式Solo單體。選擇 provider + model,也可視需要啟用 HTTP 工具 / RAGStatic Multi-Agentsupervisor + graph。Graph 會被編譯成 Stage DAG,Supervisor 會依 plan → workers → integrate 的流程執行Dynamic Orchestration執行時路由到樣板Stage DAG 的各個 stage 都會在各自獨立的沙箱裡執行,透過共享 worktree 交接工作。從滿足 dependsOn 的 stage 開始,在 maxParallel 值範圍內並行啟動。

重點在於,stage 指定的不是映像檔參照,而是「政策名稱」(base / poly / media),真正的映像檔、網路配置與資源上限都由控制器提供。也就是說,硬化旗標在任何映像檔上都一致。
政策內容網路basedistroless,只有代理二進位檔(沒有 shell 也沒有工具鏈)egresspolyNode 22 / Python 3 / Go 1.25 + shell(用於建置、測試的命令 stage)egressmediaffmpeg / ImageMagick / libvips(Daily 的圖片、影片生成用)沒有故意不把 media 放進共用映像檔。媒體 parser 會吃進不可信的二進位輸入,所以不想擴大所有代理的攻擊面。改成獨立 stage 之後,甚至連網路都不需要,反而比一般沙箱更封閉。

樣板只決定「哪個代理、以什麼順序執行」。一個節點就對應一個 stage。
要交付什麼,則由那個節點內部的 Solo 代理編輯畫面決定。

RAG、Web 搜尋、圖片生成、音訊生成、影片生成都可以逐一由每個代理單獨允許。沒有把 3 種媒體生成合併成一個旗標,是因為成本差了 10 倍。並且未允許的種類不只是被停用,而是工具根本不存在——它不會出現在模型面前,連要求都做不到。
media 政策和「影片生成」不是同一件事。
前者是 在哪個箱子裡跑(裝有 ffmpeg 等工具的沙箱映像檔,在類型為「命令」的 stage 中選擇)。後者是 要交付哪些工具(透過 gateway 呼叫生成 API 的權限)。名字很像,但一個是負責編碼,另一個是負責生成。

API 金鑰採寫入專用方式進入 OS keychain,只有 gateway 會注入它。它不會出現在代理,也不會出現在 localStorage。
在 Audit 分頁中,可以沿著階層追蹤 A2A 格式的記錄。
Context
└ Task
├ Artifact
└ Message
└ Part
Extensions / Metadata
可以依照粒度、種類、時間過濾,令牌數與 session 數的指標也在同一個畫面裡。
重點在於,這些記錄是代理無法繞過的。代理的通訊在物理上只能經過 gateway(後面會說明),所以不存在「不留下記錄就出去」的路徑。此外,記錄會以 append-only 的 SQLite 搭配雜湊鏈(hash = SHA256(prevHash + record))保存,而 /_gateway/audit/verify 會回報第一筆損壞的列。這種以集中式安全邊界配置出來的設計,也同時扮演了可偵測竄改的稽核記錄角色。

左邊的樹狀結構是 Context → Task → Message → Part,右邊是本文。可以從 run 層級的列直接跳到下一章的追蹤。
這就是 moeca 的核心。從 Audit 的 run 透過 Trace 按鈕切到 Knowledge 分頁後,那次 run 實際到達了哪些知識會以圖譜方式疊加顯示出來。

從 Audit 的 run → Knowledge 的追蹤 → 當場修正提示詞,整條流程被串成一條導線。
實際跑一次的例子如下。對於「src/retry.ts 的重試在 4xx 也生效。先查證據再修正」這個任務,代理搜尋了 7 次,在 90 個節點中到達了 26 個。
到達(前幾名):
×3 docs/payments/adr-001.md 帳本只可追加
×3 docs/payments/incident-2026-05-20.md 重送集中的事故
×2 media/payments/walkthrough.ja.vtt ← 有命中影片字幕
…(共 26 個節點。剩下 64 個節點一次都沒有到達)
64 個節點一直沉在下面不亮,正是這個畫面的價值。沒有到達的 group,可以有證據地說它對這個任務不需要。

發亮的節點就是這次 run 的到達目標。沉下去的節點一次都沒有回傳。

看著追蹤畫面,就能在右側抽屜直接修改系統提示詞。儲存時顯示的「影響範圍」是下一段要說的重點。
這個畫面特別注意的是,UI 必須誠實。
不是「參照了」,而是只能知道「到達了」。
可以記錄的只有以搜尋結果形式傳給代理的內容。代理實際讀了其中哪些內容並據此做出判斷,無法得知。所以畫面永遠只是上限值,UI 文案也統一不用 參照,而改用 到達 回傳。由於記錄量上限可能會造成尾端缺漏,因此會明確標示「部分未記錄」。
目前如下:
領域狀態安全邊界(L3/L4/L7・金鑰注入・稽核記錄)已實作(已在沙箱→代理執行的 end-to-end 中驗證)Delivery(worktree / 實際差異 / CI 閘門 / merge)已實作Daily(排程・成果物・外部工單抓取)已實作代理樣板(Solo / Graph / Supervisor / Dynamic)已實作且可執行成果物的 4 種輸出(文字 / 圖片 / 音訊 / 影片)已實作Knowledge 分頁(圖譜 / 領域圖 / scope 分配)已實作**從記錄追蹤 → RAG 最佳化循環**已實作(已用實際任務的 run 驗證)除了「從任務管理到執行」之外,回顧並修正的循環也已經大致跑通。接下來就是一邊實際使用,一邊把細節磨細。
筆者認為要解決的課題,大致可分成以下兩項。
隨著開始像 MCP 一樣把各種服務串起來使用,可能發生資訊外洩的切面也變多了。因此,本應用程式的方針定為:
外部服務所需的秘密金鑰,只放在能以安全邊界一併保障的地方。絕不交給代理。
實作上,選擇的方式不是用防火牆規則去「拒絕」,而是乾脆讓它沒有到達的路徑。

3 個網路,以及唯一跨越其間的存在:gateway。以下是這張圖的說明。
網路分成 3 個。
網路內容性質host有不想暴露的環境變數與素材的本機電腦orchestra-egress給代理觸發與作業用的 Sandbox--internal(完全私有)**orchestra-upstream公開網路(透過 NAT 連到網際網路)公開重點在 Docker 的 --internal。--internal 網路不會對 host 或網際網路建立任何路由。連到那個網路的容器會變成一座「島」,除了同一座島上的夥伴之外,根本沒有任何可傳送封包的對象**。
代理的沙箱只會連到這個 egress 島。於是:
:8788 的管理程序)——因為沒有路由因為沒有防火牆,所以也不可能有防火牆設定錯誤。
唯一的例外是 gateway,只有它同時屬於兩個網路(也就是擁有兩張 NIC 的 dual-homed)。
gateway 的介面(RFC1918 由 Docker 自動分配)
eth0 172.20.0.2 <- orchestra-egress (沙箱透過這裡接觸 gateway)
eth1 172.21.0.2 <- orchestra-upstream (透過 NAT 連往網際網路)
「要在某個網路上說話,前提是你得在那個網路上有一雙腳(NIC)」——這就是隔離本身。沙箱只有 egress 的腳,所以可以到 gateway 的 egress 端 IP,但沒有到 upstream 端、更沒有到其後的網際網路的腳。這是經典的 multi-homing(路由器/跳板機在做的事)。
給實作者的陷阱:published port 只有在「以非 internal 網路作為 primary」時才會生效。若讓 gateway 用 internal 網路當 primary 啟動,-p 127.0.0.1:8787 雖然會被記錄,但實際上不會綁定(docker port 會是空的)。正確作法是 先用 upstream 作為 primary 啟動 → 用 -p 公開 → 再執行 docker network connect egress。
常見的反駁是這樣:
如果是 HTTP 上面的檢查(L7),是不是可以用下面的 TCP(L4)硬闖過去?
答案是否定的。
:8787 上等著的是 Go 的 HTTP server。如果把非 HTTP 的位元組送進去,只會得到 400 Bad Request 或立刻斷線。不提供直接穿透生 TCP 的 SOCKS / tunnel 模式更本質的是,L3 的 --internal 隔離與協定無關。不論 TCP、UDP 還是 ICMP,只要沒有通往目的子網的路由,就一個位元組也出不去。
沙箱若用生 TCP 嘗試目標結果gateway(同一個 egress 子網)TCP 連得上。但除了 HTTP 之外都會被擋住host / 網際網路沒有路由 → 根本不存在目的地upstream 的其他容器不擁有該子網的 NIC → 連指定都不能指定另外,host UI 讀取 gateway 的路徑只會透過 127.0.0.1:8787 的 loopback 綁定。沙箱內的 127.0.0.1 是「沙箱自己的 loopback」,不是 host 的。
L3 會把所有請求集中到 gateway,而 gateway 會在應用層決定目的地。跨網路不是透過透明的 IP 轉送,而是 L7 proxy hop。沙箱會對 gateway 送出明確的 HTTP 請求,接著 gateway 再從 upstream 端 NIC 重新發出新的請求。這種中介性讓檢查成為可能。
每一個請求會經過的關卡:
/anthropic/*, /github/*, /rag/* …)host.docker.internal 這種 docker host 別名,以及 loopback / RFC1918 / link-local 的 IP 字面值都會永遠封鎖(為了防 DNS rebinding,動態目標會先解析再檢查)Content-Type。x-api-key 或 bearer token 都是由 gateway 從自己的記憶體注入依賴下載(npm install / pip install / go mod download)也採同樣思路,另外架了 registry proxy。這個 proxy 只允許 GET/HEAD(代理可以抓取套件,但絕對不能 publish)、固定 upstream、而且沒有認證資訊。如此一來,即使沙箱沒有網際網路路徑,也能解決依賴。
這是我最想寫清楚的部分。常見的誤解是:
這兩個都不對。 如果把要保護的性質拆開看,這兩件事其實是互相獨立的。
要保護的性質負責方式一句話錯誤的目的地無法到達L3 + SSRF-deny「不要連到奇怪的地方」秘密本身不會從正規出口流出gateway 的金鑰注入「即使從正確出口也不會流出秘密」核心是這樣:L3 會阻止到達任意 host,但 gateway 仍然能讓它到達被允許的上游。 如果金鑰放在沙箱環境變數中,一旦代理被入侵(或被 prompt injection),它就可以透過這個被允許的切面把金鑰帶走。
api.anthropic.com 是允許的)也就是說,「把金鑰放進沙箱 + L3」並不等同於「一開始就不放金鑰」。正因為秘密的值從未進入信任邊界,才根本沒有東西可以被帶走。
而且「不給代理金鑰」只是必要條件,不是充分條件。即使代理沒有金鑰,它仍然可能透過 gateway 作為行使該金鑰能力(capability)的代理人。
實際可用權限 = min(注入金鑰本身的範圍, gateway 的授權政策)
因此設計原則是 least privilege + attribution + revocability。從來源端就最小化(細粒度 token、預算上限),並為每次執行發放短命 session,只解鎖這次 run 所需的上游。
也把邊界說清楚:gateway 注入能涵蓋的只有 「經過 gateway 的 HTTP 流量」。像 AWS_ACCESS_KEY_ID 這種會被 SDK 讀取並用來做 SigV4 簽章的情境,或 SSH 這類原生認證、非 HTTP 的情境,header 注入是碰不到的。這裡只能選擇:(a) 讓這類通訊也經過 gateway 作為簽章 proxy,或 (b) 如果非得放在 env,就必須以短命、最小權限為前提。
安全設計我另外整理成一篇文章了(把各層的依據與反駁回答得更詳細)。
層手段保證L3 網路--internal egress 島 + dual-homed gateway沙箱無法到達 host/網際網路/其他容器**L4 埠口gateway 只公開 127.0.0.1:8787 host↔gateway 只有一個 loopback 出入口L7 應用驗證 → allowlist → SSRF-deny → 寫入授權統治理目的地,並阻擋透過 gateway 繞到 host金鑰注入秘密只存在 gateway 記憶體中,且只在出口注入秘密的值不會進入邊界=即使經過正規出口也無法外洩稽核單一 chokepoint + 雜湊鏈無法繞過、可偵測竄改的全 I/O 記錄希望你記住的是這兩個彼此獨立的性質:
docs/ 等資料夾其中最容易出事的通常是第 3 個。若不知道「為什麼這些內容會被放進上下文」,就無從修正。
在設計時,我做了如下抽象化。
概念角色reference被參照的資料本身(local 檔案 / external 的 HTTPS 文件)。會被向量化的實體index對 reference 的找法。一個 reference 可以對應多個├ group使用者明確定義的「範圍」。也是 relation 的端點└ relation參照對象彼此的關係。由使用者明確畫出的線scope在 RAG 取得之前,先限制每個任務可取得的範圍。global / project / organization設計上的關鍵就在這裡。
即使建立了向量,最後真正被讀取的仍然是原始檔案本身。index 只是「到達它的手段」,不是資料本體。
另外,我把 group 當作領域,而不是標籤。它可以重疊、可以巢狀,也可以是沒有實體的純概念 group。因此在 UI 上我也決定把它畫成圓形(領域)。
階層是 organization ──1:N──▶ project ──1:N──▶ group。任務隸屬於 project,並持有一個可參照 group 標籤的陣列。
relation 不只是圖上畫一條線而已,它也用來在搜尋時把「彼此連著的範圍」一起拉進來。
1. 先照常做向量搜尋,取出前幾名
2. 收集命中的 chunk 所屬 group
3. 收集透過 relation 相連的 group
4. 只保留被允許的 group ← 這個順序絕對不能變
5. 用同一個 query 在篩完的範圍內重新搜尋,再補上前 m 筆
第 4 步的順序是設計上最重要的。 如果 relation 追出的結果可以返回未被允許的 group,就會變成只要畫一條線就能繞過權限。relation 是「應該把什麼取進來」的提示,不是「可以看什麼」。
第 5 步要重新搜尋,是因為如果只是把相連的 group 隨便塞進來,那只會變成雜訊。relation 只負責擴大候選範圍,排序仍然完全交給相似度。
實作上,gateway 會把綁定到 session 的 group 透過 header 宣告,而 ragindex 再依此過濾。代理無法偽造自己的權限(因為宣告的人不是代理,而是 gateway)。
另外,在 UI 上我也徹底強調了,「未指定」和「空」是完全相反的。
設定意義未指定沒有政策 = 可以參照全部 空**完全不能參照**如果把這兩者搞混,權限就會反轉,所以必須清楚區分。
設定放置處理由group 標籤(什麼可以看=權限)任務每個任務都可能不同擴大的量(怎麼取回來=策略)代理樣板「要不要以較廣視角推論」屬於工作方式的設定而且我把設定值設成不是「深度(幾 hop)」,而是透過 relation 額外加入的 chunk 上限數。因為深度太依賴圖的形狀,密集圖的一個 hop 可能比稀疏圖的 3 hop 還多。用上限數的話,即使圖形改變,行為仍然可預期。深度只保留作為防止暴走的上限。
這些是同一份資料的不同呈現,不是不同的資料。
模式顯示內容主要操作節點設定點 = reference,線 = 向量上的接近程度(機器計算出來的接近)探索・確認語意設定用圓畫出 group,節點在其中。relation 則以箭頭表示(人類宣告的意義)group / relation 的建立與編輯歸屬分配分配到 organization / project / group、scope 的操作權限分配節點設定 表示「機器計算出來的接近程度」,語意設定 表示「人類宣告的意義」。我希望這個對比能以視覺方式清楚傳達。

點是參照對象,線是向量上的接近程度。門檻與數量都可以用滑桿調整。

在同一批節點上,疊加了人類宣告的 group(領域)與 relation(箭頭)。橫跨服務的 group 之所以跨越不同服務群組,是我把它們設計成領域而不是標籤的原因。

在這裡選到的節點,會直接成為搜尋時的參照範圍。
也順便放上實務估算。
text-embedding-3-small = 1536 維,chunk 預設假設為 1200 rune)為了避免重新嵌入暴衝,key 這樣設計:
key = sha256(chunk 本文 + embedModel + maxChunkRune)
這樣一來,只有在嵌入模型或 chunk 大小變更時,才會重新執行嵌入。
最後真正想完成的是這件事。
Audit → Knowledge 進行追蹤,查看到達了哪些知識如果「看記錄」和「修正」是分開的畫面,通常會忘記剛剛看的東西。所以我特別堅持能在同一個畫面直接修正。
實作之後最強烈的體會是,AI 代理的品質問題,多數不是模型問題,而是“邊界設計”的問題。
兩者的根本都在於「看不見」,而且要讓它看得見,只能改變應用程式本身的結構。光靠調 prompt,是無法解決的。
技術上最有意思的是,安全、稽核、上下文最佳化最後都匯聚到同一個 chokepoint。從一開始就選擇讓所有進出都經過 gateway 之後:
全部都集中在同一處。為了安全而做出的結構,反而直接成了最佳化所需的資料來源。這是我在開始設計時沒預料到的發現,老實說非常開心。
另一方面,最難的其實是 UI 不能說謊。把想寫成「參照了」的地方改成「到達了」、把「未指定」和「空」清楚區分、儲存時顯示影響範圍——這些都很瑣碎,但一旦含糊,使用者就會把權限或影響範圍搞錯,所以我在這些地方花的時間,甚至比功能本身還多。
既然已經把「從記錄追蹤 → 最佳化」這條循環基本跑通,接下來就會一邊實際使用,一邊持續打磨細節。若有新的進展,我也會再寫下來。
這次介紹的 moeca 雖然是個人開發,但「打造能安心使用 AI 的機制」這個想法,也和我平常在 Sapeet 從事的開發工作相通。
Sapeet 將舉辦交流活動「Open Sapeet」,讓大家了解開發的實踐,以及工作的人和公司的氛圍。
當天會由 Sapeet 的工程師等員工,分享 AI、產品開發,以及未來工作型態等主題。現場也準備了輕食與飲料,讓大家可以輕鬆與員工交流。
如果你想再多聽一些技術相關內容,或想和 Sapeet 的成員聊聊,歡迎自由參加。
活動詳情與報名請見這裡
(本文的設計,出自個人開發中的安全多代理基礎設施 moeca。)