OpenHands 这套项目,前几篇拆了事件模型、Agent 循环和工具调用,今天这篇轮到服务。为什么把服务放到第四篇才讲?因为如果一上来就看 API 路由,很容易觉得它就是一个普通的 Web 后端;但真正让 OpenHands 跑出 agentic coding 效果的,是它背后那组服务化边界:接入服务、编排服务、执行服务,以及夹在它们之间的事件流通道。理解了这层结构,本地二次开发、接入自己的模型服务、甚至把 OpenHands 改造成公司内部编码平台,都有了落脚点。适合谁看?想把 OpenHands 当工程样板抄、想知道源码从哪里读、以及正在自己设计 AI Coding 后端的人,这篇应该能给你一点参考。
1. 先分清三个"服务":接入、编排与执行
1.1 概念陷阱:它不像传统微服务
很多人一听到"服务"两个字,第一反应是微服务架构,想到一堆独立部署的进程、注册中心、网关、服务间 REST 调用。OpenHands 给我的第一印象是:它并没有走那条路,至少当前开源版本不是。
它更像一个"模块化单体 + 可选远程执行节点"的混合结构。核心服务都在同一个 Python 进程里被装配起来,通过事件流通信,而不是通过一堆 HTTP 接口互相调用。这个差异非常重要,因为你读源码时如果下意识找 service-to-service 的 RPC 定义,大概率会卡住。
我理解 OpenHands 里的"服务"有三种角色:
- 接入服务:面向浏览器和 CLI,管 WebSocket、会话创建、事件推送。
- 编排服务:面向 Agent 循环,管决策、状态、下一步动作。
- 执行服务:面向沙箱,管跑命令、读写文件、操作浏览器和解释器。
这三种角色不是三个进程,更多是三个职责边界。边界划清楚了,后面无论是改成独立服务,还是继续维持单体,都有余地。
1.2 三个角色各自守住的边界
接入服务最典型的入口是 openhands/server 下面的 FastAPI 应用。你打开路由注册文件,会看到一堆 conversation、session 相关的接口。它做的事情很聚焦:把前端发送的用户消息包装成事件,写进事件流;再把事件流里产生的新事件推回给前端。它不应该自己跑 Agent 循环,也不应该直接去 Docker 容器里执行命令。
编排服务的核心是 Agent Controller。它监听事件流里的状态变化,调用 LLM,拿到决策结果后生成 Action 事件。OpenHands 的 Agent 本身可以是不同策略,比如 CodeActAgent、通用对话 Agent、带视觉模型的 Agent,但无论哪种,它们输出的都是结构化 Action,而不是直接操作系统。
执行服务是 Runtime。它负责把 Action 翻译成真实世界的效果。CmdRunAction 到 Shell 命令,FileEditAction 到文件系统修改,BrowserAction 到浏览器控制。Runtime 把执行结果打包成 Observation 事件,写回事件流。到这里,一轮 Agent 循环才算闭合。
1.3 一张表看懂服务拓扑
| 角色 | 典型模块 | 对外表现 | 容易误解的点 |
|---|---|---|---|
| 接入服务 | server / API / WebSocket | 提供 HTTP 和长连接入口 | 它不是业务逻辑本体 |
| 编排服务 | Agent Controller / Session | 消费事件、产出 Action | 它不负责执行具体动作 |
| 执行服务 | Docker Runtime / Remote Runtime | 在隔离环境里执行动作 | 它不决定 Agent 下一步做什么 |
| 事件通道 | EventStream | 串起所有角色的消息管道 | 它不是数据库,但承担了持久化事件的责任 |
| 外部能力 | LLM / MCP / storage | 模型调用、工具服务、文件存储 | 它们和核心服务之间要保持接口边界 |
这张表值得打印出来贴在显示器旁边。因为后面很多排查问题,最后都能归结成:某个事件被某个服务重复消费了,或者某个服务阻塞了事件流。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次会话请求在服务层里怎么流动
2.1 创建会话:把事件流、运行时和 Agent 绑到一个 ID 上
我最初读代码时有个误解,以为创建一个会话就是 new 一个 Agent 出来。实际看下来并不是。
创建会话是一整套装配动作。OpenHands 需要用同一个 session_id 把以下几样东西绑在一起:
- 一个事件流实例,负责记录和分发本次会话的所有事件。
- 一个 Agent 实例,负责决策。
- 一个 Runtime 实例,负责执行。
- 一份存储配置,负责持久化会话、文件、历史消息。
- 一组模型配置和工具配置。
这才是服务层最有价值的部分。它不是简单地把对象 new 出来,而是把不同生命周期的东西统一挂到同一个会话上下文中。这个上下文既是隔离边界,也是故障恢复单元。
2.2 消息进来后,服务之间的协作顺序
一次典型的用户消息,在服务层里大概是这个顺序:
- 用户通过 WebSocket 把消息发给接入服务。
- 接入服务把用户消息转成
MessageAction写入事件流。 - Agent Controller 订阅到新事件,把事件上下文交给 LLM。
- LLM 返回决策,Agent 生成
CmdRunAction或FileEditAction。 - 事件流把 Action 分发给 Runtime。
- Runtime 在沙箱里执行命令,生成
CmdOutputObservation。 - 事件流把 Observation 写回,同时推送给前端。
- Agent Controller 看到 Observation 后决定是继续执行还是结束本轮。
这个链路没有中心化的调度器,每个服务都是事件流的订阅者。好处是职责清晰,坏处是排查问题时必须对事件流有整体认识,否则会像没头苍蝇一样到处翻日志。
2.3 事件驱动带来的回放和审计红利
事件驱动服务之间的通信,不只是为了解耦,还带来一个很实际的收益:会话回放。
OpenHands 的调试过程本质上是 Action 和 Observation 的交替序列。你把事件流完整存下来,就等于录下了 Agent 所有操作过程。之前在源码里看到类似事件回放的设计,第一反应是这是为了断点续跑;后来发现它更大的价值是审计和评估,尤其是做 Agent 评测的时候,你不需要重新跑一遍模型,只需要把历史事件喂回去,就能分析某一步为什么出错。
所以我的建议是:不要只把 EventStream 当成内存消息队列,要把它当成核心业务数据。文件也好、数据库也好、消息中间件也好,事件必须可靠落盘,否则整个系统就失去了可观测性的地基。
3. 沙箱 Runtime 才是服务层的重头戏
3.1 本地 Docker Runtime 的启动链路
前面说的接入服务和编排服务,理解起来相对容易,真正复杂的是 Runtime。OpenHands 里默认的 Runtime 是基于 Docker 的沙箱环境。它的启动链路不是你 docker run 一个容器那么粗暴。
本地 Docker Runtime 启动时大概要做这几件事:
- 拉取预置的 runtime 镜像,镜像里带了 Shell、Python、文件操作工具等基础能力。
- 为当前 session 创建容器,容器命名和 session 关联。
- 配置工作目录、环境变量、网络策略。
- 建立起一个可供 Agent 操作的文件系统视图。
- 初始化 Action 到容器内命令的翻译通道。
这个过程中最容易被忽略的是:Runtime 并不仅仅执行 bash -c,它还处理文件读写、代码编辑器操作、Jupyter 执行、浏览器操作等。你把 Runtime 理解成一个"操作系统代理人"更准确。
3.2 远程 Runtime 解决什么问题
本地 Docker Runtime 用起来直观,但有一个硬伤:你必须有一个能跑 Docker 的环境。对于个人开发没问题,但对于团队产品或线上服务,不能让每个用户都去控制宿主机上的 Docker,于是远程 Runtime 就变得很有必要。
远程 Runtime 本质上把执行环境服务化了。Agent 服务和 Runtime 服务可以不在同一台机器上,一个 Agent 后端可以对接多个远程沙箱节点,按会话去分配资源。这种结构也给了你很大的想象空间:可以按用户隔离资源、按任务分配 GPU、按负载动态扩容执行节点。
它的代价也很明显:网络延迟、沙箱启动时间、文件同步、日志聚合都成了新的问题。你在本地跑 OpenHands 时感觉不到这些,一旦拆成远程 Runtime 就会发现,沙箱启动和文件同步才是性能瓶颈。
3.3 在沙箱服务上我踩过的三个坑
先说第一个坑:容器启动慢导致会话创建超时。OpenHands 创建会话时如果 Runtime 还在拉镜像或者容器还在启动,前端很容易长时间转圈。我的处理办法是在接入层增加 readiness 状态,只有当 Runtime 报告容器就绪后,才把会话标记为可用。这是一个很朴素的健康检查,但能省掉大量"为什么卡死"的排查时间。
第二个坑:镜像版本不一致导致行为诡异。OpenHands 的 runtime 镜像和 Agent 端工具定义是有契约的,镜像太旧,某些新工具可能不可用;Agent 端太旧,镜像里的新能力也发挥不出来。后来我固定了镜像 digest,而不是用 latest 标签,才把这种偶发问题压下来。
第三个坑:沙箱资源没回收。长时间运行的会话会产生容器、文件系统快照、临时文件。如果只在会话结束做清理,崩溃的会话就会留下垃圾。我的做法是加了一个定时任务,扫描超过 N 小时没有心跳的 runtime 容器并回收。看起来是运维问题,但在服务层不处理,最后就是生产事故。
4. 模型服务和工具服务的接入边界
4.1 LLM 网关:统一模型入口比想象中重要
OpenHands 对接 LLM 的部分,核心诉求不是把请求发给 OpenAI 或者 Anthropic 就完了,而是要处理一大堆工程问题:不同厂商的 API 格式差异、流式输出、token 统计、超时重试、并发控制、模型名映射。
OpenHands 在底层依赖了类似 LiteLLM 的封装能力,让上层 Agent 不需要关心模型来自哪家服务商。你可以在配置里指定一个模型名和 base_url,它负责把请求转换成目标服务需要的格式。
我为什么觉得这个设计重要?因为当你把 OpenHands 接入公司内部的模型服务时,基本不需要改 Agent 代码,只需要改配置。你甚至可以在同一个会话里切模型,只要你的模型网关支持。这就是服务边界的价值:模型服务是插拔的,不是焊死的。
4.2 MCP 工具服务的注册与调用
MCP 这两年被聊得很多,OpenHands 也把 MCP 作为一种工具服务接入方式。
按照我的理解,MCP 在 OpenHands 服务层里的位置,可以类比成"工具服务的统一插槽"。通过 MCP 配置,可以挂文件系统、数据库、浏览器、企业内部系统。Agent 生成工具调用请求后,由 MCP 客户端转发给对应的 MCP 服务端执行,再把结果封装成 Observation 拿回来。
这个设计最大的好处,是让核心 Agent 和具体工具解耦。你的 Agent 不需要知道数据库连接串、不需要知道内部系统的 API 细节,只需要知道"有哪些工具、每个工具需要什么参数"。这非常符合服务化的思路:能力通过接口暴露,实现细节藏在服务后面。
4.3 服务治理:重试、超时与幂等
无论是模型服务还是 MCP 工具服务,外部调用总有失败的时候。OpenHands 这类系统最容易出的问题,不是调用失败,而是失败后状态不一致。
我做接入时给自己定了几条规矩:
- 模型请求要区分"可重试错误"和"不可重试错误"。限流、5xx 可以重试,参数错误不要重试。
- 工具服务要尽可能做幂等。同一个工具调用被重放两次,结果不能是双倍扣款、双倍创建订单这类效果。
- 超时时间不能一刀切。LLM 流式响应和文件读写操作的超时阈值完全不同,睡倒在事件流里比调超时更危险。
这些内容在官方文档里不一定写得很细,但做工程的人都知道,服务化的难点从来不是把功能拆出去,而是拆出去之后怎么保证可靠性。
5. 动手改服务层,从哪里切入最有效率
5.1 读代码的顺序比代码本身更重要
如果只看一个点,我建议先读 EventStream,再读 Runtime,最后读 Agent Controller。
理由很简单:EventStream 定义了服务之间传递的数据格式,数据格式定了,其他模块都围绕它转。Runtime 可以让你看到"真实世界"的边界在哪里,哪些动作需要沙箱、哪些动作是纯逻辑。最后读 Agent Controller,你会发现很多决策逻辑其实不复杂,复杂的是把 Action、Observation、上下文、模型调用串起来的状态管理。
很多初学者喜欢从 API 路由开始读,我当时也这么干过,结果读了三天下载下来的还是一堆路由文件,对系统怎么运行完全没有体感。路径一旦反了,学习成本直接翻倍。
5.2 用一个假 Runtime 绕开沙箱依赖
如果你想在 OpenHands 上做二次开发,又不想每次都启动 Docker 沙箱,最快的验证方式是实现一个假 Runtime。它的逻辑很少:
python复制class FakeRuntime(Runtime):
def run_action(self, action):
if isinstance(action, CmdRunAction):
return CmdOutputObservation(
content="fake result",
command_id=action.command_id,
exit_code=0,
)
return Observation("unsupported")
这个假 Runtime 的价值,不是让你伪造结果,而是让你在做 Agent 策略实验时,能快速跑通整个事件流链路,不被沙箱环境干扰。等链路逻辑验证完了,再切回真实 Docker Runtime 做集成测试,效率会高很多。
5.3 自定义服务需要守住的几条事件纪律
给 OpenHands 加自定义服务时,有几点我建议无论如何都要守住:
- 事件必须是可序列化的数据,不要在事件里塞数据库连接、文件句柄这类不能跨进程传递的对象。
- 不要在事件流处理器里做同步阻塞的网络请求,否则会把整个会话拖垮。
- 每个事件要有独立 ID 和明确的产生方、消费方,避免事件被重复处理。
- 新增服务时先定义好它需要消费哪些事件、产出哪些事件,不要先写实现再补协议。
这几条看着像常识,但实际改的时候很容易破戒。尤其是事件流处理器里顺手加一个 HTTP 请求,当时觉得没事,等并发一上来立刻变成事故现场。
5.4 我最常用的三条健康检查命令
如果你和我一样习惯本地起个 OpenHands 环境,下面三条命令在排查服务层问题时非常有用,前提是端口按你的实际配置调整:
bash复制# 看 API 服务是否响应
curl -s http://127.0.0.1:3000/api/health
# 看运行时容器是否真的拉起来了
docker ps --filter "name=openhands"
# 看事件流相关进程是否在写日志
tail -f /tmp/openhands.log | grep "EventStream"
第三条是我自认为最实用的一条。很多"服务没反应"的问题,最后都指向同一个答案:事件流没有新事件产生。也就是说,不是前端坏了,也不是后端挂了,而是某个环节根本没有把 Action 或者 Observation 写回去。这时候与其猜测,不如盯住事件流日志,顺着时间线看事件断在哪里。
如果你正打算在自己的项目里复刻类似架构,我的建议是先别急着把所有组件拆成独立微服务。先把事件流、Runtime 边界、模型网关这三件事定好,后续就算单体真的撑不住了,拆出去也只是把边界变成 RPC 的问题。服务化不是目的,服务边界才是。OpenHands 值得学的,恰恰就是这条边界。
