第 24 章

编写第一个 Plugin:Channel / Tool / Provider 三种类型实战

第24章 编写第一个 Plugin:Channel / Tool / Provider 三种类型实战

24.1 Plugin 目录结构与 Manifest 格式规范

在开始编写 Plugin 代码之前,先建立正确的目录结构。无论是 Channel、Tool 还是 Provider Plugin,目录结构遵循同一规范。

标准 Plugin 目录结构

my-plugin/
├── plugin.manifest.yaml     # 控制面配置(必须)
├── package.json             # npm 包描述
├── tsconfig.json            # TypeScript 配置
├── src/
│   └── index.ts             # Plugin 入口(开发用)
├── dist/
│   └── index.js             # 编译输出(生产用)
└── README.md                # Plugin 文档

package.json 基础配置

{
  "name": "@myorg/openclaw-plugin-example",
  "version": "1.0.0",
  "description": "Example OpenClaw Plugin",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "prepublish": "npm run build"
  },
  "peerDependencies": {
    "@openclaw/plugin-api": "^2.0.0"
  },
  "devDependencies": {
    "@openclaw/plugin-api": "^2.0.0",
    "typescript": "^5.0.0"
  }
}

tsconfig.json 推荐配置

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Plugin Manifest 完整格式

# plugin.manifest.yaml 完整字段说明
id: my-example-plugin              # 必填:全局唯一 ID(小写字母、数字、连字符)
version: "1.0.0"                   # 必填:SemVer 版本号
displayName: Example Plugin        # 必填:用户可见的名称
description: >                     # 必填:功能描述
  An example plugin demonstrating all manifest fields.
author: MyOrg                      # 推荐:发布者名称或组织
homepage: https://github.com/myorg/my-example-plugin

# 入口点
entryPoint: dist/index.js          # 必填:生产模式入口(已构建 JS)
devEntryPoint: src/index.ts        # 推荐:开发模式入口(TypeScript 源码)

# API 兼容性
apiVersion: "2.0"                  # Plugin API 版本要求

# 能力声明(影响 Registry 行为)
capabilities:
  - channel          # 注册了消息通道
  - tool             # 注册了工具
  - provider         # 注册了 LLM Provider
  - command          # 注册了 CLI 命令
  - httpRoute        # 注册了 HTTP 端点

# 权限声明(Safety Gates 会检查)
permissions:
  network:
    - "*.slack.com"
    - "slack.com"
  fileSystem:
    - read: "~/.config/my-plugin/**"

# 配置 schema(JSON Schema 格式)
configuration:
  schema:
    type: object
    properties:
      apiKey:
        type: string
        secret: true            # secret: true 触发加密存储
        description: API key for authentication
      enabled:
        type: boolean
        default: true
    required: [apiKey]

24.2 Channel Plugin 实战:新增一个消息平台

需求描述

我们要构建一个 Discord Channel Plugin,让 OpenClaw Agent 能够接收并响应 Discord 消息。

完整实现

// src/index.ts
import type { PluginApi, ChannelMessage, ChannelContext } from '@openclaw/plugin-api'
import { Client, GatewayIntentBits, Message } from 'discord.js'

