乐于分享
好东西不私藏

OpenHands 源码-配置系统与多环境支持

OpenHands 源码-配置系统与多环境支持

OpenHands 源码解析系列

第 12 讲:配置系统与多环境支持

基于 OpenHands 源码 · 2026-08-08

配置系统概览

OpenHands 的配置系统是一个分层架构,从环境变量到 Pydantic 模型,再到抽象存储接口,覆盖了从开发到生产、从 OSS 到 SaaS 的全部场景。本讲深入剖析配置加载链路、多环境适配策略以及设置管理的核心设计。

一、AppServerConfig:配置的核心模型

OpenHands V1 架构中,所有服务器级配置收敛于 AppServerConfig 类。它继承自 OpenHandsModel(SDK 提供的 Pydantic 基类),通过 Pydantic 的 Fielddefault_factory 机制实现灵活的环境感知默认值。

📄 openhands/app_server/config.py (第 191-237 行)

class AppServerConfig(OpenHandsModel):
    persistence_dir: Path = Field(default_factory=get_default_persistence_dir)
    file_store: FileStore = Field(default_factory=_get_default_file_store)
    web_url: str | None = Field(
        default_factory=get_default_web_url,
        description='The URL where OpenHands is running',
    )
    permitted_cors_origins: list[str] = Field(
        default_factory=get_default_permitted_cors_origins,
        description='Additional permitted CORS origins...',
    )
    openhands_provider_base_url: str | None = Field(
        default_factory=get_openhands_provider_base_url,
    )
    tavily_api_key: str | None = Field(
        default_factory=get_default_tavily_api_key,
    )
    # Dependency Injection Injectors
    llm_model: LLMModelServiceInjector | None = None
    event: EventServiceInjector | None = None
    event_callback: EventCallbackServiceInjector | None = None
    sandbox: SandboxServiceInjector | None = None
    sandbox_spec: SandboxSpecServiceInjector | None = None
    app_conversation_info: AppConversationInfoServiceInjector | None = None
    app_conversation_start_task: AppConversationStartTaskServiceInjector | None = None
    app_conversation: AppConversationServiceInjector | None = None
    pending_message: PendingMessageServiceInjector | None = None
    user: UserContextInjector | None = None
    jwt: JwtServiceInjector | None = None
    httpx: HttpxClientInjector = Field(default_factory=HttpxClientInjector)
    db_session: DbSessionInjector = Field(
        default_factory=lambda: DbSessionInjector(
            persistence_dir=get_default_persistence_dir()
        )
    )
    # Services
    lifespan: AppLifespanService | None = Field(default_factory=_get_default_lifespan)
    app_mode: AppMode = AppMode.OPENHANDS
    web_client: WebClientConfigInjector = Field(
        default_factory=DefaultWebClientConfigInjector
    )

这个类的设计非常精妙——它同时承载了两类数据:

🔹 基础配置字段(persistence_dir、web_url、CORS 等)——直接来自环境变量或默认路径
🔹 注入器引用(llm_model、event、sandbox 等 Injector)——指向各服务的依赖注入工厂

持久化目录的查找链体现了良好的向后兼容设计:

📄 openhands/app_server/config.py (第 75-89 行)

def get_default_persistence_dir() -> Path:
    persistence_dir = os.getenv('OH_PERSISTENCE_DIR')
    # Legacy V0 fallback variable
    if persistence_dir is None:
        persistence_dir = os.getenv('FILE_STORE_PATH')
    if persistence_dir:
        result = Path(persistence_dir)
    else:
        result = Path.home() / '.openhands'
    result.mkdir(parents=True, exist_ok=True)
    return result

优先级:OH_PERSISTENCE_DIRFILE_STORE_PATH(V0 遗留)→ ~/.openhands

二、config_from_env():配置加载与注入器装配

config_from_env() 是整个配置系统的入口函数。它做两件事:(1) 从环境变量解析 AppServerConfig 实例;(2) 根据环境特征装配各服务的注入器。

📄 openhands/app_server/config.py (第 240-420 行)

