《零基础 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 最长涨到过多少条?后来怎么处理的?

② 滑窗和摘要你更倾向哪个?什么场景下你会觉得摘要反而比原文更靠谱?

咖啡续命,梯度下降。| 作者:梯度下降的咖啡师