围绕 Coding Agent 的讨论,常见两种极端:将其神化为自主智能体,或将其贬为"不过是提示词工程"。两种判断都失之简单。本文从真实的架构出发,拆解 Claude Code、OpenCode 等工具的实现模式,厘清各自的设计取舍,以及那些被反复误解的核心问题。

一个循环统治一切

先说结论:所有 Coding Agent 的核心,都是一个 while(tool_use) 循环

通过对 Claude Code 实际 API 流量的追踪分析,其核心逻辑可以用伪代码描述如下:

1
2
3
4
5
6
7
8
while True:
response = llm.call(context)
if response.has_tool_call():
result = execute_tool(response.tool_call)
context.append(result)
else:
# 没有工具调用 = 任务完成,等待用户输入
break

没有复杂的状态机,没有多 Agent 协调框架,没有专门的"停止决策模块"。模型自己决定什么时候停——当它不再调用工具,循环就自然结束。

这个设计有一个被低估的优雅之处:模型可以通过"输出纯文本而不调用工具"来向用户提问或汇报进度,不需要任何额外的机制。

messages 数组:唯一的状态载体

上述伪代码中的 context,在所有主流 LLM API 里的实际形态是一个 messages 数组。没有其他格式——Agent 的全部状态,包括历史、推理过程、工具调用结果,都存储在这个数组里。

每条消息有四种角色,工具调用的插入位置是固定的:

1
2
3
4
5
6
7
8
[
{"role": "system", "content": "..."},
{"role": "user", "content": "用户输入"},
{"role": "assistant", "content": null,
"tool_calls": [{"id": "call_abc", "function": {"name": "read_file", "arguments": "{...}"}}]},
{"role": "tool", "tool_call_id": "call_abc", "content": "文件内容..."},
{"role": "assistant", "content": "基于文件内容,我的分析是..."}
]

每一轮循环的操作模式是:模型输出 assistant 消息(含 tool_calls)→ 框架执行工具 → 把结果以 tool 角色追加 → 再次调用模型。Agent 框架在做的核心事情,就是决定往这个数组里追加什么、以什么顺序追加、以及何时停止追加。

这个数组的增长方式,取决于框架策略:

策略 描述 适用场景
无限叠加 保留所有消息,数组持续增长 短任务、大 context 窗口
工具结果清除 保留工具调用记录但丢弃返回内容,减少冗余 工具结果可重新获取的场景
压缩摘要 用 LLM 总结历史,用摘要替换原始消息数组 超长任务(Claude Code 的 compaction 机制)
滑动窗口 丢弃旧消息,保留最近 N 条 长对话、有限 context 窗口

这四种策略的激进程度递增,但本质上都在做同一件事:改写 messages 数组,用一个更短的数组替代原来的数组,然后基于这个更短的数组继续推理。这就是上下文压缩的全部秘密——没有魔法,只有对数组的精确操纵。后文会详细展开不同 Agent 在这件事上的巨大差异。

终止条件的最简实现:不需要专门的 stop 工具或终止正则,让模型的"不调用工具"行为本身成为停止信号,是最低耦合的设计。

Claude Code 的真实架构

工具集:精简而非全能

Claude Code(截至 2025 年中,基于 system prompt 追踪分析)共有 14 个工具,分为四类:

类别 工具 设计意图
命令行 bashglobgrepls bash 需要用户审批,其余不需要
文件操作 readwriteeditmulti_editnotebook_readnotebook_edit Jupyter 单独处理,因为原始格式极长
网络 web_searchweb_fetch 基础信息获取
控制流 todo_writetask 计划管理与子 Agent 调度

没有 critic 模式,没有角色扮演,没有复杂的记忆数据库。简单是刻意的选择,不是能力不足。

TODO 列表:外化的计划

Claude Code 的第一个工具调用几乎总是 TodoWrite,创建一个结构化的任务列表:

1
2
3
4
5
6
7
{
"todos": [
{"id": "1", "content": "分析现有代码结构", "status": "completed"},
{"id": "2", "content": "实现新功能", "status": "in_progress"},
{"id": "3", "content": "编写测试", "status": "pending"}
]
}

这个设计解决了一个关键问题:模型在执行数百步操作后会"忘记"自己在做什么。TODO 列表是外化的工作记忆,不依赖模型的上下文记忆能力。

