Agent 工程实习复盘 04|长对话越聊越容易崩?Request-only Summary 与 Token 预算治理

Agent 工程实习复盘 04|长对话越聊越容易崩?Request-only Summary 与 Token 预算治理

本文来自我在实习期间参与的一次真实 Agent 系统稳定性改造。项目名称、服务器地址、账号和内部配置均已脱敏,只保留通用架构、事故原因、代码设计和测试数据。

一、先说结论:这次解决了四类问题

长对话治理不是简单地"消息多了就做一次摘要"。真实 Agent 请求里还有 System Prompt、Tool Schema、Memory、运行时指令和工具结果,仅按聊天消息数量判断,很容易出现两种相反的问题:

  • 低估上下文:该压缩时没有压缩,最终被模型服务拒绝;
  • 高估上下文:一个很短的正常请求,被误判成上下文超限;
  • 污染历史:摘要直接写回 LangGraph Checkpoint,刷新后原始聊天记录被替换;
  • 压缩后仍超限:虽然生成了 Summary,但保留尾部、固定 Prompt 和单条大消息仍可能超出窗口。

最终方案包含以下关键点:

  1. Summary 只修改本次 ModelRequest.messages,不改写 Checkpoint;
  2. 使用 CJK-aware Token Counter,避免按英文字符比例低估中文;
  3. keep: messages 改为 keep: tokens
  4. 使用 Provider 的真实 input_tokens 校准本地估算;
  5. 将简单倍率升级为"固定开销 + Tokenizer 比例"的线性模型;
  6. Summary 后再次检查请求,必要时继续裁剪旧消息;
  7. 单条最新用户消息已经过大时,直接返回可理解的提示;
  8. 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,于是摘要不再只是模型内部使用的压缩材料,而成了新的持久化会话历史。

用户看到的现象就是:

  1. 长会话触发 Summary;
  2. 当前回答仍然可以正常生成;
  3. 页面刷新或重新进入会话;
  4. 原始消息被 Summary 和少量尾部消息替换;
  5. 用户认为历史记录"丢了"。

问题的关键不是 Summary 内容写得好不好,而是把两种不同语义的数据混在了一起:

  • 用户历史:用于展示、审计和继续恢复,应当保存在 Checkpoint;
  • 模型输入:只服务于当前一次推理,可以临时压缩。

三、核心改造:从修改 State 变成只修改 Request

新的实现让 before_modelabefore_model 永远返回 None,不再产生消息状态更新。真正的压缩发生在 wrap_model_callawrap_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)

新旧链路的差别如下:

graph LR A[完整聊天历史] --> B{是否需要压缩} B -->|否| C[原消息调用模型] B -->|是| D[生成 Summary 和保留尾部] D --> E[仅覆盖本次 ModelRequest] E --> F[调用模型] A --> G[原始消息继续写入 Checkpoint]

这叫 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

对应测试会给每个分块放入不同标记,确认所有标记都进入了摘要请求,没有因为分块而静默丢失后面的历史。

九、最终的五层保护链

完整请求链路如下:

graph TD A[收到本轮模型请求] --> B{最新用户消息是否单独超限} B -->|是| C[返回友好提示且不调用 Provider] B -->|否| D[压缩历史工具结果用于估算] D --> E{有效 Token 是否达到触发阈值} E -->|否| F[使用原消息调用 Provider] E -->|是| G[生成 Request-only Summary] G --> H[保留 Token Budget 内的最近上下文] H --> I{压缩后是否仍超限} I -->|是| J[继续裁剪旧尾部] I -->|否| K[调用 Provider] J --> L{是否已经能安全调用} L -->|否| C L -->|是| K F --> M{Provider 是否返回上下文超限} K --> M M -->|否| N[记录 Local 和 Provider Token 样本] M -->|是| O[强制 Summary 并重试一次] O --> P{重试是否仍然超限} P -->|是| C P -->|否| N