export async function setup(api: PluginApi) {
  const config = api.getConfig<{
    botToken: string
    guildId: string
    channelAllowlist: string[]
    commandPrefix: string
  }>()

  // 初始化 Discord 客户端
  const discordClient = new Client({
    intents: [
      GatewayIntentBits.Guilds,
      GatewayIntentBits.GuildMessages,
      GatewayIntentBits.MessageContent,
    ],
  })

  // 注册 Discord 消息通道
  api.registerChannel({
    id: 'discord',
    displayName: 'Discord',
    
    // 通道初始化:连接到 Discord 并开始监听
    onInit: async (channelHandler) => {
      discordClient.on('messageCreate', async (message: Message) => {
        // 过滤:忽略机器人消息
        if (message.author.bot) return
        
        // 过滤:只处理允许列表中的频道
        if (config.channelAllowlist.length > 0 &&
            !config.channelAllowlist.includes(message.channelId)) {
          return
        }
        
        // 过滤:检查命令前缀(如 "!ai ")
        if (config.commandPrefix && !message.content.startsWith(config.commandPrefix)) {
          return
        }
        
        const content = config.commandPrefix
          ? message.content.slice(config.commandPrefix.length).trim()
          : message.content
        
        // 构建标准 ChannelMessage 并提交给 Agent 处理
        const channelMessage: ChannelMessage = {
          id: message.id,
          content,
          author: {
            id: message.author.id,
            displayName: message.author.displayName,
          },
          threadId: message.channelId,
          timestamp: message.createdAt,
          attachments: message.attachments.map(att => ({
            type: att.contentType?.startsWith('image/') ? 'image' : 'file',
            url: att.url,
            name: att.name ?? 'attachment',
          })),
        }
        
        // 触发 Agent 推理
        await channelHandler.onMessage(channelMessage)
      })
      
      await discordClient.login(config.botToken)
      console.log(`[discord-plugin] Logged in as ${discordClient.user?.tag}`)
    },
    
    // 发送响应到 Discord
    sendMessage: async (response, originalMessage) => {
      const channel = await discordClient.channels.fetch(originalMessage.threadId)
      
      if (!channel?.isTextBased()) {
        throw new Error(`Channel ${originalMessage.threadId} is not text-based`)
      }
      
      // 处理长消息(Discord 限制 2000 字符)
      const chunks = splitIntoChunks(response.content, 1900)
      for (const chunk of chunks) {
        await channel.send(chunk)
      }
    },
    
    // 支持输入状态提示(用户看到"正在输入...")
    sendTypingIndicator: async (threadId) => {
      const channel = await discordClient.channels.fetch(threadId)
      if (channel?.isTextBased()) {
        await channel.sendTyping()
      }
    },
    
    // 通道关闭时清理
    onDestroy: async () => {
      discordClient.destroy()
      console.log('[discord-plugin] Discord client disconnected')
    },
  })
}

// 辅助函数:将长文本切分为多个块
function splitIntoChunks(text: string, maxLength: number): string[] {
  const chunks: string[] = []
  let current = ''
  
  for (const line of text.split('\n')) {
    if (current.length + line.length + 1 > maxLength) {
      if (current) chunks.push(current.trim())
      current = line
    } else {
      current += (current ? '\n' : '') + line
    }
  }
  
  if (current) chunks.push(current.trim())
  return chunks
}

对应的 Manifest

# plugin.manifest.yaml(Discord Channel Plugin)
id: discord-channel-plugin
version: "1.0.0"
displayName: Discord Channel
description: Enables OpenClaw Agent to receive and respond to Discord messages.
author: MyOrg

entryPoint: dist/index.js
devEntryPoint: src/index.ts
apiVersion: "2.0"

capabilities:
  - channel

permissions:
  network:
    - "*.discord.com"
    - "discord.com"
    - "gateway.discord.gg"

configuration:
  schema:
    type: object
    properties:
      botToken:
        type: string
        secret: true
        description: Discord bot token from Discord Developer Portal
      guildId:
        type: string
        description: Discord server (guild) ID to operate in
      channelAllowlist:
        type: array
        items:
          type: string
        default: []
        description: Only respond in these channel IDs (empty = all channels)
      commandPrefix:
        type: string
        default: "!ai "
        description: Message prefix that triggers the Agent (empty = all messages)
    required: [botToken, guildId]

关键接口总结

接口 必须实现 说明
onInit(channelHandler) 连接平台,开始监听消息
sendMessage(response, original) 将 Agent 响应发回平台
sendTypingIndicator(threadId) 发送"正在输入"指示器
onDestroy() 推荐 清理连接,防止资源泄漏

