派大猩 · Agent 工程笔记

Back

给上下文定预算那篇算的是怎么让一条链路的花销算得清楚。预算定完还剩一个必须回答的问题:花到窗口边上那一刻,是把旧对话删掉,还是把它改写掉。删掉能腾出空间,改写要多花一次调用,保住的是任务能不能接着做下去。

这一篇拆一个能上生产的 Agent 运行时,看它把这件事放在哪一层、在哪一步下刀、压缩完怎么接回。它的分层、循环和工具管道在同类里都算交代得清楚的那一档,真正难抄的是几处取舍。下面按它自己的分层顺序走一遍,每层只讲它解决什么问题。

分层的红线不是隔层调用,是依赖只能朝一个方向#

这套代码的主线有四个包,支线两个,职责从下往上越来越具体。最底下那层只负责把不同模型的接口抹平,往上那层只负责把循环跑起来,再往上才管读文件、跑命令、改代码这些具体业务。第四个包专管会话与存储,历史怎么落盘、上下文缩到哪一段、分叉从哪一条出发都归它。

依赖方向是这个仓库唯一不能破的规矩,越往上越能引用底层,底层永远不回头引用上层。顶层的包同时依赖底层和中间层,看上去像是绕过了中间那道门,原因出在基础类型上:消息、模型、图片统一定义在一处,所有层都去引用那一处。

红线在反方向:底层的代码里不许出现对上层的任何引用。这个方向一旦破了,上面两层就都换不掉了,隔层调用会顺手变成可以随便走的近路。类型跟着层往上长,这也是这个仓库里最省事的一处设计。上层加字段从不改底层的定义,单独抽出一个包发布也拖不走别人。

最底层的工具只描述给模型看的部分,工具叫什么、参数长什么样。中间那层往下加一件工具该怎么执行。顶层再加这件工具怎么显示、在提示词里占哪一段。三层的同一个概念靠继承往下长,底层那份从没被上层塞过字段,所以哪个包单独拿出来发布,都不会拖着别的包一起走。

分几层取决于要处理的复杂度。只想调模型不想要循环,用最底下那一层就够,循环自己写;想要一整套跑得起来的 Agent,用中间那层,工具注册什么由你决定,一个都不注册也能纯聊天。要做成一个完整产品形态,第三层才登场,这条判断比分层本身更值钱。

循环不是模型说停才停,是输出里还有没有工具调用#

整个内核拆开其实很短:调一次模型,看返回里有没有工具调用,有就执行,把结果塞回上下文再调,直到返回里没有工具调用为止。这段逻辑本身很短,剩下的部分都是产品往外面叠的。中途插话、任务追加、每轮结束前换一次模型或换一次上下文,还有一个挂在外部、由外面说了算的停止钩子。

驱动循环往下走看的只有两件事:返回内容里有没有工具调用,这批调用是不是全部要求立刻收手。第一个条件不成立就继续转,第二个条件成立就真停。就算这一轮被长度截断,只要内容里带着工具调用,工具照样会执行;反过来,工具调用一个接一个,只要每个都在要求收手,循环也会停。

停止信号分两个来源。模型那侧给的是自然结束、被长度截断、返回了工具调用这几种;框架补的是调用出错和用户中断。后两种到了就硬停,不执行工具,不去看还能不能追加任务,直接收尾 —— 调用本身已经失败,再往下转只是在浪费预算。

内核没有写死最大轮数,这件事明确地留给外面,兜底交给外面几样东西。外部钩子会在轮数或上下文快满时喊停,模型自己的长度上限、用户随时能按下的中断同样算。少了其中任何一样,一个爱调工具的模型就能把一次任务拖成一次长跑。内核不会先于它们出手。

循环自始至终跑的是自己那一套消息类型,除了用户和模型的对话,里面还夹着压缩摘要、命令执行、分支总结这类只给内部看的消息。真正调模型之前才做一次降维,把这些挡在门外,只留下模型认得的那几种。降维压在边界上,内部才敢往里加新的消息类型,外面换一家模型接口也不用动循环。

