尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

OpenHands服务化架构拆解:事件流、Agent与沙箱协作机制

OpenHands服务化架构拆解:事件流、Agent与沙箱协作机制 OpenHands原OpenDevin是我见过的所有开源AI编程智能体里把“服务”概念贯彻得最彻底的一个。它不是一个大而全的单体应用而是一组可以独立理解、独立替换、独立通信的组件外部请求走API服务内部状态靠事件流服务调度任务执行交给Agent控制器真正跑代码的地方又是一个隔离的沙箱Runtime服务再加上LLM网关、MCP工具接入服务前端和这些服务的交互方式也很有讲究。这篇拆解就围绕“服务”两个字展开我会把每个核心服务是什么、为什么存在、彼此怎么通信、如何做二次扩展讲清楚。适合两种人看一种是想跑通OpenHands、理解它内部协作机制的开发者另一种是准备基于OpenHands做二次开发或接入自研工具链的同学。1. 为什么把 OpenHands 看作一套“服务群”1.1 从单体设计到服务化设计的必然演进传统单体应用里一个进程包揽一切接请求、处理逻辑、写数据库、调模型全在一个进程里。OpenHands早期雏形也走过这个阶段但功能一多问题马上暴露沙箱一崩整个服务跟着挂模型调用一阻塞页面直接卡死想单独换一个组件几乎等于重写。所以在中期重构里它把所有核心组件做成了边界明确的“层”和“服务”。从代码仓库目录结构就能直观看到这种服务化思路openhands/agent、openhands/controller、openhands/runtime、openhands/server、openhands/events、openhands/llm每个目录对应一个独立职责域。虽然默认情况下这些模块在同一个Python进程里被组织起来但在架构概念上它们完全是独立服务。我这里说的“服务”你可以先按“职责边界清晰、可被独立替换的模块”来理解不必默认它必须拆成多个进程跑。OpenHands默认是单进程多模块但模块边界足够清楚你完全可以把Runtime、LLM网关、Embedding之类的组件拆出去单独部署。服务化带来的第一个直接好处是故障隔离。一个模块出错不会让整个系统不可用至少日志能明确告诉你问题在哪一层。第二个好处是扩展灵活想换沙箱实现、换模型供应商或者接入公司内部的代码检索工具都只需要替换对应的服务模块而不是改一堆纠缠不清的代码。1.2 服务边界怎么划谁的活谁说了算划分服务边界这件事OpenHands有一个简单标准如果某个模块的输入输出可以在不依赖其他模块细节的情况下讲清楚它就可以被当作一个独立服务。事件流服务只负责事件的写入和分发它不关心某个动作是Python代码还是浏览器操作Agent服务只负责决策它不关心代码最终是在Docker容器还是本机跑的Runtime服务只负责执行它不关心上层是哪个模型在指挥。这种边界带来的价值是替换成本极低。想换沙箱实现只要实现Runtime接口其他模块完全感知不到想接入一个外部工具通过MCP服务暴露给Agent即可Agent不需要知道工具背后的实现细节。这种设计思想在大型项目里很常见但能在一个开源智能体项目里落实得这么干净是不多见的。我自己的体会是用“服务”视角拆解还有一个很实际的用处排查问题快。当你跑一个复杂任务Agent中途卡住时如果能清晰定位是卡在API层、事件流、Agent决策还是沙箱执行调试效率会高非常多。我见过不少人问“为什么OpenHands没有反应”实际上一看日志事件流正常、LLM调用正常只是沙箱里pip安装超时了。这种问题从服务视角看一秒就能定位。1.3 服务化的收益与代价凡事有得有失。服务化拆得细意味着追踪一条完整请求链路时需要跨多个模块看日志如果做进程级拆分还会引入网络通信和状态同步问题。OpenHands默认把核心服务放在一个进程里只有可选的组件比如MCP服务、独立的LLM网关才以子进程或外部服务方式运行。我的建议是先按它默认的方式理解等真正有需要时再去拆不要一上来就把它分布式化那是给自己找麻烦。2. 核心服务逐一拆解从入口到执行环境2.1 API服务所有外部请求的必经入口OpenHands使用FastAPI构建API服务对外提供两种核心能力REST接口和WebSocket长连接。REST接口负责会话生命周期管理比如创建会话、发送用户消息、获取历史事件、删除会话WebSocket负责事件流实时推送。前端页面一打开就会建立WebSocket连接Agent后续产生的所有事件都会通过这个通道推送到浏览器。启动API服务的入口在openhands/server目录核心文件是app.py它负责初始化FastAPI应用、注册路由、加载配置。启动方式通常有两种直接用官方Docker镜像跑或者本地开发环境用uvicorn启动。本地调试时我常用这种命令poetry run uvicorn openhands.server.app:app --reload --port 3000这里有一个坑OpenHands的配置是config.toml加上环境变量组合出来的直接裸启动会因为没有配置LLM密钥而报错。我建议第一次跑先看一眼docs目录里的配置说明把LLM_API_KEY、工作目录这些核心项配置好再启动。API层设计里值得学习的点在于它会校验入站事件并做基本格式转换不会直接把前端传来的任意数据透传给Agent。恶意构造的伪Action想混进会话至少需要绕过这一层校验。虽然它的防护能力不像专业安全网关那么重但入口收敛的思路是对的。2.2 事件流服务贯穿全局的血循环系统如果只挑一个模块来读我一定推荐openhands/events里的EventStream。它能被当成“服务”是因为它提供了统一的事件写入、订阅和回放接口几乎所有模块都在和它交互。Agent产生了Action写入EventStreamRuntime执行完产生Observation写入EventStream前端订阅了事件流就能按时间顺序看到完整操作过程。事件流的核心数据结构是Event分成Action和Observation两大类。Action是Agent主动发出的动作意图Observation是执行环境对这些动作的反馈。所有事件都带会话ID、时间戳、来源等元信息方便追踪。EventStream会把这些事件序列化后存储到会话目录下的json文件或数据库中形成一条可回放的“时间线”。排查问题时我强烈建议先把事件文件拉出来看一遍。比如tail -n 100 ~/.openhands/sessions/会话ID/events.jsonl这比看任何架构图都直观。你能看到用户消息在哪个时间点进入系统Agent在哪个时间点生成了什么Action沙箱又用了多久返回Observation。卡在哪个环节一目了然。这套机制是OpenHands调试体验的根基。2.3 Agent控制器与Agent服务决策核心AgentController是连接事件流和Agent的枢纽。它监听事件流里的用户消息把它们转成Agent的输入状态然后调用Agent的step()方法生成动作再把动作写成Action事件。说它是“控制器”是因为它还承担了会话级别的调度逻辑记录Agent历史决策、维护对话状态、处理终止条件。Agent本身是一个接口OpenHands提供多个实现。最常见的是CodeActAgent它把所有动作统一表达为Python代码执行命令就是cmdrun写文件就是file_writer访问网页就是browser操作。还有SWEAgent等为特定场景设计的Agent。它们的关系有点像大脑的多个功能分工控制器负责维持基础循环Agent负责具体思考决策。这种分层让不同Agent可以复用同一套事件流和Runtime你完全可以写一个自己的Agent挂到同一个控制循环里。2.4 Runtime沙箱服务安全的执行环境Runtime是OpenHands面对真实世界的“手”。Agent生成的Action最终要在这里执行而执行是有风险的所以必须放在隔离环境里。默认实现基于Docker一个会话对应一个容器容器内预装了Python、Node.js、Git等常见开发工具还有一个内置的Jupyter服务用于承担代码执行。Runtime向Controller暴露的是统一接口核心是run_action()。Controller把某个Python代码块传给RuntimeRuntime负责在沙箱里创建临时脚本或直连Jupyter内核执行然后返回标准输出、错误信息、退出码。对Controller来说它完全不关心容器内部细节只需要拿到结构化的Observation。如果你不想用Docker可以切换到本地Runtime但这样会牺牲隔离性不适合多人共享环境。还有一种云端Runtime把沙箱托管在远端适合本地容器能力受限的场景。沙箱执行是整个链路里最容易出问题的一环后面我会列几个高频坑。2.5 LLM服务与MCP服务外部能力的接入层LLM服务负责统一所有模型接入。OpenHands底层用了类似LiteLLM的封装所以同一套代码可以切换OpenAI、Anthropic、Gemini以及本地Ollama、vLLM等模型。配置里只需要指定model名称和API密钥LLM服务会负责请求封装、重试、流式解析。这一层对开发者来说很省心不用为每个模型供应商写一套对接代码。MCPModel Context Protocol服务则是OpenHands接入外部工具的关键。它让Agent可以通过标准协议调用任意暴露了MCP接口的工具数据库查询、GitHub操作、内部API网关等。MCP服务可以是一个本地子进程也可以是一个远程HTTP端点。配置文件中常见这样一个片段[mcp.servers.my-db] command python args [path/to/db_server.py] env { API_KEY xxx }配置好之后Agent的上下文里就会多出这个工具它可以自主决定何时调用。对于想把OpenHands接进自己公司技术栈的人来说MCP这一步是重点。3. 服务之间怎么通信事件驱动与协议分工3.1 事件流是核心总线不是普通数据库很多第一次读代码的人会疑惑EventStream既负责存储又负责分发它和数据库、消息队列有什么区别我的理解是OpenHands刻意让所有状态变化都表达为一个事件这样既保留了完整的历史记录又让订阅者只需关心增量变化。它更像一个“可回放的消息总线”而不是传统的数据表存储。这种设计带来的优势是你可以把一整个会话的运行过程当录像看任何一步出问题都能从头回放。这也是为什么调试OpenHands比调试传统CRUD系统直观得多。代价是事件量大的时候存储会成为瓶颈所以OpenHands也提供了把事件写入后端数据库的选项以应对长时间、大体量的会话。3.2 REST、WebSocket、内部调用各司其职从通信方式上看OpenHands大致分成三层。REST是控制面。创建会话、查询会话列表、发送用户消息这些低频、一次性的操作走REST简单直接。WebSocket是数据面。事件流实时推送走WebSocket前端和Agent之间是长连接关系避免频繁轮询制造无谓开销。内部直接调用则发生在同一个进程内Controller直接调用Agent和Runtime的接口不走网络协议。这种分工和微服务架构里的“控制面/数据面分离”思路一致控制命令可以短而快状态数据需要长连接持续推送。如果你自己设计一个类似的Agent系统我建议直接照搬这个模式它会省掉很多关于“事件推送怎么实现”的纠结。3.3 服务依赖关系谁在调用谁如果用文字描述依赖关系大致是这样前端依赖API服务和WebSocketAPI服务依赖Controller的会话管理Controller依赖Agent生成决策、依赖Runtime执行动作、依赖EventStream记录事件Agent依赖LLM服务生成推理结果、依赖MCP服务获取外部工具能力。有一个容易被忽略的点Controller和Agent之间并不是一对一的关系。一个会话可以创建多个Agent来轮流处理任务Controller负责把它们串联起来。这种设计在实现复杂多角色协作时会很有用你也可以理解为“单控制器驱动多决策者”的架构模式。再往外延展Runtime可以跨会话复用也可以每个会话新建这取决于你的资源预算和隔离级别需求。4. 一次真实任务的服务链路拆解4.1 从用户输入到事件流入口链路举个例子用户在Web界面输入“写一个Python脚本计算斐波那契数列并运行它”然后点击发送。前端先通过REST接口把消息提交到API服务API服务将其包装成UserMessageAction写入EventStream然后返回给前端一个事件ID。这时AgentController已经通过订阅感知到了新事件开始准备处理。这个链路看起来简单但每一步都有校验和格式转换。API服务不会直接把用户字符串塞给Agent而是先转成标准事件对象再交给事件流。这样Controller和Agent读到的永远是同一套数据格式不会出现前端字段和后端逻辑对不上的情况。4.2 决策、执行与反馈的完整闭环Controller把新事件和当前对话历史一起组成State传给CodeActAgent的step()方法。Agent把目标写入提示词请求LLM服务生成一段可执行代码。LLM可能返回类似“我先创建一个fib.py文件然后运行它”的回复Agent据此生成FileWriteAction和CmdRunAction写入事件流。Runtime从事件流里读到这两个Action之后在Docker容器里依次执行先写文件再执行python fib.py拿到输出结果后生成CmdOutputObservation再次写入事件流。AgentController观察到新的Observation会再次调用Agent.step()让Agent决定下一步动作。如果LLM判断任务已经完成Agent就生成FinishAction整个会话结束。前端在这个过程中通过WebSocket收到所有事件实时渲染文件内容、终端输出和Agent的思考文本。用户看到的“AI一步步操作”其实是这条事件链路的可视化回放。理解这条闭环之后你再看OpenHands的任何报错都能迅速判断它发生在哪个环节。4.3 关键配置项与参数选择参考实际跑这个流程时有几个配置值得把参数看明白。模型方面可以用Claude 3.5 Sonnet、GPT-4o这类较新的模型也可以本地部署Qwen类模型省成本。但本地模型对Agent式调用链路的稳定性要求很高建议先把工具调用关闭只做纯对话验证再把工具调用打开。模型服务超时设置在60到120秒之间比较常见推理速度慢的模型建议调大。沙箱方面Docker镜像的拉取速度直接影响首次启动体验。依赖多的镜像建议提前手动拉取比如docker pull ghcr.io/all-hands-ai/opendevin-sandbox:latest等你确认镜像已经在本地再启动OpenHands能省掉不少等待时间。我一般会先跑一个最小任务通一遍链路确认所有服务正常再上复杂任务。直接一上来就跑超长任务出了问题很难分清是模型问题、沙箱问题还是事件流问题。5. 服务扩展与二次开发把 OpenHands 变成自己的平台5.1 用 MCP 接入自定义工具服务接一个MCP服务是最平滑的扩展方式。先写一个基于FastMCP的Python脚本暴露一个查询接口然后在OpenHands配置文件里注册。配置后重启服务Agent就能在上下文中看到新工具。工具描述写得越清楚Agent使用工具的成功率越高。比如“查询订单状态的工具”和“输入订单号返回当前订单状态及物流进度”写清楚效果完全不同后者能让模型准确判断何时调用、传什么参数。这本质上和提示词工程里的“明确指令”是同一个道理。5.2 自定义 Agent 服务的实现要点如果想开发一个专用Agent核心是继承Agent基类并实现step()方法。这一步的关键在于step()必须有明确的输入输出约定输入是State输出是一个Action。你不必关心事件流的细节Controller会帮你处理好。一个最小实现可以是class MyAgent(Agent): def step(self, state: State) - Action: # 基于 state 里的历史事件和上下文做一个决定 return AgentMessageAction(content自定义逻辑)这只是框架示例真正落地时你需要读取state的历史消息调用LLM解析返回值再决定生成什么Action。我建议先读CodeActAgent的源码理解它的思考与工具调用流程再模仿它写自己的逻辑。不要一开始就想着做复杂的多Agent协作先把单Agent跑通再考虑扩展。5.3 服务层的测试与调试技巧给服务层做测试最直接的办法是开着日志看事件流。把日志级别调到debug然后执行一个最小任务观察每个Action的产生和对应Observation的返回时间。如果某个Action产生后长时间没有对应Observation问题多半出在Runtime或沙箱如果Observation有但内容明显不对问题可能在Agent决策或外部工具的返回质量。你还可以直接用Python连EventStream手动注入事件模拟用户消息省去前端操作。这种事件驱动架构的调试便利用过一次就回不去了。我自己经常用这种方式快速验证一个自定义Agent在特定输入下的表现比反复点页面效率高很多。6. 常见服务问题与排查实录6.1 启动失败与 Docker 相关错误启动失败最常见的原因有三个配置缺失、端口被占用、Docker环境不可用。配置方面检查config.toml是否生成、LLM_API_KEY是否正确端口方面换一个端口即可Docker方面执行docker info能正常输出才说明环境可用。镜像拉取失败时先手动拉取官方镜像再启动能避开启动过程中的超时等待。一个让我印象很深的例子是有次启动一直报找不到容器后来发现是Docker服务根本没起来。整个排查时间其实只有两分钟如果按单体应用思路去查日志可能就绕远了。所以遇到启动问题我第一件事永远是确认底层服务状态。6.2 Agent 卡住、事件流长时间不更新遇到事件流不更新先看卡在哪一步。打开事件文件看最后一条事件是什么类型。如果最后一条是UserMessageAction说明Agent一直在思考但没产出Action大概率是LLM服务超时或模型推理太慢如果最后一条是CmdRunAction说明沙箱还没返回可能是容器里正在安装依赖也可能已经挂住进容器用命令查看进程就知道。我常用的是docker exec -it 容器ID ps aux如果容器内已经没有任何进程在跑说明观察结果没有被正确回传需要查Runtime和EventStream的接口衔接。这种问题在自定义Runtime时更容易遇到默认Docker实现基本稳定。现象可能原因建议排查方向事件流停在UserMessageActionLLM调用慢或失败查看LLM日志、检查模型配置事件流停在CmdRunAction沙箱执行慢或卡住进入容器查看进程和资源Observation返回但内容为空沙箱执行异常尝试手动在容器中运行该命令前端长时间无输出WebSocket断了检查API服务日志和连接状态6.3 沙箱内网络与依赖安装问题沙箱里装依赖超时或失败是很多人遇到的头号问题。原因通常是容器内DNS或网络连通性以及软件源没有配置。解决思路是把必要的网络配置通过sandbox参数传进容器如果是公共软件源慢就在容器内提前配置国内镜像源或者预装常用的依赖到自定义镜像里。把频繁使用的依赖打进自定义镜像比每次会话都重新装快得多也能避免任务执行到一半因为网络波动断掉。我一般会专门维护一个base镜像把Python的常用科学计算库、Node工具链、数据库客户端都预装好。这样每次会话的启动时间能缩短不少Agent也不会在安装依赖上浪费时间。6.4 资源占用过高与日志文件膨胀长会话会让事件文件和日志越来越大。事件文件是功能需要不能随便删但旧会话可以定期清理。日志文件则可以在logging配置里设置轮转按大小或天数切割。Docker容器如果每次都新建不回收磁盘和内存会很快打满。建议设置容器清理策略或者定时执行docker system prune。还有一个容易被忽略的点多个会话并行会创建多个沙箱容器每个容器都要占用内存。如果是个人电脑跑建议限制并发会话数。OpenHands的配置里可以调整并发限制把最大并发调到2或3能明显减少内存压力。这个经验来自我自己的使用经历一开始为了效率开了一堆并行会话结果机器卡死反而更慢。坦白说OpenHands让我印象最深的不是哪个Agent模型多聪明而是服务边界的清晰程度。事件流、控制器、Runtime、LLM网关这些概念拆得越明白你越能在它基础上做自己的东西。如果只记住一个技巧那就是出问题时先翻events文件按时间顺序看完整链路再决定去查哪一层。最后再分享一个小经验想深入研究OpenHands先把CodeActAgent和EventStream两段源码各读几遍。读完之后你写自己的Agent系统时脑子里会一直带着“事件流优先”的思维方式。
返回列表