24.3 Tool Plugin 实战:暴露一个新工具

需求描述

构建一个 "Database Query Tool",让 Agent 能够对 PostgreSQL 数据库执行只读查询。

完整实现

// src/index.ts
import type { PluginApi } from '@openclaw/plugin-api'
import { Pool } from 'pg'
import { z } from 'zod'

export async function setup(api: PluginApi) {
  const config = api.getConfig<{
    connectionString: string
    maxConnections: number
    queryTimeoutMs: number
    allowedSchemas: string[]
  }>()

  // 初始化连接池
  const pool = new Pool({
    connectionString: config.connectionString,
    max: config.maxConnections ?? 5,
    idleTimeoutMillis: 30000,
  })

  // 测试连接
  const client = await pool.connect()
  client.release()
  console.log('[db-query-plugin] Connected to PostgreSQL')

  // 注册数据库查询工具
  api.registerTool({
    id: 'database.query',
    displayName: 'Database Query',
    description: `Execute a read-only SQL query against the database.
      Only SELECT statements are allowed. 
      Available schemas: ${config.allowedSchemas.join(', ')}.`,
    
    // 工具输入 schema(JSON Schema 格式)
    inputSchema: {
      type: 'object',
      properties: {
        query: {
          type: 'string',
          description: 'The SQL SELECT query to execute',
        },
        limit: {
          type: 'integer',
          description: 'Maximum number of rows to return (default: 100, max: 1000)',
          default: 100,
          minimum: 1,
          maximum: 1000,
        },
        parameters: {
          type: 'array',
          items: {},
          description: 'Query parameters for parameterized queries ($1, $2, ...)',
          default: [],
        },
      },
      required: ['query'],
    },
    
    // 工具执行 handler
    handler: async (input, ctx) => {
      // 运行时 Zod 验证(提供更好的错误信息)
      const params = z.object({
        query: z.string().min(1),
        limit: z.number().int().min(1).max(1000).default(100),
        parameters: z.array(z.unknown()).default([]),
      }).parse(input)
      
      // 安全检查1:只允许 SELECT 语句
      const normalizedQuery = params.query.trim().toLowerCase()
      if (!normalizedQuery.startsWith('select')) {
        throw new ToolError('Only SELECT queries are allowed for safety reasons.')
      }
      
      // 安全检查2:不允许子查询写操作
      const dangerousKeywords = ['insert', 'update', 'delete', 'drop', 'truncate', 'create', 'alter']
      for (const keyword of dangerousKeywords) {
        if (normalizedQuery.includes(keyword)) {
          throw new ToolError(`Query contains forbidden keyword: ${keyword}`)
        }
      }
      
      // 安全检查3:只允许访问指定 schema
      for (const schema of config.allowedSchemas) {
        // 检查查询只访问允许的 schema(简化实现)
      }
      
      // 添加 LIMIT 保护
      const finalQuery = params.query.includes('LIMIT')
        ? params.query
        : `${params.query} LIMIT ${params.limit}`
      
      // 执行查询(带超时)
      const client = await pool.connect()
      try {
        await client.query(`SET statement_timeout = ${config.queryTimeoutMs ?? 5000}`)
        const result = await client.query(finalQuery, params.parameters)
        
        return {
          rowCount: result.rowCount,
          columns: result.fields.map(f => ({
            name: f.name,
            type: getPostgresTypeName(f.dataTypeID),
          })),
          rows: result.rows,
          // 提供人类可读的摘要
          summary: `Query returned ${result.rowCount} row(s) with columns: ${
            result.fields.map(f => f.name).join(', ')
          }`,
        }
      } finally {
        client.release()
      }
    },
  })

  // 注册第二个工具:列出数据库表
  api.registerTool({
    id: 'database.listTables',
    displayName: 'List Database Tables',
    description: 'List all available tables in the allowed schemas.',
    
    inputSchema: {
      type: 'object',
      properties: {
        schema: {
          type: 'string',
          description: 'Schema to list tables from (default: public)',
          default: 'public',
        },
      },
    },
    
    handler: async (input) => {
      const schema = (input.schema as string) ?? 'public'
      
      if (!config.allowedSchemas.includes(schema)) {
        throw new ToolError(`Schema '${schema}' is not in the allowed list.`)
      }
      
      const client = await pool.connect()
      try {
        const result = await client.query(
          `SELECT table_name, table_type 
           FROM information_schema.tables 
           WHERE table_schema = $1
           ORDER BY table_name`,
          [schema]
        )
        
        return {
          schema,
          tables: result.rows,
          count: result.rowCount,
        }
      } finally {
        client.release()
      }
    },
  })

  // 清理:进程退出时关闭连接池
  process.on('exit', () => pool.end())
}

