跳到主要内容

Plugin 系统与 genkit() 门面:如何把能力装进注册表

30 秒导读: 上一章讲了「一切皆 Action、注册表按 key 存取」。这一章讲这些 Action 是怎么被装进注册表的——你写 googleAI()ollama() 这些插件,genkit() 把它们收编成统一的 PluginProvider;真正的模型/嵌入器不在启动时就全建好,而是等你第一次用某个 key(如 /model/googleai/gemini-2.5-flash)去查时,才被懒初始化 + 按需解析出来。本章聚焦「组合与寻址」这一层,不重复第 1 章的 Action 内部机制。


1. 这是什么(零基础也能懂)

一句话定义: genkit() 是一个工厂函数,你给它一份配置(要装哪些插件、默认用哪个模型),它还你一个 ai 对象——之后 ai.generate(...)ai.defineFlow(...)ai.defineTool(...) 全从这一个对象出。

它解决什么问题: 一个 AI 应用要用到一堆「外部能力」——Gemini 模型、OpenAI 模型、向量库、嵌入器……这些能力来自第三方包(@genkit-ai/google-genai 之类)。问题是:

  • 怎么让「装一个包」= 「多一批可用的模型」,而用户代码几乎不用改?
  • Gemini 有几十上百个模型,难道启动时就把每个都建成对象?(慢、浪费)

Genkit 的答案就是本章两个主角:Plugin(把能力打包)懒解析(用到才建)

用起来什么样: 一段最小真实代码——注意用户只碰 ai 这一个对象:

// 示意,非源码(取自 Genkit 常见用法)
import { genkit } from 'genkit';
import { googleAI } from '@genkit-ai/google-genai';

const ai = genkit({
plugins: [googleAI()], // 把 Gemini 这一批能力装进来
model: googleAI.model('gemini-2.5-flash'), // 设默认模型
});

const { text } = await ai.generate('给我讲个冷笑话'); // 只跟 ai 打交道

一句话直觉:genkit() 想成手机的应用商店 + 主屏幕:插件是「装上去的 App」,注册表是「装了哪些 App 的列表」,而 App 图标点开才真正加载——你没点的 App 不占内存。Genkit 里「点开」= 「第一次用某个模型的名字去 generate」。


2. 顶层全景(它大概怎么转)

本节讲清楚三个角色怎么串起来:用户配置 → genkit() 组装 → registry 里躺着一堆「待命的插件」→ 用到时解析出 Action

2.1 三层结构图

先看整体分层。怎么读这张图:从上往下是「用户 → 门面 → 注册表 → 插件」,箭头是「谁调用谁」;虚线是「懒」——不是启动时发生,是用到才发生

┌─────────────────────────────────────────────┐
│ 用户代码 │
│ const ai = genkit({ plugins:[googleAI()] }) │
└───────────────┬─────────────────────────────┘
│ new Genkit(options)

┌─────────────────────────────────────────────┐
│ Genkit(门面 / facade) │
│ ai.generate / defineFlow / defineTool ... │
│ —— 每个方法都转调 defineXxx(this.registry,…) │
└───────────────┬─────────────────────────────┘
│ 持有唯一一个

┌─────────────────────────────────────────────┐
│ Registry(注册表) │
│ actionsById: { "/model/googleai/…": Action }│
│ pluginsByName: { googleai: PluginProvider } │
└───────┬───────────────────────┬─────────────┘
│ 懒:第一次 lookup 才 │
│ initializer() │ resolver()
▼ ▼
┌───────────────┐ ┌─────────────────────┐
│ initializer │ │ resolver │
│ 装入"已知"模型 │ │ 按名字动态建一个模型 │
└───────────────┘ └─────────────────────┘

2.2 部件一句话职责