更聪明的是,工具调用的返回结果里会附带提醒文字,要求模型继续使用 TODO 列表跟踪进度。指令在工具结果里重复出现,比只放在 system prompt 里遵从率高得多。

计划外化原则:把计划写成可被工具读写的结构化文件(TODO 列表、plan.md),比依赖模型"内化"计划更确定、更可控。内化的计划会随着上下文增长而衰减,外化的计划不会。

System Reminder:动态注入的上下文

Claude Code 会在每次用户消息后附加 <system-reminder> 块,内容随状态动态变化:

  • 会话开始:注入基本行为约束(不要主动创建文件、不要做多余的事)
  • TODO 列表为空时:提醒模型考虑是否需要创建计划
  • TODO 列表更新后:把最新的任务状态注入上下文

这不是"提示词工程",而是程序化的上下文管理——根据 Agent 的运行状态,精确控制每一步模型能看到什么信息。

子 Agent:上下文隔离与并行

当任务复杂到单个上下文窗口装不下,Claude Code 会通过 task 工具派发子 Agent。子 Agent 是一个完整的 Claude Code 实例,接收相同的 system prompt,但有自己独立的上下文窗口。

关键设计决策:子 Agent 不接收主 Agent 的上下文历史,且在设计上被约束为不能再派发子 Agent(防止无限递归)。子 Agent 的 system prompt 中包含对其行为边界的约束,但其上下文窗口是全新的、干净的——这正是上下文隔离的核心价值。这一设计保证了行为的可预测性。值得注意的是,这个约束只针对 Agent 实例的创建——Skill(通过 SKILL.md 注入的行为扩展)则可以无限递归调用,因为 Skill 是上下文内的提示词注入,不涉及新 Agent 实例的创建。

关于子 Agent 的本质,还有一个常被误解的问题:"子 Agent"是关系描述,不是能力描述。子 Agent 可以拥有与主 Agent 完全相同甚至更强的能力,它的核心价值在于上下文隔离,而非能力弱化。详细分析见《子 Agent 的本质:上下文隔离与专门化》。

子 Agent 的两个用途:

  1. 上下文管理:把大任务拆成小任务,每个子任务有干净的上下文
  2. 并行加速:多个独立子任务可以同时执行

安全检查:用小模型做守门员

一个鲜为人知的细节:Claude Code 在执行 bash 命令前,会把命令发给 Claude Haiku(最小最快的模型)做安全检查,判断命令是否读取或修改了敏感文件。

这个设计体现了一个重要的工程权衡:用便宜的小模型做确定性判断,不让安全检查拖慢主循环。Haiku 的输出是结构化的 XML,不是自然语言,降低了解析的不确定性。

异构模型协作:主循环用强模型做复杂推理,安全/分类等确定性任务用小模型,是成本与能力的最优分配。

Claude Code 的核心竞争力:上下文工程

上文所描述的每一个机制——system reminder 的动态注入、TODO 列表的外化记忆、子 Agent 的上下文隔离、messages 数组的增长策略——本质上都在做同一件事:精确控制每一步模型能看到什么信息。这不是"改改 prompt 就能做到的事",而是对模型行为的系统性工程,需要对模型的注意力机制、上下文衰减、工具调用模式有深刻理解。

Anthropic 在其工程博客中给出了精确定义:Context Engineering(上下文工程)是在 LLM 推理期间策划和维护最优 token 集合的策略集合。它与 Prompt Engineering 的区别不在于程度,而在于维度——Prompt Engineering 关注"如何写好一段提示词",Context Engineering 关注"在每一次推理调用中,什么样的 token 配置最可能产生期望行为"。后者是一个迭代过程,涵盖 system prompt、工具定义、messages 历史、外部数据的全部组合。

一个有力的佐证:Claude Code 的 system prompt 和工具集在每次版本更新时都会变化,这些变化直接影响模型行为。这说明 Anthropic 在持续做的是 Context Engineering,而不是一次性的提示词设计。

操纵 messages 数组的艺术

前文提到 messages 数组是 Agent 的唯一状态载体。但"操纵 messages 数组"远不止"快满了就压缩"这一件事。Agent 框架对 messages 数组的操纵,至少包含五个维度:注入、定位、保护、清理、重复。 压缩只是清理维度的一个子集。

