iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
AI Engineering

從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理系列 第 26 篇

Agent 的記憶:從對話歷史到長期偏好

  • 分享至 

  • xImage
  •  

昨天我們談了自主決策的邊界。今天是這個系列最後一塊技術拼圖:記憶。

回想 Day 18 講過的一件事:

API 是無狀態的。模型完全不記得上一次的對話。

它之所以「記得」,是因為我們把歷史又傳了一次。

但這帶來兩個問題:

  1. 對話越長越貴——每輪都要重傳全部歷史
  2. 程式關掉就忘光——明天啟動又是陌生人

今天要解決這兩件事。


一、三種記憶

我把 Agent 的記憶分成三種,它們的生命週期和用途都不一樣:

類型 存什麼 活多久 放哪裡
工作記憶 這次任務的中間結果 一次任務 messages 陣列
短期記憶 最近的對話 一次 session 記憶體 + 存檔
長期記憶 使用者的偏好與重要事實 永久 JSON 檔 / 資料庫

用人來比喻:

  • 工作記憶 = 你現在正在想的事(講完就忘)
  • 短期記憶 = 你記得剛剛聊了什麼(今天結束就淡了)
  • 長期記憶 = 你知道朋友不吃牛肉(會一直記著)

二、短期記憶:對話歷史管理

問題:越聊越貴

每一輪都要重傳全部歷史。假設每輪平均 200 tokens:

輪次 輸入 tokens 累積
1 200 200
5 1,000 3,000
10 2,000 11,000
20 4,000 42,000

**成本是平方成長的。**聊到第 20 輪,光是輸入就燒了 4 萬多 token。

策略一:滑動視窗

最簡單——只保留最近 N 則:

"""memory/conversation.py"""

import json
import logging
from datetime import datetime
from pathlib import Path

logger = logging.getLogger(__name__)


class ConversationMemory:
    """管理對話歷史。"""

    def __init__(self, max_messages=20, session_id=None):
        self.max_messages = max_messages
        self.session_id = session_id or datetime.now().strftime("%Y%m%d_%H%M%S")
        self.messages = []

    def add_user(self, content):
        self.messages.append({"role": "user", "content": content})
        self._trim()

    def add_assistant(self, content):
        self.messages.append({"role": "assistant", "content": content})
        self._trim()

    def _trim(self):
        """保留最近 max_messages 則,並確保格式合法。"""
        if len(self.messages) <= self.max_messages:
            return

        self.messages = self.messages[-self.max_messages:]

        # API 要求第一則必須是 user
        while self.messages and self.messages[0]["role"] != "user":
            self.messages.pop(0)

        logger.debug("對話歷史已裁切至 %d 則", len(self.messages))

    def get(self):
        return list(self.messages)

    def clear(self):
        self.messages = []

**注意 _trim() 裡那個 while 迴圈。**API 要求 messages 必須從 user 開始,而且 user / assistant 要交替。單純切最後 N 則可能切出一個以 assistant 開頭的陣列,就會報錯。

這是實作滑動視窗時最常見的 bug。

策略二:摘要壓縮

當歷史太長時,把舊的對話請模型摘要成一段文字:

SUMMARIZE_PROMPT = """以下是一段對話記錄。請摘要成 150 字以內的重點,保留:
- 使用者提到的個人資訊、偏好、習慣
- 已經完成或約定的事項
- 還沒解決的問題

用第三人稱陳述,例如「使用者住在台北,不吃牛肉」。
不要加任何開場白,直接輸出摘要。"""