部件干什么在哪个文件
genkit() / Genkit门面工厂:new 一个 Registry、装插件、配 formats,再暴露一站式方法js/genkit/src/genkit.ts:171(Genkit)、:782(genkit)
Registry存 Action、插件、schema、值;按 key 查找并触发懒解析js/core/src/registry.ts:152(Registry)
PluginProvider一个插件的标准形态:name + initializer + 可选 resolver/listActionsjs/core/src/plugin.ts:27(PluginProvider)
genkitPlugin / genkitPluginV2插件作者用的工厂,把 init/resolve 函数包成 PluginProviderjs/genkit/src/plugin.ts:77:163
googleAI() 等 provider真实第三方插件:提供 initializer(列已知模型)+ resolver(按名字建模型)js/plugins/google-genai/src/googleai/index.ts:128

2.3 主线走一遍(高层)

一次 ai.generate('...', { model: 'googleai/gemini-2.5-flash' }) 背后:

  1. generate 拿到模型名,去 registry 用 key /model/googleai/gemini-2.5-flash 查(resolveModellookupAction)。
  2. registry 解析这个 key,发现 pluginName = googleai,于是先初始化 googleai 插件(initializer,只跑一次)。
  3. 如果这个 key 还没在表里,就调插件的 resolver('model','gemini-2.5-flash'),让插件现建一个模型 Action 并注册进表。
  4. 现在表里有了,lookupAction 返回这个 Action,generate 拿去执行。

关键点:没被用到的几十个 Gemini 模型,永远不会被建出来。这就是「组合(装插件)」与「寻址(按 key 懒解析)」的合力。


3. 核心原理

3.1 PluginProvider:插件的统一形态

它要解决的小问题: 插件五花八门(有的一装就该注册几个模型,有的要按名字动态建),门面凭什么用同一套流程对待它们?

思路: 定义一个统一契约 PluginProvider——不管你内部多复杂,对外只暴露四样东西。真源码:

// js/core/src/plugin.ts:27
export interface PluginProvider {
name: string;
initializer: () => InitializedPlugin | void | Promise<InitializedPlugin | void>;
resolver?: (action: ActionType, target: string) => Promise<void>;
listActions?: () => Promise<ActionMetadata[]>;
}

四个字段各司其职:

字段何时触发干什么
name建表时插件的命名空间前缀(如 googleai),决定 key 长什么样
initializer第一次用到该插件时(懒)注册「一装就该有」的东西(已知模型、默认值)
resolver查一个还不存在的 key 时(按需)(类型, 名字) 现场建一个 Action 注册进表
listActionsDev UI / 列举时报告「我能提供哪些」(用于展示,不真的建)

插件本身只是「一个返回 PluginProvider 的函数」:

// js/core/src/plugin.ts:51
export type Plugin<T extends any[]> = (...args: T) => PluginProvider;

所以 googleAI() 这种调用,本质是「调工厂函数,拿到一个 PluginProvider」。

3.2 v1 vs v2:两代插件,同一归宿

它要解决的小问题: Genkit 演进出了两代插件写法,门面必须同时吃下。

  • v1:genkitPlugin(name, initFn, resolveFn?, listFn?),initFn(genkit) 拿到整个 ai 对象,靠副作用ai.defineModel(...) 来注册。见 js/genkit/src/plugin.ts:77
  • v2:genkitPluginV2({ name, init, resolve, list }),init() 返回一个 Action 数组(ResolvableAction[]),由门面替它注册——更纯、更声明式。见 js/genkit/src/plugin.ts:163 与接口 js/ai/src/plugin.ts:26

门面靠一个版本标记来分流:

// js/genkit/src/plugin.ts:170
export function isPluginV2(plugin: unknown): plugin is GenkitPluginV2 {
return (plugin as GenkitPluginV2).version === 'v2';
}

两代最终殊途同归:都被 registerPluginProvider 收成同一个 PluginProvider(见 3.4)。差别只在「谁来 new 那些 Action」——v1 是插件自己在 initFn 里调 ai.defineModel,v2 是返回数组、门面用 registerActionV2 帮它注册(js/genkit/src/genkit.ts:747)。