维度一:注入(Injection)——往数组里放什么

messages 数组中有四种角色的消息,每种角色的语义和注意力权重截然不同:

角色 语义 注意力权重 是否被压缩
system 全局行为指令,定义模型身份和约束 最高——位于数组开头,享有首因效应(primacy bias) ❌ 永远不会被压缩
user 用户输入,包含实际请求和上下文 高——最新的 user message 享有近因效应(recency bias) ✅ 历史 user message 会被压缩
assistant 模型响应,包含思考过程和工具调用 中——随对话增长被推向中间位置 ✅ 会被压缩
tool 工具执行结果,返回给模型 中——与 assistant 的工具调用配对出现 ✅ 会被压缩或清除

Claude Code 的注入策略是分层的,不同类型的信息被注入到不同角色的消息中:

第一层:system prompt(不可变层)。 核心身份定义、基础行为规则、工具使用指南、安全声明。这些内容约 16,000-23,000 tokens,对所有用户共享,永远不会被压缩。Claude Code 的 system prompt 有一个精妙的设计:安全声明在 prompt 的开头和结尾都出现——利用 U 形注意力曲线的两个峰值,确保安全约束在任何上下文长度下都不会被忽略。

第二层:system prompt 动态扩展(半不可变层)。 Output Style(如 software-architect 风格指令)被追加到 system prompt 数组中。这些内容随用户配置变化,但在单次会话内保持稳定,同样不会被压缩。

第三层:<system-reminder> 注入(可变层)。 这是 Claude Code 最独特的机制。CLAUDE.md 的内容、skill 元数据、当前日期、Git 仓库状态等信息,被包裹在 <system-reminder> 标签中,作为 user message 注入到对话历史中:

1
2
3
4
5
6
7
{
"role": "user",
"content": [{
"type": "text",
"text": "<system-reminder>\n# claudeMd\nContents of /path/to/CLAUDE.md:\n\n[your CLAUDE.md content]\n</system-reminder>"
}]
}

这个设计有一个关键的工程权衡:CLAUDE.md 的内容虽然用 <system-reminder> 标签标记为"系统级",但它的实际角色是 user message。这意味着它享有 user message 的注意力权重,但也承受 user message 的命运——会被压缩。这个设计选择是为了兼容 prompt caching(后文详述),但也埋下了 skill 遗忘问题的种子。

第四层:工具调用与结果(动态层)。 每一轮循环产生的 assistant tool_call 和 tool result 消息。这些是数组增长最快的部分,也是压缩的主要目标。

维度二:定位(Positioning)——放在数组的哪个位置

Stanford 的 Lost in the Middle 论文(arXiv:2307.03172,引用 3700+)揭示了一个关键发现:LLM 对 messages 数组中不同位置信息的注意力呈 U 形分布——开头和结尾的信息获得最高注意力,中间位置的信息最容易被忽略。

1
2
3
4
5
6
7
8
9
10
注意力权重

|██ ██
|████ ████
|██████ ██████
|████████ ████████
|██████████████████████████████████
└──────────────────────────────────→ messages 数组位置
开头 结尾
(system prompt) (最新消息)

Claude Code 的定位策略充分利用了这个特性:

  • 开头位置(primacy bias):system prompt 固定在数组最前面,核心规则前置,身份定义优先
  • 结尾位置(recency bias):最新的 user message 和最近的工具调用结果自然位于数组末尾
  • 中间位置(注意力低谷):历史对话、早期的工具调用结果——这些是压缩的首选目标,因为即使不压缩,模型对它们的注意力也已经很低了

这解释了一个反直觉的现象:为什么 Claude Code 的 system prompt 长达 16,000+ tokens 却不会显著影响性能。因为 system prompt 位于数组开头,享有最高的注意力权重;而且它通过 prompt caching 被缓存为不可变前缀,不会随对话增长而被"推"到中间位置。

维度三:保护(Protection)——哪些内容不能丢

并非 messages 数组中的所有内容都是平等的。Claude Code 建立了一个隐式的信息保护层级