function getPostgresTypeName(oid: number): string {
  const types: Record<number, string> = {
    16: 'boolean', 20: 'bigint', 21: 'smallint', 23: 'integer',
    25: 'text', 700: 'float4', 701: 'float8', 1082: 'date',
    1114: 'timestamp', 1184: 'timestamptz', 1700: 'numeric',
  }
  return types[oid] ?? `type_${oid}`
}

class ToolError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'ToolError'
  }
}

Tool 定义关键要素

inputSchema 设计原则:
✓ 每个字段都有清晰的 description(Agent 用它决定如何调用工具)
✓ 必填字段在 required 数组中声明
✓ 有合理的 default 值(减少 Agent 调用时的歧义)
✓ 有适当的约束(minimum/maximum/enum 等)
✗ 不要让 Agent 猜测字段格式——description 要精确

24.4 Provider Plugin 实战:接入一个新 LLM

需求描述

接入一个假想的 "Nebula AI" LLM 服务,该服务提供 OpenAI 兼容的 REST API,但有自己的认证方案(HMAC 签名)和特殊的模型能力配置。

完整实现

// src/index.ts
import type { PluginApi, LLMRequest, StreamChunk } from '@openclaw/plugin-api'
import crypto from 'crypto'

export async function setup(api: PluginApi) {
  const config = api.getConfig<{
    baseUrl: string
    apiKey: string
    secretKey: string  // 用于 HMAC 签名
    organizationId?: string
  }>()

  api.registerProvider({
    id: 'nebula-ai',
    label: 'Nebula AI',
    
    // ==========================================
    // 阶段1:配置物化
    // ==========================================
    
    catalog: async () => ({
      models: [
        {
          id: 'nebula-ai/nebula-fast',
          displayName: 'Nebula Fast',
          description: 'Optimized for speed, ideal for interactive use',
          contextWindow: 64000,
          capabilities: {
            streaming: true,
            functionCalling: true,
            vision: false,
          },
          pricing: {
            inputPerMToken: 0.30,
            outputPerMToken: 0.80,
          }
        },
        {
          id: 'nebula-ai/nebula-pro',
          displayName: 'Nebula Pro',
          description: 'Maximum capability for complex reasoning tasks',
          contextWindow: 256000,
          capabilities: {
            streaming: true,
            functionCalling: true,
            vision: true,
          },
          pricing: {
            inputPerMToken: 2.00,
            outputPerMToken: 6.00,
          }
        },
      ]
    }),
    
    applyConfigDefaults: (config) => ({
      ...config,
      defaultModel: config.defaultModel ?? 'nebula-ai/nebula-fast',
      maxRetries: config.maxRetries ?? 3,
    }),
    
    // ==========================================
    // 阶段2:模型解析
    // ==========================================
    
    normalizeModelId: (modelId) => {
      // 处理简写别名
      const aliases: Record<string, string> = {
        'nebula-fast': 'nebula-ai/nebula-fast',
        'fast': 'nebula-ai/nebula-fast',
        'nebula-pro': 'nebula-ai/nebula-pro',
        'pro': 'nebula-ai/nebula-pro',
      }
      return aliases[modelId] ?? null
    },
    
    normalizeTransport: (modelId) => ({
      type: 'openai-compat',
      baseUrl: config.baseUrl,
      // nebula-pro 使用更长的超时
      timeoutMs: modelId === 'nebula-ai/nebula-pro' ? 120000 : 30000,
    }),
    
    normalizeConfig: (rawConfig, modelId) => ({
      ...rawConfig,
      // Nebula API 使用 max_completion_tokens 而非 max_tokens
      max_completion_tokens: rawConfig.maxOutputTokens ?? 4096,
      // Nebula Pro 支持 extended_thinking
      ...(modelId === 'nebula-ai/nebula-pro' && {
        extended_thinking: rawConfig.thinkingBudget ? {
          type: 'enabled',
          budget_tokens: rawConfig.thinkingBudget,
        } : undefined,
      }),
    }),
    
    // ==========================================
    // 阶段3:认证解析
    // ==========================================
    
    resolveConfigApiKey: () => config.apiKey,
    
    // ==========================================
    // 阶段4:模型准备
    // ==========================================
    
    capabilities: (modelId) => ({
      streaming: true,
      functionCalling: true,
      vision: modelId === 'nebula-ai/nebula-pro',
      contextWindow: modelId === 'nebula-ai/nebula-pro' ? 256000 : 64000,
      outputFormats: ['text', 'json'],
      // nebula-fast 不支持 parallel tool calls
      parallelToolCalls: modelId !== 'nebula-ai/nebula-fast',
    }),
    
    contributeResolvedModelCompat: (modelId) => {
      if (modelId === 'nebula-ai/nebula-fast') {
        return {
          // nebula-fast 的工具 schema 不支持 additionalProperties
          toolSchemaStrict: false,
        }
      }
      return null
    },
    
    // ==========================================
    // 阶段6:流式传输(核心实现)
    // ==========================================
    
    createStreamFn: (cfg, auth) => {
      return async function* nebulaStream(request: LLMRequest): AsyncIterable<StreamChunk> {
        // 构建请求体
        const body = {
          model: request.modelId.replace('nebula-ai/', ''),  // 去掉前缀
          messages: request.messages,
          stream: true,
          max_completion_tokens: request.maxOutputTokens ?? 4096,
          temperature: request.temperature ?? 0.7,
          tools: request.tools?.map(t => ({
            type: 'function',
            function: {
              name: t.name,
              description: t.description,
              parameters: t.inputSchema,
            },
          })),
        }
        
        const bodyStr = JSON.stringify(body)
        const timestamp = Date.now().toString()
        
        // Nebula API 使用 HMAC-SHA256 签名认证
        const signature = computeHmacSignature(
          bodyStr,
          timestamp,
          config.secretKey
        )
        
        const response = await fetch(`${config.baseUrl}/v1/chat/completions`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `ApiKey ${auth.apiKey}`,
            'X-Nebula-Timestamp': timestamp,
            'X-Nebula-Signature': signature,
            ...(config.organizationId && {
              'X-Nebula-Org': config.organizationId,
            }),
          },
          body: bodyStr,
        })
        
        if (!response.ok) {
          const errorBody = await response.json().catch(() => ({}))
          throw new ProviderError(
            `Nebula API error ${response.status}: ${errorBody.error?.message ?? 'Unknown error'}`,
            response.status,
            errorBody
          )
        }
        
        // 解析 SSE 流
        const reader = response.body!.getReader()
        const decoder = new TextDecoder()
        let buffer = ''
        
        while (true) {
          const { done, value } = await reader.read()
          if (done) break
          
          buffer += decoder.decode(value, { stream: true })
          const lines = buffer.split('\n')
          buffer = lines.pop() ?? ''  // 保留可能不完整的最后一行
          
          for (const line of lines) {
            if (!line.startsWith('data: ')) continue
            const data = line.slice(6).trim()
            if (data === '[DONE]') return
            
            let parsed: any
            try {
              parsed = JSON.parse(data)
            } catch {
              continue
            }
            
            const choice = parsed.choices?.[0]
            if (!choice) continue
            
            const delta = choice.delta
            
            // 文本内容
            if (delta?.content) {
              yield { type: 'text', content: delta.content }
            }
            
            // 工具调用
            if (delta?.tool_calls) {
              for (const toolCall of delta.tool_calls) {
                yield {
                  type: 'tool_call',
                  toolCallId: toolCall.id,
                  toolName: toolCall.function?.name,
                  argumentsDelta: toolCall.function?.arguments,
                }
              }
            }
            
            // 使用量统计(流结束时)
            if (choice.finish_reason && parsed.usage) {
              yield {
                type: 'usage',
                inputTokens: parsed.usage.prompt_tokens,
                outputTokens: parsed.usage.completion_tokens,
              }
            }
          }
        }
      }
    },
    
    // ==========================================
    // 阶段7:运行时
    // ==========================================
    
    buildReplayPolicy: (error, request) => {
      const status = (error as any).status
      if (status === 429) {
        // Nebula 在响应头中提供 Retry-After
        const retryAfter = parseInt((error as any).headers?.['retry-after'] ?? '5')
        return {
          shouldRetry: true,
          delayMs: retryAfter * 1000,
          maxAttempts: 3,
        }
      }
      if (status >= 500 && status < 600) {
        return { shouldRetry: true, delayMs: 1000, maxAttempts: 2 }
      }
      return { shouldRetry: false }
    },
  })
}

