读 Crush 源码:一个"好用的 AI 编程助手"背后,到底套着多少层 Harness

我们这一年代写代码的人,每天都在和"AI 编程助手"打交道。但用得多了,一个疑问会自然冒出来:同样是接 GPT/Claude 的 API,为什么有的工具能把一个复杂重构任务从头跑完,有的跑两步就卡死、跑偏、或者原地转圈?模型明明是同一个模型。

带着这个疑问,我翻了 Charmbracelet 开源的 Crush(一个终端里的 AI 编程助手,类似 Claude Code 的开源对位产品)的源码。读完最大的感受是:一个"能用"的 agentic 产品,其价值有九成不在模型,而在那套套在模型外面的 harness——执行外壳。 本文聊聊我从 Crush 里读到的、关于 harness 的那些工程取舍,以及它带给我自己的一些思考。

一、先对齐一下:什么是 Harness

如果你写过一点 LLM 应用,多半见过最朴素的 agent 循环:

while True:
    resp = llm.chat(messages, tools=tools)
    if not resp.tool_calls:
        break
    for call in resp.tool_calls:
        result = run_tool(call)
        messages.append(result)

这就是一个 harness 的雏形——它决定了"模型说的话怎么变成动作、动作的结果怎么回到模型"。但这个十几行的循环离生产可用差了十万八千里。一旦模型决定连续改二十个文件、一旦某个工具卡住三十秒、一旦模型陷入"读同一个文件、报同一个错"的死循环,这个循环就会把整个会话带崩。

Crush 的 internal/agent/agent.go 里,这个核心循环(围绕 agent.Stream)连同它外围的调度、权限、hook、防循环、上下文压缩、取消机制,撑起了几千行代码。这几千行,就是 harness 的本体。下面我挑几个让我印象最深的设计讲讲。

二、Harness 的第一性原理:把"不可信的执行者"关进笼子

读完 Crush 我最大的体会是,harness 的设计哲学可以用一句话概括:永远假设模型会犯错,并保证每个错误都有兜底。

这听起来像废话,但落实在代码里是处处可见的偏执。举几个例子:

1. 权限是 Harness 的一等公民

Crush 里有一个独立的 internal/permission 包。模型每次想跑一个工具(bash、edit、write……),都要先过 permission 这道闸。它的设计很讲究:

  • 按 (session, tool, action, path) 四元组记忆授权。用户在某个会话里允许了"编辑这个文件",后续对同一文件的编辑就不再弹窗。这个粒度卡得很准——既不烦人,又不会一次授权全局放行。
  • Hook 可以预授权WithHookApproval 把一个 toolCallID 通过 context 传下去,permission 看到这个标记就直接放行。这意味着用户写的 PreToolUse hook 可以成为一道"自动审批策略"——比如"凡是只读操作一律放行"。这其实是个很优雅的扩展点:把"什么操作安全"这件事,交给用户用 shell 脚本自己定义。
  • --yolo 模式。一键跳过所有权限。Crush 在 README 里反复用 “Be very, very careful” 警告。这是一个很诚实的取舍:harness 可以默认严格,但必须给信任环境的用户一条"别烦我"的快车道。

我自己之前做 agent 时,权限是事后才加上去的,加得七零八落。看完 Crush 我意识到,权限应该是 harness 的骨架之一,从第一天就内建,而不是出事了再打补丁。

2. Hook:用户能在 Harness 里插桩的"中间件"

hooked_tool.go 是个典型的装饰器(decorator)模式实现。每个内置工具被一层 hookedTool 包起来,在真正执行前先跑用户配置的 shell 脚本(PreToolUse hook)。这个 hook 有三种决策能力:

  • deny:阻止这次调用,把错误塞回给模型,让它换个思路。
  • halt:直接终结整个 turn,不只是阻止这次工具。用于"事态严重,立刻停手"。
  • 改写输入(UpdatedInput):hook 可以修改工具的参数。比如模型想 rm -rf /tmp,hook 可以把它改成 rm -rf /tmp/specific_dir

这个设计让我眼前一亮。它本质上是把 harness 的控制权部分让渡给用户——你不需要改 Crush 的源码,写个 shell 脚本就能干预模型每一次动作。这比那种"只能在配置文件里开关几个布尔值"的产品高明得多。

