iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

昨天我們第一次把 Conversation History 和 Memory Candidate 分開。目前使用者必須明確輸入:

remember 我的英文程度是 B1。

程式才會把這項資訊加入:

memory_candidates

但真正使用 Chatbot 時,使用者通常不會特別下達 remember 指令,而是自然地說:

我的英文程度大概是 B1,最近想加強旅遊英文。

Memora必須自己從這句話中找出:

使用者的英文程度是 B1。
使用者想加強旅遊英文。

今天要替 Memora 加入一個新的 LLM Request,專門負責:

從目前的 User Message 中抽取可能值得長期保存的資訊。


一、回答問題和抽取 Memory 是兩個不同任務

假設使用者輸入:

我的英文程度是 B1,可以幫我安排旅遊英文練習嗎?

原本的 LLM Request 只需要產生回答:

當然可以,我們可以先從機場和飯店情境開始。

但現在還需要另一個結果:

使用者的英文程度是 B1。
使用者想練習旅遊英文。

這兩個任務的目的不同。

任務 輸入 輸出
Chat Response 目前的 Conversation Context 給使用者的回答
Memory Extraction 最新的 User Message Memory Candidates

如果要求同一個 Prompt 同時回答問題,又在回答裡附上 Memory,輸出可能變成:

當然可以,我們可以先從機場英文開始。

Memory:
- 使用者的英文程度是 B1。
- 使用者想練習旅遊英文。

這會把使用者看得見的回答和 Application 內部資料混在一起。因此今天會使用兩次 LLM 呼叫:

第一次
產生正常的 Assistant Reply

第二次
分析 User Message 並抽取 Memory

Memory Extraction 是 Application 內部流程,不會直接當成聊天回答顯示。


二、先定義 Memory Extractor 的工作

我們要給抽取用的 LLM 一組獨立 Instructions。

在 Day 10 的:

SUMMARY_INSTRUCTIONS

下方加入:

MEMORY_EXTRACTION_INSTRUCTIONS = """
You extract potential long-term memories from a user message
for a personal English learning assistant.

Extract only information that may be useful in future
conversations, such as:
- The user's English level
- Long-term learning goals
- Stable learning preferences
- Recurring learning difficulties
- Relevant learning constraints

Do not extract:
- Greetings or casual conversation
- One-time requests
- Questions that only depend on the current context
- Information created by the assistant
- Uncertain assumptions
- Passwords, secret codes, or unnecessary sensitive data

Rewrite each memory as a short, self-contained statement.

Return exactly one of these formats:

NONE

or:

MEMORY: <memory>
MEMORY: <memory>

Do not add explanations or Markdown.
"""

這組 Instructions 不負責教英文,也不負責回答使用者。它只需要判斷:

這句話裡是否存在值得保存的資訊?

如果沒有,就回傳:

NONE

如果有,就使用:

MEMORY: ...

逐行輸出。


三、幾個抽取範例

有值得保存的資訊

輸入:

我的英文程度是 B1,我想加強旅遊英文。

理想輸出:

MEMORY: 使用者的英文程度是 B1。
MEMORY: 使用者想加強旅遊英文。

一個 User Message 可以包含不只一項 Memory,因此要拆成兩行。

只有當下的問題

輸入:

請解釋現在完成式。

理想輸出:

NONE

這是一個目前要完成的任務,不代表使用者長期偏好現在完成式,也不能直接推論這是他固定不熟悉的文法。

描述學習偏好

輸入:

文法解釋可以簡短一點,我比較喜歡直接看例句。

理想輸出:

MEMORY: 使用者偏好簡短的文法解釋。
MEMORY: 使用者偏好透過例句學習。

只和目前 Context 有關

輸入:

第二個單字是什麼意思?

理想輸出:

NONE

「第二個單字」離開目前 Conversation 後就失去完整意義,因此不適合直接成為 Long-term Memory。


四、建立 extract_memory_candidates()

接著加入一個新的 Function:

def extract_memory_candidates(user_input):
    response = client.responses.create(
        model=MODEL,
        instructions=MEMORY_EXTRACTION_INSTRUCTIONS,
        input=user_input
    )

    raw_output = response.output_text.strip()
    extracted_candidates = []

    if raw_output.upper() == "NONE":
        return (
            extracted_candidates,
            response.usage.total_tokens,
            raw_output
        )

    for line in raw_output.splitlines():
        line = line.strip()

        if line.upper().startswith("MEMORY:"):
            candidate = line.split(":", 1)[1].strip()

            if candidate:
                extracted_candidates.append(candidate)

    return (
        extracted_candidates,
        response.usage.total_tokens,
        raw_output
    )