// HMAC-SHA256 签名计算
function computeHmacSignature(body: string, timestamp: string, secret: string): string {
  const message = `${timestamp}.${body}`
  return crypto.createHmac('sha256', secret).update(message).digest('hex')
}

class ProviderError extends Error {
  status: number
  body: unknown
  
  constructor(message: string, status: number, body: unknown) {
    super(message)
    this.name = 'ProviderError'
    this.status = status
    this.body = body
  }
}

24.5 jiti 开发模式 vs 构建发布模式

开发阶段工作流

# 1. 安装依赖
npm install

# 2. 在 openclaw.config.yaml 中注册 Plugin(开发路径)
cat >> ~/.openclaw/config.yaml << EOF
plugins:
  - path: /path/to/my-plugin
    dev: true  # 启用 devEntryPoint + jiti
EOF

# 3. 启动 OpenClaw 开发模式
openclaw --dev

# 4. 修改 src/index.ts 后热重载
openclaw plugin reload my-plugin-id

# 5. 查看 Plugin 状态
openclaw plugin status my-plugin-id

生产构建流程

# 1. 运行 TypeScript 编译
npm run build

# 生成:
# dist/index.js       ← 编译后的 JS
# dist/index.d.ts     ← 类型声明文件
# dist/index.js.map   ← Source Map(调试用)