3.3 genkit() 工厂:一次 new,处处复用同一个 Registry

它要解决的小问题: ai.defineFlowai.generateai.defineTool 必须都作用在同一个注册表上,否则一处定义、另一处查不到。

思路: 构造时 new 唯一一个 Registry,存成实例字段;之后每个方法都只是「把 this.registry 转交给底层的 defineXxx」。构造函数:

// js/genkit/src/genkit.ts:183
constructor(options?: GenkitOptions) {
const registry = new Registry();
super(registry);
this.options = options || {};
if (this.options.context) this.registry.context = this.options.context;
this.configure(); // 装插件、配 formats
if (isDevEnv() && !disableReflectionApi) {
this.reflectionServer = new ReflectionServer(this.registry, {});
this.reflectionServer.start()// 装配 Dev UI 反射服务
}

}

门面方法几乎都是薄薄一层转发。看几个代表,注意它们全都把 this.registry 作为第一参传下去:

门面方法转发到位置
defineFlow(config, fn)defineFlow(this.registry, …) 再 push 进 this.flowsjs/genkit/src/genkit.ts:206
defineTool(config, fn)defineTool(this.registry, …)js/genkit/src/genkit.ts:240
defineModel(opts, runner)defineModel(this.registry, …)js/genkit/src/genkit.ts:289
definePrompt(opts)definePrompt(this.registry, …)js/genkit/src/genkit.ts:464
defineDynamicActionProviderdefineDynamicActionProvider(this.registry, …)js/genkit/src/genkit.ts:261

这就是「一站式门面」的实现真相:门面自己几乎没逻辑,它的价值是「持有那个唯一的 registry,并把它无处不在地传下去」

genkit() 工厂本身只有一行:

// js/genkit/src/genkit.ts:782
export function genkit(options: GenkitOptions): Genkit {
return new Genkit(options);
}

3.4 configure():装配流水线

this.configure() 是构造期最重的一步,顺序做四件事(js/genkit/src/genkit.ts:650):

configure():
1. defineGenerateAction(registry) // 注册核心 /util/generate 动作
2. configureFormats(registry) // 装默认输出格式(json/text/array…)
3. 若配了 model → registerValue('defaultModel', …) // 存默认模型引用
4. 若 promptDir≠null → loadPromptFolder(…) // 扫 .prompt 文件
5. 遍历 plugins: isPluginV2? → 走 v2 分支
否则 → 走 v1 分支
两者都 → registry.registerPluginProvider(name, {…})

其中第 2 步 configureFormats 把内置格式逐个 defineFormat 进 registry,让结构化输出(下一批章节的 Dotprompt)有格式可用:

// js/ai/src/formats/index.ts:131
export function configureFormats(registry: Registry) {
for (const format of DEFAULT_FORMATS) {
defineFormat(registry, { name: format.name, ...format.config }, format.handler);
}
}

第 5 步是本章重点:不管 v1 还是 v2,都最终调 registry.registerPluginProvider(name, provider),把插件收成统一 PluginProvider。v2 分支里还顺带把 plugin.init() 返回的 Action 用 registerActionV2 注册、把 middleware registerValue 进去(js/genkit/src/genkit.ts:670-712)。

注意:此刻插件还没真正初始化。 registerPluginProvider 只是把插件「登记待命」,并把 allPluginsInitialized 置回 false(js/core/src/registry.ts:434)。真正跑 initializer 要等第一次 lookup。这正是「懒」的落点。

3.5 懒初始化:memoize 过的 initializer

它要解决的小问题: 插件初始化(可能要读环境变量、建 HTTP client)有开销;而且一个插件可能被 lookup 很多次——不能每次都初始化。

思路: registerPluginProvider 把外部传入的 initializer 包一层记忆化(memoize):只在第一次跑,之后返回缓存结果。真源码:

