最近大模型应用落地多了,我越来越觉得 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 步,然后在第 N+1 步时改成不同的 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 项目,别急着读懂全部代码,先去问“日志在哪”,优先看日志结构。日志里藏着这个系统真正的运行轨迹,一晚上就能让你对它的了解超过读三天源码。