蒋先森のBlog

一个刚起航的后端菜鸟

这是系列的最后一篇。前八章把 harness 的骨架拆了一遍,最后一块拼图是两个”对外”的边界:记忆(agent 记住的东西)和扩展(别人给 agent 加的东西)。它们共享一个危险——都在把外部信息送进 agent 的决策里,处理不好,记忆会越权成命令,扩展会变成绕过权限门的后门。

记忆:它自己必须声明”我不是真相”

grok-build 对记忆的第一条设计原则,直接写进了提示词:“记忆是历史不是真相”,每条记忆还自带日期。为什么?因为模型有个坏习惯——把”曾经记录过的事”当成”现在成立的事”。记忆系统要做的第一件事,就是让它自己声明不可信。

它还有一个工程上值得抄的纪律:两代管线物理隔离,互不读写。v2 记忆和 legacy 记忆不共享文件、不共享搜索、不共享 flush——否则合并逻辑会渗进所有代码路径。v2 的布局是三件套:topics(维护的主题笔记)、observations inbox(新观察的收件箱)、生成的只读索引。

配套的还有 Maka 的一条:目录进提示,正文按需加载。记忆条目只把索引/摘要进系统提示,全文靠工具按需读取——这和工具系统的”默认少、按需开”是同一笔账。

记忆越权的反面案例是 CodeWhale 明确防御的:它的记忆指引教模型把记忆读作偏好,不是命令。给模型任何”可自定义”的层(记忆、风格、人格)时,同时给它”我绝不覆盖什么”的合同——风格层才不会变成行为层。

扩展:进入的是既有收口,不是平行宇宙

CodeWhale 对扩展的立场最鲜明,一句话:扩展进入的是既有收口,不是平行宇宙。MCP(外部工具)、Skills(指令知识)、Plugins(能力打包)三个扩展面,接入点是同一个——同一套工具目录(eager/deferred)、同一套九层审批、同一条 KV-cache 契约。它文档里写得很直白:reviewed 的 plugin 贡献 MCP servers 时,”without creating a second transport or approval system”。

这条的迁移价值极高:扩展面再多,审批、缓存、授权都只有一份实现,审计面不随扩展数量膨胀。反过来,每给扩展开一条”捷径”(绕过审批、绕过缓存、绕过工具目录),就是多一个不受控的后门。

pi 的扩展系统是另一条路线:40+ 个类型化事件 + 一个注册面,扩展用 jiti 运行时加载 TypeScript,不需要编译步骤,内置扩展和外部扩展用同一个 API——“吃自己的狗粮”。它把策略全下放成扩展(权限门、plan mode、沙箱都是扩展),核心只留机制。

Read more »

多 Agent 是 harness 里最容易被”神话”也最容易被”搞砸”的模块。被神话,因为大家都幻想”拆一堆子代理并行干活”;被搞砸,因为真拆起来会发现两个要命的问题:子代理的 transcript 会撑爆父上下文,子代理可能拿到父没有的权限。

先把这个权衡用真实数据摆出来。Anthropic 的多 agent 研究系统比单 agent 强 90.2%,但代价是多 agent 系统消耗的 token 约为普通聊天的 15 倍——作为参照,单 agent 约为普通聊天的 4 倍。也就是说,多 Agent 是用 15 倍的算力成本,换 breadth-first(广度优先)任务上的大幅性能提升——值不值,取决于你的任务是否足够高价值、足够可并行。而多数 coding 任务的可并行度其实不高(任务间依赖多、还要共享同一份代码上下文),所以”拆一堆子代理”绝不是免费的午餐。

这一章看三家怎么拆。它们答案不同,但共享一个判断,放最后讲。

CodeWhale:高扇出不卡,靠的不是进程边界

CodeWhale 的第一条纪律是:子代理不是第二执行基底。历史上它漂移出过两套并行 worker 系统,修法是让一个 headless Runtime worker 成为唯一的 detached 执行原语——“Everything else is a way to select, launch, or observe that one Runtime”。