// js/core/src/registry.ts:434
registerPluginProvider(name: string, provider: PluginProvider) {
if (this.pluginsByName[name]) throw new Error(`Plugin ${name} already registered`);
this.allPluginsInitialized = false;
let cached;
let isInitialized = false;
this.pluginsByName[name] = {
name: provider.name,
initializer: () => {
if (!isInitialized) { // ← 只跑一次
cached = provider.initializer();
isInitialized = true;
}
return cached;
},
resolver: async (actionType, actionName) => {},
listActions: async () => {},
};
}

isInitialized 这个闭包变量就是「这个插件初始化过没有」的开关。之后无论多少次 initializePlugin(name),真正的 provider.initializer() 只会执行一次(js/core/src/registry.ts:515initializePlugin 每次都调这个被包过的版本)。

3.6 按需解析:一个 registry key 如何变出一个 Action

这是「寻址」的核心。它要解决的小问题: 用户写 model: 'googleai/gemini-2.5-flash',但这个模型 Action 此刻并不在表里——registry 怎么把它变出来?

第一步:解析 key。 registry key 有固定格式,parseRegistryKey 拆出「类型 / 插件名 / 动作名」:

/model/googleai/gemini-2.5-flash
│ │ └── actionName = gemini-2.5-flash
│ └─────────── pluginName = googleai
└────────────────── actionType = model

拆分逻辑在 js/core/src/registry.ts:99(parseRegistryKey):tokens.length >= 4 时,tokens[2] 是插件名、tokens.slice(3) 是动作名。

第二步:查不到就唤醒插件。 lookupAction 的关键分支(js/core/src/registry.ts:222):

// js/core/src/registry.ts:242(节选)
if (parsedKey?.pluginName && this.pluginsByName[parsedKey.pluginName]) {
await this.initializePlugin(parsedKey.pluginName); // ① 先懒初始化插件
if (!this.actionsById[key]) { // ② 表里还没有?
await this.resolvePluginAction( // ③ 让插件现场解析
parsedKey.pluginName, parsedKey.actionType, parsedKey.actionName,
);
}
}
return (await this.actionsById[key]) || this.parent?.lookupAction(key);

怎么读这段流程(命中即用,否则逐步唤醒):

lookupAction("/model/googleai/gemini-2.5-flash")

├─ 表里已有? ──yes──▶ 直接返回 Action ✔
│ │no

initializePlugin("googleai") // memoized,首次真跑 initializer

├─ 表里现在有了? ──yes──▶ 返回 ✔ (initializer 注册的"已知"模型)
│ │no

resolvePluginAction("googleai","model","gemini-2.5-flash")
│ → plugin.resolver("model","gemini-2.5-flash")
│ → 插件 defineModel(...) 现建并注册进表

再读 actionsById[key] ──▶ 返回新建的 Action ✔

resolvePluginAction 只是把活转交给插件的 resolver,并在「行动运行时上下文之外」执行(js/core/src/registry.ts:480,用 runOutsideActionRuntimeContext 避免把解析行为记进当前 trace)。

这条链就是 provider 接入的全部秘密:装插件 = 提供 initializer(列已知)+ resolver(按名建);用户拿 key 一查,registry 自动走「初始化 → 解析 → 注册 → 返回」。

3.7 一个真实 provider:googleAI 怎么接进来

把上面抽象的东西落到 googleAI 这个真插件上。它是 v2 插件,工厂长这样:

// js/plugins/google-genai/src/googleai/index.ts:128
export function googleAIPlugin(options?: GoogleAIPluginOptions): GenkitPluginV2 {
let listActionsCache;
return genkitPluginV2({
name: 'googleai',
init: async () => await initializer(options), // 返回"已知模型"数组
resolve: async (actionType, actionName) => // 按名字建一个模型
await resolver(actionType, actionName, options || {}),
list: async () => {}, // Dev UI 用
});
}
export const googleAI = googleAIPlugin as GoogleAIPlugin; // :195

三个钩子对应本章三个机制:

