CareerWise 代理活動饋送 — 修補 Tool Use 的 UX 空洞

上一篇關於 Tool Use 的文章中,CareerWise 把知識庫搜尋從強制注入改成了 LLM 自主決定。效果很好——LLM 知道什麼時候該查、什麼時候自己就能回答,不再浪費 token 和時間。但新問題馬上浮現:當 LLM 決定「我要查一下知識庫」的時候,使用者看到的畫面是完全空白的。

使用者看到了什麼

改造後的串流流程分成三個階段:

Phase 1 — LLM 決定要不要查
           → content: null(不是空字串,是 null)
           → finish_reason: "tool_calls"

Phase 2 — 後端執行 searchKnowledge(約 200ms,無串流)

Phase 3 — 第二次 LLM 串流,文字終於開始出現

從使用者按下送出到 Phase 3 的第一個字出現,中間的 0.5–1.5 秒畫面完全靜止。沒有 loading、沒有提示、沒有任何「系統還活著」的信號。

這跟改造前差很多。改造前所有 career intent 問題都會先跑知識庫搜尋再進 LLM,那段時間前端可以轉一個 spinner,使用者至少知道系統在動。改造後 Phase 1 的 contentnull——後端串流迴圈裡 if (delta?.content) 是 falsy 判斷,什麼都不會 yield 給前端。前端開好了 SSE (Server-Sent Events) 連線,但等不到任何資料。

使用者只看到自己剛送出的問題,然後畫面就凍住了。過了一兩秒,文字才突然開始逐字出現。那就加個 spinner 吧?

為什麼 spinner 不夠

一個 spinner 只能回答「系統有沒有在動」,沒辦法回答:

Spinner 解決了「以為當掉」的問題,但沒有解決「不知道在做什麼」的問題。

想像使用者問 Summer 一個問題,她說「我查一下資料」——使用者知道她在查、知道她查到了,然後等她整理完後回覆。這個過程的每個階段,使用者都是知情的。

對比一下三種做法:

單純 spinner:
  [送出] → spinner 轉圈 → 文字出現
            ↑ 知道在等,但在等什麼?

活動饋送:
  [送出] → 🔍 正在查詢知識庫… → 📄 找到相關資料 → ✍️ 整理回答… → 文字出現
            ↑ 知道在查           ↑ 知道查到了      ↑ 知道快好了

差別在於:spinner 只讓畫面動,活動饋送讓使用者知情。

三步驟剛剛好

決定要做活動饋送後,第一個問題是:要揭露多少細節?

我一開始想了五個選項,從一句話到十個步驟都有,最後收斂到三個步驟:

🔍 正在查詢知識庫…     → 「查詢中」,使用者知道系統正在查
📄 找到相關資料         → 「查到了」,使用者知道有結果
✍️ 正在整理回答…       → 「產出中」,使用者知道快顯示了

為什麼不是一句話?一句「正在查詢」太模糊。如果查詢花了一秒,使用者會想:「還在查?還是已經查完了在生成文字?」一句話無法區分「查詢中」和「整理中」。

為什麼不是更多步驟?知識庫查詢約 200ms,三步驟的訊息幾乎是同時出現的。使用者的實際感受是「看到三個狀態閃過 → 文字開始出現」,而不是「等了三秒看了三行訊息」。再多就真的囉嗦了。

改寫 SSE 協定

實作上需要改的是後端的串流格式。原本 getReplyStream 只 yield 純文字,現在改成 JSON 物件,用 type 欄位區分事件:

// 文字內容
{ type: "text", content: "根據知識庫..." }

// 狀態事件
{ type: "status", icon: "🔍", message: "正在查詢知識庫…", state: "in_progress" }
{ type: "status", icon: "📄", message: "找到相關資料", state: "done" }
{ type: "status", icon: "✍️", message: "正在整理回答…", state: "in_progress" }
{ type: "status", icon: "", message: "查詢知識庫失敗(API 逾時)", state: "failed" }
{ type: "status", icon: "🔄", message: "改用自身經驗回答", state: "in_progress" }

整個饋送時序長這樣:

使用者送出「怎麼談薪水?」
  │
  ▼
Phase 1 — LLM streaming(帶 tools 參數)
  │
  └─ LLM 決定呼叫工具
     yield → { type: "status", icon: "🔍", message: "正在查詢知識庫…" }
     finish_reason = "tool_calls"
  │
  ▼