保护级别 内容 保护机制
永久保护 system prompt、工具定义 位于 API 调用的顶层参数,不在 messages 数组中,永远不会被压缩
缓存保护 system prompt + Output Style 通过 prompt caching 创建不可变前缀,缓存后的内容不会被修改
摘要保留 架构决策、未解决的 bug、最近 5 个文件 compaction 时被明确要求保留
无保护 历史工具调用结果、早期对话、CLAUDE.md 内容、skill 内容 压缩时可能被摘要化或丢弃

Prompt Caching 的保护作用值得单独说明。Claude API 的 prompt caching 通过前缀匹配工作:toolssystemmessages 按此顺序构成缓存前缀。前缀中的任何变化都会使缓存失效。Claude Code 围绕这个机制做了一个关键的架构决策:所有用户共享相同的 system prompt。这使得 system prompt 成为一个稳定的、可缓存的前缀,减少 90% 的输入 token 成本和 85% 的延迟。

但这也意味着,任何需要个性化的内容(CLAUDE.md、skill、项目规则)都不能放在 system prompt 中——否则会破坏缓存共享。这就是为什么这些内容被降级为 user message 注入,也是它们在压缩时容易丢失的根本原因。这不是一个 bug,而是一个成本与持久性之间的工程权衡

维度四:清理(Pruning)——从数组中移除什么

这是大多数人理解的"上下文压缩",但它实际上包含三个递进的层次:

第一层:工具结果清除(Tool Result Clearing)。 最轻量的清理形式。Agent 保留"我调用了 read_file 读取了 config.yaml“这个记录,但丢弃实际返回的文件内容。Anthropic 已将此作为 Claude Developer Platform 的正式功能发布(clear_tool_uses)。这是"最安全的轻触式压缩”——不丢失任何决策上下文,只丢弃可重新获取的原始数据。

第二层:上下文摘要压缩(Compaction)。 最核心也最复杂的清理形式。其操作可以用一句话概括:把整个 messages 数组传给 LLM,让它生成一个高保真摘要,然后用这个摘要替换原始数组,开始一个新的上下文窗口

1
2
3
4
5
6
7
if token_count(messages) > threshold:
summary = llm.summarize(messages)
messages = [
{"role": "system", "content": original_system_prompt},
{"role": "user", "content": summary},
# 保留最近的几条消息或最近访问的文件
]

压缩的艺术在于选择保留什么、丢弃什么——过于激进的压缩会丢失微妙但关键的上下文。Anthropic 在其工程博客中给出了调优建议:先最大化召回率(确保压缩 prompt 捕获了 trace 中的每一条相关信息),再迭代提升精确率(消除多余内容)。

第三层:结构化笔记(Structured Note-taking)。 不是对 messages 数组的直接操作,而是一种补偿机制:Agent 在运行过程中主动将关键信息写入外部存储(TODO 列表、memory 文件),使得即使上下文被压缩甚至完全清空,关键状态也不会丢失。

维度五:重复(Repetition)——关键指令的多点锚定

这是最容易被忽视、却可能是最重要的维度。Claude Code 的一个核心设计哲学是:关键指令不能只出现一次,必须在 messages 数组的多个位置重复出现

具体实现包括:

  • system prompt 首尾重复:安全声明在 system prompt 的开头和结尾都出现,利用 U 形注意力的两个峰值
  • tool result 中嵌入提醒:每次 todo_write 工具调用的返回结果中,都会附带固定文字提醒模型"keep using the TODO list to keep track"。这意味着只要模型在使用 TODO 列表,每次工具调用都会重新强化这条指令
  • <system-reminder> 的每轮注入CLAUDE.md 的内容不是只在会话开始时注入一次,而是在每个 user turn 中都重新注入。这确保了即使中间的 CLAUDE.md 内容被注意力衰减影响,最新一轮的注入仍然位于数组末尾的高注意力区域

为什么重复比单次注入更有效?原因有三:

  1. 对抗注意力衰减:单次注入的信息会随着对话增长被推到中间位置(注意力低谷),重复注入确保关键信息始终出现在高注意力区域
  2. 提供容错冗余:即使某一次注入被模型忽略,其他位置的重复仍然有效
  3. 压缩后恢复:即使历史中的注入被压缩掉,最新一轮的注入仍然完整保留

指令在工具结果里重复出现,比只放在 system prompt 里遵从率高得多——这不是经验之谈,而是 Claude Code 团队通过大量 A/B 测试验证的工程结论。