def config_from_env() -> AppServerConfig:
    # Import defaults...
    config: AppServerConfig = from_env(AppServerConfig, 'OH')

    if config.llm_model is None:
        llm_model_kwargs: dict = {}
        aws_region = os.getenv('AWS_REGION_NAME')
        aws_key = os.getenv('AWS_ACCESS_KEY_ID')
        aws_secret = os.getenv('AWS_SECRET_ACCESS_KEY')
        if aws_region and aws_key and aws_secret:
            llm_model_kwargs['aws_region_name'] = aws_region
            llm_model_kwargs['aws_access_key_id'] = SecretStr(aws_key)
            llm_model_kwargs['aws_secret_access_key'] = SecretStr(aws_secret)
        ollama_url = os.getenv('OLLAMA_BASE_URL')
        if ollama_url:
            llm_model_kwargs['ollama_base_url'] = ollama_url
        config.llm_model = DefaultLLMModelServiceInjector(**llm_model_kwargs)

    if config.event is None:
        provider = get_storage_provider()
        if provider == StorageProvider.AWS:
            config.event = AwsEventServiceInjector(bucket_name=bucket_name)
        elif provider == StorageProvider.GCP:
            config.event = GoogleCloudEventServiceInjector(bucket_name=bucket_name)
        else:
            config.event = FilesystemEventServiceInjector()
    # ... sandbox, conversation, user 等注入器依次装配
    return config

关键设计点:

