第一次看到 Agent Loop 里的 this.loopCtx.llm.stream(request),我把 ctx.llm 当成了普通依赖注入:启动时塞进去一个实例,后面大家共用。
这个理解只对了一半。继续跳进 vendor/cordis 会发现,服务从哪个作用域里取、由谁注册、什么时候撤销,都绑在当前 Context 和插件的 Fiber 上。DeepSeek Harness 所说的"一切皆插件",关键不在于文件拆得多,而在于这些东西都能按同一套规则装上去、换掉,再干净地卸下来。
项目地址:deepseek-ai/deepseek-harness
源码基线:0.1.0-rc.5
先从 ctx.llm 往下追
模型请求最终会走到 packages/core/agent-loop/src/agent.ts:
ts
const stream = preparedCall?.stream(request)
?? this.loopCtx.llm.stream(request)
这里没有导入 DeepSeek Provider,也没有出现 new DeepSeekClient()。Agent Loop 只知道当前上下文里有一项名为 llm 的服务。
这带来一个很实在的好处:官方 API、兼容网关和测试替身都可以挂在同一个入口后面,Agent Loop 不用跟着改。不过,ctx.llm 也不是从一只全局字典里硬取出来的。Context 本身是个代理对象:
ts
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
读取 ctx.llm 时,代理把查找交给 Cordis 的反射层。反射层不仅看服务名,还会看当前 Context 的隔离标签。于是同样写 ctx.llm,在不同子 Context 中可能拿到不同实现。
Context 不是全局容器,它表示"我现在在哪"
Context 最容易被误读成一个保存服务的对象。实际用法更接近一条作用域链。
extend() 会从当前 Context 派生子 Context,父节点不变;子节点可以多带一个 Agent、工作目录或其他运行元数据。isolate() 则专门给某项服务换一枚作用域标签:
ts
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
假设两个 Agent 都使用 ctx.tools,它们不必把工具名改成 agent-a.xxx、agent-b.xxx。只要各自的 Context 隔离了 tools,Cordis 就会按标签解析对应的注册项。服务名没变,作用域已经分开。
事件也是一样。监听器挂在哪个 Context 上,会影响它能收到哪些事件。DeepSeek Harness 的 Agent 级工具审批、提示词片段和执行监听,正是靠这套作用域传播,而不是在每个 API 里手工传一串 scope id。
Service 注册时,清理动作已经跟上了
Cordis 的 Service 构造函数没有做太多事,最重要的是这一行:
ts
self.ctx.reflect.provide(name, self, this[symbols.check])
provide() 会按"服务名 + 当前隔离标签"保存实现,并把注销动作登记到当前 Fiber。也就是说,注册服务和将来撤销服务不是两套互不相干的代码。
Agent Loop 本身就是一个例子。它实现 AgentFactory,但消费者不会直接依赖 AgentLoop 类;它们只通过 ctx.agents 创建或恢复 Agent。加载时,Agent Loop 把自己登记成当前工厂:
ts
export class AgentLoop extends Service implements AgentFactory {
constructor(ctx: Context, config: Config) {
super(ctx, 'agentLoop')
// ...
ctx.effect(
() => ctx.agents.setFactory(this),
'agentLoop.setFactory()',
)
}
}
AgentRegistry 管"当前工厂是谁",AgentLoop 提供默认实现。以后真要换一种执行循环,替换的是工厂实现,不需要让所有入口改成认识一个新类。
Fiber 管的是插件留下的所有东西
一个插件通常不只注册 Service。它还可能添加事件监听、启动定时器、观察文件,甚至再加载子插件。只删掉插件对象远远不够:旧监听器还在,热更新一次就会多响应一次;旧 Provider 没撤销,新 Provider 也注册不上来。
Cordis 给每次插件加载建立一个 Fiber。插件里的 ctx.effect()、ctx.on() 和 ctx.provide() 最终都会把 disposer 归到这个 Fiber。卸载时,Fiber 统一执行这些清理动作。
这张图把装配和运行时生命周期放在了一起:

所以 ctx.effect() 不只是一个"顺手返回清理函数"的工具。它把资源所有权说清楚了:谁加载,谁负责把注册项一起带走。DeepSeek Harness 里频繁出现 ctx.effect(() => register(...)),不是写法偏好,而是为了让重载和卸载真的成立。
waterfall 最容易读错
Cordis 的事件不全是广播。DeepSeek Harness 在工具执行、模型请求等关键路径上大量使用 waterfall,因为插件既要能观察流程,也要能包住默认行为或提前截断。
工具执行前的审批入口在 packages/core/tools/src/index.ts:
ts
const gate = await this.ctx.waterfall(
carrier,
'tools/pre-execute',
exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
最后一个函数是默认行为:没人拦截时允许执行。监听器如果想放行,就必须调用 next():
ts
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'echo') {
return { kind: 'deny', reason: 'denied by policy' }
}
return next()
})
这里有个很容易踩的坑:监听器执行完直接 return,并不等于"我没意见"。它会把后面的监听器和默认实现一起截断。仓库规范反复强调 waterfall 监听器必须调用 next(),原因就在这里。
从扩展能力看,这比在 Agent Loop 里不断加 if (permissionPlugin) 干净得多。权限插件可以返回 deny 或 ask;记录耗时的插件可以在 await next() 前后计时;两者都不需要改工具执行器的主体。
Profile 只负责回答"这次装什么"
前面说的是插件装上以后怎样协作。真正启动 dsh 时,还需要先决定要装哪些插件,这部分由 Profile 和 Bundle 处理。
Bundle 是一份可复用的 patch。它在 package.json 的 dsh.bundle.patch 中声明自己的 cordis.patch.yml。dsh-base 放进 Session、Agent、Tools、LLM、Agent Loop 等通用能力;Web 和 Headless 再追加自己的入口。
仓库内置的 Profile 模板很短:
ts
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}
启动时会依次应用:
text
Bundle patch
→ Profile 的 cordis.patch.yml
→ $DSH_HOME/cordis.patch.yml
→ 命令行 --patch
后面的 patch 可以按插件 id 改写前面的条目。这里有一处值得留心:命中插件配置时,patch 替换的是整段 config,不是随意做深合并。自定义某个条目时,想保留的配置也要明确写出来。
Web 和 Headless 因此能共用一套核心。二者都从 dsh-base 起步,差异留在后续 Bundle;不是复制一份 Agent Loop,再各改各的。
如果怀疑 Provider 没装上,或者某层 patch 覆盖错了,先看最终配置,通常比全仓库搜类名快:
bash
pnpm dsh --profile headless --dump-config
改功能之前,先判断它属于哪一层
读完这条链路后,我现在不会一上来就改 Agent Loop。
接一个新的模型后端,应该提供新的 LLM 实现,让调用方继续走 ctx.llm;给工具加审批,应该监听 tools/pre-execute,明确返回 ask、deny 或调用 next();只想让某种部署多装一个能力,改 Bundle 或 Profile patch 就够了。
只有 Turn、Step、工具调用与 Session 事件之间的关系真的要变,才需要碰 Agent Loop。那已经不是"加一个插件",而是在改执行模型本身。
我对"一切皆插件"的理解也落在这里:插件不是一个随便拆出来的 npm 包,而是一段运行在特定 Context 中、把所有副作用交给 Fiber 管理、通过 Service 和事件与其他部分协作的可卸载程序。
下一篇继续看这些插件装好以后,Agent、Session、System Prompt、Tools 和 LLM 是怎样组成一次运行的。