场景:最猝不及防的一种死法
长任务跑到第 30 轮,一个大工具结果塞进来,下一轮请求直接被 API 拒了:
Error: prompt_too_long / maximum context length exceeded ...
此时最不能做的就是把异常抛给用户——前面 30 轮的工作全卡在半空。我的做法是修一条"紧急逃生通道":识别它 → 立刻瘦身 → 马上重发,任务自己缓过来。
第一关:四家服务商,四种报错措辞
DeepSeek 说 prompt_too_long,OpenAI 说 maximum context length,还有 context_length、too long 各种变体。如果识别口径不统一,OpenAI 风格的错误会走普通失败重试——白烧一次熔断次数。
所以我全项目只认一个判定函数,源码在 agent/context_compressor.py:
# PTL 错误识别的统一口径(四家服务商措辞全认)。此前三处各写各的
# 子集(摘要重试认 2 种、主循环认 3 种、丢条数计算认 4 种)——OpenAI
# 风格的 "maximum context length ..." 在摘要重试路径会被当普通失败,
# 白计一次熔断还降级规则总结。全项目判定一律走 is_prompt_too_long_error
_PTL_MARKERS = (
"prompt_too_long", "context_length", "too long", "maximum context",
)
def is_prompt_too_long_error(err_str: str) -> bool:
"""判断一段错误文本是不是「输入超长」(PTL)。四家措辞口径统一。"""
s = (err_str or "").lower()
return any(kw in s for kw in _PTL_MARKERS)
教训藏在注释里:错误分类这类"知识"必须收敛到一处,三个地方各写一个子集版本,迟早有人只改一处。
第二关:紧急压缩函数(核心源码)
识别出来之后立刻瘦身重发。做法简单粗暴:只留 system + 一条边界占位 + 最近 5 条消息,其余的靠落盘的 transcript 快照找回。源码在 agent/context_pipeline.py:
# 紧急压缩的两道保险默认值(config 可覆盖):冷却窗口秒数 / 单会话次数上限
REACTIVE_COOLDOWN_SECONDS = 60
REACTIVE_MAX_PER_SESSION = 5
def reactive_compact(
messages: list,
*,
session_state: CompressionSessionState,
keep_recent: int = 5,
cooldown_seconds: float = REACTIVE_COOLDOWN_SECONDS,
max_per_session: int = REACTIVE_MAX_PER_SESSION,
now_fn=time.time,
) -> Tuple[list, bool]:
"""紧急通道:API 报「对话超长」(prompt_too_long)时立刻调用。
做法简单粗暴:只留 system + 一条说明占位 + 最后 keep_recent 条消息,
其余全靠 transcript 文件找回。
**可多次触发**:每次报错都可以再压,但有两道保险:
1. 冷却窗口:距上次触发不足 cooldown_seconds 秒(默认 60s)→ 跳过
2. 单会话上限:本场已触发 max_per_session 次(默认 5)→ 跳过
"""
# 保护 1:冷却窗口
now = now_fn()
elapsed = now - session_state.reactive_last_at
if session_state.reactive_count > 0 and elapsed < cooldown_seconds:
logger.info(
"reactive_compact 冷却中(距上次 %ds < %ds),跳过",
int(elapsed), int(cooldown_seconds),
)
return messages, False
# 保护 2:单会话上限
if session_state.reactive_count >= max_per_session:
logger.warning(
"reactive_compact 达到单会话上限 %d 次,跳过",
session_state.reactive_count,
)
return messages, False
system, conv = _split_system(messages)
keep = conv[-keep_recent:] if len(conv) > keep_recent else conv[:]
placeholder = {
"role": "user",
"content": (
# 边界前缀和大写统一:恢复侧 _truncate_at_last_compact_boundary
# 只认 [COMPACT_BOUNDARY] 开头,reactive 的占位也得能被裁剪定位
"[COMPACT_BOUNDARY]\n"
"[紧急上下文压缩:API 返回 prompt_too_long,"
f"已只保留最近 {len(keep)} 条消息。"
"如 .transcripts/ 下有快照(latest.txt 指向最新一份)可 "
"read_file 分段找回;无快照则被裁原文已不可恢复]"
),
}
new_conv = [placeholder] + keep
new_conv = _fix_tool_call_pairs(new_conv)
new_messages = _reassemble(system, new_conv)
session_state.reactive_last_at = now
session_state.reactive_count += 1
return new_messages, True
这 60 行里藏了四个细节
1. 为什么允许触发 5 次而不是 1 次? 砍到只剩 5 条后,下一轮又来一个巨型工具结果,还会再超——所以留了多次触发的余地。但必须有冷却窗口(60 秒)+ 次数上限(5 次)两道闸,否则会陷入"压了又超、超了又压"的死亡循环,日志和账单一起爆炸。
2. 占位消息以 [COMPACT_BOUNDARY] 开头。 会话恢复时只认这个前缀来定位"历史上次压到哪",紧急压缩和常规压缩共用同一套边界标记——逃生通道也得上户口,不然恢复逻辑找不到断点。
3. _fix_tool_call_pairs 不能省。 从中间砍消息,很容易把"模型要调工具"和"工具结果"切成两半——孤儿 tool_call 会让下一次请求直接被 API 拒掉(400)。砍完必须对账补配。
4. 被砍的内容给了一条活路。 压缩前完整对话已落盘 .transcripts/,占位消息里明确告诉模型:可以去读快照找回上下文。丢的是上下文窗口里的位置,不是信息本身。
小结
三句话带走:
- 错误识别统一口径:四家服务商措辞全收进一个函数,全项目只认它
- 紧急压缩 = 保 system + 边界占位 + 最近 5 条,立刻重发不断线
- 两道保险(冷却 60s / 上限 5 次)防死亡循环,砍完修 tool_call 配对
下一篇讲它的上游:四级上下文压缩怎么分层——目标是让任务压根走不到"报超长"这一步(照样附源码)。
仓库在这,注释全中文,欢迎 Star ⭐:github.com/Harvil1/cod…
把错误识别收敛、压缩后工具调用配对等生产级细节讲透了,附源码可直接借鉴。适合做长任务智能体、多模型接入的团队参考。