MyPrototypeWhat

ContextChef (1):为什么要"编译上下文"

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
  .setSystemPrompt([systemPrompt])    // 静态前缀(cache 锚点)
  .setHistory(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 前缀锚点
│  Memory(持久记忆块)             │  ← 也相对稳定
├─────────────────────────────────┤
│  Rolling History(压缩后历史)   │  ← 每轮追加,Janitor 管理
├─────────────────────────────────┤
│  Dynamic State(注入最后一条     │  ← 每轮都是最新状态,紧贴生成点
│  user 消息末尾)                 │
└─────────────────────────────────┘

关键在最后一层:动态状态不是作为独立的 system message 追加在末尾,而是注入到最后一条 user 消息的内容里。这样它既保持在消息数组的末端(recency 最优),又不打乱消息角色结构(history 的连续性不被破坏)。这就是为什么 ContextChef 的 API 要区分 setSystemPrompt()setHistory()setDynamicState()——它们对应三明治的三层,有各自不同的处理逻辑和生命周期。

Assembler:三明治的物理编译器

三明治模型描述”应该怎么排”,Assembler 负责”实际怎么排”。两个机制都指向同一个目标:让相同的逻辑状态每次产生完全一致的字节序列,最大化 KV-cache 命中。

last_user 注入:动态状态注入到历史中最后一条 user 消息的文本末尾,而不是作为独立 system message 追加。这同时满足两个约束:位置在消息数组末端,享有最高的 Recency Bias 注意力;不引入新消息节点,不破坏历史的角色结构。如果历史里没有 user 消息,Assembler 会新建一条来承载。

确定性 key 排序:输出的消息数组里所有 JSON key 按字典序排序。哪怕消息内容完全一样,key 顺序不同对 provider 就是不同的输入序列,等于 cache miss。字典序排序消除这个不确定性,保证相同逻辑内容产生完全相同的字节序列。

Guardrail:compile() 前的格式保障

长程 Agent 有时对输出格式有硬性要求:响应必须包含在特定 XML 标签里,或者必须以特定文本开头,否则下游解析就会失败。

把格式要求写进 system prompt 能用,但模型对嵌在对话开头的格式指令有”建议遵守”而非”强制遵守”的倾向——对话拉长后遵守率下降是常见问题。原因不复杂:格式指令放在 system prompt 顶部,会被后续几十轮历史稀释,到生成点时注意力已经很低。

withGuardrails() 在每次 compile() 时把格式指令注入到消息数组末尾——Recency Bias 最高的位置,紧贴生成点。注入块标记为”系统发出、不需要回复”的语义,避免模型把它当成需要应答的内容。位置优势带来显著更强的格式遵守率。

prefill 选项支持控制模型输出的起始文本:对 Anthropic 原生转成 assistant 消息,对 OpenAI / Gemini 由 Adapter 层降级为系统指令。供应商差异在编译层处理,业务代码不感知。

七个模块,覆盖七类问题

模块对应的问题灵感来源
Janitor历史压缩,避免窗口溢出Anthropic 的 compaction 实践
Pruner工具裁剪,消除幻觉Manus 的 Mask Don’t Remove 原则
Assembler动态状态注入,防止任务漂移Manus 的 recitation / todo 模式
Offloader/VFS大输出卸载,保留 URI 指针Manus 的 File System as Context
Memory跨会话持久记忆Letta/MemGPT 的 memory blocks
Adapters一套代码适配多供应商缓解供应商锁定
Guardrail输出格式保障,末端注入Recency Bias 的实际应用

后续每篇深入一个模块,重点讲设计取舍而不是 API 用法——API 文档在 README 里已经够详细了。另有一篇专门讲编译管道里的五个扩展 Hook,覆盖各模块的钩子接口。