分工措辞很精确:fleet 回答”谁”有资格被选中,Runtime 回答”如何与何处”执行,sub-agent 只是嵌套指派的角色/UX 词汇。

它还有一个直接推翻直觉的结论,是调查 Claude Code/Codex/Kimi 后写进文档的:高扇出不卡的真相不是进程边界——那三家全都让子代理跑在进程内。真正起作用的是”隔离 + 紧凑事件流”三条:

  • 子代理的 transcript 永不回灌进父上下文——父拿到的是结果摘要 + 一小段生命周期事件流;
  • UI 渲染的是计数(2 running / 3 done),不是每个 worker 一个子会话;
  • 每个 worker 的工具面直接从角色/能力 profile 构建,而不是”全建出来再过滤”。

权限上它的核心是 ChildGrant 单对象 + 与父求交(clamp 交集)、deny 并集、恢复时再次求交,配守护测试钉住”绝不放大”。一句话:委托转移工作,不转移权力。多代理系统的权限泄漏点,几乎都在”子代理获得了父没有的能力”。

grok-build:子代理是完整子会话,共享父的执行底座

Read more »

持久化是 harness 里最”安静”也最”贵”的模块。安静,因为正常情况下它不发声;贵,因为崩溃那一刻它会问你一个几乎无法回答的问题:这个工具,刚才到底跑没跑?

Maka 把这问题展开得很清楚——模型让 agent 把 config.json 的端口从 3000 改成 4000,工具开始写文件时应用恰好崩溃。重启后,缺失的结果至少有四种解释:工具根本没启动;启动了但没写;文件已改但结果没提交;文件改了又被别人改回来。总是重试会重复副作用,总是宣布成功会给模型一段虚假历史。

这一章看六家怎么回答这个问题。

那个地基:事件日志是事实,状态是投影

六家殊途同归,全部选择事件溯源:每一步(消息、工具调用、权限决策)落成 append-only 事件,状态从日志重放而来。这个地基第 04 章已经反复出现,这里只补一句它的崩溃语义:写一半只坏最后一行。append-only 文本天然崩溃安全,这就是为什么 pi 和 grok 都用 JSONL 而不是数据库当主存储——git 能 diff、grep 能搜、用户能手改。

还要补一个相关的判断:持久化要回答的不只是”崩溃后怎么办”,还有”跨会话怎么连续”。Anthropic 在跑长任务 agent 时发现一个反直觉的结论——光有 compaction 是不够的。即使顶级模型在循环里跑,给它一句高层指令(比如”造一个 claude.ai 克隆”),它也会要么试图一次性做完(上下文耗尽,把半成品留给下个会话),要么中途看进度不错就提前宣布完工。他们的解法是 initializer agent(首轮搭环境)+ 每个会话留下 claude-progress.txt 和 git 历史,让下一个”零记忆”的会话快速重建工作状态。这把”持久化”从崩溃恢复,扩展到了”跨上下文窗口的交接班”。

这里值得再拆一层:compaction 和 context reset 是两件事,解决的是不同的问题。compaction 是原地摘要、保留连续性,同一个 agent 拿着缩短的历史继续跑;context reset 是清空窗口、另起一个干净 agent,靠结构化的交接工件把状态传下去。Anthropic 在后续实验里明确指出,某些模型(如 Sonnet 4.5)会表现出 context anxiety(上下文焦虑)——感知到接近上下文上限就急着收尾——这种情况下 compaction 只能缓解、无法根治,reset 才提供真正的”干净起点”,代价是每次 reset 都要多付一层编排复杂度和 token 开销。六家里的 CodeWhale 检查点、grok 单 leader 的会话切分,本质上都在这个问题上做了各自的取舍。

pi:会话是一棵 JSONL 树,fork 零复制

pi 的会话文件是版本化的 JSONL,条目带 { type, id, parentId, timestamp }。这个 parentId 是点睛之笔:让”分支”只是叶子指针的移动。

