乐于分享
好东西不私藏

78K+ Star!OpenHands源码解析(二):它不是在新建一条记录

78K+ Star!OpenHands源码解析(二):它不是在新建一条记录

第一篇讲的是大地图:OpenHands 正在从“单体 AI 程序员”变成 Agent Canvas 控制中心。

第二篇就从一件最普通、也最容易被低估的事开始:发起一次对话。

在很多系统里,“创建对话”就是往数据库插一行记录。但在 OpenHands 里,它不是一行 DB 记录,而是一条启动流水线:

  • 找到或启动沙箱。
  • 校验 agent-server 版本。
  • 计算工作目录。
  • clone 或初始化仓库。
  • 抓初始 workspace 快照。
  • 跑 setup script。
  • 装 git hooks。
  • 加载 skills。
  • 构造请求,POST 到 agent-server。
  • 写入 conversation metadata。
  • 补默认 callback processor。
  • 处理启动过程中积压的 pending messages。

这就是为什么 OpenHands 没有把“创建对话”设计成一次同步 API,而是设计成一个可持久化、可流式返回、可失败落盘的 start task。

OpenHands 对话启动状态机

1. 先看三层模型

对话生命周期的第一层,是 AppConversationInfo

它只存“信息”,不存运行时状态。字段包括 conversation id、sandbox id、仓库、分支、git provider、标题、LLM model、agent kind、父子 conversation、tags、created_at、updated_at 等。

第二层,是 AppConversation

它继承 AppConversationInfo,再补上运行时字段:

  • sandbox_status
  • execution_status
  • conversation_url
  • session_api_key

这说明 OpenHands 把“可长期保存的元数据”和“当前运行态”分开了。前者属于 app conversation,后者要从 sandbox / agent-server 运行状态里拼出来。

第三层,是 AppConversationStartTask

它不是 conversation 本身,而是“启动过程”。模型注释说得很清楚:启动 app conversation 可能很慢,因为可能涉及启动沙箱,所以要 kick off background task。

它的字段也很像一个任务表:

  • status
  • detail
  • app_conversation_id
  • sandbox_id
  • agent_server_url
  • request

也就是说,一次对话还没出生前,OpenHands 已经先给它准备了一个“出生记录”。

2. 为什么启动是长任务

核心入口是 POST /app-conversations

router 做了一个很有意思的动作:它调用 service 的 async generator,先拿第一个 task 返回给客户端,然后用 asyncio.create_task 在后台继续消费剩下的状态更新。

关键结构是:

async_iter = app_conversation_service.start_app_conversation(start_request)result = await anext(async_iter)asyncio.create_task(_consume_remaining(async_iter, db_session, httpx_client))return result

这不是简单的异步包装。它表达的是一个产品语义:

用户点“开始”后,前端可以立刻拿到一个 start task id;后续启动过程继续跑,task 状态持续落库。

同时,OpenHands 还提供了 /stream-start。这个接口直接用 StreamingResponse 包住同一条启动 generator,把 task 更新作为 JSON list 一段段吐出来。

所以它同时支持两种体验:

  • 普通 start:先返回第一个 task,后台继续跑。
  • stream-start:连接保持打开,持续看到启动进度。

底层不是两套逻辑,而是同一个 service generator。

3. 8 个正常阶段,加一个 ERROR 兜底

AppConversationStartTaskStatus 里有 9 个枚举值:

WORKINGWAITING_FOR_SANDBOXPREPARING_REPOSITORYRUNNING_SETUP_SCRIPTSETTING_UP_GIT_HOOKSSETTING_UP_SKILLSSTARTING_CONVERSATIONREADYERROR

更准确地说,这是 8 个正常阶段,加一个 ERROR 兜底。

WORKING 是任务刚创建出来的初始态。

WAITING_FOR_SANDBOX 发生在找 sandbox、启动 sandbox、恢复 paused sandbox、等待 sandbox running 的阶段。

后面四个 setup 阶段集中在一条 pipeline 里:

  • PREPARING_REPOSITORY
    :clone 或初始化 git repo。
  • RUNNING_SETUP_SCRIPT
    :跑项目里的 setup script。
  • SETTING_UP_GIT_HOOKS
    :安装 git hooks。
  • SETTING_UP_SKILLS
    :通过 agent-server 加载并合并 skills。

这里还有一个细节:在 setup script 修改 workspace 之前,OpenHands 会尝试抓 initial workspace snapshot。它是 best-effort,不应该阻塞启动。

STARTING_CONVERSATION 是跨边界的那一步。service 构造完 StartConversationRequest 后,带 X-Session-API-Key POST 到:

{agent_server_url}/api/conversations

READY 发生在 agent-server 返回 conversation info 之后。OpenHands 写入自己的 AppConversationInfo,保证默认 SetTitleCallbackProcessor 存在,再把 task 填上 app_conversation_id

ERROR 则是最后的兜底。任何异常都会被捕获,错误详情会先做 secret / API key 脱敏,再写进 task detail。

这就是这张状态机的重点:启动过程不是“成功/失败”二值,而是每一步都能被观察、被落库、被恢复上下文。

4. 启动真正做了什么

把 service 主流程串起来看,OpenHands 在一次启动里做了十几件事。

第一,处理 parent conversation 和 suggested task。它允许子 conversation 继承父 conversation 的部分配置。

第二,启动或复用 sandbox。没有 sandbox id 时,先找当前用户 running sandbox;找不到再创建新 sandbox。已有 sandbox 处于 PAUSED 时会 resume。

第三,校验 agent-server 版本。自定义 sandbox image 可能带着不兼容的 SDK,所以 OpenHands 会先读 /server_info,失败时给更明确的错误提示。