工具出错不该抛给框架,该变成一条消息发给模型#

工具这一侧最值得抄的是它怎么对待失败。一次工具调用中间要过五道。参数先过一遍兼容处理,把某些模型常见的畸形输出修成正常形状;接着做结构校验,类型不对直接挡住。

再往后才是执行前的拦截,专门挡危险操作,中间那一步才是真正执行。收尾那一步做执行后的处理,脱敏、审计,或者干脆把这次结果改成收手。

前几道里任何一道没过,工具一次都不会执行,产出的是一条标记为失败的返回,里面写着失败原因。执行那一步自己抛出来的异常也会走到同一个出口,翻译回同一类产物。六种出错方式最后收敛成一种,这里的判断很清楚:异常的面是调用栈,谁接谁就崩;消息的面是模型,谁看谁还能自己想办法。

消息里写什么,决定了模型下一次对不对。一句「读取失败」给不出任何可行动的信息,模型只能瞎试;一句「偏移 200 超出了文件末尾,这个文件一共 100 行」一说,它下一次就知道该给哪个数。工具该做的就是把认得出来的错误重新包装一遍,附上是什么错、为什么、怎么改;认不出来的别硬编一句好看的描述,原样往外抛,让外面兜底透传。

并行这块它取的是保守路线:一批工具里只要有一个声明了必须串行,整批就都串行。理由是哪些工具会互相冲突很难提前判定,同一个文件两次编辑、两次写同一个路径都可能出问题。真正往下走还分成三段:准备按顺序做、执行才并发、结果按调用顺序回灌。模型读到的先后就是它下指令的先后,顺序一乱,它连自己刚才要求过什么都对不上。

压缩之前还隔着一层减法,工具结果先剪过再进#

轮到压缩之前,输入侧还有一层更简单的拦截。一次工具调用的产物不会整块进上下文:读文件默认留头,因为开头那段 import、类型、接口签名最密;跑命令默认留尾,因为报错和最终结果都压在末尾。两头各卡着两条线,行数与字节数同时设限,谁先到算谁。

只限行数不行,一个压扁过的产物几行就能把字节数顶爆;只限字节也不行,五十 KB 的源码可能才两百行,按字节切会切出半行。方向也不统一,读和跑命令留的是两头的不同那一头,图省事从中间截的做法两边都不采纳。切到字节那一步还要处理一个要紧的地方:按字节往下数会数进一个四字节字符的中间,做法要么整字节留下要么整字节丢掉,绝不在半中间停住。

剪掉的部分留了后路,截断因此有损但可恢复。结果里会记下总量、剪掉多少、按哪一条剪的,末尾还留一句逃生提示,全量输出落在临时文件里,要看就自己去读。这一条决定截断能一直开着当常态。

系统提示词这一侧的取法是拉。十来份技能文档全文铺进去大约五万 token 的量级,真正铺进去的只是一份只含名称和一句话说明的清单,几百 token 就够,看任务对得上哪条再自己去读全文。工具清单和工具用法照样常驻,工具本来就可调用,不需要按需加载,这两样不该混成一类。

压缩发生在两轮之间,切点只能切在人和助手上#

压缩的触发时机挪到了一轮结束之后,这是这一篇最该看的取舍。一轮跑完、收尾事件发生之后才去数 token,没超就等下一轮,超了才动手。这个位置很关键:压缩不改变正在跑的这一次,改的是下一次进来时的上下文,放在跑的过程中做等于边跑边换图纸。

判定用的是一条能自己算的算式:窗口大小减去给回复留出的余量,当前用量过了这条线就压。以 200,000 的窗口和 16,384 的余量计,阈值就是 200,000 − 16,384 = 183,616。留余量这件事本身是对的,窗口塞满之后模型连回话的地方都没有。

数 token 用的是估算,拿字符数除一个固定系数,取值方向是宁可估高不可估低。估高了最多多压一次,无害;估低了接口直接报错,有害。这里有个和中文有关的偏差:一个汉字实际占的份额远大于四分之一,按英文口径算会估小。它的应对办法是照旧保守估,同时留一条兜底路径,等接口真报溢出错误时再压一次重试。

