Harness 工程分享:我们怎么把 LLM Agent 的 prompt cache 命中率做到 90%+(附 4 家 Agent 横评数据)

查看 7|回复 0
作者:yafeilee   
接上一篇 https:///t/1211434 很多朋友好奇我们的工程实践,我把这二年的坑和 Ruby 重写的思考放出来,大家一起看看离 ClaudeCode 这种顶级 Harness 工程还有多远。
开篇
为了让新朋友重新了解一下我们的评测结果,我再列一下。
成本极优:3 项任务实测,4 家 Agent 横评,OpenRouter CSV 逐请求核算:
[td]Agent[/td]
[td]总成本[/td]
[td]请求数[/td]
[td]Cache 命中率[/td]
OpenClacky
$5.10
51
90.6%
Claude Code
$5.49
70
95.2%
OpenClaw
$15.70
81
88.7%
Hermes
$30.14
218
60.3%

完整数据和产物对比:openclacky.com/benchmark
51 个请求 + 90.6% 命中率 → $5.10 。218 个请求 + 60.3% 命中率 → $30.14 。成本差距的直接原因就两个:请求数cache 命中率
不要忘了,OpenClacky 是一个全功能 Agent:WebUI + 命令行、长期记忆、Skill 技能库、定时任务、IM 接入(飞书/企微/微信)、浏览器自动化、子 Agent 、运行时切模型、Skill 自进化与动态加载。
而很多开源 Agent 也许有较好的 Token 消耗,或功能不全,或命中率不高。
在实践中最大的问题是:这些功能里很多跟"高 cache 命中率"是结构性冲突的。
举例:
  • 切模型 → 模型 ID 写在哪?写进 system prompt 就 cache 失效一次。
  • 中途装 skill → skill 列表写在哪?写进 system prompt 就 cache 失效一次。
  • 知道"今天日期" → 写进 system prompt ?跨天就失效。
  • 加"读 PDF"能力 → 最容易的实现是再加一个工具 → 工具 schema 变了 → cache 失效面变大,模型选错工具的概率也变大。
  • 上下文不爆 → 最容易的做法是开一次独立 LLM call 做压缩 → 压缩本身 100% miss ,压完之后主对话 cache 也凉了。

    单看任意一头都不难做:少做功能,命中率自然高;不管账单,功能可以堆得很猛。难的是两头同时做。这篇文章讲我们在每个冲突点上具体怎么取舍。

    效果已经不是当前 Agent 的主要矛盾,成本才是。
    起步:两年失败史
    第三代之前还有两代,失败的很严重。但我感觉现在还有很多人在踩坑,估计很多人有争议,但我 100% 站我自己的观点。
    第一代( 2024-2025 上半):RAG / 知识库
    把用户 codebase 、文档、历史会话全 embedding 进向量库,hybrid 检索 + 重排 + query rewrite 。Agent 流程是"先查上下文,再答"。
    实际跑下来的问题:
  • 成本高,每次更新的 codebase ,需要同步更新向量,实时性无法保证。
  • 准确率有限,例如听起来 90% 的召回率是不是还不错,但是对不起,不仅没有用,还可能有害,我预测,97%的召回率可能才刚刚够用。
  • 多了一个会失败的部件(向量库),增加了很多延迟。

    结论:千万不要搞任何 RAG 、知识库分片。如果你要上 Agent ,请直接上 Agent ,外加一个适合 AI 去阅读的网站就可以了。(参考我们自反思 Skill product-help 的实现)
    第二代( 2025 中期):SWEBench / 多 Agent 工作流
    Planner / Coder / Reviewer / Tester 各一个 agent ,消息总线 + 角色 prompt 编排。
    实际跑下来的问题:
  • 每个 sub-agent 各有 system prompt ,各有 cache 命名空间。Agent 间交接靠消息序列化状态,每次交接 = 一次 cache miss
  • 一个单 agent 4 分钟能完成的任务,多 agent 编排到 14 分钟,成本 6×。
  • SWEBench 分数能刷上去,但榜单跑分跟用户实际感受脱节得很厉害。

    结论:
  • 不要做工作流编排。 多 Agent 在结构上就是 cache 灾难。人类的分工不对 AI 有任何价值。AI 是万能的。
  • 不要被 benchmark 绑架。 模型每 6 个月跨一个台阶,用今天的二流模型 + 工作流堆出来的分数,会被半年后顶级模型 + 朴素 harness 直接抹平。把工程预算花在 harness 上,不要花在编排上。对 Agent 工程来说,Benchmark 本身也并不重要,一个朴素的 Agent 思想打败一切:站在 AI 的角度思考你的上下文。

    第三代( 2025 年底至今)
    Ruby 从零重写,4 个月。围绕"cache 局部性"和"工具集稳定性"来组织。后面讲的所有决策都属于这一代。
    核心决策 1:双 cache 标记 + 允许失败回退
    OpenClacky 同时跑在 Claude / OpenAI 兼容这两条主线上,两边的 prompt cache 行为不同,但工程上我们只关心一个共性:cache 是按"前缀"匹配的——前缀里改一个字节,从那里往后全部失效
    所以前缀的"层次"和"标记位置",决定了你下一轮还能 hit 到哪里。我们把请求前缀分成几段考虑:
  • session-stable 段:system prompt 、工具 schema 。session 内绝不变。
  • append-only 段:历史消息。只追加、不修改。
  • session-volatile 段:当前轮新消息(用户输入、工具结果、模型回复)。

    前两段交给"系统提示词层"的天然断点,后续每轮都能 hit 。真正需要工程的是"append-only 段"——它每轮都在长尾部,标记打哪儿、打几个,决定了下一轮还认不认得它。
    朴素做法为什么不够
    最直觉的做法是"每轮在 messages 末尾打一个 marker"。它在以下场景都会失效:
    [ol]
  • history 单调追加:第 N 轮在 messages[-1] 打 marker ,第 N+1 轮 messages 又长了一条,原 marker 的位置内容已经不一样了——服务端找不到匹配,整段 history 上 cache miss 。
  • 模型回退一次工具调用:工具报错、用户 Ctrl-C 重试、或者模型自己决定换一种 tool call——这一刻"原本的最后一条"被丢弃,单 marker 直接作废。
  • 运行时切模型:用户在 session 中途从 Sonnet 切到 Opus ,请求路由到新 endpoint ,最理想情况下我们希望两个模型共享尽可能多的前缀。任何不必要的 marker 抖动都会让"切换"成为新的 cache miss 事件。
    [/ol]
    我们一开始就栽在 (1) 上。修复链能从 git log 里看出节奏:
    8ff66cc fix: cache
    6ea99fe fix: prompt cache
    e9a3602 feat: prompt cache works fine
    7734c97 feat: try 2 point cache
    前三个 commit 是逐步逼近,最后一个是结构性正解。
    双标记是怎么工作的
    每轮我们标 两条 连续消息,不是一条:
    第 N 轮:    [..., msg_A, msg_B(*), msg_C(*)]
                                    ↑       ↑
                              marker 1   marker 2
    第 N+1 轮:  [..., msg_A, msg_B(*), msg_C(*), msg_D(*)]
                                    ↑       ↑          ↑
                              (仍在)   (仍在)     新 marker
    第 N+1 轮发出请求时:
  • 服务端尝试匹配 msg_C 的 marker → 命中到 msg_C 之前的所有内容( system prompt + 工具 + 整段历史除最后一条)。
  • 我们在 msg_D 上加新 marker ,建立新的尾部断点供下一轮使用。

    这是一个滚动双缓冲:任何时刻都持有两个断点——一个"刚建立的"(写)和一个"上一轮建立的"(读)。下一轮把"读"再读一次,把"写"扔掉,再在新尾部写一个。永远不会出现两个 buffer 同时失效的瞬间。
    为什么是 2 ,不是 3 或 4
    主流大模型的 cache 都允许多个标记位(上限不一),但更多并不更好
  • 每多一个 marker ,那一轮就多一次 cache write ,按写入费率收。
  • 双标记要解决的失败模式只有一个位置:**"昔日尾部 / 今日尾部"这个边界。两个 marker 正好覆盖。第三个 marker 落在更靠前的位置,对应的 cache 段在下一轮仍然会被前两个 marker 之一覆盖**——它写的是一段永远不会被独立读到的前缀。
  • 标记多了之后,部分 endpoint 上服务端的候选前缀匹配代价也会涨。

    简单说:2 是覆盖尾部边界的最小数量,3 多余,4 浪费。
    允许失败:单步回退仍然命中
    这是双标记的第二个好处,也是当时 7734c97 的真正动机。
    模型偶尔需要回退一次 tool call:工具返回错误、用户 Ctrl-C 重试、或者上游 streaming 断了一半。这种情况下"昨天的最后一条"被丢弃了,但倒数第二个 marker 通常仍然落在仍存在的消息上——单步回退后还能命中。
    单 marker 在回退时直接作废;双标记是能扛住单步回退的最小数量。我们没继续往上加(三标记也能扛两步回退,但成本不划算)——回退超过一步的概率已经低到可以接受全 miss 一次。
    模型切换:为什么要 marker 不动
    OpenClacky 支持在 session 中途换模型。工程上要保证两件事:
    [ol]
  • 新模型的请求前缀和老模型尽量一致。 我们不在 system prompt 里写当前模型 ID (写在 [session context] 块里,见决策 2 ),换模型不动 system prompt 。
  • marker 位置不变。 切完模型的下一轮,前两个 marker 落在和切换前完全相同的 message 上。新 endpoint 第一次请求会因为"换了上游账号 / 区域"产生一次 cache write ,但前缀的几何结构是连续的,warm-up 只发生一轮。
    [/ol]
    这个细节不做的话,每次切模型一定要都要付完整 cache 重建的钱,用户会很不开心。
    不能标的位置
    marker 选择逻辑里有一条硬规则:跳过 system_injected: true 的消息
    [session context] 块就是典型例子——它是一次性信息,下一轮尾部已经变了,落在它身上的 marker 是一笔永远读不回来的写入。压缩指令注入也是同样的处理(决策 5 会展开)。
    marker 选择从尾部往前走,system_injected 的跳过,凑够两个真实对话消息为止。
    本节总结
  • system prompt + 工具 schema:靠 system prompt 段的天然断点 hit 。
  • history 滚动:靠双标记。
  • 单步回退:靠双标记容错。
  • 模型切换:靠"动态信息不写进 system prompt"+ marker 位置不变。

    把这四件事同时做到,普通一轮的 cache 命中率才有可能稳定在 95%+。前三件是 cache 几何,第四件是设计纪律。
    决策 2:永不变的 system prompt
    OpenClacky 的 system prompt 在 session 启动时一次性构建,之后字节冻结。 任何"想往 system prompt 里塞动态信息"的需求,必须重定向到别的位置。
    这条纪律是 cache 命中率的第一道地基——system prompt 一变,后面所有 cache 全废,没有任何"局部修补"能挽回。
    但日常跑下来,至少有四类信息"天然想插入到 system prompt":
    [ol]
  • 当前时间、当前工作目录、操作系统——模型需要这些来生成正确的命令和路径。
  • 当前模型 ID——模型知道自己是谁有助于自适应行为。
  • 用户装了新 skill——模型需要看到新的 skill 名称和描述才能调用。
  • 用户更新了 USER.md / SOUL.md——agent 的人格和用户偏好发生了变化。
    [/ol]
    这四类信息都是"session 中途可能变"的。如果写进 system prompt ,任何一次变更都意味着全量 cache 失效。
    [session context] 块
    我们的做法是把这些信息写进 message 流,而非 system prompt 。每当环境发生模型需要感知的变化时(跨天、切模型、切工作目录),agent 在 history 里追加一条 user 角色的消息:
    [Session context: Today is 2026-05-13, Tuesday. Current model: claude-sonnet-4-6.
    OS: macOS. Working directory: /Users/.../project]
    这条消息被标记为 system_injected: true。它不会被 cache marker 选中(决策 1 已经讲过),不会被算作真实用户轮数,压缩时也不会被原样搬进新历史。
    注入是按日期 gate 的:同一天内只注入一条。跨天了,插一条新的。切了模型,插一条新的。大多数 session 里你只会看到一条 session context 块。
    这个设计踩过的坑
    第一版 inject_session_context 是在 agent 构造期就急切注入的。结果 @history.empty? 返回 false ,run() 误以为是后续轮,跳过了 system prompt 的构建——第一次请求带着一条"today is Tuesday"但没有 system prompt 就发出去了。agent 的行为诡异了大约一天才定位到。
    修复只有一行:等 system prompt 构建完毕之后再注入。代码里有一段注释记录了这个约束:
    # IMPORTANT: Skip injection when the system prompt hasn't been built yet.
    # Otherwise, appending a user message to an empty history makes
    # @history.empty? false, which causes run() to skip building the
    # system prompt entirely.
    教训是:前缀的组装顺序比前缀的内容更要紧。 你可以花大力气设计每一段的内容,但只要组装顺序错一步,整个 cache 策略就是废的。
    Skill 列表怎么处理
    Skill 列表是最容易跟"永不变的 system prompt"冲突的需求。用户可以随时装新 skill ,模型需要看到 skill 名和描述才能通过 invoke_skill 去调用它。
    我们的取舍:skill 列表在 session 启动时渲染进 system prompt ,之后冻结。 session 中途装的新 skill ,模型在当前 session 里看不到——它会看到一条 [session context] 通告说"skill 列表已更新,新 skill 从下一个 session 可用"。
    这意味着用户装完 skill 想立刻用会发现用不了,要开新 session 。我们接受这个摩擦,因为替代方案是重渲染 system prompt 导致全量 cache 失效——这个代价打到所有用户的所有 session 的每一轮上。装 skill 是低频操作,cache 命中是每轮都在享受的收益,取舍方向很清楚。
    USER.md / SOUL.md 的更新也是同样的处理:session 启动时读取,session 内不再变。
    但是,在用户体验上,我们虽然降低了一些 Skill 发现的概率,但一旦用户主动提起新的 skill 时,我们系统仍能及时发现新 Skill 。没有任何缓存,每次都会重建 Skill 列表。
    决策 3:invoke_skill 的妙用
    invoke_skill 是 OpenClacky 的 16 个工具之一,它是整个 OpenClacky 最核心的设计,花费的时间也最多,它提供 Skill 热加载能力,子 Agent 架构支持,记忆召回能力、Skill 进化能力,但它只占 system prompt 不超过 200 个 Token 。
    [ol]
  • 启动子 agent
  • 子 agent 用的工具集跟主 agent 完全相同( 16 个)。它不是一个"精简版",它能做主 agent 能做的一切事情。
  • 子 agent 执行完后,把结果文本返回给主 agent,主 agent 的 history 里只看到"invoke_skill → 结果"这一对消息。
    [/ol]
    这个设计一口气解决了好几个问题:
    子 agent = 状态隔离
    做代码审查的 skill 可能需要读几十个文件、跑 grep 、输出长篇分析。如果这些中间步骤都在主 agent 的 history 里,history 会膨胀得很快——cache 命中率没变,但上下文总量上去了,压缩触发得更早,成本更高。
    子 agent 把这些中间过程隔离在自己的 session 里。主 agent 只看到最终结论。主 agent 的 history 没有被污染。
    动态加载 Skill ,不改 system prompt
    装新 skill 的流程就是把一个 SKILL.md 放到 ~/.clacky/skills// 或 .clacky/skills// 下。skill 列表渲染进 system prompt 的时间点是 session 启动,决策 2 已经讲过。
    但 invoke_skill 这个工具本身是始终存在的——它不需要 system prompt 里列出所有 skill 才能调用。模型可以通过 [session context] 通告知道新 skill 的名称,然后直接 invoke_skill(skill_name: "xxx")。Skill 的 SKILL.md 是在调用那一刻才读取的,不是预编译进 system prompt 的。
    所以"动态加载 skill"这个能力,实际上是 invoke_skill 的运行时读取 + [session context] 的通告组合出来的。不需要改 system prompt ,不需要改工具列表,不需要重启 session 。
    Skill 注入与路径处理
    每个 skill 的 SKILL.md 可以引用相对路径的资源文件(模板、配置等)。invoke_skill 在启动子 agent 之前会把 skill 的目录作为上下文路径注入,子 agent 能用 file_reader、glob 直接读到 skill 附带的资源。
    这让 skill 可以做到"自包含"——一个 skill zip 包里既有指令又有模板,装上就能用。
    加密 Skill 与选择性落盘
    部分 skill 包含商业敏感内容(客户的 prompt 策略、内部流程等)。OpenClacky 支持对 SKILL.md 做加密存储,运行时解密到内存、用完不落盘。同时 session 的落盘也是选择性的——对于涉及加密 skill 的 session ,可以配置为不持久化到磁盘,只在内存中存在。
    这不是 cache 工程的范畴,但它是 invoke_skill 架构的延伸:因为子 agent 的状态是隔离的,选择性不落盘可以精确到某次 skill 调用,而不需要把整个 session 的落盘关掉。
    决策 4:控制稳定可靠的工具集 16 个
    工具 schema 紧贴 system prompt 之后,在 cache 前缀里。schema 一变,后面全失效。这意味着:每多加一个工具,你不只是多了一份 schema 的 token 成本,你还多了一份"下次改工具时全量 cache 失效"的风险面。
    另一面,工具太少也有代价:模型本来一步能做完的事,现在要分两三步(先调一个通用工具获取信息,再调另一个来操作),轮次上去了,每轮都要付 cache 和 output 的钱。
    所以这不是一个"越少越好"的问题,而是一个经验平衡点。我们的答案是 16 个
    这 16 个分别是什么
    [td]类别[/td]
    [td]工具[/td]
    [td]说明[/td]
    文件读写
    file_reader, write, edit
    读、写、搜索替换
    代码搜索
    glob, grep
    文件查找 + 内容搜索
    执行
    terminal
    shell 命令
    浏览器
    browser
    接管 Chrome/Edge
    网络
    web_search, web_fetch
    搜索 + 抓取网页内容
    任务管理
    todo_manager, list_tasks, undo_task, redo_task
    规划、撤销、重做
    交互
    request_user_feedback
    需要用户输入时
    扩展
    invoke_skill
    调用 skill (决策 3 )
    安全
    trash_manager
    安全删除( rm → trash )
    设计原则
    简化参数。 每个工具的参数尽量少、语义尽量明确。比如 glob 只要 pattern 和 base_path,不需要模型去组合 --include / --exclude / --type 这些 flag 。参数越多,模型出错的概率越高,出错就要重试,重试就是成本。
    够用但不冗余。 glob 和 grep 是两个工具而不是一个:glob 负责"哪些文件匹配",grep 负责"文件里哪些行匹配"。合成一个会让参数变复杂,模型调错的概率上升。但也没有继续拆成 find_files / list_dir / tree 三个——glob 一个就能覆盖这三个场景。
    为每个工具写丰富的测试用例。 工具是 agent 跟外部世界的接口,一个工具出 bug 的代价远高于普通代码出 bug——它会让模型产生错误的观察,进而做出错误的决策,进而需要更多轮次来纠正。我们一共有 1600+ 的用例去覆盖各种场景的处理。最近有 V 友还给我们提交了子项目扫描慢(对,OpenClacky 支持子项目处理)的一个相关优化 issue 。
    为什么不是 10 个,也不是 25 个
    10 个做不到。undo_task / redo_task / list_tasks 这些看起来"可以不要"的工具,拿掉之后模型就只能用 terminal 跑 git 来处理代码回滚——成功率远低于专用工具,而且 git 操作的副作用很难控制。很多工具设计了一个 code_run ,我们并不推荐,实测反而导致任务变慢(需要写长代码),轮次变多(多次尝试)。
    不需要 40+,只需要 16 个。
    [td]省掉的能力[/td]
    [td]替代方式[/td]
    [td]工具数节省[/td]
    代码库分析专用工具
    code-explorer Skill
    ~5 个
    记忆读写专用工具
    recall-memory Skill
    ~3 个
    浏览器自动化(多动作拆分为多工具)
    单一 browser 工具统一覆盖
    ~8 个
    Sub-agent 编排工具
    invoke_skill 统一入口
    ~6 个
    定时任务管理工具
    cron-task-creator Skill
    ~4 个
    如果以后需要第 17 个,我们会加。4 个月了,还没加。
    决策 5:压缩——不换模型、空闲时做、压到底
    上下文窗口是有限的。不管 200K 还是 1M ,长任务跑下来总会填满。填满之前必须压缩,否则要么截断丢信息,要么溢出直接报错。
    压缩是 cache 命中率最大的单点威胁:老的消息被替换成一段摘要,前缀从那一刻起就跟之前不一样了——必然 cache miss。但压缩不可避免,所以问题不是"要不要压",而是"怎么把压缩的破坏降到最低"。
    结论一:不要换模型压缩
    很多 agent 的压缩流程是开一个独立的 LLM call,用一个便宜/快速的小模型来做摘要。
    问题:
  • 独立 call 的 system prompt 跟主 session 不一样(通常是"你是一个摘要助手"),跟主 session 的 cache 没有任何共享前缀,压缩本身就是一次 100% cache miss 。
  • 压缩完之后,主 session 的 history 被替换了(老消息变成了摘要),主 session 的 cache 也跟着失效——接下来 4–5 轮跑在 cold 费率上。

    等于你为每次压缩付了两笔钱:一笔给压缩 call 本身的 cache miss ,一笔给主 session 压缩后的 cold-warm 阶段。
    我们的做法:压缩不开独立 call ,而是把压缩指令作为一条消息插进当前对话的末尾( Insert-then-Compress )。
    这条指令被打上 system_injected: true,走正常请求路径。效果:
  • 压缩 call 命中现有 cache:同样的 system prompt 、同样的 tools 、同样的 history 前缀。只有尾部的压缩指令是 cold 的,几百 token 。
  • 压缩完成后,重建 history:[system_prompt, summary, last_N_messages]。这一刻 cache 确实会 miss 一次——但只 miss 一轮,从第二轮开始双标记重新接管。

    对比(一次 50K-token 会话的压缩事件):
    [td][/td]
    [td]独立 call 方案[/td]
    [td]Insert-then-Compress[/td]
    压缩 call 的 cache hit
    0%
    ~95%
    压缩期间 cold token
    ~50,000
    ~500
    主 session cold-warm 轮数
    4–5
    1
    结论二:20–30 万 token 是压缩的甜区
    太早压:浪费了上下文里还有价值的细节,摘要丢信息。
    太晚压:上下文太长导致模型注意力分散、推理变慢、输出质量下降。
    我们测过多个阈值。20–30 万 token 是效果和成本的甜区——模型还能有效利用上下文,但离溢出还有足够余量来完成压缩本身。
    压缩后无论如何会压到 1 万 token 以内。这不是省钱,这是控制后续每一轮的 baseline 成本——history 越短,每轮 input 越少,cache miss 时的惩罚也越小。
    结论三:空闲第 3 分钟启动压缩
    这是跟 cache TTL 的博弈。大模型厂商的 prompt cache 普遍有 TTL——cache 在一段时间无请求后会过期。过期之后下一轮的 input 是全量 cold ,直接翻到 10× 成本。而且后续每轮都在叠加成本,直到 cache 重新 warm 起来。
    所以我们跑了一个空闲计时器(idle_compression_timer.rb):
  • 用户停止输入 90 秒后开始检查。
  • 如果 history 已经接近压缩阈值 → 立刻触发压缩。此时 cache 还是热的,压缩代价很低。
  • 压缩完之后,新的短 history 在 TTL 过期前就建立了新的 cache 断点。

    效果是:用户思考了几分钟回来,看到的是一个已经压缩好、cache 已经 warm的 session 。相比之下,如果不做空闲压缩,用户回来时面对的是一个 cache 过期的长 history——那一轮的 input 可能是 30 万 token 全量付费。单这一个行为,在长思考间隔的场景下就能省 10× 的钱。
    空闲计时器跑在后台线程里。记得加锁!
    百万上下文的真相
    "百万 token 上下文"听起来很性感,但做 agent 有两个现实:
    [ol]
  • 过长的上下文对模型效果并不总是正面的。 模型在超长上下文里的注意力分散问题是已知的——关键信息被淹没在大量历史里,输出质量反而下降。
  • 你真不一定用得起。 记住,模型每轮都要把上一轮所有的上下文全部带上。100 万 token 的 input ,即使全部 cache hit ( 0.1× 费率),一轮也要付 10 万 token 等价的钱。如果 cache miss 了一次,那就是 100 万 token 全价。
    [/ol]
    真实世界用户停下来思考太过于常见,Cache Missing 太容易发生,Agent 开发者必须想办法帮用户减少开销。
    所以我们的策略不是"尽量用满上下文",而是"积极压缩,保持 history 短小"。1 万 token 的压缩后 history + 95% cache hit ,比 100 万 token 的未压缩 history + 99% cache hit 便宜得多,效果也更可控。
    如何确保压缩后仍然保证足够好的效果,这是另一个话题,我们后面展开。
    决策 6:自进化的工具能力
    PDF 、Excel 、Word 、PPT 的阅读和解析是 Agent 经常遇到的需求。处理这类文件通常有两种路径:
    [ol]
  • 内置一个 tool:比如 read_pdf、read_excel。好处是开箱即用,坏处是每个格式一个工具,工具列表膨胀(违背决策 4 ),而且解析库的依赖链往往需要 C 扩展,装起来就不"零痛"了。
  • 做成 skill 让用户装:对用户来说不友好——遇到一个 PDF 还得先去装 skill ,体验断裂。而且 skill 描述怎么写、什么时候触发,AI 效果不可控。
    [/ol]
    我们选了第三种路径:首次安装时把预设的文档处理脚本 copy 到用户目录,之后允许 AI 自行更新维护这些脚本。
    具体做法:
  • 首装 OpenClacky 时,onboard skill 会把一组 Python 脚本( PDF 解析、Excel 读取、OCR 等) copy 到 ~/.clacky/scripts/。
  • 这些脚本不是 Ruby ,而是 Python 3。原因很实际:Python 的文档处理生态(pdfplumber、openpyxl、python-docx、python-pptx)是当前最成熟的,OCR 方面 pytesseract / paddleocr 也远比 Ruby 生态完善。
  • 当 agent 需要读一个 PDF 时,它不调一个专用 tool——它用 terminal 工具跑 python3 ~/.clacky/scripts/read_pdf.py 。工具列表没有增加。
  • 如果脚本跑不过去(缺依赖、格式变了),agent 可以直接 write 修改脚本、terminal 跑 pip install 装依赖。下次再遇到同类文件就不会出问题了。

    这就是"自进化"的含义:处理文档的能力不是写死在 gem 里的,它活在用户目录的脚本里,agent 自己可以维护。 第一次可能需要装个 pdfplumber,装完之后就是永久能力。
    这个设计把"文档处理"从工具层面拉到了脚本层面,避免了工具列表膨胀,也避免了硬编码 C 扩展依赖。trade-off 是用户机器上需要有 Python 3——但 macOS 和大多数 Linux 发行版默认自带,这个前提在实际用户群里几乎都满足。
    决策 7:内置浏览器工具,No Headless
    浏览器自动化是 Agent 越来越重要的能力——验证前端改动、抓取文档、自动化测试流程。
    市面上主流的做法有两种:
    [ol]
  • Headless 浏览器( Puppeteer / Playwright ):agent 启一个无头浏览器实例,完全在后台跑。
  • 外接 MCP:通过 MCP 协议连接一个外部浏览器服务,agent 发 JSON-RPC 指令。
    [/ol]
    我们两种都不用,或者说——我们自己内置了一个 MCP Client ,去接管用户已经在跑的 Chrome / Edge
    为什么不用 Headless
    Headless 浏览器的问题是"看不见"。agent 操作的页面用户看不到、不知道 agent 在干什么、出了问题也无法判断。对于 Agent 的使用场景——用户在旁边盯着 agent 干活——"看不见"是很大的信任问题。
    另外,Headless 经常遇到反爬检测:登录态拿不到、Cloudflare challenge 过不去、需要手动验证。用户自己的浏览器里已经登录好了、cookie 都在,为什么不直接用?
    我们怎么做的
    lib/clacky/tools/browser.rb( 610 行)+ lib/clacky/server/browser_manager.rb 是整套实现。架构是:
    [ol]
  • 用户的 Chrome / Edge 开启 Remote Debugging 端口(一次性配置,browser-setup skill 引导完成)。
  • OpenClacky 内置一个 MCP Client,通过 stdio JSON-RPC 2.0 连接 chrome-devtools-mcp 这个 daemon 。
  • daemon 进程首次调用时启动,后续跨多次 tool call 保持存活。
  • browser 工具对外暴露的是高层语义动作:snapshot、click、type、navigate、screenshot 等——不是底层 CDP 指令。
    [/ol]
    对模型来说,"浏览器"就是 16 个工具里的 1 个,schema 跟其他工具一样稳定,不会因为浏览器的状态变化而改 schema 。 这符合决策 4 的原则。
    为什么不把浏览器做成外部 MCP
    我们可以不内置浏览器、让用户自己配一个 Browser MCP 服务。但这样做的问题是:
  • 用户体验差:装 agent 之外还要装 MCP 服务、配端口、配认证。
  • 稳定性不可控:外部 MCP 的版本、协议兼容性、超时行为都不在我们手里。
  • 工具 schema 不可控:外部 MCP 可能暴露几十个细粒度工具(page.click、page.evaluate、page.waitForSelector……),直接打进主 agent 的 tool list 就违背了决策 4 。

    内置一层封装的代价是我们要自己维护 MCP Client 和 daemon 的生命周期管理——browser_manager.rb 里处理了 daemon 启动、心跳检测、超时、crash recovery 。但这个代价是一次性的工程投入,换来的是用户零配置(只要 Chrome 在跑)和工具列表的稳定。
    最后,选择 Ruby 的理由
    这不是一个显而易见的选择。LLM agent 生态里 Python 和 TypeScript 是主流,Ruby 几乎没有前例。但我们选 Ruby ,而且选对了。
    动态语言 + 元编程
    Ruby 的元编程能力是我们实现 Skill 自进化、动态加载、工具注册等能力的基础。method_missing、define_method、class_eval 这些能力让运行时的行为修改非常自然。Python 也有类似能力,但 Ruby 在这一层的表达力明显更高。
    对于一个"agent 自己可能改自己的辅助脚本"的系统来说,动态语言比静态语言更合适——你不需要重编译、不需要重启,改了就生效。
    极致的分发能力
    gem install openclacky 一行搞定。RubyGems 的分发链路非常成熟:版本管理、依赖解析、全局可执行文件注册(clacky 命令)都是开箱即用的。用户不需要 clone 仓库、不需要 npm install、不需要 pip 虚拟环境。
    对比 Python 的分发——pip install + 虚拟环境 + 可能的 C 扩展编译——Ruby gem 的安装体验明显更丝滑。
    零 C 库依赖
    这是我们做了大量工程投入才做到的。看 openclacky.gemspec 的依赖列表:
    faraday, thor, tty-prompt, tty-spinner, diffy, pastel,
    tty-screen, tty-markdown, base64, logger, websocket,
    webrick, artii, rubyzip, rouge, chunky_png
    全部是纯 Ruby gem ,没有一个需要编译 C 扩展
    这意味着在 macOS / Linux 上,只要有 Ruby ( 2.6+),gem install openclacky 就能装上、立刻能跑。不需要 brew install libxml2,不需要 apt-get install libffi-dev,不需要 Xcode Command Line Tools 。
    为了做到这一点,我们做了一些反常规的选择:
  • WebSocket:没有用 websocket-driver(需要 C 扩展做 UTF-8 校验),而是用了纯 Ruby 的 websocket gem 。性能差一点点,但对 agent 场景来说完全够用,换来的是安装零阻力。
  • LLM 接口调用:完全零依赖,没有用任何第三方 LLM SDK (anthropic-rb、ruby-openai 等都没用)。直接用 faraday 做 HTTP ,自己处理 streaming 、tool_use 协议、cache_control 注入。这样我们对请求格式有完全的控制权——决策 1 的双标记就是在 client.rb 里直接操作 cache_control 字段实现的。
  • TUI:没有用 curses( C 扩展),直接用 tty-screen + ANSI escape code "画"出整个终端界面。

    这一切是 AI Coding 的产物
    说实话,"从零重写 WebSocket 客户端"、"从零实现 LLM streaming 协议"、"用 ANSI escape code 手画 TUI"——这些事情如果纯手写,工程量很大,这在以往完全不现实。
    但 OpenClacky 本身就是一个 AI coding agent 。这些"为了极致安装体验而大胆从零重写依赖"的决策,是用 OpenClacky 自己来完成的。一个能写代码的 agent 让"零依赖"从不切实际变成了可执行。这是一个自举的过程——产品帮助自己变得更好。
    结语
    回头看这 7 个决策,它们背后其实只有一句话:把工程预算花在 harness 上,把智能预算留给模型。
    不做 RAG ,不做多 Agent 编排,不做工具堆叠——不是因为这些东西没用,而是因为模型在快速变好。半年前需要 4 个 agent 协作才能勉强通过的任务,今天一个 agent + 一个好的 harness 就能做得更快更便宜。
    我们选择把精力放在那些不会随模型进步而过时的事情上:cache 命中率、工具稳定性、安装体验、压缩策略。这些是 harness 层面的基础设施,不管模型换到哪一代都用得上。
    如果这篇对你有用,请帮我们点赞,欢迎 PR 。欢迎转发和分享。
    OpenClacky 完全开源,MIT 协议:github.com/clacky-ai/openclacky
    gem install openclacky 一行装完即用,不需要 Docker 、不需要 clone 仓库。如果你也在做 Agent ,欢迎试试,遇到问题直接开 issue 聊。
    4 家 Agent 横评的完整数据、产物对比、录像回放:openclacky.com/benchmark
    本文引用的核心代码:Cache 标记 · Insert-then-Compress · Session context 注入 · 空闲压缩 · 浏览器工具
  • 您需要登录后才可以回帖 登录 | 立即注册

    返回顶部