CareerWise 代理活動饋送 — 修補 Tool Use 的 UX 空洞
18 Jul 2026在上一篇關於 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 的 content 是 null——後端串流迴圈裡 if (delta?.content) 是 falsy 判斷,什麼都不會 yield 給前端。前端開好了 SSE (Server-Sent Events) 連線,但等不到任何資料。
使用者只看到自己剛送出的問題,然後畫面就凍住了。過了一兩秒,文字才突然開始逐字出現。那就加個 spinner 吧?
為什麼 spinner 不夠
一個 spinner 只能回答「系統有沒有在動」,沒辦法回答:
- 現在是在查知識庫還是在生成文字?
- 等了 1 秒,正常還是不正常?
- 為什麼有時候馬上就回(路徑 B:直接回答),有時候要等(路徑 A:呼叫工具)?
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 數量,但他們需要知道系統正在做什麼、做到哪一步了——使用者應該看得到系統在做什麼。
下一步是觀察活動饋送實際上線後的使用者行為:展開率多少?會不會有人覺得它囉嗦?這些數據會決定饋送的下一步演化方向。