CareerWise 的記憶管理:讓 AI 記得你是誰

CareerWise 是一個職涯諮詢 AI 服務,使用者在上面詢問面試準備、履歷健檢、轉職規劃等問題。原本 CareerWise 的記憶機制很陽春:

根據這篇文章提到的三種擬人記憶——語意記憶(事實)、情景記憶(經驗)、程式記憶(規則)——這篇文章記錄怎麼幫 CareerWise 加上這套記憶系統。總共拆成四個部分:P1 語意記憶、P2 情景記憶、P3 記憶管理 UI、P4 Session State 持久化。

P1 — 語意記憶:使用者設定檔

問題career-chain.ts 的 step1 每次從對話歷史推斷使用者背景,新 session 從零開始。使用者隔天回來要重新自我介紹。

設計決策

1. 儲存時機

選項 內容 選擇
A) 只從 medium tier career-chain.ts 觸發時儲存  
B) 所有 career 路徑 simple + medium + planned + complex 都儲存
C) 只在使用者主動提供時 需額外意圖判斷,實作成本高  

選擇 B,因為 A 會讓 profile 永遠不完整(simple 路徑也有使用者背景)。C 實作成本高、邊際效益有限。B 統一在 chat.ts 的 career 路徑最後呼叫儲存邏輯,不論走哪個 tier。

2. 更新策略

選項 內容 選擇
A) 完全覆蓋 每次用最新資訊蓋掉舊的  
B) 版本追蹤 保留歷史版本可回溯  
C) 人工確認 偵測重大變更時詢問使用者是否更新

選擇 C,因為 A 可能讓 LLM 誤判的資訊直接蓋掉正確資料。B 實作成本高但邊際效益低。C 當使用者角色/年資/產業變更時,CareerWise 在回答結尾詢問「我注意到你的角色變了,要更新嗎?」,使用者可以確認或拒絕。

3. Profile 欄位

type UserProfile = {
  current_role: string | null   // 現職
  years: number | null          // 年資
  industry: string | null       // 產業
  skills: string[]              // 技能清單
  goals: string[]               // 職涯目標
  pain_points: string[]         // 痛點
  updatedAt: Timestamp
}

4. 詢問時機

選項 內容 選擇
A) 當下立即詢問 在該次回答結尾附上確認訊息
B) 下次對話開頭 延遲使用者的當前需求  
C) 背景更新被動告知 可能誤判,違反人工確認原則  

選擇 A,讓使用者在資訊還在熱記憶中立刻確認。B 會延遲使用者需求。C 如果 LLM 誤判(例如「想轉管理職」≠「已轉管理職」),會寫入錯誤資料。

Firestore 結構

users/{uid}
  └─ profile/
       ├─ current_role: "前端工程師"
       ├─ years: 3
       ├─ industry: "軟體業"
       ├─ skills: ["React", "TypeScript", "Node.js"]
       ├─ goals: ["轉管理職", "加強英文"]
       ├─ pain_points: ["團隊溝通", "技術深度"]
       └─ updatedAt: Timestamp

流程

runCareerChain(或其他 career 路徑):
  1. loadProfile() — 載入使用者設定檔
  2. step1 推斷背景(合併 profile + 對話歷史)
  3. 比對新舊值,偵測重大變更
  4. 若有重大變更 → 回答結尾 append 詢問
  5. saveProfile() — 非同步寫入 Firestore

P2 — 情景記憶:跨對話參考

問題:過去對話中的關鍵資訊(使用者偏好、重要決定、對 CareerWise 的回饋)完全遺失。使用者說過「我不喜歡空泛建議」,下次對話 CareerWise 還是給空泛建議。

設計決策

1. 儲存時機

選項 內容 選擇
A) 同步 回答完成後等記憶提取完才回應  
B) 非同步 回答送出後 background 執行,不影響回應時間
C) 批次 每天批次處理一次,時效性差  

