最近大模型应用落地多了我越来越觉得 Agent 框架这事最难的其实不是把几个模型接口串起来而是怎么把它做成一个能长期维护、出了问题能查、别人能接手的东西。之前我在一个模拟项目里做了个叫 DeepSeek Harness 的调度层核心就两个设计全插件化的能力编排外加可回放的会话日志。这俩词听着有点大但本质解决的就是 Agent 工程化的两座大山——扩展性和可调试性。这篇文章把我的设计思路、踩过的坑以及关键模块的实现细节完整拆一遍给准备自己搭 Agent 框架或者正在被各种回调函数折磨的开发者一些参考。这篇文章适合三类人一是正在做 Agent 应用的开发者想看看怎么把工具、模型、记忆这些模块解耦二是被线上 Agent 行为搞到怀疑人生的调试者回放日志这套思路应该能给你不少启发三是想理解插件化架构设计的人我会从注册机制、生命周期到配置注入都讲清楚。1. 内容整体设计与思路拆解1.1 为什么 Agent 应用必须做工程化改造早期做 Agent 原型大家普遍的做法是写一个大的编排函数里面先调大模型决定要做什么再 switch-case 分发到不同的工具函数跑完后再把结果拼回去继续循环。这个原型跑通没问题但一旦进入生产环境问题就全暴露了。最直接的痛点是扩展性。每加一个新工具就得去动那个核心编排函数改完还得担心会不会影响已有逻辑。如果团队里有多个人同时要加不同的工具代码冲突几乎避免不了。第二个痛点是可观测性。Agent 的决策过程不是线性的大模型可能在某一步突然决定调用一个我们完全没预料到的工具或者陷入了死循环。这时候没有完整的执行记录排查问题就只能靠猜。第三个痛点是不可复现性。大模型本身有随机性同样的输入两次跑的决策路径可能完全不同。出了 bug想稳定复现光靠重新跑一遍是不可靠的。所以Agent 应用想从 demo 走向生产必须解决两个核心问题模块怎么优雅地插拔扩展以及执行过程怎么被完整记录和还原。DeepSeek Harness 的“全插件化”和“可回放日志”就是针对这两个问题设计的。1.2 Harness 在 Agent 架构中的定位Harness 这个词在 Agent 领域里不是个新概念你可以把它理解成马的缰绳和挽具。马本身是能力大模型但让它拉车、犁地你得有个能把力传导出去、同时能控制方向的装置这就是 Harness。在我设计的这个框架里Harness 不直接提供业务能力它管的是四件事模型的接入、工具的注册与调度、会话上下文的维护、以及执行过程的记录。业务逻辑全部由插件承担Harness 只做编排和转发。我更倾向于把它拆分成五个核心模块核心调度引擎负责 Agent 的主循环决定下一步是继续推理、调工具还是结束。插件管理器负责工具的加载、卸载、调用路由和依赖注入。会话状态存储维护当前的对话上下文、中间变量和任务状态。日志采集器把每一次模型调用、工具调用、决策点都记录成结构化事件。回放引擎读取日志把一次完整的 Agent 执行过程重新跑一遍或分析一遍。这五个模块各司其职而插件化设计和日志设计分别决定了框架的“宽度”和“深度”——宽度决定了它能接多少能力深度决定了你能在多大程度上理解它的内部行为。2. 核心细节解析与实操要点2.1 插件化设计的三层结构插件系统听起来玄乎但说穿了就是“接口 注册表 加载器”三层结构。不管用什么语言实现核心逻辑都逃不过这三层。第一层是接口定义层。所有工具类插件必须实现同一个接口比如我定义了一个ToolPlugin接口它要求插件必须返回名字、描述、参数 schema以及一个execute()方法。这个接口是框架与插件之间的契约契约定得越稳定后续的扩展就越轻松。第二层是注册表层。框架启动时会扫描配置好的插件路径把找到的插件类实例化并注册到内存表里。注册表里存的不只是实例还包括插件声明的元信息——比如它的依赖、它的权重、它是否异步支持等。第三层是调用路由层。大模型决定调用某个工具时Harness 根据工具名去注册表里寻找对应的处理器校验参数然后执行。执行结果再格式化成模型能理解的格式返回给主循环。这里有一个关键的设计决策插件之间不允许直接调用。所有通信必须经过 Harness 的调度引擎。这会带来一点性能损耗但好处是极大的——每个调用点都可以插入日志、监控、权限校验而且插件之间彻底解耦。2.2 插件注册与生命周期管理插件的生命周期管理是插件化设计里最容易出错的地方。很多人做插件系统只做“加载”和“调用”忽略了“销毁”和“优雅退出”。我设计的插件生命周期有五个阶段加载Registered、初始化Initialized、启用Enabled、暂停Suspended、销毁Destroyed。每个阶段都有对应的钩子方法插件可以实现这些钩子来做资源的申请和释放。比如一个需要建立数据库连接的插件在onInitialize()里建连接池在onDestroy()里关闭连接池。如果开发者在插件里开了线程、建了连接但没实现销毁钩子线上跑一段时间就会出现资源泄漏。注册表的实现细节也值得一提。我用的是一个双重 Map 结构class PluginRegistry: def __init__(self): self._plugins {} # 插件名 - 插件实例 self._routes {} # 工具名 - 插件名 self._dependency_graph {} # 插件名 - 依赖的插件名列表_routes解决的是“工具名到插件实例”的映射问题。大模型返回call: search_web调度引擎就知道要去_routes里找search_web对应的处理器。而_dependency_graph解决的是插件的依赖安装顺序问题。比如搜索插件依赖网络模块注册的时候框架会先实例化网络模块再实例化搜索插件按拓扑顺序来避免出现插件 A 在初始化时要用到插件 B 的资源但 B 还没加载好的情况。2.3 配置系统的设计考量插件化框架另一个容易翻车的点是配置管理。插件的配置如果各自为政每个插件都去读环境变量或配置文件最后一定乱套。我的做法是构建一个统一的配置中心。框架启动时读取一份全局配置按插件的命名空间切分下发。每个插件只能访问自己前缀下的配置项。这么做的好处有三个一是配置集中管理部署时只需要维护一份配置二是隔离性插件 A 读不到插件 B 的配置降低了耦合三是支持动态更新配置中心可以在运行期刷新插件通过订阅者模式感知变化。比如搜索插件和数据库插件的配置就会是这样plugins: search: enabled: true timeout_seconds: 30 max_results: 5 database: enabled: true pool_size: 10 connection_string: postgresql://...Harness 启动时会把plugins.search这个前缀下的配置项包装成一个只读对象注入到搜索插件里去。插件代码不需要关心配置来自哪里只需要接受依赖注入即可。配置注入的逻辑一定要写在插件的构造函数里或者通过独立的 setter 传入千万不要让插件自己去读全局配置——否则你又要和全局变量搏斗了。3. 实操过程与核心环节实现3.1 从零实现一个最小插件骨架实践出真知拿我框架里的一个内存搜索插件举例。它实现一个语义检索能力内部用一个轻量向量库来存储文档。它的接口实现是这样的class MemorySearchPlugin(ToolPlugin): plugin_name memory_search description 在内部知识库中执行语义检索返回相关文档片段 parameters_schema { type: object, properties: { query: {type: string, description: 查询语义描述}, top_k: {type: integer, description: 返回条数, default: 3} }, required: [query] } def on_initialize(self, context): # context 里有配置中心下发的配置、日志客户端等基础设施 self.embedder context.get_embedder() self.vector_store context.create_vector_store() return True def execute(self, args): query_vector self.embedder.encode(args[query]) top_k args.get(top_k, 3) results self.vector_store.search(query_vector, top_k) return [{score: r.score, content: r.content, source: r.meta.get(source, )} for r in results] def on_destroy(self): self.vector_store.close()这个插件的写法有几个硬性注意点execute()必须返回可 JSON 序列化的数据。因为框架要把工具结果格式化成语义文本送回给模型JSON 是最省事的中间格式。on_initialize()必须做幂等操作。因为插件可能被热重启重复初始化时要保证不会创建两份连接池。参数 schema 要尽量写详细。大模型是根据 schema 来理解应该填什么参数的schema 写得马虎模型的调用准确率就会下降。3.2 会话日志事件溯源思想在 Agent 中的应用可回放会话日志是我在这个项目里花时间最多的部分也是我认为价值最高的一块。它的设计理念直接来源于分布式系统里的事件溯源Event Sourcing。事件溯源的核心思想非常简洁不保存当前状态只保存导致当前状态的事件序列。任何时候想知道系统的当前状态就把事件从头到尾重放一遍。应用到 Agent 场景就意味着我们不去保存“最后的对话上下文”而是把 Agent 执行过程中的每一个关键节点——用户消息进来、大模型发起推理、模型决定调用工具、工具返回结果、模型生成最终回复——全部作为不可变的事件追加写入日志存储中。要恢复上下文通过事件回放还原想分析决策轨迹看事件序列本身。这个思路在 Agent 调试里价值巨大。线上环境的 Agent 状态不可控因为大模型是随机的。你永远不知道它走到了哪条分支。有了事件日志就能精确知道这个 Agent 做过什么、看到了什么、基于什么做了下一步决策。从“黑盒”到“白盒”的跨越就靠这个东西。3.3 日志结构与序列化方案日志结构我采用了一个有版本号的事件信封格式{ event_id: evt_01J2..., session_id: sess_01H8..., sequence: 42, timestamp: 1735790663.4821, event_type: tool_result, agent_step: 7, payload: { tool_name: memory_search, tool_call_id: call_xyz123, duration_ms: 83, result_summary: 返回3条结果最高score 0.912, result_full: [{\score\: 0.912, \content\: \...\}] }, metadata: { model: deepseek-chat, framework_version: 0.4.1, trace_id: trace_ab12 } }设计这个结构时有几个明确考量。sequence是单调递增的序号用来解决并发或乱序问题。即使同一个会话被并发写入回放引擎也能根据序号恢复顺序。agent_step表示这是 Agent 主循环的第几步用于区分同一轮对话里的多次工具调用。event_type标识事件类型常见的类型有user_message、model_call、tool_call、tool_result、model_response、system_error等。payload和metadata分离payload 是业务数据metadata 是基础设施数据。序列化方案上我直接用了 JSON 而不是 JSONL 加变长分隔符每一行是一个完整事件。JSONL 的好处是支持流式追加和逐行解析日志文件损坏时最多丢掉一行不影响整体回放。单条事件里可以做一层 Gzip 压缩尤其是对工具返回的大段内容压缩率很可观。3.4 回放引擎的实现与分支重放玩法回放引擎是日志系统的消费端。它读取 JSONL 文件按session_id分组按sequence排序然后分批把事件喂给用户接口。回放模式下不真正调用外部工具工具结果直接从日志里读取这就是“重放”与“重跑”的本质区别。这个特性对调试和评测非常有用。比如一个工具在线上偶尔报错你想知道是为什么让 Agent 重跑一遍是不行的——这次它可能就绕过这个工具了而回放则忠实还原当时的每一步。你可以在回放过程中注入断点在特定sequence处暂停检查当时的上下文状态然后手动“掰”一下后续的决策路径继续执行——我把它叫做“分支重放”。分支重放在评测场景里是个杀手级功能。给 Agent 一批预录好的会话日志重放前 N 步然后在第 N1 步时改成不同的 prompt 前缀观察模型的决策差异就可以用来评估 prompt 变化对 Agent 行为的影响而且完全不需要真实调用模型 API。成本极低结果可控。3.5 日志接入的埋点策略要想日志有复盘价值埋点必须有讲究。刚开始我做这事时只记录了模型调用和工具调用结果回放时完全看不出 Agent 为什么会做某个决策。后来我补了“思考过程”和“触发路由”两类的中间状态埋点。所谓“思考过程”不是指模型的隐藏推理层而是指模型返回给框架的中间决策信息比如thought字段。如果 LLM 支持结构化输出我还会记录模型解析前的原始内容和解析后的结构化值方便定位“解析挂了”的问题。触发路由埋点记录的是框架内部的逻辑判断为什么走到这一步命中了什么条件走了哪条分支这种日志在调试时几乎是救命稻草。每次排查问题我都先搜event_type: router看 Agent 的执行路径十有八九一眼就看出问题出在哪。4. 常见问题与排查技巧实录4.1 插件加载导致循环依赖这是插件化框架里最常见的问题。有一个插件要调用另一个插件的能力设计时 A 依赖 B后来 B 的新版本又反向依赖 A加载时直接报栈溢出。排查方法也比较直接——在注册中心的依赖图构建处打日志看每插入一个节点时的依赖关系。如果是复杂项目可以引入拓扑排序来检测环路。我把这个写进了框架启动自检一发现有环直接启动失败并输出环路上所有的插件名方便开发定位。经验之谈插件之间依赖越少越好。如果插件 A 需要 B 的能力我建议把那个能力抽象成一个基础服务而不是让 A 直接依赖 B。Harness 提供一组基础服务接口向量检索、HTTP 客户端、缓存等插件专注于业务逻辑把通用能力下沉到框架层根本不会出现循环依赖。4.2 日志文件无限膨胀日志设计得越完善日志量就越大。工具返回的原始 payload比如一次网页抓取返回的内容可能有几 KB 甚至几十 KB。量一上来磁盘就报警。我的方案是分级存储小事件比如模型调用超时、路由决策完整落盘大事件工具返回全文只落摘要和截断后的内容全量内容可选存到对象存储里或者干脆设一个阈值超过 8KB 的部分直接丢弃。回放日志联调时如果回放发现某一步工具结果对后续决策有影响而你又需要完整的原始内容那只能从对象存储里取出那一份。这种设计和参数调优思路一样——能用摘要就绝不用全文全文是最后的选择。4.3 回放与重跑的偏差问题回放模式下我们复用的模型有随机性所以即使完全相同的输入和日志第二次重放也可能走出不同的分支。这在回放引擎里很难消除。我采取的折中方案是“固定随机种子 温度归零”。如果回放时你只是想让 Agent 按原路走就把采样温度设为 0并固定随机种子。这样大概率能复现原来的决策路径但不是 100% 保证。要完全保证就得修改模型调用逻辑让它在发现位置匹配的日志事件时直接跳过模型推理、采用日志中的结果。这种方法我们把“日志模式”和“实时模式”二选一。做评测时我通常跑三组实验纯回放不看模型输出、温度归零回放看模型是否走出同一条路、实时模式跑新生成结果。三组对照着看才能确定哪些行为差异是模型随机性带来的哪些是代码改动造成的。4.4 问题速查速解表现象可能原因排查步骤解决方案插件加载失败但无任何日志插件类继承了错误的基类检查接口签名是否匹配基类变更后重新编译插件包调用工具时报参数错误模型生成的参数不符合 schema查看模型调用事件中的原始参数优化 schema 描述显式指出枚举值回放时上下文不完整日志丢失了某些中间事件按 session_id 扫描 sequence 是否有空洞在采集端补齐会话开始/结束标记插件启用了但外部服务不可达插件初始化在依赖服务就绪之前检查 on_initialize 里是否主动探测初始化时增加阻塞重试逻辑回放非常慢大事件逐条解压耗时看整体日志大小和事件数量将大事件移出热路径仅引用索引5. 关于 Agent 框架后续扩展的个人心得整体跑下来我对这套架构最深的一个体会是插件化和日志回放看似是两件事实际上是一件事的正反面。插件化解决了扩展的“广度”让所有新能力以统一的方式接入避免了改核心代码的恐怖而日志回放解决了维护的“深度”让任何一次线上问题都能像看电影一样倒回去分析。两者结合才能支撑起一套可持续演进的 Agent 框架。后续我打算做两件事第一把回放引擎接入到自动化评测流水线每天固定回放一批典型任务日志校验新版本的框架有没有破坏既有行为第二在插件管理器里支持热加载新插件不重启主进程目前实现已经基本完成等稳定后再分享细节。最后再分享一个小技巧如果你接手了一个别人的 Agent 项目别急着读懂全部代码先去问“日志在哪”优先看日志结构。日志里藏着这个系统真正的运行轨迹一晚上就能让你对它的了解超过读三天源码。