模組 03 · 第 1 課

多輪對話:模型其實什麼都不記得

寫一個命令列聊天程式,親眼看到模型沒有記憶,"記憶"只是每次把歷史訊息重新發一遍。再看歷史太長時截斷和摘要兩種辦法各自的效果。

  • 約 35 分鐘
  • 難度:入門
  • 實測:2026-09-14 deepseek-flash

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

用過聊天機器人的人都有一個直覺:它記得我前面說過什麼。你告訴它"我叫小王",過幾句問它"我叫什麼",它能答出來。

這個直覺是錯的,至少對 API 來說是錯的。模型本身什麼都不記得,每一次呼叫都是全新的。聊天機器人之所以"記得",是因為程式每次都把之前的全部對話重新發給了模型。這一課自己寫一個聊天程式,把這件事看清楚。

先看模型不記得

code/03-llm-apps/conversation.py 是一個命令列聊天程式,加上 --stateless 參數時,它每次只發送當前這一句話,不帶歷史。我用管道給它喂兩句話:

printf '%s\n' "我叫小王,最近在用 httpx 写一个爬虫。" "我叫什么名字?" | python conversation.py --stateless
你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,httpx 很适合爬虫。有什么具体问题需要帮忙吗?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你没有告诉我你的名字。  (本轮输入 23 词元)

從模型的角度看,第二次呼叫裡只有一句"我叫什麼名字?",它當然不知道。

記憶就是訊息列表

要讓它"記住",只需要把之前的對話都放進 messages 裡。程式的核心只有這幾行:

SYSTEM = {"role": "system", "content": "你是一个说话简短的编程助手,每次回答不超过三句话。"}
history = []  # 只放 user 和 assistant 消息,system 每次单独加在最前面

while True:
    user_text = input("你:").strip()
    if not user_text:
        break
    messages = [SYSTEM] + history + [{"role": "user", "content": user_text}]
    response = client.chat.completions.create(model=MODEL, messages=messages, extra_body=NO_THINKING)
    answer = response.choices[0].message.content
    print(f"助手:{answer}  (本轮输入 {response.usage.prompt_tokens} 词元)")
    history += [{"role": "user", "content": user_text}, {"role": "assistant", "content": answer}]

每一輪,使用者說的話作為 user 訊息、模型的回答作為 assistant 訊息,追加到 history 裡。下一輪呼叫時,整個 history 連同新問題一起發出去。

我餵給它六句話:

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,httpx 支持同步和异步请求,写爬虫时注意设置 timeout 和重试。有什么具体问题吗?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 68 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 81 词元)
你:推荐一个解析 HTML 的库。
助手:推荐 parsel 或 BeautifulSoup,异步场景可选 selectolax。  (本轮输入 102 词元)
你:它和 httpx 怎么配合用?一句话。
助手:把 httpx 返回的 `response.text` 传给解析库(如 `BeautifulSoup(response.text, "lxml")`)即可。  (本轮输入 132 词元)
你:我叫什么名字?
助手:小王。  (本轮输入 170 词元)

現在它記得名字了,也能理解"它和 httpx 怎麼配合用"裡的"它"指的是上一輪推薦的解析庫。

注意每行末尾的輸入詞元數:32、68、81、102、132、170,一輪比一輪多。因為每一輪都要把之前所有的對話重新發一遍。

歷史會越來越長

這就引出了多輪對話的核心問題:歷史只增不減。

  • 越來越貴。第 6 輪的輸入是第 1 輪的 5 倍多。一個聊了一百輪的對話,每一輪都要為前面九十九輪付費。好在歷史的開頭是固定的,能命中第 01 模組第 4 課講的快取,會便宜很多,但仍然在增長。
  • 越來越慢。輸入越長,模型讀完它的時間越長。
  • 總有一天會超出上下文視窗。到那時請求會直接報錯。

所以任何一個真正上線的聊天程式,都要控制歷史的長度。常用的辦法有兩個。

辦法一:截斷

最簡單的辦法:只保留最近的幾條訊息,更早的直接丟掉。conversation.py--max-messages 4 參數就是這樣:歷史超過 4 條時,只留最近的 2 條。

def trim(history):
    if len(history) <= args.max_messages:
        return history
    keep = history[-(args.max_messages // 2):]
    dropped = history[: len(history) - len(keep)]
    print(f"  [丢掉了最早的 {len(dropped)} 条消息]")
    return keep

還是那六句話:

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,小王。请问在 httpx 爬虫过程中遇到了什么问题?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 55 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 68 词元)
  [丢掉了最早的 4 条消息]
你:推荐一个解析 HTML 的库。
助手:推荐 BeautifulSoup,配合 httpx 用很方便。  (本轮输入 45 词元)
你:它和 httpx 怎么配合用?一句话。
助手:用 `BeautifulSoup(httpx.get(url).text, "html.parser")` 就能解析。  (本轮输入 71 词元)
  [丢掉了最早的 4 条消息]
你:我叫什么名字?
助手:我不知道你的名字,你还没告诉我。  (本轮输入 60 词元)

輸入詞元一直維持在幾十個,不再增長。代價是最後一問,它忘了使用者叫小王,因為那句話早就被丟掉了。

截斷適合"最近幾輪最重要、早先的內容可以忘"的場景,比如閒聊、一次性的簡單問答。實際專案裡通常按詞元數截斷,而不是按條數,這裡為了演示方便用了條數。

有一點要注意:截斷時要按"一問一答"成對地丟,別隻丟掉一個 user 訊息卻留下它對應的 assistant 訊息,那樣模型看到的對話是殘缺的。上面的程式碼每次都保留偶數條,所以沒有這個問題。

辦法二:摘要

更好一點的辦法是,不直接丟掉舊訊息,而是讓模型把它們壓縮成一段摘要:

def summarize(messages):
    text = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": "把下面这段对话压缩成几句话的摘要,保留人名、项目、偏好等以后可能用到的事实:\n\n" + text}],
        extra_body=NO_THINKING,
    )
    return response.choices[0].message.content

