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() 执行三级降级:
- 截断所有工具结果到 10K 字符
- 截断超长用户消息到 10K 字符
- 仅保留最后 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 修复后确定的设计:裁剪只在循环开始前执行一次。