下一刀砍在哪是这个机制里最见功力的地方。工具结果不能当切点,工具结果必须紧跟在发起它的那条模型消息后面,把它挪到另一边,模型就会看到一个调用了工具却没有结果的下场。于是合法的切点只剩下用户消息和助手消息两种。

它从最新的一条往回攒,攒到一个保留额度就停止,再往后找最近的一个合法切点,那就是刀口。压缩在这里的形态是一段分了阶段、落在存储上的活。先选切点,再让模型写摘要,中途调用出错而这份错值得重试,就记下进度等一会儿再来,真放弃也留下一条人能读的原因。

选切点还多一条规矩:不能让一次工具调用停在它自己结果的前头,落点也要避开上一个模型输出还没收尾的位置。动手之前另有一次拒绝的机会,宿主可以先自己算一遍账,觉得现在压不动就压不动。切在用户消息上最干净,这一轮连着它后面的助手和工具结果会整块留下;切在助手消息上则能精确控制留下多少,代价是把一轮切成了两半,因为那个助手的发起者落在了压缩区。

它选了切在助手消息上这条,再用一段专用摘要把切掉的那半个开头补回去。取舍很清楚:先保证压得动,再补回完整性。摘要本身不自由发挥,而是填固定几段:目标、约束与偏好、进度、关键决策、下一步、关键上下文。进度里还单独分了完成中、进行中、卡住三样,卡住那一条留给下一轮,下一轮看到就知道该先去解哪个结。

让模型自由写一段总结,它大概率会把篇幅花在某个有趣的技术细节上,然后漏掉最初要干什么。固定分段就是把漏掉这件事变成必须填,自由发挥不如让它必须交作业。压过一次之后每次都把上一次的摘要一起喂进去做增补,目标还在,进度往前推。

文件维度单独记:读过哪些、改过哪些,跨几次压缩一直累积。对写代码这件事来说,改过哪几个文件比聊过什么更硬,也更可验证。最后压出来的东西存成一个条目挂在会话上,下次进来重建上下文时,摘要顶在前面,近期的原始消息整段跟在后面。

压缩在这里顺带解决了一个错觉:磁盘上一条旧消息都没动,重建上下文那一刻只是按位置把它们跳过。退回到压缩之前,完整的历史又原样出现在眼前。压缩是视图,不是清算。

对话存成一棵只追加的树,回退只是挪一个指针#

状态存成一棵只追加的树,回退只是挪一个指针。一个会话一个文本文件,一行一条,进去就不改不删,每条只认它的父节点,父节点不记自己有几个孩子。看着别扭,但这正是只追加能成立的前提:父节点若维护子节点列表,长出新分支就得回头改旧节点,而只追加的规矩正是旧节点不许改。

要找某个节点下面长过什么,靠一张全局映射反查,回退因此便宜到只剩一次赋值,丢下的分支一个字节都不会消失。分叉的实现方式是回退之后再追加一条,这条新节点的父节点自然就落在了分岔点上,和原来的那条共享同一个父。所谓两条支线,就是这个父节点的两个孩子。用空间换重做从不丢数据,这笔买卖在这个场景里划算。

再往下看还省一层,分叉只在一个节点上接着往下写。存下来的历史永远不动,当前看到的是两个指针圈出来的一段区间。回退、压缩、切分支做起来都是挪这两下,从头到尾没有一次整段搬迁。

丢掉的那条支线不必静默消失,分叉的时候还能顺手给它留一段交代。从当前位置往回走,第一个还落在另一条路径上的节点就是分岔点。分岔点之前两条路径重合的那段可以收集起来,让模型压成一段摘要挂在新分支开头,说的是之前试过什么、结论走到哪一步。

