Cordis 插件化 Agent 运行时:用 DI 容器把 LLM 后端、工具、审批流全部变成配置

作者:

🇬🇧 English

终端里跑着 make dev,一个 Web UI 弹了出来——模型在回复、工具在执行、审批流在等待确认。切到 mock 模式,只要换一份 cordis.yml,同一个二进制就是 CLI 版本的 make chat。切换模型、增减工具、加审批节点,不需要改一行核心代码,不需要重新发布。这套体验的背后,是一个把 DI 容器当装配线、把一切可变点都推到配置层的架构决策。

回溯这条路是怎么走出来的,核心矛盾其实只有一组:简单 vs 灵活、硬编码 vs 配置驱动


出发点——DI 容器是装配线,不是运行时库

直觉上,做 Agent 框架的第一反应是把所有服务包成一个"黑盒启动器":LLM 后端、工具注册表、技能索引、审批服务全部硬耦合在一起,外部只能通过 API 调接口。这个做法在起步阶段确实简单——一个函数搞定一切。但代价是,每次想换模型、加一个 shell 审批流、或者把 CLI 换成 Web,都必须在核心代码里动刀。

resolve-studio 的实际选择是把 Cordis DI 容器当作装配线,而不是一个运行时库。核心入口 packages/core/src/index.ts 只有 30 行出头,做的是三件事:创建根 Context、解析 YAML 并装配。

import { Context } from 'cordis'
import { parseArgs } from 'node:util'

const root = new Context()

const configPath = typeof values.config === 'string' ? values.config : './cordis.yml'
root.logger('boot').info('loading composition from %s', configPath)
root.logger('boot').info('composition ready — agent harness is running')

这里没有任何一个工具、任何一个 LLM 后端被硬编码。核心职责只是"读配置,把配置里的插件按序挂上装配线"。真正可变的部分——LLM 后端、工具、审批流、技能、前端——全部以 Cordis 插件的形式存在,互相零耦合。换模型,只是换 cordis.ymlllm 那一条的 name

这个设计带来的最直接收益是多配置组合零代码改动。同一个核心,通过四个 YAML 文件就能打出完全不同的运行形态:

  • cordis.yml:CLI 模式,mock LLM
  • cordis.web.yml:Web UI 模式,mock LLM
  • cordis.openai.yml:CLI 模式,真实 OpenAI 模型
  • cordis.openai.web.yml:Web UI 模式,真实 OpenAI 模型

make devmake chat 的区别,就是 --config 参数指向了不同的 YAML 文件。核心代码一行没动。


踩坑——插件注册表和 npm 包名的两路解析

直觉做法:全放在 src/plugins/,硬编码 import

早期很容易走的路径是:把所有插件都堆在 src/plugins/ 目录下,然后在入口或 loader 里用一长串 import 把所需插件全部加载。这条路在 demo 阶段很快,但很快就能感受到局限性:

  • 想加一个新工具?改代码、重新编译、重新部署。
  • 想接入一个跨项目的插件(比如别人写的 @resolve-studio/plugin-pse)?要么把它塞进本地 src/plugins/,要么直接 import——但这样 demo 项目就和生产环境产生了代码层面的依赖。
  • 想接入纯 Cordis 生态插件(如 @cordisjs/plugin-timer)?同样需要改核心代码。

实际做法:resolvePlugin 的两路解析

loader.ts 里实现了一个 resolvePlugin(name) 函数,走两路解析:

async function resolvePlugin(name: string): Promise<Plugin | null> {
  const local = PLUGINS[name]
  if (local) return local

  try {
    const mod = (await import(name)) as {
      default?: Plugin
      plugin?: Plugin
      [key: string]: unknown
    }
    const plugin = mod.default ?? mod.plugin
    if (plugin) return plugin
    for (const value of Object.values(mod)) {
      if (value && typeof value === 'object' && ('apply' in value || 'name' in value)) {
        return value as Plugin
      }
    }
  } catch {
    return null
  }
  return null
}

第一路:查 registry.ts 里的 PLUGINS 短名字典。本地 demo 项目用的 llm-mockagenttool-echo 等,全部在这里映射到实际的插件对象。

第二路:import(name) 按 npm 包名动态加载。查不到本地注册表里的短名时,就把 name 当作 npm 包名去动态 import。这个设计让生产环境只需在 cordis.yml 里加一行 name: '@resolve-studio/plugin-pse' 就能接入跨生态插件,而 @cordisjs/plugin-timer 这类纯 Cordis 插件,零 resolve-studio 依赖也能直装。

为什么更好

两路解析解决了 demo 自包含和生产可扩展之间的矛盾。registry.ts 里维护的 PLUGINS 字典既保留了 demo 的零配置可运行性,又为生产环境留出了扩展通道。

export const PLUGINS: Record<string, Plugin> = {
  tools: ToolRegistry as unknown as Plugin,
  agent: AgentService as unknown as Plugin,
  'llm-mock': llmMock as unknown as Plugin,
  'llm-openai': llmOpenAi as unknown as Plugin,
  'tool-shell': toolShell as unknown as Plugin,
  'cli-chat': cliChat as unknown as Plugin,
  'web-server': webServer as unknown as Plugin,
  // ... 其余工具和服务
}