🔹 from_env(AppServerConfig, 'OH') 使用 SDK 的 env_parser,自动将 OH_ 前缀的环境变量映射到 Pydantic 字段(如 OH_WEB_URLweb_url
🔹 注入器装配采用 条件延迟加载——仅在字段为 None 时才设置默认注入器,允许外部代码预置自定义注入器
🔹 存储服务根据 SHARED_EVENT_STORAGE_PROVIDER 环境变量自动选择 AWS S3 / GCP / 本地文件系统

三、沙箱运行时选择策略

RUNTIME 环境变量决定了沙箱服务的类型,这是多环境适配的核心:

📄 openhands/app_server/config.py (第 332-394 行)

if config.sandbox is None:
    # Legacy fallback
    if os.getenv('RUNTIME') == 'remote':
        config.sandbox = RemoteSandboxServiceInjector(
            api_key=os.environ['SANDBOX_API_KEY'],
            api_url=os.environ['SANDBOX_REMOTE_RUNTIME_API_URL'],
        )
    elif os.getenv('RUNTIME') in ('local', 'process'):
        config.sandbox = ProcessSandboxServiceInjector()
    else:
        # Docker sandbox with legacy env var support
        docker_sandbox_kwargs: dict = {}
        if os.getenv('SANDBOX_HOST_PORT'):
            docker_sandbox_kwargs['host_port'] = int(os.environ['SANDBOX_HOST_PORT'])
        if os.getenv('SANDBOX_STARTUP_GRACE_SECONDS'):
            docker_sandbox_kwargs['startup_grace_seconds'] = int(
                os.environ['SANDBOX_STARTUP_GRACE_SECONDS']
            )
        # Parse SANDBOX_VOLUMES for --mount-cwd support
        sandbox_volumes = os.getenv('SANDBOX_VOLUMES')
        if sandbox_volumes:
            mounts = []
            for mount_spec in sandbox_volumes.split(','):
                parts = mount_spec.split(':')
                if len(parts) >= 2:
                    mounts.append(VolumeMount(
                        host_path=parts[0],
                        container_path=parts[1],
                        mode=parts[2] if len(parts) > 2 else 'rw',
                    ))
            if mounts:
                docker_sandbox_kwargs['mounts'] = mounts
        config.sandbox = DockerSandboxServiceInjector(**docker_sandbox_kwargs)
RUNTIME 值沙箱注入器适用场景
remoteRemoteSandboxServiceInjector云端远程运行时(如 All-Hands Cloud)
local / processProcessSandboxServiceInjector本地开发,无容器隔离
docker(默认)DockerSandboxServiceInjectorDocker 容器隔离,支持端口/卷配置

四、ServerConfig:服务器级配置接口

除了 AppServerConfig,OpenHands 还有 ServerConfig 类,负责应用模式(OSS/SaaS)、功能开关和可插拔组件的类名注册:

📄 openhands/app_server/server_config/server_config.py (第 9-56 行)

class ServerConfig(ServerConfigInterface):
    config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
    app_mode = AppMode.OPENHANDS
    posthog_client_key = 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
    github_client_id = os.environ.get('GITHUB_APP_CLIENT_ID', '')
    enable_billing = os.environ.get('ENABLE_BILLING', 'false') == 'true'
    hide_llm_settings = os.environ.get('HIDE_LLM_SETTINGS', 'false') == 'true'
    # Pluggable class names
    settings_store_class: str = (
        'openhands.app_server.settings.file_settings_store.FileSettingsStore'
    )
    secret_store_class: str = (
        'openhands.app_server.secrets.file_secrets_store.FileSecretsStore'
    )
    user_auth_class: str = (
        'openhands.app_server.user_auth.default_user_auth.DefaultUserAuth'
    )
    enable_v1: bool = os.getenv('ENABLE_V1') != '0'

    def verify_config(self):
        if self.config_cls:
            raise ValueError('Unexpected config path provided')

    def get_config(self):
        return {
            'APP_MODE': self.app_mode,
            'GITHUB_CLIENT_ID': self.github_client_id,
            'FEATURE_FLAGS': {
                'ENABLE_BILLING': self.enable_billing,
                'HIDE_LLM_SETTINGS': self.hide_llm_settings,
            },
        }

def load_server_config() -> ServerConfig:
    config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
    server_config_cls = get_impl(ServerConfig, config_cls)
    server_config: ServerConfig = server_config_cls()
    server_config.verify_config()
    return server_config

这里体现了 OpenHands 的插件化配置扩展设计:

🔹 settings_store_classsecret_store_classuser_auth_class 存储的是完全限定类名
🔹 get_impl() 工具函数通过动态导入实例化这些类
🔹 SaaS 部署只需设置 OPENHANDS_CONFIG_CLS 环境变量指向自定义子类,即可替换所有组件

五、Settings 模型与 SettingsStore 抽象

用户级设置由 Settings 模型承载,存储则委托给 SettingsStore 抽象接口:

📄 openhands/app_server/settings/settings_store.py (第 8-60 行)

class SettingsStore(ABC):
    @abstractmethod
    async def load(
        self,
        *,
        resolve_agent_profile: bool = False,
        override_agent_profile_id: str | None = None,
    ) -> Settings | None:
        """Load session init data.
        resolve_agent_profile=True opts into the effective launch view.
        """

    @abstractmethod
    async def store(self, settings: Settings) -> None:
        """Store session init data."""

    @classmethod
    @abstractmethod
    async def get_instance(cls, user_id: str | None) -> SettingsStore:
        """Get a store for the user represented by the token."""

    @abstractmethod
    async def get_org_marketplaces(self, user_id: str | None) -> list[dict]:
        """Get organization-level marketplaces."""

OSS 模式的默认实现 FileSettingsStore 将设置序列化为 JSON 存储在 settings.json

📄 openhands/app_server/settings/file_settings_store.py (第 13-70 行)

@dataclass
class FileSettingsStore(SettingsStore):
    file_store: FileStore
    path: str = 'settings.json'

    async def load(self, *, resolve_agent_profile=False, ...) -> Settings | None:
        try:
            json_str = await call_sync_from_async(self.file_store.read, self.path)
            kwargs = json.loads(json_str)
            # Migrate legacy agent_settings.llm to llm_profiles
            if 'llm_profiles' not in kwargs:
                legacy_llm = (kwargs.get('agent_settings') or {}).get('llm')
                if isinstance(legacy_llm, dict) and legacy_llm.get('model'):
                    kwargs['llm_profiles'] = {
                        'profiles': {'Default': legacy_llm},
                        'active': 'Default',
                    }
            settings = Settings(**kwargs)
            settings.v1_enabled = True
            return settings
        except FileNotFoundError:
            return None

    async def store(self, settings: Settings) -> None:
        json_str = settings.model_dump_json(
            context={'expose_secrets': True, 'persist_settings': True}
        )
        await call_sync_from_async(self.file_store.write, self.path, json_str)

注意 向后兼容迁移逻辑:当检测到旧版 agent_settings.llm 而缺少 llm_profiles 时,自动将旧配置迁移为 "Default" 配置文件。

六、Settings API 路由

设置通过 /api/v1/settings 路由暴露,支持读取、保存和 LLM Profile 管理:

📄 openhands/app_server/settings/settings_router.py (第 94-100 行)

# Create router with /api/v1/settings prefix
router = APIRouter(
    prefix='/settings',
    tags=['Settings'],
    dependencies=get_dependencies(),
)

POST 端点采用 深合并(deep merge)策略——前端发送部分更新,后端与已有设置合并,避免保存一个页面覆盖另一个页面的字段:

📄 openhands/app_server/settings/settings_router.py (第 264-267 行)

    existing_settings = await settings_store.load()
    settings = existing_settings.model_copy() if existing_settings else Settings()
    settings.update(payload)

LLM Profile 端点还使用了 每用户异步锁防止并发写冲突:

📄 openhands/app_server/settings/settings_router.py (第 398-402 行)

_user_profile_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)

