把 Agent 架構寫進前端
agent loop 的形狀、tool_use 與 tool_result 為什麼必須成對、權限詢問怎麼做成 UI,以及前端要維護哪些狀態。
1. Agent loop 的形狀
一個 agent 就是這個迴圈:
使用者訊息
→ 模型回應(可能含 tool_use)
→ 有 tool_use? → 執行工具 → 把 tool_result 加回對話 → 再問模型一次
→ 沒有? → 結束
在程式碼裡最自然的表達是 async generator:
async function* runAgent(messages: Message[]): AsyncGenerator<AgentEvent> {
while (true) {
const assistant = yield* streamAssistantTurn(messages);
messages.push(assistant);
const toolUses = assistant.content.filter((block) => block.type === 'tool_use');
if (toolUses.length === 0) return;
const results = [];
for (const use of toolUses) {
yield { type: 'tool_start', use };
const result = await executeTool(use);
yield { type: 'tool_end', use, result };
results.push(result);
}
messages.push({ role: 'user', content: results });
}
}
generator 的好處是呼叫端決定節奏:UI 可以逐事件更新、可以在中途 return() 取消,而迴圈本身不需要知道有 UI 存在。
2. 三個不變條件
這三件事違反了,下一次 API 呼叫就會直接被拒絕,或是行為變得無法解釋。
一、每一個 tool_use 都必須有對應的 tool_result
即使工具爆炸了、使用者拒絕了、程式當掉了,也必須補一個結果回去:
{
type: 'tool_result',
tool_use_id: use.id,
is_error: true,
content: '使用者拒絕了這個操作。'
}
少一個,對話就處於無效狀態。實務上一定要有一個「補齊漏掉的 tool_result」的收尾步驟 —— 真正的 agent harness 都有。
二、順序不能亂
tool_result 的順序要對得上 tool_use。並行執行工具沒問題,但收集結果時要按原順序放回去。
三、對話是 append-only
不要「編輯」歷史訊息。要修正就追加新訊息。就地修改會讓 prompt cache 全部失效,也會讓 bug 無法重現。
3. 前端要維護的狀態
type AgentState =
| { phase: 'idle' }
| { phase: 'thinking' } // 等第一個 token
| { phase: 'streaming'; partial: string } // 文字進來中
| { phase: 'tool_running'; tool: string; startedAt: number }
| { phase: 'awaiting_permission'; request: PermissionRequest }
| { phase: 'error'; message: string; traceId?: string };
awaiting_permission 是獨立的一個 phase,不是一個布林旗標。它會停住整個迴圈,所以它就是一種狀態。
4. 權限詢問是時間軸上的一步,不是彈窗
當 agent 要寫檔案、跑指令、花錢時,使用者要能同意。做成彈窗有兩個問題:遮住了下面的上下文,而且離開後就不知道剛才發生過什麼。
做成時間軸上的一個步驟,跟其他步驟並列:
✓ 規劃修改範圍 2.1s
✓ Grep "getUser\(" 340ms
✓ Read user.service.ts 120ms
⚠ 需要你的同意才能寫入檔案
Edit · src/services/user.service.ts
[允許一次] [這次執行都允許] [拒絕]
○ Bash npm test
三個選項是有意義的:
- 允許一次 — 最小權限
- 這次執行都允許 — 同一個工具在這次 run 不再問。沒有這個,30 個檔案的重構會問 30 次,使用者會開始無腦點同意
- 拒絕 — 而且拒絕必須回一個
tool_result(見第 2 節)
「永遠允許」要謹慎,而且必須有地方可以撤銷。
5. 工具呼叫怎麼顯示
原始 JSON 太醜,完全隱藏又太魔法。中間值:一行摘要,可以展開看細節。
⌘ Read src/services/user.service.ts ✓ 完成
- 工具名稱用固定寬度的位置,這樣一串工具呼叫可以對齊掃視
- 最重要的參數放摘要行(檔案路徑、指令、查詢字串)
- 展開後才看完整輸入與輸出
- 長輸出要截斷並標示「還有 N 行」
參數是串流過來的字串片段,要累積後才有意義。想在累積過程中就顯示,用 parsePartialJson()(見 llm-streaming)。
6. 中斷
使用者一定要能停。而「停」有兩層:
controller.abort(); // 1. 停掉 HTTP 串流
generator.return(); // 2. 停掉 agent 迴圈
只做第 1 個,迴圈會繼續跑下一輪。
停下來以後:保留已經產生的內容,把該則訊息標記為未完成,並且提供「繼續」。把使用者已經看到的字丟掉是最糟的處理。
7. 費用與用量
Token 是錢,所以 UI 要看得到:
- 目前這一輪用了多少 token
- 這次 session 累計
- 接近上限時警告
顯示位置放在不干擾閱讀的地方(頂欄的小字),但不要藏在設定裡。
8. 前端 vs 後端
| 放前端 | 放後端 |
|---|---|
| 逐字渲染、捲動、游標 | 呼叫模型(金鑰在這裡) |
| 權限 UI 與使用者選擇 | 執行工具(檔案、資料庫、指令) |
| 時間軸與展開收合 | 驗證權限、速率限制 |
| 中斷按鈕 | 記錄用量與稽核 |
工具絕對不能在瀏覽器裡執行。 一個 Bash 工具的實作如果在前端,等於把任意指令執行送給任何人。
9. 想更深入
kit/ref/claude-code-source-code 裡有 Claude Code v2.1.88 的反編譯原始碼,src/query.ts 就是這個迴圈的正式版本 —— 包含 microcompact 邊界、tool_result 補齊、串流工具執行器等等。用來理解真實的 harness 要處理多少邊界情況很有幫助。
參考模組:Agent 執行流程、串流對話。
10. 檢查清單
- 每個
tool_use都有tool_result,包含錯誤與被拒絕的情況 -
tool_result順序對得上 - 對話 append-only
- 權限詢問是時間軸上的一步,有三個選項
- 工具呼叫預設收合,可以展開
- 中斷同時停掉 HTTP 與迴圈,且保留已產生內容
- 工具在伺服器執行,不在瀏覽器
- token 用量看得到