这五层分别是:

  1. 提前估算:尽可能在 Provider 拒绝前触发 Summary;
  2. Request-only:压缩只影响本次推理,不污染 Checkpoint;
  3. 压缩后复核:Summary 生成后仍要检查最终请求;
  4. 单条输入拦截:无法通过摘要解决的输入,不继续浪费调用;
  5. Provider 兜底:本地误判时相信 Provider,只强制压缩并重试一次。

重试逻辑只捕获明确的上下文长度错误,例如 maximum context lengthcontext_length_exceeded。网络错误、鉴权错误或普通模型异常继续向上抛出,不能被错误地包装成上下文问题。

同步 wrap_model_call 和异步 awrap_model_call 都实现了相同保护,避免只有某一条调用链生效。

十、一个容易忽略的细节:先压缩工具结果,再判断是否需要 Summary

系统中还有一层轻量 Context Compression,会在模型调用前缩短已经过时的大型 Tool Result,而且同样不修改 Checkpoint。

如果 Summary 判断直接统计原始消息,可能因为一个本来就会被临时缩短的旧工具结果而提前触发,把完整历史永久摘要掉。虽然现在 Summary 已经不会改 Checkpoint,但无意义的 Summary 仍会增加一次模型调用和延迟。

因此触发判断使用的是压缩后的消息视图,Provider Usage 和历史校准样本仍从原始消息中读取:

graph LR A[原始 Checkpoint 消息] --> B[临时压缩旧 Tool Result] B --> C[计算本轮可见消息 Token] A --> D[读取历史 Usage 校准样本] C --> E[计算 Effective Tokens] D --> E E --> F{是否触发 Summary}

这保证判断口径尽量接近模型真正收到的请求,而不是原始 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

这里 190674878 不能直接与 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 浪费。

十四、面试复盘速记

如果面试官问这次工作,可以按下面顺序回答:

  1. 现象是什么? 长中文会话出现 Context Overflow;同时原 Summary 会改写 Checkpoint,刷新后历史被替换。
  2. 为什么会低估? 本地只统计可见消息,忽略 System Prompt、Tool Schema、Memory 等固定开销,而且英文字符比例会低估中文。
  3. 最核心的改造是什么? 把 Summary 从 State Mutation 改成 Request-only,只覆盖本次 ModelRequest.messages
  4. 为什么改成 Token-based Keep? 固定消息条数无法约束大型 Tool Result 和长文本。
  5. 为什么简单 Provider/Local 比例失败? 短会话里固定开销占主导,分母太小会让倍率失真。
  6. 最终怎样估算? 使用 fixed_overhead + tokenizer_scale × visible_tokens,并结合最近 Provider Input 和新增尾部消息取保守最大值。
  7. 估算仍不准怎么办? 捕获明确的 Context Overflow,强制 Summary 后重试一次;仍失败则返回可见提示。
  8. 如何证明没有污染历史? 真实验证中 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 在什么阶段发生、修改哪一份数据、如何判断触发,以及失败后是否有明确边界。

参考资料

相关推荐
武子康6 小时前
GPT-Red:自动化红队如何形成 Agent 安全数据飞轮(4 个闭环 + 6 类评测指标 + 5 类风险误读)
人工智能·openai·agent
阿里云大数据AI技术6 小时前
DataWorks Data Agent 实战课堂(二):一句话搞定数据集成+处理,实现端到端数据流水线
人工智能·agent
像我这样帅的人丶你还7 小时前
MCP + npm:给五年前的老系统接上AI
前端·javascript·agent
牧艺7 小时前
从 Tool Calling 到 MCP Server:把业务能力做成 Agent 可复用接口
agent·全栈·mcp
码上解惑8 小时前
从模型接入到应用运行:智能体开发平台的整体架构设计
人工智能·agent·智能体·spring ai
FakeKesh19 小时前
学了一周Python,我决定手搓一个最小的AI Agent循环(附源码)
agent
程序员秋天9 小时前
用Spring AI实现多轮对话记忆,别再让AI每次都"失忆"
agent·ai编程
Xzh042311 小时前
智能体通信协议
agent
大强同学11 小时前
Kimi Code CLI
人工智能·agent·claude·skill·kimi code cli