第一篇讲的是大地图: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。

1. 先看三层模型
对话生命周期的第一层,是 AppConversationInfo。
它只存“信息”,不存运行时状态。字段包括 conversation id、sandbox id、仓库、分支、git provider、标题、LLM model、agent kind、父子 conversation、tags、created_at、updated_at 等。
第二层,是 AppConversation。
它继承 AppConversationInfo,再补上运行时字段:
sandbox_statusexecution_statusconversation_urlsession_api_key
这说明 OpenHands 把“可长期保存的元数据”和“当前运行态”分开了。前者属于 app conversation,后者要从 sandbox / agent-server 运行状态里拼出来。
第三层,是 AppConversationStartTask。
它不是 conversation 本身,而是“启动过程”。模型注释说得很清楚:启动 app conversation 可能很慢,因为可能涉及启动沙箱,所以要 kick off background task。
它的字段也很像一个任务表:
statusdetailapp_conversation_idsandbox_idagent_server_urlrequest
也就是说,一次对话还没出生前,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/conversationsREADY 发生在 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。

这个 finalizer 的顺序很讲究:
先 archive_conversation_workspace。如果归档 REQUIRED 且失败,不删 sandbox,留给 idle reap 兜底。 如果归档成功或非 REQUIRED,再重新按 sandbox_id 统计 conversation 数量。 只有计数为 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
夜雨聆风