第四,把用户的 LLM profiles seed 到 sandbox。SaaS 场景下 profiles 在 app-server,不在 sandbox 文件系统;会话创建前同步过去,agent 内置的 switch_llm 工具才能用。

第五,计算 working dir。这个路径后面会被写进 tags,删除归档时直接使用,避免配置变化导致归档路径漂移。

第六,跑 setup pipeline。这里包括 clone/init、initial archive、setup.sh、git hooks、skills。

第七,构造 agent-server start request。这里会把 LLM、secrets、MCP、skills、git provider、workspace 等能力拼成一次启动请求。

第八,POST 到 agent-server。请求头带 X-Session-API-Key

第九,保存 app conversation metadata。agent-server 返回 ConversationInfo 后,OpenHands 创建自己的 AppConversationInfo,记录 sandbox_id、user_id、llm_model、agent_kind、仓库、分支、provider、trigger、parent 等信息。

第十,注册 callback processor。即使请求里没有传 processors,也会默认补一个 SetTitleCallbackProcessor

第十一,处理 pending messages。如果用户在 sandbox / conversation 还没完全 ready 时已经发了消息,这些消息会在 READY 后被送进 agent-server。

这一串流程解释了为什么“启动即长任务”:它不是创建一条 conversation,而是在为一个外部执行环境准备完整上下文。

5. 删除不是 delete sandbox

启动像出生,删除也不是简单死亡。

OpenHands 的删除入口是 DELETE /app-conversations/{conversation_id}。router 先校验 conversation id,查 conversation info,拿到 sandbox_id,再统计有多少 conversation 共享这个 sandbox。

然后它调用 service 删除 conversation:

  • 先删 sub-conversations,尽量保持引用完整性。
  • 如果 sandbox 没有被共享,就调用 agent-server 删除 conversation。
  • 即使 agent-server delete 失败,也继续清 DB metadata。
  • 最后删除 app conversation info 和关联 start tasks。

router 在 DB commit 之后,才把 sandbox 收尾交给后台任务 _finalize_sandbox_delete

OpenHands 对话删除与归档收尾

这个 finalizer 的顺序很讲究:

  1. 先 archive_conversation_workspace
  2. 如果归档 REQUIRED 且失败,不删 sandbox,留给 idle reap 兜底。
  3. 如果归档成功或非 REQUIRED,再重新按 sandbox_id 统计 conversation 数量。
  4. 只有计数为 0,才 delete_sandbox

SandboxService 的抽象也把边界写死了:delete_sandbox 是 sandbox-scoped,只负责 stop/delete runtime;workspace capture 是另一个 conversation-scoped 步骤,要在 sandbox 被销毁前完成。

远端沙箱实现里同样强调:archive_conversation_workspace 是 app-server 显式删除路径下唯一的 capture path;按 conversation key 归档,避免 grouped sandbox 里的兄弟 conversation 相互覆盖。

归档实现还有一个工程细节:从 agent-server 的 GET /api/file/archive 拉 workspace 时,会 stream 到 tempfile,再上传到 object store,避免大 workspace 在 app-server 内存里整包展开。

所以删除链路的真实语义不是“把 sandbox 停掉”。它是:

先删 conversation 级数据,再在后台保护 workspace,最后在确认没有兄弟 conversation 引用时,才考虑删除 sandbox。

6. 亮点和隐患

亮点一:生命周期建模很完整。

OpenHands 没有把 conversation 当成一个表,而是拆成 info、runtime view、start task 三层。启动过程可持久化,启动进度可流式返回,失败原因会脱敏落盘。这个设计很适合“创建成本高、依赖外部运行时、可能部分失败”的系统。

亮点二:归档优先级高于销毁。

删除时,service 负责 conversation 数据清理;router finalizer 负责 workspace capture 和 sandbox teardown。归档 REQUIRED 失败时宁愿保留 sandbox,也不贸然删 runtime。这是很强的 durability 取舍。

亮点三:同一个 generator 同时服务普通 start 和 streaming start。

普通 start 先返回第一个 task,后台继续消费;stream-start 则把每个 task update 直接吐给客户端。状态源是一套,减少了两条启动链路分叉的风险。

隐患也很明显。

第一,live_status_app_conversation_service.py 已经有 2614 行。启动、恢复、删除、请求构造、密钥、MCP、skills、pending messages 都挤在同一个 service 里。它是这条链路的心脏,也已经有 God Service 的味道。

第二,启动链路跨了 router、service、sandbox、agent-server、workspace archive、event callback 多个边界。每一步都有必要,但错误定位会变难。好在 start task detail 做了脱敏落盘,否则排查体验会很差。

第三,删除收尾分在 service 和 router。service 删除 conversation 数据,router finalizer 归档并删 sandbox。这个边界有现实理由,因为 finalizer 要在响应后继续拿着 db/httpx 资源跑;但长期看,这种“业务删除在 service,资源收尾在 router”的分工需要很强的注释和测试保护。

结语:对话是一条生命周期,不是一条记录

读完这一段,再回看第一篇的控制面 / 数据面分离,就能解释很多设计选择。

OpenHands 的 app_server 不是把一个 Python 函数叫起来跑 agent。它要先准备沙箱,准备 workspace,准备技能,准备密钥边界,准备 agent-server 请求,再把这次运行交给数据面。

所以一次 conversation 的“生”,是一个长任务。

一次 conversation 的“死”,也不是 delete sandbox,而是 conversation 级数据清理、workspace 归档、引用计数、sandbox teardown 的组合动作。

问题继续往下走:

同一个会话启动后,docker、process、remote 三种沙箱后端,到底如何承担这个数据面?

相关阅读

(一)78K+ Star!OpenHands源码解析(一):它正在拆掉自己

源码参考:GitHub: https://github.com/OpenHands/OpenHands