# 2. 本地测试生产构建
openclaw plugin install /path/to/my-plugin

# 3. 验证安装
openclaw plugin list
openclaw plugin verify my-plugin-id

开发 vs 生产 对比

特性 开发模式(jiti) 生产模式(native loader)
入口文件 src/index.ts dist/index.js
需要编译步骤
热重载 支持 需要重启
启动速度 较慢(jiti 编译) 快速
类型检查 运行时检查 编译时检查
适合场景 Plugin 开发期间 生产部署

24.6 Plugin 发布到 npm 的流程

# 1. 确认 package.json 正确配置
#    - name: @yourscope/openclaw-plugin-xxx
#    - main: dist/index.js
#    - files: ["dist", "plugin.manifest.yaml", "README.md"]

# 2. 构建
npm run build

# 3. 本地验证
openclaw plugin install .
openclaw plugin verify @yourscope/openclaw-plugin-xxx

# 4. 登录 npm
npm login

# 5. 发布
npm publish --access public

# 6. 用户安装方式
openclaw plugin install @yourscope/openclaw-plugin-xxx

package.json 发布配置

{
  "name": "@myorg/openclaw-plugin-nebula",
  "version": "1.0.0",
  "files": [
    "dist",
    "plugin.manifest.yaml",
    "README.md"
  ],
  "keywords": [
    "openclaw",
    "openclaw-plugin",
    "llm-provider",
    "nebula-ai"
  ],
  "openclaw": {
    "pluginType": "provider",
    "compatibleApiVersions": ["2.0"]
  }
}