五个维度的协同:一个完整的例子

以 Claude Code 处理一个复杂编码任务为例,观察五个维度如何协同工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
messages 数组的完整生命周期:

[注入] system prompt(身份 + 规则 + 安全声明×2) ← 永久保护,首因效应
[注入] system prompt(Output Style: software-architect)← 缓存保护
[注入] user: <system-reminder>CLAUDE.md 内容</system-reminder> ← 每轮重复注入
[注入] user: "帮我重构 OrderService" ← 用户输入
[注入] assistant: tool_call(todo_write, ...) ← 模型决策
[注入] tool: "TODO 已创建。提醒:继续使用 TODO 跟踪进度" ← 重复提醒
[注入] assistant: tool_call(read_file, ...)
[注入] tool: "文件内容..." ← 后续可被清除
... 数十轮工具调用 ...
[清理] 早期的 tool result 被 clear_tool_uses 清除 ← 工具结果清除
[清理] 当 token 接近 95% 时触发 compaction ← 摘要压缩
[保护] system prompt 不变,摘要保留架构决策和最近文件
[重复] 压缩后的第一个 user turn 重新注入 <system-reminder>
[定位] 摘要位于新数组的前部,最新交互位于末尾

这个例子展示了一个关键洞察:messages 数组的操纵不是某个单一时刻的操作,而是贯穿 Agent 整个生命周期的持续性工程。每一条消息的注入位置、每一次清理的时机、每一条指令的重复频率,都是精心设计的。

为什么需要清理:Context Rot

理解了五个维度之后,我们可以更准确地讨论 Context Rot(上下文腐烂)——它是清理维度存在的根本原因。

Anthropic 在其工程博客中明确指出:context rot 是随着上下文窗口中 token 数量的增加,模型准确回忆信息的能力下降的现象。这不是一个硬性的悬崖,而是一个性能梯度。

根本原因在于 Transformer 架构的注意力机制:每个 token 需要与其他所有 token 建立注意力关系,n 个 token 产生 n² 个成对关系。上下文越长,这些关系就越"稀薄"。加上模型的训练数据中短序列远多于长序列,模型对超长上下文的处理经验天然不足。

这意味着,即使上下文窗口还没有被填满,模型的有效注意力已经在衰减。上下文清理不是"快满了才需要"的应急措施,而是维持模型推理质量的持续性工程。

一个活生生的案例:Skill 遗忘的多重原因

Context rot 并非理论推演——它正在每天困扰着数以万计的编码智能体用户。但如果把 skill 遗忘问题简单归因于 context rot,就犯了"把问题和解决方案混为一谈"的错误。Skill 遗忘是多重原因叠加的结果,context rot 只是其中之一,而 context compaction(作为 context rot 的解决方案)自身也引入了新的子问题。

以 Claude Code 的 Skill 系统为例。它采用**渐进式披露(Progressive Disclosure)**策略:启动时只加载所有 skill 的名称和描述(约 200 tokens)到 system prompt;完整的 SKILL.md 内容只在 skill 被首次调用时通过 read_file 工具加载一次,之后进入对话历史。后续再次使用同一 skill 的功能时,agent 依赖对话历史中已有的加载结果,不会重新读取 skill 文件

这个设计在短对话中工作良好,但在长对话中会遭遇至少四层退化机制的叠加

第一层:Context Rot(渐进式衰减)。 随着后续工具调用、代码编辑、错误修复等对话轮次的增加,skill 内容逐渐被"推"到上下文的中间位置——恰好是 U 形注意力曲线的最低点。模型对 skill 中具体规则的遵循精度会渐进式衰减。这是注意力机制的固有局限,与压缩无关。

第二层:注入方式的先天缺陷。 Skill 内容通过工具调用结果注入,其角色是 user message(via tool_result),而非 system prompt。这意味着 skill 指令的注意力权重天然低于 system prompt 中的规则。即使在短对话中,skill 指令也比 system prompt 中的指令更容易被忽略——这是注入维度的问题,不是 context rot 的问题。