選擇 B,因為 A 讓回應時間 +1-2s。C 時效性差,使用者今天說的話明天才記住。B 讓使用者完全無感,回答串流完成後在背景執行記憶提取 + Firestore 寫入。

2. 檢索時機

選項 內容 選擇
A) 只限 medium tier 僅 career chain 載入記憶  
B) 所有 career 路徑 simple + medium + planned + complex 都檢索
C) 只在使用者問「我上次說過」時 被動觸發,無法主動提供個人化  

選擇 B,讓所有 career 路徑都能利用過去記憶,個人化不因 tier 不同而有差異。

3. 儲存內容

選項 內容 選擇
A) 只存使用者發言摘要 使用者的關鍵陳述(偏好、決定)
B) 完整的來回對話 問題 + CareerWise 回答的摘要  
C) 兩者都存 資料量兩倍  

選擇 A,因為記憶的核心是記住使用者的事實與偏好,不是記住 CareerWise 說過什麼。B 儲存 CareerWise 的回答有「模型蒸餾」風險——回答會隨版本改善。C 資料量是 A 的兩倍,邊際效益低。

4. 保存期限

選項 內容 選擇
A) 永久保存,時間加權 檢索時依時間加權,越近期權重越高
B) 到期自動刪除 設定 90 天有效期限  
C) 數量上限 最多 N 筆,超過刪最舊的  

選擇 A,讓近期偏好優先,但舊資訊不刪除,萬一使用者想回顧還能查到。B 硬性刪除失去記憶連續性(91 天前使用者說「我不喝咖啡」,第 92 天 CareerWise 忘了)。C 使用者可能多次表達同一偏好,數量容易爆。

5. 提取格式

選項 內容 選擇
A) 自然語言段落 LLM 自行解析哪些是記憶  
B) 結構化列表 每條記憶獨立一行,附時間戳
C) 原始對話摘要 token 成本高  

選擇 B,因為 A 讓不同記憶混在一起,LLM 容易混淆。C 每條記憶 500+ tokens,成本高。B 每條只需約 50 tokens,且 LLM 可以逐條判斷相關性與時效性。

Firestore 結構

users/{uid}
  └─ memories/{memoryId}
       ├→ content: "使用者不喜歡空泛建議,喜歡具體案例"
       ├→ createdAt: Timestamp
       └→ embedding: number[] (optional,快取用)

流程

回答完成後(非同步):
  1. extractMemories() — LLM 從對話摘要提取關鍵記憶
  2. saveMemories() — 寫入 Firestore

新問題來臨時:
  1. searchMemories() — 語義搜尋相關記憶
  2. 時間加權排序
  3. 結構化列表注入 system prompt

  相關記憶:
  - [2026-07-15] 使用者表示想轉管理職
  - [2026-07-20] 使用者不喜歡空泛建議

P3 — 記憶管理 UI

問題:使用者不知道 CareerWise 記住了什麼,也無法修正錯誤的記憶。

設計決策

1. 頁面位置

選項 內容 選擇
A) 頂部導航選單 chat header 加入「記憶」按鈕
B) 設定頁面 藏在多層選單下  
C) 對話中管理 效率低,無法持續瀏覽  

選擇 A,讓使用者一鍵直達。B 記憶管理不是一次性設定,需要經常查看。C 對話顯示一次後就滑走了。

2. 頁面功能

選項 內容 選擇
A) 僅檢視 只能看不能改  
B) 檢視 + 編輯/刪除 可修改 profile、刪除記憶  
C) 檢視 + 編輯/刪除 + 新增 手動新增記憶

選擇 C,讓使用者可以主動告訴 CareerWise 該記住什麼,不只是被動修正。

3. 變更通知

選項 內容 選擇
A) 默默更新 + 回應確認 直接更新,回答結尾確認
B) 先問再更新 多餘的確認步驟  
C) 只記錄不更新 使用者覺得講了也沒用  