這個 Function 會回傳三項資料:

extracted_candidates

實際解析出來的 Memory Candidate。

response.usage.total_tokens

這次 Memory Extraction 使用的 Token。

raw_output

模型尚未經過程式解析的原始輸出,方便我們 Debug。

例如模型回傳:

MEMORY: 使用者的英文程度是 B1。
MEMORY: 使用者想加強旅遊英文。

經過:

raw_output.splitlines()

會變成:

[
    "MEMORY: 使用者的英文程度是 B1。",
    "MEMORY: 使用者想加強旅遊英文。"
]

再移除前面的:

MEMORY:

最後得到:

[
    "使用者的英文程度是 B1。",
    "使用者想加強旅遊英文。"
]

這些內容就可以加入昨天建立的:

memory_candidates

五、這還不是 Structured Output

目前我們只是要求 LLM 遵守一個文字格式:

MEMORY: ...

但 Prompt 寫得再清楚,模型仍有可能回傳:

Here are the memories:

- 使用者的英文程度是 B1。

或者:

記憶:使用者的英文程度是 B1。

這些內容對人類來說看得懂,但目前的程式只辨識:

line.upper().startswith("MEMORY:")

所以可能無法成功解析。這是今天還有的問題。

我們現在使用的是:

要求模型按照文字格式回答,再由 Python 自己解析。

明天才會正式使用 Structured Output,讓輸出符合我們定義的資料結構。Structured Output 可以提高格式穩定性,但要先注意:

格式正確,不代表抽取內容一定正確。

即使模型成功輸出合法資料,它仍可能漏掉資訊或做出錯誤判斷。資料格式與內容品質是兩個不同問題。


六、把 Extraction Token 加入統計

Day 10 的:

session_total_tokens

目前會累積一般回答與 Conversation Summary 使用的 Token。

今天又多了一次 Memory Extraction Request,因此要把它也記錄進去。

ShortTermMemory Class 裡加入:

def add_token_usage(self, total_tokens):
    self.session_total_tokens += total_tokens

位置可以放在:

finish_turn()

後面:

def finish_turn(
    self,
    assistant_reply,
    context_messages,
    response_tokens
):
    self.history.append(
        {
            "role": "assistant",
            "content": assistant_reply
        }
    )

    self.last_context = [
        message.copy()
        for message in context_messages
    ]

    self.session_total_tokens += response_tokens

def add_token_usage(self, total_tokens):
    self.session_total_tokens += total_tokens

當 Memory Extraction 完成後,再執行:

memory.add_token_usage(extraction_tokens)

這樣 session_total_tokens 才會包含目前程式實際產生的所有 LLM Response。


七、保留 Day 11 的手動 remember

今天加入自動抽取之後,不需要刪除昨天的:

remember <text>

我們可以把它保留下來,當成使用者明確指定 Memory 的方式。但是要避免同一項資訊被存兩次。

例如:

remember 我的英文程度是 B1。

Day 11 的程式已經會手動加入:

memory_candidates

如果後面又執行自動抽取,就可能再加入一次:

使用者的英文程度是 B1。

因此每一輪新增:

should_auto_extract = True

當使用者使用 remember 時,改成:

should_auto_extract = False

這一輪就不再執行自動抽取。真正的 Deduplication 會留到後續(也許是Day24)再處理;今天先避免最明顯的重複來源。


八、Memora v0.9 的主程式

Day 10 的 ShortTermMemory 與 Context Management 不需要重寫。

完成以下三項修改即可:

  1. 新增 MEMORY_EXTRACTION_INSTRUCTIONS
  2. 新增 extract_memory_candidates()
  3. 將 Day 11 的主迴圈更新成以下版本
memory = ShortTermMemory(
    client=client,
    model=MODEL,
    system_prompt=SYSTEM_PROMPT,
    summary_instructions=SUMMARY_INSTRUCTIONS,
    max_input_tokens=MAX_INPUT_TOKENS,
    max_recent_turns=MAX_RECENT_TURNS
)

memory_candidates = []
last_extraction_output = ""