cordis.ymlname 字段既可以是短名(走第一路),也可以是完整 npm 包名(走第二路),YAML 本身不感知这个区别——解析逻辑封装在 loader 里。这种设计让配置驱动真正成为可能:换模型、加工具、切换前端,都只是 YAML 层面的替换。


设计取舍——把可变点提前抽象

回到最初的场景:开发者写了一个 Agent 工具链,第一次接入 DeepSeek,第二次想切 Claude,第三次要加一个 shell 审批流。如果可变点没有被提前抽象,每次切换都需要改核心代码。resolve-studio 的解法是:在架构层面把所有可变点都变成插件,然后用 YAML 装配

这个选择有一个代价:系统复杂度从代码层转移到了配置层。但权衡下来,这是更划算的——配置可以版本化、可以 diff、可以按环境分发,而代码改动意味着重新编译和部署。

sandbox.ts 是一个具体的例子。它用 Seatbelt(macOS)或 bubblewrap(Linux)在 OS 层面隔离工具执行,通过 SANDBOX_ENABLED 环境变量总开关控制是否启用。这个服务本身是一个 Cordis 插件,通过 ctx.registry.plugin 挂载到根 Context 上。启用或禁用沙箱,只需要在 YAML 配置里改一行 enabled: true,不需要触碰任何代码。

this.enabled = config.enabled ?? process.env.SANDBOX_ENABLED === 'true'
this.allowNetwork = config.allowNetwork ?? process.env.SANDBOX_ALLOW_NETWORK !== 'false'

context.ts 里的 fitContext 函数展示了另一个设计取舍:截断超长对话时,保留连续尾部而非随机删除。这是因为工具调用和工具结果之间的邻接关系不能被破坏——如果删掉中间的消息,模型就看不到自己曾经发起的工具调用,整个对话逻辑会断裂。这个决策直接影响了截断算法的实现方式,但对外部调用者来说,只需传一个 maxChars 参数即可。


最终形态:四个 YAML,一份核心

回头看整个系统,make dev 启动的 Web UI 和 make chat 跑的 CLI,本质上用的是同一份 packages/core/src/index.ts,同一套 Cordis 装配逻辑,区别只在 cordis.ymlcli-chatweb-server 的取舍,以及 llm-mockllm-openai 的切换。

这四个配置组合、零代码改动——不是文档里的美言,而是 loader.ts 里两路解析和 PLUGINS 注册表共同作用的结果。把 DI 容器当装配线,把一切可变点推到插件层,这个设计决策决定了整个系统的可扩展性和维护成本。


源码导航

  • packages/core/src/index.ts — 入口,30 行组装核心逻辑
  • packages/core/src/loader.tsresolvePlugin 两路解析
  • packages/core/src/plugins/registry.tsPLUGINS 短名注册表
  • packages/core/src/context.tsfitContext 对话截断逻辑
  • packages/core/src/plugins/sandbox.ts — OS 级沙箱服务
  • packages/core/src/plugins/skills.ts — 技能索引服务
  • cordis.yml — CLI + mock 默认配置
  • cordis.web.yml — Web UI + mock 配置
  • cordis.openai.yml — CLI + OpenAI 配置
  • cordis.openai.web.yml — Web UI + OpenAI 配置
  • 仓库:https://github.com/erishen/resolve-studio
  • 已发布插件:@resolve-studio/plugin-hello / @resolve-studio/plugin-pse / @resolve-studio/plugin-system-info 已发布到 npm,安装后可在 cordis.yml 里直接以 name 引用

resolvePlugin 的两路解析具体是怎么工作的?

先查 PLUGINS 注册表里的短名(本地插件),查不到则把 name 当作 npm 包名做动态 import,加载纯 Cordis 生态插件。

四个 YAML 配置组合的区别是什么?

区别在于启用的前端插件(cli-chat 或 web-server)和 LLM 后端(llm-mock 或 llm-openai),核心代码同一份。

fitContext 为什么要保留连续尾部而不是随机删除旧消息?

因为工具调用和工具结果之间的邻接关系不能被破坏,随机删除会打断模型的 tool-call/result 配对。

沙箱功能是如何控制的?

通过 SANDBOX_ENABLED 环境变量总开关,macOS 用 Seatbelt 生成 profile,Linux 用 bubblewrap,不满足条件时降级为直接执行。

HARNESS_SKILLS_DIR 环境变量在 skills 服务中起什么作用?

指向一个共享的 skills 仓库目录,与本地 skills/ 目录合并,优先级低于本地目录,解决技能跨项目共享的问题。

纯 Cordis 插件(如 @cordisjs/plugin-timer)为什么能零 resolve-studio 依赖直装?

因为 resolvePlugin 的第二路会按 npm 包名动态 import,这类插件只要依赖 Cordis 标准 API 就能被加载,不需要经过 resolve-studio 服务层。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号