跳到主要內容

把 Agent 架構寫進前端

agent loop 的形狀、tool_use 與 tool_result 為什麼必須成對、權限詢問怎麼做成 UI,以及前端要維護哪些狀態。

約 6 分鐘 · claude-agent-frontend.md

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 用量看得到

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言