iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

昨天我們替 Memora 加入了 Token Usage 的觀察功能,也發現 Conversation History 不能永遠全部放進 Context。

目前的程式每一輪都會執行:

input=conversation_history

這代表無論過去的訊息是否仍然和現在有關,都會被重新送給模型。

今天要做的事情,就是第一次真正管理 Context:

Conversation History可以繼續保存,但不代表每一次都要全部送給模型。

我們會從最簡單的 Sliding Window 開始,只選擇最近幾輪對話放進目前的 Context。


一、保存 History 和建立 Context 是兩件事

Day 6 建立的:

conversation_history = []

同時負責兩件事情:

保存完整對話
把完整對話送給模型

只要 History 還很短,這樣做沒有太大問題。但當對話越來越長,就應該把這兩個責任分開。

今天開始,我們會有兩組不同的資料:

conversation_history = 這次程式執行中的完整對話紀錄

以及:

context_messages = 這一次真正要送給模型的 Message

架構會從:

Conversation History
        ↓
全部送給 LLM

改成:

Conversation History
        ↓
Context Selection
        ↓
Context Messages
        ↓
LLM

這裡的 conversation_history 可以繼續保留完整資料,但模型每次只會看見經過選擇的 context_messages

這就是最基本的Context Management。


二、Sliding Window 是什麼?

Sliding Window 的概念很簡單:

只保留最近一段範圍內的內容,當新的內容進來時,整個範圍就向前移動。

假設我們規定模型只能看到最近三輪已完成的對話。一開始的 History 是:

Turn 1
User 1
Assistant 1

Turn 2
User 2
Assistant 2

Turn 3
User 3
Assistant 3

這些內容都還在 Window 裡。

當 Turn 4 出現時,模型看到的會是:

User 1
Assistant 1
User 2
Assistant 2
User 3
Assistant 3
User 4

這裡包含最近三輪完成的對話,再加上目前的 User Message。

等 Turn 4 完成,接著進入 Turn 5,Window 就會向前移動:

User 2
Assistant 2
User 3
Assistant 3
User 4
Assistant 4
User 5

最舊的:

User 1
Assistant 1

仍然可以保存在完整的 conversation_history 裡,但不再出現在這一次送給模型的 Context 中。

因此 Sliding Window 的「滑動」指的是:

舊內容離開目前的 Context
新內容進入目前的 Context

而不一定代表舊資料已經從 Application 裡被刪除。


三、為什麼 Window 最好以「Turn」為單位?

最直接的寫法可能是:

context_messages = conversation_history[-6:]

也就是只取最後六則 Message。

但這裡有一個容易忽略的問題。

當使用者輸入新的訊息後,Conversation History 可能是:

User 1
Assistant 1
User 2
Assistant 2
User 3
Assistant 3
User 4

這時一共有七則 Message。

如果直接取得最後六則:

conversation_history[-6:]

結果會變成:

Assistant 1
User 2
Assistant 2
User 3
Assistant 3
User 4

Context 是從 Assistant 1 開始的,但它對應的 User 1 已經不在裡面。

模型會看到一個沒有問題、卻突然出現的回答。雖然 API 可能仍然接受這組 Message,但 Conversation 的語意結構已經不完整。

所以今天不直接按照 Message 數量亂切,而是以一組完整的:

User Message
Assistant Message

作為一個 Turn。

這樣 Window 在移除舊內容時,會一次移除完整的一輪,不會只留下其中一半。


四、先建立一個以 Turn 為單位的 Window

先設定要保留幾輪已完成的對話:

MAX_RECENT_TURNS = 3

接著建立一個 Function:

def build_turn_window(history):
    current_user_message = history[-1]
    completed_messages = history[:-1]

    recent_messages = completed_messages[
        -MAX_RECENT_TURNS * 2:
    ]

    return recent_messages + [current_user_message]

呼叫這個 Function 時,新的 User Message 已經被加進 conversation_history,所以:

history[-1]

一定是目前這一輪的 User Message。

剩下的:

history[:-1]

則是先前已經完成的 User 與 Assistant Message。

