前段时间给一个 Agent 开源框架做工程化改造,我最大的体会是:让 Agent 跑起来不难,难的是让它在一个清晰的工程结构里稳定地跑、可插拔地扩展、出问题时还能把整个过程像放录像一样重放一遍。这篇想拆的,就是这个框架里最值得借鉴的两块设计——全插件化架构,以及基于会话日志的可回放机制(社区里通常把支持这层能力的运行外壳叫 Harness)。
如果你正在做 Agent 框架、智能体中间件,或者编排层相关开发,这篇文章应该对你有用。如果你还停留在“写个循环调模型、注册几个工具函数”的阶段,那更建议读完,因为这套设计能把你的视角从“功能能不能通”拉到“工程能不能撑得住”。
1. 先厘清 Harness 的定位:它管的是“Agent 怎么跑”,不是“Agent 想什么”
1.1 Agent 裸跑的三个隐患
任何一个 Agent 应用,核心循环都非常简单:接收消息,调大模型,如果模型要求调用工具就去执行工具,把结果回填到上下文,继续下一轮,直到模型给出最终答案。这个循环本身不难,难的是循环外围那一圈工程问题:
- 上下文窗口怎么管,超出长度怎么办;
- 工具返回的数据要不要校验,“模型幻觉出来的工具调用”谁来拦;
- 某一步异常之后,整个会话是重试、降级还是直接失败;
- 线上出问题时,怎么知道当时模型看到了什么、工具到底返回了什么。
如果这些问题全部散落在业务代码里,项目基本活不过第三个月。我见过不止一个项目,前两周 Demo 跑得很欢,第三周要接第二个模型供应商时开始改核心循环,第四周要加新工具时发现工具全部硬编码在一个巨大的 switch 里,第五周线上出问题只能靠人肉盯着终端日志猜。这些症状的本质,不是代码写得差,而是缺了一层专门负责“运行环境”的代码。
Harness 要解决的,正是这层问题。它不替 Agent 做决策,不关心你的提示词写得有多花哨,它只保证:模型调用能被发出去,工具能被安全找到和执行,上下文能被统一管理,每一步运行都能被记录和回放。换句话说,策略层负责“Agent 想什么”,Harness 负责“Agent 怎么跑”。
1.2 三层职责表:策略层、运行时层、插件层各管什么
当时我们做框架重构,最重要的一次决策,就是把“Agent 的策略”和“Agent 的运行环境”彻底分开,然后基于这个边界继续拆出插件层。拆完之后职责非常清晰:
| 层 | 核心职责 | 不应该做的事 |
|---|---|---|
| 策略层 | 提示词模板、工具选择逻辑、规划与反思策略 | 不直接发起模型 HTTP 请求 |
| Harness 运行时 | 模型适配与调用、工具注册与调度、上下文管理、事件总线、日志与 Replay 支持 | 不写死任何业务提示词 |
| 插件层 | 具体模型供应商适配、具体工具实现、日志存储后端、外部回调 | 不感知 Agent 内部状态机 |
这个边界带来的直接好处,是各层可以独立演进。策略层换一套新的 Agent 玩法,Harness 不需要动;接入一个新的模型供应商,只需要新增一个插件;日志想从本地文件换成对象存储,也只动插件实现。早期项目里那种“改一个工具要重启整个服务、加一个供应商要翻核心代码”的窘境,就是从有了这层边界之后彻底消失的。
打个比方,策略层是司机,Harness 是车架和底盘,插件是外接设备。司机决定往哪开,底盘保证车能稳定跑,外接设备该插就插、该拔就拔,不用为了换一个导航仪去重新焊车架。这个类比虽然粗糙,但很能说明问题:底层稳定性与上层灵活性,必须通过职责隔离来同时获得。
2. 全插件化设计的落地细节:扩展点、插件契约与装配流水线
2.1 哪些能力可插拔,哪些必须内置
全插件化最容易翻车的地方,是恨不得把所有机制都做成插件。我们定的原则很朴素:凡是会因为接入方不同而变化的能力,做成插件;凡是框架自身运行必须保持一致的能力,必须内置。按这个原则,最终划分如下。
可插拔的扩展点:
- 模型适配器:不同模型供应商的协议、鉴权、参数映射;
- 工具注册表:具体工具怎么被发现、校验、执行;
- 记忆策略:上下文如何压缩、摘要、持久化;
- 日志存储后端:事件写到哪里,JSONL、对象存储还是数据库;
- 外部回调:事件发生后要不要通知外部系统。
强制内置的能力:
- 插件加载与隔离机制本身;
- Session 上下文管理与生命周期;
- 事件总线和事件 Schema 版本管理;
- 安全拦截器(比如工具参数白名单、敏感操作确认);
- 启动自检与失败快速报告。
这么划分的原因很简单:插件机制是框架的地基,地基不能自己变成可替换的零件;安全拦截器和事件总线是横切关注点,一旦允许各插件各搞一套,整个系统的可观测性就会碎成一片。记住一件事:插件化的目的是降低接入成本,而不是把框架的核心稳定性也变得可配置。
2.2 插件契约和生命周期钩子
插件不是随便实现一个接口就完事,真正好用的插件体系,一定有一套完整的契约。我们在框架里定义的插件契约包含两部分:插件清单和生命周期钩子。
插件清单描述“这个插件是谁”:名称、版本、所依赖的核心 API 版本、提供哪些能力点。生命周期钩子描述“这个插件怎么活”:加载、初始化、会话开始、会话结束、卸载。核心接口大致长这样:
public interface HarnessPlugin { // 插件清单:名称、版本、依赖的核心 API 版本、能力点列表 PluginManifest manifest(); // 类加载完成后,做静态自检,比如检查配置项是否齐全 void onLoad(PluginContext ctx); // 装配阶段,获取配置文件里的 options,初始化连接等重量级资源 void onInit(PluginContext ctx); // 一次会话开始时触发 void onSessionStart(SessionContext session); // 一次会话结束时触发,用于清理会话级资源 void onSessionEnd(SessionContext session); // 框架关闭时触发 void onShutdown(PluginContext ctx); }这里有一个非常容易被忽视的点:生命周期钩子的语义必须定义得足够具体。比如 onInit 和 onLoad 的区别,很多插件作者会搞混。我们的约定是,onLoad 只做与类加载相关的静态检查,不产生任何外部副作用;onInit 才允许建立连接、开线程、申请资源。这样框架才能在启动阶段先快速失败,避免“配置写错了,但服务起来之后第一次调用才炸”的情况。
另外,插件清单里的“依赖的核心 API 版本”一定要在装配时做硬校验。否则就会出现插件 A 按旧版事件格式写日志、插件 B 按新版格式读日志,两边的 Schema 对不上,回放直接乱掉的情况。版本校验这种脏活,宁可前置到启动阶段,也不要留到运行期。
2.3 配置驱动的装配与启动自检
装配阶段我们走的是“配置驱动”而不是“代码注册”。框架启动时读一个统一的装配文件,按照声明顺序把插件一个个加载起来。配置长这样:
harness: plugins: - name: model-adapter-openai version: 1.2.0 class: io.harness.plugins.model.OpenAIAdapterPlugin options: baseUrl: https://api.example.com/v1 timeoutMs: 30000 - name: tool-registry-http version: 0.9.1 class: io.harness.plugins.tools.HttpToolRegistryPlugin options: timeoutMs: 3000 allowPrivateNetwork: false装配流水线是固定的五步:解析配置、校验清单版本、按独立类加载器加载、实例化并注入 PluginContext、调用 onInit 做资源初始化。任何一步失败都直接中止启动,并输出一份包含“哪个插件、哪个配置项、为什么不通过”的报告。
这五步里最容易出幺蛾子的是版本校验和选项注入。版本校验要检查插件依赖的核心 API 版本是否在框架支持范围内;选项注入则要处理类型转换,比如字符串形式的超时时间转成整数。我们曾经因为一个插件把 timeoutMs 写成字符串 “3000” 而另一个插件写整数 3000,导致同一个配置项在不同插件里语义不一致。后来统一规定:所有 options 里的数值型配置只允许整数或浮点,框架层统一做类型强转和范围检查。
提示:配置驱动不是目的,目的是“装配过程可审计”。不要觉得写死在代码里更简单。当你的框架要接入几十个插件时,一份能 diff、能版本管理、能回滚的装配文件,远比散落在代码里的 new XxxPlugin() 靠谱。
3. 可回放会话日志:把一次 Agent 运行变成一沓可重演的事件
3.1 从 printf 调试到事件流沉淀
很多 Agent 项目的日志,本质上是“人肉可读的流水账”:某年某月某日,模型返回了什么,工具执行了什么。这种日志在调试时能用,但只能看,不能算,不能重演。我们做的第一件事,就是把日志从“打印文本”升级成“结构化事件流”。
所谓事件流,就是把 Agent 一次会话里发生的所有关键动作,按时间顺序、以追加方式写成一条条结构化事件。事件类型是固定的,字段是统一的,每一条事件都携带会话 ID、序号、时间戳和请求 ID。这样一份日志,既是一份审计记录,又是一份可供程序反推状态的“黑匣子”。
实际落地时我们定义了以下几类核心事件:
| 事件类型 | 关键字段 | 说明 |
|---|---|---|
| session_started | 会话 ID、初始上下文、启动插件列表 | 一次会话的起点 |
| user_message | 消息内容、附加元数据 | 用户输入 |
| model_requested | 模型名、参数、提示词摘要、token 预估 | 模型调用请求 |
| model_resolved | 输出内容、token 用量、耗时 | 模型返回结果 |
| tool_invoked | 工具名、参数、调用链 ID | 开始执行工具 |
| tool_returned | 返回数据摘要、状态码、耗时 | 工具执行结果 |
| error_raised | 错误类型、堆栈摘要、恢复动作 | 异常与处理方式 |
| session_ended | 结束原因、总耗时、总 token | 会话终点 |
一条典型的事件长这样:
{ "seq": 17, "time": "2025-06-01T10:12:03.128Z", "session_id": "sess_8f3a", "event": "tool_returned", "request_id": "req_102", "schema_version": "1.4.0", "payload": { "tool": "http_get", "args_summary": {"url": "https://api.example.com/items/42"}, "status": 200, "latency_ms": 312, "result_size_bytes": 2048 } }这里有个很关键的设计:事件里的字段名要面向“回放消费”来命名,而不是面向“给人看”来命名。比如 tool 的参数我不会把整个超大 payload 写进日志,而是写 args_summary 和 result_size_bytes,需要完整内容时再去对应的存储里拿。这既保证了事件流足够轻,又保证了关键信息不丢。
3.2 回放引擎的两种模式:状态重建与行为重演
有了事件流,回放就有了基础。回放引擎我们实现了两种模式,各自解决不同的问题。
第一种叫状态重建。给定一个会话 ID,读它的事件列表,按顺序重放,最终还原出这个会话在任意一步时的状态。它速度快,适合回答“当时这个会话进行到第几步了、模型看过什么、为什么最后走偏了”这类问题。实现上就是一个带索引的折叠操作,把每一步的事件 payload 填回一个会话上下文对象里,本质上是事件溯源的经典玩法。
第二种叫行为重演。它不是简单地重建状态,而是真的把 Agent 再跑一遍,用日志里的信息来约束边界条件。跑的时候,模型调用可以被真实调用也可以被日志中的结果替代,工具调用一律不真正执行,而是直接注入日志里记录的返回结果。这样你就能重现“当时为什么模型会走这条路”,甚至可以在中间某个节点篡改一个工具返回值,看看 Agent 会不会走向不同分支。这有点像调试器里的单步执行,只是断点粒度从代码行变成了“模型调用”和“工具调用”。
行为重演想做得靠谱,必须处理确定性。具体来说有三件事:第一,工具调用不真跑,用日志里的返回结果注入;第二,系统时钟做偏移控制,日志里记录的是相对时间,重演时用一个虚拟时钟替代真实时间;第三,所有并行工具调用在日志里要有严格的 causal order 序号,重演时按序号恢复并发顺序。这三件事都做到,重演结果才不会和原会话“漂”得面目全非。
注意:行为重演永远无法保证 100% 复现,因为模型本身有采样随机性。所以我们把重演的目标定义为“复现决策路径”,而不是“复现逐字输出”。逐字复现只对温度参数为 0 的场景有意义,在工程上不要强求。
3.3 事件 Schema 版本与存储选型
事件流一旦进入生产环境,Schema 版本问题就会找上门。我们的做法是:每条事件都携带 schema_version,每个会话文件头部写一行 meta 事件,声明这条会话日志用的完整 Schema 版本。升级 Schema 时只允许新增字段和新增事件类型,不允许改名、改类型、删字段。旧版本日志永远可以按旧 Schema 读取,新代码遇到未知字段直接跳过。
存储选型我们分了三个场景。开发环境直接写本地 JSONL,一行一个事件,方便 grep 和手动翻阅;生产环境写对象存储,按天分目录,配 gzip 压缩;需要按条件查会话时,把元数据索引到 SQLite 里,正文日志仍留在对象存储。这套组合的成本很低,但查询和归档能力都够用,不需要一上来就上重型数仓。
4. 实测中的三个深坑:插件类加载冲突、回放漂移与日志爆炸
4.1 插件依赖冲突:两个库版本在同一个运行时里打架
插件化做得越彻底,依赖冲突来得越快。我们第一次遇到典型事故是:插件 A 依赖了某个 HTTP 客户端库的 2.x 版本,插件 B 依赖同一个库的 3.x 版本,两个插件在同一个运行时里加载后,B 在运行时调用了 A 加载的同名类方法,直接 NoSuchMethodError。最麻烦的是这种错误往往不是启动时就报,而是某个边缘路径触发时才崩,排查成本非常高。
解决思路是给每个插件一个独立的类加载器,采用“父加载器最后委派”策略:插件自己的类优先,框架核心类始终从父级拿,第三方依赖尽量隔离。同时维护一份“共享依赖白名单”,只允许框架核心 API、日志库、JSON 序列化库等少数基础设施共享,其余依赖一律插件自己带。
这套方案不是万能的,但能把“莫名其妙的类冲突”变成“启动时就报的插件清单冲突”。后者至少能明确告诉你:插件 A 和插件 B 需要同一个库的互不兼容版本,请你二选一,或者换版本。这种可诊断性,本身就是工程价值。
4.2 回放状态漂移:外部响应和时间是最大的不确定源
可回放日志刚上线时,我们遇到一个很打击人的问题:同一个会话,重演第一次和第二次,走到第三步就开始对不上。排查下来发现三个“漂移源”:
| 漂移源 | 原因 | 对策 |
|---|---|---|
| 外部工具响应变了 | 重演时真去调用外部 HTTP 接口,接口侧数据已变化 | 重演时工具永不真跑,统一注入日志记录 |
| 系统时钟影响逻辑 | 提示词或工具依赖当前时间 | 记录相对时间偏移,重演时使用虚拟时钟 |
| 并行事件顺序不定 | 并发工具返回的先后不可控 | 写日志时统一分配全局单调递增序号,按序号恢复顺序 |
这三个坑里,工具响应变化是最隐蔽的。你以为日志里已经记录了返回结果,重演时只要读它就够了,但代码里如果任何一个路径漏掉了“重演模式不许真调工具”的开关,就会偷偷发出去一次真实请求,拿到一个新结果,整个重演就从这里开始分叉。所以我们在框架层做了一个强制手段:重演模式下,工具执行器直接替换成一个 Mock 执行器,物理上不给业务代码真调的机会。宁可牺牲一点灵活性,也不要靠代码自觉。
4.3 日志膨胀与敏感信息:回放的代价要提前算清楚
全量记录每一次模型请求和工具返回,数据量是很恐怖的。我们做过一次粗略估算:单轮模型调用加工具往返大概产生 5KB 到 20KB 事件数据,一个 20 轮的会话就是 100KB 到 400KB;如果一天跑 1000 个会话,原始日志就是 100MB 到 400MB。压缩后能降 60% 到 70%,但长期下来存储成本依然可观。
应对办法是分策略:完整事件全量写,但只保留 7 天;超过 7 天的,剥掉 payload 里的正文内容,只保留摘要字段和统计信息;再超过 30 天的,直接归档到冷存储。敏感信息则在写入前走一遍脱敏管道,默认对疑似密钥、手机号、身份证号做字段级掩码,必要时做字段级加密。脱敏原则不是“尽量不记录”,而是“能记录摘要就绝不记录全文”,因为日志一旦丢失,就是数据安全事故。
5. 复用价值:从单 Agent 调试到多智能体编排排查
5.1 多智能体下的会话树与跨 Agent 因果链
这套设计在单 Agent 场景下已经很好用,但真正放大它的价值,是迁移到多智能体编排场景之后。多智能体系统里,一个主 Agent 会派生子 Agent,子 Agent 之间还会互相传递结果,靠人眼去串几份独立会话日志,基本不可能。
我们的做法是给会话日志增加一个 parent_session_id 字段,子会话在 session_started 事件里声明自己的父会话。回放时不再只重放单个会话,而是重放一整棵会话树:从主 Agent 的视角往下钻,可以看到它派出了哪个子任务、子任务拿到了什么结果、结果又怎样影响了主 Agent 的下一步决策。这种“跨 Agent 因果链”的回放能力,在多智能体调试里价值极高,因为大部分线上诡异问题都不是单点故障,而是多个 Agent 之间的信息传递出了问题。
5.2 还能照抄这套思路的场景:评测回测、回归测试与审计
除了多智能体编排,还有三个场景可以直接复用这套思路。
评测回测是最直接的。过去做 Agent 评测要准备一堆测试用例,现在可以直接把真实会话日志当成“黄金轨迹”:回放一遍日志,检查关键节点的决策是否符合预期,相当于拿生产数据做回归。这比人工构造用例真实得多。
回归测试是另一个受益者。每次框架升级插件版本后,跑一批历史会话的重演,对比工具调用序列和错误分布,就能快速发现“这次升级把哪个决策路径搞坏了”。我们吃过大亏之后,这条回归链路已经成为每次发版前的必修课。
审计场景则是意料之外的收获。因为事件流是追加式、结构化的,天然适合回答“这个会话当时对哪些工具发起了调用、调用参数是什么、谁触发了一次敏感操作”这类合规问题。很多时候不需要额外开发,只要把事件流接入查询系统就能交付。
最后说点我个人的体会。在做这套改造之前,我一直觉得 Agent 框架的竞争力在算法和提示词上;做完之后我意识到,真正决定一个框架能不能被团队长期使用的,往往是运行时设计和可观测性。插件化降低的是接入成本,回放日志降低的是排查成本,这两样加起来,才让一个 Agent 系统从“能演示”变成“能上线”。如果哪天你的 Agent 项目也到了“功能全对但不敢上线”的阶段,不妨从 Harness 这一层下手看看。