Phase 2 — 執行 searchKnowledge
  │
  ├─ 成功:
  │   yield → { type: "status", icon: "📄", message: "找到相關資料" }
  │   yield → { type: "status", icon: "✍️", message: "正在整理回答…" }
  │
  └─ 失敗:
      yield → { type: "status", icon: "❌", message: "查詢知識庫失敗" }
      yield → { type: "status", icon: "🔄", message: "改用自身經驗回答" }
  │
  ▼
Phase 3 — 第二次 LLM streaming
  │
  ├─ 逐字串流:{ type: "text", content: "根" } { type: "text", content: "據" } ...
  │
  └─ 完成:{ type: "status", icon: "✅", message: "回答完成", state: "done" }

路徑 B(LLM 直接回答,不呼叫工具)不受影響——不走任何 status event,文字直接逐字出現,跟改造前一模一樣。

元件設計:出現在哪裡、完成後怎麼辦

活動饋送出現在使用者的問題與 Summer 的回答之間:

User: 怎麼談薪水?

  ┌─ 展開 ─────────────────────────┐
  │ 🔍 正在查詢知識庫…              │  ← activityFeed(進行中)
  │ 📄 找到相關資料                  │
  │ ✍️ 正在整理回答…                 │
  └─────────────────────────────────┘

Summer: 根據知識庫,談薪水有幾個關鍵技巧…

串流完成後,饋送會收合成一行:

  ┌─ 收合 ─────────────────────────┐
  │ 📄 Summer 已查詢知識庫  ▼       │  ← 可展開看細節
  └─────────────────────────────────┘

收合是折衷方案。全部保留的話對話會很囉嗦(每次回答前多三行系統訊息);完全消失就失去透明度價值了。收合後不佔空間,想查細節可以展開。

失敗的情況也一樣揭露:

  ┌─ 收合 ─────────────────────────┐
  │ ⚠️ 知識庫查詢異常    ▼          │
  └─────────────────────────────────┘

透明度核心是讓使用者知情。失敗時隱藏反而會讓使用者困惑「為什麼這次回答不一樣」,顯示失敗加上備援動作(改用自身經驗回答),使用者知道系統正在解決問題,信任感反而更高。

歷史留存:每一次工具呼叫都可回溯

饋送不只出現在當下,還會永久保留在對話歷史中。每次新的使用者訊息送出時,前一次的活動饋送會自動儲存到該則 assistant 回答的資料中。

User: 怎麼談薪水?

  ┌─ 收合 ─────────────────────────┐
  │ 📄 Summer 已查詢知識庫  ▼       │  ← 位於結果的上方
  └─────────────────────────────────┘

Summer: 根據知識庫,談薪水有幾個關鍵技巧…

User: 那前端轉職呢?

  ┌─ 收合 ─────────────────────────┐
  │ 📄 Summer 已查詢知識庫  ▼       │  ← 上一則的紀錄
  └─────────────────────────────────┘

Summer: 前端轉職可以從這幾個方向準備…

使用者可以隨時展開任一次對話的饋送,查看當時 Summer 做了哪些查詢。這也呼應了 Tool Use 文章裡提到的「稽核軌跡」——不只是讓這次對話更透明,也讓未來的使用者能夠回溯 AI 的決策鏈。

改了哪些檔案

檔案 改動
src/lib/chat.ts getReplyStream 改為 yield JSON event;在 Phase 1→2 之間插入 status events
src/app/api/chat/route.ts 不再 wrap { text },直接將 event JSON 寫入 SSE
src/components/AgentActivityFeed.tsx 新增:活動饋送摺疊元件
src/app/chat/page.tsx 解析 type 欄位,文字追加到訊息、狀態更新饋送

總結

代理活動饋送解決的問題很具體:當 LLM 在背後呼叫工具時,使用者不該看到空白。

核心思路是揭露資訊,不是加 spinner。使用者不需要知道 embedding 維度或 token 數量,但他們需要知道系統正在做什麼、做到哪一步了——使用者應該看得到系統在做什麼。

下一步是觀察活動饋送實際上線後的使用者行為:展開率多少?會不會有人覺得它囉嗦?這些數據會決定饋送的下一步演化方向。


CareerWise


Agentic Design Pattern Tool Use CareerWise UX Agentic AI LLM Architecture Streaming Server-Sent Events