于是 /tree 是在同一文件里切换活动叶子(不删离开的分支),/fork 是复制历史成新文件,编辑早先的 user message 自动产生新分支。fork 零复制、回滚不用合并算法——因为整棵历史就躺在同一个文件里。切分支时还有个贴心设计:收集旧叶子到共同祖先的条目,让 LLM 生成一个 branch summary 挂到新分支尖端,切分支不失忆。

Read more »

权限是 harness 里唯一会”出人命”的模块。模型是不可信的执行者,而它手里握着删库、发网络请求、读私人文件的能力。这一章的问题只有一个:什么能自动跑,什么必须问人,以及凭什么信它不会越权。

六家里 pi 干脆不装权限(上一篇提过,全外置成扩展),剩下五家装了的,各有各的狠招。

opencode:审批是一个 Deferred,不是一张对话框

opencode 的授权规则很朴素:action × resource × effect,effect 三态 allow / ask / deny,last match wins,没匹配到默认 ask。

但真正值得抄的是它的两个防御细节,和它把审批做成的并发原语。

第一个防御:无 agent = 全 deny。Session 没指定 agent 时,权限求值套一个 action:* resource:* effect:deny 的兜底——不能让模型在权限检查静默地求值一个空的”无 agent 策略”时,暴露出 build 模型的行为。模型行为和权限策略必须来自同一个 agent 选择。

第二个是审批的实现:审批就是一个 Deferred。请求注册 pending + 发布事件,回复完成 Deferred,拒绝是 typed error,进程退出时 finalizer 批量拒绝。没有轮询、没有回调注册表。任何”执行中等人”的场景(审批、提问、确认)都可以是 Deferred——前提是宿主有结构化取消语义。

CodeWhale:九层单调管线,模型自己的调用不能当同意

CodeWhale 的授权是九层顺序评估,开篇第一句就是立场:“An approval from one layer is not a universal bypass”——一个层的放行不是通行证,后面的安全层仍然可以要求审查或直接拦住。

它的核心性质是单调性:从第 5 层(类型化规则)之后,后层只能收紧,不能把一个前面的 block 或 prompt 变成”未审查的执行”。这条把权限系统的正确形状讲透了——是单调管线,不是”任意一链通过即放行”。每层只加 hold 不减 hold,测试就每层一个契约测试,好写得很。

Read more »

工具是 harness 和真实世界的唯一接口,也是设计密度最高的一块。这一章的两个问题看似简单,答错了代价极大:默认给模型几个工具?工具执行完,给模型看什么?

六家的答案再次分道扬镳,但底下埋着两条共识,我们放到最后讲。

pi:默认只给四个,工具结果要给”下一步指引”

pi 有 8 个内置工具(read、bash、powershell、edit、write、grep、find、ls),但默认只激活 4 个(read、bash、edit、write),grep/find/ls 注册了但默认关着,bash 的自我描述里把自己定位成”ls、grep、find 的兜底”。

为什么默认这么少?因为每多一个工具,就是每轮请求多一份 schema + 一段提示词 + 一次模型选择歧义。bash 全能但烧 token,grep/find/ls 省 token 但不全能——权衡之后选”默认少、按需开”。

但 pi 真正出彩的是它对工具结果的理解,浓缩成一句话:工具结果是给模型的下一步指引,不是日志。

举几个它的实际做法:bash 输出超长被截断,附一句 Use offset=N to continue;输出超限落盘,附 Full output: <path>;参数校验失败,回显收到的参数让模型直接改对;edit 失败,逐条给出修复动作。道理很简单——模型遇死胡同会瞎猜,遇岔路口会前进。这条纪律的回报,高于任何提示词工程。

配套的还有一对严格分离:content(进模型的 token)和 details(给 UI 的结构化数据)。edit 工具算出了完整 diff,但 diff 进模型是烧 token、进 UI 才有价值,所以成功时给模型的只有一句”Successfully replaced N block(s)”,完整 diff 留在 details。设计任何工具返回时先问一句:模型真需要这个吗?多数 harness 在这里白烧 30% 以上的 token。

代价:默认 4 个工具的极简,意味着 bash 承担了太多,风险面集中在它一个工具上。pi 的应对是把权限门做成扩展(第 06 章),而不是靠工具本身设防。

