1. 从"能跑就行"到"跑得明白":为什么我要死磕 Agent 主循环
刚接触 Agent 开发那阵子,我和大多数人一样,拿到一个框架就急着往上堆功能:接个大模型、挂几个工具、写个 while 循环,能对话、能调 API 就算完事。结果项目一上量就露馅——工具调用偶尔丢事件、多轮任务跑到一半状态错乱、想加个日志监控发现根本插不进去。排查半天,问题全出在最底层那个我从来没认真看过的"主循环"上。
后来接触到 Deepseek Harness 这套 Agent 运行时框架,我才意识到一个残酷的事实:绝大多数 Agent 项目的不稳定,根子不在模型,而在事件驱动内核的设计。Harness 最核心的两个设计理念——"一切皆插件"和"事件驱动主循环"——恰好戳中了这个痛点。它把 Agent 的运行拆成了一条清晰的事件流,每个环节都是可插拔的插件,主循环只负责调度事件、分发任务、维护状态。
这篇内容我打算把 Harness 的 Agent-Loop 主循环从里到外拆一遍。不是那种"官方文档翻译"式的复述,而是站在一个真正落地过项目的 FDE(现场交付工程师)角度,讲清楚三件事:主循环到底怎么转的、插件机制为什么这么设计、以及在实际交付中哪些坑必须提前避开。适合已经写过简单 Agent、但被状态管理和扩展性折磨过的开发者,也适合准备把 Agent 推向生产环境的团队参考。读完你应该能自己画出一张主循环的事件流转图,并且知道每个扩展点该往哪儿插。
2. Agent-Loop 主循环:一条事件流如何驱动整个智能体
2.1 主循环的本质:不是 while(true),而是事件泵
很多人对 Agent 主循环的想象就是一个while(true)里不断调用模型、解析输出、执行工具。这种理解在玩具项目里没错,但一旦涉及多轮对话、异步工具、并发任务,就会立刻崩溃。Harness 的主循环本质是一个事件泵(Event Pump):它不主动"做"任何事,而是持续地从事件队列里取出事件,交给对应的处理器,再把处理器产生的新事件推回队列。
用生活化的类比:传统 while 循环像一个厨师,自己切菜、自己炒、自己装盘,全程一个人忙;而事件泵像一个餐厅的传菜系统,厨师只管做菜(处理器),传菜员(主循环)负责把订单送到该去的地方,做完的菜再送回出餐口。厨师之间互不干扰,加一个新菜品只需要多一个厨师,不用改传菜系统。
这个设计带来的直接好处是解耦。模型调用、工具执行、状态更新、日志记录,全部变成独立的事件处理器。主循环本身极其稳定,几乎不需要改动,所有业务逻辑都通过注册新的事件类型和处理器来扩展。这就是"一切皆插件"的底层支撑。
主循环的核心伪代码大致是这样的逻辑:
class AgentLoop: def __init__(self): self.event_queue = deque() self.handlers = {} # event_type -> [handler, ...] self.state = AgentState() def register(self, event_type, handler): self.handlers.setdefault(event_type, []).append(handler) def emit(self, event): self.event_queue.append(event) def run(self): while not self.should_stop(): if not self.event_queue: self.wait_for_input() # 阻塞等待外部事件 continue event = self.event_queue.popleft() for handler in self.handlers.get(event.type, []): new_events = handler(event, self.state) for e in new_events or []: self.emit(e)注意几个关键点:emit是唯一的入队入口,run是唯一的消费入口,处理器返回的是新事件列表而不是直接修改状态。这种"事件进、事件出"的纯函数式风格,是后面所有可测试性、可观测性的基础。
2.2 事件的生命周期:从用户输入到最终响应
一个完整的 Agent 交互,在 Harness 里会被拆成一条事件链。我把它分成五个阶段,每个阶段对应不同的事件类型:
| 阶段 | 典型事件类型 | 触发方 | 主要职责 |
|---|---|---|---|
| 输入接入 | UserMessageEvent | 外部适配器 | 把用户输入标准化为事件 |
| 上下文构建 | ContextBuildEvent | 上下文插件 | 拼装历史、系统提示、工具描述 |
| 模型推理 | ModelRequestEvent/ModelResponseEvent | 模型插件 | 发起推理、接收流式输出 |
| 工具执行 | ToolCallEvent/ToolResultEvent | 工具插件 | 解析调用、执行、回填结果 |
| 响应输出 | ResponseEvent | 输出插件 | 格式化并返回给用户 |
这条链不是线性的,而是可循环、可分支的。比如模型返回了一个工具调用,主循环会发出ToolCallEvent,工具执行完发出ToolResultEvent,这个结果又会被上下文插件捕获,重新触发ContextBuildEvent,再次进入模型推理。这就是所谓的"Agent 循环"——循环的不是代码,而是事件。
理解这一点非常关键:你在调试 Agent 时看到的"卡住",往往是某个事件没有被正确 emit,或者某个处理器没有返回后续事件。我踩过最典型的一个坑是工具执行完忘了 emitToolResultEvent,结果主循环一直在等,模型那边永远收不到结果,整个会话就挂在那里。后来养成的习惯是:每个处理器返回前,先问自己"下一步该由谁接手,我有没有把接力棒传出去"。
2.3 状态管理:为什么状态不能塞进处理器里
新手最容易犯的错误,是把会话状态直接存在处理器的实例变量里。比如写一个ToolHandler,里面有个self.history = [],每次执行完往里塞。单会话测试没问题,一上并发就全乱套——多个会话共享同一个处理器实例,历史互相污染。
Harness 的做法是把状态抽出来,作为主循环的一个独立对象AgentState,通过参数传给每个处理器。处理器只读状态、返回事件,绝不自己持有可变状态。这样带来三个好处:状态可序列化(方便持久化和恢复)、处理器可复用(无状态天然线程安全)、状态变更可追踪(每次变更都对应一个事件)。
AgentState通常包含这几类字段:会话标识、消息历史、当前轮次、待执行的工具调用、累积的 token 用量、以及一个通用的scratchpad供插件临时存取数据。我个人的经验是,scratchpad 要慎用,它容易变成"什么都往里塞"的垃圾桶,最后没人知道里面有什么。更好的做法是为每类数据定义明确的事件类型,让数据流动显式化。
2.4 循环终止条件:别让 Agent 无限转下去
主循环什么时候停?这是生产环境必须回答的问题。Harness 里通常有几种终止条件并存:
- 正常终止:模型返回了不含工具调用的最终响应,发出
ResponseEvent后循环进入等待状态。 - 轮次上限:设置最大循环轮次(比如 10 轮),防止模型陷入"调用工具-看结果-再调用"的死循环。
- 超时终止:单次会话总时长超过阈值,强制中断并返回当前状态。
- 显式中断:外部发出
InterruptEvent,主循环在下一个事件边界安全退出。
我强烈建议轮次上限和超时两个都要设。只设轮次上限的话,如果某个工具执行特别慢(比如调用一个响应很慢的外部服务),10 轮可能跑十几分钟;只设超时的话,模型可能在超时前疯狂空转烧 token。两个一起设,才能既控成本又控体验。实测下来,对话类 Agent 轮次上限设 8-12 轮比较合理,任务类 Agent 可以放宽到 20-30 轮,但超时一定要卡死。
3. "一切皆插件":插件机制到底解决了什么工程问题
3.1 插件化的动机:从"改框架"到"加插件"
在没有插件机制的框架里,想加一个新能力(比如接入一个新的向量数据库做检索),你得去改核心代码:在上下文构建的地方加一段检索逻辑,在模型调用前加一段结果注入。改完还要担心会不会影响其他功能,升级框架时自己的改动会不会被覆盖。这种"侵入式扩展"是维护噩梦。
Harness 的插件机制把扩展点标准化了。每个插件声明自己关心哪些事件类型,主循环在分发事件时自动调用。加检索能力?写一个RetrievalPlugin,注册到ContextBuildEvent上,在处理器里查库、把结果塞进上下文。核心代码一行不动。这就是"一切皆插件"的价值——扩展靠注册,不靠修改。
从工程角度看,这符合开闭原则:对扩展开放,对修改关闭。但更重要的是它改变了团队协作方式。核心团队维护主循环和事件协议,业务团队各自写插件,互不阻塞。我在一个项目里见过三个团队并行开发:一个做模型接入插件,一个做工具插件,一个做可观测性插件,最后靠事件协议对接,几乎没有冲突。
3.2 插件的注册与发现:显式注册 vs 自动扫描
插件怎么被主循环发现?常见两种方式:
显式注册是在启动时手动调用loop.register(EventType, handler)。优点是清晰可控,谁注册了什么一目了然,调试时容易定位。缺点是插件多了以后启动代码会很长。
自动扫描是通过装饰器或配置文件声明插件,框架启动时自动加载。优点是新增插件零改动,缺点是"魔法"太多,出问题时不好排查——你以为是 A 插件处理的,实际是 B 插件抢先注册了。
Harness 默认走显式注册,但提供了配置驱动的批量注册。我的建议是:核心插件显式注册,业务插件配置注册。核心的模型、工具、上下文插件数量少且稳定,显式注册让主流程清晰;业务插件可能几十个且经常增删,用配置管理更灵活。另外要注意注册顺序——同一个事件类型如果有多个处理器,执行顺序通常按注册顺序,这个顺序会影响结果,务必在文档里写清楚。
3.3 插件间的通信:只通过事件,不通过引用
插件之间能不能直接调用?技术上可以,但强烈不建议。一旦插件 A 直接持有插件 B 的引用并调用其方法,两者就耦合了,B 的改动会波及 A,插件化的意义就没了。
正确的做法是插件间只通过事件通信。A 想触发 B 的行为,就 emit 一个 B 关心的事件;B 处理完再 emit 结果事件。这样 A 完全不知道 B 的存在,只知道"我发出这个事件,会有人处理"。这种松耦合让插件可以独立测试、独立替换。
举个实际例子:工具插件执行完一个数据库查询,想把结果缓存起来。它不应该直接调用缓存插件的set方法,而是 emit 一个CacheWriteEvent,缓存插件监听这个事件并写入。哪天缓存方案从内存换成 Redis,工具插件完全无感。这个原则听起来简单,但在赶工期时最容易被破坏,我见过太多"就这一次直接调用"最后变成技术债的案例。
3.4 插件的生命周期:初始化、热更新与卸载
插件不是注册完就一劳永逸。生产环境里,插件需要完整的生命周期管理:
- 初始化:插件可能需要建立连接(数据库、外部服务),这些应该在
on_init钩子里做,而不是在第一次处理事件时懒加载——懒加载会让第一个请求特别慢,还可能因为初始化失败导致事件处理异常。 - 热更新:修改插件配置(比如换模型、调参数)时,理想情况是不重启主循环。Harness 支持插件级别的重载,但要求插件实现
on_reload钩子,把旧连接优雅关闭、新配置生效。 - 卸载:插件下线时要释放资源,避免连接泄漏。
on_shutdown钩子里做清理。
我踩过的一个坑:某个插件在on_init里启动了一个后台线程做定时任务,但没实现on_shutdown,结果热更新时旧线程还在跑,新线程又起来了,两个线程同时写同一份数据,直接数据错乱。凡是插件里启动了后台任务、开了连接、占了文件句柄的,必须实现对应的清理逻辑,这是硬性要求。
4. 事件驱动内核的底层原理:队列、分发与并发
4.1 事件队列的选型:内存队列够不够用
事件队列是主循环的心脏。最简单的实现是内存里的deque,先进先出,单进程内高效。但生产环境要考虑几个问题:
持久化:进程崩溃时队列里的事件会不会丢?如果 Agent 正在执行一个长任务,崩溃后能否恢复?内存队列做不到,需要落到磁盘或消息中间件。
背压:如果事件产生速度超过处理速度,队列会无限增长直到 OOM。需要设置队列上限,满了之后要么阻塞生产者,要么丢弃低优先级事件。
跨进程:如果 Agent 要水平扩展,多个进程共享一个队列,就需要外部消息系统。
我的经验是:单机小规模用内存队列 + 定期快照持久化状态就够了;一旦涉及多实例部署或任务不能丢,就得上外部队列。但别一上来就上重型中间件,很多项目根本到不了那个量级,过早引入只会增加运维负担。Harness 的队列实现是可替换的,先用内存版跑通,有需要再换。
4.2 事件分发的顺序保证:哪些必须有序,哪些可以乱序
事件分发的顺序直接影响正确性。有些事件必须严格有序,比如同一个会话的消息历史,顺序错了上下文就乱了;有些事件可以并发,比如不同会话之间的处理。
Harness 的处理策略是按会话分区:同一个会话的事件进同一个队列,保证有序;不同会话的队列并行处理。这样既保证了单会话的正确性,又获得了跨会话的并发能力。这个设计很像消息队列里的"分区"概念,理解起来不难,但实现时要小心——会话 ID 的生成必须稳定,不能每次请求都变,否则分区就失效了。
还有一个容易忽略的点:同一会话内,工具执行和模型推理的顺序。如果模型一次返回了多个工具调用,这些工具能不能并发执行?技术上可以,但要注意它们之间有没有依赖。如果工具 A 的输出是工具 B 的输入,并发就会出错。Harness 默认串行执行同一轮的多个工具调用,保守但安全。想并发的话,需要显式声明工具之间的依赖关系,让调度器知道哪些能并行。
4.3 并发模型:单线程事件循环 vs 多线程
主循环用单线程还是多线程?这是个经典权衡。
单线程事件循环(类似 Node.js 模型)的优点是简单、无锁、状态一致性好。所有处理器串行执行,不用担心竞态。缺点是如果某个处理器阻塞(比如同步调用一个慢 API),整个循环就卡住了。
多线程能并行处理,但要处理锁、竞态、状态同步,复杂度陡增。
Harness 的选择是单线程主循环 + 异步处理器。主循环本身单线程,保证事件分发的顺序和状态一致性;处理器如果是 IO 密集型的(模型调用、工具执行),用异步方式,不阻塞循环。这个组合兼顾了简单性和性能。但要注意:处理器里绝对不能有同步阻塞调用,一个time.sleep或者同步 HTTP 请求就能把整个循环拖垮。我见过有人在处理器里同步读一个大文件,结果整个 Agent 卡了十几秒,所有会话都受影响。IO 操作一律走异步,这是铁律。
4.4 错误处理:一个插件崩了,整个循环怎么办
插件出错是常态,关键是出错后主循环怎么反应。有几种策略:
- 快速失败:任何插件抛异常,主循环立即停止。适合开发阶段,能第一时间暴露问题。
- 隔离降级:捕获插件异常,记录日志,跳过该插件继续处理。适合生产环境,一个非核心插件挂了不影响主流程。
- 重试:对可恢复的错误(网络抖动)自动重试,超过次数再降级。
Harness 默认是隔离降级,但允许插件声明自己的错误策略。我的建议是分级处理:核心插件(模型、上下文)出错要快速失败,因为它们的错误会导致整个响应无意义;辅助插件(日志、统计)出错就降级跳过,不能因为记日志失败就让用户拿不到回复。
这里有个细节:降级时要 emit 一个错误事件,让可观测性插件能捕获到。否则插件悄悄失败了,你连日志都看不到。我习惯在每个处理器的异常捕获里都 emitPluginErrorEvent,包含插件名、异常类型、堆栈,这样监控面板上能直接看到哪个插件在拖后腿。
5. FDE 落地实战:从 Demo 到生产的那些坑
5.1 可观测性:没有事件追踪,排查就是盲人摸象
Demo 阶段没人关心日志,生产阶段没有日志寸步难行。事件驱动架构的一个巨大优势是天然可观测——每个事件都是一个追踪点。关键是你要把追踪做起来。
我的做法是给每个事件加一个trace_id和span_id,trace_id贯穿一次完整会话,span_id标识单个事件处理。这样在日志系统里,你可以按trace_id拉出一次会话的完整事件链,看到每个事件的耗时、处理器、输入输出。排查问题时,直接看哪个 span 耗时异常或者哪个事件断了链。
具体实现上,写一个TracingPlugin监听所有事件(注册到通配事件类型),在事件进入和处理器返回时打点。注意不要记录敏感数据,用户输入和模型输出可能包含隐私信息,追踪日志里只记元数据(事件类型、耗时、token 数),内容另存并做脱敏。
5.2 性能调优:主循环的瓶颈通常不在你想的地方
很多人以为 Agent 慢是因为模型推理慢,优化方向全在换更快的模型。但实测下来,主循环本身的调度开销、上下文构建的重复计算、工具执行的串行等待,往往是更隐蔽的瓶颈。
我做过一次性能剖析,发现一次交互里模型推理占 60% 时间,上下文构建占 25%,工具执行占 10%,主循环调度占 5%。上下文构建之所以占这么多,是因为每次循环都重新拼装完整历史,而历史里有大量重复的 token。优化方案是上下文缓存:把不变的部分(系统提示、早期历史)缓存起来,只拼装新增部分。这一项优化就把上下文构建时间砍了一半。
另一个常见瓶颈是工具串行执行。如果一轮里有 5 个独立工具调用,串行执行就是 5 倍等待。改成并发后,总耗时接近最慢的那个。但并发的前提是工具之间无依赖,这个判断要谨慎。
5.3 状态持久化与恢复:崩溃后能不能接着跑
生产环境必须考虑崩溃恢复。用户发起一个长任务,跑到一半服务重启了,任务能不能接着跑?这取决于状态有没有持久化。
Harness 的AgentState是可序列化的,理论上每个事件处理完都可以快照一次。但每次都快照开销太大,实际做法是定期快照 + 事件日志:状态每隔 N 个事件快照一次,两次快照之间的事件记入日志。恢复时加载最近快照,再重放日志里的事件,就能回到崩溃前的状态。
这里有个坑:重放事件时,有副作用的处理器不能重复执行。比如发邮件的工具,重放时不能真发第二封。解决办法是给事件加幂等标识,处理器检查这个标识,已处理过的直接跳过。这个机制必须在设计初期就考虑,后期补会非常痛苦。
5.4 插件版本管理:升级一个插件,别把整个系统带崩
插件多了以后,版本管理是个大问题。插件 A 依赖事件协议 v2,插件 B 还在用 v1,主循环升级到 v3,谁兼容谁?
我的经验是事件协议要向后兼容:新增字段可以,删除或改语义不行。如果必须破坏性变更,就引入新的事件类型,老类型保留一段时间做过渡。插件声明自己支持的事件协议版本,主循环在注册时校验,不兼容的直接拒绝加载并报警,而不是等到运行时才出错。
另外,插件的配置要和代码一起版本化。我见过插件代码回滚了但配置没回滚,导致行为不一致的案例。配置用版本控制管理,部署时代码和配置一起发布,能避免大部分这类问题。
6. 我踩过的三个真实坑与排查链路
6.1 事件丢失:工具执行完了,模型却收不到结果
现象:某个工具调用后,Agent 卡住不动,日志显示工具执行成功,但模型没有继续推理。
排查链路:先看事件追踪,发现ToolCallEvent有记录,ToolResultEvent没有。定位到工具插件,检查代码发现——工具执行成功后的emit写在了try块里,但后面有个return提前返回了,emit根本没执行到。这是典型的控制流 bug。
修复:把emit移到finally块,或者重构代码确保所有路径都会 emit。教训:处理器的所有返回路径都要检查是否发出了必要的事件,最好用静态检查工具扫描。
6.2 状态污染:并发会话下历史串了
现象:两个用户同时对话,A 的回复里出现了 B 的问题内容。
排查链路:检查AgentState的获取方式,发现上下文插件用了单例模式缓存状态,所有会话共享一个实例。根因是状态没有按会话隔离。
修复:状态改为按会话 ID 索引的字典,每个会话独立。教训:任何"全局唯一"的状态对象都是并发隐患,状态必须按会话维度隔离。
6.3 插件顺序:日志插件把敏感信息打出来了
现象:日志里出现了用户的完整输入,包含不该记录的内容。
排查链路:日志插件注册在上下文构建之前,拿到的是原始输入。而脱敏插件注册在之后,等它处理时日志已经打完了。
修复:调整注册顺序,脱敏插件必须在日志插件之前。教训:插件顺序不是随便排的,涉及数据处理的插件要按"原始数据 → 脱敏 → 使用 → 记录"的顺序注册,这个顺序要在架构文档里明确。
7. 写在最后的一点个人体会
折腾 Harness 这套事件驱动内核大半年,最大的收获不是学会了某个 API,而是思维方式的变化。以前写 Agent 想的是"下一步该干什么",现在想的是"这个动作会产生什么事件、谁会消费它"。这种从"流程思维"到"事件思维"的转变,让代码的可扩展性和可维护性上了一个台阶。
如果你正准备把 Agent 项目推向生产,我的建议是:先把主循环和事件协议设计清楚,再动手写业务插件。前期多花两天设计事件类型和流转路径,后期能省两周的排查时间。插件化不是银弹,它带来灵活性的同时也带来"看不见的调用链"这个新问题,所以可观测性必须同步跟上,否则插件越多越难维护。
最后分享一个我一直在用的小技巧:给主循环加一个"事件回放"模式,把生产环境录下来的事件流在本地重放,配合断点调试,能复现绝大多数线上问题。这个能力在排查那些"偶发""难复现"的 bug 时,价值巨大。