2023 年我做一个能调工具的 AI 客服 Agent:用户多轮对话,Agent 中途会调用查订单、查物流这些工具,把结果拿回来再继续回答。多轮对话怎么管历史?这件事我压根没多想。第一版我做得很省事:每一轮,把从头到尾整段对话历史都塞进 messages 发给模型。后来对话长了,模型开始报"超出上下文长度",我又很省事地想:上下文窗口要满了,把最早的几条消息删掉、只留最近的,不就腾出空间了?我顺手写了个 history[-8:],只保留最近 8 条。就完事了。本地开发时——真不错:我跟它聊三五轮,删不删都正常,看着稳稳的。我心里很踏实:"历史嘛,砍掉旧的留新的,天经地义。"可等这个 Agent 真正上线、用户聊起没完没了的长对话,一串问题冒了出来。第一种最先把我打懵:砍完历史,模型直接报 400、invalid request,整轮对话崩掉——明明我只是删了几条旧消息。第二种最难缠:有时砍着砍着,Agent 突然不知道自己是谁了,语气、规则全乱,像换了个人。第三种最头疼:我改成"砍之前先摘要",结果每一轮都把全部历史重新摘要一遍,token 成本和响应延迟一起暴涨。第四种最莫名其妙:我在本地怎么测都正常——后来才反应过来,本地对话才三五轮,根本到不了窗口上限,砍历史的代码压根没被触发。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"对话历史就是一串消息,满了就从最早的开始删"。这句话把对话历史,当成了一个可以从任意位置剪断的、扁平的字符串列表。可它不是。我脑子里,messages 就是一个数组,每个元素都长得差不多、地位平等,删哪个、留哪个,只是个"要不要"的取舍。可对话历史根本不是一个扁平数组,它是一个有内部结构、有强约束的东西。它里面有两类东西,你一旦从中间切开,它就坏了。第一类:当 Agent 发起一次工具调用,assistant 这条消息里带着 tool_calls,紧跟在后面的、role 是 tool 的那些消息,是这次调用的结果——这一个 assistant 加上它后面配对的若干 tool 消息,是一个绑死的、不可拆分的原子单元。模型接口有一条硬性规则:一条 role=tool 的消息,前面必须有一条声明了对应 tool_call_id 的 assistant 消息。我那个 history[-8:],很可能恰好把 assistant 那条砍掉、却留下了它后面的 tool 结果,于是接口收到一条"无人认领"的 tool 消息,直接判定请求非法,400。第二类:第一条 system 消息,是整场对话的锚,它定义了这个 Agent 是谁、守什么规矩。我从头部砍消息,砍着砍着就把 system 也砍没了,Agent 自然就"失忆"、行为失控。说到底,我是把一份有语法的结构,当成一段可以随便裁的纯文本在裁。真正管好对话历史,核心不是"满了就从头删几条",而是把对话历史当作一个有结构的东西:先把它切成一个个不可拆分的原子单元(system 一个、每轮普通问答一个、每组工具调用连同它的结果一个),永远钉住 system 和最近的若干单元,再把更早的整组单元替换成一条摘要消息——而且摘要要滚动复用,只在历史确实溢出时才重算。这篇文章就把对话历史压缩这个坑梳理一遍:为什么"从头砍"是错的、对话历史到底有什么结构、怎么把历史切成原子单元、怎么做滚动摘要压缩、怎么算清留给历史的 token 预算,以及结构校验、单元过大这些把上下文治理做扎实要避开的坑。
问题背景
这个坑普遍,是因为"满了就删旧的"这个直觉太顺了——内存满了清缓存、磁盘满了删旧文件,删旧留新几乎是程序员的肌肉记忆。把它套到对话历史上,看起来天经地义。它错得隐蔽,是因为它在短对话里永远不出事:对话没到窗口上限,删历史的代码根本不触发;就算触发,只要没恰好切断一个工具调用对,也碰巧不报错。它只在真实的长对话、且历史里夹着工具调用时才暴露,而那时 Agent 已经在用户面前崩了。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:长对话里删旧消息后模型报 400;Agent 偶尔"失忆"、人设崩塌;改成每轮摘要后成本和延迟暴涨;本地短对话完全测不出。
- 错误认知一:以为
messages是一个扁平数组,每条消息地位平等、可以从任意位置删。真相是它是带结构的——assistant 的tool_calls和后续tool消息成对绑定,不可从中间切开。 - 错误认知二:以为 system 消息只是"第一条而已",删历史时一视同仁。真相是 system 是整场对话的锚,定义 Agent 身份与规则,必须永远钉在第一条。
- 错误认知三:以为"压缩历史"就是"摘要历史",每轮重做一遍即可。真相是摘要要花一次额外的模型调用,每轮重算会让成本和延迟翻倍,摘要必须滚动复用。
- 真相:对话历史是一份有语法的结构。压缩它不是"删行",而是先识别出不可拆分的原子单元,钉住必须保留的(system 与最近单元),把过期的整组单元替换成摘要——全程不破坏结构。
一、为什么"把最早的消息删掉"是错的
先看一段真实的 Agent 对话历史长什么样,以及第一版那个"砍最早几条"的函数。
# 一段 Agent 对话历史长这样:它不是扁平的,它有结构
history = [
{"role": "system", "content": "你是一个订单客服助手……"},
{"role": "user", "content": "帮我查一下我的订单到哪了"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "query_logistics",
"arguments": '{"order_id": "A8801"}'}}]},
{"role": "tool", "tool_call_id": "call_1",
"content": '{"status": "运输中", "city": "杭州"}'},
{"role": "assistant", "content": "您的订单 A8801 正在运输中,目前在杭州。"},
]
def truncate_history(history: list, keep: int = 4) -> list:
"""上下文要满了:把最早的消息砍掉,只留最近 keep 条(反面教材)。"""
return history[-keep:]
问题出在 history[-keep:] 这个看似无害的切片上。看上面这段历史:第 3 条是 assistant 发起的工具调用(带 tool_calls,id 是 call_1),第 4 条是这次调用的结果(role 是 tool,tool_call_id 也是 call_1)。这两条是一对。如果 keep=3,切片会从第 3 条开始留——看着没问题;但如果 keep=2,切片只留最后 2 条,也就是第 4、5 条,于是发出去的 messages 里有一条 role=tool 的消息(第 4 条),它前面却没有那条声明了 call_1 的 assistant 消息。模型接口收到这条"无人认领"的工具结果,直接判定请求非法,返回 400。更糟的是 keep 取多少全看历史长度,这个 bug 是偶发的:某些对话恰好没切在调用对中间,就侥幸正常;某些恰好切中,就崩。
另一个问题:history[-keep:] 从尾部留,意味着只要历史够长,第 1 条 system 消息一定会被砍掉。system 一没,Agent 就丢了"你是谁、守什么规矩"这套设定,行为立刻失控。
这里要建立的第一个、也是最重要的认知是:对话历史不是一个可以从任意位置剪断的扁平数组,它是一份有语法的结构,而 history[-N:] 这种切片操作,对这份语法是完全无知的。它只会数"第几条",根本不知道第 3 条和第 4 条是绑死的一对、第 1 条是不能动的锚。你用一个无知于结构的操作去裁一份有结构的数据,结果必然是把结构裁坏。这就解释了为什么这个 bug 如此诡异:它偶发(切片有时恰好没切中调用对)、它在本地不出现(短对话压根不触发切片)、它表现为莫名其妙的 400(因为坏的是请求结构,不是你的业务逻辑)。一旦你接受"历史是有结构的"这个前提,解决方向就清楚了:不能再用按条数切片这种盲操作,必须先把这份结构看懂——哪些消息是绑在一起的、哪些是必须钉住的——然后只在结构允许的边界上做删减。下面两节就来拆这个结构。
二、对话历史的结构:工具调用对与 system 锚
把一段带工具调用的对话历史摊开,它里面有两类"碰不得"的结构。
第一类是工具调用对。当 Agent 决定调一个工具,它产出的 assistant 消息里带着 tool_calls 数组,每个调用有一个唯一 id。紧接着,每个调用的执行结果,以一条 role=tool 的消息回填,靠 tool_call_id 跟前面的 id 对上号。模型接口有一条硬性语法规则:每一条 role=tool 的消息,前面必须存在一条带有匹配 tool_call_id 的 assistant 消息;反过来,一条 assistant 发起的每个 tool_calls,原则上也都该有对应的结果。这一个 assistant 加上它后面配对的 tool 消息,是一个不可拆分的原子单元。第二类是 system 锚:第一条 system 消息定义 Agent 的身份、语气、规则,它必须永远是第一条、永远不被删。
所以做任何历史裁剪之前,得先有一个能校验结构是否合法的函数,它就是你压缩历史后的"安检门"。
def validate_history(messages: list) -> list:
"""检查对话历史结构是否合法,返回所有结构错误。"""
errors = []
if not messages or messages[0]["role"] != "system":
errors.append("第一条必须是 system 消息")
open_calls = set()
for i, msg in enumerate(messages):
role = msg["role"]
if role == "assistant" and msg.get("tool_calls"):
# 这条 assistant 发起了若干工具调用,记下这些 id
for call in msg["tool_calls"]:
open_calls.add(call["id"])
elif role == "tool":
tcid = msg.get("tool_call_id")
if tcid not in open_calls:
# tool 消息没有配对的 assistant.tool_calls —— 非法
errors.append(f"第 {i} 条 tool 消息找不到对应的工具调用")
else:
open_calls.discard(tcid)
if open_calls:
errors.append(f"有工具调用没有对应的结果:{open_calls}")
return errors
裁剪历史还有一个前提:你得知道历史现在占了多少 token,才知道要不要裁、裁到哪。注意每条消息除了内容本身,还有角色、分隔符这些固定开销,工具调用的函数名和参数也都算 token。
import tiktoken
def count_message_tokens(messages: list, model: str = "gpt-4o-mini") -> int:
"""估算一组对话消息占用的 token 数(含每条消息的固定开销)。"""
enc = tiktoken.encoding_for_model(model)
total = 0
for msg in messages:
total += 4 # 每条消息有固定的角色/分隔开销
for key, value in msg.items():
if isinstance(value, str):
total += len(enc.encode(value))
elif key == "tool_calls" and value:
# 工具调用的函数名和参数也都要计入
for call in value:
fn = call["function"]
total += len(enc.encode(fn["name"]))
total += len(enc.encode(fn["arguments"]))
return total + 3
这里要建立的认知是:对话历史的"语法",不是某个框架的实现细节,而是模型接口层面的硬性契约,你违反它,换哪个框架、改哪段代码都救不了,接口照样给你 400。把它和写代码类比:对话历史更像一段有括号配对的代码,assistant.tool_calls 是左括号、tool 消息是右括号,它们必须配平;system 是文件头部不能删的声明。你不会用"删掉文件前 5 行"的方式去精简代码,因为那大概率会切断一个括号对——同理也不该用"删掉前 N 条消息"去精简历史。validate_history 这个函数的价值,正在于它把这份隐性的语法变成了一道可执行的检查:你做的任何压缩,产出的结果都必须能通过它,通不过就说明你把结构搞坏了,这时候宁可报错退回,也绝不能把坏结构发出去。先有这道安检门,后面所有的压缩动作才有了"对不对"的判据。
三、把历史切成不可拆分的原子单元
既然历史里有"绑死的一对",那压缩的最小操作单位就不该是"一条消息",而该是"一个原子单元"。所谓原子单元,就是要么整组保留、要么整组删除,绝不从中间切开的一捆消息:system 自己是一个单元;一轮普通的"用户问 + 助手答"是一个单元;一条 assistant 的工具调用连同它后面所有配对的 tool 结果,捆成一个单元。先把扁平的历史,按这个规则重组成一个个单元。
def group_into_units(messages: list) -> list:
"""把消息按"不可拆分的原子单元"分组。
system 单独成组;一条 assistant.tool_calls 和它后面所有配对的
tool 消息,捆成一个组,绝不能从中间切开。
"""
units, i = [], 0
while i < len(messages):
msg = messages[i]
if msg["role"] == "assistant" and msg.get("tool_calls"):
need = {c["id"] for c in msg["tool_calls"]}
group = [msg]
j = i + 1
while j < len(messages) and need:
nxt = messages[j]
if nxt["role"] == "tool":
need.discard(nxt.get("tool_call_id"))
group.append(nxt)
j += 1
units.append(group)
i = j
else:
units.append([msg])
i += 1
return units
有了单元这个抽象,"压缩历史"就有了一个干净的表述:把单元列表分成三段——第一个单元(system)永远钉住;最后若干个单元(最近的对话)原样保留;中间那些过期的单元,整组拿去做摘要。整个决策流程是这样的:
这里要建立的认知是:"原子单元"这个抽象,是整个历史压缩问题的转折点。在你脑子里还是"一条条消息"的时候,这个问题是无解的——因为任何"删第几条"的操作都可能切坏结构;而一旦你把视角抬到"一个个单元",问题立刻变得可处理:单元的边界,恰好就是结构允许你下刀的地方。group_into_units 做的事,本质是把那份隐性的语法,显式地物化成了一个个不会切坏的块。从此往后,你所有的删、留、换,都以单元为最小粒度,就再也不会切断一个工具调用对。这是一个很通用的解题思路:当一个东西"不能从任意处切",就先找出它"能从哪里切",把那些合法的切点之间的内容打包成块,之后只在块的边界上操作。注意一个细节:工具调用单元可能很大——一次检索工具的返回可能就是几千 token,单独一个单元自己就可能逼近预算,这种情况要对单元内部的 tool 结果再做截断,这点放到第六节细说。
四、滚动摘要:压缩旧历史而不破坏结构
过期的单元怎么"压"?把它们交给模型,生成一条浓缩的摘要。关键是摘要的提示词要明确:保留已确认的事实、用户偏好、未完成的待办,丢掉寒暄。
def summarize_units(client, units: list, model: str) -> dict:
"""把若干过期的对话单元,压缩成一条摘要消息。"""
transcript = []
for unit in units:
for msg in unit:
role, content = msg["role"], msg.get("content") or ""
if msg.get("tool_calls"):
names = [c["function"]["name"] for c in msg["tool_calls"]]
content = "调用工具:" + ", ".join(names)
transcript.append(f"[{role}] {content}")
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system",
"content": "把下面的对话浓缩成要点,保留所有已确认的"
"事实、用户偏好和未完成的待办,丢掉寒暄。"},
{"role": "user", "content": "\n".join(transcript)},
],
temperature=0,
)
return {"role": "system",
"content": "[历史摘要] " + resp.choices[0].message.content}
把分组、钉锚、摘要拼起来,就是完整的压缩函数。注意它开头那一行:没超预算就原样返回,根本不调用模型——这是省钱省延迟的关键。
def compress_history(client, history: list, model: str,
budget: int = 8000, keep_recent: int = 6) -> list:
"""把对话历史压缩到 token 预算以内,且保持结构完整。"""
if count_message_tokens(history, model) <= budget:
return history # 还没超,原样返回,不花一分钱
units = group_into_units(history)
system_unit = units[0] # system 永远钉在最前
body = units[1:]
recent = body[-keep_recent:] # 最近若干单元原样保留
old = body[:-keep_recent] # 更早的整体压成摘要
if not old:
return history # 没有可压缩的旧历史了
summary = summarize_units(client, old, model)
rebuilt = system_unit + [summary]
for unit in recent:
rebuilt += unit
return rebuilt
但 compress_history 还有个隐患:只要一超预算,它每一轮都会重新摘要一遍 old。可大多数轮次里,old 这部分旧历史根本没变,重算就是白烧钱。正确做法是滚动摘要:把上次的摘要缓存下来,只有当旧历史确实又长出了新内容时,才重算。
class RollingSummary:
"""滚动摘要:摘要只在旧历史确实新增时才更新,平时直接复用。"""
def __init__(self, client, model: str, budget: int):
self.client, self.model, self.budget = client, model, budget
self._summary = None # 缓存上一次的摘要
self._summarized_upto = 0 # 已被摘要覆盖到第几个单元
def build(self, history: list) -> list:
if count_message_tokens(history, self.model) <= self.budget:
return history # 没溢出,完全不调用模型做摘要
units = group_into_units(history)
old = units[1:-6]
if len(old) <= self._summarized_upto and self._summary:
# 旧历史没新增,直接复用缓存的摘要,省一次 LLM 调用
return self._assemble(units, self._summary)
self._summary = summarize_units(self.client, old, self.model)
self._summarized_upto = len(old)
return self._assemble(units, self._summary)
def _assemble(self, units: list, summary: dict) -> list:
rebuilt = units[0] + [summary]
for unit in units[-6:]:
rebuilt += unit
return rebuilt
这里要建立的认知是:历史压缩有两笔账,一笔是 token 账,一笔是钱和延迟的账,新手只盯着第一笔,老手两笔一起看。摘要不是免费的——它本身就是一次完整的模型调用,要花 token、要等响应。如果你每一轮对话都把全部旧历史重新摘要一遍,你确实把上下文压下去了,但你为此付出的,是每轮多一次模型调用的成本和延迟,用户会明显感觉到变慢。滚动摘要的核心思想,是认清一个事实:旧历史是"只增不改"的——上一轮被归入摘要的那些单元,这一轮它们还是老样子,不会变。既然没变,上一次为它们生成的摘要就依然有效,直接复用即可,只有当又有新的单元滑出"最近窗口"、进入"旧历史"时,才需要把摘要更新一次。这样,模型调用的次数就从"每轮一次"降到了"每隔若干轮一次"。再加上 compress_history 开头那行"没超预算就原样返回",你就得到一个分层的省钱策略:绝大多数轮次零额外调用,少数溢出轮次才花一次摘要,且摘要还能滚动复用。把 token 账和成本账一起算,压缩才算做对。
五、算清楚留给历史的 token 预算
前面一直在用一个 budget 参数,这个数不能拍脑袋。模型的上下文窗口是输入和输出共享的一块空间:你发进去的历史、本次要生成的回答、再加一点安全余量,三者加起来不能超过窗口总大小。所以留给历史的预算,要从总窗口里反推。
CONTEXT_WINDOWS = {
"gpt-4o-mini": 128000,
"gpt-4o": 128000,
}
def plan_budget(model: str, max_output: int, reserve: int = 512) -> int:
"""从模型总上下文里,算出留给历史的 token 预算。"""
total = CONTEXT_WINDOWS.get(model)
if total is None:
raise ValueError(f"未知模型,无法确定上下文窗口:{model}")
# 总窗口 = 历史输入 + 本次输出 + 安全余量
history_budget = total - max_output - reserve
if history_budget <= 0:
raise ValueError("max_output 太大,没有空间留给历史")
return history_budget
每一轮真正发请求前,流程是固定的:把用户的新输入拼到历史末尾,压缩到预算以内,然后再校验一次结构——压缩本身也可能因为代码 bug 产出坏结构,绝不能把没校验过的 messages 发出去。
def prepare_next_turn(roller: "RollingSummary", history: list,
user_input: str) -> list:
"""收到用户新输入,组装出本轮真正要发给模型的 messages。"""
history = history + [{"role": "user", "content": user_input}]
compressed = roller.build(history)
# 压缩后必须再校验一次结构,绝不能把坏结构发出去
errors = validate_history(compressed)
if errors:
raise RuntimeError(f"历史压缩产生了非法结构:{errors}")
return compressed
这套逻辑得有测试守着。压缩后的历史,必须同时满足三件事:在预算内、system 还在第一条、结构合法。
def test_compression_keeps_structure():
"""压缩后的历史,必须仍然是一个结构合法的对话。"""
history = build_long_agent_history(turns=50) # 造一段很长的历史
assert count_message_tokens(history) > 8000
compressed = compress_history(fake_client, history, "gpt-4o-mini",
budget=8000, keep_recent=6)
# 1. 压缩后确实在预算内
assert count_message_tokens(compressed) <= 8000
# 2. system 消息还在,且仍是第一条
assert compressed[0]["role"] == "system"
# 3. 结构合法:没有落单的 tool 消息、没有悬空的工具调用
assert validate_history(compressed) == []
这里要建立的认知是:上下文窗口是一块输入输出共用的、有限的空间,而不是一个"只要历史不超就行"的输入额度。很多人算预算时只想着"历史别太长",忘了同一块空间还要装下模型这一轮的输出——如果你把历史塞到接近窗口上限,模型就没地方生成回答了,轻则回答被截断,重则直接报错。所以正确的算法是反过来的:先从总窗口里,把本次输出要用的 max_output 扣掉,再扣掉一点应对 token 估算误差的安全余量,剩下的才是历史能用的预算。还有一点:count_message_tokens 算的是估算值,真实 token 数会因为模型版本、特殊格式有些许出入,所以那个 reserve 余量不是可有可无的摆设,它就是用来吸收这种误差的。最后,把"压缩后必须重新校验结构"和"用测试守住三条不变量"刻进流程里——压缩是一段会改写请求结构的代码,它自己也可能有 bug,validate_history 这道安检门和那个测试,就是确保它即使写错了,坏结构也走不到模型接口面前。
六、工程里那些历史压缩的坑
把这套压缩逻辑用起来,还有几个真实项目里反复出现的坑。
第一个是 system 被误删。任何裁剪历史的代码,第一步都必须先把 system 单元单独拎出来钉住,剩下的才参与裁剪。千万别写 history[-N:] 这种从尾部留的逻辑,它必然会把 system 砍掉。第二个是 单个单元自己就超预算:一次检索类工具可能返回几千 token 的结果,这一个工具调用单元自己就可能逼近甚至超过预算——这时光靠"删旧单元"没用,得对单元内部那条 tool 消息的 content 单独做截断或摘要。第三个是 keep_recent 该按 token 而不是按个数定:固定"保留最近 6 个单元",但每个单元大小天差地别,6 个小问答和 6 个带大检索结果的单元,token 量能差几十倍——更稳的做法是从最近往前累加 token,加到接近预算就停。
第四个是 摘要丢关键信息:摘要提示词如果只写"概括一下",模型很可能把"用户说他的订单号是 A8801""用户要求只看顺丰"这类关键事实当寒暄丢掉。提示词必须明确点名要保留的几类信息:已确认事实、用户偏好、未完成待办。第五个是 摘要也要算进预算:摘要消息本身也占 token,如果旧历史极长,摘要也可能不短,compress_history 重组完之后,最好再 count 一次确认真的在预算内,没有的话就调小 keep_recent 再压一轮。
这里要建立的认知是:历史压缩不是"写完 compress_history 就完事"的一次性功能,它是一个需要按真实对话形态持续调的策略。这几个坑串起来看,会发现它们都指向同一件事:历史里每个单元的"重量"是极不均匀的——一句"好的谢谢"是个几 token 的轻单元,一次知识库检索的返回是个几千 token 的重单元。任何"按个数"的策略(留最近 6 条、砍最早 3 条)都默认了单元等重,这个假设一旦被那些重单元打破,策略就会失真:你以为留了 6 个单元很安全,实际可能已经爆了预算;你以为砍掉旧单元就能腾出空间,结果罪魁祸首是最近一个单元里那坨巨大的工具结果。所以工程上靠谱的历史压缩,度量单位自始至终都得是 token,而不是消息条数:预算按 token 算、保留窗口按 token 累加着定、连单个工具结果该不该截断也按 token 判。再配上"摘要提示词明确点名要保留什么""压缩完再 count 一次兜底"这两条,你的上下文治理才算经得起真实长对话的反复冲刷。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 对话历史的结构 | messages 不是扁平数组,是有语法约束的结构 | 从任意位置切断会破坏结构,导致接口报 400 |
| 工具调用对 | assistant.tool_calls 与后续 role=tool 消息成对绑定 | tool 消息前必须有匹配 tool_call_id 的 assistant 消息 |
| system 锚 | 第一条 system 消息定义 Agent 身份与规则 | 必须永远钉在第一条,任何裁剪都不能删它 |
| 原子单元 | 要么整组保留、要么整组删除的一捆消息 | 压缩的最小粒度,边界即结构允许的合法切点 |
| 结构校验 | 检查 tool 配对、system 位置是否合法 | 压缩后的安检门,不通过就报错退回 |
| 摘要压缩 | 把过期单元交给模型浓缩成一条摘要消息 | 提示词须明确保留事实、偏好、待办 |
| 滚动摘要 | 缓存摘要,仅当旧历史新增时才重算 | 避免每轮重复摘要带来的成本与延迟 |
| token 预算 | 留给历史的 token 额度 | 总窗口减去 max_output 与安全余量后反推 |
| 上下文窗口 | 输入与输出共享的一块有限空间 | 历史塞太满会挤掉输出空间,致截断或报错 |
| 单元重量不均 | 不同单元的 token 量可相差几十倍 | 策略须按 token 度量,不能按消息条数 |
避坑清单
- 不要用 history[-N:] 之类按条数切片来裁历史。它无知于结构,会切断工具调用对、砍掉 system,导致偶发的 400。
- 裁剪前先把 system 单元单独拎出来钉住,它定义 Agent 身份,任何情况下都不能被删。
- 把 assistant.tool_calls 和它后续的 tool 消息当作不可拆分的原子单元,要删整组删,绝不从中间切。
- 压缩后必须用结构校验函数过一遍,确认没有落单的 tool 消息、没有悬空的工具调用,不合法就报错退回。
- 没超预算时不要做任何摘要,直接原样返回历史,摘要是一次真实的模型调用,不是免费的。
- 用滚动摘要,缓存上次结果,只在旧历史确实新增时才重算,避免每轮重复摘要烧钱又增延迟。
- token 预算要从总窗口反推:扣掉 max_output 和安全余量,剩下的才是历史可用额度。
- 保留窗口按 token 累加来定,不要按固定消息条数,因为单元的 token 量极不均匀。
- 单个工具结果可能自己就超预算,对超大的 tool 消息内容要单独截断或摘要,光删旧单元没用。
- 摘要提示词要明确点名保留项:已确认事实、用户偏好、未完成待办,否则关键信息会被当寒暄丢掉。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为对话历史就是一串地位平等的消息,满了就从最早的开始删。可它不是一个扁平数组,它是一份有语法的结构——assistant 的工具调用和后面的 tool 结果是绑死的一对,system 是不能动的锚。我用 history[-N:] 这个无知于结构的切片去裁它,自然会切断调用对、砍掉 system,换来偶发的 400 和 Agent 的失忆。问题从来不在"该留多少条",而在"我把一份有结构的东西,当成纯文本在裁"。
真正管好对话历史,工作量不在"想个聪明的删除策略",而在一次视角的抬升:从"一条条消息"抬到"一个个不可拆分的原子单元"。一旦以单元为最小粒度,问题就全顺了——单元的边界就是结构允许下刀的地方,钉住 system 单元和最近的若干单元、把过期单元整组换成一条摘要、摘要再滚动复用。每一步都不复杂,难的是先承认:历史不是文本,是结构;压缩不是删行,是在不破坏语法的前提下做替换。
我后来常拿整理会议纪要来想这件事。一叠开了几十次的会议记录堆在那里太厚了,要精简。最蠢的做法,是抽掉最前面几页——可第 8 页那个决定,白纸黑字写着"沿用第 2 页的方案",你把第 2 页抽走,第 8 页就成了一句没头没脑的话。也绝不能把某一次会议的记录从中间撕成两半。聪明的做法是:每一次完整的会议记录是一个整体,要么整份留着、要么整份缩成一段会议纪要;那些早就尘埃落定的旧会议,合起来浓缩成一页要点,但务必把"已定下的结论、各方的诉求、还没办的事"都写进去;而最前面那份项目章程——谁负责、目标是什么——永远钉在第一页不动。对话历史的 system 就是那份章程,工具调用对就是那种"撕不得"的单次会议记录,滚动摘要就是把旧会议浓缩成的那页要点。
这类问题最咬人的地方,在于它在本地几乎永远是"对"的:你跟 Agent 聊三五轮,历史短得压根触发不了压缩,那段裁剪代码一次都没跑过,你看着一切正常就上线了。它只在真实用户聊起没完没了的长对话、且历史里恰好夹着工具调用时,才露出獠牙,而那时 Agent 已经在用户面前报 400、或者忘了自己是谁。所以别等线上崩了才想起历史治理:凡是会多轮、会调工具的 Agent,从第一天就该把"对话历史是有结构的"刻进设计里——按原子单元来切、钉死 system、按 token 立预算、压缩完过一遍结构校验。把这套东西在搭 Agent 时就备齐,你才算真正跳出了那条人人都会写、却人人都会栽的 history[-N:]。
—— 别看了 · 2026