昨天我們替 Memora 加入了 Sliding Window,只把最近幾輪 Message 放進 Context。
這讓每次 Request 不再隨著完整 Conversation History 無限制增長,但也帶來一個新的問題:離開 Window 的舊訊息即使還保存在 Application 裡,模型在目前這一輪仍然看不到。
例如:
User:
我下個月要考 TOEIC。
如果這句話已經離開 Sliding Window,之後再問:
User:
我下個月要考什麼?
模型就可能無法回答。但把所有舊 Message 重新放回 Context,又會回到 Day 7 遇到的 Token 問題。
所以今天要換一種做法:
最近的對話保留原文,較舊的對話則壓縮成一份摘要。
這就是 Conversation Summarization。
Day 8 的 Context 只有:
Recent Messages
+
Current User Message
今天會加入第三個部分:
Conversation Summary
+
Recent Messages
+
Current User Message
假設原本有五輪對話:
Turn 1
Turn 2
Turn 3
Turn 4
Turn 5
當 Sliding Window 只保留最近三輪時,原本會變成:
Turn 3
Turn 4
Turn 5
今天則改成:
Summary of Turn 1–2
+
Turn 3
+
Turn 4
+
Turn 5
舊對話不再以完整原文占用 Context,而是被濃縮成一段較短的文字。
因此,新的 Context 架構是:
完整 Conversation History
↓
┌───────────────────────┐
│ Older Messages │
│ ↓ │
│ Conversation Summary │
├───────────────────────┤
│ Recent Messages │
├───────────────────────┤
│ Current User Message │
└───────────┬───────────┘
↓
LLM
Conversation Summary 和 Recent Messages 都會進入 Context,但扮演的角色並不相同。Summary 負責保存較早以前的整體資訊,例如:
使用者的英文程度是 B1
使用者下個月要考 TOEIC
這次對話已經複習過現在完成式
使用者希望例句不要太難
Recent Messages 則保留最近幾輪的完整原文,例如:
User:
請給我三個和旅遊有關的單字。
Assistant:
travel、journey、abroad。
User:
第二個和第一個有什麼不同?
最近的 Message 不適合立刻壓縮,因為其中可能包含:
第二個
剛才那句
請繼續
換一個例子
這些內容高度依賴上一輪的精確文字。
所以我們會使用兩種不同的表示方式:
較舊內容
→ 壓縮成 Summary
較新內容
→ 保留完整 Message
最直接的做法可能是每次都把完整的 conversation_history 交給模型,請它重新產生一份摘要。
概念上像是:
summary = summarize(conversation_history)
但這樣會有幾個問題。
第一,History 越來越長,產生摘要的 Request 也會跟著變大,反而沒有真正解決 Token Usage。
第二,模型每次都重新閱讀相同內容,會造成不必要的重複處理。
第三,同一份摘要經過多次重新生成,措辭和細節可能反覆改變。
所以今天採用 Incremental Summarization:
只把剛剛離開 Sliding Window 的 Message 加入既有摘要。
假設目前的 Summary 已經包含:
Turn 1
下一輪只有 Turn 2 離開 Window,那麼摘要模型收到的內容會是:
Existing Summary:
Turn 1 的摘要
New Messages:
Turn 2 的完整內容
然後產生新的 Summary:
Turn 1–2 的摘要
這樣就不需要每次重新處理完整 Conversation History。
為了避免同一段訊息被重複摘要,我們新增兩個變數:
conversation_summary = ""
summarized_message_count = 0
conversation_summary 保存目前的摘要文字。
summarized_message_count 則記錄:
Conversation History 最前面有幾則 Message 已經被放進 Summary。
假設:
User 1
Assistant 1
User 2
Assistant 2
User 3
Assistant 3
而前兩輪已經被摘要,那麼:
summarized_message_count = 4
代表前四則 Message:
User 1
Assistant 1
User 2
Assistant 2
都已經包含在 conversation_summary 裡。
下一次只需要從第五則 Message 之後繼續處理,不必重新摘要前四則。
Conversation History 的資料格式是:
{
"role": "user",
"content": "我的英文程度是 B1。"
}
在送進摘要模型之前,先把它轉成容易閱讀的形式:
[USER]
我的英文程度是 B1。
[ASSISTANT]
了解,我會以 B1 程度協助你。
建立一個 Function:
def format_messages(messages):
formatted_messages = []
for message in messages:
formatted_messages.append(
f"[{message['role'].upper()}]\n"
f"{message['content']}"
)
return "\n\n".join(formatted_messages)
這個 Function 不會改變原本的 conversation_history,只是產生一份給摘要模型閱讀的文字。
接著新增一組專門用來產生摘要的 Instructions:
SUMMARY_INSTRUCTIONS = """
You maintain a compact summary of an ongoing conversation.
Requirements:
- Merge the existing summary with the new messages.
- Preserve user facts, preferences, goals, corrections,
decisions, and unresolved questions.
- Remove greetings, repetition, and unimportant wording.
- Do not invent information.
- If newer information corrects older information,
keep the newer information.
- Keep the summary under 150 words.
- Use the same language as the conversation when possible.
- Return only the updated summary.
"""
這組 Instructions 和 Memora 原本的 SYSTEM_PROMPT 不同。
SYSTEM_PROMPT 用來控制 English Learning Assistant 如何回答使用者;SUMMARY_INSTRUCTIONS 則只負責維護摘要。
更新摘要的 Function 如下:
def update_summary(current_summary, new_messages):
summary_input = f"""
Existing Summary:
{current_summary or "(empty)"}
New Messages:
{format_messages(new_messages)}
"""
response = client.responses.create(
model=MODEL,
instructions=SUMMARY_INSTRUCTIONS,
input=summary_input
)
return response.output_text, response.usage
這裡會把:
既有 Summary
+
剛離開 Window 的 Message
一起交給模型,再回傳更新後的摘要。
這次摘要呼叫是 Application 內部的維護工作,不是使用者和 Memora 的正式對話,因此不會把摘要 Request 或模型產生摘要時的回答加入 conversation_history。
有了 Summary 之後,需要把它放回回答模型真正會看到的 Context。建立一個 Function:
def create_context_messages(
summary,
recent_messages,
current_user_message
):
context_messages = []
if summary:
context_messages.append(
{
"role": "developer",
"content": (
"The following is a compact summary "
"of earlier conversation. Use it as "
"background context. If it conflicts "
"with recent messages, prefer the "
"recent messages.\n\n"
f"{summary}"
)
}
)
context_messages.extend(recent_messages)
context_messages.append(current_user_message)
return context_messages
Summary 不是使用者剛剛說的話,也不是 Assistant 先前的原始回答,而是 Application 整理出的背景資訊,所以這裡使用 developer role 把它放進 Context。
建立完成後,context_messages 可能長這樣:
[
{
"role": "developer",
"content": "Earlier conversation summary: ..."
},
{
"role": "user",
"content": "請給我三個旅遊相關單字。"
},
{
"role": "assistant",
"content": "travel、journey、abroad。"
},
{
"role": "user",
"content": "第二個和第一個有什麼不同?"
}
]
模型可以透過 Summary 取得早期資訊,同時保留最近對話的完整結構。
現在直接從 Day 8 的程式繼續修改。
昨天已經使用 Input Token Counting 檢查送出前的 Context 大小;Day 9 會保留這項功能。相關 API 格式可以參考 OpenAI 官方 Token Counting 文件。
完整程式如下:
from openai import OpenAI
client = OpenAI()
MODEL = "gpt-5-mini"
SYSTEM_PROMPT = """
You are Memora, a personal English learning assistant.
Your goal is to help the user learn English clearly and efficiently.
Guidelines:
- Explain concepts in simple language.
- Keep answers focused on the user's question.
- Use short examples when helpful.
- Avoid unnecessary technical grammar terminology.
"""
SUMMARY_INSTRUCTIONS = """
You maintain a compact summary of an ongoing conversation.
Requirements:
- Merge the existing summary with the new messages.
- Preserve user facts, preferences, goals, corrections,
decisions, and unresolved questions.
- Remove greetings, repetition, and unimportant wording.
- Do not invent information.
- If newer information corrects older information,
keep the newer information.
- Keep the summary under 150 words.
- Use the same language as the conversation when possible.
- Return only the updated summary.
"""
MAX_RECENT_TURNS = 3
MAX_INPUT_TOKENS = 1000
print("Memora v0.6")
print("輸入 exit 可以結束對話。")
print("輸入 history 可以查看完整對話紀錄。")
print("輸入 context 可以查看上一次送出的 Context。")
print("輸入 summary 可以查看目前的 Conversation Summary。")
conversation_history = []
conversation_summary = ""
summarized_message_count = 0
last_context_messages = []
session_total_tokens = 0
def format_messages(messages):
formatted_messages = []
for message in messages:
formatted_messages.append(
f"[{message['role'].upper()}]\n"
f"{message['content']}"
)
return "\n\n".join(formatted_messages)
def count_input_tokens(messages):
result = client.responses.input_tokens.count(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=messages
)
return result.input_tokens
def update_summary(current_summary, new_messages):
summary_input = f"""
Existing Summary:
{current_summary or "(empty)"}
New Messages:
{format_messages(new_messages)}
"""
response = client.responses.create(
model=MODEL,
instructions=SUMMARY_INSTRUCTIONS,
input=summary_input
)
return response.output_text, response.usage
def create_context_messages(
summary,
recent_messages,
current_user_message
):
context_messages = []
if summary:
context_messages.append(
{
"role": "developer",
"content": (
"The following is a compact summary "
"of earlier conversation. Use it as "
"background context. If it conflicts "
"with recent messages, prefer the "
"recent messages.\n\n"
f"{summary}"
)
}
)
context_messages.extend(recent_messages)
context_messages.append(current_user_message)
return context_messages
def prepare_context(
history,
current_summary,
summarized_count
):
completed_messages = history[:-1]
current_user_message = history[-1]
summary_token_usage = 0
newly_summarized_count = 0
target_summarized_count = max(
0,
len(completed_messages)
- MAX_RECENT_TURNS * 2
)
if target_summarized_count > summarized_count:
new_messages = completed_messages[
summarized_count:
target_summarized_count
]
current_summary, usage = update_summary(
current_summary,
new_messages
)
newly_summarized_count += len(new_messages)
summary_token_usage += usage.total_tokens
summarized_count = target_summarized_count
recent_messages = completed_messages[
summarized_count:
]
while True:
context_messages = create_context_messages(
current_summary,
recent_messages,
current_user_message
)
input_tokens = count_input_tokens(
context_messages
)
if input_tokens <= MAX_INPUT_TOKENS:
return (
current_summary,
summarized_count,
context_messages,
input_tokens,
summary_token_usage,
newly_summarized_count
)
if len(recent_messages) < 2:
raise ValueError(
"Summary 和目前的 User Message "
"已超過 Input Token Budget。"
)
oldest_turn = recent_messages[:2]
current_summary, usage = update_summary(
current_summary,
oldest_turn
)
recent_messages = recent_messages[2:]
summarized_count += 2
newly_summarized_count += 2
summary_token_usage += usage.total_tokens
while True:
user_input = input("\nYou: ")
if user_input.lower() == "exit":
print("Bye!")
break
if user_input.lower() == "history":
print("\n--- Full Conversation History ---")
for message in conversation_history:
print(
f"{message['role']}: "
f"{message['content']}"
)
print("---------------------------------")
continue
if user_input.lower() == "context":
print("\n--- Last Request Context ---")
if not last_context_messages:
print("目前還沒有送出任何 Request。")
else:
for message in last_context_messages:
print(
f"{message['role']}: "
f"{message['content']}"
)
print("----------------------------")
continue
if user_input.lower() == "summary":
print("\n--- Conversation Summary ---")
if conversation_summary:
print(conversation_summary)
else:
print("目前還沒有產生 Summary。")
print("----------------------------")
continue
conversation_history.append(
{
"role": "user",
"content": user_input
}
)
try:
(
conversation_summary,
summarized_message_count,
context_messages,
counted_input_tokens,
summary_token_usage,
newly_summarized_count
) = prepare_context(
conversation_history,
conversation_summary,
summarized_message_count
)
except ValueError as error:
print("Error:", error)
conversation_history.pop()
continue
last_context_messages = context_messages.copy()
session_total_tokens += summary_token_usage
response = client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=context_messages
)
assistant_reply = response.output_text
conversation_history.append(
{
"role": "assistant",
"content": assistant_reply
}
)
usage = response.usage
session_total_tokens += usage.total_tokens
print("Memora:", assistant_reply)
print("\n--- Context Management ---")
print(
"Stored history messages:",
len(conversation_history)
)
print(
"Summarized history messages:",
summarized_message_count
)
print(
"Newly summarized messages:",
newly_summarized_count
)
print(
"Context messages sent:",
len(context_messages)
)
print(
"Counted input tokens:",
counted_input_tokens
)
print(
"Actual input tokens:",
usage.input_tokens
)
print(
"Output tokens:",
usage.output_tokens
)
print(
"Summary update tokens:",
summary_token_usage
)
print(
"Session total tokens:",
session_total_tokens
)
print("--------------------------")
假設:
MAX_RECENT_TURNS = 3
前三輪完成後,所有內容都還能保留原文:
Turn 1
Turn 2
Turn 3
當 Turn 4 的 User Message 進來時,Context 仍然可以放入:
Turn 1
Turn 2
Turn 3
Current User Message
等 Turn 4 完成,接著進入 Turn 5,最近三輪會變成:
Turn 2
Turn 3
Turn 4
這時 Turn 1 正式離開 Sliding Window,因此 prepare_context() 會把 Turn 1 送去更新 Summary。
最後建立出的 Context 會是:
Summary of Turn 1
+
Turn 2
+
Turn 3
+
Turn 4
+
Current User Message
下一輪 Turn 2 離開 Window 時,不會重新摘要 Turn 1,而是執行:
Existing Summary:
Turn 1 的摘要
New Messages:
Turn 2
再更新成:
Turn 1–2 的摘要
這就是 summarized_message_count 存在的原因。
可以再次進行 Day 8 的測試。
第一輪:
You:
我下個月要考 TOEIC。
接著進行幾輪其他對話,讓第一輪離開最近三輪的 Sliding Window。
例如:
You:
今天想複習現在完成式。
You:
請給我三個 B1 程度的單字。
You:
請解釋 journey 和 travel 的差別。
現在輸入:
summary
可能會看到:
使用者的英文程度適合 B1 學習內容,
並提到下個月要參加 TOEIC 考試。
這次對話已經複習現在完成式。
接著再問:
You:
我下個月要考什麼?
即使最早的原始 Message 已經不在 Recent Messages 裡,模型仍然可以透過 Summary 看到:
下個月要參加 TOEIC
這一次它不需要重新閱讀整段舊對話,也能回答先前提過的資訊。
Summarization 可以降低 Token Usage,但它並不是沒有代價。
原始對話可能是:
User:
我下個月 18 號要考 TOEIC,
目標是 850 分,最近聽力比較弱,
但閱讀速度也有點慢。
摘要後可能只剩:
使用者下個月要考 TOEIC,
目標為 850 分,主要想加強聽力。
原本的:
18 號
閱讀速度也有點慢
可能沒有被保留下來。
所以 Summary 不是原文的完美替代品,而是一種:
Lossy Compression,有損壓縮。
它用較少的 Token 保留主要語意,但細節可能被省略;如果摘要模型理解錯誤,錯誤內容還可能繼續被帶進後面的 Context。
因此,Conversation Summary 適合維持對話的大致連續性,卻不適合被當成完全可靠的原始紀錄。
這也是為什麼我們沒有刪除:
conversation_history
原始 History 仍然保留完整內容,Summary 只負責建立較短的 Active Context。
今天的程式有兩種不同的模型呼叫。
第一種是使用者看得到的回答:
client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=context_messages
)
第二種則是 Application 在背景更新摘要:
client.responses.create(
model=MODEL,
instructions=SUMMARY_INSTRUCTIONS,
input=summary_input
)
因此,只要有舊 Message 需要被摘要,就會多一次 API 呼叫與 Token Usage。
程式裡特別加入:
summary_token_usage
記錄摘要更新使用的 Token,並一起加進:
session_total_tokens
所以 Summarization 並不是免費把文字變短,而是:
先支付一次摘要成本
↓
換取後續 Request 較短的 Context
對話很短時,可能根本不需要摘要;但在長對話中,一份固定維持精簡的 Summary,可以避免每一次都重新傳送大量舊 Message。
現在 Memora 已經可以把較舊的對話整理成筆記,但它仍然屬於 Chapter 2 的 Short-term Memory。
原因很簡單:
conversation_summary = ""
仍然只存在目前的 Python Process 裡。
程式重新啟動後,Summary 和 Conversation History 都會消失。
而且目前的 Summary 是整段 Conversation 的壓縮版本,它還沒有處理:
哪些資訊值得跨 Conversation 保存
如何把不同 Memory 分開儲存
如何搜尋需要的 Memory
如何更新或刪除錯誤的 Memory
這些會從 Day 11 才正式開始。
目前的目標只是讓同一次 Conversation 在變長之後,仍然能以較少的 Token 維持前後關係。
今天我們在 Sliding Window 之上加入 Conversation Summarization。
Memora 的 Context 從:
Recent Messages
+
Current User Message
變成:
Conversation Summary
+
Recent Messages
+
Current User Message
完整架構則是:
conversation_history
↓
┌─────────────────────────┐
│ 已離開 Window 的內容 │
│ ↓ │
│ conversation_summary │
├─────────────────────────┤
│ 最近幾輪完整 Message │
├─────────────────────────┤
│ Current User Message │
└────────────┬────────────┘
↓
LLM
我們也加入:
summarized_message_count
記錄哪些 Message 已經進入 Summary,避免每一輪都重新摘要完整 History。
目前的 Context Management 策略是:
舊內容
→ Incremental Summary
近期內容
→ 保留完整 Message
目前問題
→ 保證進入 Context
但 Summary 是有損壓縮,可能省略細節,也會增加一次額外的模型呼叫。
所以現在的 Memora 已經有了幾個不同元件:
Conversation History
Sliding Window
Token Budget
Conversation Summary
Recent Messages
下一步,就是把這些目前分散的功能整理成一個完整的 Short-term Memory System。
下一篇我們不會再加入另一種新的 Memory了,而是整理 Chapter 2 到目前為止完成的功能。
我們會把:
History Storage
Context Selection
Sliding Window
Token Counting
Conversation Summary
整合成清楚的 Short-term Memory 架構,正式完成 Memora 的 Stateful Chatbot。
今天 Memora 學會替過去做筆記。明天我們要把這本筆記真正整理成一套可以持續運作的記憶系統。