北京时间 8 月 13 日晚,DeepSeek Harness 团队负责人崔添翼放出了DeepSeek Harness 的开发者预览版。
第一时间,想看看DeepSeek 的 Harness 到底有什么不一样。
It uses an architecture where everything is a plugin, and is powered by Cordis, whose design is described in A Programming Paradigm for Spatiotemporal Composability. 该系统的架构采用插件式的设计,由 Cordis 平台提供支持。关于 Cordis 的设计细节,可以在《时空可组合性的编程范式》一书中找到描述。
简短的描述里,出现了两个奇妙的关键字:everything is plugin !Cordis?
轻轻点击,就会发现在 DeepSeek Harness 亮相的同时,北京大学和 DeepSeek 放出一篇预印本《A Programming Paradigm for Spatiotemporal Composability》,第三作者正是崔添翼@tianyi,不过论文一个字没提自家产品,谈的是整个 Harness 工程里一个更底层、也更尴尬的问题。
什么问题?我们看看能不能从一个场景切入:
你让自己的 agent 加一个「监听飞书群」的能力。它写好 skill,搞定接口,拉起一条 websocket,一切顺利,群消息开始往里灌。十分钟后,你发现这个工具(假设就是 SKILL.md + 一些函数)写坏了,飞书消息丢失了时间戳,想卸掉它。
接下来你只有三条路。
第一条,杀掉整个 agent 进程再拉起来。websocket 是断了,一起断掉的还有正在跑的长任务、建好的检索缓存、另外几个还连着的 MCP。会话还在,进程里的东西全没了——你把它杀了。
第二条,只删那份 SKILL。下一轮对话里,模型看不见这个工具了,websocket 却还挂着,继续往一个已经不存在的处理函数上推消息。
第三条,在文档里写一句「请在 deactivate 里记得关闭连接」。写不写、写全没写全,全凭作者记性。VSCode 的扩展宿主在这条路上走了十年,结果是:扩展能装,很难单独卸。
三条路加在一起,就是今天的 Harness 对「动态组合」的真实回答:组合提示词,组合进程,组合不动就重启。
这不是比喻。OpenClaw 的插件文档写着:安装或移除插件代码需要重启 Gateway;开了配置重载的托管网关,会在装、更、卸之后自动重启。启用/禁用改的是配置和冷注册表,正在跑的进程里到底挂了什么,得另查运行时表面。配置层和活对象之间是断的,对齐靠重启。
更硬的是失败怎么传。MemOS Cloud 记忆插件有过一次加载失败(add failed: AbortError),Gateway 重启卡死,连内置工具都报 tool not found。出错的是一个插件,陪葬的是整张工具表。论文后文要讲的「失败停在出事那一个实例上」,缺的就是这个。
我这篇对《A Programming Paradigm for Spatiotemporal Composability》的解读不碰公式,只讲两件事:现在的 Harness 缺哪一块,缺的时候具体坏在哪。
人装插件的时代模型自改进化的时代装/卸频率一天几次一次对话中途可能好几次组件作者人,会写 deactivate(虽然常写漏)模型,天生的不可靠作者失败的收拾者人在旁边,重启一下就好没有人在旁边,系统得自己留后路出事的性质不方便系统不稳
作者注:everything is a plugin 这篇先按下。那是 DeepSeek Harness 自己的组装方式,仓库我还没吃透,等学完了另写。这篇只借它把 Cordis 和那篇论文带出来,讲现在的 Harness 缺哪一块、缺的时候具体坏在哪。
一、Harness 现在只会组合两样东西
先把 Harness 说清楚。它不是模型本身,而是把模型围起来的那一层:系统提示词、工具列表、权限、记忆、子 agent、MCP 连接、skill 目录。Claude Code、Cursor、Codex,以及各种开源 agent 运行时,做的都是这件事。
这一层里,真正组合得动的只有两样。
一样是提示词。skill 本质上是一份说明书,匹配到任务时被塞进上下文。加一个 skill,下一轮多喂一段字;删一个,下一轮少喂一段字。文本进进出出,很轻。但文本不是运行时。如果一份 skill 只是教模型怎么用 grep,删掉它没有任何副作用;可如果这份 skill 启动过浏览器、注册过 webhook、往全局工具表里挂过函数,那你删掉那份 markdown (skill.md),这些东西一个都不会自己消失。
另一样是进程。MCP 走的就是这条路:每个 server 单独起一个进程,要卸就杀进程,操作系统替你收回文件描述符、子进程和端口。这确实卸得干净,但粒度是一整个进程。Docker 和 K8s 把粒度再抬高一档,卸的是一整个服务。论文把这类方案叫粗粒度替代品——不是没用,是粒度不对。
问题在于,agent 越来越多的事情发生在两层中间:同一个进程里,多个模块共享一个浏览器实例、一条数据库连接、一块会话缓存。提示词组合够不着它们,杀进程又杀得太狠。于是大家在中间层靠手写约定硬补:activate 配一个 deactivate,工具表自己维护一份,依赖变了就轮询,实在不行就重启。
这里为了便于理解,我举几个例子。
例 1:共享浏览器实例
你的 agent 装了两个 skill:一个「网页抓取」,一个「截图存档」。两个都用 Playwright,但它们不会各开一个浏览器,而是共享同一个:
python# 进程里只有一个 browser,两个模块都握着它 browser = playwright.chromium.launch() # 冷启动 8 秒,占 400MB 内存 context = browser.new_context(storage_state="已登录的飞书/Notion session.json") 抓取模块.page = context.new_page() 截图模块.page = context.new_page()
# 进程里只有一个 browser,两个模块都握着它
browser = playwright.chromium.launch() # 冷启动 8 秒,占 400MB 内存
context = browser.new_context(storage_state="已登录的飞书/Notion session.json")
抓取模块.page = context.new_page()
截图模块.page = context.new_page()
- 为什么不能是提示词层:浏览器是个活物——登录态、cookie、开着的标签页全在内存里。删掉 skill 的 markdown,它一个字节都不会少。
- 为什么不能拆成进程:真拆成两个进程,就得开两个浏览器、登两次、吃两份内存;而且 Playwright 的 Page 对象没法过 JSON-RPC——MCP 那套进程边界传的是文本,传不了「指着同一个标签页的句柄」。所以工程师故意把它放在进程内共享,代价就是掉进了没人管的中间层。
现在想卸掉「网页抓取」skill:浏览器不能关(截图模块还在用),但抓取模块注册的路由、缓存的选择器规则、挂着的定时任务得收干净——谁收?今天没有答案。
例 2:共享数据库连接池
「记忆」skill 往 SQLite 里写情景记忆,「检索」skill 从同一个库里读,还有一个「评估日志」模块也在写:
pythonpool = create_pool("agent.db", max_conn=5) # 进程级单例 记忆模块.db = pool 检索模块.db = pool 日志模块.db = pool
pool = create_pool("agent.db", max_conn=5) # 进程级单例
记忆模块.db = pool
检索模块.db = pool
日志模块.db = pool
- 不能是提示词层:连接池是文件描述符和事务状态的集合,文本管不着。
- 不能是进程层:一个进程一个库,就意味着三份连接池、三次锁竞争,SQLite 这种单写者的东西直接互相锁死。所以必须进程内共享。
换库的场景(本地库换云端库)就发生在这层:检索模块握着池里的连接,你要换掉整个池——这正是前面模拟过的「守卫」问题,池子得等借连接的人还完才能拆。
例 3:共享会话缓存
agent 跑长任务时在内存里攒的东西:没写回磁盘的会话摘要、嵌入缓存、编译好的上下文索引。一个跑了 20 分钟的浏览任务,成果就躺在 session_cache 里;旁边的「定时提醒」skill 每 5 分钟读一次这个缓存决定要不要催你。
- 不能是提示词层:摘要还没落盘,它不是文件,是内存对象。
- 不能是进程层:缓存的价值就在于同进程零拷贝共享;拆出去走 IPC 序列化,每次读取都得拷一遍,那还缓存什么。
这个例子最能说明「杀进程杀得太狠」:你只想卸掉一个写坏的飞书监听,杀进程却把这 20 分钟的成果一起埋了。
三个例子放在一起看,共性很清楚:这层东西建起来贵、带着状态、同时被好几只手攥着,上不去也下不来。
上不去,是提示词那层管不了它们。skill 的 markdown 删了,浏览器还开着,连接池还占着,缓存还躺在内存里——文本的进出有一套现成的轻机制,可这套机制碰不到活对象。下不来,是进程那层也不该管它们。一个浏览器拆三个进程、一份缓存走 IPC 来回拷贝,性能和状态一致性都受不了——工程师是故意把它们放进同一个进程里共享的,不是没得选。
可进程之内,没有操作系统替你回收。于是这一层的生老病死,到今天为止只有一种管理办法:手写约定。activate 配一个 deactivate,全局工具表自己维护一份,依赖变了就轮询探测——条条都靠作者自觉。自觉不住的时候,还有最后一招:重启。重启是这堆补法里唯一「保证干净」的,代价就是刚才那一幕:为了卸一个监听,把 20 分钟的成果一起埋掉。所以「实在不行就重启」不是玩笑,是中间层唯一兜底的手段。
二、论文把「动态组合」拆成两件事
论文的核心观察说起来不复杂:动态组合的难点在两个互相独立的问题上。
第一个问题在时间上:卸得干净。一个组件走了,它在共享环境里做过的修改要能撤回——注册过的事件、占过的端口、在服务表里挂过的实现、在父组件名下登记过的子组件。注意,不是「尽量写个清理函数」,而是运行时从一开始就记账:哪一步做过什么、对应怎么撤,全部留底。
就是开头那条飞书 websocket。删掉 SKILL.md,模型是看不见工具了;连接、回调、事件表里的那一项,文本一层碰不到。OpenClaw 的答案是重启 Gateway,把整张活表推倒重来。论文要的是另一件事:装的时候就把撤回单一并留下,卸的时候按反序抽掉,别的组件不用陪葬。
第二个问题在空间上:依赖会跟着动。组件应该声明「我需要一个数据库」,而不是自己去全局对象上摸一个。数据库换了、暂时没了、过会儿又来了,依赖它的组件应该自己停、等、再起来,而不是各家各写一套探测逻辑。
就是前面那个共享连接池。记忆、检索、日志三只手攥着同一份 pool。你把本地库换成云端,检索模块不会自己松手——它甚至不知道自己握着的是「一个会换人的数据库」,它只知道手里有个对象。通行写法是这样:
`pool = get_global("db_pool") # 自己去全局摸 检索模块.db = pool 论文要的是这样:
向量库
provide("vector", client)
检索
inject = ["vector"] # 只声明要什么
vector 换人、消失、再出现:检索停、等、再起来`
pool = get_global("db_pool") # 自己去全局摸
检索模块.db = pool
论文要的是这样:
# 向量库
provide("vector", client)
# 检索
inject = ["vector"] # 只声明要什么
# vector 换人、消失、再出现:检索停、等、再起来
探测逻辑从每个模块里抽走,交给运行时看「这个名字现在是谁在提供」。
这两件事互相独立。卸得干净,解决的是「走的人把现场收干净」;依赖会跟着动,解决的是「留下的人怎么面对现场变了」。只做前一件,换库时检索还握着旧客户端;只做后一件,检索知道该停了,却没有账本可抽,连接还是漏的。
静态世界里,这两件事都算不上问题。C++ 的 RAII、Python 的 with,作用域一结束就清理;import 在编译期或启动期就把依赖图焊死了。动态世界难在:组件的寿命不再对应任何词法作用域,它依赖的对象会在运行中途换身份。一个插件可能在服务器已经接了三天请求之后才装上;一个工具可能是模型在一次对话进行到一半时自己写出来的。
通行的 Harness 对这两个问题的回答,分别是「杀进程」和「让编排器管服务」。进程之内、组件一级,目前基本没人管。下面三个例子,都是从这片空白里长出来的。
三、还是三个例子
- 卸一个飞书监听:账本在,deactivate 就不用手写
开头那条飞书连接,agent 实际上做了四件事:写了 skill 说明,调了开放平台,握住一条 websocket,在事件表里挂了回调。前一件是配置,后三件是运行时。只删 markdown,活着的那三件不会自己消失。
Cordis 的做法是:凡经过上下文的,都留下一张撤回单。卸的时候按反序抽。作者不用再写一份和 activate 对称的 deactivate。
pythonclass Ctx: def __init__(self): self.undo_stack = [] def do(self, action, undo): action() self.undo_stack.append(undo) # 做一步,留一张单 def unload(self): while self.undo_stack: self.undo_stack.pop()() # 后做的先撤 ctx = Ctx() ws, handler = "ws://feishu", on_message ctx.do(lambda: rt.websockets.append(ws), lambda: rt.websockets.remove(ws)) ctx.do(lambda: rt.on("feishu_msg", handler), lambda: rt.off("feishu_msg", handler)) ctx.unload() # 先摘回调,再关连接——顺序是账本给的
class Ctx:
def __init__(self):
self.undo_stack = []
def do(self, action, undo):
action()
self.undo_stack.append(undo) # 做一步,留一张单
def unload(self):
while self.undo_stack:
self.undo_stack.pop()() # 后做的先撤
ctx = Ctx()
ws, handler = "ws://feishu", on_message
ctx.do(lambda: rt.websockets.append(ws),
lambda: rt.websockets.remove(ws))
ctx.do(lambda: rt.on("feishu_msg", handler),
lambda: rt.off("feishu_msg", handler))
ctx.unload() # 先摘回调,再关连接——顺序是账本给的
还有一个时间窗口。另一个组件此刻正靠这条连接写未完成的回执。系统不会先拆线再通知它——那等于先把人家手里的笔抽走。它先对外宣布「飞书能力不再提供」,等还握着它的组件卸完,再真正关连接。论文把这叫做撤回时的守卫:停供在前,拆线在后。
卸连接池时最常见。调用方的清理往往要把借走的连接还回去;先把池子拆了,清理代码会打到一个不存在的对象上。现在通行的插件约定,几乎都不处理这个窗口。
- 换一个向量库:搜索工具不该自己当侦探
检索依赖向量库。你先用本地库,后来要换成云端。检索如果自己 new 客户端,或从全局单例里取,换库之后常见三种下场:还握着旧客户端,查询打到空库上,静默返回错结果;每个依赖方自己写健康检查,时机对不齐;重启整个 agent,所有人一起重新 new。实际项目里最多的是第三种,所以「热替换向量库」常被当成运维问题。
声明过之后,旧库卸掉的那一刻,检索的目标变了——它依赖的那个提供者不再是激活状态。检索进入卸载,用旧库把连接还回去;旧库等它还完再走;新库起来,检索对着新提供者再走一遍启动。检索模块里没有 if db is None,也没有轮询。
判断「依赖变了」,看的不是值相不相等,是提供者是不是同一个人。旧库和新库可能返回长得一模一样的 client。
class VectorClient:
def search(self, q): ...
旧库 = VectorClient()
新库 = VectorClient()
旧库.search == 新库.search # 方法名一样
type(旧库) is type(新库) # 类型也一样
要是按值判断,检索会觉得「还是那个 vector,不用动」。查询接着打到已经空掉的旧对象上,错得一声不响。
所以每个组件实例发一个用过即废的编号,目标记编号,不记值:
python`# 启动时 本地库 = Fiber(uid=3, provide="vector", value=旧库) 检索.target = { "vector": 3 } # 记住:我用的是 3 号
换库
本地库.unload() 云端库 = Fiber(uid=8, provide="vector", value=新库) 检索.target # 现在应该是 { "vector": 8 } 3 != 8 # 换人了,检索卸了重来`
# 启动时
本地库 = Fiber(uid=3, provide="vector", value=旧库)
检索.target = { "vector": 3 } # 记住:我用的是 3 号
# 换库
本地库.unload()
云端库 = Fiber(uid=8, provide="vector", value=新库)
检索.target # 现在应该是 { "vector": 8 }
3 != 8 # 换人了,检索卸了重来
门禁认工号不认脸。新来的人同名同姓、穿一样的衣服,工号不对就是不对。反过来也成立。同一个人在内部改了个字段,工号没变,依赖方不醒,这是故意的。否则一次内部赋值会把整棵依赖树打醒。
python`# 3 号还在,只是改了自己的实现 本地库.value.endpoint = "https://backup.local" 检索.target # 仍是 { "vector": 3 }
检索继续跑,不会重载
想让这次改动传出去,得先撤再挂——撤的时候 uid 作废
本地库.unload() 本地库2 = Fiber(uid=9, provide="vector", value=本地库.value) 检索.target # { "vector": 9 },这才重来`
# 3 号还在,只是改了自己的实现
本地库.value.endpoint = "https://backup.local"
检索.target # 仍是 { "vector": 3 }
# 检索继续跑,不会重载
# 想让这次改动传出去,得先撤再挂——撤的时候 uid 作废
本地库.unload()
本地库2 = Fiber(uid=9, provide="vector", value=本地库.value)
检索.target # { "vector": 9 },这才重来
- agent 改自己的权限模块:重启可能连退路一起重启掉
前面 MemOS 那次事故,已经是「一个插件加载失败,整张工具表陪葬」。把同样的事放到自演化上,更麻烦:agent 生成了一版更严的权限组件,替换正在跑的旧版本,新版本启动到一半炸了。
通行 Harness 里,「替换」通常是改文件再重启,或热加载覆盖全局表。重启已经发生,新进程带着坏掉的权限模块起来,负责「再改一版」的编排器如果排在它后面,就进不去了。热加载则是旧的没卸干净、新的只装一半,权限表里留下交错的条目。
论文把失败记在出错的那一个实例上,不向父级冒泡。已经装上的那半截先撤回,这个实例停在失败状态,不对同一个环境自动重试。它的兄弟还在跑。那个负责再生成一版权限模块的编排器,是兄弟,不是这段已经死掉的进程里的遗言。
热加载解决的是「不重启也能换代码」。自演化需要的是「换坏了,现场还能留给下一个能修的人」。后者要求撤回是结构,不是作者的记性。
四、那本账长什么样
上面三个例子背后,是同一套记账方式。可以把上下文想成一块所有组件共享的公告板,外加一本流水账。
组件要在公告板上挂一个服务,就写一条记录,同时留下「揭下来」的撤回单。监听一个事件、占一个定时器、装一个子组件,也一样各留一张。子组件的撤回单会订进父组件的账本,父的再订进更上面一层。卸父组件时,子的账一起按反序抽掉。
组件启动时可以一次做多步,每步 yield 一张撤回单——就是生成器那种写法。
def apply(ctx):
yield listen_ws(ctx) # 单 1:关连接
yield register_handler(ctx) # 单 2:摘回调
yield start_backfill(ctx) # 单 3:异步回填,await 已经发出去了
# 走到第 2 步,发现 vector 换人了
# → 第 3 步不做,只抽 1、2 中途改主意
# 第 3 步已经 await 出去了
# → 等它落地,再把 1、2、3 一起抽 惯性:迈出的步先走完
两步之间如果发现「我依赖的人已经换了」,后面的步骤就不做了,只把已经留下的单子抽回去,这是中途改主意。还有一种情况:某一步是异步的、已经发出去了,那就等它落地,再整段卸。论文把这个「一旦迈出就先走完」叫惯性。对应到实现,就是组件实例上那个正在执行的任务:目标变了不打断当前这一步,走完再看新目标,该装继续装,该卸接着卸。
依赖是另一本账。每个组件写明自己要哪些名字;公告板上某个名字出现、消失、换了提供者,运行时就去通知声明过这个名字的人。通知只有三种结果:该启动了,该停了,或者跟你无关——最后这种,刷新一次发现目标没变,什么也不做。
读服务也有规矩:不是从全局表里掏,而是你声明过、并且已经成功启动,才能从自己的上下文里读到它。没声明就读,报错;声明了但还没启动就读,也报错。卸载过程中读,读到的是你启动时认定的那个提供者,而不是公告板上此刻可能已经换掉的那份。上一节「把连接还给连接池」能成立,靠的就是这条:池子对外已经停供,对还没走完的依赖方仍然在场。
公告板还有两个旋钮。隔离让同一个名字在不同「房间」里指向不同实现:测试里的假数据库、租户 A 和租户 B 各自的配置,都不用改组件代码。拦截不换实现,只在调用时叠一层附加约束,比如「这个组件只能读某个目录」。改拦截不用重载组件,因为它改的是「怎么用」,不是「有没有」。
这些加在一起,就是论文说的上下文范式:副作用和依赖都经过同一个显式的 ctx。组件作者只做两件事——给每一步原子操作留撤回单,声明自己要什么。合成、排序、通知,全是运行时的事。
五、和那些「差不多」的东西比一比
看到这里,你可能会想到几样现成的方案。它们和 Cordis 的差别,恰好能勾出它管的是哪一段。
MCP 把工具放进独立进程,卸载的粒度是进程。主机这边「我把这份 schema 塞进了提示词、注册了轮询、缓存了上次的工具列表」,协议不管。两个工具想共享同一个浏览器实例,MCP 也帮不上——共享内存里的依赖,不是两个 server 之间的 JSON-RPC 能表达的。所以 MCP 该继续存在,它管进程外的边界;Cordis 管进程内、组件级。两者是叠着用的:一个 MCP server 本身可以是一个组件,它的启动、它在主机里留下的登记,都走撤回单。
React 的 useEffect 是现有生态里最像「留下清理函数」的东西,差在组合。useEffect 不能写在分支、循环和嵌套函数里,不能是 async,也不能一次 yield 多步;清理函数是作者手写的另一段代码,彼此拼不起来。Cordis 的 effect 是普通函数,可以异步、可以迭代,复合操作的撤回由每一步的撤回单反序拼出来。你把「监听」和「写库」装在一起,不用再写第三份清理。
OSGi、Eclipse、VSCode 的插件生命周期,在空间上已经很接近:有 provide / require,服务没了就停组件。但时间上仍靠作者手写 deactivate,而且几乎都是同步的。如果清理时还要跟正在离开的那个依赖说句话——把连接还回去、把半截请求收个尾——这些框架没有「等依赖方卸完再拆自己」的协议。
Webpack / Vite 的 HMR 要求你标出哪些模块愿意被替换。Cordis 不用标:一个模块既然是组件,它的副作用已经被实例圈住,换模块就是卸掉旧实例、用新代码再装一个。中途 import 失败,缓存回滚,已替换的条目用备份装回去,不让系统停在换了一半的状态。代价也明摆着:组件自己的内存状态默认活不过这一卸,想保住的东西得放进更长寿的依赖里,别指望热更新把闭包里的计数器迁走。这和 DSU(动态软件更新)那条「把旧状态搬到新版本」的路线正好相反。论文也承认,状态迁移那条路上 DSU 更顺,并把「在可撤回 effect 之上再叠一层前向迁移」留给了后续工作。
ZIO、Effect-TS 这类 effect 系统,是在类型里追踪「这段计算需要什么、可能怎样失败」,代价是程序必须写进那层类型里;而且服务被拿掉之后,计算已经做过的那些事还在。Cordis 是盖在普通宿主代码上的一层运行时:你还是写普通的 TypeScript,但经过 ctx 的操作会被记账,依赖会在提供者来来去去时重新绑定。
六、它故意不管的事
这类框架的介绍最容易写过火,把「可撤回」说成万能。论文自己把边界划得很清楚,有几件事它明确不管。
第一,只有系统能独占、能恢复的位置才进账本。一块只有自己写的内存、一个别人碰不到的临时文件,可以记。一块别人也在写的共享内存、一个别的程序也在读的路径,记了也恢复不了原样,那就不记。
第二,就算进了账本,也要分两截看。open 一个文件、malloc 一块内存、fork 一个子进程,是获取:留下句柄,撤回就是 close / free / kill。但往文件里 write、往网络上 send,是排放:字节已经到了别人手里。账本不承诺把发出去的数据收回来——想想微信里超过两分钟的消息。真要补救只有两条路:发出去之前先忍住,或者另外写补偿逻辑,比如删掉刚创建的远端对象、退一笔已经划走的款。补偿也能按反序拼起来,但「和没做过一样」这条保证,对补偿不成立。
第三,运行时查不了你写的撤回单对不对。框架保证的是「单子会按规矩抽」;单子本身写得对不对,是组件作者的义务。你把「关连接」写成空函数,框架不会拦你。它拦的是你根本不留单、或者绕过 ctx 直接做副作用——那种操作从一开始就没有账。
另外还有一个容易误解的设计:依赖成环时,相关组件会一直停在未激活状态。这不是死锁,是声明写出来就能看出来的静态事实。看起来像双向依赖的,多半能拆成更细的单向绑定——服务器核心、鉴权核心、把鉴权接到请求上的那一层、把改策略的接口接到服务器上的那一层。四个小组件,比两个互相卡住的大组件好组合。代价是名字变多。这是工程问题,不是理论漏洞。
七、对正在做 Harness 的人
如果你的 skill 只是提示词,这篇文章看完可以忘掉。文本的装和卸,文件系统就够了。
但一旦 skill 开始在进程里留东西——工具函数、长连接、文件监听、子 agent 的登记、共享浏览器、可以被别的组件调用的服务——你就已经站到 VSCode 扩展宿主十年前站过的位置上了。继续靠手写 deactivate 和「改完重启」,短期能跑,长期会得到一个能装不能卸、能堆不能换的插件生态。
自演化把这张时间表压缩了。以前是人来装插件,一天几次;以后是模型在对话中途给自己加工具、改权限、换检索后端。每重启一次就丢一次进程内状态,运气不好,还会把「再改回去」的那段一起丢掉。这时候缺的不再是新的 skill 格式,而是组件一级的装和卸:撤回靠账不靠记性,依赖靠声明不靠全局摸索,失败停在出事的那一个实例上,其他组件继续服务。
Cordis 是这条思路的一个实现,不是唯一实现。
提示词组合,解决「模型下一轮看见什么」。进程组合,解决「操作系统收回什么」。组件组合,解决「同一个进程里,一块能力怎么出现、怎么消失,而不把旁边的带走」。
现在通行的 Harness 工程,前两层已经很厚了。第三层,几乎还是空的。
论文与实现:
最后说说我自己。这篇里写的坑,我接下来 6 周,就要亲手踩一遍:我打算基于 pi-mono,带学员做一个桌面端 Agent(OpenWorkbuddy)。所以结尾不谈远的,只谈这件事落到地上长什么样。
Pi 自己把前两层的边界划得很清楚。Skill 是 markdown,装和卸走文件系统——前面说「如果 skill 只是提示词,这篇文章可以忘掉」,对 Pi 的 skill 完全成立。它故意不做的那几样——MCP、子 agent、权限弹窗、plan mode——官方答案也很一致:tmux 另起进程,或者自己写 extension,或者装别人的包。进程组合这一层,Pi 是诚实的:管不了的,不硬管。
问题出在它没替你做的那层。Pi 的 extension 是进程内的 TypeScript:注册工具、监听事件、挂快捷键、改 UI。核心可以继续瘦下去,可桌面端不一样:第一个共享浏览器、第一条飞书长连接、第一个能被别的 extension 调用的服务,只要进了桌面进程,你就站到了这篇文章一路写过的问题上。CLI 里重启一次 pi,成本接近零;桌面端杀进程,是把窗口、会话、打开的页面一起埋掉。/reload 和 pi config 的开关,管的是「下一轮装什么」,管不到已经握在手里的活对象——这和 OpenClaw 那句「启用改的是冷注册表」,是同一条缝。
就着这篇论文,立三个规矩吧,此刻也不要求先把 Cordis 搬进来。
第一,skill 继续当说明书。凡是在进程里留东西的,只许进 extension。不许把 websocket 写进一篇 SKILL.md,然后指望删文件就能断连。
第二,extension 每做一步,就留下这一步怎么撤。共享浏览器、长连接、事件回调,不往模块顶层的单例里丢。将来卸掉这个 extension——或者用户关掉这块能力——抽的是这叠撤回单,不是整个应用。子 agent 照旧走 tmux 或子进程,那是进程层该干的活;浏览器实例过不了进程边界,只能留在主进程里,所以更要记账。
第三,两个 extension 共用一样东西,必须写明谁提供、谁需要,不许互相去全局对象上摸。换掉提供方的时候,要能认出是换了人,而不是看值长得像不像。失败停在出事的那个 extension 上——一个加载失败,不能把整张工具表带走。OpenClaw 的 MemOS 插件出过一次这样的事故,整个网关半死;这个代价,桌面端同样付不起。
Pi 的价值,是没把工作流焊死。桌面端 Agent 的价值,取决于我往这个瘦核心上插的每一块,能不能单独拔下来。Cordis 提供了一个绝佳的实现思路。再次致敬 DeepSeek。
评论区