终端里跑着 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.yml 里 llm 那一条的 name。
这个设计带来的最直接收益是多配置组合零代码改动。同一个核心,通过四个 YAML 文件就能打出完全不同的运行形态:
cordis.yml:CLI 模式,mock LLMcordis.web.yml:Web UI 模式,mock LLMcordis.openai.yml:CLI 模式,真实 OpenAI 模型cordis.openai.web.yml:Web UI 模式,真实 OpenAI 模型
make dev 与 make 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-mock、agent、tool-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.yml 里 name 字段既可以是短名(走第一路),也可以是完整 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.yml 里 cli-chat 和 web-server 的取舍,以及 llm-mock 和 llm-openai 的切换。
这四个配置组合、零代码改动——不是文档里的美言,而是 loader.ts 里两路解析和 PLUGINS 注册表共同作用的结果。把 DI 容器当装配线,把一切可变点推到插件层,这个设计决策决定了整个系统的可扩展性和维护成本。
源码导航
packages/core/src/index.ts— 入口,30 行组装核心逻辑packages/core/src/loader.ts—resolvePlugin两路解析packages/core/src/plugins/registry.ts—PLUGINS短名注册表packages/core/src/context.ts—fitContext对话截断逻辑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 服务层。
发表回复