这份摘要比压缩那套少一节,长度另有硬上限,因为它是辅助上下文,新分支自己也要留地方给主线。做不做由使用者定,旧支线一个字不提也能接着走。重建上下文的时候,从当前这条指针往父节点一路走回根,再把顺序反过来,只取这条线,别的分支不在这条线上就不进上下文。

走过的过程按类型分派:是消息的进消息数组,是状态变更的就覆盖当前值,纯元数据的直接跳过。状态做成节点而不是塞进一个全局对象,好处是回退到切换模型之前,路径上不再包含那条变更,状态自己就退回去了,不需要谁去回滚。回退动作因此和翻历史是同一件事。

压缩条目在重建时先用它的摘要生成一条摘要消息顶在最前面,再去翻它之前的那些条目,但只从它记下的保留区从哪条开始往后再收集,更早的一律跳过。真正删数据的是重建时的选择性收集,写入的时候一个字节都没动,这就是压缩可以非破坏的原因。

写入还有一层延迟:一条用户提问先不落盘,等第一次出现回答之后再整批原子地写进去,这样才能保证恢复出来的一段对话至少是一问一答的,不会开局孤零零一条提问挂在半空。这类细节不在架构图上,但决定了用户上次断在哪。

内核外面还有编排和显示,都不算内核#

编排层站在产品层之上,管的是多个实例一起跑:谁起谁停、进程之间怎么通信、编排能伸到多远。它自己一点内核逻辑都不写,循环、状态、压缩还是内核那套给。显示层更薄,只管把过程渲染给人看,Agent 换一套渲染方式,下面一层不用跟着动。

编排和显示的价值都在于可换,这跟前面那条依赖红线是同一个道理的两种说法。存储这条线也留了口子,底层给的是可换的存储抽象,眼下跑的是文件实现,换一个数据库接口照样接得上去。接口在,替换就在,至于现在有没有走过去,不影响这个判断成立。

规模这一头有硬数可查。单包过去一周的下载次数超过五百万次,这个口径是包仓库给出的七日区间。官方收录的扩展包五十多个,社区那边还有人把它接进别的编辑器、别的终端,甚至有一个非 Node 语言重写的移植版本。

对外交付的形态有四种:一个交互终端,一条直接打印成 JSON 的命令行,一套走标准输入输出上 JSON 协议的远程通道,最后一个能被嵌进别人程序的软件开发包。后两种是给要把它接进自己产品的人准备的,也是判断一个运行时够不够格当底座的地方:底座要看的是它能不能被程序驱动,而不只是能被人一条条敲命令。

扩展示例给得很足,子任务、规划模式、权限闸门、路径保护、远程执行、沙箱、模型上下文协议接入,官方都当自己造的东西列出来,没有一项算预置能力。这说明扩展的形态是拿得到工具、命令、快捷键、事件和界面的普通模块。做好的扩展、技能、提示模板还能打包,从包仓库或版本库里装,别人也能拿去用,这类分发渠道比扩展接口本身更能说明一套运行时有没有活着的社区。

真把它当底座跑起来的产物,官方点名的一个是 OpenClaw,用来说明软件开发包这一路具体怎么接。围绕它还有几个外围包:终端显示是分开的那个包,多实例编排是另一个还挂着实验标记的包,循环、状态、压缩这些内核逻辑一个都不在它们里面。这两层都只图一个可换,跟前面那条依赖红线说的是同一件事。

十章源码加七章上手那份里,写得最实的是压缩那几节。它回答了两个问题:切点为什么不选工具结果,为什么允许把一轮切成两半再用一段摘要补回来。这种细节看目录结构看不出来,也只有啃到这一层才知道它为什么这么设计。

该抄的是取舍,不是那几层目录#

能上线的 Agent 和跑得动的 demo 之间的缝,落点不在那几层目录结构上。循环靠什么停、失败怎么变成一条消息、上下文快满时切在哪、状态存成什么形状,这几件事各自都有一个能自己算的口径,也都能被单独换掉。这套分层的价值只是让这些取舍不再互相焊死,改一处不用牵动全身。预算算清楚之后,剩下的功夫都在这一侧。