《零基础 AI 入门》第 4 篇|作者:梯度下降的咖啡师 | 8 年 Java 后端(京东、字节)+ 2 年 AI 应用开发
上一篇我们写了个 Agent:套个 while 循环,让模型自己决定调几个工具、转几圈。你跑了一下,问它"北京适合跑步吗",它查了天气和 AQI,给了你一段建议。
你把第三篇的代码重新跑了一遍,这次换个问法:那上海呢?
它查了上海天气、查了上海 AQI,给你一段建议。但你仔细看——它完全不记得上一轮聊过北京。你要是在同一个终端里接着问两个城市比一下呢,它又得把北京重新查一遍。它就像一个每天早上失忆的人,每次对话都是第一次见面。
这一篇就解决这个问题:给你的 Agent 加记忆,让它记得你十分钟前说过什么。同时解决一个随之而来的麻烦——messages 列表越涨越长,上下文窗口撑爆了怎么办。
01 它为什么会失忆#
先理解一件事:大模型本身是无状态的。
你每次调 client.chat.completions.create(),模型都是从头看你发给它的 messages 列表,看完就忘。它不会记住上次你发了什么。所谓"记忆",全靠你自己把历史消息塞进 messages 里。
用 Java 后端的话讲:模型就像一个无状态的 HTTP 接口,你每次请求都得把 session 数据从 Redis 捞出来、塞进 request 里。你不塞,它就当第一次见你。上一篇的代码跑完一轮就把 messages 扔了,下一轮你不重新建,它当然不记得。
所以"加记忆"这件事,说破了很简单:别把 messages 扔了,存着,下轮用户说话的时候 append 进去就行。
02 多轮记忆其实就一行代码#
在上一篇的代码外面,再包一层用户输入循环:
- 用户说一句话 →
messages.append({"role": "user", "content": 用户说的话}) - 跑那个 while 工具循环(和上一篇一模一样)
- 模型给你回复 → 自动 append 进 messages
- 回到第一步,等用户说下一句
就这么简单。messages 不再每轮重置,它一直在涨。模型每轮都能看到完整的对话历史——它"记住"了。
你可以自己试:先问"北京天气怎么样",等它回答完,再问"那上海呢"。它这次会记得你刚才问过北京,回答上海时会主动做对比,不会从头开始。
03 但 messages 会越长越长#
多轮对话能跑了,但新问题来了:messages 一直在涨。
你每问一句,至少加一条 user message;模型回一条,加一条 assistant message;中间如果调了工具,还要加 assistant(tool_calls) + N 条 tool message。聊个十几轮,messages 里可能就有三四十条消息了。
这会带来三个麻烦:
**第一,慢。**你每轮都把全部历史发给模型,模型每次都要重新读一遍。聊得越久,每轮等待时间越长。
**第二,贵。**API 按 token 收费。你发出去的每条消息都是 token,历史越长,每轮花钱越多。
**第三,有上限。**每个模型的上下文窗口都有上限,比如 deepseek-v4-flash 是 128K token。不是说你真能塞 128K——塞得越多越慢越贵,而且超了直接报错。
用 Java 的话讲:这就像你的 ArrayList 无限 add 对象,不做清理。一开始没事,时间长了 GC 压力爆炸,最后 OOM。你需要一种"记忆管理"策略。
多说一句:下面的代码按消息条数判断要不要压缩,这是教学简化。真实项目里应该按 token 数判断——一条长消息可能比十条短消息还占 token。create() 的返回结果里有个 usage 字段,能看到实际 token 数,生产环境用那个更准。
04 第一招:滑窗剪枝#
最简单的策略:只保留 system 提示 + 最近 N 条消息,老的丢掉。
就像 LRU 缓存——最近用过的留着,老的淘汰。实现起来很简单:
1
2
3
4
5
6
7
8
9
10
11
| def trim_messages(messages, keep_recent=10):
"""滑窗:永远保留 system,只留最近 keep_recent 条消息"""
system = messages[0] # system 提示永远不丢
recent = messages[-keep_recent:]
# 注意:不要让窗口以一条孤立的 tool result 开头
# tool result 前面必须跟着它对应的 tool_call,否则模型报错
while recent and recent[0]["role"] == "tool":
recent = recent[1:]
return [system] + recent
|
这里有个坑必须说:**不能把 assistant 的 tool_call 和 tool 结果拆开。**如果你剪的时候把"模型说要调工具"那条留下了、但把"工具返回了什么"那条丢了,模型看到一个没有结果的 tool_call,直接报错。所以代码里写了个 while——如果窗口第一条是 tool result,说明它前面的 tool_call 被裁掉了,这条 tool result 就是孤儿,一起丢掉。
滑窗的好处是简单、零额外开销。坏处也明显:**老消息直接丢了,模型真的忘了。**你聊了半小时,窗口一滑,它就跟新认识的人一样。
05 第二招:摘要压缩#
比滑窗更好的做法:老消息别直接丢,先让模型自己总结一下。
就像你看长论文不会每页都留着——你会边看边记笔记,把关键点摘出来。Agent 也一样:当 messages 太长时,把老消息打包发给模型,让它写一段摘要,然后把摘要当一条新消息塞回去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| def summarize_old_messages(messages, keep_recent=6):
"""把老消息压缩成一段摘要,保留最近 keep_recent 条原文"""
if len(messages) <= keep_recent + 1:
return messages
system = messages[0]
old = messages[1:-keep_recent] # 要被压缩的老消息
recent = messages[-keep_recent:] # 最近的几轮保留原文
# 把老消息拼成文本,让模型总结
old_text = "\n".join([
f"[{m['role']}] {m.get('content', '') or '(tool_call)'}"
for m in old
])
resp = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{"role": "system", "content": "你是对话摘要助手。保留关键事实、用户偏好和已确认的结论,不要遗漏数据。"},
{"role": "user", "content": f"把以下对话压缩成一段简短摘要:\n\n{old_text}"}
]
)
summary = resp.choices[0].message.content
# 新的 messages = system + 摘要 + 最近的原文
return [
system,
{"role": "user", "content": f"[之前的对话摘要] {summary}"},
] + recent
|
这样模型就不会"失忆"了——老对话的细节虽然不在了,但关键事实、数据、用户偏好都在那段摘要里。它可能记不住你第三句话具体怎么说的,但记得你问过哪些城市、得到了什么结论。
代价是多了一次 API 调用(做摘要那一下),但比你把全部历史塞进去便宜多了。调试 messages 结构时我一般直接把列表序列化成 JSON,丢到 ForJSON(forjson.com)里格式化看层级——几十条消息的缩进肉眼根本数不清。
还有个细节:摘要这里用 role: user 塞回去,不是 system。OpenAI API 没有专门的 summary 角色,这是社区通用做法——模型看到 [之前的对话摘要] 这个前缀就知道这是历史压缩,不会当成你刚说的话。
有个细节这里简化了:上面拼 old_text 的时候,assistant 调工具那轮只显示了 (tool_call),没把具体调了什么函数、什么参数拼进去。教学里够了,但真实项目里你应该把 tool_calls 的函数名和参数也拼进摘要文本——不然摘要模型不知道当时干了什么,摘要会漏掉关键操作。
06 完整代码,改个 key 就能跑#
把上一篇的 Agent 循环外面包一层多轮对话,加上摘要压缩。完整代码用摘要而不是滑窗——因为滑窗直接丢老消息,摘要至少还留个关键点。你想先试滑窗的话,把下面的 summarize_old_messages 换成 04 节那个 trim_messages 就行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
| import json
from openai import OpenAI
client = OpenAI(base_url="https://api.deepseek.com", api_key="你的DeepSeek API Key") # 去 platform.deepseek.com 注册获取
# --- 工具函数(和上一篇一样)---
def get_weather(city: str) -> str:
db = {"北京": "晴 28°C", "上海": "多云 25°C", "深圳": "雷阵雨 31°C"}
return db.get(city, f"暂无{city}的天气数据")
def get_aqi(city: str) -> str:
db = {"北京": "85(轻度污染)", "上海": "42(优)", "深圳": "30(优)"}
return db.get(city, f"暂无{city}的 AQI 数据")
tools = [
{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}},
{"type": "function", "function": {"name": "get_aqi", "description": "查空气质量", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}},
]
available_functions = {"get_weather": get_weather, "get_aqi": get_aqi}
MAX_TURNS = 5
MAX_HISTORY = 20 # 超过这个条数就触发摘要
def summarize_old_messages(messages, keep_recent=6):
if len(messages) <= keep_recent + 1:
return messages
system = messages[0]
old = messages[1:-keep_recent]
recent = messages[-keep_recent:]
old_text = "\n".join([f"[{m['role']}] {m.get('content', '') or '(tool_call)'}" for m in old])
resp = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{"role": "system", "content": "你是对话摘要助手。保留关键事实和用户偏好。"},
{"role": "user", "content": f"压缩成简短摘要:\n\n{old_text}"}
]
)
summary = resp.choices[0].message.content
return [system, {"role": "user", "content": f"[之前的对话摘要] {summary}"}] + recent
messages = [
{"role": "system", "content": "你是一个帮用户做决策的助手。需要数据时自己调工具,信息够了就直接给结论。"}
]
print("开始聊天吧,输入 exit 退出。")
while True:
user_input = input("\n你: ").strip()
if user_input.lower() == "exit":
break
if not user_input:
continue
messages.append({"role": "user", "content": user_input})
# Agent 内部工具循环(和上一篇一模一样)
for turn in range(MAX_TURNS):
resp = client.chat.completions.create(
model="deepseek-v4-flash",
messages=messages,
tools=tools,
)
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls:
print(f"\n助手: {msg.content}")
break
for call in msg.tool_calls:
fn_name = call.function.name
fn_args = json.loads(call.function.arguments)
print(f" [tool] {fn_name}({fn_args})")
try:
result = available_functions[fn_name](**fn_args)
except Exception as e:
result = f"工具执行失败:{type(e).__name__}: {e}"
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
else:
print("(达到最大轮次,强制停止)")
# 对话太长了,压缩一下
if len(messages) > MAX_HISTORY:
print(" [记忆] 对话太长,压缩中...")
messages = summarize_old_messages(messages, keep_recent=6)
|
跑起来你可以试这个对话顺序:先问"北京天气怎么样",它查完回答;再问"那 AQI 呢",它记得你在说北京;再问"上海呢",它会主动对比;聊到十几轮后你会看到终端打一行"对话太长,压缩中"——之后它不记得具体聊了哪些细节,但还记得你问过哪几个城市、结论是什么。
07 写在最后#
到这里,你的 Agent 已经有了短期记忆:它能跟你多轮对话,不会聊两句就失忆。但它的记忆还在内存里——你 Ctrl+C 一下,全没了。
下一篇我们不写新代码,直接聊两件事:手写这个版本还缺什么(持久化、多用户隔离、工具调用权限),以及为什么你撞过这三个墙之后,再去看 LangGraph 会觉得它每个抽象都刚好在解决你刚踩过的坑。
旧炉未冷,新火已燃。关注公众号「梯度下降的咖啡师」,或访问 gradcoffee.com。下一篇给你的手写 Agent 做一次"体检",看看从 Demo 到生产还差多远。
聊聊两个问题:
① 你自己写聊天机器人时,messages 最长涨到过多少条?后来怎么处理的?
② 滑窗和摘要你更倾向哪个?什么场景下你会觉得摘要反而比原文更靠谱?
咖啡续命,梯度下降。| 作者:梯度下降的咖啡师