在上一篇《How to Design an Elegant Agent System》里,我把它提炼成了十条原则。这一篇我想换个方向:不再往上抽象,而是往下钻,看这些原则在具体的代码里是怎么落下来的。
因为我读完两遍之后,发现它所有的取舍背后是同一个判断。我先把它写出来:
它从不幻想模型会听话,也从不假装自己能拦住一切。
前半句解释了那些"多此一举"的机制 —— 两阶段调度、read-before-edit、深度 1 的子代理树、系统消息折叠。
后半句解释了那些"不够硬"的地方 —— 沙箱只检查路径、SIGSEGV 不抢救状态、文档里专门有一节承认自己与注释不一致。
而两者之间那道缝,它用同一件东西填:fail-closed,加上一条不阻塞的降级路径。
下面逐条拆。
1. 单文件是一种纪律,不是一种炫技
cell.cpp 是单一翻译单元。整个程序按命名空间切开,依赖方向被写死:
cell::async_io 合并后台文件写入器cell::plat OS 垫片(只有它知道 OS API)cell::text 零拷贝行生成器、display_safe / console_safecell::box 沙箱 + 每个工具实现cell::net curl 传输(只有它知道 curl)cell::sys print/eprintln、结构化日志器、scoped_exit、动态线程池、信号处理器cell::config 提供方注册表、设置、旧式迁移cell::encrypt Argon2id + AES-256-GCM 保险库(只有它知道 libsodium)cell::tools Policy(Deny/Ask/Allow)、工具基类cell::llm SSE 解析器、三种 API 风格客户端cell::chat 会话与历史cell::skills 技能发现cell::stats 使用计数器cell::teamwork 子代理调度器 我看到这张表第一反应是:这是在用命名纪律换取链接期隔离。
把代码放进一个 .cpp,你就失去了"这个符号只在本翻译单元可见"这种编译器保证。换来的是什么?一个 grep 就能看到全貌、没有头文件循环、没有 ODR 陷阱、构建只要一行 g++ -std=c++26 -O2。
但代价是纪律必须靠人守。所以它在注释里明确写了那句话:"只有 cell::plat 了解 OS API,只有 cell::net 了解 curl,只有 cell::encrypt 了解 libsodium。"
这是一份契约,写在注释里,靠自觉执行。我认为这是诚实的选择 —— 它没有假装单文件是纯粹的优势。
我自己的判断:单文件适合规模有上限、边界清晰的程序。cell.cpp 是终端 Agent,它的规模天然有上限(一个人能维护的全部功能),所以单文件是对的。换成框架?我会坚决反对。
2. 循环:结束条件必须可枚举
代理循环画出来是这样:
user message ──▶ LLM (stream) ──▶ tool_calls? ──▶ pass 1: 只读工具,并发 │ pass 2: write/edit 顺序,exec 确认 │ │ │ results sanitized + wrapped ─▶ append to transcript ─▶ 下一轮 └──▶ no tool calls ──▶ final answer ──▶ 会话持久化 我关心的不是这张图,是它下面那句话:
没有固定轮次上限。 循环持续到模型在无工具调用的情况下回答、调用被阻止/拒绝、用户取消或发生错误。
四个结束条件,全都是可枚举的。
对比一下常见做法:"最多跑 10 轮"。那不是结束条件,那是一个放弃阈值。前者是状态机的终止态,后者是超时。区别在于:状态机的终止态可以被记录和解释,超时只能被报告。
这也解释了为什么它能做 /reload —— 因为"中断"是一个明确的状态(interrupt.json 里有 reason),而不是"不知道跑哪去了"。
失败路径同样可枚举:失败的 LLM 请求每轮最多重试 5 次,间隔 5 秒;耗尽后记一个中断轮次;如果失败是上下文溢出,则压缩一次并立即重试,不消耗重试次数。
我喜欢"不消耗重试次数"这个细节。它区分了两种失败:暂时性抖动(消耗重试)和状态性错误(改变状态后重试)。把两者混在一起计数,是很多系统越跑越没劲的根源。
同样的判断也贯穿到子代理:它们没有轮次上限——结束条件与主循环一致地可枚举(无工具调用地作答、被停止、失败),而不是"跑满 N 轮就交卷";上下文增长交给自我压缩接管:子代理把自己的输出摘要成一条助手消息、续回背景之后,原始记录先归档,再发一条 compact 通知给监督者。
3. 九个工具,逐个做过必要性论证
我判断一个指令集好不好的方法,是逐个问"去掉它会怎样"。cell.cpp 的九个工具能全部过关:
| 工具 | 去掉之后 |
|---|---|
ls | 模型无法发现目录结构,只能靠 find 猜 |
read | 无法获知文件内容 —— 后续一切无从谈起 |
write | 无法创建新文件 |
edit | 只能整文件重写,token 爆炸且容易覆盖掉没看到的部分 |
rg | 无法按内容定位,只能在文件海洋里 read |
exec | 无法构建、测试、跑脚本 —— 编程 Agent 直接残废 |
find | 无法按名字/时间/大小定位(已合并 glob) |
tw | 无法分解超出单上下文的任务 |
notice | 子代理无法汇报进度,监督者只能轮询 |
九个里最容易被质疑的是 tw 和 notice。但正是这两个把 cell.cpp 从"一个会改代码的聊天窗口"抬成了"一个有调度能力的系统"。
而 glob 被合并进 find 这件事,我在上一篇说过,这里再提一次 —— 因为它代表了一种持续的偏好:能合并就不新增。
同样的偏好出现在 edit 上:它没有为"替换""插入""追加""删除"各开一个工具,而是用一个 mode 参数把它们收进同一个工具,外加一个只读的 query 模式用于定位。少四个工具,少四次选错的机会。
4. ABI:返回值是给机器看的界面
这一节我想写得比上一篇更具体。
read 返回的每一行都带右对齐 6 列行号({:>6}: content)。为什么是 6 列、右对齐?因为右对齐让行号在视觉上形成一列,模型更容易把它解析为结构而非文本。这不是排版洁癖,这是对 tokenizer 的体谅。
rg 把命中分组为 === file === + line: content。这让模型能说"src/net.cpp 第 118 行",而不是复述上下文。
exec 结果永远以 exitcode=N 结尾 —— 成功与否不靠猜。
edit 遇到非唯一 search 会中止并报告每一个匹配及上下文,而不是随便改一个。相同替换是 no-op。如果 search 以文件没有的换行结尾,会不带它重试。
最后那句"不带它重试"让我笑了。写它的人一定被模型多写的一个 \n 折磨过无数次。这种代码只有在真实跑过几千轮之后才会长出来。
还有 rg 的两条自我保护:8M 行扫描预算、拒绝指数回溯形态((a+)+、(a | b){3}、a{2}{3})和超过 200 字符的模式。这不是防攻击,是防模型自己写出能卡死自己的正则。
工具的 ABI 不只是给模型的接口,也是给自己兜底的护栏。
5. "它从不幻想模型会听话"
这是我说的那句话的前半句。三处证据:
(a)两阶段调度。 同一条助手消息里,ls/read/rg/find 在共享工作池上并发跑;write/edit 被有意推迟到第二轮顺序跑。这样"读一定先于写完成"和"同一文件的两个编辑永不竞争"就成了调度器的性质,而不是对模型的请求。
(b)read-before-edit。 路径规范化(Windows 上小写化)后记录读取覆盖的行范围,edit 拒绝触碰任何未被覆盖的行。而日志在 /clear、/new、/session、/compact 时一律清除。
清除这一手特别关键。它承认:模型的"我读过这个文件"这份信念,只在它的上下文仍然成立时才成立。 上下文一换,信念作废,日志也跟着作废。这是把模型的认知状态当成一个有作用域的变量来处理 —— 非常工程化的思路。
(c)fold_system_messages。 因为严格的服务端聊天模板(llama.cpp 的 Jinja)只接受恰好一条 system 消息且位于最前,所以每当会话状态在对话中途到达(通知、已加载技能、中断说明),所有 system 消息都会按对话记录顺序合并进唯一的前导 system 消息 —— 而且每次请求前都会再跑一次,作为最后一道防线。
注意"最后一道防线"这个措辞。它显然不信自己第一次就折对了。
三处的共同点:不相信,所以不依赖。 这不是对模型的不信任,这是对"任何概率性组件"的正确态度。
6. "它从不假装自己能拦住一切"
后半句。六层安全模型:
| 层 | 机制 | 它管什么 |
|---|---|---|
| 1 | 沙箱模式 | read-only / edit-only / full-access 三档,按模式允许或拒绝工具 |
| 2 | 路径门控 check_path | .. 一律拒绝;.cell 运行时目录不可访问(除 .cell/skills/);磁盘上任何位置的知名凭据存储(~/.ssh/id_*、~/.aws/credentials、~/.git-credentials、~/.kube/config……)全部阻止 |
| 3 | 命令门控 check_exec | 只检查路径遍历和敏感路径 |
| 4 | 人工确认 | exec 是唯一的 Ask 工具;参数经 display_safe 打印,注入的 JSON 无法擦除提示或伪造批准 |
| 5 | 输出加固 | sanitize_output + wrap_tool_output + truncate_output |
| 6 | 凭据保险库 | Argon2id + AES-256-GCM(XChaCha20-Poly1305 回退),sodium_malloc 秘密缓冲区 |
第 3 层是整份设计里最诚实的地方。它在文档里白纸黑字写着:
check_exec不解析命令名、不解码编码载荷、也不按二进制名阻止网络出口 —— 它们只拒绝路径遍历和敏感路径。exec仍可运行解释器,例如python3 -c "exec(base64…)"。
它甚至专门开了一节"实现说明",写代码与自身帮助文本不一致的地方,以及行为故意比看上去更简单的地方。比如:--sandbox 行的帮助文本夸大了模式;exec 工具描述里"拒绝网络"的声明,其实只是"没有白名单"的意思。
我读到这里的时候,是有点respect 的。绝大多数项目会把这些东西藏起来。
威胁模型也写得清清楚楚:一个不受信任的 Agent 运行在受信任的主机内。 有了这句话,第 3 层的不作为就不是缺陷,而是在既定模型下的正确取舍 —— 因为 exec 由用户确认(或 autoallow),网络出口根本不在防护范围内。
同一判断的另一处:SIGSEGV / SIGFPE / SIGILL 故意不尝试序列化状态,恢复默认动作并终止。而 Ctrl+C 走 sigwait 专用线程,持久化代码永远不在异步信号处理器内运行。
能救的救,救不了的别添乱。不是所有死亡都值得抢救。
7. 缝怎么填:fail-closed + 可降级
既然既不幻想模型听话、也不假装能拦住一切,那中间那道缝怎么办?
看 Teamwork 的监督审查,我认为这是整份设计最漂亮的一段:
子代理改主代理授权列表上的文件时,每次 write/edit 都被暂缓,同时向主会话模型发一个短请求,只根据 edit_rules 判断这一次改动。审查携带路径、工具参数、文件当前内容以及它将产生的结果。
- 审查缺失或失败 → 拒绝,绝不静默允许;
- 每个子代理最多 8 次审查;
- 拒绝不是致命的 —— 子代理收到裁决和原因作为工具结果,可修改后重试,或把它的版本写进自己的新文件;
- 租约从不比一次运行活得更久;
- 审查是并发的 —— 租约锁在发出审查请求之前就已释放,没有子代理排队等另一个的审查。
最后两条我要单独点出来。
"租约不比一次运行活得更久"意味着权限是作用域内的临时状态,不会跨任务泄漏成持久的隐性授权。
"锁在发出审查请求之前就已释放"意味着它把互斥精确限定在真正需要互斥的那一段。绝大多数系统会一直持锁到审查返回,然后所有子代理排队 —— cell.cpp 没有。
而"拒绝不是致命的"这句,是让整个机制能跑起来的关键。子代理永远可以 write 创建新文件 —— 文件被创建的那一刻它就属于该子代理,之后可自由重写。
于是被拒绝的尝试自然流向"我另建一个文件",而不是"我报错退出"。
默认拒绝,但拒绝之后必须有一条通向结果的替代路径。
这句话我在上一篇也说过,因为我认为它是所有安全设计的通用解。只做 fail-closed 的系统是脆的;只做降级的系统是松的。两个一起做,才既硬又不断。
8. 状态不丢:从 token 边界到原子 rename
中断这一块我愿意称之为教科书级。
截断到 token 边界。 Esc 或最终失败时,模型已产出的内容原样保留,截断到最后一个完整到达的 token。因为 SSE 只应用完整流事件,累积文本永远停在 token 边界 —— 不补全、不填充。
不发明结果。 仍在发出的工具调用被取消 —— 参数通常是截断的 JSON,从未被批准或执行,也绝不为它发明结果。部分助手消息存储时不带其 tool_calls,保证对每种 API 风格都合法。
"不为半截的工具调用发明结果"这一条是分水岭。太多实现在这里塞一个假返回,然后整条 transcript 就脏了,后面所有轮次都在一个谎言上继续推理。
原因进对话。 追加一条系统消息说明原因(Esc,或错误及尝试次数)—— 它是对话的一部分,不只是控制台输出。所以模型能看到上一轮为何而停。
状态可持久。 interrupt.json 记在会话文件夹里,重启后仍在;由下一条用户消息(含 /ins)或达到自然结束的 /reload 清除。/reload 按现状发送对话记录,不添加用户消息,正好从停下的地方续。
落盘是原子的。 所有会话/配置/索引写入走后台合并写线程,每次都是:写 <name>.tmp → flush 到存储设备 → rename 覆盖目标。崩溃永远不会留下一个被下次启动静默读成空的截断文件。读取型命令(/save、/sessions、/compact、退出)会先 flush() 作为持久性屏障。
从头到尾一条链:不丢、不假、可解释、可续、原子。 我认为这五个词是任何长跑系统的及格线。
9. 零 token 的等待:把协调成本压到 0
这是我最喜欢的一处,因为它解决了一个别人甚至没意识到的问题。
监督者要等子代理汇报进度。naive 做法是轮询:每 N 秒问一次模型"子代理好了吗"。每问一次就烧一轮 token。
cell.cpp 的 notice 是一个零 token 的消息总线:
- 任何写入者(REPL 用户、本进程的子代理、共享运行时根目录的另一个 cell 进程)向目标会话的
notices/pending/投放一个 JSON 文件(原子写入,仅 UTF-8,剥离控制/ANSI 字节,16 KB 正文上限,500 个待处理上限); /notices wait在条件变量上阻塞,并周期性重新扫描文件夹(约 0.7 秒)—— 空闲时不发出任何 LLM 请求;- 进程内发送立即唤醒等待者;跨进程发送约 1 秒内到达;
- 待处理通知在每次 LLM 轮次前自动排空,所以监督者永远不必轮询;
- 已送达通知归档到
seen.jsonl(一条可以用/notices seen查询、清理、归档的审计轨迹),且在排空锁下删除文件,每个排空会话恰好送达一次; - 寻址是句柄式的:精确 id、唯一前缀、16 位工作区键、工作区路径、
current,或reply—— 直接回信给"最新一条发给我的通知"的发送者,连 id 都不用知道;/notices sessions列出所有并发会话(包括刚打开、尚未落盘的),每条发送都会记录发送者,收到的人用session=reply回信 —— 对话双向路由。
于是监督者可以在 /notices wait 里空转,被每次阶段/worker 转换唤醒 —— 全程零 LLM 往返。
而且进度通知是自动的:任务开始/完成/失败、阶段开始/结束、门控暂停、停止、每个 worker 开始/结束(带状态和摘要片段),全部以 from=teamwork/<job-id>[/<worker>] 发出。
我认为这一处体现了一种很稀缺的成本意识:不只是优化 token,而是先问"这一步真的需要模型参与吗"。
等待不需要。所以等待是零 token 的。
10. 自测与 transcript:让系统能自我证明
--selftest 在隔离的 .cell-selftest/ 目录上跑数百条断言,跑完删除。覆盖:
- 三种模式下的高风险和沙箱决策;
- 路径 / 遍历 / 符号链接 / 凭据阻止;
- 注入清理器(大小写、
\r、零宽、全角、西里尔/希腊、重音、跨行拆分、标点连接、释义、超大输出); wrap_tool_output防伪造;read/write/edit全部五种模式、歧义、部分读取覆盖、no-op;rg/find/ls语义和守卫;- exec 超时和退出码;
- 16 路并发只读工具调用;
- Teamwork 任务规范化、存储往返、历史和报告查询;
- 增量 SSE 解析、线程池记账、日志轮转、保险库往返、配置迁移、会话分组、中断轮次、行编辑器(按键解码、UTF-8 光标步进、历史去重与上限、移动/删除/yank/转置语义)、技能发现、使用统计。
"跨行拆分"这个测试项说明:注入清理器那套"6 行窗口拼接"不是随手写的,是有测试盯着它的。
而 /export 把 transcript 变成可以发给别人看的东西:自包含 HTML,无脚本无外部资源,明暗自适应,左侧粘性执行链与右侧消息 1:1 链接,工具调用/结果/推理渲染为折叠区;Teamwork 任务有可点击的任务-阶段-子代理列表;工作流任务生成带门控菱形节点的流程图子页面;页面还是手机友好的——视图标签变成拇指可达的底栏,侧栏折叠为点击打开的抽屉(依旧零脚本)。
这两件事合起来是一句话:一个 Agent 系统必须能证明自己做过什么。
灵魂小结
回到开头那句话:
它从不幻想模型会听话,也从不假装自己能拦住一切。
我把这一路读下来看到的东西,归纳成五条,作为我对 cell.cpp 的总结:
约束优先。 九个工具能合并就合并(glob→find,五种 edit 模式→一个 mode);子代理层级硬编码为深度 1 —— 子代理永远不能调用
tw,所以永远生不出孙代理。这不是配置,是硬编码。机制 > 提示词。 两阶段调度、read-before-edit、系统消息折叠、不存在的
provider直接报错而非静默回退。凡是能由代码保证的不变量,绝不写成对模型的请求。fail-closed + 可降级。 审查缺失或失败一律拒绝;但子代理永远能创建新文件,被拒的尝试自然流向替代路径。默认拒绝让系统硬,降级路径让系统不断。
状态不丢。 截断到 token 边界、不发明结果、原因进对话、原子 rename、SIGSEGV 不抢救。不丢、不假、可解释、可续、原子。
诚实。 专门开一节写自己与注释不一致的地方;明说沙箱只管路径;明说
exec能跑python3 -c "exec(base64…)"。先声明威胁模型,再在这个模型下把能做的做到位。
第 5 条是我最想留下的。
我看过太多项目,把安全做成一句 slogan,把能力吹成一层包装。cell.cpp 反过来 —— 它先告诉你笼子的边界在哪,再告诉你笼子里它能保证什么。
把不确定性关进确定性的笼子,然后老老实实画出那个笼子。
这就是我认为的 cell.cpp 的设计灵魂。
评论