让 Jarvis 学会遗忘:120 行代码解决 Context Window 崩溃

让 Jarvis 学会遗忘:120 行代码解决 Context Window 崩溃

Jarvis 聊到第 20 轮就崩了。

不是偶尔崩——是必然崩。越活跃的用户越快触发,因为根因藏在一行看起来人畜无害的代码里:

request_messages = [system_msg] + messages[-20:]

取最后 20 条消息。就这么简单。问题是:

  1. 20 条消息 ≠ 安全的 token 数 — 如果某个 tool call 返回了一大坨文件内容,20 条可能就爆了上下文窗口
  2. 前面的对话直接丢弃 — 没有摘要,没有压缩,聊过的就没了
  3. API 报 400 → 直接崩溃 — 没有 fallback,用户看到一个红色 RuntimeError

Claude、GitHub Copilot 这些产品遇到长对话时会自动压缩前文。Jarvis 也该有这个能力。

三层防御

小龙虾看完代码后,画了个分层策略:

Layer 1: Token-aware 窗口    — 按 token 预算从后往前填充
Layer 2: Compaction(压缩)   — 超限时 LLM 摘要早期对话
Layer 3: Graceful fallback   — API 报 context 错误时自动重试

三层互不依赖——任何一层单独都能改善现状,叠加起来就是完整的防御。

Layer 1: 不再数消息,数 token

替代 messages[-20:],改成按 token 预算从后往前填充:

def _fit_messages_to_window(self, messages, max_tokens):
    budget = max_tokens - RESPONSE_RESERVE - SYSTEM_RESERVE
    fitted = []
    used = 0
    for msg in reversed(messages):
        msg_tokens = self._estimate_tokens(msg.get("content", ""))
        if used + msg_tokens > budget:
            break
        fitted.insert(0, msg)
        used += msg_tokens
    return fitted

核心区别:以前是"最多 20 条",现在是"最多 N 个 token"。一条消息如果很长(比如 tool 返回了一整个文件),它占的 token 多,能装的消息就少。反过来,如果都是短消息,可以装远超 20 条。

Layer 2: 压缩而非丢弃

当消息总 token 超过 70% 限制时,主动触发压缩:

async def _compact_messages(self, messages, keep_recent=6):
    old = messages[:-6]    # 需要压缩的部分
    recent = messages[-6:]  # 保留最近 3 轮原始对话
    
    summary = await self._compact_summarize(...)  # LLM 生成摘要
    return [{"role": "system", "content": f"[对话摘要] {summary}"}] + recent

关键设计:摘要调用用的是 _compact_summarize,一个独立的轻量 LLM 调用,不走 chat_with_tools。为什么?因为 chat_with_tools 本身可能触发压缩——如果摘要请求也走同一个通道,就会无限递归。小龙虾在 Review 时抓到了这个隐患。

Layer 3: 被动兜底

即使前两层都没阻止 context 溢出(比如估算误差),API 返回 400 时也能优雅处理:

if response.status_code == 400 and "context" in error_body.lower():
    messages[:] = await self._compact_messages(messages)
    continue  # 压缩后重试当前 round

不崩溃,不丢消息,压缩后重试。

CJK Token 估算

一个有趣的细节:中文和英文的 token 密度完全不同。len(text) // 2 对英文偏保守但对中文偏激进——中文一个字 len() 返回 1,除以 2 得 0.5,但实际中文每字约 1-2 个 token。

小龙虾建议分开算:

def _estimate_tokens(self, text):
    cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
    ascii_chars = len(text) - cjk
    return cjk + ascii_chars // 4  # 中文 1:1,英文 4:1

不需要 tiktoken 那么精确——估算的目的是比 messages[-20:] 好,不是完美。

改动范围

只改了两个文件:

文件改动行数
src/llm/__init__.py5 个新方法 + chat_with_tools 集成三层防御+120 行
src/cli/chat.pyon_compaction 回调 + 📦 压缩提示+6 行

压缩发生时,用户会看到:

  📦 对话已压缩,早期内容已摘要

就像 Claude 的"context compressed"提示,让用户知道发生了什么。

测试

跑了一遍 Phase 4 的 9 个测试,全部通过,无回归。Phase 4.5 的定位跟 Phase 2.5 一样——基础设施修复,改动小但影响大。不加新功能,而是让现有功能在边界条件下不会崩溃。

为什么排在 Phase 5 之前

Phase 5(Skill Evolution Observer)会让 daemon 的 think loop 产生更多 LLM 调用——如果 context window 管理没有搞定,Phase 5 的额外调用量只会让崩溃更频繁。所以 4.5 是 5 的前置基础设施,必须先做。

从 messages[-20:] 到三层防御,Jarvis 终于学会了一件事:有时候,优雅地遗忘比拼命记住更重要。

Comments