CareerWise 的記憶管理:讓 AI 記得你是誰
23 Jul 2026CareerWise 是一個職涯諮詢 AI 服務,使用者在上面詢問面試準備、履歷健檢、轉職規劃等問題。原本 CareerWise 的記憶機制很陽春:
- 對話歷史:儲存在 Firestore,但只有當次 session 會載入。原因是系統從來沒有設計跨 session 的記憶機制——資料有存,但沒有被讀取。使用者開啟新對話,過去說過的話就不在了
- 使用者背景:不存在。
career-chain.ts(medium tier 的處理路徑,用提示鏈拆成三步驟依序執行)的 step1 每次從對話歷史重新推斷,新 session 從零開始 - 計劃狀態:存在 in-memory Map(程式執行期間暫存在記憶體中,server restart 後全部消失),server restart 後遺失
- 跨對話記憶:完全不存在。使用者隔天回來,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 去重合併 ✅
跨對話個人化
測試 2:
重新整理 chat 頁面(F5)
輸入「該不該轉職去新創」
→ CareerWise 應該知道:
- 現職 A 公司的工程師
- 年資 3 年
- 技能 前端開發
- 不喜歡空泛建議
→ 回答不該是「出社會一~三年」這種通用內容
計劃持久化
測試 3:
輸入「該不該轉職去新創」→ 計劃卡片出現
→ Cmd+R 重新整理 → 計劃卡片仍在(client 端 Firestore Web SDK 儲存)✅
對 UI/UX 的影響
記憶管理對使用者來說,最直接的感受是跨對話的連續感。
不用重新自我介紹。 使用者隔天回來繼續對話,CareerWise 記得他的現職、年資、技能,也知道他不喜歡空泛建議。這種連續感讓使用者覺得 CareerWise「記得我」,而不是每次都從陌生人開始。從測試結果來看,使用者說「我是 A 公司的工程師」後,profile 就記錄下來了,下次不用再說一次。
看得見、管得著。 /memories 頁面讓使用者知道 CareerWise 記住了什麼,也能編輯或刪除錯誤的記憶。對比大部分 AI 產品只會默默記住使用者卻不給管理介面,這是 CareerWise 的一個差異點。
確認機制不擾人。 當 CareerWise 發現使用者角色或年資變更時,在回答結尾附上一句「我注意到你的角色變了,要更新嗎?」。使用者可以確認或忽略,不會強迫操作。
解法
- Client Context 架構。讓 client 端讀取 Firestore,隨 API 請求傳遞給 server。server 不需要 Admin SDK 金鑰,也不用直接存取資料庫。
- 陣列欄位合併。profile 的陣列欄位(技能、目標)用
Set去重合併而非覆蓋,避免「會 SEO」蓋掉「前端開發」的問題。 - 非同步儲存。記憶提取和 profile 儲存都在回答串流完成後的非同步階段執行,不影響回應時間。
總結
這次升級讓 CareerWise 從「每次都是第一次見面」變成「記得你是誰」。
- P1 語意記憶記錄使用者的事實(現職、年資、技能),新 session 不用從零推斷
- P2 情景記憶記錄使用者的偏好與決定,跨對話持續累積
- P3 記憶管理 UI讓使用者看得見且能修正 CareerWise 的記憶
- P4 Session State 持久化讓計劃狀態不因 server restart 而遺失
Client Context 的架構設計解決了 server 端無法直接存取 Firestore 的限制——client 讀取資料,隨請求傳遞給 server,server 注入 prompt,達到個人化效果。沒有完美,但已經比原本的「每次從零開始」好太多了。
當前限制
- LINE Bot:LINE 路徑不傳遞
uid,profile 與記憶功能不適用 - 記憶視覺化:目前僅以文字列表顯示,無時間軸或分類
- 記憶矛盾處理:系統不會主動標記衝突的記憶(如「愛咖啡」vs「戒咖啡」)