OpenClacky 的 Harness 工程
两年三代架构迭代,第三代的实践与说明。
过去两年,我们经历了三代 Agent 架构。第一代尝试 RAG,第二代在 SWEBench 和云端多 Agent 上下了很多功夫——踩了很多坑,也学到了很多。
最深的教训是:用户想要的只是把任务又快又好地完成。最好的架构,不是盲目追求多 Agent 和复杂编排,而是在单 Agent 上把效果和成本控制做到极致。
于是有了第三代:用 Ruby 语言从零重构,历时三个月,这就是现在的 OpenClacky。
本文记录第三代架构的核心工程决策,以及每个决策背后的权衡。
一、始终追求 100% Cache 命中
Prompt Caching 是降低 LLM 成本最有效的手段之一。但大多数 Agent 实现都在这里漏掉了大量收益——天价账单和推理变慢,很多时候根源都在 cache 架构设计不合理。
Session 不重启,System Prompt 不变
OpenClacky 在 session 开始时完成 system prompt 的构建,之后永远不再修改,只向后追加新消息。
但"不修改"不等于"不能动态更新"。我们设计了一套 [session context] 机制:把需要动态变化的内容(比如 Skill 列表重载、模型切换)以独立块的形式插入到上下文中,而不是重新构建 system prompt。
效果是:session 全程 system prompt 的 cache 断点始终有效。绝大多数 Agent 遇到这类更新时会选择重启 session,Claude Code 和 OpenClaw 均是如此——重启意味着所有缓存全部失效、重新计费。我们的设计让这个代价降为零。
双重 Cache 标记
最直觉的做法是对最后一条消息打 cache_control。但这会造成结构性 miss:
第 N 轮:标记 messages[-1],建立缓存
第 N+1 轮:messages[-1] 变成了 messages[-2],标记丢失 → cache miss
我们同时标记最后 2 条消息:
第 N 轮:标记 messages[-2] 和 messages[-1]
第 N+1 轮:messages[-2] 标记仍然存在 → 命中缓存
这同时解决了回滚的问题——当发生 LLM 重试或出错后换方向重构时,双重标记确保仍能最大程度保住已有缓存,而不是全部重新付费。
Insert-then-Compress
压缩是另一个 cache 杀手。常见做法是开一个新的 LLM 调用来压缩历史——这会让所有已建立的 cache 全部失效。
我们把压缩指令插入当前对话流,在下一轮正常请求时顺带完成压缩。Cache 天然复用,压缩成本接近零。
二、最小工具集:一切皆 Skill
工具越多,模型选择时的噪声越大,schema 越长,cache 越难保持稳定。这是大多数 Agent 工具膨胀的根本代价。
Hermes Agent 是目前比较流行的 Agent 框架,内置 52 个工具。虽然支持按需加载,但一旦配置完成,大部分工具都会出现在上下文里。52 个工具的 schema 本身就是一笔固定成本,还会因为工具说明互相干扰而引发更多幻觉。工具越多,模型越容易在"用哪个"上出错。
架构第一决策:invoke_skill
OpenClacky 的核心工具集只有 16 个。这不是靠精简掉什么功能实现的,而是靠一个架构决策:把 invoke_skill 作为第一等公民。
invoke_skill 本质上是一个元工具——它让 Agent 可以将任何复杂能力委托给独立的 Skill,而不是把所有工具塞进核心列表。这个设计一次性解决了多个问题:
Sub-agent 调用:Skill 就是 sub-agent。调用一个复杂的代码审查 Skill,和调用一个 shell 命令,在协议上是完全等价的。Agent 不需要理解 sub-agent 的内部实现,只需要知道"有这个能力"。
复合工具能力:一些操作需要多步工具配合才能完成——记忆召回(读文件 + 语义判断 + 摘要)、代码库探索(glob + grep + 多文件阅读 + 结构分析)、历史压缩(读取 + 总结 + 写回)——这些放进核心工具列表会产生大量 schema 噪声,做成 Skill 就是一次干净的调用。
能力无限扩展,核心列表不变:用户安装新 Skill,工具数量不增加,schema 不变,cache 不受影响。这是 16 个工具能覆盖无限能力边界的核心原因。
省下了什么
| 省掉的能力 | 替代方式 | 工具数节省 |
|---|---|---|
| 代码库分析专用工具 | code-explorer Skill |
~5 个 |
| 记忆读写专用工具 | recall-memory Skill |
~3 个 |
| 浏览器自动化(多动作拆分为多工具) | 单一 browser 工具统一覆盖 |
~8 个 |
| Sub-agent 编排工具 | invoke_skill 统一入口 |
~6 个 |
| 定时任务管理工具 | cron-task-creator Skill |
~4 个 |
核心工具列表稳定,cache 断点不移动。 工具数量不是竞争力,任务完成率才是。
竞品对比
各家 Agent 在核心功能上并无本质差异,真正的区别在于工具数量。
| OpenClacky | Claude Code | OpenClaw | Hermes Agent | |
|---|---|---|---|---|
| 内置工具数 | 16 | 40+ | 23(含插件/MCP/channel 可达 30–50) | 52 |
| Sub-agent 调用 | ✅ | ✅ | ✅ | ✅ |
| Channel 集成 | ✅ | ✅ | ✅ | ✅ |
| Web Search / Fetch | ✅ | ✅ | ✅ | ✅ |
| Browser 自动化 | ✅ | ✅ | ✅ | ✅ |
| 能力热扩展(无需重启) | ✅ Skill 热加载 | ❌ | ❌ | ❌ |
| 工具膨胀应对 | 架构上不产生膨胀 | ToolSearchTool(超 ~30 自动启用) | 扩展形式按需加载 | 按需加载 |
Claude Code 的 ToolSearchTool 是一个有创意的工程方案:工具数超过 ~30 后,模型改为按需搜索工具,避免 context 爆炸。这是在工具已经膨胀的前提下做的补救。OpenClacky 的选择是从源头不让工具膨胀,所以不需要这一层。
三、FuzzySearchReplace
代码编辑是 Coding Agent 最高频的操作。LLM 生成的 old_string 有一个固有问题:转义符、缩进、行尾空白——这些细节在模型生成时很容易出错,导致精确匹配失败,进入重试循环。
问题
每次 edit 失败,Agent 需要重新理解错误、生成新的 old_string、再次尝试。在长任务里,这类失败会积累成显著的 token 浪费和时间消耗。
我们的决策
在 StringMatcher 里实现了 5 层降级匹配策略:
| 层级 | 策略 | 针对的问题 |
|---|---|---|
| 1 | 精确匹配 | 正常情况 |
| 2 | Trim 前后空白 | 模型生成时多了换行或空格 |
| 3 | Unescape 转义符 | \n \t \uXXXX 未被展开 |
| 4 | Trim + Unescape 组合 | 两种问题同时存在 |
| 5 | 逐行智能匹配(Tab/Space 容差) | 文件缩进风格不一致 |
大多数情况在第 2-3 层就能命中。这不是模糊匹配——每一层都针对 LLM 输出的具体出错模式设计,匹配结果是确定性的。
四、自进化的 Parsers & Scripts
PDF、Word、Excel、图片处理是 Agent 的核心基础能力。但本地环境的多样性让这件事很棘手——不同系统、不同版本、不同依赖,总会遇到意料之外的格式问题。
面对这个困境,通常只有两条路:放弃这部分能力,或者提高安装门槛,要求用户在安装时预置好所有依赖。两条路都不好走。
我们的决策
我们选了第三条路:把工具进化的权力交给 Agent 本身。
Gem 内置默认解析脚本,首次运行时复制到用户目录 ~/.clacky/parsers/。此后 Agent 永远使用这个用户空间的版本。
当解析失败时,Agent 直接定位到对应脚本,自行修复 bug,下次执行自动生效。Shell 脚本(~/.clacky/scripts/)采用相同机制。
结果是:安装时零门槛,使用中越来越好用。第一次遇到某种特殊格式会失败,Agent 自修复之后,永久修好。不需要等待版本更新,也不需要用户手动干预。
五、有门槛的 Skill 自进化
Skill 自动创建是个双刃剑。没有门槛的自动创建会积累大量低质量 Skill,它们互相矛盾,检索时产生噪声,反而干扰 Agent 的判断——我们把这个问题叫做 Skill 熵增。
我们的决策
两个自进化钩子,触发条件都很克制:
SkillAutoCreator(从任务中提炼新 Skill):
- 完成一个 ≥ 12 轮迭代的复杂任务
- 且任务过程中没有调用现有 Skill(说明确实是新工作流)
- 由 LLM 自己判断这个工作流是否值得沉淀为 Skill
SkillReflector(优化已有 Skill):
- 用户显式调用了一个 Skill(不是被动推断触发的)
- 执行超过 5 轮迭代(说明 Skill 确实承担了复杂任务)
- LLM 反思指令是否清晰,是否有遗漏的边缘情况
宁可少创建,不创建垃圾。~/.clacky/skills/ 里的每一个 Skill,都应该是经过足够多真实使用验证的。
六、白名单记忆触发
长期记忆对 Agent 的连续性很重要,但记忆写入本身有成本:一次额外的 LLM 调用,以及写入内容的质量问题。低质量的记忆在未来的会话里会成为上下文噪声。
我们的决策
记忆写入需要同时满足两个条件:
- 对话轮数 ≥ 10 轮(短对话通常没有值得长期保留的信息)
- 出现明确的高价值信号:用户的显式决策、新建立的持久上下文、反复出现的错误模式
不满足条件时,会话结束后什么都不写。满足条件时,LLM 自己判断哪些内容值得保留、写到哪个文件、如何与已有记忆合并。
~/.clacky/memories/ 里的每一条记忆,都是经过筛选的。召回时的信号噪声比更高,判断更准确。
七、无 Gateway 设计
许多 Agent 工具会常驻一个后台服务进程——即使你已经停止使用,它依然在监听端口,依然可以被访问,依然可能在你不知情的情况下响应请求。
我们认为这不对。用户说停,就应该真的停。
OpenClacky 不常驻任何额外的后台进程。关掉 Agent,端口随之释放,没有任何监听入口留在系统里。这是用户应该拥有的完整控制权。
无缝升级
不常驻进程通常意味着一个代价:升级时需要停服。用户在 Web UI 点击升级,如果服务中断、页面刷新失败,体验会很糟糕。
我们用的方案和 nginx 等顶级服务器程序相同:Master-Worker 分离 + Socket 继承。
Master 进程永久持有 TCP 端口,自身不处理任何请求。Worker 进程继承 Master 的 socket,负责真正的 HTTP 服务。升级时,Master 用新版本启动一个新 Worker,等它就绪后再向旧 Worker 发送退出信号,整个切换过程端口始终保持占用,连接无中断。
对用户来说,在 Web UI 点击升级,新版本静默生效,页面不会断开。
小结
这七个决策有一条共同的底层逻辑:不用复杂性换能力,而是用精确性换效率。
大多数 Agent 框架走的是加法路线——更多工具、更多记忆、更多编排层——然后在膨胀之后用补丁弥补:工具太多加个搜索层,记忆太杂加个过滤器,上下文太长加个压缩器。每一层补丁都带来额外的 token 消耗和潜在的失控点。
我们的选择是在每一层主动设边界:
- Cache 层:不接受默认的缓存失效。双标记窗口锁住 system prompt,Insert-then-Compress 让压缩本身也能命中缓存。命中率接近 100%,每次调用的成本可预期。
- 工具层:16 个工具,不是因为懒,而是因为工具数量直接影响 schema 噪声和模型判断。扩展能力通过 invoke_skill 外包出去,不污染核心工具集。
- 容错层:FuzzySearchReplace 5 层降级,是承认字符串匹配在真实代码里会失败,提前把失败路径设计进去。
- 知识层:记忆写入有白名单门槛,Skill 自进化有轮次门槛,Parser 自修复而非等升级。每一条进入长期知识库的信息,都经过了足够的使用验证。信号密度高,噪声少。
- 可靠性层:Time Machine 让文件和对话状态同步回滚,用户可以大胆尝试。无 Gateway 让停止就是真的停止,没有隐藏服务。Master-Worker 分离让升级无缝,端口全程不释放。
这些决策没有一个是炫技。它们是两年踩坑之后,对「AI Agent 应该怎么设计」这个问题的诚实回答。
完全开源,MIT 协议:github.com/clacky-ai/openclacky