上下文窗口管理:对话太长了怎么办
Day 08 那个 ReAct 循环,每转一圈就往 messages 里追加一条观察——转个十几圈,加上工具 schema、结构化 schema,对话就可能撑爆模型的"脑容量"(上下文窗口)。今天把这条线走通:LLM_CONTEXT_WINDOW_SIZES 这张"每个模型多大窗口"的表、get_context_window_size 为什么要乘一个 0.85 系数、is_context_length_exceeded 怎么从五花八门的报错里认出"超长"、handle_context_length 怎么决定"摘要压缩 vs 直接报错"、以及 summarize_messages 怎么把长历史切块压缩。
痛点:对话为什么会"装不下"?
LLM_CONTEXT_WINDOW_SIZES 表 + get_context_window_size,还留 15% 余量);②真超长时能认出来(is_context_length_exceeded 匹配各家报错关键词);③认出后按用户配置分流(respect_context_window=True → 摘要压缩历史后继续;否则 → 明确报错让用户上 RAG)。核心是"预留余量 + 超长自愈",而不是硬撞天花板。窗口大小表 + 那个神秘的 0.85
先看那张"模型→窗口大小"的静态表和两个常量(llm.py:168 与 llm.py:325):
# llm.py:168
LLM_CONTEXT_WINDOW_SIZES: Final[dict[str, int]] = {
"gpt-4": 8192,
"gpt-4o": 128000,
"gpt-4o-mini": 200000,
"gpt-4.1": 1047576, # 官方文档值
"o1-preview": 128000,
"o3-mini": 200000,
...
"mistral/mistral-large-latest": 32768,
}
# llm.py:325
DEFAULT_CONTEXT_WINDOW_SIZE: Final[int] = 8192 # 不认识的模型给个保守下限
CONTEXT_WINDOW_USAGE_RATIO: Final[float] = 0.85 # ★只用 85%,留 15% 余量
Final[dict]Final 标记"这是常量,别改"。一张硬编码的查询表,key 是模型名前缀,value 是官方窗口 token 数。值差异巨大从 gpt-4 的 8192 到 gpt-4.1 的 100 万+——不同模型脑容量差百倍,所以必须逐个记。DEFAULT = 8192★兜底:表里查不到的模型,保守假设只有 8192,宁可小估也不冒险超发。USAGE_RATIO = 0.85★核心设计:实际可用的当成"窗口 × 0.85"。为什么不用满?下面取舍详解。get_context_window_size 也明说了 "using 75% of the maximum to avoid cutting off messages mid-thread"(文档写 75%,常量实际是 85%,思路一致:都是留余量)。get_context_window_size:惰性算 + 缓存
把表和系数结合起来算出实际可用窗口(llm.py:2443):
# llm.py:2443
def get_context_window_size(self) -> int:
"""Returns 75% of the maximum to avoid cutting off messages mid-thread."""
if self.context_window_size != 0:
return self.context_window_size # ★已算过 → 直接返回缓存
min_context = 1024
max_context = 2097152 # gemini-1.5-pro 的上限
for key, value in LLM_CONTEXT_WINDOW_SIZES.items():
if value < min_context or value > max_context:
raise ValueError(f"Context window for {key} must be between ...") # 表自检
self.context_window_size = int(DEFAULT_CONTEXT_WINDOW_SIZE * CONTEXT_WINDOW_USAGE_RATIO)
for key, value in LLM_CONTEXT_WINDOW_SIZES.items():
if self.model.startswith(key): # ★前缀匹配模型名
self.context_window_size = int(value * CONTEXT_WINDOW_USAGE_RATIO)
return self.context_window_size
if != 0: return 缓存★惰性 + 缓存:Day 49 说过初始 context_window_size=0。第一次算完就存进字段,之后直接返回,不重复算。表自检 raise启动即校验:表里若有明显离谱的值(<1024 或 >209 万)直接报错——防止有人改表时手滑写错。先赋 DEFAULT × 0.85先给个兜底值(8192×0.85≈6963),万一模型名一个都不匹配也有合理下限。startswith(key)★前缀匹配:gpt-4o-2024-08-06 用 startswith("gpt-4o") 命中,不用把每个日期版本都列进表。× 0.85 存回字段命中就用"真实窗口×0.85"覆盖兜底值,存进 self.context_window_size 缓存。AnthropicCompletion.get_context_window_size)会覆盖它,用自家更准的窗口表。基类 BaseLLM(base_llm.py:494)则只返回保守的 DEFAULT_CONTEXT_WINDOW_SIZE。三层各司其职。llm = LLM(model="gpt-4o"),第一次调 llm.get_context_window_size():表里 "gpt-4o": 128000 命中,返回 int(128000 × 0.85) = 108800,并缓存。第二次调直接返回 108800,不再遍历表。所以框架认为 gpt-4o "实际能用" 约 10.8 万 token,留了 1.9 万余量。认出"超长"错误:关键词匹配
真超长时各家 API 报错文案五花八门,用一张关键词表统一识别(utilities/exceptions/context_window_exceeding_exception.py):
# context_window_exceeding_exception.py
CONTEXT_LIMIT_ERRORS: Final[list[str]] = [
"expected a string with maximum length",
"maximum context length",
"context length exceeded",
"context_length_exceeded",
"context window full",
"too many tokens",
"input is too long",
"exceeds token limit",
]
class LLMContextLengthExceededError(Exception):
@staticmethod
def _is_context_limit_error(error_message: str) -> bool:
return any(phrase.lower() in error_message.lower()
for phrase in CONTEXT_LIMIT_ERRORS) # ★任一关键词命中就算超长
上层用一个薄封装调它(utilities/agent_utils.py:698):
# agent_utils.py:698
def is_context_length_exceeded(exception: Exception) -> bool:
return LLMContextLengthExceededError(str(exception))._is_context_limit_error(str(exception))
CONTEXT_LIMIT_ERRORS 列表★把 OpenAI/Anthropic/各网关的"超长"报错文案都收集进一张表——因为没有统一错误码,只能靠文案关键词认。any(... in ... lower())报错消息里含任一关键词(不区分大小写)就判定为超长。宽松匹配,尽量不漏。is_context_length_exceeded给执行循环用的简洁入口——传进一个异常,返回"是不是超长导致的"。handle_context_length:压缩还是报错?
认出超长后,按用户配置的 respect_context_window 分流(utilities/agent_utils.py:712):
# agent_utils.py:712
def handle_context_length(respect_context_window, printer, messages, llm, callbacks, verbose=True):
"""Handle context length exceeded by either summarizing or raising an error."""
if respect_context_window: # ① 用户允许自动处理
if verbose:
printer.print("Context length exceeded. Summarizing content to fit ...", color="yellow")
summarize_messages(messages=messages, llm=llm, callbacks=callbacks, verbose=verbose) # 压缩
else: # ② 用户不允许
if verbose:
printer.print("Context length exceeded. Consider ... RAG tools ...", color="red")
raise SystemExit( # ★直接退出,不硬撞
"Context length exceeded and user opted not to summarize. "
"Consider using smaller text or RAG tools from crewai_tools.")
respect_context_windowDay 07/08 见过的开关(默认 False)。它决定"撑爆时是自动救还是直接停"。控制权交给用户。True → summarize_messages用户说"你看着办"→ 调 summarize_messages 把历史压缩到能塞下(L06)。黄色警告提示正在压缩。False → raise SystemExit★用户没开自动压缩 → 明确退出并给出建议(用更小的文本 / 上 RAG 工具),而不是默默截断丢信息。红色 vs 黄色细节:压缩用黄色(警告但能继续),报错用红色(严重需干预)。颜色即语义。respect_context_window=False,撑爆就明确报错并建议 RAG(治本:从源头减少塞进去的量);用户明确 =True 才启用有损压缩(治标:能继续但可能降质)。"有损的自动救援"是把双刃剑,把选择权交还用户,比框架擅自决定更负责任。summarize_messages:切块 + 摘要
压缩的核心思路是"切成小块、各自摘要、拼回去"(utilities/agent_utils.py:920,配合辅助函数):
# agent_utils.py:752 —— token 估算(保守启发式)
def _estimate_token_count(text: str) -> int:
return len(text) // 4 # ★经验值:约 4 个字符 ≈ 1 token
# agent_utils.py:819 —— 按消息边界切块
def _split_messages_into_chunks(messages, max_tokens):
non_system = [m for m in messages if m.get("role") != "system"] # system 不参与切块
chunks, current_chunk, current_tokens = [], [], 0
for msg in non_system:
msg_tokens = _estimate_token_count(str(msg.get("content") or ""))
if current_chunk and (current_tokens + msg_tokens) > max_tokens:
chunks.append(current_chunk); current_chunk = []; current_tokens = 0 # 满了另起一块
current_chunk.append(msg); current_tokens += msg_tokens
if current_chunk: chunks.append(current_chunk)
return chunks
# agent_utils.py:867 —— 从模型输出里抠摘要
def _extract_summary_tags(text: str) -> str:
match = re.search(r"<summary>(.*?)</summary>", text, re.DOTALL)
return match.group(1).strip() if match else text # 有标签取标签内,否则取全文
len(text) // 4★不精确算 token(那要 tokenizer、慢),用"4 字符≈1 token"的启发式快速估。保守估算够用即可。system 不切块系统提示(角色/目标)是灵魂,绝不能被压缩掉——切块时先排除它。按 max_tokens 切累加到快超一块的上限就"另起一块",保证每块都能塞进模型去做摘要。在消息边界切,不切断单条消息。<summary> 标签让模型把摘要写进标签里,框架用正则抠出——比让模型"直接回摘要"更可控(能过滤掉客套话)。接入点:执行循环怎么触发这一切
回到 Day 08 的执行循环,超长处理就挂在 except 里(agents/crew_agent_executor.py:447):
# crew_agent_executor.py:447(文本 ReAct 循环的异常分支)
if is_context_length_exceeded(e): # ① 认出是超长错误
handle_context_length( # ② 分流:压缩 or 报错
respect_context_window=self.respect_context_window,
printer=self._printer,
messages=self.messages, # 把当前对话历史传进去
llm=self.llm,
callbacks=self.callbacks,
)
continue # ③ ★压缩完 continue,接着转下一圈
原生工具调用循环里也有对称的一处(crew_agent_executor.py:582),逻辑相同。
在 except 里触发★不是主动预判,而是"真报错了"才处理——事后自愈。发一次请求,超长了,接住这个异常。is_context_length_exceeded(e)先用 L04 的关键词匹配确认"这个异常确实是超长导致的",不是别的错误。handle_context_length(...)传入 self.messages——注意它原地修改这个列表(压缩后的历史直接替换进去),不是返回新列表。continue★关键:压缩完不是结束,而是 continue 回到循环开头,用压缩后的短历史重新发一次请求。这次就塞得下了。边界 + 今日小结
👶 小白:既然会自动压缩,我干脆一直开着 respect_context_window=True 不就一劳永逸?
👨🏫 老师:不建议无脑常开。压缩是有损的——它会把详细历史换成摘要,多步精确推理任务可能因此丢关键细节而答错。而且每次压缩都要额外调一次(或多次)LLM 做摘要,费时费钱。更健康的做法是:从源头控制塞进去的量(用 Day 16 的 context 只传相关任务、用 RAG 检索而非全文塞入),把压缩当"最后的安全网"而非"日常依赖"。治本优于治标。
🧠 今天你应该能回答
- 为什么对话会越来越长、直到撑爆窗口?
CONTEXT_WINDOW_USAGE_RATIO=0.85为什么要留余量?get_context_window_size怎么惰性算 + 缓存 + 前缀匹配?- 超长错误为什么靠"关键词匹配"识别?这有什么脆弱性?
respect_context_window两个分支各做什么?为什么留这个开关?- 摘要压缩为什么要切块?为什么 system 消息不参与?
✋ 10 分钟动手
P=lib/crewai/src/crewai
sed -n '168,326p' $P/llm.py # 窗口表 + 两个常量
sed -n '2443,2470p' $P/llm.py # get_context_window_size
cat $P/utilities/exceptions/context_window_exceeding_exception.py # 超长识别
sed -n '698,760p' $P/utilities/agent_utils.py # handle_context_length
# 看看你的模型算出多大可用窗口
python -c "
from crewai import LLM
for m in ['gpt-4','gpt-4o','gpt-4.1']:
print(m, LLM(model=m).get_context_window_size())
"
UsageMetrics 数据模型、from_provider_dict 怎么把各家不同的 usage 字段归一化、TokenProcess 累加器、以及 crew 级 calculate_usage_metrics 怎么把全队消耗汇总。