codemode:脚本即工具,嵌套调用不进上下文

Read more »

上下文工程是 harness 里最拧巴的一块:模型的窗口是有限的,token 成本随历史长度上涨,可关键信息一条都不能丢。这三个约束互相打架,而打架的战场通常在一个叫 compaction(压缩)的机制上。

更麻烦的是,窗口变大不等于可以随便塞。Anthropic 给长上下文里的性能退化起了个名字叫 context rot(上下文腐烂):随着 token 数增加,模型从上下文里准确召回信息的能力会持续下降——这是 transformer 注意力的固有约束(每个 token 与其他 token 是 n² 关系),不是某个模型的 bug。所以上下文工程的核心不是”塞得越多越好”,而是在每个推理步,挑出最可能产生期望行为的那一小撮高信号 token。后面六家的所有机制,本质上都是在做这一个动作。

这一章看六家怎么处理”压缩又不丢真相”。答案分成了两派,而两派其实共享同一个地基。

那个地基:投影,不是改写

先说六家共同的地基,因为它决定了一切。发给模型的上下文是投影,磁盘上的事实日志永不动。压缩、上下文编辑,改的只是”模型可见的那份投影”,原始条目一条都不删。

这个地基带来一个漂亮的心理安全感,上一篇已经埋过:既然模型上下文只是有损投影,那压缩就可以放心大胆地做,甚至做错了也不要紧——真相永远在日志里,大不了重新投影一遍。

丢掉这个地基的反面模式是:图省事直接删消息。短期省了 token,但把一个会出错的摘要变成了不可校验的第二真相——从此再也无法回放审计,崩溃后恢复出来的也是失真的历史。Maka 把这条说得很重:checkpoint 是物化视图,不是 WAL 截断——它可以从源日志重建,但没资格宣布源日志过时。

pi:三级膨胀防线,加一个”增量 diff”的巧思

pi 把上下文膨胀的防线分三级:超长的先截断 → 超过阈值触发 compaction(老消息摘要 + 保留近期原样)→ 还不够就溢出恢复。

但真正有辨识度的是它的系统提示增量 diff。动态 harness 里系统提示会变(工具激活了、skill 列表变了、切了模型),如果每轮重发整串提示,provider 的前缀缓存立刻失效、成本翻倍;如果只改内存里一份”当前提示”,transcript 就不再自解释——回看一条旧消息,无法还原”当时模型看到的规则是什么”。

Read more »

主循环是 harness 里最”不值钱”也最”要命”的部分。不值钱,因为它看起来就一个 while;要命,因为中断、重试、插话、预算这些语义,全压在同一个 while 上。你写得再漂亮的分层,只要循环里有一个 if 没处理对,整个 agent 的行为就悄悄漂移。

这一章看六家怎么把那个 while 写对。答案比想象中更哲学:有人把它拆成两个循环,有人干脆不写循环函数而写一个队列,有人把”用户输入”和”执行”拆成两步。

pi:两个 while,因为两种打断是两种语义

pi 的核心循环是两个 while 套在一起,而不是一个。拆开的理由写得很明白:steering(用户中途插话)和 follow-up(agent 该停了才处理的排队任务)是两种语义完全不同的输入。

前者要在当前轮工具跑完后立刻注入,后者只在 agent 自然停下时才消费。拆成内外两层,把这两种时机写成代码结构本身,而不是在一个循环里堆 if 标志位。

还有一个更细的纪律:steering 只在当前轮工具批次完成后注入,从不打断执行中的工具调用。用户打字进来了,你不能让正在 rm 到一半的进程被打断——插话要排队等这批工具跑完。

代价是这套双 while 比单 while 复杂一截。pi 用它换的是”中断语义可读、可测”,每个队列的消费模式(all 还是 one-at-a-time)独立配置。

另外两个细节值得记:一是”失败兜底”——循环外炸了,pi 会合成一条 stopReason:"error" 的空 assistant 消息补齐事件尾巴,保证消费者永远看到完整合法的事件序列,不用为异常路径单独写 UI 分支。二是”截断即判死刑”——被输出 token 上限截断的消息里,所有 tool call 一律判失败,因为那些参数是 JSON 残缺补救出来的,”silently incomplete”,宁可不执行也不能执行半个参数。

