Day 06 / 共 20 天 · 第 2 周 app_server 编排层
app_server 架构总览
上周(Day 01-05)我们从外部视角看懂了"一个任务如何被自主完成";本周开始钻进"管家"app_server 的源码里看它到底怎么实现。今天先建整体地图:模块划分、FastAPI 应用怎么装配、以及贯穿全代码库的灵魂——Injector 依赖注入。有了这张地图,Day 07-10 逐个模块深入时才不会迷路。
📍 第 2 周 后端编排层 · 你在这里
Day6 app_server总览→
Day7 会话生命周期→
Day8 事件系统→
Day9 Sandbox→
Day10 server装配
L01
先明确管家的职责边界
回忆 Day 05:app_server 是"管家/网关层",它不亲自跑 Agent(那是沙箱里 agent-server 的活)。管家只干这些:
- 管理用户、会话(conversation)的生命周期
- 供给/暂停/回收沙箱(sandbox)
- 持久化事件、鉴权
- 对沙箱里的 agent-server 做代理转发(浏览器连不上沙箱时)
💡 本质:app_server 就像一家公司的"总部 + 前台调度台"把整套系统想象成一家软件公司:真正写代码的是各个项目组的实习生(沙箱里的 agent-server),而 app_server 是总部——前台负责接待来访(收 HTTP 请求)、调度台负责派活和安排工位(管会话/沙箱)、档案室负责归档(存事件)、保安负责验证身份(鉴权)。总部本身不写一行业务代码,它只做"接待、调度、记录、把关"。本周就是逐个参观总部的这几个部门。
一个重要事实:
openhands/server/*.py 现在全是"向后兼容的转发壳"(deprecated)——server/app.py 只有一行 re-export app_server.app。真正的实现全部在 openhands/app_server/。所以这一周我们只看 app_server。L02
顶层模块地图
| 模块目录 | 职责 | 本周 |
|---|---|---|
app_conversation/ | 会话创建、启动编排、增删改查、导出 | Day 07 |
event/ | 事件持久化与查询(REST 只读 + save) | Day 08 |
event_callback/ | 接收 agent-server 推送的事件 webhook | Day 08 |
sandbox/ + sandbox_spec/ | 沙箱供给/暂停/删除;spec 是镜像模板 | Day 09 |
config_api/ | LLM 模型清单、AppMode 等配置型 API | — |
app_lifespan/ | 启动/关闭钩子、DB 迁移(Alembic) | Day 10 |
user*/ settings/ secrets/ | 用户、鉴权、设置、密钥、Git 集成 | — |
v1_router.py | 汇总所有子路由,统一前缀 /api/v1 | Day 10 |
怎么记这张地图?
抓三个主角:app_conversation(会话)、event(事件)、sandbox(沙箱)——这正是 Day 05 旅程里的三个关键动作(建会话、记事件、管沙箱)。其余是配套(配置、用户、生命周期)。把主角记牢,本周就有主线了。
L03
app.py:应用装配点
app_server/app.py 是 FastAPI 应用的"组装车间"——它只做拼装,不含业务逻辑:
# app_server/app.py(简化)
app = FastAPI(
lifespan=combine_lifespans(*lifespans), # 合并多个生命周期钩子
routes=[Mount(path='/mcp', app=mcp_app)], # 挂载 MCP 子应用
)
app.include_router(v1_router.router) # 业务 API(/api/v1/*)
app.include_router(health_router) # 探活
if os.path.exists('./frontend/build'):
app.mount('/', SPAStaticFiles(...)) # 挂前端单页应用
# 中间件:CORS、缓存控制、限流
app.add_middleware(LocalhostCORSMiddleware)
app.add_middleware(CacheControlMiddleware)
app.add_middleware(RateLimitMiddleware, ...)
读法:app.py 就像一张"总装配单"——把路由、中间件、静态文件、生命周期钩子拼到一个 FastAPI app 上。它自己不写任何业务,所有具体服务都通过 config.py(L04)注入进来。这种"装配与实现分离"让入口文件保持清爽、易读。
L04
config.py:整个 app_server 的控制中心
核心是一个 Pydantic 模型 AppServerConfig(config.py:191),字段分三类:基础配置、Injector(注入器)字段、Services。关键的是那一堆 Injector 字段:
class AppServerConfig(BaseModel):
# 基础
persistence_dir: str
web_url: str
# ★ 一堆"注入器"——每个都是 XxxServiceInjector | None
event: EventServiceInjector | None = None
sandbox: SandboxServiceInjector | None = None
app_conversation: AppConversationServiceInjector | None = None
user: UserServiceInjector | None = None
# ...
读法:config 里每个"能力"(事件/沙箱/会话/用户…)都是一个"注入器"字段,初始为 None。启动时
config_from_env() 根据环境变量,把每个 None 填上一个具体实现。这是"根据部署模式选实现"的中枢。什么是"注入器"?先建直觉
想象一个插座面板,上面标着"事件服务插槽""沙箱服务插槽"。config 就是这块面板,插槽默认空着。启动时,系统根据环境(你想用 Docker 还是远程?文件存储还是云存储?)往每个插槽插入对应的"实现模块"。业务代码要用某个能力,就从插槽取——不关心插的是哪个具体实现。这就是"依赖注入",下一课详解。
L05
依赖注入 Injector(本周最重要的设计)
🤔 痛点:一个"沙箱服务"要在两种完全不同的场景里用处理 HTTP 请求时,希望 FastAPI 自动把它塞进路由参数;可有些活(比如 Day 05 那个"启动会话后台继续跑")在后台任务里,没有 FastAPI 帮忙,得自己手动创建、用完手动关。同一个服务,两种取用姿势——难道要写两套构造/清理逻辑?
💡 本质:Injector = "统一的领用窗口"就像公司里不管你是走正式流程借设备、还是临时急用去仓库自取,最终都走同一个"领用窗口"登记发放。
Injector 用一个 inject() 方法当这个窗口,对外包出 depends()(给 FastAPI 用)和 context()(给后台任务 async with 用)两个门面——底层只写一遍。services/injector.py:12 定义了统一的 Injector 抽象,一份实现同时服务两种使用场景:
class Injector(Generic[T], ABC):
@abstractmethod
async def inject(self, state, request=None) -> AsyncGenerator[T, None]: ...
@contextlib.asynccontextmanager
async def context(self, state, request=None): # 用法1:后台任务 async with
async for result in self.inject(state, request): yield result
async def depends(self, request): # 用法2:FastAPI Depends 注入
async for result in self.inject(request.state, request): yield result
读法:一个
inject() 方法,包装出两种用法:context() 给后台任务用(async with),depends() 给 FastAPI 路由参数用(Depends(...))。无论从哪进来,拿到的都是同一个构造好的服务实例。为什么要"一份实现两种用法"? 因为服务在两种场景都要用:① 处理 HTTP 请求时(走 FastAPI 的 Depends 自动注入);② 在后台任务里(比如 Day 05 那个"启动会话后台消费剩余进度",得手动
async with)。统一抽象避免了写两套。state(请求级共享状态)还能让同一请求内的多个注入复用同一个 db 连接/http 客户端,不重复构造。L06
"改环境变量就换实现"的魔法
📝 如果让你自己写,你大概会这样最朴素的写法是哪要用就直接
sandbox = DockerSandbox() new 一个。问题:写死了 Docker。哪天要换远程沙箱,就得满代码库搜出每一处 DockerSandbox() 逐个改,漏一个就出 bug。真实版把"选哪个实现"收敛到一个地方,用 RUNTIME 开关决定 ↓看 config_from_env() 里选沙箱实现的真实代码(config.py:332):
if os.getenv('RUNTIME') == 'remote':
config.sandbox = RemoteSandboxServiceInjector(...) # 远程沙箱
elif os.getenv('RUNTIME') in ('local', 'process'):
config.sandbox = ProcessSandboxServiceInjector() # 本地进程
else:
config.sandbox = DockerSandboxServiceInjector(...) # 默认 Docker
图注:一个环境变量决定往"沙箱插槽"插哪种实现,业务代码不用动
读法:一个
RUNTIME 环境变量,决定往"沙箱插槽"插入哪种实现。业务代码里所有"要沙箱"的地方都用同一个接口 SandboxService,完全不知道背后是 Docker 还是远程还是本地进程——换实现只需改环境变量,业务代码一行不动。这个模式为什么强大?
同一份代码,本地开发用
沿用公司的比喻:依赖注入就像人事招聘——岗位说明书只写"要一名沙箱运维"(接口
💥 不这么做会出什么事故:如果到处
底层还用了
RUNTIME=local(快、不用 Docker),生产用 RUNTIME=docker(隔离),大规模用 RUNTIME=remote(远程集群)。事件存储同理:本地用文件、云上用 AWS S3/GCP。"面向接口编程 + 依赖注入"让一套代码适配所有部署形态——这和 eino 教程里反复强调的"接口与实现分离"是同一个道理,是所有大型系统的通用功夫。沿用公司的比喻:依赖注入就像人事招聘——岗位说明书只写"要一名沙箱运维"(接口
SandboxService),至于招正式工(docker)、临时工(local)还是外包(remote),由 HR 按当前预算/规模(环境变量)决定;用人的部门只管"我要个运维",不关心他的编制。💥 不这么做会出什么事故:如果到处
new DockerSandbox() 写死,某天要上远程集群,你得改几十处、且很容易漏改,测试环境和生产环境行为悄悄不一致——半夜排查这种"某处忘改"的 bug 最折磨人。底层还用了
DiscriminatedUnion,能直接从环境变量/JSON 反序列化出正确的实现子类。L07
三层 Service 结构(普遍套路)
app_server 里几乎每个能力都用同一种三层结构(以会话为例):
- 抽象接口:
AppConversationService(定义"该有哪些方法") - Mixin 基类:
AppConversationServiceBase(放公共能力,如 git 相关) - 具体实现:
LiveStatusAppConversationService(真正干活)
还有"模板方法"模式:比如事件存储基类
EventServiceBase 实现了 search/count/save 的编排逻辑,但把"具体怎么读一个文件 _load_event、怎么写 _store_event"留给子类(文件系统 / AWS / GCP)填。基类定框架、子类填细节——这样加一种新存储,只需实现三个原语方法,不用重写编排逻辑。这套"抽象接口 + 基类 + 实现 + 模板方法"是本周你会反复见到的骨架。L08
今日小结 + 动手
🧠 今天你应该能回答
- app_server 的职责边界?server 目录现在是什么?
- 三个主角模块是?(会话/事件/沙箱)
- app.py 干什么?(只做装配)
- Injector 依赖注入怎么做到"一份实现两种用法"?
- "改环境变量换实现"是怎么实现的?为什么强大?
✋ 动手
sed -n '1,90p' openhands/app_server/app.py # 应用装配
grep -n 'Injector' openhands/app_server/config.py | head -20 # 一堆注入器字段
grep -n "getenv('RUNTIME')\|SandboxServiceInjector" openhands/app_server/config.py
sed -n '12,40p' openhands/app_server/services/injector.py # Injector 抽象
明天预告 · Day 07:深入会话生命周期——从
start_app_conversation 那个 HTTP 请求,到 _start_app_conversation 的启动状态机全流程(拉沙箱→clone→建 Agent→READY),以及"生成器即进度流"的巧妙设计。