OpenHands 源码解析系列
第 12 讲:配置系统与多环境支持
基于 OpenHands 源码 · 2026-08-08
配置系统概览
OpenHands 的配置系统是一个分层架构,从环境变量到 Pydantic 模型,再到抽象存储接口,覆盖了从开发到生产、从 OSS 到 SaaS 的全部场景。本讲深入剖析配置加载链路、多环境适配策略以及设置管理的核心设计。
一、AppServerConfig:配置的核心模型
OpenHands V1 架构中,所有服务器级配置收敛于 AppServerConfig 类。它继承自 OpenHandsModel(SDK 提供的 Pydantic 基类),通过 Pydantic 的 Field 与 default_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_DIR → FILE_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_URL → web_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 值 | 沙箱注入器 | 适用场景 |
|---|---|---|
| remote | RemoteSandboxServiceInjector | 云端远程运行时(如 All-Hands Cloud) |
| local / process | ProcessSandboxServiceInjector | 本地开发,无容器隔离 |
| docker(默认) | DockerSandboxServiceInjector | Docker 容器隔离,支持端口/卷配置 |
四、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_class、secret_store_class、user_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 覆盖 |
|---|---|---|
| AppMode | OPENHANDS (oss) | SAAS |
| SettingsStore | FileSettingsStore | 数据库实现 |
| Event Service | FilesystemEventService | AwsEventService / GoogleCloud |
| Sandbox | DockerSandboxService | RemoteSandboxService |
| User Auth | DefaultUserAuth | 自定义认证 |
| Lifespan | OssAppLifespanService | SaasAppLifespanService |
九、关键设计模式
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技术推荐官」获取更多源码解析内容
夜雨聆风