第三层:渐进式披露的固有缺陷。 所有 skill 描述的总预算约为 15K tokens。当用户安装了大量 skill 时,部分 skill 的元数据可能被截断,导致模型根本不知道某些 skill 的存在。此外,skill 内容只加载一次、不会重新读取的设计,意味着一旦加载时的上下文环境发生变化(比如后续的工具调用改变了项目状态),skill 中的指令可能已经与当前状态不匹配,但模型仍在遵循旧版本的指令。

第四层:Context Compaction(断崖式遗忘)。 当对话长度接近上下文窗口上限时,Claude Code 会触发自动压缩。这是一个断崖式的遗忘事件——因为 skill 内容被作为 user message 注入对话历史,它们会被压缩摘要化或完全丢弃。更糟糕的是,agent 不会在压缩后主动重新读取 skill 文件。即使在 ~/.claude/CLAUDE.md 中添加"压缩后重新加载 skill"的指令,这条指令本身也会被压缩掉——形成一个自指悖论

注意第四层的性质:compaction 是 context rot 的解决方案,但解决方案自身引入了比原问题更严重的子问题。Context rot 是渐进式的性能衰减,模型仍然"大致记得"skill 的内容;而 compaction 是断崖式的信息丢失,模型完全忘记 skill 的存在。这就像用截肢来治疗肌肉酸痛——病因消除了,但代价远超原始问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
指令遵循精度

100%|████
|████████
|████████████
|████████████████ ← 第一层+第二层+第三层:渐进式衰减
|████████████████████
|████████████████████████
| ████ ← 第四层:Compaction(断崖式遗忘)
| ████
└──────────────────────────→ 对话长度

压缩触发点

Claude Code 的 GitHub Issues 中充斥着相关报告:#13919 记录了 “Skills context completely lost after auto-compaction”——压缩后 Claude 完全失去对正在使用的 skill 的感知,不会主动重新读取 skill 文件,开始重复那些 skill 专门设计用来防止的错误;#6354 报告 “Claude forgets everything in CLAUDE.md after compaction”——Claude 开始以"常识"方式工作,偏离"正确方式";#3021 则发现 Claude 声称它应该已经读取了规则,但实际上没有。用户报告简单任务从 1 小时变成 5-6 小时,因为 agent 不断重复之前已经通过 skill 规避的错误。

这个案例的价值在于它展示了上下文工程的系统性:skill 遗忘不是某一个维度的失败,而是注入方式(user message 而非 system prompt)、定位策略(被推到中间位置)、保护机制(无保护,会被压缩)、清理策略(compaction 的副作用)四个维度共同作用的结果。解决它也需要多维度的协同——比如将 skill 内容提升为 system prompt 级别的不变量、引入 PostCompact hook 自动重新加载关键文件、或者在 compaction prompt 中明确要求保留 skill 文件路径以便压缩后重新读取。

三个 Agent 的压缩策略对比

