OpenCode

Go 终端 AI 编程助手 — 内置 Auto Compact 与 DCP 动态上下文裁剪

架构概览

01 / 04

问题:上下文膨胀

在 agentic 编程会话中,每次工具调用(读文件、执行命令、搜索代码)都会把结果追加到上下文。一个 2 小时的开发会话可能产生 200+ 轮交互,轻松突破 200K token 的上下文窗口。窗口溢出时,最早的消息被截断——模型'忘记'之前做过什么。

02 / 04

Auto Compact:全量总结

OpenCode 内置的 Auto Compact 在 token 使用量达到 95% context window 时自动触发。它调用一个 summarizer LLM 对整个对话历史生成摘要,然后用摘要替换所有旧消息。简单粗暴但有效——代价是历史不可恢复。

03 / 04

DCP:精细化裁剪

DCP 插件采用完全不同的策略——非破坏性的精细化裁剪。它通过 OpenCode 的 hook 机制,在每次请求发送前对消息副本进行三层处理:去重(相同工具调用只保留最新)、错误清理(过期错误标记移除)、区间压缩(将一段对话压缩为技术摘要)。原始数据始终保留在数据库中。

04 / 04

对比效果

两种方案各有取舍:Auto Compact 零配置、压缩率高(50-80%),但破坏历史且 cache 完全失效。DCP 保留历史、支持解压恢复、cache 命中率约 85%,但需要配置且压缩率较低(30-60%)。选择取决于会话复杂度和对历史的需求。

user读取 src/main.go
+1.2K1.2K
toolView: src/main.go (350 行)
+8.5K9.7K
assistant分析完成,修改第 42 行...
+2.1K11.8K
toolEdit: src/main.go
+3.2K15.0K
toolBash: go build ./...
+4.8K19.8K
toolBash: go test ./...
+12.3K32.1K
user再看看 config.go
+0.8K32.9K
toolView: src/config.go (520 行)
+11.2K44.1K
200K token limit
... 200+ 轮交互后,最早的消息被截断
Before
~180K tokens
Summarize()
After
Summary
~8K tokens
New messages
去重

同一文件被读取 3 次 → 仅保留最新版本

错误清理

已修复的编译错误 → 标记移除

区间压缩

20 轮调试对话 → 一段技术摘要

非破坏性:原始历史保留
Auto Compact
触发95% 窗口用量
压缩率50-80%
历史保留否 (破坏性)
Cache 命中完全失效
配置零配置
DCP
触发每次请求前
压缩率30-60%
历史保留是 (可恢复)
Cache 命中~85%
配置需要配置

代码走读

从 Auto Compact 的触发条件到 DCP 的精细化裁剪,走读 5 个关键函数。

01 / 05

Auto Compact 触发

当一轮对话结束后,TUI 检查 token 用量是否达到 context window 的 95%。如果是,且用户开启了 autoCompact,则自动发送 startCompactSessionMsg 启动总结流程。

这是一个"硬阈值"策略 —— 没有渐进式压缩,触发即全量总结。

02 / 05

Summarize 流程

触发后进入 agent.Summarize()。它取出当前 session 的全部消息,追加一条总结 prompt,调用 summarizeProvider 生成摘要,然后将摘要作为新消息写回 session,并设置 SummaryMessageID

后续加载时,只保留 SummaryMessageID 之后的消息 —— 这是破坏性截断,旧消息不可恢复。

03 / 05

DCP Hook 注册

DCP (Dynamic Context Pruning) 是一个 OpenCode 插件,通过 hook 机制在消息发送给 LLM 之前进行非破坏性转换。核心 hook 是 messages.transform —— 它在每次请求时对消息副本执行去重、裁剪、压缩。

插件还注册了 compress 工具,让 LLM 自己决定何时压缩哪些内容。

04 / 05

去重策略

去重是 DCP 的自动策略之一。它根据 tool name + 参数生成签名,将相同签名的调用分组,只保留最新的一个,其余标记为 prune。

createToolSignature() 对参数做归一化(去除 null/undefined)并排序 key,确保 {a:1, b:2}{b:2, a:1} 产生相同签名。

05 / 05

Prune 执行

prune() 是最终的消息转换管道,包含 4 个步骤:处理压缩区间、替换 tool output、清理 question 输入、清理错误 tool 的输入。每个步骤只修改消息副本,不影响 OpenCode 数据库中的原始记录。

被 prune 的 tool output 统一替换为一句占位文本,从数百甚至数千 token 缩减为 1 行。

// internal/tui/tui.go
type startCompactSessionMsg struct{}
// ... 在 Update() 的 AgentEvent 分支中:
if payload.Done && payload.Type == agent.AgentEventTypeResponse &&
a.selectedSession.ID != "" {
model := a.app.CoderAgent.Model()
contextWindow := model.ContextWindow
tokens := a.selectedSession.CompletionTokens +
a.selectedSession.PromptTokens
if (tokens >= int64(float64(contextWindow)*0.95)) &&
config.Get().AutoCompact {
return a, util.CmdHandler(startCompactSessionMsg{})
}
}
// internal/llm/agent/agent.go
func (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.ts
const 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.ts
export const deduplicate = (
state: SessionState, logger: Logger,
config: PluginConfig, messages: WithParts[],
): void => {
const allToolIds = state.toolIdList
const 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) continue
if (isToolNameProtected(metadata.tool, protectedTools))
continue
const 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 tool
const normalized = normalizeParameters(params)
const sorted = sortObjectKeys(normalized)
return `${tool}::${JSON.stringify(sorted)}`
}
// dcp-src/lib/messages/prune.ts
const 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") continue
if (!state.prune.tools.has(part.callID)) continue
if (part.state.status !== "completed") continue
part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT
}
}
}