聊天机器人记得你三句话之前说过什么,这个体验太自然,以至于很多人没想过:服务器到底把对话存哪了?答案是没存。本文是「从零手写 Agent」系列第 2 篇,用官方 SDK 把多轮对话跑起来,看着一个 Python 列表如何撑起全部"记忆",并算清这份记忆的账单。上一篇把请求体拆到了字段级,本篇让其中的 messages 动起来。
本文按"记忆的实现 → 账单膨胀 → 失忆实验 → 总结"组织,所有数字来自一次真实运行。
1、概述:记忆是个列表
大模型服务是无状态的:每次请求互相独立,服务器不记得上一轮说过什么。多轮对话能"接得上话",是因为客户端把历史消息装在一个列表里,每次请求全量重发。这个列表就是 messages,记忆的实体。
用官方 SDK 后,裸 HTTP 的拼头、发送、解析都被封装,剩下的核心代码只有维护这个列表:
from anthropic import Anthropic
client = Anthropic() # 自动读环境变量里的端点与 key
history = []
while True:
history.append({"role": "user", "content": input("你: ")})
response = client.messages.create(
model=MODEL, max_tokens=1024, system=SYSTEM_PROMPT,
messages=history, # 每轮把完整历史发过去
)
reply = "".join(b.text for b in response.content if b.type == "text")
history.append({"role": "assistant", "content": reply}) # 关键一步
print("助手:", reply)
解读:循环里两次 append 构成记忆的全部机制——先把用户新话说进历史,收到回答后再把模型的话也写进历史。漏掉第二次 append,下一轮模型就看不到自己说过的话,对话立刻崩坏。
两轮对话后,这个列表的真实形态如下:
[
{"role": "user", "content": "用五个字打个招呼"},
{"role": "assistant", "content": "你好呀同学!"},
{"role": "user", "content": "我叫小明,记住了"},
{"role": "assistant", "content": "记住啦,小明!"},
]
解读:第三轮请求发出前,列表里躺着前两轮的四条消息加新的提问共五条——所谓记忆,就是这个不断变长的数组,没有别的本体。
值得点破的是:官方 SDK 也没有把这个列表存到别处。client.messages.create() 每次调用,就是把 history 序列化成请求体发出去,用完即弃——SDK 封装的是传输,不是存储。所以"清空记忆"在代码里就是 history.clear(),"导出对话"就是把这个列表写进文件,没有任何隐藏状态需要去猜。
1.1、一轮对话的时序
把一轮对话里客户端做的事画成时序图,记忆机制的位置一目了然:
解读:注意第三步——发出去的不是"新话",是追加后的整个列表。服务器收到的是一份自包含的完整对话,它不需要、也不记得你上一轮发过什么。同一个对话历史在任何机器上重放,模型看到的语境完全一致,这就是"无状态"的全部含义,也是 Agent 能把历史存盘、换机续跑的原因。
时序图还藏着两个工程推论。一是断点续聊:把 history 存盘,下次启动从盘上加载接着 append,对话无缝续上——第 7 篇的持久化就是这一步的正经实现。二是多端同步:想让手机和电脑看到同一份对话,要同步的就是这个列表文件,而不是去服务器上找一个不存在的会话。
2、账单膨胀的实测
每轮全量重发意味着输入 token 随轮次累加。一次两轮对话的实测记录如下:
第 1 轮:历史 2 条 | 输入 38 token | 输出 275 token
第 2 轮:历史 4 条 | 输入 51 token | 输出 152 token
解读:第 2 轮比第 1 轮多付 13 个输入 token,恰好约等于第 1 轮一问一答的长度。把规律推广开:假设每条消息 100 token,第 N 轮的输入约为 2N 减 1 条消息的总和;聊满 10 轮,累计输入约 10000 token,其中新内容只有 1000,九成在为重复发送历史付费。
生产环境对这份膨胀有两类对策。其一是官方 API 的 prompt caching:重复的稳定前缀按约一折计费,不改代码结构即可大幅降本;其二是压缩历史,把旧对话摘要化后丢弃原文,这是系列第 7 篇的主题。
2.1、膨胀的数学
上面的规律值得算得更透。每条消息按 100 token 计,第 1 到 10 轮的单轮输入依次是 100、300、500……1900 token——等差数列,公差 200。十轮累计用求和公式一算恰好 10000 token,其中新内容只有 1000。
关键结论是增长曲线的形状:新内容线性增长,重发的历史按轮次平方增长。聊到第 50 轮,累计输入约 25 万 token;第 100 轮就到百万 token 量级,上下文窗口与钱包双双顶不住。这不是"省着点聊"能解决的问题,而是架构问题——第 7 篇的摘要压缩、第 8 篇的子 Agent 派工,都是冲着这条平方曲线去的。
给这条曲线一个日常体感:智能客服聊到第 20 轮,单轮输入已是开场时的约 40 倍;如果历史里还夹过文件内容,膨胀要再乘一个系数。凡是"对话窗口开得越久越卡、越聊越贵"的应用,根子几乎都是这条曲线。
2.2、prompt caching 实测
第一类对策的标准化形态是官方 Messages API 的 prompt caching:把请求里稳定不变的前缀缓存在服务器,命中部分按约一折计费,首次写入多付四分之一。开启方式是给缓存起点打 cache_control 标记,示例如下:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system=[ # system 也支持块列表形态
{"type": "text", "text": LONG_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}} # 缓存断点
],
messages=history,
)
print(response.usage.cache_read_input_tokens) # 命中量,大于 0 即生效
实测记录(同一 system 前缀连发两轮)如下:
第 1 轮:cache_read 0 | input 1024 | 前缀全价,写入缓存
第 2 轮:cache_read 1024 | input 39 | 命中部分按约一折计费
解读:system 提示词两轮一字未改,第二轮 1024 token 全部命中,只有新增的 39 个 token 走全价。缓存对前缀的字节级变化极其敏感——提示词里塞一个时间戳或随机 id,命中率立刻归零,易变内容必须放在断点之后。还要分清边界:缓存治的是"重复发送太贵",治不了"上下文本身太长",后者仍要靠第 7 篇的压缩。细节可查 Anthropic 官方文档,兼容端点的支持情况以 智谱开放平台 文档为准。
什么时候收益最大?两个信号:system 提示词或工具定义很长(几千 token 量级),以及对话轮次密集。缓存默认存活约五分钟,命中会自动续期,高频对话基本一直处于命中状态;一天聊不了几次的场景则可能总在过期后冷启动,收益有限。两个信号同时满足时,缓存能把输入成本砍掉一个数量级。判断标准别靠猜,看 cache_read_input_tokens 占输入的比例即可。
3、失忆实验
理解记忆机制的最好方式是动手破坏它。两个实验值得亲手做一遍。
第一个实验是伪造历史:手动在 messages 里插入一条并不存在的助手发言,比如"你好,小明!",再问模型"我叫什么名字"。模型会自然接住这个被伪造的上下文——它无法区分真说过的话和你替它写的话,只认请求体里的历史。这也解释了为什么对话数据本身就能塑造模型行为。
第二个实验是静默失忆:注释掉追加助手消息的那行,观察连续两条用户消息时各家的行为差异。实测结果如下:
| 行为 | 现象 |
|---|---|
| 官方 API | 校验角色交替,直接返回 400 错误 |
| 部分兼容网关 | 不报错,但模型看不到自己的旧发言 |
解读:第二种更危险——错误是安静的,问"我叫什么"它答得出(都在用户消息里),问"你上一句回了什么"它只能现编。静默失败比报错难排查一个数量级,这也是贯穿整个系列的排查主题。
实验的做法本身也有方法论价值:面对无状态系统,"构造一份不可能的历史"是最锋利的探针——它一刀切开"服务器存了什么"与"请求体里有什么"两个世界。后面每篇遇到看似玄学的问题,都可以用同样的思路:把请求体打印出来,一切行为都能找到解释。
3.1、伪造历史实验
给第一个实验补上可跑的代码。在历史里插一条模型从未说过的话,再考它:
history = [
{"role": "user", "content": "我们开始聊天吧"},
{"role": "assistant", "content": "你好,小明!"}, # 伪造:模型没说过
{"role": "user", "content": "我叫什么名字?"},
]
reply = client.messages.create(model=MODEL, max_tokens=64, messages=history)
print(reply.content[0].text)
实测记录的输出如下:
你叫小明呀,开场我还主动跟你打了招呼呢。
解读:模型不仅答对,还把伪造的招呼认作自己的行为。服务器不校验历史真伪——谁组装 messages,谁就定义模型的过去。这个特性一面是提示词注入的攻击面(往历史里塞假对话骗模型),一面是少样本示例的正规用法(往历史里塞示范对话),第 10 篇结构化输出会用到后一面。反过来,正经产品要防的也正是这一手:用户可控的文本永远不要直接拼进 assistant 侧的历史。
4、从对话机器人到 Agent 的距离
把这套循环和系列第 1 篇的认知合起来,距离 Agent 只差一块拼图。对话机器人维护的历史里只有文字;而 Agent 的历史里会出现第三种消息——工具调用记录。模型"决定调用某个工具"是一条结构化消息,"工具执行结果"也是一条结构化消息,它们同样进列表、同样全量重发。
等到第 4 篇,历史里长出的第三类内容长这样:
{"role": "assistant", "content": [
{"type": "tool_use", "id": "t1", "name": "read_file",
"input": {"path": "agent.py"}}]}
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1",
"content": "while response.stop_reason == ..."}]}
解读:角色仍然只有 user 与 assistant 两种,变的是 content 的形态——从字符串升级为内容块列表,与第 1 篇响应体里的块结构同源。工具调用是助手侧的块,工具结果是用户侧的块,历史结构的扩展方式始终是"加块"而不是"加字段"。
换句话说,Agent 的记忆和聊天机器人的记忆是同一个数据结构,差别只在内容块的类型更丰富。所有 Agent 框架里玄乎其神的状态管理,落到地面都是操作这个列表。SDK 用法细节可查 Anthropic Python SDK。
5、总结
多轮对话没有魔法:记忆是一个客户端维护的 messages 列表,每轮全量重发,账单随之线性膨胀,十轮对话里九成输入在为历史付费。漏掉追加助手消息,各端点会给你报错或静默失忆两种教训。对于动手派,建议在聊天循环里打印每轮的历史条数与输入 token,亲眼看着数字长大,这份体感会让后面每一篇的上下文管理设计都变得直觉化。下一篇拆开流式输出,看看打字机效果在协议层长什么样。

