Day 07 / 共 20 天 · 第 2 周 app_server 编排层

会话生命周期

昨天(Day 06)拿到了 app_server 的整体地图,今天走进第一个、也是最核心的部门——会话(conversation)。从数据模型到启动状态机,逐层读"一个会话如何被创建和启动",并见识"生成器即进度流"这个贯穿始终的巧妙设计。读懂了会话的诞生,明天(Day 08)的事件系统就是它运转时产生的血液。

📍 第 2 周 后端编排层 · 你在这里
Day6 app_server总览 Day7 会话生命周期 Day8 事件系统 Day9 Sandbox Day10 server装配
L01

三个会话数据模型

app_conversation_models.py 里有三个关键模型,分工明确:

  • AppConversationInfo:110):要落库的会话元数据(id、sandbox_id、repo、分支、llm_model、tags…),不含实时状态。
  • AppConversation:195):= Info + 实时字段(sandbox_status、execution_status、conversation_url、session_api_key)。运行时拼装,不落库
  • AppConversationStartRequest:221):启动参数(repo、branch、initial_message、llm_model、agent_type…)。
为什么要区分"落库的"和"实时的"? 这就像公司里"员工档案"和"员工此刻在忙啥"的区别:入职信息(姓名、部门、工号)写进人事档案永久保存(AppConversationInfo 落库);而"他现在是在工位、在开会还是请假了"(沙箱运行/暂停、Agent 跑到哪)是时刻在变的,你要看就去实时查,没人会把它写进档案——写进去下一秒就过期了。
会话有些信息是静态、需要永久保存的(它绑了哪个仓库、用哪个模型)——存数据库。有些是动态、时刻在变的(沙箱现在是运行还是暂停、Agent 现在跑到哪了)——这些去实时查沙箱,没必要也不应该存库(存了立刻就过期)。Info(存档)+ 实时字段拼成 AppConversation(完整视图)。把"持久事实"和"瞬时状态"分开,是数据建模的基本卫生。
L02

启动任务状态机

启动会话很慢(拉容器、clone、装环境要几十秒),所以用一个"启动任务"对象 AppConversationStartTask 承载进度,它是一个状态机(app_conversation_models.py:288):

WORKING WAITING_FOR_SANDBOX PREPARING_REPOSITORY RUNNING_SETUP_SCRIPT SETTING_UP_GIT_HOOKS SETTING_UP_SKILLS STARTING_CONVERSATION READY /ERROR
读法:每个状态就是"启动进度的一个阶段"。WAITING_FOR_SANDBOX=正在等沙箱起来,PREPARING_REPOSITORY=正在克隆仓库,STARTING_CONVERSATION=正在沙箱内创建会话,READY=可以聊了。任何一步出错就进 ERROR。前端按这些状态显示进度条文案(Day 05 见过)。
L03

"生成器即进度流"设计

🤔 痛点:启动要几十秒,用户干瞪眼怎么办?如果启动方法是"调用一次、卡住几十秒、最后才返回结果",那这几十秒里前端啥也拿不到,用户只能看着转圈,以为卡死了。可这几十秒里其实一直有进展(等沙箱、克隆中、装环境……),凭什么不能告诉用户?
💡 本质:生成器 = 入职进度实时播报就像新员工入职进度条:HR 不会等你"办完所有手续"才通知你一声,而是每办完一项(工牌好了、邮箱开了、电脑到了)就更新一下状态。异步生成器就是这个"每完成一步就播报一次"的机制——用 yield 吐出当前 task 的最新状态,前端一路看着进度走,而不是干等到最后。

启动方法的签名很特别(app_conversation_service.py:81)——它返回一个异步生成器,反复 yield 同一个 task 的最新状态:

async def start_app_conversation(
    self, start_request: AppConversationStartRequest
) -> AsyncGenerator[AppConversationStartTask, None]:
    # 每完成一步,就 yield 一次更新后的 task
    yield task  # WORKING
    # ...拉沙箱...
    yield task  # WAITING_FOR_SANDBOX
    # ...clone...
    yield task  # PREPARING_REPOSITORY
    # ...
    yield task  # READY
