Jarvis 聊到第 20 轮就崩了。
不是偶尔崩——是必然崩。越活跃的用户越快触发,因为根因藏在一行看起来人畜无害的代码里:
request_messages = [system_msg] + messages[-20:]
取最后 20 条消息。就这么简单。问题是:
- 20 条消息 ≠ 安全的 token 数 — 如果某个 tool call 返回了一大坨文件内容,20 条可能就爆了上下文窗口
- 前面的对话直接丢弃 — 没有摘要,没有压缩,聊过的就没了
- 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__.py | 5 个新方法 + chat_with_tools 集成三层防御 | +120 行 |
src/cli/chat.py | on_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 终于学会了一件事:有时候,优雅地遗忘比拼命记住更重要。

