第 4 篇把工具调用滚成了循环,而循环能一直转的前提,是每一轮请求都带着完整的对话历史——Messages API 无状态,服务端不保存任何一次调用的现场,模型“记得你说过什么”,完全是因为你的代码把历史原样重发了一遍。单轮问答无所谓;可一旦对话变长、Agent 任务跑上几十上百个来回,历史就成了一个越滚越大的数组:窗口够不够用、费用涨到多快、哪些内容可以丢,全都要你自己算、自己裁。
本文是《Anthropic SDK 从入门到实战》第 5 篇,专门解决“历史怎么拼、账怎么算、怎么裁”:先把多轮拼接封装成可复用的 Chat 类,再衔接第 4 篇看清工具轮次落进历史后的形态,然后算清 1M 窗口下的成本账,给出手动裁剪三招,最后核实官方压缩能力的现状并组装一个带滑动窗口与用量统计的终端聊天程序。全文按“拼接 → 工具入史 → 成本账 → 裁剪三招 → 官方现状 → 实战 → 总结”组织。
1、无状态 API 与历史拼接
先把这个系列反复依赖的事实说透:Messages API 的每一次调用都是独立的。create() 不知道上一次调用发生过什么,多轮对话的全部秘密就是 messages 数组——把用户与模型的往来按时间顺序放进去,每次全量重发。拼接规则如下:
| 规则 | 说明 |
|---|---|
| 角色交替 | 按 user、assistant 交替排列,首条必须是 user |
| 回复入史 | 把 response.content 原样作为 assistant 消息的内容 |
| system 独立 | 系统提示是顶层参数,不占 messages、每轮重发 |
| 无状态 | 服务端不留会话,历史只活在你的内存或数据库里 |
第二条最值得注意:assistant 消息的 content 不是纯文本,而是第 1 篇讲过的块数组——入史时要把整个 response.content 存进去,text、tool_use 都在其中。把这些规则封装成一个最小的 Chat 类,代码如下:
import anthropic
from dotenv import load_dotenv
load_dotenv()
class Chat:
def __init__(self, system="你是一个乐于助人的中文助手。"):
self.client = anthropic.Anthropic()
self.system = system
self.messages = [] # 历史由本地维护
def ask(self, user_text: str) -> str:
self.messages.append(
{"role": "user", "content": user_text})
response = self.client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
system=self.system, # 每轮原样重发
messages=self.messages, # 全量历史滚雪球
)
self.messages.append( # 回复入史,下轮可见
{"role": "assistant", "content": response.content})
return "".join(
b.text for b in response.content if b.type == "text")
chat = Chat()
print(chat.ask("我叫王雷,正在学 Python。"))
print(chat.ask("我刚才说我叫什么?在学什么?"))
解读:类只做三件事——append 用户输入、带着全量历史请求、append 模型回复。第二个问题不携带任何名字信息,模型照样答对,“记忆”来自历史数组而不是模型本身。两处细节照旧:max_tokens 必填;2026-10-21 API 版本起 temperature 等采样参数已移除,多轮请求同样一个都不要带,完整参数定义见官方 Messages API 参考。这个类是后面一切的地基,第 6 节的实战会直接在它上面加窗口与统计。
2、工具轮次如何进入历史
第 4 篇的工具循环每转一圈,历史就多两条消息:模型的 tool_use 请求跟着 assistant 消息入史,执行结果以 tool_result 块回到下一条 user 消息。循环转完、对话继续时,数组里会同时存在三种形态的消息。一次“查天气再作答”之后的完整历史如下:
[
{ "role": "user", "content": "旧金山现在多少度?" },
{ "role": "assistant", "content": [
{ "type": "text", "text": "我查一下天气。" },
{ "type": "tool_use", "id": "toolu_01A", "name": "get_weather",
"input": { "location": "旧金山" } }
]},
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "toolu_01A",
"content": "晴,18 摄氏度" }
]},
{ "role": "assistant", "content": [
{ "type": "text", "text": "旧金山现在晴,18 摄氏度。" }
]}
]
解读:三个要点。其一,assistant 消息的 content 是块数组,text 与 tool_use 同居一条消息,入史时整条保留,不能只挑文本;其二,tool_result 位于 user 消息的 content 数组里,tool_use_id 与前面的 tool_use 块一一对应——第 4 篇强调过,多个并行调用的全部结果必须合并在同一条 user 消息里,这条规矩在历史形态上就体现为“一条 user 消息装多个 tool_result 块”;其三,最新一条 user 输入直接 append 在末尾即可,交替关系不会被工具回合破坏,因为 tool_result 那条消息的 role 本来就是 user。
这份形态还埋着一个约束:tool_use 与它的 tool_result 必须相邻共存,裁剪历史时如果把两者拆开,下一轮请求立刻 400。第 4 节讲裁剪时会专门处理这个坑。
3、上下文的账:窗口与成本
当前一代 Claude 全系提供 100 万 token 上下文窗口与 128K 最大输出,1M 窗口默认开启、无需 beta 头,按标准价计费(claude-haiku-5-5 超 10 万 token 的请求加价是唯一例外)。照每轮 1000 token 的增量估算,大约第 1000 轮才会撞窗——窗口几乎不构成限制,但大窗口不等于可以放纵,原因有两个。
第一个是钱。API 无状态意味着每轮输入都是全量历史,输入 token 随轮次线性增长,整个会话的累计输入费按平方量级上涨。账目怎么涨,看响应里的 usage 对象最直观,字段分工如下:
| 字段 | 含义 |
|---|---|
| input_tokens | 本次请求的输入 token |
| output_tokens | 本次生成的输出 token |
| cache_read_input_tokens | 命中缓存的输入 token |
| cache_creation_input_tokens | 本次写入缓存的输入 token |
开了提示缓存之后,输入侧会拆进 input_tokens 与两个缓存字段,三者相加才是真实输入量,且都计入窗口。以 claude-sonnet-5-5(输入 2 美元每百万 token)为例,假设每轮新增 1000 token,未开缓存的账目增长如下:
| 轮次 | 本轮输入 | 本轮输入费 | 会话累计输入费 |
|---|---|---|---|
| 第 1 轮 | 1K | 0.002 美元 | 0.002 美元 |
| 第 25 轮 | 25K | 0.05 美元 | 0.65 美元 |
| 第 50 轮 | 50K | 0.10 美元 | 2.55 美元 |
| 第 100 轮 | 100K | 0.20 美元 | 10.10 美元 |
100 轮、每轮仅 1000 token 的轻量对话,光输入就烧掉 10 美元;真实场景一轮带代码、带工具结果常常上万 token,账目按倍数放大。第二个原因是质量:token 越多,模型的准确率与召回率越差,官方文档把这种现象命名为上下文腐烂(context rot)——长对话变笨不只是错觉,是可观测的退化。两笔账都指向同一个动作:历史要主动管理,而不是等它自己变糟。
撞窗的行为也值得知道:输入本身超过 100 万 token 直接返回 400(prompt is too long);输入加 max_tokens 之和超窗的请求会被接受,但生成可能以 model_context_window_exceeded 停止。发送前想预估,SDK 提供 count_tokens 方法可以离线估算任意消息列表的 token 数,不必真金白银试错。
4、手动裁剪三招
窗口之内、预算之内,历史该怎么裁?按“能不能丢细节”做决策,路线如下:
解读:三条路线不是互斥的单选——摘要替换本质上就是“先给被裁掉的部分留一份遗嘱”,滑动窗口决定留多少,system 与配对约束是所有路线共同的底线。逐招落地。
第一招,滑动窗口:只保留最近若干轮,实现最简单、行为最可控。代码如下:
def trim_sliding(messages, keep_rounds=10):
"""只保留最近 keep_rounds 轮(一轮 = 一问一答两条消息)"""
keep = keep_rounds * 2
if len(messages) <= keep:
return messages
trimmed = messages[-keep:]
if trimmed[0]["role"] != "user": # 首条必须 user
trimmed = trimmed[1:]
return trimmed
解读:按“条”成对裁,永远从 user 起头;代价是最早的对话彻底消失——用户十轮前报过的名字,裁完就再也没人记得。对闲聊与客服问答,丢掉的是废话;对长任务 Agent,丢掉的可能是关键约束,所以有第二招。
第二招,中段摘要替换:把要裁掉的部分先压成一份摘要,替换原始消息留在数组开头。代码如下:
def compact_middle(client, messages, keep_rounds=6):
"""把早期历史压成一条摘要,再拼接最近几轮"""
old = messages[:-keep_rounds * 2]
resp = client.messages.create(
model="claude-haiku-5-5", # 摘要任务用轻量档
max_tokens=512,
messages=[{"role": "user", "content":
"把以下对话压缩成要点,保留人名、结论与未决问题:\n"
+ str(old)}],
)
summary = "".join(b.text for b in resp.content if b.type == "text")
return ([{"role": "user",
"content": f"(此前对话摘要:{summary})"}]
+ messages[-keep_rounds * 2:])
解读:丢掉细节、留下骨架,一次 Haiku 调用的成本远低于每轮重发全文——这也是各类 Agent 产品“自动压缩”功能的同款思路。摘要请求本身要挑要点保什么:人名、决定、未决事项写进压缩指令,比一句“总结一下”可靠得多。
第三招,system 保留与配对守护。前两招裁的都是 messages,而 system 是独立顶层参数,永远不参与裁剪——把不可丢失的规则、人设、任务目标放进 system,是最稳的保留手段,每轮原样重发还几乎不占心智。真正要动手守卫的是第 2 节埋下的约束:截断点不能落在 tool_use 与 tool_result 中间。检查代码如下:
def trim_safe(messages, keep_rounds=10):
trimmed = trim_sliding(messages, keep_rounds)
first = trimmed[0]
if (first["role"] == "user" and isinstance(first["content"], list)
and first["content"]
and first["content"][0].get("type") == "tool_result"):
trimmed = trimmed[2:] # 截断点落在工具来回中间,再裁一对
return trimmed
解读:裁完看一眼新的首条消息——若它是 tool_result 开头的 user 消息,说明它的主人(前一条 assistant 里的 tool_use)已被裁掉,这对孤儿必须整体移除,否则下一轮 400。三招组合的标准形态:system 放规则、摘要接开头、窗口控总量、配对做守卫。
5、官方压缩能力现状
手动管理是当下的主战场,但官方能力也在演进,2026 年 10 月的口径如下。服务端压缩(compaction)已被官方定位为长任务上下文管理的主要策略,让服务端把历史压成摘要、对话从摘要继续,提供两种形态:按需压缩由应用自己择机触发,阈值压缩在 token 达到设定上限时自动执行。
不过要泼一盆冷水:这套能力目前仍处于 beta,仅 Claude 4.6 及之后的模型支持,接口形态随版本变动的风险真实存在。配套的还有上下文编辑(context editing),走更细的粒度——把已完成使命的 tool_result 块或思考块清空,而不是压缩整段历史,适合工具结果动辄几万 token 的 Agent 场景。能力全景与用法详见官方压缩文档与上下文窗口文档。
提醒:beta 特性可以尝鲜,但生产代码请以上一节的手动三招为主——手动方案不依赖任何 beta 头,接口稳定、行为完全可预期。转正之后本系列会更新用法。
另一个“已转正”的成本利器是提示缓存:重复历史走缓存读,输入成本最多省九成,与裁剪配合使用效果最好——那是第 7 篇的主题,本篇先把窗口内的账算明白。
6、实战:多轮终端聊天
零件齐了,组装一个完整可跑的终端聊天程序:多轮拼接、滑动窗口、每轮显示用量并累计。代码如下:
import anthropic
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic()
SYSTEM = "你是终端聊天助手,回答简洁准确。"
MAX_MSGS = 20 # 滑动窗口:保留最近 20 条
messages, total_in, total_out = [], 0, 0
print("聊天开始,输入 exit 退出。")
while True:
user = input("\n你:").strip()
if not user or user in ("exit", "quit"):
break
messages.append({"role": "user", "content": user})
resp = client.messages.create(
model="claude-sonnet-5-5", max_tokens=1024,
system=SYSTEM, messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
total_in += resp.usage.input_tokens
total_out += resp.usage.output_tokens
text = "".join(b.text for b in resp.content if b.type == "text")
print(f"Claude:{text}")
print(f"[本轮输入 {resp.usage.input_tokens},累计输入 {total_in}、"
f"输出 {total_out} token,历史 {len(messages)} 条]")
if len(messages) > MAX_MSGS: # 偶数条成对裁,交替不破坏
messages = messages[-MAX_MSGS:]
print(f"会话结束:累计输入 {total_in}、输出 {total_out} token。")
解读:四件事各就各位。每轮打印的 input_tokens 会让你亲眼看到第 3 节的线性增长——聊上二十轮,数字翻着番往上走,比任何表格都直观;窗口滑到 20 条时历史被成对裁掉,纯对话场景没有工具块,按条裁即安全;system 每轮重发,窗口怎么滑都带得走规则;想让模型不忘早期内容,把第 4 节的 compact_middle 替换掉最后两行即可。跑起来先问一句“记住这个词:企鹅”,聊够十轮再问“我最早让你记住的词是什么”,窗口滑过之后亲眼看着它忘掉——这一幕比任何文档都能说明多轮记忆的本质。
7、总结
本篇把多轮对话的“历史”从黑箱变成账本:Messages API 无状态,多轮等于 messages 全量重发,Chat 类用两次 append 就封装了滚雪球;工具回合以 tool_use 块入 assistant 消息、tool_result 块入相邻 user 消息,配对不可拆;1M 窗口默认开启,但输入费用随轮次线性增长、累计按平方上涨,加上上下文腐烂,历史必须主动管理。裁剪三招——滑动窗口丢旧、摘要替换留骨、system 与配对做底线——是当前生产代码的可靠答案;官方服务端压缩仍是 beta,值得跟踪但别押注。对于任何要跑长对话或 Agent 循环的项目,建议今天就给历史加上窗口与用量统计——十几行代码,换来的是成本曲线从失控变成可控。
留三个练习:其一,给第 1 节的 Chat 类加一个 stats() 方法,返回累计的 input_tokens 与 output_tokens,并用它验证第 3 节表格里的增长规律;其二,把第 6 节实战中的滑动窗口换成 compact_middle,对比两种策略下“你还记得我最早说的话吗”的回答质量与累计费用;其三,在每轮请求前调用 count_tokens 打印预估值,与响应里的 input_tokens 对照,看看估算偏差有多大——这三个数字在手,第 7 篇讲提示缓存时你会对“省九成”有更准的期待。下一篇讲思考模式:让模型先想再答,以及推理深度怎么调。