异步生成器:每完成一步 yield 一次,前端进度条同步前进 start_app_ conversation() yield WORKING yield WAITING_FOR_SANDBOX yield PREPARING_REPOSITORY yieldREADY 前端:会话已创建… 正在等沙箱… 正在克隆仓库… 可以聊了!
图注:慢启动被拆成一串 yield,前端每收到一个状态就更新一句进度文案——而不是干等到最后。
读法:不是"调用一次、等几十秒、返回最终结果",而是"每推进一步就吐一个中间状态"。调用方 for 循环消费,一路看到进度,直到 READY 或 ERROR。
生成器(generator)是什么?为什么适合这里? 普通函数 return 一次就结束。生成器用 yield"吐一个值、暂停、下次继续吐",像挤牙膏。这里完美契合"慢启动的多阶段进度"——每完成一个阶段 yield 一次,消费者立刻能拿到并显示,不用干等到最后。同一个生成器,既能被后台任务慢慢消费,也能被 StreamingResponse 包成流式 HTTP 响应直接吐给前端/stream-start 端点就这么干)。一份逻辑两种消费方式——优雅。
L04

路由:先返回、再后台继续

启动端点 start_app_conversationapp_conversation_router.py:364)有个巧妙处理:只取生成器的第一个值就返回,剩下的进度放后台消费:

set_db_session_keep_open(request.state, True)     # 请求返回后别关连接
set_httpx_client_keep_open(request.state, True)
async_iter = app_conversation_service.start_app_conversation(start_request)
result = await anext(async_iter)                  # 只取第一个(初始 task)立即返回
# ...
asyncio.create_task(_consume_remaining(async_iter, db_session, httpx_client))  # 剩余进度后台跑
读法:anext(async_iter) 取生成器第一个产出(初始 task)就 return 给前端——用户马上看到"会话已创建,正在启动",不用等几十秒的完整启动。后面的阶段用 asyncio.create_task 丢到后台继续跑(进度通过别的通道给前端)。
为什么要 keep_open 连接? 正常情况下,HTTP 请求返回后 FastAPI 会关闭这个请求的 db/http 连接。但这里后台任务还要继续用它们跑剩余启动流程,所以显式标记"别关",由后台任务用完再关。这是"HTTP 请求已响应、但工作在后台继续"这类场景的经典处理。

👶 小白:既然启动会经历那么多阶段,为什么只取第一个 anext 就 return,不干脆等全部 yield 完再回?

👨‍🏫 老师:因为等全部跑完要几十秒,用户会以为卡死了。就像去餐厅点单——服务员不会让你站在门口等菜全炒好才给你桌位,而是先领你入座(返回"会话已创建"),厨房继续做菜(后台跑剩余阶段),你在座位上看上菜进度。先给用户一个"已受理"的确定反馈,慢活丢后台,是所有"长耗时操作"的通用体验设计。

L05

启动核心全流程 _start_app_conversation

真正的启动逻辑在 live_status_app_conversation_service.py:361_start_app_conversation——会话逻辑的心脏。串起来就是 Day 05 那条旅程:

# 概念流程(每步 yield 一个状态)
# 1. 取 user_id;有 parent_conversation_id 则继承父会话配置(复用同一沙箱/repo/model)
# 2. _wait_for_sandbox_start:找一个在跑的沙箱,或 start_sandbox 新建,
#                             轮询 wait_for_sandbox_running 直到容器 RUNNING 且 agent-server /alive
# 3. _seed_sandbox_profiles:把用户的 LLM profiles 同步进沙箱
# 4. 算 working_dir → 建 AsyncRemoteWorkspace
# 5. run_setup_scripts:clone 仓库、跑 setup.sh、装 git hooks、加载 skills
# 6. 构建 Agent(见 L06)
# 7. POST {agent_server_url}/api/conversations 在沙箱里真正创建会话
# 8. 落库 AppConversationInfo
# 9. 注册事件回调(总是确保有 SetTitleCallbackProcessor 自动生成标题)
# 10. 状态置 READY;处理排队的 pending 消息
# 异常 → 状态置 ERROR,detail 经 redact_text_secrets 脱敏
读法:这十步就是 Day 05 旅程的代码版。重点体会几个细节:② 会复用已有沙箱(省资源);② 轮询等 agent-server /alive(避免"容器起了但服务没就绪"的竞态);⑨ 总是挂一个自动生成标题的回调;异常时错误信息脱敏redact_text_secrets,防止密钥泄漏到日志)。
L06