ZCode:turn 不是一个函数,是一个队列

ZCode 最反直觉的地方:内核里没有 run_turn(input) 这种裸函数。公开入口 executeTurn 只做一件事——把本轮包成一条命令塞进命令队列,返回这条命令的 Promise,而不是同步跑完一轮。

Read more »

接一家模型 API 很容易,接到第十家,你才会开始疼。疼的不是请求怎么写——curl 一下的事——而是”方言”:同样一句”模型要调工具”,Anthropic 用 tool_use 块,OpenAI 用 function_call 塞进一个叫 tool_calls 的数组,Google 又是另一套叫法。你把每家的适配代码写上三遍,就得到一个没人敢动的 adapter 目录。

这一章看六家怎么处理”多 provider 收敛”这个问题的。答案比想象中分得开:有人把方言压进一张数据表,有人把问题拆成四个正交维度,有人干脆绕开方言、先解决缓存。

pi:四十二家 provider,进出只有一个协议

pi 面对的是最狠的版本:packages/ai 里塞着 42 个 provider、10 种 wire 方言,而进出只有一个协议。上一篇已经埋了伏笔——那个”永不 throw 的函数类型”。但真正难的不是签名本身,而是签名背后那些 provider 私有的编码怎么被体面地处理掉。

举个最刁钻的例子:thinking 签名。模型的推理过程往往带签名(Anthropic 的 thinkingSignature、OpenAI 的加密 reasoning、Google 的 thoughtSignature),这些签名是 provider 私有的、跨家互不相认。pi 的处理原则一句话:签名按不透明数据透传,跨层不理解内容。它不知道也不想知道签名里是什么,原样搬过去。

这条原则在”中途换模型”时变成一张降级表——长会话里切便宜模型是刚需,而各家的签名和 tool-call ID 格式互不兼容。pi 把它做成 transformMessages():同 provider 原生回放(签名保留);跨模型时 thinking 降级成纯文本、redacted thinking 直接丢弃、thoughtSignature 剥离;OpenAI 那串 450 字符的 tool-call ID 要裁成 Anthropic 的字母数字下划线规则。原则就三句:签名不透明透传、同族原生回放、异族有损降级但保持结构合法。

另一个值得记的决定:方言差异全沉在 compat 数据里,而不是子类层级里。某个 provider 的 max_tokens 字段名不同,加一条数据就行,不新建抽象类。OpenAICompletionsCompat 一张表挂 27 个字段,11 种 thinking 方言的命名差异都在里面。

代价也很实在:那张 compat 表和 3726 行的目录生成脚本(generate-models.ts)本身就是长期负债。pi 的模型目录不运行时拉取、而是离线生成,为的是字面量类型和”数据变更走 git diff 评审”——但这意味着加一个新模型要走一遍生成管线,不是改一行配置。

opencode:把问题拆成四个正交维度

opencode 的解法更”学院派”:它把”接一家 provider”拆成四元组——Protocol / Endpoint / Auth / Framing。

Read more »

如果你今天要从零写一个 coding agent,第一个要做的决定不是选哪个模型——模型换起来太容易了。真正的第一个决定是:你的 agent 内核,写多大?

Anthropic 在一篇谈 harness 设计的文章里给过定义:agent harness 是模型外围的软件脚手架——循环、工具、上下文管理和护栏,把原始智能变成能干活的 agent。他们还有一句更锋利的话:harness 里写死的每一条规则,本质上都是”模型做不到什么”的假设,而模型在持续变强,这些假设会不断过期。

我把六个主流开源 harness 的源码并排放在一起后发现,它们对这个”内核写多大”的问题给出的答案,差异大得近乎吵架。最极端的两家差了两个数量级:pi 的内核只有两千多行,CodeWhale 光 TUI 一个 crate 就上百万行。更有意思的是,吵完之后它们又在另一件事上达成了罕见的一致——这件事我们放到最后讲。