钩子内容位置
init把 imagen/gemini/embedder/veo… 的已知模型元数据拼成一个数组返回,门面注册进表initializer,…/googleai/index.ts:48
resolve收到 ('model','gemini-2.5-flash'),switch(actionType) 分派到 gemini.defineModel(...) 现建一个 Actionresolver,…/googleai/index.ts:60
listlistModels 拉真实模型清单(带缓存),供 Dev UI 展示listActions,…/googleai/index.ts:95

resolver 的分派骨架(真源码精简)——这就是「一个名字 → 一个模型 Action」的落点:

// js/plugins/google-genai/src/googleai/index.ts:60(节选)
switch (actionType) {
case 'model':
if (imagen.isImagenModelName(actionName)) return imagen.defineModel(actionName, options);
if (lyria.isLyriaModelName(actionName)) return lyria.defineModel(actionName, options);

return gemini.defineModel(actionName, options); // 默认当 gemini 处理
case 'embedder':
return embedder.defineEmbedder(actionName, options);
}

因为有 resolver,googleAI() 不需要预先声明它支持哪些模型——你写任何 googleai/<模型名>,只要 Gemini API 认,gemini.defineModel 就当场把它建出来。这解释了为什么「装一个包 = 多一整族模型」。

3.8 generate 如何触发这条链

闭环一下:用户侧的 generate 并不直接碰 registry key,而是通过 resolveModel(js/ai/src/model.ts:551)。它把「字符串 / ModelReference / ModelAction」都归一成一次 lookupModel:

// js/ai/src/model.ts:605(lookupModel)
return (
(await registry.lookupAction(`/model/${model}`)) ||
(await registry.lookupAction(`/background-model/${model}`))
);

于是 'googleai/gemini-2.5-flash' 被拼成 /model/googleai/gemini-2.5-flash,交给 3.6 那条懒解析链。没传 model 时,resolveModel 会先去 lookupValue('defaultModel', …) 取 3.4 里存的默认模型(js/ai/src/model.ts:560)。至此,「组合」(装插件)和「寻址」(按 key 解析)完整合拢。


4. 深入一层:动态动作提供者(DAP)

它要解决的小问题: 有些能力不是「固定几个模型」,而是运行时才知道有哪些——典型是 MCP host:连上一个 MCP server,它有哪些工具要连上才知道,而且会变。

思路: 除了「插件级 resolver」,Genkit 还有一层动态动作提供者(dynamic-action-provider)。它本身是一个 Action,内部带带 TTL 的缓存,按需拉取一批子动作。用 defineDynamicActionProvider 定义:

// js/core/src/dynamic-action-provider.ts:164(defineDynamicActionProvider)
const cache = new SimpleCache(cfg, fn); // fn 拉一批动作,默认缓存 3s
const a = defineAction(registry, {, actionType: 'dynamic-action-provider' },
async () => { const v = await fn(); cache.setValue(v); return transformDapValue(v); });
implementDap(a as DynamicActionProviderAction, cache);

它的 key 用另一种格式(带 : 的 host 语法),parseRegistryKey 专门有分支处理(js/core/src/registry.ts:102):

/dynamic-action-provider/mcp-host:tool/my-tool
│ │ │ └── actionName = my-tool
│ │ └─────── actionType = tool
│ └──────────────── dynamicActionHost = mcp-host
└─────────────────────────────────── 固定前缀

lookupAction 见到 dynamicActionHost 就走 getDynamicAction(js/core/src/registry.ts:495),从 DAP 的缓存里 getAction(type, name) 找那一个子动作(js/core/src/dynamic-action-provider.ts:202)。支持 * 通配来「列一批」。

和插件 resolver 的分工:

维度插件 resolver动态动作提供者(DAP)
解析对象静态、名字空间已知(如 Gemini 模型族)运行时才知道、会变(如 MCP 工具)
触发lookupAction 里插件分支lookupAction 里 DAP 分支
缓存initializer memoize(一次)SimpleCache 带 TTL(会过期重拉)
key 形态/model/plugin/name/dynamic-action-provider/host:type/name

