AI 的理想与现实:从 Stack Overflow 调查看开发团队如何落地 AI Coding
2026-07-22 3415781
2026-07-22 0

长对话治理不是简单地“消息多了就做一次摘要”。真实 Agent 请求里还有 System Prompt、Tool Schema、Memory、运行时指令和工具结果,仅按聊天消息数量判断,很容易出现两种相反的问题:
最终方案包含以下关键点:
ModelRequest.messages,不改写 Checkpoint;keep: messages 改为 keep: tokens;input_tokens 校准本地估算;真实验证中,Summary 触发后 Provider Input 从约 46k 降到 22.9k,Checkpoint 中 Summary 消息数量保持为 0。
最初的摘要逻辑运行在 before_model 阶段。超过阈值后,它会返回一组消息更新:先删除原有消息,再写入 Summary 和保留的尾部消息。
简化后的旧逻辑类似:
复制代码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 内容写得好不好,而是把两种不同语义的数据混在了一起:
新的实现让 before_model 和 abefore_model 永远返回 None,不再产生消息状态更新。真正的压缩发生在 wrap_model_call 和 awrap_model_call 中。
复制代码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 + 最近消息;测试中还专门检查了两个细节:
RemoveMessage。Agent 的真实模型请求远不止聊天正文。可以把 Provider Input 粗略拆成:
复制代码Provider Input
= System Prompt
+ Tool Schema
+ Skill 和能力说明
+ Memory 注入
+ Runtime 指令
+ 用户与 AI 历史消息
+ Tool Result
本地中间件最容易看到的是最后三项,前面的固定内容可能在模型适配层或其他中间件中才被加入。
因此,下面这种估算不够:
复制代码estimated_tokens = visible_message_tokens
即使聊天历史只估算出几百 Token,Provider 实际收到的输入也可能已经超过一万 Token。
最终触发判断同时参考三种信号:
复制代码configured_estimate
= local_visible_tokens + configured_overheadcalibrated_estimate
= learned_fixed_overhead + tokenizer_scale × local_visible_tokensreported_estimate
= latest_provider_input + tokens_added_after_last_responseeffective_tokens
= max configured_estimate calibrated_estimate reported_estimate
取最大值是为了避免某一个估算信号偶然偏低。
常见近似计数器会使用“大约 4 个字符等于 1 个 Token”的经验值。这个经验对英文勉强可用,但对中文、日文、韩文会明显低估。
中间件因此把文本分成两部分分别计算:
复制代码cjk_tokens = ceil(cjk_chars / 1.7)
latin_tokens = ceil(non_cjk_chars / 4.0)
除了正文,还会计算:
tool_call_id;对应测试使用 500 个 CJK 字符验证:新的估算值必须显著高于原来的英文近似计数结果,同时纯英文场景仍应接近原估算。
这里要明确:CJK-aware Counter 依然是近似值。它解决的是明显低估,不可能代替每一家 Provider 的真实 Tokenizer,所以后面还需要 Usage 校准和 Provider 级兜底。
第一版思路是记录:
复制代码scale = provider_input_tokens / local_visible_tokens
以后再用这个倍率放大本地估算。
长会话里,这个方法有一定参考价值;短会话里却会严重失真。真实样本中出现过:
| 本地可见消息估算 | Provider Input |
|---|---|
18 | 11136 |
46 | 11167 |
80 | 11199 |
可见消息只增加了几十 Token,Provider Input 却一直在 11k 左右。原因不是本地 Tokenizer 差了一百倍,而是请求中存在一大块固定 Prompt 和 Tool Schema。
如果直接计算比例:
复制代码11136 / 18 ≈ 618
再把 618 倍用于下一轮请求,任何正常请求都会被判断成超限。
当时先做了一次止血:本地消息少于 4000 tokens 的样本不参与倍率计算,并把倍率上限限制为 4.0。这修复了短工具请求被误拦的问题,但它仍然只是经验规则。
更合理的模型是:
复制代码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 中记录一组样本:
复制代码{
"local_input_tokens": local_input_tokens,
"provider_input_tokens": provider_input_tokens,
}
需要注意:这里记录的 Local Tokens 必须来自模型实际收到的 Request。如果本轮做过 Request-only Summary,就不能拿原始 Checkpoint 的完整历史长度去和压缩后的 Provider Input 配对,否则训练样本本身就是错的。
当前拟合还有几个保护条件:
scale=1;1000 时,不做线性拟合;tokenizer_scale 限制在 1.0~4.0;真实样本拟合结果约为:
复制代码fixed_overhead ≈ 11066
tokenizer_scale ≈ 1.7479
4 次真实 Provider Input 的预测误差都在 0.1% 以内。这个结果也证明了:短会话的大头确实是固定开销,而不是聊天正文。
假设配置为“Summary 后保留最近 20 条消息”,这 20 条消息可能是:
所以消息条数不能代表消息大小。最终配置改成 Token Budget:
复制代码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 在换算可保留的可见消息时,还会扣掉固定开销:
复制代码visible_tail_budget
= keep_budget - fixed_overheadlocal_tail_budget
= visible_tail_budget / tokenizer_scale
然后通过二分查找寻找截断位置,并回退到安全消息边界。这样不会因为逐条线性扫描而增加太多成本,也不会机械地保留固定条数。
生成 Summary 后,请求仍可能超限:
因此代码会对 Summary + preserved tail 再次估算。如果仍超过阈值,就从 Summary 后面的最旧尾部消息开始继续删除,同时保留 Summary 和最新上下文。
待摘要的旧历史本身过大时,也不能只截取前一部分然后丢掉后半部分。实现会按 trim_tokens_to_summarize 分块生成 Summary,再按原顺序合并:
复制代码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
这五层分别是:
重试逻辑只捕获明确的上下文长度错误,例如 maximum context length 和 context_length_exceeded。网络错误、鉴权错误或普通模型异常继续向上抛出,不能被错误地包装成上下文问题。
同步 wrap_model_call 和异步 awrap_model_call 都实现了相同保护,避免只有某一条调用链生效。
系统中还有一层轻量 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 的理论大小。
使用和测试环境一致的主要配置:
复制代码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 等不可见开销。它们的意义是证明:
相关回归测试随着修复逐步从 41 passed 增加到 44 passed。
最终重点覆盖了这些场景:
| 场景 | 预期行为 |
|---|---|
| 中文长文本 | CJK 估算显著高于英文字符近似 |
| 纯英文文本 | 与原近似计数保持接近 |
| 未超过阈值 | 原消息对象直接进入模型调用 |
| 超过阈值 | 仅本次 Request 使用 Summary |
| Checkpoint 检查 | 不产生 RemoveMessage,不写入 Summary |
| 最新用户消息单独过大 | 不调用 Provider,直接返回可见提示 |
| Summary 后仍超限 | 继续裁剪旧尾部或安全失败 |
| Provider 首次返回 Context Overflow | 强制 Summary 后只重试一次 |
| Provider 重试仍超限 | 返回友好提示,不继续循环 |
| 普通 Provider 异常 | 原样抛出,不误判为上下文超限 |
| 同步和异步链路 | 行为保持一致 |
| Summary 内容需要分块 | 所有分块按顺序参与摘要 |
| Provider 返回 Usage | 记录实际 Request 的 Local/Provider 样本 |
| 短工具会话 | 不再被固定开销放大的倍率误杀 |
用户历史需要完整、稳定和可恢复;模型上下文可以为了本轮推理临时压缩。两者共享来源,但不应该共享写回语义。
System Prompt、Tool Schema、Memory 和 Skill 都会占用输入窗口。只统计页面上能看到的消息,会系统性低估 Agent 请求。
短会话中分母很小,provider/local 比例会被固定开销无限放大。把固定项和可变项拆开,模型才符合真实 Prompt 结构。
消息数量相同,体积可能相差几百倍。保留最近 N 条只控制了数组长度,没有控制模型输入。
模型配置、Prompt、工具和 Tokenizer 都可能变化。估算负责提前保护,Provider Context Error 负责最后兜底。
只重试一次,只捕获明确的上下文错误。否则一个保护机制可能变成无限 Summary、无限调用和 Token 浪费。
如果面试官问这次工作,可以按下面顺序回答:
ModelRequest.messages。fixed_overhead + tokenizer_scale × visible_tokens,并结合最近 Provider Input 和新增尾部消息取保守最大值。这次问题表面上是“长对话超过模型窗口”,实际上同时涉及四个边界:
最后形成的方案不是单点修复,而是一条完整保护链:
复制代码CJK-aware 估算
→ 固定开销与 Usage 校准
→ Token-based Keep
→ Request-only Summary
→ 压缩后复核
→ 单条输入拦截
→ Provider 超限重试一次
对 Agent 系统来说,最重要的不是“有没有 Summary”,而是 Summary 在什么阶段发生、修改哪一份数据、如何判断触发,以及失败后是否有明确边界。