因為每一輪包含兩則 Message,所以最近三輪就是:

MAX_RECENT_TURNS * 2

最後再把目前的 User Message 加回來:

recent_messages + [current_user_message]

這樣建立出來的 Window 就會是:

最近三輪完成的對話
+
目前的 User Message

而且永遠不會從一個失去問題的 Assistant Message 開始。


五、只限制 Turn 數量還不夠

以最近三輪為範圍,已經可以避免 History 無限增長,但它仍然不是精確的 Token 控制。

因為三輪對話可能非常短:

User:
Hello.

Assistant:
Hi!

也可能每一輪都有數千字。

所以:

3 Turns

不代表固定數量的 Token。

比較完整的做法應該同時考慮兩個限制:

最多保留幾輪
+
最多允許多少 Input Tokens

今天先設定一個刻意偏小的 Input Token Budget:

MAX_INPUT_TOKENS = 1000

這不是 gpt-5-mini 真正的 Context Window 上限,而是我們替 Memora 設定的 Application Budget。

刻意把數字設小,是為了方便觀察 Sliding Window。如果直接使用模型完整的 Context 上限,可能需要輸入非常大量的文字,才看得出 Window 發生變化。

實際產品中的 Budget 通常還需要考慮:

System Prompt
預計保留的 Output 空間
成本
延遲
應用情境

所以不應該直接把模型的完整 Context Window 全部分配給 Input。


六、在送出 Request 前先計算 Token

Day 7使用:

response.usage.input_tokens

查看 Request 完成後實際用了多少 Input Tokens。

但如果希望在送出 Request 之前控制 Context,就需要先知道目前的 context_messages 有多大。

OpenAI Responses API 提供 Input Token Counting:

client.responses.input_tokens.count(
    model=MODEL,
    instructions=SYSTEM_PROMPT,
    input=messages
)

它可以接受與 responses.create() 相同的 Input 結構,並回傳模型實際會收到的 Input Token 數量。相關格式可以參考 OpenAI 官方 Token Counting 文件

先建立一個 Function:

def count_input_tokens(messages):
    result = client.responses.input_tokens.count(
        model=MODEL,
        instructions=SYSTEM_PROMPT,
        input=messages
    )

    return result.input_tokens

接著把剛才的 Turn-based Window 再加上一層 Token 檢查:

def build_context_window(history):
    current_user_message = history[-1]
    completed_messages = history[:-1]

    recent_messages = completed_messages[
        -MAX_RECENT_TURNS * 2:
    ]

    context_messages = recent_messages + [
        current_user_message
    ]

    while True:
        input_tokens = count_input_tokens(
            context_messages
        )

        if input_tokens <= MAX_INPUT_TOKENS:
            return context_messages, input_tokens

        if len(context_messages) == 1:
            raise ValueError(
                "目前的 User Message 本身已超過 Input Token Budget。"
            )

        context_messages = context_messages[2:]

一開始,Function 會先取得最近三輪對話。

如果沒有超過 MAX_INPUT_TOKENS,就直接回傳:

return context_messages, input_tokens

如果超過 Budget,就移除最舊的一整輪:

context_messages = context_messages[2:]

因為最前面的兩則 Message 是一組完整的:

User
Assistant

所以每次移除兩則,可以維持 Conversation 的 Turn 結構。接著重新計算Token,直到符合 Budget 為止。


七、完整的 Memora v0.5

現在把這個 Sliding Window 接進昨天的程式。

除了原本的 history 指令,我再新增一個:

context

用來查看上一個 Request 真正送給模型的 Message。