def _profile_lock_key(user_id: str | None) -> str:
    return user_id or '<anonymous>'

源码注释明确指出:多 Worker 部署仍需数据库级行锁或乐观并发令牌,进程内锁仅解决单进程竞态。

七、config.template.toml:TOML 配置参考

OpenHands 提供了 config.template.toml 作为配置参考文档,覆盖了六大配置域:

📄 config.template.toml (结构概览)

[core]
  # workspace_base, cache_dir, debug, runtime, jwt_secret, ...
  runtime = "docker"
  max_iterations = 500
  max_budget_per_task = 0.0
  max_concurrent_conversations = 3

[agent]
  # Tool enable/disable flags
  enable_browsing = true
  enable_cmd = true
  enable_jupyter = true
  enable_history_truncation = true

[sandbox]
  # Container image, GPU, volumes, timeouts
  base_container_image = "nikolaik/python-nodejs:..."
  enable_gpu = false
  close_delay = 300

[security]
  confirmation_mode = false
  security_analyzer = "llm"

[condenser]
  type = "noop"
  # Options: noop, observation_masking, recent, llm, amortized, llm_attention

[kubernetes]
  # K8s namespace, PVC, resource limits, tolerations
  namespace = "default"
  pvc_storage_size = "2Gi"

[mcp]
  # SSE/SHTTP/Stdio MCP server configuration

TOML 配置与 AppServerConfig 的环境变量解析形成互补——TOML 适合结构化配置(如 K8s 资源限制),环境变量适合部署时动态覆盖。

八、多环境适配策略总结

配置加载完整链路

🔹 环境变量层:OH_* 前缀变量 → Pydantic from_env → AppServerConfig 实例
🔹 注入器装配层:config_from_env() 根据 RUNTIME / STORAGE_PROVIDER 装配各服务注入器
🔹 服务器配置层:ServerConfig 通过 OPENHANDS_CONFIG_CLS 支持 OSS → SaaS 切换
🔹 用户设置层:SettingsStore 抽象接口,OSS 用 FileSettingsStore,SaaS 用数据库存储
🔹 TOML 配置层:config.template.toml 提供人类可读的配置参考

配置维度OSS 默认SaaS 覆盖
AppModeOPENHANDS (oss)SAAS
SettingsStoreFileSettingsStore数据库实现
Event ServiceFilesystemEventServiceAwsEventService / GoogleCloud
SandboxDockerSandboxServiceRemoteSandboxService
User AuthDefaultUserAuth自定义认证
LifespanOssAppLifespanServiceSaasAppLifespanService

九、关键设计模式

OpenHands 配置系统体现了几个值得学习的设计模式:

🔹 Pydantic + from_env:利用 Pydantic 的类型校验和环境变量绑定,减少手动解析代码
🔹 注入器延迟装配:config_from_env() 仅在字段为 None 时设置默认注入器,保留覆盖能力
🔹 抽象存储接口:SettingsStore 抽象类使 OSS 和 SaaS 共用同一套 API 路由代码
🔹 深合并策略:POST /settings 使用 model_copy() + update() 避免部分更新覆盖问题
🔹 向后兼容迁移:旧版 settings.json 自动迁移到 llm_profiles 格式
🔹 全局配置单例:get_global_config() 返回模块级单例,避免重复加载

📚 系列导航

← 第 11 讲:Git 集成与版本控制

→ 第 13 讲:数据库设计与 ORM 层

关注公众号「AI技术推荐官」获取更多源码解析内容