一个细节:sub-agent 不会再触发 hook(isSubAgent 时直接返回原工具)。这避免了"主 agent 调用子 agent 工具时已经过了一次 hook,子 agent 内部每个工具再过一遍"的双重拦截。这种边界处理,是好 harness 和糙 harness 的分水岭。

3. 循环检测:给"原地打转的模型"踩刹车

这是我觉得整个项目最巧妙的设计之一,在 loop_detection.go 里,全部代码不到 100 行:

const (
    loopDetectionWindowSize = 10  // 看最近 10 步
    loopDetectionMaxRepeats = 5  // 同一签名出现超过 5 次
)

它的做法是:给每一个"工具调用 + 工具结果"的组合算一个 SHA256 签名(工具名、输入参数、输出结果三者拼接哈希)。在最近的 10 步窗口里,如果同一个签名出现超过 5 次,就判定为"陷入循环",强行中断。

为什么这个设计好?

  • 它不依赖模型自报家门。很多 agent 让模型自己输出"我在第几步",但模型不可靠。Crush 用的是客观的输入输出指纹,模型骗不了。
  • 签名包含输出。这点容易被忽略但很关键。如果只哈希"工具名+输入",那模型每次读同一个文件、文件内容因为模型自己改了而变化,签名就不一样,检测就失效。把输出也纳入哈希,意味着只有"完全相同的动作产生完全相同的结果"才会被算作重复——这才是真正的死循环。
  • 窗口 + 阈值的组合。10 步窗口避免了对长任务的误判,5 次阈值给了模型一定的"重试容错"。这俩参数显然是调过的。

我自己见过太多 agent 因为模型陷入"读文件→报错→换个姿势读同一个文件→又报错"的怪圈而把 context 烧光的。一个 100 行的循环检测,能省下真金白银的 token 和用户的耐心。

三、Harness 的第二性原理:让模型"持续可用地工作"

光防错还不够,harness 还得保证模型能在长任务里持续工作。这涉及几个更"基础设施"层面的问题。

1. 上下文压缩:长对话的续命术

任何 agentic 产品都绕不开 context window 的物理上限。模型改一个项目,读几十个文件、跑几十次测试,上下文很容易撑爆。Crush 在 SessionAgent 里有自动摘要机制——当 token 数逼近阈值,调用一个小模型把历史对话压缩成摘要,腾出空间继续干。

这其实是 harness 里最容易被低估的难点。压缩做得粗暴,会丢失关键的"我刚才改过哪个文件、用户说过哪个偏好";做得太细,又频繁触发、拖慢响应。这是一个没有银弹、只能靠真实任务反复调参的工程问题。

2. 会话级的串行化:并发不是越多越好

SessionAgent 里有一把 dispatchMu 锁和一个请求队列。同一个 session 内,LLM 请求和工具执行是严格串行的——如果上一个任务还在跑,新来的 prompt 会被排队,而不是并发插入。

这乍看反直觉(并发不是更快吗),但细想非常合理。Agent 的状态本质上是"对话历史 + 待执行计划",这是个强状态机。两个 prompt 并发改同一个文件、并发修改同一个会话上下文,结果几乎一定是灾难。宁可排队,也不要在 agent 状态机上玩并发——这是 harness 区别于普通 Web 服务的重要特征。Web 服务无状态可以并发,agent 有状态必须串行。

3. 取消机制:让用户能随时喊停

agent.Stream 跑起来之后,模型可能要连续执行十几个工具调用,每个都可能耗时。如果用户中途想停(“诶方向不对”),harness 必须能立刻中断,而且要中断得干净——不能留下半截写坏的文件、不能让 LLM 请求继续烧 token。

Crush 用 context.Context 贯穿整个调用链来实现取消,并且在排队逻辑里做了精细处理:一个被取消的 queued prompt 不会被执行,但它的 RunID 仍会收到一个"已取消"的 RunComplete 通知,避免调用方一直 hang 住。这种"取消也要通知到等待者"的细节,是分布式系统里才常见的讲究,放在 agent harness 里说明作者很懂。

四、一些"超出预期"的设计

读完核心模块,还有几个设计让我觉得"这帮人是真的在拿这个工具干活",而不是做 demo:

1. 工具自描述:.go.md