選擇 A,使用者說「不對,我沒說過想轉管理職」——已經明確表達修正意圖了。直接更新並回應「好的,已更新你的個人資料」,體驗流暢。B 再多問「你想刪除嗎?」會讓使用者覺得煩。

頁面設計

chat header: 新對話  |  📋 記憶  |  user@email  |  登出

/memories 頁面:

📋 CareerWise 對你的了解

┌─ 個人檔案 ──────────────────────┐
│ 💼 現職:前端工程師(3 年)  [編輯] │
│ 🎯 目標:成為資深工程師      [編輯] │
│ 📍 產業:軟體業             [編輯] │
│ 🛠 技能:React, TypeScript  [編輯] │
└─────────────────────────────────┘

┌─ 歷史記憶 ─────────────────────────┐
│ 📌 2026-07-20 不喜歡空泛建議    [✕] │
│ 📌 2026-07-15 想成為資深工程師  [✕] │
│                                    │
│ [+ 新增記憶]                       │
└────────────────────────────────────┘

P4 — Session State 模式

問題plan-review.ts 用 in-memory Map 儲存 plan state,server restart 後遺失。使用者正在審查計劃時 server 重啟 →「計劃已不存在」。

設計決策

選項 內容 選擇
A) Firestore users/{uid}/plans/{planId},持久化跨實例共享
B) Server session serverless 環境不跨請求共享  
C) In-memory + TTL restart 仍遺失  

選擇 A,解決 plan 在 server restart 後遺失的問題,多個 serverless instance 也可共享。B 在 Next.js serverless 環境中 session 不跨請求。C 解決了記憶體洩漏但 restart 仍遺失。

Firestore 結構

users/{uid}
  └─ plans/{planId}
       ├─ steps: [{ id, title }]
       ├─ status: "pending" | "approved" | "cancelled"
       ├─ message: string
       └─ createdAt: Timestamp

關鍵技術決策:Client Context 傳遞

在實作過程中碰到一個架構上的問題:Firebase Admin SDK 需要服務帳戶金鑰才能在 server 端存取 Firestore。若金鑰未設定,server 端無法讀取 profile 和記憶。

解法是讓 client 端(Firebase Web SDK)讀取 profile + memories,隨 API 請求一起送給 server,server 直接使用:

Client(page.tsx)                    Server(chat.ts / plan-review.ts)
    │                                        │
    ├─ loadUserContext()                     │
    │  ├─ getDoc(profile) ← Web SDK         │
    │  ├─ getDocs(memories) ← Web SDK       │
    │  └─ { profile, memories }             │
    │                                        │
    ├─ POST /api/chat { context } ─────────→ │
    │                                        ├─ formatClientProfile()
    │                                        ├─ formatClientMemories()
    │                                        ├─ 注入 system prompt
    │                                        └─ 回應(個人化)
    │                                        │
    └─ POST /api/chat/plan { context } ────→ │
                                              ├─ 存入 plan.context
                                              ├─ approvePlan 時取出
                                              ├─ 注入 merge prompt
                                              └─ 回應(個人化)

Profile 寫入路徑(Client Write-back)

Server 端 inferProfile() 推斷 profile 後,無法直接寫入 Firestore(Admin SDK 需金鑰)。改為透過 API response 回傳,由 client 端 Web SDK 寫入:

Server(inferProfile)                  Client(page.tsx)                     Firestore
       │                                      │                                 │
       ├─ SSE type: "profile_update" ───────→ │  saveProfileToFirestore()       │
       │                                      │  ├─ load existing profile       │
       ├─ JSON { ..., profile } ───────────→  │  ├─ merge 陣列欄位             │
       │                                      │  │  (skills, goals, pain_points)│
       │                                      │  └─ setDoc(merge: true) ──────→ │

陣列欄位合併規則:讀取 Firestore 中既有的 skills/goals/pain_points,與新推斷的值用 Set 去重合併,不覆蓋。

架構

