<站名>
<两三句:你会得到什么,以及它凭什么不一样。**这里必须和字典里 home.sub 一字不差**>
第0章 零基础起步:一个 agent 到底是什么
这一章回答一个问题:当别人说「我写了个 agent」,他到底写了什么?答案会让很多人失望——他写的那部分,基本没有 AI。
学完这一章你应该能做到
- 用自己的话解释「大语言模型没有记忆」这句话的确切含义,以及它为什么会逼出后面十几章的所有设计
- 估算一段中文文字大约值多少 token,并说清 token 上限为什么是硬约束
- 把「工具调用」这件事拆成四步,并指出这四步里哪一步是 AI 干的、哪三步不是
- 读懂
async/await/回调/事件监听这四种写法各自在解决什么,并说出每个生活类比在哪里失效 - 判断一组插件的
inject声明能不能启动起来 - 亲手写出一个二十行的最小 agent,并用一条可验证的判据确认它跑对了
0.1 先说清楚:没有 agent 的时候,人是怎么干活的
假设你想让 AI 帮你把电脑里一个叫 report.txt 的文件改一下。2023 年你会这么干:打开聊天窗口,手动把文件内容复制进去,问它该怎么改,它给你一段新文字,你再手动复制回文件里。中间每一次「复制、粘贴、切窗口」,都是你在当搬运工。
你会发现你干的活其实特别机械:把外面世界的东西喂给模型,再把模型说的话拿到外面世界去执行。既然机械,就能写成程序。这个程序就是 agent 的全部。
agent(智能体):一个能自己循环调用模型、并且能真的动手(读写文件、执行命令、访问网络)的程序。它不是一个模型,是一个围着模型转的程序。
harness(智能体运行框架):承载 agent 的那一层框架。DeepSeek Harness 的 README 第一句就是这么自我介绍的(README.zh.md):一个开源的 agent harness。harness 这个词本站不翻译——中文里没有一个不别扭的对应词,「框架」太泛,「外壳」又太轻。你可以先把它理解成:模型是马,harness 是马身上那套挽具。
为什么要专门造一层 harness
因为「围着模型转的那个程序」看起来简单,实际上要处理的事情多到离谱:对话变长了怎么办、模型说要删文件你放不放行、命令跑了一半用户按了停止怎么办、程序崩了下次怎么接着来。这些问题一个都不需要 AI,但一个都躲不掉。本站要拆的 DeepSeek Harness 为此写了 21.2 万行代码。
下面四件事,哪些是模型干的,哪些是你的程序干的?
(1) 决定下一步该读哪个文件;(2) 真的把文件内容从磁盘上读出来;(3) 把这次对话保存下来,好让下一轮还能整段发过去;(4) 判断刚读到的内容够不够回答用户的问题。
变式:再加一条——「决定这个文件到底允不允许被读」。它归谁?如果你把它交给模型判断,会出什么事?
0.2 大语言模型:一个吃文字吐文字的函数,而且它没有记忆
要理解后面所有东西,你只需要接受一个极度简化但基本正确的模型图像:
把模型当成一个函数
大语言模型(Large Language Model, LLM)对外就是一个函数:你给它一段文字,它还你一段文字。没有别的接口。它不能读文件,不能上网,不能执行命令,不能记住昨天你说过什么。它只做一件事:接一段文字,续一段文字。
「没有记忆」这四个字,是这一章、也是这个站最重要的一句话,所以要讲透。
你在聊天软件里跟 AI 连聊十轮,感觉它记得你说过什么。真相是:你的聊天软件在第十轮时,把前面九轮的全部内容重新发了一遍。模型这一侧根本不知道有「第十轮」这回事,它只是收到了一大段文字(里面碰巧包含了九轮对话的抄本),然后续写了一段。
| 符号 | 是什么 | 直觉 |
|---|---|---|
| f | 模型本身 | 那台只会「接一段、续一段」的机器。它是纯函数:同一份输入进去,不依赖任何外部状态 |
| S | 系统提示(system prompt) | 每次都要重发的开场白:你是谁、你能用哪些工具、有什么规矩 |
| mk | 第 k 轮用户说的话 | 人打的字 |
| rk | 第 k 轮模型的回复 | 上一次它吐出来的那段文字,下一次要原样带回去 |
| n | 轮数 | 聊到第几个来回 |
注意式 0-1 右边的括号里:第 n 轮的输入,包含了前面所有轮的输入和输出。这就是「没有记忆」的精确写法。所谓记忆,是括号里那一长串东西——而那一长串东西,是你的程序负责保存和重发的,不是模型。
打个比方
模型像一个每天早上都会彻底失忆的专家。你要他接着昨天的工作干,唯一的办法是每天早上把昨天的完整工作日志重新念一遍给他听。他听完就能接上,但你一旦少念了一段,他就真的不知道。
类比失效处:失忆的人至少知道「我失忆了」,模型连这个都没有——它不是忘了,是压根没有「上一次」这个概念。另外,真人听同一份日志两次会给同样的回答,模型不一定:它的输出带随机采样,同一份输入两次可以给出不同结果。所以严格说 f 是个带随机性的函数,式 0-1 是简化写法。
常见误解
很多人以为「模型能记住 128K 上下文」意思是模型自己存了 128K 的记忆。不是。那个数字是单次输入的长度上限——是每次你能往括号里塞多少东西的上限。上限之内的东西,仍然要你每次原样重发。
这一条会在后面变成两整章。第一,既然对话要整段重发,那这份「整段」必须被完整、可靠地存下来——那就是会话日志(session log)。DeepSeek Harness 把这条写成了一条运行时不变量,原话是「模型可见即已记录」(docs/architecture.zh.md):任何抵达模型请求的东西,都必须能从日志重建。第二,既然要整段重发、而单次长度有上限,那对话总有一天塞不下——那就是压缩(compaction)。
你和 AI 聊了 5 个来回(你说 5 次,它答 5 次),现在你要发第 6 句。按式 0-1,你的程序这一次要往模型发过去的内容,包含哪些东西?总共几条消息?
变式:如果第 3 轮的时候你手动删掉了一条你自己发的消息(对话框里看不见了),第 6 轮发出去的会是多少条?这个「删除」发生在哪一层?
0.3 token:为什么按字算钱,为什么装不下
模型不是按「字」处理文字的,它按 token 处理。
token(不译):模型把一段文字切碎之后的最小单位。英文里一个常见单词大致是 1 个 token,长单词会被切成两三个;中文里一个汉字大致是 0.5 到 1 个 token 之间。切法由模型自带的一张词表决定,不同厂商的切法不一样。
为什么要有 token 这个概念
不用它你会遇到两个说不清的问题:一是钱——API 按 token 计费,你不知道一次调用花多少;二是上限——每次调用能塞进去的文字量有硬上限,你不知道还剩多少额度。这两件事在写玩具时都无所谓,一旦对话长起来,它们就是让程序直接报错的那个原因。
给你几个尺度感(下面这组换算是粗略量级,不同模型差 20% 很正常):
| 东西 | 大约多少 token | 可感知的比较 |
|---|---|---|
| 一个汉字 | 0.5 ~ 1 | —— |
| 一条微信消息(30 字) | 20 ~ 30 | —— |
| 一篇公众号推文(3000 字) | 2000 ~ 3000 | —— |
| 一本 20 万字的中文书 | 12 万 ~ 20 万 | 比绝大多数模型单次能吃下的还多 |
| 一个 500 行的代码文件 | 4000 ~ 8000 | agent 读一次文件就是这个开销 |
| DeepSeek Harness 的全部中文文档 | 约 75 万 ~ 120 万 | 仓库有 105 篇中文文档、共 123 万字符 |
那么单次上限到底是多少?这个数不在仓库里。DeepSeek Harness 把它做成了模型目录里的一个可选字段 contextWindow(见 packages/llm/llm-deepseek/src/index.ts,字段注释写的是「已知的请求/响应合计上下文容量;部署元数据不可用时省略」)。也就是说,harness 自己不假定这个数,它由你接的那个模型告诉它。现实中的量级从几万到几十万 token 都有。
读的时候要小心
上面表 0-1 里那些换算是本站给的量级参考,不是仓库里的数据,也不是任何模型的官方 tokenizer 结果。真要算准,只能用对应模型的 tokenizer 跑一遍。你在网上看到的「1 个汉字 = 2 个 token」之类的说法,大多是老模型的经验值,别当常数用。
有意思的是,DeepSeek Harness 自己也不精确数 token。它在 packages/llm/token-meter/src/estimate.ts 里写死了一个常数:
/** Fixed text-density estimate used until exact tokenization is needed. */ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4
四个字符算一个 token,每个内容块再加 4 个 token 的结构开销。这是一个故意粗糙的估计:它只用在「还没有真实用量数据时」的兜底上,一旦提供方返回了真实的 usage 数字,harness 就改用真实值当锚点(docs/subsystems/token-meter.zh.md)。
顺便,这条常数对中文是系统性低估的:4 个汉字算 1 个 token,而实际大约是 1 到 2 个汉字就 1 个 token。所以用中文跑这个 harness 时,它对压力的估计会偏乐观。仓库没有讨论这一点,这是本站读源码时注意到的。
现在把 0.2 和 0.3 拼起来,会得到一个不太好的结论。假设每一轮对话新增的内容量差不多是常数 c,因为每轮都要把之前的全部重发一遍,那么聊到第 n 轮为止,你累计发送的总量是:
| 符号 | 是什么 | 直觉 |
|---|---|---|
| c | 每轮新增的 token 数 | 你打的一句话 + 模型答的一段 + 工具结果 |
| n | 轮数 | 聊了几个来回 |
| T(n) | 到第 n 轮为止累计发送的 token | 你要付钱的那个数 |
关键是 n2:轮数翻一倍,花的钱变四倍。10 轮对话的成本是 1 轮的 55 倍,而不是 10 倍。这就是为什么 agent 跑长任务时账单会突然失控,也是为什么后面会有一整章专门讲怎么把历史剪短。
读的时候要小心
式 0-2 是本站的推导,仓库里没有这个式子。它还做了一个简化假设:每轮新增量是常数。真实情况下工具结果的大小差异巨大(读一个大文件可能一次就把预算吃掉一半),所以真实曲线比 n2 更不平滑。这个式子只用来说明「为什么会平方增长」,不能拿去算你的真实账单。
用仓库里那条 CHARS_PER_TOKEN = 4 的规则,估算一段 2000 个汉字的中文文档值多少 token。再说明:这个估计相对真实值是偏高还是偏低,大约差多少倍?
变式:如果这段文字是 2000 个字符的英文(约 350 个单词),估计值和真实值的差距会变成多少?为什么中英文的表现差这么多?
0.4 工具调用:全站的地基
到这里为止,模型还是只会说话。让它能动手的那个机制叫工具调用(tool call)。这一节是整个站的地基,请慢读。
不用它会怎样
不用工具调用,你的 agent 就只能聊天。你问它「我这个目录里有几个文件」,它只能猜或者编。你说「帮我把这行删了」,它只会回你一句「好的,已删除」——而文件一个字节都没变。模型说的话和现实之间,隔着一堵墙。工具调用就是在这堵墙上开的那扇门。
很多人第一次听到「模型可以调用工具」,脑子里的图像是:模型内部长了一双手,能伸出来碰文件。这是错的,而且错得很重要。真实的机制是这样四步:
- 你在发请求的时候,除了对话内容,还附上一张工具清单:每个工具叫什么名字、干什么用、需要哪些参数。这张清单本身也是文字,也占 token。
- 模型读完,如果它觉得该动手,就吐出一段结构化的文字,大意是「我要调用
read_file,参数是{path: 'report.txt'}」。注意:它只是说了这句话,什么都没发生。 - 你的程序解析这段文字,发现是个工具调用请求,于是真的去打开那个文件、读出来。这一步是纯粹的普通编程,一点 AI 都没有。
- 你的程序把读到的内容包成一条新消息,接在对话后面,然后把整段对话再发一次给模型(回到式 0-1)。模型这才「看到」文件内容。
这不是本站的比喻,是仓库里的类型定义。packages/llm/llm/src/types.ts 里,一条消息由若干「内容块」组成,块的种类是这样一张表(docs/subsystems/llm-streaming.zh.md 摘录):
interface ContentBlockMap {
'text': TextBlock
'reasoning': ReasoningBlock
'image': ImageBlock
'tool-call': ToolCallBlock
'tool-result': ToolResultBlock
}
其中 ToolCallBlock 带三个字段:一个调用 id、一个工具名 name、一串原始 JSON 文本 arguments。ToolResultBlock 带一个 toolCallId(配对用)、嵌套的结果内容和一个 isError 标记。它们都只是消息里的文字块——模型产出前者,你的程序产出后者。
而「真的去执行」那一步,在仓库里长这样(packages/core/tools/src/index.ts,注释已简化):
interface ToolDefinition extends ToolSchema {
/** 跑一次被接受的调用,只返回它的规范 JSON 值。 */
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
}
一个普通的 execute 函数,参数是模型给的 JSON,返回值是普通的 JSON。里面写的是 fs.readFile 这种最普通不过的代码。DeepSeek Harness 一共注册了 52 个这样的工具,名字都很朴素:bash、read、write、edit、glob、grep、web_search、todo_write……
常见误解
「模型自己执行了命令」——没有。模型输出了一串字,你的程序看见那串字,自己去执行的。所以:工具能干的事,就是你的程序允许它干的事。模型说「删除整个磁盘」,跟模型说「今天天气不错」,在传输层面是完全一样的东西——一段文字。会不会真的删,取决于你的程序里有没有写那段删除代码、有没有拦。这也是为什么后面会有整整一章讲权限和审批:那一章解决的问题,从头到尾没有 AI 参与。
你的程序里只注册了 read 和 write 两个工具。模型在回复里输出了一个 tool-call 块,name 是 delete_everything。文件会被删掉吗?你的程序接下来应该做什么?
tool-call 块本质上是什么东西?delete_everything,查不到。然后问自己:这时候直接崩掉合适吗?模型还在等一个 tool-result。tool-result 块,toolCallId 填模型给的那个 id,isError 置真,内容写「没有这个工具」,然后照常把整段对话再发一次。这样模型会看到自己叫错了名字,改用别的办法。如果你选择直接抛异常退出,模型那一侧就永远收不到配对的结果——下一次请求会因为「有一个 tool-call 没有对应的 tool-result」而被提供方拒绝。变式:把条件改成——工具名是对的(write),但参数 JSON 少了必填的 path 字段。这时你应该在哪一层拦住它?拦住之后回给模型的东西,和上面那种情况有什么不同?
有人写的循环是这样的:把工具结果直接当成一条普通的用户消息追加进对话,内容是纯文本,不带任何 id。这个写法在单个工具调用时完全能跑。请构造一个它一定出错的场景,并说清出错的机制。
tool-call?ToolResultBlock 里那个 toolCallId 字段。它存在,说明有某种情况下光靠顺序是不够的。a.txt 和 b.txt),而你的程序为了快,把两个读操作并发执行、谁先返回就先追加。写出这时对话里的消息顺序。b.txt 先读完,对话里就变成「调用 a、调用 b、b 的内容、a 的内容」,而结果里没有 id,模型只能按顺序猜——它会把 b 的内容当成 a 的。于是它会拿着错误文件的内容继续往下做,而且整个过程不会报任何错,因为每一步语法都合法。这就是 toolCallId 存在的理由:它把「哪个结果对应哪个调用」这件事从「靠顺序」变成「靠标识」。顺带说明了一个更一般的道理——一旦引入并发,任何靠隐式顺序建立的对应关系都会碎掉。变式:如果你坚持不加 id,但强制所有工具串行执行、且严格按模型给出的顺序追加结果,这个写法就安全了吗?想想「某个工具超时了、你决定跳过它」这种情况。
0.5 JavaScript 最小够用集
DeepSeek Harness 是用 TypeScript 写的(TypeScript 就是加了类型标注的 JavaScript)。你不需要会写,但要看得懂后面章节里的代码片段。这一节只讲六样东西。
对象:一袋带名字的东西
const call = {
id: 'call_001',
name: 'read',
arguments: '{"path":"report.txt"}'
}
call.name // 'read'
大括号里是若干「名字: 值」。取值用点。上面这个恰好就是一个 ToolCallBlock 的样子。
数组:一排有顺序的东西
const messages = []
messages.push({ role: 'user', text: '你好' })
messages.length // 1
整段对话在程序里就是这么一个数组。式 0-1 括号里那一长串,落到代码里就是 messages。
函数:一段能被叫起来的代码
function add(a, b) { return a + b }
const add2 = (a, b) => a + b // 同一件事的简写,叫箭头函数
async / await:为什么要等
有些事情是要花时间的:向模型发一次请求要几秒,读一个大文件要几十毫秒。JavaScript 遇到这类事情不会傻等——它会先记下「这件事在办」,然后继续跑后面的代码。
不用 await 会怎样
你会拿到一个空壳。写 const reply = callModel(messages)(没有 await),reply 里不是模型的回复,而是一张「回头给你」的取货凭条(叫 Promise)。你拿这张凭条去读 reply.text,得到的是 undefined。await 的意思就是:拿着凭条在这儿等,等到真东西再往下走。
async function step(messages) { // 函数里要用 await,函数自己得标 async
const reply = await callModel(messages) // 在这儿停住,等模型答完
return reply // 拿到真东西才往下走
}
打个比方
去餐厅点餐,服务员给你一个取餐号(Promise)。你可以拿着号先去洗手(继续跑别的代码),也可以站在窗口等叫号(await)。
类比失效处:餐厅的号一定会叫到,而 Promise 可能失败(网络断了、API 报错),这时 await 那一行会直接抛异常。真实的 agent 循环必须处理这个失败——DeepSeek Harness 专门为它留了一个扩展点叫 agent/request-error,监听器可以决定重试还是让它失败(docs/subsystems/core.zh.md)。另外餐厅的号只能等一次,Promise 可以被多个人同时等。
回调:把一段代码交给别人,让别人替你在合适的时候跑
function onEachTool(calls, handle) { // handle 是别人传进来的一段代码
for (const c of calls) handle(c) // 我不知道它要干什么,我只负责在对的时候叫它
}
onEachTool(reply.toolCalls, (c) => console.log('模型想调用', c.name))
这个想法后面会被放大成整个架构:DeepSeek Harness 里所有的扩展,本质上都是「把一段代码交给框架,让框架在某个时刻替你跑」。
事件:on 和 emit
ctx.on('tool/call', (call) => { // 订阅:我关心这件事
console.log('有人要调用', call.name)
})
ctx.emit('tool/call', someCall) // 广播:这件事发生了
打个比方
广播站和收音机。emit 是电台播一条消息,on 是有人调到这个频道。播的人不知道谁在听,听的人不用认识播的人——这就是「解耦」。
类比失效处:三处。第一,真实的 emit 是同步的,广播完所有监听器都跑完了才继续,不像电波发出去就不管了。第二,监听器是有顺序的,按注册先后依次跑。第三,也是最关键的——某些事件的监听器能改内容甚至能拦下来,收音机可不能改电台在播什么。DeepSeek Harness 里有一种叫 waterfall(瀑布式事件)的分发模式,监听器一层套一层,谁都可以在中间截胡;docs/cordis-primer.zh.md 把它叫「环绕中间件」。这个机制后面有整整一章。
仓库里 56 个事件,全都是这套写法。举一个真实的例子(packages/todo/tool-todo/src/index.ts,删去了类型标注和长文本):
export const name = 'tool-todo'
export const inject = ['tools']
export function apply(ctx, config) {
ctx.tools.register(defineTool({
name: 'todo_write',
description: '记录并更新当前工作的结构化任务列表……',
parameters: { todos: { type: 'array', required: true, /* … */ } },
async execute(args, exec) { /* 真正干活的地方 */ },
}))
}
整个插件就是两样东西:一行 inject 声明「我需要什么」,一个 apply 函数说明「给我上下文,我就去注册点东西」。这就是所谓「一切皆插件」在代码上的样子——第 1 章会展开讲它的代价。
下面这段代码打印出来的会是什么?为什么?const reply = callModel(messages); console.log(reply.text)(注意:callModel 是个 async 函数,这里没写 await)
async 函数不加 await 调用时,立刻返回的是什么类型的东西?reply 的实际类型(Promise),再问这个类型上有没有 text 这个字段。undefined。reply 是一个 Promise 对象,它上面没有 text 字段,读一个不存在的字段在 JavaScript 里不报错、直接给 undefined。这正是最难查的一类 bug:程序不崩,只是安静地拿到空值,然后你会去怀疑模型、怀疑网络、怀疑 API key,就是不会怀疑这一行。变式:如果改成 console.log(reply)(不取字段),打印出来的是什么?这个输出对排查问题有帮助吗?
0.6 依赖:为什么「谁先启动」会变成一个问题
「依赖」这个词的意思很朴素:A 要用 B 提供的东西,那就说 A 依赖 B。
问题出在启动顺序上。如果 A 在启动的时候就要用 B,那 B 必须先准备好。两个模块时你手写顺序就行;三个五个还行;219 个的时候,手写启动顺序是不可能完成的任务——你改一个模块的依赖,可能要重排整张表,而且漏了不会立刻报错,只会在某个罕见路径上炸。
不解决它会怎样
典型症状是这样的:程序在你的机器上跑得好好的,换一台机器就报「ctx.tools 是 undefined」。原因是某个模块比工具注册表先启动了半毫秒。这种 bug 极难复现,因为它取决于磁盘速度、网络快慢这类完全不受控的东西。
DeepSeek Harness 底下那个框架 Cordis 的做法是:不排顺序,只声明依赖。docs/cordis-primer.zh.md 的原话是「插件声明所需的服务后,会等待这些服务就绪才启动;加载顺序通过服务依赖表达,而非手动编排启动序列」。
落到代码上就是那一行 export const inject = [...]。看几个真实的声明:
| 包 | 声明它需要的服务 | 它自己提供的服务 |
|---|---|---|
core/agent-loop | agents、invariants、llm、sessions、systemPrompt、tools | agentLoop |
core/tools | invariants、sessions、systemPrompt | tools |
core/session | invariants、sessions | sessions |
todo/tool-todo | invariants、sessions、tools | (不提供服务,只注册一个工具) |
shell/bash-local | invariants、subprocess | (不提供服务,只挂一个执行后端) |
看第一行:那个「agent 循环」——也就是你马上要写的 20 行的真身——需要六项服务才能启动。框架会自己算出:先起 session,再起 tools(因为 tools 需要 sessions),再起 agent-loop。你不用管。
打个比方
像做菜写食谱。你不用规定「先开火还是先切菜」,你只要写清「炒这一步需要:切好的菜、烧热的锅」,剩下的顺序自己就定了。
类比失效处:食谱里不会出现「A 需要 B 做好的,B 也需要 A 做好的」这种事,但程序里会——这叫循环依赖,结果是两个谁都起不来,而且不报错,只是永远停在等待状态。这类问题比崩溃更难查,因为屏幕上什么都没有。
有四个插件,声明如下:
P1 需要 [sessions];P2 需要 [tools];P3 需要 [sessions, tools],提供 agentLoop;P4 什么都不需要,提供 sessions。
(tools 由 P2 之外的某个包提供,暂时没装)
问:启动之后,这四个里哪几个会真的跑起来?如果此时有人问「为什么我的 agent 循环不工作」,你怎么用一句话回答?
tools 这个服务没有任何人提供。凡是需要它的,都会停在等待状态——注意是等待,不是报错。sessions;于是 P1 起(只要 sessions)。P2 和 P3 都需要 tools,没人提供,两个都永远停在等待。回答那句话:「agentLoop 这个服务根本没被注册,因为提供它的 P3 在等一个不存在的 tools 服务。」要点在于:这不是崩溃,是静默地什么都没发生——所以这类框架必须提供一个「把当前配置树打印出来」的命令,DeepSeek Harness 的那个命令是 dsh --profile web --dump-config(docs/architecture.zh.md)。变式:把 P4 改成「需要 [agentLoop],提供 sessions」。现在还有几个能起来?这种情况有没有办法在启动前就检查出来?
0.7 状态机:几个格子,加上格子之间的箭头
最后一个前置概念。状态机(state machine)的定义简单到有点可疑:一样东西在任何时刻处于有限个状态中的某一个,并且只能沿着规定好的箭头从一个状态转到另一个。
不用它会怎样
你会写出一堆布尔标记:isLoading、isDone、hasError、isCancelled。四个标记有 16 种组合,其中大概只有 5 种是合法的,剩下 11 种是「不该出现但代码里没拦」的状态。所有那种「怎么会同时又在加载又已经出错」的诡异 bug,都出在这 11 种里。状态机的价值就是:把 16 种压到 5 种,剩下的在类型层面根本表达不出来。
不用编例子,仓库里就有一个。Cordis 里管理一个插件实例的东西叫 fiber(不译;它是「一个已加载插件实例的运行时句柄」)。它的状态在 vendor/cordis/src/fiber.ts 里明明白白写着六个:
export const enum FiberState {
PENDING, // 在等依赖,还没开始
LOADING, // 正在启动
ACTIVE, // 跑着呢
FAILED, // 起崩了
DISPOSED, // 被拿掉了,回不来了
UNLOADING, // 正在拆
}
对照 0.6 节那个「P2 永远停在等待」的例子——它就是卡在 PENDING。PENDING 和 FAILED 是两件完全不同的事:前者是「条件还没满足,条件满足了我就起」,后者是「我试过了,我起不来」。如果只用一个 isBroken 布尔值,这两件事就混成一件,你永远搞不清该去装个依赖还是该去修个 bug。
打个比方
红绿灯:红、黄、绿三个状态,箭头是红→绿→黄→红,不能红直接跳黄。
类比失效处:红绿灯的转移是时间驱动的,到点就变。程序里的状态转移是事件驱动的——依赖就绪了才从 PENDING 走到 LOADING,可能是一秒后,也可能永远不会。另外红绿灯只有一条环路,fiber 的箭头是有分叉的(LOADING 之后可能去 ACTIVE,也可能去 FAILED)。
读的时候要小心
上面那段代码里的中文注释是本站加的,源码里只有英文的枚举名。六个状态之间到底允许哪些转移、哪些不允许,源码里没有画成一张表——那是分散在几百行逻辑里的。本站后面会画一张图,但那张图是本站从代码里读出来的,不是仓库明文给的。
你马上要写的那个 20 行的 agent,从头到尾只有一个 while 循环和一个 messages 数组,它没有任何状态的概念。现在给它加一个需求:用户可以在任何时刻按一下停止键。请说明:(a) 为什么这个需求逼你引入状态;(b) 至少需要哪几个状态;(c) 哪一个转移是最容易写错的。
while 循环不记录「我现在在哪一步」,所以它无法回答这个问题。(b) 最少需要三个:idle(空着)、running(在跑)、stopping(已经喊停但还没停干净)。第三个是关键——没有它,你就只能在「还在跑」和「停了」之间二选一,而现实中存在一段两者都不是的时间。(c) 最容易写错的是 stopping → idle:很多人在喊停的那一瞬间就直接置成 idle,结果那个还没死透的工具在几百毫秒后返回了结果,被追加进了一个「已经结束」的对话里,下一轮就是一段来历不明的内容。DeepSeek Harness 对这件事的处理留在 Agent 句柄的取消约定里(docs/subsystems/core.zh.md),后面有专门一章。变式:把需求换成「用户在 agent 干活的中途又发了一句新指令」。这需要新的状态吗,还是需要别的东西?(提示:DeepSeek Harness 用的是一个叫 inbox 的队列,而不是新状态。)
一个 agent 用了一个 read 工具,实现是「把整个文件内容原样返回」。它在一个代码仓库里干活,每次读的文件大约 8000 token。假设模型单次上限是 6.4 万 token,系统提示加工具清单占了 4000。请估算它大约在第几次读文件时会撞上限,并给出两种不改模型的缓解办法,说明各自的代价。
read 加上行号范围参数,或者在返回前截断到前 N 行并附一句「文件还有 M 行未显示」。代价是模型可能因为看不全而做错判断,而且它可能反复调用来补齐,反而更贵。其二,事后清理——在快撞上限时把老的工具结果替换成一句摘要,或者干脆遮蔽掉。代价是信息不可逆地丢失了,模型可能会重新去读同一个文件(于是你付两次钱),而且摘要本身要调一次模型,也要花钱。DeepSeek Harness 两条路都走了:前者体现在工具定义自己的输出约定上,后者是 packages/compaction 那一组包(包括一个专门修剪工具结果的 compaction-tool-result-pruner)。注意这里没有免费的办法——上下文预算是零和的,任何策略都是在拿准确性换空间。变式:如果这个 agent 改成每次只读文件的 50 行(约 800 token),但因此需要读 10 次才能看完一个文件,总开销是变大了还是变小了?在什么条件下会反过来?
自己推一遍:从「模型只会说话」到「一个能干活的循环」
模型只能吃文字吐文字。你希望它能读文件。在不改模型的前提下,你唯一能动的是「吃进去的文字」和「吐出来的文字怎么解释」。你会先动哪一头?
想好了再看
只能先动吐出来那一头的解释方式。因为你没法预先知道它要读哪个文件——那正是要它决定的事。所以必须先让它说出想读什么,你才知道该往吃进去那一头塞什么。这个先后关系决定了整个 agent 是个循环而不是一条直线:先说、再做、再说、再做。
好,让它说。但它说话是自然语言,「帮我看看 report.txt 吧」这种句子你的程序解析不了。怎么让它说得让程序能读懂?
想好了再看
约定一种结构化的说法,并且提前告诉它这套约定长什么样。早期做法是让模型输出一段 JSON 文本,程序去解析;现在的模型 API 把这件事做进了协议里,直接返回一个独立的
tool-call块,不用你从自然语言里抠。DeepSeek Harness 里对应的类型就是ToolCallBlock(id、name、arguments)。这一步的本质是:把「模型的意图」从散文变成数据。程序读懂了、执行了、拿到了文件内容。接下来怎么让模型知道结果?
想好了再看
没有别的路——只能把结果变成文字,接在对话后面,整段重发。因为模型只有一个输入口(式 0-1)。这就是为什么
ToolResultBlock是一种消息内容块,而不是什么特殊通道。想通这一点,你就想通了「agent 循环」为什么必然是循环:每一次工具执行,都必须以一次完整的重新提问来收尾。现在你已经有一个能转的循环了。它什么时候该停?
想好了再看
当模型这一次的回复里没有任何 tool-call 块时——它不再要你做事,说明它认为活干完了。这条判据非常朴素,但它有个隐含前提:模型自己知道什么时候该收手。现实中它经常不知道(会无限重复调用同一个工具),所以真实系统还要加外部的终止策略。DeepSeek Harness 里
guard那一组包干的就是这个(比如repeat-tool-reminder)。你现在写的版本可以先不管。
写出那个二十行的最小 agent
现在把这一章所有东西拼起来。你要写的东西只有一个循环:把整段对话发给模型;如果模型要调工具就执行;把结果塞回对话;再发一次。停止条件是模型不再要工具。
难点在哪里?不在代码,在想通它为什么必须长这样。三个地方值得停一下:
第一,为什么 ① 要重发整段而不是只发新增的?因为模型无状态(0.2 节)。第一次写 agent 的人几乎都会试图只发增量,然后发现模型开始胡言乱语——它根本不知道前面发生过什么。第二,为什么 ④ 要带 call.id?因为一次回复可能有多个工具调用,而你迟早会想并发执行它们(q0-5)。第三,为什么 ③ 要 try/catch?因为工具是会失败的,而失败也必须作为一条结果回给模型——不能让它悬在那儿等一个永远不来的答复。
这三条,就是 20 万行里最开头的那一小撮。当年写第一个 agent 的人也是这么一步步撞出来的。
做对了的样子:把 callModel 换成一个假模型——第一次固定返回「一个 tool-call:read,参数 {path:'report.txt'}」,第二次固定返回一段没有 tool-call 的纯文字。跑一遍,结束时 messages.length 应该正好是 4(user / assistant 带调用 / tool 结果 / assistant 纯文字),循环体正好转 2 圈。如果长度是 2,说明你没把工具结果塞回去;如果循环停不下来,说明 ② 的判断写反了或者假模型第二次还在要工具;如果长度是 3,多半是你忘了把模型的回复本身 push 进去——那样模型下一轮会看不见自己说过要调工具,行为会变得很怪。
答辩:如果我是审稿人
你花了一整节讲「模型没有记忆」。可我明明用过能记住我名字、记住我上周聊过什么的 AI 产品。你这句话是不是把话说过头了,或者干脆是错的?
参考防守(先自己组织语言再看)
这句话说的是模型这个函数无状态,不是说产品无记忆。产品的记忆是外面那层程序做出来的,实现方式无非几种:把历史整段重发(最朴素)、把历史存进数据库并在每次请求前检索出相关片段拼进去(所谓长期记忆)、或者把旧历史压成摘要再拼进去。这三种做法的共同点是——它们都在改「输入的那一段文字」,模型那一侧的行为一点没变。
有两个反驳需要正面接住。第一,prompt caching / KV cache。提供方确实会把你上次发过的前缀缓存起来,第二次就不用重算。但那是性能优化,不改变语义:你还是必须把整段发过去(或者发一个指向缓存的引用),少发一段结果就不一样。它省的是算力,不是你的传输责任。第二,某些 API 提供服务端会话状态,你只发新消息、由服务端拼历史。这是真的存在的。但这恰好证明了论点:那一层拼接还是有人在做,只不过做的人从你变成了厂商——厂商在服务端替你跑了一个 harness。
最后要承认边界:本站说的「模型是纯函数」是一个工程上有用的简化。严格说它带采样随机性,而且有些部署会在模型内部维护跨请求的状态。但对于「你要不要自己保存对话历史」这个具体决策,把它当无状态纯函数来设计,从来不会错。
真未解把历史塞得更长,模型就一定用得更好吗?
0.3 节的结论是「上下文是稀缺资源」,于是很自然会想:那就买上限更大的模型,把所有东西都塞进去。但这里有一个至今没有公认答案的问题——模型对超长上下文里的信息,利用率是不是均匀的?业界普遍观察到长上下文里中间部分的信息更容易被忽略,但「多长算长」「哪些任务受影响」「这是模型缺陷还是任务本身的性质」,都没有定论。
这个问题直接决定了压缩策略该怎么设计:如果长上下文利用率均匀,那最优策略就是「能塞多少塞多少」;如果不均匀,那主动裁剪反而可能提升效果,而不只是省钱。DeepSeek Harness 的 packages/compaction 显然选了后一种假设,但仓库里没有给出实验依据。
怎么入手:先读 docs/subsystems/compaction.zh.md,弄清它的压力触发条件和保留尾部策略;然后设计一个能自己跑的对照实验——同一个任务,一组不压缩直接塞满,一组主动压到一半,比较完成率。难点在于任务要选得能客观判分,否则你测的是自己的主观印象。
对你而言未知你要接的那个模型,单次到底能吃多少 token?
0.3 节说了,这个数不在 DeepSeek Harness 仓库里——它是模型目录里一个可选字段 contextWindow,注释写明「部署元数据不可用时省略」。也就是说 harness 把这件事完全推给了配置。这是有答案的问题,只是答案在别处。
怎么入手:先去读 packages/llm/llm-deepseek/src/index.ts,找到 DeepSeekCatalogModel 这个接口,确认 contextWindow 是怎么被消费的(谁在读它、读不到时会怎样)。然后去你要用的模型的官方文档查实际值。最后回答一个更有意思的问题:如果这个字段缺失,harness 是按最保守的假设走,还是干脆不做预算控制?
本章小结
这一章只讲了六件事,但后面 19 章全部建在它们上面。
一,模型是个无状态函数——吃一段文字吐一段文字,你每次都得把整段历史重发(式 0-1)。所谓记忆,是你的程序在维护的。二,token 是计量单位,它同时是钱和硬上限;因为要整段重发,累计成本随轮数平方增长(式 0-2)。三,工具调用是模型请你替它做事的语法:模型只输出一个 tool-call 块,真正执行的是你的程序,责任也在你的程序——这一条是全站的地基。四,JS 的六样东西:对象、数组、函数、async/await、回调、事件;后面所有代码片段都只用这些。五,依赖不是排顺序,是声明需求(inject),框架自己算顺序;没人提供的服务会让插件静默地永远等待。六,状态机把「一堆布尔标记的组合爆炸」压成有限几个合法状态,Cordis 的 fiber 有六个。
你现在应该已经写出了那个 20 行的循环。它能跑,而且它真的是一个 agent——不是玩具版,是同一个东西的最小形态。DeepSeek Harness 里对应的那个包叫 packages/core/agent-loop,1643 行。
那么问题来了:从 20 行到 1643 行,中间那 1871 行在干什么?而整个仓库 21.2 万行里,剩下那 20 万行又在干什么?第 1 章会一条一条告诉你,你那 20 行在真实世界里会怎么死。
第1章 全景:那 20 行为什么变成了 20 万行
你刚写完一个能跑的 agent,感觉没什么难的。这一章的任务是把那 20 行放进真实世界里,然后一条一条告诉你它会怎么死。
学完这一章你应该能做到
- 说出最小 agent 的九种死法,每一种都能举一个具体场景,并指出本站哪一章负责解决
- 说清 DeepSeek Harness 的规模,并把这些数字换算成能感知的东西
- 解释「一切皆插件」这句话的确切含义,以及它换来了什么、付出了什么
- 知道本站的五个模式各是干什么的,并在自己卡住时选对模式
- 把任意一条技术论断分到「仓库明文 / 作者设计意图 / 本站推断」三档里,并说出判据
inject」,以及你自己写出来的那 20 行循环——这一章会反复拿它当靶子。1.1 九种死法:把你那 20 行放进真实世界
先把靶子摆好。第 0 章末尾那个循环,干净利落:发请求、执行工具、塞回结果、再发一次。它在你的笔记本上、对着一个小任务,能跑。
下面九个场景,每一个都足以让它当场报废。请注意:没有一个跟模型聪不聪明有关。
死法一:对话越来越长,直到发不出去
你让它改一个中等大小的项目。它读了 package.json、读了三个源文件、跑了一次测试(输出 200 行)、又读了两个文件。第 9 次请求发出去,服务端返回一个错误:输入超长。你的循环在 await 那一行抛异常退出。更糟的是——你重新跑一遍,它对刚才做过的一切一无所知,从头再来一遍,然后在同一个地方再死一次。
这一条第 0 章已经算过账了(式 0-2,平方增长)。解药:上下文压缩,第 12 章。那一章会让你看到,压缩不是「删掉旧消息」这么简单——删哪些、什么时候删、删了之后模型会不会重新去读同一个文件,每一条都是取舍。
死法二:模型中途改主意
它说:「我先读 a.ts,然后改 b.ts。」读完 a.ts,它发现里面写着「b.ts 已废弃,请改 c.ts」。于是它转头去改 c.ts。这本身没问题,循环本来就允许它一步步来。
问题出在你想给这件事划边界的时候。用户此刻插了一句「等等,先别改」——这句话应该接在当前这一串动作里,还是另起一段新对话?你的 20 行循环里没有「一串动作」这个概念,只有一次次请求。所以你无处安放这句话。
DeepSeek Harness 把这件事切成两层:一个步骤(step)是一次模型请求加上它调用的工具;一个轮次(turn)包含零个或多个步骤,它在领取第一条输入之前打开,在不再欠下任何工作时关闭(docs/architecture.zh.md)。解药:轮次与步骤,第 7 章(「循环 · Agent 的心脏」那一组)。
死法三:工具跑到一半,用户想停
模型调了 bash npm install。跑到第 40 秒你发现它装错了包,按 Ctrl+C。
你的 20 行版本会发生什么?Ctrl+C 杀掉整个 Node 进程。messages 数组在内存里,没了。npm install 那个子进程可能还活着,变成孤儿继续往 node_modules 里写东西。下次你启动,看到一个被写了一半的目录,而且完全不知道刚才发生过什么。
「停止」不是一个瞬间动作——这一点第 0 章的 q0-8 已经埋过了。真实系统需要:一个能传下去的取消信号、一个「已经喊停但还没停干净」的中间状态、以及一条规矩说明还没开始的工作要不要保留。DeepSeek Harness 的 Agent 句柄上就有个 cancel(cause, options),其中 keepInbox 决定排队中的工作是一起丢掉还是留着(docs/subsystems/core.zh.md)。
解药:取消与错误恢复,同样在第 7 章。
死法四:两个工具同时改一个文件
模型一次回复里发了两个工具调用,都是编辑 config.json。你为了快,并发执行。两个都读到了同一份旧内容,各自改各自的那行,后写的把先写的覆盖掉。两个工具都返回 ok,模型以为两处都改成功了,接着往下做。
这是最阴险的一类失败:没有任何一处报错。程序继续运行,只是结果是错的。DeepSeek Harness 的处理方式是让工具自己声明能不能并发——ToolDefinition 上有个 isConcurrencySafe(args) 方法(docs/subsystems/tools.zh.md),注意它接收参数:同一个工具,改不同文件时可以并发,改同一个文件时不行。
解药:工具执行流水线,第 9 章;并发调度在第 12–15 章那一组「规模 · 让它撑得住」。
死法五:模型要求删掉整个磁盘
不用想象一个恶意模型。真实场景通常是这样:模型想清理构建产物,生成了 rm -rf $BUILD_DIR/*,而 BUILD_DIR 这个变量恰好是空的。于是命令变成 rm -rf /*。
你的 20 行版本会老老实实执行它。第 0 章讲过:模型只是输出了一段文字,是你的程序去执行的,所以责任在你的程序。
DeepSeek Harness 在「模型说要调用」和「真的执行」之间塞了一整条流水线:先跑 tools/pre-execute(钩子、权限、沙箱),再跑注册的单调守卫(monotonic guard),然后才是 tools/execute(docs/tool-execution-pipeline.zh.md)。守卫有一条奇怪的规矩:只许否决,不许放行。为什么要这么设计,第 10 章会让你把它拆掉,亲眼看越权发生。
解药:守卫与审批,第 10 章。
死法六:要换一家模型厂商
你的 callModel 里写死了:某家的接口地址、某家的鉴权头、某家的流式响应格式、某家的 tool-call 字段名。现在要换一家。你全文搜索改一遍——改完之后,原来那家跑不了了。要两家都支持?那就在 callModel 里加 if。三家呢?五家呢?再加一个「A 家超时就自动切 B 家」的需求呢?
DeepSeek Harness 把「跟模型说话」这件事变成了一个可替换的接缝:ctx.llm 定义词汇和适配器接口,llm-deepseek、llm-pi-ai、llm-mock-server、llm-replay 是并列的提供方,还有一个 llm-retry 专门管重试。换厂商是改配置,不是改代码。
解药:Cordis 的服务与注入,第 2 章起的那一组;能力 seam 的完整讲法在第 9–11 章那一组。
死法七:要在别人的机器上跑
你想让 agent 在一个云端沙箱里干活,别碰你的笔记本。可你的工具实现里写的是 fs.readFileSync——它硬编码了「文件系统就是本机文件系统」这个假设。bash 工具也一样,它直接 spawn 了本机进程。
DeepSeek Harness 对这件事的说法很值得记住(docs/architecture.zh.md 原话):文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。仓库里 fs-local 和 fs-e2b 是同一个接口的两个实现,subprocess-e2b 同理。
解药:能力 seam,第 9–11 章那一组。
死法八:崩了之后没法恢复现场
跑了 40 分钟,笔记本睡眠了,进程没了。messages 是个内存里的数组,它跟着进程一起消失。你损失的不只是时间——你损失的是「它到底做过哪些改动」这个信息,而工作区里的文件已经被改了一半。
DeepSeek Harness 的答案是把对话变成一份仅追加的事件日志:每一件事都是一条 SessionEvent,日志是模型所见上下文的唯一来源,模型历史是从日志投影出来的(docs/architecture.zh.md)。有了日志,恢复、fork、回放、遥测就全都是同一份数据的不同读法。仓库里光是 session 这一组就有 13 个包,两种持久化后端(JSONL 和 SQLite)。
解药:会话日志,第 6 章;持久化与恢复在第 12–15 章那一组。
死法九:想加个新功能,得改核心循环
这一条最不起眼,但它是前面八条的公因数。
你想加一个小功能:每次工具调用前,往公司的监控系统打一条日志。你只能改循环体。第二个人想加「工具超时就中断」,也改循环体。第三个人想加「某些工具需要人工确认」,还是改循环体。三个月后,那个 20 行的循环变成 300 行,里面缠着监控、超时、审批、重试、压缩,谁都不敢动,因为改一处会莫名其妙影响另一处。
DeepSeek Harness 把这件事写成了一条硬规矩(AGENTS.md):新行为放在插件里,不改循环;确实要改 agent-loop 的话,必须同时更新架构文档。这条规矩换来的东西就是下一节的主题。
解药:一切皆插件,第 2–5 章那一组「Cordis · 把一切变成插件」。
读的时候要小心
「九种死法」这个分类是本站为了组织内容编的,仓库里没有这样一张清单。真实的问题不会这么整齐地分成九类,它们互相纠缠(比如死法一的解药会破坏死法八的前提,见 q1-7)。把它当成一张地图,别当成一张完备的分类表。
新需求:你要让 agent 同时接两家模型,A 家超时就自动切到 B 家重试,而且用户不该察觉。这属于九种死法里的哪一(几)种?在插件架构里,这个功能应该放在哪一层,为什么不能放在 callModel 函数里?
agent/request-error。ctx.llm 上,重试则是一个独立插件(仓库里就有 llm-retry),失败恢复挂在 agent/request-error 这个扩展点上——按 docs/subsystems/core.zh.md 的说法,处理该错误的监听器返回 { kind: 'retry' } 且不调用 next()。不能放进 callModel 的理由有三条:一是那样重试逻辑会和某一家的协议细节缠在一起,换厂商时要重写;二是循环本身不该知道「一次请求可能其实是三次」,否则轮次与步骤的计数会全乱;三是这个功能有一天要能被关掉——写在函数里的东西关不掉,注册上去的东西可以卸载。变式:把需求改成「A 家和 B 家同时发,谁先回用谁」。这还是同一个位置的问题吗?它会引出九种死法里的哪一种新麻烦?
1.2 DeepSeek Harness 有多大
DeepSeek Harness(命令行叫 dsh)是 DeepSeek AI 开源的 agent harness,MIT 许可证。它自己的一句话介绍是:采用一切皆插件的架构,由 Cordis 驱动(README.zh.md)。
本站依据的是 commit 47f9438(2026-08-13)。下面这些数字是从那个 commit 现场数出来的:
| 项 | 数值 | 换算成能感知的东西 |
|---|---|---|
| npm 包数量 | 219 个 | 另有 9 个 vendor(内嵌)包,包括 Cordis 本体 |
| 源码行数 | 198,402 行 | 不含测试。每天认真读 300 行,一年 250 个工作日,要读将近 3 年 |
| vendor 行数 | 6,493 行 | 其中 Cordis 内核本身只有 2,693 行 |
| TypeScript 文件 | 2,085 个 | 其中 692 个是测试文件——每 3 个文件就有 1 个只用来测别的文件 |
| 类型化事件 | 56 个 | 就是第 0 章那个 ctx.on / ctx.emit,56 个不同的事件名 |
| 面向模型的工具 | 52 个 | bash、read、write、grep、web_search、subagent…… |
| 中文文档 | 105 篇 / 123 万字符 | 按每分钟 500 字读,不吃不喝要读 41 小时 |
现在把它跟你的 20 行放一起比。198,402 ÷ 20 ≈ 一万倍。
但这个比法有点耍赖,因为那 20 万行里绝大部分不是循环。真正对应你那个循环的包叫 packages/core/agent-loop,它有 1,643 行——是你那 20 行的 95 倍,占全仓的 0.89%。
换句话说:agent 的「心脏」只占不到百分之一,剩下 99% 都是围着它的那些事。这个比例本身就是这个站要讲的东西。
上面那张图是从各个包的 package.json 里现抽出来的真实依赖关系,不是示意图。转两下你会注意到几件事:有几个节点被特别多的箭头指着(那是枢纽,比如 invariants、session、tools);边上有一大圈几乎没人依赖的叶子(那多半是某个具体的提供方或某个工具);整张图没有环——不然按第 0 章讲的依赖解析,成环的那几个包谁都启动不了。
| 包 | 行数 | 它是什么 |
|---|---|---|
core/agent-loop | 1,891 | 你那 20 行循环的真身 |
core/tools | 5,620 | 工具注册表和那条带把关的执行流水线 |
core/session | 3,181 | 仅追加的会话日志 |
vendor/cordis | 2,693 | 整个插件框架的内核——比 agent 循环大不了多少 |
core 组共 8 个包 | 13,812 | 产品的 API 主干 |
client 组共 39 个包 | 44,932 | 浏览器界面。界面比整个核心大三倍多 |
最后一行值得停一下。很多人以为 agent 系统的难点在算法,实际上这个仓库里最大的一块是界面——因为要把一个会自己动手的东西的行为,实时、可理解地摊给人看,本身就是件难事。
用表 1-1 的数字回答:(a) 测试文件占 TypeScript 文件总数的比例是多少?(b) agent-loop 占全仓源码的比例是多少?(c) 这两个比例分别说明了什么?
变式:如果有人告诉你他写了一个「只有 3000 行的完整 agent harness」,用表 1-2 判断:他最可能砍掉了哪几块?砍掉之后哪些死法会立刻回来?
1.3 「一切皆插件」到底在说什么,以及它的代价
这句话在 README.zh.md 和 AGENTS.md 里都是加粗的。它的意思比字面激进得多。
docs/architecture.zh.md 是这么解释的:插件向共享上下文贡献服务、类型化事件和可逆的副作用;产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop 本身,因此每一部分都可以从配置替换。紧接着一句更狠的:不存在需要打补丁的特权内核。
把这两句翻译成人话:这个系统没有主程序。你在 packages/ 下找不到一个「一切从这里开始」的 main()。启动时发生的事情是——按配置把一堆插件挂到一棵树上,它们各自声明需要什么、提供什么,框架算出顺序,然后一切自己长起来。
连那个 agent 循环也不例外。core/agent 定义了 Agent 接口,core/agent-loop 只是「实现该接口的默认驱动器」(docs/architecture.zh.md 的表格里就是这么写的)。你可以写一个自己的驱动器把它换掉,配置里改一行。
为什么值得做到这个地步
回头看 1.1 的九种死法:六、七、九三条的解药,本质上都是同一件事——让一部分行为能被换掉,而不用动其余部分。换模型厂商是换一个提供方;换执行环境是换一组提供方;加监控是挂一个监听器。如果系统里有一个「特权内核」不允许被替换,那这三条就都得靠改内核解决,而改内核意味着每个人的改动都堆在同一个文件里。
具体怎么组装?docs/architecture.zh.md 说:运行中的 dsh 是一棵插件树,由启动时按序叠加的各层组合而成。
profile(profile):一份具名的组装方案,它列出自己叠放哪些组合包。仓库随发行版交付两个模板:web(带浏览器界面)和 headless(一次性运行器,完全不带服务器)。
组合包(bundle):Cordis 配置项及其挂载代码的分发格式。dsh-base 是每个 profile 的第一层——模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测都在里面。
各层的叠加顺序是明确的:先按 profile 列出的顺序应用每个组合包,然后是 profile 自己的 cordis.patch.yml,然后是 home 级的那份,最后是命令行给的 --patch。一条 patch 按 id 定位某个条目,替换它整个 config,或者插入一个新条目。
所以「从配置替换」不是一句漂亮话,它是一套有明确优先级的覆盖机制。想知道你的机器实际启动了什么,仓库给了一条命令:
dsh --profile web --dump-config
文档紧跟着一句:它打印出的任何条目,都可以由你自己的 patch 替换。
那么代价是什么
这一段仓库没写。下面是本站读完源码之后的判断。
读的时候要小心
接下来这四条代价是本站的推断,不是仓库的说法。官方文档通篇在讲这套架构解决了什么,没有一处系统地讲它的成本——这本身就是技术文档的常态(1.5 节会讲怎么读这种文档)。你完全可以不同意下面任何一条。
代价一:没有主函数,就没有入口。读一个传统程序,你从 main() 往下读。读这个仓库,你从哪读?答案是没有「从哪读」,只有「你想知道什么」。这就是为什么仓库自己在 docs/architecture.zh.md 里建议:建议使用 agent 探索代码库并理解其架构——一个 20 万行、没有单一入口的代码库,作者自己也认为人力顺序阅读不是好办法。
代价二:一个功能散在三个包里。仓库把「可替换能力」正式化成了一个叫 seam(本站译「能力 seam」,seam 不译,意思是「可替换能力的接缝」)的东西,它规定必须有三种角色:声明接口的 Service Definition、实现它的 Service Provider、使用它的 Consumer(docs/glossary.zh.md)。范例是 shell 这一组:dsh-shell(定义)、dsh-bash-local / dsh-bash-sandbox(两个提供方)、dsh-tool-bash(消费方)。
好处是替换提供方就能改变整个产品。代价是:你想搞清「模型执行一条 bash 命令时到底发生了什么」,得同时打开四个包。而且术语表明确写着seam 是完整能力,绝不是其中一个角色——也就是说,理解其中任何一个包,都不等于理解这项能力。
代价三:启动失败从「崩溃」变成「静默地什么都没发生」。第 0 章 q0-7 已经演示过:一个插件缺依赖,它不会报错,它会安安静静停在 PENDING。219 个包互相声明依赖时,这类问题的排查方式跟传统程序完全不同——你不是去看堆栈,是去 --dump-config 看树。
代价四:抽象本身要花钱。Cordis 内核 2,693 行、事件系统 56 个事件、seam 三角色的样板、每个注册都要配一个释放函数……这些代码不实现任何用户能看见的功能,它们只是为了让别的代码能被替换。如果你只是要写一个自己用的脚本,这笔钱不值得花。这不是黑话——判断一套架构值不值,就是在判断「未来要替换的次数」乘以「每次替换省下的工夫」,够不够抵消这笔固定开销。
补充:这个仓库还处在什么阶段
README.zh.md 明说:DeepSeek Harness 目前处于开发者预览阶段,正在快速迭代,未来将出现破坏兼容性的变更。AGENTS.md 里甚至有一节叫「预发布立场:地基优先于影响半径」,意思是既然还没有外部使用者,宁可重命名、重新分包,也要把地基弄对。所以本站讲到具体 API 形态的地方,你都应该以仓库当前代码为准——本站会一路给出文件路径,方便你回去查。
你装了一个第三方插件,说明书写着「装上之后模型就多一个 screenshot 工具」。你装好、重启、问模型能不能截图,它说它没有这个工具。程序没有任何报错。请构造出至少三种不同的原因,并说出一条能同时区分这三种情况的排查手段。
apply 就什么都不会注册)。再加上一个新的:配置层的叠加顺序意味着后面的 patch 能覆盖前面的条目。inject 了一个当前组合里没人提供的服务(比如它需要 ctx.screenshotBackend,而对应的提供方包没装),于是它永远等待、永不 apply、永不注册工具;(3) 启动了但被覆盖或被限制:更靠后的 patch 层按 id 定位到它并替换了 config(比如把它禁用了),或者当前 agent 的作用域上有一条 restriction 把这个工具过滤掉了——按 docs/glossary.zh.md 的说法,被过滤掉的全局工具「既不出现在提示词中,也拒绝执行,与不存在的工具无法区分」。能同时区分三者的手段是
dsh --profile <你的 profile> --dump-config:条目不在打印结果里 → 情况 1;条目在、但服务没起来 → 情况 2;条目在且被改过 → 情况 3。这就是代价三的具体形态:在这类架构里,第一手排查工具不是日志,是配置树。变式:把现象改成「工具出现在清单里,但每次调用都立刻返回被拒绝」。这时 --dump-config 还够用吗?你会转而去看什么?
1.4 本站怎么用:五个模式
这一节看着像使用说明,但它其实是这个站最重要的一节——因为不知道有这个功能,就等于没有这个功能。
每一章的顶部有一条模式切换栏,五个模式。它们不是五种排版,是五种不同的学习动作。
| 模式 | 它干什么 | 什么时候切过去 |
|---|---|---|
| 讲解 | 正常读。从问题起源讲起,先说不用这个东西会怎样,再说它是什么 | 第一遍读一章时的默认模式 |
| 推导 | 不给结论,一级一级问你,答案由你自己产出 | 读到一个「凭什么要这么设计」的地方,看答案之前先切过去自己走一遍 |
| 训练 | 把本章题目按四个难度层排出来做 | 读完一章之后,或者隔一天回来时 |
| 答辩 | 你讲,它攻:追问定义边界、构造反例、打最弱的那一环 | 当你觉得自己已经懂了的时候——这是唯一能戳破错觉的模式 |
| 研究 | 本章学完之后的开放课题,分「真未解」和「对你而言未知」两类 | 一章全部通过之后,或者你想找点真事做的时候 |
为什么要有答辩模式
因为「我觉得我懂了」这个感觉,是自己测不出来的。你读完一段流畅的讲解会产生一种熟悉感,而熟悉感和能力之间没有必然关系。唯一可靠的检验是:把你的理解输出出去,然后让人挑毛病。答辩模式做的就是这件事——你用自己的话讲一遍,它扮演最尖锐的同行评审来攻你。攻不动,你才是真懂了。
另外三件事,也是不知道就等于没有的:
提示是阶梯,请按顺序用。每道题下面有四级提示:第一级只告诉你往哪个方向想,第二级给关键思路,第三级给第一步怎么走,第四级才是完整答案。用到第几级会被记下来,直接影响这道题下次什么时候再问你——一上来就点开第四级,系统会认为你没掌握,很快就会再考你一遍。这不是惩罚,是让复习时间花在真的不会的地方。
选中就能划线。正文里选中一段文字,会浮出一条四色工具条:不懂 / 重要 / 存疑 / 深挖。键盘 1 到 4 直接上色,N 写批注,C 把这段连同上下文打包成一个可以直接粘给任意大模型的提问。划过的东西都汇总在「笔记」页。
两种判分。机判题(选择、排序、数值)机器直接给分;开放题有一个「让 AI 批改」按钮,它把题干、参考答案、评分标准和你的作答一起打包复制走,你粘到任意大模型里,再把分数填回来。进度存在这台设备的浏览器里——顶栏那个小圆点变黄就说明存不了,记得去「进度」页导出。
常见误解
很多人把模式当成「难度选择」,读不懂就想切到简单的那个。五个模式没有难度高低,它们是同一份内容的五种用法。真正的用法顺序大致是:讲解读一遍 → 卡住的地方切推导自己走 → 读完切训练做题 → 自以为懂了切答辩挨打 → 全通了切研究找活干。
三个情境,各该切到哪个模式?(a) 你读到「守卫只许否决、不许放行」,觉得这条规矩很多余,想不通为什么。(b) 你读完了整章,能复述每个概念,感觉挺清楚。(c) 你三天前学过第 2 章,今天想确认还记得多少。
变式:如果你在答辩模式里被问倒了,接下来该切回讲解,还是切到推导?两者的区别在哪?
1.5 证据分级:怎么读一份「我们的设计更好」的技术文档
你正在读的这个站,内容来自一个开源仓库的源码和文档。这里有一个诚实问题必须摊开讲:仓库的文档是它的作者写的。作者会讲自己的设计好在哪,很少会系统地讲它差在哪。这不是不诚实,这是所有技术文档的常态。
所以本站把每一条陈述分成三档,并且在页面上用不同方式标出来。
| 档 | 意思 | 怎么标 | 你该怎么对待 |
|---|---|---|---|
| 仓库明文 | 源码或文档里逐字写着的 | 正常叙述,附文件路径 | 可以直接采信,但要记住 commit——这是个开发者预览,会变 |
| 作者设计意图 | 文档表达的取舍理由、动机、主张 | 写明「这是作者的说法」 | 当成一种观点:它解释了为什么这么做,但没有证明这么做最好 |
| 本站推断 | 本站的类比、简化、外推、评价 | 红框(callout caution) | 当成可以反驳的东西。你不同意就是不同意 |
拿本章和上一章的具体例子对号入座:
- 仓库明文:
CHARS_PER_TOKEN = 4(packages/llm/token-meter/src/estimate.ts里的一行代码);FiberState有六个取值;52 个工具、56 个事件、219 个包(这三个是本站数的,但数的是明文事实,可以复现)。 - 作者设计意图:「不存在需要打补丁的特权内核」;「seam 是完整能力,绝不是其中一个角色」;「新行为放在插件里,不改循环」。这些都是作者主张的设计原则——它们说明了作者想要什么,但没有给出对照实验说明这样比别的做法好。
- 本站推断:式 0-2 那个平方增长;1.1 的「九种死法」这个分类;1.3 那四条代价;以及一切以「你可以把它理解成……」开头的比喻。
学会分这三档,你就有了一套读任何技术文档的方法。遇到一份声称「我们的设计更好」的文档,问三个问题:
问题一:它有没有说自己拒绝了什么?一个只讲自己好的设计文档,往往意味着作者没有认真考虑过替代方案,或者考虑过但不想写。DeepSeek Harness 在这一点上部分及格——比如它明确写了「seam 只做一个角色不算 seam」「waterfall 监听器不调 next() 就是短路,而这在单决策事件里是设计意图」,这些都是在划边界。但它没有一处系统讨论「如果不做成插件会怎样」。
问题二:它有没有说代价?1.3 那四条代价,是本站补的,不是仓库给的。你读到任何一份只有收益没有成本的文档,都应该自己把成本那一栏补上——补不出来,说明你还没真懂这个设计。
问题三:这句话是「可验证的事实」还是「我们认为更好」?判据很简单:能不能设计一个实验证伪它。「ToolDefinition 有个 isConcurrencySafe 字段」可以证伪(去看源码)。「一切皆插件的架构更好」不能证伪——「更好」在哪个指标上、对谁而言、多大规模下,一个都没定义。这不是说它错,而是说它属于另一类陈述,不能用读事实的方式去读。
把下面三条陈述各分到一档,并说明你的判据:
(1)「文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去。」
(2)「Cordis 内核只有 2,693 行。」
(3)「如果你只是要写一个自己用的脚本,这套架构不值得。」
docs/architecture.zh.md,前半句是事实(两个提供方共享执行世界),但「因此……无需提供方专用 fork」是作者在解释自己设计的好处;它可能是对的,但文档没有给出「换个设计就必须 fork」的证据,你只能当成作者的说法。(3) 是本站推断——纯粹的价值判断,连「值不值」的度量都没定义,所以本站把它放进了红框。要点:三条里最容易分错的是 (1)。混着事实一起说出来的因果判断,最像事实。识别方法是盯住连接词——「因此」「所以」「这正是……的原因」后面跟的那半句,几乎总是需要单独定档的。
变式:本章 1.2 节说「界面比整个核心大三倍多」,这句话该分到哪一档?如果再加上一句「所以 agent 系统的难点其实在界面」,档次会变吗?
「一切皆插件」意味着 agent 循环本身也能被整个换掉。那么问一个边界问题:有没有什么东西是不能被换掉的?如果有,它是什么?如果没有,这个系统靠什么保证换掉之后还能正常工作?
inject = ['tools']。这里的 'tools' 是个字符串键。如果每个人都能随便改这个键的含义,会怎样?ctx.tools、ctx.llm、ctx.sessions)、事件的名字和它们的分发模式、以及每个接口的类型。docs/cordis-primer.zh.md 说得很清楚:其他插件「通过 key 查找服务,而非导入具体实现」。所以可替换的是实现,不可替换的是接口。另外还有一层框架本身:Cordis 内核(2,693 行)不是插件,它是让插件成立的那个东西。保证「换掉之后还能工作」的机制有两层:类型系统在编译期检查你的替代实现符不符合接口;运行时不变量(
ctx.invariants,几乎每个包都 inject 它)在运行期断言那些类型表达不了的关系,比如「模型可见即已记录」。这就是这类架构的真正代价所在——它把「保证正确」的责任从『只有一份实现,读一遍就知道』搬到了『接口约定必须写得足够严,且必须有人来查』。变式:如果有人写了一个替代的 agent 驱动器,它不往会话日志里写 turn/start 事件。类型检查会通过吗?什么时候会出问题?
死法一的解药是压缩(把旧历史删掉或摘要掉),死法八的解药是会话日志(把一切完整记下来以便恢复现场)。这两条要求看起来是直接矛盾的。请说明:(a) 矛盾具体出在哪;(b) DeepSeek Harness 用什么办法让它们同时成立;(c) 这个办法引入了什么新的复杂度。
docs/architecture.zh.md 里的一个词:投影。日志和模型历史之间不是等号,是一个函数。deriveMessages()),压缩改的是投影规则,不是日志本身。文档原话是「会话日志是模型所见上下文的来源,deriveMessages() 从中投影出模型历史」。于是「压缩」这个动作本身也成了日志里的一条新事件——这正好满足另一条不变量:模型可见即已记录,任何抵达模型请求的东西都必须能从日志重建。(c) 新复杂度有三处:其一,多了一层投影,任何人想知道「模型到底看见了什么」都不能直接读日志,必须跑一遍投影;其二,日志会一直变大,磁盘和查询都要另想办法(仓库里 session 组有 13 个包,两种持久化后端,还有一个专门的查询子系统);其三,投影必须是确定性的——同一份日志两次投影必须得到同样的结果,否则恢复出来的现场和崩溃前不一样,这条约束会一路传染给每一个往日志里写东西的插件。变式:再挑一对——死法三(用户中途要停)和死法八(要能恢复现场)。它们冲突吗?如果一次取消发生在「工具已经改了文件、但结果还没写进日志」的那一瞬间,恢复出来的现场是什么样?
本章末尾那一层只做了一件事:在发请求前后各留一个回调,并让注册函数返回一个「摘掉它」的函数。请用依赖、可撤销的副作用、状态这三个概念解释:(a) 为什么这十几行是后面所有扩展的前提;(b) 如果 on 不返回释放函数,具体会在什么场景下出事?
register() 返回的就是那个释放函数。状态:钩子点把「循环现在走到哪一步」这件事变成了外部可观察的事件,而不再是循环体里的一个局部变量;后面要加取消、要加轮次与步骤、要加审批,全都要求外面的人能看见并介入这些位置。(b) 不返回释放函数的具体事故:你装了一个监控插件,用了一会儿把它卸载(或者热更新重新装了一遍)。监听器还留在
hooks.beforeRequest 数组里,于是——旧版本和新版本同时在跑(每次请求打两条监控,数据翻倍);被卸载的插件持有的资源(数据库连接、文件句柄)永远不会释放;如果那个监听器还引用了插件里的对象,整个插件对象永远无法被垃圾回收,反复装卸就是稳定的内存泄漏。而且这三样后果都不会立刻报错,它们只会让系统在跑了几小时之后开始变慢、数据开始变怪——这正是最难查的那一类问题。变式:现在这版钩子只能「看和改」,不能「拦」。如果你只用现有的机制去实现「某个钩子说不发这次请求就不发」,你会怎么写?写出来之后,说说它跟真正的 waterfall 差在哪。
自己推一遍:从「改循环」到「挂插件」
三个人要给你那 20 行循环各加一个功能:打监控、加超时、要审批。最省事的做法是三个人都去改循环体。这个做法在第几个人身上开始出问题?为什么?
想好了再看
严格说第二个人就已经出问题了,只是还看不出来。第一个人加的监控代码和第二个人加的超时代码写在同一个函数里,它们之间产生了一种没人声明过的耦合:谁在前谁在后是有区别的(超时了还要不要打监控?),但代码里没有任何地方写着这个顺序是有意的。到第三个人时,三段逻辑两两之间都有隐含顺序,共六种排列,而正确的只有一种,没人知道是哪种。
那就别改循环,改成「循环在关键位置叫一下外面的人」。具体要在哪几个位置叫?
想好了再看
凡是「有人可能想在这里插一脚」的地方。最少两个:发请求之前(可以改要发的东西,可以直接拦下不发)和拿到回复之后(可以看、可以改)。工具那一侧同理,执行前和执行后各一个。DeepSeek Harness 的做法正是如此,只是位置更细:
agent/pre-step决定模型看到什么,agent/request包住请求,三个tools/*事件包住工具执行。现在有了钩子点。但如果两个人都挂了钩子,谁先跑?而且,第一个人想「改一下再传下去」,第二个人想「直接拦掉」,这两种意图在同一套机制里怎么表达?
想好了再看
需要一种比「广播」更强的分发方式:监听器要能决定要不要继续往下传。这就是 waterfall(瀑布式事件)——每个监听器拿到
(参数..., next),调用next()就把控制权交给下一层,下一层的返回值还会回到自己手上,可以再加工;不调用next()就是短路,后面的人根本不会跑。docs/cordis-primer.zh.md把它叫「环绕中间件」,并且明说:对于单决策事件,短路是设计意图。最后一个问题:这三个人的功能,将来要能一个个关掉。关掉一个已经挂上去的钩子,需要什么?
想好了再看
需要挂的时候就把「怎么摘下来」一起交出来。Cordis 的说法是注册是可逆的副作用:所有注册都通过
ctx.effect()或ctx.on()安装,reload 和 teardown 时会按预期撤销;一个注册表的register()返回的就是释放函数(disposer)。这一条听起来是个小细节,其实它才是「插件能被卸载」的全部基础——一个不能被撤销的注册,等于一个永远拆不掉的补丁。
给你的循环留两个钩子点
这一层只加十来行,而且看起来毫无必要——你现在还没有任何插件要挂。但它是后面每一层的前提。
难点不在代码,在于想清楚为什么非得是这一步。三件事值得说明。
第一,它把「循环做什么」和「循环之外的人想在什么时候插一脚」拆开了。从此新增行为不用碰循环体——这正是 AGENTS.md 里那条硬规矩的雏形:新行为放在插件里,不改循环。
第二,on 必须返回一个能摘掉它的函数。第一次写的人几乎都会省掉这个返回值,因为「反正也没打算摘」。但插件的本质就是能装能卸,而卸载意味着它装上去的每一样东西都得能撤回来。Cordis 把这条上升成了原则:注册是可逆的副作用。你现在写的这个返回函数,就是它说的「释放函数」(disposer)。
第三,现在这个版本还很弱:钩子只能看和改,不能拦——想让某个钩子说「这次请求别发了」,你没有语法可用。要表达「拦截」和「包住」,需要的是 waterfall,那是第 4 章的内容。先把这一层立住。
做对了的样子:按顺序注册三个 beforeRequest 钩子——A 打印 messages.length,B 往 messages 最前面插一条系统消息,C 也打印 messages.length。跑一次,日志里 A 打印的数应该比 C 少 1(说明顺序是确定的,而且 B 的修改被 C 看见了)。然后把注册 B 时拿到的那个返回值调用一次,再跑一次:A 和 C 应该打印同一个数,而且循环体里那 20 行你一个字都没改过。做不到就说明两件事之一:要么你把插消息的逻辑写进了循环体(那这一层白加了),要么你的 on 没返回释放函数(那插件就只能装不能卸)。
答辩:如果我是审稿人
你自己说了,20 行就能跑一个 agent。现在你要我相信同一件事值得写 219 个包、20 万行。这不就是典型的过度工程吗?而且这个项目还在开发者预览期,连一个正式版本都没发过——凭什么认为它的设计是对的?
参考防守(先自己组织语言再看)
这个质疑分成两问,要分开接。
第一问:20 万行值不值。不能笼统地答,只能按用途分开答。如果目标是「我自己在本机跑个脚本,出错了我手动重来」,那 20 行版本确实够用,20 万行是纯粹的负担——1.3 节的代价四说的就是这件事。但如果目标是「多人用、能被打断、能恢复、要审批、要换模型、要在别人机器上跑、要让第三方加功能」,那 1.1 的九种死法就一条都躲不掉,而每一条的解药都要真代码。注意一个关键分布:agent-loop 只有 1,643 行,占全仓 0.89%;client 组 44,932 行。20 万行里的绝大部分不是在把简单的循环搞复杂,而是在做循环之外的、无论如何都得有人做的事——界面、持久化、权限、协议适配。
第二问:凭什么认为设计是对的。诚实的回答是:不能认为。仓库自己在 README 里写着「开发者预览,未来将出现破坏兼容性的变更」,AGENTS.md 里那节「地基优先于影响半径」甚至明说因为还没有外部使用者,所以可以随便重命名重新分包。所以本站从来不主张「这套设计是对的」,只主张两件更弱但更可靠的事:一,它面对的那九个问题是真的——任何人写 agent 都会撞上;二,它给出的解法是一个值得研究的完整样本——完整到能让你看清每个决定的代价。至于对不对,你学完之后自己判断,本站在 1.5 节把工具都给你了。
最后要主动承认一个弱点:本站没有拿它跟别的 harness 做过对照。「插件化是不是必要的」这个问题,只看一个仓库是回答不了的。
真未解插件化的边际收益,在哪一层停止?
DeepSeek Harness 把可替换性推到了极致:连 agent 循环本身都是可替换的插件。但这里有一个没有公认答案的问题——一个系统里,把某样东西做成「可替换」的收益,在什么时候会被它的抽象成本吃掉?
这不是一个可以靠读文档解决的问题,因为它需要一个度量:怎么衡量「一次替换省下的工夫」,又怎么衡量「多一层接缝的固定成本」。软件工程界至今没有公认的答案,常见的做法只是经验法则(比如「同一件事出现三次再抽象」),而这类法则从来没有在 20 万行的规模上被验证过。
DeepSeek Harness 提供了一个罕见的观察样本:它有 219 个包,每个包都能看到它的 inject 和 provides,也能看到有多少个实际存在的替代提供方。
怎么入手:先统计一件事——219 个包里,有多少个服务键真的存在两个以上的提供方?(ctx.llm 有四个,ctx.fs 至少有 local 和 e2b 两个,但绝大多数可能只有一个。)只有一个提供方的接缝,等于付了抽象成本却没兑现替换收益——但也可能它的价值在于「将来能换」。把这个比例算出来,再逐个看那些「只有一个提供方」的接缝当初是为什么建的(去读对应包的 README 和 .agents/notes/ 里的设计笔记)。这不会给你一个数,但会给你一批真实案例。
对你而言未知跑起来一个最小的 dsh,到底需要多少个包?
219 个包听起来吓人,但其中有多少是「跑一个最简单的任务」真正需要的?这个问题有确定答案,而且答案就在你手上——仓库交付了两个 profile 模板,web 和 headless,后者是「一次性运行器,且完全不带服务器」。
怎么入手:跑 dsh --profile headless --dump-config,数一数打印出来的条目数,跟 dsh --profile web --dump-config 比。然后回答三个问题:(1) 两者的差集里,有多少是界面相关的?(2) 两个 profile 的交集是什么——那才是这个 harness 真正的最小内核;(3) 交集里有没有你觉得可以去掉的?去掉之后九种死法里哪几种会回来?做完这一轮,你对「一切皆插件」的感觉会和现在完全不同。
本章小结
这一章做了四件事。
一,把那 20 行的九种死法摆出来了:对话超长、轮次边界不清、停不下来、并发改同一个文件、危险命令直接执行、换厂商要改代码、换执行环境要改工具、崩了没法恢复、加功能只能改循环。这九条就是后面 18 章的知识依赖图——你每读一章,其实是在拆其中一条。
二,给了规模:219 个包、198,402 行源码(不含测试)、2,085 个 TS 文件(其中 692 个是测试)、56 个事件、52 个工具、105 篇中文文档。最值得记住的一个比例是:agent-loop 只占全仓 0.89%。
三,讲了「一切皆插件」:没有特权内核,连 agent 循环本身都能从配置替换;组装靠 profile 叠组合包再叠 patch;排查靠 --dump-config。同时补了仓库没写的四条代价——没有入口、一个功能散在三个包、失败变成静默等待、抽象本身要花钱。
四,交了两件工具给你:五个模式(讲解 / 推导 / 训练 / 答辩 / 研究),以及三档证据分级。第二件工具你现在就该用起来——从下一章开始,每读到一句「我们的设计更好」,先问它属于哪一档。
你的 mini-harness 现在有两层:一个能转的循环,和两个钩子点。下一组四章要做的事是:把那两个钩子点,变成一个能挂 219 个插件的框架。
第2章 上下文与服务:为什么不用 import
这一章回答一个几乎每个人第一眼都会问的问题:我要用别的模块的功能,import 一下不就行了?为什么 DeepSeek Harness 的插件之间几乎不互相 import,全都写成 ctx.某个名字?
学完这一章你应该能做到
- 用自己的话说清
import和「从上下文里取服务」的区别究竟落在哪一件事上 - 看到一行
export const inject = ['tools', 'web'],能说出这个插件什么时候会启动、什么时候会停下来 - 解释为什么把两个插件在配置文件里的先后顺序对调,程序的行为一模一样
- 遇到一个插件「一句话都不输出、也不报错」,能说出最可能的原因,并知道去哪儿确认
- 指出扁平命名空间的代价,并说出仓库给出的缓解办法与它没能解决的部分
2.1 先看看只有 import 的世界
假设你已经写好了一个 agent(智能体),它能调用大模型来总结一段文字。你的总结插件里大概会有这么一行:
// 简化示意,不是仓库代码
import { deepseek } from './llm-deepseek.ts'
export async function summarize(text) {
return deepseek.chat([{ role: 'user', content: '总结:' + text }])
}
这行 import 干了两件事,而它们本来是两件事:第一,声明我需要一个能跟大模型说话的东西;第二,指定这个东西就是 llm-deepseek.ts 里那一个。import 把这两句话焊在了同一行代码里。
焊死的代价,要等你想换实现的时候才会显形。设想下面这个具体场景:你的 harness(智能体运行框架)跑了三个月,有十四个插件调用模型——总结、起标题、压缩历史、给子 agent 分派任务、给工具生成参数……现在公司说,海外用户要走另一家厂商的接口,国内还留在原来的。于是你得把这十四个文件全部打开,把 import { deepseek } 改成某种能二选一的写法。改完,测试全部重跑。下个月又要接第三家,再来一遍。
更难受的是另一半:有些调用在启动时就能定,有些要按用户当前的设置来定。import 是模块加载时就结算完的,它没有「等一下再决定」这个档位。你只能在它之外再手写一层工厂函数、一个全局变量、一张注册表——写着写着,你就在重新发明这一章要讲的东西,只是发明得更糙。
所以真正要解决的是什么
不是「import 不好用」,而是「我需要什么」和「谁来满足我」这两个决定,应该由不同的人在不同的时刻做。插件作者知道自己需要什么,但不该知道谁来满足;部署这套系统的人知道该用谁,但不该去改十四个文件。
这在 DeepSeek Harness 里是能看见的实物。packages/bundle/base/cordis.patch.yml 是每个部署的第一层配置,里面有这样几行(各行不相邻,我把它们摘到一起):
- id: llm name: '@deepseek-ai/dsh-llm' # 定义「llm 这个能力长什么样」 - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' # DeepSeek 原生适配器 - id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' # 多厂商适配器
能力的定义和能力的具体实现是三行独立的配置,不是一行 import。想换厂商,改的是这份 YAML,不是那十四个插件。
有人说:「用 import 也能换实现啊,我把 ./llm-deepseek.ts 这个文件的内容整个换掉不就行了。」这句话哪里不对?
import 在模块解析时就结算了,它天生表达不了「等运行的时候再定」。而且他的办法没法让两个实现共存:仓库里 dsh-llm-deepseek 和 dsh-llm-pi-ai 是同时挂着的,谁生效由用户设置文档决定。变式:如果这个 harness 一辈子只在一台机器上跑、只接一家厂商、永远不换,import 是不是就够了?(是的——这一章讲的机制是为可替换性付的钱,不需要可替换性就别付。)
2.2 上下文:一个装着服务的容器
服务(service):一个插件对外公开的、有名字的能力。别的插件通过这个名字拿到它,不需要知道它是哪个文件、哪个类实现的。
上下文(context,代码里一律写作 ctx):装服务的容器。每个插件启动时都会收到一个 ctx,它贡献的一切也都挂在这个 ctx 上。
在 harness 里,ctx.llm 是模型能力,ctx.tools 是工具注册表,ctx.sessions 是会话日志(这三个分别来自 packages/llm/llm、packages/core/tools、packages/core/session,见 docs/architecture.zh.md 的核心包表)。消费方写的是 ctx.llm——它只说了要哪个名字,没说要谁的实现。
打个比方
墙上的插座。电器只认「这里有 220V 交流电」,不认电是火电还是光伏。发电方式换了,你家的台灯不用改。ctx.llm 就是那个插座,插座名叫 llm。
类比失效处有两个,都很关键。其一,插座永远在墙上,而服务会消失——提供方被卸载,ctx.llm 就真的不存在了,这是 2.5 节的主题,插座类比在这里彻底失效。其二,插座是被动的,插上就有电;而服务不在时你的插件根本不会启动,不是「启动了但取不到电」。
谁把服务挂上去的?提供方自己。Cordis 提供了一个 Service 基类,构造函数的第二个参数就是名字(vendor/cordis/src/service.ts):
import { Service, type Context } from '@deepseek-ai/cordis'
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // 从这一刻起,别人写 ctx.greeter 就能拿到它
}
greet(who: string) {
return `Hello, ${who}!`
}
}
这段代码来自 docs/cordis-tutorial/03-services.zh.md。注意 super(ctx, 'greeter') 做的事:它不是把实例存进全局变量,而是向当前上下文登记「名字 greeter 由我提供」。登记本身是可撤销的——第 3 章会讲这为什么至关重要。
一个反直觉的地方
你可能以为,只要有人提供了 greeter,任何插件写 ctx.greeter 就都能拿到。不能。没有声明依赖就直接读,会当场抛错。我在浏览器里跑仓库那份 Cordis 试了一下,错误原文是:cannot get property "greeter" without inject(实现在 vendor/cordis/src/reflect.ts 的代理 get 陷阱里)。
这不是刁难。它逼你把依赖写在明面上,这样框架才有可能替你安排加载顺序——下一节的全部内容就建立在这上面。
同一个 harness 里,dsh-llm-deepseek 和 dsh-llm-pi-ai 两个适配器都挂着。它们会不会都去 super(ctx, 'llm')?为什么?
packages/llm/llm/src/index.ts,找 super(ctx, 那一行,看它属于哪个包。llm 这个名字由 packages/llm/llm 里的 LlmRuntime extends Service 独占登记(super(ctx, 'llm'))。两个适配器都不占这个名字,它们反过来依赖它——两个包的源码开头都写着 export const inject = ['llm'],然后在 apply 里调 ctx.llm.registerAdapter(...) 把自己注册进去。这正是仓库反复强调的角色划分:定义能力的、提供实现的、消费能力的是三种不同的插件。如果两个适配器真的都去登记 llm,后登记的那个会直接失败——见 2.6 节。变式:那 ctx.llm 存在,是不是就说明模型一定能用?(不一定。适配器可能一个路由都没注册——base 配置里 llm-pi-ai 就是「挂着但休眠」的,用户设置里没有配置就零路由。服务在,不等于能力在。)
2.3 inject:声明依赖,顺序自己会长出来
消费方这样声明自己需要什么:
export const name = 'consumer'
export const inject = ['greeter']
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world'))
}
inject 是一个字符串数组,列出这个插件必须拿到的服务名。Cordis 的承诺很硬:apply 被调用时,inject 里的每一个服务都保证已经就绪。在那之前,这个插件一动不动。
真实的 harness 插件长得完全一样。packages/web/tool-web/src/index.ts 里就是一行:
/** Services required by the web tool suite. */ export const inject = ['tools', 'web', 'systemPrompt']
这行代码说的是:我要一个工具注册表、一个网页能力、一个提示词组装器;至于网页能力是谁提供的(仓库里至少有 web-search-exa、web-search-perplexity、web-search-deepseek 三种),我不管,也不想知道。
把两行对调,输出一样
教程第 3 章给的组合是这样:
- name: './greeter.ts' - name: './consumer.ts'
运行输出 Hello, world!。现在把两行对调,让消费方排在提供方前面,再跑一遍——输出一模一样。我不是照抄文档说的,我把仓库那份 Cordis 打包进浏览器,在两种顺序下各跑了一次,两次都打印 consumer says: Hello, world!。
为什么?因为消费方在 greeter 就绪之前,只是安静地待着;提供方一登记,Cordis 立刻把等着这个名字的插件全部唤醒。决定加载顺序的是依赖关系,不是文件里的行号。
仓库自己把这件事写进了配置文件的注释里。packages/bundle/base/cordis.patch.yml 开头有一句:
# Row order carries no load semantics (activation is service-availability # driven); the grouping is for readers. # (行的顺序不携带任何加载语义,激活由服务是否可用驱动;分组只是给人看的。)
某个插件必须在会话日志建好之后才能工作,但它并不调用会话日志的任何方法,只是需要「那时候日志已经存在」。它该怎么写?
inject 表达的不是「我要调用它」,而是「没有它我不该启动」。inject,哪怕 apply 里一次都不用。export const inject = ['sessions']。inject 的语义是「这些服务不在时我不该运行」,用不用它的方法是另一回事。反过来说,如果你不写 inject、指望把自己的配置行往后挪一挪来实现「晚一点启动」,那是彻底无效的——行序不携带加载语义。顺带一提,这也让「顺序」这个概念在 Cordis 里变得可检查:依赖写在代码里,而不是藏在某个人对配置文件的记忆里。变式:如果它要等的那个东西根本没有对应的服务名(比如「等某个文件被写完」),inject 还能用吗?(不能。inject 只认服务名。这种时候要么由某个插件把那件事包装成服务,要么改用事件——那是第 4 章的话题。)
2.4 PENDING 是合法状态,不是错误
现在把提供方整个删掉,只留消费方。你觉得会怎样?大部分人的直觉是「报错」,或者至少「打印一条警告」。
都不是。它什么也不会发生:不崩溃,不报错,不输出,也不会只执行一半。这个插件停在一个叫 PENDING 的状态里,安静地等着。
PENDING:已经声明、但所需服务尚不可用的状态。它是六种合法生命周期状态之一(完整状态机是第 3 章的主题),不是错误码。
为什么设计成不报错?因为 Cordis 无法判断这是不是错误。提供方可能三秒后才挂载完,可能由用户在界面上点一下才启用,也可能这次部署压根就不需要这个功能。「现在没有」和「永远不会有」在运行时是分不开的两件事,框架选择不替你猜。
「我的插件为什么一点输出都没有」
这是这个问题的头号答案,而且往往不是你自己那行 inject 写错了。教程第 6 章举了一个极其典型的例子:HMR(热模块替换)插件自己 inject 了 timer 服务来做去抖,如果你的配置里没有 @deepseek-ai/cordis-plugin-timer,HMR 就会永远停在 PENDING,而且不发出任何提示——于是你会觉得「热重载坏了」,其实是它根本没启动。
还有一个更隐蔽的:如果模块路径拼错了,Cordis 会通过 logger 服务报告错误而不是让进程崩溃;而在启动早期,这条报告可能在控制台导出器开始工作之前就丢了(docs/cordis-tutorial/01-first-plugin.zh.md 明确写了这一点)。新加的配置项看起来毫无效果时,先检查拼写。
还有一个会把人绕进去的现象:PENDING 的插件不会让 Node 的事件循环保持活跃。所以如果你的组合里没有别的东西在跑,进程会静静地以状态码 0 退出——看起来就像「程序正常结束了」,而实际上你的插件一次都没运行过。
怎么确认它是不是 PENDING
直接去问注册表。每个上下文都能枚举所有已挂载的插件实例,教程第 6 章给了一段可以直接抄的诊断代码:
import { FiberState, type Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log(`${fiber.name} is PENDING — a required service is missing`)
}
}
}
}, 500)
}
为什么要包一层 setTimeout?因为挂载是并发的,立刻去查会查到一堆还在加载中的东西。等半秒再看,剩下的 PENDING 才是真的缺东西。
你的插件毫无输出。可能是它停在 PENDING,也可能是它根本没被加载(比如配置里的路径拼错了)。请设计一个最小实验,把这两种情况区分开——不许改插件本身的代码。
if (fiber.state === FiberState.PENDING) 这个过滤条件去掉,改成把每个 fiber.name 和 FiberState[fiber.state] 都打出来。inject 了什么、谁该提供。如果名字压根没出现,那它就没被加载——去查模块路径拼写,那条解析错误多半在控制台导出器就位之前就丢了。附带收获:不加过滤地打印时,你还会看到 Loader、Include 这些 loader 自身的插件处于 ACTIVE,因为配置文件本身也是被一个插件挂载的(教程第 6 章明确提到了这一点)。变式:如果你的插件出现在列表里,状态却是 FAILED 而不是 PENDING,说明什么?(说明它启动过但抛异常了——那是第 3 章的另一条路径,处理方式完全不同。)
2.5 依赖是一直盯着的,不是启动时查一次
到这里为止,你可能把 inject 理解成了「启动前的一道检查」:查一遍,通过就放行,之后各过各的。不是。
它是持续追踪的。docs/user/develop/framework/service.zh.md 把规则写得很直白:如果运行期间某项必需服务消失(比如它的提供方被卸载了),依赖它的插件会自动卸载;当服务重新出现时,插件自动重新加载。
我把这条规则在浏览器里跑了一遍:挂一个英文版的 greeter 提供方 + 一个消费方,输出 Hello, world!;然后卸掉英文提供方、挂上一个中文版的,消费方没有任何改动,自己重新执行了一次,这回打印的是 你好,world!。它不知道自己被换了实现,也不需要知道。
关于这个实验室
上面这个台子里跑的不是复刻,不是伪代码模拟,是仓库 vendor/cordis/src/ 那 2693 行原始实现被打包进了浏览器(context.ts 146 行、events.ts 352 行、fiber.ts 754 行、reflect.ts 418 行、registry.ts 337 行等等)。你在上面挂插件、拔服务,触发的是真的 Fiber 状态机、真的服务解析、真的副作用回卷。这一章里所有带「我跑了一遍」字样的结论,都是在这份实现上跑出来的。
它不能证明的是:整个 DeepSeek Harness 能在浏览器里跑。跑得起来的只有这个框架内核,harness 那二十多万行要用文件系统、子进程和网络,浏览器里没有。
这条规则解释了一件很重要的事:为什么配置里能换实现。教程原话是——卸载 dsh-bash-local 这个配置项,挂上另一个 shell 提供方,所有 inject 了 'shell' 的插件都会重新启动并使用新实现。没有一个插件需要为此改一行代码。
但它也带来一个必须马上回答的问题:一个插件被卸载的时候,它之前注册过的东西——监听器、定时器、往工具表里塞的那条记录——怎么办?如果这些东西撤不干净,那么「卸载再加载」就不是刷新,而是叠加:监听器留了两份,定时器还在跑,工具表里躺着一个指向已死代码的条目。整套依赖追踪机制会立刻变成灾难制造机。
所以第 3 章的主题不是可选的锦上添花,而是这一章能成立的前提:注册必须可逆。
团队想做一个功能:用户在界面上切换模型厂商,不重启进程。有人提议在 ctx.llm 的实现里加一个 switchProvider() 方法,运行时改内部指针。请评价这个方案,并说出 Cordis 原生的做法是什么、代价差在哪。
apply 里注册的一堆副作用。换指针只换了方法。apply 里注册了一个提示词片段和一个事件监听器。写下 switchProvider() 执行之后,这两样东西各自处于什么状态。switchProvider() 只能换掉方法表,换不掉旧提供方注册过的副作用——旧的提示词片段还在,旧的监听器还在监听,而且新提供方注册的那份会叠加上去。Cordis 原生的做法是卸载旧插件、挂载新插件:旧插件的所有注册随它一起回卷,依赖 llm 的插件被自动停用再重启,拿到的是干净的新实现。代价是这次切换不是原子的——中间存在一段 llm 不存在的时间,依赖它的插件在这段时间里是停着的,正在进行中的请求会怎样,仓库没有给出统一说明(见本章末尾的研究题)。所以这个方案的取舍是:用「短暂不可用」换「状态绝对干净」。switchProvider() 反过来,用「永远可用」换「状态可能脏」,而脏状态的 bug 极难复现。变式:如果这个能力真的一秒都不能中断(比如正在流式输出),你会怎么设计?(一种思路是让两个提供方在不同的隔离作用域里共存,用 isolate——服务隔离让不同插件组看到同一服务名的不同实例,见 docs/user/develop/framework/service.zh.md 的服务隔离一节。)
2.6 可选依赖,和一个扁平的命名空间
inject 表达的是硬依赖:没有就不启动。但有些功能是「有更好,没有也能活」——比如你想在有埋点服务时记一笔,没有就算了。这时候不写 inject,改在用的地方探测:
export function apply(ctx: Context) {
// 没有提供方时返回 undefined;插件照常运行
const greeter = ctx.get('greeter')
console.log(greeter?.greet('maybe') ?? 'no greeter available')
}
ctx.get(name) 是探测式读取,不参与依赖判定,所以它不会让你的插件进 PENDING。我实测过:有提供方时返回实例,没有时返回 undefined,不抛错。
读的时候要小心:这里有个本站要提醒你的坑
ctx.get() 的第二个参数 strict 默认是 true,含义是只返回提供方 fiber 处于 ACTIVE 状态的实现(vendor/cordis/src/reflect.ts 的 _getImpl)。也就是说,当提供方正在重新加载的那一小段时间里,ctx.get() 也会返回 undefined——不是「服务不存在」,是「此刻它不可用」。这两种情况在返回值上分不开。
「所以可选依赖的代码必须能承受偶发的 undefined,而不只是启动时那一次」——这句是本站从源码行为外推的建议,仓库文档没有明说这一层。仓库处于 developer preview,官方明说会有破坏兼容性的变更,具体形态请以 reflect.ts 为准。
服务名是全局共用的一张平表
教程第 3 章说得很明确:每个应用中的服务名称共用一个扁平命名空间。没有 com.yourteam.metrics 这种分层,只有 metrics。harness 已经占掉了 tools、llm、sessions、web 这些很普通的名字。
撞名会怎样?我试了:让两个不同的类都去 super(ctx, 'greeter'),第二个插件直接进 FAILED 状态,错误原文是 service "greeter" has been registered at <Greeter>(抛出点在 vendor/cordis/src/reflect.ts 的 provide 里)。
好消息是它失败得很响,不会静默覆盖;坏消息是这是运行时才发现的,而且失败的是后来者——两个第三方插件撞名时,谁该改名并没有机制来裁决。仓库给的办法是约定:给自有服务加一个有辨识度的前缀。这是纪律,不是机制。
你写一个插件,它在有 ctx.telemetry 时上报耗时,没有时正常干活。应该写 export const inject = ['telemetry'] 吗?
inject 之后,没有这个服务时你的插件会怎样?inject 的语义是「没有它我不该运行」,而你的需求是「没有它我照样运行」。inject 里去掉,在用到的那一行改成 ctx.get('telemetry'),并处理 undefined。inject,在使用处 const t = ctx.get('telemetry') 然后 t?.record(...)。判断标准只有一条:缺了它,这个插件还有没有意义?有,就用 ctx.get();没有,才写进 inject。变式:如果你写了 ctx.get('telemetry') 却忘了写 ?.,会在什么时候炸?(在没装埋点服务的那个部署上、第一次执行到那一行时炸——本地永远复现不了。这类 bug 是可选依赖的典型代价。)
你的团队写了一个服务叫 cache,用了半年一直好好的。某天升级了一个第三方插件包,你的服务突然启动失败。请说出最可能的原因,以及为什么这个故障在升级前的测试里没被发现。
super(ctx, 和 ctx.provide(,看有没有新增一个叫 cache 的登记。cache。扁平命名空间下这是硬冲突,后登记的那个抛错进 FAILED,抛出点在 reflect.ts 的 provide:service "cache" has been registered at <...>。至于谁是后登记的那个,取决于两边插件谁先激活,而激活由依赖驱动、不由配置行序决定——所以你的服务和它的服务,哪个失败可能是不稳定的。测试没发现,是因为冲突只在两个插件同时挂载时才成立:单元测试里通常只挂自己那一个。这也是仓库要求给自有服务加前缀的原因——但那是约定,装不上强制。变式:如果你确实需要在同一个应用里跑两份配置不同的 shell 提供方,有没有合法办法?(有:isolate。给两个插件组各自隔离 shell,两组各看到自己那份实例。见服务隔离一节。注意这解决的是「有意共存」,不是「意外撞名」。)
答辩:如果我是审稿人
你说 import 把「要什么」和「谁提供」焊死了。可我完全可以 import 一个接口再加一个工厂函数,照样能换实现——这不就是几十年前就有的依赖注入吗?为了这个,值得引入一个 2693 行的框架、一套新的生命周期状态、还有一个会让插件静默不启动的 PENDING 状态?
参考防守(先自己组织语言再看)
前半句完全成立,必须先承认:只要你只想在启动时选实现,接口加工厂就够了,不需要框架。这一章讲的机制在那个需求上是过度设计。
它多出来的东西只有一样,但这一样手写工厂拿不到:时间维度。工厂能回答「用哪个实现」,回答不了「这个实现刚刚消失了,谁在用它、他们现在该怎么办」。Cordis 把依赖做成持续追踪:提供方一走,所有依赖方自动停用;提供方回来,自动重启。这不是选实现,是管理依赖方的存活状态,而这件事必须由一个知道全部依赖关系的中心来做——你手写的工厂不知道谁 import 了它。
PENDING 的静默确实是真实成本,而且是这套设计里最招人烦的一处。可以这样为它辩护:静默的替代方案是超时报错,而超时值取多少都是错的——服务可能十秒后由用户点击才启用。框架选择不猜,代价是把诊断责任交给了开发者,同时提供了注册表枚举这个逃生口。这是取舍,不是疏忽。
最后一句诚实话:如果你的系统里没有任何东西需要在运行期被替换或卸载,这一整章可以不用。DeepSeek Harness 需要——因为它的每一部分,包括 agent loop(智能体循环)本身,都要能从配置里替换掉。
真未解服务被替换时,那些正在进行中的调用该由谁负责?
依赖追踪保证了消费方会被卸载重载,但它管的是插件的存活状态,不是调用的完成状态。设想消费方刚发起 ctx.llm.stream()、模型正一个词一个词往回吐,这时提供方被卸载了:这次流应该被取消、应该让它跑完、还是应该切到新提供方接着吐?三种做法在不同系统里都有人选,没有公认答案。
源码里能看到 Cordis 做了一半:vendor/cordis/src/reflect.ts 的 provide 返回的释放函数会先把自己从全局表里删掉、通知所有依赖方,然后 await Promise.allSettled(fibers.map(fiber => fiber.await()))——等依赖方各自稳定下来,最后才删掉自己上下文里的那条记录(源码注释写的是 ensure self access before dependencies cleanup)。但「fiber 稳定」不等于「业务调用结束」,这中间的语义边界仓库没有任何一份文档说明。
怎么入手:先读 vendor/cordis/src/reflect.ts 的 provide 与 notify,确认这个仓库实际怎么做(这一半是有答案的,只是没写在文档里)。然后自己构造一个能复现的最小场景:写一个提供方,它的方法返回一个三秒后才 resolve 的 promise;消费方调用它并 await;调用发出后 0.5 秒卸载提供方。观察 await 那一行最终是 resolve、reject 还是永远挂着,再对照 fiber.ts 的 _unload 解释你看到的现象。做完这个实验,你对第 3 章会有完全不同的读法。
给你的 mini-harness 装一个服务容器
到上一章为止,你那个 while 循环里的所有能力都是直接写死的函数调用。这一层要把它们变成「按名字取」的服务,并且让插件能声明自己要等谁。三十行左右,纯 JS,不需要任何库。
难点在最后那个 for (const n of need) ctx[n] = ...:为什么不干脆把 services 整个摊给插件?因为那样一来,插件不声明依赖也能拿到别人的服务,inject 就退化成一句没人遵守的注释了。真 Cordis 在这一点上更狠——它用 Proxy 拦截属性读取,没 inject 就直接抛 cannot get property ... without inject。你这三行是那套机制的低配版,但把最重要的那条纪律保住了。
另一个不显然的地方是 retry() 要在 provide 里调用,而不是只在 mount 里。当初想不到这一点的人,写出来的容器就变成了「启动时检查一次」,正好错过这一章最核心的那条性质。
做对了的样子:三个判据,缺一不可。一,把 mount(consumer) 写在 mount(provider) 前面,输出必须和反过来写逐字一致;二,只挂 consumer 不挂 provider,程序不许崩、不许打印任何东西,且 waiting.length 应该是 1;三,让一个没写 inject 的插件去访问 ctx.greeter,它应该拿到 undefined(而不是碰巧能用)。第三条不成立,说明你把 services 整个摊出去了。
本章小结
import 把「我需要什么」和「谁来满足我」写死在同一行里,而这两个决定本该由不同的人在不同时刻做出。上下文是一个装服务的容器,消费方只说名字(ctx.llm),不说实现。
inject 声明硬依赖,Cordis 保证 apply 运行时它们全都就绪;在那之前插件停在 PENDING,安静、合法、不报错。这直接推出一条反直觉的性质:加载顺序由依赖决定,调整配置文件的行序毫无作用——仓库自己在 base 配置的注释里写了这句话。
依赖不是启动时查一次,而是一直盯着:服务消失,依赖它的插件跟着卸载;服务回来,它们再加载。这条性质让「从配置里换掉任何一个实现」成为可能,也直接提出了下一章必须回答的问题——一个插件被卸载时,它注册过的东西怎么办。
另外两件要记住的小事:可选依赖用 ctx.get(name) 探测,它不会让你进 PENDING,但在提供方重载期间也会返回 undefined;服务名是一张全局平表,撞名会让后登记者进 FAILED,仓库靠加前缀的约定来缓解,没有强制机制。
第3章 副作用为什么必须可逆
这一章回答上一章留下的那个问题:一个插件被卸载了,它注册过的东西怎么办?答案听起来平淡——每一次注册都要能撤销——但整套热重载、整套「从配置里换掉任何一个实现」,全都压在这一条上。
学完这一章你应该能做到
- 描述一次不可逆注册在热重载之后具体会留下什么,并说出它为什么难以复现
- 看到一段插件代码,判断它哪些注册需要包
ctx.effect()、哪些包了反而是画蛇添足 - 说出 fiber 六种状态各自怎么进来、怎么出去,看到一个卡住的插件知道先查哪一个
- 解释为什么「按注册逆序释放」不等于「按逆序释放完」,以及要求顺序时该怎么写
- 把「热重载可行」还原成两条更基本的性质,而不是当成框架的魔法
3.1 先看看不可逆注册留下的现场
先把场景摆具体。你写了一个插件,它干三件事:注册一个叫 read_file 的工具、监听 tool/result 事件做统计、每五秒把统计结果打一条日志。代码大概是这样:
// 反面教材,别照着写
export function apply(ctx) {
toolRegistry.push(readFileTool) // 塞进全局工具表
eventBus.on('tool/result', countIt) // 挂个监听器
setInterval(() => console.log(stats), 5000) // 起个定时器
}
看起来没问题。现在你改了一行代码保存,热重载触发:旧的插件实例被卸载,新的被加载。由于这三行谁都没有撤销的办法,卸载什么也没干。于是新实例又跑了一遍 apply。现场变成这样:
工具表里现在有两条 read_file。模型看到的工具清单里出现了重复项,而且第一条指向的是已经被替换掉的旧代码——那份代码还活着,因为工具表还引用着它。tool/result 上挂了两个统计监听器,每次工具返回结果,统计数字加两次,你会以为工具被调用了两遍。定时器有两个在跑,日志每五秒出现两条,内容还不一样,因为它们各自持有各自那份 stats。
改第三次代码,就是三份。而这一切没有任何一条错误信息。
为什么这类 bug 特别难查
它只在重新加载过之后出现。你本地开着热重载,改了十几次代码,数字开始不对劲;同事在他机器上从头启动一次,一切正常。你重启,也正常。故障和你做过的操作历史绑定,而不是和代码绑定——这类 bug 复现不出来,就修不掉。
这不是我编的场景。我把仓库那份 Cordis 打包进浏览器,挂了一个插件,apply 里就一行裸的 setInterval,不做任何包装。等它跑一会儿,调用 dispose() 把插件卸载掉,然后继续观察:卸载之后的 100 毫秒里,这个定时器又触发了 5 次。插件已经不在了,它留下的东西还在跑。
上面那个插件热重载了两次(也就是加载过三次)。此刻工具表里有几条 read_file?tool/result 上有几个监听器?定时器有几个在跑?
apply 一共执行了几次,每次各留下几样东西。apply 就执行了三次,每次都往全局结构里追加一份,而卸载没有撤销任何一次。注意其中最有害的一份是工具表:它还引用着前两版的函数,所以旧代码不但活着,还在被模型调用——你以为自己在调试新代码,实际上有三分之二的调用落在了旧版本上。变式:如果这个插件在 apply 里没有注册任何东西,只是打了一行日志,热重载三次会有什么后果?(没有后果。可逆性的成本只落在有注册行为的插件上——这也是为什么这条规则值得当成硬要求。)
3.2 ctx.effect():拿资源的时候,就把钥匙交回来
Cordis 给的解法只有一个动作:凡是获取资源,都在 ctx.effect() 里做,并返回一个能释放它的函数。
副作用(effect,代码里保留原文 ctx.effect()):一段「获取资源」的代码,外加它配套的释放动作。
释放函数(disposer):effect 体返回的那个函数。Cordis 在插件卸载时替你调用它。
下面这段是仓库教程里的真代码(docs/cordis-tutorial/02-lifecycle-and-effects.zh.md):
function heartbeat(ctx: Context) {
console.log('heartbeat plugin loading')
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => {
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}
结构就两层:外面那个函数是 effect 体,加载时立刻执行;里面 return 的那个函数是释放函数,卸载时执行。教程给出的实际输出是 heartbeat plugin loading、三个 tick、heartbeat cleaned up、disposed——定时器确实停了。
打个比方
借书。你从图书馆借一本书(获取资源),同时拿到一张借书条(释放函数)。借书条不是给你的,是给系统的:闭馆时管理员按借书条把书一本本收回来,不需要你记得还。
类比失效处:借书条只能用一次,而 Cordis 的释放函数被调用两次是安全的——源码里 effect() 用一个 disposing 标志保证重复调用直接返回同一个清理任务(vendor/cordis/src/fiber.ts)。另一处更重要:图书馆的书是你主动去还的,而这里你几乎永远不需要主动调用释放函数。教程的原话是:对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。
什么东西必须包 ctx.effect()?判据很简单:凡是 Cordis 不知道的资源——定时器、网络连接、文件监听、你自己维护的一张表。Cordis 管不着它,就得你把钥匙交给它。
有人这样写:const timer = setInterval(f, 200); ctx.effect(() => () => clearInterval(timer))。也就是先起定时器,再单独用 effect 登记释放动作。这样写有没有问题?
ctx.effect() 这一行本身抛了异常(或者在它之前有别的代码抛了异常),画出定时器和释放函数各自的状态。setInterval 之后、ctx.effect() 之前有任何代码抛异常,这个定时器就永远没人认领了——而插件启动失败时 Cordis 会回滚已登记的 effect,回滚不了没登记的。把获取动作放进 effect 体内部,获取和登记就成了一个不可分割的整体,这段空窗不存在。另外还有一个诊断上的差别:effect 体内部创建的资源会挂在这条 effect 的元数据树上(getEffects() 能看到),外面创建的看不到。变式:如果 effect 体是一个 async 函数、要等一个连接建好才能返回释放函数,中途插件被卸载了会怎样?(Cordis 支持返回 promise 的 effect;源码里卸载会等这次启动结算完再执行清理——fiber.ts 的 disposeAfter 就是干这个的。这也说明为什么释放函数可以是异步的。)
3.3 哪些操作已经自带 effect(读者最常犯的错在这里)
看到这里很容易过度紧张,把每一行注册都裹一层 ctx.effect()。不用。教程写得很直接:你很少需要亲自编写 ctx.effect(),因为内置注册 API 本身已经是 effect。
| 你写的 | 卸载时自动发生什么 | 依据 |
|---|---|---|
ctx.on(事件, 监听器) | 监听器被移除 | vendor/cordis/src/events.ts 的 register() 内部就是 ctx.fiber.effect(...) |
ctx.plugin(子插件) | 子插件随父插件一同卸载 | fiber.ts 构造函数里用 parent.fiber.effect(...) 登记,标签是 ctx.plugin() |
super(ctx, '名字') / ctx.provide() | 服务被注销,依赖方被唤醒 | reflect.ts 的 provide() 内部是 fiber.effect(...) |
ctx.tools.register(工具) | 工具从注册表中移除 | packages/core/tools/src/index.ts,JSDoc 写明 returns the exact disposer that unregisters the tool |
ctx.systemPrompt.section(...) | 提示词片段被撤下 | packages/core/system-prompt/src/index.ts,JSDoc 写明 returns the exact Cordis effect disposer |
换句话说,3.1 那个反面教材里的三行,有两行在真 Cordis 里根本不需要你操心:ctx.on('tool/result', countIt) 和 ctx.tools.register(readFileTool) 天生可逆。只有 setInterval 那一行是 Cordis 不认识的资源,需要你包一层。
最常见的两个错法
错法一:重复包一层。ctx.effect(() => ctx.on('x', f)) 这样写不会报错,也不会双重释放(释放函数重复调用是安全的),但它多制造了一层没有意义的嵌套,诊断树上会多一层空壳,而且会让下一个读代码的人以为 ctx.on 需要手动清理——于是他在别处也照抄。
错法二(更有害):不知道它是 effect,于是自己另找地方清理。典型写法是把 ctx.on() 返回的东西存进一个数组,再用 process.on('exit', ...) 统一清理。这在插件卸载(而不是进程退出)时完全不生效——而卸载才是常态:配置改一行、服务提供方被换掉、热重载,都会卸载插件,进程一次都不退。
下面四行里,哪些需要包进 ctx.effect()?
①ctx.on('tool/result', f) ②fs.watch('./config', f) ③ctx.tools.register(myTool) ④myGlobalMap.set('k', v)
ctx 走的?ctx 走的它知道,绕过 ctx 的它不知道。ctx 走的注册 API,本身已经是 effect,再包一层是多余的。②是 Node 的文件监听,Cordis 完全不知情,不包就会在卸载后继续触发回调、继续持有文件句柄。④最容易被忽略——往你自己维护的全局 Map 里写一条,看起来「又没占什么资源」,但它和 3.1 里那张工具表是同一类问题:热重载之后表里会残留指向死代码的条目。正确写法是 ctx.effect(() => { myGlobalMap.set('k', v); return () => myGlobalMap.delete('k') })。变式:ctx.tools.register() 会把 disposer 返回给你。既然不用手动调,为什么还要返回?(因为有时你确实要提前撤销——比如某个工具只在特定条件下可见,条件消失时你主动调用它,不必等插件卸载。返回 disposer 让「提前释放」成为可能,而不是必需。)
3.4 Fiber:一个插件实例的六种状态
fiber(不译):一个已加载插件实例的运行时句柄。同一个插件被挂载三次,就有三个 fiber,各有各的配置、各有各的 effect 列表、各有各的状态。ctx.plugin(...) 的返回值就是它。
教程给的状态图是这样:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
| 状态 | 怎么进来 | 怎么出去 |
|---|---|---|
| PENDING | 刚创建,或 inject 里有服务不可用 | 依赖凑齐 → LOADING |
| LOADING | 依赖凑齐,正在跑 apply | apply 跑完 → ACTIVE;抛异常 → 回滚后 FAILED |
| ACTIVE | apply 顺利结束 | 依赖消失、被卸载、被 restart → UNLOADING |
| FAILED | apply 抛异常,或配置没通过校验 | 改配置(update())或依赖变化触发重试 |
| UNLOADING | 正在跑释放函数 | 跑完:依赖还在 → 回 LOADING;被彻底移除 → DISPOSED |
| DISPOSED | fiber.dispose() 完成 | 出不去了,不能重启 |
状态是算出来的,不是设出来的
读源码时最容易看漏的一点:fiber.state 不是某处 state = 'ACTIVE' 赋值出来的,而是由三个事实推出来的(vendor/cordis/src/fiber.ts 的 _getState()):句柄被清空了就是 DISPOSED,记着一个错误就是 FAILED,依赖凑齐了就是 ACTIVE,否则就是 PENDING。
「依赖凑齐了」这个判断本身也有意思。Cordis 会把当前依赖状况拼成一个字符串,源码里叫 epoch:遍历 inject 里的每个名字,把提供它的那个 fiber 的编号拼进去;只要有一个名字没人提供,整个 epoch 就退化成一个表示「不可用」的特殊值。
为什么要把编号拼进去,而不是只记一个「够不够」的布尔值?因为这样一来,提供方换了人也会被发现。上一章那个把英文 greeter 换成中文 greeter 的实验,从服务名的角度看什么都没变(还是叫 greeter,还是有人提供),但 epoch 字符串里的编号变了,于是消费方老老实实地卸载、重载了一次。这一行拼接就是上一章「依赖持续追踪」的全部实现。
失败时会先回滚,再进 FAILED
如果 apply 跑到一半抛异常,那些已经注册成功的 effect 会怎样?我实测了:让插件先 ctx.effect() 登记一个资源、再抛异常,控制台先打印释放函数里的日志,最后这个 fiber 停在 FAILED。也就是说半途失败的插件不会留下半份注册,Cordis 会把它已经做的事回滚干净再标记失败。这条性质文档没有单独讲,是从 fiber.ts 的 _reload() 行为里跑出来的。
卡住了怎么诊断
三种「没反应」要分清,处理方式完全不同:
停在 PENDING:缺服务。用上一章那段遍历 ctx.registry.values() 的诊断代码,看它 inject 了什么、谁该提供。停在 FAILED:启动时抛过异常,去日志里找那条错误;注意 Cordis 是通过 logger 服务报告的,没挂控制台导出器就看不见。停在 UNLOADING:某个释放函数没有结束——这一节的下半段就是它。
还有一个信号很好认:如果你在某处调用 ctx.effect() 或 ctx.on() 时收到 cannot create effect on inactive context,说明你拿着的这个 ctx 属于一个已经被卸载(或正在卸载)的插件。我实测过这条报错,来源是 fiber.ts 的 assertActive()。最常见的成因是把 ctx 存进了闭包,在一个晚到的回调里用它注册东西。
插件 A 的 inject 是 ['shell']。运维卸载了 shell 的提供方,又立刻挂上另一个同样叫 shell 的提供方,整个过程不到一毫秒。A 会不会重启?请用 epoch 的构造方式解释,不要凭直觉。
:7 变成了什么),再看这两个字符串相不相等。ctx.plugin() 时从注册表计数器递增取得,绝不重复。所以旧提供方是 :7、新的就是 :12,两个字符串不相等,Cordis 判定依赖变了,把 A 卸载再重新加载。速度快慢完全不影响判断——它比的是身份,不是可用性。这正是我们想要的:A 在新旧提供方之间是换了一个对象,如果不重启,A 手上还攥着旧实例的引用,而旧实例的资源已经被释放了。变式:如果运维不是卸载再挂载,而是让同一个提供方插件 restart() 一下(同一个 fiber),A 会重启吗?(会。restart() 内部先把 epoch 打成不可用再刷新,服务在这中间确实短暂消失过,依赖方会看到这次变化。)
同事说「我的插件没反应」。你只被允许问一个问题来定位方向,问什么?
变式:如果状态是 ACTIVE,但它就是不干活呢?(那问题不在生命周期,在它自己的逻辑或者它监听的事件从没被触发过——排查转向第 4 章的事件系统。)
3.5 释放顺序:逆序启动,并发推进
教程里有一句很容易被扫过去的话:disposer 会按注册顺序的逆序启动,但多个异步 disposer 会并发运行。
这句话每一个字都是精确的,而误读它的代价很实在。我把三个释放函数按 A、B、C 的顺序注册,A 的释放要 120 毫秒、B 要 60 毫秒、C 是同步的,然后卸载插件,打出每一步的时刻(毫秒):
C sync 28 ← 最后注册的,最先启动 B start 28 ← 三个都在同一时刻启动了 A start 28 B done 92 ← 60ms 的先结束 A done 160 ← 120ms 的后结束
看清楚两件事。第一,启动确实是逆序:C、B、A。第二,它们是同时启动的——B 没有等 A,A 也没有等 B。整个卸载耗时约等于最慢那个(120 毫秒),不是三个之和。
源码里就两行:vendor/cordis/src/utils.ts 的 DisposableList.clear() 把释放函数列表倒过来返回,fiber.ts 的 _unload() 拿它去做 Promise.all(...)。倒序决定了启动次序,Promise.all 决定了并发。
这个坑长什么样
你注册了两个 effect:先建数据库连接,后建一个每秒往数据库写一笔的写入器。凭「逆序释放」的直觉,你以为卸载时会先停写入器、再关连接。实际上两个释放函数同时启动:如果关连接是同步的、停写入器要等最后一笔写完,那么连接会先断,最后那笔写入落在一个已经关掉的连接上。
解法是仓库明确给的:如果拆除步骤必须按顺序执行,把它们放进同一个 disposer,在里面依次 await。同一个 effect 内部的多个释放动作是串行链起来的(fiber.ts 的 effect() 里能看到它把它们 then 成一条链),跨 effect 才是并发。
还有一条相关的硬规矩,写在 docs/defensive-patterns.zh.md 里:dispose 必须达到完全停稳,而不仅仅是请求停止。清理逻辑如果只发了个终止信号就返回,不等对方真的停下来,就会留下孤儿进程。所以异步释放函数不是特例,是常态——该等就得等。
同事坚称「Cordis 保证释放是严格逆序完成的,先注册的最后释放完」。请设计一个最小实验证伪他,并说清实验里哪一个设计选择是关键——换句话说,什么样的实验看不出差别。
变式:把这两个 effect 合并成一个(在同一个释放函数里先 await 停 B 再 await 停 A),观测结果会变成什么?(会变成严格串行:A 的完成时刻推迟到 B 结束之后。这就是仓库推荐的「要顺序就放进同一个 disposer」。)
3.6 热重载凭什么可能
热模块替换(HMR)听起来像魔法:改一行代码保存,程序不重启,新代码就生效了。教程第 6 章把它拆开了,结论平淡得让人失望——因为卸载会释放 effect,加载会遵循依赖,所以 HMR 可以先卸载、再加载。
就这两条。它们正好是第 2 章和第 3 章各自的主题:
卸载能真的卸干净(这一章):旧实例的每一项注册都随它回卷,工具表里没有残留,监听器没有留两份,定时器停了。所以新实例接手的是一张干净的桌子。
加载遵循依赖(上一章):新实例不需要有人告诉它「在谁之后启动」,它 inject 了什么,就等什么。所以重载一个插件不会打乱其他插件的加载关系。
这两条都成立时,HMR 就不是新机制,而是这两条的推论——它只是一个监视文件变化、在合适的时机调 dispose 再调 plugin 的插件(@deepseek-ai/cordis-plugin-hmr)。教程给出的实际输出是:
hello from my first plugin 2026-07-22 15:44:36 [I] hmr watching [ '.' ] 2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts hello from my EDITED plugin
dispose 会递归,而且会等
教程明确写了 fiber.dispose() 的两条承诺:它会递归卸载该插件挂载的所有子插件,并且等所有清理工作(包括异步释放函数)完成之后才结束。
我搭了一个三层结构验证:父插件挂子插件,子插件再挂孙插件,每层各注册一个会打日志的释放函数。调用父的 dispose() 并 await 之后,三条日志全部出现了,父的状态是 DISPOSED。没有一层被漏掉。
读的时候要小心:一个会让你误判的观测结果
同一个实验里,三条日志的顺序是父、子、孙——先打印的是最外层。凭 3.5 节的「逆序」直觉,你可能预期是孙、子、父。
本站的解释是:父插件的两项注册里,子插件那一项的释放是异步的(fiber.ts 里子 fiber 的释放函数被写成 async,它要等自己的内部清理),而父自己那个 effect 的释放是同步的。两者同时启动,同步的那个当场就打印完了,异步的那条链还在往下走。所以这不是「顺序反了」,而是 3.5 节那条规则的又一次体现:逆序的是启动,不是完成。
这一段是本站从实测现象加源码推断出来的,仓库文档没有描述父子插件之间释放日志的先后。仓库处于 developer preview,官方明说会有破坏兼容性的变更,请以 vendor/cordis/src/fiber.ts 为准。
团队里有个插件在热重载后行为怪异:每次改代码保存,它上报的调用次数就翻一倍。有人提议给 HMR 插件加个开关,把这个插件排除在热重载之外。请评价这个方案,并给出你认为正确的处理,以及理由。
apply,把每一行注册列出来,逐行问「它卸载的时候会被撤销吗」。大概率你会找到一行绕过 ctx 的注册。ctx 的注册(多半是直接操作了某个全局结构,或者用了 Node 原生的监听 API),改成 ctx.on() 或用 ctx.effect() 包起来,让它随插件回卷。判据也很干脆:连续热重载三次,计数只能是一份,不是三份。变式:如果查下来发现漏掉的那一项确实无法撤销(比如它给某个第三方库打了个全局补丁,那个库不提供卸载接口),你会怎么办?(诚实的做法是把这件事做成幂等的——重复安装不叠加,并在插件文档里写明它不支持热重载;而不是假装没事。这类「注册不可逆」的外部依赖,是 Cordis 这套模型在现实中最主要的破口。)
答辩:如果我是审稿人
你把可逆性说得像地基,可我看下来,它最主要的受益者就是热重载——一个开发期的便利功能。为了让开发时保存文件能少按一次重启,让每一个插件作者都必须在写每一行注册时想着怎么撤销它,这个成本收益比说得通吗?
参考防守(先自己组织语言再看)
热重载是这条性质最显眼的消费者,但不是主要的。卸载在生产环境里是常态动作,至少四种情况会触发:配置改一行(loader 按 id 比对,只卸载发生变化的那几项)、某个服务提供方被替换(上一章的依赖持续追踪,依赖方全体卸载重载)、一个子 agent 干完活收摊、用户在界面上关掉某个功能。这些都跟开发期无关。可逆性在这些场景里不是便利,是正确性的前提。
成本那一半也可以压得很低,这是审稿人容易高估的地方:3.3 节那张表说明,日常写插件时绝大多数注册(事件监听、工具注册、提示词片段、服务登记、子插件)本来就是 effect,插件作者一个字都不用多写。真正需要手写 ctx.effect() 的只有框架不认识的资源——定时器、连接、文件监听。一个典型插件里这样的行数是零到两行。
反过来看不做这件事的代价:3.1 节那种叠加型 bug 的特征是「只在重新加载过之后出现」,也就是它和操作历史绑定而不是和代码绑定。这类 bug 复现不出来,就修不掉,只能靠重启掩盖。把它设计成不可能发生,比事后去查便宜得多。
最后承认一处真实成本:这套设计确实把「资源生命周期」变成了插件作者必须理解的概念,零基础的人写第一个插件时会被 ctx.effect() 那个双层函数结构绊一下。这是学习曲线上真实的一格台阶,不是错觉。
真未解一个永远不结束的释放函数,该由谁兜底?
vendor/cordis/src/fiber.ts 的 _unload() 用 Promise.all(...) 等所有释放函数完成,全文没有任何超时——我把这个文件通读过一遍,754 行里一个定时器都没有。这意味着:只要有一个释放函数永远不 resolve(等一个再也不会回来的子进程、等一个已经断掉的连接回 ACK),这个 fiber 就会永远停在 UNLOADING,fiber.dispose() 的 promise 永远不 settle,而所有等着这次卸载完成的东西——热重载、配置更新、依赖它的插件重启——全都跟着卡住。
仓库在 docs/defensive-patterns.zh.md 里要求「dispose 必须达到完全停稳,而不仅仅是请求停止」,这是对插件作者的要求;但它没有回答:当插件做不到时,框架该怎么办。超时之后强行往下走,会留下一个只清理了一半的现场;无限等待,会把整棵树拖死。两种做法在系统设计里都有严肃的支持者,也都有著名的翻车案例,没有公认答案。
怎么入手:先在 fiber.ts 里确认 _unload() 和 effect() 里确实没有任何超时(搜 setTimeout,你会发现一个都没有)。然后自己构造:写一个插件,它的释放函数返回一个永不 resolve 的 promise,挂上去再 await fiber.dispose(),确认它真的挂住;再挂一个依赖它服务的插件,观察这个插件的状态卡在哪里。做完之后去比较别的系统怎么处理同一个问题——Kubernetes 的 terminationGracePeriodSeconds、systemd 的 TimeoutStopSec,看它们各自选了什么、代价是什么。这个对比会让你对「该不该超时」形成自己的判断。
给服务容器加上 effect 与 dispose,然后亲手造一次泄漏
上一层你写的容器只会挂载,不会卸载。这一层把卸载补上——加起来不到二十行——然后你要用它先制造一次泄漏,再修好它。不亲手造一次,这一章等于没读。
难点在于想明白 on 为什么要自己往 disposers 里塞一条,而不是让插件作者去写 ctx.effect(() => ctx.on(...))。答案就是 3.3 节那张表:把可逆性做进注册 API 本身,插件作者才不会漏——如果需要每个人都记得包一层,那就一定有人不记得,而且不记得的那次不会报错。
还有一处值得停一下:disposers.reverse() 是原地反转,会改掉数组本身,所以后面那行 disposers.length = 0 才是安全的收尾。真 Cordis 在这里做的是「取出全部并清空,再反转」(utils.ts 的 DisposableList.clear()),比原地改稳妥——你可以顺手改成那种写法。
做对了的样子:分两步验,两步都要跑。第一步,造泄漏:写一个插件 { apply(ctx) { setInterval(() => console.log('tick'), 200) } }(故意不包 effect),挂上,等 1 秒,调用 dispose(),然后再等 3 秒。你必须亲眼看到 dispose 之后控制台还在稳定地打 tick——这就是 3.1 节那个现场,你自己造出来了。第二步,修好:改成 ctx.effect(() => { const t = setInterval(...); return () => clearInterval(t) }),重跑。判据是硬的:dispose() 之后 3 秒内 tick 的条数必须正好是 0,不是「变少了」「基本没了」。如果还有一条,就是你的 disposers 里漏了这一项。
本章小结
不可逆的注册在热重载之后不是被替换,而是被叠加:工具表里多一条指向死代码的记录,事件上多一个监听器,多一个定时器在跑,而且没有任何报错。这类 bug 和操作历史绑定、不和代码绑定,所以复现不出来也就修不掉。
解法是一个动作:获取资源的代码放进 ctx.effect(),返回一个能释放它的函数,生命周期就此和插件绑定。但大多数时候你连这个都不用写——ctx.on()、ctx.plugin()、服务登记、ctx.tools.register()、ctx.systemPrompt.section() 本身就是 effect,重复包一层是多余,而绕过 ctx 自己找地方清理是有害。
每个插件实例有一个 fiber,状态在 PENDING、LOADING、ACTIVE、FAILED、UNLOADING、DISPOSED 之间转移,而且状态是算出来的:句柄清空即 DISPOSED,记着错误即 FAILED,依赖凑齐即 ACTIVE,否则 PENDING。判断「依赖凑齐」用的是把提供方 fiber 编号拼成的 epoch 字符串——所以换了提供方也会被发现,这正是上一章依赖追踪的实现。
释放函数逆序启动,但并发推进:跨 effect 没有完成顺序,要顺序就把步骤放进同一个释放函数里依次 await。fiber.dispose() 递归卸载子插件并等到全部清理完成——但框架不设超时,一个永不结束的释放函数会把整棵树卡在 UNLOADING。
最后一条,也是这一章真正想让你记住的:热重载不是魔法,它是「卸载能卸干净」加「加载遵循依赖」的推论。哪个插件破坏了前一条,热重载就在那儿静默失效。
第4章 事件:五种分发模式与洋葱
这一章回答一个看起来很蠢、其实很要命的问题:上一章已经能让插件互相直接调用了,为什么还要再搞一套事件?答完这个,再把 Cordis 的五种分发方式拆开,最后停在 waterfall(瀑布式事件)——整个 harness(智能体运行框架)里最难懂、也最值钱的那一个。
学完这一章你应该能做到
- 给一个具体场景,判断该用服务的方法调用,还是该发一个事件
- 说出
emit/parallel/serial/bail/waterfall各自等不等、收不收返回值、能不能互相打断 - 手画洋葱模型,说清「不调用
next()」为什么等于否决 - 看到一个只做日志的 waterfall 监听器,一眼判断它踩没踩那条静默的坑
- 给自己的 mini-harness 写出一个二十几行的 waterfall 分发器
4.1 有了服务能直接调用,为什么还需要事件
上一章的结论是:一个服务占据上下文里一个固定的名字,比如 ctx.tools、ctx.llm。谁要用工具注册表,就写 inject: ['tools'],等它就绪,直接 ctx.tools.register(...)。既然这么好用,为什么还要事件?
试着不用事件,看会怎样。
场景:一次工具调用刚跑完,结果拿到了。谁需要知道这件事?在 DeepSeek Harness 里答案不止一个,而且这份名单不归工具包管:会话日志要把它记成持久事实,遥测要计一次数,对接 Claude Code 和 Codex 的钩子插件要转发给外部脚本,子 agent(智能体)驱动要看看是不是自己派出去的孩子回话了。
不用事件,工具包就得自己去调这些人。要调就得先 import 进来;可这些包反过来也依赖工具包(它们要用 ctx.tools 的类型)——互相 import,就是循环依赖。更糟的是这份名单每加一个插件就要改一次工具包的源码,而这正是整个 harness 想避免的事。docs/architecture.zh.md 第 13 行那句话说得很硬:不存在需要打补丁的特权内核,扩展的方式是把插件挂到别的插件旁边。
事件(event):一个带名字的通知。发出方喊一声名字和几个参数,框架负责找出当前所有登记了这个名字的监听器并挨个交过去。发出方不知道有谁在听,也不需要知道有没有人在听。
教程里有句话把这层意思讲得很干净。docs/cordis-tutorial/07-into-the-harness.zh.md 第 94 行说的是一个工具插件和一个日志插件跑在同一棵树上:两个插件都不知道对方存在,它们是被注册表服务和事件连起来的。这就是事件的全部意义——把「谁在听」这个决定从代码里搬到配置里。在 cordis.yml 里多列一行就多一个监听方,删掉那行监听方就消失,被监听的包一个字都不用改。
那什么时候还是该用服务
反过来的场景:agent loop(智能体循环)要发一次模型请求。它必须拿到结果才能往下走,而且非常清楚要找谁——ctx.llm。这时候用事件是自找麻烦:你得额外约定「谁来回答」「没人回答怎么办」「两个人都回答听谁的」,而这三个问题服务调用根本不会有。
docs/cordis-primer.zh.md 第 48 行把它写成了实践规则:拦截和策略优先使用事件;直接能力调用优先使用服务方法。
打个比方
服务像打电话:你拨号,你知道对面是谁,你等他回答。事件像在办公室里喊一嗓子「快递到了」:谁在意谁抬头,不用点名,不用等——甚至可能一个人都没听见,而这在事件里是完全合法的状态。
类比失效处:喊话是无序的,Cordis 的事件却有严格顺序,监听器按注册顺序排队;而且有几种模式里,前面的人回一句话就能让后面的人根本听不到。这一点喊话没有对应物,下一节就是讲它。
你在写一个插件:每次会话被创建时,往磁盘上写一个空的日志文件。会话服务本身完全不需要知道有这么个插件存在。应该让会话服务直接调用你的写文件函数,还是让它发一个事件、由你监听?为什么?
vendor/cordis/src/events.ts 第 254–260 行的 register 把监听器登记成 fiber 的 effect),而「直接调用」这种耦合没法自动撤销。仓库就是这么做的:session/created 是 emit 事件,docs/event-producer-consumer.zh.md 第 43 行列出它有十几个监听方。变式:改成「会话创建前先检查磁盘剩余空间,不够就不许创建」。现在还该用事件吗?如果还用,哪一种模式才能让你的否决真的生效?
4.2 五种分发模式,一个一个来
「分发」就是「把一次通知交给所有监听器」的那个动作。听起来只有一种做法,其实有五种,区别在三件事:等不等(监听器是异步的话,发出方要不要等它跑完)、收不收返回值、能不能互相打断。五种在 vendor/cordis/src/events.ts 第 32 行写成了一个类型:
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
一个事件用哪种模式不是调用方临时决定的,而是这个事件约定的一部分:声明时就定死,之后只能用对应方法分发。仓库在每个事件的注释里写一个 @mode 标签记录它(packages/core/agent/src/runtime-types.ts 里有一堆 @mode waterfall)。
emit:喊一嗓子就走
ctx.emit('stats/report', name, count)
同步广播,按注册顺序挨个调,不等、不收返回值。源码就三行(第 194–196 行)。有个细节值得记住:那三行里没有 try/catch。所以第一个监听器要是同步抛了异常,后面的一个都不会跑,异常还会冲回发出方;而 async 监听器里抛的异常因为没人 await,会变成一个没人处理的 promise 拒绝静静飘走。这两条都是从源码直接读出来的,文档没有专门提醒。
parallel:一起跑,一起等
await ctx.parallel('session/flush')
所有监听器同时开跑,一起等到全部结束。用的是 Promise.allSettled 而非 all——有人失败也要等所有人停下,最后把失败打包成 AggregateError 抛出(第 183–187 行)。用它的时机是「你要的是所有人都收工了这个时刻」。仓库 56 个事件里只有一个 parallel:session/flush,监听方是持久化和遥测(docs/event-producer-consumer.zh.md 第 46 行)。语义很贴切——把没落盘的都写下去,我等着。换成 emit,进程可能在落盘前就退出了。
serial:排队跑,谁先给出答案谁赢
const result = await ctx.serial('agent/turn-stopping', payload)
按注册顺序一个个跑,每个都 await。一旦某个监听器返回「有效值」,立刻停下,把这个值当作整次分发的结果,后面的不再执行。什么叫有效值?第 13–15 行给了确切定义:
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
只有 null、false、undefined 算「我没意见,下一位」。其他一切都算有意见——包括 0,包括空字符串。这两个在 JavaScript 里是假值,凭直觉很容易以为会被跳过,实际上它们会停住整条链。
bail:serial 的同步版
const result = ctx.bail('slash/input-begin-command', request)
规则和 serial 一模一样,唯一区别是不 await、全程同步(第 217–222 行)。只适合监听器都是同步函数的场合,好处是能在同步代码里直接拿到结果,不用把整条调用链染成 async。
waterfall:环绕中间件
const config = await ctx.waterfall('agent/request', payload, async () => defaultConfig)
这一个和前四个都不同:调用形式里多了一样东西,最后一个参数是「本来该干的事」。监听器不是被叫到说句话就完,而是套在这件事外面,一层层往里进、再一层层往外出。下一节整节讲它。
| 模式 | 怎么调 | 等不等 | 有返回值吗 | 典型用途 |
|---|---|---|---|---|
emit | ctx.emit(名字, ...) | 不等 | 没有 | 广播既成事实:这件事发生了 |
parallel | await ctx.parallel(名字, ...) | 等,全部并发 | 没有 | 需要一个「大家都收工了」的时刻 |
serial | await ctx.serial(名字, ...) | 等,按序 | 第一个有效值 | 问一圈:有没有人反对/谁能答 |
bail | ctx.bail(名字, ...) | 不等(全同步) | 第一个有效值 | 同上,但整条链都是同步代码 |
waterfall | ctx.waterfall(名字, ..., next) | 看链条自己 | 最外层监听器的返回值 | 包装、改写、否决一个决策 |
把仓库自己的用量摊开看,这五种远不是平均使用的。docs/event-producer-consumer.zh.md 那张矩阵列了 56 个 harness 自有事件,分布是 41 个 emit、13 个 waterfall、1 个 serial、1 个 parallel。四分之三的事件只是在广播事实,剩下的几乎全是 waterfall——正好对应上一节那条判据:事件要么用来告知,要么用来拦截。
三个需求各该选哪种模式?
(甲)进程要退出了,通知所有插件把缓存写进磁盘,写完才真的退出。
(乙)模型刚吐出一个字符块,通知界面刷新一下。
(丙)问一圈:有没有哪个策略插件觉得这次轮次不该结束?
parallel:要等,且各插件互不相干,并发最省时间,仓库的 session/flush 正是这个用法。乙用 emit:广播既成事实,等它没有意义,界面卡住还会拖慢模型输出。丙用 serial:需要一个答案,第一个给出有效值的胜出即可,且监听器可能要做异步检查(比如问一次外部钩子);仓库的 agent/turn-stopping 是全仓唯一的 serial 事件。丙若确定所有监听器都同步,也可以用 bail。变式:把(丙)改成「让每个策略插件都有机会修改这次轮次的结束条件,而不只是投反对票」。serial 还够用吗?
4.3 少了一种:本站在核对源码时发现的文档不一致
写这一节时我们做了件很笨的事:把仓库里所有讲分发模式的地方摊开逐字对。结果对出一处不一致,而且不是翻译问题——中英两份一样。
本站的核对结果,不是仓库承认的问题
三个地方各说各的:
vendor/cordis/src/events.ts第 32 行的DispatchMode类型,5 种:'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall';第 217–222 行有bail()的完整实现。docs/cordis-tutorial/04-events.zh.md第 84–90 行的表格,5 种,第 89 行明确写着bail是「serial 的同步版本」;开头第 82 行也写着「emit是 5 种分发模式之一」。docs/cordis-primer.zh.md第 21–26 行的表格,只有 4 种:emit / waterfall / parallel / serial,没有 bail。而这一节开头第 19 行还写着「每个事件具有以下分发模式之一,且只能通过对应方法分发」,读起来就像在说只有这四种。英文原文docs/cordis-primer.md第 19–24 行完全一样,所以不是中文版漏译。
这是本站在核对源码时发现的文档内部不一致,不是仓库已经承认的缺陷,也没有对应的 issue。写在这里是因为它有实际影响:入门文档是大多数人唯一会读完的那份,而它给出的是一张缺了一格的表。请你自己打开这三个文件对一遍再相信本节。
更有意思的是「为什么会漏」。我们把那 56 个事件的模式数了一遍:41 emit、13 waterfall、1 serial、1 parallel,bail 一个都没有。如果一个人靠读 harness 的事件目录来总结分发模式,他会自然得到四种——第五种在那份目录里根本不出现。这大概就是那张表少一格的来源。
但必须补一句,否则本站自己就成了新的不准确来源:bail 在这个仓库里确实有人用,只是不在那份矩阵覆盖的范围内。查到三处:Cordis 自己就用(events.ts 第 296 行注册监听器时先 this.bail(this.ctx, 'internal/listener', ...) 问一圈有没有人要接管,第 348–349 行的注释直接标着这是 bail 事件);浏览器那一侧用得更多(packages/client/ui-input-trigger/src/types.ts 第 231、238、245、253 行声明了四个 slash/input-* 事件,注释都写着 @mode bail,分发点在同包 src/client/controller.ts 第 322–327 行和 packages/client/ui-commands/src/client/service.ts 第 210、428 行);还有测试里的 test/scope-probe。
那份矩阵在文件末尾自述是「生成内容」,由仓库的 TypeScript Program 解析,覆盖的是 harness 自有事件;浏览器端插件跑在另一个上下文里,用的是 actx.bail(...) 这种带 this 参数的调用形式,没有进表。所以准确说法是:那 56 个 harness 事件里没有一个用 bail,但 bail 在这个仓库里是活的。
顺手核出来的第二处,同样是本站结论
读 events.ts 时还撞见一件事。第 165 行的 dispatch(type, args) 会把 type(这次是什么模式)广播给 internal/dispatch(第 169 行),仓库里有 22 个包在听这个诊断事件(docs/event-producer-consumer.zh.md 第 73 行)。但 parallel() 在第 184 行传进去的是 'emit',不是 'parallel':
async parallel(...args: any[]) {
const results = await Promise.allSettled(this.dispatch('emit', args).map(...))
}
而 serial(第 205 行)、bail(第 218 行)、waterfall(第 235 行)传的都是自己的名字。结论:监听 internal/dispatch 的诊断插件永远看不到 'parallel' 这个取值,parallel 在它眼里长得和 emit 一模一样。这同样是本站读源码得到的观察,不是仓库承认的 bug——全仓只有一个 parallel 事件,实践中几乎不会被注意到。你可以自己去这几行对一遍。
有一个 serial 事件,用来问「有没有人知道这次请求该重试几次」。三个监听器按注册顺序是:A 返回 undefined(不知道),B 返回 0(我知道,答案是零次),C 返回 3。await ctx.serial(...) 最终拿到什么?C 跑了吗?换成返回空字符串的监听器呢?
isBailed 的定义,它比的是什么。isBailed 用的是三个全等比较,不是 if (value)。这两种写法在 0 和空字符串上结论相反。0 代进 value !== null && value !== false && value !== undefined,三个条件逐个算。0,C 没跑。0 不等于 null、不等于 false、不等于 undefined,三个条件全部成立,isBailed(0) 返回 true,serial 立刻停下并把 0 当成整次分发结果(第 13–15 行与第 204–209 行)。空字符串同理。这很反直觉:绝大多数人会写 if (result) return result,那样 0 会被当作弃权继续往下问。所以给 serial/bail 事件设计返回类型时,不要用 0 或空字符串表示「无意见」,要用 undefined。变式:如果一个监听器返回 NaN,链条会停吗?再想一步:返回一个空数组呢?
4.4 waterfall:洋葱是怎么回事
前四种模式里,监听器都是「被叫到、说句话、坐下」。waterfall 不是——它的监听器把后面所有人都套在自己里面。
先看形状。一个 waterfall 监听器收到的参数是 (...args, next),最后多一个 next。它可以做三件事:
- 调用
next():往里走,让后面的监听器(以及最里面那个默认逻辑)跑起来。next()的返回值就是「里面那层给出的结果」。 - 拿到那个结果,包一层再返回出去:加个前缀、改个字段、记一笔。
- 不调用
next(),直接返回自己的结果。后面所有监听器和最里面的默认逻辑一个都不会跑。Cordis 文档把这个行为叫否决(docs/cordis-tutorial/04-events.zh.md第 96 行)。
vendor/cordis/src/events.ts 第 234–243 行的 waterfall() 实现与 docs/cordis-primer.zh.md 第 32–38 行的「Cordis Waterfall 语义」绘制。教程里有个能直接跑的例子(docs/cordis-tutorial/04-events.zh.md 第 98–126 行),关键是这两个监听器:
// 监听器 1:把下游的结果包一层
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// 监听器 2:轮到我做主时,直接短路
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
分发两次,输出是 HELLO 和 ** BLOCKED **。第二行怎么来的?监听器 1 先跑并调 next(),监听器 2 跑起来;它看见输入里有 blocked,直接返回、没有调 next()——最内层那个默认逻辑从头到尾没执行过;返回值往外走回到监听器 1 手里,它老老实实转成大写。于是 ** blocked ** 变成 ** BLOCKED **。
这里有件很重要的事:监听器 1 完全不知道自己包住的是「默认逻辑」还是「监听器 2 的否决」。它拿到的只是一个字符串。这正是中间件模型的力量——每层只跟相邻的内外两侧打交道,谁也不需要知道整条链有多长。
实现只有十行,但有个反直觉的地方
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args) // 这次要跑的监听器队列
const inner = args.pop() // 最后一个参数是最内层默认逻辑
const next = () => {
const cb = cbs.shift() ?? inner // 队列空了才落到默认逻辑
return cb(...args)
}
args.push(next) // 参数末尾换成 next 本身
return next()
}
反直觉的地方是:next 只有一个。它不是为每层各造一个,而是一个共享闭包,每被调用一次就从队列头 shift 掉一个监听器。后果值得现在就记下:在同一个监听器里调两次 next(),第二次跑的不是同一个下游,而是再下一个监听器。队列是一次性的,走过就没了。
三个监听器按注册顺序是 A、B、C,最内层默认逻辑叫 D。A 写的是 const r = await next(); return r + '-A';B 写的是 const r = await next(); return r + '-B';C 直接 return 'C'(不调 next());D 返回 'D'。await ctx.waterfall(...) 的结果是什么?D 跑了吗?
'C-B-A',D 没跑。往里:A 起 B,B 起 C,C 停住;D 是队列耗尽后才会落到的那层,而 C 从来没调 next(),队列根本没走到底。往外:C 返回 'C' 给 B,B 拼成 'C-B' 给 A,A 拼成 'C-B-A'。注意后缀顺序是倒着的——先注册的在最外层,返回时最后一个动手。变式:把 C 改成 const r = await next(); const r2 = await next(); return r + r2(连调两次 next)。现在结果是什么?D 会跑几次?
4.5 一条必须背下来的纪律:只观察也要调 next()
next()。忘了调,就等于你替所有下游做了否决——而且不报错。请把「静默」两个字圈起来,这是这条坑最恶劣的地方。假设你写了个日志插件:
// 错的。它看起来只是打了行日志。
ctx.on('tools/pre-execute', async (exec, next) => {
console.log('[log] 准备执行', exec.name)
})
没有语法错误,类型检查也过得去,运行起来日志照打。但从它被挂上那一刻起,所有工具都不会真的执行了——它没调 next(),整条链在这儿断了,最内层那个「真的去跑工具」的默认逻辑再没被叫起来。没有异常,没有警告,日志里一切正常,你只会看到 agent 变得很奇怪。正确写法就是把 next() 的结果交出去,多一行 return next() 而已。
这不是本站编的规矩。它同时写在三个地方:教程第 138 行(「如果日志监听器忘记调用 next(),会悄无声息地吞掉所有下游的默认行为」)、docs/cordis-primer.zh.md 第 38 行(「仅做标注或观察的监听器则必须委托」)、以及仓库根目录 AGENTS.md 约定清单里那条全大写的 Waterfall listeners MUST call next()。一条规则被写三遍,通常说明它被踩过。
常见误解
很多人以为「返回 undefined 就等于什么都没做,框架会自动往下走」。不是。在 emit/serial/bail 里返回 undefined 确实等于弃权,但在 waterfall 里返回本身就是终止,整条链的结果就是你返回的那个 undefined。waterfall 判断你是否委托,看的不是返回值,而是你有没有调用 next()。两套判据长得很像,混起来就出事。
反过来说,短路在 waterfall 里是设计意图而非滥用。docs/cordis-primer.zh.md 第 38 行的原话是:对于单决策事件,短路是设计意图;策略监听器在拥有决策权时可以不调用 next() 直接返回。仓库里两个典型例子:approval/request(审批请求,packages/interaction/user-approval/src/index.ts 第 30 行)——策略插件能自己拍板就直接返回决定,用户根本不会被弹窗打扰;agent/request-error(packages/core/agent/src/runtime-types.ts 第 260 行)——重试插件决定接管就返回 { kind: 'retry' } 而不调 next(),否则委托给别人。
所以真正的判据只有一句:你是这次决策的主人吗?是——可以短路;不是(你只是路过看一眼)——必须委托。
下面这个监听器挂在一个 waterfall 事件上,作者本意是「统计每次请求耗时」。指出它的问题,并说明症状会以什么形式出现在使用者面前。
ctx.on('agent/request', async (payload, next) => {
const t0 = Date.now()
const config = await next()
console.log('耗时', Date.now() - t0)
})
next(),链条是通的。那还能出什么问题?盯着最后一行看。config 被赋了值,然后……没有然后。函数走到底,隐式返回 undefined。next(),下游确实跑了(所以耗时统计看起来是对的),但它自己返回 undefined,而 waterfall 的整次结果就是最外层监听器的返回值(第 243 行 return next())。于是 agent/request 拿回来的模型调用配置成了 undefined。症状不是「没执行」,而是「执行了、结果被吃掉了」——调用方在使用这个配置时才炸,堆栈里完全看不到这个日志插件。修法是把最后一行改成 return config。这比忘记调 next() 更难查,因为链条是通的、日志是对的。变式:如果这个事件的返回类型本来就允许 undefined(比如 agent/request-error 的默认就是 undefined),同样的代码还会炸吗?会变成什么样的故障?
答辩:如果我是审稿人
你们把「忘了调 next() 会静默吞掉下游」写进了约定清单、写进了入门文档、写进了教程——写了三遍。一条要靠反复提醒才守得住的规则,通常说明它不该由人来守。为什么不让框架兜底:监听器返回后如果发现它没调过 next(),就自动替它调一次?观察型监听器永远不会出错,真想否决的人显式返回一个特殊值就好。
参考防守(先自己组织语言再看)
这个提议在观察型监听器那一侧确实更安全,但它把成本转嫁到了另一侧,而那一侧更重要。
第一,自动兜底会让否决无法表达 undefined。agent/request-error 的约定是「默认 undefined 表示这次失败是终局」。如果框架看见「没调 next 且返回 undefined」就自动往下走,这个语义就没法写了——你必须再发明一个哨兵值,而哨兵值是另一类静默错误的温床。
第二,自动兜底会让短路的时机不可控。现在的语义是「返回即终止」,时刻确定;自动补调意味着框架要在监听器返回之后再执行下游,那么监听器里的 try/finally、计时、基于 AbortSignal 的取消,全都会跑在下游之前而不是包住下游。中间件的价值恰恰在于「包住」。
第三,最实际的一条:Cordis 的 next 是一个消费队列的共享闭包(events.ts 第 236–241 行),框架并不跟踪「这一层调过没有」,要兜底就得给每层加状态和判定。为一个能靠代码审查和类型约定拦住的问题去换整个分发器的复杂度,不划算。
能承认的部分:仓库确实没有提供任何静态检查来拦这个错误——没有 lint 规则,返回类型允许 undefined 的事件尤其危险。「写三遍文档」是当前的全部防线。这是真实的薄弱点,只是防守方向应该是加检查,而不是改语义。
4.6 prepend:谁排在最外层
监听器的顺序就是注册顺序——先 ctx.on 的先跑,在 waterfall 里就是更靠外那层。但注册顺序你其实控制不了:插件的挂载顺序是由服务依赖解析出来的(第3章讲过),不是 cordis.yml 里的行序。所以偶尔需要一个开关:不管别人什么时候注册,我要排在最前面。
ctx.on('some/event', myListener, { prepend: true })
ctx.on('some/event', myListener, true) // 布尔值是 prepend 的简写
实现很朴素(第 254–260 行):prepend 为真就 unshift 进数组头部,否则 push 到尾部。EventOptions 一共只有两个字段(第 112–117 行):prepend,以及 global(无视上下文过滤照收不误,给框架内部用)。docs/cordis-primer.zh.md 第 36 行给的口径很克制:仅当监听器必须在普通注册之前运行时才使用。Cordis 自己用它的地方是 internal/update(第 148–155 行)。
这一段是本站的推断
prepend 只是一个布尔值,没有优先级数字。当两个插件都觉得自己必须在最外层时,框架无法仲裁——谁先注册谁在前,而注册顺序又取决于服务依赖的解析结果。本站由此推断:prepend 只能可靠地服务于「全局唯一的那一个」监听器,一旦出现第二个 prepend,顺序就重新变成偶然的。仓库没有讨论过这种冲突,也没提供检测手段,这个结论是本站从「只有布尔、没有优先级」外推出来的,请当作分析而不是仓库结论。
真未解互不相识的中间件,谁该排在外面?
把上面那个推断放大就是一个真问题。仓库里 agent/pre-step 这一个 waterfall 事件上挂了 13 个监听方(docs/event-producer-consumer.zh.md 第 20 行,从压缩策略、计划模式、时间上下文到两套外部钩子桥都在里面)。它们彼此不知道对方存在,却共享一条链,而顺序会影响结果——先压缩再注入提醒,和先注入提醒再压缩,喂给模型的东西不一样。
Cordis 提供的全部排序手段就是一个布尔 prepend。这不是 Cordis 特有的窘境:中间件排序在整个软件工程里都没有公认答案,优先级数字会退化成军备竞赛(大家都写 999),拓扑声明要求中间件互相知道名字(那就回到了耦合),显式全局顺序表则把「谁在听」重新写死回配置里。
怎么入手:先去 docs/event-producer-consumer.zh.md 把 agent/pre-step 的 13 个监听方列出来,逐个到对应的包里找 ctx.on('agent/pre-step',看有没有人用了 prepend。然后挑两个明显会互相影响的(比如 compaction-basic 和 agent-instructions),设想把顺序对调,推演一次请求的差别。最后回答:如果你要给 Cordis 加一个排序机制,你会加什么?它在「新插件不改老插件」这条底线下还成立吗?
自己构造一个例子:两个 waterfall 监听器 P 和 Q 挂在同一个事件上,要求 P 在前时整条链的结果是 X,Q 在前时结果是 Y,且 X 与 Y 不同。写出 P 和 Q 的伪代码,并说明哪一类监听器组合不会受顺序影响。
return (await next()) + '!',Q 写成 return (await next()).toUpperCase(),最内层返回 'ok'。手算两种顺序各得到什么。'ok' 转成 'OK',P 再加感叹号,得 'OK!'。Q 在外:P 先得 'ok!',Q 转大写得 'OK!'——糟糕,这个例子恰好可交换,因为感叹号没有大小写。把 P 改成加前缀 'a':P 在外得 'aOK',Q 在外得 'AOK',两者不同。更极端的构造是让 Q 短路(不调 next()):Q 在外时 P 根本不会跑,此时不只结果不同,连副作用都不同。不受顺序影响的组合:所有监听器都只观察不修改(原样 return next()),或者它们的修改作用在互不相交的字段上——这也正是 docs/cordis-primer.zh.md 第 36 行说的「协作式监听器通常修改一个共享的请求对象,然后委托」在实践中大体安全的原因。变式:两个监听器都只读不写,但其中一个会在读到某个值时抛异常。顺序还重要吗?
4.7 让事件有类型:declare module 到底在干什么
类型(type):对「这个位置可以放什么」的一句声明,比如「这个参数必须是字符串」。JavaScript 本身不管这些,你把数字塞进要字符串的地方它照跑,直到某一行崩掉。TypeScript 是 JavaScript 加上类型,它在你写代码的时候就检查这些声明——写错了编辑器当场标红,不用等到运行。
为什么值得为它多写几行?因为事件名就是一个字符串。ctx.emit('stats/report', ...) 里的 'stats/report',对 JavaScript 来说和拼错的 'stats/reprot' 没有任何区别——后者不会报错,只会永远没人响应。参数写反顺序、少传一个、多传一个,同样不报错。事件是整个 harness 里最容易悄悄写错的地方,因为它天生没有调用点检查:发出方和监听方根本不认识彼此。
Cordis 的解法叫声明合并(declaration merging):你在自己的文件里往框架的类型里加一条。
declare module '@deepseek-ai/cordis' {
interface Events {
'stats/report'(name: string, count: number): void
}
}
这段代码在运行时什么都不做,编译之后整个消失。它只对 TypeScript 说一句话:事件表里还有一个叫 stats/report 的事件,监听器长这样。加上之后 ctx.emit 和 ctx.on 就都有了完整类型——名字打错标红,参数类型不对标红,监听器少写一个参数也标红。它和第3章的 interface Context 合并是一对(教程第 44 行):一个往上下文里加服务名,一个往事件表里加事件名,用的是同一个 TypeScript 机制。
还有个容易忽略的小动作。要让另一个文件也享受到这些类型,需要写一行看起来很怪的导入:import type {} from './stats.ts'。大括号是空的,什么都没导入;它在运行时同样什么都不做,唯一作用是让 TypeScript 去看一眼那个文件,从而发现里面的 declare module(教程第 65 行专门解释了这行)。包与包之间也一样:教程第7章用 import type {} from '@deepseek-ai/dsh-tools' 让 'tools/result' 有类型。
最后一个约定:事件名统一用「命名空间/动作」的形式,比如 tools/pre-execute、agent/pre-step、session/created。事件名是一个扁平的全局命名空间,加个前缀能让它读起来还像有结构。
同事说:「declare module 那几行会让程序变慢,因为每次 emit 都要查一遍事件表。」这句话对吗?如果不对,错在哪一步?
ctx.emit 运行时到底做了什么——去第 194 行看,它只是从 _hooks 这个普通对象里按名字取一个数组。declare module 是纯类型声明,编译后完全消失,运行时不存在「事件表」这种东西可查。运行时真正发生的是 this._hooks[name]——一次普通的对象属性查找,跟有没有写类型声明毫无关系。类型的全部收益都在写代码那一刻:拼错事件名、参数给错,编辑器当场标红。代价是几行编译后消失的代码,运行时开销是零。变式:如果一个团队完全不用 TypeScript,纯 JavaScript 写 Cordis 插件,会失去什么?有别的办法把这部分安全性补回来吗?
你要写一个诊断插件,统计「每一种分发模式各被用了多少次」,打算监听 internal/dispatch(它的第一个参数就是模式名)。请回答:(甲)统计里会出现哪几种模式?(乙)为什么会少一种,少的是哪一种?(丙)这个插件被卸载时,需要手动注销监听器吗?(丁)如果把统计结果拿去跟 docs/cordis-primer.zh.md 的分发模式表对照,会得出什么错误结论?
dispatch() 的第一个参数,有一个和方法名对不上。(丙)想想 ctx.on 属于什么。this.dispatch('...') 抄成一列,和方法名并排看。'emit'、'serial'、'bail'、'waterfall' 四种。(乙)少的是 'parallel':parallel() 在第 184 行传给 dispatch 的是 'emit',并发分发在诊断事件里伪装成了广播。另外第 168 行还有一道过滤——名字以 internal/ 开头的事件不会广播 internal/dispatch,所以框架内部事件也统计不到。(丙)不需要。ctx.on 是副作用,监听器登记在当前 fiber 上(第 254–260 行调用 this.ctx.fiber.effect),插件卸载时自动注销,这正是第2章那条「注册是可逆的副作用」。(丁)你会得出「文档少写了 bail」这个方向正确但理由错误的结论:你的统计里确实有 bail(Cordis 内部注册监听器时就在用,第 296 行),也确实没有 parallel,而入门文档的表恰好写了 parallel、漏了 bail。不看源码只对表,你会以为是文档把两者写反了;真实情况是文档漏一格、你的观测也漏一格,两个漏洞碰巧长得像。教训是:用观测去校对文档时,先确认你的观测手段本身没有盲区。变式:如果不监听 internal/dispatch,而是直接给 ctx.emit 等五个方法打补丁来计数,能拿到完整数据吗?这么做会破坏什么?
给你的 mini-harness 装上 waterfall 分发器
到上一章为止,你的 mini-harness 已经有了上下文、插件、服务和释放函数。现在装事件,而且直接装最难的那种。前四种模式各只有三五行,照着 4.2 节自己补即可;这一层专啃 waterfall,因为它是后面几章所有拦截点(工具流水线、审批、压缩)共同的地基。
难点在最后三行的自引用:next 里要用 args,而 args 的最后一项又要是 next。第一次写的人几乎都会掉进递归版本——写一个 compose(i),每层现造一个新的 next,代码长一倍。Cordis 的做法是只造一个 next,让它消费一个队列:调一次,队头出一个。所以这个 next 不是「下一层」,而是「还没跑的人里下一个」。这个差别不是抠字眼,它决定了连调两次 next() 会发生什么。
另一处不显然的是拷贝队列那行。不拷也能跑,但只要有监听器在分发过程中注销自己(比如一次性监听),数组一边被 shift 一边被 splice,就会漏人——这是那类「十次错一次」的 bug,事后极难查。
做对了的样子:注册三个监听器:第一个 async (s, next) => (await next()) + '|外层',第二个 async (s, next) => '中层拦下'(故意不调 next()),第三个 async (s, next) => { console.log('第三个跑了'); return next() }。然后跑 await waterfall('demo/x', 'hi', async () => { console.log('默认逻辑跑了'); return '默认' })。
结果必须是 中层拦下|外层,且控制台里一行都不该出现「第三个跑了」和「默认逻辑跑了」。只要有任何一句打印出来,说明短路没生效——多半是你在 next() 返回后又无条件往下调了一次。
反向检查:把第二个监听器改成 return next() 重跑,两句都必须出现,结果变成 默认|外层。两次都对,这一层才算成了。
本章小结
服务和事件不是二选一,是两个方向。你知道要找谁就用服务;你不知道谁在听就用事件。方向搞反的代价是循环依赖和一个谁都改不动的特权内核。
Cordis 的事件有五种分发方式。前四种按两个问题分类:等不等(emit/bail 不等,parallel/serial 等),收不收返回值(emit/parallel 不收,serial/bail 收第一个有效值)。判断「有效值」用的是三个全等比较,所以 0 和空字符串会停住链条。
第五种 waterfall 是另一个物种:监听器套在「本来该干的事」外面,进去一次、出来一次,返回值一层层往外包。调 next() 是委托,不调是否决,而框架不会因为你忘了调而报任何错。这条纪律仓库里写了三遍,本站再写一遍:只观察的监听器必须调 next()。比它更难查的是「调了 next() 却没把结果 return 出去」——链条通了、日志对了,结果被悄悄吃成 undefined。
本章有两处需要你自己去核对:入门文档的分发模式表只有四种、漏了 bail(源码和教程都是五种);以及 parallel 在诊断事件里把自己报成了 'emit'。两处都是本站读源码得到的结论,仓库既没承认也没否认——打开那几个文件自己看一遍,这也是本书希望你养成的习惯。
下一章离开单个插件,去看几百个插件怎么被组装成一个产品,以及别人怎么在不改你代码的前提下改你的产品。
第5章 组装:一棵可以被别人改的插件树
前四章讲的都是「怎么写一个插件」。这一章换个尺度:几百个插件怎么变成一个能跑的产品,以及——更要紧的——别人怎么在不改你一行代码的前提下改你的产品。
学完这一章你应该能做到
- 读懂
cordis.yml里一个配置项的每个字段,说清id为什么最重要 - 说出 profile 与组合包的关系,以及四层 patch 的应用顺序
- 写一条 patch 去替换别人的配置,并预测它会不会命中
- 用
dsh --profile web --dump-config把自己机器上真正启动的那棵树打印出来 - 看到一个「什么都不干、也不报错」的插件,知道去查它的 fiber 状态
- 给自己的 mini-harness 写一个能做增量重挂的 loader
5.1 一棵树,不是一个内核加一堆插件
先把话说白:绝大多数你见过的「支持插件」的软件,结构是内核 + 插件。内核是核心逻辑,插件挂在内核开好的口子上。想改内核行为?要么求作者加个口子,要么给内核打补丁自己维护一个分支。
DeepSeek Harness 不是这样。docs/architecture.zh.md 第 11–13 行写得很直白:产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop(智能体循环)本身;不存在需要打补丁的特权内核,扩展的方式是把插件挂载到其他插件旁边。
这句话的分量要掂一下。它意味着「agent 循环」和「你昨天写的那个小工具」在框架眼里是同一种东西——都是配置文件里的一行。你想换掉 agent 循环,不需要 fork 仓库,只要在自己的 patch 里把那一行换成别的实现。反过来,你写的插件也享有同样的地位:没人能绕过你,也没人需要绕过你。
为什么值得付出这个代价
代价是真实的:一个「什么都是插件」的系统,启动时要解析一棵有几十上百个节点的树,任何一行配错都可能让某个插件默默地永远不启动(5.8 节专门讲怎么查)。换来的是一件很难用别的办法换到的东西——产品的可替换性不依赖作者的预见能力。作者不需要提前想到「会有人想把文件系统换成远程沙箱」,因为文件系统本来就只是一行配置。
某个 agent 框架的文档写着:「我们提供了 15 个 hook,覆盖了主循环的各个阶段,欢迎通过 hook 扩展。」按本节的说法,它有没有特权内核?如果有,症状会在什么时候出现?
cordis.yml 里的一行(@deepseek-ai/dsh-agent-loop)。在 hook 模型里,主循环在哪?变式:如果一个框架说「我们没有内核,一切都是插件」,但它的插件加载顺序写死在一个数组里,这算不算特权内核?
5.2 一个配置项都有什么
Cordis 配置项(entry):cordis.yml 里 YAML 数组的一项,描述「挂载哪个插件、用什么配置」。整个应用就是一个配置项的列表,列表可以嵌套。
字段一共不多,vendor/loader/src/config/entry.ts 第 9–22 行的 EntryOptions 就是全部:
- id: greeter # 这一项的稳定身份(下一节整节讲它)
name: './greeter.ts' # 挂哪个插件:包名,或相对路径
config: # 传给插件的配置,交给插件的 schema 校验
greeting: 'Hello'
disabled: true # 保留这一项,但不挂载
inject: ['timer'] # 追加声明这一项需要的服务
group: true # 这一项是个组,它的 config 是一份子列表
逐个说:
name 是唯一必需的实体信息——挂什么。可以是 npm 包名(@deepseek-ai/dsh-tools),也可以是相对路径(./greeter.ts)。
config 是给插件的配置块,会先过插件声明的校验器再交给 apply(5.7 节)。
disabled: true 的语义很特别:保留这一项,但不挂载它。它和「把这一行删掉」不一样——删掉之后别人就没法按 id 找到它了,而留着并禁用,后面的层还能把它再打开。docs/cordis-tutorial/06-composition-and-hmr.zh.md 第 19 行说:改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。
group: true 让这一项变成一个组:它的 config 不再是配置,而是一份嵌套的子列表,整组作为一个单元加载和卸载。组还有一个更重要的用途——isolate,5.6 节讲。
组合包里的真实写法
这些不是教学示例。packages/bundle/web-app/cordis.patch.yml 里满是这样的行:
- id: tool-bash disabled: true
浏览器那个 profile 把 base 层插进来的 tool-bash 关掉了(因为它改由每个会话自己的 preset 提供)。文件里那段注释写得很清楚:禁用而不是删除是刻意的——base 是共享层,一行在某个界面上「消失」了,某天有人重排组合时它会悄悄回来;写成显式的 disabled,意图就留在了文件里。
你要让某个组合包插进来的遥测插件不生效。两种做法:(甲)在你的 patch 里给它写 disabled: true;(乙)想办法让它那一行不出现。从「别人还能不能改回来」和「意图留没留下」两个角度比较,为什么仓库选甲?
cordis.patch.yml、命令行的 --patch)就能再写一条 disabled: false 把它打开;行没了就谁也够不着,只能重新 insert 一份,而重新插的那份和原来的默认配置未必一致。第二,显式的 disabled 把「我们知道有这行,我们故意关掉」这个意图写进了文件,而「让它不出现」是一种沉默——packages/bundle/web-app/cordis.patch.yml 的注释就说,沉默的后果是某天有人重排组合时它会悄悄回来,没人知道那是不是有意的。变式:如果你不是组合包作者、只是最终用户,你的 cordis.patch.yml 里写 disabled: true 和把插件从磁盘上卸载掉,有什么区别?
5.3 id:loader 唯一认得的身份
这一节是全章最实用的一节。
loader 每次读配置文件,都要回答一个问题:这次读到的东西,和上次跑着的东西,哪些是同一个?没有答案就只能全拆了重装——那 HMR(热模块替换)就没意义了。它给的答案是 id。
vendor/loader/src/config/group.ts 第 60–83 行的 update() 把新旧两份列表各建一张 id 索引表,然后:新表里有、旧表里没有的,挂上;旧表里有、新表里没有的,卸掉;两边都有的,比一比字段有没有变。vendor/loader/src/config/entry.ts 第 157–159 行就是那个「比一比」:
const diff = Object.keys({ ...candidate, ...legacy })
.filter(key => !deepEqual(candidate[key], legacy[key]))
if (!diff.length && !force) return // 一字未改:什么都不做
问题来了:如果你没写 id 呢?vendor/loader/src/config/tree.ts 第 66–73 行的 ensureId 会给你现造一个:
if (!options.id) {
do {
options.id = Math.random().toString(16).slice(2, 10)
} while (this.store[options.id])
}
随机八位十六进制。每次读文件都重新造。后果 docs/cordis-tutorial/06-composition-and-hmr.zh.md 第 59 行说得很清楚:不带 id 的配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使它自身文本一个字都没变,它也会被当成「先删掉旧的、再加一个新的」,然后重新挂载。
id,你就没有身份。你改文件里任何一行,所有没写 id 的插件都会被拆掉重装一遍。「重新挂载」不是无害的。第2章讲过,卸载会回卷这个插件的所有副作用:它注册的工具没了、监听器没了、它提供的服务消失了,而依赖那个服务的插件会跟着掉回 PENDING、再跟着重启。改一行注释,半棵树抖一下。这就是为什么组合包里每一行都带 id——packages/bundle/base/cordis.patch.yml 插入的 78 行,一行不落全有 id。
还有一条硬规则:同一层里 id 不能重复。group.ts 第 62–67 行发现重复会直接抛 duplicate loader entry id。这是「misconfiguration fails loud(配错就大声报错)」在这里的体现。
常见误解
很多人以为「改了 config 就等于插件重启」。看 entry.ts 第 194 行:
const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group')
只有 name、inject、group 变了才走「重新 import 模块并整个换掉」这条路。只改 config 走的是另一条:调用这个 fiber 的 update()。而 update()(vendor/cordis/src/fiber.ts 第 736–752 行)会先跑一遍 internal/update 这个 waterfall——第4章学的东西在这儿又出现了——监听它的插件可以否决或替换掉默认的重启。没人否决时默认行为才是重启。所以「改 config 会不会重启」的答案是:默认会,但这是一个可以被插件接管的决定。
一份 cordis.yml 有五项,其中第 3 项提供 ctx.shell 服务,第 4 项 inject: ['shell']。五项都没有写 id。现在你在文件末尾加了一行注释并保存(HMR 开着)。会发生什么?如果只有第 3 项写了 id 呢?
update() 的逻辑套一遍。旧表里的五个 id 在新表里一个都找不到,新表里的五个 id 在旧表里也一个都找不到。这时它会做什么?ctx.shell 消失,第 4 项立刻掉回 PENDING;等第 3 项重新挂上、服务再次就绪,第 4 项才重新 apply。一行注释换来一次全树抖动,而且中间那段时间里 shell 能力是不可用的。只有第 3 项写了 id 的话:第 3 项在新旧两表里都能按 id 找到,字段一字未改,
diff 为空,它完全不动,ctx.shell 全程在线;第 4 项还是会被拆装一次(它没有 id),但它重挂时依赖已经满足,能直接启动。这就是「给每一行都写 id」在实践中的价值。变式:如果你给两项写了同一个 id 会怎样?这个错误是在保存的瞬间暴露,还是要等到某个插件出问题才被发现?
id 的配置项在任何一次编辑后都会重挂。5.4 profile 与组合包:一个产品是怎么被叠出来的
到这里为止我们只谈了一个文件。真实的 dsh 启动时,那棵树是叠出来的。
组合包(bundle):一个 npm 包,它带着一份 cordis.patch.yml。它的 package.json 里用 dsh.bundle.patch 字段指向那份文件。组合包是「一组配置项 + 它们的代码」的分发格式。
profile:一份具名装配,放在 Harness home 里($DSH_HOME/profiles/<名字>,$DSH_HOME 没设就是 ~/.dsh)。它的 package.json 用 dsh.profile.bundles 列出自己要叠哪些组合包,另外还放用户自己的 cordis.patch.yml。
发行版带两个 profile 模板(packages/boot/app-boot/src/profile.ts 第 114–117 行):
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']
三个组合包各干什么,去数一下它们的 patch 文件就知道了:
| 组合包 | 插入的行 | 按 id 改写的行 | 它加了什么 |
|---|---|---|---|
dsh-base | 78 | 0 | 每个 profile 的第一层:模型适配器、工具、会话持久化、沙箱与审批策略、设置、凭据、遥测 |
dsh-web-app | 51 | 27 | 浏览器应用:HTTP 服务器、API 网关、几十个前端 UI 插件;同时关掉 base 里那些改由会话 preset 提供的行 |
dsh-headless | 3 | 3 | 一次性运行器,完全不带服务器:只加代码运行时、启动参数解析和运行器本身 |
headless 只有 3 加 3 行——这个数字很说明问题。「无界面跑一个任务」和「浏览器里的完整应用」共享的是同一个 78 行的底座,差别小到能写在一页纸上。
四层,按顺序叠
层的顺序在 docs/architecture.zh.md 第 27 行写着,代码在 apps/cli/src/profile-boot.ts 第 151 行:
composeEntries([bundlePatches, profile.patches, homePatches, overlays])
apps/cli/src/profile-boot.ts 第 151 行与 docs/architecture.zh.md 第 27 行。注意一个容易搞反的地方:home 级的那份排在 profile 自己那份之后,也就是说 home 级更晚应用、优先级更高。packages/boot/app-boot/README.md 第 43 行给了理由:home 级是「适用于每一个 profile 的机器本地偏好」,所以它压过单个 profile 的设置。
dsh-base 给 session-title 这一行的 config 是 { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }。你在 profile 的 cordis.patch.yml 里写 { id: session-title, config: { maxTitleBytes: 200 } }。启动后这一行的 config 是什么?
vendor/include/src/index.ts 第 121–124 行,看它是怎么把 patch 的字段写进目标行的。for (const [key, value] of Object.entries(overrides)) target[key] = value。config 是一个键,整个被赋值。{ maxTitleBytes: 200 }——另外两个字段没了。patch 替换的是整个 config 键,不做深度合并(packages/bundle/base/README.zh.md 的「已知限制」一节明确列了这条:profile 覆盖必须重述该行需要保留的每个字段)。那另外两个字段会怎样?它们不会变成 undefined 崩掉,而是由插件自己的 schema 默认值补齐(5.7 节)——但补出来的是插件默认值,未必等于 base 层原来写的值。所以正确写法是把三个字段全部重述一遍。这也是为什么 packages/bundle/web-app/cordis.patch.yml 开头的注释专门写着「每一行都重述它拥有的每个键」。变式:如果你的 patch 只写 { id: session-title, disabled: false }(不带 config),原来的 config 会被清空吗?为什么?
5.5 patch:按 id 定位,替换整个 config
patch 的全部语义在一个函数里:vendor/include/src/index.ts 第 58–128 行的 applyEntryPatches。它不长,规则有五条:
- 非 insert 的 patch 必须带
id,否则跳过并警告(第 105–108 行)。 - 按 id 找目标行;找不到就跳过并警告(第 110–113 行)。注意——只是警告,不是报错。
name是守卫不是修改:patch 里写了name且和目标行对不上,整条 patch 跳过并警告(第 116–119 行)。这是防止你打错行的保险。- 剩下的字段整键覆盖(第 121–124 行):
config、disabled、inject各自被整个替换,没有深度合并。 insert加新行:不带id就追加到根列表,带id就插进那个组的子列表里(目标必须是组,否则警告跳过,第 80–102 行)。插进来的行会立刻被登记进索引,所以同一份 patch 列表里更靠后的条目可以再去改它(第 96–100 行的注释专门解释了这一点:没有这条,插入的行就永远打不上补丁)。
把这五条和 5.1 节那句「不存在需要打补丁的特权内核」合起来看,才能明白它的分量:整棵树上没有任何一行是特殊的。dsh-base 插进来的 78 行里,agent-loop、tools、session——这些听起来像内核的东西——和 tool-todo 一样,都只是带 id 的一行,都能被后面任何一层按 id 改掉或关掉。
把你自己的产品打印出来
这是本章给你的实操出口。装了 dsh 之后:
dsh --profile web --dump-config
docs/architecture.zh.md 第 35 行的原话是:它打印出的任何条目,都可以由你自己的 patch 替换。打印出来的每一行都带注释标明它来自哪个组合包、哪个 patch 层(apps/cli/src/dump-config.ts 第 30–50 行)。挑一行你想改的,记下它的 id,在 $DSH_HOME/cordis.patch.yml 里写:
- id: 你记下的那个 id
config:
# 这一行原来有的字段,你要保留的全部重述一遍
某个字段: 新值
再 dump 一次,看它变了没有。这个循环——dump、改、再 dump——是你以后调这个产品的主要方式。
本站的核对结果:dump 出来的和真跑的,不完全是一份
apps/cli/src/dump-config.ts 的模块注释说它「不启动、也不求值 !!js」,所以带表达式的字段你在 dump 里看到的是表达式本身而不是结果。除此之外我们还读到一点差别:真正启动走的 composeProfile(apps/cli/src/profile-boot.ts 第 142–170 行)会在 --patch overlay 之后再追加两层——一层把随发行版交付的 agent preset 目录写进 agent-presets 行,一层是 DSH_TELEMETRY_DISABLED 环境变量对应的遥测开关;而 runDumpConfig 里没有这两步。
这是本站读这两个文件得到的比对结果,不是仓库承认的问题;packages/boot/app-boot/src/index.ts 第 352 行的注释说 dump 和 boot 走的是同一套 patch 算法,这一点是对的——差别不在算法,在于喂给算法的层数。实际影响很小(涉及的只有 preset 根目录和遥测开关两行),但如果你正在用 dump 校对遥测是不是真的关了,请以实际启动为准。请自己打开这两个文件核一遍。
构造三条各自「打不中」的 patch,让它们分别触发 applyEntryPatches 里三种不同的警告。写出 YAML,并说明每一条为什么打不中。然后回答一个更要紧的问题:这三条 patch 都只是警告、不报错,程序照常启动——这跟仓库自己「配错就大声报错」的约定冲突吗?
id。第二种写一个不存在的 id。第三种要用到那个「守卫」字段。tools),但 name 故意写成别的包名。第 116–119 行会因为名字对不上而整条跳过。- config: {...}(无 id,触发第 105–108 行);- id: 打错的名字(第 110–113 行 entry not found);- id: tools 加 name: '@deepseek-ai/dsh-session'(第 116–119 行 name mismatch)。关于冲突:存在张力,但可以辩护。仓库根目录
AGENTS.md 的约定是「配错要在能解析的最早时刻大声失败,绝不静默跳过缺失的引用」,而 patch 打空只写 stderr(packages/boot/app-boot/README.md 第 43 行明确写着这是警告)。辩护理由:patch 层是叠加的,一条 patch 引用的行可能来自某个用户没装的组合包,硬失败会让一份通用的 overlay 在任何缺少那一行的组合上都启动不了。但这确实意味着拼错 id 的 patch 会静默无效——你以为改了,其实没改。这也是上一节让你用 dump 校对的原因。变式:如果 applyEntryPatches 改成打不中就抛错,前面提到的 DSH_TELEMETRY_DISABLED 那条自动追加的 patch 会出什么问题?(提示:去看 resolveTelemetryPatch 为什么要先判断 hasRow。)
答辩:如果我是审稿人
「不存在需要打补丁的特权内核」这句话很漂亮,但你们把复杂度转嫁给了用户。我只想把标题长度从 80 改成 200,却必须先跑 dump 找到 id、再把那一行原有的三个字段全部抄一遍——抄漏一个,它就悄悄退回插件默认值,而不是保持 base 层的值。再加上打不中只警告,我甚至不会知道自己改错了。这比「改一个配置文件里的一个数字」难了一个数量级。为了「一切可替换」这个理想,值得吗?
参考防守(先自己组织语言再看)
这个批评的事实部分全部成立,不该否认:没有深度合并、必须重述、打不中只警告,三条都是真的,packages/bundle/base/README.zh.md 自己就把第一条列在「已知限制与暂缓事项」里。
能辩护的是「为什么不做深度合并」。深度合并看起来友好,实际上会制造一类更难查的故障:当一个字段的值是数组或对象时,「合并」有好几种都说得通的做法(数组是替换还是拼接?对象里被删掉的键算不算删?),而层数一多,你就无法只看自己那一层推断出最终值——必须把所有层在脑子里合并一遍。整键替换的规则很粗暴,但它有一个很硬的性质:一行的最终 config 一定等于最后一个写它的那层写的东西,看一眼就知道。这是可预测性和便利性之间的一次明确取舍。
能辩护的第二点是「转嫁给谁」。真正频繁改配置的不是终端用户,而是组合包作者和部署方;对他们来说,重述全部字段同时也是一份自文档化的清单——web-app 那份 patch 里每一行都能独立读懂,不需要同时打开 base 那份对照。
辩护不了的是「打不中只警告」加上「没有深度合并」的组合效应:拼错 id 静默无效,抄漏字段静默降级,两种静默叠在一起,用户很可能长期跑在一个自以为改过的配置上。仓库目前给的唯一防线是 --dump-config,而那是要用户主动去跑的。一个诚实的结论是:这套机制对「知道要跑 dump」的人很好用,对不知道的人不友好,而文档并没有把 dump 摆在足够显眼的位置。
5.6 isolate:让两个组各看到一个自己的 shell
先说不用它会怎样。服务是按名字放在上下文里的,ctx.shell 只有一个。如果两个插件都想提供 shell,第二个挂载时会直接抛错(vendor/cordis/src/reflect.ts 第 289–290 行:service "shell" has been registered at ...)——packages/bundle/base/README.zh.md 里就有一句实例:在 fs-sandbox 旁边再挂一个 dsh-fs-local 会重复注册 ctx.fs 并在加载时失败。
可有时候你就是需要两份。最典型的场景是子 agent(subagent)要有不同的能力集:主 agent 能跑任意命令,派出去做调研的子 agent 只能读文件、不能写;或者一个子 agent 的 shell 指向远程沙箱,另一个指向本地。它们跑在同一个进程里,却必须看到不同的 ctx.shell。
isolate 就是干这个的。写法(真实例子在 packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml):
- id: 组A
group: true
isolate:
shell: true # 这个组里的 shell 是它自己的
config:
- id: shell-本地
name: '@deepseek-ai/dsh-bash-local'
- id: 用 shell 的工具
name: './my-tool.ts'
原理在 vendor/cordis/src/context.ts 第 121–124 行:每个上下文都带一张「服务名 → 符号」的隔离表,isolate(name) 给这个名字换一个新的符号,然后基于新表派生出一个子上下文。组里的插件查 shell 时查到的是新符号,组外的还是旧符号。两边同名不同物,互不打扰。
docs/architecture.zh.md 第 116 行把这件事写成了一条路由规则:让某个会话拥有不同的能力集合 → 组装一个 agent preset;其中的服务行需要 isolate realm。packages/bundle/web-app/cordis.patch.yml 里那一大片 disabled: true 就是这个设计的后果——浏览器界面把工具行从全局关掉,改由每个会话自己的 preset 在自己的隔离域里挂载。
这一段是本站的推断
把服务隔离和第4章的事件放在一起会产生一个问题:隔离域里的插件发出的事件,域外的人听不听得到?我们在 events.ts 第 171–173 行看到 dispatch 确实有一道基于 Context.filter 的过滤,service.ts 第 62 行也有一处按隔离符号比较的检查,所以存在作用域过滤这套机制;但「isolate 之后事件到底怎么走」本站没有在文档里找到成体系的说明,也没有实际跑过验证。上面这段只讲服务隔离,不要外推到事件。真要用,请自己写一个最小例子跑一遍。
你想让子 agent 用一个只读的文件系统提供方,主 agent 用完整的那个。有人建议:不用 isolate,只要给只读的那个提供方换个服务名叫 fsReadonly,让子 agent 的工具去注入 fsReadonly 就行。这个方案能跑吗?它和 isolate 的差别在哪?
inject 里写的是哪个名字?dsh-tool-fs),它的源码里写死了 inject: ['fs']。改名方案要求你改它。isolate 改的是同一个名字在哪个上下文里指向谁(消费方一个字不用改)。isolate 的价值恰恰在于让同一份工具代码在不同的组里拿到不同的实现——因为它换的是名字到实现的映射(context.ts 第 121–124 行给这个名字换一个新符号并派生子上下文),而不是名字本身。这也回到了第3章的核心:插件通过 key 查找服务、而不是 import 具体实现,正是为了让这种替换成为可能。改名方案等于放弃了这个好处。变式:如果两个组都 isolate 了 shell,但你希望它们其实共用同一个实例,有办法吗?(提示:去看 context.ts 第 115 行那句关于 label 的注释。)
5.7 配置校验,以及 !!js 能写在哪
回到单个插件。它怎么保证拿到的 config 是对的?
docs/cordis-tutorial/05-config.zh.md 第 11–34 行的做法是:插件导出一个叫 Config 的东西,而这个名字同时是两样东西——一个 TypeScript 接口,和一个同名的运行时校验器。
export interface Config {
greeting: string
targets: string[]
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(String).default(['world']),
})
「同名」不是巧合,是 TypeScript 允许一个名字既是类型又是值。消费方拿到类型,Cordis 拿到校验器。仓库用的是 Schemastery;Cordis 本身接受任何 Standard Schema 校验器——但导出一个普通对象是不行的(教程第 34 行明确写了这句)。
配错了会怎样?教程第 57–68 行给了实例:
ValidationError: invalid config: - $.targets expected array but got not-an-array (at targets)
然后这个插件的 fiber 进入 FAILED 状态。FAILED 是 vendor/cordis/src/fiber.ts 第 147–154 行那个六态枚举里的一个(PENDING / LOADING / ACTIVE / FAILED / DISPOSED / UNLOADING)。关键在于:apply 根本不会被调用。
这一条比它看起来重要。没有它,一个配错的插件可能跑到一半才发现某个字段是 undefined,那时候它已经注册了三个工具、开了两个连接。校验前置意味着失败是干净的。教程第 68 行还补了半句作者的说法:如果配置通过了 schema 校验、但里面指定的资源或提供方不可用,插件也应当在能解析该引用时立即拒绝。
!!js:哪里能算,哪里不能
有些配置值必须在加载时算出来,比如从环境变量取。loader 支持一个 YAML 标签:
- name: './config-demo.ts'
config:
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
!!js 只在 config 里、以及条目的 disabled 字段里有效(教程第 80 行;注意是 !!js 不是 !js)。其余元数据——name、id、inject——保持字面值,写在里面的表达式只是普通字符串数据。想按环境选插件,得用 overlay(docs/cordis-primer.zh.md 第 44 行)。
两者的求值时机也不同:config 在这一项声明的注入激活之后、基于该插件的上下文求值(所以你能写 ctx.webStartup.host 这种东西);disabled 在每次挂载决策时基于 loader 上下文求值。
仓库里最漂亮的一个 !!js 用法是平台门控。packages/bundle/base/cordis.patch.yml 给 bash 那一套写 disabled: !!js process.platform === 'win32',给 pwsh 那一套写取反的表达式——同一份 patch 文件,每台机器恰好挂载一个 shell 栈。两套执行器注册的都是同一个 ctx.shell 服务(packages/shell/shell/src/index.ts 第 67 行 super(ctx, 'shell')),同一个进程里只能活一个;用 disabled 表达式而不是两份文件,意图和条件都留在了同一个地方。
有人想按环境切换插件实现,写了这样一行:- name: !!js process.env.USE_REMOTE ? '@x/remote' : '@x/local'。它能工作吗?如果不能,正确做法是什么?
name 属于哪一类字段?教程里那句「其余元数据保持静态」包不包括它。name 会变成什么?loader 拿它去 import 会发生什么。!!js 的白名单只有两项——config 和 disabled。name 不在里面。name 是元数据,保持字面值,那个表达式会被当成普通数据而不是被求值,loader 拿着一个根本不是包名的字符串去 import,直接失败。正确做法有两条:(一)把两行都写进去,各带一个取反的 disabled: !!js ...——这正是 base 组合包门控 bash/pwsh 的做法,两行的意图都留在文件里;(二)用 overlay:不同环境启动时用 --patch 叠不同的文件,docs/cordis-primer.zh.md 第 44 行说的「由环境选择插件时,请使用 overlay」就是这个意思。变式:config 里的 !!js 能读到 ctx 上的服务(比如 ctx.webStartup.host),disabled 里的能吗?为什么这两个的求值上下文不一样?
5.8 HMR,以及怎么找出卡住的插件
第2章说卸载会释放副作用,第3章说加载遵循依赖关系。把这两件事接起来就是 HMR(热模块替换):先卸载、再加载,替换一个正在运行的插件。@deepseek-ai/cordis-plugin-hmr 监视文件,保存时执行这个过程(docs/cordis-tutorial/06-composition-and-hmr.zh.md 第 23–57 行)。
要注意的是:编辑 cordis.yml 本身也会触发更新。loader 按 id 比较配置项,只挂载、卸载或重新配置发生变化的部分(第 59 行)——这正是 5.3 节那件事的正面用途。
诊断:一个既不干活也不报错的插件
依赖驱动加载有个阴暗面。如果一个插件 inject 了没人提供的服务,它就一直等,不输出任何内容。这不是错误——PENDING 是合法状态,提供方可能稍后才挂上(教程第 63 行)。
教程第 42 行给了一个真实的坑:HMR 插件自己要 inject timer 服务来做去抖。你如果只装了 HMR 没装 @deepseek-ai/cordis-plugin-timer,它就永远停在 PENDING,而且不发出任何提示——你会以为 HMR 坏了。
查法是直接去看状态。每个上下文都能枚举插件注册表(教程第 65–83 行的 diagnose.ts):
import { FiberState, type Context } from '@deepseek-ai/cordis'
export const name = 'diagnose'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log(`${fiber.name} is PENDING — a required service is missing`)
}
}
}
}, 500)
}
为什么要 setTimeout 等 500 毫秒?因为启动过程本身是异步的,刚开始所有插件都可能在 PENDING,等一会儿再看,剩下的才是真卡住的。教程第 109 行还提醒:不加 PENDING 过滤直接遍历,你会看到 loader 自己的插件(Loader、Include)处于 ACTIVE——因为配置文件本身也是通过插件挂载的。这句话值得多想一秒:连「读配置文件」这件事都是一个插件。
真未解按 id 后期绑定的配置层叠,正确性怎么静态保证?
本章讲的这套机制有一个共同特征:绑定发生在运行时,靠字符串匹配。patch 用 id 字符串找目标行,inject 用服务名字符串找提供方,isolate 用服务名字符串换符号。三处都没有类型系统覆盖——TypeScript 管得了插件内部,管不了「你的 patch 里那个 id 在这次组装里存不存在」「你重述 config 时有没有抄漏一个字段」。
仓库当前的答案是三样东西:打不中时 stderr 警告、--dump-config 让你自己核对、以及 fiber 状态可以枚举。这三样都是事后的。这不是这个仓库特有的困难:Kubernetes 生态里 Helm 与 Kustomize 的层叠覆盖踩的是同一个坑,至今没有公认解法——静态校验需要知道「所有层的全集」,而层叠机制的价值恰恰在于允许任何人后加一层。
怎么入手:先动手制造故障,把感觉建立起来——在 $DSH_HOME/cordis.patch.yml 里写一条 id 拼错的 patch,跑 dsh --profile web --dump-config,看它是不是真的静默无效;再写一条抄漏字段的 patch,对比 dump 前后那一行的差别。然后读 vendor/include/src/index.ts 第 58–128 行,想清楚要做静态校验需要哪些信息,其中哪些在「组装完成之前」根本拿不到。最后回答:如果给 patch 加一个可选的 expect 字段(声明你以为的目标行长什么样,对不上就硬失败),能挡住多少种错误?它和第 3 条规则里那个 name 守卫是什么关系?
你启动 dsh,它跑起来了,没有任何报错,但模型说自己没有 bash 工具。已知:bash 那一行在 base 组合包里确实存在。请列出至少四种可能的原因,并说明每一种你会用什么手段确认。
!!js 表达式门控掉了、config 校验失败进了 FAILED、依赖没满足停在 PENDING。dsh --profile web --dump-config:它能一次性区分「这一行在最终组装里是什么样」和「它跑起来之后怎么了」。(一)被后面的层关掉了。
dsh-web-app 就明确给 tool-bash 写了 disabled: true(改由会话 preset 提供);你自己的 cordis.patch.yml 或某个 --patch 也可能干这事。用 dump 看那一行的 disabled 值。(二)被平台表达式门控了。base 给 bash 那套写的是
disabled: !!js process.platform === 'win32',Windows 上它本来就该关,实际生效的是 pwsh 那套。dump 里看到的是表达式原文,所以要自己判断当前平台的取值。(三)fiber 进了 FAILED:config 没通过 schema 校验,
apply 从未运行。这时候通常有 ValidationError 输出,翻启动日志。(四)fiber 停在 PENDING:它
inject 的某个服务没有提供方(比如沙箱后端那一行被关了)。这一种完全没有输出,只能用 5.8 节的 diagnose.ts 枚举状态。顺序上先跑 dump(区分「组装成什么样」),再看日志(区分 FAILED),最后枚举 fiber 状态(抓 PENDING)。
变式:如果模型说它有 bash 工具但每次调用都超时,上面四种原因还有哪几种可能?这时候该往哪查?
你在 $DSH_HOME/cordis.patch.yml 里把某一行的 config 改了一个数字并保存,HMR 正开着。请按发生顺序说出这一次改动会经过哪些机制,并回答三个问题:(甲)这一行会被卸载重挂吗?(乙)这一行的邻居会受影响吗?(丙)如果你改的是 inject 而不是 config,第(甲)问的答案变吗?
entry.ts 第 157–159 行的 diff,和第 194 行的 replace。replace 为假那条分支之后会调用 fiber.update(...)。去 fiber.ts 第 736–752 行看它第一件事干了什么——那是第4章的东西。group.ts 第 60–83 行)→ 你改的那一行 id 没变,两边都有,进入字段比对(entry.ts 第 157–159 行),diff 得到 ['config'] → 第 194 行判断 replace:config 不在 name/inject/group 里,所以不重新 import 模块 → 调用这个 fiber 的 update() → update() 先跑 internal/update 这个 waterfall(第4章),监听它的插件可以否决或替换默认行为;没人否决时默认动作是 restart()。(甲)默认会重启(这一行自己的副作用回卷再重建),但这是一个可被插件接管的决定,不是硬编码。(乙)邻居的 id 和字段都没变,
diff 为空,直接 return,什么都不做——除非它 inject 了你这一行提供的服务,那它会跟着掉 PENDING 再恢复。(丙)变。inject 在第 194 行那个列表里,会走 replace 分支:模块重新 import、旧 fiber 整个换掉,而且新的依赖没满足的话它会停在 PENDING。变式:如果你在 home 级 patch 里改的那一行,恰好在 profile 级 patch 里也被写过一次,你的改动一定生效吗?把 5.4 节的层序代进去验证一遍。
config 修改从保存文件到插件重启,中间经过了哪两个判断点。给你的 mini-harness 装一个会做增量重挂的 loader
前四层你都是在代码里手写 ctx.plugin(...) 一个个挂。这一层让配置说了算:吃一个 JSON 数组,每项是 { id, name, config, disabled },挂载所有插件;然后做到「改一项的 config,只重挂那一项」。
难点不在代码,在为什么必须先有 id 才能做这件事。第一次写 loader 的人几乎都会按数组下标比对——第 0 项对第 0 项、第 1 项对第 1 项。这在你只改 config 时能跑,但只要你在中间插入一行,后面所有项的下标全体错位,loader 会认为「从第 3 项开始全都换了实现」,于是把后半棵树整个拆掉重装。id 就是为了让身份和位置脱钩:行可以移动、可以中间插入,身份不变。真实的 Cordis 用的正是这个办法(vendor/loader/src/config/group.ts 第 67–68 行建的就是两张 id 索引表)。
第二个不显然的地方是那句 same(live.entry, entry) → continue。少了它,程序照样正确,但每次 load() 所有插件都会重挂一遍——功能对、体验废。真实实现里对应的是 entry.ts 第 160 行那句 if (!diff.length && !force) return。「什么都不做」是这类代码里最重要的一条分支。
做对了的样子:准备三个插件 A、B、C,各自的 apply 里第一行打印自己的名字。第一次 load() 三行都打印。然后只改 B 的 config 再 load() 一次:控制台里只能多出 B 那一行,A 和 C 一行都不许多。
再做两个检查:(一)在 A 和 B 之间插入一个新的 D 再 load,只应多出 D 那一行,A/B/C 都不动——这一条能验出你有没有偷偷按下标比对;(二)把 B 的 disabled 改成 true 再 load,B 的释放函数应该被调用,且下次把它改回 false 时 B 能重新 apply。三条都过,这一层才算成了。
本章小结
一个 dsh 产品不是「内核加插件」,而是一棵叠出来的配置项树:空的 profile 根 → 每个组合包按序 → profile 自己的 cordis.patch.yml → home 级那份 → 命令行的 --patch。后写的赢。dsh-base 铺 78 行底座,dsh-web-app 和 dsh-headless 在上面各自特化——headless 只用了 3 加 3 行。
id 是这套机制的地基。loader 只认 id:patch 靠它定位,增量重挂靠它比对。不写 id 的行每次读文件都换一个随机身份,于是任何一次编辑都会让它被拆掉重装,连带它的依赖方一起抖。
patch 按 id 定位,替换整个 config,不做深度合并——你保留的字段必须重述。打不中时只写 stderr 警告、不报错,这是本章唯一一处「静默」,也是答辩里守不住的那一点。对策是养成 dsh --profile web --dump-config 的习惯:它打印出来的每一行,都能被你自己的 patch 替换。
isolate 让两个组各看到一个自己的服务实例,同名不同物,这是子 agent 拥有不同能力集的实现基础。配置校验让插件绝不带着半截配置启动——过不了 schema 就进 FAILED,apply 一行不跑。!!js 只在 config 和 disabled 里有效,其余元数据是字面值。
最后一条实操:一个既不干活也不报错的插件,去枚举 fiber 状态。PENDING 不是错误,是「我在等一个没人提供的服务」——而这句话,框架永远不会主动说给你听。
第6章 会话日志:模型可见即已记录
一句话导语:这一章回答「这段对话到底存在哪」——答案不是一个消息数组,而是一份只往后加、从不修改的事件日志;模型看到的历史是从它算出来的,而且有代码在运行时盯着这件事。
学完这一章你应该能做到
- 说出至少三件「一个消息数组做不到」的事,并解释为什么它做不到
- 用自己的话解释「仅追加」为什么必须是「从不修改」,而不只是「一般不修改」
- 区分「会话日志」「surface」「派生出的模型历史」这三样东西,并说清它们各自装的是什么
- 解释「模型可见即已记录」这条不变量,以及它对「我想给模型多加一样输入」这件事的强制后果
- 指出这条不变量在仓库里的适用边界(哪些请求它管、哪些不管、什么情况下它根本没被加载)
6.1 一个消息数组,五件它做不到的事
第0章讲过一件反直觉的事:模型没有记忆。你和它聊到第十句,它并不记得前九句;是你的程序每次都把前九句连同第十句一起打包发过去,它才显得像记得。
那么这段对话由谁保管?最自然的写法只有两行:
const messages = []
messages.push({ role: 'user', content: '帮我把这个函数改成异步的' })
一个数组,来一条推一条,要发请求时整个丢过去。第0章那个循环就是这么干的,而且它真的能跑起来。问题是它撑不过第二天。下面五件事,每一件都能把它按死。
第一件:用户中途反悔。他说「刚才那句我说错了,撤回」。你从数组里 pop 掉最后一条——然后呢?模型基于那句话已经调用过一次工具、已经动过一个文件。数组里没有任何东西记着「那次工具调用是因为哪句话发生的」,所以你既不知道该连带撤掉什么,也没法向用户交代到底撤到了哪。
第二件:想 fork 一个分支重试。同一段历史,你想试两种不同的后续走法。数组能 slice 出一个前缀——但那是模型历史的前缀,不是发生过的事的前缀。切出来之后你丢掉了:这次尝试当时用的是哪个模型、系统提示词长什么样、上一个轮次是正常结束还是被打断的。两个分支跑出来的结果没法比较,因为你不确定它们的起点真的一样。
第三件:界面要回放打字过程。模型是一个字一个字流出来的。数组里躺着的是组装好的整段文本。你想让「重新打开这个会话」看起来和当时一模一样——文字逐字浮现、工具卡片从等待变成完成——数组里根本没有这些信息,它只有终局。
第四件:崩溃之后恢复现场。进程挂了,内存里的数组跟着没了。就算你每次都把它写进文件,写的也是当前状态,是覆盖式的。崩在半路时文件里是一个说不清的中间态:可能工具调用记下了、结果还没记,也可能反过来。你没法判断该从哪儿接着走。
第五件:算清楚花了多少钱。token 用量得有地方放。你可以在每条 assistant 消息上挂一个用量字段——但失败的那次请求怎么办?它没产出任何消息,token 却是真的花掉了。数组里压根没有它的位置:数组只装消息,而那次尝试没有消息。
五件事其实是同一件事
撤销、fork、回放、崩溃恢复、记账,表面上八竿子打不着。但把它们并排放,会发现问的都是同一类问题:过程。而消息数组存的是状态——它只回答「现在这段对话长什么样」,不回答「它是怎么变成这样的」。用一个只存状态的东西去回答关于过程的问题,怎么绕都绕不出来。
有人提议:给消息数组的每一项都加一个 timestamp 字段,这样就有「过程」了。这个补丁能解决上面五件事里的哪几件?
变式:如果改成给数组加一个平行的 events 数组,专门记那些不产生消息的事,能解决几件?(提示:能解决不少,但你现在有了两份数据,谁是对的?)
6.2 只往后加,从不修改
仅追加日志(append-only log):一个只允许在末尾添加条目、永远不修改也不删除已有条目的列表。
规则就这一条,简单到有点像废话。但「从不修改」这四个字是整章的承重墙,它换来三样东西。
可回放。任何时刻的状态,都等于从第 0 条开始重新走一遍。这句话成立的唯一前提就是条目不会变——如果第 3 条今天是 A、明天被人改成了 B,那「当时的状态」就永远算不回来了。dsh 把这件事做到了物理层面:事件在被接受时会被深冻结,源码注释的原话是,这样一来「无论是类型断言还是普通 JavaScript,都改不动已经落库的历史」(packages/core/session/src/index.ts)。
可审计。出了事故要追问「模型当时到底看到了什么」,答案就在日志里,而且没有任何人能事后修改它来圆自己的说法。
可 fork。fork 就是取一段前缀当作新会话的种子。前缀不会变,所以两个分支共享的那段历史逐字节相同。若允许修改,父会话改一下,子会话的来路就被悄悄改写了,而它自己毫不知情。
这套做法在软件工程里有个名字,叫事件溯源(event sourcing)。名字不重要,那条规则才重要——记住「只往后加」,比记住术语有用得多。
dsh 里的这份日志
它叫 Session(packages/core/session/src/index.ts)。每条条目是一个 SessionEvent,四个必备字段:
| 字段 | 是什么 | 为什么必须有 |
|---|---|---|
type | 这是什么事:turn/start、user/message、assistant/chunk…… | 读日志的人靠它分派处理逻辑 |
seq | 第几条,从 0 开始,连续;写入时恒等于日志当前长度 | 让「引用某条早先的事件」成为可能 |
time | Unix 纪元毫秒 | 回放时间线、遥测 |
data | 这件事的内容,必须能无损转成 JSON | 能存进磁盘、能跨进程传 |
事件类型的清单叫 SessionEventMap。核心声明了 13 种:turn/start、turn/end、step/start、step/end、user/message、assistant/chunk、assistant/message、tool/call、tool/result、todo/write、request/header、request/context、session/end-seed。插件可以往这张表里加自己的类型(第7章会讲这件事的分寸)。
两个不起眼但很硬的约定。其一,seq 必须连续且恒等于日志长度,这意味着你不能「过滤掉一些事件再存」——持久化后端必须把每一条都无损保存,包括那些又碎又多的 assistant/chunk。
其二,data 必须能无损序列化成 JSON,而且 Session.append 在写入那一刻就校验:塞进去一个 Map、一个 Date、一个函数、一个 NaN,当场抛异常,坏事件根本进不了日志。为什么这么急着炸?因为日志是真源。如果放它进去、等到几秒后刷盘时才失败,那时内存里的日志和磁盘上的日志已经分叉了,而分叉的那一刻你毫无察觉。
有人为了省内存,提议给日志加一个「压缩」功能:把连续 200 条 assistant/chunk 合并成 1 条,内容拼起来,seq 用第一条的。这个改动会破坏哪一条约定?后果是什么?
seq 还是「连续且等于长度」吗?seq 去指向早先的事件。合并会让那些指针指到哪里去?seq 引用——是 assistant/message 的 sourceEventSeqs,它逐条列出自己是由哪些 chunk 拼成的。seq 要么留下 199 个空洞、要么整体前移;前者违反「seq = 日志长度」,后者等于修改了已有条目——而已有条目是不可修改的。引用完整性:assistant/message.sourceEventSeqs 里那 200 个数字会集体指错位置,同理 surface 上的 replace 声明的 start/end 也会失效。真正该做的是仓库的做法:日志的语义一条不省,让存储层自己选编码——JSONL 后端默认就把分片行打包存,读回来时还原成一模一样的事件。语义和编码是两层,能优化的是后者。变式:如果只在写入之后、持久化之前合并,内存日志保持原样,这算不算违反约定?(提示:想一想 load 必须返回什么。)
6.3 投影:模型历史是算出来的,不是存着的
日志里现在什么都有了:轮次边界、每一个流式碎片、请求头、待办清单。但发给模型的请求只需要一串规规矩矩的消息。中间那一步怎么走?
先看一个诱人的错误答案:一边往日志里追加,一边顺手维护一个 messages 数组,发请求时直接拿现成的。它错在你现在有两份数据在描述同一件事。任何一处代码只更新了其中一份,两者就分叉;而分叉之后,「哪一份是对的」这个问题没有答案——它们都自称是对的。
投影(projection):从一份数据算出另一份数据的过程。这里是:日志 → 模型历史。它是一个函数,输入日志,输出消息列表;不存第二份,只在需要时算。
这个函数叫 deriveMessages()。规则(据 docs/subsystems/session.zh.md):
| 事件类型 | 投影结果 |
|---|---|
user/message | 一条 user 消息,content 原样搬过去 |
assistant/message | 一条 assistant 消息,带上产生它的提供方与模型 |
tool/result | 一条携带 tool-result 块的 user 消息 |
assistant/chunk | 跳过——组装好的 assistant/message 才是权威 |
内容为空的 assistant/message | 跳过(第7章会讲为什么它还要存在) |
turn/*、step/*、request/header、todo/write… | 不产生任何消息,它们是结构信息或仅日志记录 |
注意 user/message 那一行的「原样」:仓库在源码注释里专门警告过,不要在这里给注入的上下文补一层 <context> 之类的外壳——包装归生产方所有,谁产生的内容谁自己包好再写进日志,投影必须是逐字透传。为什么这么较真?因为一旦投影开始加工内容,日志里的字节就不再等于模型看到的字节,「从日志重建请求」这件事就有了灰色地带。
一份日志,多个读法
投影不止一种,这才是「存过程」真正的回报:
- 模型历史:
deriveMessages(),上面那张表。 - 人类看的 transcript(文本记录):读的是日志中追加来源的那些事件(源码里的
isAppendSurfaceEvent)。它和模型历史故意不同——压缩会把一大段对话遮蔽成一条摘要,模型看摘要,人想看原文,而原文一直在日志里。 - 账单:token 记账读每个步骤的
assistant/chunk { type: 'usage' };没有用量分片时,退回assistant/message.usage。 - 会话投影 seam(
ctx.sessionProjections):各领域插件各注册一个纯函数单元apply(state, event) → state,框架只订阅一次session/event,把每条已提交事件喂给所有单元。领域自己不持有任何订阅,只负责算。
打个比方
日志像一本流水账:每一笔进出按发生顺序记下来,写错了只能再记一笔冲销,不能拿橡皮擦。余额表、月度分类、报税表都是从这本流水账算出来的报表——你想换一种报表格式,重算一遍就行,流水账一个字都不用动。
类比失效处:银行流水改不了是因为法律和外部审计;这里改不了是因为代码把它冻结了,而且改了会让已经 fork 出去的子会话与父会话的共享历史对不上——是一个纯技术后果,不是合规要求。另外,流水账的每一笔都对应真金白银,而日志里有大量事件(turn/start、step/end)对模型和账单都毫无贡献,它们只是结构。
一个插件想让 UI 显示「本次会话累计花了多少 token」。作者的实现是:监听 session/event,看到 assistant/message 就把 usage 累加到一个模块级变量 total 上,UI 直接读 total。请构造一个具体场景,让这个 total 显示出错误的数字。
total 是一份独立保存的状态,还是从日志算出来的?既然是前者,就去找「日志有、而它没被喂到」的时刻。session/event 这个广播?Session 的构造函数种子。恢复(resume)和 fork 都是把已有事件作为构造种子塞进去的,而仓库明说「构造种子不会发出(session/event)」。session/event 都不发,total 从 0 开始,界面上一个跑了三天的会话显示花了 0 token。其二,fork:子会话继承前缀,同样不广播,累计值凭空归零。其三,压缩:压缩用 replace 遮蔽掉一段历史,那段的消息不再发给模型,但它们确实花过钱;累加器不知道该不该减,而正确答案取决于你要显示的是「历史总花费」还是「下次请求的预估体积」——这是两个不同的数字,累加器一个都答不准。正解是把它写成一个投影:一个 apply(state, event) 纯函数,注册进 ctx.sessionProjections,框架会在会话比它更早存在时惰性地从头折叠一遍日志。变式:如果作者改成「监听事件的同时,启动时也扫一遍 session.events 补齐」,还剩下哪个场景会出错?
6.4 surface:不是每个事件都到得了模型
假设我们偷懒,把日志里所有事件一股脑发给模型。会发生什么?模型会收到几千条 assistant/chunk(它自己上一句话的碎片)、一堆 turn/start 和 step/end(纯结构标记)、还有 request/header(它自己的系统提示词)。这些是噪音,而且按 token 计费。
所以必须有一层筛选。dsh 没有把它做成运行时的 if 判断,而是做进了类型里:只有三种事件类型有资格产生消息,它们叫 SurfaceEventType。
type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result'
surface(不译,「模型能看到的那一层投影」):一个有序的 seq 列表,列出当前对模型可见的那些事件,顺序就是模型看到的顺序。session.surface 返回 { nodes, replaceGeneration }——nodes 是那串序号,replaceGeneration 是发生过多少次位置替换。
surface 和日志的区别,三条,值得逐条对照:
| 会话日志 | surface | |
|---|---|---|
| 能不能变短 | 不能,只增 | 能——压缩会用一条替换掉一整段 |
| 装什么 | 全部事件,13 种以上 | 只装那三种类型里当前在册的那些 |
| 顺序从哪来 | 发生的先后 | 声明出来的:每个产生消息的事件在写入时必须说清自己怎么加入 |
那个声明叫 surfaceOp,只有两种取值:
type SurfaceOp =
| 'append' // 加到尾巴上,常规路径
| { op: 'replace'; start: number; end: number } // 用这一条替换掉 start 到 end(含两端)
replace 是给压缩用的:对话太长顶不住上下文窗口时,把中间几十条替换成一条摘要。关键在于——被替换掉的事件仍然原封不动地躺在日志里,只是不在 surface 上了。所以人类还能翻到原文,遥测还能算出原始用量,只有模型看到的是摘要。这正是 6.3 里「一份日志多个读法」的具体兑现。
再看一个类型层面的巧劲:session.append() 对这三种类型强制要求传 surfaceOp,对其余类型则在编译期拒绝这个参数。也就是说,你没法「顺手」加一个产生消息的事件却忘了声明它怎么进 surface——编译就过不去。这比写在文档里靠人记要可靠得多。
常见误解
很多人以为 surface 是日志的一个「过滤视图」,即「日志里符合条件的那些事件」。不是。surface 是一个被显式维护的有序列表:每次 append 一个产生消息的事件,都在往这个列表上做一次操作(追加或替换),列表的当前内容是这些操作折叠的结果。区别在 replace 上暴露无遗——被遮蔽的事件完全符合「是 tool/result 类型」这个过滤条件,却不在 surface 上。
仓库要求:replace 声明的 start 和 end 都必须是当前 surface 上真实存在的节点,而且这条新事件的 sourceEventSeqs 必须包含每一个被遮蔽的节点。请构造一个反例,说明少了后半条要求会出什么事。
sourceEventSeqs 是给谁看的?它记录的是「这条事件由哪些早先的事件产生」。replace { start: 10, end: 30 },但 sourceEventSeqs 只写了 [10, 11]。现在让一个 transcript 工具去回答「seq 25 那条工具结果去哪儿了」。变式:如果 start === end,这条 replace 在做什么?举一个「替换单个节点」有意义的场景。
6.5 「模型可见即已记录」:一条会被代码检查的规矩
现在到了这一章最值钱的部分。仓库架构文档里有这么一句(docs/architecture.zh.md,逐字):
SessionEventMap 并从日志渲染。运行时不变量(runtime invariant):一条在程序运行期间必须始终为真的性质,并且有代码在运行时反复检查它——一旦不成立就当场抛异常,而不是留着让它慢慢发酵。
断言长什么样
它就在 packages/core/agent-loop/src/invariant.ts。做法是在 llm/stream 这个 waterfall(瀑布式事件 / waterfall)上挂一个监听器,而且带 prepend: true——挂到最前面。源码注释解释了为什么要抢第一位:防止某个会短路的回放监听器把这项检查静默掉。
每次循环发出的请求经过这里时,它逐项核对:请求必须是冻结的;必须带 sessionId,而且那个会话还活着;日志里必须已经有 step/start,必须已经有 request/header。然后是核心的那三行(真实源码):
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
翻译成人话:这次真正要发给模型的消息,必须和「此刻从日志算出来的消息」逐字符相等。不等就报错,错误信息里那个词很直白——log-reconstruction desync,日志重建失步。
紧接着它还核对请求头:model、system、temperature、maxTokens、stop、tools 六项,必须和从日志折叠出来的 request/header 逐项相等。
那条推论:你没有别的入口
现在设想你要给模型加一样新东西。比如:当前打开的文件列表、刚才 git 的改动摘要、用户在编辑器里选中的那段代码。
最省事的写法当然是——在组装请求的时候,往 messages 数组里临时拼一条进去。
这个写法在 dsh 里跑不起来。不是「风格不好」,是那条断言会当场炸:你拼进去的东西不在日志里,deriveMessages() 算不出它,两个 JSON 字符串不相等。
于是你只剩下一条路,三步:
- 通过声明合并扩展
SessionEventMap,声明一个新的事件类型(要给模型看,它就得是SurfaceEventType;只是记录用,就做成仅日志事件); - 在合适的时机
session.append()它; - 让投影从日志渲染它。
这就把「要不要记日志」从一个可选的好习惯,变成了硬约束。你不是被说服的,你是被堵死的。
实际操作里,dsh 已经给这件事准备好了官方入口:agent.inject()(架构文档的「新行为的归属位置」表里写着:「添加模型可见上下文 → 调用 agent.inject();它会落到下一次获准的请求中」)。注入进去的东西最终以 user/message 事件落进日志,靠 source 字段区分它到底是人类的提示词、还是合成的上下文(文件变更通知、子目录的 AGENTS.md、技能内容、定时任务通知……)。三者都逐字投影出 content,谁产生的由 source 说了算。
这条约束换来了什么
回头看 6.1 那五件事——撤销、fork、回放、崩溃恢复、记账。它们全都建立在同一个前提上:没有任何东西能绕过日志抵达模型。只要开一个后门(哪怕只是「临时拼一条系统提醒进去」这种看起来无害的),fork 出来的子会话就会和父会话行为不同,回放就会和当时不一样,审计就答不出「模型当时看到了什么」。五件事是一荣俱荣、一损俱损的,所以护栏必须是硬的。
读的时候要小心:这条断言的适用边界
本站核实:这条不变量不是无条件生效的,有三层限制,都能在仓库里查到。
其一,检查由 dsh-invariants 这个注册表服务驱动(packages/runtime-diagnostics/invariants),而它不在任何一个发行组合包里——packages/bundle/base/cordis.patch.yml 通篇没有它。真正挂载它的是参考组合 packages/examples/agent-spine-demo(源码里 ctx.plugin(InvariantRegistry, …) 之后连挂 session / agent / scope / agent-loop 四个配套插件)以及测试拓扑。
其二,服务本身带开关:enabled、package_allowlist、package_blocklist,可以按包名正则关掉任意一项检查。
其三,它只管被循环显式标记过的请求(markAgentLoopRequest / isAgentLoopRequest)。dsh-invariants 的 README 在「Known Limitations」里明说:直接发起的一次性 LLM 调用不在这个标记约定内,哪怕调用方自己冻结了请求、也附上了 session id。
本站推断(红框内容):把这条断言理解成「开发期与参考组合里的护栏」比理解成「线上运行时的强制」更准确——它保证的是违反这条规矩的代码活不过测试和参考组合,不是「生产部署里绝无可能违反」。仓库没有在任何一处明确说明发行 profile 是否应该挂载不变量服务。
你要做一个功能:每次请求前,把「当前时间」告诉模型(很多 agent 会答错「今天几号」)。请给出两种实现方案,一种会触发那条断言,一种不会;并说明不会触发的那种,在 fork 一个旧会话时会发生什么。
agent/pre-step 的返回类型 PreStepDecision——它可以返回 { kind: 'enter', messages: [...] },而循环随后会把这批消息作为 user/message 逐条追加进日志,再从日志派生历史。顺序是承重的。agent/request 或直接在请求组装处把一条「现在是 X 年 X 月 X 日」拼进 messages。断言当场失败,因为日志里没有这条消息(顺带一提,agent/request 的文档本来就写明「模型可见内容必须使用已记录的通道;这个 waterfall 无法改动消息」)。不会触发的方案——用 agent.inject() 提交一条带时间的上下文消息,或在 agent/pre-step 里把它加进 enter 的消息批次;它会先作为 user/message 落进日志,再被 deriveMessages() 算出来,两边自然相等。fork 一个旧会话时:那条时间消息作为历史事件被继承下来,所以子会话的历史里会带着当时的时间,而不是 fork 时刻的时间——这是正确行为,因为模型当时确实看到的是那个时间;新的时间会在子会话的下一次 pre-step 上以一条新事件的形式出现。这恰好说明了为什么必须走日志:只有这样,「模型当时以为今天是几号」才是一个可以回答的问题。(仓库里确实有一个 dsh-time-context 包,其不变量检查「持久的时钟读数与会话的开放轮次、下一次 pre-step 位置和基准时间一致」。)变式:如果你要加的不是给模型看的内容,而是「这次请求走了哪条路由、上下文窗口多大」这种纯记账信息,该做成 SurfaceEventType 还是仅日志事件?(提示:去看 request/context 是怎么处理的,以及它为什么没有被折进 request/header。)
6.6 为什么连碎片都要留着
assistant/chunk 记的是模型流式返回时的每一个原始分块。分块协议 StreamChunk 有七种变体:block-start、text-delta、reasoning-delta、tool-call-delta、block-end、usage、finish。模型说一句话,日志里可能就多出好几百条。
既然组装好的 assistant/message 已经有了,为什么还要留着碎片?三个理由,都很实在:
回放保真。重新打开一个会话时,界面能一模一样地重演当时的过程——文字逐字浮现、推理块和正文交错出现、工具调用的参数一点点流出来。因为每个 delta 的内容和先后顺序都在。
记账。usage 本身就是一种 chunk。失败的模型请求不会产生 assistant/message(源码里那次 append 在错误分支之后),但它的用量分片还在日志里——那是这次尝试唯一的持久记账记录。6.1 的第五件事,就是在这儿被解决的。
溯源。assistant/message 带一个 sourceEventSeqs,逐条列出自己由哪些 chunk 拼成。这里有个精细到值得停一下的区分:存在但为空的数组 [] 表示「提供方的流确实是空的,而且我们确知这一点」;字段整个缺失表示「没有记录这条消息由哪些早先事件产生」。空数组不等于没这个字段。
代价当然是日志又大又碎。仓库承认这件事,并且把边界划得很清楚:日志的语义不许省——持久化约定要求「无损保存每个事件,包括 assistant/chunk;seq 必须连续,因此不能从规范日志中过滤分片」——但存储的编码可以优化,JSONL 后端默认就把分片行打包存,load 返回的事件与追加时完全一致。
团队抱怨日志文件太大,提了三个方案。哪一个不违反仓库约定?为什么另外两个违反?
(A) 持久化时丢弃 text-delta,只保留 block-end;
(B) 把连续的分片行压缩成一个 Zstandard 帧存盘,读取时解开;
(C) 内存日志照旧,但只把最近 1000 条事件写盘。
load() 能返回的事件集合?load 返回与追加时完全一致的事件即可」。拿这句去卡三个方案。seq 还连续吗?」(A) 丢了事件,序号出洞;(C) 前面整段没了,序号从 1000 开始。seq 连续,而且更糟:sourceEventSeqs 和 surface 的 replace 声明会指向已经不存在的位置,恢复时整份日志读不通。这里的分界线是一句可以背下来的话:能压缩的是字节,不能省的是事件。变式:如果一个部署确实不需要 UI 回放(比如纯 headless 的批处理),能不能在写入时就不 append 分片?这会连带失去什么?
6.7 fork、恢复、崩溃:从同一条流派生
前面说了这么多约束,回报集中体现在这一节:三件看起来完全不同的事,实现上是同一件事——拿一段事件流当种子。
fork
ctx.sessions.fork(source, boundary?, childSessionId?)
boundary 是源日志里的一个 seq(含这一条),省略就是当前最后一条。子会话拿到深克隆的种子事件,加上自己的元数据:parentSession、seedLength、继承的 cwd。
有一条限制特别能说明设计口味:这段前缀结束时不能有开放的轮次,停在轮次中间的前缀会被拒绝而不是被悄悄截断。为什么宁可报错?因为悄悄截断意味着调用方以为 fork 到了 A 点、实际拿到的是 B 点,而他不会知道,直到某天两个分支的行为莫名其妙地不同。
恢复
从持久化后端把整份日志读回来,作为构造种子。种子之后的第一条实时写入是 session/end-seed——payload 是空的(Record<string, never>),位置和 time 就是它的全部含义。
为什么要这么一条空事件?因为种子历史和实时工作在字节层面完全相同。想象一个压缩插件,它有一对开闭括号 compaction/start … compaction/end。日志里现在有一个没配对的 compaction/start:是上次崩在压缩中途留下的,还是此刻正在压缩?光看字节,一模一样。有了这条边界,答案就明确了——在 session/end-seed 之前的未配对开括号,一律属于一个已经结束的生命周期,无论它是怎么结束的。
一个容易踩的细节:要找存储历史里最后一条 session/end-seed,而不能假定 firstLiveSeq 那个位置一定有一条——已经以它结尾的种子不会被重复标记,所以重新打开一个没被改动过的会话,日志不会因此变长。
崩溃
后端重新加载一份崩在轮次中途的日志时,会发现有 turn/start 却没有 turn/end。它不截断——长任务的一个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已经真的落盘了,扔掉就是扔掉真实历史。它改为合成一条 turn/end { reason: { kind: 'interrupted' } },在不动前后任何独立事件的前提下配平这次被中断的执行。
interrupted 是唯一一个循环永远不会自己发出的结束原因。看到它,就知道这条是恢复路径补的。
答辩:如果我是审稿人
你花了整整一章论证「日志不可修改」,结果崩溃恢复往里补了一条本次运行从没发生过的事件。这不就是在编造历史吗?既然这里可以补,那「不可修改」到底还剩多少约束力?
参考防守(先自己组织语言再看)
三点。
第一,补的是尾部追加,不是修改已有条目。「仅追加」禁止的是回头改第 3 条,不是在末尾写第 N+1 条。恢复路径没有动被中断轮次里的任何一个事件,它只是在后面加了一条闭合标记。所有依赖「已有条目不变」的性质——回放、fork 的共享前缀、sourceEventSeqs 的指向——一条都没被破坏。
第二,这条合成事件是自我标注的。interrupted 这个原因只有恢复路径会写,循环永远不发。所以读日志的人分得清哪条是机器补的、哪条是当时真的发生的——它没有伪装成 completed。真正该被批评的做法是补一条 { kind: 'completed' },那才叫编造。
第三,替代方案更糟。不补的话,日志里永远留着一个开放轮次,之后所有关于「轮次是否封闭」的检查都会失败,fork 会拒绝任何跨过它的前缀,这份会话实际上变成了只读的废墟。截断的话,等于删除已经落盘的真实历史——那才是真正违反不可修改。在「留下一个永久破损的日志」「删掉真实历史」「追加一条自我标注的闭合标记」三者之间,第三个是唯一保住全部已有性质的选项。
真未解一份仅追加日志遇到并发写入方,怎么判断某个开括号的主人还活着?
仓库把「种子历史 vs 实时工作」这个问题解得很干净:session/end-seed 之前的未配对开括号一律属于已结束的生命周期。但它在同一段文档里主动交代了这个解法的边界(docs/subsystems/session.zh.md,逐字):「这只覆盖本会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。」
仓库说了需要那个信号,但没有给出它。而这确实是一个没有公认答案的问题:只靠日志字节,你无法区分「另一个进程正在写,暂时还没闭合」和「另一个进程死了,永远不会闭合」——这在分布式系统里就是失败检测(failure detection),已经被证明在异步网络下无法完美实现。任何解法都得引入日志之外的东西:租约、心跳、文件锁、单写入者约定。各有各的代价,选哪个取决于部署形态,而 dsh 目前的姿态是把这个问题挡在门外(单写入者)。
怎么入手:先读 packages/core/session/src/index.ts 里 firstLiveSeq 的那段 JSDoc 和 session/end-seed 的注释,把「本生命周期」这个概念的确切定义抠出来;再去 packages/session/session-persistence/ 看崩溃修复路径,确认它只关闭轮次、步骤、工具边界,而从不处理 compaction/*——想清楚为什么核心敢碰前者不敢碰后者。然后自己构造一个场景:两个进程同时以同一个会话 id 打开同一份日志,各写各的,看会发生什么;再想一想,如果要让它安全,你最少需要在日志之外增加什么(提示:先问「谁有权写」,再问「怎么知道他还在」)。
fork、恢复、崩溃修复,这三件事在实现上共用哪一个机制?各自又靠什么把自己和另外两个区分开?
parentSession;恢复和 fork 都在种子之后留 session/end-seed;崩溃修复留一条 turn/end { reason: interrupted }。ctx.sessions.create(id, { seed, meta }) 是底层的回放/fork 原语,三条路都从它下来。区分:fork 的 header 带 parentSession 和 seedLength,种子是源日志到 boundary 为止的深克隆前缀;恢复 的种子是完整的存储日志,header 保留原来的 fork 值(所以「恢复的会话」和「fork 出来的会话」在 header 上仍然分得清);崩溃修复不是一条独立的创建路径,它是恢复的一个子步骤——加载时发现尾部有开放轮次,就补一条 interrupted。三者之后的第一条实时写入都是 session/end-seed,因为对新的生命周期来说,「我从哪里开始写」是同一个问题。变式:一个从空日志恢复的会话,和一个全新创建的会话,日志上有区别吗?(提示:仓库为此专门规定了一条行为——显式传入的空种子会在 seq 0 写入 session/end-seed。)
把消息数组换成仅追加日志 + 投影函数
到上一章为止,你的 mini-harness 里还是一个 messages 数组。这一层把它拆成两半:一份只往后加的日志,和一个从日志算出模型历史的函数。核心动作只有一个——删掉那个数组,别舍不得。
难点在两处,都不显然。
其一:为什么不顺手缓存?每次发请求都从头遍历整份日志,看起来蠢。dsh 确实缓存了——每个 surface 节点只投影一次,surface 被重写时整体重建。但注意它缓存的方式:缓存藏在函数内部,外面拿不到第二份可写的历史;deriveMessages() 每次返回一个新数组,里面的消息对象全是深冻结的。先把没缓存的版本跑通,确认结果对了再加缓存——顺序反了,你分不清哪个 bug 是投影规则写错了、哪个是缓存没失效。
其二:surfaceOp 这个参数为什么不能省?你可能想「按 type 判断不就行了吗,三种类型进 surface,其他不进」。在只有 'append' 的世界里确实等价。但等你后面要做压缩(用一条摘要替换掉一段历史)时,「按类型判断」就彻底失效了:被遮蔽的那些事件类型完全正确,却不该出现。现在先写上这个参数,你后面能少推翻一次。
做对了的样子:跑几轮对话,然后把日志倒回第 3 条(log.length = 3),再调一次 deriveMessages()——得到的模型历史必须和「当年第 3 条刚写完那一刻」一模一样,用 JSON.stringify 比一下应该逐字符相等。不相等,说明你的投影读了日志之外的东西:去找是不是有个变量在 append() 之外被改过,或者某条消息对象被后来的代码原地改写了(Object.freeze 会帮你把这种改写变成静默失败或抛错,取决于是否严格模式——这正是要冻结它的原因)。
本章小结
消息数组存的是状态,而撤销、fork、回放、崩溃恢复、记账这五件事问的全是过程——所以要换成一份仅追加日志:只往末尾加,从不修改已有条目。「从不修改」不是洁癖,它是可回放、可审计、可 fork 这三条性质的唯一支点,dsh 用写入点的深冻结和 JSON 校验把它做成了结构上的不可能。
模型历史不再被单独存储,而是从日志投影出来(deriveMessages())。同一份日志还能投影出人类看的 transcript、账单、各领域的 UI 状态——它们互不打架,因为都从同一个真源算。产生消息的只有三种事件类型(user/message、assistant/message、tool/result),它们必须在写入时声明自己怎么加入 surface:追加到尾部,还是替换掉一段(压缩用)。被替换掉的事件仍然留在日志里,只是模型看不见了。
整章最硬的一句是「模型可见即已记录」:抵达模型请求的一切都必须能从日志重建,packages/core/agent-loop/src/invariant.ts 里有代码逐字符核对这件事。它的推论是一条硬约束——想给模型加一样新输入,就必须新增一个会话事件类型并从日志渲染,没有第二条路。这条约束换来的正是开头那五件事。fork、恢复、崩溃修复则共用同一个机制:拿一段事件流当种子。
下一章进入循环本身:轮次和步骤到底怎么划分,输入通过什么排队进来,以及那条流程上的每一环「没有它会怎样」。
第7章 轮次与步骤:循环真正的样子
一句话导语:这一章把第0章那个 20 行的 while 循环拆开,换成真实版本——它多出来的每一样东西,都是为了回答一个「没有它会怎样」的问题。
学完这一章你应该能做到
- 严格区分轮次、步骤、Round 三个词,并说出「零个步骤的轮次」为什么必须存在
- 解释 inbox 的两条列表为什么数量规则不一样,以及注入的上下文为什么不唤醒驱动器
- 照着轮次流程逐环回答「去掉这一环会坏在哪里」
- 判断一个新功能该挂在会话事件、agent 事件还是能力事件上,并说清选错的后果
- 区分 waterfall 与 serial 两种分发模式,说出
agent/turn-stopping为什么故意不给否决权
7.1 那个 while 循环缺了什么
第0章的循环大致长这样:
while (true) {
const reply = await callModel(messages)
messages.push(reply)
if (!reply.toolCalls) break
for (const call of reply.toolCalls) messages.push(await runTool(call))
}
它能跑,而且是对的——真实的循环骨架就是这个形状。dsh 的实现在 packages/core/agent-loop/src/agent.ts,一个叫 ReactLoopAgent 的类,不到五百行。多出来的东西可以归成四类:
一、输入不是一条一条同步来的。人类会在模型正思考时又打一句;插件会往里塞上下文(文件变了、定时任务到点了);工具执行完还会带回额外上下文。这些得排队,得有规矩决定谁在什么时候进来。
二、得有人能在请求发出之前拦一下。审批要问「这一步能不能做」,压缩要在上下文顶不住时先动手,钩子要能直接否掉一次尝试。
三、得记账。第6章讲的日志需要边界事件:这一段从哪开始、到哪结束、为什么结束。没有边界,日志就是一锅粥。
四、得能被取消、能从错误里爬起来。用户按了 Esc、模型请求超时、上下文溢出——每一样都得有确定的收场方式。
要把这四类东西安放好,首先需要一套能精确说出「现在在哪」的词。这就是轮次和步骤。
上面那个 while (true) 循环隐含了一个很强的假设,正是它让「用户中途插话」无处安放。是哪个假设?
messages 只在循环自己的两处被追加(模型回复、工具结果)。外部想加东西,只能趁循环没在跑的时候。await callModel(...) 正在等待时打了一句话。这句话能进 messages 吗?如果直接 push 进去会怎样?break 才重新对外开放。中途插话的三条路全是死路:直接 push 进 messages——这个数组可能已经被复制进正在飞的请求里,改了也没用,而且会让下一次请求的历史和日志对不上(第6章那条断言会炸);打断当前请求——代价太大,模型说了一半的话全废;丢掉——用户会觉得程序坏了。真正的出路是在循环里显式地开出一个「可以接收新输入」的时刻,也就是步骤边界,再配一个队列(inbox)放那些还没轮到的输入。变式:如果只允许「打断当前请求」这一条路,你会失去哪些第6章讲过的能力?(提示:想想已经流出来的那些 assistant/chunk 和已经花掉的 token。)
7.2 轮次、步骤、Round:三个不能混的词
仓库的术语表(docs/glossary.zh.md)给了三个定义,一字不差抄在这里:
轮次(turn):会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。
步骤(step):一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。
Round:承载一个轮次的外层策略迭代,例如一个 Goal Round 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。
架构文档(docs/architecture.zh.md)给的是更好上手的操作化说法:「一个步骤是一次模型请求加上它调用的工具。一个轮次包含零个或多个步骤:它在领取首条输入之前打开,并在不再欠下任何工作时关闭。」
三者的关系要一次说清楚:Round 不是轮次的复数,也不是轮次的同义词。Round 是外层策略的计数——一个目标(goal)允许自动续跑几次、一次 Ralph 工作流跑了几个全新子会话。一个 Round 会具体化成一个轮次,但同一个会话里那些无关的人类轮次不消耗 Goal Round 上限。所以「跑了 5 个 Round」和「日志里有 5 个 turn/start」是两句不同的话,混着说会把上限算错。
「零个步骤」为什么必须存在
正常情况下一个轮次至少有一个步骤:问一次模型,可能再调几个工具。但被拒绝的输入仍然会留下一个不含步骤的轮次。
直觉上这很别扭:既然什么都没做,为什么还要开一个轮次、往日志里写两条事件?
因为日志要记录这次尝试。设想一个权限钩子:用户发了一句危险的指令,钩子在 agent/pre-step 里直接拒绝。如果什么都不记,日志上这句话就像从来没发生过——可它确实发生了:消息进过 inbox,被领取了,然后被挡下来了。第二天有人问「我明明发了消息,为什么什么都没有」,日志得能回答。
看真实源码里 turn() 的骨架(packages/core/agent-loop/src/agent.ts):
this.session.append('turn/start', { turn }) // 先写边界,再去领输入
...
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
if (turnEnds && decision.messages.length === 0) break
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
三个细节值得咬一咬:
其一,turn/start 写在最前面——在 claim 之前、在 pre-step 之前。这是刻意的:边界必须先于任何可能失败的动作存在,否则失败就没地方记。
其二,两条早退路径的结束原因不一样:被拒绝的记 { kind: 'blocked' },被改写为空的记 { kind: 'completed' }。因为这是两件事——一个是被人挡下,一个是本来就没内容可说。
其三,两条路径都从 return false 走掉,而 turn/end 写在 finally 里。也就是说轮次一定会闭合,哪怕中间抛了异常。
还有一层更细的:被领取的消息在被拒绝之后就到此为止了。agent/inbox/claimed 的文档原话是:「如果拟进入的步骤被拒绝,被领取的消息就在此结束:它既不会被标为 discarded,也不会重新作为 user/message 发出,轮次不含步骤就关闭。」被拒绝既不等于被丢弃、也不等于被消费,它是第三种结局。
这个细节在说明什么
设计者没有把「边界」和「内容」混为一谈。轮次边界记的是发生过一次尝试,步骤记的是真的问了模型一次。两件事分开记,日志才答得上「试了几次」和「花了几次钱」这两个不同的问题。如果图省事把它们并成一个概念——比如「没步骤就不开轮次」——省下的是两条日志,赔进去的是「用户的输入到底有没有被系统接到」这个可追溯性。
下面这段日志片段合法吗?deriveMessages() 会从它得到什么?seq 0: turn/start {turn:1}seq 1: turn/end {turn:1, reason:{kind:'blocked'}}seq 2: turn/start {turn:2}seq 3: step/start {turn:2, step:1}seq 4: user/message {...}
step/start。这违反了什么规则吗?回去看轮次的定义里「零个或多个」那四个字。dsh-session 的不变量配套插件检查(packages/core/session/src/invariant.ts)。它盯的是轮次编号递增、轮次不能套轮次、步骤必须在开着的轮次里、turn/end 时不能还有开着的步骤。逐条对一遍。turn/start 要求 event.data.turn 恰好等于「下一个轮次号」,而下一个轮次号在每次 turn/end 后加一。seq 2 的 turn=2,对不对?再看 step/start 的编号规则——每次 turn/start 会把 nextStep 重置为 1。reason 是 blocked 也和「被拒绝」对得上。轮次 2 编号正确(1 之后是 2),步骤编号从 1 重新开始(turn/start 会把 nextStep 复位),user/message 落在开着的步骤里。deriveMessages() 得到一条 user 消息——只有 seq 4。轮次和步骤边界都是结构信息,不产生任何消息;被拒绝的那次尝试在日志里留下了痕迹,但在模型眼里它从未存在过。这两句话同时成立,正是第6章「日志 ≠ surface」的直接兑现。变式:把 seq 1 改成 turn/end {turn:1, reason:{kind:'interrupted'}},这份日志还讲得通吗?(提示:interrupted 是谁写的?循环会不会发出它?)
7.3 inbox 与 claim:输入怎么排队进来
如果输入直接写进历史,「模型正在生成时用户又发了一句」就没有安放的地方(7.1 那道题)。所以需要一个队列。
inbox(收件箱):agent 拥有的两条有序待处理消息列表。所有输入都通过它抵达驱动器。
type InboxTarget = 'next-turn' | 'next-step'
next-turn 是「等一个属于自己的新轮次」,next-step 是「等最近的一个步骤边界」。Agent 接口上有三个固定预设的别名方法(packages/core/agent/src/types.ts),它们都是 send(message, target, wakeup) 的特例:
| 方法 | 落到哪条列表 | 唤醒驱动器吗 | 典型用途 |
|---|---|---|---|
followup(m) | next-turn | 是 | 人类的下一条提示词 |
steer(m) | next-step | 是 | 中途引导:「等等,别改那个文件」 |
inject(m) | next-step | 否 | 合成上下文:文件变更通知、定时任务、技能内容 |
inject 那个「否」是这一节最值得停下来想的地方。它的 JSDoc 原话是:「在不唤醒驱动器的前提下,为下一次 pre-step 排入面向模型的上下文。运行中的驱动器会在最近的后续步骤边界领取它;空闲的驱动器则让它一直待着,直到 followup 或 steering 把自己唤醒。它可能错过一次 pre-step 已经领走批次的请求。」
为什么要这样设计?因为注入的上下文本身不构成一件要做的事。文件被改了、cron 到点了、子目录里发现一份 AGENTS.md——这些是「下次说话时顺带告诉模型」的背景,不是「现在就去干活」的理由。如果 inject 也唤醒,agent 会因为你在编辑器里保存了一下文件就自己醒过来发一次请求,白花钱还可能自作主张改点什么。这一条正是理解 steering(中途引导)与注入区别的钥匙:两者走同一条列表,区别只在唤不唤醒。
claim:一次领走多少
claim(领取):在步骤边界上,驱动器从 inbox 里取走这一步要用的那一批消息。
claim(target, turn) {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) // 全部 next-step
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false)) // 外加一条 next-turn
}
for (const message of claimed) this.notifications.claimed(message, turn)
return claimed
}
读法:全部 next-step 输入,外加轮次边界上的一条 next-turn 消息。
这两个数量不对称,是故意的。next-step 里装的是「顺带带上的东西」——steering、注入上下文、工具带回的额外上下文——它们都是给同一次请求当背景的,一次带完才合理。next-turn 里装的是「一件要做的事」,followup 的文档写得很死:「该条目成为它自己那个轮次里唯一的普通消息。」两件事就两个轮次,不合并——合并了你就分不清模型是在回应哪一件。
还有一层容易被忽略:inbox 本身也是持久的。append、prepend、replace、remove、clear、splice 和 claim 都会往会话日志里记一条规范化的 agent/inbox/spliced(这个事件类型由 dsh-agent 包通过声明合并加进 SessionEventMap——正是第6章那条推论的实例)。而 claim 记的是纯删除的 splice,并且不发 discarded 通知,循环另外逐条发 agent/inbox/claimed。为什么要区别对待?因为「被领走去执行」和「被取消掉」在界面上是完全不同的两件事,一个进入运行、一个直接消失。
一个插件在 agent/session-start 时调用了 agent.inject(),塞进一条工作区说明。会话创建后用户一直没说话。请问:模型会看到这条说明吗?如果用户十分钟后发了一句「你好」,模型看到的第一批消息是什么?
inject 唤醒驱动器吗?没有驱动器在跑的时候,被注入的消息待在哪里?followup,落 next-turn 并唤醒。驱动器开一个轮次,然后 claim(target = 'next-turn')。照着上面那段 claim 源码走一遍,看它会拿到几条。followup 唤醒驱动器,轮次打开,claim 先取走全部 next-step(那条工作区说明),再取走一条 next-turn(「你好」)。所以模型看到的第一批是两条 user 消息,顺序是先说明、后你好——因为 claim 里 next-step 那一批排在前面。它们随后作为两条 user/message 事件写进日志,再一起被 deriveMessages() 算出来。仓库的钩子桥接文档也印证了同一件事:SessionStart 不生成 hook/* 记录,因为它在轮次 1 之前运行,其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次。变式:如果这个插件改用 steer(),行为会怎么变?对一个从没打算和 agent 说话的用户来说,这个改动的后果是什么?
7.4 agent/pre-step:决定模型看到什么的那道闸
架构文档只用了一句话:「agent/pre-step 决定模型看到什么。」这句话的分量在于——它是请求推导之前唯一的串行监听器链。
waterfall(瀑布式事件 / waterfall):一串监听器排成一队,每个都拿到当前的值,然后二选一——调 next() 把决定权交给下一个(并拿到下游的结果),或者直接返回一个值不调 next(),从而短路,后面的全都不跑。
它的返回类型只有两种可能:
type PreStepDecision =
| { kind: 'reject' } // 不开步骤
| { kind: 'enter'; messages: UserMessage[] } // 用这批消息进入步骤
payload 里带着:已领取的批次 messages(独占,别人拿不到)、拟进入步骤的坐标 turn 和 step、当前轮次的取消 signal。三种用法:
- 放行:调
next()。文档明说「调用next()会保留当前的消息」。 - 改写:返回
{ kind: 'enter', messages: [...] },换一批进去。dsh-compaction-basic就是在这里处理上下文压力的——在派生请求之前动手,而不是等请求失败了再补救。 - 拒绝:返回
{ kind: 'reject' }。不开步骤,轮次以blocked收场(7.2)。
两条容易被忽略的规则:最终决策省略掉的已领取消息保持已删除——你改写时丢掉的那条不会自己飘回 inbox;而领取之后才插进来的输入仍留待后续处理——它没被这一批带走,会等下一次 claim。
默认行为是什么?看真源码里的 preStep():没有任何监听器时,决策是「领到什么就进什么,外加一条运行时上下文(如果有)」。也就是说这道闸默认全开。
最后划一个重点:steering 和注入的上下文经过同一个 waterfall。它们不是走后门进来的。等下一次 claim 把它们收进批次,它们和人类的提示词一样要过这道闸。这跟第6章那条不变量是同一套思路——只留一个入口,所以只需要在一个地方拦。
常见误解
很多人以为 agent/request 也能改消息,毕竟它就在请求发出前。不行。agent/request 的文档原话是:「模型可见内容必须使用已记录的通道;这个 waterfall 无法改动消息。」它能换掉的只有那份冻结的调用配置——提供方、模型、采样参数。想改消息,只有 agent/pre-step 这一条路,因为只有它改的东西会先落进日志再被派生出来。
两个插件都监听 agent/pre-step。A 想在每批消息前面加一条系统提醒,写法是:读到 payload.messages,返回 { kind: 'enter', messages: [提醒, ...payload.messages] },没有调 next()。B 是压缩插件,也监听这个事件。运行时会发生什么?
next() 意味着什么?next() 才能委托下去;不调就短路整条链。」const inner = await next() 拿到下游的决策,再基于 inner 改写并返回——这样 A 既加上了自己的提醒,也保留了 B 的工作。仓库的 agent-lifecycle 文档同样强调:「通过包装 next() 的监听器会保留下游消息,除非有意替换这些消息。」变式:如果 A 想做的是「无条件拒绝一切」(一个只读模式插件),它应该短路吗?此时不调 next() 算不算违反约定?
7.5 一次轮次的完整流程
下面这张图和后面那张表,是这一章的核心。先看整体形状,再逐环问「没有它会怎样」。
docs/architecture.zh.md 的 turn-flow 代码块与 packages/core/agent-loop/src/agent.ts 的 turn() / step() 绘制,非仓库原始图示。现在逐环追问。这张表建议对照上面的图读一遍,每一行都试着自己先答,再看右边那一栏。
| 环节 | 它做了什么 | 没有它会怎样 |
|---|---|---|
turn/start | 在日志里开一个轮次边界 | 分不清「两条消息在同一轮次」和「两个轮次各一条」;fork() 失去判据(它拒绝结束在开放轮次里的前缀);崩溃恢复认不出「崩在半路」 |
| claim | 取走这一步的输入批次 | 输入没有明确的「进入时刻」,并发送来的消息会和正在飞的请求打架 |
| 组装提示词与工具 schema | 把插件注册的片段和工具 schema 收集成这一步的前缀 | 系统提示词和工具清单只能写死在循环里,插件无从贡献;每个步骤都重新组装一次,所以插件热加载后下一步就能看到它的工具 |
agent/pre-step | 唯一一次改写或拒绝的机会 | 审批、压缩、钩子桥接全部失去落脚点,只能去改循环源码 |
step/start | 给这次模型调用编号 | assistant/chunk、tool/call 等事件的 payload 里都带 turn 和 step,没有边界它们就挂不上坐标;不变量检查也无从确认「这个事件属于当前打开的步骤」 |
user/message* | 把进入这一步的消息写进日志(在 step/start 之后) | 这一步给模型看的东西不在日志里,第6章那条断言当场炸 |
| 派生历史 | session.deriveMessages(),算出要发的消息 | 退回「两份真源」,日志和请求会悄悄分叉 |
agent/request | waterfall,可替换这一次的调用配置 | 没法按步骤换模型、换采样参数、做路由降级 |
llm/stream | waterfall,适配器的接缝 | 提供方不可替换,录制/重放/注入式测试没有挂载点;第6章那条运行时不变量也正是挂在这里(prepend: true) |
assistant/chunk* | 逐条记下原始分块,并收集它们的 seq | 回放和 UI 保真全没了;失败请求的用量也没地方存 |
assistant/message | 组装好的权威回复,带 sourceEventSeqs | 模型这一步说了什么就只剩碎片,没有权威版本;碎片和成品之间的对应关系也断了 |
tool/call* | 记下模型要调哪个工具、参数原文 | 日志里只有结果没有请求。注意参数存的是模型产出的原始 JSON 字符串,不解析——因为模型可能吐出不合法的 JSON,日志要记它真的说了什么,而不是我们理解成了什么 |
三个 tools/* | 权限、沙箱、超时、重试、结果改写的挂载点(都是 waterfall) | 这些策略只能写进循环,且无法按工具族复用 |
tool/result* | 面向模型的执行结果 | 模型看不到工具干成了什么,只能瞎猜。它是三种 SurfaceEventType 之一,投影成一条带 tool-result 块的 user 消息 |
step/end | 闭合步骤(写在 finally 里) | 步骤不闭合,不变量会拒绝下一个 step/start(报「step N while step M is still open」)。写在 finally 里意味着哪怕这一步抛异常也要闭合 |
| 还欠工作 → 再来一步 | 判据是「本步骤已给出结束原因 且 next-step 为空」才收手 | 工具结果永远送不回模型(调完工具就结束了),中途插进来的 steering 也没机会被消费 |
agent/turn-stopping | serial,轮次关闭前的最后一道检查点 | 没有「最后喊一嗓子」的机会——目标续跑、循环卫生检查这类策略无处安放 |
turn/end | 带结束原因闭合轮次(也在 finally 里) | 日志里留下永久开放的轮次,之后一切封闭性检查全失败,这份会话变成只读废墟 |
关于 turn/end 的 reason,多说一句:它是一个可合并扩展的和类型,核心给了六种——completed、aborted、blocked、error、max-tokens、interrupted。其中 max-tokens 是粘性的:只要轮次里任何一个步骤撞上了输出上限,整个轮次就以 max-tokens 结束,哪怕后面还有步骤正常完成(源码注释:截断这个事实优先)。而 interrupted 是唯一一个循环永远不会自己发出的原因——它由崩溃恢复合成。
仓库把 user/message 的追加放在 step/start 之后,而不是之前。同时它把 step/end 和 turn/end 都放在 finally 里。请解释这两个选择各自防住了什么,并说明如果把 user/message 挪到 step/start 之前,会破坏哪一条已经写在代码里的检查。
step/start 之前的那一刻,日志处于什么状态?轮次开着,但步骤还没开。packages/core/session/src/invariant.ts 里的检查会对每个事件问「它是不是在当前打开的步骤里」。去看它对 user/message 这一 case 的处理,再看它对 todo/write、request/header 那几个 case 的处理,比较两者。finally 保证的是边界一定成对。分别问:哪一种故障会让坐标不可用?哪一种故障会让边界落单?顺序——把消息写在
step/start 之后,是为了让「模型这一步看到了什么」和「这一步的编号」在日志里天然绑定。更实际的是失败路径:pre-step 可能拒绝,也可能被改写为空,这两种情况下根本不该有任何 user/message 落地;如果先写消息再判断要不要开步骤,你就得在拒绝时把已经写进去的事件收回来——而日志是仅追加的,收不回来。顺序在这里替代了回滚。finally——防的是异常和取消。模型请求超时、工具抛异常、用户按 Esc,任何一条路径下步骤和轮次都必须闭合,否则日志留下不配对的开括号,后续 step/start 会被不变量拒绝,fork() 会拒绝跨过它的任何前缀。挪到之前会破坏什么——
packages/core/session/src/invariant.ts 里的 requireOpenStep 系列检查。不过要说准确一点:user/message 这一 case 在那份检查里恰好是放行的(case 'user/message': break),因为注入的上下文允许出现在轮次之间。真正会立刻炸的是紧随其后的东西——这一步派生出的历史会包含一条本不该存在的消息,而 agent-loop 的重建不变量逐字符比对请求与 deriveMessages(),在被拒绝的那条路径上,日志里多出来的消息会让下一个步骤的请求与派生结果出现难以定位的偏差。所以正确答案是:这个顺序不是被某一条断言逼出来的,而是被「仅追加、不可回滚」这条更底层的性质逼出来的。变式:如果一次模型请求失败了并且没有插件接手恢复,日志里这一步会留下哪些事件?(提示:数一数 assistant/chunk、assistant/message、step/end 各有没有。)
模型在某一步没有调用任何工具,回答完就停了。此时 next-step 列表里恰好有一条刚被 inject() 塞进来的文件变更通知。这个轮次会不会就此结束?
if (turnEnds && this.inbox.nextStep.length === 0) break。两个条件都成立才 break。target 改成 'next-step',再走一圈:claim 领走那条通知,pre-step 放行,开一个新的步骤,把通知作为 user/message 写进日志,再问一次模型。这正是「注入的上下文会留在 inbox 里,直到另一条消息把它唤醒」的后半段——它自己不唤醒驱动器,但只要驱动器已经在跑,它就会在最近的步骤边界被领走。顺带注意一个执行顺序:在 break 之前,循环会先跑
agent/turn-stopping(条件同样是「本步骤有结束原因且 next-step 为空」),跑完之后再检查一次同样的条件——因为监听器可能在这期间 steer 了新消息进来。变式:如果那条通知是用 followup() 投递的(落 next-turn),这个轮次会怎样收场?下一个轮次又会是什么样?
7.6 三个事件域:大多数改动的第一个决定
架构文档里有一句话,值得当成口诀:「事件就是扩展点,而选对事件域是大多数改动的第一个决定。」
三个域(逐字引自 docs/architecture.zh.md):
- 会话事件是追加到日志并通过
session/event广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。 - Agent 事件(
agent/*)携带活跃Agent:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。 - 能力事件无需导入循环即可向某个 seam(
fs/*、tools/*、telemetry/*)附加策略和适配器。
为什么说这是「第一个决定」?因为选错了域,你写的东西会在某个时刻静默消失或静默变质,而且不会报错:
该持久的写成了 agent 事件——进程一重启,那件事就没了。日志里没有它,fork 出来的会话不知道它,恢复回来的会话当它从没发生。而你在开发时完全看不出问题,因为进程一直没重启。
该实时的写成了会话事件——它进了日志,从此就要跟着 fork、跟着回放、跟着持久化格式版本走。一个本来只是「界面要闪一下」的东西,变成了磁盘格式的一部分;以后想删掉它,是一次破坏性的格式变更。
该挂在能力 seam 上的写成了 agent 事件——你的插件被迫依赖 agent-loop 包,而仓库的规矩是「扩展插件依赖 agent,绝不直接依赖 agent-loop」,为的就是让循环保持可替换。一旦有插件直接依赖了具体循环,换循环这件事就做不成了。
那么,流程上哪些是持久的?架构文档给了名单:turn/*、step/*、user/message、assistant/* 和 tool/* 是持久会话事件;其余是分属三个事件域的实时扩展点。
waterfall 与 serial
同一条流程上还有第二个维度:这个事件的监听器怎么被调用。
| 模式 | 哪些事件 | 监听器怎么写 | 能否否决 |
|---|---|---|---|
| waterfall(瀑布式) | agent/pre-step、agent/request、llm/stream、tools/pre-execute、tools/execute、tools/post-execute | 必须调 next() 才委托下去;不调就短路,后面的都不跑 | 能——短路返回自己的值就是否决 |
| serial(串行) | agent/turn-stopping | 没有 next(),逐个 await | 不能——见下面的答辩 |
waterfall 的坑在 7.4 那道题里已经踩过:忘了调 next() 是一个安静的 bug,不报错,只是后面所有监听器都没跑。仓库把它写进了全仓约定,大写强调:Waterfall 监听器 MUST call next()。
读的时候要小心:事件清单会变,而且文档已经有一处对不上
仓库处于 developer preview,官方明说会有破坏兼容性的变更,日志格式尤其如此——SESSION_FORMAT_VERSION 目前停在 0,仓库根目录的 AGENTS.md 直接写着「no compatibility promise」。所以下面这些具体形态请一律以你手上的仓库为准,本站读的是 commit 47f9438。
本站核实的一处文档不一致(值得作为练习去验证):docs/subsystems/core.zh.md 的「会话」一节列出「十二种事件变体」,其中包含 steering/message,并在「六个规范 map」表里列了 TurnTriggerMap。但按 docs/subsystems/session.zh.md 与源码 packages/core/session/src/types.ts:核心的 SessionEventMap 是 13 种,没有 steering/message,多出 request/context 与 session/end-seed;steering/message 只作为历史遗留类型存在于 packages/session/session-persistence/src/coordinator.ts 的迁移代码里(加载旧日志时被就地升级成 user/message);TurnTriggerMap 在整个 packages/ 里已经搜不到,而 session.zh.md 明说「turn/start 没有 trigger 字段」——同一份迁移代码里还有一个专门删掉旧 trigger 字段的函数。
本站推断:这说明 core.zh.md 那一段没有跟上一次重构。读仓库文档时,越是「概览性」的页面越容易过期,遇到冲突以子系统页和源码为准。这本身也是一条读大型仓库的通用经验——但它是本站的判断,不是仓库的说法。
你要做一个「本次会话的审批记录」功能:每当一个工具因为权限被拒绝,都要留下一条记录,要求重启后仍然看得到、并且不能进入模型的对话历史。请给出你的事件域选择,并说明为什么另外两个域都不行。
SurfaceEventType。SurfaceEventType 是哪三个,再想想:一个插件通过声明合并加进 SessionEventMap 的新类型,能不能成为 SurfaceEventType?SessionEventMap 加一个新类型,append 时不带 surfaceOp(对非 surface 类型,编译器本来就拒绝这个参数),于是它落进日志、跟着持久化和 fork 走,却在 deriveMessages() 里投影成 null。仓库里已经有现成的同类:dsh-hook-protocol 的 hook/invoked / hook/result、压缩的 compaction/*、以及 todo/write,全都是仅日志事件。agent 事件不行:它只在进程内广播,重启后一条不剩,直接违反第一个要求。能力事件也不行:
tools/* 那三个 waterfall 是「拦截并改写一次调用」的地方,不是记录事实的地方;你在监听器里当然可以顺手 append 一条日志,但那说明真正承载事实的仍然是会话事件——能力事件只是触发点。这里有个通用的判据:先问「这件事需要活多久」,再问「谁需要看到它」;前者选域,后者选是不是 surface。最后补一条仓库的硬规矩:一个
SessionEventMap 成员默认是「读取时必需」的——遇到不认识的类型且没有 ignorable: true 标记时,读日志的一方必须拒绝重建整个会话,而不是悄悄跳过。所以加一个新事件类型不是白加的,它会影响所有读这份日志的程序。变式:如果需求改成「记录还要能被模型看到,好让它知道自己被拒绝过」,方案要怎么改?(提示:核心的三种 SurfaceEventType 是固定的,你只能借道其中一种。)
7.7 记账和给模型看,是两件事
assistant/message 会记录每一次成功的提供方调用——包括返回空内容的,以及以 max-tokens 结束的(撞上输出上限,一个字都没吐完整)。
而 deriveMessages() 的规则是:内容为空的 assistant/message 跳过。
两条规则放一起,意思是:这条事件存在,但它不进模型历史。
为什么要这么拧巴?因为它同时要满足两个互相冲突的需求。
记账那边:这次调用真的发生了、真的消耗了 token。用量、提供方、模型都得留下来。丢了它,账就对不上,而且是永久对不上——日志是唯一的账本。
给模型看那边:一条没有内容的 assistant 消息塞进 transcript,对某些提供方直接就是非法请求,对所有提供方都是噪音。源码注释写得很直白:无内容的 assistant 轮次不得进入提供方 transcript。
sourceEventSeqs 在这里还有一个精细区分(第6章提过,这里是它真正派上用场的地方):assistant/message 可以带一个存在但为空的数组,表示「提供方的流确实是空的,而且我们确知这一点」;字段整个缺失表示「没有记录这条消息由哪些早先事件产生」。空数组不等于没这个字段。
更极端的是失败的模型请求:它连 assistant/message 都不会有(源码里那次 append 排在错误分支之后)。但它的 assistant/chunk { type: 'usage' } 还在日志里——那是这次尝试唯一的持久记账记录。
答辩:如果我是审稿人
agent/turn-stopping 是 serial,监听器不能直接说「别停」。它想让轮次继续,必须去调 agent.steer(...) 塞一条消息,然后机器重新读一遍 inbox。这不是绕远路吗?给一个返回值 false 不是更直接、更好懂?
参考防守(先自己组织语言再看)
返回 false 会让监听器顺序变成语义的一部分。设想两个监听器,一个说停、一个说不停,最终结果取决于谁先注册——而注册顺序是插件加载顺序的副产物,没有任何人在管它。同一份配置在两台机器上加载顺序稍有不同,行为就不一样,而且这种 bug 几乎不可能复现。
改成「塞一条消息」之后,决定权交给了数据:inbox 里到底有没有新的 steering,是所有监听器都跑完之后可以客观读到的一个事实。文档把这件事说得很干脆:由数据决定,所以监听器顺序不能改变结果。
代价确实有:想让轮次继续的监听器必须真的提供内容(一条消息),不能只投一张反对票。但这大概正是设计者要的——一个没有内容的「别停」,模型也无从继续,它只会再问一次同样的问题然后再次停下。把「反对」和「提供下一步的材料」绑在一起,反而堵住了死循环。
还有一个对称的证据:反方向的控制(提前结束工具循环)同样是数据——一个工具结果携带 concludesTurn 标记,轮次就在它那一步结束。而且这个结束不会抄近路砍掉已经提交的下一步工作:同一步骤的 additionalContexts 或竞争中的 steering 仍会运行,轮次要等那批 inbox 排空才关。两个方向用同一套原则,这不是临时起意。
真未解一个 agent 怎么知道「活干完了」?
agent/turn-stopping 把「停不停」变成了「有没有新 steering」,这解决的是协调问题——多个监听器如何在不打架的前提下影响结果。但它没有解决判断问题:agent 自己怎么知道任务真的完成了?
现在的判据完全是机械的(源码里那两处 break):本步骤给出了结束原因(模型没有再调工具),并且 next-step 列表为空。就这两条。模型完全可以在活没干完的时候停下来说「我先做到这里」,循环照样心满意足地写下 turn/end { kind: 'completed' }——那个 completed 说的其实只是「模型不再欠一次响应」,而不是「任务完成了」。
仓库确实提供了外层策略来续跑:目标(goal)用 Goal Round 上限接纳下一次续行,Ralph 循环用一个个全新子会话反复冲击同一个不可变目标,中间靠一份有界的交接报告传状态。但请注意——这些都是在外面再套一层,不是循环自己会判断。「一个自主 agent 何时算完成了任务」在这个领域确实没有公认答案:靠模型自评不可靠,靠外部验收又需要一个能验收的程序,而能写出验收程序往往意味着任务本来就不需要 agent。
怎么入手:先去 packages/core/agent-loop/src/agent.ts 的 turn() 里把 while (true) 循环的所有出口找齐(两处 break、两处 return false、一处异常路径),确认「完成」这个判断到底由几行代码构成;再读 docs/glossary.zh.md 的「Goal Round」和「Ralph Round」两条,看外层策略是靠什么把轮次串起来的,特别注意 Ralph 子会话不接收父会话的对话种子这一点。然后做个实验:给一个明显需要多步的任务,但在系统提示词里要求「简短回答」,跑完看 turn/end 的 reason 是什么——它会是 completed。拿着这个结果去想:如果要让循环自己分辨这两种 completed,你需要往日志里加什么?那样东西该由谁产生?
你要给界面加一个「本次会话已消耗 token」的实时数字,同时给模型加一个「你还剩多少上下文预算」的提示。请说明这两个数字为什么不应该是同一个数,并各自说出它们该从日志的哪些事件算出来。
replace 遮蔽一段历史,被遮蔽的事件仍在日志里,它们花过的钱不会退回来,但它们不再进入下一次请求。assistant/chunk { type: 'usage' },没有用量分片时退回 assistant/message.usage;预算看 surface——当前在册的那些节点,加上 request/context 里记的 contextWindow。已消耗是一个只增不减的量,来源是日志里全部步骤的用量记录:优先读每步的
assistant/chunk { type: 'usage' },缺失时退回 assistant/message.usage。失败的请求也要算进去——它没有 assistant/message,但用量分片仍在日志里,那是它唯一的记账痕迹。压缩不会让这个数变小,因为钱已经花了。剩余预算是一个会跳变的量,来源是当前 surface(
session.surface.nodes)投影出的历史体积,再对照 request/context 里记录的路由容量 contextWindow。压缩一旦提交,replaceGeneration 递增、surface 被重写,这个数会断崖式下降——这正是压缩要达到的效果。两者必须分开,因为它们回答的是两个不同的问题,而同一份日志能同时算出这两个数,恰恰是第6章「一份日志、多个投影」的最好例子。顺带注意
request/context 被刻意排除在请求重建之外:容量描述的是路由,不是请求输入;把它折进 request/header 会让一次容量变化被误登记成请求信封的变更。变式:如果这个会话是从别处 fork 来的,「已消耗」应该从 0 算起,还是把继承来的种子历史也算进去?两种口径各自对应什么样的界面文案?
给你的 mini-harness 装上 inbox + claim + 轮次/步骤边界
到上一章为止,你的循环还是「来一条消息、发一次请求」。这一层加三样东西:一个两列的 inbox、一个批量领取的 claim、以及写进日志的轮次与步骤边界。
难点在两处。
其一:turn/start 为什么写在 claim 之前?直觉上应该「先看看有没有活儿,有才开轮次」。但那样一来,被拒绝的输入就不会在日志里留下任何痕迹——而「有人试过、被挡了」本身就是必须记下来的事。把边界提前,是用一条多余的日志换一件事的可追溯性。当初想到这一步的人,大概是被「我明明发了消息,为什么什么都没有」这类问题问怕了——这句动机是本站的猜测,仓库只写了结果,没写理由。
其二:为什么 claim 对两条列表的数量规则不一样?写成「两条都全取」很省事,但那等于把「用户连发的两句话」合并成一次请求,模型的回答会含混地同时回应两件事,而你在日志里再也分不开它们。写成「两条都只取一条」也不行:steering 和注入上下文本来就是给同一次请求当背景的,拆开会让它们分散到好几个步骤里,每一步都花一次钱。不对称本身就是设计。
做对了的样子:连着往 inbox.nextStep 里投两条消息(也就是 steer 那种),然后跑一个轮次。日志里应该只出现一个 turn/start,两条消息作为两条 user/message 出现在同一个 step/start … step/end 之间。出现两个 turn/start,就说明你的 claim 写成了「一条一个轮次」——回去看 splice(0) 有没有写成 splice(0, 1)。
(对照一下真仓库:next-turn 那条列刻意是「一条一个轮次」的,followup 的每条消息都独占一个轮次。两条列的数量规则不同,别互相套。)
本章小结
步骤是一次模型请求加上它调用的工具;轮次包含零个或多个步骤,在领取首条输入之前打开、在不再欠下任何工作时关闭;Round 是外层策略(目标续跑、Ralph 尝试)的计数,不统计会话里的每个轮次。「零个步骤」不是边角情况——被拒绝或被改写为空的输入仍然留下一个持久轮次,因为日志必须记录这次尝试,而被领取的消息在被拒绝后既不算丢弃也不算消费,是第三种结局。
输入通过同一个 inbox 抵达驱动器,两条列表:next-turn(一件要做的事,claim 每次只取一条)和 next-step(顺带的背景,claim 一次全取)。inject() 落 next-step 但不唤醒——注入的上下文会一直待着,直到另一条消息把驱动器叫醒。agent/pre-step 是请求推导前唯一的串行监听器链,能改写也能拒绝,而 steering 和注入的上下文都要过这同一道闸。
整条流程上的每一环都对应一个具体的失败:去掉 turn/start,fork 和崩溃恢复失去判据;去掉 user/message,第6章那条断言当场炸;把 step/end 移出 finally,一次异常就让日志留下永久开放的括号。turn/*、step/*、user/message、assistant/*、tool/* 是持久会话事件,其余分属三个事件域——选对域是大多数改动的第一个决定,因为选错了会静默消失或静默变质。分发模式上,六个 waterfall 的监听器必须调 next(),而 agent/turn-stopping 是 serial、故意不给否决权,让数据而不是注册顺序决定结果。
最后一条容易被忽略但很能说明设计口味:assistant/message 记录每一次成功的调用(包括空内容和 max-tokens 截断的),而空内容的那条不进模型历史。记账和给模型看,是两件事。
第8章 提示词与工具 schema:模型看到的那一页纸
一句话导语:这一章回答一个看起来很傻、其实决定整个 harness 形状的问题——模型怎么知道它能调哪些工具?
学完这一章你应该能做到
- 用自己的话解释「系统提示词是拼出来的,不是写死的」,并说出参与拼装的四种贡献各是什么
- 指出一个工具的哪些字段会被送给模型、哪些字段绝不会,并说明为什么这条线必须画得这么死
- 解释「被过滤掉的工具与不存在的工具无法区分」这句话在防什么,以及只从提示词里删掉它会漏掉什么
- 估算一份工具目录在一次任务里的重发成本,并说出哪种改动会让缓存前缀失效
- 给自己的 mini-harness 加上提示词组装:片段注册、汇总、工具 schema 注入
ctx、ctx.effect()、释放函数、服务与注入),以及前面讲过的 waterfall(瀑布式事件)语义——尤其是「监听器必须调用 next() 才会往下传」。8.1 模型其实什么都不知道
先把一件事说透,因为后面所有设计都从它长出来:你在用的这个模型,不是一个住在服务器里、能看见你硬盘的小人。它是一个函数——你塞给它一大段文本,它吐回来一大段文本。它没有记忆(上一轮说过什么,全靠你这次再原样发一遍),没有手(没法自己打开文件),甚至不知道今天几号,除非你在文本里告诉它。
不用系统提示词会怎样?你直接问它「把这个目录里的临时文件删掉」,它会回你一段非常礼貌的自然语言:「你可以运行 rm *.tmp」。然后没有然后了。没有任何东西去执行那一行,因为模型压根不知道「执行」这个动作在它这里是可以发生的。
系统提示词(system prompt):每次向模型发请求时,放在最前面的一段固定说明文本。它不是用户说的话,是部署方替模型写的「岗前须知」——你是谁、你能做什么、做的时候注意什么。
工具 schema(tool schema):一个工具的说明书,三样东西——名字、一句人话描述、参数的结构描述(JSON Schema)。模型读到它,才知道自己可以发出一个「我要调用 read,参数是 {file_path: "/tmp/a.txt"}」这样的请求块。
所以整件事没有魔法:模型能调工具,是因为你把工具说明书写进了它读的那页纸。这一章讲那页纸是怎么拼出来的。
有人说:「我的 agent 里模型明明知道有 read 工具,我什么都没配。」最可能的解释是什么?(提示:想想「什么都没配」到底是谁替你配了)
read 一无所知。它「知道」是因为这次请求的头部带了 read 的 schema——某个已加载的插件(这里是 packages/fs/tool-fs)注册了这个工具,注册的副作用让 schema 自动流进了提示词组装。换个只装了 bash 的部署,同一个模型会告诉你它没有 read。所谓「模型知道」永远等价于「这次请求里写了」。变式:如果模型在没有 read schema 的情况下,硬是输出了一个名为 read 的工具调用块,harness 应该怎么办?(第 9 章会给出仓库的确切答案:UNKNOWN_TOOL。)
8.2 那页纸是拼出来的,不是写死的
最容易犯的直觉错误是:把系统提示词想成源码里一个大字符串常量,要改就去编辑那个常量。
不这么做会怎样?回想前面讲插件的那一章:这个 harness 里一切都是插件。你事先不知道这次部署装了哪些工具——装了 bash 吗?装了子 agent 吗?沙箱开了没有?如果提示词是常量,你就得为每种组合各写一份常量,然后祈祷没人漏改。装 20 个可选插件,理论上是 220 份常量。
仓库的做法是反过来的:谁提供能力,谁自己带一段说明。每个插件把自己那一小段注册进去,真正要发请求时再把所有片段汇总、排序、拼成一页纸。这个注册表就是 ctx.systemPrompt 服务,源码在 packages/core/system-prompt/src/index.ts。
它收四种东西:
| 方法 | 贡献什么 | 落在模型请求的哪里 |
|---|---|---|
section(...) | 一段有序的提示词段落 | 系统提示词正文,按 order 升序拼接 |
context(...) | 会变的动态事实(当前审批策略、沙箱模式……) | 不在提示词里,而是作为一条 user 角色的快照追加在保留历史之后 |
variable(name, ...) | 一个可被 {{name}} 引用的值 | 渲染时插值进 section/context 的文本 |
tools(provider) | 本次组装对模型可见的工具 schema 集合 | 请求的工具列表字段(不是提示词正文) |
注意第二行那个设计:会变的东西不写进系统提示词。审批策略从「问我」切成「一律拒绝」,harness 不去改请求头里的系统提示词,而是在历史后面追加一份新的完整快照。packages/interaction/user-approval/src/index.ts 里那行注释把理由写得很直白:完整的当前值走在保留历史之后,这样切换策略不会重写稳定的系统提示词缓存前缀。8.6 节会讲这个「缓存前缀」值多少钱。
order:一份不成文但写在类型里的排版约定
section 按 order 升序拼接。仓库把约定直接写在 PromptSection.order 的 JSDoc 里:-100 是 harness 身份,0 是部署人格,工具指引用 100–199。这不是强制的,是一条大家都遵守的车道线。下面是我从源码里逐个抄出来的真实占位:
| order | 片段名 | 出处 |
|---|---|---|
| -100 | harness:identity | packages/core/system-prompt/src/index.ts(构造函数里注册) |
| 0 | deployment:persona | 同上,值来自插件配置 persona |
| 99 | tools:code-only | packages/core/tools/src/index.ts |
| 100 | tool:read | packages/fs/tool-fs/src/read.ts |
| 105 | tool:bash | packages/shell/tool-bash/src/index.ts |
| 112 | tool:lsp | packages/lsp/tool-lsp/src/index.ts |
| 115 | tool:cordis | packages/extensions/tool-cordis/src/index.ts |
| 150 | tools:sdk | packages/core/tools/src/code-mode.ts(SDK_SECTION_ORDER) |
为什么 tools:code-only 特意排在 99、抢在所有工具指引前面?源码注释给了理由,而且这个理由值得记住:每个工具都贡献一段「我叫什么、怎么用」,没有一个会说明「你到底能不能直接调我」。如果没有那条排在前面的规则,模型读到一整本工具目录,却读不到「只有 run_code 可以直接调」,于是它发出一个原生调用、收到 UNKNOWN_TOOL——一个提示词刚刚声明过的工具居然不存在——然后它会得出结论:这个部署坏了。顺序在这里不是排版,是语义。
组装是一次 waterfall,每一步都能改写
assemble(context) 干的事,按源码顺序是:解析变量(作用域内的覆盖全局的)→ 合并 section 与 context(同样是作用域覆盖全局)→ 调用所有工具 provider 收集 schema →按配置的 toolOrder 排序 → 然后跑 system-prompt/assemble 这个 waterfall。
waterfall 的返回值是权威的:监听器可以增、删、改这次组装的任何部分。但有一个例外:如果某个 section 声明了 complete: true(意思是「我就是整份系统提示词」),waterfall 跑完之后 harness 会把它恢复成唯一的 section。文档写得很清楚——监听器无法给那个作用域的系统提示词添加或替换内容。
为什么要留这么一个后门
有些部署(比如某些评测、某些嵌入式 agent)需要逐字控制模型读到的每一个字,多一句都不行。complete 让它们拿到这个保证,同时仍然享受组装流程带来的工具 schema、变量与动态上下文解析。代价是:这个作用域里所有想往提示词里塞话的插件都被静默地架空了。多于一个有效的 complete 段会直接让组装失败——这是仓库明文。
某插件在 system-prompt/assemble 上注册了监听器,往 assembly.sections 里加了一段自己的话。但这个作用域里恰好有一个 complete: true 的 section。模型最终读到那段话了吗?
complete 的字面意思是「我就是完整的系统提示词」。它是在 waterfall 之前生效还是之后?assemble() 的最后几行——waterfall 返回之后,如果存在有效的 complete 段,返回值里的 sections 被替换成只有那一段。sections,然后整个 sections 数组被换成了只含 complete 段的数组。注意不是整个组装都被跳过——工具 schema、动态上下文、变量都照常解析并保留,被丢掉的只有 section 正文。所以一个 complete 部署仍然有完整的工具能力,只是没人能往它的提示词里插话。变式:如果两个插件都注册了 complete: true 的 section,会发生什么?(仓库对这种情况有明确规定,去 PromptSection.complete 的 JSDoc 里找。)
到底有多少个包在往这页纸上写字
我数了一下:仓库 219 个包里,有 25 个在生产代码里调用 systemPrompt.section() 或 systemPrompt.context()(用 grep 统计,排除测试)。下面这个实验室把 219 个包和它们的依赖关系全翻给你,你可以自己去验证这个数,也可以看看这 25 个都是谁。
这个数字的口径要小心
「25 个包」是本站的统计,不是仓库文档里的数字:口径是「生产源码中出现 systemPrompt.section( 或 systemPrompt.context( 的包」。它不等于「一次真实运行中有 25 段提示词」——一个包可能注册多段(tool-fs 就有 read/write/edit 三段),也可能因为没被这次部署加载而一段都不贡献。要看某次真实运行的实际结果,只能去读那次的会话日志。
当前工作目录(cwd)这个事实,你会用 section() 还是 context() 贡献给模型?给出理由。
context()。cwd 是会变的运行时事实,写进 section 意味着每次变更都要重写系统提示词,而系统提示词是请求的最前缀——前缀一改,后面所有内容的缓存复用就从改动点起全部作废(8.6 节)。context() 的快照追加在保留历史之后,是纯追加,前缀不动。仓库对沙箱模式(sandbox:policy,order 110)和审批策略(approval:policy,order 115)用的正是 context(),两处源码注释都写明了这个理由。反过来,「用 read 而不是 cat」这种永远为真的规矩就该用 section()。变式:如果一个事实几乎不变(比如项目名),但一年会改一次,你放哪?说说你的判断依据是什么——是变化频率,还是「变化时愿不愿意付重算前缀的代价」?
8.3 工具 schema 从哪来:一个 provider,52 份说明书
工具作者要做的事少得出奇。这是 docs/cookbook/adding-a-tool.zh.md 里的最小形态,一字未改:
export const name = 'my-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // what the model sees
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // optional by default
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}
然后文档补一句话:「注册基于副作用:dispose 插件 fiber 即注销该工具。schema 会自动流入系统提示词的组装过程。」——工具作者从头到尾没碰过提示词。
「自动流入」是怎么实现的?我去全仓库搜了一遍 systemPrompt.tools(,生产代码里只有一处,在 packages/core/tools/src/index.ts 的 ToolRuntime 构造函数里:
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
工具注册表在自己出生的时候,就把「本次组装该看到哪些工具」这个问题接了过来。每次组装,提示词注册表回头问工具注册表一句:这个作用域看得见什么?工具注册表按那个作用域算一遍,把答案交回去。两个注册表之间只有这一根线。
那条绝不能越过的线:白名单投影
一个 ToolDefinition 身上挂着一大堆东西:execute(函数本体)、output(规范输出声明与渲染器)、timeoutMs、isConcurrencySafe、presentCall/presentResult(UI 卡片怎么画)……而模型只该看到三个字段。docs/subsystems/tools.zh.md 用了很重的措辞:这些字段绝不能泄漏到模型请求中。
实现方式不是「小心别写错」,而是显式允许列表——schemas() 拿到定义之后,只挑出 name、description、parameters 三个字段重新造一个对象出来。多一个字段都进不去,因为根本没人去拷贝它。
为什么必须是允许列表,而不是「删掉几个不想要的字段」
黑名单(删几个)在你新增字段的那一天就漏了:某天有人给 ToolDefinition 加一个 internalApiKey,黑名单不认识它,于是它跟着进了请求,进了模型的上下文,进了日志。白名单(只留几个)默认拒绝一切新东西——新增字段除非有人显式把它加进列表,否则永远出不去。安全设计里,默认值应该是「不」。这个思路你会在第 9 章的审批和第 10 章的守卫里反复看到。
模型最终读到的那份 schema 长什么样?这是 read 工具的真实 schema,抄自 docs/tool-catalog.zh.md:
{
"type": "object",
"properties": {
"file_path": { "type": "string",
"description": "Path to read, resolved by the filesystem backend." },
"offset": { "type": "number",
"description": "1-based first line to return. Defaults to 1." },
"limit": { "type": "number",
"description": "Maximum number of lines to return. Defaults to 2000." }
},
"required": ["file_path"]
}
顺带一提 ToolProviderResult 里那个不起眼的 knownNames 字段:它是 provider 在过滤之前的名字全集。有了它,harness 才能区分两种长得一样的错误——「配置里把工具名拼错了」和「这个工具确实存在,只是在这个作用域被有意藏起来了」。前者该报错,后者是正常的。
timeoutMs 是 ToolDefinition 上的字段,声明这个工具的超时预算。它会出现在模型看到的 schema 里吗?如果不会,模型怎么知道自己的命令可能被掐断?
timeoutMs 在不在里面?bash 工具的 parameters——它有一个 timeoutMs 参数,描述里写着执行器会应用默认值和上限并在到期时杀掉命令。注意这跟定义上的 timeoutMs 字段是两回事。docs/subsystems/tools.zh.md 明文写 timeoutMs 「绝不会被发送给模型——schemas() 只允许 name/description/parameters」。它是给宿主用的调度元数据,由 tools/execute 的超时策略插件读取(第 9 章第四道关)。模型要知道这件事,只能通过另外两条合法通道:一是工具自己在 parameters 里开一个模型可填的 timeoutMs 参数并在 description 里说明(bash 就是这么做的),二是某个插件注册一段 section 说明这条部署规矩。「宿主的调度事实」和「模型该知道的事」是两个集合,交集要显式地一条条搬运。变式:presentCall 决定 UI 上那张卡片长什么样。它也不进 schema。那么一个只有 CLI(命令行界面)、根本不画卡片的部署,加载同一个工具会出问题吗?为什么?
8.4 为什么要作用域化:不是每个 agent 都该看到全部
不作用域化会怎样?你的主 agent 派出一个子 agent 去「读一读这三个文件,总结一下」。如果子 agent 继承了全套工具,它手里就握着 bash、握着 write、握着再派子 agent 的能力。它大概率不会乱来,但「大概率」在安全上等于零。你想要的是:这个孩子手里就只有 read 和 grep。
仓库的机制是 ctx.tools.restrict(filter),行为在 docs/subsystems/tools.zh.md 里定义得很细:
- 它作用于该作用域继承来的工具——部署全局层,加上它这条链上的每个祖先作用域。
- 多个限制取交集:链上任何一个作用域都能把某个名字对它内部的一切遮掉。
- 它不动这个作用域自己注册的工具。原因很实在:被委派的子 agent 需要保留它用来汇报的那几个工具(
report之类),过滤器如果把这些也削掉,孩子就没法把结果交回来了。 - 只写
deny的过滤器允许以后新增的、未列出的继承工具通过;写allow列表则把它们统统排除。 restrict()必须在带作用域的上下文(agent.ctx)里调用;名字拼错会大声报错,不会静默忽略。
这一章最该记住的一句话
packages/subagent/subagent/src/types.ts 里,toolFilter 字段的 JSDoc 只用一句话就说清了整个安全模型(我逐字翻译):被点名的工具会从孩子的提示词里消失,并且拒绝执行(一处可见性),并带有大声的未知名校验。
把这句话拆开看,它其实是三个承诺:
- 从提示词里消失。组装时调用的
wireSchemas(scope)和查找时调用的get(name, scope),走的是同一个内部方法view(scope)——同一次层遍历同时算出「可见的定义表」。所以提示词里有什么,就是这个作用域能看见什么,两者不可能对不上。 - 拒绝执行。分发时解析工具也走同一个
view。被过滤掉的名字在这个作用域里解析不出定义,于是执行阶段报UNKNOWN_TOOL——和一个真正不存在的工具名收到的错误一模一样。 - 与不存在的工具无法区分。这是前两条的合力结果,也是它作为安全设计的关键。
为什么第三条这么重要:只从提示词里删掉会漏掉什么
假设你偷懒,只做第一条——组装时把 bash 的 schema 过滤掉,但执行路径不管。会发生什么?
模型并不是只从这一次的提示词里获取想法。它读得到会话历史:历史里可能有上一轮某个工具的结果提到了 bash;它读得到用户的话:用户可能直接说「用 bash 跑一下」;它还带着训练时见过的世界:它知道 agent 一般都有个叫 bash 的东西。于是它完全可能凭空发出一个 bash 调用。这时如果执行路径照单全收,你的过滤器就只是一张贴在门上的「请勿入内」,门根本没锁。
仓库把这条原则写成了包级规矩,在 packages/AGENTS.md 里:「在做出决定的那个操作里执行这个决定。schema 省略、提示词过滤、门面、包装层和监听器顺序都不算执行,只要直接或替代的调用方能绕过它们;要通过执行器来测试拒绝。」翻译成人话:把工具从菜单上划掉不叫禁用,厨房也得拒绝做才叫。
常见误解:plan 模式会砍掉工具目录
很多人(包括我一开始)以为「规划模式」的实现方式是限制工具集——只留只读工具。这个仓库不是这么做的。packages/plan/plan-mode/src/index.ts 的模块注释明确写着:退出工具 exit_plan_mode 在规划模式未激活时仍然注册着,所以进入或离开规划模式改变的只有提示词 section,不是请求的工具目录;沙箱模式和审批策略各自独立地施加限制,既不读也不写 plan 状态。
为什么这样设计?我的理解(本站推断,仓库没有直接说明):如果进出规划模式会改动工具目录,每次切换都会让请求前缀发生变化,缓存复用作废;而且规划状态是协作状态,靠提示词引导模型,跟能力边界是两件事——能力边界交给沙箱和审批那两套真正的执行器。
你的团队实现了一个「只读子 agent」:在组装提示词时把所有写类工具的 schema 过滤掉,执行路径不改。请构造一个具体的调用序列,让这个子 agent 成功写了一个文件。
write,把结果(含工具名和参数形状)作为任务材料传给子 agent;或者干脆让用户消息里写明「调用 write 工具,参数是 file_path 和 content」。write,但用户的话就在它的上下文里,模型完全可能照做,发出一个 write 调用块。(3) 执行路径没有作用域过滤,注册表在全局视图里找到了 write 的定义,执行,文件被写了。整个过程没有任何一步报错。这正是为什么可见性必须一处解析、并且在执行器上测试拒绝——仓库对 toolFilter 的承诺是「从提示词消失且拒绝执行」,两个动词缺一不可。附带一提:真实实现里 get(name, scope) 的 scope 参数省略时是全局视图,所以「忘了传 scope」正是这个 bug 最可能的长相。变式:反过来——如果只做「拒绝执行」而不过滤提示词,会有什么后果?(提示:想想模型每一轮都读到一个它永远调不动的工具,会怎么解释这件事;tools:code-only 那段注释描述的就是这个失败模式。)
8.5 shadowing:最具体者胜出
shadowing(遮蔽):同一个名字在多个层里都有注册时,最靠近使用者的那一层赢,其他层的同名注册被「遮住」。你可能在编程语言的变量作用域里见过同一个词——局部变量遮住全局变量,是一回事。
在这个 harness 里,工具、提示词 section、提示词变量都遵守这条规则:带作用域的注册替换同名的全局注册。ctx.tools.register() 的文档写得很直接:「带作用域的工具遮蔽全局工具;同一层内的重复名字和保留的 run_code 名字会失败。」
注意「同一层内重复会失败」这半句。跨层重名是遮蔽(正常操作),同层重名是错误(大声抛异常)。这个区分很讲究:跨层重名一定是有人在表达「我要为这个 agent 换一个实现」,同层重名一定是两个插件撞车了,谁都没打算让对方赢。
实际用途很多:子 agent 换一个受限版的 bash;给某个 agent 的 deployment:persona 换一份人格(packages/subagent/subagent/src/child-agent.ts 就是这么给孩子换人格的——注册一个同名的 deployment:persona section,仅为这个孩子遮蔽部署人格)。
一个仓库自己记录下来的真实教训
packages/core/tools/src/index.ts 里 view() 方法的注释保留了一次踩坑记录,我照着意思复述:过滤器的豁免规则本来被理解成「豁免全局层」,而正确的理解是「豁免我自己那一层」。这两种理解在所有模型可见工具都住在宿主组合里的时候,行为完全一样,所以长期没人发现区别。直到 profile/组合包把这些工具搬到了 agent 平面上——它们从此变成了祖先的贡献而不是全局贡献——于是子 agent 的过滤器悄悄地什么都不再约束了。没有报错,没有告警,只是安全边界不见了。
这类 bug 是本章的核心风险模型:可见性规则里一个措辞的偏差,能在别的地方改动之后突然变成一个洞。
全局注册了 bash。某个子 agent 的作用域里,先用 restrict({ deny: ['bash'] }) 拒了它,同时又在这个作用域里 register() 了一个自己的 bash。这个子 agent 的提示词里有 bash 吗?调用它会执行哪一个?
restrict 到底作用在哪些注册上?文档那句话里的关键词是「继承来的」。view() 的步骤手算一遍——先把全局 + 祖先的表拼成「继承表」,逐个名字问「链上每一层都放行吗」,通过的放进可见表;最后把自己层的注册直接塞进可见表(不过滤)。bash,执行的是作用域自己那个。deny 只清掉了继承来的全局 bash;作用域自身的注册不受限制约束,最后写入可见表,于是它既进了 schema 也能执行。这不是漏洞,是有意为之——文档里的原话是限制「再叠加该作用域自身的注册——后者不受约束,因此被委派的子 agent 会保留其回报所依赖的工具」。真正的含义是:restrict 是一把对着上游的剪刀,不是对着自己的锁。想真正禁掉一个能力,别只在孩子身上 deny,要确认没人在孩子那一层又把它注册回来了。变式:把上题的 deny 换成 allow: ['read'](白名单,且不含 bash),其余不变,答案会变吗?
8.6 提示词的成本:每加一句都在花钱
这是本章最容易被忽略、也最花钱的一节。
回到 8.1:模型没有记忆。所以在一个任务里,模型走的每一步——每一次「思考→调工具→看结果→再思考」——都是一次全新的请求,而每一次请求都要把系统提示词和整份工具目录原样重发一遍。不是发一次就存在那儿了,是每一步都发。
给你尺度感。我统计了 docs/tool-catalog.md(这份目录是仓库的生成器真的启动每个工具插件、读 ctx.tools.schemas() 抄出来的,所以它就是模型看到的东西):
| 项 | 值 |
|---|---|
| 工具个数 | 52 |
| schema 总字符数(目录里的缩进格式) | 29,847 |
| 单个工具 schema 的中位数 | 404 字符 |
最大(workflow) | 2,159 字符 |
最小(job_list 等无参工具) | 43 字符 |
差不多 3 万字符。换算成日常可感知的东西:一篇一万字的中文长文那么长,你每走一步就完整重发一遍。一个跑 20 步的任务,光工具目录就发了 60 万字符——而这还不含提示词正文、不含用户消息、不含会话历史。
这里有一处本站推断
按英文文本大约 4 个字符一个 token 的常见经验值折算,3 万字符大概是 7000–8000 个 token 量级。这是本站的粗略估算,不是仓库给出的数字——真实值取决于具体的分词器,而且没有哪个部署会同时装上全部 52 个工具(很多是需要显式选择启用的)。把它当数量级看,别当精确值用。要拿准数,去读你自己那次运行的会话日志。
省钱的机制:缓存前缀,以及它怎么被弄坏
KV 缓存(KV cache):模型服务端的一个优化——如果这次请求的开头和上次一模一样,那部分的计算结果可以直接复用,不必重算。关键词是开头:它是前缀匹配,从第一个不同的 token 起,后面全部作废。
packages/core/tools/README.md 把工具目录的缓存行为写得非常明确(我逐字翻译):「只要可见的定义及其顺序不变,前缀就是稳定的。注册、释放或作用域限制的变化,可能从第一个改变的 schema token 起使复用失效。」
把这句话和前面几节连起来,很多设计选择一下子就说得通了:
- 为什么工具顺序要可配置。
SystemPrompt有个toolOrder配置,还有个保留标记<unlisted-tools>表示「没列出的工具插在这里」。不配的话默认按名字字典序排——而且源码里特意注明用的是「code-unit 比较,与语言环境无关,所以每台机器上顺序都一样」。顺序必须是确定的,否则同样的工具集在两台机器上会生成两个不同的前缀。 - 为什么会变的事实走
context()而不是section()。快照追加在历史后面是纯追加,前缀不动;改系统提示词是改前缀。 - 为什么动态挂载插件要谨慎。运行到一半装一个新工具插件,这个 agent 的下一次请求前缀就变了。(
tool-cordis那段部署说明里写着:运行中的 Package 可以注册额外的模型可见工具,发生这类工具集变化时系统会记录完整且有变动的请求头。)
答辩:如果我是审稿人
你说「每加一句提示词都在花钱」,可仓库自己给 52 个工具里的一大半都配了 section 指引,光工具指引就占了 order 100–199 整整一个波段。这不是自相矛盾吗?既然这么贵,为什么不把所有指引都压进工具的 description 里,一句不多写?
参考防守(先自己组织语言再看)
三点。
第一,它们不是一类信息。tool-bash 的源码注释把线画在这里:「跨调用的指引属于提示词,而不是单次调用的 schema 散文」。它注册的那段 section 是「每条 bash 结果都要检查 [exit code: N] 标记,失败了先查清楚再往下走」——这是一条关于怎么串起多次调用的工作纪律,塞进 description 里既不该由参数说明承担,也会在模型只看单个工具时显得莫名其妙。tool:read 那段更典型:「用 read 工具,而不是 cat 这类 shell 命令」——这句话说的是两个工具之间怎么选,它不属于任何一个工具的自我介绍。
第二,成本不对称。二者都在前缀里,都要每步重发,字节意义上没有区别。但一段错误的指引会让模型反复走弯路,一次弯路的代价(多几轮工具调用、多几份文件内容进上下文)通常远超那段指引本身的字节数。这里真正该优化的是每字节的期望收益,不是字节总数。
第三,也是最诚实的一点:仓库并没有给出「一段指引值不值」的判据。它给了机制(section、order、可配置的顺序)和成本模型(README 里逐工具的 Token effect/KV Cache effect 段落),但要不要写这一句、写多长,是部署方的判断,没有量化门槛,也没有自动化的检查。我的看法是:这属于目前还没有好答案的工程问题,见下面的研究框。
一个部署有 40 个工具。主 agent 全都要,但它派出的三种子 agent 各自只需要其中 5–8 个。有人提议:干脆给每种子 agent 各写一个 toolOrder,把它用得上的排在最前面,用不上的排在后面——这样「常用的在前面,缓存前缀更稳」。请评价这个提议。(这题要同时用到 8.4 的作用域、8.5 的可见性和 8.6 的缓存。)
toolOrder 是部署级的插件配置(SystemPrompt 的 Config),而 restrict 是作用域级的。想想「给每种子 agent 各写一个 toolOrder」在这个体系里能不能表达出来。packages/core/system-prompt/src/index.ts 里 toolOrder 的来源——它在构造函数里从 config 读一次,存成实例字段 this.toolOrder,之后每次 assemble 都用同一份。再看 restrict():它要求带作用域的上下文,不要求也不接受顺序。toolOrder 是插件配置,一个进程一份,不随作用域变;子 agent 想要不同顺序,这套 API 没给这个旋钮。(2) 就算做得到,也省不了钱。排到后面的工具 schema 仍然完整地在请求里,字节一个不少;顺序只影响「从哪个 token 开始失配」,不影响总量。真正省钱的是 restrict——README 明文:「隐藏工具的限制为该 agent 移除它们的全部 schema 成本」。(3) 方向反了。缓存前缀要的是稳定,不是「常用的靠前」。真正该做的是:主 agent 与每种子 agent 各自靠 restrict 收敛到自己需要的集合,同时保持一份全局确定的顺序(默认的字典序就够,它跨机器一致)。这样每一类 agent 各自拥有一个稳定且更短的前缀,而不是所有人共享一个长前缀再互相打断。顺带一提:这也说明「什么该做成配置、什么该做成作用域能力」本身就是一次设计判断——仓库把顺序放在部署级(一次运行内所有 agent 一致,便于比对与复现),把可见性放在作用域级(每个 agent 不同)。变式:如果这三种子 agent 用的工具集互不相交,把它们拆成三个进程各自只装自己的工具插件,和一个进程里靠 restrict 分开,两种做法在成本上有区别吗?在安全性上呢?
8.7 模型没看到的东西就不存在
最后一节讲一个工程上非常反直觉的事实。
你花两周写了一个特别好的工具:正确、快、边界处理完美、测试 100% 覆盖。然后你给它写了一句 description:「Process the input.」这个工具等于不存在。模型读不懂它是干嘛的,永远不会挑中它——你那两周唯一的产出,是给每一次请求增加了几十个字符的成本。
这是 harness 工程和普通软件工程最不一样的地方之一。普通软件里接口文档写得烂,调用方是人,人会去读源码、去问你、去试。harness 里调用方是模型,它只有你写的那一句话,没有别的渠道,也不会来问你。schema 的描述不是文档,它是产品界面。
仓库把这件事写成了包级规矩(packages/AGENTS.md):「从模型的视角撰写面向模型的约定。提示词、工具 schema、结果和诊断信息里只包含与任务相关的概念,不包含 UI、传输层或实现层的词汇。」——不要在 description 里写「本工具通过 ctx.fs seam 调用后端」,模型不知道什么是 seam,也不该关心。
几个可以直接抄的正面例子,都来自仓库真实的 schema:
bash的description参数(这是个参数,要求模型自己填一句话描述这条命令)里,直接给了三个例子:「ls」→「List files in current directory」、「git status」→「Show working tree status」,并且规定 5–10 个词。与其解释规格,不如给样例。read的offset参数描述写的是「1-based first line to return. Defaults to 1.」——把「从 1 开始数」和默认值都写死在描述里。不写的话,模型会自己猜是 0 还是 1,而且不同时候猜得不一样。run_code的描述里有一句:「只有打印或返回的内容会传回,请谨慎筛选。」——这是在描述里直接预防一类错误行为,而不是等它犯了再报错。
反过来,tools:code-only 那段前面提过的注释,正是一个「没写清楚导致模型自我怀疑」的实例:模型读到一整本工具目录却读不到「只有 run_code 能直接调」,于是它发出一个原生调用、收到 UNKNOWN_TOOL,然后得出结论说这个部署不一致。修法不是让报错更详细,而是把那条规则排在工具目录前面。
真未解一段提示词值不值那几百个 token,有没有办法量出来?
仓库给足了机制和成本口径:README 里每个包都要按统一格式写 What the model sees / Token effect / KV Cache effect 三段,工具目录由生成器真实启动插件抄出来,顺序可配置且跨机器确定。也就是说,「这段东西花了多少钱」是可测的。
但「这段东西赚回了多少」没有任何口径。tool:read 那句「用 read 而不是 cat」到底把多少次错误的 shell 调用挡在了门外?删掉它,成功率掉几个百分点?仓库里没有这类度量,也没有任何门禁在管提示词的长度或收益——目前它是纯粹的人工判断。这不是这个仓库的疏忽,整个领域都还没有公认答案:提示词的边际收益依赖具体模型、具体任务分布,换个模型版本可能整体失效。
怎么入手:先去读 packages/core/tools/README.md 的 ## Model Experience 段和 docs/testing.md 里关于 keyless snapshot(无密钥快照回放)的策略——仓库要求每个模型可见的行为变更都配一份可回放的组装脚本记录。这套快照机制正好是做 A/B 的现成骨架:固定任务集,把某一段 section 注册掉与不注册各跑一遍,比较两边的工具调用序列。难点在于评价指标——「更少的步数」不一定等于「更好」,你得先定义什么叫做对了。做完之后再回头看:如果一段指引对某个模型有效、对另一个无效,那它到底该属于部署配置,还是该属于模型适配层?仓库目前把它放在插件里,这个位置对不对,是个开放问题。
下面这个工具定义在技术上完全合法,注册也不会报错。请指出至少三处会让模型用错的地方,并各说明会导致什么具体失败。{ name: 'q', description: 'Query.', parameters: { s: { type: 'string', required: true }, n: { type: 'number' } } }
read 的 schema 逐项比。read 的每个参数都有 description,offset 写明了 1-based 和默认值,limit 写明了默认 2000。这里一条都没有。q/s/n 什么都不说。模型选工具时靠名字和描述做第一轮筛选,这个工具在任何任务里都不会被优先想到;即便想到了,它也无法判断 s 该填查询词还是查询语言。失败形态是沉默——工具永远不被调用,你还以为是模型笨。(2)「Query.」不是描述。查什么?数据库、日志、网络?返回什么形状?模型会靠猜,猜的结果因上下文而异——同一个部署里今天猜对明天猜错,你会得到一个无法复现的 bug。(3) n 没有单位、没有默认值、没有范围。模型可能填 10,也可能填 100000。参数校验只管类型(defineTool 会按 ParameterSchemaSpec 校验类型、必填、字面量约束),管不了「合不合理」——文档明说,非空字符串、正数、跨字段规则这类你得自己手动检查。所以 n: 100000 会一路进到 execute,然后要么打爆下游、要么返回一大坨结果撑爆上下文。(4,加分) 没有说明何时不该用它。好的描述会划边界(read 的提示词 section 就在划:读文件用 read,别用 cat)。修法:给工具和每个参数写人话描述,写清默认值、单位、边界,给两三个例子,并把跨工具的选择规则写进一段 section 而不是塞进 description。变式:假设你没法改这个工具的名字(外部 MCP 服务器提供的,名字是人家定的)。你还能在不改工具的前提下,让模型正确使用它吗?(提示:8.2 表里的四种贡献,哪一种是为这种情况准备的?)
给你的 mini-harness 装上提示词组装
到上一章为止,你的循环发给模型的东西是硬编码的:一段写死的系统提示词,加上一个写死的工具数组。现在要把这两样都改成「谁提供,谁登记」,让插件自己往请求里加东西——加完了还要能拆掉。
难点在两处,都不显然。
第一,为什么 assemble() 必须每步现算,不能启动时算一次存起来。因为工具可以在运行中被装卸(前面讲过的热模块替换,以及后面讲自我修改的那一章),可以随作用域变化。缓存一份就意味着提示词说的和实际能执行的会漂移——而这正是 8.4 那个安全洞的成因。真实仓库里连 text 都允许是函数(每次组装重新求值),就是为了这个。
第二,为什么工具 schema 要经过一次白名单投影,而不是直接把工具对象扔进去。你的工具对象上挂着 execute 函数。直接扔进请求,轻则序列化炸掉,重则把不该给模型的元数据(超时预算、并发标记、内部路径)一起发出去。上面那行 .map() 看起来多余,它就是那条线。
做对了的样子:注册两个工具(比如 echo 和 now),组装一次,打印 tools.length——应该是 2。然后调用其中一个的释放函数把它卸掉,重新组装,再打印——必须是 1,而且打印出来的提示词里只剩一份工具说明。如果还是 2,说明你把组装结果缓存住了,或者释放函数没真正从表里删掉。再加一道:把 execute 塞进工具对象,组装后 JSON.stringify(tools)——结果里不应该出现任何函数痕迹。
本章小结
模型能调工具,唯一的原因是你把工具说明书写进了它读的那页纸。这一章讲的就是那页纸的生产流程:
- 它是拼出来的。插件各自用
section()/context()/variable()/tools()登记片段,assemble()按 order 汇总,最后跑一次system-prompt/assemblewaterfall——每一步都能改写,除了被complete锁死的那种。 - 工具 schema 自动流入。工具作者只调
ctx.tools.register();工具注册表在构造时接了一根线到提示词注册表。模型只拿到 name/description/parameters 三个字段,靠显式允许列表保证,不靠自觉。 - 可见性一处解析。提示词、查找、分发共用同一个
view(scope)。被过滤掉的工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分——菜单划掉不叫禁用,厨房也得拒绝做才叫。 - 最具体者胜出。带作用域的注册遮蔽同名全局注册;跨层重名是遮蔽,同层重名是错误。
restrict只剪继承来的,不剪自己注册的。 - 它每一步都要重发。52 个工具约 3 万字符的 schema,每走一步发一遍。省钱靠
restrict(真的删掉字节)和稳定前缀(顺序确定、变化的事实走context()),不靠调顺序。 - 模型没看到的东西就不存在。description 不是文档,是产品界面。一个描述不清的好工具,在模型眼里等于一段纯成本。
下一章我们换个方向:模型终于发出了一个工具调用块,说「删掉这个文件」。从这句话到文件真的没了,中间要过七道关。
第9章 一次工具调用要过七道关
一句话导语:模型说「删掉这个文件」,到文件真的没了,中间发生了什么——这一章把这条路上的每一个路口都走一遍。
学完这一章你应该能做到
- 按正确顺序说出一次工具调用经过的每一个环节,并指出哪三个环节能改写这次调用
- 解释为什么
tool/call必须在执行之前就写进日志 - 说清「默认拒绝」在审批里具体指什么,以及哪四种情况会走进这个默认
- 解释为什么审批要排在单调守卫之前,而不是之后
- 给自己的 mini-harness 加上 pre/execute/post 三层 waterfall 和一条先记录后执行的调用日志
9.1 从「删掉这个文件」到文件真的没了
模型这一步的回复里,除了自然语言,还夹着一个结构化的块:工具名 bash,参数 { command: "rm -rf build/" }。
最朴素的实现会怎样?你在循环里写:看到工具调用块,就查表找到函数,把参数传进去,执行,把返回值发回给模型。二十行搞定,能跑。
然后需求陆续来了:删文件前弹窗问一句;某些目录永远不能被写;每个工具都要有超时;记录每次调用花了多久;结果太大时截断;合规插件在特定命令上一律拒绝——而且这六件事来自六个团队,谁都不该去改另外五个人的工具代码。
这就是流水线存在的理由。它把「一次调用」从一个函数调用,变成一条有编号的、每一段都能挂东西的路。仓库把这张图画在 docs/tool-execution-pipeline.zh.md;下面这张是我按那张流程图重画的,把它归成七道关。
docs/tool-execution-pipeline.zh.md 的 Mermaid 流程图重画,把节点归并为七组以便记忆。紫色线是拒绝路径:被拒的调用跳过工具本体,但仍然进入第 6 关。虚线是流水线自身抛异常的路径,它连第 6 关也跳过。下面这个实验室把这七道关做成了七个可以单独关掉的开关。建议你读一节、去关一关——正文里我会点名让你关哪一个。
finalizeContent 和 tools/result 排在它们之后,只做收尾,改不动决定。你的部署有 30 个工具,现在要加一条规矩:「所有会写文件的操作都要先问用户一句。」用流水线做和在每个工具里各写一遍,这两种做法在今天的工作量上差不多。请说出它们真正的差别在哪。
tools/pre-execute 上是 O(1):一个插件,对所有工具生效,包括你没写过、也管不到的那些。还有一层更隐蔽的差别:前一种做法要求每个工具都 import 那个权限服务——于是一个连权限概念都不需要的部署,也被迫背着这个依赖。文档那句总结说的就是这件事:钩子可以跨工具系列生效,而不用让工具与某个策略服务耦合。变式:如果这条规矩是「只有 bash 工具需要额外确认」,只针对一个工具,还值得用流水线吗?判据是什么?
9.2 开场两步:先记录,再点亮卡片
模型的调用块到手,harness 做的第一件事不是执行,也不是问权限,而是往会话日志里写一条 tool/call。
源码在 packages/core/agent-loop/src/tool-calls.ts,那段调度代码里的顺序一眼可见:
const startCall = async (index: number): Promise<void> => {
const call = group[index]!
callSeqs[index] = appendToolCall(session, turn, step, call.block) // 先记
started++
const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec) // 再走策略
...
}
appendToolCall 追加的事件里带着 turn、step、callId、工具名和模型原样发来的参数。
为什么必须先记录再执行
反过来想:如果先执行、成功了再记录,那么在「已经执行」和「记录完成」之间的任何一刻——进程被 kill、机器断电、代码里一个没接住的异常——你都会得到一份说谎的日志:文件已经被删了,日志里却没有任何痕迹说有人要删它。
这类日志比没有日志更糟。事后你去排查「这个文件怎么没的」,日志告诉你「本轮没有工具调用」,于是你去怀疑别的东西。先记录后执行,保证日志的失败方向是「记了但没做」,而不是「做了但没记」。前者你能查出来,后者你查不出来。
这也是前面讲会话日志时那条全局规矩的一半:模型可见 ⟺ 已记录——任何能到达模型请求的东西,都必须能从会话日志里重建出来。
紧接着,UI 拿这条事件点亮一张 pending 卡片:调用 presentCall(args),得到一个带 card 标签的渲染意图——bash 返回 { card: 'terminal', title: 命令 },write 返回 { card: 'diff', diffs: [...] }。用户于是在这次调用还在排队时就看见了「它要干什么」。
这里有一条硬规矩,docs/cookbook/adding-a-tool.zh.md 用了加粗:presentCall 必须是 args 的纯函数——不做 I/O、不读会话状态、不用时钟和随机数。为什么?因为它既在实时流式输出时被调用,又在你事后回放会话日志时被调用。如果它偷偷读了当前的文件内容,那么回放三个月前的会话时,它读到的是今天的文件——同一条日志,两次渲染,画出两张不一样的卡片。
某个 harness 把顺序写反了:工具执行成功后才追加 tool/call。它跑了三个月都没出问题。请说出这个实现在什么情况下会造成一个查不出来的故障。
bash,命令是 rm -rf build/,命令跑完的那一瞬间进程被 OOM 杀掉。现在去看日志。tool/call,也没有 tool/result。事后排查的人从日志得出的结论是「这一轮 agent 没有执行任何工具」,于是他去怀疑 CI、怀疑另一个进程、怀疑同事——真正的原因永远查不到。「三个月没出问题」不是证据,是因为进程恰好没在那 3 毫秒的窗口里死过。正确顺序把失败方向翻了过来:最坏情况是日志里有一条 tool/call 却没有配对的 tool/result,这是一个显眼且可检测的状态——仓库甚至为取消的场景专门补了合成结果(appendSkippedToolCall),就是为了让日志里不留下这种悬空的调用。变式:如果一次工具调用只写了 tool/call 而没有 tool/result,你会怎么设计一条自动检查把它抓出来?(提示:仓库要求每个包都拥有一个 ./invariant,检查的是事件与数据之间的关系。)
9.3 第 1 关:tools/pre-execute——所有策略挂靠的地方
第一道关是一个 waterfall。每个监听器拿到 (exec, next),返回一个决定:
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
调 next() 就是「我没意见,交给下一个」,最后没人有意见就默认 allow;直接返回一个决定就是短路,后面的监听器不再运行。钩子插件、权限插件、沙箱策略全都挂在这一关。
有一件它做不到的事,而且是故意的:前置策略不能改写参数。类型里就没有这个选项。docs/subsystems/tools.zh.md 给的理由是一句话:「参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。」
想想 9.2:参数已经写进 tool/call 了,也已经画在用户屏幕上的卡片里了。这时候如果某个监听器悄悄把 rm -rf build/ 改成 rm -rf /,日志说的、用户看见的、真正执行的,就是三件不同的事。
这是一个被明确记录的取舍,不是遗漏
packages/core/tools/README.md 的「已知限制与待办」里专门列了这一条:tools/pre-execute 有意不能改写 exec.arguments——记录下来的和渲染出来的参数会和真正运行的脱节;改写方案存在,但停留在一份提案状态的 Agent Note 里(.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。也就是说:仓库知道有人想要这个能力,也知道它的代价,目前选择不给。
去实验室试试:把第 1 关关掉(或者让它固定返回 deny),看会话日志里出现了什么、没出现什么。你应该会看到工具本体没跑,但日志里照样有 tool/call 和一条 tool/result——这正是 9.2 的设计在起作用。
产品提了个需求:模型经常给 bash 传相对路径,希望 harness 自动把它们改写成绝对路径再执行。你知道第 1 关不能改参数。请给出两种在现有结构下可行的实现方案,各说明它们把这件事放在了哪一层、以及各自的代价;然后说明为什么「偷偷在包装层里改」是不可行的。
bash 的 workdir 参数描述——「默认为会话工作区;相对路径会相对它解析」。这说明相对路径的解析本来就在工具内部完成。再想第二条路:如果你想让模型一开始就写绝对路径,该往哪儿加话?bash 的 workdir 就是这么做的。代价是:这属于工具的行为,你改不了第三方工具;而且日志里留下的仍是模型写的相对路径,排查时要知道解析规则才能还原。方案二:放在提示词。注册一段 section 或改 schema 的 description,明确要求模型给绝对路径(第 8 章:模型没看到的东西就不存在)。代价是它是引导不是保证,模型仍可能偶尔不照做,所以通常要配一个第 1 关的 deny 兜底——发现相对路径就拒绝,理由里写清正确格式,让模型自己改一次再来。为什么不能在包装层偷偷改:tools/execute 的包装层只能替换 exec.signal,参数在策略开始前就被物化成无损 JSON 并深度冻结了,写不进去;就算写得进去也不该写——参数已经进了 tool/call 日志、已经画在用户的 pending 卡片上,改了就等于让日志、界面和实际执行三者说三种话,而这正是文档给出的原始理由:「历史记录、审计、UI 和执行必须保持一致」。补一句:仓库确实知道有人想要改写参数的能力,它躺在 .agents/notes/proposed/ 里,是提案,不是实现。变式:把需求换成「把参数里的密钥替换成占位符再执行」,上面两种方案还成立吗?(提示:这次日志里留着的就是那个密钥。)
9.4 第 2 关:ctx.approval——一次性询问,默认拒绝
如果第 1 关有人返回了 ask,harness 就去问人。
问的方式是 ctx.approval.request(req):请求里有 agent、工具名、callId、一句「为什么要问」的理由和一个 signal。它有意不带工具参数——callId 已经指向那条画成卡片的调用,UI 把弹窗挂上去即可,不必再渲染一份可能漂移的副本。
结果类型是闭合的四选一:
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
只有 allowed-once 放行,而且只授权所问的那一个操作。其余三个都拒绝。
「默认拒绝」到底默认在哪
这四个字要拆开看,因为它在这条链路上被写了不止一次。我把 packages/core/tools/src/index.ts 里 serviceAsk 的分支和 docs/subsystems/approval.zh.md 的规定合起来列成一张表:
| 情况 | 结果 | 模型读到的理由 |
|---|---|---|
| 人点了「允许这一次」 | 放行 | —— |
| 人点了「拒绝」 | 拒绝 | 用户拒绝了这个工具 |
| 问题被撤回(signal 中止) | 拒绝 | 该工具的审批被取消 |
| 压根没装审批服务 | 拒绝 | 该工具需要审批(尚不支持) |
| 装了服务,但没有任何应答者 | 拒绝 | 该工具需要审批,但没有可用的审批通道 |
| 应答者抛异常 / 返回了词汇表外的值 | 拒绝 | 同上(归一化为 unavailable) |
| 这次调用没有 agent | 拒绝 | 需要审批,但这次调用没有 agent 可路由 |
会话策略是 never | 拒绝 | 确定性地返回 rejected,根本不分发给任何应答者 |
注意这张表没有「超时后放行」「没人管就当同意」这样的行。文档的措辞是:缺失、不负责该请求、抛异常或不合规的应答者会产生 unavailable,而非放行。
为什么默认必须是「不」
另一种设计是「默认允许,除非有人明确反对」。它在功能上更顺手:新装一个工具马上能用,不用配权限。
但这两种默认在出故障时的行为完全相反。审批 UI 崩了、应答者插件加载失败、跑在没有终端的 CI 里——「默认允许」会在这些时刻放行一切,而且是静悄悄地放行:没人报错,因为「没人反对」在它眼里就是正常状态。「默认拒绝」在同样的时刻会大声地什么都干不成,你十分钟内就会发现。
一个安全机制在自己坏掉的时候应该表现得像「全禁」,而不是像「全通」。这个原则英文叫 fail closed(失败时关闭),仓库在文档里就是这么写的。
还有两个细节值得单独点出来:
其一,四个理由字符串是不一样的。源码注释解释了为什么要费这个劲:「三个非授予结果以不同的理由拒绝,这样模型能分辨一个人类说的『不』和一个不存在的审批通道。」模型收到「用户拒绝了这个工具」应该换个方案;收到「没有可用的审批通道」则应该知道再问一百次也是这个结果——这是给模型的可操作信息,不是给人看的日志。
其二,never 策略在服务内部、waterfall 分发之前就执行了。文档明说这样做的原因:即使后来用 prepend 注册的应答者也无法绕过它。如果 never 是靠「注册一个总是返回 rejected 的应答者」实现的,那么任何人只要把自己的应答者插到更前面,就能把它架空。把不可协商的策略放在协商机制之外——这个套路你在下一节会再看到一次。
最后,审批留下一对只进日志、不进模型 transcript(文本记录)的审计事件:approval/asked 与 approval/decided,靠一个全新的 ApprovalRequestId 配对。而且 request() 要求发起请求的会话处于一个尚未结束的轮次内——因为这对审计事件必须被持久日志的提交/回放边界包住;空闲时发起的询问会在追加任何东西之前就被拒绝。
你把 harness 部署到无人值守的 CI 里跑。某个工具的策略是 ask,而 CI 里没有任何人能点按钮。这次调用会发生什么?如果你希望 CI 里这类工具直接放行,正确做法是什么?
unavailable,失败时关闭)。想让它放行,不能去改审批服务的默认值——那会把整个部署的安全底线掀了。正确做法是让这次调用压根不产生 ask:在第 1 关配置那个产生 ask 的策略插件,让它在 CI 这种部署下直接返回 allow。这符合仓库的一条约定:部署会变化的选择应该是 cordis.yml 里可校验的配置字段,而不是代码里的默认值。顺带说:ApprovalPolicy 里那个 never 是更严的一档(一律拒绝,用于严格的无人值守场景),不是「不用问了直接过」——名字容易让人误会,读的时候要小心。变式:如果你确实想要「CI 里自动同意」,仓库里有现成的东西吗?(提示:去看 packages/acp/acp 的说明——ACP 自动化桥接层为它拥有的 agent 提供一次性机器决策。想想「机器应答者」和「改默认值」在审计上有什么本质区别。)
9.5 第 3 关:单调守卫——只能拒绝,不能放行
过了审批,还有最后一道预分派检查:已注册的单调守卫。
单调守卫(monotonic guard):一个同步函数,看一眼这次调用,要么返回一个拒绝理由(字符串),要么返回 undefined 表示弃权。它没有「允许」这个返回值。「单调」在这里的意思是:权限只会朝一个方向走——越走越少,绝不回头。
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
这个类型少了一个分支,威力却很大。ToolGuard 的 JSDoc 把理由写在了签名旁边:「因为守卫没有允许结果,监听器的顺序无法把一个拒绝变回许可。」
对比一下第 1 关:那是个 waterfall,谁先注册谁先跑,一个监听器可以短路掉后面所有人。这意味着顺序决定结果——而顺序取决于插件加载顺序,那是配置文件的事。你的安全策略如果依赖「我一定排在他前面」,那它其实依赖的是别人没改过 cordis.yml。
守卫这一层把顺序从等式里拿掉了:任何一个匹配的守卫返回理由,这次调用就死了,谁也救不回来。全局守卫先跑,然后是作用域链上的(从最远的祖先开始),第一个拒绝就是最终答案。
身份受保护是什么意思
守卫拿到的是 Readonly<ToolExecution>,而且这不只是 TypeScript 的类型标注。在策略开始之前,注册表已经做了三件事(docs/cookbook/adding-a-tool.zh.md 明文):把 arguments 在一次递归遍历中物化成分离的无损 JSON、深度冻结它、分配一个不透明的 exec.token。callId、name、arguments、agent、token、调用方的 signal 在整个分发过程中保持不可变。
所以守卫看到的参数,就是写进日志的那份、画进卡片的那份、待会儿真正传给工具本体的那份。它不可能被谁在中间掉包。一个守卫如果不能确信自己检查的东西就是待会儿要执行的东西,它的检查毫无意义。
为什么审批排在守卫之前
这是本章最容易问反的一个设计问题。直觉上「问人」应该是最后一道,人说了算嘛。
仓库的顺序是相反的,docs/tool-execution-pipeline.zh.md 用一句话给了理由:「ctx.approval 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册守卫。」
把它翻成人话:问人是可协商的,所有者策略是不可协商的。
先问人,人说「允许这一次」;然后守卫仍然可以说「不行」。结果是:人的同意不能推翻所有者策略。这正是你要的——一个部署方写的「这个目录永远不许写」的守卫,不应该因为某个用户在弹窗上点了「允许」就失效。
反过来排会怎样?就会出现「守卫拒绝了,但审批放行了」这种组合,而 allowed-once 是一个授予。一旦授予排在拒绝之后,你就必须回答「谁赢」,而任何答案都会让某一方的语义变脏(下面那道题会逼你把两个答案都算一遍)。仓库的排法从结构上消灭了这个问题:能授予的那一层排在前面,只能拒绝的那一层排在后面,链条整体只会越走越紧。
第 10 章会专门讲守卫这一层——它怎么和 agent 作用域配合、单调性在实践中意味着什么、以及为什么这层的可测试性比 waterfall 好得多。这里先记住它在流水线上的位置和那个「没有 allow 分支」的类型。
一个源码里的细节:被拒绝的调用不会再跑守卫
读 prepareExecution 会发现,只有当前面的决定是 allow 时才去算 guardReason(exec);如果第 1 关已经 deny 了,直接用那个理由。这不影响安全性(结果都是拒绝),但影响你写守卫时的假设:你的守卫不会看到每一次调用,只会看到走到它面前的那些。想统计「所有被拒的调用」,守卫不是那个观察点——tools/result 才是。
有人提议把顺序改成「守卫 → 审批」,理由是「让人有最终决定权」。请构造一个具体场景,说明这个改动会导致什么安全后果。注意:你需要说清在新顺序下系统必须回答一个什么问题,以及无论怎么回答都有代价。
/etc 下的任何写入一律拒绝」。再设一个 ask 策略在写文件时问用户。然后按新顺序走一遍。/etc」,然后流程走到审批,弹窗问用户,用户点了「允许这一次」。现在系统必须回答:这次调用到底执行不执行?两个答案都有毒。(a) 执行——那么部署方写的硬性策略被一个终端用户的点击推翻了,守卫的「单调」承诺变成一句空话,而且这个推翻是无声的:日志里只看得到「用户批准了」,看不到「一条部署策略被覆盖了」。(b) 不执行——那么你刚刚给用户弹了一个无论点什么都不会生效的窗,用户点了允许却什么也没发生。这不只是体验差,它会训练用户不认真读弹窗,长期看反而降低了整套审批的有效性。仓库的现行顺序把这个问题从结构上删掉了:授予层在前,只能拒绝的层在后,任何调用最多只被授予一次、之后只可能被收紧。顺带一提,这也解释了为什么审批服务把 never 策略放在 waterfall 分发之前——同一个思路的另一次应用:把不可协商的东西放在协商机制够不着的地方。变式:如果某个部署确实需要「特权用户可以覆盖部署策略」,在现有结构下该怎么实现?(提示:守卫拿得到 exec.agent;想想「谁在问」这个信息应该在哪一层被读取,以及这个覆盖要不要留下自己的审计事件。)
9.6 第 4、5 关:环绕分发与工具本体
三道策略关都过了,才轮到执行。
第 4 关:tools/execute,一个「环绕」的 waterfall
环绕分发(around dispatch):这一层的监听器不是「在执行前跑一下」,而是把执行包在自己里面——它调用 next() 得到结果,next() 前后都是它的地盘。所以它能测时间(前后各取一次时钟)、能重试(next() 失败了再调一次)、能加超时(起一个定时器,到点就中止)。
它能改的东西只有一样:exec.signal(取消信号)。而且只能替换,不能移除。文档写得很死:注册表会在调用工具本体之前,把原始调用方的 signal 和包装层替换的那个重新融合起来。
为什么?因为超时插件的做法是「换一个 5 秒后自动中止的 signal」。如果这个替换真的把调用方原来的 signal 顶掉了,那么用户按 Ctrl-C 取消整个任务时,这个工具收不到通知——它会安安静静地跑完那 5 秒。融合之后,两个 signal 任一触发都能中止。包装层可以让取消更早发生,但不能让取消不发生。
第 8 章那个「timeoutMs 不进 schema」的伏笔在这里收:定义上的 timeoutMs 是声明性的,注册表自己从不执行截止时间——README 的已知限制里明说了,执行要靠 @deepseek-ai/dsh-tool-call-timeout-policy 这个 tools/execute 包装层。工具声明预算,策略插件执行预算,两件事分在两个包里。
第 5 关:工具本体,以及它路上的两个小门
工具的 execute(args, exec) 终于跑了。它只该返回一样东西:自己声明的那个规范 JSON 值——不是内容块,不是给人看的字符串。注册表接过来之后按 output.schema 校验、冻结,再调用纯渲染器 output.render(args, value) 变成模型可见的内容。
如果工具要动文件,路上还有一道文件系统的门:fs/write-intent 或 fs/edit-intent。这两个是单槽决策 waterfall——和前面几个不一样,工具分发时给的默认 thunk 直接返回 undefined(意思是「裸提供方,无条件放行」),而监听器不调 next(),自己把决定做完;第一个返回决定的监听器拥有这次决策。
先读后编辑的策略就住在这里(插件 @deepseek-ai/dsh-fs-observation-policy):它记录你读过哪些文件、读到的是哪个版本,然后在你要编辑一个没读过、或者读完之后已经被别人改过的文件时拦下来。注意这条策略完全不在工具流水线的七道关里——它挂在文件系统这一层,因此对所有走 ctx.fs 的工具一视同仁,不需要每个工具各写一遍。
工具还会在这一步写下自己的会话事件:fs/observed(我确认这个文件在这个版本存在/不存在)、todo/write、hook/invoked 与 hook/result、以及 Code Mode 的 tool/code-dispatch。这些是工具私有的账,和统一的 tool/result 是两回事。
一个超时包装层给某次调用换上了「3 秒后中止」的 signal。用户在第 1 秒按下 Ctrl-C 取消了整个任务。工具本体会在什么时候收到中止?如果注册表没有做信号融合,又会怎样?
ABORTED;如果本体还没开始跑,则是 ABORTED_BEFORE_DISPATCH——两个不同的错误码,因为「做了一半」和「根本没做」对后续处理是两回事。变式:如果某个包装层为了实现重试,在第一次失败后没有恢复自己替换的 signal 就再调一次 next(),会出什么问题?(文档要求包装层「必须恢复其 signal 并到达静默」。)
9.7 第 6 关:tools/post-execute——结果出来了,还能改
工具跑完了,结果先不急着交给模型。第三个 waterfall 拿到 (exec, result, next),返回四选一:
type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: UserMessage[] }
| { kind: 'accept'; value: JsonValue; additionalContexts?: UserMessage[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
- 接受:原样放行(调
next()就是这个)。 - 替换内容:换掉模型看到的那份文本,规范值和元数据保留。典型用途是结果太长时截断成「预览 + 一个定位符」。
- 替换值:换掉规范值,注册表会重新校验、重新渲染内容和元数据。内容和值只能替换其中一个,两个都给会抛错。
- 阻止:把纠正性的反馈变成一个
isError结果。比如「你写的这个文件没通过 lint,先修好再说」——模型收到的是一次失败加一段能照着改的反馈。
还有一个横跨三种决定的能力:additionalContexts,往下一次请求里塞一条带来源标注的用户消息。
常见误解:替换内容 = 把敏感数据挡住了
文档在这里给了很重的警告:内容替换是展示策略,而非保密策略。你把 content 换成了「[已脱敏]」,但规范值 value 还在那里——而在 Code Mode 下,程序拿到的正是 value,不是你替换的那段文本。所以一个「脱敏」插件如果只换 content,在原生模式下看起来生效了,一旦部署切到 Code Mode 就整个失效,而且不会有任何报错。想真正挡住,只有两条路:阻止,或者替换 value。
另一个容易忽略的细节:被前面几关拒绝的调用,也会走到第 6 关(看图 9-1 那条紫线)。这不是随手连的——它意味着一个「记录所有工具调用最终去向」的插件只需要挂在 tools/post-execute 或 tools/result 上,就能同时看到成功的、失败的和被拒的,不必去每一道关上各挂一个监听器。
但流水线自己抛出的异常不走这条路(图上的虚线):如果第 1 关的某个监听器抛了异常,或者审批服务抛了异常,那个失败已经是最终结果,直接进第 7 关归一化,跳过第 6 关。
去实验室试试:只关掉第 6 关,让一个会被 block 的调用通过。观察模型收到的东西从「一段纠正反馈 + isError」变成了「工具的原始成功结果」——然后想想,如果这个 block 原本是你的安全策略,这一关被关掉意味着什么。
你写了一个脱敏插件:挂在 tools/post-execute,把结果内容里的密钥串换成 [REDACTED],返回 { kind: 'accept', content: 脱敏后的内容 }。它在测试里表现完美。请列出至少三条这个实现仍然会泄漏密钥的路径,并给出正确做法。(需要同时用到 9.7、9.8 和 9.9 的知识。)
content 之外还有哪些字段?各自会去哪里?ToolExecutionSuccess 的字段列出来——value、content、meta、additionalContexts——然后逐个问「谁会读它」。value。在 run_code 里 await tools.x(args) 拿到的是策略处理后的规范 JSON 值,不是渲染后的内容——你只换了 content,程序原样拿到含密钥的 value,随手 console.log 一下就进了模型上下文。(2) meta(展示元数据)没被你碰。它由 output.presentationMeta 从同一个规范值派生,会被持久化在 tool/result 事件上供回放重现卡片。密钥如果出现在 meta 里,就写进了磁盘上的会话日志。(3) 子调用的日志副本。Code Mode 的每个子分派会记 tool/code-dispatch,存的是子调用渲染后的内容——那是子工具自己的 render 结果,不经过你挂在外层的这个监听器。(4,加分) 其他监听器。tools/result 的观察者收到的是完整的冻结结果,含 value;任何一个把结果写进指标系统或调试日志的插件都会把它带出去。正确做法:替换 value({ kind: 'accept', value: 脱敏后的值 }),注册表会用新值重新校验、重新渲染 content 和 meta,三者一起干净;或者干脆 block。文档那句话可以直接当结论背下来:「内容替换是展示策略,而非保密策略;需要隐藏程序化值的监听器必须阻止或替换该值。」再补一层:保密这件事最好根本不要在 post-execute 做——让工具的 output.schema 一开始就不要把密钥放进规范值,是更省事也更可靠的做法。变式:如果你没法改那个工具(第三方 MCP 服务器提供的),也不能接受 block,你会怎么在不碰工具的前提下拿到一个真正安全的实现?(提示:想想 finalizeContent 归谁所有,它能不能替你做这件事。)
9.8 第 7 关:收尾的四件事,一件都不能少
最后这一关看起来像是「把结果发出去」,其实是四个独立动作,顺序固定。
其一:注册表外层规范化
流水线里任何地方抛出的异常——前置监听器炸了、审批服务炸了、包装层炸了、工具本体炸了、结果快照失败了、渲染器抛了、元数据投影器抛了、返回值不符合 output.schema——全部变成一个结构化的 isError 结果。
这一条的价值在于它给了上层一个极强的保证:ctx.tools.execute() 这个 Promise 只会 resolve,不会因为工具的问题 reject。调用它的循环不需要写 try/catch 去猜可能出什么错,它拿到的永远是一个 ToolExecutionResult,只要看 isError。未知工具映射成 UNKNOWN_TOOL,调用失败但不终止当前轮次——模型收到一条错误,可以自己纠正后重试。
其二:finalizeContent——工具自己的最后一道内容不变式
这是唯一一处由工具定义自己控制的收尾钩子,而它的约定很特别,值得逐条读:
- 注册表在执行开始时就把这个回调快照下来(源码注释说明了原因:参数物化过程中的 getter 有可能替换掉已注册的回调)。
- 它恰好被调用一次,对每一种归一化结果——包括那些绕过了第 6 关的流水线失败。
- 返回
undefined表示保留原内容;其他所有结果字段仍归注册表所有,它改不了。 - 它必须是全函数,且不得抛异常。
为什么要给工具留这么一个位置?因为有些不变式只有工具自己知道。比如一个工具规定「我的输出永远不超过 N 行、永远带一个固定的结尾标记」——这条规矩必须对所有出口成立,包括「参数非法」「被拒绝」「超时中止」这些工具本体根本没跑的情况。第 6 关是别人对你的结果说话,finalizeContent 是你对自己的结果说最后一句话。
其三:tools/result——冻结的权威结果
结果被物化、冻结,然后通过 tools/result 同步通知所有观察者。这是通知,不是 waterfall:观察者只能看,不能改;观察者自己抛异常会被隔离(注册表记一条 warn 日志),不影响结果。
还有一个容易漏的点:规范值 value 只活在执行期间。循环持久化的只有 content、error 和 meta。文档说得很直白:回放可以重现展示,却无法重建规范的中间值。
其四:tool/result 会话事件,与 additionalContexts 的 FIFO 注入
最后写进会话日志的 tool/result,是面向模型的单一结果——不管中间经过了多少层改写,模型只会读到这一份。它通过 sourceEventSeqs 引用回 9.2 那条 tool/call 的序号,一问一答成对。
然后才轮到 additionalContexts:整批工具结果都记录完之后,按先进先出的顺序,把它们作为用户角色的消息注入。
为什么必须排在所有结果之后?因为模型的历史里,「工具调用块」和「工具结果」是严格配对的。如果在两条工具结果中间插一条用户消息,这对配对关系就断了。所以规矩是:先把这一批的结果按模型顺序全部记完,再统一追加上下文。
你要给一个工具加一条硬性规矩:「无论如何,这个工具的结果内容都不得超过 200 个字符。」现在有两个可选位置:写一个 tools/post-execute 监听器,或者给工具定义加 finalizeContent。选哪个?请构造一个能把另一个方案打穿的具体场景。
finalizeContent。打穿 post-execute 方案的场景:第 1 关的某个钩子插件自己抛了异常(比如它去调一个外部权限服务,服务超时了)。这个异常被注册表接住、变成 isError 结果,而这条路径跳过第 6 关直接进归一化——你的 200 字符检查根本没被调用,那条可能很长的异常消息原样进了模型上下文。finalizeContent 的约定恰好覆盖这个缺口:注册表对每一种归一化结果都恰好调用它一次,包括绕过 tools/post-execute 的流水线失败。补两点:(1) 正因为它会在这些异常路径上被调用,它才被要求「必须是全函数且不得抛异常」——它是最后一道防线,防线自己不能倒。(2) 反过来,如果你的规矩是「别人的工具也要遵守」(部署级策略),那就只能用 post-execute,因为 finalizeContent 是工具定义自己的字段,你改不了别人的定义。判据是所有权:这条规矩属于工具自己,就用 finalizeContent;属于部署,就用第 6 关,并接受它覆盖不到流水线异常这个已知缺口。变式:如果两个地方都写了截断逻辑(工具有 finalizeContent,部署又挂了 post-execute),最终生效的是哪个?(提示:看顺序——谁在后面谁说了算。)
9.9 Code Mode 也走同一条路,以及这一章的落点
第 8 章提过 Code Mode:模型不直接调工具,而是写一段程序,程序里 await tools.read(...)。
那么问题来了——程序里的那些调用,是不是绕开了这七道关?
不是。docs/tool-execution-pipeline.zh.md 最后一段专门交代了这件事:Code Mode 会把保留的 run_code 传输及其序列化子调用都送进同一条流水线。子调用有四个特殊之处:
- 携带父级 token。子调用的
parent字段是外层run_code执行的不透明 token。它有两个作用:让「等外层结果」这类观察者能关联,同时标记这是一次传输子分派而不是模型直呼——在mode: 'code'下,只有带 parent 的调用才可以执行原生工具名,模型直呼(没有 parent)会在策略流水线之前就被判为UNKNOWN_TOOL。 - 记录
tool/code-dispatch。每个已结算的子调用都留一条日志,存的是渲染后的内容和isError。这条日志的内容还能被tools/code-dispatch-logwaterfall 改写(比如结果太大时换成预览加定位符)——只改日志副本,程序拿到的值和模型看到的结果都不受影响。 - 拒绝呈现为具有约束力的驳回。子调用被守卫拒了,程序里那次
await会以一个真正的ToolCallErrorreject,程序可以try/catch继续;但它拿不到内部错误码,只能读工具名和给人看的消息。 - 省略
additionalContexts。理由写在文档里:为了保持调用与结果相邻。外层run_code是一次调用、一个结果;如果每个子调用都能往历史里插消息,模型看到的就是一堆插在调用和结果之间的东西。
这一章真正的落点
docs/tool-execution-pipeline.zh.md 用一句话总结了整条流水线存在的意义,我逐字翻译:
「这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。」
回头看 9.1 那六个需求,再回头看 q9-8 那道题:没有流水线时,每一条策略的实现量都随工具数增长,而且够不到别人写的工具;有流水线时,它是一个插件,对所有工具生效——包括明天才写出来的、别人写的。而工具那边一行都不用改,也不认识什么权限服务,它只知道自己要读文件。
这就是「一切都是插件」这句话在工具这一层的具体兑现方式。
答辩:如果我是审稿人
你把这条流水线夸成了扩展性的胜利。可它有七道关、三个 waterfall、一个只能拒绝的守卫层、一个绕过第 6 关的异常路径,还有一个只有工具自己能用的 finalizeContent。一个新人要读懂「我的调用为什么被拒了」得跨五个包。这难道不是把复杂度从工具里挪到了流水线里,总量一点没少,还多了一层没人完整理解的间接?
参考防守(先自己组织语言再看)
这个批评有一半是对的,得先认下来:复杂度确实没有消失,它被搬家了。而且搬家本身有代价——排查一次拒绝要跨包,是真的。
但「总量一点没少」不成立,关键在复杂度和什么成正比。挪之前,「所有写操作都要问一下用户」这条策略的实现量是 O(工具数),每加一个工具就要复制一次,而且复制的对象是产品代码,谁都能改坏。挪之后是 O(1):一个插件,一处实现。工具数从 52 涨到 200 的那天,前一种做法的成本涨了四倍,后一种没变。这不是把复杂度移动了,是把它从乘法变成了加法。
第二点,那些看起来多余的分支,大多是被真实故障逼出来的,不是设计者的洁癖。绕过第 6 关的异常路径存在,是因为「监听器自己炸了」和「工具执行失败了」不是同一类事件;finalizeContent 存在,是因为工具自有的不变式必须覆盖工具本体没跑的那些出口。你把这些分支删掉,得到的不是一个更简单的系统,而是一个在这些情况下行为未定义的系统——复杂度会跑到别处,变成故障排查时间。
第三点,仓库自己也承认这条链需要文档才能读懂——所以它把流程图作为生成产物维护(scripts/gen-doc-graphs.ts 写出英文源,中文侧作为经评审对侧配对维护),并且要求任何改动流水线的 PR 同步更新它。这不能消除理解成本,但把「文档和实现漂移」这个更坏的问题堵住了。
最后一点必须诚实:仓库处于开发者预览期,官方明说会有破坏兼容性的变更。这条流水线的形状(哪几个 waterfall、什么顺序)我认为是稳定的,因为它由约束推出来;但具体的类型名、字段名、错误码完全可能改。要以仓库为准,起点是 docs/tool-execution-pipeline.zh.md 和 packages/core/tools/src/index.ts。
真未解并发的工具调用,谁来保证它们之间的相互影响是安全的?
仓库的现状是这样的:一个工具可以声明 isConcurrencySafe(args),只有精确返回 true 才允许并行;未知、隐藏、未声明、无效、抛异常的分类器全部按独占处理(失败时关闭)。循环把连续的并行调用组成一个有界滚动池,把每个独占调用当作排序屏障。只有分发和工具本体会重叠,策略、持久结果和结果上下文仍保持模型顺序。
没有回答的是:这个声明由谁验证?README 的已知限制里写着一句很关键的话——「并发策略不是事件门禁」:executionMode() 直接读工具定义,插件只能为自己拥有的定义声明分类器。也就是说,一个工具说自己并发安全,系统就信了;没有任何一层能对它做校验,也没有任何插件能推翻它。那么,如果两个都自称并发安全的工具同时改同一个文件,会怎样?仓库把这条约定写在了并行调用的 Agent Note 里(要求「共享状态必须容忍并发分发;记录器竞态只有在可交换或失败时关闭的情况下才被允许」),但那是一份约定,不是一道门禁。
怎么入手:先读 packages/core/tools/src/index.ts 里的 executionMode()(注意它的失败时关闭分支),再读 packages/core/agent-loop/src/tool-calls.ts 的 runGroup——特别是 fillPool 里那句「在有序提交之后重新读取后续调用的模式,这样注册表变化可以造出一道屏障」。然后自己构造一个反例:写两个都声明 isConcurrencySafe: () => true 的工具,让它们对同一个文件做「读-改-写」,在同一个模型步骤里一起发出,看会不会丢更新。做出来之后,真正的开放问题才开始:这种检查应该放在哪一层?做成一个能否决工具自我声明的事件门禁?还是像文件系统的先读后编辑那样,把冲突检测下沉到资源那一层(谁碰同一个资源谁排队),完全不去问工具「你安不安全」?后者听起来更可靠,但需要每种资源都有版本概念——而 ctx.fs 有,ctx.shell 没有。这没有公认答案。
下面五句话里,只有一句是对的,指出是哪句,并说明其余四句错在哪。
(A) tools/result 的监听器可以修改最终结果。
(B) 单调守卫可以把一个被 deny 的调用改回允许。
(C) tools/execute 的包装层可以修改这次调用的参数。
(D) tools/post-execute 可以同时替换 content 和 value。
(E) 被拒绝的调用仍然会进入 tools/post-execute。
denied → post,被拒的调用跳过工具本体但仍进第 6 关,所以一个挂在这里的审计插件能看到所有去向。其余四句:(A) 错——tools/result 是 emit(同步通知),观察者收到已冻结的结果,改不了;观察者抛异常也会被隔离。(B) 错——ToolGuard 的返回类型是 string | undefined,只有拒绝和弃权,没有允许;这正是「单调」的含义。(C) 错——环绕包装层只能替换 exec.signal,调用身份(callId/name/arguments/agent/token)保持不可变,而且注册表还会在工具本体前把调用方的 signal 重新融合回来。(D) 错——两个 accept 分支是互斥的,同时给 content 和 value 会抛 TypeError。变式:把 (E) 改成「流水线自己抛异常的调用仍然会进入 tools/post-execute」,还对吗?
给你的 mini-harness 装上三层 waterfall
到上一章为止,你的循环看到工具调用块就直接查表执行。现在要把这一步拆成一条能挂东西的流水线,并且把「先记录后执行」这条规矩焊进去。
难点有三处,都不显然。
第一,被拒绝的路径为什么还要走 post。最自然的写法是「拒了就直接 return」。别这么写——那样你的审计插件就得在三个地方各挂一次。让所有出口都汇到同一个点上,是这条流水线最省事的一个设计。
第二,为什么 tool/call 要写在最前面、连策略都没跑就写。因为你要保证的是「日志宁可记了没做,也不能做了没记」。把它挪到 if (decision.kind === 'allow') 里面,看起来更「干净」——被拒的调用不脏日志——但你从此就查不出「谁试图删这个文件」了。被拒绝的尝试恰恰是最该留痕的那一类。
第三,那个 next 闭包里的 i++。它必须在取出监听器之后自增,否则重复调用 next() 会无限循环。真实的 Cordis waterfall 还处理了「监听器不调 next 就返回」(短路)和「监听器抛异常」——你这一版先不管,第 10 章再补。
做对了的样子:注册一个 pre 监听器,让它对某个工具直接返回 { kind: 'deny', reason: '测试拒绝' }(不调 next())。然后发一次这个工具的调用,检查三件事:(1) 工具本体的 execute 没有被执行(在里面 console.log 一句,应该一个字都不打);(2) log 里必须有一条 tool/call;(3) log 里必须有一条 tool/result,且 isError 为 true、内容里带着「测试拒绝」四个字。三条缺一条都算没做对:少了 (1) 说明你的短路没生效;少了 (2) 说明你把记录写在策略后面了;少了 (3) 说明拒绝路径没汇进 post 和最终记录。再加一道验证:注册一个 post 监听器打印一行,确认它在被拒绝的这次调用里也被调用了。
本章小结
模型说「删掉这个文件」,到文件真的没了,中间是这样一条路:
- 还没到关口就先记账。
tool/call在执行之前写进日志,保证失败方向永远是「记了但没做」。UI 同时用纯函数presentCall(args)点亮 pending 卡片。 - 第 1 关
tools/pre-execute:allow/deny/ask 的可重排 waterfall,钩子、权限、沙箱都挂这里。它不能改写参数——日志、审计、UI 和执行必须一致。 - 第 2 关
ctx.approval:一次性询问,只有allowed-once放行;拒绝、取消、无服务、无应答者、无 agent、never策略——全部拒绝,而且理由各不相同,好让模型分辨「人说不」和「没有通道」。 - 第 3 关 单调守卫:只能拒绝或弃权,没有 allow 分支,所以顺序推翻不了拒绝;身份(参数、callId、token)已被冻结。审批排在它之前,是为了让人的同意无法推翻所有者策略。
- 第 4、5 关:环绕分发包住工具本体,只能替换 signal 且注册表会重新融合调用方的取消;文件变更还要过
fs/write-intent/fs/edit-intent这道单槽决策。 - 第 6 关
tools/post-execute:接受/阻止/替换内容/替换值/追加上下文。替换内容不是保密——要挡住程序化消费方,只能 block 或替换 value。被拒的调用也走这一关,流水线自身的异常不走。 - 第 7 关 收尾四件事:外层规范化(一切异常变
isError)→ 工具自有的finalizeContent(每种结果恰好一次,含跳过第 6 关的失败)→tools/result冻结通知 →tool/result会话事件,最后按 FIFO 注入additionalContexts。 - Code Mode 不是后门:子调用重新进入同一条流水线,携带父级 token、记
tool/code-dispatch、省略 additionalContexts 以保持调用与结果相邻。
整章的落点只有一句:钩子可以跨工具系列生效,而不用让工具与某个策略服务耦合。
下一章我们把第 3 关放大——单调守卫为什么值得单独成章,以及「只能收紧、不能放松」这个约束能换来什么。
第10章 权限:只许说不,不许说是
一句话导语:这一章不问「怎么拦住 rm -rf /」,而问一个更难的问题——凭什么保证拦截逻辑本身不会因为多装了一个插件而失效。
学完这一章你应该能做到
- 用「加法只有一个方向」解释单调守卫,并说出如果守卫能放行会出现什么具体故障
- 分清默认拒绝与默认放行,并说出仓库在四种「答不上来」的情况下各自选了哪一边
- 解释为什么审批要排在单调守卫之前——顺序反过来会丢掉什么保证
- 说清守卫与沙箱是两层不同的防线,并指出各自失效时另一层还剩什么
- 说出本章五处「仓库自己承认不牢靠」的地方,并能回仓库把它们各自核实一遍
- 给你的 mini-harness 装上单调守卫与默认拒绝的审批,并亲手把它弄坏一次
next() 就短路;第 3 章的副作用与释放函数。10.1 黑名单是错的答案
先把问题摆正。模型这一步吐出一个工具调用:bash,参数 { command: "rm -rf /" }。你要拦住它。
最朴素的做法:写一张黑名单。命令里出现 rm -rf / 就拒绝。十行代码,今天下午就能上线。
然后你会花掉接下来的一整年:rm -rf /* 不在名单上;rm -fr / 换了参数顺序;find / -delete 根本不叫 rm;python3 -c "import shutil,os;shutil.rmtree('/')" 连 shell 命令都不是;echo cm0gLXJmIC8K | base64 -d | sh 把它藏进了一串字母。你在做的事情,本质上是**猜一个比你更会写命令的东西下一步会怎么写**。这场比赛你赢不了。
那正确的问法是什么
不要问「这条命令是不是坏的」,要问「这次操作有没有得到授权」和「就算它跑起来,能碰到的东西有多大」。前一个问题的答案来自一套决定权的安排,后一个来自隔离。这一章讲的就是这两件事,以及它们怎么叠在一起。
最小权限原则(principle of least privilege):只给完成当前这一件事所必需的权力,一点都不多给。不用它会怎样?你只有两种状态——「全权」和「没权」。一旦给了全权,之后每一次判断失误都是灾难性的:模型想删 build/ 却把路径拼错成 /,没有任何一层会替你兜住。
难点在于,最小权限听起来像一句正确的废话。真正难的是工程化:谁来判断「必需」?这个判断由多少段代码共同做出?当第五个团队往里塞第五段判断逻辑时,前四段的结论会不会被冲掉?
下面这张图是本章的地图。它把一次调用要穿过的几层画成同心的几道墙——这种叫法有个名字,纵深防御(defense in depth):不指望任何一道墙是完美的,而是让每一道墙的失效方式互相不重叠。
docs/tool-execution-pipeline.zh.md、docs/subsystems/sandbox.zh.md、filesystem.zh.md 归并重画,同心圆的画法是本站的表达方式,仓库并没有这样一张图。蓝色几层回答「要不要做」,橙色两层回答「做了能跑多远」。有人说:「你们那套太复杂了,我在 tools/pre-execute 里写一个正则,把危险命令全拒掉,一样安全。」请指出这句话在工程上而不是道德上错在哪,并说出他这套东西唯一真正有效的场景是什么。
tools/pre-execute 这一层(钩子、策略插件都在这里),而真正的边界放在它后面的守卫和沙箱两层——这正是图 10-1 里第 1 层用细线、第 3 和第 5、6 层用粗线的原因。变式:把他的正则改成白名单——只允许 git status、ls、cat 三条命令,其他一律拒绝。现在失败方向变成了默认拒绝,这套东西可以当安全边界用了吗?(提示:想想 cat 能读什么,以及 git status 后面能不能跟东西。)
10.2 加法只有一个方向
现在进入本章的核心。仓库里有一行类型定义,整章的重量都压在它身上(packages/core/tools/src/index.ts):
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
一个单调守卫(monotonic guard)是一个函数:给它一次已经冻结好的调用(工具名、参数、callId、token 都不可改),它返回一个字符串表示「拒绝,理由是这个」,或者返回 undefined 表示「我没意见」。
注意这里没有的东西:没有 'allow',没有 true,没有任何一种「我批准了」的返回值。这不是作者忘了写。仓库在同一处的注释里点明了原因:因为守卫没有 allow 结果,监听器顺序无法把一次拒绝重新变回许可。
为什么这条约束看起来多余
第一次读到它的人几乎都会想:「我自己写的守卫当然只会拒绝,我又不会闲着没事写一个专门放行的守卫。」——对,你不会。但你不是唯一一个注册守卫的人。这条约束的价值不在你写的那一个守卫上,而在所有守卫叠在一起的时候。
组合:为什么「只能减,不能加」是一个可以证明的性质
把每个守卫看成一次对「允许集合」的操作。守卫只能拒绝或弃权,意味着它对这个集合只能做一件事:拿掉一些元素,或者什么都不做。永远不会往里加。
于是,无论你装多少个插件、它们以什么顺序注册、谁先谁后,最终的允许集合都是所有守卫各自结果的交集。交集有一个小学生就能验证的性质:再交一个集合,只会更小或不变,绝不会变大。
这就是「单调」二字的含义:沿着「注册的守卫越来越多」这个方向,「被允许的调用」这个集合单调不增。你不需要读懂那五个插件各自写了什么,就能对整体下这个结论。在一个由不同团队、不同来源的插件拼起来的系统里,这种「不用读就能下的结论」贵得离谱。
反例:如果守卫能放行会怎样
不要接受一句抽象的「那样就不安全了」。我们把它具体化。假设 ToolGuard 的返回类型改成 'allow' | string | undefined,第一个给出非 undefined 答案的守卫说了算。现在有两个守卫:
- 守卫 A(安全团队装的):如果命令里带
--force,返回'禁止强制推送'。 - 守卫 B(某个「效率插件」装的):如果当前 agent 是它自己创建的子 agent,返回
'allow'——作者的想法很朴素:「我自己的子 agent 我信得过,别老弹窗打断它。」
两个作者各自都写得没错,都通过了自己的测试。装到一起之后,发生的事情取决于注册顺序:
| 注册顺序 | git push --force 在子 agent 里 | 安全团队看到的 |
|---|---|---|
| A 先,B 后 | 被拒绝 | 「我们的策略生效了」 |
| B 先,A 后 | 放行 | 「我们的策略生效了」(他们看到的是同一份代码) |
请盯住最后一列。安全团队读自己的代码读不出问题;效率插件的作者读自己的代码也读不出问题。故障只存在于「两者拼装时的顺序」里,而顺序取决于 cordis.yml 里那几行的先后,也取决于谁先被 HMR(热模块替换)重载过一次。这种 bug 的排查成本极高,因为它不在任何一个人的代码里。
更糟的是它的失败方向:拼错顺序的后果是放行,而且没有任何一处会报错。系统安安静静地变得更松,直到有人事后从日志里发现。
把 allow 从类型里删掉,这一整类 bug 就在类型层面消失了。没有 allow 分支,就没有「谁的 allow 盖过了谁的 deny」这个问题;顺序不再影响结果,只影响你先看到哪一条拒绝理由。仓库里 guardReason() 的实现因此只有五行:从头走一遍,谁先返回字符串就用谁的理由,全都返回 undefined 就放过——它甚至不需要知道「放过」意味着什么,因为那不是它的决定。
打个比方
像一扇需要多把钥匙的门,但每个人手里的都是锁不是钥匙:任何人都可以再加一把锁,没有人能替别人开锁。门开着,只说明还没有人上锁,不说明谁批准了。
类比失效处:真实的多锁门里,加锁的人自己能开自己那把;而这里守卫连自己那把都开不了——它只能在每次调用时重新决定要不要落锁,没有任何「记住我批准过」的状态。真正的「批准」在下一节,由另一套机制承担。
下面这个实验室把守卫这一层单独拎了出来,并且给了你一个危险开关:把「单调」这条约束关掉,让守卫能返回 allow。请务必去点它。先注册一个拒绝的守卫,确认调用被拦下;然后打开开关,再加一个总是 allow 的守卫,把它拖到前面——看着刚才那条拒绝被吃掉。你需要亲眼见一次「越权」长什么样,后面所有关于顺序的讨论才有分量。
一个守卫函数执行完,返回了 undefined。这次工具调用接下来会发生什么?请在这四个说法里选出正确的那个:
(A) 调用被允许执行。
(B) 调用被拒绝。
(C) 这个守卫弃权,交给下一个守卫;全部弃权时调用保持进入这一层时的状态。
(D) 调用回到 tools/pre-execute 重新走一遍。
undefined 意味着「我允许」,那么一个弃权的守卫就等于一次投赞成票,多个守卫之间就会出现「几票赞成几票反对」的问题;而在「弃权 = 不改变」的语义下,永远只有一种票有效力——反对票。(A) 错在把「没反对」当成了「批准」;真正让这次调用处于 allow 状态的是第 1 层 tools/pre-execute 的默认值,不是守卫。(D) 不存在,流水线不回头。变式:如果一个守卫返回的是空字符串 '',按这个类型定义会发生什么?这是一个好设计吗?(提示:'' !== undefined,但一条空的拒绝理由对模型意味着什么?)
有人提议给守卫加一个折中的放行能力:只有全局守卫可以返回 allow,agent 作用域的守卫不行,理由是「全局的是部署方装的,可信」。请构造一个具体的插件组合,说明这个折中仍然会产生越权;然后指出它比原方案多引入了什么新麻烦。
/etc 一律拒绝」,G2「凡是本部署的自动化维护任务一律 allow(免得半夜弹窗)」。然后构造一次既属于维护任务、又要写 /etc 的调用,把两种注册顺序的结果都写出来。agent.ctx 注册的,才能预测它的效力;而在真方案里这两种守卫的效力完全一样(都只能拒绝),作用域只决定「管谁」不决定「多大权」。第二,多了一个可被利用的提权路径:只要一个插件能拿到普通上下文,它就获得了推翻所有 agent 级策略的能力,于是「能不能拿到普通 ctx」变成了一个安全边界——而在仓库的实际设计里,插件拿到什么样的上下文只是可见性与生命周期问题,从来不是权限问题。顺带说清楚真方案里作用域的作用:guardReason() 先走全局层,再沿该 agent 的 scope 链自远及近逐层走,任何一层返回理由就结束——因为大家都只能拒绝,这个顺序只影响你先看到哪一条理由,不影响是否被拒。变式:反过来问——既然守卫不能放行,那部署方确实想给某个受信任的 agent「开个口子」时该怎么做?(提示:口子不在守卫这一层开;想想第 1 层的 allow、以及下一节那个「一次性」的东西。)
10.3 答不上来的时候,锁死还是敞开
守卫解决了「策略之间不打架」,但它有一个先天限制:它是同步函数。ToolGuard 的返回类型是 string | undefined,不是 Promise。同步意味着它不能等:不能等网络,不能等磁盘,更不能等一个人抬头看屏幕。
可是「问人」恰恰是权限系统里最重要的一件事。于是仓库把它单独做成一层:ctx.approval(源码 packages/interaction/user-approval/src/index.ts,全文 347 行)。
一次性询问(one-shot approval):服务把一个请求发给已组合的应答者链,拿回一个结果。结果的类型只有四个取值,而其中只有一个是许可:
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
allowed-once——名字里就写着 once。它只授权所询问的那一个操作:这一个 callId、这一次调用。它不是一张通行证,不会被记住,下一次同样的命令要重新问。另外三个值,调用方一律当作拒绝。
fail-closed 与 fail-open:两个词,两种世界观
默认拒绝(fail closed):当系统无法得到答案时,按拒绝处理。默认放行(fail open):无法得到答案时,按允许处理。
这两个词的分界线不在「有人说不」,而在「没人说话」。真正决定一套权限系统性格的,正是它在沉默时的选择。仓库把这四种沉默逐一钉死,全部倒向拒绝:
| 沉默的形态 | 结果 | 换成 fail-open 会怎样 |
|---|---|---|
| 压根没装审批服务 | 拒绝(工具结果里写明「需要审批,但此组合尚不支持」) | 忘了装一个插件 = 全站无人值守放行 |
| 装了,但没有任何应答者(比如没接 UI) | 链走到底,默认值 'unavailable' → 拒绝 | 关掉界面就等于拿到了最高权限 |
| 应答者自己抛了异常 | 被服务捕获 → 'unavailable' → 拒绝 | 写崩一个 UI 组件就能提权 |
| 应答者返回了一个不在词汇表里的值 | 规范化成 'unavailable' → 拒绝 | 返回 {} 之类的脏数据可能被当成真值 |
把第三列连起来读一遍,你会发现 fail-open 的共同特征:让系统更容易出错的那一方,同时也是获得更多权限的那一方。这在安全设计里是最不该出现的激励结构。仓库源码里那两行注释直白得几乎是在喊:a missing or throwing answerer yields 'unavailable' (fail closed);a throwing answerer must fail the QUESTION closed, not the caller's tool call open——应答者炸了,炸的是这个问题,不是把调用方的工具调用敞开。
打个比方
电动门断电时会怎样?消防通道的门断电自动打开(fail open),因为「人被困在里面」比「有人溜进来」严重得多;金库的门断电自动锁死(fail closed),因为反过来。选哪一边,取决于你更怕哪种事故。
类比失效处:真实的门要在两种人命相关的风险之间权衡,所以两种选择都合理。而在 agent 这里,「拒绝」的代价只是一次任务失败——模型会收到一条写清原因的错误结果,可以换个方式再来。既然一边的代价小到这个程度,就没有理由选另一边。
一个部署把 harness 跑在 CI(持续集成)流水线里:没有界面,没有人盯着,因此没有注册任何审批应答者。某个工具在 tools/pre-execute 里返回了 { kind: 'ask' }。这次调用的结局是什么?模型会收到什么?
'unavailable' 到底算哪一边。表 10-2 的第二行。'unavailable';工具注册表把它翻译成 { kind: 'deny' },理由是「该工具需要审批,但没有可用的审批通道」。模型收到的是一条 isError 的工具结果,里面写着这句话。注意这句理由的措辞是精心选过的:它明确说的是「没有通道」,而不是「用户拒绝了你」——这两件事对模型的下一步完全不同。前者意味着换个不需要审批的做法可能可行,后者意味着人已经表态、不该再试。仓库为四种拒绝各写了一条不同的理由,就是为了让模型能分辨。变式:同样这个 CI 部署,如果把会话的审批策略显式设成 'never',结局有什么不同?(提示:结果是一样的拒绝,但走到拒绝的路径不同——一个是问了没人答,一个是压根没问。想想这个差别在日志里留下什么。)
10.4 为什么问人这一步排在守卫前面
第 9 章给过顺序:第 2 关是 ctx.approval,第 3 关才是单调守卫。为什么不反过来?直觉上,「人都同意了」听起来应该是最高权威,理应排在最后一锤定音。
仓库把这句话写在 docs/tool-execution-pipeline.zh.md 里:「ctx.approval 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。」
拆开这句话。它说的是两件事的分工:
- 可以被人推翻的东西,放在审批之前或之中——比如某个钩子说「这个操作有风险,问一下」,人说「可以」,那就继续。
- 不该被人推翻的东西,放在审批之后,也就是守卫这一层。这些是所有者策略(owner policy):部署方、平台方定下的红线。
如果顺序反过来会怎样?守卫先跑,弃权;然后弹窗问人,人点了「允许」——于是这次调用带着一个已经作废的守卫结论走进了工具本体。整条红线形同虚设,因为红线的判定发生在获得许可之前。反过来,守卫排在审批之后,人的「允许」只能把调用送到守卫面前,送不过去。
同一个「顺序不能被绕过」的执念,在审批服务内部又出现了一次。会话的审批策略有两个取值,其中 'never' 表示确定性拒绝。这个判断写在服务自己的 request() 路径里,在 waterfall 分发之前。源码注释解释了为什么不能把它做成一个监听器:
// The 'never' policy is decided HERE, before any dispatch: a listener // registered with `prepend: true` after this service mounts would sit // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the // documented promise that 'never' rejects deterministically regardless // of registration order — only the service's own request path can.
翻译成人话:Cordis 允许一个后来的插件用 prepend 把自己插到监听器链的最前面。如果 'never' 只是链上的一个监听器,那么任何一个后加载的插件都能抢在它前面把请求认领走,「永不询问」的承诺当场作废。所以它必须待在链之外。
常见误解:'never' 是「不用问,直接过」
不是。ApprovalPolicy 的 'never' 意思是永不询问任何人 = 每一次询问都确定性地返回 'rejected'。它是更严的那一档,不是更松的。仓库源码的原话是 never prompt anyone: every ask resolves 'rejected' deterministically,用途写得很清楚:CI、无人值守运行——「结果不问就已知」的那种场景。
这个命名有坑,值得记一下:读到 never 时脑子里冒出来的往往是「never ask me, just do it」(别烦我,做就是了),而它的真实含义是「never ask anyone, so the answer is no」(不问任何人,所以答案是不)。如果你按前一种理解去配置一台机器,你会以为自己打开了自动化,其实是把所有需要审批的操作全锁死了。
某个部署有一条铁律:任何情况下都不许改动 /etc/sudoers,它以一个全局守卫的形式存在。现在假设有人把流水线的顺序改成「先守卫,后审批」,其余一律不变。请构造一次具体的调用序列,说明这条铁律怎么被破掉的;然后说明为什么「让守卫在审批之后再跑一遍」这个补丁并不能真正解决问题。
write 改 /etc/sudoers;(2) 某个 pre-execute 钩子判断为敏感,返回 ask;(3) 守卫(现在排在前面)返回拒绝理由;(4) 审批弹窗,用户看到一句「这个操作要改系统文件,允许吗」,点了允许。第 (4) 步的输出是什么?它盖过了第 (3) 步吗?allowed-once 是流水线拿到的最后一个决定,调用被放行,铁律失效。更值得注意的是这里的社会工程学:用户看到的弹窗只说「这个操作要改系统文件」,没有任何一处告诉他「部署方已经禁止过这件事」——他不是在推翻红线,他根本不知道有红线。为什么「审批之后再跑一遍守卫」这个补丁不行:它把一次调用的判定从「一遍」变成了「两遍」,于是必须回答一个新问题——两遍的结论不一致时算谁的?如果算第二遍的,那第一遍就是纯浪费;如果算第一遍的,那第二遍还有什么意义;更糟的是守卫现在可能被同一次调用触发两次,而守卫作者写代码时假定的是「每次调用问我一次」(比如一个按次数限流的守卫,会平白多扣一次配额)。正确的做法不是跑两遍,而是让唯一的那一遍站在最后——这就是仓库的顺序。
变式:把铁律改成「/etc/sudoers 默认不许改,但如果用户明确批准可以改一次」。这条规矩还能用守卫实现吗?如果不能,它该写在哪一层?
10.5 预设:把两个开关捆成一句人话
到这里你已经见到两个互相独立的旋钮:
- 沙箱模式(
sandbox/mode):read-only/workspace-write/danger-full-access——管的是文件效果。 - 审批策略(
approval/policy):ask/never——管的是要不要问人。
两个旋钮各三档和两档,组合起来六种。现在把它们摆到一个真实用户面前,让他自己选——大部分人会在第一个下拉框就卡住,因为 workspace-write 这个词对他来说不是一个决定,是一道题。
权限预设(permission preset):一组预先配好的旋钮取值,起一个人能看懂的名字,客户端把它做成一个选择器。仓库的默认表只有两项(packages/interaction/permission-presets):workspace-write(沙箱 workspace-write + 审批 ask)和 danger-full-access(沙箱 danger-full-access + 审批 never)。
为什么需要预设,而不是每次都问
三个理由,按重要性排:
一、每次都问会训练出「一路点允许」。这是权限弹窗最著名的失效模式:问得越勤,用户越不看内容。一个预设把一整类决定提前一次性做完,把弹窗留给真正的例外。
二、两个旋钮的组合里有一些是没有意义的。用户不该有机会走进那些格子(下一道题会让你找出一个)。预设表就是部署方对「哪些组合是我们支持的」的表态。
三、它是可回放的用户意图。切换预设会往会话日志追加一条仅记日志的 permission/preset 事件,然后通过每个旋钮各自的规范 setter 写入实际值。为什么要多记一条?因为两个预设可能共享同一组旋钮取值,光看旋钮分不出用户当初选的是哪一个。
要抓住的关键是:预设自己不执行任何强制。执行、提示词叙述、回放,读的仍然是各自旋钮折叠出来的结果。预设只是一层「组合与命名」。这一点值得记住——一个只做命名、不做强制的层,替换或删掉它都不会让系统变得更松。
默认预设表里,danger-full-access 把沙箱开到最大(danger-full-access),却把审批策略设成了最严的 never(一律拒绝)。乍看是自相矛盾的一松一紧。请解释这个配对为什么自洽。然后回答:在另一个预设 workspace-write(沙箱 workspace-write + 审批 ask)之下,模型的一条命令被沙箱拒绝了,它按提示带上 sandbox_permissions 重试一次——这次重试会经过哪些环节?如果此时会话的审批策略被人改成了 never,重试的结局是什么?
never 是「不问 = 拒绝」。当没有任何东西需要问的时候,「一律拒绝」拒绝的是空集,代价为零;而它换来的是确定性——不会有任何弹窗跳出来打断无人值守的运行。danger-full-access 模式下消费方的行为(docs/subsystems/sandbox.zh.md 原话:这种模式的消费方直接 spawn 原始 argv,不调用 ctx.sandbox),再想想 sandbox_permissions 这个参数是在什么前提下才有意义的。danger-full-access 恰好把会触发询问的那类事情消灭了:既然没有隔离,就不存在「被沙箱挡住、需要请求更宽权限」的重试,也就没什么可问的。此时 never 的实际效果是「保证不弹窗」,代价接近零,收益是确定性(CI、无人值守)。它是一个「全放开 + 不打扰」的组合,不是「一松一紧」的自相矛盾。重试的路径(据
packages/shell/tool-bash/src/index.ts 与 docs/subsystems/shell.zh.md):模型必须原样重发同一条命令,加上 sandbox_permissions(够用的最窄的更宽模式)和一句 justification;工具层在任何东西执行之前拿这两样去 ctx.approval 请求批准,只有拿到 allowed-once 才用更宽的模式跑这一次。注意这一次批准同样是一次性的、绑定这一次调用的。策略是
never 时:审批请求在服务内部、任何应答者跑起来之前就被判为 'rejected',重试失败。仓库连模型侧的措辞都配好了——never 策略下注入系统提示词的那句话明确写着:本会话的审批提示已禁用,需要审批的操作会被自动拒绝,不要请求沙箱提权(不要设置 sandbox_permissions)。这是一条「把已知的死路提前告诉模型」的设计:否则模型只会一遍遍重试同一条注定失败的路。变式:某部署想要「沙箱开到 read-only,但审批策略 ask」这一组,并起名叫 strict-review。这是合法的预设吗?它描述的是什么样的工作方式?(提示:想想在只读模式下,模型每次想写文件会发生什么。)
10.6 第二堵墙:就算做了也跑不出去
前面所有机制都在回答同一个问题:要不要做。它们共享同一个前提——harness 能够看到并拦住这次操作。这个前提有一个明显的缺口:一旦 bash 真的把一个进程 spawn 起来了,那个进程接下来干什么,harness 就管不着了。它可以自己再 fork,可以读任何它有权读的文件,可以把你的 ~/.ssh 打包发出去。
所以需要第二类防线,它不回答「要不要做」,只回答「做了能跑多远」。这就是沙箱。
进程沙箱(process sandbox,ctx.sandbox):消费方交出即将 spawn 的那一串确切 argv,沙箱提供方把它包装成另一串 argv 还给你,你 spawn 包装后的那一串。真正的强制不在 JavaScript 里,在操作系统内核里——Linux 上是 bwrap 或 Landlock,macOS 上是 Seatbelt,Windows 上是 ACL 受限令牌。
「包装 argv」这个做法值得多看一眼。它意味着沙箱层完全不需要理解命令的语义:不解析 shell 语法,不识别危险模式,不做任何猜测。它只是把 ['bash','-c','rm -rf /'] 变成 ['bwrap','--ro-bind','/','/', '--bind', workspaceRoot, workspaceRoot, '--', 'bash','-c','rm -rf /'] 这样的东西(示意,实际参数由提供方与平台决定)。命令照跑,删除照做,但内核在系统调用那一层把写操作挡掉。
三件必须知道的事实
一、这一层同样是 fail-closed。没有可用后端时,confine() 抛 SandboxUnavailableError(错误码 SANDBOX_UNAVAILABLE),执行被拒绝。仓库的原话是:对于受限策略,静默的无隔离透传永远不合法。也就是说,「沙箱装不上,那就先不隔离跑着吧」这条路被明确封死了。
二、强制的完整度是一个要如实上报的事实。后端返回的 enforcement 有两档:full 表示这个模式承诺的文件效果全都管住了;partial 表示当前后端或较旧的内核 ABI 只管住了一部分。仓库要求「需要绝对保证的消费方必须拒绝或把这个区别向上暴露,不得当作 full」。当前已知的 partial 情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。
三、模式只管文件效果。SandboxMode 的三档管的是文件读写,网络与进程可见性不在这套词汇的定义范围内。这句话必须原样记住:在 read-only 沙箱里跑起来的进程,仍然可能访问网络。如果你的威胁模型里包含「把数据外传」,沙箱模式这一层不解决它。
本站推断,请自行核实
「一旦进程 spawn 起来 harness 就管不着了」这句话是本站为了讲清楚沙箱存在的理由而做的简化。仓库实际上仍然通过 ctx.subprocess 持有进程组、能 kill、能收集输出、能做超时(见 docs/subsystems/subprocess.zh.md)。准确的说法是:harness 能终止它,但在它活着的这段时间里,harness 不能逐个系统调用地审查它做了什么——能做这件事的只有内核。要拦住「进程在被 kill 之前的那 200 毫秒里读走了密钥」,只有沙箱这一层。
答辩:如果我是审稿人
你们这几道防线全都跑在同一个进程里,由同一个插件系统加载,用的是同一个 ctx。一个插件完全可以不经过 ctx.tools,直接 import 一个 child_process 就把命令跑了;它也可以抢先占住 fs/write-intent 那个单槽,让先读后改的检查形同虚设。那么这一整套东西到底在防谁?
参考防守(先自己组织语言再看)
防的是模型,不是防插件。这套系统里有两条截然不同的信任线,把它们混为一谈就会得出「全是纸糊的」这个结论。
插件是可信的。它们由部署方在 cordis.yml 里显式写下、显式组合;仓库的通用规则甚至明说「在同进程的类型化边界上信任 TypeScript」,不为静态接口已经保证的值加运行时校验。一个能往组合里塞插件的人,本来就已经能改整个部署——对他做防御没有意义,就像给一栋楼装门禁却把钥匙交给盖楼的人:这不是漏洞,这是所有权。
模型的输出是不可信的。它是唯一一股来自系统之外、内容不可预测、且每一步都在变的输入。本章所有机制的防御对象都是它:工具调用的参数、命令文本、要写的文件路径。审稿人举的两个例子——插件绕过 ctx.tools 直接 spawn、抢占单槽——都需要先有一个恶意插件被组合进来,而那一步已经在信任线的另一侧了。
但有两点必须让步。第一,这条信任线在文档里说得不够响:一个新用户很容易把 tools.restrict() 当成安全边界来用(仓库自己在 packages/core/tools/README.zh.md 里补了一句「这是实时可见性组合,不是权限边界」,但那是一句夹在别处的话,不是显眼的警告)。第二,「插件可信」这个前提在 MCP 服务器、第三方 skill 越来越多的现实里正在变松——今天由部署方手写的那份组合清单,明天可能有一半来自别人的仓库。这是真实存在的、尚未被这套设计覆盖的方向。
下面四句话里,只有一句符合仓库对沙箱的定义,找出它并说明其余三句各错在哪:
(A) 在 read-only 模式下,进程无法访问网络。
(B) 在 danger-full-access 模式下,ctx.sandbox.confine() 会返回一个不做任何限制的包装 argv。
(C) 当 enforcement 报告 partial 时,说明沙箱完全没生效,等同于没装。
(D) 同一时刻,两个不同的消费方可以在不同的沙箱策略下各自执行,而提供方不需要为此改变自身状态。
SandboxMode 的定义那段话:它明确划出了自己不管什么;以及 ConfinedSandboxMode 为什么要把一档排除在外。docs/subsystems/sandbox.zh.md 找这两句——「网络与进程可见性不在此处的定义范围内」和「danger-full-access 的消费方直接 spawn 原始 argv,不调用 ctx.sandbox」。这两句直接判掉 (A) 和 (B)。read-only 下跑,同时一个受限子 agent 需要它的状态目录可写」这种情况可以并存,一次获批的提权重试也只是一次带着更宽策略的新调用。(A) 错——模式只管文件效果,网络与进程可见性根本不在这套词汇里。(B) 错——danger-full-access 的消费方压根不调用 ctx.sandbox,直接 spawn 原始 argv;能传给提供方的只有两种受限模式,类型 ConfinedSandboxMode 就是把它排除掉之后的那个。(C) 错——partial 表示管住了这个模式所承诺效果的一个子集,不是没生效;它的意思是「需要绝对保证的调用方不能把它当 full 用」,而不是「等于零」。变式:一个消费方拿到了 enforcement: 'partial',但它的任务只是跑一个不碰硬链接、不涉及 Everyone 权限的编译命令。它应该拒绝执行吗?(提示:仓库把这个判断交给了谁?)
10.7 没读过就不许改,以及看不见的工具
先读后改
还有一类风险,前面所有机制都拦不住:模型凭想象改文件。它以为某个配置文件长什么样,直接 write 一份新的覆盖上去——文件其实早就变了,或者内容跟它想的完全不同,于是一次「修改」变成了一次静默的数据丢失。这不是权限问题,是新鲜度问题。
仓库的做法是把这件事做成两个单槽决策 waterfall(packages/fs/fs):fs/write-intent 和 fs/edit-intent。工具在真正调用 ctx.fs.writeText() / editText() 之前分发它们,由策略插件 dsh-fs-observation-policy 认领并给出一个守卫值。策略插件内部维护一张「谁看过哪个文件的哪个版本」的表,规则是:
| 这个所有者对该文件的观测状态 | write 的决定 | edit 的决定 |
|---|---|---|
| 未见(从没读过) | createIfAbsent:只允许「创建一个还不存在的文件」 | 拒绝(FS_NOT_OBSERVED) |
| 缺失(读过,确认不存在) | createIfAbsent | 拒绝(FS_NOT_FOUND) |
| 存在(读过,记下了版本) | replaceIfVersion:版本对得上才替换 | 用那个版本作守卫 |
为什么 write 和 edit 待遇不同?因为语义不同。write 是「我给出全文」——如果文件本来就不存在,模型不需要读过它也能安全地创建;只有在文件已存在时才要求版本匹配,避免覆盖掉别人的改动。edit 是「把这段文本换成那段」——它的正确性完全依赖于模型对当前内容的认知,没读过就编辑,本质上是在猜。所以未见即拒绝,没有安全的降级方式。
看不见的工具
最后一件事,也是最容易误解的一件。tools.restrict() 可以为单个 agent 过滤全局工具集合(多个 restriction 取交集),而 scope-local 注册在过滤之后合并。glossary 里这句话要一字一句地读:
restriction / scope-local 注册:被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分。
「无法区分」这四个字是设计目标,不是实现巧合。它意味着模型收到的错误是 UNKNOWN_TOOL(未知工具),跟它调用一个纯粹拼错的名字得到的错误一模一样。为什么要这样?因为任何形如「这个工具存在,但你没有权限用」的回答,都在向模型泄露它本不该知道的信息,而且会诱导它去反复尝试、去请求提权、去绕路——把一次干净的「没这个东西」变成一场拉锯。
但它不是权限边界
packages/core/tools/README.zh.md 在 restrict() 那一条的末尾写着:这是实时可见性组合,不是权限边界,并链到「作用域安全非目标」那份设计说明。图 10-1 里第 0 层画成虚线就是这个意思:它非常有用(控制每个 agent 看到什么、防止模型被无关工具带偏),但你不能拿它当墙用。真正的墙是第 3 层和第 5、6 层。
一个模型在同一个会话里做了三步:(1) read 了 config.json;(2) 用 bash 跑了一条 sed -i 命令,把这个文件改了;(3) 用 edit 工具去替换文件里的一段文本。第 (3) 步会发生什么?如果第 (3) 步改成 write(给出全文覆盖),结果又如何?
fs/observed 事件由 dsh-tool-fs 分发。bash 走的是另一条路,它压根不发这个事件。{present, version: v1};(2) 磁盘上的真实版本变成 v2,而状态表里还是 v1;(3) 带着 v1 去做守卫。FS_STALE_VERSION——策略给出的守卫是「版本必须等于 v1」,而后端算出来的当前版本已经是 v2 了。这正是这套机制要防的事:模型手里的内容认知已经过期,此时的编辑很可能是错的。改成 write 同样失败,且是同一个原因:存在状态映射到 replaceIfVersion,版本一样对不上。正确的做法是重新 read 一次,让状态表刷新到 v2,再改。顺带记住一个反直觉之处:
bash 改文件不会更新观测状态,因为观测记录是 dsh-tool-fs 在自己的读写路径上发出的,而 bash 的世界里 harness 只看得到一串输出文本,不知道它动了哪个文件。所以「用 bash 改文件」和「用 fs 工具改文件」在这套策略眼里是两个互不相通的世界。变式:把第 (1) 步删掉——模型没读过就直接 write 一个已经存在的文件。会发生什么?错误码是哪一个?(提示:查表 10-3 第一行,注意 createIfAbsent 遇到已存在目标时的行为。)
10.8 这一章里五件不牢靠的事
前面七节讲的都是「设计上应该怎样」。这一节讲实际怎样。以下五条每一条我都回仓库核实过,也给了路径,你应该自己再验一遍——它们全都是那种「读文档读不出来、出事时才发现」的东西。
一、never 的命名坑(前面已埋,这里钉死)
ApprovalPolicy 的 'never' = 永不询问 = 一律拒绝,是更严的一档。源码:packages/interaction/user-approval/src/index.ts 的 ApprovalPolicy 注释与 decide()。把它读成「不用问,直接过」的人会在生产环境里得到一台什么都做不了的机器,而且日志里全是「用户拒绝」。
二、isConcurrencySafe 是工具的自我声明,无人校验
工具定义里可以带一个 isConcurrencySafe(args) 分类器,只有它恰好返回 true 时这次调用才允许并发执行,其他一切结果(未声明、返回非 true、抛异常)都按独占处理——这一半是稳的(fail-closed)。不稳的那一半是:没有任何人检查这个 true 是不是在说谎。工具说自己并发安全,注册表就信;它不做静态分析,不做运行时检测。仓库在 packages/core/tools/README.zh.md 的「已知限制与暂缓事项」里明说:并发策略不是事件门禁——executionMode() 直接读已解析的工具定义,插件只能在自己拥有的定义上声明分类器,别人管不着。所以一个写错的 isConcurrencySafe 造成的数据竞争,权限层一点忙都帮不上。
三、timeoutMs 是声明性的,不装策略包就完全没有超时
工具定义上的 timeoutMs 只是策略元数据。同一份 README 的限制清单里写着:注册表绝不会强制执行截止时间;要真的有超时,必须把 @deepseek-ai/dsh-tool-call-timeout-policy 这个包装层加进组合。也就是说,你可以有一个声明了 timeoutMs: 5000 的工具,在一个没加载该插件的部署里跑上一整天,没有任何警告。而且即便加载了,它也只是协作式的:派生信号只负责通知,工具自己不转发 exec.signal 就不会停——该包 README 的原话是「声明 timeoutMs 意味着与 exec.signal 协作」。
四、fs 单槽的归属是部署约定,不是强制不变式
docs/subsystems/filesystem.zh.md 的原话:fs/write-intent 与 fs/edit-intent 是单槽决策 waterfall,监听方完全决策而不调用 next(),该 slot 按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。翻译一下:先注册的插件可以抢走这个决策位,让先读后改的检查整个不生效,而系统不会报任何错。同一份文档还提醒:加载了 dsh-tool-fs 的部署也应加载 dsh-fs-observation-policy——「应」是建议语气,不是强制。少装一个插件,write 就退回无条件覆盖、edit 退回无条件替换。
五、被拒绝的调用不会执行守卫
这条是我在源码里读出来的,文档没有专门讲。packages/core/tools/src/index.ts 的 prepareExecution() 里只有一行:
const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason
看清楚这个三元表达式:只有在前面的决定是 allow 时,才会去跑守卫。如果 tools/pre-execute 已经拒绝了,或者审批没通过,guardReason() 根本不会被调用。这有它的道理(已经拒了就没必要再问一遍),但它有一个不显然的后果:守卫不是一个能统计所有被拒调用的观察点。如果你想写一个「记录所有被拦下的危险尝试」的插件,把它写成守卫是错的——它只能看到那些「通过了前面所有关卡、最后被自己拦下」的调用。要看全,得挂到 tools/post-execute(第 9 章讲过,被拒的调用仍然会进这一关)或者读会话日志里的 tool/call / tool/result 对。仓库自带的 dsh-repeat-tool-reminder 就是这么干的,它的 README 明确写着「被拒绝的调用也计数。检测位于 tools/post-execute」。
顺带一提:仓库里几乎没有人在用单调守卫
我在 packages/ 下搜 tools.guard(,测试之外只有一处:packages/subagent/subagent-in-process-driver/src/structured.ts——子 agent 交出结构化输出之后,用一个守卫把这一步之后的所有工具调用挡掉(「结构化输出已记录,本次运行结束」)。而 packages/guard/ 这个名字听起来最像的目录里,两个包都不是单调守卫:timeout-policy 是 tools/execute 环绕监听器,repeat-tool-reminder 是 tools/post-execute 监听器;该目录的 README 自己说这一族是「核心服务和扩展点的自包含消费方」。这说明什么?说明单调守卫是一个为未来的第三方策略准备的扩展点,而不是当前部署的主力。这不减损它的设计价值,但你在评估「这套机制被验证得有多充分」时应该知道这个数字。
真未解「最小权限」到底该按什么粒度切?
本章讲的所有旋钮,粒度都是会话级(沙箱模式、审批策略,一次设好管一整个会话)或单次调用级(一次性审批、一次提权重试)。中间那一大段——「git status 自动放行,git push 要问,rm 一律拒绝」这种按操作分类的规则——仓库没有内置。它把这件事完全交给了扩展点:要么写一个原生插件挂 tools/pre-execute,要么用 dsh-hooks-claude-code / dsh-hooks-codex 桥接层去跑用户已有的 Claude Code 或 Codex 钩子配置(那两个桥接把钩子的结果映射成 {kind:'deny'} / {kind:'ask'},多个钩子按最严格合并)。
这不是疏漏,是一个真实的开放问题:整个行业还没有公认答案。按命令名切?模型换个写法就绕过去了(10.1 讲过)。按文件路径切?那 bash 里的重定向算谁的。按「意图」切?意图是模型自己声明的,正好是最不可信的那个东西。每一个已发布的产品都给了不同答案,而且都不完全对。
怎么入手:先去读 packages/hooks/hooks-claude-code/src/index.ts 里把钩子结果映射成决定的那两行(搜 merged.decision),弄清一个外部钩子能表达和不能表达什么;再读 docs/subsystems/approval.zh.md 确认 ApprovalRequest 有意省略了工具参数(它只带 agent、工具名、callId、reason)——想清楚这个省略对「按参数做细粒度授权」意味着什么。然后自己设计一个规则语言,并逼自己给出三条能绕过它的命令;绕不过去说明你的规则太窄,绕得太容易说明太宽。这个练习本身就是这道题目前最好的答案。
某团队的部署组合是:dsh-tools + dsh-tool-fs + dsh-tool-bash + dsh-bash-sandbox + dsh-sandbox-local + dsh-user-approval(策略默认 ask,接了 UI 应答者)。他们没有加载 dsh-fs-observation-policy,也没有加载 dsh-tool-call-timeout-policy。请判断下面四句话的对错,每句都要说出依据:
(A) 模型可以不读文件就 edit 它。
(B) 一个声明了 timeoutMs: 30000 的工具卡住时,30 秒后会被自动终止。
(C) 模型跑 rm -rf ~ 会被沙箱挡住(会话在 workspace-write 模式下)。
(D) 团队想加一个「记录所有被拒尝试」的审计插件,写成 ctx.tools.guard() 是合适的。
(A) 对。没有
dsh-fs-observation-policy,就没有人认领 fs/edit-intent 这个单槽,waterfall 走到默认 thunk 返回 undefined,即裸提供方无条件编辑。仓库原话:没有该插件时,定义、一个提供方和 dsh-tool-fs 组成「完整且不受约束」的文件系统 seam。(B) 错。
timeoutMs 只是声明;注册表绝不强制截止时间,必须加载 dsh-tool-call-timeout-policy。而且即使加载了,它也只是协作式通知,工具不转发 exec.signal 依然不会停。(C) 对,但要限定。
workspace-write 模式下工作区之外的写会被后端挡下,rm -rf ~ 会拿到拒绝(消费方还能按后端的拒绝方言把它归类为沙箱拒绝而非普通命令失败)。但要注意两个限定:一是 enforcement 可能是 partial(较旧的 Landlock ABI 等),二是沙箱只管文件效果——同一条命令如果改成把家目录打包上传,沙箱模式这一层不负责。(D) 不合适。守卫只在决定为 allow 时才被调用,看不到被 pre-execute 或审批拒掉的那些调用——而那恰恰是审计最想看的一类。应该挂
tools/post-execute(被拒的调用也会进这一关),或者直接读会话日志里的 tool/call / tool/result 与 approval/asked / approval/decided 审计对。变式:这个团队现在要补齐短板,但只能加一个插件。加哪一个收益最大?说出你的判据——是按「失效时后果最严重」排,还是按「最容易被忘掉」排?两种排法给出的答案一样吗?
给你的 mini-harness 装上单调守卫与默认拒绝的审批
第 9 层你已经有了 pre / around / post 三层 waterfall。现在往 pre 之后、工具本体之前塞进两样东西:一次只能拒绝的守卫检查,和一次答不上来就算拒绝的询问。
三处难点,都不显然。
第一,guardReason 为什么不需要「允许」这个返回值。它走到底只有两种出口:某个守卫给了理由,或者没有。「没有」不等于「批准」——批准这件事在这一层之前就已经由 gate 决定了。你会很想在函数末尾写 return 'allow' 让它「对称」一点,忍住。
第二,那个 try/catch 和 VOCAB.includes。这两行看起来是防御式编程的噪音,其实是本层的灵魂:它们规定了「应答者行为异常时倒向哪一边」。删掉 catch,异常会冒泡出去、以另一种方式变成一个错误结果——看起来也没放行,但你失去了把「问题失败」和「调用失败」分开的能力,也就没法给模型一条准确的理由。
第三,denial 那一行的三元表达式。照抄,别改成先跑守卫再看决定——那正是 10.4 讲的顺序错误。
做对了的样子:分三次验,每一次都要看到指定的现象。
(1) 单调性。注册两个守卫:A 返回 '不许碰这个文件',B 返回 undefined。用 A、B 和 B、A 两种注册顺序各跑一次同样的调用——两次结果必须完全一样:拒绝,理由是「不许碰这个文件」,工具本体一次都没执行(在里面 console.log 一句来确认)。顺序换了结果不变,这就是单调性。
(2) 亲手弄坏它。把 guardReason 改成「越权版」:如果某个守卫返回字符串 'allow',就立刻 return undefined(放行),后面的守卫不再跑。然后加一个守卫 C:() => 'allow',按 C、A 的顺序注册,再跑同一次调用。你应该看到:工具本体执行了,A 那条拒绝被吃掉了,日志里连一句拒绝理由都没有。再换成 A、C 顺序跑一次——又变回拒绝了。同一份代码、同一次调用、两个相反的结果,差别只在 cordis.yml 里那两行谁写在前面。看到这个现象,再把改动改回去。
(3) 默认拒绝。让某个工具的 pre 监听器返回 { kind: 'ask' },然后跑三遍:answerer = null(没人能答)、answerer = async () => { throw new Error('UI 挂了') }(应答者炸了)、answerer = async () => ({ ok: true })(返回了词汇表外的东西)。三次都必须是拒绝,而且三次的理由字符串里都能看出是「答不上来」而不是「用户说不」。任何一次放行,都说明你在某个分支上写成了 fail-open——回去找那个分支。
本章小结
「凭什么拦得住 rm -rf /」这个问题,真正的答案不是一条更聪明的规则,而是一组互相不重叠的防线,以及一条关于它们如何叠加的约束:
- 黑名单是错的答案。枚举坏的,失败方向是放行;枚举好的,失败方向是拒绝。安全边界必须选后者。
- 单调守卫:加法只有一个方向。
ToolGuard的返回类型string | undefined里没有 allow,所以任意多个守卫叠加的结果是交集,只会更严不会更松,且与注册顺序无关。删掉 allow 分支,等于让「谁的允许盖过了谁的拒绝」这一整类 bug 在类型层面不存在。 - 默认拒绝,不是默认放行。四种「答不上来」——没装服务、没有应答者、应答者抛异常、返回值不在词汇表里——全部倒向拒绝。
ApprovalOutcome四个取值里只有allowed-once是许可,而且只授权所询问的那一次。 - 审批排在守卫之前。人的同意只能把调用送到守卫面前,推翻不了所有者策略。同一个执念在服务内部又出现一次:
'never'在 waterfall 分发之前判定,任何prepend的监听器都抢不到它前面。 - 预设只做组合与命名,不做强制。把沙箱模式与审批策略捆成一个人能看懂的选择器,避免把用户训练成「一路点允许」;实际强制仍由两个旋钮各自的消费方负责。
- 沙箱是另一类防线。守卫决定要不要做,沙箱决定做了能跑多远。消费方交出确切 argv、拿回包装后的 argv 再 spawn;没有可用后端就
SANDBOX_UNAVAILABLE,静默的无隔离透传永远不合法。记住它只管文件效果,网络不在其内。 - 先读后改由
fs/write-intent/fs/edit-intent两个单槽 waterfall 承担:未见状态下 write 只能创建新文件,edit 直接拒绝——因为编辑的正确性完全依赖于对当前内容的认知。 - 被过滤掉的工具与不存在的工具无法区分,这是设计目标;但可见性不是权限边界,仓库自己在 README 里写明了。
- 五件不牢靠的事:
never的命名坑;isConcurrencySafe无人校验;timeoutMs不装策略包就没有超时;fs 单槽归属只是部署约定;被拒绝的调用不会执行守卫(所以守卫不是审计观察点)。
下一章换一个完全不同的角度。这一章我们一直在说「沙箱提供方」「fs 提供方」「shell 执行器」——这些「提供方」到底是什么?为什么把 ctx.fs 和 ctx.subprocess 换成远程实现,Bash、终端和 LSP 会一起搬到远程去,而调用它们的代码一行都不用改?
第11章 能力 seam:换一个提供方,等于换一个世界
一句话导语:怎么让「在我的电脑上跑一条命令」和「在一台远程沙箱里跑同一条命令」变成同一件事,而调用它的那段代码一个字符都不用改。
学完这一章你应该能做到
- 一字不差地复述 seam 的定义,并说出为什么「单一角色本身不是 seam」
- 解释 Service Definition 为什么绝不能是一个 TypeScript
interface - 指着
packages/shell的四个包说出各自是什么角色、各多少行、各干什么 - 解释为什么把
ctx.fs和ctx.subprocess指向远程沙箱,会顺带把 Bash、PTY 和 LSP 一起搬过去 - 说出这套做法的代价,并给出「什么时候不该抽成 seam」的判据
- 把你 mini-harness 里写死的「跑命令」抽成一个 seam,然后换掉提供方而不改消费方
ctx.xxx 是运行时的一个键,不是 import 来的东西);第 3 章的副作用与生命周期;第 5 章的组装(cordis.yml 决定谁被装进来);第 10 章的沙箱与 ctx.sandbox。11.1 一件小事:把命令跑到别的地方去
你的 harness 里有一个 bash 工具。今天它在你的笔记本上跑命令。现在来三个需求:
- 公司要求:所有命令必须在沙箱里跑(第 10 章那套)。
- 某个客户要求:命令必须跑在一台远程 Linux 机器上,本地一个进程都不许起。
- Windows 用户要求:用 PowerShell,不是 bash。
最朴素的做法:在 bash 工具里加分支。
// 反面教材
async function execute(args) {
if (config.remote) {
return runOnRemote(args.command) // 远程
} else if (config.sandbox) {
return runSandboxed(args.command) // 沙箱
} else if (process.platform === 'win32') {
return runPowerShell(args.command) // pwsh
}
return runLocal(args.command) // 本机
}
今天它能跑。问题在明天。
第一个问题:分支会传染。不只是 bash 工具需要知道「现在在哪跑」。持久终端(PTY)需要知道,语言服务器(LSP)需要知道,读写文件的工具需要知道,跑测试的后台任务需要知道。每一个都要抄一遍这套 if。四个消费方 × 四种执行环境 = 十六段几乎一样但细节各有出入的代码,而且它们会各自演化,直到没有人敢说清「远程 + Windows」这一格到底是什么行为——因为那一格从来没有人跑过。
第二个问题:加一种环境要改所有人。客户明天说要跑在容器里。你得回到这四个文件,各加一个分支,各写一遍测试。而这四个文件很可能不归你管。
第三个问题:也是最要命的——你无法只替换一部分。「远程执行 + 本地沙箱策略」这种组合,在 if 的写法里是表达不出来的,因为分支是互斥的。
接口与实现分离(separating interface from implementation):把「能做什么」和「怎么做到」拆成两样东西。调用方只对着「能做什么」编程,永远不知道也不需要知道背后是谁在做。不用它会怎样?就是上面那三个问题:分支传染、改动扩散、组合爆炸。
打个比方
墙上的插座。你的台灯只认识「两个孔、220 伏」这件事,完全不知道电是核电站发的、屋顶太阳能发的,还是院子里柴油机发的。发电方式换掉,台灯不用改;反过来,你换一盏灯,发电厂也不用管。插座这个标准本身,就是那个「接口」。
类比失效处:插座标准是全球性的、几十年不变的公共约定;而这里的接口是你自己定的、随时可以改的私有约定。所以真正难的部分不是「有一个接口」,而是接口该切在哪里——切错了,两边都别扭。11.7 会讲这件事的代价。
上面那段反面教材里有四个分支。现在有四个消费方(bash 工具、PTY 后端、LSP 宿主、后台任务)都需要「在哪跑」这个能力。请回答三问:(1) 今天一共要写多少段这样的分支代码?(2) 新增一种执行环境(容器)要改几个文件?(3) 改成 seam 之后,这两个数字各变成多少?
if 分支的总数(提示:跟 M 和 N 都有关),再数「新增一种环境」时被触碰的文件数。注意第二个数字才是真正决定维护成本的那个。cordis.yml 里换一行。真正的收益不在 16 变 4,而在第二个数字从 M 变成 0:新增一种实现不触碰任何既有代码,也就不需要重新评审、重新测试那四个消费方。顺带说,这也解释了为什么 seam 的收益随消费方数量增长——消费方越多,一次替换省下的越多。只有一个消费方时,这套拆分基本是白拆(11.7 会诚实算这笔账)。
变式:如果四个消费方里有三个其实只需要「跑一条命令拿输出」,而第四个需要「拿到一个能持续读写的伪终端」,那么一个接口够不够?(提示:仓库对这两件事的处理不一样——去看 ctx.shell 和 ctx.terminals 是不是同一个 seam。)
11.2 定义:三种角色,缺一个就不成立
这个词在仓库里有严格定义,写在 docs/glossary.zh.md 的 capability-seam 一节。下面这段是原文照抄,一个字没改——请先读一遍,再看拆解。
仓库原话(docs/glossary.zh.md · capability-seam)
seam:一种包含三种角色的可替换能力:Service Definition(拥有自身 ctx.<key> 和词汇类型的 Cordis Service——可以是 ShellExecutor 这样的抽象类,也可以是 WebRuntime 这样的具体注册表,绝不是 TypeScript interface)、一个或多个 Service Provider,以及一个或多个注入该服务的 Consumer。packages/shell 是规范范例:dsh-shell(Service Definition)、dsh-bash-local / dsh-bash-sandbox(提供方),以及 dsh-tool-bash(Consumer)。角色需要独立演进时通常位于不同包,但属于同一关注点时,一个包也可以承担多个角色(dsh-llm 同时承担 Service Definition 和 Consumer)。seam 是完整能力,绝不是其中一个角色;该术语仅保留此义,能力成员应按其角色、类、服务、约定或扩展点命名。
把它拆成三块。
一、Service Definition(服务定义)。它拥有一个 ctx.<key>——比如 ctx.shell——以及这项能力的全部词汇:请求长什么样、结果长什么样、失败有哪几种。它不实现任何东西。
二、Service Provider(服务提供方)。真正干活的那个。一个或多个。
三、Consumer(消费方)。注入并使用这项能力的人。一个或多个。通常是一个面向模型的工具。
为什么「绝不是 TypeScript interface」
这句话在定义里被特意加粗强调,值得单独讲——它是整章最容易被略过、也最容易被做错的一句。
TypeScript 的 interface 是一个纯编译期的东西。tsc 一编译,它就消失了,运行时的 JavaScript 里没有任何痕迹。你不能问「这个 interface 现在的实现是谁」,因为运行时压根没有「它」。
而 seam 需要的恰恰是运行时的能力:
- 能被查到。消费方在运行时通过
ctx.shell拿到当前实现;换了提供方,同一个表达式拿到的是另一个对象。 - 能被注入。
inject: ['shell']声明的是一种依赖关系:没有提供方时消费方插件不启动,有了才启动,提供方走了它再停掉。这套生命周期只有运行时的服务才有(第 2、3 章讲过)。 - 能拒绝重复。
ShellExecutor的类注释写着:一个上下文只能有一个实现,装第二个会抛——这是 Cordis 对重复服务的标准行为。TypeScript 的interface不可能提供这条保证,因为它连「装」这个动作都没有。 - 能有生命周期。提供方被 HMR(热模块替换)换掉时,依赖它的消费方要一起重启;后台进程要不要被杀,取决于边界画在哪。
所以定义里说它是「一个 Cordis Service」——可以是抽象类(ShellExecutor extends Service,提供方去继承它),也可以是具体的注册表(像 WebRuntime 那样,提供方往里注册条目)。两种形态的共同点是:它在运行时真实存在,占着一个 ctx 的键。
下面四个包,各自在 ctx.subprocess 这个 seam 里扮演什么角色(Service Definition / Provider / Consumer / 都不是)?
(A) dsh-subprocess (B) dsh-subprocess-local (C) dsh-lsp-stdio (D) dsh-tool-bash
docs/subsystems/subprocess.zh.md 第一段,它把定义方和提供方直接点名了;消费方那一列去 docs/capability-seams.zh.md 的表里查 ctx.subprocess 那一行。ctx.subprocess、受管的 DSH_* 环境命名空间、凭据清除、CollectedOutput 形状等词汇。(B) Provider——本地实现。(C) Consumer——LSP 宿主用它 spawn 语言服务器并接原始协议管道;注意 dsh-lsp-stdio 同时是 ctx.lsp 这个 seam 的 Provider,角色是相对于某个 seam 说的,不是包的固有属性。(D) 都不是——dsh-tool-bash 注入的是 ctx.shell,它跟子进程隔着一层:bash 执行器才是 ctx.subprocess 的消费方。这一格最容易答错,因为「bash 工具当然要起进程啊」的直觉是对的,但起进程这件事被它委托出去了——这正是分层的意义。变式:dsh-llm 在定义里被点名为「同时承担 Service Definition 和 Consumer」的例子。一个包同时当定义方和提供方行不行?如果行,会失去什么?
「单一角色本身不是 seam」这句话,用两个残缺的例子最容易讲清。请分别构造并描述症状:(1) 只有 Service Definition、没有任何 Provider 的服务;(2) 有 Definition 和 Provider、但没有任何 Consumer 的服务。然后去仓库的能力图里各找一个真实例子(提示:这两种情况都真实存在)。
inject: ['xxx'],而 ctx.xxx 从来没有被任何人提供,这个消费方插件会处于什么状态?docs/capability-seams.zh.md 的那张表,找「实现」这一列为 - 的行,再找「直接消费方」这一列为 - 的行。各挑一个。inject 的依赖不满足),能力静默缺席;对模型来说就是这个工具根本不存在。真实例子:ctx.userQuestions,表里的「实现」列是 -,因为它的提供方不在仓库里——由 UI 前端在运行时提供,dsh-tool-ask-user 在一个提供方无关的 ask() promise 上把工具调用挂起。这不是缺陷,是有意的:这个 seam 的实现天然属于宿主应用。(2) 有实现没有消费方——能力完整可用,但没有任何一段进程内代码去调它。真实例子:ctx.sessionTelemetry,表里「直接消费方」列是 -,注释直说:这个 seam 捕获会话记录、脱敏、交给后端,没有其他组件消费该服务,其输出会离开当前进程——它的「消费方」在进程之外(你的观测平台)。两个例子放在一起,正好说明为什么定义要强调「seam 是完整能力」:判断一项能力是否完整,不能只看仓库里有几个包,还要看缺的那一角是不是有意留给外面的。
变式:如果某个 seam 的定义、提供方、消费方全都在同一个包里,它还算 seam 吗?(提示:定义里那句「角色需要独立演进时通常位于不同包」是充分条件还是必要条件?)
11.3 规范范例:packages/shell 的四个包
定义里点名说 packages/shell 是规范范例。我们把它翻开,一个包一个包看。下面的行数是我自己数的:src/ 目录下所有 .ts 文件的总行数,含注释与 JSDoc(这个仓库的 JSDoc 密度很高,行数里有相当一部分是文档)。
dsh-shell —— Service Definition,350 行,4 个文件
它拥有 ctx.shell,什么都不实现。350 行里:
types.ts(183 行):全是词汇。ShellExecRequest(调用方的请求)、ShellExecSpec(解析后的规格)、ShellRunResult(前台运行的结果)、ShellProcess(后台进程句柄)、ShellSandboxInfo(沙箱事实)。一行可执行逻辑都没有。index.ts(103 行):抽象类ShellExecutor extends Service,构造函数只有一句super(ctx, 'shell');三个抽象方法resolve/run/start;一个能力事实get sandboxMode()(默认返回undefined,表示「我不做隔离」);外加这项能力的 settings 命名空间。render.ts(42 行):parseExitStatus——两个 shell 工具共享的退出状态解析。invariant.ts(22 行):运行时不变量检查。
请注意这个比例:整个「接口」的核心只有几十行,剩下的绝大部分是在把词汇说清楚。这是 seam 的常态——难的从来不是写抽象方法,是把「请求里哪些字段可以省、省了谁来补」这类事定死。
顺便看一眼这里最值得抄走的一个设计:resolve() 请求/规格拆分。ShellExecRequest 里 workdir / timeoutMs / stdoutMaxBytes 都是可选的,而 ShellExecSpec 里它们是必填的。中间的 resolve(request) 就是那个「把可选补成必填」的显式步骤。仓库的通用规则写着:包边界处显式优于隐式——补默认值是一个显式的 resolve(request): Spec 步骤,绝不是藏在 run() 里的一个 ?? default,而 dsh-shell 的这个拆分就是全仓库的模板。为什么较真?因为默认值藏在实现里的时候,你没法回答「这次调用实际用的超时是多少」——而这正是排查问题时第一个要问的。
dsh-bash-local —— 提供方 A,363 行,2 个文件
本机执行。公开命令以 bash -c 的形式,在一个受管进程组里通过 ctx.subprocess spawn。它自己拥有的东西是:命令默认值的补全、超时与中止的原因归类(同一次终止到底算超时还是算调用方取消,两者互斥且要报第一因)、对模型友好的终端环境(NO_COLOR=1、TERM=dumb、PAGER=cat,免得输出里全是颜色转义码),以及后台读取时 stdout/stderr 的合并。
它自己不拥有的:进程组机制、有界收集器、spill 文件、凭据清除——那些属于 ctx.subprocess。也就是说,这个「提供方」自己也是另一个 seam 的消费方。
dsh-bash-sandbox —— 提供方 B,3 个文件(index.ts 182 行 + helpers.ts 116 行 + invariant.ts 30 行)
这个包最能说明「提供方之间可以差多远,也可以差多近」。它的第一行是:
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
它继承了本地执行器:进程机制、超时归类、环境变量,全部原样复用;自己只多做一件事——在 spawn 之前把 argv 交给 ctx.sandbox 包一层,再把后端报告的模式、强制完整度、拒绝事实贴到结果上(第 10 章那套)。helpers.ts 那 116 行几乎全是归因:从 stderr 里分辨「沙箱正常工作、命令被挡住」和「沙箱 runner 自己启动失败、命令压根没跑」。
注意它的 Config:export type Config = LocalConfig——一个字段都没加。沙箱策略(默认模式、工作区根)不在这里,它在 ctx.sandboxPolicy;runner 选哪个是 ctx.sandbox 提供方的配置。三样东西各归各家。
dsh-tool-bash —— 消费方,554 行,4 个文件
面向模型的那个 bash 工具。它注入 shell、shellEnv、systemPrompt、tools(外加运行时不变量服务),负责:工具 schema、结果渲染(那个 [exit code: N] 标记就是它加的)、把后台进程句柄适配成通用任务、以及第 10 章讲的提权审批流程。
它从头到尾不知道命令跑在哪里。它调 ctx.shell.resolve(request) 拿到规格,再调 run(spec) 或 start(spec)。至于 ctx.shell 背后是 bash-local、bash-sandbox、pwsh-local 还是别的什么——它没有任何一处代码去问。
ctx.shell 这个 seam 的三种角色。示意图——包名与角色出自 docs/subsystems/shell.zh.md 与 docs/capability-seams.zh.md,行数是我按 src/ 下 .ts 行数自己数的。左边两个消费方共享同一个接口,右边四个提供方每次只能装一个。有人重构:把 dsh-shell 里的抽象类删掉,改成一个 TypeScript interface ShellExecutor { resolve(...); run(...); start(...) },提供方各自 implements 它,消费方通过一个普通的 import { getExecutor } from './registry' 拿实现。类型检查全过,测试全绿。请说出这次重构具体失去了哪三样东西。
interface 编译之后还剩什么?运行时能不能问「现在的实现是谁」?cordis.yml 换实现?(b) 没有任何实现时,消费方插件会怎样?(c) 同时装了两个实现会怎样?cordis.yml 一行」变成「改 registry.ts 的 import 再重新构建」——决定权从部署方回到了写代码的人手里,而这正是整套插件架构要避免的(第 5 章)。(2) 失去依赖生命周期。inject: ['shell'] 表达的是「没有它我就不启动、它走了我就停」;换成 import 之后,没有实现时你拿到的是 undefined 或一个抛异常的桩,故障从「插件安静地不激活」变成「运行到一半炸掉」;HMR 换实现时也没有东西通知消费方重启。(3) 失去重复检测。Cordis 在同一上下文注册第二个 ctx.shell 会直接抛,而 import 式注册表通常是「后写的覆盖先写的」——两个提供方装在一起,执行世界取决于加载顺序且无人报错。这和第 10 章那个「守卫能 allow」的故障是同一类:顺序决定结果,而顺序不在任何人的视野里。这就是定义里「绝不是 TypeScript
interface」那句话的全部分量:它不是风格偏好,是三条运行时保证。变式:那 TypeScript 的类型在这套设计里还有用吗?(提示:ShellExecRequest 那 183 行词汇是什么?它们和「Service Definition 不能是 interface」矛盾吗?)
11.4 换两个提供方,三样东西一起搬了家
现在讲这一章最有说服力的推论。docs/architecture.zh.md 的「能力 seam」一节里有一句话,读第一遍时很容易滑过去:
「文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。」
把它展开成一个具体的故事。
故事:一个下午之内让所有命令跑到远程
假设你要把整套执行环境搬进一台云端 Linux 沙箱(仓库里真有这个 POC,在 packages/e2b/,用的是 E2B)。你需要做的事情是:
- 写一个包拥有那台远程沙箱的生命周期:创建它、准备工作目录、暴露一个共享的 SDK 句柄、超时或释放时删掉它。这就是
dsh-e2b(ctx.e2b,212 行)。 - 写一个
ctx.fs提供方,把文件操作转成远程文件系统 API。这是dsh-fs-e2b(612 行)。 - 写一个
ctx.subprocess提供方,把进程操作转成远程命令与 PTY API。这是dsh-subprocess-e2b(1835 行)。
然后就没有了。在 cordis.yml 里把这三行加进去,你会发现下面这些东西全都跟着搬了家,而它们的代码一个字符都没改:
- Bash 工具——因为
dsh-bash-local起进程走的是ctx.subprocess。 - 持久终端(PTY)——因为
dsh-terminal-bash用的是同一个 seam 的终端原语。 - LSP(语言服务器)——因为
dsh-lsp-stdiospawn 语言服务器、读写它的文件,走的也是这两个 seam。 - 所有读写文件的工具——因为它们走
ctx.fs。
仓库的 packages/e2b/README.zh.md 把这件事说得很干脆:现有的 dsh-bash-local、dsh-terminal-bash 和 dsh-lsp-stdio 无需 E2B 专用 fork。它们把执行环境中的所有操作都委托给 ctx.fs 和 ctx.subprocess,因此挂载这两个 E2B 适配器后,它们所有涉及可变状态的工作都发生在同一个沙箱内。
为什么这件事能成立
关键在「共享同一个执行世界」这个说法。ctx.fs 的接口里有三个方法专门为此存在(docs/subsystems/filesystem.zh.md):
processPath(target):返回这个文件系统的执行世界里,一个子进程能打开的规范绝对路径。fileUrl(target):返回该世界语法下的file:URI(LSP 协议里全是这个)。contains(parent, child):不暴露内部标识就能判断包含关系。
这三个方法是桥。LSP 宿主要告诉语言服务器「请打开这个文件」,它不去自己拼路径字符串(拼出来的是 harness 所在机器的路径,在远程机器上根本不存在),而是问 ctx.fs 要一个「执行世界里的路径」。文件系统提供方和进程提供方指向同一台机器,这个路径就对了。
不要过度外推:有一条明确的边界
仓库把「什么会搬走、什么不会」写死了。不会迁移的东西包括:harness 进程本身、Cordis 对象、模型调用、agent/会话状态、会话持久化、skill(技能)、更高层协议状态。搬走的只是「执行世界」——文件在哪、进程在哪。
还有一句话必须原样记住:packages/e2b/README.zh.md 第一句就写着这是「实验性提供方组合 POC」。它证明了这套抽象是成立的,但不等于它已经是生产就绪的方案。
同一个部署,两种改法:甲把 ctx.shell 的提供方从 bash-local 换成一个「远程 bash 执行器」(自己直连远程机器跑命令);乙把 ctx.fs 和 ctx.subprocess 换成远程提供方,ctx.shell 仍然用 bash-local。两种改法下,模型跑 bash 命令都会在远程执行。请回答:(1) LSP 的语言服务器分别跑在哪台机器上?(2) 模型用 read 工具读到的是哪台机器上的文件?(3) 甲这种改法会在什么时候第一次暴露出问题,症状是什么?
ctx.shell 的提供方,它会受影响吗?ctx.fs + ctx.subprocess 这一对共同定义的,而 ctx.shell 只是它们之上的一个消费方。换上层不影响下层,换下层会带走所有上层。lsp-stdio 走 ctx.subprocess,甲没动它);乙:在远程。(2) 甲:读到的是本机文件(tool-fs 走 ctx.fs);乙:远程文件。(3) 甲的问题在第一次跨能力协作时暴露,而且症状极其难查:模型用 bash 在远程创建了一个文件,紧接着用 read 去读——FS_NOT_FOUND,因为那个文件在另一台机器上;或者模型改了一个本地文件,让 LSP 找引用,语言服务器看到的是本地那份而 bash 的编译结果来自远程那份,两边永远对不上。更糟的是这些症状看起来全都像「文件系统抽风」,没有任何一条错误信息会提示你「你有两个执行世界」。这就是「共享同一个执行世界」这句话的分量:它不是一句描述,是一条必须被维持的不变式。甲那种改法破坏了它,代价不是某个功能坏了,而是整个系统进入一种自相矛盾的状态。仓库把替换点放在
ctx.fs / ctx.subprocess 这一层而不是 ctx.shell 那一层,正是为了让「换一次就整体一致」成为默认结果。变式:那么在乙的方案下,第 10 章讲的本地沙箱(ctx.sandbox)还有意义吗?(提示:去看 docs/subsystems/sandbox.zh.md 那句「容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 ctx.sandbox 的提供方」。)
11.5 同一个接口后面,差别可以有多大
看到「同一个接口」,人容易默认背后的几个实现是同一类东西的小变体——就像 bash-local 和 bash-sandbox 那样(后者甚至直接继承前者)。ctx.subagents 这个 seam 会把这个印象打碎。
子 agent(subagent)能力就是「把一部分工作委派出去给另一个 agent 做」。仓库里有六个提供方,它们背后是六种截然不同的东西:
| 提供方包 | 行数 | 它实际做的事 |
|---|---|---|
subagent-spawn-in-process | 94 | 在同一个进程里新建一个子 agent 跑起来 |
subagent-fork-in-process | 124 | 同进程,但用父级日志的一段前缀作为子 agent 的起点 |
subagent-acp | 1024 | spawn 一个子进程,用 ACP 协议驱动里面的子 agent |
subagent-dsh-sdk | 482 | spawn 一整个子 harness 运行时,走 stdio JSON-RPC |
subagent-codex | 705 | 把一次委派交给 Codex(官方 app-server 协议) |
subagent-claude-code | 604 | 把一次委派交给 Claude Code(官方 Agent SDK) |
看清最后两行:同一个 ctx.subagents 接口背后,可以是另一家公司的产品。架构文档那句话说的就是这个——「subagent 提供方在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品」。94 行和 1024 行之间差了十倍,而消费方(dsh-tool-subagent,497 行)对这两种情况一视同仁。
但这个 seam 有一处和 shell 不一样
ctx.shell 一个上下文只能装一个提供方;ctx.subagents 可以同时装好几个,按名字注册——文档明说它「不同于其他能力 seam,因为同一上下文中可共存多个提供方实现,并按名称注册」,这一点它学的是 LLM 适配器注册表,不是 bash 执行器。
为什么?因为语义不同。「在哪跑命令」是一个环境事实,同时有两个答案就是自相矛盾(回想 11.4 那道题的第 3 问);而「把这个任务委派给谁」是一个每次调用都要选的决定——这次交给同进程的子 agent,下次交给 Codex,完全合理。
能力差异怎么办:不静默忽略
六个提供方能做的事不一样(有的支持「可继续的子 agent 对话」,有的只支持一次性委派)。仓库的处理是:提供方用一个静态描述符公布自己的启动时能力,服务在真正开跑之前就检查;请求依赖了提供方没有的功能,会以 SubagentError('UNSUPPORTED_CAPABILITY') 明确拒绝,绝不会被接受后静默忽略。
这条值得单独记:一个 seam 的接口越大,「某些提供方只实现了一部分」就越难避免。这时候唯一可接受的做法是把「我不支持」变成一个响亮的、可发现的事实,而不是让调用方去猜为什么结果不对。
部署方在 cordis.yml 里同时写了 dsh-bash-local 和 dsh-bash-sandbox 两行,又同时写了 dsh-subagent-spawn-in-process 和 dsh-subagent-codex 两行。启动时会发生什么?
ctx.shell 和 ctx.subagents。它们对「装多个」的规定一样吗?ShellExecutor 类注释里那句话,以及 subagent 文档里「同一上下文中可共存多个提供方实现,并按名称注册」。ctx 键只能有一个值」的形态,哪一组是「一个注册表里可以有很多条目」的形态。ctx.shell 一个上下文只能有一个实现,装第二个会抛(Cordis 对重复服务的标准行为)。这是好事:它把一个「执行世界不一致」的配置错误变成了一次响亮的启动失败,而不是让系统带着两个执行器悄悄跑起来。后一组正常启动,两个提供方各自按名字注册进 ctx.subagents,模型委派时选一个用。顺带记住这个判断法:看名字是不是「一个能力事实」(在哪跑、用哪个文件系统)还是「一组可选方案」(委派给谁、用哪个模型、用哪个搜索引擎)。前者是单实现服务,后者是命名注册表。
变式:ctx.web 有四个提供方(两三个搜索、一个抓取),它属于哪一类?如果同时装了三个搜索提供方,模型搜一次会发生什么?
11.6 添加一项能力,是一次设计三样东西
把前面几节合起来,得到这一章最实用的一条操作准则。docs/architecture.zh.md 的原话是:「一个包可以合并承担多个角色,但单一角色本身不是 seam;添加一项能力意味着把三者一并设计。」
也就是说,当你想给 harness 加一项新能力时,不能只写一个接口就交差。你必须同时回答三个问题:
- 词汇是什么?请求、结果、错误分类,各长什么样。这是 Service Definition 的活,也是最难的一步——一旦定错,两边都要迁就它。
- 至少有一个能跑的提供方吗?没有提供方的接口是一张空头支票:消费方永远不启动,而且你无法验证词汇设计得对不对。写第一个提供方的过程,就是在检验接口。
- 谁来用?通常是一个面向模型的工具。这一步决定了很多东西:schema 长什么样、结果怎么渲染给模型、要不要走审批。
顺序上有个经验:三者要一起设计,但接口应该最后定稿。先写一个粗糙的提供方和一个粗糙的消费方,让它们互相磨,磨出来的词汇才是对的。反过来先把接口定死再去实现,几乎必然要返工。
能力图:几个真实的 seam
仓库把所有服务的角色整理成了一张表(docs/capability-seams.zh.md,包含一张 mermaid 图和一张 56 行的表)。下面挑七个 seam 列出来——包名我逐个回仓库核对过,其中有两处对不上,留到 11.7 讲。
| 服务键 | 定义方 | 提供方 | 直接消费方 |
|---|---|---|---|
ctx.shell | shell | bash-local、bash-sandbox、pwsh-local(仓库里还有 pwsh-sandbox,表里没列) | tool-bash、tool-pwsh、hooks-claude-code、hooks-codex |
ctx.subprocess | subprocess | subprocess-local、subprocess-e2b | bash-local、bash-sandbox、terminal-bash、lsp-stdio、三个进程外 subagent 后端 |
ctx.fs | fs | fs-local、fs-sandbox、fs-e2b | tool-fs(配套插件 fs-observation-policy) |
ctx.terminals | terminal | terminal-bash | tool-terminal |
ctx.sandbox | sandbox | sandbox-local | bash-sandbox、terminal-bash |
ctx.subagents | subagent | 六个(见表 11-1) | tool-subagent、tool-subagent-control、tool-ralph |
ctx.compaction | compaction | compaction-basic | compaction-basic(面向用户的 command-compact 也在其上) |
ctx.lsp | lsp | 表里写 lsp-local;仓库里实际是 lsp-stdio | tool-lsp |
读这张表时有两个模式值得注意。
模式一:提供方常常是另一个 seam 的消费方。bash-local 是 ctx.shell 的提供方,同时是 ctx.subprocess 的消费方;bash-sandbox 更极端,它同时是 ctx.shell 的提供方、ctx.sandbox 的消费方、ctx.sandboxPolicy 的消费方。角色是相对于某个 seam 说的,不是包的固有属性。
模式二:消费方数量比提供方数量更能说明问题。ctx.subprocess 只有两个提供方,但有七个消费方——所以换掉它的收益极大(那正是 11.4 的故事)。反过来 ctx.terminals 目前一个提供方一个消费方,这层抽象暂时还没换来什么。
下面这个实验室把 219 个包的真实依赖翻给你看。建议你现在就去用它做一件事:挑一个服务键(比如 fs 或 shell),把「提供这个键的包」和「注入这个键的包」各数一遍,跟表 11-2 对一对。11.7 会告诉你为什么这件事必须自己数。
你要给 harness 加一项新能力:发邮件。请设计完整的三件套——服务键叫什么、词汇里必须有哪些类型、至少两个真实可能的提供方、消费方是什么。然后回答一个更重要的问题:这三步里哪一步最容易做错,错了之后会以什么形式暴露出来?
ctx.shell 的结构。请求类型、结果类型、错误分类,各要哪些字段?Config 里?ctx.mailer。词汇:MailRequest(收件人、主题、正文、可选附件引用)、MailResult(提供方侧的消息 id + 接受时间)、MailErrorCode(MAIL_INVALID_ADDRESS / MAIL_QUOTA_EXCEEDED / MAIL_TRANSPORT_ERROR…,稳定可路由的字符串,模仿 FsErrorCode)。提供方:SMTP 直发;某个邮件 API 服务。消费方:一个面向模型的 send_email 工具(一定要走第 10 章的审批——发邮件不可撤销)。最容易做错的是第一步,词汇。典型错法是把某一个提供方的概念漏进接口:比如在
MailRequest 里放一个 smtpHost 字段,或者放一个只有第三方 API 才有的 templateId。它的暴露形式非常有欺骗性:第一个提供方写得无比顺畅(接口就是照着它长的),一切正常;直到你写第二个提供方时才发现有一半字段填不出来、另一半没地方放——而这时接口已经被消费方用上了,改它意味着改所有人。这正是「添加一项能力意味着把三者一并设计」的实操含义,也是为什么写第二个提供方之前不要认为接口稳定。一个可操作的自检:把接口里每个字段问一遍「如果换一种实现,这个字段还有意义吗」,答不上来的就是漏进来的实现细节。
变式:假如你只可能有一个提供方(公司只允许用自家邮件网关),还值得拆成三个包吗?如果不拆,你会怎么组织?(这道题的答案在下一节。)
11.7 代价,以及什么时候不该抽
前面六节都在讲好处。现在讲账单——不讲清楚这一段,这一章就是软文。
代价一:一个功能散在三个包里
要看懂 bash 是怎么跑起来的,你得同时打开四个包:dsh-shell(词汇与抽象方法)、dsh-bash-local(默认值与进程机制)、dsh-bash-sandbox(隔离与归因)、dsh-tool-bash(schema 与渲染)——再加上它们下面的 dsh-subprocess 和 dsh-subprocess-local。一个「跑条命令」的功能,跨六个 README。
更实际的痛点是改动的传导:给 ShellExecRequest 加一个字段,你要动定义包,动每一个提供方(不加会类型报错,加了要各自实现),可能还要动消费方,外加所有相关的文档与测试。一次「小改动」变成一串 PR。
代价二:只有一个提供方时,抽象成本白付
这一条要老实说。如果一项能力从头到尾只会有一个实现,那么把它拆成定义 + 提供方 + 消费方,你付出了全部的拆分成本,却一次替换的收益都没拿到。你得到的只是「将来也许能换」这个期权——而期权是有价格的,价格就是上面那些多出来的文件和 PR。
仓库自己的规则写得很克制:角色需要独立演进时通常位于不同包,「属于同一关注点时,一个包也可以承担多个角色」(dsh-llm 就同时是定义方和消费方)。CLAUDE.md 里那条更直接:split only when roles evolve independently——只在角色确实独立演进时才拆。
对你而言未知到底有几个服务键真的存在两个以上提供方?
这个数字直接决定上面那笔账划不划算,而仓库里没有任何地方统计它。docs/capability-seams.zh.md 有一张 56 行的服务表,但它只是逐行列出,没有汇总,也没有任何一处提到「多少个 seam 真正被替换过」。
我按那张表自己数了一遍(把「角色」列为 seam 的行挑出来,数「实现」列的包个数):26 个 seam 里,11 个有两个或更多提供方,14 个只有一个,还有 1 个(ctx.userQuestions)一个都没有——也就是说,超过一半的 seam 目前只有一种实现。提供方最多的是 ctx.subagents(6 个)和 ctx.web(4 个)。
但这个数字你不能直接信,我也不能。因为那张表的「实现」列不是从代码里发现的,而是手写在生成器里的(scripts/gen-doc-graphs.ts 的 SERVICE_ROLES 常量)。我核对时抓到了两处:(a) 表里 ctx.lsp 的实现写的是 lsp-local,而这个包在 2026-08-11 的重命名台账里已经改叫 lsp-stdio(理由是「该提供方通过可替换的文件系统和子进程服务,以 stdio 传输 LSP,它不一定在本地运行」),生成器第 538 行仍然硬编码着旧名——于是这一格在渲染出来的表里是个没有链接的幽灵包名;(b) packages/shell/pwsh-sandbox 确实注册 ctx.shell(它继承 PwshLocalExecutor,而后者继承 ShellExecutor),但 pwsh-sandbox 这个词在整份 capability-seams.zh.md 里出现 0 次。
为什么这两处能活下来?我去读了那个「完整性守卫」(assertServiceRolesComplete):它比对的是发现到的服务键与分类过的服务键,两边对不上就抛错。它完全没有检查「implementations 里写的包名是否真实存在」。一个被删掉或改名的包名,可以在这张表里一直挂着。
怎么入手:用本章那个依赖实验室,按「provides 某个服务键」和「inject 某个服务键」两个方向各筛一遍,得到一份从代码来的提供方/消费方清单,再和表 11-2 逐行对。重点核 ctx.lsp、ctx.shell、ctx.fs 三行。然后想一个更难的问题:「提供方个数」真的是衡量抽象值不值的指标吗?ctx.sandbox 只有一个提供方(sandbox-local),但它内部要处理 bwrap / Landlock / Seatbelt / Windows ACL 四套完全不同的机制——如果没有这层 seam,这四套东西会直接长进 bash 执行器里。这算「只有一个提供方」吗?
真未解一项能力该在什么时候被抽成 seam?
仓库给的判据是「角色需要独立演进时」。这句话是对的,但它不可测:你要在写第一行代码的时候预测未来。预测错了有两种代价,方向相反——抽早了,付了成本没收益(上面那 14 个单提供方 seam);抽晚了,等到真需要第二种实现时,逻辑已经缠进消费方,要做一次大手术。
软件工程给过很多经验法则(「事不过三」、「先重复再抽象」),但它们都是事后叙述,没有一条能在事前判定。而 agent harness 这个领域还多一层特殊性:你不知道明年模型会需要什么能力——两年前没人会把「把一个轮次委派给另一家公司的 agent 产品」写进接口设计里,而现在它是 ctx.subagents 的六分之一。
怎么入手:挑仓库里两个反差最大的例子做对照研究——ctx.subagents(6 个提供方,抽得很值)和 ctx.terminals(1 提供方 1 消费方)。分别去读它们的 Agent Note(.agents/notes/implemented/feature/ 下的 2026-06-21-subagent-capability-seam.md 与 2026-07-16-persistent-pty-sessions.md),看作者当时写下的理由是什么,再对照今天的结果,判断当初的理由是不是站得住。做完这个练习,你会对「什么时候该抽」有一套自己的、能说出口的判据——这是目前能拿到的最好的东西。
答辩:如果我是审稿人
你们把一个「跑命令」拆成四个包,一次字段改动要动三处、要过三份评审;而且照你们自己的表,26 个 seam 里 14 个只有一个提供方。这不是架构,这是过度设计。请给我一个不靠「将来也许有用」的理由。
参考防守(先自己组织语言再看)
第一,把「已兑现的收益」摆出来,不谈期权。E2B 那个 POC 是硬证据:写三个包(沙箱生命周期、ctx.fs 适配、ctx.subprocess 适配),Bash、PTY、LSP 三套能力零改动跟着搬到远程。如果没有这两个 seam,等价的工作量是给三个能力各写一个远程 fork,然后永久维护两份——差别不是「多写几个文件」,是「一次性投入」与「双份长期维护」的区别。同一笔账在 ctx.shell 上也兑现过:沙箱化执行器(第 10 章那套隔离)是新增一个包拿到的,dsh-tool-bash 一行没改。
第二,「只有一个提供方」不等于「没被替换过」。ctx.sandbox 是最好的反例:一个提供方包里装着 bwrap、Landlock、Seatbelt、Windows ACL 四套机制加功能探测。seam 在这里挡住的不是「换一个提供方」,而是让四套平台机制不要长进 bash 执行器。数提供方个数会漏掉这一类收益。
第三,让步要给足。(a) 那 14 个单提供方 seam 里,确实有一部分目前是纯成本——ctx.terminals(1 提供方 1 消费方)就很难用今天的证据辩护。(b) 那张能力表是手写维护的,已经出现了幽灵包名和漏列的提供方,说明「三个角色分散在三处」这件事的文档成本是真实的、而且已经开始欠账。(c) 「三者一并设计」是一条纪律,纪律靠人执行;仓库能做到,是因为有 Agent Note、doc-sync 闸门和评审文化在撑着——把同一套做法搬到一个没有这些的团队,很可能只会得到代价、拿不到收益。
某人读完本章后下了两个结论:结论一「26 个 seam 里 14 个只有一个提供方,超过一半是过度设计,应该合并回去」;结论二「ctx.lsp 只有一个提供方 lsp-local,所以 LSP 这个 seam 是白抽的」。请分别评价这两个结论。评价结论二时,请特别注意它引用的事实本身。
packages/lsp/ 下面看一眼实际有哪几个目录。结论一:方向可讨论,判据太单薄。「提供方个数」只衡量了一种收益。至少还有两种它没算到:(a) 消费方数量——
ctx.subprocess 只有两个提供方,却有七个消费方,替换一次带走七样东西;(b) 提供方内部吸收的复杂度——ctx.sandbox 就一个提供方,但它把四套平台隔离机制挡在了 bash 执行器外面。所以正确的说法不是「一半是过度设计」,而是「一半的 seam 目前还没有兑现替换收益,其中哪些是纯成本需要逐个看」。另外还要考虑拆回去也有成本,而且合并之后再想拆开会更贵。结论二:引用的事实本身是错的。仓库里根本没有
lsp-local 这个包——packages/lsp/ 下是 lsp、lsp-stdio、tool-lsp。lsp-local 是 2026-08-11 那次重命名之前的旧名,生成器 scripts/gen-doc-graphs.ts 里还硬编码着它,而它的完整性守卫只校验服务键不校验包名,所以旧名一直挂在发布出来的表里。这道题真正要学的是:拿生成的文档当事实之前,先确认那一列到底是「从代码发现的」还是「手写在生成器里的」——这份文档的维护模式在页脚自己写着「混合模式:服务从 Cordis 声明中发现;接口、实现和消费方角色在 scripts/gen-doc-graphs.ts 中分类」。发现的那一半可信,分类的那一半是人写的。变式:如果让你给这个仓库补一条自动化检查来防住 lsp-local 这类问题,你会检查什么?它应该在哪个环节跑?(提示:仓库已经有一个 doc-sync 闸门,以及一个只校验服务键的 assertServiceRolesComplete。)
ctx.fs 和 ctx.subprocess 会把 LSP 一起搬走,而换掉 ctx.shell 不会。把写死的「跑命令」抽成一个 seam
你的 mini-harness 里,bash 工具大概是直接 execFile 的。这一层把它拆成三块:一个定义、两个提供方、一个消费方;然后换掉提供方,验证消费方一行都没改。
三处难点。
第一,resolve 为什么必须单独存在。你会很想把补默认值直接写进 run。别写——两个提供方的默认工作目录和默认超时本来就该不一样(本机用 process.cwd(),远端用 /home/user/workspace),把它显式暴露成一步,调用方才能在执行前看到「这次实际用的是什么」。这就是仓库那条「包边界处显式优于隐式」。
第二,provideRunner 里那个 throw。写成 ctx.runner = impl(后者覆盖前者)只少一行,但你会造出一个「加载顺序决定执行世界、且无人报错」的系统——和第 10 章「守卫能 allow」那个故障是同一类。
第三,消费方里不能出现任何一个 if。没有 if (remote),没有 instanceof,没有读配置判断模式。一旦出现,你造的就不是 seam,是一个带开关的实现。
做对了的样子:四步验证,第 (3) 步是真正的判据。
(1) 用 LocalRunner 跑一次 bash {"command":"echo hi"},输出是 hi。(2) 只把组装那一行换成 FakeRemoteRunner,再跑同一条调用,输出变成 [remote /home/user/workspace] echo hi。(3) 对消费方的源文件做 diff(先备份一份,或者用 git diff)——必须是零行改动。工具名、schema、结果处理逻辑一个字符都不该变,变化只发生在组装那一行和新增的提供方文件里。diff 不为空就说明你没做成 seam:回去找消费方里那个偷偷判断实现的地方。(4) 故意把两个 provideRunner 都打开,必须抛错而不是「后面的赢」;把 throw 删掉再跑一次,观察两种加载顺序给出两种执行世界、而且一声不吭——看完把 throw 加回去。
加分题(这一步才看得到 seam 的真实收益):再写一个第二消费方——一个不面向模型的小插件,启动时跑一次 git status --porcelain 把结果记到日志里。写完再做一遍第 (2) 步:换一行提供方,两个消费方一起搬了家。消费方从 1 个变成 2 个的这一刻,你会直接感受到 11.1 那道题里的结论——seam 的收益随消费方数量增长。
本章小结
「换一个提供方,等于换一个世界」这句话不是修辞,它有确切的机制:
- seam 的严格定义(
docs/glossary.zh.md):一种包含三种角色的可替换能力——Service Definition(拥有自身ctx.<key>和词汇类型的 CordisService,绝不是 TypeScriptinterface)、一个或多个 Service Provider、一个或多个注入该服务的 Consumer。seam 是完整能力,绝不是其中一个角色。 - 为什么不能是
interface:它换来的是三条运行时保证——组合期可替换(改cordis.yml而不是改 import)、依赖生命周期(没有提供方消费方就不启动)、重复检测(装第二个ctx.shell直接抛)。 packages/shell是规范范例:dsh-shell(350 行,其中 183 行只是词汇;抽象类只有三个方法加一个能力事实)、dsh-bash-local(363 行)、dsh-bash-sandbox(继承前者,只多做「包 argv + 归因」)、dsh-tool-bash(554 行,全程不知道命令跑在哪)。resolve()的请求/规格拆分是全仓库「显式优于隐式」的模板。- 最有说服力的推论:文件系统与进程提供方共享同一个执行世界,所以把
ctx.fs与ctx.subprocess指向远程沙箱,就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。桥是processPath()/fileUrl()/contains()这三个「执行世界坐标」方法。边界也很清楚:harness 进程、Cordis 对象、模型调用、会话状态都不搬。 - 同一接口后面差别可以极大:
ctx.subagents的六个提供方,从 94 行的「同进程新建一个子 agent」到把一整个轮次委派给 Codex 或 Claude Code。它还和 shell 不同——按名字注册、可共存多个,因为「委派给谁」是每次调用要选的决定,而「在哪跑命令」是一个环境事实。 - 添加一项能力意味着把三者一并设计。只写接口不算数;写第一个提供方的过程就是在检验接口,而在写第二个提供方之前不要认为接口稳定。
- 代价要诚实算:一个功能散在三到六个包里,一次字段改动传导成一串 PR;只有一个提供方时抽象成本白付。仓库自己的纪律是「只在角色确实独立演进时才拆」。按那张能力表数,26 个 seam 里 11 个有两个以上提供方、14 个只有一个——而那张表的「实现」列是手写在生成器里的,已经出现了幽灵包名(
lsp-local)和漏列的提供方(pwsh-sandbox),所以这个数字你得自己去数。
到这里,「一次工具调用怎么被拦住」和「一项能力怎么被替换」都讲完了。下一章我们把视线拉回模型那一侧:当对话长到装不下时,谁来决定扔掉什么、留下什么——而这个决定,同样是一个 seam。
第12章 上下文满了怎么办
一句话导语:这一章回答「对话长到装不下时怎么办」——答案不是删日志,而是让日志继续变长、让投影变短;这两个相反的方向,是第 6 章那条不变量的直接后果。
学完这一章你应该能做到
- 自己算出一次长任务会在第几步撞上上下文阈值,以及到那时累计付了多少 token
- 说清
CHARS_PER_TOKEN = 4是什么、它在什么情况下会严重失准、失准的后果是什么 - 区分
pressure与context-overflow两种触发,并说出它们各自挂在哪个扩展点上 - 解释为什么压缩要写四条事件、为什么其中三条故意不进 surface
- 说清
compaction/end为什么必须最后写,以及中途崩溃会留下什么可检测的痕迹 - 解释
shadowedRange的start为什么可能大于end,并举一个具体过程 - 说出 spill 和压缩解决的是不同的问题
CHARS_PER_TOKEN = 4;第6章的「仅追加日志」「投影」「surface」「surfaceOp 的 replace」。这一章直接建在第 6 章之上:surface 和投影是什么,那边已经讲透,这里不再重复。12.1 先算账:那堵墙在第几步
第 0 章给过一个不太好的结论:因为每一步都要把之前的全部重发一遍,累计发送量是 T(n) = c·n(n+1)/2,轮数翻一倍、花的钱变四倍。那是曲线的形状。这一节要问的是更实在的两个问题:墙在第几步?撞墙之前你已经付了多少?
好消息是这两个数在仓库里都有真实依据,不用瞎猜。
| 数字 | 值 | 出处 |
|---|---|---|
| 上下文窗口容量 | 1_000_000 | packages/llm/llm-deepseek/src/adapter.ts 的 DEFAULT_CONTEXT_WINDOW,两个目录模型 deepseek-v4-flash / deepseek-v4-pro 都用它 |
| 触发压缩的压力比例 | 0.8 | packages/compaction/compaction-basic/src/config.ts 的 DEFAULT_THRESHOLD_RATIO |
| 逐字保留的尾部比例 | 0.16 | 同一文件的 DEFAULT_RETAIN_RATIO |
阈值的算法是源码里那一行 Math.floor(contextWindow * policy.thresholdRatio)。写成式子:
| 符号 | 是什么 | 直觉 |
|---|---|---|
| W | 上下文窗口容量(token) | 一次请求最多塞得下多少 |
| ρ | thresholdRatio,默认 0.8 | 装到八成满就开始收拾 |
| c | 每一步新增的 token 数 | 模型答一段 + 工具吐一堆结果 |
| k* | 第几步会触发压缩 | 墙在哪儿 |
自己算一遍:墙在第几步,到那时你付了多少
上下文窗口 W = 1,000,000,ρ = 0.8。阈值是多少 token?
想好了再看
Math.floor(1000000 × 0.8)= 800,000。注意这是请求压力的阈值,不只是历史消息——系统提示词和工具 schema 也算在里面(第 8 章讲过它们每步重新组装,且体积不小)。假设每一步新增 c = 2000 token(保守:读一个 500 行的文件按表 0-1 就是 4000~8000)。第几步撞阈值?
想好了再看
800,000 ÷ 2000 = 第 400 步。听起来很远?一个真实的编码任务里,「读文件、跑测试、改代码、再跑测试」四步就走完了,400 步大概相当于一百个这样的小循环——长任务跑一晚上就到了。
到第 400 步为止,你累计发送了多少 token?用式 0-2。
想好了再看
T(400) = 2000 × (400 × 401 ÷ 2) = 2000 × 80,200 = 160,400,000,约 1.6 亿 token。
这 1.6 亿里,有多少是「真正的新内容」?剩下的是什么?
想好了再看
新内容只有 400 × 2000 = 80 万。剩下的 1.59 亿全是重发——同一段历史被反复送进模型 400 次。比值是 200 倍。这就是为什么压缩不是「省一点钱」的优化,它是让长任务能存在的前提。
最后一问,这一问才是压缩策略的核心:压缩之后要保留多少?为什么不干脆全压掉?
想好了再看
默认
retainRatio = 0.16→Math.floor(1000000 × 0.16)= 160,000 token 的近期尾部逐字保留。为什么不全压:摘要是有损的。最近几步的原文里有模型正在做的事——刚打开的文件、刚失败的那个测试、用户上一句纠正。把它们也换成摘要,模型会立刻重新去读一遍同样的文件,你省下的 token 马上又花回去了,而且这次还多花了一次摘要调用的钱。保留尾部是在「省钱」和「别失忆」之间选的一个点,配置里还要求retainRatio必须严格小于thresholdRatio,否则插件在加载时就报错——因为那样一来压缩永远压不动任何东西。
某个部署把一个小模型接了进来,上下文窗口是 65,536。用仓库的两个默认比例,算出它的压缩阈值和保留预算各是多少 token。再判断:如果有人给这个模型配了 retainTokens: 60000,插件会不会加载失败?
retainTokens 是绝对值,它和 retainRatio 互斥;校验规则是「保留预算必须低于阈值 token 数」。Math.floor(65536 × 0.8),保留 = Math.floor(65536 × 0.16)。算出阈值之后再拿 60000 去比。Math.floor(52428.8) = 52,428;默认保留 = Math.floor(10485.76) = 10,485。配 retainTokens: 60000 会失败,因为 60000 ≥ 52428,源码里那句 retainTokens (60000) must be less than threshold tokens 52428 会抛出来。但要注意失败的时机:比例之间的冲突(retainRatio ≥ thresholdRatio)在插件加载时就报;而绝对值 retainTokens 的冲突要等到第一次解析出这个路由目标才报,因为这个比较需要知道模型容量,而容量是适配器给的。README 把这条写得很明白,值得记住:能在加载时判定的错就在加载时炸,判定不了的才推迟——但绝不静默通过。变式:如果适配器根本没给出这个路由的容量(contextWindow 缺失),自动压缩会怎么做?(提示:README 说自动 listener 会对该精确目标「警告一次」,然后做什么?)
12.2 harness 怎么知道自己快满了
上一节的算式里有个东西被我含混过去了:「当前压力是多少 token」这个数,是谁数出来的?
直觉答案是「调 tokenizer 数一遍」。dsh 不这么干。它有一个单例服务 ctx.tokenMeter(docs/subsystems/token-meter.zh.md),而这个服务的底层估算器是一条写死的常数:
/** Fixed text-density estimate used until exact tokenization is needed. */ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4 /** Role-field framing overhead added to every priced message. */ export const ROLE_OVERHEAD = 4
(packages/llm/token-meter/src/estimate.ts,逐字。)四个字符算一个 token,每个内容块再加 4,每条消息的角色字段再加 4。第 0 章已经点过这条常数,这一节要讲的是它错在哪、错多少、以及为什么系统还能正常工作。
它对中文错得有多离谱
4 个字符 1 个 token 是按英文文本的密度定的——英文里 4 个字符差不多是一个词。中文里 4 个字符就是四个汉字,信息量完全不是一个量级。第 0 章给过量级:一个汉字大约 0.5 到 1 个 token。所以 2000 个汉字,估算器说 500 token,真实值在 1000 到 2000 之间——系统性低估 2 到 4 倍。
这件事不是本站的发现,仓库自己写在文档里(packages/llm/token-meter/README.zh.md,逐字):
源码注释里也有同一句:the estimator systematically underprices CJK text and JSON schemas(packages/llm/token-meter/src/projection.ts)。注意它把 JSON schema 和中文并列——工具 schema 是一大坨结构化 JSON,尖括号、引号、字段名全是短碎片,4 个字符一个 token 同样吃不准。
那为什么系统还能正常工作
因为压力不是每次都从头估的。measure() 的实际算法是这样(packages/llm/token-meter/src/index.ts):
| 符号 | 是什么 | 直觉 |
|---|---|---|
| baseline | 锚点。两种:usage(提供方真报的用量)或 estimated(纯启发式) | 「上次称重的读数」 |
| surfaceDelta | 当前 surface 相对锚点时刻的有符号启发式差值 | 「称重之后又加了/减了多少」 |
| totalTokens | 当前请求与响应的压力 | 拿去和阈值比的那个数 |
关键在 usage 那一支:只要最近一次成功调用的规范请求 envelope 与当前一致(同样的系统提示词、工具、路由……),就直接拿提供方报回来的真实用量当锚点,启发式只负责计算「从那一刻到现在新增了多少」这个差值。也就是说,那条 4:1 的误差只作用在增量上,不作用在整段历史上。仓库把这一点写成了一句很准确的话:Only the delta is estimated, so the figure stays anchored to the sample.
还有一层保险:源码里选锚点时要求「提供方总量不低于这次调用的完整启发式锚点」才采用(providerTokens >= estimatedAnchorTokens),注释写的是有符号的启发式差值只有从一个保守锚点出发才保守。因为启发式对中文偏低,真实用量通常更大,所以中文场景下这个条件反而更容易满足——锚点会被采用,误差被挡在外面。
读的时候要小心:误差没有消失,只是被推到了两个角落
上面那套锚点机制并不能让 4:1 的误差消失,它只是把误差限制在两处:
其一,还没有可复用锚点的时候——会话的第一次请求、或者 envelope 变了(系统提示词改了、工具装卸了、换了模型路由)。这时 baseline.kind === 'estimated',整个信封和整个 surface 都按 4:1 重新计价,surfaceDelta 被置 0。一段全中文的长历史在这一刻会被低估 2~4 倍。
其二,增量本身很大的时候——一步里读了三个大文件、跑了一次输出几千行的测试,这些新内容全按 4:1 算。它们恰恰是最容易把请求顶爆的那部分。
后果(本站推断):非拉丁语系的内容下,压力检测会触发过晚——仪表说 60 万、真实已经 150 万,请求直接被提供方拒掉。于是压缩不再走「压力」这条从容的路,而是被迫走 12.3 要讲的「已经撞墙了」那条补救路。这个推断链条本站没有在仓库里找到明文确认,仓库只写了「严重低估」这个事实,没有把它连到压缩触发时机上。你可以自己验:把 ctx.tokenMeter.measure() 的读数和提供方返回的真实 usage 并排打印,跑一段中文对话看两条线什么时候分叉。
一个纯中文的会话已经跑了 50 步,每次请求都成功,压力仪表一直显示得挺准(因为有 usage 锚点)。现在用户装了一个新插件,它往系统提示词里加了一段。请说明下一次 measure() 会发生什么,以及为什么这是这套机制里最危险的一个时刻。
measure() 里那句 optionalHeaderEquals(anchor.header, header)——envelope 不相等时走的是哪一支?那一支的 surfaceDeltaTokens 是多少?packages/llm/token-meter/src/index.ts 里 measure() 的三个分支。第二支(有 header 但不匹配锚点)把 baseline 设成 { kind: 'estimated', … }、把 delta 设成 0。也就是整份历史重新按 4:1 估一遍。baseline 退化成 estimated,surfaceDelta 归零,50 步的中文历史被整段按 4 字符 1 token 重新计价。读数会断崖式下跌——真实压力一点没变(还多了一段新提示词),仪表却可能只显示原来的三分之一。危险就在这里:这一步既是压力真实增加的一步(提示词变长了),又是仪表精度最差的一步,两个误差同向叠加。更隐蔽的是它不会报错,只会安静地把压缩推迟到某个更靠后的步骤,甚至推迟到提供方直接拒绝请求。这也解释了为什么这套设计要保留 context-overflow 那条补救路径:估算失准是被承认的常态,不是异常。变式:如果新插件加的不是系统提示词,而是一个新工具(工具 schema 变了),结果一样吗?(提示:EpochHeader 里都有哪几项?第 6 章讲不变量时列过六项。)
12.3 两种触发:还没满,和已经炸了
仓库把压缩的自动触发分成两种,类型只有两个取值(packages/compaction/compaction/src/types.ts):
/** Why automatic policy is asking a backend to consider compaction. */ type CompactionTrigger = 'pressure' | 'context-overflow'
为什么要分两种?因为它们的确定性不一样,所以能采取的手段激烈程度也不一样。
pressure | context-overflow | |
|---|---|---|
| 什么时候 | 还没溢出,但仪表读数过了阈值 | 提供方已经明确返回「上下文超限」 |
| 挂在哪 | 串行的 agent/pre-step,在派生请求之前 | agent/request-error,只用于规范的上下文溢出 |
| 依据可靠吗 | 估算,可能偏低(见 12.2) | 确凿——提供方说的 |
| 能有多激进 | 守常规阈值与保留预算 | 可以绕过常规压力与保留,做一次「最大平衡头部缩减」,只留最近一个不可分单元 |
| 需要知道容量吗 | 需要(要算阈值) | 不需要——提供方已经证明了必要性 |
最后一行是这个分类最漂亮的地方。压力路径必须先问适配器「这个模型能装多少」,问不到就只能警告一次然后带着完整历史继续跑。而溢出路径根本不问:既然提供方已经拒绝了,就不需要任何容量元数据来论证该压缩了。一个已经发生的事实,比任何估算都可靠。
恢复的时机:一个很讲究的位置
docs/agent-lifecycle.zh.md 里有一句话把时机钉得极死(逐字):
拆开看三件事。
「失败步骤结束之后」:那个步骤已经写了 step/end,它是一段完整的、闭合的历史。压缩不会在一个半开的步骤中间去改 surface。
「失败轮次结束之前」:轮次还开着,所以重试可以接在同一个轮次里继续,用户不会看到一个莫名其妙中断然后又冒出来的新轮次。
「只有当 surface replacement generation 前进了才重试」——这一条是全场最要紧的。replaceGeneration 是第 6 章讲过的那个计数器:surface 上发生过多少次位置替换。源码里的写法是先记下它,压缩之后再比一次:
const generation = agent.session.surface.replaceGeneration
// …尝试压缩…
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
this.overflowRetries.set(agent, retries + 1)
return { kind: 'retry' }
(packages/compaction/compaction-basic/src/index.ts。)没前进就 return next()——把原始的请求错误原样交出去,不重试。
为什么必须有这道门
不用它会怎样?请求因为超长失败 → 触发压缩 → 压缩没能减少任何东西(比如所有内容都在保留尾部里、或者单个工具结果本身就超过整个窗口)→ 重试 → 还是超长 → 再触发压缩 → 再没减少 → 再重试……这是一个不会自己停下来的循环,而且每一圈都真的向提供方发了一次请求、花了一次钱。
replaceGeneration 前进 = 「这次真的改变了要发出去的东西」。它是「进步」这个概念的可判定形式:不靠猜、不靠计数、不靠超时,直接问 surface 有没有被改过。仓库另外还配了一个 maxOverflowRetries(默认 1,设 0 就完全禁用恢复)作为第二道保险。
还有一个精细到值得停一下的分支:如果剪枝已经落地、后续的摘要工作抛异常了怎么办?源码的注释原话是「不依赖模型的剪枝可能在后续摘要失败之前就已落地。那份持久的缩减足以作为重试凭据,不要仅仅因为可选的第二阶段抛了异常就把它丢掉。取消仍然优先」。所以答案是:只要 surface 真的前进了,即使后半段失败,也照样重试。判据始终是那一个数,不是那一串步骤有没有全部成功。
一个新人想「简化」这套设计:既然溢出路径更可靠,干脆把压力路径删掉,只在提供方报错时压缩。请指出这个方案会丢掉哪三样东西。
maxOverflowRetries 默认是几?)。retainRatio 的保留预算,压得温和;溢出路径按 README 是「绕过常规压力与保留,尝试一次最大平衡头部缩减,只留下最新不可分单元」——它更狠,模型丢的上下文更多。只靠溢出,等于每次都用最激烈的手段。其三,重试预算:maxOverflowRetries 默认只有 1。压力路径是每一步都检查的常规维护,不消耗这个预算;把它删掉,你就把一个连续的调节机制换成了一发子弹。补一句:压力路径还是 compactNow() 之外唯一能在轮次之间从容工作的入口,删掉之后手动 /compact 就成了唯一的主动手段。变式:反过来,能不能只留压力路径、删掉溢出路径?(提示:先回答 12.2 里那个中文低估的场景下,压力路径还剩多少可靠性。)
12.4 两种手段:先剪枝,后摘要
触发之后干什么?仓库的顺序是固定的:先可选地剪枝工具结果,再考虑摘要。
工具结果剪枝(tool-result pruning):把超大的工具结果中间那一段砍掉,保留头和尾,换成一条更短的内容。由可选服务 ctx.toolResultPruner 提供(packages/compaction/compaction-tool-result-pruner/),它是确定性的,不调模型。
顺序为什么是这个?三个理由,一个比一个硬。
第一,剪枝不花钱。摘要是一次真实的模型调用——要把被遮蔽的那一整段对话回放给摘要模型,再等它生成。剪枝只是本地字符串切片。README 说得直白:「不依赖模型的剪枝可以完全避免辅助调用」。先做免费的那件事,是显然的。
第二,剪枝不损失语义结构。摘要会把一整段对话压成一段散文,之后模型再也拿不回原来的消息边界。剪枝只动一条工具结果的内部文本,消息还是那条消息,工具调用与结果的配对关系一点没变。
第三,剪枝之后要重新测量。这一步才是设计的精髓:compaction-basic 剪完之后会通过 ctx.tokenMeter 再量一次,「如果压力已回到安全范围,就跳过摘要」。也就是说,剪枝不只是省了一步,它有可能让整个摘要根本不必发生。
打个比方
行李超重了。剪枝是「把行李箱里那堆报纸抽出来扔掉」——你知道扔的是什么,箱子里其他东西一动没动,扔完再上秤,说不定就够了。摘要是「把三件衣服拍张照,然后把衣服寄回家,只带着照片走」——确实轻了很多,但你到了目的地只有照片。先抽报纸,再考虑寄衣服。
类比失效处:报纸是「明显没用的东西」,而剪枝砍掉的是工具结果的中间段,它未必没用——只是按「头尾比中间重要」这条固定策略猜的。另外照片和衣服不同,摘要是模型写的,它可能写错、可能漏掉关键信息,而抽报纸不会出错。
剪枝还留了一份很讲究的收据。每次替换都记一条 PrunedEntry:被遮蔽的原始事件 seq、新追加的替换事件 seq、共享的 callId、以及替换前后的字符数(charsBefore / charsAfter,单位是 Unicode code point 而不是 UTF-16 code unit——所以切割边界不会把一个代理对劈成两半)。切完的总减少量汇总成 PruneResult.charsRemoved。
剪枝服务是可选的(没装就直接进摘要)。请说明:为什么它可以做成可选的,而摘要不行?再指出一种「剪枝也救不了」的情况。
ctx.compaction 是能力 seam,ctx.toolResultPruner 是它可选消费的另一个服务)。剪枝救不了的情况:README 列了几种,最典型的是「不可分的非工具单元」——比如用户一次粘贴了一份 30 万 token 的日志作为提示词。它是一条 user/message,不是工具结果,剪枝碰不到它;而且它是一个 surface 节点,压缩的最小粒度就是节点,切不开。README 明说:「恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。」再补一条更极端的:如果超的是信封本身(系统提示词加工具 schema 就已经超过窗口),表层压缩一点忙都帮不上——它压的是消息,不是信封。变式:剪枝按「头/中/尾」保留,为什么保留的是头和尾而不是中间?举一个这条策略会砍掉关键信息的具体例子。
12.5 摘要怎么做到不破坏日志
现在到了这一章和第 6 章接得最紧的地方。
第 6 章立了一条不变量:模型可见即已记录,抵达模型请求的一切都必须能从日志重建。压缩要做的事听起来正好和它对着干——「把一大段历史从模型眼前拿掉」。怎么做才不破坏那条不变量?
答案分两半,而这两半的分工是整章最值得学的设计。
第一半:三条只写日志的事件
压缩通过声明合并给 SessionEventMap 加了事件类型。文档的表里列了三种(docs/subsystems/compaction.zh.md):
| 事件 | 载荷要点 | 作用 |
|---|---|---|
compaction/start | { compactionId, turn } | 取锁。turn 是数字表示归属于某个未结束的自动轮次,null 表示独立的手动尝试 |
compaction/summary | summary、shadowedRange、shadowedSeqs、shadowedTokenCount、provider、model、可选 rawOutput / llmStreamCall / maxTokens / usage | 记下摘要本身、被遮蔽了哪些节点、值多少 token、以及这次摘要调用的完整信封 |
compaction/end | { compactionId, turn, error? } | 释放锁。error 记录一次失败的尝试 |
三者的共同点,也是关键点:它们全都只写日志,绝不进入 surface。文档的原话是「这里有意不扩展 SurfaceEventType(只有产生消息的事件才到达模型)」。
补充:其实还有第四条,而它揭示了一个共用协议
本站核实:docs/subsystems/compaction.zh.md 的那张表只列了三种事件,但源码 packages/compaction/compaction/src/types.ts 的声明合并块里有四种——多出来的是 compaction/prune,剪枝用的。它同样只写日志,载荷是 { shadowedRange, shadowedSeqs, shadowedTokenCount }。
它存在的理由,源码注释叫「共用的影子计价协议」:一次 surface replace 的价格,由紧挨在它前面的那条计价事件说明——摘要压缩用 compaction/summary,剪枝用 compaction/prune。注释还规定「替换必须紧跟在这条事件之后同步追加」,而 types.ts 里把这条相邻关系称为 contractual(是约定的一部分,不是巧合)。
目的是什么?让一个纯消费方(比如算 token 组成的那个投影)不必为每个节点单独保存价格:它顺着日志往下折叠,看到一条 replace,就从紧邻的前一条事件里读出「被替换掉的那段值多少 token」,直接减掉即可。把「减多少」写在日志里,而不是让每个读者自己算——这是「一份日志多个读法」在实现层面的一个具体代价与收益。
存疑:手写的文档表格没有收录 compaction/prune。这可能是有意的(那张表在讲摘要压缩的生命周期,剪枝在另一节讲),也可能只是漏了。以源码为准。
回想第 6 章:SurfaceEventType 只有三个成员,user/message、assistant/message、tool/result。压缩本可以给自己开一个第四种「摘要事件」,让它也能进 surface。它故意没有。
第二半:摘要本身骑在一条普通的 user/message 上
那摘要怎么给模型看到?靠另一条事件——一条普普通通的 user/message,只不过它的 surfaceOp 不是 'append' 而是:
{ op: 'replace', start, end }
文档说这是「摘要压缩执行的唯一 surface 变更」。它的 content 是第 6 章讲过的那种逐字投影:一段检查点前导 + 一个空行 + <compacted-summary> 包住的摘要正文(真实的前导文本在 compaction-basic README 的「模型体验」一节里逐字写着)。为了让消费方认得出它,这条消息的 source 用 compactCheckpointSource(compactionId) 构造,配一个判定函数 isCompactCheckpointSource()——所以「这是一次压缩检查点」这件事,识别起来不依赖任何特定后端。
compaction/start、compaction/summary、替换用的 user/message、compaction/end),而模型看到的历史从 6 条变短到 4 条。两个方向相反,这就是这一章的全部要点。示意图,seq 编号为举例,非仓库实际数据。常见误解
很多人以为「压缩 = 删掉旧消息」。日志里一条都没删。被遮蔽的事件原封不动地躺在原处,seq 连续,内容一字未改——所以人类翻 transcript 还能看到全部原文,遥测还能算出原始用量,fork 出去的子会话依旧共享逐字节相同的前缀。变短的只有投影。这正是第 6 章那句「一份日志,多个读法」的兑现:模型读摘要,人读原文,账本读全部。
假设压缩的设计者当初选了另一条路:给 SurfaceEventType 加第四个成员 compaction/checkpoint,让摘要事件自己进 surface,投影时把它渲染成一条 user 消息。这个方案在功能上是等价的吗?请从第 6 章那条不变量和其他消费方两个角度各说出一个它更差的地方。
session.append() 对 surface 类型有什么特殊的编译期要求。SurfaceEventType 是一个闭合的小集合,投影函数、transcript 渲染器、token 计价、UI 卡片全都按它分派。加一个成员,是在改所有人的分派表。deriveMessages() 与实际发出的消息(第 6 章那三行)。多一个 surface 类型,就多一条投影规则要写、要和渲染保持一致——而这条规则由可选插件贡献。于是「模型能看到什么」这件事的定义,从核心的一张闭合表,变成了「核心的表 + 装了哪些插件」。核心再也不能独立回答「一条日志投影出什么」,因为它可能遇到自己不认识的 surface 类型。从其他消费方的角度:任何只认三种类型的消费方(transcript 渲染器、token 计价的位置折叠、第三方 UI、SDK 客户端)在遇到第四种时都得更新,否则要么漏掉摘要(模型历史算少了,那条断言当场失败)、要么按未知类型报错。而现在的做法下,摘要就是一条 user/message——所有已有消费方零改动就能正确处理它,想区分的人再去看 source 是不是 compactCheckpointSource。这就是复用既有类型换来的东西:可选能力不扩张核心词汇表。代价也要诚实说:模型看到的是一条「用户消息」,而它其实是系统生成的;仓库靠 <compacted-summary> 标签和检查点前导来告诉模型这是什么,这是提示词层面的约定,不是类型层面的保证。变式:那三条记账事件既然不进 surface,为什么不干脆做成日志之外的一份 metadata 文件?(提示:想想第 6 章的 fork——子会话拿到的是什么?)
12.6 锁:为什么 end 一定写在最后
压缩中间有一次模型调用,要花好几秒。这段时间里如果又来一次压缩请求怎么办?如果进程崩了怎么办?
仓库的答案是一把写在日志里的锁。文档把顺序写得很死(docs/subsystems/compaction.zh.md,逐字):
compaction/start,然后执行摘要生成、写入 compaction/summary 记录与 user/message 替换,最后才追加 compaction/end。紧接着的那句话,是这一节要讲的全部:
compaction/start 而无匹配的 compaction/end),而非一个虚假声称压缩已完成的 compaction/end。把两种写法并排看,差别就出来了。
| 崩溃发生在 | end 最后写(仓库的做法) | 假想:先写 end 再干活 |
|---|---|---|
| 摘要生成到一半 | 日志里有 start 没 end → 一眼看出「有一次压缩没完成」 | 日志里 start、end 齐全 → 看起来成功了,但 surface 一点没变 |
| 下次启动怎么判断 | 发现未配对的 start,可以报 busy 或按陈旧证据忽略 | 无从判断——完整的括号对是「成功」的样子 |
| 错在哪一侧 | 误报「还在忙」(假阳性) | 误报「已完成」(假阴性) |
为什么宁可假阳性也不要假阴性
这是可靠性工程里一个反复出现的选择:当你只能在两种错误里挑一种时,挑会被发现的那种。
「误以为还在忙」的后果是下一次压缩被拒(报 busy),有人会看到、会去查、会发现遗留锁。「误以为已完成」的后果是系统安静地相信历史已经被压过了——压力照旧、请求照旧超长,而日志上写着一切正常。前者吵,后者哑;吵的错能修,哑的错会烂在那里。
那遗留锁怎么清?靠第 6 章那条 session/end-seed。文档的规则是:「活动的未匹配 start 会阻塞所有入口点;较新 session/end-seed 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。」这正是第 6 章讲 session/end-seed 时举的那个例子的真实原型——种子历史里的开括号属于一个已经结束的生命周期,边界之后的才是真的还在跑。
还有两个细节值得记:其一,error 字段。失败的尝试也写 compaction/end,只是带上 error——「摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试」。失败不是不留痕,是留成另一种痕。其二,README 里一句冷冰冰的话:「闭合失败会有意留下阻塞性的未匹配标记」。连 end 都写不进去(比如持久化坏了),系统选择让这把锁卡住——因为这时候它已经不知道自己处在什么状态了,卡住比继续猜安全。
有人提议改成:compaction/start 和 compaction/end 都在操作成功之后一次性写进去(既然要写日志,不如攒着一起写,少一次刷盘)。请构造一个具体场景,说明这个改动会让什么变得不可判定。
/compact。两边都先检查「有没有活动的未匹配 start」。/compact,它检查日志——干净——于是也开始工作。两次压缩并发选择范围、并发写替换,第二次的 replace 引用的 start/end 可能已经被第一次遮蔽掉了,写入时校验失败;就算侥幸都通过,surface 也被两次不协调的替换改成了谁也说不清的样子。文档明说「活动的未匹配 start 会阻塞所有入口点」,正是为了防这个。崩溃不可判定:攒着写意味着崩溃时日志上一条压缩事件都没有——但那次摘要调用已经真的发生了,钱已经花了。你既查不出「有过一次未完成的压缩」,也永远算不回那次调用的用量。这就把 12.6 的假阳性/假阴性之争推到了更糟的极端:现在连「错」都没留下,只留下一段无法解释的账单。附带一提,这个提议的出发点(少刷一次盘)本身也站不住:compaction/start 必须同步追加在任何异步工作之前,这是它作为锁的定义,不是性能选项。变式:compaction/start 的 turn 字段可以是数字或 null。如果把它简化成永远存数字(手动压缩就存当前轮次号),会丢掉什么?(提示:手动压缩是在轮次之间跑的。)
12.7 shadowedRange:一个 start 可能大于 end 的「区间」
这一节讲一个小得不能再小的字段。之所以值得单独一节,是因为它是一个极好的样本,说明读文档要读到什么程度才算读懂。
CompactionResult 里有这么一项:
shadowedRange: { start: number; end: number }
看到 start 和 end,任何人的第一反应都是「一个数值区间,start ≤ end」。写代码时顺手就会写出 for (let s = start; s <= end; s++),或者 seqs.filter(s => s >= start && s <= end)。
源码注释专门为此写了一段警告(packages/compaction/compaction/src/types.ts,逐字):
/**
* The surface-boundary pair that was shadowed: the seqs of the first
* (`start`) and last (`end`) surface nodes of the replaced range. A
* surface-POSITION span, not a numeric seq interval — after a prior replace
* lands a fresh high-seq summary node at an older range's position, `start`
* can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the
* authoritative set of shadowed nodes, in surface order.
*/
翻成人话:它是一个「位置跨度」,不是「数值区间」。start 完全可能大于 end。
怎么会这样
顺着图 12-1 往下走一步就明白了。第一次压缩之后,surface 是这样:
start 就大于 end 了。示意图,seq 编号为举例,非仓库实际数据。为什么摘要节点的 seq 一定大?因为它是新追加的事件——日志只往后加,新事件的 seq 永远是最大的。而 replace 把它放在被替换那段的位置上。于是 surface 的 seq 序列不再单调递增。docs/subsystems/token-meter.zh.md 也提醒过同一件事:「表层顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。」
所以正确的用法只有一条:要遍历被遮蔽的节点,用 shadowedSeqs——注释里说得明明白白,它才是「authoritative set of shadowed nodes, in surface order」。shadowedRange 只是两个端点的名字,用来指认那一段在哪儿开始、在哪儿结束。同理,服务定义里那两个边界检查函数叫 toolPairingBalancedBefore(session, seq) 和 toolPairingBalancedAfter(session, seq)——它们接受的是一个 seq,问的是「在这个位置的前面/后面,工具调用与结果是否配平」,而不是拿两个数去做大小比较。
读的时候要小心:这类坑不会有编译器帮你
{ start: number; end: number } 在类型系统眼里就是两个数字,TypeScript 不可能知道它们不构成数值区间。compactRegion(start, end, …) 的文档写着它会「拒绝活动、缺失、反转或不平衡的范围」——注意这里的「反转」指的是位置反了,不是数值反了。仓库里这类「名字看起来像 A、语义其实是 B」的字段,只能靠读注释发现。本站建议:读一个仓库的类型定义时,凡是遇到 start/end、from/to、min/max 这种成对字段,都值得停下来问一句「这两个数之间是什么关系,有人保证过吗」。
一个第三方 UI 想做「点开摘要,展开被折叠的原文」。作者的实现是:从 compaction/summary 事件读出 shadowedRange,然后取日志中 seq 在 [start, end] 之间的所有事件展示出来。请说明这个实现在第一次压缩时表现如何、在第二次压缩时会怎样出错,并给出正确做法。
start 是上一次的摘要节点 seq(很大),end 是一个后来的节点 seq(较小)。把这两个数代进 s >= start && s <= end 会得到什么?s >= 15 && s <= 12 对任何 s 都是 false。start = 15 > end = 12,区间为空,展开按钮点了什么都不出来。更糟的变体:如果作者写的是「取 min 和 max 之间」来「兼容」反转,那就会捞出 [12, 15] 之间的所有事件——包括 compaction/start、compaction/summary、以及完全不属于这次遮蔽的其他事件,UI 上会冒出一堆内部记账信息。而真正被遮蔽的 seq 2 之后那批老节点,一条都不在里面。正确做法:读 shadowedSeqs——它按 surface 顺序逐个列出被遮蔽的节点,是权威来源。shadowedRange 的用途只是标出这一段在 surface 上的两个端点。这道题的一般教训是:当一个字段的名字暗示了一种数据结构、而注释说它其实是另一种时,永远相信注释;而且要专门为「第二次」写一个测试——只压一次的测试对这个 bug 是完全瞎的。变式:如果一次压缩恰好只遮蔽一个节点,shadowedRange 会是什么?这时候数值比较和位置比较还能区分开吗?
12.8 spill:另一条路——把大东西挪走,只留一张取货单
压缩处理的是「历史累积得太多」。还有一类问题它管不了:单次输出就巨大。一个 web_fetch 抓回来一个网页,一次 grep 匹配到几万行,这些东西一进 surface,压力瞬间跳一大截,而且下一步、下下步都要重发它。
spill(溢出存储,不译):把工具产生的超大文本存到别处,只给模型一个不透明的定位符(SpillLocator)和一句取回指引(retrievalHint)。定义在 packages/spill/spill/,本地后端把文件写进会话私有目录,定位符就是那个路径。
三者的分工,用一句话各自概括:
| 什么时候动手 | 动谁 | 原文还能拿回来吗 | |
|---|---|---|---|
| spill | 工具结果刚产生时(tools/post-execute 策略) | 单条超大结果 | 能——模型自己用定位符去 read / grep |
| 剪枝 | 压力已经上来了 | surface 上已有的超大工具结果 | 不能(保留头尾预览),但被遮蔽的原始事件仍在日志里 |
| 摘要压缩 | 剪枝之后压力仍超阈值 | 一整段历史 | 不能(只剩摘要),但原始事件仍在日志里 |
最关键的区别是那一列「原文还能拿回来吗」。spill 之后,模型手里握着一个能主动取回的把手——它随时可以去读那个文件、grep 那个文件、只看自己关心的那 20 行。压缩之后,模型手里只有一段摘要,它想看原文得重新去做一遍当初那件事(重新读文件、重新跑命令)。spill 是把「什么时候需要这段内容」这个决定权交还给模型;压缩是替它做了决定。所以顺序上 spill 排在最前面,它是最省事的一档。
几个值得注意的实现细节:定位符是不透明的——文档明说消费方「将它视为不透明值,并使用 retrievalHint 渲染,而不是假定 read 始终是正确的检索机制」,因为远程或数据库后端可能返回 URI 或键而不是路径。本地后端用 open(path, 'wx', 0o600) 排他写入,防止有人预先埋一个符号链接把写入重定向走。而策略层是尽力而为:保存失败时保留原始的内联结果,「不会把成功的调用变成 isError」——存不下不是工具的错,不该让工具背这个锅。
下面四种情况,各自最合适的手段是 spill、剪枝、摘要压缩,还是三者都救不了?
(A) 一次 web_fetch 抓回 80 万字符的网页。
(B) 会话跑了 300 步,历史里全是小的读写操作。
(C) 用户一次粘贴了一份 40 万 token 的日志作为提示词。
(D) 系统提示词加工具 schema 合起来就超过了模型窗口。
user/message,不是工具结果。tools/post-execute 就能被策略拦下来换成预览加定位符,压力从一开始就没上去过。(B) 摘要压缩——没有哪一条特别大,是「多」而不是「大」,剪枝无从下手,只能整段摘要。(C) 摘要压缩,但要看它能不能被压掉:它是一条 user/message,spill 和剪枝都够不着(两者的作用对象都是工具结果)。摘要可以把它连同周围一段一起压掉,前提是它不在保留尾部里;如果它就是用户刚发的那一条,它必然在尾部,那就暂时压不动——README 说的「不可分的非工具单元」正是它。(D) 三者都救不了——README 逐字写着「恢复无法缩减系统/工具/前缀」,surface 压缩压的是消息,不是信封。这时候只能换模型、精简系统提示词,或者减少可见工具(第 8 章那套按作用域过滤工具的机制在这里就有用了)。变式:(A) 那种情况,如果 spill 的保存失败了(磁盘满了),会发生什么?这个选择合理吗?
答辩:如果我是审稿人
你们花了整整一章论证「模型可见即已记录」的不可动摇,然后压缩用一次模型调用生成摘要,把几十条真实历史换成一段模型自己写的散文。这段散文可能漏掉关键信息、可能理解错、甚至可能编造。换句话说:为了省钱,你们让模型的记忆变成了模型自己的转述。这难道不是把 agent 的可靠性建在一次没人检查的模型输出上吗?
参考防守(先自己组织语言再看)
这个批评是成立的,先承认这一点:摘要确实有损,确实可能出错,仓库也没有任何机制去校验摘要的正确性。下面是为什么这仍然是当前条件下的正确取舍。
第一,替代方案是「死」,不是「更好」。不压缩的结果不是「历史更完整」,而是请求被提供方拒绝、任务在半路终止、然后重跑一遍再在同一个地方死一次(第 1 章的「死法一」)。「有损的记忆」和「没有记忆」之间,前者严格更好。
第二,损失被限制在最不重要的那一段。retainRatio 保证最近 16% 的窗口逐字保留——模型正在做的事、刚失败的测试、用户上一句纠正,全都是原文。被摘要的是最旧的那一段,也是最可能已经完成、最不需要逐字精度的那一段。这不是随机丢失,是按时间顺序的、有策略的丢失。
第三,原文一条都没丢。这是最要紧的一条,也是这一章的主线:被遮蔽的事件全部留在日志里。所以「摘要写错了」这件事是可诊断、可追责、可复盘的——你能翻出原文,逐条对照摘要错在哪。如果当初选的是「删掉旧消息」,这个批评就无解了:错了也查不出来,因为证据没了。仓库还把摘要调用的完整信封(provider、model、maxTokens、usage、可选的 rawOutput)一并写进 compaction/summary,理由是「该一次性请求可由日志 + 代码重建」——连「是哪个模型写的这段摘要」都有持久答案。
第四,摘要之前先试免费的手段。剪枝优先,剪完重新测量,够了就不调模型。模型调用是最后一招,不是第一招。
最后一条要老实说:仓库确实拒绝了缩不小的摘要(README:「拒绝不能缩小源内容的摘要」),也确实会在重试后仍超阈值时抛异常——但这些检查的是体积,不是质量。「摘要写得对不对」在仓库里没有任何自动判据,这是真实的空白。要补上它,你得先回答一个更难的问题:一段摘要「保真」的可判定定义是什么。
真未解压缩之后,模型「忘了」的那部分,谁来发现?
仓库把体积这一侧做得很完整:拒绝缩不小的摘要、按 compactionRetries 重试、重试后仍超阈值就抛异常、replaceGeneration 作为「是否真的推进了」的可判定判据。这些全是可机器判定的量。
没有回答的是质量那一侧。摘要提示词要求模型输出八个固定小节(Primary Request and Intent、Files and Code、Errors and Fixes、Pending Jobs……,逐字见 compaction-basic README 的「模型体验」一节),并且明确要求「保留精确的文件路径、命令、错误字符串、标识符、数值、函数签名与语法片段」。但没有任何一层去检查它真的照做了。摘要模型漏掉一条用户的关键纠正、把一个文件路径写错一个字符、或者把「这个方案已经试过、失败了」这条信息丢掉——系统一无所知,只知道体积变小了、于是继续跑。而后果往往在很多步之后才显现:模型重新提出一个已经被否掉的方案,或者去改一个已经不存在的文件。
这确实是一个没有公认答案的问题,不是「读者还没查到」。它的难点在定义本身:一段摘要「保真」意味着什么?逐字覆盖率不行(摘要本来就该更短);用另一个模型去评判,等于用一个同样会出错的东西去检查第一个;跑一遍下游任务看结果对不对,成本比压缩本身高得多,而且不可复现。这是「如何评价一个有损压缩在语义层面的损失」,学术界和工业界都还没有能落地的标准答案。
怎么入手:先把可观测的那一半做出来——仓库已经把原文全留在日志里了,所以你可以写一个离线脚本,读一份真实会话日志,取出 compaction/summary 的 shadowedSeqs,把被遮蔽的原文和摘要并排导出。第一步只做确定性的检查:原文里出现过的文件路径、命令、错误码,有多少在摘要里失踪了?这类实体是可以精确匹配的,不需要判断语义。做完你会得到一个粗糙但真实的漏失率。然后才是难的部分:去读 packages/compaction/compaction-basic/src/summarizer.ts,看那个受保护的 summarize() 钩子——它是唯一的子类扩展点,所以「换一种摘要策略再比一比」是可做的实验。最后想清楚一个更根本的问题:这个检查应该发生在压缩当时(阻塞、昂贵、但能重试),还是事后(便宜、但错误已经发生)?仓库现在两者都没有做。
给你的 mini-harness 装上 token 估算、压力检测和一次 replace
到第 6 层为止,你的 deriveMessages() 是直接遍历日志的——那是「只有 append」的世界里才成立的简化。这一层要补三样东西:一个 token 估算器、一个压力检测、以及真正会折叠的 surface。
难点在三处,都不显然。
其一:为什么 surfaceNodes() 必须用 indexOf,不能用数值比较。你会很想写成 nodes.filter(s => s < start || s > end),看起来更简洁。第一次压缩它是对的——那时 surface 上的 seq 还单调递增。第二次就错了:上一次的摘要节点 seq 很大却坐在很前面的位置,start 会大于 end,数值过滤要么删空、要么删错一片。indexOf 找的是位置,这才是 replace 的真实语义。
其二:compaction/end 为什么写在 finally 里、而且在最后。写在 try 末尾,摘要抛异常时锁就永远不释放;写在开头(比如和 start 一起写)就根本不是锁了。放 finally 的最后一行,等于承诺「无论成败,这次尝试一定会闭合,而且闭合发生在所有实际工作之后」——崩在中间就留下一个有 start 没 end 的可检测痕迹。
其三:为什么 cut === 0 时要一个字都不写。「没有可压的范围」不是错误,是常态(比如全部内容都在保留尾部里)。这时候如果照样写一对 start/end,日志里就会堆满什么也没干的空压缩记录,而且每一对都要付一次刷盘。仓库的说法是「没有有效范围时返回 null 且不写入」——不做事的时候就真的什么都不做。
做对了的样子:跑一段长对话把压力顶过 THRESHOLD,在调用 compactIfNeeded() 前后各记一次三个数:log.length、deriveMessages().length、pressure()。判据是:
(1) log.length 必须恰好增加 4(三条记账事件 + 一条替换消息)——增加 3 说明你漏了 compaction/end 或把摘要直接塞进了记账事件;
(2) deriveMessages().length 必须变小,pressure() 也必须变小——没变小说明你的 replace 没生效(多半是 surfaceNodes() 还在用旧的「遍历日志」写法,压根没折叠);
(3) 被遮蔽的那些事件在 log 里必须一条不少地原样还在,用 JSON.stringify(log[shadowed[0]]) 比一比压缩前后,必须逐字符相等。
这两个方向相反才算做对:日志变长,投影变短。
再加一道专门验位置语义的:连续触发两次压缩,第二次跑完打印 shadowedRange——你应该能看到 start > end。然后故意把 surfaceNodes() 里的 indexOf 换成数值过滤再跑一遍,观察 deriveMessages() 怎么崩。亲眼看见它崩,比记住这条注释有用得多。
本章小结
长对话的代价是平方的:每一步都要重发全部历史。用仓库的真实默认值算一遍——1,000,000 的窗口、0.8 的阈值、每步 2000 token——你会在第 400 步撞墙,而到那时累计发送量是真实新内容的 200 倍。压缩不是省钱的优化,它是让长任务能存在的前提。
压力由 ctx.tokenMeter 估算,底层是一条写死的 CHARS_PER_TOKEN = 4。仓库自己承认它对 CJK 文本和 JSON schema「严重低估」,但设计上留了一道保险:只要请求信封没变,就拿提供方报回的真实用量当锚点,启发式只负责算增量。误差因此被限制在两处——没有可复用锚点时(首次请求、信封变更),以及增量本身很大时。
触发分两种:pressure(估算说快满了,挂在 agent/pre-step,在派生请求之前)和 context-overflow(提供方已经拒绝了,挂在 agent/request-error,可以更激进且不需要容量元数据)。恢复发生在失败步骤结束之后、失败轮次结束之前,且只有 replaceGeneration 真的前进了才重试——这是「进步」这个概念的可判定形式,也是防止无限重试循环的那道门。手段上先做免费的剪枝,重新测量,够了就不摘要。
摘要怎么做到不破坏第 6 章那条不变量?分两半:三条 compaction/* 事件只写日志,有意不扩展 SurfaceEventType;摘要本身骑在一条普通的 user/message 上,靠 surfaceOp: { op: 'replace', start, end } 完成唯一一次 surface 变更。于是日志变长四条、投影变短一段,原文一条不少地留在原处。锁的写法同样讲究:start 在前、end 在最后,所以中途崩溃留下的是「有 start 无 end」的可检测遗留锁,而不是一个虚假声称成功的 end——宁可犯会被发现的错。
最后两个细节值得带走:shadowedRange 是位置跨度不是数值区间,start 可能大于 end,要遍历请用 shadowedSeqs;spill 与压缩解决的是不同问题——它把单条巨型输出挪到别处,只给模型一个能主动取回的定位符,把「什么时候需要这段内容」的决定权还给模型。
下一章换一个方向:模型一次要求调五个工具,怎么让它们同时跑,又不让它们互相踩到。
第13章 并发:三个工具同时跑,谁先谁后
一句话导语:这一章回答「模型一次要五个工具,能不能一起跑」——能,但代价是要重新定义「顺序」这个词;而 dsh 的答案是让执行乱序、记录有序。
学完这一章你应该能做到
- 用自己的话解释什么是并发、什么是竞态、什么是死锁,并各举一个工具调用里的例子
- 说清
isConcurrencySafe为什么是「只能说是」的一元分类器,以及它表达不了什么 - 解释为什么调用在开始之前还要重新分类一次
- 说出「屏障」和「有界滚动池」各自解决什么问题,以及为什么不用固定窗口
- 区分完成顺序与记录顺序,并说明为什么后者必须等前者
- 说清用户按下停止之后,已开始、未开始的调用分别会在日志里留下什么
- 解释后台任务怎么脱离当前轮次,以及完成通知为什么要限次
tool/call 与 tool/result 的配对」;第7章的轮次与步骤;第9章的工具流水线七道关(tools/pre-execute、tools/execute、tools/post-execute)。13.1 五个工具,两种跑法
模型的一条回复里可以带好几个工具调用块。它可能一口气说:「读 a.ts、读 b.ts、读 c.ts、搜一下这个函数名、再抓一下那个网页。」五件事,互不相干。
最老实的写法是排队:读完 a 再读 b,读完 b 再读 c……每件事花 2 秒,五件事 10 秒。用户就坐在那儿看着。
但这五件事之间没有任何依赖关系。如果同时发出去,等最慢的那个回来就行——2 秒。
并发(concurrency):让多件事的执行时间重叠,而不是一件做完再做下一件。注意它不要求真的有多个 CPU 核心在同时算——工具调用大部分时间是在等(等磁盘、等网络、等子进程),等的时候把 CPU 让出来去发起下一件事,就已经重叠了。
打个比方
你要煮面、烧水、烤面包。串行是:站在锅前等面熟(8 分钟),然后去烧水(3 分钟),然后去烤面包(4 分钟),一共 15 分钟。并发是:下面、按下水壶、按下烤箱,然后坐着等——8 分钟全好。关键不在于你长了三只手,而在于这三件事的大部分时间都是设备在忙、你在等。
类比失效处:厨房里三个灶是真的物理并行;而单线程的 JavaScript 里,任何时刻真正在跑的代码只有一段,重叠的只是「等待」。所以工具本体如果是一段纯计算的死循环,把它标成并发安全一点用都没有——它不让出 CPU,其他调用一样得干等。
那为什么不全都并发
因为并不是所有五件事都互不相干。把「读 a.ts」换成「改 a.ts」,两个都改同一个文件的调用同时跑,会发生一件很难查的事。
竞态(race condition):两段代码同时访问同一份数据,最终结果取决于它们谁先谁后——而这个先后是不确定的,每次跑可能都不一样。
具体到工具上,最经典的形态叫读-改-写丢失更新:
调用甲:读 a.ts(得到 100 行)→ 在第 5 行插一句 → 写回(101 行) 调用乙:读 a.ts(得到 100 行)→ 在第 80 行插一句 → 写回(101 行)
如果甲乙串行,最后是 102 行,两处修改都在。如果它们并发,两边都在对方写回之前读到了原始的 100 行,后写回的那一个会把先写回的那一个整个覆盖掉——最终文件里只有一处修改,另一处无声无息地消失了。没有报错,没有异常,模型还以为两件事都干成了。
这类 bug 最难受的地方是它不稳定复现:磁盘快一点、网络慢一点,先后就变了。你在自己机器上跑一百遍都是对的,上线之后偶尔错一次。
死锁(deadlock):两件事互相等对方先完成,于是谁也不动了,永远卡住。比如甲拿着文件 X 等文件 Y,乙拿着文件 Y 等文件 X。它比竞态「好」的地方在于——它至少会卡住,而不是安静地给你一个错误答案。13.5 会讲仓库怎么故意用这一点来写测试。
下面四组工具调用,哪几组同时跑是安全的?说明理由。
(A) read('a.ts') 和 read('b.ts')
(B) read('a.ts') 和 read('a.ts')
(C) write('a.ts') 和 read('a.ts')
(D) write('a.ts') 和 write('b.ts')
变式:把 (C) 改成 write('a.ts') 和 read('b.ts'),安全吗?如果 b.ts 是一个指向 a.ts 的符号链接呢?(这个变式说明「资源身份」比「参数字符串」难判定得多。)
13.2 谁能一起跑:一个只能说「是」的分类器
dsh 的做法是让每个工具自己声明。ToolDefinition 上有一个可选方法(packages/core/tools/src/index.ts,逐字节选):
/** * Pure synchronous classifier for overlap with sibling tool calls. Only * `true` opts in; omission, exceptions, non-`true` returns, and invalid * `defineTool` arguments are exclusive. This metadata is never model-visible. */ isConcurrencySafe?(args: unknown): boolean
循环不直接读这个方法,而是问注册表要一个带标签的模式:
/**
* Scheduling mode for one pending call. `parallel` may overlap with siblings;
* `exclusive` runs alone and forms an ordering barrier.
*/
type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
四个设计决定,每一个都值得单独看。
其一:只有精确的 true 才算同意
源码是这么写的:
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
} catch {
return { kind: 'exclusive' }
}
}
没声明分类器 → 独占。工具查不到(未知名字、被作用域隐藏)→ 独占。分类器返回了 1、'yes'、任何 truthy 但不是 true 的东西 → 独占。分类器抛异常 → 独占。
失败时关闭(fail closed):出任何差错时,退到最保守的那个选项。反义词是「失败时打开」(fail open)——出错就放行。安全相关的判断几乎都该 fail closed。
这里的取舍很直白:判错成独占,代价是慢一点;判错成并行,代价是数据被悄悄改坏。
其二:分类器是一元的,只看自己
它只拿到这一次调用的参数,看不到兄弟调用。Agent Note 把这条限制说得很清楚(.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md):
true 是这个工具的承诺:这次调用可以和任何一个同样返回 true 的兄弟调用重叠;调度器不会比较两个调用,也不会去证明它们访问的资源互相兼容。所以 13.1 的 (D)——两个写不同文件——表达不了。Note 原话:「接口无法表达『只有当路径不同时这些写才安全』这类关系型规则,因此一个安全性依赖于兄弟调用的调用保持独占。」
但一元不等于死板:分类器看得到参数,所以同一个工具可以「只读操作判并行、改动操作判独占」。这是「一元」和「工具级布尔开关」的区别,Note 里专门把后者列为被否掉的方案。
其三:这份元数据绝不给模型看
注释里那句 This metadata is never model-visible 呼应第 8 章讲过的白名单投影:schemas() 只搬 name / description / parameters 三项,isConcurrencySafe、timeoutMs、execute 全都留在宿主这边。Note 给的理由是:模型本来就能一次发好几个调用,把调度元数据告诉它只会撑大请求,并不会让它选得更好。并发是宿主的调度问题,不是模型的知识。
其四:返回带标签的对象,而不是一个布尔值
{ kind: 'parallel' } 比 true 多写好几个字符,图什么?Note 的解释是:带标签的模式让「资源感知的变体」以后还能被表达,而不必改动分类器约定。今天只有两个取值,明天如果要加一个 { kind: 'parallel', resources: [...] },调用方那个 switch 不用推倒重来。这是第 4 章讲过的那种「给未来留缝,但现在不实现」的口味。
仓库里实际有谁开了口
并行(isConcurrencySafe: () => true) | 独占(没有声明分类器) |
|---|---|
文件读 read、读图 read_imageweb_search、web_fetch会话查询的 trace / read 三个工具 子 agent 委派(第 14 章) | 文件写与编辑 bash 系工具 会话查询的 search 工具 工作流、用户交互、待办修改 Code Mode、Cordis 自我修改工具 |
Note 自己评价这份清单是「保守的」(conservative)。有两条理由特别值得记:bash 保持独占,是因为「没有已证明的输入敏感分类器」——你没法从一条 shell 命令字符串里可靠地判断它会碰什么;文件读能开口,靠的是一个很窄的例外:它的同步观测更新可能乱序结算,但写和编辑在改动前会重新核对观测到的版本,所以陈旧状态只会产生一个 FS_STALE_VERSION 错误,而不是一次静默的丢失更新。并发安全不是工具单方面声明就成立的,它取决于整条链上有没有一道兜底检查。
你要给一个新工具 grep(pattern, path) 写分类器。它只读不写。下面三种写法,哪种是对的?为什么另外两种有问题?
(A) isConcurrencySafe: (args) => !args.path.startsWith('/tmp')
(B) isConcurrencySafe: () => true
(C) isConcurrencySafe: (args) => fs.existsSync(args.path)
args.path 一定存在吗?如果模型给的参数里没有 path,这句会怎样?再想:这个判断和「并发安全」有关系吗?read 和 web_search 的写法。(A) 的问题有两层:第一,判断依据和并发安全无关——路径在不在 /tmp 不影响两个 grep 能不能同时跑;第二,如果 args.path 是 undefined,.startsWith 会抛 TypeError,而抛异常的分类器被判为独占——于是你写了个分类器,效果却是随机地退回串行,还查不出原因(好在用 defineTool 时它会先校验参数再调分类器,无效参数直接判独占)。(C) 违反「纯」:它做了同步文件 I/O。Note 否掉这条路的理由是「让分类器拿到实时的 agent、注册表或 I/O 访问,会让决定取决于它什么时候运行,并在分类与分发之间制造一道缝」——分类完到真正开始跑之间,文件可能已经被删了。可变的授权和陈旧状态检查属于执行期,不属于分类期。顺带一提,(C) 还会让调度慢下来:分类在每次补池之前都要重跑。变式:如果这个工具改成 grep(pattern, path, write_results_to)——可以把结果写进一个文件——分类器该怎么写?(提示:这时候「一元且看得到参数」这条特性终于有用了。)
13.3 为什么开始之前还要再分类一次
时序图里那一行写着(docs/agent-lifecycle.zh.md):
loop barriers and bounded rolling pool, reclassify before start
调用不是一次分类完就定了。循环在每次往池子里补人之前,都会把下一个调用重新分类一遍。源码里那两行带注释:
// Re-read later modes after ordered commits so registry changes can create a barrier. const nextCall = group[nextToStart]! if (nextToStart > 0 && mode === 'parallel' && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
(packages/core/agent-loop/src/tool-calls.ts。)为什么要这么麻烦?
因为前面的调用可能已经改变了世界
这个 harness 的一个核心特性是工具可以在运行中被装卸(第 3 章的 fiber、第 8 章的热重载、以及自我修改那套 Cordis 工具)。所以:模型这一步发了五个调用,第一个调用卸掉了一个插件,而第三个调用正好用的是那个插件里的工具。
如果按最初的分类表跑下去,第三个调用会以「并行」的身份进池子——然后在执行时才发现工具已经没了。而重新分类会立刻发现它现在是「未知工具」,按 fail-closed 判为独占,于是当前池子先排空,它变成下一道屏障,在一个干净的时刻单独失败。失败得更早、更整齐。
Note 把这件事总结成一句很好记的话:工具注册是一个调度边界。注册表的变化会影响还没开始的调用;已经开始的调用则保留它进池子时的那个决定,不会中途被改判。
这里还藏着一个很讲究的顺序。源码在最外层循环上写着 // Commit before classifying again so registry changes affect unstarted calls.——先提交结果,再分类下一个。为什么?因为「注册表变了」这件事往往正是前一个调用的结果造成的。如果先分类再提交,你分类时看到的还是旧世界。
请构造一个具体场景:模型在一条回复里发出三个调用,如果没有「开始前重新分类」这一步,结果会出错;有了它就正确。要求写清三个调用分别是什么、错在哪一步、错成什么样。
调用①:
read('config.yml')(并行)调用②:
read('main.ts')(并行)调用③:
search(...),此时注册的是一个只读的、声明了并行的实现。现在让调用①之外的某个东西——比如上一步刚挂载的一个插件在初始化完成后——用一个会写缓存文件的新实现替换了
search,新实现没有声明分类器(独占)。没有重新分类:③按最初的「并行」进池子,和还在跑的①②重叠。新的
search 会写缓存文件,而①②可能正在读同一个目录——竞态成立,而且这次竞态是调度器亲手造成的,因为它用一份过期的分类结果做了决定。有重新分类:补池之前重新问一次
executionMode,拿到 exclusive,于是 break 出补池循环——当前池子排空,③成为下一道屏障,单独跑。正确。更简单也更常见的一个构造:调用①是一个卸载插件的工具,③用的工具正好在那个插件里。没有重新分类,③会以「并行」身份进池子然后在执行时报「未知工具」;有重新分类,它在开始前就被判为独占(
resolveExecution 查不到工具 → fail closed),当作屏障单独失败。两种构造的共同点是:分类的依据是可变的,而分类和执行之间隔着时间。变式:为什么已经开始的调用不重新分类?(提示:想想「重新分类成独占」对一个正在跑的调用意味着什么——你打算怎么把它塞回去?)
13.4 屏障:某些调用必须等前面全跑完
屏障(barrier):一个所有前面的工作都必须完成、之后的工作才能开始的时刻。想象一条队伍走到一道门前,全部到齐才一起过。
循环的分组算法只有三步(executeToolCalls):从当前位置看第一个调用的模式;如果是 parallel,就把从这里开始的所有后续调用当作一组候选;如果是 exclusive,这一组就只有它自己。然后跑完这一组,指针往后挪,再来一遍。
Agent Note 里那个例子最清楚:
[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)] → [read(A), read(B)] → [write(A)] → [read(C)]
read(A) 和 read(B) 可以重叠。write(A) 要等它们都结束才开始——不然可能有人读到写了一半的文件。read(C) 要等写完才开始——不然可能读到旧内容。
所以「独占」这个词其实承担了两件事:它自己独自运行,以及它是一道排序屏障。第二件事更重要——一个工具声明独占,不只是说「别人别和我一起跑」,还是说「在我之前发出的都得先做完,在我之后发出的都得等我」。
模型在一步里发出四个调用:
① read('a.ts') ② subagent('把 a.ts 改成异步的') ③ subagent('给 b.ts 写测试') ④ read('a.ts')
按表 13-1,read 和子 agent 委派都声明了并发安全;并发上限是 10。请回答三问:(a) 会分成几组?(b) ④ 读到的是改之前还是改之后的 a.ts?(c) 如果把 ② 换成 write('a.ts'),(a) 和 (b) 的答案各有什么变化?
a.ts 这件事?runGroup 里那句「补池之前重新分类,不是并行就 break」——它不会跳过独占调用去启动后面的,而是让当前这一组提前收尾。所以 ① 会和谁一组?isConcurrencySafe 是一元的,read 说「我可以和任何同样说 true 的兄弟重叠」,子 agent 也这么说,于是它们就重叠了;没有任何一层注意到三个调用的参数里都写着 a.ts。仓库对这种情况的立场是明文的:协调兄弟之间的工作区影响是模型的责任。日志里也看不出发生过竞态——两个调用都会显示成功。(c) 变成三组:
[①] | [write(a.ts)] | [③, ④]。这里有个容易漏的细节:① 被迫单独跑了。分组时循环看到 ① 是并行,于是把「从这里到末尾」全都当作候选组;但补池启动 ① 之后,重新分类下一个发现是独占,就 break 出补池循环——这一组只消耗了 1 个,① 独自跑完,写操作成为下一道屏障。屏障不只是让独占调用等前面的,它也让前面那一组提前收尾。(b) 的答案则部分变确定:④ 一定读到
write 之后的内容,因为屏障保证 write 完全结束后才开始下一组。但只是部分——④ 和 ③ 仍然并发,如果 ③ 那个子 agent 也去动 a.ts,不确定性又回来了。这道题的一般教训是:屏障能保证的是「独占调用和它的邻居不重叠」,保证不了「碰同一个资源的调用不重叠」——后者根本不在这套机制的表达能力之内。变式:把四个调用的顺序改成 ① read ④ read ② subagent ③ subagent(两个 read 挨在一起),(a) 的答案变吗?再问:如果模型知道这四件事会互相干扰,它能做什么来避免?(提示:它唯一的工具是「分几条消息发」。)
13.5 有界滚动池:既不是全并发,也不是一个一个来
假设模型一口气发了 50 个 web_fetch,全都是并行的。要不要 50 个一起发?
不要。理由很实在:提供方有配额限制,操作系统的文件句柄有上限,一台机器能同时开的连接和子进程也有限。50 个同时打出去,多半是一半失败、一半超时,比排队还慢。
有界滚动池(bounded rolling pool):维持一个固定大小的「在跑集合」,跑完一个就立刻补一个进来,直到没有待跑的为止。上限由 maxParallelToolCalls 决定,默认 10(packages/core/agent-loop/src/constants.ts 的 DEFAULT_MAX_PARALLEL_TOOL_CALLS),设成 1 就退回完全串行。
「滚动」两个字是关键。它的替代方案叫固定窗口:把 50 个调用切成 5 批,每批 10 个,等一批全部结束再开下一批。Note 把这条路明确否掉了,理由一句话:
图 13-1 里就能看到差别:B 在 1 秒时结束,C 立刻补进来。如果是固定窗口,C 要等到 A 也结束(3 秒)才能开始,整条时间线会拖长两秒——而池子里那半格容量白白空了两秒。
实现上就是一个 Promise.race:
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values()) // 谁先完成谁先出来
inFlight.delete(settledIndex)
await commitReady() // 记录仍按模型顺序
await fillPool() // 空出一个就补一个
}
Promise.race 的意思是「这一堆里谁先结算就返回谁」。每次有人结算,就把它移出在跑集合,然后 fillPool() 再补——上限自然被守住。
上限管的到底是什么
这一点特别容易搞混,Note 说得很精确:maxParallelToolCalls 限的是一个 agent 步骤里未结算的工具调用数。它不限制:
- 后台任务留下的活——一个
run_in_background: true的调用在启动时就结算了、马上让出池子里的位置,而它启动的那个进程还在跑(13.8 会讲)。 - 提供方那边的并发——「LLM 提供方拥有自己的容量控制」。
- 其他 agent——它是每个步骤的上限,不是全局的。
另外,Code Mode(模型只发一个 run_code 调用,程序内部再派发子调用)有它自己的上限 maxParallelSubCalls,默认也是 10,注释里说这是「循环调度器自己的默认值」——两处保持一致不是巧合,是有意对齐。
一个用死锁写测试的漂亮例子
13.1 说过,死锁虽然是灾难,但它至少会卡住。仓库把这一点变成了测试手段。子 agent 委派声明了并发安全,怎么证明这条声明底下的整条链路真的容忍并发?
做法是(.agents/notes/.../2026-08-09-parallel-subagent-delegations.md):起两个子 agent,让每一个都阻塞,直到两个都已经启动。如果这条链上任何一层偷偷把它们串行化了,第一个会永远等第二个、而第二个永远开不了——测试超时卡死。
妙在这个「卡死」是被设计出来的失败信号。如果换成一个普通的断言,隐藏的串行化会安静地通过(结果还是对的,只是慢)。Note 的原话是:这条链里「隐藏的串行化会死锁,而不是静默地通过」。把一个只在错误时才发生的死锁,当作错误的检测器。
部署配了 maxParallelToolCalls: 3。模型在一步里发出 8 个调用:5 个 web_fetch(并行)、1 个 write(独占)、2 个 read(并行)。请说明会分成几组、每组怎么跑,以及任意时刻最多有几个工具在跑。
write 把序列切成了几段?[P P P P P | X | P P],竖线是屏障。数一数几组。web_fetch,用上限 3 的滚动池跑:先开 3 个,谁结算就补一个,直到 5 个都跑完;任意时刻最多 3 个在跑。第二组是那个 write,独占——它要等 5 个 fetch全部结束才开始,自己跑,池子等效于 1。第三组是 2 个 read,等 write 结束后一起跑(2 < 3,所以两个同时开)。全程最多 3 个在跑。两个容易错的地方:其一,第一组的 5 个不会被切成「3 + 2 两批」,滚动池是补位式的,第 4 个在第一个结算的那一刻就进场,不等另外两个。其二,那 2 个
read 不会和 fetch 合并成一组——它们在序列里被 write 隔开了,而分组要求连续。就算把 write 换成一个不写任何东西的独占工具,隔断依然成立:屏障管的是位置,不是内容。变式:如果那 5 个 web_fetch 里第 3 个在跑到一半时,有人把 web_fetch 这个工具从注册表里卸载了,会发生什么?(提示:分两问——已经开始的第 3 个,和还没开始的第 4、5 个。)
13.6 完成顺序 ≠ 记录顺序
现在到了这一章最值得讲的一个取舍。
图 13-1 里,B 在 1.0 秒完成、C 在 2.5 秒完成、A 在 3.0 秒完成。但写进日志的 tool/result 顺序是 A、B、C——而且三条都是在 3.0 秒那一刻一起写进去的。B 的结果在内存里干等了 2 秒。
时序图里那一行就是这件事(docs/agent-lifecycle.zh.md):
opt next model-order result ready Driver->>Tools: ordered post Driver->>Session: tool/result
实现叫提交游标:一个只会向前走的下标,只有当它指着的那个格子被填上了,它才往前挪一格并记录一条结果。源码里 committed 那个变量的注释写着 committed advances only across contiguous model-order slots——只跨越连续的模型顺序格子。
const commitReady = async (): Promise<void> => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break // 前面的还没好,后面再快也得等
// …跑有序的 post-execute,然后 appendToolResult(…)
committed++
}
}
为什么值得付这个代价
第一,模型看到的顺序必须是它请求的顺序。模型发出的是 [read a, read b, read c],它心里的映射是「第一个结果对应 a」。如果结果按完成顺序排成 [b, a, c],模型要靠 callId 去对号入座——它能做,但每次都要多花一点注意力,而且不同模型的可靠程度不一样。更糟的是:同样的输入会产生不同的历史,因为完成顺序取决于磁盘和网络的当时状态。
第二,同样的输入必须产生同样的日志。这一条更硬。第 6 章讲过 fork、回放、快照测试全都建立在「日志可复现」上。如果结果按完成顺序写,同一个任务跑两遍会得到两份不同的日志——快照测试立刻失效,两个 fork 分支的共享前缀也不再逐字节相同。Note 里那个 subagent-parallel 快照测试的断言正是这个:父会话的日志必须是 tool/call, tool/call, tool/result, tool/result(串行执行会交错成 call/result、call/result)。
第三,有序的策略中间件需要它。Note 说得明确:只有分发和工具本体会重叠,tools/pre-execute 和 tools/post-execute 都按模型顺序跑,因为中间件可能持有依赖顺序的状态。所以 tools/execute 的环绕包装层必须是可重入的(同时被多个不同的执行调用),而 pre / post 不需要——它们天然被序列化了。
第四,用户界面并没有被拖慢。Note 承认了这个代价(「有序提交可能把一个快的结果压在慢的兄弟后面」),紧接着补了一句:「实时界面仍然显示每个调用自己的进度」。也就是说,B 完成了、卡片上就是完成状态,用户看得见;被延后的只有写进日志这一刻。
读的时候要小心:有一处竞态还是漏到了模型面前
「结果按模型顺序」并不等于「一切都确定」。子 agent 委派的 Agent Note 里承认了一个例外(逐字):「一条消息里的两个一次性后台委派,会按分发竞争顺序取得它们模型可见的任务 id(subagent-<n>)。」
也就是说:两个后台子 agent 同时启动,谁抢到 subagent-1、谁拿到 subagent-2,取决于哪个先跑到注册那一行。这个 id 是模型可见的(模型后面要用它去 job_output),所以竞态的结果泄露到了模型面前。
Note 的立场是:id 会被记进日志,所以回放仍然有效(回放读的是记下来的那个 id,不重新竞争);但一个需要区分两个后台子 agent 的快照场景,会继承同样的不确定性——所以现在这类场景只能用「可互换的双胞胎委派」来写。本站评价:这是一处诚实标注的、被接受的不确定性,不是 bug;但它说明「模型顺序」这条保证覆盖的是结果的排列,不是执行过程中分配的每一样东西。
有人提议做一个「快路径」优化:结果一完成就立刻写 tool/result(按完成顺序),但在事件里加一个 modelOrder: number 字段,投影成模型历史时按这个字段排序。这样既省了等待,模型看到的又还是原顺序。请指出这个方案破坏了哪两条已经讲过的性质,并说明其中一条为什么连测试都测不出来。
seq 分配给了不同的事件。现在去看 tool/result 的 sourceEventSeqs——它指向对应的 tool/call,这个指针在两次运行里数值不同。再想想 tools/post-execute。seq 分配不同的两份日志——快照测试(仓库要求每个模型可见的行为变更都配一份可回放的快照)会随机失败;两个 fork 分支的共享前缀不再逐字节相同;「这份日志和那份日志是不是同一次运行」这个问题变得难以回答。加一个 modelOrder 字段救不了这一条,因为它修的是投影,而破坏发生在日志这一层——第 6 章讲过,投影可以有很多种,但真源只有一份。其二,有序的
tools/post-execute。这一条才是「测不出来」的那条。post-execute 是 waterfall,中间件可以持有依赖顺序的状态(限额累计、审计序列、内容改写)。按完成顺序提交,等于让 post 中间件按不确定的顺序看到结果。它大部分时候仍然给出对的答案——因为大多数中间件的操作恰好可交换(加法、集合插入)。于是测试全绿。直到某天有人写了一个不可交换的中间件(比如「前 3 个结果免费、之后的计费」),它开始随机地给不同调用记账——而且因为竞态不稳定复现,你在本地跑一百遍都对。一个只在特定中间件组合下才暴露、且不稳定复现的错误,是最贵的那一类。顺带一提,这个提议还有第三个问题:它默认「投影时排序」是免费的,但投影必须是纯函数且逐字对应日志(第 6 章那条断言逐字符比较
deriveMessages())。给投影加一个排序步骤,等于让「模型看到什么」多依赖了一个字段的正确性——而那个字段由并发路径写入,恰恰是最不该被信任的地方。变式:如果把范围缩小到「只有当这一组里全部是纯读工具时才走快路径」,上面两条还成立吗?(提示:先问「纯读」这件事是谁声明的、由谁验证。)
13.7 取消:用户中途按下停止
三个工具正在跑,用户按了停止键。已经跑起来的那些怎么办?还没开始的那些怎么办?
规则有三条,源码模块顶部的注释就是它的摘要(packages/core/agent-loop/src/tool-calls.ts):
第一,停止补人,但不抛弃已经开始的。取消信号一到,fillPool() 就不再往池子里补新调用;已经在跑的会被排空(drain)——等它们自己结算,然后按模型顺序提交结果。为什么不直接扔掉?因为它们可能已经产生了真实的副作用(文件已经写了、命令已经跑了)。已经发生的事必须被记下来,这是第 6 章那条「宁可记了没做,也不能做了没记」的延伸。
第二,还没开始的调用,补一对合成的记录。这是很多人猜错的一点。源码里:
/** Append the durable call/result pair for a model call skipped after cancellation. */
function appendSkippedToolCall(session, turn, step, block) {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
}, callSeq)
}
模块注释给的理由只有半句:so replay stays valid——为了让回放仍然有效。展开说就是:模型的那条 assistant 消息里躺着五个 tool-call 块。按协议,每一个都必须有一个对应的结果,否则下一次请求的消息序列是残缺的,多数提供方会直接拒绝。所以「没跑」这件事也得表达成一个结果——一个 isError 的结果,错误码 ABORTED_BEFORE_DISPATCH。
读的时候要小心:这里文档和源码对不上
并行调用的 Agent Note(2026-07-10)里有两句话说的是相反的行为:「一次在组开始之前的取消,不会记录这一组的任何调用」和「从未开始的调用没有审计事件」。
但 packages/core/agent-loop/src/tool-calls.ts 的当前代码会为跳过的调用补一对 tool/call + tool/result(就是上面那段 appendSkippedToolCall,在 runGroup 的取消分支和 executeToolCalls 的外层各调用一次)。模块顶部的 JSDoc 也明确写着 Abort records synthetic error results for skipped calls so replay stays valid。
本站判断:Agent Note 是一份带日期的设计记录,代码在它之后演进了,而 Note 按仓库自己的归档政策是冻结的(「归档笔记是冻结的:绝不编辑,也不要把它们当作现行权威」)。所以这里以源码为准。但仓库没有任何地方说明这次行为变更发生在哪个改动里、为什么改——这是本站在核对时发现的一处文档与源码的漂移,写给你的时候仍然存疑。你自己去查时,先信 src/,再信当前的 docs/,最后才是 .agents/notes/。
第三,调度器自己出故障时,不伪造任何结果。这一条区分得很细。如果是工具失败(抛异常、超时、被拒绝),那是一个正常的工具结果,isError: true,走完整条流水线。但如果是调度器内部出了故障(比如 prepare 阶段炸了),源码的做法是:停止发起新的分发、等已经在飞的全部结算、然后把第一个故障原样抛出去——不为任何调用编造结果。
Note 解释了理由:AgentLoop 无法判断一次被拒绝的分发到底有没有真的调用过工具本体——那是 ToolRuntime 的内部状态。既然判断不了,就不猜。「内部调度器故障因此保持终止性,而不是被重新分类成 ABORTED 结果。」代价是:日志里可能留下有 tool/call 却没有 tool/result 的调用,然后那个步骤以失败关闭。宁可留下一个明显残缺的记录,也不写一条可能是假的记录。
给你一份日志,某个步骤里有 4 个 tool/call。请说明下面三种情况各自会在日志里留下什么,以及你怎么只看日志把它们区分开。
(A) 第 2 个工具自己抛了异常。
(B) 用户在第 2 个工具跑到一半时按了停止。
(C) 调度器在准备第 3 个调用时内部故障。
tool/call 都配上一个 tool/result?哪些不会?isError: true,错误信息是工具自己的(比如 ENOENT、超时、守卫拒绝)。其余三个正常。(B) 4 个 call、4 个 result。已经开始的(第 1、2 个,可能还有第 3 个,取决于池子上限)拿到真实结果——包括第 2 个,它被排空、等它自己结算;从未开始的那些拿到合成结果:isError: true,文本是 Error: tool call aborted before dispatch,错误码 ABORTED_BEFORE_DISPATCH。(C) call 和 result 数量不等——已经开始的会被排空并提交,第 3 个及以后的连结果都没有,步骤以失败关闭。只看日志怎么区分:第一刀切数量(不等 → (C));第二刀切错误码(有
ABORTED_BEFORE_DISPATCH → (B));剩下的是 (A)。为什么这套区分做得到:因为三种情况留下的痕迹被刻意设计成不一样。如果调度器故障时也伪造结果,(C) 就会和 (A) 长得一模一样,而这两件事的处置完全不同——(A) 是模型该看到并绕开的正常失败,(C) 是需要人来查的系统故障。把「我不知道发生了什么」诚实地表达出来,比编一个看起来完整的记录更有用。
变式:(B) 里,如果第 2 个工具永远不结算(写了个不响应取消信号的死循环),会怎样?(提示:注释里说注册表「不会抛弃这个 promise,但它无法硬杀同进程的代码」。)
13.8 跑得久的活:让它脱离当前轮次
还有一类活,快不起来,也等不起:起一个开发服务器、跑一整套测试、编译一个大项目。它可能跑十分钟。如果它占着池子里一个位置,这个步骤十分钟内什么都做不了。
解法是把它踢出当前轮次:调用在启动时就结算——立刻让出池子里的位置、立刻记下 tool/result(内容是「后台任务 bash-3 已启动」)——而真正的工作在后面继续跑。
这套机制叫后台任务(jobs,ctx.jobs,docs/subsystems/jobs.zh.md)。任务 id 形如 <kind>-N,目前的 kind 有两种:bash 和 subagent。模型用三个与 kind 无关的工具去管它们(packages/jobs/tool-jobs/):
| 工具 | 做什么 |
|---|---|
job_output(job_id, wait?, timeout_ms?) | 默认非阻塞读。流式任务只返回上次读之后的新增量(每个任务只有一个消费游标);最终输出型任务在终止后返回结果。每个响应都以 [status: ...] 结尾。wait: true 最多等到配置上限,超时也让任务继续活着 |
job_list() | 列出调用方可见的任务,格式 <id> [<kind>] <status> — <label> |
job_kill(job_id, reason?) | 立即请求取消并转发原因;对已终止的任务返回一份非消费式快照 |
生产方(bash 执行器、子 agent 提供方)实现三个钩子。其中一个的约定特别值得看:
/** * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. */ done: Promise<JobOutcome>
「在生产方释放资源之后 resolve,而不仅仅是工作完成时」——这半句是整个设计的重量所在。一个子进程的「工作做完了」和「它的进程真的没了、文件句柄真的还了」是两个时刻。如果 done 在前一个时刻就 resolve,上层会以为可以清理了,结果去删一个还被占用的目录、或者在进程还活着的时候就宣布关停完成。第 3 章讲释放函数时讲过的「静默(quiescence)」,在这儿是同一条要求。
完成通知:由哪条通道送,取决于所有者在干什么
任务跑完了,怎么告诉模型?tool-jobs 的 README 把这件事讲得很细,而它的设计正好把第 7 章的 inbox 用上了:
- 所有者正忙(还在跑轮次)→ 走注入:通知进 next-step inbox。而 inbox 还有内容时轮次不能结束,所以同时结算的好几个任务只花掉一步,不是各占一轮。
- 所有者空闲 → 走唤醒:像一条 follow-up 那样开启一个新轮次。README 的理由很实在:「无人领取的待发通知等于模型永远不会知道的完成」。
- 配置
completionDelivery: quiet可以让空闲所有者也留在注入通道上——「确定性 transcript 需要的正是这一点」(也就是快照测试)。
为什么唤醒必须限次
唤醒有个上限 maxConsecutiveWakes,默认 3;超过之后通知降级为注入。README 给的理由是一句极精炼的话:这条链会自激。
展开看:一次被唤醒的轮次里,模型可能又启动了一个后台任务;那个任务完成时又唤醒同一个所有者;新轮次里再启动一个……一个不需要任何人输入就能永远转下去的循环,而且每一圈都在花钱调模型。设界是唯一的出路。
更精细的一条:领取任何用户撰写的消息都会恢复这个预算,而「本插件自己排队的通知永远不会补充它刚花掉的预算」。也就是说,只有真人的介入才能续命——机器自己产生的事件不算。这是一条很值得抄走的规则:给自激循环设界时,重置条件必须来自系统外部,否则界就是假的。
maxParallelToolCalls: 2。模型在一步里发出 4 个调用:3 个 bash(run_in_background: true) 和 1 个 read。请回答:这一步大约要花多久?跑完这一步之后,机器上还有几个进程在跑?
read 单独跑。真正耗时的工作全都留在步骤之外。跑完之后机器上还有 3 个进程在跑。这道题的要点是:
maxParallelToolCalls: 2 完全没有限制住「同时有 3 个进程在跑」。Note 逐字写着上限只覆盖「一个步骤里未结算的工具调用」,「后台和可续的调用在启动时就结算并释放它们的池位,因此它们留下继续运行的子进程不受该上限约束」。想控制真正的并发度,得去看生产方自己的容量控制,或者干脆别用后台形式。补一条容易忽略的:三个后台任务如果几乎同时结束,它们的完成通知会走注入通道进同一个 next-step inbox,模型在一步里就把三条通知一起读到——这正是 13.8 里「同时结算的多个任务只花掉一步」的设计目的。
变式:如果这 3 个 bash 不加 run_in_background,同样的 4 个调用要花多久?(提示:bash 是独占的,先数屏障。)
答辩:如果我是审稿人
你们做了一整套滚动池来省时间,然后又用「有序提交」把省下来的时间还回去了一部分——图 13-1 里 B 在 1 秒就完成了,它的结果却要在内存里干等 2 秒。说到底,这不就是为了让 transcript 看起来整齐,让用户多等吗?工程上应该服务于更快,不是服务于更好看。
参考防守(先自己组织语言再看)
先把事实摆清楚,再回答。
第一,用户并没有多等。被延后的只是「写进会话日志」这一刻,不是整个步骤。步骤要等到最慢的那个调用结束才能进入下一轮请求——图 13-1 里那是 3.0 秒的 A。B 的结果不论 1 秒记还是 3 秒记,下一次模型请求都在 3 秒之后发出。关键路径上一毫秒都没多花。而实时界面照旧显示 B 已完成——Note 明说「实时界面仍然显示每个调用自己的进度」。所以「用户多等」这个前提不成立。
第二,它买到的不是美观,是可复现。这才是核心。按完成顺序写日志,同一个任务跑两遍会得到两份 seq 分配不同的日志。于是:快照测试随机失败(而仓库要求每个模型可见的行为变更都配一份可回放的快照);两个 fork 分支的共享前缀不再逐字节相同(第 6 章 fork 的整个前提);「模型当时看到了什么」这个问题的答案变得依赖当天的磁盘速度。把一个确定性系统换成非确定性系统,换来的是零延迟收益。
第三,它还是有序中间件的前提。tools/pre-execute 和 tools/post-execute 里可能挂着依赖顺序的状态(限额、审计序号、内容改写)。乱序提交会让它们按不确定的顺序看到结果,产生的是最难查的那种 bug:偶发、不可复现、且大多数时候恰好是对的(见 q13-5)。
第四,代价被精确地限制住了。不是「所有并发都被序列化」,只有提交被序列化,而提交是纯本地操作(跑 post 中间件、追加一条事件),耗时和工具本体不在一个量级。真正重叠的部分——网络等待、磁盘等待、子进程运行——一点没受影响。
最后,什么情况下这个批评会成立?如果有一天「结果一旦产生就要立刻流给用户看」变成硬需求(比如一个工具的输出要边跑边显示),有序提交确实会挡路。但那时候正确的做法也不是打乱日志顺序,而是把「实时流」和「持久记录」拆成两条通道——实际上仓库已经这么做了:agent/* 是实时协调接口,session/event 才是可回放的记录(docs/agent-lifecycle.zh.md 最后那段专门讲了这个分工)。
真未解并发的正确性被外包给了模型,这行得通吗?
第 9 章已经指出过:isConcurrencySafe 是工具的自我声明,没有任何一层校验它。这一节要往前推一步,因为仓库在最新的一次改动里把这个立场推到了明面上。
dsh-tool-subagent 现在对每一种委派形式都声明 isConcurrencySafe: () => true。它的结构性理由是成立的:子 agent 在自己的会话里工作,一次运行不会改动父会话(启动时那几条 append 只落在孩子自己的日志里)。但子 agent 会真的去改文件系统——两个兄弟子 agent 完全可能同时编辑同一个文件。Agent Note 对这一点的回答是一句直白的话(逐字):
「后果」一节里再说了一遍:「兄弟子进程可能在共享工作区或外部资源上竞争;模型拥有这项协调。」
这是一个真未解的问题,而且它比「谁来校验声明」更根本。理由有三层:
第一,一元分类器在原理上表达不了关系型安全。Note 自己把「资源感知的分类」列为被否掉的方案,理由是它「需要在互不相关的工具之间建立共享的资源身份与冲突语义」。这不是懒得做——「什么算同一个资源」本身就没有通用答案:两个路径可能通过符号链接指向同一个文件,两条 bash 命令可能通过环境变量影响彼此,一个 HTTP 请求可能和一次文件写通过服务端状态耦合。
第二,把它交给模型,意味着正确性依赖于模型的规划质量——而模型的规划质量是一个概率量,不是一条保证。Note 引了同行的做法来支撑这个立场(Claude Code 的 Task 工具无条件并发安全、opencode 无上限并发……),但「大家都这么做」是先例,不是论证。
第三,最难的是这类失败几乎无法归因。两个子 agent 并发改坏了一个文件,日志里看到的是两次都「成功」的委派——竞态没有留下任何痕迹,因为丢失更新不产生错误。你只会在很久之后发现代码不对,而那时已经没有证据指向那一刻。
怎么入手:先把问题变成可观测的。写两个都声明并发安全的工具,让它们对同一个文件做「读-改-写」,在同一个模型步骤里一起发出,重复跑一百次,统计丢更新的比例——先拿到一个真实数字,别停留在「理论上会出问题」。然后去读 packages/fs/tool-fs/src/ 里读和写的那条例外链路:读是并发安全的,而写和编辑在改动前会重新核对观测到的版本,失败就报 FS_STALE_VERSION。把这条链路抠清楚,你会得到一个重要的观察——这里真正提供保护的不是分类器,是资源那一层的版本检查。于是真正的开放问题浮出来了:能不能把这个思路推广?也就是说,与其让每个工具声明「我安全」,不如让每种资源提供一个冲突检测(谁碰同一个资源谁排队或谁失败重试)。难点是覆盖面:ctx.fs 有版本概念,ctx.shell 没有,网络请求更没有。所以最后你要回答的是一个设计问题:一个没有全局资源身份的系统里,「安全并发」这件事到底能不能被机器判定?如果不能,把它交给模型是不是唯一剩下的选项——还是说,至少应该给它一份「你正在并发做这几件事」的显式告知?(现在没有:并发元数据对模型完全不可见。)
给你的 mini-harness 装上有界滚动池、屏障和按模型顺序记录
到上一层为止,你的循环看到几个工具调用块就 for 一遍挨个 await。这一层把它换成调度器。核心只有三样东西:一个按模型顺序编号的格子数组、一个只会向前走的提交游标、一个补位式的在跑集合。
难点在三处,都不显然。
其一:slots 为什么按模型顺序编号,而不是按完成顺序 push。你会很想写成 results.push(r)——短得多。但那样 results 的顺序就是完成顺序,你再也拿不回模型顺序了(callId 能对上,但 tool/result 已经按错的顺序写进日志了,而日志是不可修改的)。先用下标占好位、后填内容,是把「顺序」和「时机」这两件事拆开的最小手段。
其二:commitReady 里那个 break 为什么不能换成 continue。continue 会跳过没准备好的格子去记录后面的——那就变成乱序提交了,而且 committed 这个游标从此失去意义(它不再表示「前面 N 个都记完了」)。游标只能在连续的格子上前进,这是它作为「顺序」保证的全部内容。
其三:fillPool 里那个 break 为什么不是 continue。遇到一个重新分类成独占的调用时,你不能跳过它去启动后面的——那会把它挪到后面去,破坏模型顺序。正确做法是停止补人,让当前池子自然排空,然后这一组以 committed 个结束,外层循环从它开始新的一组——它自然就成了下一道屏障。
做对了的样子:注册三个都声明 isConcurrencySafe: () => true 的假工具,耗时分别是 3 秒 / 1 秒 / 2 秒(用 setTimeout 假装),MAX_PARALLEL = 2,在一个调用批次里按 1、2、3 的顺序发出去。让每个工具在自己结算时 console.log 一行,再把 log 里 tool/result 的顺序打印出来。
先自己在纸上推一遍时间线(这一步别跳过):t=0 开 1、2;t=1 工具 2 完成,池子空出一格,工具 3 开始;工具 3 要 2 秒,所以在 t=3 完成;而工具 1 也在 t=3 完成。两个撞在一起了——这正是这套数字要教你的事:1 + 2 = 3,第三个工具的开始时间取决于第二个的结束时间。
所以把工具 3 改成 1.5 秒(或者把工具 1 改成 4 秒)再跑。现在判据是明确的两条:
(1) 完成顺序必须是 2 → 3 → 1(约 1.0s、2.5s、3.0s);
(2) 记录顺序必须是 1 → 2 → 3,而且三条 tool/result 都在 3.0 秒那一刻才出现——工具 2 的结果在内存里等了 2 秒。
两个顺序不同才算做对。如果记录顺序也变成 2、3、1,说明你在 Promise.race 之后直接 push 了结果,没走提交游标;如果两个顺序都是 1、2、3 而总耗时是 6 秒,说明你根本没并发(多半是在循环里 await 了 run())。
再加一道验屏障的:把工具 2 的 isConcurrencySafe 删掉,重跑。现在应该分成三组(1 | 2 | 3),总耗时回到 3+1+1.5 = 5.5 秒,而记录顺序仍然是 1、2、3。
本章小结
模型一次能要好几个工具,串行跑就是把它们的等待时间加起来。并发把等待重叠,代价是「顺序」这个概念要重新定义——凡是结果会被先后影响的事(竞态),都不能随便并发。
dsh 让每个工具用 isConcurrencySafe(args) 自己声明,而且这个分类器是同步、纯、一元的:只有精确的 true 才算同意,没声明、返回别的、抛异常、工具查不到,全部失败时关闭判为独占。一元意味着它只能看自己的参数,表达不了「两个写只有路径不同才安全」这类关系型规则——所以文件写、bash、工作流全都保持独占。调用在每次补池之前还要重新分类,因为前面的调用可能刚刚装卸了插件:工具注册是一个调度边界。
调度形态是屏障 + 有界滚动池:连续的并行调用组成一组,每个独占调用自成一组并形成排序屏障;组内维持一个固定大小的在跑集合(maxParallelToolCalls 默认 10,设 1 退回串行),跑完一个立刻补一个。之所以是滚动而不是固定窗口,是因为固定窗口会让容量闲置在一个慢调用后面。
整章最值得记的是那条取舍:完成顺序和记录顺序是两回事。提交游标只在连续的模型顺序格子上前进,所以一个 1 秒就完成的结果可能要等到 3 秒才写进日志。它买到的不是「好看」,而是日志可复现(快照测试、fork 的共享前缀都依赖它)和有序的策略中间件;而关键路径一毫秒都没多花,因为步骤本来就要等最慢的那个。只有分发和工具本体重叠,pre-execute 和 post-execute 仍按模型顺序跑。
取消时,已经开始的被排空并按序提交,从未开始的补一对合成的 call/result(错误码 ABORTED_BEFORE_DISPATCH)——因为协议要求每个 tool-call 都有结果,否则回放和下一次请求都会坏掉。而调度器自己的内部故障不伪造任何结果:宁可留下一个明显残缺的记录,也不写一条可能是假的。跑得久的活则用后台任务脱离当前轮次,一启动就结算、立刻让出池位,由 job_output / job_list / job_kill 收集或停止;完成通知按所有者忙闲走注入或唤醒,而唤醒有次数上限,因为这条链会自激。
第14章 子 agent、目标与 Ralph:让 agent 派活给 agent
一句话导语:这一章回答同一个问题的三种答法——一个任务太大,一次轮次干不完、上下文也装不下,怎么办。
学完这一章你应该能做到
- 说清子 agent 为什么本质上是一种压缩,并算出一次委派大致省下了多少 token
- 逐字复述 scope 的定义,并解释「带作用域的注册不会向下继承给 subagent」这句反直觉的话
- 区分 scope 结构与 lineage 数据:哪一个决定可见性,哪一个只是记录父子关系
- 说出 setup window 是哪一段时间,以及「setup 只做注册,从不驱动 agent」防的是什么
- 解释目标的
armed/disarmed为什么有意不参与持久回放 - 把轮次、Round、Goal Round、Ralph Round 四个词摆在正确的层级上,不混用
- 面对一个具体任务,判断该用同会话目标、子 agent、Ralph 循环还是工作流脚本
tools.restrict,第11章的能力 seam,第12章的压缩(本章会不断和它对照)。14.1 一个人干不完的活
先把问题摆清楚。第7章给过轮次的定义:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。注意最后半句——轮次会结束。模型说完「我做完了」,这一轮就落幕了。没有人再说话,循环就一直空转下去。
再叠上第12章那个更硬的限制:上下文窗口有上限。一个真的很大的任务——比如「把这个仓库里所有用了旧 API 的地方都改过来,改完跑测试,测试挂了继续改」——你会撞上两堵墙:一堵是轮次会结束,得有人不停地说「继续」;一堵是上下文会满,读过的每个文件都还堵在历史里。
这个仓库对这两堵墙给了三个不同的答案,它们不是三个版本的同一个东西,而是三种取舍完全不同的东西:
| 做法 | 上下文怎么处理 | 谁在数数 | 典型场景 |
|---|---|---|---|
| 同会话目标(Goal) | 全留着,一直往后加,直到压缩来收拾 | Goal Round 上限 | 一件事要连着做很多轮,但前面做过什么必须记得 |
| 子 agent(subagent) | 孩子的过程留在孩子那里,父亲只拿回最后一段话 | 委派深度 delegationDepth | 一块可以独立完成、只需要交回一个结论的活 |
| Ralph 循环 | 每一轮从零开始,靠共享工作区 + 一份有界的交接报告传状态 | Ralph Round 上限(默认 256) | 目标不变、可以反复冲的活,且工作区本身就是记忆 |
这一章按「隔离一块活」→「让一件事持续下去」→「每轮从零重来」的顺序讲。中间会插进一个更基础的东西:scope——没有它,「派活给孩子」就没法安全地做。
14.2 子 agent 的本质,是压缩的另一种形式
不用子 agent 会怎样?假设你让 agent 回答一个问题:「这个仓库里的审批策略是在哪几个文件里决定的?」它得读十几个文件才能确定。第6章讲过,会话日志只往后加,从不修改。所以这十几个文件的全文,会永远躺在它的历史里——哪怕答案只有一句话「在 packages/interaction/ 下的三个文件里」。下一轮请求,这十几个文件还要再发一遍给模型;再下一轮,还要再发一遍。
子 agent 的做法是:把这块活连同它的上下文一起挪到别处去。父 agent 调用一次委派工具,参数是一段任务描述;harness 起一个新的 agent,它有自己的会话、自己的日志、自己的历史。孩子在自己那边读完十几个文件、跑完几十个步骤,最后写一段话。父亲拿回来的,就只有这段话。
这不是我的类比,仓库自己就是这么写的。packages/subagent/tool-subagent/README.md 的「Model Experience」一节写着(我逐字翻译):「提示词与结果留在父历史里直到压缩;孩子的工作上下文留在孩子那里。」工作流那边说得更直白,packages/workflow/workflow-worker-thread/README.zh.md:「中间子 agent 输出可供脚本使用,但不提供给父模型。」
算一笔账:这一下省了多少
下面这组数字是本站为了给你尺度感编的一个典型例子,不是仓库测出来的。假设孩子读了 12 个文件,平均每个 400 行、每行 8 个 token,光文件内容就是 12 × 400 × 8 ≈ 38400 token;加上它自己的思考、工具调用参数和结果,一次探索烧掉七八万 token 很正常。它最后交回来一段 300 字的中文总结,大约 400 token。
也就是说,父 agent 用 400 token 的代价,买到了 80000 token 的探索结果,压缩比约 200:1。而且这 400 token 是干净的:它是结论,不是原料。
代价也要说清楚:父亲看不到那 80000 token 里的东西。如果孩子的总结漏了关键细节,父亲没有办法回头去翻——那些细节在孩子的会话里,父亲的历史里从来没有过。这就是这笔交易的全部内容。
同样是「让父 agent 的上下文不被撑爆」,压缩和子 agent 有一个根本区别,它决定了「出了问题能不能追回去」。是什么区别?
变式:如果你希望「既隔离上下文,又能事后翻查孩子的过程」,仓库有没有给出办法?(提示:孩子有自己的会话 id,去看 SubagentRun.id 的 JSDoc 和 listChildren() 在干什么。)
14.3 scope:只有两层,扁平结构
把活派出去,就得回答一个问题:这个孩子手里有什么?第8章讲过一半答案(tools.restrict 和 shadowing)。这一节讲底下那个更基础的结构。仓库的术语表 docs/glossary.zh.md 里,这段定义我一字不差抄下来:
scope(作用域):按 agent(智能体)划分的注册单位。一项贡献(工具、提示词段、变量、限制、监听器)要么是全局的(对所有 agent 可见),要么是带作用域的(归属于恰好一个 scope key)。只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 lineage 数据表达,从不通过 scope 结构。
scope key:scope 的不透明标识,按对象同一性比较。harness 约定:一个活跃的 agent 就是其自身 scope 的 key。
先解释第二条,它比看上去有意思。不透明(opaque)意思是:这套机制从不检视这个对象里面有什么。packages/core/scope/src/index.ts 里,ScopeKey 的类型定义就是三个字:type ScopeKey = object。任何对象都能当 key,比较的时候用的是「是不是同一个对象」(identity),不是「内容是不是相等」。
打个比方
像用实物钥匙开锁,而不是用密码。两把长得一模一样的钥匙,只要不是同一把,锁就不认。好处是:谁也没法「猜出」或「伪造」一个 key——你要么手里握着那个对象,要么就没有。
类比失效处:真钥匙丢了能配一把,scope key 丢了就是彻底没了;而且这里锁的目的不是防坏人。packages/core/scope/README.md 的「Design contract」明说:scope 为受信任的同进程插件做路由,它们不是沙箱,也不是权限边界。下一节的答辩会揪住这句话。
「harness 约定:一个活跃的 agent 就是其自身 scope 的 key」——这句话的意思是,底层的 scope 包不知道什么是 agent,是上面的 agent loop 决定拿 Agent 对象本身当钥匙。分层分得很干净:原语只管路由,产品决定用什么当身份。
反直觉的那一条:注册不向下继承
现在说重点。很多人第一次看到「父 agent、子 agent」,脑子里会自动画出一棵树,并且默认树是会继承的:父亲有的,孩子也有。这个仓库不是这样。
docs/subsystems/subagent.zh.md 里的原话是:「每个子 agent 获得一个新的扁平作用域,而非继承父级注册。」也就是说,父 agent 在自己的 agent.ctx 上注册的工具、提示词段、变量、限制,孩子一样都看不到。孩子看得到的是:全局层,加上它自己那一层。
为什么要这么设计?仓库没有专门写一段来论证这一点,我的理解是(本站推断):如果注册会向下继承,那么「这个孩子能看到什么」就取决于它上面每一级祖先此刻的注册状态——而祖先是活的,随时会注册新东西。你没法在派出孩子的那一刻把它的能力集合固定下来。扁平结构则让每个 agent 的可见集合只有两个来源,都能在创建那一刻算清楚。
读的时候要小心:这里有个看起来矛盾的地方
packages/core/scope/README.md 里明明写着 key 之间可以形成父链(bindScopeParent),而且「注册视图沿着它向下继承——子 scope 看得到祖先的层,最近的遮蔽最远的」。这和术语表那句「只有两层,扁平」看起来直接打架。
我的读法是(本站推断,仓库没有把这两处放在一起解释):父链确实存在,但它连的不是「父 agent → 子 agent」,而是「agent preset 的常驻挂载 → 它下面的 agent」。依据有两条,都是仓库明文:其一,同一份 README 说「一个 agent preset 的常驻挂载是它那些 agent 的父 scope」;其二,packages/subagent/subagent/src/child-agent.ts 的 applyChildComposition() 里,孩子做的第一件事是 childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx)——它加入的是父亲所属的那个 preset,不是父亲自己那一层。
换句话说:孩子和父亲是兄弟,共同挂在同一个 preset 底下;他们之间不存在 scope 意义上的父子。第8章讲 restrict 时提到的「祖先链」,指的就是 preset 那条链。如果你要在代码里下判断,请以 packages/core/scope/src/index.ts 和 child-agent.ts 为准,并注意仓库处于 developer preview,这类结构随时可能变。
shadowing 在这里的位置
第8章已经把 shadowing 讲透了,这里只补一句它在 scope 词汇里的准确定义:最具体者胜出的名称解析:一个带作用域的工具/片段/变量仅在该 scope 内替换同名的全局对应项。注意「仅在该 scope 内」——遮蔽是局部的,不会影响别的 agent 看到的全局项。child-agent.ts 用的正是这一招给孩子换人格:注册一个同名的 deployment:persona 段,只在这个孩子的 scope 里盖住部署人格。
父 agent 在自己的 agent.ctx 上注册了一个叫 house_style 的工具(不是全局注册)。然后它派出一个子 agent,没有设任何 toolFilter。孩子的提示词里有 house_style 吗?
agent.ctx 上的注册就是带作用域的注册。house_style 落在哪一层里?house_style 注册在父亲自己那一层,而孩子拿到的是一个全新的扁平作用域,它的可见集合是「全局层 + 自己那层」(再加上它加入的 preset 层)。父亲那一层从来不在孩子的查找路径上。想让孩子也有这个工具,只有两条路:把它注册到全局层,或者在孩子的 setup window 里(下一节)给孩子那一层再注册一份。顺带一提,这也是为什么 applyChildComposition() 必须显式调用 composeFrom 把孩子接进 preset——源码注释里写得很吓人:一个没做这一步的孩子会看到一个空的工具注册表,父亲的提示词段一个都没有。变式:把「注册工具」换成「用 ctx.on() 在父亲的 agent.ctx 上挂一个监听器」,孩子的活动会触发它吗?(提示:术语表说 agent.ctx 上的监听器「参与该 agent 的 scope 过滤分发」。再看 subagent/start 事件的 JSDoc:分发载体是按发起委派的那个父亲做 key 的。)
14.4 setup window:给扩展留的那道口子
不用它会怎样?设想没有这个机制:agent 创建出来、发布、开始跑,然后你才有机会往它身上注册东西。问题立刻就来了——第一次提示词可能已经组装完了。你给它注册的人格、工具、限制,全都错过了第一次请求。更糟的是,如果注册失败了,这个 agent 已经在外面被人拿到引用了,你没法干净地把它撤回来。
所以仓库开了一个「创建时隙」。术语表的定义,逐字抄:
setup window(创建时隙):创建者组装 agent 作用域环境的创建时隙(CreateAgentOptions.setup):此时 scope 和 agent 对象已存在,但 agent 或会话尚未发布,agent/session-start 尚未触发,首次提示词尚未组装。setup 只做注册,从不驱动 agent。
把这段拆开看,它精确地划了一条线,线的两边各有三件事:
| 窗口内(setup 执行时) | 窗口后(发布之后) | |
|---|---|---|
| scope 与 agent 对象 | 已经存在,可以往上注册 | 同左 |
| agent / 会话 id | 尚未发布,外面拿不到 | 已发布 |
agent/session-start | 尚未触发 | 已触发 |
| 首次提示词 | 尚未组装 | 已组装或正在组装 |
最后那句「setup 只做注册,从不驱动 agent」是一条纪律,不是描述。它的意思是:你在 setup 里可以注册工具、注册提示词段、装监听器,但不许在这里给 agent 发消息、开轮次、调模型。为什么?因为此刻这个 agent 还没发布——没有人能观察到它,没有 id 可以在日志里指认它。在一个还没出生的东西上开始工作,出了错谁都收不了场。
而「收场」这件事仓库做得很干净:core.zh.md 写着,setup 可以返回一个「在发布前一刻调用的同步 commit」;setup 拒绝、commit 抛出或所有者 dispose,都会回滚事务,两个 id 均不发布。要么这个 agent 带着完整的装备出生,要么它根本就没出生过,不存在「半个 agent」这种状态。
可继续子 agent 有一个自己的版本
ctx.subagents.registerContinuableSetup() 做的是同一件事,但针对的是「每一个」可继续孩子:部署方注册一次,之后每个孩子在新建和冷恢复时都会装上。subagent.zh.md 说它「按顺序执行且具有事务性:设置失败或被撤销时会回滚未发布的 Activation」,而且「移除注册项时则会立即撤销每个驻留中的安装」。reportFrom()(孩子主动向父亲汇报的通道)就是靠这个扩展点实现的——仓库特意点明这样做「无需新增第二条队列」。
某个插件想让每个子 agent 一出生就先自我介绍一句。开发者在 setup 回调里直接调用了 agent.followup('先说一下你要做什么')。这违反了「setup 只做注册,从不驱动 agent」。请说出至少两件会因此出错的具体事情。
agent/session-start 尚未触发、首次提示词尚未组装。逐个问「如果现在就开一轮,会撞上哪个尚未」。turn/start 和一条 user/message。现在回滚就意味着要删日志,而日志是仅追加的(第6章),删不了。你造出了一个「不存在的 agent 留下的持久痕迹」。其三,事件顺序破了。agent/session-start 还没触发,任何靠这个边沿做初始化的插件都还没准备好,却已经有轮次在跑了。想实现这个需求,正确做法是在 setup 里注册一个监听器,等发布之后由那个监听器去发消息——注册是 setup 的事,驱动是发布之后的事。变式:如果 setup 里注册的某个工具需要一个异步初始化的外部连接,你会怎么安排?(提示:想想「注册」和「就绪」能不能分开——注册一个立刻可用的壳,让第一次调用去 await 那个连接。)
14.5 lineage:父子关系是数据,不是结构
上一节说 scope 不继承。那父子关系记在哪儿?答案是 lineage(谱系)。术语表定义,逐字抄:
lineage(谱系):以数据形式携带的父子关系事实(parentSession、持久的 delegationDepth、运行时 subagentDepth);从不影响可见性。
三个字段各管一件事:
parentSession:孩子的会话 header 里记着父亲的会话 id。这是「谁生的」。它有实际用途:reportFrom()就是从孩子持久化的parentSession中推导唯一接收方——调用方不能指定接收方,孩子只能向自己的直接父亲汇报。delegationDepth:持久的委派深度,写在会话 header 里。顶层是 0,每往下一层加 1。subagentDepth:运行时的同一个数,挂在AgentOptions上。
为什么同一个数要存两份?subagent.zh.md 给了答案:「缺失表示顶层深度为零,存在的较大值具有权威性。」持久那份是单调下界——一个被恢复的父 agent无法假装自己是顶层再重新开始派孩子。packages/subagent/subagent/src/child-agent.ts 里的 resolveChildDepth() 只有几行,做的事情正是:取父亲的深度加 1,检查没有溢出安全整数范围,再检查没有超过请求里的 maxDepth,超了就抛 SubagentDepthError。
// packages/subagent/subagent/src/child-agent.ts(节选,真实源码)
export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number {
const childDepth = delegationDepthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (maxDepth !== undefined && childDepth > maxDepth) {
throw new SubagentDepthError(childDepth, maxDepth)
}
return childDepth
}
默认上限是 3(packages/subagent/tool-subagent/README.md 的配置表),0 表示禁止委派。注意这个上限的执行方式:工具在到达上限时仍然可见,每次尝试启动才去检查当前深度,超了就返回一个 errored 工具结果。为什么不干脆把工具藏起来?因为藏起来会让提示词随深度变化,缓存前缀就断了;而且模型看到「我试了但被拒绝了」比看到「这个工具不存在」更容易自我纠正。
某人想做一个功能:「凡是我这棵子树里的 agent,都不许用 bash」。他的实现是:在根 agent 的 agent.ctx 上调用 tools.restrict({ deny: ['bash'] }),理由是「限制会顺着谱系传下去」。请构造一个具体场景说明这个实现完全不起作用,并指出他真正该做什么。
restrict() 是一个带作用域的注册,而带作用域的注册不会向下继承给 subagent。谱系只是三个数据字段。deny: ['bash'],R 派出孩子 C。C 拿到一个全新的扁平作用域,它的可见集合是「全局层 + C 自己那层 + C 加入的 preset 层」。R 那一层不在其中,所以那条 restriction 对 C 一点约束都没有;C 直接看得到全局层的 bash 并且能调用。C 再往下派孙子 D,同理。整棵子树里除了 R 自己,谁都不受影响——而这正是术语表那句话的字面意思:子树行为通过 lineage 数据表达,从不通过 scope 结构。正确做法是把限制作为委派请求的一部分逐个传下去:委派时带上 toolFilter,由 applyChildComposition() 在孩子自己的创建窗口里调用 childCtx.tools.restrict(composition.toolFilter);要让孙子也受限,孩子在派孙子时必须再传一次。换句话说,这个仓库里没有「设一次管一棵树」的开关,每一代都得自己交代。最后一层保险:如果你要的是安全而不只是可见性,得去第10章的守卫和审批那边处理——scope 明说不是权限边界。变式:把需求改成「这棵子树最多再往下派两层」,用 lineage 能不能优雅地做到?(提示:maxDepth 是绝对上限,不是相对的。想想根 agent 当前的 delegationDepth 是多少,你该传什么值下去。)
14.6 一次委派的两种形状
把活派出去之后,父亲和孩子之间是什么关系?仓库给了两种,差别很大。
形状一:一次性 run(SubagentRun)。这是最简单的委派——一次可 dispose 的前台委派,只有一个结果。父亲 await run.result,拿到 SubagentResult,然后必须 dispose()。JSDoc 里有一条很讲究的约定:孩子级别的失败不会 reject,模型或传输失败会以 stopReason: 'error' 正常 resolve,好让消费方把它映射成一个 isError 的工具结果;只有 seam 无法表示的基础设施故障才 reject。stopReason 有五个已知值:completed、aborted、error、max-tokens、refusal,而且这是个可合并扩展的联合类型——后端可以加新的,所以消费方必须把不认识的终态一律当失败处理。
形状二:可继续子 agent。这个复杂得多,也有意思得多。一份持久化的子 agent 会话,至多关联一个进程内的 Activation(激活)——「被重建的子 Agent 处于驻留状态的时段」。文档画了这么一张图:
persisted Session
-> optional live Activation
-> one retained AgentHandle
-> Agent inbox as the only turn FIFO
-> zero or more owned child Activations
关键在于「Agent 收件箱是唯一的队列」。第7章讲的 inbox 在这里被复用了:父亲给孩子发的每一条后续消息,都变成孩子的一个普通 FIFO 轮次。没有第二套排队机制,没有 subagent 专属的投递路由。Activation 有三个状态,全都是推导出来的而不是另设一套状态机:
| 状态 | 含义 | followup 怎么走 |
|---|---|---|
running | Agent 有活跃的准入或轮次,或正在唤醒收件箱工作 | 在同一个 Activation 中入队 |
waiting | 已完全停稳,但仍拥有至少一个未 dispose 的子 Activation | 唤醒同一个 Activation |
| 无 Activation | 只剩磁盘上那份会话 | 冷恢复一个新的 Activation |
孩子说的话,和运行时说的话,绝不能混
这里有个细节我特别想让你看到,因为它体现了这个仓库的一贯态度。孩子可以主动调用 report 向父亲汇报(reportFrom())。同时,当孩子的 Activation 结算时,管理器自己也会向父亲投递一条通知,说明这一段是怎么结束的。这两条消息用了两个不同的 kind:subagent-report 和 subagent-settled。源码注释解释了为什么:
源码里的原话(packages/subagent/subagent/src/types.ts,我逐字翻译)
「有意与 SubagentReportMessageSource 采用不同的 kind:report 是孩子选择的内容,而这条消息是管理器在陈述孩子最终怎么样了;一份把两者合并的 transcript 会把孩子从没写过的话算在它头上。」
「会把孩子从没写过的话算在它头上」——为了避免这一件事,专门多定义了一个类型。这就是第6章那条「模型可见即已记录」原则的延伸:不只要记录,还要记录清楚是谁说的。
答辩:如果我是审稿人
你花了两节讲 scope 怎么隔离孩子的能力,可 packages/core/scope/README.md 白纸黑字写着「scope 不是沙箱,也不是权限边界」,tool-subagent 的 README 更直接:「toolFilter 改变孩子的全局工具层,但它不是一个由父亲派生的权限上限。」那这一整套 toolFilter、restrict、扁平 scope,到底防住了什么?说它是安全机制是不是在骗人?
参考防守(先自己组织语言再看)
这个追问是对的,而且仓库自己先承认了——它把「安全与权限是非目标」写进了 Agent Note 的标题里。防守要分三层说。
第一层:它防的是模型,不是防插件。scope 的威胁模型里,同进程的插件是受信任的:一个能直接拿到 Session 的插件本来就能伪造事件(dsh-goal 的 README 也承认了同样的事)。它要拦的是另一个东西——模型可能在没有能力的情况下发出工具调用。第8章证明过这条路真实存在:用户在消息里写出工具名,模型就可能照着调。toolFilter 承诺「从提示词消失且拒绝执行,与不存在的工具无法区分」,这一条是真的被执行器执行的,不是贴在门上的告示。
第二层:真正的边界在别处,而且仓库不许它们互相冒充。进程隔离归沙箱(第15章),能不能做归守卫和审批(第10章)。委派时的做法很能说明问题:captureDelegatedPolicyOverrides() 把孩子的审批策略钉死在 'never'——孩子的权限范围在启动那一刻就固定了,会话内部无法拓宽,需要审批的操作直接被拒。这才是权限边界,它压根不走 scope。孩子的系统提示词里甚至有一句固定的话告诉模型这件事(SUBAGENT_DELEGATION_CONTEXT):你的权限范围在启动时已定,不要重试被拒的操作,把限制写进回复里让派你来的 agent 处理。
第三层:把可见性和权限分开,本身就是一种诚实。如果 toolFilter 自称是权限边界,部署方就会拿它当安全措施用,然后在某个插件绕过它的时候出事。明说「这是可见性组合,不是权限边界」,反而逼着你去配真正的那两层。仓库愿意在 README 里写下自己不提供什么保证,比含糊其辞可靠得多。
14.7 目标(Goal):一种状态,不是调度器
子 agent 解决的是「上下文装不下」。现在回到另一堵墙:轮次会结束。同一件事要连着做几十轮,谁来不停地说「继续」?
仓库的答案叫目标。术语表定义,逐字抄:
目标(Goal):附着在现有会话上的单个持久完成目标,带有按修订号演进的 active / paused / blocked / complete 阶段和 Goal Round 上限;blocked 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。
最后那句是全章最容易被误读的地方,值得逐字掰开:
- 不是调度器:
dsh-goal这个包不决定什么时候继续。它的 README 的「Known Limitations」第一条就写着「State, not scheduling」。真正去排队的是另一个包dsh-goal-round-driver——它在 agent 完全 idle 的时候检查一次:目标还 active 吗?还 armed 吗?还有额度吗?三个都是,才排一个<goal_round>提示词进 inbox。 - 不是一段独立对话:它不新开会话,不 fork 前缀,不起新 agent。整件事就发生在原来那个会话里,每一轮都追加在同一条历史后面。
- 会话日志仍是其真源:每一次变更都是一条持久的
goal/change事件,载荷是变更后的完整快照。目标状态不依赖 inbox 里发生了什么——严格回放只从这些事件推导生命周期。
四个阶段里,blocked 是唯一表示「因问题而停止」的持久状态。仓库特意把很多不同的失败收敛到这一个阶段上:goal 包的 README 写着「提供方限制、配置的预算、执行错误、请求人类输入,全都用这一个持久阶段,而不是繁殖出更多生命周期状态」。代价是你得靠 GoalBlockReason 里那个 lower-kebab-case 的 code 去区分。
轮次、Round、Goal Round:接着第7章往下数
第7章已经给过前两个定义,这里补齐第三、第四个,并把四个词摆到一张表上。请特别注意谁在数数那一列——混淆几乎都发生在这里。
| 词 | 定义 | 计数器归谁 |
|---|---|---|
| 步骤(step) | 一次模型请求,以及由模型响应引发的工具执行 | 不单独计数,属于某个轮次 |
| 轮次(turn) | 会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束;含零个或多个步骤 | 会话日志(turn/start … turn/end) |
| Round | 承载一个轮次的外层策略迭代。Round 计数器归该策略所有,并不统计会话中的每个轮次 | 那个策略 |
| Goal Round | 为当前目标接纳的一次续行周期。同会话驱动器将它具体化为一个由目标触发的轮次,其中可包含零个或多个步骤 | 目标(roundsStarted / maxGoalRounds) |
| Ralph Round | Ralph 循环中的一个全新子会话 | Ralph 工具(maxRounds,默认 256) |
从这张表能直接读出一条实用规则,术语表也明说了:同一会话中无关的人类轮次不消耗 Goal Round 上限。你在目标跑的中途插一句「等一下,先看看这个」,这一轮是普通轮次,不算进目标的额度里。goal-round-driver 的 README 把这条实现得很仔细:人类的活先进 inbox,自动的活就让路,等 agent 重新 idle 再重新预约一个 Round 号;而且「被判定为陈旧而拒绝的预约不会消耗那个 Round 号」。
14.8 armed / disarmed:从磁盘捞起来的会话,不会自己开始花钱
这一节讲这一章我最喜欢的一个设计决定。先看定义,逐字抄:
目标激活:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 armed 或 disarmed;它有意不参与持久回放,因此在恢复或 fork 后,只有随后通过 /goal 或模型工具执行一次经人类授权的恢复变更,自动工作才能开始。
「有意不参与持久回放」是什么意思?回忆第6章:会话是一条仅追加的事件日志,恢复一个会话就是把日志重放一遍,把状态算回来。目标的阶段(active/paused/blocked/complete)就是这么算回来的——它写在 goal/change 事件里。但是激活态不写。它只活在内存里,进程一死就没了。恢复之后,哪怕日志清清楚楚地说「这个目标是 active 的」,激活态也一律是 disarmed。
为什么要专门这么做:它防的是什么
设想激活态也持久化了。会发生什么:
你三个月前跑过一个目标,「把这个项目的测试覆盖率提到 90%」,跑了一半你关掉了。今天你想看看当时的记录,从磁盘里打开这个会话——它立刻开始干活了。开始调模型,开始改文件,开始花钱。你只是想看一眼。
更糟的是 fork。第6章讲过 fork:从一个会话的历史派生出一条新分支。如果激活态跟着 fork 走,你 fork 十次就得到十个同时在跑的自动任务,全都朝着同一个目标改同一个工作区。
所以这条规则可以浓缩成一句话:把一个会话从磁盘上捞起来,是一个只读动作。要让它重新开始自动工作,必须有一个活人再点一次头(/goal 命令或者模型工具走的那次 resume)。dsh-goal 的 README 把这条纪律说得很干脆:「Activation is never persisted. A fresh cache and every agent/session-start edge disarm it even when replay finds an active durable phase.」——每一次 agent/session-start 边沿都会解除激活,哪怕回放算出来的阶段是 active。
这个设计还有一个副作用值得注意:它把「这件事该不该继续」这个判断,从数据里拿走了,交给了人。持久层老老实实记着目标是什么、做到第几轮、为什么卡住;但「现在可以开跑了」这个许可,每次进程重启都要重新给一遍。goal-round-driver 的做法一致:插件卸载时会「disarm 每一个活着的目标」;持久化 flush 失败经 agent/error 传来时,也会在下一个 Round 开始前 disarm。任何一点不确定,就先收权限。
一个目标的持久阶段是 active,roundsStarted 是 7,上限 256。进程崩溃后重启,会话被恢复。此时:(a)阶段是什么?(b)roundsStarted 是多少?(c)驱动器会不会自己开始第 8 轮?
goal/change 与获准的 user/message 事件。active——它写在 goal/change 事件里,严格回放照原样算回来。(b)roundsStarted 仍是 7——只有获准的、带 GoalMessageSource 的 user/message 事件才推进它,这些事件都在日志里。(c)不会。激活态不持久,恢复后一律 disarmed;驱动器的 idle 检查点要求「active 且 armed 且有额度」三个条件同时成立,第二个不成立。要继续,得有人执行一次经人类授权的 resume,那次 resume 会记下新的激活边沿。变式:把「进程崩溃重启」换成「同一个进程里把 goal-round-driver 插件热替换了一次」,答案变吗?(提示:goal-round-driver 的 README 说「Activation is never inherited when this plugin loads over an existing agent」。)
14.9 Ralph 循环:每一轮都从零开始
目标是「一直往后加」,子 agent 是「隔出去一块」。Ralph 是第三种,也是最极端的一种:每一轮都换一个全新的 agent,什么都不带。
三个定义,逐字抄:
Ralph 循环:一次面向不可变目标的前台全新 agent 工作流运行。它是由工作流和 subagent 原语组合而成的面向模型的工具策略,不是同会话目标、agent loop 模式、调度器或通用工作流脚本功能。
Ralph Round:Ralph 循环中的一个全新子会话。子会话不接收父会话或此前子会话的对话种子;共享工作区和一份有界的 Ralph 交接承载跨 Round 的状态。
Ralph 交接:从一个仍需继续的 Ralph Round 传给下一个 Ralph Round 的规范化、有界结构化报告,包含状态、摘要、证据、后续步骤和阻塞说明。它补充共享工作区,而不取代工作区的权威地位。
这三条合起来描述了一个很奇怪的循环:第 5 轮的 agent,对第 1 到 4 轮一无所知——它没有那些对话,它是刚出生的。它只有两样东西:一个不变的目标,和上一轮交给它的一份结构化报告。那它怎么知道之前做了什么?去看工作区。文件系统本身就是长期记忆。
packages/workflow/tool-ralph/src/index.ts 里那段固定脚本,把这个意思直接写进了发给每一轮的提示词(真实源码,我逐字翻译):
你是一次前台 Ralph 循环里的一个全新工人。你不会收到任何父对话,也不会收到 此前的子会话。不要调用 ralph 工具:这一轮本身就是它的工人。 不可变目标: <objective> Ralph round: N of M. 共享工作区及其当前工作树是长期记忆和真源。动手前先检查它们,保留已有工作, 做具体的、在范围内的工作,并验证你改动的部分。把上一份报告只当作一份有界的 交接;请对照工作区确认它。 上一份结构化交接: <上一轮的 JSON,第一轮是「(none — this is the first round)」>
注意「把上一份报告只当作一份有界的交接;请对照工作区确认它」。报告可能是错的、过时的、或者只是不完整;工作区才是权威。这和术语表那句「它补充共享工作区,而不取代工作区的权威地位」是同一件事,说了两遍。
报告的形状是被写死的
Ralph 的交接不是自由发挥的一段文字,而是一个严格校验的 JSON:status(continue/complete/blocked)、summary、evidence、nextSteps、blocker。而且校验规则是交叉的,源码里逐条写着:
continue:nextSteps至少一条,blocker必须是空串。complete:evidence至少一条,nextSteps必须为空,blocker必须是空串。没有证据就不许说完成。blocked:blocker必须是一个具体的、非空的、规范化的字符串。
还有一道尺寸闸:序列化后超过 maxHandoffChars(默认 16384 个字符)直接报错。这就是「有界」的字面含义——交接不许无限膨胀,否则跑到第 50 轮,报告本身就变成了一个新的上下文爆炸源。
另外,Ralph 对它用的提供方有三条硬性检查(requireFreshProvider()):必须已注册、必须支持结构化输出、必须 inheritsParentContext === false。第三条是灵魂——如果你把提供方配成 fork(它会把父亲已完成轮次的前缀种进孩子),Ralph 直接拒绝加载,报错「inherits parent context; Ralph requires a fresh provider」。「全新」不是一句口号,是一条会让插件挂掉的断言。
为什么要「每轮从零开始」
这和「保留上下文」是完全相反的取舍,各有各的道理:
| 同会话目标(保留) | Ralph(从零) | |
|---|---|---|
| 模型记得之前的推理 | 记得,包括走过的弯路 | 完全不记得 |
| 上下文长度 | 单调增长,靠压缩兜底 | 每轮回到起点,长度基本恒定 |
| 错误会不会累积 | 会。一个错误结论留在历史里,后面每一轮都受它影响 | 不会。上一轮的错误只可能通过报告和工作区传递 |
| 适合什么 | 需要连贯判断、前后强依赖的活 | 目标明确、进展看得见(工作区有痕迹)、可以反复冲的活 |
| 不适合什么 | 要跑很多很多轮,压缩迟早会伤到关键信息 | 推理链很长、无法从工作区还原思路的活 |
「错误不会累积」是 Ralph 最实在的好处。长会话有一个众所周知的失败模式:模型在第 12 轮下了一个错误结论,之后 40 轮都建立在这个错误上,而且因为它就写在历史里,模型会反复引用它,越陷越深。Ralph 的每一轮都是新人,它不知道第 12 轮的那个结论,它只看得见工作区里的真实状态和一份被 schema 约束过的报告。这是用记忆换纠错能力。
真未解一个任务该保留上下文还是每轮从零,有没有可计算的判据?
仓库把三种做法都实现了,也给了适用场景的定性描述(Ralph 工具的 description 里写着「Ordinary long-running same-session work belongs to goal tools」),但没有给出任何量化判据:多长的任务、多大的工作区、多少轮之后,从零重来会比保留上下文更划算?这不是仓库偷懒——据我所知,这个问题在整个领域里都没有公认答案。相关的量都不好测:「上一轮的结论有多少能从工作区还原」本身就没有定义。
怎么入手:先读 packages/workflow/tool-ralph/src/index.ts 的固定脚本和 packages/goal/goal-round-driver/README.md 的「Model Experience」段(后者精确写了每轮增加多少 token)。然后自己设计一个可测的对照:同一个目标、同一个工作区,一边用目标跑 N 轮,一边用 Ralph 跑 N 轮,记录三件事——总 token、每轮的 token、最终是否达成。重点观察 Ralph 那边的报告在第几轮开始变得空洞(summary 开始重复上一轮)。那个拐点大概就是「工作区不足以承载记忆」的信号。
父 agent 在一个轮次里调用了一次 ralph 工具,maxRounds 设为 3。三轮全部返回 continue,于是运行以 budget-limited 结束。每一轮的孩子内部都做了 2 个步骤。请回答:(a)父会话的日志里出现了几个 turn/start?(b)总共有几个会话?(c)如果父 agent 同时还有一个 active 且 armed 的目标,这次 Ralph 消耗了几个 Goal Round?
agent() 调用;最内层是三个独立的子会话,各自有自己的轮次和步骤。然后数每一层。goal-round-driver 排进 inbox 的、带 GoalMessageSource 的 user/message 事件推进;Ralph 完全不经过目标领域。反过来说,父亲那个轮次如果本来就是一个 Goal Round,那这次 Ralph 是在那一个 Goal Round 里面发生的,仍然只算 1 个。附带:孩子内部那 2×3=6 个步骤,分别记在三个子会话自己的日志里,父会话的日志里一个都看不到——这正是 14.2 讲的隔离。变式:把 Ralph 换成「父 agent 连续调用了 3 次一次性子 agent 工具,三次在同一条 assistant 消息里并发发出」。(a)(b)(c)分别变成多少?(提示:并发工具调用仍属于同一个步骤的分发;tool-subagent 的 README 说兄弟委派会在循环的滚动池里重叠。)
有人觉得 Ralph 的报告 schema 太啰嗦,改成「交接就是一段自由文本,随便写多长」。请构造一个具体的失败过程,说明这个改动会让 Ralph 退化成什么,以及在第几轮左右开始出问题。
complete 必须带 evidence 且 nextSteps 为空,自由文本做不到这一点,模型可以写一句「我觉得差不多完成了」就收工,而校验器无从判断。所以那两道闸——maxHandoffChars(默认 16384)和交叉字段校验——不是洁癖,它们一个防膨胀、一个防含糊。真正该改进的方向不是放开长度,而是让工人更信任工作区:提示词里那句「对照工作区确认它」就是在往这个方向推。变式:反过来,把 maxHandoffChars 调到极小(比如 200 个字符)会怎样?(提示:想想 evidence 和 nextSteps 是数组,超限时脚本会抛错——这一轮会以什么形式结束?)
14.10 工作流:确定性编排 vs 模型驱动
Ralph 是「由工作流和 subagent 原语组合而成」的。所以最后要讲一下那个底座:工作流 seam。
不用它会怎样?假设你要「对这 20 个文件各做一次同样的检查,然后汇总」。让模型自己一个一个派子 agent:它得发 20 次工具调用,每一次的结果都回到它的上下文里,它还要自己记住已经做到第几个。20 次往返,每次都在烧 token,而且模型完全可能数错。
工作流的做法是:让模型写一段脚本,脚本里可以循环、可以并发,脚本自己去派那 20 个子 agent,最后只把汇总结果返回给模型。docs/subsystems/workflow.zh.md 的第一句就是这个意思:「工作流 seam 允许 agent 运行由模型编写的、会启动 subagent 的编排脚本。」脚本里可以用的东西不多,一共四个(packages/workflow/workflow-worker-thread/README.zh.md):
agent(prompt, { label, phase, schema, model }) // 启动一个子 agent;给了 schema 就返回结构化值
parallel(thunks) // 在配置的并发上限下跑一批
pipeline(items, ...stages) // 流水线,阶段之间没有屏障
phase(title) / log(message) // 只发给观察者的叙述,不影响执行
这里有一个设计细节值得单独说:meta 和 args 是纯 JSON 数据,引擎用 schema 校验 meta,并且——原话——「引擎绝不会通过对脚本文本求值来获取它们。」换句话说,工作流的身份信息和输入数据,走的是和脚本代码不同的通道。如果它们是从脚本里 eval 出来的,一段恶意或者只是写错的脚本就能在校验之前先跑起来。
还有一条「失败纪律」也很有代表性:脚本里的钩子误用——写错选项名、schema 超出支持的子集、超上限——会抛出 fatal: true 的错误,而 parallel()/pipeline() 对 fatal 错误直接重新抛出,不会像处理普通子运行失败那样把这一项映射成 null。文档给的理由:「一个拼写错误的选项必须明确报错并终止脚本,绝不能消融为看似普通子 agent 失败的结果。」把「你写错了」和「那件事没做成」区分开,否则你会花几个小时 debug 一个根本不存在的子 agent 问题。
什么时候用哪个
现在四种东西都讲完了,给一条实用的判断顺序(这是本站整理的经验法则,不是仓库明文):
- 编排逻辑是确定的吗?「对每个 X 做 Y,然后汇总」——确定。用工作流脚本,让代码去循环,不要让模型一次次决定下一步。
- 不确定,但每一步都需要记得前面发生了什么?用同会话目标。它保留全部上下文,代价是要靠压缩兜底。
- 不确定,而且进展都体现在工作区里(文件被改了、测试通过了)?用 Ralph。每轮从零,用记忆换纠错。
- 只是有一块活想隔离掉,拿回一个结论?用单次子 agent。这是最轻的一种,也是最常用的。
任务:「读这 30 个测试文件,找出哪些测试在 CI 上是 flaky 的,给我一份清单。」判断标准需要模型来定(什么叫 flaky 要看代码写法),但「对 30 个文件各做一次」这件事是确定的。该用哪种做法?为什么不用另外三种?
parallel(),里面 30 个 agent() 调用,每个带一个 schema 要求返回 { file, flaky, reason }。然后想:这 30 个孩子的中间输出会不会回到父模型?parallel() 会在配置的并发上限下跑,比模型自己发 30 个并发调用更可控。判断的那一半交给孩子:每个 agent() 带一个结构化输出 schema,孩子读文件、做判断、返回一条结构化记录;30 个孩子的读文件过程不会回到父模型(「中间子 agent 输出可供脚本使用,但不提供给父模型」),父亲最后只看到脚本的返回值。为什么不用另外三种:单次子 agent——可以,但你得让父模型自己发 30 次调用并记住进度,往返成本和出错概率都高得多;同会话目标——目标是给「不知道要几轮」的活准备的,这里轮数是确定的 30,而且 30 个文件的内容会全部堆进同一条历史;Ralph——Ralph 要求一个不可变目标和「工作区就是记忆」,可这个任务不改工作区,每一轮的新人会把 30 个文件重读一遍,纯浪费。变式:把任务改成「一直修这个 bug 直到测试通过,不知道要几次」,答案变成哪个?如果再加一句「而且每次尝试都会改代码,改坏了要能看出来」呢?
对你而言未知可继续子 agent 的所有权图,到底是怎么保证不死锁的?
14.6 说过 Activation 之间有所有权关系:一个父 Activation 只要还拥有未 dispose 的子 Activation,就无法 settle(这就是 waiting 状态)。这里立刻会冒出一个疑问:如果 A 拥有 B,B 又以某种方式牵扯到 A,会不会互相等着谁都结束不了?仓库对此是有答案的,只是分散在几个地方,需要你自己拼起来。
怎么入手:读 packages/subagent/subagent/src/continuation.ts,重点找三处——(1) ownedChildren: Set<SessionId> 是怎么维护的,什么时候加、什么时候删;(2) drainContinuableDescendants() 的释放顺序(文档说是 child-first,自顶向下传播取消);(3) 谁能成为谁的 parent(SubagentInterruptAuthority 里那句「陈旧的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝」)。然后回答一个更具体的问题:所有权图是不是一棵树?如果是,为什么不会有环;如果不是,环是怎么被排除的。答案就在源码里,不需要猜。
给你的 mini-harness 装上子 agent
到上一章为止,你的 harness 只有一个 agent 在跑。现在给它加一个能力:创建一个带独立 scope 的子循环,跑完把结论塞回父循环。这一层要同时做对两件事——隔离(孩子看不到父亲那层)和连通(孩子看得到全局层)。
难点在哪?难点是「什么都不做」比「做点什么」更难想到。写 delegate 的时候,你会有一股很强的冲动去写 child.scope.tools = new Map(parent.scope.tools)——把父亲的工具复制给孩子,看起来又体贴又合理。当初做这个决定的人也一定想过。不复制的理由要到出问题的时候才看得见:父亲那一层是活的,它随时可能注册新东西、也可能因为插件卸载而少掉东西。如果孩子继承了父亲那层,那么「这个孩子能做什么」就变成了一个随时间变化的量,你没法在派出它的那一刻把它固定下来。扁平结构的全部好处就是这一句:孩子的能力集合在它出生那一刻就算完了,之后不再变。
第二个难点是 lineage 那个字段的纪律:它是数据,只用来做深度检查和「谁是我爸」的记录,绝不能出现在 lookup() 里。你只要在 lookup() 里写一行「找不到就去 agent.lineage.parent 那儿再找找」,这一层就白做了——而这行代码看起来无比自然。
做对了的样子:做两个方向的检查,两个都通过才算对。方向一(隔离):在子 agent 的 scope 里注册一个只有它有的工具,比如 child.scope.tools.set('only_mine', …),然后在父 agent 里执行 lookup(parent, 'only_mine')——必须得到 undefined。方向二(连通):在父 agent 那边执行 GLOBAL_TOOLS.set('ping', …),然后在子 agent 里执行 lookup(child, 'ping')——必须找得到。
如果方向一失败(父亲看得到孩子的工具),说明你把孩子的工具写进全局层了;如果方向二失败(孩子看不到全局工具),说明你的 lookup 漏了 ?? GLOBAL_TOOLS.get(name) 那一半。
再加一道自选题:在父亲自己那层注册 parent.scope.tools.set('dad_only', …),然后 lookup(child, 'dad_only') 必须是 undefined。这一条最反直觉,也最容易在你手滑复制父亲工具表的时候悄悄失败。
本章小结
一个任务太大,有三条出路,它们的取舍完全不同:同会话目标保留全部上下文,靠压缩兜底;子 agent把一块活连同它的上下文隔离出去,父亲只拿回结论——这本质上就是压缩,只是动手时机从事后提前到了事前;Ralph 每一轮都换新人,靠共享工作区加一份有界交接传状态,用记忆换纠错能力。上面还有一层工作流:编排逻辑确定的时候,让代码去循环,别让模型一次次决定下一步。
撑起「派活」这件事的结构是 scope:只有两层,扁平——一项贡献要么全局,要么归属恰好一个 scope key,而且带作用域的注册不会向下继承给 subagent。父子关系记在 lineage 数据里(parentSession、delegationDepth、subagentDepth),它从不影响可见性。装备孩子的时机是 setup window:scope 和对象已存在,但 id 未发布、agent/session-start 未触发、首次提示词未组装;这里只做注册,从不驱动 agent,失败就整体回滚。
最后是那个最该记住的设计选择:目标的激活态有意不参与持久回放。把一个会话从磁盘捞起来是只读动作,要重新自动干活,必须有活人再点一次头。整个 harness 里这类「宁可多问一次」的地方不多,这是最典型的一个。
第15章 外围:那些没进循环但少不了的东西
一句话导语:前面十四章讲的都是循环本身,这一章讲挂在循环周围的十三样东西——每一样都不进 agent loop,每一样拿掉了你都会立刻难受。
学完这一章你应该能做到
- 对每一个外围子系统,用一句话回答「不用它会怎样」
- 区分守卫和沙箱这两层防线各自管什么,说出哪一层是进程级的
- 解释 spill 为什么故意跳过
read工具 - 说清
SESSION_FORMAT_VERSION停在0且明说无兼容承诺,对使用者意味着什么 - 说出 Code Mode 省 token 的真正来源,以及仓库不承诺什么
- 对着自己造的 mini-harness 列出:这十三样里,哪些完全没有,哪些已经有个简陋版
tools/post-execute 在这一章会反复出现),第10章的守卫与审批,第11章的能力 seam三个角色,第12章的压缩。这一章和前面十四章的读法不一样。前面每一章都在往同一个东西上加零件,这一章是横着扫一遍:十三个子系统,每个一小节。它们的共同点写在各自的文档第一句里——几乎每一份都写着同一句话:「它是一项可选能力,不属于 agent loop 主干。」这句话的分量在第11章讲过:不属于主干,意味着你可以把它整个拆掉,循环照样转。
但「能拆」不等于「该拆」。下面每一节都先回答「不用它会怎样」,你会发现答案往往不是「跑不起来」,而是「跑得起来,但很快就变得没法用」。
15.1 沙箱:第二层防线
不用它会怎样?第10章的守卫和审批做的是「这次操作要不要放行」的判断。但判断放行之后呢?一条 bash 命令被批准了,它拿到的就是一个和 harness 同等权限的子进程。命令本身可能只是 npm test,可 npm test 会跑测试脚本,测试脚本会跑任意代码。你批准的是一条命令,实际执行的是一整棵你没看过的进程树。
所以需要第二层:沙箱。docs/subsystems/sandbox.zh.md 说得很直接——它「将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中」。三种模式:
read-only:后端必须拒绝写入(POSIX 后端还会额外放行/dev/null这个 shell 必需的接收口)。workspace-write:允许写工作区根目录和后端承诺的临时区。danger-full-access:绕过隔离——注意,这种模式的消费方直接 spawn 原始 argv,根本不调用ctx.sandbox。不是「调用了但什么都不做」,是压根不走这条路。
后端按平台不同:Linux 用 bwrap/Landlock,macOS 用 Seatbelt,Windows 用 ACL 受限令牌。它们提供的保证不一样,而仓库把这件事变成了一个必须被处理的返回值:SandboxEnforcement 是 'full' | 'partial'。partial 表示「活跃后端或较旧的内核 ABI 仅管控其中一个子集」,当前的已知情形是较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。文档给消费方下了硬命令:要求绝对保证的消费方必须拒绝或向上暴露这一区别,不许把 partial 当 full 用。
常见误解:沙箱管网络
不管。文档写得很清楚:SandboxMode 仅管控文件效果,「网络与进程可见性不在此处的定义范围内」。一个 read-only 的沙箱进程照样可以把整个工作区通过网络发出去。要拦网络,得靠别的东西——这个仓库里没有一个统一的网络策略层。
审批策略已经配成「所有写文件的工具都要问一遍人」。有人说:那沙箱就多余了,反正没批准的写不进去。这个说法错在哪?举一个具体的操作序列。
bash:它是一个工具,但它执行的是一串你没有逐条审过的东西。bash 跑一条看起来无害的命令,比如运行一个构建脚本。审批系统看到的是什么?脚本内部做了什么,审批系统看得到吗?bash,命令是 npm run build。(2) 这不是「写文件的工具」,审批放行——就算配成要问,人看到的也只是这一行命令。(3) npm run build 执行 package.json 里的脚本,脚本执行一个构建工具,构建工具执行插件,插件里有一行代码往 ~/.ssh/ 写东西。整条链上,审批系统只看到了第一环。沙箱的作用正是在这里:它包住的是子进程 argv,也就是这棵进程树的根,之下所有后代都受同一套文件效果策略约束。所以这两层的关系不是冗余,是粒度互补——审批管调用粒度,沙箱管进程粒度。第14章讲委派时那个细节也是同一个思路:captureDelegatedPolicyOverrides() 把孩子的沙箱模式从父会话继承过去,权限范围在委派那一刻就定死。变式:如果部署根本没组合沙箱插件,bash 工具还能不能用?(提示:去看第11章的 seam 三角——缺了 Service Provider 会怎样,缺了整个 seam 又会怎样。)
15.2 spill:大输出溢出到别处,只给模型一个引用
不用它会怎样?模型调用了 web_fetch 抓一个网页,结果是 400 KB 的 HTML。这 400 KB 会作为工具结果进入模型历史。按第12章那笔账,一次就吃掉十万量级的 token,而且因为日志只往后加,之后每一轮都要把它重发一遍。一个工具结果就能把整个会话拖垮。
spill 的做法:把完整文本存到别处,给模型一个引用加一小段预览。这是一个标准的三包 seam——Service Definition 是 dsh-spill(ctx.spillStore),提供方是 dsh-spill-local(存在宿主文件系统里,按会话分组的私有文件),消费方是 dsh-spill-policy。
消费方是一个 tools/post-execute 转换器——第9章那七道关里的第六关。它的逻辑值得逐条看,因为每一条都是踩过的坑:
- 先
next()放工具跑完,这样它能约束任何下游钩子最终接受的结果。 - 跳过嵌套执行(有
exec.parent的,也就是 Code Mode 里的子调用)、跳过已被替换的值、跳过非accept的决策(block的纠正反馈要原样传给模型)。 - 跳过
read。文档给的理由只有一行:避免read → spill → read again循环。 - 只处理纯文本结果;含任何非文本块(比如图片)的结果原样放过。
- UTF-8 字节数不超
maxInlineBytes就不动它。
第 3 条特别值得琢磨。read 是读文件工具;如果它的结果太大也被 spill 掉,模型拿到的是「内容已存到某处,用某某方式取回」。那模型会怎么做?它会去读那个 spill 文件——而读文件用的就是 read。于是又太大,又被 spill,又给一个新引用。模型陷进一个自己造的循环里,每一圈都在烧钱。一个只有一行的例外规则,挡掉的是一个无限循环。
还有一个分工要注意:spill-policy「不注册任何服务」,也不负责存储和预览——预览(首尾拆分)归 dsh-output-retention 的 TextRetainer,存储归 ctx.spillStore。它只决定什么时候 spill。而且 maxInlineBytes 省略时这个策略完全禁用(插件什么都不注册),不是悄悄取一个默认值——这是 packages/AGENTS.md 那条规矩的直接体现:「插件里不许有硬编码可调参数:随部署变化的选择必须是能从 cordis.yml 改的、经过校验的 Config 字段;一个 DEFAULT_* 常量或测试钩子不算可配置。」
spill 策略明确跳过 read。假设某人把这条例外删了,认为「大文件也该 spill,一视同仁」。请写出模型接下来最可能做的三步,并说明这个循环靠什么才会停下来。
read。read 的结果长什么样,再写模型的第二个动作,再写那个动作的结果。看看第 3 步和第 1 步是不是同构的。read('big.log'),结果 3 MB,被 spill,模型收到「预览 + 定位符 + 取回指引」。(2) 模型照着指引去取,而取回的手段仍然是读一个文件,于是它再次调用 read,目标是 spill 出来的那个产物。(3) 那个产物就是原文,一样 3 MB,一样超限,一样被 spill,模型收到一个新的定位符。第 3 步和第 1 步同构,循环成立。它靠什么停?靠外部预算:轮次的步数上限、token 上限、或者人按下取消。也就是说,没有任何一个语义层面的东西会让它停,只有硬性资源耗尽——这是最糟的一类 bug,它不报错,只是安静地把钱烧光。顺带说一句,模型每转一圈还会往历史里加一段预览,所以上下文也在同时膨胀。这就是为什么那条例外必须写在策略里,而不是指望模型自己不上当。变式:那 read 读到超大文件时靠什么保护?(提示:去看 packages/fs/tool-fs 的 README——read 自带 offset/limit 参数,默认和上限都是 2000 行,每行超过 2000 字符还会截断并加后缀。这是工具自己分页,不是事后 spill。想想这两种做法给模型的体验差在哪。)
15.3 skill:可加载的工作流说明书
不用它会怎样?你想让 agent 按你们团队的规矩做代码评审:先跑哪些检查、按什么顺序、结论怎么写。这些说明放哪?塞进系统提示词——那它每一次请求都要发一遍,哪怕这次只是让它改个错别字(第8章那笔提示词成本的账)。写在 README 里让它自己找——它不一定找得到。
skill 的答案是:做成一份可以按需加载的说明书,目录常驻(一行名字加一句描述),正文只在模型主动读的时候才进上下文。ctx.skills 是一个提供方注册表,本地提供方 dsh-skill-filesystem 从目录里扫,消费方 dsh-tool-skill 提供面向模型的 skill 工具和那份初始目录。
注册表的分层和第8章的工具注册表一模一样,仓库自己就是这么说的:「注册表采用宿主 + 按 scope 的分层结构,即工具注册表在 dsh-scope 之上确立的形态」。宿主行和 repository 插件落在全局层,由 agent preset 常驻组合挂载的落在该 preset 的层;读取时合并,最近层的条目直接赢得重名 skill。第14章讲过的 shadowing,在这里原样复用了一次。
有一个小设计值得单独看:失效事件 skills/change 不携带 diff。文档解释了原因——「该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 snapshot()」。为什么不带 diff 更好?因为每个消费方的查找选项不一样(不同的 cwd、不同的 scope),同一次变更对不同消费方意味着不同的结果。发一个「变了,你自己重算」比发一个「加了 A 删了 B」更不容易错。
15.4 持久化与存储:落盘,以及一个停在 0 的版本号
这里其实是两个不同的东西,名字很像,很容易混:
| 会话持久化 | 存储 | |
|---|---|---|
| ctx 键 | ctx.sessionPersistence | ctx.storage / ctx.storageDomain |
| 存什么 | 仅追加的会话事件日志 | 一切不属于会话日志的数据 |
| 后端 | JSONL 或 SQLite | 注册为 json 和 sqlite 的两个后端 |
| 特点 | 没有平行的持久化事件类型——直接存 SessionEvent | 枢纽自身不做任何 IO,后端拥有介质,数据形式拥有语义 |
不用会话持久化会怎样?进程一关,所有会话消失。更要命的是崩溃:agent 跑到一半进程死了,那个轮次的所有事件都没了。
这里有一个我觉得特别体现工程品味的细节:崩溃恢复不截断日志。后端重新加载一个崩在轮次中途的日志,会发现一个只有 turn/start 没有 turn/end 的开放轮次。天真的做法是把这段不完整的尾巴砍掉。仓库不砍,理由写在文档里:「在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加」——砍掉就是白干几个小时。它的做法是补一条合成的 turn/end { reason: { kind: 'interrupted' } } 把这个轮次配平,前后的独立事件一个不动。而 interrupted 是唯一一个不由循环发出的 TurnEndReason——一看到它你就知道这不是正常结束,是有人从外面补上的。
那个停在 0 的版本号
现在说这一章最需要你留个心眼的地方。仓库根目录的 AGENTS.md 里有一节叫「Pre-release stance: foundation over blast radius」,第一句是「在第一个打标签的发行版时删掉本节」。这一节里有一句:
逐字引用,请认真读
「Backends reject old on-disk formats. SQLite uses monotonic SCHEMA_VERSION; dsh-session keeps SESSION_FORMAT_VERSION at 0 with no compatibility promise.」
翻译:后端拒绝旧的磁盘格式。SQLite 用单调递增的 SCHEMA_VERSION;会话日志格式版本停在 0,并且不作任何兼容承诺。
对你意味着什么:今天这个版本写下的会话文件,不保证下个版本还打得开。这不是 bug,是仓库明确写下来的当前立场——它现在没有外部使用者,所以宁可保持地基正确,也不背兼容包袱。反向的情况倒是有明确处理:packages/session/session-persistence/src/coordinator.ts 里,读到比自己新的版本会给一句人话错误——「该日志由更新的 harness 写入——升级 harness 才能打开它」。
结论:不要把这个仓库现在写下的会话日志当作长期档案。要留存的东西,自己另外导出一份。
你在公司内部部署了这个 harness,团队用了三个月,攒下几千个会话。某天你按仓库主分支升级了一版。请构造一个具体的失败序列,说明什么情况下这几千个会话全部打不开,并指出你事前本该做的一件事。
SESSION_FORMAT_VERSION 停在 0 意味着什么变了它也不会变?如果格式真的变了,旧文件里那个数字是多少?SESSION_FORMAT_VERSION」这条规则在版本号被人为按住时会失效成什么样。AGENTS.md 的鼓励「自由重命名」,把某个会话事件的载荷结构改了。(2) 按规矩,只有结构性格式变更才推 SESSION_FORMAT_VERSION;但当前立场是把它按在 0 不动,因为没有外部使用者需要兼容。(3) 你的旧文件版本号写的也是 0,新代码于是认为「这是我认识的版本」,直接按新结构去读。(4) 读到老结构,可能是解析失败,也可能是更坏的情况——某个必读字段缺失,那条日志被整体拒绝(AGENTS.md 里写着:SessionEventMap 成员默认是 required-on-read,不认识类型的构建会拒绝整条日志,除非事件带 ignorable: true)。(5) 几千个会话全打不开,而且版本号那道本该拦住你的闸没有响,因为两边都是 0。事前该做的一件事:把升级前的会话目录整个备份,并且在升级前先拿一个副本试打开。更根本的做法是别把它当档案系统用——需要长期保存的内容(结论、产物、决策)应该在会话之外另存一份,比如直接写进工作区的文件里。补充:SQLite 那边有单调 SCHEMA_VERSION,情况好一些,但「后端拒绝旧格式」这句依然成立。变式:如果反过来——你在一台机器上用新版写了会话,拿到另一台还没升级的机器上打开,会发生什么?(提示:coordinator 那条错误消息就是为这个方向写的,而且它不对称。想想为什么向前不兼容比向后不兼容更容易做对。)
15.5 LSP:grep 为什么不够
不用它会怎样?模型想知道 resolveChildDepth 这个函数在哪些地方被调用。它能用的就是 grep。grep 匹配的是字符:同名但不同类型的方法会被一起匹配上,注释和字符串里的同名词也会匹配上,而通过接口调用、类型推导出来的间接引用一个都找不到。要判断改这个函数会波及哪里,模型只能靠猜。
LSP(语言服务器协议)是编辑器里「跳转到定义」「查找所有引用」背后的那套东西——它由真正的编译器前端支持,知道类型、知道作用域。ctx.lsp 把这个能力接进来,通用提供方 dsh-lsp-stdio 负责按配置启动一个 stdio 语言服务器。
seam 只公开四个操作,而且这是个封闭联合类型:
type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
封闭的意思是:加一个操作会通过编译强制要求同步改动 seam、提供方和工具三处,漏改一处编译就不过。这是第11章那个三角的具体体现。
一个容易望文生义的地方:这里没有诊断
很多人(包括我最初写这一节时)会想当然地认为「LSP 嘛,跳定义、找引用、看错误提示」。这个仓库没有诊断。packages/lsp/lsp/README.md 的「Known Limitations」写得很明白:符号和调用层次被推迟(它们需要不同的 schema),诊断需要单独的新鲜度与累积规则,而修改类操作(重命名、code action、格式化)需要独立的工具并接上预览、权限和写策略。lsp-stdio 的连接层里还有一行注释直说了:服务器主动推来的通知(例如诊断)「被这个 MVP 宿主忽略」。
为什么诊断特别麻烦?我的理解(本站推断):前四个操作都是问一句答一句,而诊断是服务器持续推送的流——你得决定什么时候的诊断算数(文件刚改完,服务器可能还没重新分析)、要不要累积、过期的怎么清。这套「新鲜度」规则和现在的请求-响应模型是两回事。
还有一个细节体现了「面向模型写约定」的原则:协议用的是从零开始的 UTF-16 坐标,而面向模型的工具用的是从 1 开始的光标约定,在输入和输出两个方向都做转换。为什么不省掉这层转换?因为人和模型讲行号都是从 1 开始的,让模型去适应一个协议实现细节,是把机器的麻烦推给了使用者。
给出两个具体的重构任务:一个用 grep 就能可靠完成,另一个必须用 findReferences。说清楚分界线在哪。
SUBAGENT_DELEGATION_CONTEXT(全大写常量,几乎不可能重名),一个像 run(到处都是)。分别设想 grep 的结果。SUBAGENT_DELEGATION_CONTEXT 改名。它是全大写、语义唯一、只可能以标识符形式出现;grep 的结果基本就是全集,人扫一眼就能确认。必须用 LSP 的例子——把某个接口上的方法 run() 改名。grep run 会命中几百处:别的类的同名方法、注释里的英文单词 run、字符串 'npm run build'、变量名 runId(如果你没加词边界)。而真正该改的那些调用里,有一部分是通过接口类型调用的——调用点的字面文本可能是 provider.run(...),你光看这一行根本不知道 provider 是不是这个接口。分界线:grep 回答「哪些地方出现了这串字符」,LSP 回答「哪些地方引用了这一个符号」。当「字符相同」和「符号相同」这两个集合几乎重合时(唯一的长名字),grep 够用;当它们差得远时(常见短名、接口方法、重载),grep 给你的是一份既有假阳性又有假阴性的清单——而假阴性最危险,你不会知道自己漏了什么。变式:那 goToImplementation 和 goToDefinition 有什么区别,为什么要分成两个操作?(提示:想想接口和实现类。定义在接口那一行,实现可能有五个。)
15.6 terminal:持久终端和一次性命令的区别
不用它会怎样?一次性命令(bash 工具)每一次都开一个新进程:你 cd 进一个目录,下一条命令又回到原点;你 export 一个环境变量,下一条命令看不见它;更要命的是交互式的东西——Python REPL、ssh、需要输入密码或者按 y 确认的命令——一次性执行模型根本表达不了。
ctx.terminals 提供的是持久 PTY 会话:开一个终端,它一直在那儿,你往里发字符、读输出,状态(工作目录、环境变量、正在运行的程序)全都保留。
这里最需要理解的是一个词:TerminalWaitReason——「一次发送为何返回」。四个值:
stdin_read:程序在等你输入了。inferred_idle:推断它安静下来了。timeout:等够时间了。session_exit:顶层 shell 退出了。
注意第二个词:推断。终端里的程序不会告诉你「我说完了」,harness 只能观察「一段时间没有新输出」然后猜。文档因此加了一条极重要的免责声明,tool-terminal 的模型说明里逐字写着:「inferred_idle 或 timeout 结果不能证明前台命令已经退出。」同一段还给了使用纪律:只有在工作需要持久终端状态或交互式 stdin 时才用终端会话;有界的一次性操作优先用 shell/read/write/edit。还有一句很实在的:追踪每一个终端会话 id,不再需要的就关掉——持久的东西不关就会一直占着。
对你而言未知「推断它安静下来了」到底是怎么推断的?
inferred_idle 是这一节里唯一一个靠猜的东西,而它的准确程度直接决定了 agent 用终端时的体验:猜早了,命令还在跑就返回,模型读到半截输出;猜晚了,每次都白等。仓库有具体实现和可配置的参数,只是这一页文档没展开。
怎么入手:从 packages/terminal/terminal/src/types.ts 找到 TerminalWaitReason 的定义位置,然后往上找是谁产出这个值——重点看那个包的后端实现里跟静默时长、输出速率有关的配置项。然后回答两个具体问题:(1) 判据是「多久没有新字节」这么简单,还是也看输出的形态(比如是否出现了提示符)?(2) 一个每隔两秒打印一行进度条的命令,会不会被判成 idle?第二个问题的答案能直接告诉你这套判据的适用边界。
15.7 Code Mode:让模型写代码来调工具
不用它会怎样?模型要做「读这 5 个文件,把里面所有的 TODO 提出来,按文件名排序」。原生工具调用的做法:调 read 五次,每次的完整文件内容都回到上下文里,然后模型自己在脑子里筛选和排序。五个文件的全文,全部进了历史。
Code Mode 的做法:模型写一段程序,程序里 await tools.read(...) 五次,在程序内部筛选排序,最后 return 一个只有几十行的结果。packages/core/tools/README.zh.md 里那句话是关键:「只有程序的外层日志与返回值会重新进入模型上下文。」
机制上,tools: { mode: code } 会做三件事:公开一个保留的 run_code 传输、生成一段 tools:sdk 提示词段(把每个可见工具的参数与返回类型声明出来)、并声明一条 tools:code-only 规则。第三条是被执行器真正执行的:模型直接调用其他任何工具,会在创建执行时、早于 tools/pre-execute、审批和守卫,就解析为 UNKNOWN_TOOL。拒绝信息还特意写清了正确路径(不能只说 unknown tool,因为同一份提示词刚刚声明过那个工具,模型会以为部署坏了)。
而每一个 SDK 绑定调用仍然走完整的工具流水线——第9章那七道关一关不少,只是以外层执行的 token 作为 parent。所以 Code Mode 不是绕过安全检查的后门。
真未解Code Mode 到底省不省 token?
先说仓库自己怎么说的。packages/core/tools/README.zh.md 的「Token effect」一节,最后一句是(逐字):「Code Mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。」
这句话很克制,而且是对的。拆开看有两笔账,方向相反:固定成本这一笔可能是亏的——生成的 SDK 文本要把每个工具的参数和返回类型都声明出来,它未必比原来的 JSON schema 短;工具越多,这段文本越长,而且它每次请求都要发。变量成本这一笔是赚的——中间结果留在程序里不回上下文,这一项的收益跟「中间数据有多大」成正比。所以真正的问题是:这两笔账在什么条件下交叉?没有公开的对照测量能回答。
怎么入手:先跑 pnpm run demo:code-mode(仓库里有这个 demo)。然后设计一个能分离两笔账的实验:固定一组工具,只改变「中间数据量」这一个变量——比如让任务分别处理 1 个、5 个、20 个文件,两种模式各跑一遍,记录每次请求的输入 token。packages/llm/token-meter 提供的 TokenMeasurement 正好是干这个的(它带一个 logRevision 字段,标明这次计量消费了多少条持久事件)。你要找的是两条曲线的交点——那个交点就是「该不该开 Code Mode」的判据,而这个判据目前没有人公开发表过。
第14章说子 agent「本质上是压缩的另一种形式」。Code Mode 看起来也在做同一件事——中间结果不进父上下文。请说出这两者的三个实质区别(提示方向:谁在执行、失败了会怎样、结果的可追溯性)。
completed 的 stopReason 结算,父亲拿到一个 isError 的工具结果加上孩子保留的部分文本。Code Mode 失败会抛 CodeRunFailedError,消息是「失败类型 + 已捕获日志」,流水线把它变成一个结构化的 isError 好让模型自我修正——注意这个差别:程序失败了模型可以改代码重跑,孩子失败了父亲只能换个说法重派。区别三,可追溯性。子 agent 的过程在另一条会话日志里,父会话完全看不到(第14章)。Code Mode 的每个子调用都在同一条会话日志里留下记录——tool/code-dispatch-start 和 tool/code-dispatch 两个事件,带确定性 id <parent>:code:<n>,用的是 tool/result 的词汇,所以 UI 会像呈现普通工具调用一样呈现它们。但这两个事件不进模型历史(deriveMessages() 不公开它们)。也就是说:Code Mode 对模型隐藏了中间过程,对人和日志没有隐藏;子 agent 对两者都隐藏了。这是第6章「记账和给模型看是两件事」的又一个实例。变式:什么情况下你会把两者叠起来用——在一个工作流脚本里用 Code Mode?(提示:第14章表 14-1 那三行,再想想「确定的循环交给代码」这条判断规则可以套几层。)
15.8 MCP:把别人的工具接进来
不用它会怎样?你想让 agent 能操作公司的 issue 系统。你得自己写一个插件:定义 schema、写执行器、处理认证、维护它。而这个 issue 系统的官方可能已经提供了一个 MCP 服务器——别人写好、别人维护,你却用不上。
MCP(Model Context Protocol,模型上下文协议)是一个跨 harness 的工具接入协议。dsh-mcp-client 是桥接插件:连上外部 MCP 服务器,把它们的工具注册到 ctx.tools——注意是注册到同一个注册表,不是另开一套。也就是说,MCP 工具和原生工具走的是完全相同的七道关:同样的审批、同样的守卫、同样的 spill 策略。
配置上,每个服务器一个插件实例,支持 stdio(起一个子进程)和 streamable-http(连一个 URL)两种传输。模型看到的工具名是服务器限定的:mcp__<serverName>__<rawName>,比如 mcp__github__create_issue。文档特意说明这个形状和 Claude Code、Codex 用的一样——照顾的是模型:它在训练里见过这个前缀形状。
还有一句在实践中很值钱:HMR 支持热替换,改配置会触发断开 + 重连,不用重启进程;只要 serverName 不变,重连后生成的工具名完全相同(第8章讲过为什么这对提示词的缓存前缀很重要——工具定义一变,从那一条起后面的缓存就全作废了)。
你接了两个 MCP 服务器,一个叫 github,一个叫 gitlab,两边都提供一个叫 create_issue 的工具。会不会撞名?模型看到的是什么?
ctx.tools 的名字是原始名字,还是加了前缀的?mcp__<serverName>__<rawName>。serverName 由谁决定?mcp__github__create_issue 和 mcp__gitlab__create_issue——serverName 就是命名空间,它由你在 cordis.yml 里配置,规则是 [A-Za-z0-9_-]{1,32} 且在存活实例中唯一。这个设计顺带解决了另一个问题:模型光看工具名就知道这个操作会打到哪个系统,不需要额外解释。反过来说,如果你给两个实例配了相同的 serverName,那就真的会撞——第8章讲过,同一层内名字重复会大声抛异常,不会静默覆盖。变式:一个 MCP 服务器提供了 40 个工具,全部注册进来。这会带来什么代价?(提示:第8章 8.6 那笔提示词成本的账,以及第14章讲的 toolFilter。)
15.9 hooks:外部程序在流水线上插一脚
不用它会怎样?你想在每次写文件之后自动跑一遍格式化,或者在每次执行 shell 命令前记一条审计日志。这些逻辑用什么写?如果只能写 TypeScript 插件,那就意味着:改一次要动仓库、要重新构建、要懂 Cordis。而这些需求往往只是一个 shell 脚本。
这里要先分清两个层次,packages/hooks/README.zh.md 说得很清楚:规范扩展接口本身,就是 harness 的类型化拦截点(也就是第9章那七道关上的各个扩展点);所谓「原生钩子」只是挂在这些扩展点上的普通 Cordis 插件。hooks 这一组包不是新机制,它们是桥接——把外部的 shell 钩子协议翻译到同一套接口上。
三个包:hook-protocol(共享协议库)、hooks-claude-code 和 hooks-codex(两个桥接插件)。用法是把桥接插件指向你已有的 hooks.json(或对应的设置),那些为 Claude Code 或 Codex 写的 shell 钩子就能忠实地在这个 harness 里跑起来。
为什么值得专门做这两个兼容层
这不是技术问题,是迁移成本问题。一个团队可能已经积累了十几个 hook 脚本:提交前检查、敏感文件保护、命令审计。如果换 harness 就得全部重写,那这个 harness 的实际迁移成本远高于它看起来的样子。做一个兼容层,等于说「你原来的东西可以直接搬过来」。
顺带注意分层的干净程度:桥接没有为自己发明新的拦截点,它复用的是 harness 已有的那套。所以一个 shell 钩子能做的事,恰好是一个原生插件能做的事的子集——不会出现「用 hook 能绕过某个检查」这种漏洞。
15.10 遥测:花了多少钱、慢在哪
不用它会怎样?你部署了这个 harness 给一个团队用。月底账单来了,你不知道钱花在哪:是某个人的某个会话失控了,还是所有人都在正常用?某个操作特别慢,你不知道慢在模型请求、工具执行还是持久化。你只有一堆会话日志文件,没有任何聚合视角。
这里同样是两个东西:
其一,ctx.sessionTelemetry(会话遥测)——把会话里发生的事导出到外部系统。Service Definition 拥有捕获点、固定分片投影、脱敏 waterfall 和 handoff 游标;提供方 dsh-session-telemetry-otel 是一条按原样配置的 OpenTelemetry 日志流水线。这个 seam 有一条被称作「边界公理」的规矩:harness 的职责止于 emit();批处理、重试、排队与丢失策略都属于上报 SDK。不重新发明一个可靠投递机制,是很克制的选择。
脱敏这一环特别值得看。session-telemetry/record 是一个 waterfall(第7章讲过:监听器必须调 next() 才会往下传),它是这个 seam 的脱敏扩展点。而它自己一条规则都不带——文档原话:「最内层的 next() 原样放行该记录,在没有挂载任何 listener 时记录会按捕获时的样子到达后端,因此导出的数据恰好和部署挂载的规则一样干净。」这句话是在说:默认是什么都不脱敏。如果你没配规则,你的工具参数、文件路径、命令行,会原样出现在你的可观测平台里。
另有一条重要保证:脱敏只作用于导出的副本,规范会话日志绝不被重写。第6章那条「只往后加,从不修改」在这里没有例外。
其二,dsh-token-meter(token 计量)——它公开一份独立的回放快照,表示「请求压力」与按位置计算的表层定价。里面有一个字段叫 logRevision:「生成该计量中每个字段时所消费的持久事件数量」。也就是说这份计量可以精确对应到日志的某个位置,你能回答「第 400 条事件的时候上下文有多大」这种问题,而不只是一个当前值。
15.11 attachment:图片怎么进上下文
不用它会怎样?用户拖一张截图进来。最省事的做法是把它 base64 编码后直接塞进会话事件里。后果是:一张 2 MB 的图变成约 2.7 MB 的 base64 字符串,写进那条仅追加的日志——从此这个会话的每一次加载、每一次回放、每一次投影都要搬运这坨字符串。发十张图,日志文件就废了。
attachment seam 的做法是把二进制的所有权从会话日志里分离出去。生产方把校验过的编码字节交给 ctx.attachments;服务在对象完成持久化之后才发布一个不可变的、内容寻址的引用。会话事件和模型可见的 ImageBlock 里放的是这个引用加元数据,文档特意列举了绝不放什么:浏览器 object URL、宿主临时路径、提供方 URL、base64 数据——四样全排除。
顺序也是有讲究的:宿主接受用户消息后,先把图片移到 <DSH_HOME>/attachments/v1 下,再追加用户事件。为什么不能反过来?因为反过来的话,日志里会先出现一条指向「还没存好的东西」的引用;如果这时候崩了,你就有了一条永远解析不了的日志记录。这是第9章「在做出决定的那个操作里执行这个决定」的同一个思路:只在事实成立之后才记录它。
AttachmentId 是不透明字符串。本地后端目前生成的是 sha256:<digest> 的形式,但文档下了死命令:消费方既不能解析这种表示,也不能据此派生文件系统路径。今天它长这样,明天可能不。
15.12 web server 与 client:那个 UI 是怎么接上的
不用它会怎样?没有 UI。你只能用命令行——对很多场景够用,但你没法看到实时的工具卡片、diff、图片,也没法多人共用一个实例。
dsh-host-webserver 是最底下那一层:一个 node:http 插件,提供 ctx.webServer,包含具名路由注册表、index.html 转换回调,以及一个可被插件认领的回退处理器。它的定位写得非常明确,值得逐字看:「它不属于 agent loop,也不是能力 seam;它不了解任何 harness 概念。」所有功能路由——/api 桥接、插件 bundle、HMR 事件流——都由别的插件注册上去。
上面一层是 dsh-client-modules。它做的事很有意思:扫描宿主 Loader 的 entry,找出声明了 dsh.client 的包,把它们组合成一张启动图(window.__DSH_BOOT__),在 /plugins/<id>/client.js 提供各个 bundle,并通过 index 转换把这张图注进 <head> 的第一个脚本。
换句话说:浏览器里的那个界面,也是由插件拼出来的。第5章讲的「一切皆插件」不止于 Node 那一侧——一个包可以同时有 host 半和浏览器半,Loader 里加载了它,浏览器里就多出对应的界面模块。
两个细节值得记:其一,注入图的时候 < 被转义,所以插件可控的字符串无法逃出 script 元素——这是一个防注入的具体动作。其二,没有有效 manifest 的页面无法启动,浏览器侧的解析器会在图缺失或畸形时大声抛错,而不是降级成一个半残的界面。
还有一个容易误会的地方:Electron 客户端不用这个服务器。它通过 file:// 加载已构建的文件,用 IPC 桥接转发 fetch 请求。所以「Web 服务器」这个名字要按字面理解——它只服务浏览器。
15.13 测试与不变式:640 个测试文件,和一个没被挂载的服务
最后这一节讲这个仓库怎么保证自己是对的,以及一个我核实之后发现和直觉不符的事实。
先给数字(我在 commit 47f9438 上自己数的,命令是 find 找 *.spec.ts 和 *.test.ts 并排除 node_modules):
| 位置 | 文件数 |
|---|---|
packages/ | 640 |
scripts/ | 45 |
apps/ | 7 |
| 合计 | 692 |
数字会随你的计数口径变(要不要算 vendor/、要不要算 Python 那边的 test_*.py),所以请以你自己跑出来的为准。覆盖率的门槛倒是明文写着:pnpm run test:coverage 才是 CI 的覆盖率闸门,要求 packages/*/*/src 逐文件 100%。
ctx.invariants:包自己声明自己的运行时不变式
除了测试,仓库还有一套运行时检查机制。dsh-invariants(在 packages/runtime-diagnostics/invariants)提供 ctx.invariants,每个工作区包发布一个 ./invariant 配套插件,用自己确切的 npm 包名注册检查。
它检查什么是有严格约定的:断言权威事件流或可变数据,绝不是服务或方法是否存在。也就是说不许写「ctx.goals 存在吧?存在,通过」这种废话检查。packages/goal/goal/src/invariant.ts 是个好例子:它维护一份独立的目标状态折叠,在候选事件进入持久日志之前就拒绝格式错误的变更、不连续的修订号、非法的生命周期转换。
没有可检查关系的包怎么办?导出一个空安装器,注释必须以 No runtime invariant: 开头并具体解释为什么这个包没得可查。pnpm run verify-package-invariants 会机械地拒绝没有解释的空安装器、漏掉报告器的非空安装器、错误的注册名称。
答辩:如果我是审稿人
692 个测试文件、逐文件 100% 覆盖率、每个包都有运行时不变式——听起来铜墙铁壁。可我 grep 了一遍:packages/bundle/base/cordis.patch.yml、headless/cordis.patch.yml、web-app/cordis.patch.yml 三份发行组合包的补丁里,一个 invariant 字样都没有;全仓库唯一一处非测试代码里的 ctx.plugin(InvariantRegistry, …),在 packages/examples/agent-spine-demo/src/index.ts 第 245 行——一个 demo。那这些不变式在真实产品里到底跑不跑?
参考防守(先自己组织语言再看)
先承认事实,因为它就是事实,我逐条核实过:没有任何一个发行组合包挂载 dsh-invariants 服务。而每个配套插件都写着 export const inject = ['invariants']——Cordis 的规矩是依赖不满足则 fiber 不激活。所以在一个标准 dsh --profile headless 的进程里,这几百个配套插件一个都没在跑。
那它们的价值在哪?三条。
第一,它们在测试拓扑里跑,而且跑得很凶。我数了一下,ctx.plugin(InvariantRegistry, …) 出现在大量 tests/ 文件里(subagent、goal、jobs、sandbox-policy、credentials……)。也就是说这套机制的实际用法是:在测试里把断言打开,让每一条被测行为顺便接受一次不变式检查。它不是生产监控,是给测试加的一层横切断言。这样一来,一个包的不变式违规不只在它自己的测试里暴露,任何用到它的测试都会暴露——这比在每个测试里手写断言覆盖面大得多。
第二,服务本身设计成可以在生产里打开。Config 有 enabled(默认 true)、package_allowlist、package_blocklist 三个字段,正则筛选。这套东西存在,说明「在生产里按包打开一部分检查」是被预期的用法,只是发行默认不开。这是个合理的默认:不变式检查要跟着热路径跑,成本不是零,而且失败方式是抛异常(InvariantError),在生产里让一个诊断检查把用户的活干挂掉,代价可能比它防的问题还大。
第三,也是最该讲的:机械闸门本身就是产出。verify-package-invariants 强制每个包要么写出一条能检查的关系,要么写清为什么没有可检查的关系。这个动作发生在写代码的时候,跟运行时开不开无关。你被迫回答「我这个包拥有什么可观察的关系」,这个问题本身就会逼出更清楚的设计。
但审稿人的批评仍然成立的那部分是:如果有人读了 docs/subsystems/invariants.zh.md,以为自己部署的产品在生产中受这些检查保护,那是误解,而文档没有明确澄清这一点。要判断你自己的部署里到底开没开,去看你的 cordis.yml 里有没有 @deepseek-ai/dsh-invariants 这一行——没有就是没开。
你的部署用的是 dsh --profile headless,没有改过组合。一个同事说:「dsh-goal 的不变式会在坏的 goal/change 事件写进日志前拦住它,所以我们不用担心目标状态被写坏。」请判断这句话在你的部署里成不成立,并说出你会怎么验证你的判断(不要只靠读文档)。
inject。去看 packages/goal/goal/src/invariant.ts 的头几行,它声明注入了什么服务。没有这个服务会怎样?packages/bundle/headless/cordis.patch.yml 里搜 invariant。搜不到的话,再想想还有什么地方可能挂载它。packages/goal/goal/src/invariant.ts 写着 export const inject = ['invariants']——它需要 ctx.invariants 服务才能激活。(2) 三份发行组合包补丁(base、headless、web-app)里搜不到任何 invariant 行,也就没有任何一个挂载了 dsh-invariants。(3) 依赖不满足,配套插件的 fiber 不激活,那份独立折叠从来没装上,检查一次都没跑。怎么验证(这是这道题的重点,别只靠读文档):办法一,启动之后用仓库自带的自省能力问一句 ctx.invariants 在不在——packages/extensions 下有面向模型的 cordis inspect 工具(ctx.cordisInspect),它就是让 agent 查询自己运行时元数据用的。办法二,最直接——故意造一个违规:写一小段测试,往一个会话里追加一条修订号不连续的 goal/change,看它是被拒绝还是被接受。被接受就说明检查没在跑。办法三,读你实际用的那份 cordis.yml(包括 profile 叠上来的每一层补丁),找 @deepseek-ai/dsh-invariants。顺带一个更值得记住的教训:「文档描述了一个机制」和「你的部署里这个机制在跑」是两件事,中间隔着组合配置。第5章讲的 profile/组合包不只是启动方式,它决定了哪些保证对你成立。变式:假设你决定在生产里打开它。package_allowlist 该怎么写才既有用又不至于让一次诊断失败搞挂用户的活?(提示:想想 fail() 抛的是 InvariantError,抛在哪条路径上;再想想先从哪几个包开起风险最低。)
不加新层了——回头审视一遍
前面十四层,你的 mini-harness 已经有了循环、轮次、日志、工具流水线、守卫、压缩、子 agent。这一层不加东西,改成一件更有用的事:对着这一章的十三个子系统,给自己造的东西做一次体检。
做法很简单。拿一张纸(或者开个文件),画三列,把十三项逐一归位:
我猜你会发现三类情况。第一类,你以为没有其实有个雏形。比如你多半已经写过「工具结果太长就截断」——那就是一个最原始的 spill,只是它把内容扔了而不是存起来给个引用。再比如你可能写过「把 messages 存成一个 JSON 文件」——那是一个最原始的会话持久化,只是它没有 flush 检查点,也不处理崩在半路的情况。
第二类,你确实完全没有,而且暂时也不用有。LSP、MCP、web server 大概率属于这一类。它们的共同点是:没有它,你的循环功能上不缺什么,只是能力上限低一截。
第三类最值得停下来想:你以为不需要,其实是还没撞上。沙箱和 attachment 是这类的典型。你的 mini-harness 在自己电脑上跑、只处理文本,当然用不上。但只要有一天你让它跑别人给的代码,或者让它读一张截图,这两项就立刻从「不需要」变成「必须有」。
做对了的样子:「我有个简陋版」这一列,每一格都必须能指出你代码里的一个具体函数名或一行。指不出来的,老老实实划到「我完全没有」去——「我的循环大概也算处理了这个」不算数。
第二条判据:如果「我确定不需要」这一列超过 3 项,回头把那几节的「不用它会怎样」再读一遍。多半不是你不需要,是你的 mini-harness 还没跑到会撞上那个问题的规模。真正可以理直气壮划进这一列的,只有你能说出一个具体理由的那些——比如「我的 harness 只在一个受信任的容器里跑一个人的代码,所以沙箱这一层由容器提供了」。说得出替代方案的才叫不需要,说不出的都叫还没遇到。
本章小结
十三个子系统,一句话一个:沙箱包住子进程的文件效果,和第10章的守卫构成粒度互补的两层(审批管调用,沙箱管进程树),而且它不管网络;spill 把超大工具结果换成引用加预览,并且故意跳过 read 以免造出无限循环;skill 让说明书按需加载而不是常驻提示词;会话持久化崩溃后补一条合成的 interrupted 而不截断日志,但 SESSION_FORMAT_VERSION 停在 0 且明说无兼容承诺;LSP 只有四个操作、没有诊断;terminal 保住跨命令的状态,但 inferred_idle 只是推断,不证明命令退出了;Code Mode 让中间结果留在程序里,可仓库不承诺它普遍省钱;MCP 把外部工具注册进同一个 ctx.tools,走同样七道关;hooks 只是把外部 shell 协议桥接到已有的拦截点上;遥测止于 emit(),脱敏默认一条规则都不带;attachment 先持久化再追加事件,引用里绝不放路径或 base64;web server 不懂任何 harness 概念,界面本身也是插件拼的。
最后一项最值得你记住的不是数字,而是那个核实出来的落差:文档里描述得很完整的 ctx.invariants,没有任何一个发行组合包挂载它。这不是仓库在骗人——机制是真的,测试里真的在用,机械闸门也确实逼出了更好的设计——但「文档描述了一个机制」和「你的部署里它在跑」中间隔着一层组合配置。看完文档,永远还要再看一眼你自己那份 cordis.yml。
第16章 你要改它:从读代码到提 PR
一句话导语:前面十六章都在讲这个系统是怎么想的;这一章讲你怎么真的动手改它,以及改完之后怎么知道自己没把别的东西弄坏。
学完这一章你应该能做到
- 用两条不同的路把这个仓库跑起来,并说清这两条路的区别在哪
- 在动手写第一行代码之前,先判断出「我想加的东西该挂在哪个扩展点」
- 照着仓库自己的教程给它加一个能被模型调用的工具,并验证它真的到了模型手里
- 说清
llm-replay(录制回放)解决的是什么问题,以及没有它测试会变成什么样 - 解释「空的不变式安装器必须写解释」这条规矩在强迫谁做什么
- 把自己造了十六层的 mini-harness 和真仓库做一次逐层对照
16.1 先把它跑起来:两条路,区别很大
读了十六章代码,第一件该做的事不是继续读,是让它在你面前动起来。仓库给了两条路,它们不是「快的那条」和「慢的那条」,而是两件不同的事。
第一条:只是想用。README.zh.md 的原话是,装好 Node.js 然后运行:
npx @deepseek-ai/dsh web
它会启动 Web UI,默认地址 http://127.0.0.1:3080。这条路拿到的是已发布的产物——里面全是编译好的 JavaScript,你可以配置它、给它挂自己的插件,但你改不了它自己的源码。
第二条:想改它。
git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install pnpm run build pnpm dsh web
四条命令,每一条都有你该知道的事。
pnpm install 不只是装依赖。按 docs/development.zh.md,它还会通过 scripts/install-lefthook.mjs 配置两样东西:worktree 本地的 Lefthook 钩子(提交前跑的那些快速检查),以及一个叫 dsh-translation-pairing 的 Git 合并驱动。后者是干什么的,16.9 会讲——现在只要知道,这个仓库连「中英文档配对记录怎么合并」都写进了 Git 配置里。
前置条件也别跳过:Node 22.19+ 或 24+(CI 跑 22.19、24、26 三个版本),pnpm 通过 Corepack 固定在 11.7.0(pnpm --version 解析不出来就先 corepack enable),Git 2.26 以上。
新克隆之后,文档给了一个明确的「搭建完成」判据:
pnpm run typecheck
它成功退出,就说明环境搭好了。注意这条命令比它看起来重的多——按 docs/development.zh.md,typecheck 会先跑完整的 Host lib 阶段(包含 Typert 的约定生成),再跑 Client 的 TypeScript 检查。第一次跑会比较久。
为什么 pnpm run build 不能跳
你可能会想:既然 pnpm dsh 是从源码启动的(它走的是 node --import tsx/esm apps/cli/src/bin.ts),为什么还要先构建一次?
因为这个仓库有些东西是生成出来的,不是写出来的:Typert 的类型图、Host 给 Client 用的 Remote 声明、Web 前端的产物。docs/development.zh.md 把构建顺序写得很死——先 tsc -b tsconfig.host.json,再 tsdown(Host 面),再 Client 的 tsc 和 tsdown,最后 build:web。跳过它,你会在一个「源码全对但产物是空的」的状态里排查半天。
要跑真实模型,需要一个 DEEPSEEK_API_KEY,从环境变量或仓库根目录一个被 gitignore 的 .env 读取,可选 DEEPSEEK_BASE_URL。没有密钥也能开发:无密钥的测试通道是完整的,这一点 16.6 会展开。
读到这里要记住的一件事
仓库处于开发者预览,README.zh.md 明说「未来将出现破坏兼容性的变更」。本章给出的每一条命令、每一个文件路径,都是 commit 47f9438(2026-08-13)时的状态。命令跑不通的时候,第一反应应该是去看仓库当前的 package.json 和 AGENTS.md,而不是怀疑自己。
16.2 先看清自己那棵树:--dump-config
第 5 章讲过:跑起来的 dsh 是一棵插件树,由若干层叠加而成。现在你要改它,第一个问题就是——我这台机器上,这棵树到底长什么样?
dsh --profile web --dump-config
它把这个 profile 组合出来的配置整棵打印出来,并且带注释标明每一行来自哪个源文件、哪一层 patch。docs/architecture.zh.md 紧跟着一句话,是这条命令真正的意义:它打印出的任何条目,都可以由你自己的 patch 替换。
叠加顺序(apps/cli/src/profile-boot.ts 里的 composeProfile)是:
| 顺序 | 层 | 谁拥有它 |
|---|---|---|
| 1(最底) | 各个组合包的 cordis.patch.yml,按 dsh.profile.bundles 里的顺序 | 仓库 / 你装的插件包 |
| 2 | $DSH_HOME/profiles/<name>/cordis.patch.yml | 你,针对这一个 profile |
| 3 | $DSH_HOME/cordis.patch.yml(home 级) | 你,针对这台机器的所有 profile |
| 4(最上) | 命令行 --patch 给的覆盖文件,按 argv 顺序 | 你,针对这一次启动 |
注意第 3 层压在第 2 层上面:packages/boot/app-boot/README.md 的说法是,home 级是「机器本地偏好,适用于每个 profile,所以它压过 per-profile 那层」。这个顺序不直觉,值得单独记一下。
本站核对结果:dump 出来的树,和真正启动的树不完全一样
本站对照了两份源码:apps/cli/src/profile-boot.ts 里的 composeProfile,在四层之后还会追加两样东西——一条 agent-presets 的交付根覆盖,和一条遥测开关;而 apps/cli/src/dump-config.ts 的 runDumpConfig(第 30–52 行)没有这两步。另外,该文件的模块 JSDoc 自己写明:dump 走 patch 算法但不启动、也不求值 !!js。
所以 --dump-config 是一份「配置层怎么叠」的高保真示意,不是运行时快照。这是本站的核对结果,不是仓库承认的问题;第 17 章会把它和其他几条一起交代清楚。
你在 $DSH_HOME/profiles/web/cordis.patch.yml 里把某个插件的 config.timeout 改成了 60000,重启之后它还是老值。请说出至少两个可能的原因,以及你会先跑哪条命令。
dsh --profile web --dump-config,看那个 id 的 config 里到底是什么,以及注释标的来源是哪一层。$DSH_HOME/cordis.patch.yml 排在 per-profile 之上,命令行 --patch 更在其上;(2) patch 打空了——按 vendor/include/src/index.ts,一条 patch 找不到目标 id 时只打印一句 stderr 警告然后 continue,不报错、不中断,所以拼错 id 的表现就是「什么都没发生」;(3) 整个 config 被替换了——patch 按 id 定位后替换目标行的整个 config,不做深度合并,所以你只写了 timeout 一个字段的话,其余字段会一起消失,而不是保留。三个原因指向同一个观察点:--dump-config。先看树,再改文件。变式:如果你根本不确定这个插件有没有被挂上去,--dump-config 能回答这个问题吗?它能回答「挂上去了但没启动」吗?
16.3 改一个东西之前的第一个决定:它属于哪个扩展点
docs/architecture.zh.md 有一张叫「新行为的归属位置」的表,左边是「目标」,右边是「机制」,十七行。这张表非常有用,但它是查表用的——你得先知道自己要干什么,才能在左列里找到那一行。
对刚上手的人,更有用的是把它倒过来:变成一串你可以自己走一遍的问题。下面这个流程是本站根据那张表重新组织的,问题的顺序是按「答对了收益最大」排的。
docs/architecture.zh.md「新行为的归属位置」表重新组织的判断流程。示意图,问题的措辞与顺序为本站所拟。把每个问题展开讲一句:
问题一:它需要模型主动决定要不要用吗?如果答案是「模型得看着当前情况自己判断该不该做这件事」,那它是一个工具——ctx.tools.register(),schema 会自动流进提示词组装。判据很干脆:这件事需不需要出现在模型的能力清单里。
问题二:它是「同一件事的另一种做法」吗?接一家新模型厂商、换一个 shell 后端、把文件读写指向远程沙箱、换一种压缩策略、加一种子 agent 的启动方式——这些都不是新能力,是已有能力的新提供方。对应的动作是在对应的服务上注册:ctx.llm、ctx.shell、ctx.fs、ctx.subprocess、ctx.sandbox、ctx.terminals、ctx.subagents、ctx.compaction。答对这一问的收益最大,因为它决定了你是「加一个包」还是「改一堆包」。
问题三:它是「在别人的流程中间插一脚」吗?调用前拦一道、结果出来改一改、每一步之前塞点东西、轮次想结束时拦住它——这些全是监听器,挂在 agent/*、tools/*、fs/* 这些扩展点上。docs/architecture.zh.md 的原话是「拦截请求、工具或轮次:使用相应的 agent/* 或 tools/* 事件」。这一档的东西通常一个文件就写完了。
问题四:它只是「让模型知道一件事」吗?分两种:长期都该知道的,用 ctx.systemPrompt.section()(提示词里多一段,支持排序和作用域局部覆盖);这一次才需要知道的,用 agent.inject()——按仓库说法,它会落到下一次获准的请求里,而且不是唤醒:空闲的 agent 会继续空闲。
问题五:它是人触发的、模型不参与吗?ctx.commands。表格里的措辞很关键:「它无需模型轮次即可分派」。斜杠命令、面板按钮,都在这里。
问题六:它有必须活过重启的状态吗?那就得扩展 SessionEventMap,从日志渲染和回放。第 6 章的规矩在这里生效:模型可见的东西必须能从日志重建。
问题七:它只该对某一个 agent 生效吗?同样的注册动作,换个上下文做——用那个 agent 的 agent.ctx 而不是插件自己的 ctx。
AGENTS.md 挂了一道闸:改 agent-loop 必须同时更新 docs/architecture.md。需求:「每次模型要写文件时,先把改动发到公司的审计系统;审计系统说不行就拒绝这次写入;另外,模型应该知道公司有这条规定。」请把这个需求拆开,分别落到哪些扩展点上。
tools/pre-execute 上的监听器,返回 { kind: 'deny', reason } 或调 next() 放行;这正是 docs/cookbook/extension-cookbook.zh.md 里那个「权限门禁」示例的形状。(2) 让模型知道规定:一个 ctx.systemPrompt.section() 提供方,写清这条规则。(3) 如果你还想拦住那些绕过工具、直接走 ctx.fs 的写入,那就不是 tools/* 这一层的事了,得用 fs/write-intent/fs/edit-intent——注意这是单槽决策 waterfall,先注册者占位,而且按 docs/subsystems/filesystem.zh.md,「由策略插件占据是部署约定,而非强制不变式」。最容易错的是把三块并成一个插件里的一堆 if:它们的生命周期和作用域不一样,(2) 在请求组装时生效,(1) 在调用发生时生效,(3) 在资源层生效。变式:把需求改成「审计系统离线时,一律放行」。这个改动会踩到第 9 章讲的哪条原则?
16.4 走一遍:给它加一个工具
仓库自己有一份逐步教程:docs/user/develop/basic/index.zh.md(第一个插件)和 docs/user/develop/basic/tool.zh.md(开发一个工具)。下面把这条路走一遍,重点解释每一步在干什么。
第一步:造一个插件。在仓库根目录 mkdir -p scratch-plugin/src,写 scratch-plugin/src/my-plugin.ts:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
这段十几行代码里,每一处都对应前面某一章讲过的东西:
export const inject = ['tools']——第 2 章。这行让 Cordis 等到工具注册表就绪才启动这个插件。删掉它,插件可能在ctx.tools还不存在时就跑apply,直接炸。export function apply而没有 default export——packages/AGENTS.md的第一条硬规矩:函数式插件具名导出name/inject/Config/apply,不能有 default export。混用两种形态会让 loader 丢掉这个插件的命名空间(仓库为此专门写了一份事故复盘docs/postmortem/0001)。ctx.tools.register(...)的返回值是一个释放函数——第 3 章。注册是副作用,插件被卸载时工具自动消失,你不用写任何清理代码。parameters是模型看到的说明书;output.schema是程序看到的返回值形状;output.render把那个值转成模型读到的文字。三层是分开的,别混——第 8 章讲过为什么。
第二步:把它挂到配置里。创建 scratch-plugin/cordis.yml,把绝对路径换成你自己的:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
教程特意提醒:插件路径必须是绝对路径,因为「patch 文件只贡献配置,不会改变 loader 解析模块路径时使用的 profile 目录」。这是新手第一个坑。
第三步:跑起来验证。
pnpm dsh web --patch ./scratch-plugin/cordis.yml
打开 http://127.0.0.1:3080,输入 Use the greet tool to greet Ada.,模型应该调用 greet 并拿到 Hello, Ada!。
再往下走,docs/cookbook/adding-a-tool.zh.md 是工具约定的真源。几条最容易违反的:
| 约定 | 意思 | 违反了会怎样 |
|---|---|---|
| 参数已为你校验 | defineTool 在 execute 之前按 schema 校验模型给的参数 | 你重复写一遍校验,是在做无用功;但 schema 表达不了的约束(非空、正数、跨字段)仍要自己查 |
| 注册借用你的只读定义 | 注册之后不要改它的 schema 或替换回调 | 热替换要走 dispose + 重新注册,直接改对象的行为未定义 |
| 返回一个规范 JSON 值 | execute 只返回 output.schema 声明的值,不返回内容块 | 调用方被迫从自然语言里解析字段——这正是 Code Mode 想根除的事 |
抛异常或返回无效值即 isError | 注册表捕获异常并收敛各种失败 | 把基础设施故障当成正常返回值,模型会以为成功了 |
遵守 exec.signal | 信号触发就停下手上的活 | 用户按了取消,你的进程还在跑 |
| 展示方法必须是纯函数 | presentCall/presentResult 在实时流和日志回放时都会跑 | 回放一条旧日志时崩溃,或者画出来的卡片和当时不一样 |
常见误解:给工具设了 timeoutMs 就有超时了
packages/core/tools/README.md 的已知限制里写得很清楚:timeoutMs 只是声明性的——注册表从不执行截止时间,真正的超时来自 @deepseek-ai/dsh-tool-call-timeout-policy 这个包装插件。不加载它,你那个 timeoutMs: 5000 就是一行注释。
16.5 更大的改动:加一个包、一个模型适配器、一个 Chat 节点
上面那个工具只有一个文件。改动再大一点,仓库有三份对应的实操手册。
什么时候要新开一个包?docs/cookbook/adding-a-package.zh.md 给了判据:单一用途的插件保持为一个包;只有当一项能力需要「Service Definition/Service Provider/Consumer」三个角色各自独立演进时,才拆成多个包。shell 那一组是模板。
新开一个包不只是建目录。清单里有几件事会被机械门禁检查:package.json 必须 private: true、版本跟根一致、type: module、@deepseek-ai/cordis 同时出现在 peerDependencies 和 devDependencies;要在 tsconfig.host.json 或 tsconfig.client.json 里登记(恰好一个,绝不两个都加);README 必须写「Model Experience」三段式(What the model sees/Token effect/KV Cache effect)和「Known Limitations and Deferred Work」——没有限制可写的包要在一个 allowlist 脚本里登记理由。这些不是风格建议,是 pnpm run hygiene 和 pnpm run doc-sync 会红的东西。
什么时候要写一个 LLM 适配器?接一家新模型厂商的时候,仅此而已。docs/cookbook/adding-an-llm-adapter.zh.md 给了两个参考实现——llm-deepseek(直接 HTTP,SSE 分帧)和 llm-pi-ai(封装一个第三方库)——并列出了六条被两个实现共同验证过的协议义务。最容易踩的两条是:usage 必须在 finish 之前发,finish 之后不许再发任何东西;以及某个字段你的提供方不支持时,要抛 LlmError(..., 'UNSUPPORTED'),不许静默丢弃。第二条特别值得琢磨:静默丢弃一个 stop 列表,程序照跑,只是行为悄悄变了——这类 bug 能藏几个月。
什么时候要加一个 Chat 节点?当你的插件想在 Web 聊天流里画出自己的一行内容(不是一条普通消息,而是比如「一个 review 任务的进度条」)。docs/cookbook/adding-a-conversation-node.zh.md 的核心难点只有一个词:可回放。你的事件族必须做到「按日志 seq 升序重放时能确定性地产生同一个状态」,每条增量都得带稳定 id,绝不能依赖只存在于内存里的东西。文档里那句提醒最能说明问题:Client 端绝不能把一条 update 猜成属于「最近一个未完成的」上下文。
你要给 harness 加一个「把文本转成语音并播放」的能力。请给出一个判据,判断它该做成一个包还是三个包(Definition/Provider/Consumer),并各举一个会让你改变判断的具体情况。
speak 工具用,那么「接口」「实现」「消费方」三者永远一起改——按 docs/cookbook/adding-a-package.zh.md,「单一用途的插件保持为一个包」,此时拆三个包只是把一份代码摊成三份 package.json,你付了 packages/AGENTS.md 里那一整套包级义务(README 的 Model Experience、Known Limitations、tsconfig 登记、./invariant 配套插件)的成本,什么也没买到。会让判断反过来的具体情况有两个方向:(a) 提供方要多样——本机引擎和云端引擎的鉴权、延迟、可用性完全不同,而消费方不该知道差别;(b) 消费方要多样——除了模型调用的 speak 工具,UI 还要「朗读这段回复」的按钮,自动化端还要批量合成,这三个消费方对同一个接口有不同要求,此时 packages/AGENTS.md 那条「为所有当前 Consumer 设计 Service Definition,不要让某一个 Consumer 决定服务约定」才有意义。反过来说:只有一个消费方时,这条规矩无从检验——这正是第 17 章会展开的一个代价。变式:假设你已经做成一个包了,半年后真的出现了第二个提供方。此时把它拆成三个包,代价主要在哪里?仓库的「预发布立场」那一节对这种情况是什么态度?
16.6 测试体系:为什么它必须有一台「假模型」
先给尺度感。本站用 find 在 commit 47f9438 上数了一遍(去掉 node_modules):692 个 .spec.ts,其中 640 个在 packages/*/*/tests/ 下;另有 129 个 .e2e.ts。仓库根目录躺着七个 vitest 配置文件,其中 vitest.shared.ts 是公共片段、vitest.web.perf.config.ts 继承自 web 配置,真正独立的跑道是五条:
| 跑道 | 命令 | 它证明什么 | 要密钥吗 |
|---|---|---|---|
| 单元 | pnpm run test | 包内行为、边界、错误路径、事件顺序、并发竞态 | 不要 |
| 真实 API e2e | pnpm run test:e2e | 对着真模型能不能正常工作 | 要,没有就自动跳过 |
| 快照 | pnpm run test:snapshot | 组装完的应用产出的 transcript 与预期输出逐字节一致 | 不要(回放) |
| Web 浏览器 | pnpm run test:web | Chromium 里渲染出来的东西和金标一致 | 不要(CI 固定 replay) |
| Web 压力/性能 | test:web:stress/test:web:perf | 手动诊断用,不在 CI 门禁里 | 不要 |
还有一条不是跑道但很关键:pnpm run test:coverage。AGENTS.md 特意点名——它,而不是 test,才是 CI 的覆盖率门禁,标准是对 packages/*/*/src 按文件 100% 行覆盖。docs/testing.zh.md 对这个门禁的说明比数字本身更有意思:「未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试」,并且紧跟一句「行覆盖率是必要条件,但永远不是充分条件」。
为什么必须有 llm-replay
先回答「不用它会怎样」。
假设没有回放机制。那么任何一个想验证「整条链路装起来之后行为对不对」的测试,都必须真的调一次模型。后果有四条,每一条都致命:慢(每个用例几秒到几十秒);贵(CI 每次 push 都在烧钱);不确定(同样的输入,模型两次输出可能不一样,测试变成掷骰子);外部贡献者跑不了(没有密钥就一条也跑不了)。于是团队会做出唯一理性的选择:把这类测试写得极少,改用 mock 去替代真实组件。而 docs/testing.zh.md 恰恰明说 mock 替身「只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言」。
llm-replay(packages/test-support/llm-replay)的解法很漂亮,而且它的漂亮之处正好落在第 6 章那个决定上。它的 README 第一句就是关键:fixture 就是那份持久化的会话日志本身(<scenario>/session.jsonl)。因为会话日志里的 assistant/chunk 事件带着模型吐出的每一个 StreamChunk,把它们按 (turn, step) 分组,就能原样重建每一次 stream() 调用的分片序列。
所以「录制」这个动作是什么?README 的原话:「run the real agent once and harvest the .jsonl」——真跑一次 agent,然后把日志收走。这个插件本身不负责录制。
打个比方
像是给一场排练录了像。之后你要检查灯光、字幕、走位对不对,不需要把演员再叫回来一次——放录像就行,而且每次放出来的都一模一样。
类比失效处:录像只能重放已经发生过的那一场。演员临场发挥不一样(模型换了、提示词改了)的时候,录像救不了你,必须重录一次,也就是 pnpm run test:snapshot:record——那一次是要花钱的。README 也诚实列了两种录不下来的情况:一次纯粹的抛异常(比如 HTTP 401,日志里只有 turn/end {error} 没有任何分片)和取消/挂起(那是时序问题,不是分片内容),两者都得靠一个叫 replay.override.json 的旁挂文件补。
顺带一提这个仓库对密钥测试的立场,写得很直白(docs/testing.zh.md):「我们是 DeepSeek,不要吝惜真实 API 测试」,并且强调价值最高的是冒烟测试——启动真实示例、发一条提示词、检查外部世界。同一页还有一条纪律,任何写过测试的人都该记住:e2e 断言要重新跑一遍命令或从外部重读文件,绝不能对 agent 自己的输出做关键词探测,因为那样一个会作弊的 agent 就能通过。
你给工具执行流水线加了一层新的策略插件。你跑了 pnpm run test:snapshot,全绿。请说出这个「全绿」没有证明的两件事。
test:coverage 才是那道门禁。补充第三件也成立:全绿有时只是没人驱动到你的代码——所以 llm-replay 专门提供了 assertConsumed(),用来把「这个场景实际发起的模型调用比录下来的少」变成一条明确的诊断,而不是静悄悄的绿。变式:如果你的改动只影响 UI 卡片的画法,不影响发给模型的任何字节,五条跑道里哪一条最该跑?
16.7 运行时不变式:一套强制你解释「为什么没检查」的机制
这一节讲的东西在别的开源项目里很少见,值得单独拿出来。
ctx.invariants(packages/runtime-diagnostics/invariants)是一个注册表服务,用来登记「运行时不变式检查」。所谓不变式,就是一条「必须永远成立的关系」,比如第 6 章那条「模型可见即已记录」。
机制本身分三层。第一层:每个工作区包都发布一个 ./invariant 配套插件,用自己完整的 npm 包名去 ctx.invariants.register(packageName, installer) 登记。注册表为每个包名保留唯一一个活跃注册,重名、空白、带空格全部抛异常。第二层:被启用的安装器跑在一个专属的子 fiber 里,拿到一个绑定了包名的 fail(message);它抛出的 InvariantError 带稳定 code: 'INVARIANT' 和归属包名,消息前缀是 invariant violated by "<package>": …。所以违规能追责,而注册表自己不需要 import 任何产品包。第三层,也是最有意思的一层:
No runtime invariant: 开头,针对这个包具体解释为什么没有可检查项。然后 pnpm run verify-package-invariants 会机械地拒绝这些情况(docs/subsystems/invariants.zh.md 原文):「生成文件」标记、无解释的空安装器、遗漏或忽略报告器的非空安装器、错误的注册名称,以及不完整的导出、发布、依赖或打包接线。
停下来想一下这条规矩到底在做什么。它没法检查你的解释写得对不对——一段自然语言,脚本判断不了。它只能强制你必须写一段。但这就够了:它把「我想过了,这里不需要检查」从一个存在于某人脑子里的默认,变成了一份写在文件里、会被 code review 看到、会随包一起演进的文字。仓库还配了一句维护要求:「当所有方获得可变状态或事件协议时,必须重新审视该解释。」
另外要理解为什么规矩里说不变式必须断言权威事件流或可变数据,而不是「服务或方法是否存在」。AGENTS.md 的理由是:确认某个方法在不在、插件名对不对、注入有没有——这些是类型、加载或单元测试该管的事。运行时不变式要盯的是那些类型系统表达不了的关系。举个真的例子,packages/core/agent-loop/src/invariant.ts 里那条:每次发出 llm/stream 时,把请求里的 messages 和「从会话日志现场推导出来的 messages」做 JSON.stringify 比对,不一致就 fail(消息是 log-reconstruction desync)。这条关系没有任何类型能表达。
写之前必须知道:这套机制默认不在跑
第 15 章已经把这个落差核实过一遍,本站也独立复核了一次:检索所有 *.yml/*.yaml 之后,没有任何发行组合包挂载 dsh-invariants 服务(packages/bundle 下 base/headless/web-app 三份 cordis.patch.yml 里都没有它);非测试代码里唯一一处 ctx.plugin(InvariantRegistry, ...) 在 packages/examples/agent-spine-demo/src/index.ts:245。包 README 自己也写着:「单独加载服务不会安装任何产品检查。」
对你写代码的直接影响是:你写的那条检查,默认没有人会跑它——它在测试拓扑里生效(每个普通 Vitest 拓扑都会显式启用服务并挂上当前包的配套插件),在你的产品部署里不生效。所以别把它当成运行时的最后一道防线来设计,它是开发期的安全网。第 17 章会把这条的连带影响讲完。
真未解一句「这里没有运行时不变式」的解释,怎么判断它是对的?
仓库把「必须写解释」这件事做成了门禁,这一步是可机械化的:verify-package-invariants 能检查注释在不在、前缀对不对、注册名对不对。但它检查不了那段话说得对不对。一个包的作者完全可以写下「No runtime invariant: 这是一个纯工具库,没有可变状态」,而三个月后这个包悄悄长出了一个缓存——解释就过期了,门禁却依然是绿的。仓库对这种情况只给了一句人工要求:所有方获得可变状态或事件协议时必须重新审视该解释。
这是个真的开放问题,不只是这个仓库的问题:怎么让一段自然语言的免责说明,随着它所描述的代码一起失效?已有的思路都不完整——用类型或静态分析检测「这个包有没有可变状态」会大量误报(闭包、模块级常量、外部句柄都算吗);用「文件哈希变了就要求重新确认」会让每次无关改动都触发一次仪式(这正是本仓库对文档配对采用的办法,见 16.9,但它对代码注释是否也适用,是另一回事)。
怎么入手:先在仓库里把所有空安装器捞出来(grep -rn "No runtime invariant:" packages/),读二十个,给它们分类——哪些理由是「结构性的」(纯类型包、纯组合包、二进制入口),哪些是「当下恰好如此」(这个实现现在没状态)。前一类几乎不可能过期,后一类才是风险所在。然后设计一个判据:能不能只对后一类要求更强的检查?再往前一步就是真正的开放问题:这个判据本身能不能机械化。
16.8 防御式模式:用事故换来的六条规矩
docs/defensive-patterns.zh.md 开篇一句话就定了性质:「下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。」AGENTS.md 要求在写生命周期、并发、子进程或清理代码之前先读它。
六条里挑三条讲,因为它们最容易被自己写插件的人违反:
「dispose 必须达到完全停稳,而不仅仅是请求停止。」如果你的清理逻辑只是发一个终止信号就返回,不等工作真的停下,就会留下孤儿进程。文档要求的做法是:异步清理流程,等子进程退出(发信号后等 done);而且要在终止进程之前先关掉监听器注册表和通知注册表,让迟到的完成事件保持安静。
「在分发器中隔离回调异常。」用户提供的监听器抛异常,不得让它所在的 promise 被 reject,也不得饿死排在它后面的监听器。用 try/catch 包住分发循环并记日志——「一个行为不当的订阅者绝不能破坏核心生命周期」。
「正交结果独立上报。」这条最微妙。一个进程可能同时已经超时、又以退出码 0 结束(因为它捕获了终止信号)。文档要求 timedOut、signal、exitCode 各自单独上报,切勿把一个标志的上报嵌套在另一个标志的分支里,否则调用方会把「提前被杀掉的运行」读成「正常成功」。
还有一条属于安全边界,写插件时特别容易忘:绝不将环境变量或可预测路径暴露给不可信输出——启动的命令要用清理过的环境变量,移除名字里带 KEY/SECRET/TOKEN/PASSWORD 的项;临时文件放在权限 0700 的私有目录,随机文件名,用 'wx'、0o600 独占打开。
16.9 文档也是被机器校验的(以及为什么本站仍然找到了不一致)
读到这里你大概已经注意到,这个仓库的文档质量高得反常:中英双语、路径全对、类型块跟源码一字不差、目录页永远是新的。这不是靠自觉。
pnpm run doc-sync 是一个门禁集合。本站数了 scripts/run-gates.ts 里 docSyncLeafGates() 的返回列表(第 581–615 行):28 道门禁。挑四道讲清楚它们各自在防什么。
verify-translation-pairing:中英文档必须成对。规则见 docs/i18n/README.md。一「对」文档是三个兄弟文件:英文 foo.md、中文 foo.zh.md、一致性记录 foo.i18n.yaml。那个 yaml 里存的是两侧文件的 git blob 哈希——上一次确认「两边说的是同一件事」时的内容指纹。你改了任何一侧而没有重新确认,门禁就红。门禁还比结构:标题层级与顺序、代码块(连信息串和内容)、表格行列数、列表种类、有序列表起始号、条目数、以及除语言切换器之外的每一个链接目标,全部一一对应。两种语言地位相等——文档可以先用中文写,英文是译出来的,反过来也行。
verify-type-equiv:文档里粘的类型定义必须和源码一致。子系统页面会把与源码等价的声明连同原始 JSDoc 一起粘进来。为了防漂移,这种代码块用 ` ```ts type-equiv ` 围栏,并在 scripts/type-equiv.manifest.json 里登记它镜像的源文件和符号。门禁用 TypeScript 解析器从源码抽出那个符号的声明和 JSDoc,断言代码块同时匹配两者。你改了一个被记录的类型,门禁会一直红到你更新粘贴内容为止。
verify-cordis-catalog/verify-tool-catalog/verify-config-catalog/verify-persistence-catalog:生成的目录必须是新鲜的。这些页面(工具目录、配置目录、每个子系统页底部那段 Cordis API)是脚本从源码生成的。校验方式很直接:把生成器再跑一遍(--check),和文件里现有的内容比,不一致就红。
gen-doc-graphs/verify-doc-graphs:文档里的流程图也是生成物。第 9 章那条七道关的流水线图,在仓库里不是手画的。
为什么这解释了「文档质量反常地高」,也解释了「本站仍然找到了不一致」
把 28 道门禁能管的和管不了的分开看,就一目了然了:
被机器盯着的是:生成的目录、粘贴的类型块、双语配对的结构签名、链接目标、Markdown 折行、包 README 的必需章节、Agent Note 的格式与分类。这些永远不会漂。
没被盯着的是:手写散文说的内容对不对。「两个执行器家族注册同一个 bash 服务」这句话是人写的自然语言,没有任何脚本能把它和 super(ctx, 'shell') 对上。同理,一段概念解释里少列了一种分发模式、一份子系统页面描述的还是三个月前的事件集合——门禁全都看不见,因为它们在结构上完全合法。
这正是本站在核对源码时找到若干不一致的位置:全都在手写的部分,一条也不在生成的部分。第 17 章会逐条给出。
一个仓库如果只有「生成 + 校验」这一类文档门禁,长期下来,它的文档错误会集中在什么地方?请给出这个推论的依据(不是举例),并据此设计一条能补上这个缺口的新门禁——同时说清你这条新门禁必然仍然管不到什么。
shell」写成一个带 manifest 登记的行内 type-equiv(就像 verify-type-equiv 对类型块做的那样),让脚本去源码里核对那个字面量。它必然仍然管不到:因果与价值判断。「因为共享执行世界,所以无需提供方专用 fork」这句话里,前半句可核对,后半句是主张——没有任何门禁能判断一个「因此」成不成立。这也正是第 1 章那三档证据分级要解决的问题:门禁能守住「仓库明文」那一档,守不住「作者设计意图」那一档。变式:把问题反过来——如果一个仓库只有人工评审、没有任何机械门禁,它的文档错误会集中在哪里?这两种错误分布,哪一种更容易被新读者发现?
16.10 你的第一个真实任务
先说一件必须诚实交代的事。
现在提不了 PR
CONTRIBUTING.zh.md 写得很明确:「DeepSeek Harness 仍处于早期阶段,并在积极开发中。很抱歉,我们目前无法接受外部 PR。」所以本章标题里那个「提 PR」暂时只能在你自己的 fork 里完成。
仓库同时给了几条它欢迎的路:在 GitHub Discussions 里报问题、给你关心的讨论投票、写你自己的插件并分享(给仓库打上 dsh-plugin 话题让别人找得到)、写博客和操作指南。而且它对生态的立场写得相当罕见:「我们并不认为官方仓库中的包天然就比社区开发的包更重要。你可以将本仓库看作一种理念、一份官方示例以及一处灵感来源,而不是我们要求社区遵循的方向。」
这句话对你意味着:最有价值的第一个任务不是改这个仓库,是写一个挂在它上面的插件。
任务 A(半小时,必做):把 16.4 那个 greet 工具真的跑通。做完之后加一个小改动检验你真的懂了——给它加一个可选参数 excited: { type: 'boolean' },为真时结尾加感叹号。这个改动会逼你确认一件事:parameters 里不写 required: true 的字段默认是可选的,而 execute 拿到的 args 类型是从 schema 推出来的。
任务 B(一小时,更能学到东西):写一个只观察、不改变任何东西的监听插件。
import type { Context } from '@deepseek-ai/cordis'
export const name = 'event-spy'
export function apply(ctx: Context) {
ctx.on('session/event', (_session, event) => {
console.log('[spy]', event.type)
})
}
挂上去,跑一次真实对话,看终端。你会亲眼看到第 6、7、9 章讲的一切按顺序流过去:turn/start、step/start、一串 assistant/chunk、tool/call、tool/result、step/end、turn/end。这比再读三遍文档有用得多。
做完这个再加一层:把监听换成 ctx.on('tools/pre-execute', async (exec, next) => { console.log('[spy] tool:', exec.name); return next() })。注意那个 return next()——这是第 4 章那条纪律:只观察也必须调 next()。故意去掉它跑一次,你会看到所有工具调用被静默短路掉。这个实验值得做一次,因为它是这套架构里最容易犯、也最难查的错误。
怎么知道自己没搞坏别的东西
AGENTS.md 的原则是「运行与改动行为相关的检查」,并且明确反对默认跑全套:「绝不默认跑全套或为提交、推送重复一个已通过的检查。CI 负责穷尽覆盖和平台矩阵。」怎么选,它也给了对应表:
| 你改了什么 | 该跑什么 |
|---|---|
| 某个行为 | 该包的定向测试(pnpm run test <path>) |
| 模型看到的东西,或用户看到的输出 | pnpm run test:snapshot |
| 文档 | pnpm run doc-sync |
| 会被发布出去的路径(包入口、bin) | pnpm run build + pnpm run hygiene + 构建产物冒烟 |
| 某个模型提供方的行为 | pnpm run test:e2e(要密钥) |
| 不确定 / 改动是仓库级的 | pnpm run check:all(这条是给人用的,不是给 agent 的指令) |
另外,Lefthook 在你提交和推送时会自动跑几样轻量的东西:pre-commit 校验暂存的配对记录、用不加载项目的配置跑 Oxlint、检查暂存 diff 里的空白错误、跑 vendor manifest 守卫;pre-push 跑 pnpm run typecheck。这些钩子有意不跑测试、快照、文档检查、构建和 hygiene——它们的定位是快速检查点,不是门禁。
最后一条纪律,来自 AGENTS.md,改这个仓库时绕不过去:非平凡的改动必须在同一个 PR 里附一份 Agent Note(只有机械的、局部的编辑豁免)。还有一条对新手最有用的:测试描述的是行为,不是正确性——如果你的改动让一个旧测试挂了,先问「这个行为是不是本来就该变」,该变就连测试一起改,并在 PR 里解释为什么;不该变就说明你改错了。
答辩:如果我是审稿人
让我算一笔账。改这个仓库加一个小功能,你要:写包(含 README 的 Model Experience 三段式和 Known Limitations)、写 ./invariant 配套插件(哪怕是空的也得写一段解释)、在两个 aggregate 里选一个登记 tsconfig、补按文件 100% 的行覆盖、加一个真实组合的快照场景、更新中英两份文档并重新记一次 blob 哈希、附一份 Agent Note。这一套走下来,改一行代码的边际成本高得离谱。这难道不是把「仪式感」当成了工程质量?真正的高手项目应该让好的做法变容易,而不是让所有做法都变难。
参考防守(先自己组织语言再看)
批评里有一半必须认下来:边际成本确实高,而且这套义务对「加一个小功能」和「加一个核心能力」几乎是同一份,缺少梯度。这是真的成本,不该辩解。
但「仪式感」这个判断得看证据。区分仪式和工程的判据只有一条:这道要求拒绝过什么?逐条对:verify-package-invariants 拒绝无解释的空安装器——它拒绝的是「默默地什么都不检查」;verify-type-equiv 拒绝文档类型块与源码不符——它拒绝的是「文档慢慢变成谎言」;verify-translation-pairing 拒绝单侧修改——它拒绝的是「中文版落后英文版半年」;快照门禁拒绝没有组装 transcript 的行为变更——它拒绝的是docs/postmortem/0001 那类「单元测试全绿、产品却坏了」。每一条都能说出它挡住的具体故障类别,这就不是仪式。仪式的特征是说不出它拒绝过什么。
第二点,成本的分母被算错了。这些义务是一次性的(建包时付一次)加上随改动摊薄的(改行为时同步文档)。真正每天都在付的只有最后一类,而它换来的是这个仓库最反常的一个属性:三个月前的文档还能信。任何读过一份过期架构文档、然后按它写了两天代码的人,都知道那笔账有多贵。
第三点必须说清楚,也是对批评最实质的让步:这套义务的性价比强烈依赖于协作方式。这个仓库的贡献者约定写在 AGENTS.md 和 packages/AGENTS.md 里,一份 15 KB 的、写给 agent 看的规则文件;它的日常改动大量由 agent 执行,而 agent 恰恰不怕重复劳动、最怕规则模糊。换成一个人力小团队,同样一套义务很可能就真的变成仪式了。所以正确的结论不是「这套做法好还是不好」,而是「它适配的是哪种协作形态」——这一点仓库自己没有明说,是本站的判断。
最后,成本这件事有个便宜的对冲:CONTRIBUTING.zh.md 明说目前不收外部 PR,而且明说「官方仓库中的包并不天然比社区开发的包更重要」。也就是说,你要试一个想法,根本不必进这个仓库——写一个自己的插件包,一个文件起步,上面这套义务一条都不适用。这套重装备是给「进入官方交付面」的东西准备的,不是给探索准备的。
请构造一个具体场景:某人做了一次改动,所有门禁(test、test:coverage、test:snapshot、doc-sync、hygiene、typecheck)全绿,但这次改动确实引入了一个用户能察觉的问题。说明它是怎么绕过每一道的。
test:snapshot:record/refresh 会重写它们。description 少了一句关键约束。逐道问:这道门禁有没有可能察觉?description 从「Write a file. Overwrites without warning.」改成「Write a file.」,然后跑 pnpm run test:snapshot:record 更新预期输出。逐道看:typecheck 绿,字符串常量没有类型约束;test/test:coverage 绿,没有任何单元测试断言这句描述的内容,代码路径一行没变,覆盖率不动;test:snapshot 绿,因为预期输出刚被重录过——门禁比的是「当前输出」与「文件里的预期」,而不是「当前输出」与「正确的输出」;doc-sync 绿,如果这句话没有出现在任何被登记的 type-equiv 块或生成目录里(verify-tool-catalog 会跟着重生成,仍然一致);hygiene 绿,包的接线一点没动。但用户能察觉:模型不再知道这个工具会无警告覆盖,于是它开始覆盖不该覆盖的文件。这个场景的普遍教训是:一切「预期文件可被同一次改动重写」的门禁,本质上只能防意外漂移,防不了有意的错误决定——所以仓库才在测试策略里反复要求「请审查每一处 JSONL 与预期输出差异」。门禁挡住的是手滑,挡不住判断失误;后者只能靠评审。变式:如果这次改动改的不是描述文字,而是把某个工具的 timeoutMs 从 5000 改成 500000,上面哪几道门禁的回答会变?
你新建了一个包 dsh-string-utils,里面只有三个纯函数,没有任何状态。按仓库规矩,你的 ./invariant 配套插件该长什么样?
./invariant」。这里的「每个」有没有例外?docs/subsystems/invariants.zh.md 找「配套插件约定」那一段,看空安装器有什么额外要求。No runtime invariant: 开头,针对这个包具体解释为什么没有可检查项(比如「纯函数,不拥有可变数据,也不参与任何事件协议」)。注册名必须是完整 npm 包名。pnpm run verify-package-invariants 会拒绝无解释的空安装器、错误的注册名,以及不完整的导出/发布/依赖/打包接线。文档还点名说,这种情况「对纯工具、行为已通过其接口包被观察的薄实现、纯组合包、二进制、需要崩溃与往返测试的持久化适配器,以及测试支持包很常见」——所以你不是特例。变式:半年后这个包加了一个内部 LRU 缓存。按仓库的要求,你现在必须做什么?
你要做一个功能:模型每次调用 bash 之前,把命令发到公司内网的一个「命令风险评级」服务,评级为高时要求人工确认,评级为中时放行但在结果里附一条警告,评级为低时静默放行。请给出:(a) 它落在哪些扩展点;(b) 做成几个包,为什么;(c) 你会为它写哪几层测试;(d) 哪些门禁会因为这个改动而变红,你分别要做什么。
tools/pre-execute——按 docs/cookbook/extension-cookbook.zh.md,从这个 waterfall 返回 ask 并通过 ctx.approval 应答,正是权限系统的标准形状;「在结果里附一条警告」不在这一关,它在 tools/post-execute(替换展示内容或附加模型可见上下文);如果你只想观察不改变,那是 tools/result。注意「静默放行」不需要任何代码——不匹配时调 next() 就行。(b) 包结构:一个包。三个角色不需要独立演进:接口、实现、消费方都只服务这一个公司内网服务。除非你预见到要支持多家评级服务且消费方不该知道差别,那才值得开 seam。(c) 测试:至少三层。单元测试盖三种评级的分支和评级服务超时/报错的路径(评级服务挂了该 fail closed 还是 fail open?这是个必须显式决定的问题,参考第 9 章的默认拒绝);一个真实组合测试——packages/AGENTS.md 明说「手动构建的 ctx.plugin(...) 套件不够」,要通过 Loader 和 app/process 启动一份仅用于测试的 cordis.yml,只 mock 那个外部评级服务;还要一个快照场景,因为「中」这一档改变了模型看到的东西——按测试策略,模型可见的变更必须在同一 PR 里加或更新一个无密钥快照场景。(d) 门禁:新包会触发 constraints(package.json 不变式)、verify-package-invariants(./invariant 必须存在——这个包有可观察的事件关系,比如「每个高评级都必须对应一条 approval/asked」,所以适合写一个真的检查而不是空安装器)、verify-package-readme-model-experience 与 verify-package-readme-limitations(README 的两个必需章节);文档改动会触发 verify-translation-pairing(要同时写中英两份并重新记 blob 哈希);test:coverage 要求按文件 100% 行覆盖,所以那些「理论上到不了」的分支要么补测试要么删掉。最后按 AGENTS.md,这是非平凡改动,必须在同一 PR 里附一份 Agent Note。变式:把需求改成「评级服务不可达时一律放行,以免挡住工作」。这个决定会让你上面 (c) 里的哪个测试变得更重要?它和第 9 章那条「安全机制坏掉时应该表现得像全禁」的原则冲突吗?
把你造的十六层,和真仓库逐层对一遍
前面每一章你都往 mini-harness 上加了一层。这一层不写新代码,做一件更重要的事:把你自己的实现和真实现放在一起量一遍。这是全站主线的收束。
先准备两个数。第一个是你自己的:把你每一层加的代码行数记下来(如果你是一路往同一个文件里加的,用注释把层的边界标出来再数)。第二个是仓库的,用这条命令量:
然后填这张表。十六行都要填,一行都别跳——跳过的那一行通常正是你其实没搞懂的那一层。
| 层 | 我的实现行数 | 仓库对应包 | 仓库行数 | 差在哪(多出来的部分在解决什么问题) |
|---|---|---|---|---|
| 第 0 层 | 约 20 | packages/core/agent-loop/src | 1643 | 示例行:多出来的部分在处理轮次与步骤的边界、取消、并发调度、错误恢复、以及每一步都要落日志——我的版本一件都没做 |
| 第 1 层 | ||||
| 第 2 层 | vendor/cordis/src/reflect.ts(418) | |||
| 第 3 层 | vendor/cordis/src/fiber.ts(754) | |||
| 第 4 层 | vendor/cordis/src/events.ts(352) | |||
| 第 5 层 | vendor/loader/ + vendor/include/ | |||
| 第 6 层 | packages/core/session/src | 3156 | ||
| 第 7 层 | packages/core/agent/src(1636)+ agent-loop | |||
| 第 8 层 | packages/core/system-prompt/src | 605 | ||
| 第 9 层 | packages/core/tools/src | 5620 | ||
| 第 10 层 | ||||
| 第 11 层 | ||||
| 第 12 层 | ||||
| 第 13 层 | ||||
| 第 14 层 | ||||
| 第 15 层 |
填「仓库对应包」这一列时,最快的办法是从服务键反查:你那一层如果实现的是「工具注册」,就去 grep -rn "super(ctx, 'tools')" packages/;如果是「压缩」,就找 ctx.compaction。找不到对应包的层要单独标出来——那说明你造的那一层在真仓库里不是一个独立的东西,它被拆进了别处,这个发现本身就有价值。
难点在最后一列,而它才是这张表的目的。「差在哪」不要写「仓库更完整」这种废话,要写具体的问题:比如第 9 层,你的 waterfall 骨架大概三十行,仓库那个包五千多行——多出来的不是同一件事写得更啰嗦,而是审批的八种归宿、身份冻结、并发池与屏障、Code Mode 的子调用重入、结果的无损 JSON 快照与冻结、UI 卡片的纯函数投影……每一条都对应一个你的版本会在真实使用中撞上的具体故障。能把这一列写满,你才算真的读懂了这个仓库。
做对了的样子:十六行都有内容,且最后一列里至少有三行写的是「我这么做在什么情况下会出错」这种具体的失败场景,而不是「功能更多」。另外还要有至少一行你敢写「仓库多出来的这部分,对我的场景不必要」——如果十六行你一条都不敢这么写,说明你还在仰视它,没在评估它。
本章小结
从读到改,这一章走的是这条路:
- 两条跑法:
npx @deepseek-ai/dsh web拿到的是发布产物(默认http://127.0.0.1:3080);pnpm install/pnpm run build/pnpm dsh web拿到的是能改的源码。pnpm run typecheck成功退出=环境搭好了。 - 先看树再动手:
dsh --profile web --dump-config。四层叠加,home 级压过 per-profile。本站核对发现它和真正启动的树有差异,第 17 章交代。 - 七个问题定位扩展点:模型要用的→工具;同一件事的另一种做法→seam 提供方;插一脚→事件监听;让模型知道→section/inject;人触发→commands;要活过重启→
SessionEventMap;只对一个 agent→agent.ctx。七问全否才轮到改循环,而改循环必须同时改架构文档。 - 加一个工具:
inject = ['tools']、不要 default export、parameters/output.schema/output.render是三层不同的东西、timeoutMs只是声明不是执行。 - 测试体系:692 个 spec、五条跑道、按文件 100% 行覆盖是 CI 门禁。
llm-replay让「组装后的完整应用」能在没有密钥、没有随机性、没有账单的情况下被反复验证——它的 fixture 就是那份会话日志本身,这正是「仅追加日志」这个决定意想不到的红利。 - 运行时不变式:每个包一个
./invariant;没有可检查项也必须写一段No runtime invariant:开头的解释;verify-package-invariants机械拒绝无解释的空安装器。这套机制强制的不是检查,是解释。 - 文档被机器校验:
doc-sync28 道门禁,管住了生成物、类型块、双语配对结构和链接。管不住手写散文说的内容对不对——这正是下一章那些不一致的藏身之处。 - 现在提不了 PR:官方明说暂不接受外部 PR,但明确欢迎你写自己的插件,并且不认为官方包天然更重要。
这一章的落点是一句话:读懂一个系统的最后一步,是把它的边界摸出来——而摸边界唯一的办法是动手推一下。你造了十六层,现在你有资格评估它了。下一章就来做这件事。
第17章 这套设计欠了什么债,以及这个仓库没告诉你的事
一句话导语:前面十七章讲的都是这套设计买到了什么;这一章讲它付了什么,以及它自己没讲、或者讲了但你多半没读到的部分。
学完这一章你应该能做到
- 把「架构文档讲的好处」和「系统文档里那些奇怪的限制」认出来是同一个决策的正反两面
- 说出至少四条「为了得到 A,必须付出 B」的完整因果链,而不是背一张缺点清单
- 区分三种不同性质的问题:文档和源码对不上、仓库说了但藏得深、仓库根本没说
- 用七个可复用的问题去审一份你从没见过的技术文档
- 给自己造的 mini-harness 写一份诚实的「已知限制」清单
先说清这一章的性质
下面每一条,本站都回仓库核对过:给出文件路径、行号,原文措辞对得上才写。核对不了的一律不写——本章末尾还专门列了三条没通过核对、被本站自己推翻的说法,因为那三条比通过的更能说明问题。
但请注意分寸:这些是本站的核对结果,不是仓库承认的问题。一个快速迭代中的项目,文档滞后于源码是常态而不是失职;本章列出的多数不一致,最合理的解释就是「代码先改了,那句话还没跟上」。这一章不是控诉,是示范怎么读。
17.1 先讲债:每个设计都欠下了什么
第 1 章末尾说过一句话:架构文档讲这套设计解决了什么,系统文档讲各个部件的限制,两者通常被当成两类内容读。这一节要做的事情是把它们叠在一起——因为它们往往是同一个决策的正面和背面。
下面七条不是缺点列表,是七条因果链。每一条的格式都是:为了得到 A,必须付出 B。你可以不同意 B 值不值,但你不能只要 A 不要 B,因为 B 是 A 的实现方式本身带来的。
docs/architecture.zh.md 与各包 README 的表述;右列由本站从源码与各包「已知限制」章节归纳,属本站推断。示意图,非仓库原始数据。把其中四条展开,因为它们的因果关系不是一眼可见的。
链条一:一切皆插件 → 你无法一眼看出「哪些东西现在是活的」
第 1 章讲过这条的四个代价。这里补一条更深的:能被配置替换,就意味着「能力在不在」是一个运行时问题,而不是一个可以读代码读出来的事实。
第 15 章核实过、第 16 章又用了一次的那个例子最能说明问题:ctx.invariants 那一整套运行时不变式(包括「模型可见即已记录」这条断言),没有任何发行组合包挂载它;包 README 自己也写着「单独加载服务不会安装任何产品检查」。
这不是 bug——诊断工具本来就该能关掉。这里要说的是它的一般形态:在一个「一切皆插件」的系统里,「这个机制存在」和「这个机制在你的进程里跑着」是两个必须分开问的问题,而所有文档回答的都是前一个。文档陈述的是机制,你的进程里跑的是组装,两者之间永远隔着一层配置。这一条会在本章反复出现——17.3 的 llm-pi-ai 零路由是它,第 10 章讲的 timeoutMs 不装策略包就没有超时也是它。它不是一个孤立的疏漏,它是这个架构决定的必然形状。
链条二:仅追加日志 → 磁盘只增不减
第 6 章讲过为什么日志必须只追加:一旦允许改写,恢复、fork、回放就全都失去基础。第 12 章讲过压缩怎么让上下文不撑爆。把两件事接起来会得到一个容易被忽略的推论:压缩裁剪的是「模型看到的那一侧」,磁盘上的日志本身一直在长。
仓库对此有明文,但位置很偏——在 packages/session/session-persistence/README.zh.md 的「已知限制」里,第 83 行:「无删除或保留接口:剪枝已存储会话是带外后端维护。」下一行还有一条同样关键的:list() 不分页也不过滤,「返回每个已存储会话的头部;对本地存储没问题,规模化后没有索引」。
所以这条债是明确的、被承认的,只是没有出现在任何概念文档或架构文档里。一个跑了半年的部署会有多少 GB 的会话日志、谁来删、按什么规则删——答案是「你自己想办法」。
链条五:seam 三角色 → 只有一个提供方的时候,你白付了抽象成本
AGENTS.md 把这条写成硬规矩:一个能力 seam 由 Service Definition/Service Provider/Consumer 三个角色构成;它是完整能力,绝不是其中一个角色。好处第 1 章讲过:换提供方就能换掉整条能力。
代价是:当一个 seam 只有一个提供方时,这三层抽象的收益是零,成本照付。第 11 章已经点到过这件事;这里要往前推一步——把它量出来。成本是什么?多两个 package.json、多两份 README(各带强制的 Model Experience 三段式和 Known Limitations)、多两个 ./invariant 配套插件、多两处 tsconfig 登记,以及每个读者理解这项能力时都要多打开两个文件。
本站数了一下:用 super(ctx, '…') 这种类形态注册的服务键,去重之后是 56 个。这 56 个里有多少真的存在两个以上提供方?仓库没有统计,也没有任何脚本或门禁与此相关(本站检索了 package.json 的全部脚本)。举一个明确的单提供方例子:packages/compaction/README.md 第 10 行明写,这一组四个包里只有 compaction-basic 注册 ctx.compaction。
补充:这不等于说这些 seam 是错的
一个只有一个提供方的 seam 仍然可能是对的——它可能是为了让第三方能替换而存在,而不是为了内部有多个实现。CONTRIBUTING.zh.md 说得很明白:仓库希望生态自己长出来,「并不认为官方仓库中的包天然就比社区开发的包更重要」。在这个前提下,为一个未来的、还不存在的第三方实现预留接缝,是一个可以辩护的选择。
本站要指出的只是:这个选择的赌注大小是可度量的(有多少 seam 至今只有一个提供方),而仓库不度量它。
链条六:waterfall 的拦截能力 → 忘调 next() 是静默失败
第 4 章已经把这条钉死了:waterfall 监听器必须调 next(),不调就短路整条链,包括框架内建的行为。AGENTS.md 把它写成加粗规则,docs/cordis-primer.zh.md 又写了一遍。
问题在于这条规则的执行方式:它只存在于文档里。vendor/cordis/src/events.ts 第 234–241 行的 waterfall 实现是一个消费队列的共享闭包,框架不跟踪「这一层调过没有」;没有 lint 规则,返回类型允许 undefined 的事件尤其危险。这是用文档执行的不变式——而全站到处都是这类东西:工具的 isConcurrencySafe 自我声明、fs 单槽的先到先得、服务名加前缀的纪律。
某个框架宣传:「所有配置项都可以在运行时热更新,无需重启。」不看它的文档,请推断这句话必然带来哪两类代价,并说明你的推理依据(不是举例)。
ctx.effect() 就是这个),漏一个就是泄漏;而且卸载本身可能失败或挂住——本站在第 3 章验证过,vendor/cordis/src/fiber.ts 全文 754 行里一个 setTimeout 都没有,一个永不 resolve 的释放函数会让 fiber 永久停在 UNLOADING。第二类代价:跨越一次更新的进行中操作,语义必须被定义,否则就是未定义行为。推理依据是:更新是一个瞬间,而操作有持续时间,两者必然会重叠;框架要么定义重叠时发生什么(取消、跑完、排队),要么就留下一个洞。本仓库在这一点上有洞——见 17.4 第一条。注意这两条都是从「热更新」这个承诺本身推出来的,不需要读任何具体文档。这就是本节想教的读法:一条设计承诺会强制推出一组必须回答的问题,你可以先列出问题,再去文档里找答案;找不到的那些,就是这份文档没告诉你的事。变式:把承诺换成「所有操作都可以撤销」。用同样的方法推出它必然带来的两类代价。
17.2 文档与源码对不上的地方(逐条核实后写)
这一节的地位
下面每一条都是本站的核对结果,不是仓库承认的问题,仓库也没有为它们开过 issue(本站无法核实 issue 状态,只能说明这些文字在 commit 47f9438 上就是这样)。最可能的解释是文档滞后于快速迭代。读的时候请自己回去验证——每条都给了路径和行号。
一、parallel() 在诊断事件里把自己报成 'emit'
Cordis 的事件总线有一个内部诊断事件 internal/dispatch,声明在 vendor/cordis/src/events.ts 第 351 行:
'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void
第一个参数叫 mode,类型是 DispatchMode(五个取值:emit/parallel/serial/bail/waterfall)。它的实际值来自 dispatch() 的第一个入参——该方法在第 165 行,第 169 行把 type 原样发出去。
然后看四个分发方法各自传了什么:
| 方法 | 行号 | 传给 dispatch() 的值 |
|---|---|---|
parallel() | 184 | 'emit' |
emit() | 195 | 'emit' |
serial() | 205 | 'serial' |
bail() | 218 | 'bail' |
waterfall() | 235 | 'waterfall' |
parallel() 是唯一一个不传自己名字的。后果很具体:任何监听 internal/dispatch 的插件,永远观测不到 'parallel' 这个值,它会把并行分发和同步广播记成同一种。如果你写一个统计各类事件分发量的埋点插件,这两类会被合并,而你不会收到任何提示。
这行代码本身可能是有意的(parallel 复用 emit 的监听器解析路径),但它和第 351 行那个被命名为 mode、类型是 DispatchMode 的参数所承诺的东西对不上。
二、组合包 README 说的服务键,和源码注册的不是同一个名字
packages/bundle/base/README.zh.md 第 7 行,在讲 Windows 上怎么换回 bash 执行器时写道:
「bash 恢复配方必须完整:禁用 pwsh-sandbox/tool-pwsh 并重新启用 bash-sandbox/tool-bash——两个执行器家族注册同一个 bash 服务,配方不完整会在加载时直接报错。」(英文版 README.md 第 7 行同样写作 the same `bash` service,所以不是翻译问题。)
而源码里,两个执行器家族的共同基类 ShellExecutor 在 packages/shell/shell/src/index.ts 第 67 行注册的键是:
super(ctx, 'shell')
两个家族确实继承同一个基类(packages/shell/bash-local/src/index.ts:102 的 LocalBashExecutor、packages/shell/pwsh-local/src/index.ts:128 的 PwshLocalExecutor),所以 README 描述的机制是对的——两家抢同一个键,同时挂就在加载时撞名报错。错的只是那个键的名字:是 shell,不是 bash。
这条为什么值得单独讲?因为它演示了第 16.9 节那个结论的具体形态:这句话是手写散文,没有任何门禁能把它和 super(ctx, 'shell') 对上。而如果它是一个被登记的 type-equiv 代码块,verify-type-equiv 五秒钟就会红。
三、不变式包 README 的正文和它自己的示例不一致
packages/runtime-diagnostics/invariants/README.md 第 39 行的表格里,把 dsh-session、dsh-agent、dsh-scope、dsh-agent-loop 列为一组核心配套插件;第 69 行说「标准 agent 组合挂载该服务及其四个核心有状态配套插件」。而同一页第 52–66 行的 Composition 示例是这样的:
ctx.plugin(InvariantRegistry, {
enabled: true,
package_allowlist: ['^@deepseek-ai/dsh-'],
package_blocklist: ['^@deepseek-ai/dsh-agent-loop$'],
})
这个示例用阻止列表把 dsh-agent-loop——也就是那条最重要的「模型请求可从日志重建」检查——排除掉了,而页面没有解释为什么示例要这么写。它可能只是在演示过滤器语法,但对一个照着示例抄的读者来说,结果是:他以为自己按标准组合装好了四个核心检查,实际装了三个。
四、--dump-config 打印的树,和真正启动的树不完全一样
第 16.2 节提过,这里给出核对依据。两个函数:
apps/cli/src/profile-boot.ts的composeProfile():在「组合包层 → profile 层 → home 层 →--patch覆盖」之后,还会再追加两条——一条给agent-presets补上交付根(源码注释解释了原因:那是「只有这个 app 能解析的名册部分」),一条是遥测开关(resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, …))。apps/cli/src/dump-config.ts的runDumpConfig()(第 30–52 行):只组装前四层,没有这两步。
另外,dump-config.ts 的模块 JSDoc(第 2–5 行)自己写明:它「通过 include 插件的 patch 算法组合各 patch 层,不启动、也不求值 !!js」。而 base 组合包里那些按平台门控的行用的正是 disabled: !!js process.platform === 'win32'。
结论:--dump-config 是一份高保真的配置层示意,不是运行时快照。这不是缺陷(一个「不启动就能看」的诊断工具本来就做不到求值),但文档没有把这层区别说出来,而读者最需要它的时刻,恰恰是在排查「为什么实际行为和我 dump 出来的不一样」。
另外两条已经在前面章节里给过
第 4 章讲过两条同类的:docs/cordis-primer.zh.md 第 21–26 行的分发模式表只列了四种,漏掉 bail(英文版 cordis-primer.md 第 19–24 行同样缺,所以不是翻译问题),而 vendor/cordis/src/events.ts 第 32 行的 DispatchMode 是五种、教程 docs/cordis-tutorial/04-events.zh.md 第 86–90 行也是五种;以及 docs/subsystems/core.zh.md 的事件清单与 packages/core/session/src/types.ts 的 SessionEventMap 已经不同步。
上面四条,本站是靠「一条条读」找到的吗?请设计一个系统性的方法,去找一份文档里所有「散文提到了某个标识符」的地方并核对它——然后说明你的方法必然会漏掉哪一类不一致。
.md 里被反引号包起来的词提出来去重,再逐个 grep 源码,先看哪些一次都搜不到。ctx.foo/fooBar()/'string-literal' 这种有代码形态的留下,纯英文词丢掉;(3) 对每个候选在 packages/ 和 vendor/ 里做全文搜索,零命中的排在最前面人工看——「文档提到了一个源码里不存在的名字」是最高信噪比的信号,第二条(bash 服务)就属于这类的近邻(那个词存在,但不在服务注册的位置上);(4) 对有命中的,检查命中位置是否与散文声称的角色一致(比如声称是服务键,就该出现在 super(ctx, '…') 或 ctx.provide('…') 附近)。必然漏掉的一类:不包含任何标识符的纯自然语言断言。第三条(README 正文说四个、示例排除一个)和第四条(dump 少两层)都不是标识符错误——它们是数量、顺序、条件上的不一致,需要真的理解两段文字在说什么才能发现。这一类目前没有任何自动化办法,只能靠人读,而且必须是同时读文档和源码的人。这正好回答了第 16.9 节那个问题的另一半:机械门禁的能力边界,就是「可计算的等价关系」的边界。变式:如果让你只做一件事来把这类不一致降到最低,你会加一道门禁,还是改一条写作规范?给出你的判据。
17.3 仓库明说、但容易被漏读的限制
这一节和上一节性质完全不同:下面每一条都是仓库自己写的。问题不在于它没说,而在于它说的位置——藏在某个包 README 的最后一节、某个 YAML 注释里、某份教程的第 91 行。你不专门去找就会漏。
| 事实 | 它写在哪 | 为什么容易漏 |
|---|---|---|
| 会话日志没有删除或保留接口,剪枝属于带外后端维护 | packages/session/session-persistence/README.zh.md:83(限制清单) | 不在任何概念文档或架构文档里;读完 docs/subsystems/session.zh.md 也不会知道 |
「服务挂着」不等于「能力可用」:llm-pi-ai 以零路由挂载,直到设置文档里出现对应配置 | packages/bundle/base/cordis.patch.yml:88–94(YAML 注释) | 写在配置文件的注释里。inject 只保证服务对象存在,不保证能力可用——这层边界没进任何概念文档 |
| 模块解析失败不会崩溃,只经 logger 报告;启动早期这条报告可能在 console 导出器就位前丢掉 | docs/cordis-tutorial/01-first-plugin.zh.md:91 | 在一份入门教程的中段,作为「例外」一笔带过;而它正是「新增配置项好像没生效」的头号原因之一 |
fs/observed 的监听器抛异常会污染工具结果:可能在变更已经成功之后让工具返回 isError | docs/subsystems/filesystem.zh.md:185 | 藏在一个讲单槽 waterfall 的长句子后半段;而它的后果是「文件改成功了但模型以为失败了」 |
tools/pre-execute 有意不能改写 exec.arguments;改写能力的设计停在一份「提案中」的 Agent Note | packages/core/tools/README.md 的 Known Limitations | 很多人读完「pre-execute 是可重排的策略层」会以为它能改参数;理由(日志与实际执行会脱节)在别处 |
SESSION_FORMAT_VERSION 停在 0,且明说无兼容承诺;版本不符直接抛错拒绝载入 | packages/core/session/src/types.ts:56 + AGENTS.md 预发布立场一节 + packages/core/session/src/index.ts:101–102 | 三处分散;而它决定了「你今天的会话日志明天还读不读得出来」 |
其中第二条值得多说一句,因为它是一整类误解的源头。
常见误解:inject 成功了,就说明这项能力可用
inject: ['llm'] 保证的是:ctx.llm 这个服务对象存在,且它的提供方 fiber 处于 ACTIVE。它不保证 ctx.llm 后面接着任何一个能真的发出请求的适配器。
packages/bundle/base/cordis.patch.yml 第 88–94 行那段注释把这件事说得很清楚:llm-pi-ai 是「以休眠状态挂载:零路由(模型选择器里也不多出模型),直到 llm-pi-ai: 设置段落提供了提供方配置——那时这些路由才真的注册上,密钥按请求通过 apiKeyEnv 引用解析;段落清空时它们又消失」。最后两句是这条区分的正式表述:「哪些适配器存在,是组装的事;哪些提供方在跑,是用户设置文档的事。」
这个区分很重要,但它只活在一个 YAML 注释里。第 2 章讲的 inject 语义、第 5 章讲的组装、第 16 章讲的扩展点,都没有这一层。「服务在」「能力在」「能力此刻可用」是三件事。
另外,第 10 章第 8 节已经列过同一性质的五条(ApprovalPolicy 的 never 是一律拒绝、isConcurrencySafe 是无人校验的自我声明、timeoutMs 不装策略包就完全没有超时、fs 单槽归属是部署约定、被拒绝的调用不执行守卫),这里不重复。它们和上表六条共享同一个特征:都写在包 README 的「已知限制」或子系统文档的长句子里,而不是在你会先读到的地方。
为什么这些限制会集中出现在包 README 的最后一节
因为那是仓库强制它们出现的地方。packages/AGENTS.md 规定:包 README 必须把「持久的消费方缺口和本包拥有的非显而易见的维护者约束」放在 ## Known Limitations and Deferred Work 下面;一个限制都没有的包,要在 scripts/verify-package-readme-limitations.ts 里登记一条有理由的 allowlist 条目。这道门禁叫 verify-package-readme-limitations,在 doc-sync 里。
这是个很好的制度:它逼着每个包的作者把自己知道的坑写下来。但它同时产生了一个副作用——所有的坑都被收纳到了 219 个包各自的最后一节里,没有任何一处把它们汇总。想知道你这套组合总共有哪些已知限制,你得读 219 份 README。
这一段最后这个判断是本站的推断,仓库没有这样说。
你的插件写了 export const inject = ['llm'],它成功启动了(状态是 ACTIVE,不是 PENDING)。请判断:这能否说明「现在调用 ctx.llm 一定能发出模型请求」?给出依据。
inject 在等的到底是什么东西就绪?是一个服务对象,还是一项能力?packages/bundle/base/cordis.patch.yml 第 88–94 行那段注释,它描述的是哪种状态。inject 保证的是 ctx.llm 这个服务对象存在且其提供方 fiber 处于 ACTIVE,它不保证 ctx.llm 上注册了任何可用的适配器路由。llm-pi-ai 就是现成的反例:它以零路由的休眠状态挂载,一直到用户的设置文档里出现 llm-pi-ai: 段落才注册真实路由。仓库对此的原则性表述是「哪些适配器存在是组装的事,哪些提供方在跑是用户设置文档的事」——只不过这句话写在一个 YAML 注释里。附带一提,还有更细的一层:ctx.get() 的 strict 默认为真(vendor/cordis/src/reflect.ts 的 _getImpl),所以提供方正在重载的那一小段时间里读到的也是 undefined——第 2 章讲过。三层区分要记住:服务在 / 能力注册了 / 能力此刻可用。变式:那么一个插件想在「模型能力真的可用」时才启动,它该怎么写?inject 能表达这个需求吗?
17.4 仓库完全没说的(缺口)
这一节比上一节严重一档:下面的东西本站在仓库里找不到任何说法。每条都写清两件事——为什么这个缺口重要,以及你遇到它时会看到什么。
缺口一:服务被替换时,进行中的异步调用怎么办,没有定义
机制:vendor/cordis/src/reflect.ts 的 provide() 返回的释放函数里有这么一行(第 300 行):
await Promise.allSettled(fibers.map(fiber => fiber.await()))
它等的是依赖这个服务的那些 fiber 重新稳定下来。这是必要的,但它回答的不是同一个问题。
缺口:「fiber 稳定」不等于「业务调用结束」。设想一次正在流式返回的 ctx.llm.stream(),此刻它的提供方被热重载卸载了。这次调用会被取消?会跑完?还是永远挂住?仓库没有任何文档回答这个问题,本站也没有在源码里找到统一的处理。
你会遇到什么:改一行 llm-deepseek 的代码触发 HMR,恰好此时有一个 agent 正在跑。最好的情况是这次调用干净地失败、循环走错误恢复;最坏的情况是那个 await 永远不返回,agent 卡在一个既不成功也不失败的状态里,而且日志里没有任何异常。
缺口二:事件分发器不隔离监听器异常——而仓库自己的规则要求隔离
这是本章最值得琢磨的一条,因为它是一条仓库自己的规则和它所依赖的框架之间的张力。
docs/defensive-patterns.zh.md 有一节叫「在分发器中隔离回调异常」,原话:「用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。」
然后去看 Cordis 的事件总线。本站在 vendor/cordis/src/events.ts(352 行)里搜 try { 和 catch:一个都没有。emit() 的实现是第 194–195 行:
emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args))
}
一个同步抛出的监听器会中断这个 .map,后面的监听器一个都跑不到,异常会一路冲回发出方;一个 async 监听器抛出的异常则变成未处理的 promise 拒绝(因为 emit 不 await 任何东西)。
公平地说:vendor/ 是钉住的上游快照
docs/rescope.zh.md 和 vendor/README.md 说明得很清楚:vendor/ 下面是上游 Cordis 的源码副本,按 manifest 里的 SHA 钉住,通过同步流程更新。所以「防御式模式」作为一条对本仓库自有代码的要求,和上游框架的实现选择,严格来说不是同一个约束对象。
本站要指出的不是「谁违规了」,而是一个对使用者更实际的事实:你写监听器的时候不能指望分发器兜底。而这一点,两份文档里都没有正面写给插件作者看。
这个缺口不是理论上的,它有一个已经被记录下来的具体后果。docs/subsystems/filesystem.zh.md 第 185 行讲 fs/observed(一个 emit 模式的记录事件)时说:其监听方「必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方可能取代读取操作原本待返回的错误,或使工具在变更已经成功后返回 isError 结果」。
把两件事接起来就是完整的因果链:分发器不隔离异常 → 一个只想记日志的观察者插件,能让一次已经成功的文件写入在模型眼里变成失败。文档在子系统那一页记录了这个后果,但没有把它归因到分发器这一层,也没有在任何面向插件作者的地方提醒「你的 emit 监听器抛异常会伤到别人」。
缺口三:FiberState 的状态转移没有一张表
vendor/cordis/src/fiber.ts 第 147–154 行只有一个枚举:PENDING、LOADING、ACTIVE、FAILED、DISPOSED、UNLOADING。哪些转移是合法的、什么条件触发、有没有回边(FAILED 能不能回到 ACTIVE),散落在这个 754 行文件的各处。
本站检索了 docs/ 下所有 Markdown:FiberState 只在 docs/cordis-tutorial/06-composition-and-hmr.md 及其中文版里出现过,而且是作为诊断代码的一部分(if (fiber.state === FiberState.PENDING)),不是一张转移图。
为什么重要:第 0 章就讲过,状态机是「几个格子加上格子之间的箭头」。这里格子有了,箭头没有。任何人整理出来的转移关系——包括本站在第 3 章列的那张「怎么进来怎么出去」的表——都是从代码里读出来的推断,会随实现变化而失效,且没有任何门禁盯着它。
你会遇到什么:写一个依赖 fiber 状态的诊断插件时,你无法确定自己有没有漏掉一个状态或一条转移。
缺口四:容忍并发写入方需要一个「日志之外的存活信号」,而这个信号不存在
这一条特别值得注意,因为仓库自己把问题提出来了,然后停在那里。docs/subsystems/session.zh.md 第 591 行,在讲 session/end-seed 这个边界事件时写道:
「……这只覆盖本会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。」
这句话准确地指出了「仅追加日志」这个设计的一个边界:日志本身无法区分「有人正在写」和「有人写到一半死了」,因为两者在字节层面完全相同。然后——那个存活信号是什么、现在有没有、当前是否假定单一写入者,仓库没有回答。
你会遇到什么:两个进程同时打开同一个会话(比如你在 Web UI 里跑着,又在另一个终端 --resume 了同一个 id)。这种情况下会发生什么,你只能自己试。
缺口五:提示词的成本有统一格式,收益没有任何口径
第 8 章讲过,每加一段系统提示词都在花钱。仓库对这个成本的管理是很认真的:docs/cookbook/adding-a-package.zh.md 规定每个包 README 必须写「Model Experience」三段式,其中两段专讲成本——Token effect(固定/条件/保留/替换/有上限/零直接消耗)和 KV Cache effect(仅追加增长/稳定前缀/替换/独立请求,以及会让缓存复用失效的确切条件)。这个结构由 verify-package-readme-model-experience 门禁强制。
但反过来的那一半——这段 section 值不值得占这些 token——没有任何度量、判据或门禁。没有字段记录「加了它之后任务成功率变化多少」,没有要求给出对照,也没有门禁问「你凭什么认为这段话有用」。
为什么重要:这是一个不对称的激励结构。成本可见、被强制记录、被门禁盯着;收益不可见、无人要求、无处记录。在这样的结构里,一段没用的提示词只要它的 token 成本被诚实写下来,就完全合规。它会被记录,但不会被质疑。
这一条要小心:本站没有证据说仓库的提示词有问题
上面说的是机制上的缺口,不是对内容的判断。本站没有评估过任何一段 section 的实际效果,也没有能力评估——这正是问题所在:缺口是「无法评估」,不是「评估结果不好」。
真未解一段系统提示词到底值不值它占的那些 token?
这不只是这个仓库的问题,它是当前整个 agent 工程里最缺方法的地方之一。困难在于:这是一个多变量、结果带随机性、且改变一处会影响全局的评估问题。你想知道「删掉这段 AGENTS.md 提示会怎样」,理论上要跑 A/B——但 agent 任务的成功与否往往没有客观判据(「代码改对了吗」本身需要人判断),单次运行的方差极大,而且提示词之间会互相作用:删掉 A 之后 B 的效果可能变了,因为模型原本是靠 A 才理解 B 在说什么。
更麻烦的是 KV 缓存这一层。仓库要求每个包记录自己对缓存前缀稳定性的影响——因为一段插在前面的动态内容会让后面所有内容的缓存全部失效。这意味着「一段 section 的成本」根本不是它自己那几百个 token,而是取决于它放在哪个位置。同一段话放在最前面和放在最后面,成本可能差一个数量级。目前没有任何工具能自动算这笔账。
怎么入手:先做能做的那部分——写一个插件监听 agent/request(或直接读会话日志的 request/header 事件),把每次请求的系统提示词按 section 拆开,统计每段的字符数与出现频率,做出一张「谁在花钱」的排行榜。这一步纯粹是计量,一定能做出来,而且仓库现在没有。第二步才是真问题:设计一个能测出收益的实验。建议从最容易的一类入手——那些声称能阻止某类错误行为的 section(比如「不要在未读文件的情况下编辑它」),因为「错误行为发生了没有」比「任务做得好不好」容易判定得多。构造一批必然会诱发该错误的任务,有/无这段提示各跑 N 次,数发生率。做完之后你会立刻撞上真正的开放问题:N 要多大?以及,对那些声称能提升「质量」而非阻止错误的 section,这个方法完全用不上——那才是没有公认答案的部分。
仓库文档里写着:一个 fs/observed 监听器抛异常,可能让工具在文件已经改成功之后返回 isError。请回答三问:(a) 这个后果的根因在哪一层;(b) 为什么它没有被仓库的任何门禁、不变式或类型系统拦住;(c) 如果让你修,有几种改法,各自的代价是什么。
emit 的实现方式。去 vendor/cordis/src/events.ts 搜 try,看看有几个。vendor/cordis/src/events.ts 全文没有 try/catch,emit()(第 194–195 行)直接 .map(cb => cb(...args)),所以监听器的同步 throw 会中断后续监听器并冲回发出方。工具那一侧只是没有捕获它——但工具本来就没有理由去捕获一个「记录事件」的异常。(b) 三层都拦不住,各有各的原因。类型系统拦不住:监听器的签名是 void,抛异常不改变类型;门禁拦不住:这是运行时行为,而门禁检查的是源码结构与文档一致性;运行时不变式也拦不住——按 AGENTS.md,不变式必须断言「权威事件流或可变数据」的关系,而「某个监听器有没有抛异常」是控制流事件,不是数据关系,何况 ctx.invariants 服务默认根本不在发行组合里跑。(c) 三种改法。① 在分发器里 try/catch——正是 docs/defensive-patterns.zh.md 对本仓库自有代码的要求;代价是要改 vendor/,而 vendoring 政策规定那是钉住的上游快照,改动必须登记进 manifest 并在每次上游同步时重新施加或退役,成本是长期的。② 在工具那一侧包住这次 emit——改动小、只在本仓库内;代价是每一个发出 emit 的地方都要各写一遍,而且下一个新增的 emit 点会重新踩坑(这正是第 9 章那道题里 O(N) vs O(1) 的老问题)。③ 不改代码,改约定——就是现状:在文档里要求监听器必须同步且只产生副作用;代价是这条约定没有任何机制执行,一个第三方插件作者读不到 docs/subsystems/filesystem.zh.md 第 185 行就会犯这个错。三种改法的取舍,本质上是「在哪一层付代价」的选择,而不是有没有正确答案。变式:如果把 fs/observed 从 emit 改成 parallel(parallel 用 Promise.allSettled 收集所有结果,再把失败聚合成 AggregateError 抛出),这个问题会消失吗?会带来什么新问题?
答辩:如果我是审稿人
你在 17.1 里说,seam 三角色在只有一个提供方时「抽象成本白付」,还特意点名 ctx.compaction 只有一个提供方。那我直说了:这套 seam 纪律就是过度设计。219 个包里塞着一堆只有一个实现的接口,每个都要三份 README、三个 ./invariant、三处 tsconfig 登记。这不是可扩展性,这是把「万一以后要换」当成了现在就要付钱的理由——而软件工程里最著名的反模式,恰恰就是为想象中的未来做抽象。请你为这个仓库辩护。
参考防守(先自己组织语言再看)
这个批评瞄得很准,但它把两个不同的问题合成了一个。拆开之后,一半要认,一半站不住。
先认下来的一半:「只有一个提供方的 seam,此刻的收益是零」这句话是对的,而且仓库确实不统计这个比例——本站也确实没找到任何脚本或门禁与此相关。批评者要求的「拿数据说话」是合理的,而这份数据不存在。这一点无法辩解。
站不住的一半,是「为想象中的未来做抽象」这个定性。判断一个抽象是不是投机,判据不是「现在有几个实现」,而是「谁被允许写第二个实现」。这两件事完全不同。一个公司内部的服务,多加一层接口而只有自己会实现,那确实是投机——因为潜在实现者只有你自己,你随时可以重构。但这个仓库的立场写在 CONTRIBUTING.zh.md 里:目前不接受外部 PR,同时明说「我们并不认为官方仓库中的包天然就比社区开发的包更重要……你可以将本仓库看作一种理念、一份官方示例以及一处灵感来源」,还专门建议给插件仓库打 dsh-plugin 话题。在这个立场下,「第二个提供方」的默认位置根本不在这个仓库里。用「仓库内有几个实现」去度量一个明确面向仓库外的接缝,度量的东西是错的。
第二点,成本被高估了,因为批评者把「三个包」的成本算成了「三倍」。实际不是。Service Definition 包通常只有类型和抽象基类——packages/compaction/compaction 整个包 792 行,而它的提供方 compaction-basic 是 1621 行。三个包里承载真实复杂度的只有一个,另外两个是边界。而边界这个东西,你不把它单独放一个包,它也存在——只不过变成了一份没人维护的隐式约定,藏在某个 import 里。拆包没有创造复杂度,它把已经存在的边界变成了看得见的。
第三点,也是最实质的:这套纪律的真正产出不是「可替换」,是「说得清」。packages/AGENTS.md 有一条规矩很少见——「为所有当前 Consumer 设计 Service Definition,不要让某一个 Consumer 决定服务约定」,还专门点出一个反向气味:一个只有一个内部调用方的公共服务方法,应该改成传一个私有能力闭包。注意这条规矩的方向:它在阻止过度暴露。一个真正只会投机地做抽象的项目不会写这种规矩。同一份文件里还有一条更硬的:「每个抽象、状态机、选项、防御性拷贝和兼容路径,都必须绑定到一个当前的约定或生产消费方」。这是一份明确反对投机抽象的规则集——它和 seam 纪律出现在同一份文件里,说明作者知道这条线在哪,并且试图划出来。
最后一句必须诚实:上面的辩护成立与否,取决于一件还没发生的事——生态里到底会不会长出第二批提供方。这是一个赌注,不是一个已被证明的结论。而本站唯一坚持的批评是:既然是赌注,那就应该有人报比分。仓库有能力算出「56 个服务键里有几个存在两个以上提供方」这个数,也有 28 道门禁的基础设施可以把它做成一份持续更新的报告,但它没有做。不统计,这个赌注就永远无法被证伪。
下面三句话,请分别判断它属于「仓库明说的限制」还是「仓库没说的缺口」,并说明你会怎么在十分钟内确认自己的判断。(A) 会话日志没有删除接口。(B) 一个 emit 监听器抛异常会影响后面的监听器。(C) timeoutMs 不加载策略插件就完全没有超时。
Known Limitations and Deferred Work。先去那里找。docs/。packages/session/session-persistence/README.zh.md:83,原文「无删除或保留接口:剪枝已存储会话是带外后端维护」。确认方法:grep -rn "保留接口\|retention" packages/session/。(B) 没说的缺口——本站在 docs/ 和包 README 里都找不到面向插件作者的这条提醒;能找到的只有它的一个具体后果(docs/subsystems/filesystem.zh.md:185 讲 fs/observed),而没有归因到分发器这一层。确认方法:grep -n "try {\|catch" vendor/cordis/src/events.ts——零命中就说明分发器不隔离,然后反过来在 docs 里搜有没有人提醒过这件事。(C) 明说的限制——packages/core/tools/README.md 的 Known Limitations 里逐字写着「timeoutMs on a definition is declarative only」。本题真正要练的是那个十分钟流程:先定位包 → 读它的 Known Limitations → 没有就搜子系统文档 → 还没有就去源码验证行为 → 反过来搜文档里有没有人提过。走完这五步还找不到,才有资格说「仓库没说」。跳过任何一步就下结论,你多半会把一条藏得深的限制误报成缺口——本站在核对时就干过这件事,见 17.6。变式:再加一句 (D)「--dump-config 打印的树和真正启动的树不完全一样」。它属于哪一类?为什么它和 (A)(B)(C) 都不太一样?
17.5 这个仓库处于什么阶段,以及怎么判断任何一个开源项目的成熟度
先把这个仓库的坐标钉死。
| 信号 | 当前值 | 出处 |
|---|---|---|
| 自我定位 | 开发者预览,「未来将出现破坏兼容性的变更」 | README.zh.md |
| 版本号 | 0.1.0-rc.5 | 根 package.json |
| 依赖的框架版本 | Cordis 4.0.0-rc.7(也是 rc) | docs/rescope.zh.md 映射表 |
| 持久格式版本 | SESSION_FORMAT_VERSION = 0,明说无兼容承诺 | packages/core/session/src/types.ts:56、AGENTS.md |
| 对破坏性变更的态度 | 有一节叫「预发布立场:地基优先于影响半径」,明写「随意重命名或重新分包」「后端拒绝旧的磁盘格式」 | AGENTS.md |
| 外部贡献 | 暂不接受外部 PR | CONTRIBUTING.zh.md |
最有意思的一条不在表里:AGENTS.md 那节「预发布立场」的第一句是——「在第一个打标签的发行版时删除本节。」
这一句话干了三件事:它承认当前处于一个特殊时期;它给出了这个时期结束的明确判据(第一个 tagged release);它把「到时候要改回来」这件事写进了代码库本身,而不是留在某个人的记忆里。
把这套读法一般化:判断一个开源项目成熟度的七个信号
下面这七条不是关于这一个仓库的,是你以后拿到任何一个陌生项目都能用的。按可靠性排序——越靠前的越难伪装。
- 持久格式和线上协议有没有版本号,以及那个版本号动过没有。这是最硬的信号,因为它是行为不是声明。一个格式版本停在 0 且从没升过,意味着项目还没有真正面对过「老数据要怎么办」这个问题。反过来,一个有
SCHEMA_VERSION且真的写过迁移的项目,说明它已经付过那笔学费。(本仓库两者都有:SQLite 用单调递增的SCHEMA_VERSION,会话格式停在 0。) - 有没有「已知限制」,以及那是不是强制的。注意区分两档:一个项目有限制清单,说明作者诚实;一个项目强制每个模块都写限制清单(本仓库是
verify-package-readme-limitations门禁),说明这件事不依赖作者当天的心情。第二档比第一档强一个数量级。 - 它有没有写下自己拒绝了什么。第 1 章讲过这条。一份只讲自己好的文档,要么是作者没认真考虑过替代方案,要么是考虑过但不想写。找那些「我们不做 X,因为 Y」的句子——本仓库有不少,比如 plan 模式明说是「软性指引」,强制约束留给沙箱和审批这两条独立的轴。
- 接不接受外部贡献,以及说没说清为什么。「暂不接受外部 PR」听起来是个负面信号,但它比「欢迎 PR」然后半年不回复要诚实得多。真正的负面信号是没有立场。
- 它对自己所处阶段的表述,有没有退出判据。「我们还在早期」是废话,「在第一个 tagged release 时删除本节」是承诺。有判据的自我定位比没判据的强。
- 依赖链上有没有别的 pre-release。这个很容易被忽略:本仓库自己是
0.1.0-rc.5,它 vendored 的 Cordis 是4.0.0-rc.7。一个 1.0 的项目如果依赖一堆 rc,它的 1.0 是假的。查法很机械:翻 lockfile 或依赖表,搜rc/alpha/beta/0.x。 - 文档的一致性是靠人还是靠机器维持的。这条最难看出来,但一旦看出来就极有价值——去 CI 配置和
scripts/里找答案,而不是去读 CONTRIBUTING 里的承诺。本仓库 28 道文档门禁,这是一个非常强的正信号;同时(第 16.9 节)它精确地告诉你哪部分文档可信、哪部分要自己验。
你看到一个开源 agent 框架:版本号 2.4.1,README 顶部有「Production Ready」徽章,星标三万,文档站很漂亮。你还发现:它的 CHANGELOG 里最近三个小版本都有 BREAKING 条目;package.json 里有四个依赖是 0.x;仓库里搜不到任何 schema_version/format_version 之类的东西;docs/ 下没有任何一处写「限制」或「不支持」。请给出你的判断,并按可靠性排序你用到的证据。
2.4.1 和「三个小版本都有 BREAKING」这两条互相矛盾——语义化版本里小版本不该有破坏性变更。当两个信号打架时,信任哪一个?0.x。你的稳定性上限被依赖锁死,2.4.1 是假的。(4) 没有任何「限制」或「不支持」的表述。这不能证明它有问题,但它证明作者没有系统地想过边界——任何真跑在生产里的系统都会积累一份不支持清单,一份都没有只有两种解释:没人用,或者没人写下来。(5) 最软——星标数和文档站的观感。星标测量的是传播,不是质量;漂亮的文档站测量的是有人配了 VitePress。这两条几乎不该进入判断。本题的核心是那个冲突的处理:当「版本号说稳定」和「发布记录说不稳定」打架时,信发布记录——因为写一个版本号的成本是零,而做一次破坏性发布是要挨骂的,挨了骂还这么发,说明它真的必须这么发。变式:再加一条证据——这个项目有 1200 个 issue,其中 900 个开着,最老的开了三年。这条证据该排在第几位?它到底测量了什么?
17.6 怎么读任何一份「我们的设计更好」的技术文档
第 1 章给了三档证据分级(仓库明文/作者设计意图/本站推断)和三个问题。走到这里,你已经有材料把它扩成一套真正能用的清单了。
下面七个问题是这一整章的方法论沉淀。它们的共同点是:每一个都指向一个具体的、可执行的动作,而不是「保持批判性思维」这种没用的建议。
问题一:这个数字的口径是什么?
看到任何数字,先问三件事:谁数的、数的是什么、能不能复现。本站在第 16 章说「692 个 spec 文件」的时候,同时给了口径——用 find 数 .spec.ts、排除 node_modules、其中 640 个在 packages/*/*/tests/ 下。给了口径你就能自己复现,也就能发现本站有没有数错。
没给口径的数字,问题不在于它是假的,而在于它不可反驳。「支持 50+ 工具」——50 个里有几个是默认开启的?有几个只在某个 profile 下存在?有几个是同一个能力的不同封装?口径不同,同一个系统可以诚实地报出 30 或 80。
问题二:有没有对照组?
「我们的设计让扩展变得容易」——比什么容易?一份技术文档里绝大多数「更好」都没有对照组,因为做对照组意味着要真的实现另一种方案。这不是造假,是成本问题。但你读的时候必须知道:没有对照组的「更好」,是一个假设,不是一个结论。
可以退一步问一个更容易回答的问题:作者有没有说清另一种做法会怎样?本仓库在这一点上有正面例子——第 9 章那个「默认拒绝 vs 默认允许」的对比,文档明确写了另一种做法在什么时刻会出什么事。这种「不做对照实验但把对照方案的后果讲清楚」,是技术文档能做到的最好水平。
问题三:「我们支持 X」,是不是意味着默认开启?
这是本章命中率最高的一个问题。本仓库里至少三个例子:
- 运行时不变式:机制完整、门禁齐全,但
ctx.invariants服务不在任何发行组合包里,「单独加载服务不会安装任何产品检查」。 - 工具超时:
ToolDefinition上有timeoutMs字段,但注册表从不执行截止时间,要加载dsh-tool-call-timeout-policy才有超时。 - 模型能力:
llm-pi-ai挂着,但零路由,直到用户设置里出现对应段落。
三个都不是虚假宣传——机制真的存在,文档也没说它默认开着。但「支持」这个词在读者脑子里默认等于「装好就有」。具体动作:看到「支持 X」,去配置文件或默认组合里搜 X,确认它在不在默认那一档。
问题四:作者承认的限制在哪一页?
不是「有没有」,是「在哪一页」——因为答案本身就是信息。本仓库的答案是「每个包 README 的最后一节,而且是强制的」,这是一个很强的正信号。但同一个答案也告诉你:没有汇总,你要读 219 份。
具体动作:拿到一个新项目,先 grep -ri "known limitation\|not supported\|limitations\|已知限制" 。搜到的位置分布,比搜到的内容更能说明这个项目的组织方式。一处都搜不到,参见 17.5 的信号四。
问题五:生成的部分和手写的部分怎么区分?
这是本章最有实操价值的一条,也是第 16.9 节那个结论的直接应用:生成的部分永远是新鲜的,手写的部分可能过期。
具体动作:搜 BEGIN GENERATED、do not edit、Generated from source、--check 这类标记,以及 CI 配置里跑的那些 verify-*/gen-*。本仓库的做法很典型——生成段落用 <!-- BEGIN GENERATED … --> 包起来并注明生成脚本,逐字粘贴的类型块用特殊围栏 ```ts type-equiv 标记。读到被这些标记包住的内容,可以直接采信;读到它们之外的散文,请自己回源码验证。本章 17.2 那四条不一致,全部落在标记之外。
问题六:如果这句话错了,会以什么形式被发现?
这个问题是前一个的一般化,也是全套清单里最锋利的一把。对任何一条陈述问:假设它现在是错的,谁会知道?
三种可能的答案,可信度依次递减:(a) 会有一道门禁变红——那么它错的概率极低;(b) 会有测试失败或用户报 bug——那么它可能错,但错了会被修;(c) 没有任何人会知道——那么它错的概率取决于它上次被人读是什么时候。
「两个执行器家族注册同一个 bash 服务」属于第三类:这句话错了,没有任何机制会发现,因为它在结构上完全合法,而且没有人会因为它跑不通程序。这类句子在任何项目里都是错误密度最高的地方。
问题七:这份文档在哪里说了「不」?
第 1 章问过一次,这里给一个更可操作的版本:去数「不」的句子。「我们不支持 X」「这个设计不解决 Y」「Z 属于另一个模块的职责」「这条约定不是不变式」。
一份成熟的技术文档,「不」的密度会相当高,因为划边界比声称能力更需要想清楚。本仓库有很多这样的句子,其中一句本章反复引用:docs/subsystems/filesystem.zh.md 那句「由策略插件占据是部署约定,而非强制不变式」——作者在这里主动放弃了一个更强的说法,而且明确告诉你强的那个不成立。这种句子是文档里最贵的部分。
最后:本站自己被这套方法打脸了三次
写这一章的原始素材,是前面各章撰写代理在核对源码时报上来的四十条。本站逐条回仓库核实,其中三条没通过——它们被写进初稿,然后被自己的核对推翻。这三条比通过的三十几条更值得你读:
被推翻一:「单次上下文上限仓库不给数」。核对结果:假。packages/llm/llm-deepseek/src/adapter.ts:91 有 DEFAULT_CONTEXT_WINDOW = 1_000_000,Config.defaultContextWindow 的 JSDoc 写着「default 1,000,000」,交付的两个默认模型都带着 contextWindow。真实情况只是:DeepSeekCatalogModel 上的 contextWindow 字段是可选的(注释:「部署元数据不可用时省略」)。教训:把「某个字段可选」读成了「这个值不存在」——只看了类型定义,没看默认值。
被推翻二:「会话日志的保留策略仓库完全没说」。核对结果:假。写在 packages/session/session-persistence/README.zh.md:83。教训:只搜了概念文档和子系统文档,没搜包 README 的限制清单。这是把「藏得深」误报成了「没说」——正是 q17-5 那道题里那个五步流程要防的错。
被推翻三:「plan 模式不改工具目录,但仓库没解释为什么这样选」。核对结果:假。packages/plan/plan-mode/src/index.ts 第 64–65 行的 JSDoc 就给了理由(退出工具常驻,使请求的工具目录在状态切换之间保持稳定),README.md 也写明 plan 模式是软性指引、强制约束留在沙箱与审批这两条独立的轴上。教训:只读了 Markdown,没读源码里的 JSDoc。而这个仓库有一道叫 verify-export-jsdoc 的门禁——它的很多「为什么」就写在 JSDoc 里。
三条错误,三种不同的检索盲区。这就是为什么本章开头强调「确认不了的不写」:如果本站不做这一轮核对,你现在读到的这一章里会有三条假话,而且它们看起来和真话一模一样。
给你一份陌生项目的架构文档,只允许你花二十分钟,而且不许读它的源码。请设计一个流程,输出两样东西:(1) 这份文档里哪三句话最可能是错的;(2) 你这个流程必然会误判的一类句子。
BEGIN GENERATED/do not edit/--check,再扫一眼 CI 配置里的 verify-*,把文档切成机器维护和手写两半,后面只看手写那半。②【5 分钟】在手写部分里挑出所有带具体标识符的断言(服务名、事件名、字段名、命令名)——这些是错了也不会有人发现、但你能验证的句子。③【5 分钟】挑出所有数量与顺序断言(「三种模式」「四个核心组件」「先 A 后 B」),这类最容易随代码演进而过期,因为加一个第五种不需要改任何文档。④【4 分钟】挑出所有「默认」「自动」「内置」的句子,对照默认配置文件核对——问题三命中率最高的那一类。⑤【3 分钟】数「不」的句子,形成对整份文档可信度的整体判断。输出的三句话,应该来自 ②③④ 三堆里最具体的那些——具体是关键:越具体的断言越容易过期,也越容易被验证。必然会误判的一类:因果与价值判断。「因为 A,所以 B 更好」——这句话既不属于生成部分,也带不了标识符,还常常混在事实里说出来(第 1 章那道题练的就是这个)。这个流程会把它当成普通散文放过,或者当成事实误报。它没有真假,只有「作者的说法」这一档,而任何以「找错」为目标的流程,都会在这一档上失效。这也正是第 1 章那三档分级必须和本章七问配合使用的原因:七问负责在「仓库明文」这一档里找错,三档分级负责告诉你哪些句子根本不该拿去找错。变式:把限制从「不许读源码」改成「不许读文档,只能读源码」。二十分钟内你会先看哪三个地方?为什么这个方向反而更难?
本章把问题分成三类:文档与源码对不上、仓库说了但藏得深、仓库完全没说。请说出这三类对你作为使用者各自意味着什么不同的风险。
llm.stream() 会怎样」,你只能自己触发一次看结果)。这一类最贵,因为它把验证成本完全推给了每一个使用者,而且每个人都要重新试一遍。三类的严重程度不是递增的:第一类最容易骗到你,第三类最费你时间,第二类其实最容易补救——这也是为什么本章把它单独列出来,而不是并进「缺口」里。变式:如果你要给这个仓库提一条最有价值的改进建议(假设它接受外部建议),你会针对哪一类?为什么?
给你的 mini-harness 写一份诚实的「已知限制」
最后一层不加功能,写一份文档。这不是收尾仪式——它是本站认为整条主线里最难的一步,因为前面十七层你都在证明自己能做到什么,这一层要你写清自己做不到什么。
照仓库的形式来。packages/AGENTS.md 要求每个包 README 有一节 ## Known Limitations and Deferred Work,每条的格式是:消费方可见的缺口 —— 确切缺失的操作或情况、它的后果,以及任何维护者约束。注意这三段的顺序,它把「缺什么」和「所以会怎样」分开了,而后者才是读者真正需要的。
三条纪律,缺一条这份清单就不诚实:
第一,只写「消费方可见」的缺口。「我的代码写得比较乱」不是限制,那是你自己的事,读者不关心。限制必须是使用者能撞上的:某个操作不支持、某种情况下行为不对、某个数值有上限。分辨方法很简单——如果一条限制没法写出「后果」那一句,它多半不是限制。
第二,「后果」那一句必须是具体场景,不是形容词。「性能不好」是形容词;「一次要读五个文件的任务耗时是并行版本的五倍」是场景。写不出场景,说明你其实不知道这个限制什么时候会咬人。
第三,不许用「暂时」「未来会」这类词逃避。仓库把这类事情叫 Deferred Work,而且要求分清:日常清理留在源码 TODO 或 Agent Note 里,只有持久的缺口才进这一节。判据是:这件事如果三年后还是这样,它还算个缺口吗?算,就写进来。
写完之后做一件事:把你的清单和第 16 层那张十六行对照表并排放。你会发现两者高度重合——第 16 层「仓库多出来的那部分在解决什么问题」的每一条,几乎都对应你这份清单里的一条限制。这不是巧合:那五千行代码,就是别人已经写完的、你这份清单上的条目。
做对了的样子:清单至少有五条,且每一条都能通过这三道检验——(1) 有一个具体的、你能当场演示的失败场景(不是形容词);(2) 你能说清这个限制是设计选择还是没来得及做,两者都合法但不能混;(3) 至少有一条你写的是「我知道这是个问题,但我不知道怎么修」——一份全是「以后会修」的清单是不诚实的,因为真实系统里总有一些问题是没有已知解法的(本章 17.4 缺口一和第 3 章那个卸载超时就是现成例子)。
本章小结
这一章讲的不是这个仓库的缺点,是怎么看一个系统的账:
- 债是决策的背面,不是缺陷。一切皆插件买来「任何部分都能替换」,付出的是「没有入口、能力散在多个包、启动失败变成静默 PENDING」;仅追加日志买来恢复与回放,付出的是磁盘只增不减;单调守卫买来组合安全,付出的是放宽策略无法用插件表达;waterfall 买来极强的拦截能力,付出的是忘调
next()静默短路。想去掉右边,就得放弃左边同一行。 - 文档与源码对不上(本站核对,非仓库承认):
parallel()在诊断事件里报成'emit';组合包 README 说的bash服务实际叫shell;不变式包 README 正文说四个核心配套,同页示例排除掉了其中一个;--dump-config比真正启动少两层且不求值!!js。四条全部落在手写散文里,一条也不在生成物或类型块里。 - 仓库说了但藏得深:会话日志无删除/保留接口;「服务在 ≠ 能力在」只活在一句 YAML 注释里;模块解析失败只走 logger 且启动早期可能丢;
fs/observed监听器抛异常能让已成功的写入返回isError;pre-execute有意不能改参数;SESSION_FORMAT_VERSION停在 0。它们都在包 README 的强制限制章节或长句子的后半段——制度让它们必然被写下来,也让它们必然被分散。 - 仓库完全没说:服务被替换时进行中的调用语义;事件分发器不隔离监听器异常(
events.ts零个try/catch,而仓库自己的防御式模式要求隔离);FiberState没有转移表;「容忍并发写入方需要日志之外的存活信号」——问题提出来了,信号没给;提示词的成本有强制格式,收益没有任何口径。 - 成熟度看机制不看措辞:格式版本号动过没有、限制清单是不是强制、有没有写下拒绝了什么、自我定位有没有退出判据、依赖链上有没有 rc、文档一致性靠人还是靠机器。
- 七个问题:数字的口径、有没有对照组、「支持」是不是默认开启、限制写在哪一页、生成的和手写的怎么分、这句话错了谁会发现、这份文档在哪说了「不」。
最后回到第 1 章那句话:本站也是一份技术文档,也有它的三档证据。这一章列出的每条不一致都给了路径和行号,就是为了让你能回去推翻本站——包括推翻本章。本站自己在核对时就推翻了三条初稿里的说法,这三条的价值不比留下来的那三十几条低。
你从一个 20 行的 while 循环开始,造了十七层,最后给自己写了一份诚实的限制清单。能写出这份清单,才说明你真的理解了自己造的东西——对别人造的东西,标准也一样。
第18章 终极闯关:把 18 章连起来
这一章一个新知识点都没有。它只做一件事:把前面十八章拆开讲的东西重新按一次真实调用的顺序串起来,看看你是不是真的把它们连上了。连不上的地方,就是你该回头重读的那一章。
学完这一章你应该能做到
- 拿到一句普通的用户请求,能从头到尾说出它在 harness 里经过的每一个环节,并指出每个环节是哪一章讲的
- 对任意一个环节回答「把它去掉会怎样」——不是「会出错」,而是具体会在哪一步、以什么方式出错
- 做出至少八道需要同时动用三章知识才能答的题
- 为这套设计辩护,也能说清哪些地方你守不住
- 挑出一个真正没有答案的问题,并给出你明天就能动手的第一步
18.1 一次真实调用的全链路追踪
场景是这样的。你打开 harness,敲进去一句话:
把 src 目录下所有 .ts 文件里的 console.log 删掉
这句话十九个字。从它落地到任务结束,中间会经过大约二十个环节,横跨你读过的每一章。下面这个推导块就是要你自己把它走一遍。
规则:每一级都先问你两个问题——这一步是第几章讲的,以及如果把这一步去掉会怎样。第二个问题才是重点。「会出错」不算答案,你要说出在哪一步、以什么形式出错。想好了再展开。
为了让追踪有具体的落点,假定这次模型返回了三个工具调用,顺序是:read(src/a.ts)、read(src/b.ts)、edit(src/a.ts)。这三个的选择不是随便的,后面第 9 级你会看到为什么。
自己推一遍:从十九个字到文件真的被改了
第 1 级。这句话进入 harness 之后,第一个落脚点是哪里?它会立刻触发一次模型请求吗?
想好了再看
第 7 章。它落进 inbox,不是立刻发请求。inbox 是一个待处理输入的队列,循环在合适的时机整批取走(claim)。
去掉会怎样:回到第 0 章那个 20 行版本——「来一条消息、发一次请求」。于是你连着敲两句话,就会变成两个轮次、两次请求、两份完整的历史重发。更糟的是第二句话到达时第一句还在跑,你没有任何地方安置它,只能丢掉或者插队打断。inbox 不是为了排队好看,它是为了让「输入到达的时刻」和「循环推进的时刻」解耦。
第 2 级。claim 一次取多少条?取一条和取一批,对日志会产生什么可见的差别?
想好了再看
第 7 章。取一批。差别在日志里一眼可见:一次 claim 对应一个
turn/start,批里的每条消息各是一条user/message,它们落在同一个step/start … step/end之间。你在第 7 层建造台阶里验的就是这一条——出现两个turn/start就说明 claim 写成了「一条一个轮次」。去掉会怎样:轮次这个概念就没有了。轮次是外层策略(目标、Ralph)唯一能挂靠的边界;没有它,「这次任务跑了几轮」「该不该继续」这些问题连提都提不出来。
第 3 级。轮次开始了。在发请求之前,harness 往会话日志里写了什么?为什么要先写?
想好了再看
第 6 章 + 第 7 章。先写
turn/start,再写这一批user/message。日志是仅追加的,只往后加、从不修改。去掉会怎样:先执行后记录,你就会拿到一份会说谎的日志——崩溃发生在两者之间时,事情做了而日志里没有痕迹。第 9 章那句纪律说的就是这件事:宁可记了没做,不能做了没记。这条规矩在整条链上出现了三次(写
user/message、写tool/call、写压缩锁),它不是巧合。第 4 级。
agent/pre-step是一个 waterfall。它在这一步做什么?为什么它是全站挂载方最多的扩展点之一?想好了再看
第 4 章 + 第 7 章。它是「决定模型这一步看到什么」的那道闸,用洋葱式的环绕中间件实现:每个监听方拿到
(…args, next),改一改共享的请求对象,然后调next()往里走。本站从docs/event-producer-consumer.zh.md数出来,它有 13 个监听方——注入时间上下文的、注入 agent 指令的、压缩的、plan 模式的、两套钩子桥接的、目标驱动器的,等等。去掉会怎样:上面这十三件事就得全部搬进循环本体,于是「改行为」变成「改 agent-loop」,而仓库的规矩是插件而非改循环。更现实的后果是:压缩没地方挂了。压缩必须在组装请求之前知道「这一步要发多少 token」,除了这里没有别的位置。
要小心的地方:13 个监听方之间只有
prepend这个布尔开关,没有优先级。出现第二个prepend时,顺序就重新变成偶然的了。第 5 级。现在要组装那页给模型看的纸。它由哪几块拼成?工具的说明书从哪来?
想好了再看
第 8 章。系统提示词走
system-prompt/assemble这个 waterfall,每个想说话的包贡献一段;工具 schema 走ctx.tools.schemas(scope),注册表按显式允许列表把定义投影成面向模型的那几个字段。去掉会怎样(这一题有两个答案):去掉分段组装,提示词就得写死在一个地方,于是「装了哪些插件」和「模型知道自己能干什么」这两件事会脱节——你装了
lsp插件但提示词里没提,模型永远不会用它。去掉那个允许列表,后果更严重:execute、timeoutMs、isConcurrencySafe、presentCall这些宿主专用字段会漏进模型请求,白花钱不说,还会让模型看见它管不着的东西。第 6 级。提示词有了,那对话历史呢?那个要发给模型的
messages数组是从哪儿来的?想好了再看
第 6 章。它不是存着的,是算出来的。
deriveMessages()把仅追加的事件日志投影成模型历史。投影规则是逐条定死的:assistant/chunk跳过(组装后的消息才权威),内容为空的assistant/message也跳过,tool/result变成一条带tool-result块的 user 消息,turn/*和step/*是结构信息、不进消息。去掉会怎样:你就得同时维护「日志」和「消息数组」两份东西,然后它们会不一致——这不是可能,是必然,因为压缩、fork、恢复三条路径都会改其中一份。把历史做成日志的纯函数,一致性就不用维护了,它是算出来的。你在第 6 层建造台阶里验的正是这一条:日志倒回第 3 条再投影,结果必须和当年逐字符相等。
第 7 级。请求发出去了。它是直接
fetch某家厂商的接口吗?想好了再看
第 2 章 + 第 4 章。不是。它走
ctx.llm——一个能力 seam:dsh-llm定义词汇和适配器接口,llm-deepseek之类是并列的提供方。流式响应本身又是一个 waterfall(llm/stream,5 个监听方),重试、录制、回放、抽标题都挂在上面。去掉会怎样:第 1 章的死法六——换厂商就要全文搜索改一遍。而且重试没地方挂:重试必须包住整个流式调用,不是在某个
catch里补一句。第 8 级。模型返回了三个工具调用。harness 拿到它们之后,第一件事是执行第一个吗?
想好了再看
第 9 章。不是。第一件事是往日志里写
tool/call,带着turn、step、callId、工具名和模型原样发来的参数。第二件才是策略。顺带一个容易忽略的细节:模型发来的参数是一段字符串,解析用的是
parseArguments(packages/core/agent-loop/src/tool-calls.ts)——解析失败不报错,原样当文本留着,空串映射成{}。这样即使模型吐了一段坏 JSON,日志里留下的也是它真正发出来的东西。去掉会怎样:被拒绝的调用就查不到了。而被拒绝的尝试恰恰是最该留痕的那一类——「谁试图删这个文件」这个问题,只有先记后判才答得出来。
第 9 级。三个调用是一起跑,还是一个一个跑?谁决定?
想好了再看
第 9 章。由每个工具自己声明的
isConcurrencySafe决定,调度器每次重新分类。本站在packages/下数了一遍,明确写着isConcurrencySafe: () => true的一共 8 处、分布在 6 个包里:read、read_image、web_search、web_fetch、subagent,以及三个会话查询工具。edit不在其中,glob、grep也不在。所以这次的实际执行是:两个
read并行起飞,走到edit时重新分类发现它是独占的,于是等当前这一池排空,edit留给下一个屏障单独跑。同时在飞的调用数还有个上限,默认DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10(packages/core/agent-loop/src/constants.ts)。去掉会怎样:全部串行,一次读十个文件要等十趟;全部并行,两个都改同一个文件的工具会互相覆盖。分类调度就是在这两个极端之间取一个由工具自己声明的中间态——代价是这个声明没有任何人校验,这是 18.4 的研究课题之一。
第 10 级。每一个调用各自要过七道关。凭记忆按顺序说出来,并指出哪一关不能改写参数、哪一关不能放行。
想好了再看
第 9 章 + 第 10 章。顺序是:
tools/pre-execute(可重排的 allow/deny/ask waterfall,钩子、权限、沙箱都挂这里,有意不能改写exec.arguments)→ctx.approval(一次性询问,只有allowed-once放行)→ 单调守卫(只能拒绝或弃权,没有 allow 分支)→tools/execute环绕分发 → 工具本体 →tools/post-execute→ 收尾四件事。为什么审批排在守卫之前:因为守卫没有放行能力。人点了「同意」也推翻不了所有者策略——这个顺序不是随便排的,它编码了一条权力关系。
去掉会怎样:去掉单调性(也就是给守卫加一个 allow 分支),顺序就变成安全属性了:谁最后跑谁说了算,而顺序是由依赖驱动的激活顺序决定的,不由你的配置行序决定。你的安全性会随部署漂移。
第 11 级。两个
read并行跑,B 比 A 先返回。日志里tool/result的顺序是 B、A 还是 A、B?想好了再看
第 6 章 + 第 9 章。是 A、B——按模型顺序,不按完成顺序。
tool-calls.ts里那个committed计数器只沿着连续的模型顺序槽位往前推进:B 先settle 也只是先占住自己的槽,要等 A 的槽填上,两个才一起提交。去掉会怎样:日志顺序变成非确定的,于是同一次运行回放两遍会得到两份不同的模型历史。回放不一致,第 6 章那套「日志即真源」就全塌了——fork、恢复、快照测试、事后审计全部失去意义。为并发付出的代价必须不包括可重放性,这是设计上的硬边界。
第 12 级。假设你在
edit跑到一半时按了 Ctrl-C。第三个调用还没开始,日志里会有它吗?想好了再看
第 6 章 + 第 9 章。会。
executeToolCalls在中断路径上对每一个没开始的调用执行appendSkippedToolCall:补一条tool/call,再补一条合成的tool/result。去掉会怎样:你会得到一条有调用没结果的历史。而模型协议要求每个 tool-call 必须配一个 tool-result——下次带着这段历史发请求,厂商接口直接报错,会话从此死掉,而且是永久死掉,因为日志只追加不修改。这也正好是 18.2 第一题要考的那个结构。
第 13 级。工具结果回来了,进入第二个步骤。这次模型说「改完了」,不再调工具。轮次怎么结束?
想好了再看
第 7 章。写
step/end,然后turn/end,带一个completed标记,最后agent/turn-stopping(全站唯一的serial事件)给钩子一个必须等完的收尾机会。去掉会怎样:没有轮次结束事件,外层就不知道该不该发起下一轮,只能靠轮询猜。
第 14 级。最后一问,也是最要紧的一问:
turn/end{completed}是不是表示「console.log真的都删掉了」?想好了再看
不是。它只表示模型不再欠一次响应——模型这一轮没有再发起工具调用,仅此而已。
src目录里可能还剩十七个console.log,可能有三个文件被改坏了,也可能模型压根搞错了目录。循环自身没有任何任务完成判据,它只知道对话停了。真正的「完成」是由外层策略套上去的:目标(Goal)用
active/paused/blocked/complete四个阶段加一个 Goal Round 上限,Ralph 循环用一份跨 Round 的交接报告。但这两个也不是判据,它们是预算和状态机——上限用完就停,不管做没做完。这是第 7 章埋下的那个伏笔,也是 18.4 的第一个研究课题。整本书讲了十八章的机制,唯独这一件事,仓库没有答案,学界也没有。
不看上面的推导块,按时间顺序写出这五个环节的正确次序:(A) 写 tool/call 进日志 (B) claim 一批 inbox 输入 (C) deriveMessages() 投影模型历史 (D) 单调守卫 (E) agent/pre-step waterfall。
agent/pre-step 的作用是决定这一步模型看到什么,所以它必须排在组装历史之前,不是之后。agent/pre-step 是发请求前的最后一道闸,压缩、上下文注入都在这里改动请求;C 在闸放行之后把日志投影成 messages;模型回话带来工具调用,A 先把 tool/call 写进日志;D 的单调守卫在七道关的第 3 关,排在 tools/pre-execute 和审批之后。注意 E 在 C 之前是最容易错的一处——如果 pre-step 排在投影之后,压缩就改不动这一次请求了。变式:如果把 tool/call 的写入(A)挪到单调守卫(D)之后,链条还能跑通吗?跑得通的话,你会失去什么?
同一步里模型返回了四个调用,顺序是 web_fetch、web_fetch、edit、read。已知 web_fetch 和 read 声明了并发安全,edit 没有。请说出这四个调用分成几个批次、每批有谁,并说明最后那个 read 为什么不能跟前两个 web_fetch 一起飞。
[web_fetch, web_fetch] 并行;走到 edit 时重新分类发现独占,池停止填充并排空,这一批只消耗掉两个。第二批 [edit] 单独跑。第三批 [read]。最后那个 read 虽然自己并发安全,但它排在一个独占调用后面——模型顺序是不能打乱的,把 read 提前到 edit 之前跑就等于改写了模型的意图(edit 可能正好改的是 read 要读的文件)。并发只在连续的一段并发安全调用之间发生,永远不跨越屏障重排。变式:如果四个调用全部并发安全,而 maxParallelToolCalls 配成 2,会分成几批?这时候「批」还等于「屏障」吗?
有人在你的部署上加了一条逻辑:「收到 turn/end{completed: true} 就把任务标记为成功并通知用户。」请构造一个具体的、完全正常的运行过程,让这条逻辑给出错误的结论——不许假设模型在撒谎,也不许假设任何组件有 bug。
completed 到底是对什么下的判断?它的主语是模型,还是任务?src 下所有 .ts 里的 console.log 删掉」,模型调 glob 拿到文件列表。但这个部署里 glob 默认跳过隐藏目录,src/.generated/ 下的三个 .ts 文件没被列出来。模型对着它拿到的完整列表逐个改完,确认无误,停手。turn/end{completed: true} 写进日志——每一个环节都正确工作,模型没撒谎,工具没出错,而任务客观上没完成。根因是 completed 的主语是模型的响应义务,不是任务的客观状态;模型只能对它看到的世界负责,而它看到的世界是由工具返回值构成的。第二类构造更简单:模型改完后说「还有三个文件需要你确认后我再改」然后停手——这也是 completed: true,因为它确实不欠响应了。想真的判断完成,你必须在循环之外准备一个独立可判定的检验(比如跑一遍 grep -r "console.log" src/ 看返回是否为空),而这个检验 harness 不会替你写。变式:把这条逻辑改成「收到 completed: true 且本轮至少执行过一次工具调用,才标记成功」。这个补丁挡住了上面哪一类构造,没挡住哪一类?
18.2 跨章综合题
下面八道题全部是 level 4,每一道都要同时用到至少三章的知识。它们的共同特征是:单看任何一章,你都会给出一个听起来合理但错误的答案。
建议的做法:先不看提示,把你的答案写在纸上,写完再展开第四级对照。写不出来不丢人,写出来一个笃定的错答案才危险。
(第 6 章 + 第 9 章 + 第 12 章)压缩把一段历史替换掉了。那段被替换的范围里有一个 tool/call,而它对应的 tool/result 在替换范围之外(还留着)。接下来会发生什么?请分三层回答:日志层面、投影层面、发给模型之后。
surfaceOp: { op: 'replace', start, end } 作用在哪一层。回忆第 6 章:日志和 surface 是两样东西。tool/call 到底是不是一个 surface 事件(第 6 章列过:产生消息的只有三种类型)。这个确认会直接改变整道题的答案。tool/call 根本不是 surface 事件。第 6 章讲过,产生消息的 SurfaceEventType 只有三种:user/message、assistant/message、tool/result。模型历史里那个「助手发起了工具调用」是从 assistant/message 里带出来的,不是从 tool/call 事件投影的;tool/call 是记账事件,deriveMessages() 压根不看它。所以分三层:日志层面,什么都没丢——日志仅追加,压缩不删任何东西,那条
tool/call 永远还在,事后审计照样查得到。投影层面,被遮蔽的是 assistant/message(那条带着 tool-call 块的助手消息),如果它落在替换范围内,而 tool/result 在范围外,那么投影出来的历史就会出现一条没有对应调用的 tool-result。发给模型之后,这是协议级错误:厂商接口普遍要求每个 tool-result 必须回指一个存在的 tool-call id,请求会被拒。这就是为什么压缩的
shadowedRange 是一个 surface 位置跨度(start/end 都必须是有效的 surface seq),而不是一个日志区间:范围只能落在 surface 这一层,才谈得上「切没切开配对」。要真正防住,还需要选范围的那一步保证不把配对切断——而这一层保证归压缩提供方的选范围逻辑,不归 surface 词汇本身。第 12 章那个「基础提供方」怎么选范围,是你该回去核对的地方。变式:反过来——被替换的范围里有 tool/result,而产生它的那条 assistant/message 在范围之外。这次会出什么问题?为什么它比原题更难被发现?
(第 2 章 + 第 3 章 + 第 10 章)一个守卫插件写着 inject = ['permissionPresets']。运行到一半,提供 permissionPresets 的那个插件被热重载卸掉了。此刻正好有一次工具调用已经过了 tools/pre-execute、还没到工具本体。这次调用会怎样?请说出你能确定的部分和你不能确定的部分。
import 进去的,还是被注册进去的?ctx.tools.guard() 注册的,返回一个释放函数,生命周期绑在守卫插件自己的 fiber 上(第 3 章)。(二)提供方卸载会让守卫插件失去 inject 的服务,它的 fiber 会退回 PENDING(第 2 章:依赖是一直盯着的,不是启动时查一次),退出 ACTIVE 的过程中它注册的所有 effect 都会被撤销——包括这个守卫。(三)撤销之后再走到第 3 关的调用,不会被这个守卫检查——而且第 9 章讲过,守卫只能拒绝、不能放行,所以少一个守卫的效果是放松,不是收紧。(四)日志不受影响:tool/call 早写完了。不能确定的部分,也是这道题真正的落点。「已经过了第 1 关、还没到第 3 关」这个瞬间,卸载和调用是并发的。这次调用到底会不会被检查,取决于释放函数跑完的时刻和调用推进到第 3 关的时刻谁先谁后——而这两件事之间没有任何同步。仓库对这件事没有任何说明:Cordis 卸载服务时会
await 依赖它的 fiber 稳定,但「fiber 稳定」不等于「业务调用结束」,一次进行中的调用在提供方卸载时是被取消、跑完、还是挂住,是无定义的。所以正确的答案是:这是一个竞态,而且是一个会让安全策略在热重载窗口内短暂失效的竞态。要在生产上避免它,唯一稳妥的做法是别让安全相关的插件参与 HMR,或者让守卫不
inject 任何可卸载的服务(把策略数据在注册时快照进闭包)。变式:如果这个守卫的 inject 写成可选依赖(第 2 章 2.6 节那种),上面的分析哪几条会变?失效窗口是变长了还是变短了?
(第 8 章 + 第 10 章 + 第 14 章)子 agent 在自己的 setup 里注册了一个工具 deploy。父 agent 的提示词里不会出现它。请给出三个不同层面的原因——层面不同的意思是:就算你把其中一个机制去掉,另外两个仍然各自足以让这件事成立。
schemas(scope) 的那个参数)。这三个词各自在哪一章?层面一,可见性(scope,第 8 章 + 第 10 章)。通过
agent.ctx 做的注册是带作用域的,归属于恰好一个 scope key。scope 只有两层且是扁平的:一项贡献要么全局、要么归一个 scope。ctx.tools.schemas(scope) 读的是「全局层 + 观察 scope 的那条链」——父 agent 传的是自己的 scope,子 agent 的层根本不在这条链上。层面二,方向性(第 10 章)。就算你以为「子的东西应该往上冒」,规矩也明说了反向:带作用域的注册不会向下继承给子 agent,更不会向上渗给父 agent。父子关系是靠
lineage 这类数据表达的(parentSession、delegationDepth),而 lineage 明确规定从不影响可见性。这是两套完全分开的东西:一套管「看得见什么」,一套管「谁是谁的孩子」。层面三,生命周期(第 3 章 + 第 14 章)。注册是副作用,绑在子 agent 的 scope 上——同一个事实同时决定可见性和生命周期。子 agent 结束,它的 scope 释放,那个
deploy 注册连同释放函数一起消失。就算前两层都失效,父 agent 也只能在子 agent存活期间看到它,而典型的委派里子 agent 是一次性的。三个层面是真正独立的:一是读取时的过滤,二是注册时的归属规则,三是释放时的回收。这也解释了为什么 setup window 要卡在「scope 和 agent 对象已存在、但首次提示词尚未组装」那个位置——注册必须发生在第一次读取之前,否则子 agent 自己的第一页纸上也不会有
deploy。变式:如果你确实想让父 agent 用到 deploy,在不破坏上面任何一条规矩的前提下有哪两种做法?(提示:一种改注册位置,一种根本不注册工具。)
(第 3 章 + 第 4 章 + 第 9 章)你保存了一个文件,HMR 把一个挂在 tools/pre-execute 上的监听器插件卸载并重新加载。而此刻正好有一次工具调用已经进入了这条 waterfall 链、停在这个监听器的 await next() 上。新的监听器会接手这次调用吗?旧的那个闭包会怎样?
next 闭包——它捕获了什么?回忆第 9 层建造台阶里那个 i++ 必须放在取出监听器之后的细节。next() 走到的是哪张表?(一)waterfall 分发在开始时把监听器组装成一条链,
next 闭包捕获的是那一刻的列表。第 50 毫秒的卸载改的是下一次分发要读的那张表,改不动已经在飞的这条链。所以这次调用会一路走完旧监听器,新监听器一次都不会被调用。(二)旧插件的释放函数已经跑完了,它
inject 的服务引用、它开的连接、它注册的别的东西全撤了——但那个还在栈上的闭包不知道。它接着往下跑,可能去访问一个已经被释放的资源。第 3 章讲的「注册可逆」保证的是注册表干净,不保证在飞的调用里的引用还有效。这两件事经常被混为一谈。(三)现象上你会看到:改了策略、保存了、界面提示重载成功,然后正在跑的那次调用仍然按旧策略执行。如果你改的是一条收紧的安全规则,你以为它已经生效了,其实这一次没有。
三章的交叉点在这里:第 4 章解释为什么链是定死的(闭包),第 3 章解释为什么资源已经没了(effect 撤销),第 9 章解释为什么这件事有安全后果(这条链上挂着权限和沙箱)。单看任何一章都会得出「重载了就生效了」这个错误结论。
变式:如果这个监听器没有调 next()(是个短路的策略监听器),上面三条里哪一条不再成立?为什么短路反而让情况变简单了?
(第 6 章 + 第 7 章 + 第 9 章)有人写了两个工具 append_log 和 append_note,都声明 isConcurrencySafe: () => true,而它们都往同一个文件末尾追加内容。模型在同一步里各调了它们一次。请判断下面四件事哪些会坏、哪些不会坏,并各给理由:(A) 文件最终内容 (B) 日志里两条 tool/result 的先后顺序 (C) 把日志回放一遍得到的模型历史 (D) 这一步的 token 记账。
committed 计数器——它保证了什么,又没有保证什么。它管的是记录,不是执行。(A) 坏。两个工具真的在并发地写同一个文件。如果它们各自用「读全文—拼接—写全文」,后写的会覆盖先写的,你丢一条;如果用 O_APPEND 追加,多半没事但不保证(跨平台、跨后端不一致)。仓库对这件事只有一句约定——参与并发的执行不得改父级拥有的状态,共享状态必须容忍并发分发——但没有任何机制去校验这句话。工具自称安全就被采信。
(B) 不坏。调度器的
committed 只沿连续的模型顺序槽位推进,谁先 settle 都要按模型顺序提交。(C) 不坏。模型历史是从日志投影出来的纯函数,日志顺序确定,投影就确定。
(D) 不坏。记账读的是每个步骤的
assistant/chunk { type: 'usage' },跟工具怎么执行无关。这道题真正要考的是边界在哪:harness 的并发保证是「记录可重放」,不是「执行互不干扰」。执行层的正确性被完全下放给了工具作者的一句自我声明。这也是本站认为最值得研究的开放问题之一(见 18.4)——一个连编译期都拦不住、只靠约定的安全承诺,在插件生态里意味着什么。
变式:把两个工具改成都只读同一个文件。(A) 还坏吗?如果其中一个读、另一个写呢?这两种混合情形,仓库的那句约定管得住哪一种?
(第 4 章 + 第 6 章 + 第 8 章)一个插件挂在 agent/pre-step 上,每一步都往模型历史最前面塞一段 500 token 的项目背景——它直接改了传给模型的 messages 数组,没有往会话日志里写任何事件。它调了 next(),一切看起来正常。请说出这个写法造成的三个不同层面的后果,并指出哪一个后果是沉默的(不会以任何报错的形式出现)。
后果一,回放不一致(第 6 章)。会话日志是模型所见上下文的唯一真源,模型历史应当能从日志重建。现在有 500 token 的内容进了请求却没进日志,于是
deriveMessages() 重建出来的历史和当时真正发出去的不相等。fork、恢复、快照测试、事后审计全部会得到一个「少了一段」的版本。后果二,成本被低估(第 8 章)。token 记账走的是日志侧,这 500 token 每一步都花,但账上看不到。一个 30 步的任务,这是 15000 token 的隐形开销,而且它随步数线性增长——提示词的成本是每一步都要付的。
后果三,也是沉默的那一个。仓库确实有一条断言在管这件事(
packages/core/agent-loop/src/invariant.ts),但驱动它的 dsh-invariants 服务不在任何发行组合包里——真正挂载它的是示例与测试拓扑。所以在一个普通部署上,这个错误不会有任何报错:请求正常、回话正常、界面正常,只有当你哪天去 fork 或者回放时才会发现历史对不上,而那时候已经攒了几百个会话了。正确写法是把这段背景作为一条会话事件写进日志(第 6 章讲的那种非
user 来源的注入上下文,它会按时间顺序投影成一条 user-role 消息),而不是在 waterfall 里直接改数组。「新的模型可见输入需要一条会话事件」这句话不是风格建议,它是可重放性的全部依据。变式:如果这个插件改成只在第一步注入一次、后续步骤不再注入,上面三个后果各自变成什么样?「沉默」这个性质变了吗?
(第 2 章 + 第 3 章 + 第 5 章)你在 cordis.patch.yml 里给一个插件改配置,但把条目 id 拼错了一个字母。启动后一切正常,没有报错,程序照跑。三周后你发现某个功能一直是老行为。请回答:(1)为什么没报错?(2)为什么 --dump-config 也可能骗过你?(3)如果那个 patch 本来是要补上一个必需的配置项,症状会出现在哪里,而不会出现在哪里?
throw 还是别的什么?(1)因为
vendor/include 里定位失败走的是 warn('patch: entry %C not found', id) 然后 continue——一行 stderr 警告,跳过,继续。packages/boot/app-boot/README.md 里写得明明白白:「A patch naming an entry id absent from the composed tree is a stderr warning.」这跟仓库自己「misconfiguration fails loud」的约定是有张力的,而且现实中 stderr 那一行会被淹没在启动日志里。(2)
--dump-config 打出的树和真正启动的树不完全一致:composeProfile 在 --patch 之后还追加了两层(agent-presets 交付根、遥测开关),而 runDumpConfig 没有这两步;dump 也不求值 !!js。所以你对着 dump 看「我的改动在不在」,看到的是一个近似版本。(3)这一问是三章交叉的落点。配置项缺失 → 该插件的配置校验抛异常 → 它的 fiber 进 FAILED(第 3 章)。症状不会出现在它身上——一个 FAILED 的插件是安静的。症状出现在所有
inject 了它提供的服务的插件上:那些插件会永远停在 PENDING(第 2 章:依赖是一直盯着的),既不报错也不启动,只是什么都不做。而 PENDING 没有任何内建诊断——仓库给的办法是让你自己写个插件去遍历 ctx.registry.values()。所以最终的故障形态是:你改了 A 的配置,拼错了 id,三周后发现跟 A 毫无关系的 C 功能一直没生效,而日志里一个错字都没有。这条从「拼错一个字母」到「远处一个功能静默消失」的传播链,横跨配置层、生命周期层和依赖层——这也是为什么第 5 章要专门教你怎么找出卡住的插件。
变式:如果拼错的不是 patch 的 id,而是 inject 数组里的服务名(比如把 'sessions' 写成 'session'),症状和上面第(3)问一样吗?哪一个更容易被发现?
(第 2 章 + 第 5 章 + 第 8 章)配置里有两个组,各自 isolate 了 shell:组甲挂本地执行器,组乙挂沙箱执行器。现在有一个 bash 工具插件写在组甲里,它 inject 了 shell,注册到全局工具层。一个 agent 调用 bash,会跑在哪个执行器上?为什么这个问题的答案不取决于是谁调用的?
execute 函数里,ctx.shell 这个引用是在什么时候确定的?调用时,还是插件启动时?isolate 管的是服务实例的可见性(第 2 章 + 第 5 章),scope 管的是工具对哪个 agent 可见(第 8 章)。它们是两套完全不相干的机制,只是名字听起来像。shell。线二:工具的注册层 → 哪些 agent 能看到这个工具。然后问:这两条线在哪里相交?答案是不相交。理由:工具插件的
ctx 是它挂载位置的上下文——它挂在组甲里,所以它的 ctx.shell 解析到组甲那份实例。这个绑定在插件启动时就定了,写进了 execute 的闭包里。调用方是谁、调用方属于哪个 scope,跟它一点关系都没有。而它注册到全局工具层,意味着所有 agent 都能在自己的提示词里看到
bash 并调用它。于是你得到一个很危险的组合:一个本该被沙箱约束的 agent,调到了一个绑定在非沙箱执行器上的工具——而配置文件读起来像是「我已经隔离好了」。两套机制的分工要说清楚:
isolate 让一个组看到某个服务名的独立实例,它作用在服务解析上;工具的 scope 决定哪个 agent 能看见这个工具,它作用在 schemas(scope) 和执行时的可见性检查上。前者管「这个插件用的是谁」,后者管「谁能用这个插件」。把工具注册成全局,等于把它背后那个被隔离的服务实例也变成了全局可达的——隔离在服务这一层是真的,在能力这一层就被这个全局注册捅穿了。正确做法是把工具也注册进组乙自己的 scope(通过对应 agent 的
agent.ctx),让「谁能用」和「用的是谁」在同一个层里对齐。变式:如果组甲和组乙各挂一份同名的 bash 工具插件、都注册到全局层,会发生什么?(提示:先想工具名冲突,再想第 8 章的 shadowing 只在 scope 之间生效、不在全局层内部生效。)
18.3 总答辩
下面四个问题,都是这套设计真正会被问到的。请先自己组织语言,写出你的防守,再展开看参考版本。参考版本里都留了一段守不住的地方——一个只会说好话的防守是没有用的,它经不起第二个问题。
下面两段回应,都是在回答「插件化会不会拖慢性能」。请判断哪一段是实质性防守,并说出判断依据(不许用「第二段更长」这种理由)。
甲:「这确实是个值得关注的问题。任何抽象都有开销,但我们认为可维护性带来的长期收益超过了这部分成本,而且现代 JavaScript 引擎的优化已经相当成熟。」
乙:「事件分发的成本与监听方数量成正比。这个仓库里 56 个事件,监听方最多的是 session/event(23 个),其次 session/created(16 个)、agent/pre-step(13 个),其余绝大多数是 0 到 5 个。一步几十次函数调用,对比一次模型请求的几百毫秒,可以忽略。真正的开销不在分发,在提示词的 token。」
甲还有一个更隐蔽的毛病:它换了话题。问的是性能,答的是可维护性。这在辩论里叫转移,在评审里会被立刻抓住。
还有一个附带的判据:乙顺手缩小了自己的主张范围——它没说「插件化零成本」,它说「成本在 token 那一侧」。一个主动划定边界的防守,比一个宣称全面胜利的防守难攻得多。
变式:给乙补一句诚实的软肋(它确实有一处守不住),让它变得更强而不是更弱。想想 waterfall 的链式结构在什么情况下会让「监听方少」这个前提失去意义。
答辩一:过度工程
「我只想跑通一个 agent。你给我 219 个包、105 份中文文档、一百二十多万字,还要我先学什么是 fiber、什么是 waterfall。这不是过度工程是什么?」
参考防守(先自己组织语言再看)
先承认真的部分,不要绕。对一个只想跑通的人,这套东西确实过度。这不是需要被辩护的指控,这是事实。
然后把问题问对。正确的问题不是「是否过度」,而是代价被推给了谁、什么时候付。
这套东西的成本分布是不均匀的。用它的人付的是零:dsh --profile headless "task" 一行命令跑起来,你不需要知道 fiber 是什么,就像你用浏览器不需要知道渲染管线。真正付代价的是改它的人——而改它的人,本来就要面对第 1 章那九种死法。
反过来看代价的另一端:如果一个 harness 把「跟哪家模型说话」「工具执行前问不问用户」「历史太长了怎么办」这些硬编码进主循环,那么第一个需要换厂商、加一条权限规矩、或者接一个自己的沙箱的人,付出的代价是重写。而这三件事,是任何一个 agent 走出玩具阶段时必然会遇到的。插件化不是把复杂度凭空造出来,是把它从「以后某个人的重写」挪到了「现在的一份结构」。
还有一个可检验的证据:这套结构上真的长出了多个提供方。bash 有本地/沙箱/pwsh,subagent 有六个后端,LLM 有 DeepSeek/mock/replay 加一个重试包装。这些不是设想中的扩展点,是仓库里同时存在的兄弟包。「可替换」这个主张只有靠这个才站得住。
守不住的地方(必须自己说出来)。上面这套辩护只覆盖了写代码的成本,没覆盖读的成本。105 份中文文档、123 万字、219 个包,这个认知负担是实实在在压在每一个想改它的人身上的,而且它不会因为你只想改一个地方就变小——你得先知道该改哪里。更要命的是,仓库通篇没有系统讨论过「一切皆插件」的代价:架构文档讲了收益、讲了规矩、讲了怎么扩展,唯独没有一节讲「什么时候不该拆成插件」。一个不讨论自己代价的架构文档,读的时候要打个折。
答辩二:凭什么说这个架构是对的
「219 个包、20 万行,听起来很宏大。但它连正式版都没发,官方自己写着 developer preview、会有破坏性变更、会话格式版本号停在 0 且不做兼容承诺。你凭什么说这个架构是对的?」
参考防守(先自己组织语言再看)
第一步,把主张缩小到能守住的范围。没有人能说「这个架构是对的」——架构没有对错,只有在什么约束下成立。可以守住的主张是:这个架构在这个仓库自己身上被用住了。这个弱一些的主张,是有硬证据的。
证据一:它自己跑得起来,而且被测着。198,402 行源码配 692 个 spec 文件,CI 的覆盖率门禁是 packages/*/*/src 上逐文件 100%(不是整体百分比,是每个文件都要 100%)。这不能证明设计好,但能证明它不是 PPT。
证据二:架构声明是被机器检查的。这一条最关键。多数项目的架构文档是一份愿望清单,写完就开始腐烂。这个仓库把文档接进了门禁:doc-sync 一整套检查、verify-tool-catalog(工具目录靠真的启动每个工具插件去读 ctx.tools.schemas() 生成,不是静态分析)、verify-export-jsdoc、verify-type-equiv、双语配对检查。文档跟源码对不上,构建直接失败。一个架构主张能不能被机器检查,是判断它是真规矩还是口号的最好标尺。
证据三:接缝上真的有多个实现。见答辩一。一个只有一个实现的接口,是设想;有三个并行实现且都在仓库里跑着的接口,是接缝。
守不住的地方。三条:
(一)没人统计过有多少 seam 真的有两个以上提供方。仓库把 Service Definition / Provider / Consumer 三角色写成硬规矩,却没有任何地方给出「多少服务键存在多个提供方」这个数。那些只有一个提供方的 seam,付了拆包的成本,却没有拿到可替换的证据。
(二)developer preview 是真的。会话格式版本停在 0 且明说无兼容承诺,后端直接拒绝旧格式。你今天照着写的任何 API 形态,都要以仓库为准,而且随时可能变。
(三)「跑得通」和「设计对」之间没有推理关系。一个设计糟糕的系统同样可以有 100% 覆盖率。上面这三条证据全部只能支撑「它是自洽且被自己用住的」,支撑不了「这是解决这个问题的正确方式」——后者需要至少一个规模相当的对照系统,而这个对照不存在。
答辩三:可扩展性靠事件和服务,性能怎么办
「所有可扩展性都靠事件分发和服务查找。每一次工具调用要过三层 waterfall,每一步要过一次 13 个监听方的 agent/pre-step。这些开销加起来怎么保证性能?」
参考防守(先自己组织语言再看)
把开销拆成三块分别看,不要笼统地谈。
一、服务查找的开销约等于零。服务是 ctx.key 上的属性访问,不是每次调用都跑一遍依赖解析。依赖关系只在挂载和卸载时重算,稳态运行时没有这一层成本。
二、事件分发的开销跟监听方数量成正比,而这个数是个位数量级。本站从 docs/event-producer-consumer.zh.md 数了 56 个 harness 自有事件:监听方最多的是 session/event(23 个),其次 session/created(16 个)、agent/pre-step(13 个),再往下 session/disposed 和 agent/status 都只有 6 个,绝大多数事件是 0 到 5 个。一步几十次函数调用,对比一次模型请求的几百毫秒到几秒——差了四到五个数量级,可以忽略。
三、真正的开销不在分发,在内容。这才是把问题拉回正确坐标系的那一步:插件化的代价不是 CPU,是 token。每个贡献提示词片段的包(本站数出来是 219 个里的 25 个)都在往那页纸上加字,而那页纸每一步都要重发一遍。一段 500 token 的段落在一个 30 步的任务里就是 15000 token。这个成本比所有事件分发加起来大好几个量级,而且它随任务长度线性增长。所以「插件化的性能问题」这个问句本身问错了地方——该问的是「这段提示词值不值」。
守不住的地方。「监听方少所以便宜」这个论证有一个前提:每个监听方都很快。而 waterfall 是环绕中间件,一层套一层,任何一个监听方在链上做一次同步阻塞(同步读文件、跑一段正则回溯、等一个网络往返),整条链和它外面的每一层都被拖住。仓库对此没有任何防线:timeoutMs 只覆盖 tools/execute 这一层,而且是声明性的——不加载超时策略插件就完全没有超时;fiber.ts 全文零个 setTimeout,卸载没有超时兜底。事件链没有延迟预算,也没有任何机制阻止一个插件把整条链拖慢。所以正确的说法是:分发机制本身便宜,但它不保护你免受一个慢监听方的伤害,而这件事仓库没有讨论过。
答辩四:模型再强十倍,这 20 万行有多少会变成累赘
「你们写了 20 万行来伺候一个不太靠谱的模型。如果模型能力再翻十倍,这里面有多少是给弱模型打的补丁,会在两年后变成纯粹的累赘?」
参考防守(先自己组织语言再看)
把 20 万行按「它到底在补什么」分成三类,然后各自回答。
第一类:补模型能力的。提示词里教模型怎么用工具的叙述性段落、repeat-tool-reminder 这种纠正模型重复行为的守卫、plan 模式这类「先想再做」的脚手架、把长输出截断再给个定位符的 spill 策略。这一类会随模型变强而萎缩,坦率承认。它也确实是「补丁」。
第二类:管责任归属的。审批、沙箱、单调守卫、会话日志、权限预设、不变量断言。这一类会随模型变强而变得更重要,不是更不重要。理由很直接:模型越强、越自动、越少被人盯着,「这件事是谁批准的、当时它看到了什么、为什么做了这个决定」这些问题就越必须有一份能事后回答的记录。一个能删你整个仓库的强模型,比一个笨手笨脚的弱模型更需要单调守卫,因为它执行意图的能力更强。这些机制约束的不是模型的能力,是模型的权限——而权限从来不因为能力提升而放松。
第三类:跟模型完全无关的。Cordis 那一层——上下文、服务、inject、effect、fiber、waterfall、loader、HMR。它解决的是「一群人要协作改同一个程序,而且要能热替换其中一块」这个问题,跟对面是什么模型一点关系都没有。这一层在模型翻十倍、翻一百倍之后完全不受影响。
所以「翻十倍」冲掉的主要是第一类。而按包的分布看,第一类是少数——多数包在做的是接缝、持久化、作用域、投影、传输这些和模型能力正交的事。
守不住的地方(两处,都必须说)。其一:上面这个三分类是本站的推断,不是仓库的说法。仓库从来没有做过这种分类,也没有任何一处讨论「哪些设计是为当前模型能力做的」。你可以不同意这个分法,我没有仓库文本给你当依据。
其二,更要命:就算分类是对的,我也没法告诉你第一类有多大。因为「一段提示词值不值」在这个仓库里没有任何度量——成本侧有统一格式(每个包的 README 都要写 Token effect 和 KV Cache effect),收益侧一片空白,没有判据、没有门禁、没有实验方法。所以「第一类是少数」这句话我只能凭包的功能去数,数不出 token 占比。这恰好是 18.4 里那个研究课题:没有收益口径,就没法回答哪些提示词该在模型变强时删掉。
18.4 开放研究课题
下面五个问题,前三个是真未解——不是你没学到,是这个领域目前确实没有公认答案;后两个是对你而言未知——别处有成熟答案,只是这个仓库没做,需要你自己去查。
每一条的最后一段都给了明天就能动手的第一步:读哪个文件、构造什么实验、查什么资料。选一个真的去做,比把这一章读五遍有用得多。
下面四个问题,请各自判断是「真未解」还是「对你而言未知」,并给出判断依据。(A) 一个 disposer 永不返回时,框架该怎么办? (B) 压缩掉一段历史到底损失了多少信息? (C) 两个插件注册同名服务时该由谁裁决? (D) agent 循环怎么判断任务真的完成了?
terminationGracePeriodSeconds)、systemd 里都有成熟方案。难点在于能不能搬进一个进程内框架(JS 里你没法强杀一个挂住的 Promise),但方案空间是已知的。(C) 同样是对你而言未知——命名冲突裁决在包管理器、DNS、模块系统里都有先例(先到先得、显式优先级、命名空间强制)。(B) 和 (D) 是真未解。(B) 要求先定义「信息损失」是什么——对谁而言的信息、以什么任务度量——这个定义本身就是开放问题。(D) 更根本:「任务完成」在自然语言指令下没有形式化定义,而这正是整个 agent 领域绕不过去的那道坎。判断依据总结:能被抽象成一个已有领域的经典问题的,是「对你而言未知」;连问题的形式化表述都还没有共识的,是真未解。把后者当成前者去查资料,你会找到一堆看似相关其实答非所问的文献;把前者当成后者去硬想,你会重新发明一个更差的轮子。
变式:再加一个问题——「两个自称并发安全的工具同时改一个文件该怎么办」。它属于哪一类?(这道变式没有干净答案,说清你的理由比答案重要。)
真未解agent 循环的「任务完成」判据是什么?
整本书的机制里,唯独这一件事没有答案。turn/end{completed} 只表示模型不再欠一次响应,循环自身没有任何完成判据。外层的目标(Goal)和 Ralph 循环也不是判据——它们是预算(Goal Round 上限)和状态机(active/paused/blocked/complete 四个阶段由人或模型显式改写),上限用完就停,跟做没做完无关。
这不是这个仓库的疏忽,是整个领域的空白。困难在于:自然语言指令的「完成」通常不可判定——「把代码整理干净」没有真值。而可判定的那一小类(「让测试全绿」),判据又完全落在任务本身,harness 没有通用位置放它。
怎么入手:第一步读 packages/core/agent-loop/src/agent.ts,找到 turn/end 的 completed 是在什么条件下置位的,确认它只跟「本步有没有工具调用」有关;再读 docs/subsystems/goal.zh.md,看 Goal Round 上限是怎么代替判据的。第二步构造一个判据完全外置的实验:选一类可判定的任务(例如「让 pnpm test 全绿」),跑 20 次,每次在 turn/end{completed: true} 的那一刻记录 pnpm test 的真实退出码,统计「模型说完了但客观没完」的比例。这个比例就是你手上第一个关于这个空白有多大的数。第三步把实验推广一格:把任务换成「删掉所有 console.log」这种判据可写但模型看不全的类型,看比例怎么变——这一格的差值,衡量的是「模型视野受限」和「判据本身缺失」各占多少。
真未解并发工具调用的相互干扰,怎么才能不靠自我声明?
isConcurrencySafe 是工具自己声明的,没有任何人校验。仓库的已知限制里明说并发策略不是事件门禁:工具自称并发安全就被采信。共享状态该怎么办,只有 Agent Note 里一句约定(不得改父级拥有的状态、共享状态必须容忍并发分发、记录方竞态只在可交换或失败关闭时允许),没有强制机制。
这在单仓库里还能靠 review 兜住,但插件生态一旦长起来,你会加载别人写的工具——他的自我声明就是你的安全边界。这个问题在一般意义上很难:判断两段任意代码会不会互相干扰,等价于判断它们的副作用集合是否相交,而副作用集合在有文件系统和子进程的情况下是不可静态判定的。
怎么入手:第一步把现状量出来。跑 grep -rn "isConcurrencySafe" packages/ --include=*.ts,本站数到的是 8 处声明为 true 的位置,分布在 6 个包里(read、read_image、web_search、web_fetch、subagent,以及三个会话查询工具)。逐个打开它们的 execute,把每一个碰到的外部状态列出来(文件、网络、进程、注册表、缓存)。第二步造一个真的会坏的例子:写两个自称并发安全的工具,都用「读全文—拼接—写全文」往同一个文件追加一行;在同一步里让它们各被调用五次,看最终文件是不是十行。跑二十次,记录丢失率。这个数会告诉你「自我声明」在最简单的情形下就已经不够用。第三步去找可能的门禁形态:读 docs/subsystems/filesystem.zh.md 里 fs/write-intent 那个单槽决策是怎么做的——它已经是一个「所有写入必须过同一个点」的结构了。问自己:能不能把并发安全性从「工具声明」改成「由资源声明」(工具不说自己安不安全,只说自己要碰哪些资源,由调度器算相交)?这个方向在数据库和构建系统里叫什么,是你该去查的下一个关键词。
真未解压缩的信息损失,怎么量化?
压缩把一段历史替换成一条摘要。所有人都知道这会丢东西,但丢了多少没有任何口径。仓库对这件事只记录了输入侧的量:compaction/summary 事件的载荷里有 shadowedRange(被遮蔽的 surface 边界对)、shadowedSeqs(按 surface 顺序排的被遮蔽 seq)和 shadowedTokenCount(估算 token 数)。这三个字段告诉你盖住了多少,不告诉你盖住的东西还需不需要。
难点在于「信息损失」这个词在这里没有定义。信息论意义上的损失(压缩前后的熵差)跟任务无关,量出来也没用;有用的定义必须是任务相关的——「后续步骤需要而摘要里没有的事实的比例」。而这个定义依赖于「后续会需要什么」,那是未来才知道的事。
怎么入手:第一步读 docs/subsystems/compaction.zh.md 的载荷表,确认上面三个字段确实已经在日志里——这意味着你的自变量是现成的,不用改代码就能采集。第二步设计一个可判分的探针集,把「信息损失」变成一个能测的量:在对话历史里埋 N 条只在一处出现过的事实(比如「配置文件在 etc/app.d/ 下」「端口是 8123」「负责人叫老张」),触发压缩,然后逐条提问,记录召回率。把召回率对 shadowedTokenCount 画一条曲线,你就有了第一个「损失—压缩比」关系。第三步换一个自变量再画一遍:固定压缩比,改变摘要提示词(让它明确要求保留具体数值和路径 vs 不要求),看曲线抬高多少。两条曲线之间的差,是「摘要策略」能拿回来的部分——这一格的大小,决定了这个问题是工程问题还是根本性问题。
对你而言未知插件化的边际收益,在哪一层停止?
把一个能力拆成 Service Definition / Provider / Consumer 三个包,收益是可替换性,成本是三份 package.json、三份 README、一份跨包的类型契约。这个交换在 shell 这种真有三个提供方的地方明显划算。但仓库里 219 个包,不可能每个拆分都划算——总有一层,再往下拆就只剩成本了。那一层在哪里?
这个问题在软件工程里是有研究基础的(可变性建模、软件产品线、插件架构粒度),只是这个仓库没有讨论过——它把 seam 三角色写成硬规矩,却没有给出「什么时候不该拆」的判据。所以这是「对你而言未知」:答案的形状在别处存在,你需要去把它取回来,再对着这个仓库检验。
怎么入手:第一步在本仓库量一个可操作的代理指标。用本站抽出来的 data/packages.json(或者自己重跑 extract_repo.py),筛出「src 行数少于 100 但 peerDependencies 超过 5」的包——这些是拆得最细、接线成本相对最高的。第二步对其中三到五个跑 git log --oneline -- packages/组/包,数它们各自被独立改过几次(独立 = 那次提交没有同时改它的兄弟包)。独立改动次数为 0 或 1 的,就是「拆了但从没单独演进过」的候选——拆分的理由(角色需要独立演进)在它们身上没有兑现。第三步去查两个关键词:软件产品线里的 variability modeling,以及模块化研究里关于接口成本的经典论证(一个接口的成本随实现方数量摊薄,随契约复杂度上升)。带着这两个框架回来看第二步的数据,你就能给出一个有依据的答案,而不是一句「拆得太细了」。
对你而言未知卸载没有超时,进程内框架该怎么兜底?
本站核对过:vendor/cordis/src/fiber.ts 全文 754 行,零个 setTimeout,卸载是一个裸的并行等待。这意味着一个永不返回的释放函数会让 fiber 永久停在 UNLOADING,连带热重载和配置更新全部卡死。仓库的防御性模式文档只对插件作者提要求(你的 disposer 要能返回),没说框架侧怎么兜底。
「监督者如何处理不肯终止的被监督者」这个形状,在别的地方是解决过的。所以这是「对你而言未知」——但有一个真正的难点让它不能直接照搬:那些方案最终都靠强制终止(发信号、杀进程),而 JavaScript 里你没法强杀一个挂住的 Promise。超时之后你能做的只有「不再等它」,而不是「让它停下来」,那么它持有的资源怎么办?
怎么入手:第一步亲手确认现状,别信转述:打开 vendor/cordis/src/fiber.ts,搜 setTimeout(应该是零个),再找到 _unload(),确认它是不是一个没有时限的并行等待。然后写个十行的插件,disposer 返回一个永不 resolve 的 Promise,挂上去再 dispose,观察进程行为。第二步去查两份现成设计:Erlang/OTP 里 supervisor 子进程规格的 Shutdown 参数(brutal_kill / 整数毫秒 / infinity 三种取值分别意味着什么),以及 Kubernetes 的 terminationGracePeriodSeconds 到 SIGKILL 的完整时序。把它们的状态机画出来。第三步回到 JS 的约束上做取舍设计:既然不能强杀,那么「超时后放弃等待」会留下什么(内存里的闭包、没关的文件句柄、还在跑的定时器),以及 AbortSignal 能不能承担一部分「请你自己停下」的职责——注意它是协作式的,被 abort 的一方可以不理你。写下你的方案和它守不住的地方,这份对比本身就是这个课题的产出。
18.5 你造的那个 mini-harness,现在怎么办
你从第 0 层那个 20 行的 while 循环开始,往上叠了十七层。它现在有服务容器、有可逆的副作用、有 waterfall、有增量重挂的 loader、有仅追加日志和投影、有 inbox 和轮次步骤、有提示词组装、有三层工具流水线。
它不会变成第二个 DeepSeek Harness——那也不是目的。它的用处是:你现在有一个自己完全理解的参照物,可以拿它去量任何一个别人写的 harness。
下面三条是真的能做的下一步。不用都做,挑一条做到底,比三条都做一半有用。
把它接上真实世界:三条路,每条都有硬判据
三条路难度递增,但方向不同:第一条验的是抽象对不对,第二条验的是不变量守不守得住,第三条验的是你有没有真的读懂。
路一:接上真模型 API。你到现在用的一直是假的 callModel——固定返回预设的工具调用。现在换成真的。
难点不在 fetch,在翻译层那一行。你会发现自己不得不写一个 adapt()——而这一刻你就亲手复现了第 1 章死法六的那个接缝:ctx.llm 定义词汇、适配器负责翻译。如果你的 adapt() 写着写着开始往外渗(比如循环里开始出现「如果是 A 厂商就……」),说明你的边界画歪了,回去把翻译收回到这一个函数里。
做对了的样子:两条硬判据。(1) 除了 callModel 内部,你一行别的代码都没改就能跑完一个两步任务(第一步调工具、第二步收尾)。如果你不得不去改循环、改日志、改投影,说明你之前某一层的抽象漏了,把改动的那几行记下来——那就是你的设计债。(2) 跑完之后打开日志,每一条 assistant/message 上都必须带着 provider 和 model 两个字段。带不上就说明你把「谁回答的」这个事实丢了,而这正是第 6 章那条规矩要防的:没有它,这段历史以后换个厂商回放时无法路由。
路二:用它跑一个真实小任务。选一个足够小但真的会动文件的任务,比如「统计当前目录下每个 .md 文件的行数,把结果写进 report.txt」。给它三个工具:glob、read、write。
难点在于:真实任务会把你所有偷过的懒都翻出来。模型会发一段坏 JSON,会调一个不存在的工具,会在你没准备好的地方中断。这些都不是意外,是常态——第 9 章那七道关里每一关都对应着一类你现在要亲自遇到的事故。
做对了的样子:三条判据,全部要跑,缺一条都说明有一层没立住。(1) 可重放:把日志倒到任意一个 seq,调 deriveMessages(),得到的历史必须和「当时那一步真正发出去的 messages」逐字符相等(跑的时候把每次请求的 messages 存一份下来对比)。不相等,说明有东西绕过日志进了请求——去找是不是有人直接改了 messages 数组。(2) 中断留痕:跑到一半按 Ctrl-C。日志里,没来得及开始的那些工具调用也必须各有一条 tool/call 加一条合成的 tool/result(对照 dsh 的 appendSkippedToolCall)。缺了的话,把这段历史再喂给模型会直接被接口拒绝——你可以真的试一次,看看报错长什么样,这个报错值得亲眼见一回。(3) 拒绝也留痕:把 write 的守卫改成「一律拒绝」,重跑。任务当然失败,但日志里 tool/call 一条都不许少,每条都要有对应的 tool/result 且 isError 为真。少了就说明你把记录写在了策略后面。
路三:拿它去读 dsh 的源码做对照。这是三条里收获最大也最省事的一条——不用写代码,只要读。
挑你实现得最草率的那一块(十有八九是 waterfall 或者工具调度),打开对应的真源码逐行对照:waterfall 看 vendor/cordis/src/events.ts,工具调度看 packages/core/agent-loop/src/tool-calls.ts。不要通读,只找一件事:他们处理了、而你没处理的情况。
做对了的样子:列出至少 5 处「他们处理了、我没处理」的情况,每一处都要能说出触发它需要什么条件(不是「他们多了个 if」,而是「当监听器在 await next() 期间抛异常时,我的版本会……」)。如果你一处都找不到,那不是你写得好,是你没读到出错路径——回去搜这三个关键词:catch、aborted、assertNever。这三个词标出的位置,就是真实系统和玩具系统的全部差距所在。找齐 5 处之后再加一道:从这 5 处里挑一处补进你自己的实现,并写一个能复现该情况的最小测试——补得进去,说明你真的懂了;补不进去(比如发现要动很多层),把卡住的原因写下来,那句话就是你这一版设计的真实边界。
你按路二跑通了任务。现在给 mini-harness 加一个功能:每一步自动往历史里注入一句「当前时间是 X」。加完之后,路二的判据 (1)(可重放)会立刻失败。请说出为什么会失败,以及两种修法各自的代价。
修法甲:写成会话事件。注入时往日志追加一条事件(非
user 来源的注入上下文),投影时它按时间顺序变成一条 user-role 消息。代价:日志变大(每一步一条),而且这些事件永久留在日志里(日志只增不减,压缩裁的是模型可见那一侧,磁盘上的日志一直长)。修法乙:让投影也重新注入。代价致命:时间是非确定的,重放时算出来的是重放那一刻的时间,判据 (1) 照样过不了。要让它过,你得把当时的时间存下来——而「把它存下来」就是修法甲。所以乙这条路对任何非确定输入(时间、随机数、环境变量、外部服务返回值)都走不通,它只对纯函数式的注入成立(比如「历史里有几条消息」这种从日志本身算得出来的东西)。
结论:凡是模型看得见、又不能从日志算出来的东西,只有写进日志一条路。这就是「模型可见即已记录」这条规矩的完整理由——它不是洁癖,它是可重放性的充分必要条件。
变式:把注入内容从「当前时间」换成「当前会话已经有几条消息」。哪一种修法这时候变得可行了?为什么这个区别正好落在「能不能从日志算出来」这条线上?
本章小结
这一章没有新知识,只有连接。如果要留下一句话,是这句:
- 全链路是一条闭合的链。inbox → claim → 轮次开始 →
agent/pre-step→ 组装提示词 → 投影历史 → 模型请求 → 记tool/call→ 分类调度 → 七道关 → 按模型顺序提交 → 下一步 →turn/end。每一环都可以被去掉,去掉之后程序都还能跑——这正是它们难被理解的原因。 - 三条贯穿全书的纪律。宁可记了没做,不能做了没记;模型可见即已记录;记录的顺序按模型顺序,不按完成顺序。这三条互相支撑,抽掉任何一条,可重放性就没了。
- 八道综合题的共同点是:单看一章都会得出一个听起来合理的错答案。压缩那题错在没分清日志和 surface;守卫那题错在没分清「注册表干净」和「在飞的引用有效」;子 agent 那题错在把可见性和血统混为一谈;
isolate那题错在把两个都叫「作用域」的东西当成了一个。这套系统里同名不同物的概念很多,读的时候要盯住它作用在哪一层。 - 四场答辩里,最强的防守都长一个样子:先承认真的部分,再把主张缩小到能守住的范围,给出可被证伪的具体量,最后自己说出守不住的地方。一个不说软肋的防守,经不起第二个问题。
- 五个研究课题里,两个是真未解(任务完成判据、并发干扰、压缩损失量化),两个是别处有答案而这里没做(拆分粒度、卸载超时)。分清这两类,决定了你该去做实验还是该去查文献。
- 你的 mini-harness 现在最有价值的用法不是继续加功能,是拿它当尺子去量真源码——找「他们处理了、我没处理」的那五处,那五处就是玩具和真实系统的全部差距。
下一章是附录。它不是用来读的,是用来查的:全站术语一张表、56 个事件的用法、十几条「症状 → 去哪查」的对照,还有一份关于这个站的数据是怎么抽出来的、各自会漏掉什么的老实交代。
附录 术语速查与真实数据
这一章不是用来读的,是用来查的。四张表加一份数据来源交代:术语、事件、常见故障、这个站的数字是怎么来的。忘了什么就翻回来。
这一章你不用学,要会用
- 忘了某个术语的准确含义时,能在 19.1 一分钟内找到它,并知道该回仓库哪个文件核对
- 要写一个插件时,能在 19.2 判断该挂哪个事件、用哪种分发模式
- 程序出了奇怪的毛病时,能在 19.3 按症状反查可能原因,而不是从头读源码
- 引用本站任何一个数字之前,知道它是怎么抽出来的、会漏掉什么
19.1 术语速查表
五十五个术语,按英文原文字母序排——不按章节顺序,因为你查的时候脑子里多半只有那个英文词。
关于「定义」这一栏的口径,说清楚三件事:
docs/glossary.zh.md里有的条目,定义照抄原句,太长时只截前半句用省略号截断,不改写。- 那份术语表只覆盖六组概念(能力 seam、agent-scope、目标、人类命令、循环层级、Ralph)。Cordis 层的概念取自
docs/cordis-primer.zh.md与vendor/cordis/src/,其余取自docs/subsystems/对应页,出处见最后一栏。 - 「主讲处」是本站哪一节讲的,格式
章.节。有两处的表示两章各讲一面。仓库正在开发者预览阶段,官方明说会有破坏兼容性的变更——以最后一栏的仓库文件为准。
| 中文名 | 英文原文 | 一句话定义 | 主讲处 | 仓库文件 |
|---|---|---|---|---|
| agent(智能体) | agent | 持有一个会话、一份 inbox 和一个自己的 scope 的运行实体;「harness 约定:一个活跃的 agent 就是其自身 scope 的 key」 | 0.1、7.3 | packages/core/agent/src/runtime-types.ts |
| agent 上下文 | agent.ctx | 「agent 的带作用域上下文;通过它进行的注册既具有 scope 可见性,其生命周期也绑定到该 scope(同一事实决定两者)」 | 14.3、14.4 | docs/glossary.zh.md |
| 审批 | approval | 回答「这个具体操作是否可以继续」;结果闭合为 allowed-once / rejected / cancelled / unavailable,只有第一种放行,其余一律拒绝 | 9.4、10.4 | docs/subsystems/approval.zh.md |
| 组合包 | bundle | 「Cordis 配置项及其挂载代码的分发格式,因此它插入的内容始终可被其上各层 patch」 | 5.4 | docs/architecture.zh.md |
| 能力 seam | capability seam | 「一种包含三种角色的可替换能力」:Service Definition、一个或多个 Service Provider、一个或多个 Consumer;「seam 是完整能力,绝不是其中一个角色」 | 1.3 | docs/glossary.zh.md |
| Code Mode | Code Mode | 模型不逐个调工具,而写一段程序交给 ctx.codeRuntime 用宿主提供的异步绑定运行,报告它打印了什么、返回了什么;子调用重新进入同一条工具流水线 | 9.9、15.7 | docs/subsystems/code-runtime.zh.md |
| 命令平面 | command plane | 「由 UI 适配器和命令插件负责的发现、解析、分发、取消与结果渲染机制。除非处理器另行改变持久领域,否则命令输出属于 UI 状态」 | 本表 | docs/glossary.zh.md |
| 压缩 | compaction | 一项可选能力,把一段模型可见历史换成一条摘要;摘要本身承载在另一条带 surfaceOp: { op: 'replace', start, end } 的 user/message 上 | 第12章 | docs/subsystems/compaction.zh.md |
| 上下文 | context(ctx) | 「上下文是服务的容器。一个服务占据一个稳定的 ctx.<key>;其他插件通过 key 查找服务,而非导入具体实现」 | 2.2 | docs/cordis-primer.zh.md |
| 派生模型历史 | deriveMessages() | 「将事件日志投影为模型看到的 Message[]」;缓存且冻结,assistant/chunk 与内容为空的 assistant/message 会被跳过 | 6.3 | docs/subsystems/session.zh.md |
| 分发模式(五种) | dispatch mode | emit / waterfall / parallel / serial / bail;「分发模式是事件公开约定的一部分」,一个事件只能用它对应的方法分发 | 4.2 | vendor/cordis/src/events.ts:32 |
| 释放函数 | disposer | 「每个注册都应有对应的 disposer(资源释放函数):要么从 ctx.effect() 返回一个,要么使用 Cordis 提供的辅助方法自动处理」 | 3.2 | docs/cordis-primer.zh.md |
| 副作用 | effect(ctx.effect()) | 「注册是可逆的副作用。提示词片段、工具 schema、适配器、提供方和监听器通过 ctx.effect() 或 ctx.on() 安装,reload 和 teardown 时会按预期撤销」 | 3.2 | docs/cordis-tutorial/02-lifecycle-and-effects.zh.md |
| fiber | fiber | 一个已加载插件实例的运行时句柄:持有它的状态、它注册的全部 effect,以及它下面的子树 | 3.4 | vendor/cordis/src/fiber.ts |
| fiber 六态 | FiberState | 「PENDING—等待所需服务;LOADING—插件回调正在运行;ACTIVE—已加载并在提供服务;FAILED—回调或其配置抛异常;UNLOADING—释放函数正在运行;DISPOSED—fiber 已被移除且无法重启」 | 3.4 | vendor/cordis/src/fiber.ts:147 |
| 目标 | goal | 「附着在现有会话上的单个持久完成目标,带有按修订号演进的 active / paused / blocked / complete 阶段和 Goal Round 上限」;「目标是一种状态,不是调度器」 | 14.7 | docs/glossary.zh.md |
| Goal Round | Goal Round | 「为当前目标接纳的一次续行周期……其中可包含零个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限」 | 14.7 | docs/glossary.zh.md |
| harness | harness | 把模型、工具、会话、权限、持久化组装成一个能连续干活的程序的那一层;本站不译,首次出现注「智能体运行框架」 | 0.1、1.1 | README.zh.md |
| 热模块替换 | HMR | 「卸载会释放 effect,加载则遵循依赖关系,因此 HMR 可以先卸载、再加载,以替换正在运行的插件」 | 5.8 | docs/cordis-tutorial/06-composition-and-hmr.zh.md |
| 人类命令 | human command | 「以斜杠开头的指令,由面向人类的适配器通过 ctx.commands 解释并执行,不会成为模型消息」 | 本表 | docs/glossary.zh.md |
| 注入 | inject | 「插件声明所需的服务后,会等待这些服务就绪才启动;加载顺序通过服务依赖表达,而非手动编排启动序列」 | 2.3 | docs/cordis-primer.zh.md |
| isolate | isolate | 「为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 shell 提供方,互不影响」 | 5.6 | docs/cordis-tutorial/06-composition-and-hmr.zh.md |
| 血统 | lineage | 「以数据形式携带的父子关系事实(parentSession、持久的 delegationDepth、运行时 subagentDepth);从不影响可见性」 | 14.5 | docs/glossary.zh.md |
| loader | loader | 读那棵 cordis.yml 配置树、按条目 id 认插件、并在配置变化时做增量重挂的那一层 | 5.3、5.8 | vendor/loader/ |
| 语言服务器协议 | LSP | 一个能力 seam,在 ctx.lsp 上「恰好公开 4 项语义查询」:goToDefinition / findReferences / goToImplementation / hover,闭合联合 | 15.5 | docs/subsystems/lsp.zh.md |
| 模型上下文协议 | MCP | 桥接插件连接外部 MCP 服务器,把它们的工具注册到 ctx.tools,以服务器限定名 mcp__<serverName>__<rawName> 作为原生工具暴露给模型 | 15.8 | packages/mcp/mcp-client/README.md |
| 单调守卫 | monotonic guard | 在可扩展的 tools/pre-execute waterfall 之后注册的同步检查:「任何匹配的守卫都可以通过返回一个理由来拒绝,而没有守卫能强制放行另一个守卫已拒绝的调用」 | 9.5、10.2 | packages/core/tools/src/index.ts |
| patch | patch | 「一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目」;没有深度合并,未改的字段也必须重述 | 5.5 | docs/architecture.zh.md |
| 权限预设 | permission preset | 把沙箱模式与审批策略两个开关「捆绑成具名预设,供客户端作为单个权限选择器提供」;它「不拥有任何强制执行」,只写穿到各自的 knob | 10.5 | docs/subsystems/permission-presets.zh.md |
| 插件 | plugin | 「插件是实现 Service 的对象。它可以是一个带有可选 inject 和 apply(ctx) 字段的函数,也可以是一个 Service 子类」 | 1.3、2.2 | docs/cordis-primer.zh.md |
| profile | profile | 「存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 cordis.patch.yml」 | 5.4 | docs/architecture.zh.md |
| 投影 | projection | 从仅追加日志算出某个视图的纯函数;模型历史、面向人类的 transcript、UI 状态各是一份不同的投影,都从同一条流派生 | 6.3 | docs/subsystems/session-projection.zh.md |
| Ralph 交接 | Ralph handoff | 「从一个仍需继续的 Ralph Round 传给下一个 Ralph Round 的规范化、有界结构化报告,包含状态、摘要、证据、后续步骤和阻塞说明」 | 14.9 | docs/glossary.zh.md |
| Ralph 循环 | Ralph loop | 「一次面向不可变目标的前台全新 agent 工作流运行」;「不是同会话目标、agent loop 模式、调度器或通用工作流脚本功能」 | 14.9 | docs/glossary.zh.md |
| Ralph Round | Ralph Round | 「Ralph 循环中的一个全新子会话。子会话不接收父会话或此前子会话的对话种子;共享工作区和一份有界的 Ralph 交接承载跨 Round 的状态」 | 14.9 | docs/glossary.zh.md |
| 工具限制 | restriction | 「为单个 scope 过滤全局工具集合(多个 restriction 取交集组合)」;「被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分」 | 10.7 | docs/glossary.zh.md |
| Round | Round | 「承载一个轮次的外层策略迭代,例如一个 Goal Round 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次」 | 7.2、14.7 | docs/glossary.zh.md |
| 沙箱 | sandbox | 把子进程 argv 包在文件效果策略里;模式只有 read-only / workspace-write / danger-full-access 三种,且「仅管控文件系统效果。网络与进程可见性不在此处的定义范围内」 | 10.6、15.1 | docs/subsystems/sandbox.zh.md |
| 作用域 | scope | 「按 agent 划分的注册单位……只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 lineage 数据表达,从不通过 scope 结构」 | 8.4、14.3 | docs/glossary.zh.md |
| scope key | scope key | 「scope 的不透明标识,按对象同一性比较。harness 约定:一个活跃的 agent 就是其自身 scope 的 key」 | 14.3 | docs/glossary.zh.md |
| 服务 | service | 占据一个稳定 ctx.<key>(如 ctx.tools、ctx.llm、ctx.sessions)的能力对象;按 key 查找,不按实现导入 | 2.2 | docs/cordis-primer.zh.md |
| 消费方 | Service Consumer | seam 三角色之一:注入并使用该服务的一方。规范范例是 dsh-tool-bash(消费 ctx.shell) | 1.3 | docs/glossary.zh.md |
| 服务定义 | Service Definition | 「拥有自身 ctx.<key> 和词汇类型的 Cordis Service——可以是 ShellExecutor 这样的抽象类,也可以是 WebRuntime 这样的具体注册表,绝不是 TypeScript interface」 | 1.3 | docs/glossary.zh.md |
| 提供方 | Service Provider | seam 三角色之一:实现该服务的一方。规范范例是 dsh-bash-local 与 dsh-bash-sandbox——同一个接口的两个兄弟实现 | 1.3 | docs/glossary.zh.md |
| 会话日志 | session log | 「一份类型化 SessionEvent 的仅追加日志——唯一的真源。LLM 消息历史从日志派生(deriveMessages()),而非单独存储」 | 6.2 | docs/subsystems/core.zh.md |
| setup window | setup window | 「创建者组装 agent 作用域环境的创建时隙……此时 scope 和 agent 对象已存在,但 agent 或会话尚未发布……setup 只做注册,从不驱动 agent」 | 14.4 | docs/glossary.zh.md |
| 被遮蔽范围 | shadowedRange | 压缩记录下来的「被遮蔽的 surface 边界对(start/end seq——位置跨度,而非数值区间)」 | 12.7 | docs/subsystems/compaction.zh.md |
| 遮蔽 | shadowing | 「最具体者胜出的名称解析:一个带作用域的工具/片段/变量仅在该 scope 内替换同名的全局对应项」 | 8.5 | docs/glossary.zh.md |
| 技能 | skill | 可加载的指令而非会话事件;注册表在宿主层与各 scope 层之间合并各提供方的目录,模型通过 skill 工具取用 | 15.3 | docs/subsystems/skills.zh.md |
| spill | spill | 一个存储 seam,「持久保存工具的超大文本,并返回面向模型的定位符与检索指引」;saveText 是唯一的服务操作 | 12.8、15.2 | docs/subsystems/spill.zh.md |
| 步骤 | step | 「一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤」 | 7.2 | docs/glossary.zh.md |
| 子 agent | subagent | 「让一个 agent 将工作委派给子 agent」的能力 seam;与别的 seam 不同,「同一上下文中可共存多个提供方实现」,按名称注册在 ctx.subagents 上 | 14.2、14.6 | docs/subsystems/subagent.zh.md |
| surface | surface | 日志中「产生消息、有资格出现在有序 surface 上」的那部分事件构成的模型可见投影;只有 user/message、assistant/message、tool/result 三种类型属于它 | 6.4 | docs/subsystems/session.zh.md |
| 轮次 | turn | 「会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束」 | 7.2 | docs/glossary.zh.md |
| 瀑布式事件 | waterfall | 「ctx.waterfall 是环绕中间件。监听器接收 (...args, next)。调用 next() 会执行下游监听器……不调用 next() 直接返回则短路」 | 4.4 | docs/cordis-primer.zh.md |
两个只在这张表里定义的条目
人类命令和命令平面属于 UI 适配器那一侧,正文没有单独一节讲它们。它们的定义逐字来自 docs/glossary.zh.md,实现入口是 packages/interaction/commands/src/index.ts。要点是那句限定:人类命令不会成为模型消息——它既不是面向模型的工具,也不是通过 ctx.shell 执行的 shell 命令,模型根本看不到你敲了 /goal。
在表 19-1 里找出唯一一个定义里明确写着「从不影响可见性」的术语。写出它的英文原文,并说出这句限定是在跟哪个概念划清界限。
lineage(血统)。定义原话:「以数据形式携带的父子关系事实(parentSession、持久的 delegationDepth、运行时 subagentDepth);从不影响可见性。」它划清界限的对象是
scope(作用域)。这两个概念都描述「agent 之间的关系」,极易混:scope 管看得见什么(工具、提示词段、变量、限制、监听器归谁),lineage 管谁是谁的孩子(用来记深度、限制委派层数、做审计)。把它们分开的代价是要多维护一套数据,收益是可见性规则可以保持极简——只有两层,扁平结构,没有继承链要算。顺带记住配套的那半句:「带作用域的注册不会向下继承给 subagent;子树行为通过 lineage 数据表达,从不通过 scope 结构。」
变式:表里还有一个术语的定义里写着「不拥有任何强制执行」。找出它,并说出这句限定防的是什么误解。
19.2 事件速查
window.DSH.events 是本站从 docs/event-producer-consumer.zh.md 抽出来的 56 条 harness 自有事件。每一条是一个数组,五个位置:
[ 事件名, 分发模式, 声明位置, 派发方[], 监听方[] ] // 例: ["agent/pre-step", "waterfall", "packages/core/agent/src/runtime-types.ts:231", ["agent-loop"], ["agent-instructions", "compaction-basic", "goal-round-driver", ... ]]
怎么用它:想知道某件事发生时谁会被通知,查监听方那一栏;想知道自己该挂哪里,先看模式对不对得上你要做的事。
先看模式分布
| 模式 | 条数 | 语义 | 你写监听器时最容易栽的地方 |
|---|---|---|---|
emit | 41 | 按注册顺序观察,不 await,无返回值 | 它没有 try/catch(vendor/cordis/src/events.ts:194):同步抛异常会中断后续监听器并冲回发出方;async 监听器抛出则变成未处理拒绝 |
waterfall | 13 | 环绕中间件,监听器收 (...args, next),有返回值 | 只观察也必须调 next()。不调就是短路,下游一个都不跑,而且不会报错 |
serial | 1 | 按注册顺序 await,直到有人 bail | 全站唯一一条是 agent/turn-stopping。挂在这里的东西会拖住轮次结束 |
parallel | 1 | 所有监听器并行 await | 全站唯一一条是 session/flush。诊断事件 internal/dispatch 里它把自己报成 'emit'(events.ts:184),所以你永远观测不到 'parallel' |
bail | 0 | 停在第一个同步 bail 值 | 这份矩阵里是 0 条,但 bail 在仓库里是活的:events.ts:296 的 internal/listener 用它,浏览器端 packages/client/ui-input-trigger/src/types.ts 有四个 @mode bail 事件。矩阵只覆盖 harness 自有事件,不含浏览器端 |
最该记住的 12 个
56 条不用背。真正会被你挂上的就下面这十二个——它们覆盖了「改模型看到什么」「拦工具」「跟着日志走」这三类几乎所有需求。
| 事件 | 模式 | 什么时候触发 | 你想干这个的时候挂它 |
|---|---|---|---|
session/event | emit | 每有一条事件提交进会话日志 | 想跟着日志做任何事:持久化、遥测、投影、起会话标题、算 token。全站监听方最多的一条(23 个) |
agent/pre-step | waterfall | 每一步组装模型请求之前 | 想改这一步模型看到什么:注入上下文、触发压缩、plan 模式、外部钩子。13 个监听方挤在这里,而顺序只有 prepend 一个布尔开关 |
system-prompt/assemble | waterfall | 组装系统提示词那页纸时 | 想往那页纸上加一段或删一段。记住每加一句都是每一步都要付的 token |
llm/stream | waterfall | 每一次模型流式请求 | 想做重试、录制、回放,或从流里边抽会话标题 |
tools/pre-execute | waterfall | 工具执行前、单调守卫之前 | 想拦下一次调用:权限、沙箱、外部钩子。注意它有意不能改写 exec.arguments——日志、审计、UI 和执行必须一致 |
tools/execute | waterfall | 环绕工具本体 | 想给执行加超时、检查点这类环绕关注点。只能替换 signal,注册表会重新融合调用方的取消 |
tools/post-execute | waterfall | 结果出来了、还没定稿 | 想改写/挡住结果,或往下一步塞上下文。被拒绝的调用也走这一关;替换内容不等于保密 |
tools/result | emit | 结果已冻结、已可 JSON 无损表示 | 只想观察不想改:审计、统计、计费。挂错到 post-execute 上你就有了改写能力,而改写能力迟早会被误用 |
agent/inbox/claimed | emit | 一批输入被取走、轮次开始 | 想知道「活儿开始了」以及这一轮吃进去了什么。目标驱动器、子 agent、任务插件都挂在这 |
agent/turn-stopping | serial | 轮次即将结束(全站唯一 serial) | 想在停之前做一件必须等完的事。两套钩子桥接用的就是它 |
session/flush | parallel | 会话检查点(全站唯一 parallel) | 想把自己那份状态跟着一起落盘。持久化和遥测都挂这 |
approval/request | waterfall | 有操作需要人点头 | 想接管「谁来回答」:UI 通道、自动化桥接。第一个应答者占掉唯一的决策槽 |
挑事件时最常犯的两个错
错一:想改东西,却挂了一个 emit 事件。emit 没有返回值,你在里面改什么都不会被上游看见。要改,必须找 waterfall。反过来,只想看不想改却挂了 waterfall,你就必须记得调 next()——忘了的后果是把下游全掐了,而且不报错。
错二:把事件挂错了域。事件名的前缀决定它归谁:agent/* 是循环的节奏,tools/* 是一次调用的生命周期,session/* 是日志的变化。要做「每次工具调用都记一笔账」,挂 tools/result;要做「每一轮都记一笔账」,挂 agent/inbox/claimed 加 turn/*。挂错域的典型症状是数量对不上——比如你以为在数轮次,其实数的是步骤。
你要写一个插件:把每一次工具调用的耗时和结果大小记到一个外部监控系统。请从表 19-3 里选出该挂的事件,并说明为什么不选 tools/post-execute——虽然那里也拿得到结果。
tools/result(emit)。三个理由:一、量的是最终值。
tools/post-execute 是可改写的一关,你在那里量到的「结果大小」后面还可能被别的监听方替换掉,账就不准了。tools/result 观察的是不可变、可由 JSON 无损表示的最终结果。二、能力最小化。你只需要观察,就该待在一个没有改写能力的位置。挂在
post-execute 上,你的监控插件从此有了改写工具结果的权力——这个权力没人会用,但它会在某次「顺手在这里加个小逻辑」的时候被用掉,然后监控插件就变成了业务逻辑的一部分,再也拆不干净。三、别把别人的链拖慢。
post-execute 是 waterfall,你的上报如果是同步的、或者忘了调 next(),会直接影响工具流水线。emit 不 await,你拖不住谁——代价是你的异常也没人接(见表 19-2 那一格),所以上报代码必须自己包 try/catch。补一句「耗时」怎么办:
tools/result 单点量不出耗时,你需要一个起点。起点用 tools/pre-execute 记时间戳、按 callId 配对——注意在那里也要老老实实调 next()。变式:需求换成「禁止任何单次工具结果超过 100 KB」。这次该挂哪个?为什么这个需求非得站在能改的位置上不可?
19.3 常见错误对照表
下面这张表按症状查,不按原理查——出问题的时候你手上只有症状。
用法:先在左栏找最像的一行,再顺着中栏的可能原因去右栏指的地方核对。这些症状的共同特点是不报错,所以你不会有堆栈可看。
| 症状 | 可能原因 | 去哪查 |
|---|---|---|
| 插件没有任何输出,也不报错,像根本没装 | 卡在 PENDING:inject 的某个服务一直没就绪。PENDING 是合法状态,不是错误,所以没人会告诉你 | 写个插件遍历 ctx.registry.values() 打 fiber 状态——仓库没有内建的 PENDING 诊断,官方给的办法就是自己遍历 |
dispose() 之后定时器还在打日志、监听器还在响应 | 注册没走 ctx.effect()/ctx.on(),于是没有释放函数可撤 | 翻这个插件 apply 里所有 setInterval/addEventListener/第三方 register,逐个问「它的释放函数在哪」(3.2、3.3) |
| waterfall 链上后面的监听器一个都没跑 | 前面某个监听器返回了但没调 next()——这是短路,是设计意图,不是 bug | docs/cordis-primer.zh.md 的「Cordis Waterfall 语义」;先怀疑最近新加的那个监听器 |
改了 cordis.patch.yml,重启后行为完全没变 | patch 的 id 打不中(拼错、条目被上层改名)。打不中只写一行 stderr 警告然后跳过 | packages/boot/app-boot/README.md:「A patch naming an entry id absent from the composed tree is a stderr warning.」;vendor/include/src/index.ts 的 warn('patch: entry %C not found') |
| 改了 patch 里的一个字段,结果别的字段都变回默认值了 | patch 没有深度合并:它替换目标条目的整个 config,没重述的字段就等于没写 | docs/architecture.zh.md「一条 patch 按 id 定位某个条目并替换其整个 config」(5.5) |
--dump-config 看着是对的,跑起来还是不对 | dump 出来的树和真正启动的树不完全一致:composeProfile 在 --patch 之后还追加两层,dump 没有这两步;dump 也不求值 !!js | apps/cli/src/profile-boot.ts 的 composeProfile 对比 apps/cli/src/dump-config.ts 的 runDumpConfig |
| 工具跑了很久不返回,也不超时 | timeoutMs 是声明性的:注册表从不执行截止时间。不加载 dsh-tool-call-timeout-policy 就完全没有超时 | docs/subsystems/tools.zh.md 的 timeoutMs JSDoc;确认组合里挂了那个策略包 |
审批策略设成 never,结果什么都干不了 | never = 「永不询问 = 一律拒绝」,不是「不用问直接过」。它在服务内部、waterfall 分发之前就强制执行,prepend 也绕不过 | docs/subsystems/approval.zh.md:「never 确定性地返回 rejected,不分发任何应答者」(10.4) |
可选依赖偶发拿到 undefined,重试一下又好了 | ctx.get() 的 strict 默认为真:提供方处于非 ACTIVE(比如正在热重载)时也返回 undefined | vendor/cordis/src/reflect.ts 的 get(name, strict = true) 和它下面那行状态判断;可选依赖的代码必须承受偶发 undefined |
两个插件都好好的,换台机器部署就有一个进 FAILED | 服务撞名。后登记者失败,而「谁后登记」由依赖驱动的激活顺序决定,不由配置行序决定,所以会随部署漂移 | 仓库只给了「加前缀」这条纪律,没有裁决机制也没有静态检查;先把两个包的 provides 键列出来比对 |
| 热重载之后整个进程卡住,连配置更新也不响应了 | 某个释放函数永不返回,fiber 永久停在 UNLOADING。_unload() 是裸的并行等待,没有超时 | vendor/cordis/src/fiber.ts(全文 零个 setTimeout);从最近改动过的插件的 disposer 查起 |
| 中文对话老是撑爆上下文,压缩触发得太晚 | token 估算用的是固定密度 CHARS_PER_TOKEN = 4,对中文系统性低估 2 到 4 倍 | packages/llm/token-meter/src/estimate.ts;把压缩阈值按你的语种调低(12.1、12.2) |
工具明明成功了,返回的却是 isError | fs/observed 的监听器抛了异常。工具不捕获该 emit 的异常,于是抛异常的监听方会在变更已经成功之后把结果污染成失败 | docs/subsystems/filesystem.zh.md(文档记录了这个行为,没修);检查所有监听 fs/observed 的插件是否同步且只产生副作用 |
| 提示词里看不到某个工具,模型调它也说不存在 | 被某个 restriction 过滤掉了。「被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分」——这是设计如此 | docs/glossary.zh.md 的 agent-scope 一节;查这个 agent 上挂了哪些 tools.restrict(多个取交集)(10.7) |
监听 internal/dispatch 做诊断,永远看不到 'parallel' | parallel() 在诊断事件里把自己报成 'emit' | vendor/cordis/src/events.ts:184(对比 205/218/235 行传的都是自己的模式名) |
| 启动早期的模块解析失败报告不见了 | 解析失败走 logger 不崩溃,而启动早期该报告可能在 console 导出器就位之前就丢掉了 | docs/cordis-tutorial/01-first-plugin.zh.md(文档承认这个可观测性缺口,没给解法) |
症状:你新写的一个插件,启动没有任何日志、也不报错,像根本没被加载。表 19-4 第一行给了一个原因(卡在 PENDING)。请再构造两条不同的原因链,让同一个症状成立,并给出一个能一次分开三者的检查动作。
原因链一(表里给的):进了注册表,卡在
PENDING。inject 的某个服务一直没就绪,插件回调根本没被调用。原因链二:
FAILED。配置校验或 apply 抛了异常,fiber 直接进 FAILED——一个 FAILED 的插件同样是安静的。而这可能是被别的东西引起的:比如你的 patch 拼错了 id,本该补上的必需配置项没补上(表 19-4 第四行),于是校验失败。原因链三:它根本不在树里。你的 patch 的
insert 没生效,或者 profile 的哪一层把它 disabled 了,或者你改的是 home 级的文件而 profile 级的那份覆盖了它。这一条最气人,因为你会一直盯着插件代码找问题,而问题在配置层。还有一条隐藏的第四链:它其实跑了,但 logger 没有 console 导出器,所以你看不到任何输出(表 19-4 最后一行)。
能一次分开三者的检查动作:跑
--dump-config,然后写一个遍历 ctx.registry.values() 打状态的小插件,两件事一起做。判读方式:· dump 里没有这个条目 → 原因链三(配置层,别去看代码)。
· dump 里有,registry 里状态是
PENDING → 原因链一,接着看它 inject 了什么、那个服务的提供方又是什么状态(很可能是一条 PENDING 链)。· dump 里有,状态是
FAILED → 原因链二,去拿它的错误对象。· dump 里有,状态是
ACTIVE → 那它跑了,问题在日志通道,去检查有没有 console 导出器。要注意一处坑:
--dump-config 本身就有偏差(表 19-4 第六行)——它比真正启动的树少两层、且不求值 !!js。所以「dump 里没有」还有一种可能是「dump 漏了它」。真正保险的是以 registry 遍历为准,dump 只用来快速排除。变式:把症状换成「插件时灵时不灵:有时启动后功能正常,有时完全没反应,同一份配置、同一台机器」。表 19-4 里哪两行最可能是原因?为什么「时灵时不灵」这个限定词一下子就把范围缩小了?
19.4 这个站的数据是怎么来的
本站正文里所有带数字的说法——219 个包、198,402 行、56 个事件、52 个工具、13 个监听方——都是从仓库现场抽的,不是从哪篇文章里抄的。抽取脚本是本站自己写的,用的是 Python 加正则,不是仓库自己的 TypeScript 分析器。
这意味着它有明确的能力边界。下面把四条抽取路径和各自漏掉的东西列清楚。
| 哪块数据 | 怎么抽的 | 抽不到什么 |
|---|---|---|
| 219 个包与它们之间的依赖边 | 读每个 packages/*/*/package.json 的 peerDependencies,只保留 @deepseek-ai/ 开头的(仓库自己生成模块图用的也是这个信号) | 运行时才成立的关系:loader 动态挂载的插件、!!js 表达式在启动时才选出来的条目、通过服务 key 建立而不写进 peerDependencies 的耦合。也不含 devDependencies,所以「测试用到谁」看不见 |
每个包 inject 哪些服务、provides 哪些服务 | 对 src/**/*.ts 跑正则:export const inject、static inject、inject: [...]、super(ctx, 'key')、ctx.provide('key') | 键不是字面量的一律看不见。本站实测漏掉的真实例子:ctx.provide(WEB_RUNTIME_SERVICE, runtime)、ctx.provide(storageBackendServiceKey('json'), backend)、super(ctx, remoteServiceKey(name))。另外,一个包里所有插件(包括 invariant 伴生插件)的 inject 会被并成同一个集合,所以你看到的是「这个包用到过谁」,不是「主插件依赖谁」 |
| 56 个事件(名字、模式、声明位置、派发方、监听方) | 解析 docs/event-producer-consumer.md 那张生成出来的表格 | 三类:浏览器端事件不在那份矩阵里(所以 bail 统计成 0 条,而它在客户端是活的);Cordis 内部事件(internal/*)只出现在文档的附表里、不进主表;本站解析该表格的正则还有一个已确认的缺陷,见下面的红框 |
| 52 个工具名 | 取 docs/tool-catalog.md 里所有 ### 名字 三级标题 | 运行时才注册的工具全都不在:MCP 服务器带进来的 mcp__* 一个都没有,cordis_define 现造的工具也没有。而且这 52 条里 bash 出现两次(dsh-tool-bash 和 dsh-tool-bash-persistent 各注册一个同名工具),去重后是 51 个不同名字 |
198,402 行、2,085 个 .ts、692 个 spec 文件 | find 加逐行计数,排除 node_modules 与 *.spec.ts / *.test.ts | 行数是物理行,含空行和注释,不是「有效代码行」。vendor/ 单独统计(9 个包、6,493 行),没有并进那 19.8 万 |
一个本站已经查实的数据缺陷
本站把 window.DSH.events 和 docs/event-producer-consumer.md 逐行对了一遍,56 行里有 11 行的派发方或监听方列是错的。
原因。那份文档里绝大多数包名写成 Markdown 链接([`acp`](../packages/acp/acp)),但有三个包名是裸的行内 code——apiproxy、server、timeout-policy——生成器没能给它们解析出路径。本站的正则按「链接」和「裸 code」两套模式扫,遇到裸的就发生配对错位:把这三个名字丢掉,同时把 , [ 或 ), [ 这样的片段当成包名收了进来。
具体是哪 11 行。监听方列出错 8 行:session/event(真实 23 个,站里 22,少 apiproxy 和 server)、agent/status(真实 6,站里 5)、session/created、session/disposed、agent/error、subagent/end、approval/request(真实 2,少 apiproxy)、tools/execute(真实 2,少 timeout-policy)。派发方列出错 3 行:fs/write-intent、fs/edit-intent、fs/observed,各多出一个 ), [ 的空条目。
影响范围。事件总数(56)、模式分布(41/13/1/1)、名字和声明位置都不受影响,因为那几列不涉及包名列表。受影响的只有「谁派发/谁监听」这两栏,且偏差方向是系统性少算。所以本站正文里凡是引用监听方数量的地方(比如 agent/pre-step 的 13 个),都以文档原文为准重新数过,没有直接用 data.js。
为什么留着不修。因为这个缺陷本身是这一节最好的例子:用正则去读一份「生成出来的文档」,你继承的是两层误差——生成器的,加上你自己的。仓库自己那份是用 TypeScript Program 解析源码生成的,不走正则;本站没有那个能力,所以只能诚实标注。
快照的确切坐标:commit 47f9438(完整 47f943859bef60e4160492346772ded9b24f765a),提交时间 2026-08-13T19:38:46+08:00。仓库处于 developer preview,官方明说会有破坏兼容性的变更——你现在读到的任何 API 形态,都要回仓库对一遍。
有人拿本站的 window.DSH.events 做了三个判断:(甲)「这个仓库的事件绝大多数是 emit,说明它主要靠观察而不是拦截来扩展」;(乙)「tools/execute 只有一个监听方,说明超时功能基本没人用」;(丙)「bail 模式在这个项目里是死代码,可以从 Cordis 里删掉」。请逐个判断这三个结论能不能成立,并说出各自依赖了这份数据的哪一个被破坏的前提。
(甲) 数据上成立,推论上不成立。模式分布 41/13/1/1 是可信的——那一列不涉及包名,没被 bug 碰到。但「靠观察而不是拦截来扩展」这个推论错了,因为它按条数而不是按使用量下结论:13 条 waterfall 承载的是全站最重的扩展点(
agent/pre-step 13 个监听方、tools/* 三关、llm/stream、system-prompt/assemble),而 41 条 emit 里有大量是零监听方的通知。数据本身没问题,是把「事件种类数」当成了「扩展方式的权重」。(乙) 不成立,正好踩在 bug 上。
tools/execute 是红框列出的出错 8 行之一:真实监听方是 2 个,本站数据里只有 1 个——被丢掉的恰恰就是 timeout-policy。所以这个结论用了一个被系统性少算的数字,而且少掉的正是它要论证的那个东西。(丙) 不成立,踩的是抽取范围的盲区,不是 bug。「0 条」的正确读法是「这份矩阵的统计范围内是 0 条」,而那份矩阵只覆盖 harness 自有事件。
bail 在仓库里是活的:vendor/cordis/src/events.ts:296 的 internal/listener 在用,浏览器端 ui-input-trigger 有四个 @mode bail 事件。把它删掉会直接弄坏 Cordis 的监听器注册钩子。三个前提各自是:(甲) 「条数能代表权重」——这个前提数据本身从没承诺过;(乙) 「包名列表是完整的」——被解析 bug 破坏;(丙) 「统计范围等于全集」——被抽取范围破坏。
可迁移的判读法则:拿到任何一份二手数据,先问它的统计范围是什么、抽取方式会漏什么,再问你的结论用到了哪几列。总数和分类通常最结实,关联关系(谁连着谁)最脆弱——因为关联关系需要正确解析两端的名字,而名字是最容易被格式差异搞坏的东西。
变式:如果你要把结论 (乙) 变成一个能站住的判断,最省事的验证动作是什么?(提示:不需要修抽取脚本,一条 grep 就够。)
对你而言未知用正则读一个仓库,误差到底有多大?
本站的画像是正则抽的,仓库自己那份是 TypeScript Program 解析源码生成的。两者的差有多大,本站只在事件这一块量过(56 行错 11 行),包依赖和服务键这两块没量——只知道有哪几类会漏,不知道漏了多少。
这不是真未解:仓库里就有那套权威工具,答案是可以算出来的。它对你的价值在于,你会得到一个可迁移的直觉——正则读代码在什么问题上够用,在什么问题上会骗你。
怎么入手:第一步找到仓库自己的生成器:scripts/gen-doc-graphs.ts(事件与文档图)、scripts/gen-module-graph.ts(模块图)、scripts/gen-cordis-catalog.ts(Cordis 服务与事件目录)。先读 gen-doc-graphs.ts 的开头,搞清楚它是怎么拿到 TypeScript Program 的——这一段就是「正则做不到」的全部原因。第二步做一次可量化的对照:跑 pnpm run gen-cordis-catalog(或直接读它已经生成好的产物),把里面的服务键清单和本站 data/packages.json 里的 provides 并集比一遍,算出召回率(权威清单里有多少被正则抓到)和误报率(正则抓到的有多少是假的)。第三步把漏掉的那些逐个归类:是常量键、是函数算出来的键、还是写法本身没被覆盖?归完类你就能回答一个更有用的问题——在这个代码库里,正则的召回率是被「写法多样性」拖低的,还是被「间接引用」拖低的?前者可以靠补模式救回来,后者救不回来,只能上真正的解析器。
19.5 继续往下走
下面这些地址全部逐字出现在仓库文件里,没有一个是本站自己找的。每一条都注明了出处行。
仓库之外
| 去处 | 地址 | 为什么值得去 | 出处 |
|---|---|---|---|
| Cordis 论文 | github.com/cordiverse/paper | 《A Programming Paradigm for Spatiotemporal Composability》——README 里对这个架构给出的唯一理论出处。你在第 2、3、4 章学的上下文、effect、fiber 都是它的落地 | README.zh.md:7/README.md:7 |
| Cordis 本体 | github.com/cordiverse/cordis | 仓库 vendor/ 下那份是钉住的源码副本,上游在这里。想看这个框架自己怎么演进,看上游 | README.zh.md:7 |
| 仓库本体 | github.com/deepseek-ai/deepseek-harness.git | 本站所有内容的来源。你现在读的是 47f9438 那一刻的快照,去看现在是什么样 | README.zh.md:30(git clone 那一行) |
| GitHub Discussions | github.com/deepseek-ai/deepseek-harness/discussions | README 明写「欢迎通过 GitHub Discussions 提交反馈或 bug 报告」。本站第 18 章那五个研究课题,有几个适合直接去问 | README.zh.md:39 |
dsh-plugin 话题 | github.com/topics/dsh-plugin | README 建议插件仓库打这个标签便于被发现。想看别人拿这套插件模型做了什么,从这里翻 | README.zh.md:40 |
| 模型上下文协议 | modelcontextprotocol.io | MCP 的规范本身。想把外部工具接进来,先读它 | packages/mcp/mcp-client/README.md |
本站在这一节的自我约束
上面六条只列仓库里逐字出现的地址,一条都没有自己补。这不是谨慎过度:一个学习材料里最容易混进假信息的地方,就是「延伸阅读」——因为读者默认不会去核对它。所以规矩是,凡是给地址,必须能指出它在仓库哪个文件的哪一行。你转述本站内容时,建议沿用这条规矩。
仓库之内:接下来该读哪几份
读完这个站,仓库里最值得按顺序读的是这几份(都是路径,不是网址):
docs/architecture.zh.md—— 全仓库的总入口。仓库自己规定:改packages/下任何东西之前先读它。docs/glossary.zh.md—— 就是本站 19.1 的主要来源。全文只有 45 行,值得整份读一遍原文,感受一下「一个概念只给一个规范术语」是什么密度。docs/cordis-tutorial/—— 六章动手教程,从第一个插件到组合与 HMR。你造的那个 mini-harness 可以拿它逐章对照。docs/subsystems/—— 四十多个子系统页,每一页对应一个能力。查某个具体功能怎么实现,来这里。docs/postmortem/—— 事故复盘。这是全仓库信息密度最高的一批文档:每一份都是「有人真的踩到了」,比任何设计文档都更能告诉你哪里危险。.agents/notes/—— Agent Note,按implemented/proposed/rejected/archived分。rejected那一档最值得读:它记录的是「想过但没做」,而一个架构真正的形状,是由它拒绝过什么决定的。
本章小结
这一章是工具,不是读物。四张表各自回答一类问题:
- 表 19-1(55 个术语):忘了某个词是什么意思。按英文原文字母序排,定义照抄仓库原文,最后一栏是回去核对的文件。
- 表 19-2、19-3(事件):想扩展某个行为,该挂哪里。先看模式对不对——想改必须找
waterfall,只想看就用emit;再看域对不对——agent/*是循环节奏、tools/*是一次调用、session/*是日志变化。 - 表 19-4(16 条症状):出了不报错的怪毛病。这些症状的共同点是沉默——卡在 PENDING、patch 打不中、没有超时、
never是拒绝、释放函数不返回,全都不会给你一行堆栈。 - 表 19-5(数据来源):引用本站数字之前先看一眼它漏了什么。凡是运行时才成立的关系,这份画像里一律没有。
最后留一条比表更重要的判读法则,它适用于本站,也适用于你以后读到的任何一份「我们统计了某某项目」的材料:
到这里,整个站就结束了。你从一个 20 行的 while 循环出发,读完了一个 219 个包、20 万行的真实系统,并且手上有一个自己造的、完全理解的参照物。剩下的事只有一件:去仓库里挑一个你不服气的地方,把它读到底。