前阵子我把 OpenChiip Harness 的源码从头到尾读完了。这套东西对外讲是“单进程装下整个数字员工”的 Agent 运行时,读完之后我的评价是:名字起得很诚实,它真的把所有东西都塞进了一个进程,而且塞得还挺漂亮。
先说它解决什么问题。市面上大多数 Agent 框架其实只是把模型调用包了一层,你写完一个 Demo 容易,真到生产环境就麻了:多 Agent 怎么协作、工具调用怎么管、记忆往哪存、任务中断后怎么恢复、几十个 Agent 同时挂着怎么撑住。OpenChiip Harness 的思路跟主流完全相反,它不拆微服务,不搞 K8s,不引入一堆中间件,而是靠一套进程内的事件驱动架构,把一个“数字员工”需要的全部能力——对话、规划、调工具、记事情、定时干活、状态恢复——全部收敛进一个进程里。
这篇文章按我实际读源码的路线来写:先讲它为什么敢做单进程,再依次拆 Agent 状态机、工具系统、记忆管理、任务调度这几块核心机制,最后专门用两节聊这套源码怎么读最省力,以及单进程模式下我踩过的坑。适合正在设计 Agent 运行时、或者想把自己那个“玩具 Agent”升级成能落地产品的朋友。
1. 单进程架构的取舍:为什么它敢反主流
1.1 微服务的账,在这个场景下算不过来
很多团队一提到 Agent 落地,第一反应就是拆服务:模型网关一个服务、工具服务一个、记忆服务一个、编排引擎再一个。拆完发现更难受了——Agent 的每一次思考都要跨三四个服务走一圈,一次工具调用链路从几十毫秒涨到几百毫秒,还得处理分布式事务、服务发现、超时重试。
我读 Harness 源码时,能明显感觉到作者是吃过这个亏之后才反着来的。它的所有模块跑在同一个进程里,模块之间不通过 HTTP/RPC,而是通过进程内事件总线直接投递消息。这个取舍的本质是:Agent 运行时的内部协作频率远高于传统业务系统。一次“用户提问 → 模型思考 → 调工具 → 再思考 → 输出”的完整循环,内部状态要切换几十次,每次切换如果都走网络,序列化和网络开销会吃掉一大半响应时间预算。单进程把通信成本降到几乎为零,换来的是极低的延迟和极高的状态一致性。
当然,单进程不等于放弃扩展。Harness 的扩展单位不是“服务”,而是“进程”——一个数字员工一个进程,进程之间天然隔离。这跟传统微服务“一个功能一个服务”的拆法完全两个思路,但它更贴合 Agent 场景:你不需要水平拆分模块,你需要的是垂直切分“员工”。
1.2 数字员工是个“长期驻留”场景,不是聊天机器人
读代码时我一直在想,为什么 Harness 要把事情搞得这么“重”。后来从Session、Schedule、Goal这几个核心数据结构里看明白了:数字员工不是“你问一句它答一句”的聊天机器人,它的工作方式是——接受一个目标、拆解成任务、自己排期、主动调工具、遇到障碍自己绕行、最后向你汇报结果。
这就要求运行时具备三件普通 Agent 框架没有的东西:
- 长生命周期管理:一个 Agent 实例要能存活几周甚至几个月,中途状态要落盘,进程重启之后能恢复,不能一断电就失忆。
- 主动执行能力:除了被动响应对话,还要有定时任务、事件触发、目标驱动的自主循环。
- 受控的工具执行环境:数字员工要碰真系统、真数据,但任何一次工具调用都不能把宿主进程搞垮。
这三条需求叠加在一起,单进程内的精巧设计反而比一堆微服务更容易实现。因为状态都在一个进程里,不需要跨节点同步;因为通信走内存,高频调度没有成本;因为工具执行可以做严格的进程级超时控制,出了问题能快速处置。
2. 顶层设计:一个进程里到底装了什么
2.1 五大核心模块的职责地图
读完源码之后,我把这个仓库的模块划分整理成了一张职责图。整个运行时可以分成五块,每一块解决一类问题:
| 模块 | 核心职责 | 关键数据结构 |
|---|---|---|
| Runtime Core | 事件循环、任务调度、生命周期管理 | EventLoop, Task, StateMachine |
| Agent Manager | Agent 实例的创建、注册、销毁、状态恢复 | AgentHandle, AgentState |
| Tool Registry | 工具注册、参数校验、执行调度 | ToolSpec, ToolResult |
| Memory Manager | 工作记忆、长期记忆、向量检索、上下文压缩 | Message, MemoryRecord, ContextWindow |
| Session Layer | 用户会话、连接管理、消息路由 | Session, SessionStore |
这五块不是分层调用,而是围绕事件总线各自独立。Agent Manager 不直接调 Tool Registry,它发一条TOOL_CALL_REQUESTED事件,Tool Registry 订阅之后执行,执行完再发一条TOOL_CALL_FINISHED事件,Agent 收到事件后继续自己的状态流转。
我第一次看到这种设计觉得绕,后来想明白了:直接函数调用会让模块之间产生强耦合,一旦工具执行是异步的、可能耗时几秒甚至几分钟,你等不起一个同步返回值。事件驱动天然适合异步长流程,这也是整个运行时能在单进程里承载大量并发 Agent 的根本原因。
2.2 进程内事件总线:一切皆消息
事件总线是这套源码里最值得学习的一块。它不是用 Redis、RabbitMQ 那套东西,就是一个进程内的发布订阅器,核心实现大概几百行。我简化之后,它的核心逻辑长这样:
class EventBus: def __init__(self): self._subscribers: dict[str, list[Callable]] = defaultdict(list) def subscribe(self, event_type: str, handler: Callable): self._subscribers[event_type].append(handler) async def publish(self, event_type: str, payload: dict): for handler in self._subscribers.get(event_type, []): # 注意:逐个串行分发,保证顺序 await handler(payload)注意它这里故意做了串行分发,不是并发分发。我一开始觉得并发分发更快,后来看它的单元测试才明白——Agent 的许多事件是有因果关系的,比如“工具执行完成”之后才能“继续规划”,如果并发分发,两个 handler 同时处理同一事件,状态就乱套了。串行分发的代价是吞吐量受限,但换来的是确定性,这在生产环境里比吞吐更重要。
事件总线的另一个好处是方便调试。我在读代码时发现,它们的测试里大量使用事件订阅来断言 Agent 行为——监听某个事件是否触发、触发的顺序对不对。这种“面向事件断言”的测试思路,比 mock 一堆内部方法要稳得多。
2.3 模块间数据流,一条主线串到底
把五块模块串起来看,一条典型的 Agent 处理链路是这样的:用户消息进入 Session Layer → 打包成事件发布 → Agent Manager 唤醒对应 Agent → 状态机从 IDLE 进入 THINKING → 请求模型生成决策 → 如果决策是调工具,发 TOOL_CALL_REQUESTED → Tool Registry 找到 ToolSpec、校验参数、执行 → 结果以事件形式回到 Agent → 状态机进入 OBSERVING → 把工具结果并进上下文 → 再请求模型生成最终回复 → 回复通过 Session Layer 返回给用户。
这条链路每一步都有状态记录,任何一步失败都有明确的事件和错误对象。读源码时跟着这条链路走一遍,整个系统的逻辑就通了。我当时就是拿这张数据流图当“地图”,然后顺着事件类型去搜代码,效率比逐文件通读高得多。
3. 核心机制一:Agent 生命周期状态机
3.1 状态设计与流转规则
Agent 状态机是整套源码里我读得最仔细的一块。它的状态不多,但每个状态的边界非常清楚:
| 状态 | 含义 | 允许进入的事件 |
|---|---|---|
| IDLE | 空闲待命,等待输入 | 初始化完成、任务完成后的回归 |
| THINKING | 正在请求模型生成决策 | 新消息、工具结果返回、定时触发 |
| WAITING_TOOL | 已发出工具调用,等待结果 | 模型决策为调用工具 |
| WAITING_USER | 需要用户确认或补充信息 | 模型决策为向用户提问 |
| PAUSED | 任务被挂起,可恢复 | 手动暂停、超时未响应 |
| ERROR | 出错,进入可恢复的失败态 | 未捕获异常、工具连续失败 |
| TERMINATED | 终止,清理资源 | 用户取消、任务彻底失败 |
这个设计的精妙之处在于:它把“等待”也建模成了显式状态。大多数自己写 Agent 的人,会在代码里用await硬等工具结果,但如果工具执行要 3 分钟,你不可能让整个 Agent 线程干等着。Harness 的做法是:Agent 发出工具调用后,立即把自己挂到 WAITING_TOOL 状态,释放执行权;工具结果回来时,通过事件把 Agent 唤醒。这样单线程事件循环就能同时管理成百上千个在等工具的 Agent。
我在代码里看到一个细节:每个状态都定义了超时时间,比如 THINKING 默认 30 秒、WAITING_TOOL 默认 10 分钟。超时后会发一个STATE_TIMEOUT事件,状态机强制把 Agent 拉回 IDLE 或 ERROR。这个兜底机制非常实用,否则模型 API 卡住或工具死循环,Agent 就永久卡死了。
3.2 状态持久化与崩溃恢复
单进程架构最大的隐忧是进程挂了怎么办。Harness 的处理方式是写日志的同时,把每一个状态变更同步写进 SQLite。它维护了一张agent_state_history表,记录每个 Agent 的状态流转轨迹,定期做快照。
恢复逻辑也很朴素:重启时扫描所有 Agent 快照,把状态恢复到最后一次持久化点。如果是 IDLE 就直接拉起来;如果是 WAITING_TOOL,会先探测执行中的工具是否还活着,工具活着就继续等,工具死了就发一个TOOL_FAILED事件让 Agent 自行处理;如果是 THINKING,直接重置为 IDLE 并清掉未完成的那次模型请求。
我试过模拟崩溃——直接kill -9进程,再重启,Agent 果然能恢复到断点继续执行。这个能力对数字员工这类需要 7x24 在线的场景是刚需。
3.3 多 Agent 共存与全局状态隔离
单进程里跑多个 Agent,最怕的就是全局变量互相污染。Harness 在这方面做得非常规矩:每个 Agent 的状态、上下文、内存引用全部收进AgentHandle对象里,模块之间只通过消息传递数据,不共享任何可变全局对象。
我读的时候专门搜了一下global关键字,整个数据面代码里几乎没有。连配置都是启动时加载、运行时只读,需要变更配置就走热更新的消息通道。这种“显式传递、隐式共享为零”的纪律,是多 Agent 场景不出鬼故事的前提。
4. 核心机制二:工具注册与沙箱执行
4.1 ToolSpec:用一份声明搞定注册与校验
工具系统是另一个值得抄作业的部分。Harness 里每一个工具都是一个ToolSpec对象,它的定义我简化后大概是这个结构:
@dataclass class ToolSpec: name: str description: str parameters: dict # JSON Schema,用于参数校验 handler: Callable # 真正的执行函数 timeout: float = 30.0 # 单次调用超时 recovery: str = "retry" # 失败策略:retry / ignore / abort_agent auth_required: bool = False关键在于parameters是 JSON Schema,不是 Pydantic 模型。这样模型在生成工具参数时,可以用字符串形式的 schema 直接输入给 LLM,不需要序列化转换。工具执行前,运行时先用jsonschema校验一遍参数合法性,校验不过直接返回错误,不进 handler。这套流程我在自己项目里复刻过,实测能拦截掉 90% 以上的模型幻觉参数——比如模型编造一个不存在的文件名、把数字字段传成字符串。
4.2 超时、取消与并发控制
Harness 对工具执行的几个硬性约束,我觉得是所有 Agent 生产化都必须抄的:
- 硬超时:每个工具调用都有 timeout,用事件循环的定时器实现,超时后不等 handler 主动返回,直接标记失败并唤醒 Agent。
- 可取消:用户中途取消任务时,运行时会给正在执行的工具发协程取消信号。不是强杀,而是通过控制器的取消 token,让工具自己安全退出。
- 并发闸门:每类工具可以单独设置最大并发数。比如数据库查询工具限制最多 4 个并发,外部 API 工具限制 2 个。超出的请求排队等待。
我在自己的 Agent 项目里踩过没做超时的坑:某个工具因为上游 API 卡住,整个 Agent 线程锁死,后续所有任务排不上队。后来直接照搬了 Harness 这套“每工具独立超时 + 全局闸门”的模式,才彻底解决。
4.3 子进程沙箱与安全边界
工具执行的安全问题是单进程架构最容易翻车的地方。Harness 的做法分两层:默认的普通工具直接在运行时协程里执行,高风险工具会被投递到独立的子进程沙箱里运行。
子进程沙箱的执行协议是:父进程通过 stdin/stdout 传 JSON-RPC 消息,工具在子进程里执行,结果序列化后回传。这个设计隔离了崩溃风险——就算工具把子进程搞崩,父进程也只是收到一个连接断开,可以重启沙箱继续工作。我本地测过,一个 Python 工具里故意写死循环,子进程沙箱能正常超时杀掉,宿主进程完全不受影响。
唯一的代价是通信开销。子进程每次调用约多 1-2 毫秒,我觉得完全可接受。安全性和稳定性永远比这点延迟值钱。
5. 核心机制三:记忆与上下文管理
5.1 工作记忆:Token 预算怎么分
数字员工的上下文窗口是有限的,Harness 把有限预算分成三块:固定系统提示占 10%-15%、最近对话占 50%-60%、检索到的长期记忆占 20%-30%。每次处理消息前,运行时都会按这个比例做一个“上下文预算表”,动态决定要裁剪哪些历史。
关键是裁剪策略。Harness 不是简单地从最旧开始丢,而是按“重要性”打分:用户明确提到的关键信息、用户偏好、未完成任务的上下文,优先级最高;闲聊和早先的规划过程,优先级最低。分数低的消息先被丢弃,如果还不够,就对早先的对话做一次摘要压缩,用一段 200 字的总结替代 5000 字的原始对话。
这个策略我在实践里验证过,比“固定保留最近 N 轮”好用得多。固定窗口会丢掉用户两天前提过的关键约束,重要性打分能把这些关键信息留得更久。
5.2 长期记忆:SQLite 加向量检索
长期记忆模块的存储结构很朴素:SQLite 存结构化记录,每一条记忆有内容、类型、时间戳、关联 Agent ID;同时把记忆内容做 embedding 后存进向量索引,检索时先按相似度召回 Top-K,再拿召回结果和结构化条件(比如时间范围、记忆类型)做过滤。
这套设计没有用单独的向量数据库,全部落在本地文件里。我一开始觉得小打小闹,后来想想,对单进程数字员工来说,本地 SQLite 加一个轻量向量索引完全够用,还省掉了部署、运维、网络开销三个麻烦。只有当 Agent 数量上到几百个、记忆量到千万级,才值得引入独立向量库,那种场景反而更适合拆多进程。
5.3 上下文压缩的触发条件
压缩不是每次都要做,Harness 设了一个阈值:当前上下文 token 数超过预算的 85% 才触发。触发时先做摘要压缩,压缩完成后会把原始对话以“归档记忆”的形式写入长期记忆库,保证信息可以回溯。
我踩过一个跟压缩顺序有关的坑:一开始我的实现是先压缩后存记忆,结果压缩引擎把重要的细节丢了,再从那堆“压缩后的记忆”里检索,出来的东西质量很差。后来改成先存原始对话再压缩,检索时优先搜原始记录,压缩摘要只当兜底,效果立刻好了很多。Harness 源码里也是这个顺序,跟我最后调出来的方案一致,读到这里的时候还挺有共鸣。
6. 任务调度:让 Agent 从“被动应答”变成“主动干活”
6.1 Goal 驱动与任务拆解
数字员工跟聊天机器人的最大区别是要自己干活。Harness 里有个Goal对象,从创建开始就进入一个“目标-计划-执行-检查”的循环。我读代码时发现,它对任务拆解有一套分层结构:
Goal:顶层目标,比如“每天生成一份销售分析报告”。Plan:一次执行的具体计划,由模型或预置模板生成。Step:Plan 里的最小执行单元,一个 Step 通常对应一次工具调用或一次信息收集。
运行时维护一个GoalManager,负责把 Goal 转成 Plan,把 Plan 转成 Steps,然后逐步执行。每完成一个 Step,重新评估剩余步骤是否仍然有效——如果中间发现数据源不可用或者前提变了,它不会硬着头皮继续,而是回到规划环节生成新 Plan。
6.2 定时触发与事件触发
主动干活靠两种触发器:定时器和事件订阅。定时器用的是进程内的调度器,支持 cron 表达式,到点后向对应 Agent 发布一条SCHEDULED_TASK事件。事件订阅则是让 Agent 订阅特定外部事件,比如收到新邮件、文件系统变化、Webhook 到达,触发自己的处理流程。
这套触发机制的设计重点是幂等。同一个定时任务可能因为进程重启、网络抖动重复触发,Harness 给每个触发都分配了一个唯一的事件 ID,Agent 在处理时会先去会话存储里查这个 ID 是否处理过,处理过就直接忽略。这个细节我在生产里深有体会——重复触发是线上 Agent 最常见的“灵异事件”之一。
6.3 并发控制与优先级
多个定时任务同时到点,运行时不会一股脑全跑,而是按优先级排队。每个 Agent 实例可以配置最大并发任务数,默认 1——也就是说一个数字员工同一时间只专注干一件事。这个默认值我觉得很有远见,多数 Agent 出问题都是因为同时接了好几个任务,上下文互相污染。
优先级队列的调度参数就三个:优先级(高/中/低)、预计执行时长、等待时间。短任务优先,同优先级先到先得,等太久的任务自动升优先级防饿死。简单粗暴,但实测很少出问题。
7. 这套源码怎么读:我推荐的阅读路线
7.1 从入口和初始化出发,别一上来就读内部模块
这个仓库大概有几十个文件,如果从头到尾按目录顺序啃,很容易淹没在细节里。我推荐的顺序是:先找到进程入口和初始化代码,看它启动时初始化了哪几个管理器、注册了哪些默认事件,这能让你快速建立“运行时由哪些零件组成”的整体感。然后找一个最简单的内置 Agent 示例,从用户输入开始,跟着一条消息走完整个链路。
带着“一条消息走了哪些事件”的问题去读,比带着“每个文件是干什么的”去读高效得多。事件类型就是导航地图,你只需要顺着MESSAGE_RECEIVED → PLAN_GENERATED → TOOL_CALL_REQUESTED → TOOL_CALL_FINISHED → RESPONSE_READY这条链路搜,就能把核心代码全部覆盖。
7.2 核心文件的职责速查表
我整理了一张我当时反复翻看的文件清单,按阅读优先级排:
| 优先级 | 文件/模块 | 看什么 |
|---|---|---|
| P0 | 进程入口与初始化 | 模块装配顺序、配置加载 |
| P0 | 事件总线实现 | 分发模型、订阅机制 |
| P0 | Agent 状态机 | 状态定义、流转表、超时策略 |
| P1 | 工具注册与执行 | ToolSpec 结构、沙箱协议 |
| P1 | 记忆与上下文管理 | Token 预算、压缩触发逻辑 |
| P1 | Session 与会话存储 | 消息持久化、会话恢复 |
| P2 | 定时调度器 | cron 解析、幂等控制 |
| P2 | 子进程沙箱通信 | JSON-RPC 协议、超时处理 |
7.3 带着三个问题去读
读源码最怕“看完就忘”。我读这套代码时给自己设了三个问题,读的过程就是回答问题的过程:一是“一个 Agent 从创建到销毁的完整生命周期是什么”,二是“一次工具调用的全链路经过了哪些模块”,三是“进程崩溃后如何恢复到可用状态”。如果你也能回答清楚这三件事,这个运行时你就基本吃透了。
8. 单进程模式踩坑实录与排查技巧
8.1 阻塞调用是头号杀手
单进程事件循环最大的敌人是阻塞。我拿到源码先做的第一件事,就是全局搜索有没有在协程里同步调用requests、time.sleep、pandas这类阻塞代码。果不其然,早期版本里确有一个工具 handler 用了同步requests,执行时整个事件循环被卡住,同一进程里其他 Agent 的调度全部停滞。
排查这种问题有个笨但有效的办法:给事件循环加一个 watchdog,每隔几秒发一条空转事件,如果没有 handler 在预期时间内响应,就证明循环被阻塞了。定位到具体阻塞点之后,要么把同步调用丢进线程池,要么换成异步客户端。我在自己的项目里用这个套路抓住过好几次潜伏的阻塞。
8.2 内存与句柄泄漏排查
单进程长时间运行,内存和句柄泄漏会被时间放大。我在读源码前就怀疑这种事件驱动架构容易漏掉两类资源:定时器没有及时取消、子进程沙箱没有回收。实际操作中,我用psutil每 10 秒采样一次进程的内存和句柄数,跑高峰负载场景,观察是否随时间单调增长。
工具侧的泄漏还有个隐蔽来源:事件总线订阅了却不取消。Agent 销毁后,如果它订阅的 handler 还挂在总线上,不仅泄漏内存,还会收到后续所有相关事件导致幽灵行为。Harness 的做法是给订阅加作用域——Agent 销毁时自动取消它名下全部订阅。
8.3 状态漂移:消息处理到一半,Agent 却被暂停了
单进程里事件是串行处理的,但外部触发是异步的。我遇到过一个典型问题:Agent 正在处理一条消息的中途,用户发来了暂停指令,暂停事件在消息事件之后被处理,但状态机此时处于中间态,导致状态漂移。
解决思路来自源码本身:所有状态变更不做“跳跃式”修改,而是先进入一个统一的待定状态,再根据当前状态和到达事件去查状态流转表,确定合法目标状态。非法流转直接拒绝并记日志。这个设计相当于给状态变更加了事务,实践下来能挡住绝大多数并发事件导致的错乱。
8.4 调试工具与日志规范
最后分享一个读源码时学到的调试习惯。Harness 的日志格式是“事件类型 + Agent ID + 状态 + 耗时”,每一行日志都能跟状态机流转对上。排查问题时,我通常随手写一个小脚本订阅事件总线,实时打印 Agent 的状态流转,比翻日志快得多。单进程架构在这里也有优势——你可以在运行时里直接挂调试钩子,不需要远程调试分布式系统那一套。
我自己搭 Agent 运行时的时候,把日志规范改成了同样格式:时间 | agent_id | 当前状态 | 触发事件 | 耗时 ms。连续跑一周之后,排查问题的效率提升明显,因为所有问题都能快速定位到“哪个 Agent、在哪个状态、因为什么事件”上。
读这套源码给我最大的收获,其实不是哪个具体模块的实现,而是它让我重新想明白了 Agent 运行时的本质:一个数字员工就是一个有状态、有记忆、有工具、有自我恢复能力的长期运行实体。把它放进单进程,不是技术上的保守,而是对场景的深刻理解。如果你也在做 Agent 相关的东西,哪怕只把事件总线、状态机、工具超时这三个设计抄走,都够你少踩半年的坑。