跳转到内容

07 - 上下文压缩与 Token 保护

学习目标

理解 CowAgent 的三层上下文截断策略、Token 估算算法与上下文溢出时的多级恢复机制。

前置知识

上下文压缩的通用原理见 上下文压缩与 Token 优化。本章聚焦 CowAgent 的具体实现。

项目实践

三层截断策略

CowAgent 的 _trim_messages() 实现了一个渐进式的三层截断策略:

关键决策:截断只在 run_stream() 循环开始前执行一次,循环中不截断。

Token 估算算法

CowAgent 使用基于字符类型的加权估算:

def _estimate_text_tokens(text: str) -> int:
non_ascii = sum(1 for c in text if ord(c) > 127) # CJK、emoji 等
ascii_count = len(text) - non_ascii
# CJK: ~1.5 tokens/字; ASCII: ~0.25 tokens/字
return int(non_ascii * 1.5 + ascii_count * 0.25) + 1

结构开销:

  • tool_use 块:50 tokens 基础开销 + 参数 JSON 文本
  • tool_result 块:30 tokens 基础开销 + 结果文本
  • 图片块:1200 tokens 固定开销

工具结果截断

CowAgent 对工具结果实施分级截断:

阶段截断限制说明
创建时50,000 字符工具执行结果在创建时就截断
历史轮20,000 字符历史 turn 的工具结果进一步截断
激进裁剪10,000 字符上下文溢出恢复时统一截断

上下文溢出恢复

当 API 返回上下文溢出错误时,_aggressive_trim_for_overflow() 执行三级降级:

  1. 截断所有工具结果到 10K 字符
  2. 截断超长用户消息到 10K 字符
  3. 仅保留最后 5 个完整 turn

如果仍然溢出,清空全部历史并清除数据库脏数据。

上下文摘要注入

截断时被移除的 turn 会通过 LLM 摘要注入到保留的第一个用户消息中:

# 伪代码 — 摘要回调注入
def _on_summary_ready(summary: str):
target_block["text"] = (
f"[System: Previous conversation summary — "
f"{turn_count} turns were compacted]\n\n"
f"{summary.strip()}\n\n"
f"The recent conversation continues below.\n\n---\n\n"
f"{original_text}"
)

这样 LLM 虽然看不到完整的旧 turn,但能了解其摘要,保持对话连续性。

问题与规避

问题CowAgent 的规避策略
截断导致 tool_use/tool_result 断裂以完整 turn 为单位截断,不拆分单个 turn
截断后 LLM 丢失上下文摘要回调注入到保留的用户消息中
Token 估算偏差导致实际溢出保守估算(CJK 1.5x)+ 10% 预留缓冲区
上下文溢出后数据库脏数据_clear_session_db() 清除破损的 session 数据
不同模型 tool 格式不兼容历史恢复只保留纯文本 user/assistant 对,剥离 tool 消息

设计取舍

为什么少 turn 时压缩而非丢弃?

当 turn 数量 < 5 时,CowAgent 选择压缩全部 turn 为纯文本(仅保留用户问题和助手回复),而不是丢弃任何 turn。注释说明:“With <5 turns, losing even one is too painful when context is thin.” 在上下文较少时,每条信息都可能有价值,丢弃的代价远大于压缩的损失。

为什么不在每个 turn 都检查 Token 预算?

在循环中裁剪会破坏当前轮次的 tool_use/tool_result 链——LLM 发出 tool_call 后,如果结果被裁剪,LLM 看不到结果就会重复调用相同的工具。这是一个经过 bug 修复后确定的设计:裁剪只在循环开始前执行一次。

参考来源