维度 Claude Code OpenCode Aone Copilot
触发时机 约 95% 上下文窗口使用率时自动触发;支持 /compact 手动触发 约 95% 上下文窗口使用率时自动触发;支持 /compact 手动触发 由框架层面管理,对用户透明
压缩执行者 主模型自身(Claude Sonnet) 内置隐藏的 compaction Agent(独立配置,可使用不同模型) 框架层面处理
压缩结果可见性 对用户不可见,静默替换 messages 数组 显式输出到 TUI 对话框,用户可以看到压缩摘要的内容 对用户不可见
工具结果清除 支持(API 级别的 clear_tool_uses 压缩时一并处理 框架层面处理
压缩后保留内容 摘要 + 最近 5 个访问的文件 + 架构决策 + 未解决的 bug 摘要 + 推荐的剩余任务列表 由框架策略决定
跨会话持久化 memory tool + CLAUDE.md 会话数据持久化到本地文件系统 会话内管理

这个对比揭示了一个有趣的设计分歧:

Claude Code 选择了"静默压缩"——用户不需要知道压缩发生了,体验上就像一个连续的对话。这降低了认知负担,但也意味着用户无法审查压缩质量。

OpenCode 选择了"显式压缩"——压缩的摘要直接输出到 TUI 的对话框中,用户可以看到 Agent 认为哪些信息值得保留。这增加了透明度,但也暴露了压缩的"接缝"。更值得注意的是,OpenCode 将 compaction 实现为一个独立的隐藏 Agent(在其架构中标记为 mode: primary, hidden: true),这意味着压缩任务可以使用与主对话不同的模型——比如用更便宜的模型做摘要,用更强的模型做推理。

Aone Copilot 的上下文管理则更多地依赖框架层面的策略,包括动态 system prompt 注入和子 Agent 的上下文隔离,而非显式的 compaction 机制。

messages 数组操纵的本质

回到全局视角。操纵 messages 数组的艺术,不是"压缩"这一个动作,而是五个维度的持续协同

  • 注入决定了数组里有什么——system prompt 的静态规则、<system-reminder> 的动态上下文、工具调用的结果
  • 定位决定了信息在数组中的位置——利用 U 形注意力曲线,把关键信息放在开头和结尾
  • 保护决定了哪些信息不能丢——system prompt 永久保护,prompt caching 创建不可变前缀
  • 清理决定了何时移除什么——从轻量的工具结果清除到激进的摘要压缩
  • 重复决定了关键指令出现几次——在 system prompt、tool result、<system-reminder> 中多点锚定

上下文工程是 Agent 框架做的,不是模型做的。模型只是这个过程中的一个工具——框架决定何时触发压缩、把什么内容传给模型做摘要、摘要完成后如何重组 messages 数组。模型本身对 messages 数组没有直接的读写权,它只能通过工具调用间接影响数组的内容(比如通过 TODO 列表写入外部存储)。

这个区分很重要,因为它解释了为什么不同 Agent 的表现差异如此之大:差异不在于模型能力,而在于框架的工程实现。同一个 Claude Sonnet 模型,在 Claude Code 的精细上下文工程下和在一个简陋框架的粗暴截断下,表现会截然不同。

Anthropic 在其工程博客中给出了 compaction 的调优建议:先最大化召回率(确保压缩 prompt 捕获了 trace 中的每一条相关信息),再迭代提升精确率(消除多余内容)。这个"先保后删"的策略,与信息检索领域的经典方法论一脉相承。

OpenCode:TUI 做得好,Agent Core 一知半解

OpenCode 是一个开源的 Coding Agent,用 Go 实现,以终端 TUI 为核心交互界面。它的架构比 Claude Code 更显式,也更复杂。

架构概览

OpenCode 采用 C/S 架构:TypeScript 服务端 + 多客户端(TUI、CLI、IDE 插件)。核心模块:

1
2
3
4
5
Client (TUI/CLI/IDE) → REST API + SSE → Server
├── Agent(配置模板)
├── Session(ReAct 循环)
├── Tool(工具集)
└── Event Bus(事件驱动)

会话数据持久化到本地文件系统:

1
2
3
4
~/.local/share/opencode/storage/
├── session/{projectID}/{sessionID}.json
├── message/{sessionID}/{messageID}.json
└── project/{projectID}.json

OpenCode 的设计亮点

事件驱动架构:模块间通过 Event Bus 通信,客户端通过 SSE 订阅事件流,实现实时更新。这让 TUI 的响应性非常好。

声明式 Agent 配置:OpenCode 的"Agent"本质上是配置模板,定义了工具权限、执行权限、system prompt 和模型参数。内置三个 Agent:

  • build:完整权限,适合实际开发
  • plan:只读权限,适合分析和规划
  • general:搜索优化,适合代码研究

多 LLM 提供商支持:通过统一 API 层支持 OpenAI、Anthropic、Google、Amazon 等,这是 Claude Code 不具备的灵活性。

OpenCode 的问题:Agent Core 的深度不足

OpenCode 的 TUI 做得确实好——差异渲染、低闪烁、响应迅速。但 Agent Core 的实现存在明显的工程深度不足:

上下文管理深度不足:OpenCode 确实有上下文压缩机制——它内置了一个隐藏的 compaction Agent,会在上下文使用率达到 95% 时自动触发摘要压缩,并将压缩结果显式输出到 TUI 对话框中。但与 Claude Code 相比,它缺少动态 system reminder 机制、工具结果的选择性清除、以及压缩后保留最近访问文件等精细策略。压缩的"有无"不是问题,压缩的"精细度"才是差距所在。

工具调用兼容性问题:OpenCode 通过统一 AI 抽象层处理工具调用,但对自托管模型的兼容性存在明显缺陷,使用非主流提供商模型时工具调用稳定性较差。

"Agent"是配置,不是真正的多 Agent:OpenCode 的 Agent 系统本质上是 system prompt 的切换,没有真正的多 Agent 协调能力。子会话(parent-child session)的实现也相对简单,缺乏 Claude Code 那种精细的上下文隔离。

sysprompt 过长:OpenCode 的 system prompt 包含大量"你是一个工程师,你应该……"之类的角色设定,这类内容会占用宝贵的上下文空间,并且可能干扰模型的原生推理能力。

sysprompt 的反模式:过长的角色设定 prompt 不会让模型"更像工程师",反而会压缩模型处理实际任务信息的注意力空间。

pi:一个人的极简主义实验

Mario Zechner 独立开发的 pi 是一个有趣的对照实验。他刻意做减法:

  • 没有内置 TODO 列表:认为这是不必要的复杂性
  • 没有 plan 模式:不需要专门的规划阶段
  • 没有 MCP 支持:认为 MCP 增加了不必要的抽象层
  • 没有子 Agent:单一上下文,简单可控
  • 默认 YOLO 模式:不需要用户逐步确认

pi 的核心贡献是 pi-ai,一个统一的多 LLM API 层,支持跨提供商的上下文切换(把 Claude 的 thinking trace 转换成 <thinking> 标签传给 GPT),以及结构化的工具结果(LLM 看到的内容和 UI 显示的内容分离)。

pi 的存在证明了一件事:一个人独立开发,可以做出在某些场景下不输主流工具的 Coding Agent。这既说明了 Coding Agent 的核心架构确实不复杂,也说明了"让循环好好跑起来"的工程细节才是真正的难点。

行业的真实问题:缺乏有意义的 Benchmark

当前 Coding Agent 领域最大的问题不是技术,而是没有有意义的 benchmark

现有的 benchmark(如 SWE-bench)测试的是特定类型的 GitHub issue 修复,与真实开发场景差距很大。这导致:

  • 工具的"优化"可能是针对 benchmark 的过拟合,而非真实能力提升
  • 无法量化"sysprompt 变长"对实际任务的影响
  • 不同工具之间的比较缺乏客观基准

一个有价值的 benchmark 应该模拟真实的开发任务:需求理解、代码修改、测试验证、回归检测。在这样的 benchmark 下,很多看起来"功能丰富"的工具可能并不比极简实现更好。

统一的底层逻辑:Context Engineering

回到最开始的问题:COT、tool calling、RAG、memory 系统、workflow,这些技术的本质是什么?

它们都是通过编程手段控制模型上下文的方式。

  • COT:让模型在上下文里写出推理过程,影响后续输出
  • Tool calling:把外部信息注入上下文,扩展模型的感知范围
  • RAG:把相关文档注入上下文,补充模型的知识
  • Memory 系统:把历史信息压缩后注入上下文,突破单次上下文限制
  • Workflow:控制信息注入的顺序和时机

这个统一视角有一个重要推论:评估任何 Agent 技术的标准,应该是"它如何改变了模型的上下文,以及这种改变是否让模型做出了更好的决策",而不是技术本身的复杂程度。

一个 50 行的 while 循环加上精心设计的上下文管理,可以比一个有 20 个模块的复杂框架更有效。

Aone Copilot:同一套逻辑的另一个实现

这个统一视角也解释了为什么不同产品的 Coding Agent 在架构上高度趋同。以 Aone Copilot 为例,它与 Claude Code 在核心机制上几乎是镜像关系:

机制 Claude Code Aone Copilot
任务追踪 todo_write 工具 todo_write 工具
文件读取 read 工具 read_file 工具
文件写入 write 工具 create_file 工具
文件编辑 edit / multi_edit file_replace 工具
子 Agent task 工具 task 工具
动态上下文注入 system reminder system prompt 动态注入
上下文压缩 compaction(静默摘要替换) 框架层面管理

这不是巧合,而是收敛到同一个最优解的结果:当你要解决"让模型在长任务中保持状态、安全操作文件、可控地调用子任务"这个问题时,TODO 列表 + 文件读写工具 + 子 Agent 调度几乎是唯一合理的工具集组合。

工具集收敛定律:不同团队独立设计的 Coding Agent,最终工具集会高度相似——这说明工具集的形态是由任务结构决定的,而非由实现者的偏好决定的。

参考资料