Day 52 / 共 60 天 · 阶段8 LLM 与集成

上下文窗口管理:对话太长了怎么办

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 怎么把长历史切块压缩。

📍 你在 60 天里的位置(阶段8 LLM 与集成 · 共 6 天)
D49 LLM 抽象 D50 provider 适配 D51 函数调用/结构化 D52 上下文窗口 D53 token/成本 D54 遥测 阶段9 进阶
💡 先用一个类比兜住今天 上下文窗口就像一个人的短期记忆容量。你跟他聊天,他能记住的对话是有限的——超过就"这前面说的我忘了"。模型也一样,一次能"看到"的文字有个上限(token 数)。对话越聊越长,早晚顶到天花板。人怎么办?会把前面聊过的浓缩成一句"刚才我们讨论了 X、决定了 Y",腾出脑子继续。CrewAI 的做法一模一样:快撑爆时,把旧历史摘要压缩成一小段,再接着跑。今天看它怎么测容量、怎么压缩。
L01

痛点:对话为什么会"装不下"?

🤔 痛点模型不是号称能读很长吗,为什么还会超长?因为每一次调用,整个对话历史都要重新发一遍——模型没有真正的"记忆",你想让它记得前面说过啥,只能把之前所有消息再塞进这次请求。Day 08 的循环转 10 圈,第 10 次请求就带着前 9 圈的全部问答 + 工具结果。再叠加 Day 51 的工具 schema、结构化 schema,token 数蹭蹭涨。一旦超过模型窗口,API 直接报错整个任务挂掉。怎么在撑爆前优雅处理?
💡 一句话本质 CrewAI 分三步应对:①知道每个模型多大窗口LLM_CONTEXT_WINDOW_SIZES 表 + get_context_window_size,还留 15% 余量);②真超长时能认出来is_context_length_exceeded 匹配各家报错关键词);③认出后按用户配置分流respect_context_window=True → 摘要压缩历史后继续;否则 → 明确报错让用户上 RAG)。核心是"预留余量 + 超长自愈",而不是硬撞天花板。
大白话三件事:先量好"这个模型脑子多大",再在真塞爆时"认出这是撑爆了而不是别的错",最后"要么帮它把旧记忆压缩腾地方、要么老实告诉你该换方案"。这条链路平时你感觉不到,但它是长任务不崩的关键保险。
L02

窗口大小表 + 那个神秘的 0.85

先看那张"模型→窗口大小"的静态表和两个常量(llm.py:168llm.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"。为什么不用满?下面取舍详解。
💡 设计取舍①:为什么只用 85%,白白浪费 15%? 模型标称的窗口是"输入 + 输出"的总和。如果你把输入塞到 100% 满,模型就一个字都吐不出来了——没地方放回答。而且 token 计数是估算的(不同 tokenizer 有偏差),贴着上限走很容易实际超一点点就报错。留 15% 余量,既给回答留了空间,又给估算误差留了缓冲。朴素做法"用满窗口"看似高效,实则频繁在边界翻车;留余量看似浪费,实则换来稳定。这是"为不确定性预留 buffer"的经典工程判断——注释里 get_context_window_size 也明说了 "using 75% of the maximum to avoid cutting off messages mid-thread"(文档写 75%,常量实际是 85%,思路一致:都是留余量)。
L03

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-06startswith("gpt-4o") 命中,不用把每个日期版本都列进表。
× 0.85 存回字段命中就用"真实窗口×0.85"覆盖兜底值,存进 self.context_window_size 缓存。
注意这是 litellm 版 LLM 的实现。Day 50 讲过原生 provider(如 AnthropicCompletion.get_context_window_size)会覆盖它,用自家更准的窗口表。基类 BaseLLMbase_llm.py:494)则只返回保守的 DEFAULT_CONTEXT_WINDOW_SIZE。三层各司其职。
📝 例子:gpt-4o 的可用窗口 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 万余量。
数据结构:窗口的三层来源 LLM_CONTEXT_WINDOW_SIZES gpt-4 : 8192 gpt-4o : 128000 gpt-4.1 : 1047576 ... (前缀匹配) 查不到 → DEFAULT 8192 × 0.85 USAGE_RATIO self.context_window_size = 108800(算一次后缓存) 下次直接返回,不再遍历
图注:静态表(前缀匹配,查不到用 DEFAULT)× 0.85 系数 → 算一次缓存进实例字段。
L04

认出"超长"错误:关键词匹配

真超长时各家 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给执行循环用的简洁入口——传进一个异常,返回"是不是超长导致的"。
⚠️ 边界:靠"报错文案关键词"识别是脆弱的 这套机制有个天生的坑:它依赖各家 API 报错消息里的英文文案。如果某家 provider 改了措辞(比如把 "context length exceeded" 换成 "prompt too large"),而关键词表没收录,就会识别失败——超长错误被当成"未知错误"抛出,触发不了自动压缩。这就是为什么表里列了 8 种不同说法尽量覆盖。教训:当底层没有稳定的错误码、只能靠文案匹配时,要么维护一张尽量全的关键词表,要么就得接受偶尔漏判。更稳的做法是提前用 token 计数预判(在发送前就估算会不会超),但那又依赖准确的 tokenizer,同样有成本。这是"事后认错 vs 事前预判"的现实权衡。
L05

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 黄色细节:压缩用黄色(警告但能继续),报错用红色(严重需干预)。颜色即语义。
💡 设计取舍②:超长了,为什么不无脑自动压缩,非要留个开关? 自动摘要压缩会丢信息——把 20 条详细对话压成 3 句摘要,某些细节必然损失。对有些任务(闲聊、总结)无所谓,对另一些(精确的多步推理、代码生成)可能压缩后就答错了。所以源码不替用户做主:默认 respect_context_window=False,撑爆就明确报错并建议 RAG(治本:从源头减少塞进去的量);用户明确 =True 才启用有损压缩(治标:能继续但可能降质)。"有损的自动救援"是把双刃剑,把选择权交还用户,比框架擅自决定更负责任。
L06

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> 标签让模型把摘要写进标签里,框架用正则抠出——比让模型"直接回摘要"更可控(能过滤掉客套话)。
大白话历史太长塞不下?那就把它切成几段能塞下的小块,每块让模型"帮我总结一下",再把这些小结拼起来当新的、短得多的历史。就像把一本厚书先分章缩写、再把缩写拼成一页纲要。system 提示(你是谁、要干嘛)不动,只压缩中间的问答过程。
L07

接入点:执行循环怎么触发这一切

回到 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 回到循环开头,用压缩后的短历史重新发一次请求。这次就塞得下了。
控制流:超长自愈的闭环 发请求(带全部历史) API 抛超长异常 is_context_length_exceeded? 是 respect=True → 摘要压缩 messages respect=False → SystemExit 报错 continue:用压缩后的短历史重发
图注:超长→识别→分流。压缩这条路会 continue 重发形成闭环;报错那条路终止任务。
L08

边界 + 今日小结

👶 小白:既然会自动压缩,我干脆一直开着 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())
"
明日预告 · Day 53:压缩省的是"窗口空间",但每次调用还在花"钱"和"token"。明天讲token / 成本 / usage 追踪UsageMetrics 数据模型、from_provider_dict 怎么把各家不同的 usage 字段归一化、TokenProcess 累加器、以及 crew 级 calculate_usage_metrics 怎么把全队消耗汇总。
← Day 51 函数调用 Day 53 · token/成本/usage →