class SummarizingMemory(ConversationMemory):
    """歷史太長時自動摘要壓縮。"""

    def __init__(self, client, max_messages=20, keep_recent=6, **kwargs):
        super().__init__(max_messages=max_messages, **kwargs)
        self.client = client
        self.keep_recent = keep_recent
        self.summary = None

    def _trim(self):
        if len(self.messages) <= self.max_messages:
            return

        # 把舊的部分拿出來摘要
        to_summarize = self.messages[:-self.keep_recent]
        recent = self.messages[-self.keep_recent:]

        transcript = "\n".join(
            f"{'使用者' if m['role'] == 'user' else '助理'}:{self._text(m)}"
            for m in to_summarize
        )

        if self.summary:
            transcript = f"【先前的摘要】\n{self.summary}\n\n【新的對話】\n{transcript}"

        response = self.client.messages.create(
            model="claude-haiku-4-5",          # 摘要用便宜的模型就好
            max_tokens=512,
            system=SUMMARIZE_PROMPT,
            messages=[{"role": "user", "content": transcript}],
        )
        self.summary = "".join(
            b.text for b in response.content if b.type == "text"
        ).strip()

        logger.info("已壓縮 %d 則對話為摘要", len(to_summarize))

        self.messages = recent
        while self.messages and self.messages[0]["role"] != "user":
            self.messages.pop(0)

    @staticmethod
    def _text(message):
        content = message["content"]
        if isinstance(content, str):
            return content
        # content 可能是 block list(工具呼叫的情況)
        return " ".join(
            b.get("text", f"[{b.get('type')}]")
            for b in content if isinstance(b, dict)
        )

    def get_context(self):
        """回傳要注入 system prompt 的摘要。"""
        if not self.summary:
            return ""
        return f"\n\n## 先前對話摘要\n{self.summary}"

兩個實務重點:

**用便宜的模型做摘要。**摘要是簡單任務,用 Haiku 就好,成本差五倍。

**_text() 要處理 block list。**當對話包含工具呼叫時,content 不是字串而是 list。直接拿去串接會噴錯,這個坑我踩過。


三、長期記憶:使用者檔案

短期記憶解決「這次對話」的問題。長期記憶解決「這個人是誰」。

"""memory/profile.py"""

import json
import logging
from datetime import datetime
from pathlib import Path

logger = logging.getLogger(__name__)

PROFILE_PATH = Path("data/user_profile.json")

DEFAULT_PROFILE = {
    "name": None,
    "city": "臺北市",
    "facts": [],           # 關於使用者的事實
    "preferences": {},     # 偏好設定
    "updated_at": None,
}