每个内置工具都是一对文件:bash.go(实现)+ bash.md(给 LLM 看的说明文档)。LLM 看到的工具描述不是写死在代码里的字符串,而是一份独立的 markdown。这意味着改工具的 prompt 不用碰逻辑代码,改逻辑代码也不用怕影响 prompt。这种高内聚、自包含的组织方式,对维护一个几十个工具的项目来说是刚需。

2. 配置即服务,而不是全局变量

Config is a Service——配置通过 config.Service 访问,不是 package-level 的全局变量。这使得配置可以被注入、mock、热加载。读到这里我有点汗颜,我自己写过不少"全局 config 结构体 + init 函数"的代码,Crush 给我上了一课:配置是依赖,依赖就该走 DI,而不是全局态。

3. 内建 Bash 解释器做配置

Crush 的 crushrc 配置文件不是 JSON/YAML,而是真正的 Bash 脚本,配合一组内置命令(provider addmcp addpermissions allow)。这意味着配置里可以写 if、可以 source、可以 $(op read ...) 从 1Password 取密钥。

这个取舍很大胆。好处是配置的表达力极强,跨平台一致(Windows 上也能跑,因为自带 bash 解释器);代价是配置变成了"可信代码"——README 里反复警告别 source 来历不明的文件。这是一个典型的"权力越大责任越大"的工程选择,我个人很欣赏这种不把用户当傻子的设计。

4. 双重 Fallback

配置里指定的模型如果不可用(比如 Provider 列表里没了),Crush 会自动回退到安全的默认模型(比如 Sonnet),并写回配置。这种"启动时自愈"的设计,让升级、换模型时的容错性好很多。一个 harness 不应该因为用户配错了一个字段就拒绝启动。

五、回到我自己:读完之后想改的几件事

读源码的意义,最终还是落回"我自己的东西能怎么改进"。我整理了几条对自己有触动的:

  1. 把权限当成骨架,不是补丁。 下次写 agent,第一天就把 (session, tool, action, target) 四元组的权限模型建起来,而不是等到出事。
  2. 给 agent 一个循环检测器。 这个 100 行的指纹方案可以直接抄。烧 context 的死循环是 agentic 系统最隐蔽的成本黑洞。
  3. Hook 化的中间件思维。 与其把"安全策略"写死在代码里,不如暴露成 hook 让用户自己定义。这既是扩展性,也是一种谦逊——承认 harness 作者不可能穷尽所有用户场景。
  4. 串行化会话状态。 别在 agent 状态机上图并发,那是 Web 服务的思维,套到 agent 上必崩。
  5. 工具的 prompt 和实现分离。 这是个小习惯,但对长期维护的帮助巨大。

六、结语:Harness 是 agentic 时代的"操作系统"

合上 Crush 的源码,我有一个越来越强烈的感受:未来的 agentic 产品竞争,模型层会越来越同质化(大家都能调同样的 API),真正的护城河是 harness。

模型决定"能做到什么",harness 决定"能不能稳定地做到"。前者是上限,后者是下限。一个产品如果只有好的模型接入、没有好的 harness,用户用两次就会因为"它又卡死了""它又把文件改坏了"而流失;反过来,一个 harness 扎实的产品,哪怕接的是中等水平的模型,也能给出靠谱的体验。

从这个角度看,Claude Code、Cursor、Crush 这些工具,本质上都是在做同一件事:给 LLM 写一个操作系统。 进程调度(agent 循环)、权限管理(permission)、系统调用(tools)、信号处理(cancel/halt)、文件系统(context 管理)、shell 脚本(hooks/skills)……这些操作系统的经典概念,在 agentic harness 里几乎一一对应。

Crush 的价值在于,它把这套"LLM 操作系统"开源了出来,让你能拆开看看里面每一个齿轮怎么转。如果你在做 agentic 方向的产品,不管用不用 Crush,认真读一遍它的 internal/agentinternal/permission,比看十篇 agent 综述都有用。 因为那些"模型不会告诉你、论文也不会写"的工程细节——循环检测怎么算签名、取消怎么不丢通知、权限怎么记忆——才是 agentic 产品真正难的地方。

而这,大概也是开源最大的意义:不是让你免费用别人的成果,而是让你看见别人踩过的坑、想明白的取舍。


(利益无关:本文基于 Crush 公开源码的阅读笔记,仅为技术探讨。Crush 是 Charmbracelet 出品的开源项目,MIT 风格许可证,地址 github.com/charmbracelet/crush。)

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