先把六位选手介绍清楚,后文不再重复背景:

  • pi:earendil-works 出品,TypeScript monorepo,切成几个可独立复用的 npm 包,主打”极简核心 + 扩展”。
  • opencode:TypeScript 写的终端 coding agent,整个运行时压在 Effect 这个函数式库上。
  • Grok Build:xAI 官方开源,Rust workspace 下 101 个 crate,TUI / 无界面 / 编辑器协议三种形态共用一个 runtime。
  • ZCode:智谱(zai-org)开源的 coding agent,一个内核喂饱终端、桌面、Web 多个前端。
  • Maka:Apache 孵化器项目,事件溯源内核,”什么算事实”被焊死在包依赖上。
  • CodeWhale:Rust 写的,六家里工程纪律最重的一家。

这一篇是整个系列的地基:先看六家的整体骨架怎么搭。后面各章再按模块逐层深入——LLM 抽象层、Agent Loop、上下文工程、工具系统、权限与沙箱、持久化与恢复、多 Agent、Memory 与扩展——每章回答同一个问题:这个模块,几家是怎么设计的,代价是什么,我该抄谁的。

下面逐个看六家的答案,以及它们为答案付出了什么代价。

pi:两千五百行,连权限系统都不肯要

先说最偏执的一家。

pi 的作者是 Mario Zechner——就是写 libGDX 的那位,他把 pi 做成了一套可以独立复用的 npm 包。代码切成三个包,依赖严格单向:pi-ai 只管把几十家模型 API 的方言统一成一种事件流;pi-agent-core 只有六个文件、约两千五百行,装着 agent 的主循环和状态,没有持久化、没有产品逻辑;最外面的 pi-coding-agent 才是产品,系统提示、工具实现、压缩调度都堆在这一层。

Read more »

本文是一篇综述与交叉验证性质的解读,视角为”一个社区实践如何长成产品能力”。素材来自四条独立线索:Geoffrey Huntley 的原始博文与哲学续篇、阿里云云原生的中文综述、Anthropic 官方工程博客、以及 Claude Code / ChatGPT 的官方文档,文末附全部出处。除特别标注「」的直引外,行文均为重新组织与个人分析。

整理日期:2026-09-25

一、一个真实的问题:LLM 什么时候算”做完了”?

所有 AI 编程工具的用户都遇到过同一类尴尬:任务做到七八成,模型输出一句”任务完成”,然后停了。具体表现有四种:

  1. 主观收工:模型在它自己觉得”差不多了”时退出,而不是在客观标准达成时
  2. 单发脆弱:稍微复杂的任务,一次提示不可能做完,中间全靠人工续命
  3. 续命昂贵:每次人工重新引导,消耗的都是开发者的注意力
  4. 断档失忆:会话一旦重启,之前做了什么、为什么这么做,全部清零

把这四种现象抽象一下,根因是同一个:LLM 的自我评估不可信。它判断”完成”的依据是自己的主观感受,而不是一个可以被外部验证的事实。

社区的回应方式粗放到近乎行为艺术——一个 bash 循环:

1
2
3
while :; do
cat PROMPT.md | claude-code --continue
done

Geoffrey Huntley 给它起了个名字(借自《辛普森一家》里屡败屡战的角色 Ralph Wiggum),并留下一句著名的定义:“Ralph is a Bash loop”。这个循环能跑通任务的前提只有一条:提示词永远不变,变的是硬盘上的东西——代码被改了、测试跑了、git 历史长了。模型每一轮看到的”世界”都不一样,它实际上是在通过文件系统读自己的工作痕迹,形成自我修正。

这个看似玩笑的方案后来被推到了相当夸张的规模:Huntley 声称用它在 YC 黑客松一夜生成 6 个仓库、以约 $297 的 API 成本交付一份报价 $50k 的合同、历时三个月写出完整的编程语言 “cursed”(数字出自其 README,听听就好,重点是方法本身)。

二、循环的三种形态:从野路子到产品能力

Read more »
0%