print("Memora v0.9")
print(
    "Commands: history, context, summary, status, "
    "remember <text>, memories, extraction, exit"
)

while True:
    user_input = input("\nYou: ").strip()

    if not user_input:
        continue

    command = user_input.lower()
    should_auto_extract = True

    if command == "exit":
        print("Bye!")
        break

    if command == "history":
        print_messages(
            "Full Conversation History",
            memory.history
        )
        continue

    if command == "context":
        print_messages(
            "Last Request Context",
            memory.last_context
        )
        continue

    if command == "summary":
        print("\n--- Conversation Summary ---")
        print(memory.summary or "(empty)")
        print("----------------------------")
        continue

    if command == "memories":
        print("\n--- Memory Candidates ---")

        if not memory_candidates:
            print("(empty)")
        else:
            for index, candidate in enumerate(
                memory_candidates,
                start=1
            ):
                print(f"{index}. {candidate}")

        print("-------------------------")
        continue

    if command == "extraction":
        print("\n--- Last Extraction Output ---")
        print(last_extraction_output or "(not run)")
        print("------------------------------")
        continue

    if command == "status":
        print("\n--- Memory Status ---")

        for name, value in memory.get_status().items():
            print(f"{name}: {value}")

        print(
            "Memory candidates:",
            len(memory_candidates)
        )

        print("---------------------")
        continue

    if command == "remember":
        print("Usage: remember <text>")
        continue

    if command.startswith("remember "):
        candidate = user_input[
            len("remember "):
        ].strip()

        if not candidate:
            print("Usage: remember <text>")
            continue

        memory_candidates.append(candidate)

        print(
            "Saved as Memory Candidate:",
            candidate
        )

        user_input = candidate
        should_auto_extract = False
        last_extraction_output = "(manual memory)"

    memory.add_user_message(user_input)

    try:
        memory_stats = memory.prepare_context()

        response = client.responses.create(
            model=MODEL,
            instructions=SYSTEM_PROMPT,
            input=memory_stats["context_messages"]
        )

    except Exception as error:
        memory.rollback_last_user_message()
        print("Request failed:", error)
        continue

    assistant_reply = response.output_text

    memory.finish_turn(
        assistant_reply=assistant_reply,
        context_messages=memory_stats["context_messages"],
        response_tokens=response.usage.total_tokens
    )

    extracted_candidates = []
    extraction_tokens = 0

    if should_auto_extract:
        try:
            (
                extracted_candidates,
                extraction_tokens,
                last_extraction_output
            ) = extract_memory_candidates(user_input)

            memory_candidates.extend(
                extracted_candidates
            )

            memory.add_token_usage(
                extraction_tokens
            )

        except Exception as error:
            last_extraction_output = (
                f"Extraction failed: {error}"
            )

    print("Memora:", assistant_reply)

    if extracted_candidates:
        print("\n--- New Memory Candidates ---")

        for candidate in extracted_candidates:
            print("-", candidate)

        print("-----------------------------")

    print("\n--- Memory Status ---")
    print(
        "Newly summarized messages:",
        memory_stats["newly_summarized_count"]
    )
    print(
        "Context messages sent:",
        len(memory_stats["context_messages"])
    )
    print(
        "Counted input tokens:",
        memory_stats["input_tokens"]
    )
    print(
        "Actual input tokens:",
        response.usage.input_tokens
    )
    print(
        "Output tokens:",
        response.usage.output_tokens
    )
    print(
        "Summary update tokens:",
        memory_stats["summary_token_usage"]
    )
    print(
        "Memory extraction tokens:",
        extraction_tokens
    )
    print(
        "Memory candidates:",
        len(memory_candidates)
    )
    print(
        "Session total tokens:",
        memory.session_total_tokens
    )
    print("---------------------")

這次修改沒有改變 Day 10 建立 Context 的方式。ShortTermMemory 仍負責目前 Conversation 的 State,新的 Extractor 只負責分析 User Message。OpenAI 官方文件也將 Conversation State 說明為跨多輪保留訊息脈絡的機制;我們新增的 Memory Extraction 則是 Application 在這些訊息之外額外執行的資訊處理流程。OpenAI Conversation State 文件


九、實際測試自動抽取

啟動程式後,直接使用一般的說話方式:

You:
我的英文程度是 B1,最近想加強旅遊英文。

