ContextChef (1):为什么要"编译上下文"
5 Mar 2026
2 min read
English version: ContextChef (1): Why “Compile” Your Context
你的 Agent 在前 30 轮表现完美,然后在第 51 轮突然忘掉了用户三十条消息前说过的约束,开始生成明显错误的结果。你加大 temperature、换模型、重写 prompt,问题时好时坏。
这不是模型的问题,是上下文的问题。
行业先到了同一个结论
2025 年前后,几家认真做 Agent 的厂商陆续发了技术博客,话题不约而同地集中在上下文管理上。
Manus 在 Context Engineering for AI Agents: Lessons from Building Manus 里说,他们把 Agent 框架重写了四次,才找到现在的局部最优解。核心结论是:KV-cache 命中率是生产级 Agent 最重要的单一指标,比模型能力更重要——因为 cache miss 意味着成本暴涨和延迟翻倍。Anthropic 则在 Effective Context Engineering for AI Agents 里正式把这个方向命名为 context engineering,与 prompt engineering 区分开来,并提出 context rot 的概念:随着 token 数量增长,模型的信息召回精度会系统性下降,不等到窗口上限就已经开始出问题了。Letta(MemGPT 的继任者)用 OS 类比把上下文窗口拆解为 kernel context 和 user context,前者是 system prompt、memory blocks、工具 schema 这些被管理的结构,后者是流动的消息缓冲区。
这些文章出发点各异,但有一个共同的底层判断:上下文不是随便塞进去的,它需要被设计、被压缩、被结构化。在每次 LLM 调用之前,有一层编排工作要做,而这层工作当前散落在每个项目各自的胶水代码里。
ContextChef 是把这些工程实践提炼成一套 TypeScript 可用的编译管道的尝试。
四个反复出现的问题
不同项目的胶水代码不同,但要解决的问题高度重叠:
对话太长,模型会忘事。 128k 的上下文窗口听起来很大,但工具密集型 Agent 跑 20 分钟就能填满。更糟的是 context rot——注意力在超长上下文里会稀释,早期的约束被”淹没”,模型开始偏离。
工具太多,模型会幻觉。 50 个工具的 schema 注入进来大约 5000 token,而且语义相近的工具之间会产生竞争,导致错误调用甚至凭空编造参数。Anthropic 的工程团队写道:“如果一个人类工程师都无法确定在某个情况下该用哪个工具,AI agent 也别指望能做得更好。”
切换供应商要重写 prompt。 Anthropic 有 prefill 和 cache breakpoint,OpenAI 没有。Gemini 的 tool call 格式又完全不同。一套调好的 prompt 架构,换供应商就要整体重构。
长程任务会跑偏。 System prompt 是静态的,任务状态是动态的。到了第 8 步,模型可能已经不记得第 1 步确定的约束。Manus 的解法是每步都把 todo.md 写进上下文末尾——用重述(recitation)把目标拉回模型的近期注意力。
编译器,而不是框架
ContextChef 的定位是 context compiler,不是 Agent 框架。
它不接管控制流,不决定何时调用模型、何时执行工具、出错了怎么重试。它只在一个时间点介入:你准备好了所有的原始状态(历史、工具列表、任务状态),准备发出 LLM 调用的那一刻。在这个时间点,它把你的状态编译成目标供应商的最优 payload。
const payload = await chef
.setTopLayer([systemPrompt]) // 静态前缀(cache 锚点)
.useRollingHistory(conversationHistory) // 历史(Janitor 自动压缩)
.setDynamicState(TaskSchema, state) // 任务状态(Zod 类型注入)
.compile({ target: "anthropic" }); // 编译到目标供应商这个设计的核心是机制而非策略:ContextChef 提供压缩管道、截断钩子、格式适配,但你决定什么时候压缩、用什么模型摘要、保留多少历史。策略留在你的业务逻辑里,机制放在库里复用。
三明治模型:上下文的物理结构
在开始介绍各个模块之前,有一个更基础的问题需要先回答:当你把 system prompt、历史消息、任务状态、记忆块这些东西都准备好之后,它们应该按什么顺序拼装成消息数组?
这个问题看起来平凡,实际上有两个互相竞争的目标:
KV-cache 稳定性要求上下文的前缀尽量不变。Manus 提到,哪怕 system prompt 里加了一个时间戳,从那个 token 往后的所有内容都无法命中 cache,等于全量重新 prefill。所以静态的内容应该放在最前面,一旦确定就不动。
Recency Bias 则要求动态的任务状态要离生成点尽可能近。LLM 对消息数组末尾的内容注意力最高,对中间内容注意力最低——这就是著名的 “Lost in the Middle” 问题。如果你把当前任务状态放在 system prompt 里,它在长对话中会被几十轮历史”淹没”,模型的实际行为越来越偏离这个状态。
这两个目标的解法方向刚好相反——一个要把重要内容放前面,一个要把重要内容放后面。
三明治模型是 ContextChef 对这个矛盾的解法:把”静态”和”动态”分开放,分别满足各自的目标。
┌─────────────────────────────────┐
│ Top Layer(静态 system prompt) │ ← 永不变动,KV-cache 前缀锚点
│ Core Memory(持久记忆块) │ ← 也相对稳定
├─────────────────────────────────┤
│ Rolling History(压缩后历史) │ ← 每轮追加,Janitor 管理
├─────────────────────────────────┤
│ Dynamic State(注入最后一条 │ ← 每轮都是最新状态,紧贴生成点
│ user 消息末尾) │
└─────────────────────────────────┘关键在最后一层:动态状态不是作为独立的 system message 追加在末尾,而是注入到最后一条 user 消息的内容里。这样它既保持在消息数组的末端(recency 最优),又不打乱消息角色结构(history 的连续性不被破坏)。Assembler 模块负责这个物理拼装步骤,以及另一个小细节:对消息的 JSON key 做字典序排序,保证相同内容产生完全一致的字节序列,进一步提升 cache 命中率。
这就是为什么 ContextChef 的 API 要区分 setTopLayer()、useRollingHistory()、setDynamicState()——它们对应三明治的三层,有各自不同的处理逻辑和生命周期。
六个模块,覆盖六类问题
| 模块 | 对应的问题 | 灵感来源 |
|---|---|---|
| Janitor | 历史压缩,避免窗口溢出 | Anthropic 的 compaction 实践 |
| Pruner | 工具裁剪,消除幻觉 | Manus 的 Mask Don’t Remove 原则 |
| Assembler | 动态状态注入,防止任务漂移 | Manus 的 recitation / todo 模式 |
| Offloader/VFS | 大输出卸载,保留 URI 指针 | Manus 的 File System as Context |
| Core Memory | 跨会话持久记忆 | Letta/MemGPT 的 memory blocks |
| Adapters | 一套代码适配多供应商 | 缓解供应商锁定 |
后续每篇深入一个模块,重点讲设计取舍而不是 API 用法——API 文档在 README 里已经够详细了。