class UserProfile:
    """使用者的長期記憶。"""

    def __init__(self, path=PROFILE_PATH):
        self.path = Path(path)
        self.data = self._load()

    def _load(self):
        profile = dict(DEFAULT_PROFILE)
        if not self.path.exists():
            return profile
        try:
            saved = json.loads(self.path.read_text(encoding="utf-8"))
            profile.update(saved)        # Day 10 講過的合併技巧
        except json.JSONDecodeError:
            logger.warning("使用者檔案損壞,使用預設值")
        return profile

    def save(self):
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.data["updated_at"] = datetime.now().isoformat(timespec="seconds")
        self.path.write_text(
            json.dumps(self.data, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    # --- 事實 ---

    def remember_fact(self, fact: str, category: str = "general"):
        """記住一件關於使用者的事。"""
        fact = fact.strip()
        if not fact:
            return False

        # 避免重複
        for existing in self.data["facts"]:
            if existing["content"] == fact:
                existing["mentioned_count"] += 1
                existing["last_mentioned"] = datetime.now().isoformat(timespec="seconds")
                self.save()
                return False

        self.data["facts"].append({
            "content": fact,
            "category": category,
            "created_at": datetime.now().isoformat(timespec="seconds"),
            "last_mentioned": datetime.now().isoformat(timespec="seconds"),
            "mentioned_count": 1,
        })
        self.save()
        logger.info("記住新事實:%s", fact)
        return True

    def forget_fact(self, keyword: str):
        before = len(self.data["facts"])
        self.data["facts"] = [
            f for f in self.data["facts"] if keyword not in f["content"]
        ]
        self.save()
        return before - len(self.data["facts"])

    # --- 偏好 ---

    def set_preference(self, key, value):
        self.data["preferences"][key] = value
        self.save()

    def get_preference(self, key, default=None):
        return self.data["preferences"].get(key, default)

    # --- 給模型看的格式 ---

    def to_context(self, max_facts=15) -> str:
        """轉成可以放進 system prompt 的文字。"""
        lines = []

        if self.data.get("name"):
            lines.append(f"- 稱呼:{self.data['name']}")
        if self.data.get("city"):
            lines.append(f"- 所在地:{self.data['city']}")

        # 依提及次數排序,最常提到的放前面(Day 16 的 sorted)
        facts = sorted(
            self.data["facts"],
            key=lambda f: (-f["mentioned_count"], f["created_at"]),
        )[:max_facts]

        for fact in facts:
            lines.append(f"- {fact['content']}")

        for key, value in self.data["preferences"].items():
            lines.append(f"- {key}:{value}")

        if not lines:
            return ""

        return (
            "\n\n## 關於這位使用者\n"
            + "\n".join(lines)
            + "\n\n請自然地運用這些資訊,不要刻意提起你記得這些事。"
        )

to_context() 產生的東西長這樣:

## 關於這位使用者
- 稱呼:pzhiqi
- 所在地:臺北市
- 正在參加 iThome 鐵人賽,主題是 AI Agent
- 不吃牛肉
- 習慣晚上運動
- 通勤方式:機車
- reminder_time:08:00

請自然地運用這些資訊,不要刻意提起你記得這些事。

最後那句話很重要。沒有的話,模型會一直說「我記得你說過你不吃牛肉」,很刻意也很煩。


四、讓 Agent 自己決定要記什麼

最漂亮的做法:把記憶做成工具。

"""tools/memory_tool.py"""

from tools.base import Tool, ToolError


class RememberTool(Tool):
    name = "remember"
    tags = ("memory", "write")
    description = (
        "記住一件關於使用者的長期事實,未來的對話都會記得。"
        "適合記錄:飲食限制、通勤方式、作息習慣、家庭成員、"
        "長期目標、對助理的偏好。"
        "不要記錄:一次性的待辦事項(請用 add_todo)、"
        "臨時的資訊、可以隨時查到的東西(天氣、日期)。"
        "只在使用者明確透露個人資訊時使用,不要過度記錄。"
    )
    input_schema = {
        "type": "object",
        "properties": {
            "fact": {
                "type": "string",
                "description": (
                    "用第三人稱陳述的一句話,例如「使用者不吃牛肉」、"
                    "「使用者習慣晚上七點後運動」。要具體、簡短。"
                ),
            },
            "category": {
                "type": "string",
                "enum": ["飲食", "作息", "工作", "家庭", "偏好", "其他"],
                "description": "這件事的分類",
            },
        },
        "required": ["fact"],
    }

    def __init__(self, profile):
        self.profile = profile

    def run(self, fact, category="其他"):
        is_new = self.profile.remember_fact(fact, category)
        if is_new:
            return f"已記住:{fact}"
        return f"這件事我已經記得了:{fact}"


class RecallTool(Tool):
    name = "recall"
    tags = ("memory",)
    description = (
        "查詢關於使用者已經記住的資訊。"
        "當使用者問「你還記得我…嗎」,"
        "或你需要確認某件個人資訊時使用。"
    )
    input_schema = {
        "type": "object",
        "properties": {
            "keyword": {
                "type": "string",
                "description": "要查詢的關鍵字,留空則列出全部",
            }
        },
    }

    def __init__(self, profile):
        self.profile = profile

    def run(self, keyword=None):
        facts = self.profile.data["facts"]
        if keyword:
            facts = [f for f in facts if keyword in f["content"]]

        if not facts:
            return f"沒有找到關於「{keyword}」的記憶。" if keyword else "目前還沒有記住任何事。"

        return "目前記得:\n" + "\n".join(
            f"- {f['content']}({f['category']})" for f in facts
        )


class ForgetTool(Tool):
    name = "forget"
    tags = ("memory", "write")
    dangerous = True                      # 需要確認(Day 25)
    description = (
        "刪除關於使用者的某項記憶。"
        "只在使用者明確要求忘記某件事時使用。這個操作無法復原。"
    )
    input_schema = {
        "type": "object",
        "properties": {
            "keyword": {
                "type": "string",
                "description": "包含這個關鍵字的記憶會被刪除",
            }
        },
        "required": ["keyword"],
    }

    def __init__(self, profile):
        self.profile = profile

    def run(self, keyword):
        count = self.profile.forget_fact(keyword)
        if count == 0:
            raise ToolError(f"沒有找到包含「{keyword}」的記憶")
        return f"已忘記 {count} 件關於「{keyword}」的事。"

實際體驗:

你:我不吃牛肉,之後推薦餐廳的時候注意一下
  🔧 使用工具 remember({'fact': '使用者不吃牛肉', 'category': '飲食'})
小幫:好的,我記下來了。之後推薦的時候會避開牛肉料理。

--- 隔天重新啟動程式 ---

你:晚餐吃什麼好?
小幫:看你想吃什麼類型。日式的話烏龍麵或鮭魚定食,
     中式可以吃個雞肉飯,都是輕食一點的選擇。

你:你怎麼沒推薦牛肉麵?
小幫:因為你之前說過不吃牛肉,所以我沒列進來。

它記得,而且是自然地運用,不是每句話都提醒你「我記得你說過」。

ForgetTool 標記了 dangerous = True——刪除記憶是不可逆的,照 Day 25 的原則要先確認。


五、整合進 Agent

"""assistant.py — 有記憶的助理。"""

import anthropic

import config
import prompts
from memory.conversation import SummarizingMemory
from memory.profile import UserProfile
from tools import ToolBox
from tools.datetime_tool import DateTimeTool
from tools.memory_tool import ForgetTool, RecallTool, RememberTool
from tools.todo_tool import AddTodoTool, ListTodosTool
from tools.weather_tool import WeatherTool
from agent import Agent
from agent.budget import Budget
from agent.confirm import ConfirmationPolicy


class Assistant:
    """整合記憶、工具與 Agent 的完整助理。"""

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
        self.profile = UserProfile()
        self.conversation = SummarizingMemory(self.client, max_messages=20)

        self.toolbox = ToolBox([
            DateTimeTool(),
            WeatherTool(default_city=self.profile.data.get("city")),
            AddTodoTool(),
            ListTodosTool(),
            RememberTool(self.profile),
            RecallTool(self.profile),
            ForgetTool(self.profile),
        ])

        self.policy = ConfirmationPolicy(mode="ask")

    def build_system_prompt(self):
        """每次都重新組裝,因為記憶會變。"""
        return (
            prompts.AUTONOMOUS_SYSTEM
            + self.profile.to_context()
            + self.conversation.get_context()
        )

    def chat(self, user_input, confirm_fn=None):
        agent = Agent(
            system_prompt=self.build_system_prompt(),
            toolbox=self.toolbox,
        )

        answer, _ = agent.run(
            user_input,
            history=self.conversation.get(),
            budget=Budget(max_turns=8, max_cost_usd=0.30),
            policy=self.policy,
            confirm_fn=confirm_fn,
        )

        self.conversation.add_user(user_input)
        self.conversation.add_assistant(answer)

        return answer

一個重要的設計決定

注意 build_system_prompt() 是每次對話都重新組裝的。

為什麼?因為記憶會變——如果模型剛剛呼叫了 remember,下一輪的 system prompt 就應該包含那件新記住的事。

但這也有代價:system prompt 一變,快取就失效了。

所以實務上要權衡:

  • 記憶更新不頻繁 → 每次重組(簡單、正確)
  • 記憶更新頻繁 → 把變動的部分移到 messages 裡,保持 system prompt 穩定

這個系列的規模不大,我選簡單的做法。


六、記憶設計的幾個原則

1. 不要記太多

記憶會進到每一次的 system prompt。記 100 件事,每次對話就多 1000+ tokens。

我的做法:

  • to_context(max_facts=15) 限制數量
  • 依「提及次數」排序,常提到的優先
  • 定期清理很久沒提到的

2. 事實要具體、原子化

# ❌ 太籠統
"使用者喜歡運動"

# ✅ 具體
"使用者習慣晚上七點後在住家附近跑步,每次約 30 分鐘"

# ❌ 一句話塞太多(要改其中一項時很麻煩)
"使用者不吃牛肉、住台北、騎機車通勤、習慣晚上運動"

# ✅ 拆開
"使用者不吃牛肉"
"使用者住在台北"
"使用者騎機車通勤"

3. 記憶要能被使用者檢視與刪除

這是隱私的基本要求。RecallTool 讓使用者可以問「你記得我什麼」,ForgetTool 讓他可以刪除。

而且檔案是明文 JSON,使用者隨時可以自己打開看、自己改。不要把記憶藏在使用者看不到的地方。

4. 敏感資訊不要記

健康狀況、財務細節、他人的個資——除非使用者明確要求,否則不要主動記錄。

可以在工具的 description 裡明講:

"不要記錄:健康狀況、財務資訊、密碼、他人的個人資料。"

5. 記憶不等於真相

使用者的狀況會變。「我不吃牛肉」可能三個月後就變了。所以:

  • 記錄 created_at,讓舊記憶可以被識別
  • 使用者的新說法優先於舊記憶
  • 重要的判斷還是要確認,不要完全依賴記憶

七、到這裡,技術內容告一段落

回顧一下我們蓋了什麼:

┌─────────────────────────────────────────┐
│  Assistant(整合層)                      │
│  ┌──────────┐  ┌──────────┐             │
│  │ 記憶      │  │ Agent    │             │
│  │ ・工作記憶 │  │ ・迴圈    │             │
│  │ ・短期記憶 │  │ ・預算    │  Day 21-26 │
│  │ ・長期記憶 │  │ ・確認    │             │
│  └──────────┘  └──────────┘             │
│  ┌─────────────────────────┐            │
│  │ Workflow(固定流程)      │  Day 23-24 │
│  └─────────────────────────┘            │
│  ┌─────────────────────────┐            │
│  │ Tools(能力層)           │  Day 15,22 │
│  │ 天氣 / 待辦 / 時間 / 記憶  │            │
│  └─────────────────────────┘            │
│  ┌─────────────────────────┐            │
│  │ LLM Client / Prompt      │  Day 18-20 │
│  └─────────────────────────┘            │
│  ┌─────────────────────────┐            │
│  │ 基礎設施                  │  Day 8-17  │
│  │ 設定 / HTTP / 日誌 / 分析 │            │
│  └─────────────────────────┘            │
│  ┌─────────────────────────┐            │
│  │ Python 基礎              │  Day 2-7   │
│  └─────────────────────────┘            │
└─────────────────────────────────────────┘

接下來三天(Day 27–29),我們要把這些零件組裝成一個完整、可以每天實際使用的生活助理。


小結

  • 三種記憶:工作記憶(一次任務)、短期記憶(一次 session)、長期記憶(永久)
  • 對話成本是平方成長的,必須管理歷史長度
  • 滑動視窗要注意:切完之後第一則必須是 user
  • 摘要壓縮用便宜的模型就好;記得處理 content 是 block list 的情況
  • 長期記憶用 JSON 存,to_context() 轉成 system prompt 的一部分
  • 把記憶做成工具(remember / recall / forget),讓 Agent 自己決定記什麼
  • 加上「請自然地運用,不要刻意提起」,避免模型一直說「我記得你說過」
  • 記憶要具體、原子化、數量有限、使用者可檢視可刪除
  • 敏感資訊不要主動記錄

明天開始最後的整合專案:需求拆解與系統設計。


上一篇
自主決策的分界線:什麼時候該讓 AI 自己決定
下一篇
生活助理專案(一):需求拆解與系統設計
系列文
從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言