OpenCode
Go 终端 AI 编程助手 — 内置 Auto Compact 与 DCP 动态上下文裁剪
架构概览
问题:上下文膨胀
在 agentic 编程会话中,每次工具调用(读文件、执行命令、搜索代码)都会把结果追加到上下文。一个 2 小时的开发会话可能产生 200+ 轮交互,轻松突破 200K token 的上下文窗口。窗口溢出时,最早的消息被截断——模型'忘记'之前做过什么。
Auto Compact:全量总结
OpenCode 内置的 Auto Compact 在 token 使用量达到 95% context window 时自动触发。它调用一个 summarizer LLM 对整个对话历史生成摘要,然后用摘要替换所有旧消息。简单粗暴但有效——代价是历史不可恢复。
DCP:精细化裁剪
DCP 插件采用完全不同的策略——非破坏性的精细化裁剪。它通过 OpenCode 的 hook 机制,在每次请求发送前对消息副本进行三层处理:去重(相同工具调用只保留最新)、错误清理(过期错误标记移除)、区间压缩(将一段对话压缩为技术摘要)。原始数据始终保留在数据库中。
对比效果
两种方案各有取舍:Auto Compact 零配置、压缩率高(50-80%),但破坏历史且 cache 完全失效。DCP 保留历史、支持解压恢复、cache 命中率约 85%,但需要配置且压缩率较低(30-60%)。选择取决于会话复杂度和对历史的需求。
同一文件被读取 3 次 → 仅保留最新版本
已修复的编译错误 → 标记移除
20 轮调试对话 → 一段技术摘要
代码走读
从 Auto Compact 的触发条件到 DCP 的精细化裁剪,走读 5 个关键函数。
Auto Compact 触发
当一轮对话结束后,TUI 检查 token 用量是否达到 context window 的 95%。如果是,且用户开启了 autoCompact,则自动发送 startCompactSessionMsg 启动总结流程。
这是一个"硬阈值"策略 —— 没有渐进式压缩,触发即全量总结。
Summarize 流程
触发后进入 agent.Summarize()。它取出当前 session 的全部消息,追加一条总结 prompt,调用 summarizeProvider 生成摘要,然后将摘要作为新消息写回 session,并设置 SummaryMessageID。
后续加载时,只保留 SummaryMessageID 之后的消息 —— 这是破坏性截断,旧消息不可恢复。
DCP Hook 注册
DCP (Dynamic Context Pruning) 是一个 OpenCode 插件,通过 hook 机制在消息发送给 LLM 之前进行非破坏性转换。核心 hook 是 messages.transform —— 它在每次请求时对消息副本执行去重、裁剪、压缩。
插件还注册了 compress 工具,让 LLM 自己决定何时压缩哪些内容。
去重策略
去重是 DCP 的自动策略之一。它根据 tool name + 参数生成签名,将相同签名的调用分组,只保留最新的一个,其余标记为 prune。
createToolSignature() 对参数做归一化(去除 null/undefined)并排序 key,确保 {a:1, b:2} 和 {b:2, a:1} 产生相同签名。
Prune 执行
prune() 是最终的消息转换管道,包含 4 个步骤:处理压缩区间、替换 tool output、清理 question 输入、清理错误 tool 的输入。每个步骤只修改消息副本,不影响 OpenCode 数据库中的原始记录。
被 prune 的 tool output 统一替换为一句占位文本,从数百甚至数千 token 缩减为 1 行。
// internal/tui/tui.gotype startCompactSessionMsg struct{}// ... 在 Update() 的 AgentEvent 分支中:if payload.Done && payload.Type == agent.AgentEventTypeResponse &&a.selectedSession.ID != "" {model := a.app.CoderAgent.Model()contextWindow := model.ContextWindowtokens := a.selectedSession.CompletionTokens +a.selectedSession.PromptTokensif (tokens >= int64(float64(contextWindow)*0.95)) &&config.Get().AutoCompact {return a, util.CmdHandler(startCompactSessionMsg{})}}
// internal/llm/agent/agent.gofunc (a *agent) Summarize(ctx context.Context, sessionID string) error {summarizeCtx, cancel := context.WithCancel(ctx)a.activeRequests.Store(sessionID+"-summarize", cancel)go func() {defer cancel()msgs, err := a.messages.List(summarizeCtx, sessionID)if err != nil { return }summarizePrompt := "Provide a detailed but concise summary " +"of our conversation above. Focus on information " +"that would be helpful for continuing the conversation..."promptMsg := message.Message{Role: message.User,Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt},},}msgsWithPrompt := append(msgs, promptMsg)response, err := a.summarizeProvider.SendMessages(summarizeCtx, msgsWithPrompt, make([]tools.BaseTool, 0),)summary := strings.TrimSpace(response.Content)// 写回 session,设置截断点msg, _ := a.messages.Create(summarizeCtx, oldSession.ID,message.CreateMessageParams{Role: message.Assistant,Parts: []message.ContentPart{message.TextContent{Text: summary},},})oldSession.SummaryMessageID = msg.ID}()return nil}
// dcp-src/index.tsconst server: Plugin = (async (ctx) => {const config = getConfig(ctx)if (!config.enabled) { return {} }const state = createSessionState()const logger = new Logger(config.debug)return {"experimental.chat.system.transform":createSystemPromptHandler(state, logger, config, prompts),"experimental.chat.messages.transform":createChatMessageTransformHandler(ctx.client, state, logger, config, prompts,hostPermissions,) as any,"command.execute.before":createCommandExecuteHandler(ctx.client, state, logger, config,ctx.directory, hostPermissions,),event: createEventHandler(state, logger),tool: {...(config.compress.permission !== "deny" && {compress: config.compress.mode === "message"? createCompressMessageTool(compressToolContext): createCompressRangeTool(compressToolContext),}),},}}) satisfies Plugin
// dcp-src/lib/strategies/deduplication.tsexport const deduplicate = (state: SessionState, logger: Logger,config: PluginConfig, messages: WithParts[],): void => {const allToolIds = state.toolIdListconst unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id),)const signatureMap = new Map<string, string[]>()for (const id of unprunedIds) {const metadata = state.toolParameters.get(id)if (!metadata) continueif (isToolNameProtected(metadata.tool, protectedTools))continueconst signature = createToolSignature(metadata.tool, metadata.parameters,)signatureMap.get(signature)?.push(id)?? signatureMap.set(signature, [id])}const newPruneIds: string[] = []for (const [, ids] of signatureMap.entries()) {if (ids.length > 1) {const idsToRemove = ids.slice(0, -1)newPruneIds.push(...idsToRemove)}}for (const id of newPruneIds) {const entry = state.toolParameters.get(id)state.prune.tools.set(id, entry?.tokenCount ?? 0)}}function createToolSignature(tool: string, params?: any): string {if (!params) return toolconst normalized = normalizeParameters(params)const sorted = sortObjectKeys(normalized)return `${tool}::${JSON.stringify(sorted)}`}
// dcp-src/lib/messages/prune.tsconst PRUNED_TOOL_OUTPUT_REPLACEMENT ="[Output removed to save context - " +"information superseded or no longer needed]"export const prune = (state: SessionState, logger: Logger,config: PluginConfig, messages: WithParts[],): void => {filterCompressedRanges(state, logger, config, messages)pruneToolOutputs(state, logger, messages)pruneToolInputs(state, logger, messages)pruneToolErrors(state, logger, messages)}const pruneToolOutputs = (state: SessionState, logger: Logger,messages: WithParts[],): void => {for (const msg of messages) {const parts = Array.isArray(msg.parts) ? msg.parts : []for (const part of parts) {if (part.type !== "tool") continueif (!state.prune.tools.has(part.callID)) continueif (part.state.status !== "completed") continuepart.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT}}}