
维护 Agent 框架三个月我最大的感慨是让一个 Agent 跑起来不难让一百个 Agent 稳定地跑、且出了问题还能把现场精确还原难度完全是另一个数量级。这篇文章想做的事是把我在 DeepSeek Harness下文我简称 Harness这个项目里的两个核心工程决策拆开聊聊——全插件化设计和可回放会话日志。前者解决的是框架越改越乱后者解决的是线上出了错只能靠猜。如果你也在写自己的 Agent 框架或者正被 LLM 的非确定性折腾得睡不好觉这篇的经验应该能直接平移过去用。1. 先把问题聊透Agent 框架是怎么变成一坨的1.1 从能跑到失控我经历的三个典型阶段大多数 Agent 框架不是设计坏的是长坏的。复盘我自己的实践路径基本都逃不过三个阶段。第一阶段是 demo 期。一个agent.py两百行循环里直接调模型接口工具注册就是个字典prompt 写在字符串里。这个阶段没有任何抽象跑通一个 ReAct 循环就完了根本不需要谈插件。第二阶段是叠加期。记忆要接、工具要加、流式输出要上、重试要补。今天加一个if use_memory明天加一个elif streaming后天在循环里塞一个响应判空的补丁。等反应过来核心的run()已经三百多行里面全是分支和 Feature Flag。我见过最夸张的一个版本核心循环里有七个布尔开关交叉组合改一处要测八种排列。第三阶段是协作期。不同小组要不同的模型供应商、不同的记忆策略、不同的工具集但所有人都改同一个核心文件。每次合并都在打架每次上线都要全量回归。到这一步框架已经不是难扩展的问题而是根本动不了。这个阶段最典型的表现是你有过一些抽象但抽象长在了错误的位置——不是沿着变化点切而是沿着上次谁着急改动切。1.2 全插件化的真正目标不是扩展而是隔离一说插件化很多人第一反应是抽象层太多会不会影响性能为一个小功能搞这么重的机制值不值。我的理解不太一样全插件化的首要收益不是扩展性是隔离。隔离体现在三个层面。第一是改动的隔离——模型供应商从云厂商 API 换成内部模型网关时只需要新增一个实现类核心循环一行不动第二是测试的隔离——核心逻辑可以对着一个假的模型提供者跑不花钱、不等网络、不担心限流第三是责任的隔离——记忆坏了查记忆插件工具坏了查工具插件不用在一个三百行的函数里从头断点。想明白这个设计目标就清晰了Agent 框架的稳定内核应该是流程骨架。观察、决策、执行、再观察这个循环永远不变真正变化的是每一环的具体实现。把循环写成骨架把实现放进插件这才是全插件化该有的样子。1.3 我在 Harness 里划出的扩展边界插件边界不能拍脑袋划。我的原则是只有当一个扩展点出现了至少两次真实的替换需求才把它正式提成接口。过早抽象出来的接口往往连文档都写不清楚。Harness 目前稳定下来的扩展点一共有七个列在下面扩展点职责典型替换场景模型提供者把消息列表变成模型回复统计 token处理流式云厂商 API 换内部网关、换本地部署模型工具提供者工具发现、参数校验、执行、权限检查换一套内部工具集、加沙箱执行记忆提供者读写长期记忆维护上下文窗口滑动窗口换向量检索决策策略决定每一步调用哪个工具、何时结束ReAct 换 Plan-and-Execute、换树搜索会话存储按会话 ID 读写状态本地文件换数据库事件出口把日志事件写到哪里本地 JSONL 换监控平台安全护栏输入输出过滤、限流、敏感内容检查不同合规要求换不同护栏有两点我特意没做插件。一是 prompt 模板它大部分时候是数据不是逻辑用配置项表达更合适做成插件反而让版本管理变复杂。二是事件序号分配它是流程骨架自己的职责交给插件容易乱序。2. 插件接口的落地过程继承、协议到注册表的三次迭代2.1 第一版基类继承改一个功能崩一串插件最开始 Harness 的插件机制很朴素所有插件继承一个BaseAgentPlugin里面预留钩子方法比如on_user_message、on_tool_call、on_llm_response。class BaseAgentPlugin: def on_user_message(self, message): ... def on_tool_call(self, tool): ... def on_llm_response(self, response): ... class MyPlugin(BaseAgentPlugin): def on_user_message(self, message): super().on_user_message(message) ...跑了三个月问题接踵而至。第一个问题是基类的便捷方法造成隐式耦合子类悄悄覆盖某个方法你只是改了基类里的日志格式结果一串工具的行为全变了。第二个问题是多继承工具插件想同时复用两个基类的行为时Python 的 MRO 会把问题拖到运行期才爆。第三个问题是兼容性往基类加一个新方法等于给所有现存子类做一次全体回归改一次伤一次。2.2 第二版 Protocol够灵活但少了身份和配置第二次迭代改用 Python 的 Protocol结构化接口from typing import Protocol class ModelProvider(Protocol): async def chat( self, messages: list[dict], tools: list[dict] | None None, ) - ModelResponse: ...好处是鸭子类型测试可以传任意假实现不再被基类绑死。真用起来短板也很明显协议只约束了能力长得什么样没有任何身份信息。注册表没法发现插件有哪些、吃什么样的配置、生命周期怎么管理。最后还得在入口函数里手动 import 一堆工厂再手动组装插件化名存实亡。2.3 第三版注册表能力名、依赖解析、生命周期第三版我把描述信息和能力实现绑在一起。每个插件带一份PluginMeta声明名字、版本、提供的能力列表、依赖的能力列表、配置类。# harness/plugin.py from dataclasses import dataclass dataclass class PluginMeta: name: str version: str provides: tuple[str, ...] # 能力名例如 (llm.chat,) requires: tuple[str, ...] () config_cls: type | None None注册表负责三件事按能力名索引、按requires做拓扑排序、统一驱动生命周期。# harness/registry.py class PluginRegistry: def __init__(self): self._factories: dict[str, type] {} def register(self, factory: type) - None: meta factory.meta if meta.name in self._factories: raise PluginError(fduplicate plugin: {meta.name}) self._factories[meta.name] factory def provide(self, capability: str): for factory in self._factories.values(): if capability in factory.meta.provides: return factory raise CapabilityNotFound(capability)依赖排序是这版的核心。插件声明requires(event.sink,)加载时先解析出完整的依赖图再按拓扑序实例化。发现循环依赖时直接抛异常并打印依赖图里的环而不是在运行到一半时崩在某个调用里。这一步让我少了很多为什么这个插件拿不到那个插件的排查。注意插件间不要互相引用实例要通过注册表按能力名拿。这样依赖方向永远是从插件指向框架声明的能力而不是插件之间的实体纠缠。插件生命周期我固定为五个阶段load实例化、validate配置校验、start打开连接、runtime提供服务、stop关闭连接。模型提供者的 API 连接、工具插件的 HTTP 客户端都在 start 里建立、stop 里释放避免进程退出时一堆半开连接。2.4 一个最小可用的工具插件长什么样# plugins/weather_tool.py class WeatherToolPlugin: meta PluginMeta( nameweather_tool, version0.1.0, provides(tool.weather,), requires(event.sink,), config_clsWeatherConfig, # pydantic 校验 ) def on_load(self, ctx): # ctx 是 PluginContext只能通过它拿能力 self.sink ctx.require(event.sink) self.api_key ctx.config.api_key def on_stop(self): self.sink None def execute(self, args: dict) - dict: city args[city] self.sink.emit(tool.weather.request, {city: city}) ...核心循环里永远不会出现import weather_tool它只调registry.provide(tool.weather)。这个习惯很关键核心代码对具体插件的依赖降为零替换、删掉、模拟任何插件都不碰主流程。3. 可回放会话日志把 Agent 的脑回路完整留下来3.1 传统日志对付不了 LLM 的非确定性传统日志的颗粒度放在 Agent 场景里完全不够用。某个周五运营来报Agent 在第五步调用了一个不该调用的工具。我去翻日志只看到一行tool_call search 返回成功。但真正要回答的问题是模型当时看到了什么上下文有没有被截断工具参数是谁填的回答不了这些就只能复现——而 LLM 是非确定的同一个输入第二次跑结果可能完全不同。复现不了就只能靠猜。可回放日志的第一动机就是把每一次决策的输入-输出完整留下来。不是记结论而是记全过程。3.2 事件溯源式日志一次决策 一串事件我在 Harness 里把一次 Agent 循环拆成四类核心事件链事件类型触发点必须记录的内容llm_request每次调用模型前完整 prompt、工具定义、模型参数llm_response模型返回后完整补全内容、停止原因、token 用量tool_call工具执行前工具名、参数原文tool_result工具返回后原始返回内容step_end每轮循环结束该步之后的完整上下文快照、步骤摘要再加上session_start、user_message、session_end整条时间线就是 Agent 的完整脑回路。设计时有三个硬规则seq单调递增保证顺序payload 全量保存宁可多不可少因为事后你无法再向模型询问你当时看到了什么所有外部调用必须落事件漏一条回放就断一节。3.3 格式选择、落盘策略与脱敏格式上我选了 JSON Lines 而不是 SQLite。原因有三追加写天然适配流式每行独立可以直接tail观察可压缩性很好能与日志平台做管道对接。SQLite 查询能力强但回放只需要顺序读不需要复杂查询。JSONL 唯一要防的是 schema 漂移所以schema_version从第 0 天就加上了。{schema_version: 1, session_id: sess_ab12cd, seq: 5, event_type: tool_call, ts: 2025-06-11T10:22:31.102Z, payload: {call_id: call_03, tool: search_web, arguments: {query: ...}}}落盘策略踩过一个坑一开始攒批写进程被调度平台杀掉时最后十步事件直接蒸发回放残了。改成每条事件写入、会话结束时统一fsync一次。多花一点 IO换来的是任何时刻宕机已完成的步骤都完整。脱敏同样不能省。LLM 的 prompt 里可能混着 API key、密钥、个人数据这些如果原样进日志回放日志就成了泄露源。我在事件出口层加了一个 Redactor 钩子敏感字段统一替换成[REDACTED:类型:哈希前几位]保留长度和形状信息方便调试时对照。3.4 为什么回放不等于重跑必须把两个词分开回放是按记录重演重跑是重新调用模型。最本质的区别是回放是确定性的重跑是随机的。回放引擎会骗过程序——模型提供者接口返回的是记录里那一条真实响应而不是真的去调模型。这也是为什么回放能在毫秒级跑完上千步而且每一步的结果都与线上完全一致。4. 回放引擎的三个核心机制4.1 时间线重建与断点注入回放引擎的第一步是把一个会话的所有事件按seq排序重建时间线# replay/timeline.py class Timeline: def __init__(self, events: list[SessionEvent]): self._events sorted(events, keylambda e: e.seq) self._idx 0 def next(self) - SessionEvent | None: if self._idx len(self._events): return None event self._events[self._idx] self._idx 1 return event断点是回放里最有价值的功能。我把它做成条件对象命中即暂停# replay/conditions.py class StopOn: def __init__(self, event_type: str, **filters): self.event_type event_type self.filters filters def matches(self, event: SessionEvent) - bool: if event.event_type ! self.event_type: return False return all( event.payload.get(k) v for k, v in self.filters.items() )暂停后可以检查该步的完整上下文快照也可以临时改掉某个事件再继续。这里有个实现细节我在step_end事件里直接保存了这一步之后的上下文快照回放加载快照比从零逐条拼回去快得多顺带还能校验事件链是否完整——快照对不上就说明日志有缺口直接报警。4.2 确定性保障现场还原的关键回放要成立必须消除所有非确定因素。第一是模型调用用注入替代这是核心。第二是时间工具逻辑里如果有人用了time.time()回放时对不上我会在回放模式里注入一个 Mock 时钟时间轴从会话ts起步。第三是内部随机数凡是框架自己生成的随机数种子统一从session_id派生保证同一会话结果一致。还有一个隐蔽问题Python 的字符串哈希受随机种子影响大多数场景无所谓但当你按 dict 顺序序列化事件 payload 时哈希随机化可能影响顺序导致回放结果与真实现场出现细微差异。我的做法是序列化前统一按 key 排序而不是去固定PYTHONHASHSEED——固定整个进程的哈希种子会改变哈希碰撞面有安全顾虑。铁律回放引擎绝不调用真实模型绝不执行带副作用的工具。这条约束我写进了代码注释并在假的模型提供者实现里做了强制断言防止后人优化掉确定性。4.3 三种回放模式重演、断点巡检、分支实验回放引擎实际使用时有三种模式用途完全不同模式用途行为特征重演事故复盘纯事件推进秒级跑完上千步断点巡检定位问题条件暂停检查快照继续推进分支实验如果当时……改掉某个事件后继续用真实模型往下跑分支实验是后期加的需求也是最能给团队惊喜的。某次线上事故我们怀疑是工具返回里混进了一条脏数据直接加载会话定位到tool_result事件把 payload 替换成清洗后的数据再让真实模型从这一分支继续跑一对比就确认了根因。整个过程十分钟不用重新构线上环境。events load_session(sess_ab12cd) branch Timeline(events).seek(seq7) branch.replace(seq7, event_typetool_result, payload{...}) harness Harness(live_modelTrue) harness.play(branch)5. 项目落地时的六个坑以及我的取舍5.1 坑一插件加载顺序与循环依赖循环依赖初期出现过好几次最典型的错误发生在两个插件都想互相拿对方的配置结果谁先加载都不对。解法是前文说的拓扑排序加环检测不再赘述。真正想提醒的是插件加载阶段不要 import 任何其他插件模块只保留能力名依赖。一旦你在 load 里直接import weather_tool循环依赖就从可能发生变成必然发生。5.2 坑二日志 schema 升级旧日志全部作废上线第三周我往事件 payload 里加了一个字段然后发现所有旧日志在回放引擎里解析失败。教训是即使日志只是内部用schema_version也要从第一天带着。现在每次 schema 变更都升版本号回放引擎按版本分发解析器旧日志依然可读。兼容层代码不多但少了它历史会话就是一堆打不开的 JSON。5.3 坑三回放时的副作用拦截重演模式里我们不会真正执行工具因为结果已经记录在案。但分支实验模式会继续调用真实工具这时如果工具是发邮件写数据库这类有副作用的就会造成二次影响。解法是在工具注册时强制声明effectfulTrue分支实验里遇到这类工具默认用记录结果替代只有人工显式确认后才允许真实执行。宁可牺牲一点便利也不能让调试工具变成事故放大器。5.4 坑四日志体积、脱敏与 IO 开销完整 prompt 加完整响应加工具输出一步轻松几十 KB一百步就是一个会话几 MB。如果不控制磁盘和回放性能都会告急。我的取舍是不做事件采样回放完整性优先对超大工具结果做截断策略保留前 N 个字符加哈希日志按会话文件存储按大小和时间滚动压缩。因为选了 JSONL压缩率很可观冷数据基本上压缩到原来的十分之一。IO 开销方面单条事件写盘在机械盘上确实有压力。解决思路是事件出口用异步队列写正常路径批量刷盘但在step_end和会话结束两个关键节点强制 flush。这样既保住了完整性又没有把每次模型调用都变成一次磁盘等待。5.5 坑五不要把热更新插件想得太美Python 的 import 机制决定了模块热替换很难做干净。importlib.reload只能重载模块对象但所有已经持有旧类引用的地方不会自动切换。我的结论是做配置热更新 代码冷重启插件配置可以在运行期变更换 API key、调阈值、开关按钮插件代码变更必须走正常的发布流程。真想做到代码级热加载那就得把插件放到独立的子进程里跑这是另一种量级的架构不建议从小框架起步就上。5.6 坑六插件化过度设计的边界全插件化不是把所有东西都变成插件。我的判断标准很直接如果只有一个实现且未来一年内都只会有一个实现就不做成插件。核心循环、会话 ID 生成、事件序号分配这些永远是骨架自己的事。把它们插件化只会得到一团互相依赖的抽象调试时每跳一层都要骂一次。这整套设计实际跑下来单看任何一环都不难难的是它们互相咬合。插件隔离让回放引擎可以作为一个特殊插件接进框架回放日志又让每个插件的问题能快速定位到具体版本。如果让我重来一次我会更早地把事件日志的schema_version加上也会更早地为带副作用的工具打上effectful标记。给后来者的建议是动手顺序先定事件格式和 schema再切插件边界最后做回放引擎——这个顺序能让你少走很多弯路。