iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

昨天我們替 Memora 加入了 Sliding Window,只把最近幾輪 Message 放進 Context。

這讓每次 Request 不再隨著完整 Conversation History 無限制增長,但也帶來一個新的問題:離開 Window 的舊訊息即使還保存在 Application 裡,模型在目前這一輪仍然看不到。

例如:

User:
我下個月要考 TOEIC。

如果這句話已經離開 Sliding Window,之後再問:

User:
我下個月要考什麼?

模型就可能無法回答。但把所有舊 Message 重新放回 Context,又會回到 Day 7 遇到的 Token 問題。

所以今天要換一種做法:

最近的對話保留原文,較舊的對話則壓縮成一份摘要。

這就是 Conversation Summarization。


一、從「移出 Context」變成「整理成筆記」

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

二、Summary 和 Recent Messages 的功能不同

Conversation Summary 和 Recent Messages 都會進入 Context,但扮演的角色並不相同。Summary 負責保存較早以前的整體資訊,例如:

使用者的英文程度是 B1
使用者下個月要考 TOEIC
這次對話已經複習過現在完成式
使用者希望例句不要太難

Recent Messages 則保留最近幾輪的完整原文,例如:

User:
請給我三個和旅遊有關的單字。

Assistant:
travel、journey、abroad。

User:
第二個和第一個有什麼不同?

最近的 Message 不適合立刻壓縮,因為其中可能包含:

第二個
剛才那句
請繼續
換一個例子

這些內容高度依賴上一輪的精確文字。

所以我們會使用兩種不同的表示方式:

較舊內容
→ 壓縮成 Summary

較新內容
→ 保留完整 Message

三、不要每一輪都重新摘要全部 History

最直接的做法可能是每次都把完整的 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 之後繼續處理,不必重新摘要前四則。


五、先把 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,只是產生一份給摘要模型閱讀的文字。


六、讓 LLM 更新 Conversation Summary

接著新增一組專門用來產生摘要的 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

有了 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 取得早期資訊,同時保留最近對話的完整結構。


八、完整的 Memora v0.6

現在直接從 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("--------------------------")

九、Summary 是在什麼時候更新的?

假設:

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 存在的原因。


十、重新測試離開 Window 的資訊

可以再次進行 Day 8 的測試。

第一輪:

You:
我下個月要考 TOEIC。

接著進行幾輪其他對話,讓第一輪離開最近三輪的 Sliding Window。

例如:

You:
今天想複習現在完成式。
You:
請給我三個 B1 程度的單字。
You:
請解釋 journey 和 travel 的差別。

現在輸入:

summary

可能會看到:

使用者的英文程度適合 B1 學習內容,
並提到下個月要參加 TOEIC 考試。
這次對話已經複習現在完成式。

接著再問:

You:
我下個月要考什麼?

即使最早的原始 Message 已經不在 Recent Messages 裡,模型仍然可以透過 Summary 看到:

下個月要參加 TOEIC

這一次它不需要重新閱讀整段舊對話,也能回答先前提過的資訊。


十一、Summary 是一種 Lossy Compression

Summarization 可以降低 Token Usage,但它並不是沒有代價。

原始對話可能是:

User:
我下個月 18 號要考 TOEIC,
目標是 850 分,最近聽力比較弱,
但閱讀速度也有點慢。

摘要後可能只剩:

使用者下個月要考 TOEIC,
目標為 850 分,主要想加強聽力。

原本的:

18 號
閱讀速度也有點慢

可能沒有被保留下來。

所以 Summary 不是原文的完美替代品,而是一種:

Lossy Compression,有損壓縮。

它用較少的 Token 保留主要語意,但細節可能被省略;如果摘要模型理解錯誤,錯誤內容還可能繼續被帶進後面的 Context。

因此,Conversation Summary 適合維持對話的大致連續性,卻不適合被當成完全可靠的原始紀錄。

這也是為什麼我們沒有刪除:

conversation_history

原始 History 仍然保留完整內容,Summary 只負責建立較短的 Active Context。


十二、Summary 也會產生成本

今天的程式有兩種不同的模型呼叫。

第一種是使用者看得到的回答:

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。


十三、Conversation Summary 還不是 Long-term Memory

現在 Memora 已經可以把較舊的對話整理成筆記,但它仍然屬於 Chapter 2 的 Short-term Memory。

原因很簡單:

conversation_summary = ""

仍然只存在目前的 Python Process 裡。

程式重新啟動後,Summary 和 Conversation History 都會消失。

而且目前的 Summary 是整段 Conversation 的壓縮版本,它還沒有處理:

哪些資訊值得跨 Conversation 保存
如何把不同 Memory 分開儲存
如何搜尋需要的 Memory
如何更新或刪除錯誤的 Memory

這些會從 Day 11 才正式開始。

目前的目標只是讓同一次 Conversation 在變長之後,仍然能以較少的 Token 維持前後關係。


Day 9 小結

今天我們在 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。

Day 10|Short-term Memory 完成:打造 Stateful Chatbot

下一篇我們不會再加入另一種新的 Memory了,而是整理 Chapter 2 到目前為止完成的功能。

我們會把:

History Storage
Context Selection
Sliding Window
Token Counting
Conversation Summary

整合成清楚的 Short-term Memory 架構,正式完成 Memora 的 Stateful Chatbot。

今天 Memora 學會替過去做筆記。明天我們要把這本筆記真正整理成一套可以持續運作的記憶系統。


上一篇
Day 08|Token 太多怎麼辦?Sliding Window 與 Context Management
下一篇
Day 10|Short-term Memory 完成:打造 Stateful Chatbot
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言