Client(Web SDK)                               Server(Groq API)
    │                                               │
    ├─ loadUserContext()                            │
    │  profile + memories ──────────────────────→  │
    │                                               ├─ formatClientProfile()
    │                                               ├─ formatClientMemories()
    │                                               ├─ 注入 system prompt / merge prompt
    │                                               ├─ inferProfile() → profile 資料
    │  ←── SSE text + profile_update ───────────── │
    │  ←── JSON { reply, profile } ─────────────── │
    │                                               │
    ├─ saveProfileToFirestore(profile)              │
    │  ├─ getDoc(existing)                          │
    │  ├─ merge 陣列欄位                            │
    │  └─ setDoc(merge: true) → Firestore          │
    │                                               │
    └─ loadUserContext()(下次請求)                 │
       profile + memories ──────────────────────→  │

個人化涵蓋路徑

路徑 載入 profile 載入記憶 注入方式
simple streaming system prompt
non-streaming(getReply) system prompt
medium(career-chain) step3 報告 prompt
planned(plan-review) ✅ 存入 plan.context ✅ 存入 plan.context merge prompt
complex(orchestrator) merge prompt

記憶儲存涵蓋路徑

路徑 inferAndSaveProfile extractMemories + saveMemory
simple streaming ✅(saveProfileAndMemory)
non-streaming ✅(saveProfileAndMemory)
medium(career-chain)
planned(approvePlan) ✅(inferAndSaveProfile)
complex(executeComplex)

測試結果

個人檔案與記憶管理頁面

測試 1:
- 編輯個人檔案欄位(現職、年資、技能) ✅
- 新增一筆記憶(輸入「偏好遠端工作」→ 按新增) ✅
- 刪除一筆記憶(點 ✕) ✅
- 回到 chat(點「← 回到對話」) ✅
- saveProfileToFirestore:
  - 現職/年資/產業:新值優先,保留舊值 ✅
  - 技能/目標/關注:讀取既有 + Set 去重合併 ✅

CareerWise 的記憶管理:讓 AI 記得你是誰

跨對話個人化

測試 2:
重新整理 chat 頁面(F5)
輸入「該不該轉職去新創」
→ CareerWise 應該知道:
  - 現職 A 公司的工程師
  - 年資 3 年
  - 技能 前端開發
  - 不喜歡空泛建議
→ 回答不該是「出社會一~三年」這種通用內容

CareerWise 的記憶管理:讓 AI 記得你是誰

計劃持久化

測試 3:
輸入「該不該轉職去新創」→ 計劃卡片出現
→ Cmd+R 重新整理 → 計劃卡片仍在(client 端 Firestore Web SDK 儲存)✅

對 UI/UX 的影響

記憶管理對使用者來說,最直接的感受是跨對話的連續感

不用重新自我介紹。 使用者隔天回來繼續對話,CareerWise 記得他的現職、年資、技能,也知道他不喜歡空泛建議。這種連續感讓使用者覺得 CareerWise「記得我」,而不是每次都從陌生人開始。從測試結果來看,使用者說「我是 A 公司的工程師」後,profile 就記錄下來了,下次不用再說一次。

看得見、管得著。 /memories 頁面讓使用者知道 CareerWise 記住了什麼,也能編輯或刪除錯誤的記憶。對比大部分 AI 產品只會默默記住使用者卻不給管理介面,這是 CareerWise 的一個差異點。

確認機制不擾人。 當 CareerWise 發現使用者角色或年資變更時,在回答結尾附上一句「我注意到你的角色變了,要更新嗎?」。使用者可以確認或忽略,不會強迫操作。

解法

總結

這次升級讓 CareerWise 從「每次都是第一次見面」變成「記得你是誰」。

Client Context 的架構設計解決了 server 端無法直接存取 Firestore 的限制——client 讀取資料,隨請求傳遞給 server,server 注入 prompt,達到個人化效果。沒有完美,但已經比原本的「每次從零開始」好太多了。

當前限制


CareerWise Agentic Design Pattern Agentic AI AI UX Memory Management Architecture Design Pattern