作者:互联网搜索团队-Wang Wenqian
本文通过对OpenClaw,Claude Code等主流Agent产品的设计进行分析,给出Agent架构设计的关键决策,分析各方案的利弊。
1分钟看图掌握核心观点
图 1 VS 图 2,您更倾向于哪张图来辅助理解全文呢?
一、引言
构建一个 Agent 需要做一系列架构决策:上下文怎么管理?工具怎么加载?工具怎么查找?Agent 的主循环围绕什么来设计?
这些决策没有标准答案,但每个选择都有明确的代价。本文从 OpenClaw、Claude Code 等主流 Agent 产品的实现出发,拆解四个关键的设计决策,分析各种方案的利弊。
二、上下文管理
2.1 追加式上下文
OpenClaw 和 Claude Code 采用了同一种模式:**追加式上下文(Append-only Context)。**Agent 维护一个持续增长的对话历史,每次调用 LLM 时将完整历史作为 prompt 发送,每轮交互的结果追加到同一个数组中。
第1轮 LLM 调用:
┌──────────────────────────────────────────────┐
│ System Prompt │ User: "帮我重构登录模块" │ → LLM → 回复 + 工具调用
└──────────────────────────────────────────────┘
~2K tokens
第2轮 LLM 调用(工具结果返回后):
┌──────────────────────────────────────────────────────────────────┐
│ System Prompt │ User │ Assistant │ Tool Call │ Tool Result │ ... │ → LLM
└──────────────────────────────────────────────────────────────────┘
~8K tokens
... 经过 20 轮工具调用 ...
第N轮 LLM 调用:
┌─────────────────────────────────────────────────────────────────────────────┐
│ System │ U │ A │ T │ R │ A │ T │ R │ A │ T │ R │ ... │ User: "你好" │
└─────────────────────────────────────────────────────────────────────────────┘
│◄──────────────── 80K tokens 历史 ──────────────────────►│◄─ 2 tokens ──►│
全部重新发送 实际新内容
这种模式运行在一个典型的 Agent Loop 中:
用户输入
│
▼
┌─────────────────────────────────────────────────────────┐
│ Agent Loop │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ messages = [system, ...history, user_input] │ │
│ └───────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 调用 LLM API │◄────────────┐ │
│ │ (全量上下文) │ │ │
│ └────────┬────────┘ │ │
│ │ │ │
│ ▼ │ │
│ 有工具调用? │ │
│ ╱ ╲ │ │
│ 是 否 │ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌────────────┐ ┌──────────┐ │ │
│ │ 执行工具 │ │ 输出回复 │ │ │
│ │ 追加结果 │ │ (结束) │ │ │
│ │ 到 messages │ └──────────┘ │ │
│ └─────┬──────┘ │ │
│ │ 追加到 messages │ │
│ └──────────────────────────────────┘ │
│ │
│ messages 只增不减,直到会话结束或达到上下文窗口上限 │
└─────────────────────────────────────────────────────────┘
优势:
- 缓存利用率高——每次请求的前缀高度重叠,prompt cache 命中率极高
- 实现简单——一个数组,只追加不修改,不需要复杂的上下文选择或裁剪逻辑
- 上下文连贯——模型始终能看到完整的交互历史,对单一任务的长期推理非常理想
问题:
当用户在同一个会话中切换话题时,所有历史被无差别地塞入同一个上下文窗口。用户上午讨论了一个复杂的代码重构(消耗 80K token),下午回来说了句"你好"——这句"你好"需要携带 80K token 的完整历史一起发送给模型。即便命中缓存,以 Opus 定价计算也要 $0.30。
上下文只增不减,无关信息干扰模型推理,成本与当前请求的复杂度完全脱钩。
2.2 压缩策略
追加式模式终究会撞上上下文窗口的天花板。Claude Code 和 OpenClaw 各自设计了压缩机制。
Claude Code 在上下文使用率达到约 83.5% 时自动触发压缩——优先裁剪工具输出,LLM 生成结构化摘要前置到新会话开头,最近几轮对话原样保留。
压缩前:
┌─────────────────────────────────────────────────────────────────┐
│ System │ U │ A │ T │ R │ ... │ U │ A │ T │ R │ U │ A │ U │ A │
└─────────────────────────────────────────────────────────────────┘
│◄──────────────────── ~167K tokens ─────────────────────────────►│
压缩后:
┌─────────────────────────────────────────────────────────────────┐
│ System │ [压缩摘要: 已完成X, 正在做Y, │ 最近几轮 │ 新输入 ││ │ 修改了哪些文件, 关键决策...] │ 原样保留 │ │
└─────────────────────────────────────────────────────────────────┘
│◄─ 固定 ─►│◄──── ~10-20K ──────────────►│◄─ 近期 ─►│
OpenClaw 采用更复杂的多层策略:在压缩之前先进行记忆落盘(让模型把重要信息写入磁盘上的 .md 文件),然后分阶段渐进压缩。落盘的记忆文件跨会话存活,下次可加载。
压缩机制是必要的安全网,但它是对上下文膨胀的缓解而非解决:压缩本身需要额外的 LLM 调用,摘要是有损的,而且压缩后的摘要仍然混合了所有话题。
这引出了另一个思路:与其在上下文膨胀之后想办法缩小,能否从源头避免不相关的内容进入同一个上下文?
2.3 任务隔离:另一种思路
除了压缩,还有一种更直接的应对方式:以任务为单位隔离上下文。这并不是什么新概念——传统的任务队列、工单系统早就是这么做的,只是在 Agent 领域,对话驱动模式的流行让这种思路被忽略了。
对话驱动(当前主流模式):
┌──────────────────────────────────────────────────────────────┐
│ 重构登录模块(80K) │ "你好"(2) │ 查询用户数据(30K) │ ... │
│ 所有内容挤在同一个上下文中 │
└──────────────────────────────────────────────────────────────┘
每次调用都携带全部历史,无论是否相关
任务隔离:
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 任务1: 重构登录 │ │ 任务2: 查询数据 │ │ 任务3: 部署服务 │
│ 上下文: 80K │ │ 上下文: 30K │ │ 上下文: 15K │
│ (只含相关内容) │ │ (只含相关内容) │ │ (只含相关内容) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
每个任务只为自己的上下文付费
以任务为单位隔离上下文,每个任务拥有独立的上下文窗口。任务内部仍然使用追加式上下文——这完全合理,一个任务内的所有交互记录天然相关。但任务之间的上下文是隔离的,互不干扰。
这种方式要求用户建立一个简单的意识:不同的问题,在不同的任务中解决。 就像程序员知道不同的功能应该在不同的分支上开发一样。好的设计应该让这种隔离自然发生——新问题自动创建新任务,上下文跟着任务走,用户不需要手动管理 prompt。
任务隔离的优势是彻底消除了话题混杂问题,每个任务的上下文成本与其自身的复杂度成正比。代价是放弃了跨任务的上下文连贯性——如果两个任务之间有关联,需要额外的机制(如任务间共享摘要、会话关联)来传递信息。
对话驱动模式屏蔽了任务概念,换来的是入门门槛低;任务隔离模式引入了任务边界,换来的是更高的效率和更低的成本。两者的取舍取决于目标用户和使用场景。
三、工具加载
3.1 tools 字段与缓存机制的冲突
LLM API 的请求中,tools 是独立于 messages 的顶层字段。模型针对这种结构化格式专门微调过,保证了工具调用的高正确率和解析确定性。
但 Anthropic 的 Prompt Cache 按严格顺序构建缓存前缀: system → tools → messages。 tools位于前端,缓存从第一个 token 开始逐一比对,任何位置出现差异,该位置及之后的所有内容全部失效。
请求 A(5 个工具):
┌──────────────────────────────────────────────────────────┐
│ system prompt │ tools(5个) │ msg1 │ msg2 │ ... │ msg100 │
└──────────────────────────────────────────────────────────┘
✅ 全部命中
请求 B(加了1个新工具,变成6个):
┌───────────────────────────────────────────────────────────┐
│ system prompt │ tools(6个) │ msg1 │ msg2 │ ... │ msg100 │
└───────────────────────────────────────────────────────────┘
↑ tools 变了,整条链全部失效
❌ 100K token 的 messages 缓存失效(即使内容完全没变)
MCP 的核心价值在于动态工具发现和加载,但每切换一次工具集,就要为整个对话历史全额付费。Claude Code 报告的 92% 缓存命中率,很大程度上靠的就是永远不改变 tools 列表。
当前架构的矛盾:
动态工具(MCP 的核心价值)
↕ 不可调和
缓存稳定性(成本控制的关键)
3.2 工具描述从 tools 字段移入 prompt
一种替代方案是将工具描述嵌入 messages 而非 tools 字段:
当前方式(tools 字段注入):
┌──────────────────────────────────────────────────┐
│ system │ tools [A,B,C] │ messages... │
└──────────────────────────────────────────────────┘
↑ 改这里 → 后面全部失效
替代方式(工具描述嵌入 prompt):
┌──────────────────────────────────────────────────┐
│ system │ messages... │ [工具 A 描述] [工具 B 描述] │
└──────────────────────────────────────────────────┘
↑ 追加新工具 → 前面的缓存全部保留 ✅
优势是增加工具不破坏缓存,按需加载,与追加式上下文兼容。代价是离开了 tools 字段的结构化保证,需要自行实现工具结构的解析和调用逻辑,模型从纯文本描述中理解自定义 API schema 的可靠性可能下降。
3.3 本地用控制台,远程用 MCP
工具描述移入 prompt 后,格式可靠性取决于工具类型。
如果工具是终端—— execute: 执行 shell 命令,返回 stdout/stderr ——几乎不需要"教"模型任何东西。curl、grep、psql、docker 这些是模型在预训练中已经掌握的世界知识,不是需要通过 JSON Schema 定义的陌生 API。
MCP 方式:需要 N 个工具定义
┌─────────────────────────────────────────────────────────┐
│ tools: [get_weather, query_db, send_email, read_file, │
│ write_file, search_web, translate, ...] │
└─────────────────────────────────────────────────────────┘
每增加一个 → 缓存全部失效
控制台方式:始终只需要 1 个工具定义
┌───────────────────────────┐
│ tools: [execute] │ ← 永远不变,永远命中缓存
└───────────────────────────┘
需要查天气? → execute: curl wttr.in/Beijing
需要查数据库?→ execute: psql -c "SELECT ..."
需要发邮件? → execute: sendmail ...
控制台的优势:零学习成本、格式解析问题消失、工具数量无限、与缓存完全兼容。
但控制台不能覆盖所有场景——需要 OAuth 认证的第三方 API、需要维持长连接的服务等,终端命令并非最佳选择,这类远程服务仍然需要 MCP 提供标准化的接口结构和鉴权机制。
实际的工具策略是:本地能力用控制台,远程能力用 MCP,二者组合覆盖绝大部分场景。 少数需要持久连接或复杂状态管理的场景(如 WebSocket 订阅、连接池维护)仍然是开放问题。
3.4 渐进式工具加载
结合控制台和 MCP,理想的工具加载方式是渐进式的:
任务开始(轻量上下文):
┌────────────────────────────────────────────┐
│ system │ 任务目标 │ 基础工具(execute) │
└────────────────────────────────────────────┘
~3K tokens
Agent 判断需要远程搜索工具:
┌────────────────────────────────────────────────────────────┐
│ system │ 任务目标 │ 基础工具 │ 对话... │ + mcp_search 描述 │
└────────────────────────────────────────────────────────────┘
│◄────────── 缓存命中 ──────────────►│◄─ 新增 ─►│
Agent 发现并加载数据库工具:
┌──────────────────────────────────────────────────────────────────────┐
│ system │ 任务目标 │ 基础工具 │ 对话... │ mcp_search │ + query_db 描述 │
└──────────────────────────────────────────────────────────────────────┘
│◄─────────────── 缓存命中 ──────────────────────►│◄── 新增 ──►│
每次只追加新工具的描述,已有的上下文缓存完全保留——工具跟着对话流走,而不是钉死在缓存前缀的头部。
3.5 两种加载方式的对比
两种方式可以混合使用:高频、核心的工具(如 execute)放在 tools 字段保证可靠性和缓存稳定;低频、动态发现的远程工具通过 prompt 渐进式加载,避免破坏缓存前缀。
四、工具查找
当可用工具达到数百甚至上千个时,Agent 如何高效地找到解决当前问题所需的工具?
4.1 四种方式及其取舍
- 全量注入 tools 字段。 最简单,模型路由准确率高。但工具列表变动导致缓存失效,工具数量多时固定 token 开销巨大。
- 追加上下文加载。 不破坏缓存,但工具描述一旦追加就永远留在上下文中,几轮搜索下来上下文堆满已用完的工具描述。
- 子 Agent 查找。 不污染主上下文,但子 Agent 缺乏主对话的上下文理解,可能选择不够精准的工具。
- 向量搜索 / 关键词检索。 零 LLM token 消耗,但语义模糊时不够准确,工具库大时噪声严重。
这四种方式面临一个共同的矛盾:我们在接口维度上查找工具(一个 API 一个工具),但实际需求是在功能维度上查找能力。
4.2 Skill:按功能维度组织工具
Skill 的本质是:将多个工具的调用方法内聚在一起,按功能维度组织,而非按接口维度罗列。
接口维度(传统工具列表):
┌────────────────────────────────────────────────────────────┐
│ pg_connect │ pg_query │ pg_insert │ pg_update │
│ pg_delete │ pg_schema │ pg_export │ pg_import │
│ redis_get │ redis_set │ redis_del │ redis_scan │
│ s3_upload │ s3_download│ s3_list │ s3_delete │
└────────────────────────────────────────────────────────────┘
20 个工具定义,搜索空间:20 个选项
功能维度(Skill):
┌──────────────────────────────────────────────┐
│ 数据库管理 │ 缓存操作 │ 文件存储 │
│ (内含 8 个 │ (内含 4 个 │ (内含 4 个 │
│ pg_* 工具的 │ redis_* │ s3_* 工具的 │
│ 组合用法) │ 工具用法) │ 组合用法) │
└──────────────────────────────────────────────┘
3 个技能,搜索空间:3 个选项
一个 Skill 不是一个工具,而是一份**"如何组合使用多个工具完成某类任务"的说明书**:
# 数据库管理技能
## 能力描述
管理 PostgreSQL 数据库:查询、写入、schema 管理、数据导入导出。
## 可用工具
- psql:命令行客户端,支持 SQL 查询和管理
- pg_dump / pg_restore:备份与恢复
## 常见用法
### 查询数据
> psql -h $HOST -d $DB -c "SELECT * FROM users WHERE active = true"
### 导出为 CSV
> psql -h $HOST -d $DB -c "\COPY (SELECT ...) TO STDOUT CSV HEADER" > output.csv
### 查看表结构
> psql -h $HOST -d $DB -c "\d+ tablename"
## 注意事项
- 写操作前先确认,避免误修改
- 大查询加 LIMIT
4.3 Skill 是工具调用知识的 Cache
就像 CPU 缓存把频繁访问的内存数据放在更快的存储中一样,Skill 把频繁使用的工具调用知识预先组织好,避免每次都从头搜索和学习。
没有 Skill(每次从头探索):
任务: "导出用户数据为 CSV"
Agent 第1次做:搜索工具 → 阅读文档 → 尝试执行 → 调试 → 成功
Agent 第2次做:同样的探索过程再来一遍
每次都消耗大量 token 在"重新发现"已知的方法
有 Skill(知识已缓存):
任务: "导出用户数据为 CSV"
Agent 任意次做:匹配技能 → 加载 → 直接执行
1 次搜索 + 加载技能文本 ≈ 几百 token
技能不一定需要人工编写。Agent 可以在首次完成新类型任务后,将探索过程中学到的工具组合自动整理成 Skill,下次遇到同类任务直接加载。这形成了一个自我优化的循环:Agent 使用工具越多,积累的 Skill 越丰富,未来的效率越高。
4.4 综合对比
Skill 不是替代其他方式,而是在它们之上提供了一个缓存层。 底层仍然可以用搜索检索来匹配 Skill,用追加上下文来加载 Skill 内容,但搜索空间和加载量都被功能聚合大幅压缩了。
五、主循环设计
前三章讨论了 Agent 的上下文、工具加载和工具查找机制,其中第一章已经提到了任务隔离作为上下文管理的一种思路。本章从更上层的视角展开这个话题:Agent 的主循环围绕什么来设计?
5.1 对话驱动
当前几乎所有 Agent 产品——OpenClaw、ChatGPT、Claude——都围绕对话框设计:用户在对话框里输入,Agent 在对话框里回复,对话框既是输入源、输出目标,也是维系上下文的容器。
这种模式的优势是交互门槛极低——和聊天没有区别,不需要理解任何抽象概念。但它也意味着 Agent 的一切行为都被包裹在对话交互中,上下文管理、工具调用、结果输出全部绑定在对话框这个载体上。
5.2 任务驱动
另一种思路是围绕任务来设计主循环:
对话驱动:
┌─────────────────────────────────────────────┐
│ 对话框 = Agent 的世界 │
│ │
│ 用户输入 → Agent 思考 → Agent 回复 │
│ 用户输入 → Agent 思考 → 调用工具 → 回复 │
│ │
│ 对话框既是输入源,也是输出目标, │
│ 更是维系上下文的容器 │
└─────────────────────────────────────────────┘
任务驱动:
┌─────────────────────────────────────────────┐
│ Agent 主循环:感知→思考→行动 │
│ │
│ 感知: │
│ ├─ 聊天框(工具)→ 读取用户消息 │
│ ├─ Cron 定时器 → 触发定期检查 │
│ ├─ Webhook → 接收外部事件 │
│ └─ 自身推理 → fork 新子任务 │
│ │
│ 思考:(Agent 的核心输出) │
│ 分析任务 → 制定方案 → 选择工具 │
│ │
│ 行动: │
│ ├─ 控制台(工具)→ 执行本地操作 │
│ ├─ MCP(工具)→ 调用远程服务 │
│ ├─ 聊天框(工具)→ 回复用户 │
│ └─ 任务队列 → 创建后续任务 │
└─────────────────────────────────────────────┘
在任务驱动设计中,聊天框被降格为一个工具——Agent 通过它读取用户消息,也通过它回复用户,和 execute(控制台)、MCP 远程调用没有本质区别。聊天框的特殊之处在于它同时充当输入源和输出工具,但它是 Agent 众多可调用能力中的一个,不是 Agent 运行的容器。
5.3 思考过程作为核心输出
如果聊天框只是工具之一,Agent 的核心输出就不是对话回复,而是思考过程:
对话驱动:Agent 的输出 = 对话回复
┌────────────────────────────────────────────┐
│ User: 帮我查一下线上报错 │
│ │
│ Assistant: 我来查一下。 │ ← 主要输出:对话文本
│ [调用工具: grep error.log] │ ← 附带动作
│ Assistant: 找到了,是数据库连接超时... │ ← 主要输出:对话文本
└────────────────────────────────────────────┘
任务驱动:Agent 的输出 = 思考链
┌────────────────────────────────────────────┐
│ 任务: 排查线上报错 │
│ │
│ 思考: 需要先查看错误日志 │
│ 行动: execute("grep -n ERROR app.log") │
│ 思考: 发现数据库连接超时,需要检查连接池配置 │
│ 行动: execute("cat config/db.yaml") │
│ 思考: 连接池 max_idle 设置过低, │
│ 需要告知用户结果 │
│ 行动: chat.send("问题定位到了:...") │ ← 回复用户只是其中一个动作
│ 任务完成 │
└────────────────────────────────────────────┘
当思考过程成为核心输出时,Agent 的行为变得可观测——用户可以看到 Agent 在想什么、为什么选择某个工具、为什么放弃某个方案。当 Agent 犯错时,可以追溯到具体哪一步思考出了问题,这种可追溯性是 Agent 持续改进的基础。
Claude Code 在这个方向上做了有意义的尝试——它展示了思考过程、工具调用和执行结果。虽然目前的形式更像是 Agent 在自问自答,但核心理念值得借鉴:让思考过程成为一等公民。
5.4 现实约束:模型的对话训练倾向
任务驱动设计面临一个现实障碍:当前的大语言模型是以对话模式训练的。
- RLHF 优化的是"回复满意度" ——直接给出答案几乎总是比"我先调用工具"获得更高的评分,模型的本能是直接回答
- 工具调用是后天习得的 ——function calling 能力通过后期微调加入,没有明确触发信号时模型会 fallback 到对话模式
- 回复和工具调用是竞争关系 ——训练数据中"说话"的样本远多于"调用工具"的样本
要让 Agent 真正以"思考→工具调用"为主循环,不仅需要 system prompt 层面的引导,更需要模型厂商在训练阶段针对 agent 模式专门优化。从 GPT-4 到 Claude 到 Gemini,每一代模型的工具调用能力都在提升,但距离"模型天然就是任务执行器"的状态还有距离。
5.5 两种模式的取舍
两种模式并非非此即彼。对话驱动可以作为任务驱动的前端——用户通过对话提交需求,系统自动将其转化为任务,在后台以任务驱动模式执行。关键是在架构层面保留任务隔离的能力,而不是将所有东西绑定在一个对话框上。
六、总结
构建 Agent 的四个关键设计决策:
①上下文管理。
追加式上下文简单高效但成本与请求复杂度脱钩,压缩机制可以缓解膨胀但无法消除话题混杂。任务隔离从源头避免了这个问题,代价是需要额外机制处理跨任务的上下文关联。
②工具加载。
tools 字段保证了调用可靠性但与动态加载存在缓存冲突,工具描述移入 prompt 兼容了缓存但牺牲了格式保证,两者可以混合使用。本地能力用控制台、远程能力用 MCP 的组合是当前的务实选择。
③工具查找。
接口维度的查找在工具规模增长时效率下降,Skill 通过功能维度的聚合压缩了搜索空间,并形成了可复用的经验沉淀,但需要前期投入来构建和维护技能库。
④主循环设计。
对话驱动门槛低、模型兼容性好,任务驱动灵活、可观测性强,但受制于模型的对话训练倾向。两者可以结合——对话作为前端,任务作为后端。
这些决策之间并非独立。任务隔离影响上下文管理策略,工具加载方式决定了 Skill 的组织形式,主循环设计决定了 Agent 能从哪些来源获取任务。理解这些决策的关联和取舍,是设计一个高效 Agent 的前提。