构建 Agent(第6步细看)

_build_start_conversation_request_for_user:1610)组装出要交给 agent-server 的 Agent 配置:

if agent_type == AgentType.PLAN:
    tools = get_planning_tools(plan_path=plan_path)     # 规划型 Agent
else:
    register_builtins_agents(enable_browser=True)
    tools = get_default_tools(enable_browser=True, enable_sub_agents=...)  # 默认工具集
configured_agent_settings = user.agent_settings.model_copy(update={
    'llm': llm, 'tools': tools, 'mcp_config': mcp_config or {},
    'agent_context': AgentContext(system_message_suffix=..., secrets=secrets)})
agent = configured_agent_settings.create_agent()
读法:根据 agent_type 选工具集(规划型 vs 默认),再把 LLM、工具、MCP 配置、密钥组装进 agent 设置,最后 create_agent()这个 agent 配置会随"创建会话"请求送进沙箱里的 agent-server,由它真正实例化并运行。可选附加 SwitchLLMTool(多模型时让 Agent 自己切换模型)。
LLM 配置在 _configure_llm:1215),强制 stream=True(流式,Day 03 的打字机效果靠它)、usage_id='agent'(用量归属)。注意:app_server 只是"组装并转交"Agent 配置,真正的 Agent 循环在沙箱里跑——再次印证管家/工人分工。
L07

薄代理与删除

会话建好后,很多操作(发消息、看 diff、下载文件)前端无法直连沙箱,就由 app_server 代理转发。这些端点刻意做成"薄代理"——只转发、不加逻辑:

# send-message 端点(router:441)文档原话:convenience wrapper,不做额外处理
# 把消息直接转发到沙箱 agent-server 的 /api/conversations/{id}/events
为什么发消息要做成"薄代理",不加业务逻辑? 因为前端有两条路能给 Agent 发消息:① 直连沙箱 agent-server;② 走 app_server 代理。如果代理这条路偷偷加了额外处理,两条路的行为就不一致了,埋下诡异 bug。所以 OpenHands 定了"薄代理原则":代理端点只转发,任何自定义业务逻辑统一走 webhook 回调(Day 08)。保证多条路径行为一致,是分布式系统避免玄学 bug 的纪律。
删除会话DELETE /{id}router:928):删会话记录;如果这个沙箱没有别的会话在用了,后台任务 _finalize_sandbox_delete归档 workspace(保留你的工作成果)再删容器。所有代理端点都靠 _get_agent_server_context:134)解析出"会话→沙箱→暴露URL→agent_server_url"这条链路,再转发。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 三个会话模型的分工?为什么区分落库/实时?
  • 启动状态机有哪些阶段?为什么需要它?
  • "生成器即进度流"是什么?好在哪?
  • 路由为什么"先返回、后台继续"?keep_open 为什么?
  • 薄代理原则是什么?为什么删会话要先归档 workspace?

✋ 动手

grep -n 'class AppConversationInfo\|class AppConversation\|StartTaskStatus' openhands/app_server/app_conversation/app_conversation_models.py
grep -n 'def start_app_conversation\|anext\|create_task' openhands/app_server/app_conversation/app_conversation_router.py
grep -n 'def _start_app_conversation\|_wait_for_sandbox_start\|redact_text_secrets' openhands/app_server/app_conversation/live_status_app_conversation_service.py
明天预告 · Day 08事件系统源码——app_server 侧的事件是"REST 只读 + webhook 入站 + 文件存储",为什么实时推送反而不在这(而靠前端直连沙箱)。读 webhook 回调闭环和模板方法式的存储设计。
← Day 06 总览 Day 08 · 事件系统 →