多轮对话:模型其实什么都不记得
写一个命令行聊天程序,亲眼看到模型没有记忆,"记忆"只是每次把历史消息重新发一遍。再看历史太长时截断和摘要两种办法各自的效果。
- 约 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 积分。内容经审核后公开。
正在加载讨论…