keywords 中包含 openclaw-plugin 是约定,让用户能通过 openclaw plugin search 在 npm 上找到你的 Plugin。


24.7 常见陷阱

陷阱1:直接修改全局状态

// ❌ 错误:在 Plugin 内部修改全局变量
import { globalRegistry } from '@openclaw/core'  // 这个 import 根本不应该存在
globalRegistry.providers.set('my-provider', ...)  // 绕过了 Plugin 加载管道

// ✓ 正确:通过 api 注册
api.registerProvider({
  id: 'my-provider',
  // ...
})

陷阱2:在 setup 之外存储 config 引用

// ❌ 错误:在模块顶层存储 config(config 在 setup 调用前不存在)
let globalConfig: any  // 危险的模块级变量

export function setup(api: PluginApi) {
  globalConfig = api.getConfig()  // 如果 setup 被多次调用(如热重载),会有竞态
}

// ✓ 正确:将 config 传递到需要的地方
export function setup(api: PluginApi) {
  const config = api.getConfig()  // 局部变量,通过闭包传递
  
  api.registerProvider({
    createStreamFn: (cfg, auth) => {
      // config 通过闭包安全访问
      return async function* (request) { /* ... */ }
    }
  })
}

陷阱3:忽略穿透规则导致 normalizeModelId 拦截所有请求

// ❌ 错误:非 Provider Plugin 的 normalizeModelId 拦截了所有模型
api.registerProvider({
  id: 'my-logging-plugin',
  
  normalizeModelId: (modelId) => {
    console.log('Model requested:', modelId)
    return modelId  // ← 错误!返回非 null 值会阻止穿透
  }
})

// ✓ 正确:非 Provider Plugin 不应实现 normalizeModelId
// 如果需要日志,使用 wrapStreamFn
api.registerProvider({
  id: 'my-logging-plugin',
  
  wrapStreamFn: (originalStream) => {
    return async function* (request) {
      console.log('Request for model:', request.modelId)
      yield* originalStream(request)
    }
  }
})

陷阱4:在 handler 中泄漏 Promise(忘记 await)

// ❌ 错误:未 await 的异步操作
handler: async (input, ctx) => {
  pool.query(input.query)  // 忘记 await!
  return { status: 'done' }  // 立即返回,查询在后台运行
}

// ✓ 正确:
handler: async (input, ctx) => {
  const result = await pool.query(input.query)  // 等待完成
  return { rows: result.rows }
}

24.8 本章小结

三种 Plugin 类型的核心差异:

类型 核心 API 必须实现的 Hook 主要作用
Channel registerChannel onInit, sendMessage 新增消息平台入口
Tool registerTool handler 为 Agent 提供新工具
Provider registerProvider catalog, createStreamFn 接入新 LLM Provider

jiti 开发模式大幅降低了 Plugin 开发的迭代成本——修改源码后无需编译即可看到效果。生产发布前一定要切换到 native loader 模式验证构建产物的正确性。

下一章将站到更高视角,讨论 Skills 和 Plugin 的决策框架:给定一个需求,你怎么判断该用哪种方式实现?

本章评分
4.6  / 5  (6 评分)

💬 留言讨论