三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

读Crush源码-AGI时代的LLM操作系统

读Crush源码-AGI时代的LLM操作系统

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

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

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

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

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

whileTrue:resp=llm.chat(messages,tools=tools)ifnotresp.tool_calls:breakforcallinresp.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。)

← 返回列表