完整程式如下:

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.
"""

MAX_RECENT_TURNS = 3
MAX_INPUT_TOKENS = 1000

print("Memora v0.5")
print("輸入 exit 可以結束對話。")
print("輸入 history 可以查看完整對話紀錄。")
print("輸入 context 可以查看上一次送出的 Context。")

conversation_history = []
last_context_messages = []
session_total_tokens = 0


def count_input_tokens(messages):
    result = client.responses.input_tokens.count(
        model=MODEL,
        instructions=SYSTEM_PROMPT,
        input=messages
    )

    return result.input_tokens


def build_context_window(history):
    current_user_message = history[-1]
    completed_messages = history[:-1]

    recent_messages = completed_messages[
        -MAX_RECENT_TURNS * 2:
    ]

    context_messages = recent_messages + [
        current_user_message
    ]

    while True:
        input_tokens = count_input_tokens(
            context_messages
        )

        if input_tokens <= MAX_INPUT_TOKENS:
            return context_messages, input_tokens

        if len(context_messages) == 1:
            raise ValueError(
                "目前的 User Message 本身已超過 Input Token Budget。"
            )

        context_messages = context_messages[2:]


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

    conversation_history.append(
        {
            "role": "user",
            "content": user_input
        }
    )

    try:
        context_messages, counted_input_tokens = (
            build_context_window(
                conversation_history
            )
        )
    except ValueError as error:
        print("Error:", error)
        conversation_history.pop()
        continue

    last_context_messages = context_messages.copy()

    skipped_message_count = (
        len(conversation_history)
        - len(context_messages)
    )

    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(
        "Context messages sent:",
        len(context_messages)
    )
    print(
        "Older messages skipped:",
        skipped_message_count
    )
    print(
        "Counted input tokens:",
        counted_input_tokens
    )
    print(
        "Actual input tokens:",
        usage.input_tokens
    )
    print(
        "Output tokens:",
        usage.output_tokens
    )
    print(
        "Session total tokens:",
        session_total_tokens
    )
    print("--------------------------")

這次最重要的改變,是 API 的 input 不再使用完整 History:

input=conversation_history

而是改成:

input=context_messages

現在資料流變成:

conversation_history
        ↓
build_context_window()
        ↓
context_messages
        ↓
Responses API

conversation_history 仍然保存完整對話,而 context_messages 只負責這一次模型真正需要看到的內容。


八、實際觀察 Window 如何滑動

現在可以依序進行幾輪對話。

第一輪:

You:
我下個月要考TOEIC。

第二輪:

You:
今天想複習現在完成式。

第三輪:

You:
請給我三個 B1 程度的單字。

第四輪:

You:
請解釋 journey 和 travel 的差別。

這時第四輪送出的 Context 仍然包含:

我下個月要考TOEIC。

因為它還位於最近三輪已完成的對話中。

接著進入第五輪:

You:
我下個月要考什麼考試?

此時 Window 已經向前移動。這次送給模型的內容大致是:

User:
今天想複習現在完成式。

Assistant:
...

User:
請給我三個 B1 程度的單字。

Assistant:
...

User:
請解釋 journey 和 travel 的差別。

Assistant:
...

User:
我下個月要考什麼考試?

最早的:

User:
我下個月要考TOEIC。

以及對應的 Assistant Message,已經離開目前的 Context。

可以輸入:

context

直接檢查上一個 Request 真正送出了哪些 Message。

再輸入:

history

則會發現完整的 History 裡仍然保留:

user: 我下個月要考 TOEIC。

因此這時候會出現一個很重要的差別:

Application 還保存這段資料

不代表:

模型在這次 Request 中看得到這段資料

資料是否存在,和資料是否進入目前 Context,是兩件不同的事情。


九、被移出 Window 就等於被忘記了嗎?

從 Python Application 的角度來看,沒有。

因為完整的內容仍然存在:

conversation_history

但從目前這一次模型呼叫的角度來看,離開 Window 的 Message 確實不再能影響回答。

目前架構是:

完整 Conversation History
├── 最近的 Message
│       ↓
│   進入 Context
│       ↓
│   模型看得見
│
└── 較舊的 Message
        ↓
    暫時不送出
        ↓
    模型看不見

這也是為什麼判斷 AI 是否「記得」一件事情,不能只看資料有沒有存在某個 List 或 Database 裡。

它還必須在正確的時間被放回 Context,才能真正影響模型的輸出。

目前 Sliding Window 使用的選擇規則非常單純:

越新的 Message 越優先。

也就是只依靠 Recency 決定哪些內容進入 Context。


十、Sliding Window 解決了什麼?

加入 Sliding Window 後,每次 Request 不再隨著完整 History 無限制增長。

原本是:

第 1 輪:傳送全部 History
第 10 輪:傳送全部 History
第 100 輪:仍然傳送全部 History

現在則是:

完整 History 繼續增長
        ↓
只選最近幾輪
        ↓
檢查 Input Token Budget
        ↓
必要時再移除最舊的一輪
        ↓
送給模型

因此 Sliding Window 可以幫助我們:

限制每次 Request 的 History 範圍
控制 Input Token 數量
避免無限累積的舊訊息全部進入 Context
保留最近對話的連續性

這是最簡單、也最容易實作的 Context Management 方法。


十一、Sliding Window 的代價

Sliding Window 的優點是簡單,但它的缺點也很直接:

它只知道一段訊息夠不夠新,不知道這段訊息重不重要。

例如:

Turn 1:
我的英文程度是 B1。

接下來可能只是幾輪普通的單字問答。

當 Window 向前移動後:

英文程度是 B1

這個對後續學習很有用的資訊,可能和一般閒聊一樣被移出 Context。

Sliding Window 不會判斷:

這是使用者的重要設定

也不會判斷:

這只是暫時性的對話

它只會按照時間順序移動。

所以它解決的是:

Context 放不下所有 Message

卻同時產生另一個問題:

舊 Message 離開 Context 後,
其中的重要資訊也一起消失了。

十二、Context Management 不只是刪除訊息

到這裡,我們可以對 Context Management 有一個更完整的理解。

它不只是:

conversation_history[-6:]

而是一個選擇過程:

完整資料有哪些?
        ↓
這次回答需要哪些?
        ↓
哪些內容可以放進 Token Budget?
        ↓
最後送給模型哪些 Message?

今天使用的策略是:

Recency
+
Turn Boundary
+
Input Token Budget

也就是優先保留最近的完整對話,並確保 Input 不超過我們設定的範圍。

後面的 Memory Retrieval、Importance Score 和 Memory Policy,實際上都會繼續回答類似的問題:

在有限的 Context 裡,現在最值得放進去的是什麼?

不過我在Chapter 2想要先解決的問題還只是舊的 Conversation 離開 Sliding Window 後,有沒有辦法不要完全失去它提供的資訊勒?,所以目前還不用跳到那麼遠。


Day 8 小結

今天我們第一次替 Memora 加入真正的 Context Management。

原本的架構是:

conversation_history
        ↓
全部送給模型

現在則變成:

conversation_history
        ↓
Sliding Window
        ↓
Token Budget Check
        ↓
context_messages
        ↓
LLM

我們沒有刪除完整的 Conversation History,而是把:

保存過什麼

和:

這次要看什麼

分成兩個不同的責任。

目前的 Sliding Window 會:

保留最近三輪完整對話
加入目前的 User Message
檢查 Input Token Budget
超過時移除最舊的一整輪

這讓 Memora 不再把所有 History 無限制塞進 Context,也避免從孤立的 Assistant Message 開始。

但 Sliding Window 仍然有一個明顯限制:

舊對話一旦離開 Window,模型就看不到其中的重要資訊。

如果直接把所有舊內容放回來,Context 又會再次變大;如果完全不放,先前的資訊就會消失。

那能不能不要保留每一句原文,而是把舊對話整理成一份比較短的筆記?

這就是明天要討論的問題囉~

Day 09|AI 也要做筆記:用 Summarization 壓縮對話

下一篇我們來讓 Memora 把離開 Sliding Window 的舊對話整理成 Conversation Summary。

新的 Context 將不再只有:

Recent Messages

而是:

Conversation Summary
+
Recent Messages
+
Current User Message

今天我們讓 Memora 學會在有限的空間裡只看最近的對話。明天它要開始學著替過去做筆記囉!!


上一篇
Day 07|Context Window 為什麼會爆?Token Limit 的真相
下一篇
Day 09|AI 也要做筆記:用 Summarization 壓縮對話
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言