Memora 會先正常回答:

Memora:
了解,我可以使用適合 B1 的難度,
並從機場、飯店和交通情境開始。

接著顯示這一輪抽取到的 Memory:

--- New Memory Candidates ---
- 使用者的英文程度是 B1。
- 使用者想加強旅遊英文。
-----------------------------

輸入:

memories

可以看到:

--- Memory Candidates ---
1. 使用者的英文程度是 B1。
2. 使用者想加強旅遊英文。
-------------------------

接著輸入一個只有當下用途的問題:

You:
請給我三個機場常用單字。

Memora 仍然會正常回答,但 Extractor 應該回傳:

NONE

輸入:

extraction

可以查看模型的原始輸出:

--- Last Extraction Output ---
NONE
------------------------------

這個 Debug Command 很重要,因為如果 memories 沒有新增內容,我們可以檢查:

模型判斷沒有 Memory

還是模型有輸出內容,但格式沒有被 Parser 接住

十、現在一輪可能不只呼叫一次 API

目前每個 User Message 至少可能產生兩次 LLM Request:

Chat Response Request
Memory Extraction Request

如果這一輪又剛好需要更新 Conversation Summary,則可能變成:

Conversation Summary Request
Chat Response Request
Memory Extraction Request

因此加入 Memory Extraction 後,會帶來額外的:

Token Cost
Latency
Failure Possibility

這也是為什麼程式把 Extraction 放在獨立的:

try:
    ...
except Exception:
    ...

裡面。即使 Memory Extraction 暫時失敗,Memora 仍然可以完成正常的聊天回答。Memory 功能不應該因為一次抽取失敗,就讓整個 Chatbot 無法回應。


十一、目前的 Extractor 有哪些限制?

現在 Memora 已經可以自動找出 Memory Candidate,但仍然很初步。

1. LLM 可能漏掉資訊

輸入:

我準備三個月後考 TOEIC,希望至少拿到 850 分。

模型可能只抽取:

使用者的 TOEIC 目標是 850 分。

卻漏掉三個月後要考試。

2. LLM 可能存下不該保存的內容

輸入:

我今天很累,先練習五分鐘就好。

Extractor 可能把:

使用者每天只想練習五分鐘。

當成長期偏好,但這其實只是今天的狀態。

3. Output Format 不一定穩定

我們要求:

MEMORY: ...

模型仍可能使用其他格式,導致 Python 解析失敗。

4. Memory 可能重複

不同 Conversation 裡,使用者可能多次說:

我的英文程度是 B1。

目前程式可能把相同資訊存入多次。

這個問題會在 Day 24 的 Deduplication 與 Update 再處理。

5. Memory 還不能跨程式保存

目前:

memory_candidates = []

仍然只存在 Python Process 裡。重新啟動程式後,內容依然會消失。真正的持久化要等到 Day17 的 Long-term Memory Store。


Day 12 小結

昨天的 Memora 必須依賴:

remember <text>

才能建立 Memory Candidate。

今天我們加入:

extract_memory_candidates()

讓每一個自然的 User Message 都可以經過另一個 LLM 分析:

User Message
      ↓
Memory Extractor
      ↓
Memory Candidates

因此:

我的英文程度是 B1,我想加強旅遊英文。

可以被拆成:

使用者的英文程度是 B1。
使用者想加強旅遊英文。

目前的 Memora 已經開始主動從 Conversation 中尋找值得保存的資訊。

但我們仍然依賴:

MEMORY: ...

這種由 Prompt 約定的文字格式,再透過:

splitlines()
startswith()

自行解析。只要模型多加一段說明、改用不同符號,程式就可能失敗。

Day 13|Structured Output:把 Memory 變成真正可以存的資料

下一篇,我們要把目前不穩定的文字輸出:

MEMORY: 使用者的英文程度是 B1。

改成具有固定欄位的資料,例如:

{
    "should_remember": true,
    "memories": [
        {
            "content": "使用者的英文程度是 B1。"
        }
    ]
}

到時候 Python 不需要再猜測:

模型有沒有遵守 MEMORY: 格式?

而是能直接取得具有明確結構的資料。

Memora 也會從「能抽取一段文字」,正式走向:

能產生可以驗證、處理與保存的 Memory Data。


上一篇
Day 11|什麼事情值得 AI 記住?從聊天紀錄到 Memory
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言