5. 巧妙之处(可借鉴的技术)

  • 统一契约收编异构插件。 不管 v1 副作用式还是 v2 声明式,门面都用 isPluginV2 分流后,归一到同一个 PluginProvider 注册(js/genkit/src/genkit.ts:670)。上层再也不关心插件是哪一代。

  • 闭包做 memoize,不用额外状态。 懒初始化只靠 registerPluginProvider 里一个 isInitialized 闭包布尔量(js/core/src/registry.ts:443),没有全局注册状态、没有锁,简单可靠。

  • key 即路由。 「用哪个模型」这件事被编码进一个字符串 key,parseRegistryKey 把它拆成 (类型,插件,名字)(js/core/src/registry.ts:99),lookup 顺着这三段自动完成「找哪个插件、初始化、解析」——寻址和懒加载是同一个动作

  • 解析在 trace 之外跑。 resolvePluginAction / initializePlugin 都包在 runOutsideActionRuntimeContext 里(js/core/src/registry.ts:487:517),避免把「建模型」这种基础设施行为污染进用户请求的调用链追踪。

  • DAP 用 skipTrace 喂 Dev UI。 列举动态动作给开发面板时走 getOrFetch({ skipTrace: true })(js/core/src/dynamic-action-provider.ts:240),不给每次刷新都留一条 trace。


6. 边界与局限

  • 重复注册会「覆盖」而非报错(action 层)。 同 key 再 registerAction 只打一条 error log 然后覆盖(js/core/src/registry.ts:303);但同名插件registerPluginProvider 会直接抛错(js/core/src/registry.ts:435)。粒度不一致,写插件时要留意。

  • resolver 是「按名字猜类型」。 googleAI 的 resolver 靠 isImagenModelName/isVeoModelName 等前缀判断分派(…/googleai/index.ts:60),名字不匹配任何特例就兜底当 gemini——传错名字不会在解析期报错,要到真正调 API 才失败。

  • 懒解析对 key 拼写敏感。 模型名要能拼成合法 /model/<plugin>/<name>;插件名前缀错了,lookupAction 找不到对应 pluginsByName 就不会触发任何初始化,静默返回 undefined 后交给 parent

  • 本章只讲「组合与寻址」。 Action 自身怎么执行、怎么带 streaming/中断,见同组其它章;这里止于「Action 被建出来并注册进表」。


7. 横向对比 / 与同组章节的关系


8. 代码地图(导航索引)

主题文件路径符号名
插件统一契约js/core/src/plugin.tsPluginProviderPluginInitializedPlugin
v1 插件工厂js/genkit/src/plugin.tsgenkitPlugin
v2 插件工厂 / 实例js/genkit/src/plugin.tsgenkitPluginV2GenkitPluginV2InstanceisPluginV2
v2 插件接口js/ai/src/plugin.tsGenkitPluginV2
门面类 / 构造js/genkit/src/genkit.tsGenkit(constructor)、genkit
装配流水线js/genkit/src/genkit.tsGenkit.configureregisterActionV2
门面转发方法js/genkit/src/genkit.tsdefineFlowdefineTooldefineModeldefinePrompt
注册插件(memoize)js/core/src/registry.tsregisterPluginProviderinitializePlugin
懒查找 / 按需解析js/core/src/registry.tslookupActionresolvePluginAction
key 解析js/core/src/registry.tsparseRegistryKeyACTION_TYPES
默认模型 / 值存取js/core/src/registry.tsregisterValuelookupValue
格式装配js/ai/src/formats/index.tsconfigureFormats
generate 侧解析模型js/ai/src/model.tsresolveModellookupModel
真实 provider(googleAI)js/plugins/google-genai/src/googleai/index.tsgoogleAIPlugingoogleAIinitializerresolver
动态动作提供者js/core/src/dynamic-action-provider.tsdefineDynamicActionProviderSimpleCachegetAction