Agent 工程实习复盘 04|长对话越聊越容易崩?Request-only Summary 与 Token 预算治理
本文来自我在实习期间参与的一次真实 Agent 系统稳定性改造。项目名称、服务器地址、账号和内部配置均已脱敏,只保留通用架构、事故原因、代码设计和测试数据。

一、先说结论:这次解决了四类问题
长对话治理不是简单地"消息多了就做一次摘要"。真实 Agent 请求里还有 System Prompt、Tool Schema、Memory、运行时指令和工具结果,仅按聊天消息数量判断,很容易出现两种相反的问题:
- 低估上下文:该压缩时没有压缩,最终被模型服务拒绝;
- 高估上下文:一个很短的正常请求,被误判成上下文超限;
- 污染历史:摘要直接写回 LangGraph Checkpoint,刷新后原始聊天记录被替换;
- 压缩后仍超限:虽然生成了 Summary,但保留尾部、固定 Prompt 和单条大消息仍可能超出窗口。
最终方案包含以下关键点:
- Summary 只修改本次
ModelRequest.messages,不改写 Checkpoint; - 使用 CJK-aware Token Counter,避免按英文字符比例低估中文;
- 将
keep: messages改为keep: tokens; - 使用 Provider 的真实
input_tokens校准本地估算; - 将简单倍率升级为"固定开销 + Tokenizer 比例"的线性模型;
- Summary 后再次检查请求,必要时继续裁剪旧消息;
- 单条最新用户消息已经过大时,直接返回可理解的提示;
- Provider 仍然返回上下文超限时,强制 Summary 并且只重试一次。
真实验证中,Summary 触发后 Provider Input 从约 46k 降到 22.9k,Checkpoint 中 Summary 消息数量保持为 0。
二、事故一:Summary 成功了,历史记录却少了
最初的摘要逻辑运行在 before_model 阶段。超过阈值后,它会返回一组消息更新:先删除原有消息,再写入 Summary 和保留的尾部消息。
简化后的旧逻辑类似:
python
def before_model(state, runtime):
summary = create_summary(old_messages)
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
summary,
*preserved_messages,
]
}
这段代码对于"减少 State 中的消息数量"是有效的,但它改变了 LangGraph State。State 随后会进入 Checkpoint,于是摘要不再只是模型内部使用的压缩材料,而成了新的持久化会话历史。
用户看到的现象就是:
- 长会话触发 Summary;
- 当前回答仍然可以正常生成;
- 页面刷新或重新进入会话;
- 原始消息被 Summary 和少量尾部消息替换;
- 用户认为历史记录"丢了"。
问题的关键不是 Summary 内容写得好不好,而是把两种不同语义的数据混在了一起:
- 用户历史:用于展示、审计和继续恢复,应当保存在 Checkpoint;
- 模型输入:只服务于当前一次推理,可以临时压缩。
三、核心改造:从修改 State 变成只修改 Request
新的实现让 before_model 和 abefore_model 永远返回 None,不再产生消息状态更新。真正的压缩发生在 wrap_model_call 和 awrap_model_call 中。
python
def before_model(self, state, runtime):
return None
def wrap_model_call(self, request, handler):
original_messages = list(request.messages)
summarized = self._summarized_messages_for_request(original_messages)
if summarized is not None:
request = request.override(messages=summarized)
return handler(request)
新旧链路的差别如下:
这叫 Request-only Summary:
- 模型看到的是
Summary + 最近消息; - Checkpoint 保存的仍然是完整原始消息;
- 前端刷新后能恢复完整历史;
- 下一轮是否再次压缩,由新的请求重新判断。
测试中还专门检查了两个细节:
- 超过阈值时,传给模型的是一个新的消息列表;
- 原列表的对象和顺序没有被修改,也没有生成
RemoveMessage。
四、为什么不能只计算聊天消息的 Token
Agent 的真实模型请求远不止聊天正文。可以把 Provider Input 粗略拆成:
text
Provider Input
= System Prompt
+ Tool Schema
+ Skill 和能力说明
+ Memory 注入
+ Runtime 指令
+ 用户与 AI 历史消息
+ Tool Result
本地中间件最容易看到的是最后三项,前面的固定内容可能在模型适配层或其他中间件中才被加入。
因此,下面这种估算不够:
text
estimated_tokens = visible_message_tokens
即使聊天历史只估算出几百 Token,Provider 实际收到的输入也可能已经超过一万 Token。
最终触发判断同时参考三种信号:
text
configured_estimate
= local_visible_tokens + configured_overhead
calibrated_estimate
= learned_fixed_overhead + tokenizer_scale × local_visible_tokens
reported_estimate
= latest_provider_input + tokens_added_after_last_response
effective_tokens
= max configured_estimate calibrated_estimate reported_estimate
取最大值是为了避免某一个估算信号偶然偏低。
五、中文 Token 不能继续按英文比例估算
常见近似计数器会使用"大约 4 个字符等于 1 个 Token"的经验值。这个经验对英文勉强可用,但对中文、日文、韩文会明显低估。
中间件因此把文本分成两部分分别计算:
python
cjk_tokens = ceil(cjk_chars / 1.7)
latin_tokens = ceil(non_cjk_chars / 4.0)
除了正文,还会计算:
- 消息角色;
- 消息名称;
- Tool Call 的结构化内容;
tool_call_id;- 每条消息的固定开销。
对应测试使用 500 个 CJK 字符验证:新的估算值必须显著高于原来的英文近似计数结果,同时纯英文场景仍应接近原估算。
这里要明确:CJK-aware Counter 依然是近似值。它解决的是明显低估,不可能代替每一家 Provider 的真实 Tokenizer,所以后面还需要 Usage 校准和 Provider 级兜底。
六、从简单倍率到线性模型
6.1 第一版校准为什么会误判短会话
第一版思路是记录:
text
scale = provider_input_tokens / local_visible_tokens
以后再用这个倍率放大本地估算。
长会话里,这个方法有一定参考价值;短会话里却会严重失真。真实样本中出现过:
| 本地可见消息估算 | Provider Input |
|---|---|
18 |
11136 |
46 |
11167 |
80 |
11199 |
可见消息只增加了几十 Token,Provider Input 却一直在 11k 左右。原因不是本地 Tokenizer 差了一百倍,而是请求中存在一大块固定 Prompt 和 Tool Schema。
如果直接计算比例:
text
11136 / 18 ≈ 618
再把 618 倍用于下一轮请求,任何正常请求都会被判断成超限。
当时先做了一次止血:本地消息少于 4000 tokens 的样本不参与倍率计算,并把倍率上限限制为 4.0。这修复了短工具请求被误拦的问题,但它仍然只是经验规则。
6.2 最终改成固定开销加可变开销
更合理的模型是:
text
provider_input_tokens
≈ fixed_overhead
+ tokenizer_scale × visible_message_tokens
大白话解释:
fixed_overhead:System Prompt、Tool Schema、Memory 等每轮都会出现的固定成本;visible_message_tokens:会随着对话变长而增加的消息成本;tokenizer_scale:本地近似计数与 Provider Tokenizer 之间的差异。
每次模型成功返回后,中间件会在 AIMessage 的内部 Metadata 中记录一组样本:
python
{
"local_input_tokens": local_input_tokens,
"provider_input_tokens": provider_input_tokens,
}
需要注意:这里记录的 Local Tokens 必须来自模型实际收到的 Request。如果本轮做过 Request-only Summary,就不能拿原始 Checkpoint 的完整历史长度去和压缩后的 Provider Input 配对,否则训练样本本身就是错的。
当前拟合还有几个保护条件:
- 少于两个有效样本时,使用配置的 Overhead 和
scale=1; - 样本 Local Tokens 的跨度小于
1000时,不做线性拟合; tokenizer_scale限制在1.0~4.0;- 固定开销取样本截距的偏保守分位值;
- 学到的固定开销不能小于配置的兜底值。
真实样本拟合结果约为:
text
fixed_overhead ≈ 11066
tokenizer_scale ≈ 1.7479
4 次真实 Provider Input 的预测误差都在 0.1% 以内。这个结果也证明了:短会话的大头确实是固定开销,而不是聊天正文。
七、为什么保留最近 N 条消息仍然不安全
假设配置为"Summary 后保留最近 20 条消息",这 20 条消息可能是:
- 20 条简短问答,总共几百 Token;
- 1 条巨大 Tool Result 加 19 条普通消息,总共几万 Token;
- 一条用户粘贴的长文档,本身已经超过输入预算。
所以消息条数不能代表消息大小。最终配置改成 Token Budget:
yaml
summarization:
enabled: true
trigger:
type: tokens
value: 49152
keep:
type: tokens
value: 24000
trim_tokens_to_summarize: 12000
overhead_tokens: 4000
use_reported_usage: true
cjk_chars_per_token: 1.7
latin_chars_per_token: 4.0
这些数字的含义是:
trigger=49152:接近模型安全输入上限时触发;keep=24000:Summary 后把整体请求压回更安全的预算;trim_tokens_to_summarize=12000:待摘要内容过大时分块处理;overhead_tokens=4000:没有足够历史样本时的固定开销兜底。
Token-based Keep 在换算可保留的可见消息时,还会扣掉固定开销:
text
visible_tail_budget
= keep_budget - fixed_overhead
local_tail_budget
= visible_tail_budget / tokenizer_scale
然后通过二分查找寻找截断位置,并回退到安全消息边界。这样不会因为逐条线性扫描而增加太多成本,也不会机械地保留固定条数。
八、Summary 不是结束,还要再做一次复核
生成 Summary 后,请求仍可能超限:
- Summary 本身比预期长;
- 保留尾部里存在大型 Tool Result;
- System Prompt 或 Tool Schema 后续变大;
- Memory 注入发生变化;
- Provider Tokenizer 与本地估算仍有误差。
因此代码会对 Summary + preserved tail 再次估算。如果仍超过阈值,就从 Summary 后面的最旧尾部消息开始继续删除,同时保留 Summary 和最新上下文。
待摘要的旧历史本身过大时,也不能只截取前一部分然后丢掉后半部分。实现会按 trim_tokens_to_summarize 分块生成 Summary,再按原顺序合并:
text
Summary chunk 1/3
Summary chunk 2/3
Summary chunk 3/3
对应测试会给每个分块放入不同标记,确认所有标记都进入了摘要请求,没有因为分块而静默丢失后面的历史。
九、最终的五层保护链
完整请求链路如下:
这五层分别是:
- 提前估算:尽可能在 Provider 拒绝前触发 Summary;
- Request-only:压缩只影响本次推理,不污染 Checkpoint;
- 压缩后复核:Summary 生成后仍要检查最终请求;
- 单条输入拦截:无法通过摘要解决的输入,不继续浪费调用;
- Provider 兜底:本地误判时相信 Provider,只强制压缩并重试一次。
重试逻辑只捕获明确的上下文长度错误,例如 maximum context length 和 context_length_exceeded。网络错误、鉴权错误或普通模型异常继续向上抛出,不能被错误地包装成上下文问题。
同步 wrap_model_call 和异步 awrap_model_call 都实现了相同保护,避免只有某一条调用链生效。
十、一个容易忽略的细节:先压缩工具结果,再判断是否需要 Summary
系统中还有一层轻量 Context Compression,会在模型调用前缩短已经过时的大型 Tool Result,而且同样不修改 Checkpoint。
如果 Summary 判断直接统计原始消息,可能因为一个本来就会被临时缩短的旧工具结果而提前触发,把完整历史永久摘要掉。虽然现在 Summary 已经不会改 Checkpoint,但无意义的 Summary 仍会增加一次模型调用和延迟。
因此触发判断使用的是压缩后的消息视图,Provider Usage 和历史校准样本仍从原始消息中读取:
这保证判断口径尽量接近模型真正收到的请求,而不是原始 State 的理论大小。
十一、真实验证结果
使用和测试环境一致的主要配置:
text
trigger = 49152 tokens
keep = 24000 tokens
trim_tokens_to_summarize = 12000 tokens
configured overhead = 4000 tokens
长中文会话验证结果:
| 指标 | 结果 |
|---|---|
| Summary 前 Provider Input | 约 46k |
| Summary 后 Provider Input | 22978 |
| 原始 Checkpoint 本地消息估算 | 19067 |
| Summary 后实际请求本地估算 | 4878 |
| Checkpoint 中 Summary 数量 | 0 |
这里 19067 和 4878 不能直接与 Provider Input 相等,因为 Provider Input 还包含固定 Prompt、Tool Schema 等不可见开销。它们的意义是证明:
- 原始 Checkpoint 仍然保留完整历史;
- 真正发送给模型的 Request 已经明显缩短;
- Summary 没有写回持久状态;
- Provider Input 回到安全范围,没有再触发 Context Overflow。
相关回归测试随着修复逐步从 41 passed 增加到 44 passed。
十二、测试矩阵
最终重点覆盖了这些场景:
| 场景 | 预期行为 |
|---|---|
| 中文长文本 | CJK 估算显著高于英文字符近似 |
| 纯英文文本 | 与原近似计数保持接近 |
| 未超过阈值 | 原消息对象直接进入模型调用 |
| 超过阈值 | 仅本次 Request 使用 Summary |
| Checkpoint 检查 | 不产生 RemoveMessage,不写入 Summary |
| 最新用户消息单独过大 | 不调用 Provider,直接返回可见提示 |
| Summary 后仍超限 | 继续裁剪旧尾部或安全失败 |
| Provider 首次返回 Context Overflow | 强制 Summary 后只重试一次 |
| Provider 重试仍超限 | 返回友好提示,不继续循环 |
| 普通 Provider 异常 | 原样抛出,不误判为上下文超限 |
| 同步和异步链路 | 行为保持一致 |
| Summary 内容需要分块 | 所有分块按顺序参与摘要 |
| Provider 返回 Usage | 记录实际 Request 的 Local/Provider 样本 |
| 短工具会话 | 不再被固定开销放大的倍率误杀 |
十三、这次改造最重要的六个认识
13.1 用户历史和模型上下文不是同一份数据
用户历史需要完整、稳定和可恢复;模型上下文可以为了本轮推理临时压缩。两者共享来源,但不应该共享写回语义。
13.2 Input Token 不只来自聊天正文
System Prompt、Tool Schema、Memory 和 Skill 都会占用输入窗口。只统计页面上能看到的消息,会系统性低估 Agent 请求。
13.3 固定开销不能用比例解释
短会话中分母很小,provider/local 比例会被固定开销无限放大。把固定项和可变项拆开,模型才符合真实 Prompt 结构。
13.4 Token Budget 比消息条数可靠
消息数量相同,体积可能相差几百倍。保留最近 N 条只控制了数组长度,没有控制模型输入。
13.5 本地估算永远不是最终真相
模型配置、Prompt、工具和 Tokenizer 都可能变化。估算负责提前保护,Provider Context Error 负责最后兜底。
13.6 兜底必须有边界
只重试一次,只捕获明确的上下文错误。否则一个保护机制可能变成无限 Summary、无限调用和 Token 浪费。
十四、面试复盘速记
如果面试官问这次工作,可以按下面顺序回答:
- 现象是什么? 长中文会话出现 Context Overflow;同时原 Summary 会改写 Checkpoint,刷新后历史被替换。
- 为什么会低估? 本地只统计可见消息,忽略 System Prompt、Tool Schema、Memory 等固定开销,而且英文字符比例会低估中文。
- 最核心的改造是什么? 把 Summary 从 State Mutation 改成 Request-only,只覆盖本次
ModelRequest.messages。 - 为什么改成 Token-based Keep? 固定消息条数无法约束大型 Tool Result 和长文本。
- 为什么简单 Provider/Local 比例失败? 短会话里固定开销占主导,分母太小会让倍率失真。
- 最终怎样估算? 使用
fixed_overhead + tokenizer_scale × visible_tokens,并结合最近 Provider Input 和新增尾部消息取保守最大值。 - 估算仍不准怎么办? 捕获明确的 Context Overflow,强制 Summary 后重试一次;仍失败则返回可见提示。
- 如何证明没有污染历史? 真实验证中 Provider Input 从约 46k 降到 22.9k,Checkpoint 中 Summary 数量为 0。
十五、总结
这次问题表面上是"长对话超过模型窗口",实际上同时涉及四个边界:
- LangGraph State 和 Model Request 的边界;
- 可见消息和真实 Provider Prompt 的边界;
- 本地 Token 估算和 Provider Tokenizer 的边界;
- 提前保护和最终错误兜底的边界。
最后形成的方案不是单点修复,而是一条完整保护链:
text
CJK-aware 估算
→ 固定开销与 Usage 校准
→ Token-based Keep
→ Request-only Summary
→ 压缩后复核
→ 单条输入拦截
→ Provider 超限重试一次
对 Agent 系统来说,最重要的不是"有没有 Summary",而是 Summary 在什么阶段发生、修改哪一份数据、如何判断触发,以及失败后是否有明确边界。