截斷時,把丟掉的部分交給 summarize,得到的摘要作為一對訊息放在歷史的最前面:

    summary = summarize(dropped)
    return [{"role": "user", "content": f"(之前对话的摘要:{summary})"},
            {"role": "assistant", "content": "好的,我记住了。"}] + keep

加上 --summarize 參數再跑一遍:

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的小王,httpx 适合爬虫,支持异步和 HTTP/2。有具体问题可以问我。  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 63 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 76 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫;httpx 支持异步和 HTTP/2,适合爬虫。]
你:推荐一个解析 HTML 的库。
助手:推荐 BeautifulSoup 或 lxml,前者易用,后者更快。  (本轮输入 83 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫;httpx 支持异步和 HTTP/2,适合爬虫。]
你:它和 httpx 怎么配合用?一句话。
助手:用 httpx 获取响应后,把 `resp.text` 传给 BeautifulSoup 解析即可。  (本轮输入 95 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫,需要 HTML 解析库;推荐了 BeautifulSoup(易用)和 lxml(更快)。]
你:我叫什么名字?
助手:你叫小王。  (本轮输入 104 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫,需 HTML 解析库;推荐了 BeautifulSoup(易用)和 lxml(更快)。与 httpx 配合时,把 `resp.text` 传给 BeautifulSoup 解析即可。]

最後一問,它記得。摘要裡保留了"小王"、"httpx"、"爬蟲"這些關鍵事實。輸入詞元比不截斷時少(104 對 170),但比直接截斷多,因為摘要本身也要佔位置。

摘要也有代價:

  • 每次壓縮要多呼叫一次模型,要花錢,也會讓那一輪變慢。可以在後臺非同步做,或者等歷史長到一定程度再壓縮,不要每輪都壓。
  • 摘要會丟細節。它只保留模型認為重要的東西。使用者在第 3 輪隨口提到"我的伺服器在香港",摘要裡可能就沒有了。
  • 摘要可能出錯。模型可能把事實概括錯。

仔細看上面的輸出,每一次壓縮都是把"舊摘要 + 新丟棄的訊息"重新壓縮一遍,摘要在不斷滾動更新。

該選哪種

場景 做法
對話一般很短,比如客服問答 不用處理,或者設一個寬鬆的上限直接截斷
聊得很長,但只有最近的內容重要 截斷
聊得很長,早先的資訊後面還會用到 摘要,或者截斷加摘要
需要記住跨越很多次會話的資訊,比如使用者的偏好 把關鍵資訊單獨存下來,需要時再取出來放進上下文,這是 05 模組第 5 課的"長期記憶"

常見問題

把 system 訊息也放進 history 裡一起截斷了:system 訊息應該每次單獨放在最前面,永遠不參與截斷。上面的程式碼把它和 history 分開存,就是為了這個。

多個使用者的歷史串了:做成網頁服務時,每個使用者、每個會話都要有自己的 history,通常存在資料庫或者快取裡,用會話 ID 區分。一個全域性變數 history 只適合單人使用的命令列程式。

練習

  1. 執行 conversation.py,和它聊十輪以上,觀察每輪輸入詞元的變化。再加上 --max-messages 6 聊一次,找出它忘記事情的那一刻。
  2. 改寫 trim,讓它按詞元數截斷,而不是按條數:歷史的總詞元數超過 500 時,從最早的開始成對地丟掉訊息。可以用上一輪返回的 usage.prompt_tokens 來判斷。
  3. 在第 3 輪隨口提一個細節(比如"我的伺服器在香港"),在第 8 輪問起它。分別用截斷和摘要跑一次,看看摘要有沒有保住這個細節。如果沒有,試著修改 summarize 的提示詞。

自測

1. 模型怎麼"記住"你前面說過的話?

模型本身不記得任何東西。程式每次呼叫時,把之前所有的 user 和 assistant 訊息連同新問題一起發給模型,模型是從這些訊息裡"讀到"了之前的內容。

2. 為什麼多輪對話的每一輪都比上一輪貴?

每一輪都要把之前全部的對話歷史重新發一遍作為輸入,歷史越長,輸入詞元越多,費用越高。歷史開頭相同的部分可以命中快取,便宜很多,但總的輸入仍然在增長。

3. 截斷和摘要各有什麼缺點?

截斷會徹底丟掉早先的資訊,比如使用者的名字,後面問起來就答不上了。摘要能保留關鍵事實,但每次壓縮要額外呼叫一次模型,會丟掉模型認為不重要的細節,還可能概括錯。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…