跳到主要内容

第 4 章 · 上下文工程

这一章讲 Crush 怎么在「有限的上下文窗口」里长时间干活而不崩:满了就摘要,断了就修复,每轮拼 prompt 时怎么省 token。

4.1 自动摘要:满了就压缩,以摘要为新起点

LLM 的上下文窗口有限。长任务跑着跑着消息流就撑爆了。Crush 的策略:快满时把整段对话摘要成一条消息,以它为新起点继续

触发点是第 1 章提过的 StopWhen 条件①(internal/agent/agent.go:1003):

remaining = 上下文窗口 - (已用 prompt token + completion token)
大窗口(>200k): 阈值 = 固定 20k buffer
小窗口(≤200k): 阈值 = 窗口 × 20%
若 remaining ≤ 阈值 且 没禁用自动摘要 → shouldSummarize=true,停这一步

停下后,RunSummarize(internal/agent/agent.go:1291):用一个专门的 summary system prompt(templates/summary.md)跑一轮,生成一条 IsSummaryMessage 的助手消息,然后在会话上记下 SummaryMessageID、把 PromptTokens 归零。

下次加载历史时,getSessionMessages(internal/agent/agent.go:1629)发现有 summary,就只取从摘要消息往后的部分,并把摘要消息的角色改成 User——等于「之前的几百条消息浓缩成一条用户消息,模型从这里接着干」:

// internal/agent/agent.go:1643
if summaryMsgIndex != -1 {
msgs = msgs[summaryMsgIndex:]
msgs[0].Role = message.User // 摘要变成新对话的开场
}

4.2 摘要续跑:别把没干完的活弄丢

有个微妙问题:如果摘要发生时模型正打算调工具(这一轮没真正结束),摘要后不能就这么停——得让它接着干。Run 的处理(internal/agent/agent.go:1154):

if shouldSummarize {
a.activeRequests.Del(call.SessionID)
if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
return nil, summarizeErr
}
// 如果摘要前模型还有没执行完的工具调用,把原 call 重新入队续跑
if len(currentAssistant.ToolCalls()) > 0 {
// ...
call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
existing = append(existing, call)
a.messageQueue.Set(call.SessionID, existing)
}
}

这就是第 2 章那个「例外」——同一个 RunID 被重新入队。因为它用同一个 RunID 续跑,所以那一轮不需要当场补发 RunComplete(留给最终那一轮发),否则会重复发(对照 internal/agent/agent.go:1258outerOwesRunComplete 判定)。摘要的 prompt 还会带上原始用户请求,让续跑的模型知道「最初要干嘛」。

4.3 孤儿工具调用修复:防止会话被永久锁死

这是 Crush 一个救命级的细节。LLM API(尤其 Anthropic)有硬性要求:每个 tool_use 后面必须紧跟一个 tool_result。如果一轮被中途取消、或工具结果没写成功,消息流里就会留下「有 tool_use 但没有对应 tool_result」(孤儿调用)或反过来(孤儿结果)。这种破损一旦存在,之后每一轮 API 调用都会校验失败,会话被永久锁死

preparePrompt(internal/agent/agent.go:1470)在每次组装历史时主动修复两个方向:

先扫一遍,建两个集合:
knownToolCallIDs —— 所有 assistant 消息里的 tool_use ID
knownToolResultIDs —— 所有 tool 消息里的 tool_result ID

① 孤儿 result(有结果没调用)→ filterOrphanedToolResults 丢掉它
② 孤儿 call(有调用没结果) → syntheticToolResultsForOrphanedCalls
合成一条「该工具调用被打断、未产生结果」的占位结果

合成占位结果的代码(internal/agent/agent.go:1613):

syntheticParts = append(syntheticParts, fantasy.ToolResultPart{
ToolCallID: tc.ID,
Output: fantasy.ToolResultOutputContentError{
Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"),
},
})

这样无论会话怎么被打断,下次组装出来的历史总是「调用与结果配对完整」的——API 不会再拒,会话能继续。这是写生产级 agent 必踩的坑,Crush 把它做成了加载时的自愈。

4.4 prompt 组装:系统提示里塞了什么

系统提示由模板渲染(coder.md.tpl,经 prompt.Build,internal/agent/prompt/prompt.go:82)。渲染时注入的上下文(promptData,internal/agent/prompt/prompt.go:165)包括:

注入项来源作用
工作目录 / 平台 / 日期runtime让模型知道「在哪、什么系统」
Git 状态getGitStatus 实时跑 git 命令当前分支、git status --short、最近 3 条 commit
上下文文件CRUSH.md 等(ContextPaths)项目级约定(类似 CLAUDE.md)
可用 skillsskills.ToPromptXML把每个 skill 的触发描述塞进 <available_skills>

Git 状态是每次构建系统提示时实时采集的(internal/agent/prompt/prompt.go:240),所以模型看到的总是当前工作树状态。

Skills 是个轻量的「按需知识」机制:系统提示里只放每个 skill 的触发描述(便宜),模型判断相关时才 view 它的 SKILL.md 拿到完整步骤(贵)。这正是渐进式披露——而且 coder.md.tpl 的 critical rule #14 强制「描述只是触发器,真要做必须先 view 完整 SKILL.md」。

4.5 Anthropic 缓存:给稳定的前缀打标记省钱

长 prompt 反复发很烧钱。Anthropic 的 prompt caching 能缓存「不变的前缀」,后续命中只按缓存价计费。Crush 在 PrepareStep 里给系统消息 + 最后两条消息ephemeral 缓存标记(internal/agent/agent.go:833-847),并给工具列表的最后一个工具也打标记(internal/agent/agent.go:663-666):

// internal/agent/agent.go:1440 —— getCacheControlOptions(节选)
return fantasy.ProviderOptions{
anthropic.Name: &anthropic.ProviderCacheControlOptions{
CacheControl: anthropic.CacheControl{Type: "ephemeral"},
},
bedrock.Name: /* 同上 */,
vercel.Name: /* 同上 */,
}

打标记的位置很讲究:系统提示和工具定义是最稳定的前缀(几乎每轮一样),缓存它们收益最大;最后两条消息打标记是为了让下一轮能命中到「这一轮为止」的整段前缀。可用环境变量 CRUSH_DISABLE_ANTHROPIC_CACHE 关掉。

4.6 跨 provider 媒体兼容:工具结果里的图片怎么办

一个容易忽略的兼容性细节:Anthropic/Bedrock 支持「工具结果里直接带图片」,但 OpenAI/Google 等只接受工具结果里的纯文本。如果工具(比如截图工具)返回了图片,直接发给 OpenAI 会报错。

workaroundProviderMediaLimitations(internal/agent/agent.go:2093)对不支持的 provider 做转换:把工具结果里的图片换成文本占位符「[Image/media content loaded - see attached file]」,再紧跟着注入一条 user 消息把图片作为附件带上——既绕过 API 限制,又保持了工具执行的逻辑流。


下一章:05-cleverness-and-map.md —— 提炼巧思、列边界、横向对比、给代码地图。