昨天我們第一次把 Conversation History 和 Memory Candidate 分開。目前使用者必須明確輸入:
remember 我的英文程度是 B1。
程式才會把這項資訊加入:
memory_candidates
但真正使用 Chatbot 時,使用者通常不會特別下達 remember 指令,而是自然地說:
我的英文程度大概是 B1,最近想加強旅遊英文。
Memora必須自己從這句話中找出:
使用者的英文程度是 B1。
使用者想加強旅遊英文。
今天要替 Memora 加入一個新的 LLM Request,專門負責:
從目前的 User Message 中抽取可能值得長期保存的資訊。
假設使用者輸入:
我的英文程度是 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 內部流程,不會直接當成聊天回答顯示。
我們要給抽取用的 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: 使用者偏好透過例句學習。
輸入:
第二個單字是什麼意思?
理想輸出:
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
目前我們只是要求 LLM 遵守一個文字格式:
MEMORY: ...
但 Prompt 寫得再清楚,模型仍有可能回傳:
Here are the memories:
- 使用者的英文程度是 B1。
或者:
記憶:使用者的英文程度是 B1。
這些內容對人類來說看得懂,但目前的程式只辨識:
line.upper().startswith("MEMORY:")
所以可能無法成功解析。這是今天還有的問題。
我們現在使用的是:
要求模型按照文字格式回答,再由 Python 自己解析。
明天才會正式使用 Structured Output,讓輸出符合我們定義的資料結構。Structured Output 可以提高格式穩定性,但要先注意:
格式正確,不代表抽取內容一定正確。
即使模型成功輸出合法資料,它仍可能漏掉資訊或做出錯誤判斷。資料格式與內容品質是兩個不同問題。
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。
remember今天加入自動抽取之後,不需要刪除昨天的:
remember <text>
我們可以把它保留下來,當成使用者明確指定 Memory 的方式。但是要避免同一項資訊被存兩次。
例如:
remember 我的英文程度是 B1。
Day 11 的程式已經會手動加入:
memory_candidates
如果後面又執行自動抽取,就可能再加入一次:
使用者的英文程度是 B1。
因此每一輪新增:
should_auto_extract = True
當使用者使用 remember 時,改成:
should_auto_extract = False
這一輪就不再執行自動抽取。真正的 Deduplication 會留到後續(也許是Day24)再處理;今天先避免最明顯的重複來源。
Day 10 的 ShortTermMemory 與 Context Management 不需要重寫。
完成以下三項修改即可:
MEMORY_EXTRACTION_INSTRUCTIONS
extract_memory_candidates()
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 接住
目前每個 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 無法回應。
現在 Memora 已經可以自動找出 Memory Candidate,但仍然很初步。
輸入:
我準備三個月後考 TOEIC,希望至少拿到 850 分。
模型可能只抽取:
使用者的 TOEIC 目標是 850 分。
卻漏掉三個月後要考試。
輸入:
我今天很累,先練習五分鐘就好。
Extractor 可能把:
使用者每天只想練習五分鐘。
當成長期偏好,但這其實只是今天的狀態。
我們要求:
MEMORY: ...
模型仍可能使用其他格式,導致 Python 解析失敗。
不同 Conversation 裡,使用者可能多次說:
我的英文程度是 B1。
目前程式可能把相同資訊存入多次。
這個問題會在 Day 24 的 Deduplication 與 Update 再處理。
目前:
memory_candidates = []
仍然只存在 Python Process 裡。重新啟動程式後,內容依然會消失。真正的持久化要等到 Day17 的 Long-term Memory Store。
昨天的 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()
自行解析。只要模型多加一段說明、改用不同符號,程式就可能失敗。
下一篇,我們要把目前不穩定的文字輸出:
MEMORY: 使用者的英文程度是 B1。
改成具有固定欄位的資料,例如:
{
"should_remember": true,
"memories": [
{
"content": "使用者的英文程度是 B1。"
}
]
}
到時候 Python 不需要再猜測:
模型有沒有遵守 MEMORY: 格式?
而是能直接取得具有明確結構的資料。
Memora 也會從「能抽取一段文字」,正式走向:
能產生可以驗證、處理與保存的 Memory Data。