多輪對話:模型其實什麼都不記得
寫一個命令列聊天程式,親眼看到模型沒有記憶,"記憶"只是每次把歷史訊息重新發一遍。再看歷史太長時截斷和摘要兩種辦法各自的效果。
- 約 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 只適合單人使用的命令列程式。
練習
- 執行
conversation.py,和它聊十輪以上,觀察每輪輸入詞元的變化。再加上--max-messages 6聊一次,找出它忘記事情的那一刻。 - 改寫
trim,讓它按詞元數截斷,而不是按條數:歷史的總詞元數超過 500 時,從最早的開始成對地丟掉訊息。可以用上一輪返回的usage.prompt_tokens來判斷。 - 在第 3 輪隨口提一個細節(比如"我的伺服器在香港"),在第 8 輪問起它。分別用截斷和摘要跑一次,看看摘要有沒有保住這個細節。如果沒有,試著修改
summarize的提示詞。
自測
1. 模型怎麼"記住"你前面說過的話?
模型本身不記得任何東西。程式每次呼叫時,把之前所有的 user 和 assistant 訊息連同新問題一起發給模型,模型是從這些訊息裡"讀到"了之前的內容。
2. 為什麼多輪對話的每一輪都比上一輪貴?
每一輪都要把之前全部的對話歷史重新發一遍作為輸入,歷史越長,輸入詞元越多,費用越高。歷史開頭相同的部分可以命中快取,便宜很多,但總的輸入仍然在增長。
3. 截斷和摘要各有什麼缺點?
截斷會徹底丟掉早先的資訊,比如使用者的名字,後面問起來就答不上了。摘要能保留關鍵事實,但每次壓縮要額外呼叫一次模型,會丟掉模型認為不重要的細節,還可能概括錯。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…