做Agent开发大半年,我最大的感受是:模型本身的智商已经不是瓶颈,真正拖后腿的往往是工程层。一套Agent框架如果缺少一个“编排层”,再聪明的模型也会在多轮工具调用里退化成无法调试的黑盒子。最近我在深入梳理一套命名为Harness的Agent框架工程,发现它的两个设计非常戳中痛点:一个是组件的全插件化,另一个是可回放的会话日志。前者解决“改一处崩一片”的耦合问题,后者解决“上线之后根本说不清Agent当时为什么这么干”的追溯问题。这篇文章就是我对这两个设计的完整工程化解剖,适合正在搭Agent平台、或者在业务里被多步骤工具调用折磨过的同学参考。
1. Agent失控的本质:模型决策与外部状态的错配
1.1 模型只会“说话”,不会“做事”
要理解Harness这个层到底在解决什么,先要接受一个基本事实:大模型本质是一个文本生成系统,它既不擅长精确执行外部操作,也不具备事务性。让它“查一下天气然后订机票”,它不会直接调用天气API或机票接口,它只是生成一段看起来合理的JSON或自然语言指令。真正把这些指令翻译成真实调用、把返回值再塞回上下文、再根据结果决定下一步的,是框架层的事。
很多人一开始写Agent就是纯循环:把用户问题拼进prompt,调模型,解析输出,发现需要调用工具就手动执行,再把执行结果拼进消息历史,继续下一轮。这个写法在demo阶段完全没问题,跑通两三个工具非常快。但一旦进入生产,问题就暴露了:模型输出偶尔不合法、工具超时、外部API返回了意外结构、某一轮上下文被撑爆……这些异常散落在代码各个分支里,日志打得再密也拼不出完整因果链。最痛苦的是,你无法回答一个最基本的问题:“这一轮Agent到底基于哪些信息做出的这次工具选择?”
1.2 Harness层到底在管哪几件事
Harness这个名词在Agent语境里,我理解它是模型和真实世界之间的“控制面”。它不负责模型效果,也不负责业务逻辑,它管的是:控制流怎么走、工具怎么被调度、上下文怎么维护、每一步怎么被记录。
具体拆开,Harness至少有四件事要做。第一是调度控制流,决定什么时候该调工具、什么时候该终止,而不是简单循环N次。第二是输出解析与校验,把模型的自由文本转成结构化工具调用参数,参数不符合schema时要有重试或纠错策略。第三是工具生命周期管理,工具的加载、初始化、超时控制、异常归一化、并发限制都得统一管理,不能让每个工具开发者各自为政。第四是审计记录,把一次会话从输入到最终输出的完整决策路径落盘,做到可回放、可对比、可复盘。
打个比方,模型像决策者的大脑,Harness则是连接大脑和四肢的神经系统外加一个黑匣子记录仪。大脑负责“想”,神经系统负责“做”,黑匣子负责“事后还原”。没有神经系统,大脑的指令只是纸上谈兵;没有黑匣子,做得再多也说不清过程。
1.3 为什么插件化是必然选择
当Harness管的事变多,抽象就不可避免。工具是插件、模型服务是插件、记忆存储是插件、日志记录是插件、输出解析器也是插件。理由很朴素:Agent场景的变化速度远快于框架本身。
今天你可能只接一个模型厂商的接口,明天要同时支持本地模型和云端模型;今天工具只有两个查询类API,后天要加一个带审批流的内部系统;今天日志写本地文件,下周大概率要接日志平台。如果这些能力被硬编码在主干代码里,每次变更都是一次牵一发动全身的重构。我见过一个很典型的老项目,支持四种工具,主干函数里十几个if分支,每个分支带着自己的超时、重试和日志逻辑,后来要加一个工具,改动波及了三个文件。这不是代码能力问题,是抽象边界没划好。
插件化的核心价值是稳定边界:主干框架只依赖插件接口,不依赖具体实现。新增一个工具,不需要改框架;换一个模型供应商,也不触碰工具层。这个原则听起来像老生常谈,但在Agent这种编排逻辑极其密集的系统里,边界清晰带来的收益会被放大很多倍。
2. 全插件化的落地细节:接口、注册表与两阶段加载
2.1 一切皆插件的接口设计
接口设计是插件化的地基。接口太粗,插件实现会互相踩脚;接口太细,写一个插件要实现十几个方法,谁都不想写。我采用的思路是:每个插件只负责一类职责,用Python的抽象基类或Protocol来定义契约。
以工具插件的接口为例,最小集其实很小:一个名字、一个执行方法、一个参数schema。执行方法接收统一的上下文对象和参数字典,返回统一的结果对象。
from abc import ABC, abstractmethod from dataclasses import dataclass, field @dataclass class ToolResult: ok: bool data: dict error: str | None = None class ToolExecutor(ABC): name: str = "" description: str = "" @abstractmethod def execute(self, ctx, params: dict) -> ToolResult: """执行工具调用,返回统一结果"""有人会问,为什么参数不直接用JSON Schema,而是要一个params dict?因为模型输出的工具调用参数本身就是JSON,直接传dict能减少序列化损耗,也让执行端可以用统一的schema校验库。返回结果用ToolResult包一层,是为了把“业务数据”和“执行状态”分离:工具内部失败不应该抛异常导致整个会话崩掉,而是返回一个ok=False的结果,由Harness来决定是否重试、换策略还是直接结束。
模型服务插件也一样抽象。不同的模型供应商,API风格完全不同,但Harness需要的是统一的生成入口:给它消息列表和参数,它返回文本和用量统计。插件内部可以做协议转换,对外保持接口稳定。
2.2 注册表与两阶段加载机制
接口定了之后,下一步是让框架能发现插件。我见过几种做法,最简单粗暴的就是用模块导入时执行装饰器注册,把插件类登记到一个全局注册表。
TOOL_REGISTRY: dict[str, type] = {} def register_tool(name: str): def decorator(cls): TOOL_REGISTRY[name] = cls return cls return decorator @register_tool("weather") class WeatherTool(ToolExecutor): name = "weather" ...装饰器注册的好处是直观:写插件的人只需要在类上打一个标记,框架启动入口统一import插件包目录,注册表就自然填满。但它有一个隐患:如果插件包没有被import到,注册不会发生。所以框架必须约定一个固定的插件发现路径,或者显式读取配置里声明的插件模块列表再逐个导入。
导入完成只是“收集”阶段,还不算真正初始化。我建议采用两阶段加载:第一阶段只收集插件类,校验它们的元信息是否合法、参数schema是否是合法的JSON Schema、是否有重复名字;第二阶段才根据配置实例化,按声明的依赖关系排序,然后调用每个插件的init方法。
两阶段加载最大的好处是解耦“顺序问题”。插件A的初始化需要依赖插件B的实例,如果边导入边实例化,必然遇到“A先被导入但B还没创建”的尴尬。先全部收集再统一实例化,配合一个简单的依赖拓扑排序,就能稳定解决。启动时一次性完成校验和装配,也比运行到某一步才报“插件不存在”要友好得多。
2.3 插件间通信与依赖注入
插件之间不能直接引用对方的全局变量,这是铁律。正确的做法是把共享依赖塞进构造器或上下文对象里。配置加载阶段,框架会根据插件的构造参数类型提示,自动注入已经实例化的组件。
class HarnessEngine: def __init__(self, tools: list[ToolExecutor], recorder: LogRecorder): self.tools = tools self.recorder = recorder执行阶段,框架每处理一轮,都会创建一个SessionContext,把当前会话ID、消息历史、记忆存储、日志记录器全部挂载进去,然后传给工具执行器。工具要做日志也好、要读状态也罢,都从ctx里拿,而不是自己去new一个全局实例。这样带来的直接好处是:同一个工具类可以同时服务多个会话而互不干扰,因为所有会话态都在Context里,工具本身变成无状态函数。
配置驱动是另一个让我少踩很多坑的设计。插件可配、参数可配、启停可配。比如一个工具在某些环境禁用了,不需要改代码,配置里enabled: false就行。这也让同一套代码能同时跑在测试环境和生产环境,只是配置不同。
2.4 为什么不做动态热插拔
聊插件化,很多人第一反应是“那能不能不重启进程直接加载新插件”?我的结论很明确:业务系统不要轻易做动态热插拔,尤其是带状态的Agent框架。Python里常见的做法是用importlib.reload重新加载模块,但reload的模块可能被之前创建的实例继续引用,新旧代码混在一起运行,状态直接错乱。更现实的是,插件的依赖可能不止Python模块,比如它初始化时建立了数据库连接池、加载了模型权重,这些资源在reload时并不会被自动释放。
我的替代方案是“配置变更加优雅重启”:插件目录里新增一个插件,更新配置文件声明它启用,框架检测到配置变化后,执行一次session优雅结束流程,把正在跑的会话落盘,再重启进程加载新插件。整个过程对外表现为短暂的不可用,但内部状态是干净、确定性的。对绝大多数企业内部Agent平台来说,这个代价完全可以接受,远比在生产环境做不可控的热加载稳妥。
3. 可回放会话日志:把黑匣子变成时间机器
3.1 日志究竟要记录哪些字段
插件化解决的是系统的“可组合性”,而可回放会话日志解决的是“可追溯性”。普通日志和可回放日志的差别,就像“行车记录仪拍了几张照片”和“完整录了一段视频”。普通日志只记录结果,比如“调用了工具A,返回了B”;回放日志要记录足以让整场会话原地重跑的全部信息。
一个完整事件至少要有:事件类型(session启动、模型输出、工具调用、工具结果、最终答案)、会话ID、回合号、事件序号、时间戳、父事件引用、具体负载。工具调用事件里必须包含完整的参数;模型输出事件里必须包含完整的消息内容;状态变更事件里要包含关键变量的前后值。
事件统一用JSON Lines格式落盘,一行一个事件。相比一个巨大的JSON文件,JSON Lines天然支持追加写入,也方便用常规命令行工具逐行过滤。
{"event": "tool.call", "session_id": "s-001", "turn": 3, "seq": 17, "ts": 1711000000.123, "parent": "span-2", "span": "span-7", "tool": "weather", "params": {"city": "sample_city"}} {"event": "tool.result", "session_id": "s-001", "turn": 3, "seq": 18, "ts": 1711000000.456, "parent": "span-7", "span": "span-8", "ok": true, "data": {"temp": 25, "unit": "c"}}3.2 用树的形状还原一次多工具调用
Agent的一次会话,尤其是复杂任务,往往不是线性步骤。它可能是:模型先并行调用了两个工具,一个结果又触发了后续工具,另一个结果只作为旁路参考。这种情况下,如果日志只是一个平铺列表,回放时根本分不清谁是谁的后续。
我的做法是引入span的概念。每一次“逻辑处理单元”分配一个span_id,比如一次完整的模型回合是一个span,一次工具调用也是span;嵌套或者关联关系通过parent_span_id表达。所有span组合起来就是一棵树,根是会话开始,叶子是具体调用。回放时,沿着这棵树就能精确复现每一步的执行顺序和依赖关系,包括并行分支各自的上下文。
有了树形结构,日志分析也能做得更深。审计人员可以只筛选某个span的子树,看这个分支上到底发生了什么;性能分析可以统计一个回合内各span的耗时,定位是哪一次工具调用拖慢了整体。没有这层结构,这些分析都得靠事后猜。
3.3 回放器的两种工作模式
日志记录完备之后,回放器就是读取事件序列、按原逻辑重新执行的组件。我把它分成两种模式。
第一种是离线重放模式。回放时不真正调用任何外部模型或工具,而是从事件流里读取当时记录的结果,把结果原样返回给状态机。这种模式适合调试:我想复现某次会话的决策路径,不想再花一笔模型费用、也不想让外部接口产生真实副作用,只要语义相同的执行轨迹。模型输出、工具结果全部走“记忆”,状态迅速还原到任意一步。
第二种是策略重放模式。工具和模型结果用真实执行,但允许在某个节点替换决策。比如我想测试“如果模型第3轮选择了另一个工具,后续会不会更好”,就在回放器里注入一个新的决策覆盖点。这种模式本质上变成了一个实验框架,可以验证不同提示词、不同工具选择对最终结果的影响。
不管哪种模式,回放器都需要一个统一的ReplayContext,它对外暴露的接口和真实SessionContext一致。这样业务插件完全无感:它不知道自己是在真实执行还是被回放驱动,只要从Context里拿数据就行。这是回放系统能落地的关键。
3.4 回放日志在调试、审计与评测中的位置
把回放日志的价值按场景排个序,日常最直接的是调试。以前遇到“用户说Agent乱调工具”,我只能看prompt猜。现在导出会话日志,把每一步工具调用和当时的上下文摊开,模型基于什么信息做了这个选择,肉眼可见。
第二个是回归评测。每次优化了prompt或换了一个模型版本,最怕的就是“这个问题修好了,另外三个问题又坏了”。把历史会话日志整理成测试集,回放器逐条重放,对比最终答案是否偏离、关键工具调用是否变化,就能快速发现劣化。这比拿手工构造的几条case来点验可靠得多。
第三个是审计合规。Agent代替人做出了一些决策,事后必须能说清楚当时依据什么、调用了什么、返回了什么。这类场景下,不可篡改的追加式日志本身就是一种资产。
4. 动手接入一个工具插件并回放这次会话
4.1 工程目录与最小配置文件
光讲概念不够,我直接给你一套可以照着写的骨架。假设我们在做一个“咨询助手”,今天要给它加一个天气查询工具。
harness_demo/ config/ config.yaml plugins/ __init__.py weather_tool.py harness/ engine.py recorder.py replayer.py run_demo.pyconfig.yaml是全局唯一配置入口,插件声明、模型配置、日志路径都在这里:
session: max_turns: 8 recorder: backend: file path: ./logs/ tools: - name: weather enabled: true options: provider: "demo-provider" model: provider: mock params: temperature: 0.2注意tools下每个条目有一个options子对象,它是工具的自定义配置,会在插件实例化时被注入。框架不关心options内部是什么,工具自己解析。这种“框架只透传,不解读”的设计,能最大限度保持插件自由度。
4.2 实现一个自定义工具插件
一个最小工具插件长这样:
# plugins/weather_tool.py import json from harness.engine import ToolExecutor, ToolResult, register_tool @register_tool("weather") class WeatherTool(ToolExecutor): name = "weather" description = "查询指定城市当前的天气情况" def __init__(self, provider: str = "demo-provider"): self.provider = provider def execute(self, ctx, params: dict) -> ToolResult: city = params.get("city", "") # 实际项目中这里会调用外部天气API,demo里直接返回常量 return ToolResult(ok=True, data={"city": city, "temp": 25, "weather": "sunny", "unit": "c"})执行方法里最重要的三个细节:第一是从ctx里拿会话上下文,而不是自己去查状态;第二个是异常要吞到ToolResult里,让框架层面统一处理重试策略;第三是返回结构必须是可JSON序列化的,否则后续写日志时会出问题。
4.3 跑出第一份可回放日志
引擎侧,每次会话开始、每次模型返回、每次工具调用前后,分别触发对应事件,并交给recorder落盘。
# run_demo.py from harness.engine import HarnessEngine from harness.recorder import FileRecorder recorder = FileRecorder(path="./logs/") engine = HarnessEngine(recorder=recorder) answer, session_id = engine.run(task="杭州今天适合穿短袖吗?") print("answer:", answer) print("session_id:", session_id)运行一次之后,日志目录里会出现一个形如session_s-001.jsonl的文件。打开它,能看到完整的事件链:session.start、agent.turn、tool.call、tool.result、agent.turn、final.answer。每一条都带时间戳和父子关系。有了这个文件,问题就成功了一半——因为任何一次客观的“它为什么这么做”,都能从这里找到答案。
4.4 用回放器重新“经历”会话
回放的代码比想象中短,因为真正复杂的状态管理已经由事件流解决了。
from harness.replayer import Replayer replayer = Replayer.from_file("./logs/session_s-001.jsonl") report = replayer.replay(strict=False, dry_run=True) for step in report.steps: print(f"turn {step.turn}: {step.description} -> {step.result_summary}")dry_run=True表示所有工具调用都不真正发起,从事件流里读取当时的返回;strict=False表示允许事件流里缺少某些非关键字段时继续。回放器跑完会生成一份结构报告,包含每一轮做了什么、耗时多少、状态如何变化。对比不同会话的报告,差异一目了然。
5. 实测中最容易翻车的五个设计细节
5.1 插件参数能进日志的才叫参数
第一个坑藏在工具调用参数里。有的工具调用会传一些不能落盘的敏感字段,比如内部token、数据库连接串、用户手机号。如果不做过滤,日志就成了数据泄露通道。反过来,如果为了安全把整个params打码,回放时又拿不到足够的上下文。
我的经验是:每个工具声明一个可记录字段列表,只把白名单字段写进日志,其他字段标记为redacted。回放时,redacted字段用占位符替代。这样既保住现场,又不碰敏感数据。这个字段列表建议挂在插件元的description旁边,因为它是工具契约的一部分。
5.2 回调事件不能只记“我来了”
事件体系设计时,最容易做成的样子是“在工具调用前后打了两个点”,但这种日志回放不出来。真正要记录的是可供重建状态的差异信息。模型回合事件里,要带上当时的消息列表长度甚至完整prompt;工具调用事件里,要带完整参数和当时上下文的关键字段。
一个判断标准很实用:如果某一天你完全失去线上数据库,只靠这份日志,能不能把这次会话从头到尾重建出来。能,说明日志合格;不能,说明记录的信息缺了。
5.3 回放时外部副作用必须和真实执行分离
这是我在一次评测中踩得最惨的坑。当时要回放一批历史会话做回归测试,结果回放器真的把“发送邮件”工具执行了一遍,给客户发了一堆测试邮件。问题出在工具没有区分“真实执行”和“回放执行”。
解决方案是在Context里加一个execution_mode标记,取值real或replay。工具在内部判断:如果当前是replay模式,只从日志结果里读取数据,不产生任何外部副作用。发送类、写库类、审批类工具都要严格遵守这个约定。回放器本身也要默认把所有工具当作“可能有副作用”来对待,除非工具显式声明自己可安全重放。
5.4 插件注册顺序和命名冲突
注册表这种全局机制,天然有命名冲突的风险。两个插件都注册了weather,后加载的会覆盖先加载的,而且往往不发生任何报错。两阶段加载的校验阶段就必须做重复检查:名称重复时直接启动失败,并提示冲突来源。
另一个容易被忽略的是插件依赖第三方库的版本冲突。插件A需要某库1.x,插件B需要2.x,Python环境下很难共存。这个问题没有完美的解法,只能通过约定:插件必须在元信息中声明依赖,框架在加载前做依赖分析,版本不可调和时给出警告。至少把问题提前到启动期,而不是运行期才爆。
5.5 重放与评测要分开看待
回放日志天然适合做回归,但它不能替代评测。回放是“复现当时”,评测是“判断好坏”。同一个会话,模型版本变了之后重放,工具调用序列可能一样,但潜在语义已经不同。所以评测必须同时看结果指标和过程指标,而不能只对比输出字符串。
我的建议是三重对比:第一步对比工具调用序列是否一致;第二步对比关键节点的工具结果是否被合理使用;第三步对比最终答案在语义上是否有显著劣化。只做最后一步,往往会漏掉中间过程里的隐患。
6. 把插件化和回放日志用好的几点体会
6.1 好框架的评判标准是“能扔进生产环境”
这半年我评估过不少Agent框架,也在几个项目里落地过自研的Harness。一个框架值不值得用,我不看demo多炫,只看它面对生产环境的三问:模型输出不规范时,它有没有兜底策略;组件要替换时,改动面能不能控制在边界内;出了问题后,能不能在五分钟内还原现场。插件化回答第二问,回放日志回答第三问。当这两个能力都不缺,这个框架才算从“实验玩具”变成了“工程基础设施”。
6.2 插件化不是越多越好
全插件化思想很好,但别走到另一个极端:什么东西都做成插件,导致配置复杂、启动链路冗长、排查问题时要在十几个抽象之间跳来跳去。我见过一个项目,把字符串格式化都做成插件,结果改一个格式要动四个文件。
插件化的边界应该划在“变化点”上:模型会变、工具会变、存储会变、日志会变,这些做成插件;而控制流的骨架、事件协议、上下文模型,是稳定核心,不应该被轻易替换。稳定的部分做成框架,多变的部分做成插件,这才是合理的分工。
6.3 从日志到资产:复用你的会话数据
最后分享一个让我观念转变的实践。一开始我设计回放日志只是为了“查到问题”,后来发现会话日志越积越多,变成了一个珍贵的语料库。人工复盘时标记哪些会话是成功、哪些是失败,这些标记过的回放日志可以直接变成评测集和微调数据源。
所以,日志结构设计应该在这个方向上留好扩展位:事件类型字段保留语义区分,负载字段保留原始文本,文件头写入schema版本号。以后任何工具可以直接读这套日志做数据挖掘,而不需要再从各处临时捞数。这就是把“事后追责”升级成“事前迭代”的关键一步。