聊到 Agent 工程的落地,我其实绕了一大段弯路。最初用 DeepSeek 这类大模型的 API 搭了一个能调工具、能多轮对话的 Demo,半天就跑通了,当时觉得 Agent 也没有传说中那么难。可等业务场景不断变复杂,代码开始不受控制:工具调用、记忆读写、上下文组装、重试策略、日志打印,全部堆在一个主循环里,每加一个功能就要把主干代码改一遍,改完还要担心会不会把别的环节带崩。没错,一个曾经能跑的 Agent 在三个月后变成了一座拆迁难度极高的违章建筑。
后来我做了一个决定:把内部使用的这套 Agent 框架重新按插件化结构设计,并且让所有会话过程都沉淀为可回放日志。这就是今天要聊的 Harness。这套方案的定位很明确,它解决两个核心问题:第一,让每个会变化的部件都有清晰边界和替换入口;第二,让任何一次 Agent 的完整执行过程,都能在事后被稳定、无损地重放出来。如果你也在做 Agent 的业务化落地,或者已经感觉到单体代码开始拖累迭代速度,这篇内容应该能给你一些结构上的参考。
1. 单体 Agent 从"能跑"到"难护"的分界线
1.1 主循环里到底堆了多少东西
大多数 Agent 应用最初长一个样子:一个 while 循环,接收用户输入,把历史消息和当前请求拼成 prompt,调用模型,解析模型返回的动作,执行工具,把工具结果再拼回去,调模型,继续循环。这个结构在 demo 阶段非常高效,因为所有逻辑都在眼皮底下,出问题加个 print 就能找到。
可一旦进入真实业务,主循环里就会悄悄长出各种横切逻辑:
- 接收输入之后,可能需要查数据库、读记忆,决定要不要做上下文截断。
- 调用模型之前,要判断当前应该用哪个模型、是否需要限流、失败要不要重试。
- 执行工具时,要处理第三方接口超时、鉴权失败、分页、参数校验。
- 每一轮结束,还要决定记录什么日志、是否保存会话快照。
这些逻辑在代码层面一视同仁地平铺在主循环里,彼此之间的依赖关系却非常微妙。有一次我们排查线上问题,发现某个第三方用户信息接口偶尔超时,重试逻辑被写在了工具函数内部,做了三次重试、每次等两秒。问题是,在一次会话里连续触发这个工具时,模型的输入上下文已经随着前面几轮结果发生了变化,重试期间旧的上下文还在被后续步骤读取。修好了一个超时,却引入了上下文过期的问题。这种案例反复出现后,我意识到真正缺的不是代码补丁,而是结构上的隔离。
1.2 单体结构下无法回答的三种问题
单体结构最让人难受的,不是代码丑,而是它让三个关键问题变得无法回答。
第一个问题:最新一次模型决策为什么这么选?当你只有散落的 print 日志时,你很难还原模型某一轮输出 tool_call 之前,到底看到了哪些上下文片段。那些片段是被裁剪过的,还是完整保留的?上一个工具结论有没有被正确拼进去?这些信息在事后基本不可考证。
第二个问题:如果换一个工具实现,整体行为会怎样?在单体代码里,工具的调用逻辑和主循环深度耦合,工具 A 换成工具 B 不只是换个函数,还得考虑主循环里有没有针对 A 的特判分支。改动风险大,导致大家宁可继续往旧工具里补丁,也不愿意重构。
第三个问题:外部依赖全部失败时,Agent 的表现稳定吗?真实场景里第三方工具不可能永远可用,单体结构下模拟故障需要手动改代码、改超时时间、甚至伪造返回值,非常麻烦。外部依赖一旦抖了一下,你根本说不清是 Agent 决策问题还是依赖问题。
这三个问题放在一起,答案已经指向了插件化和会话回放。插件化负责把边界划清楚,会话回放负责把过程看明白。
| 场景 | 单体代码表现 | 插件化 + 可回放表现 |
|---|---|---|
| 新增一个工具 | 改主循环,给解析分支加 case,风险外溢到整个流程 | 新增一个工具插件,注册到插件清单,回放验证一次即可 |
| 更换记忆存储 | 改上下文组装处,牵一发动全身 | 换记忆插件实现,接口不变 |
| 排查一次多轮 Agent 异常 | 翻 print、靠猜,信息不完整 | 用回放器重放会话语义,按步骤对比 |
| 做故障注入测试 | 手动改代码模拟错误,容易留坑 | 回放时注入 mock 工具响应,验证分支逻辑 |
2. Harness 的全插件化:接口、生命周期与加载机制
2.1 插件不是"可选的配置项",而是唯一扩展方式
Harness 的插件体系不是把代码拆成几个类再挂上"plugin"后缀那么简单,它实际上划定了整个框架的能力边界。核心被刻意做得非常薄:只维护会话状态机、分发事件、编排插件调用链。除此之外的业务能力,全部通过插件注入。
当前 Harness 里主要跑着四类插件:
- 模型适配插件:负责对接各家大模型 API,处理鉴权、超时、模型参数映射。对业务侧暴露的永远是统一的 generate 接口,具体用哪个模型、哪个版本,由配置和路由策略决定。
- 工具执行插件:负责把 Agent 发出的工具调用意图翻译成真实请求。插件内部可以包含多个工具,也可以只包含一个;每个工具暴露统一的入参、出参结构和错误类型。
- 记忆存储插件:负责读写会话记忆、长期记忆、向量索引。它对框架暴露的是追加、检索、删除这三个操作,底层的存储介质可以随时换。
- 策略注入插件:负责重试、限流、模型路由、上下文裁剪策略。这类插件的典型特征是"包裹在调用链周围",框架在合适的时机把它们织入调用过程。
- 观察者插件:负责记录会话事件、上报指标、输出日志。它不参与业务决策,只监听事件,是后面可回放日志的基础。
复用"接口"这个概念很直观:框架不关心你插件内部是 Redis 还是 MySQL、是直连 API 还是走代理,它只关心你有没有实现约定的接口,能不能在正确的时间被调用。一个插件做到极致就只做一件事,替换时不需要理解其他插件。
2.2 统一生命周期:init、prepare、cleanup
插件要在一个框架里稳定运行,最重要的是有统一的"运行节奏"。Harness 给每个插件定义了三个生命周期阶段,所有插件都必须遵守,但可以选择不实现某个阶段。
- init:应用启动阶段。插件在这里加载配置、建立连接、注册事件监听。这个阶段不做任何业务计算,只做资源准备。
- prepare:每次会话开始前调用。插件在这里创建会话级资源,比如临时目录、会话级缓存、统计计数器。
- cleanup:应用收尾阶段。释放全局资源,比如关闭连接池、落盘未写完的指标。
曾经有一版设计把 prepare 和 init 合并成了一个方法,结果多会话并发时状态全都串了。两个阶段分开之后逻辑非常清晰:init 管全局,prepare 管会话,会话结束之后所有 prepare 阶段创建的东西全部销毁,互不污染。
from abc import ABC, abstractmethod class Plugin(ABC): name: str @abstractmethod def init(self, registry: Registry, config: dict) -> None: """应用加载期:注册事件监听、初始化共享资源""" def prepare(self, run_context: RunContext) -> None: """会话开始前:创建会话级资源""" def cleanup(self) -> None: """应用收尾:释放全局资源"""这里有一个关键约定:init 阶段不能假设某个依赖插件已经初始化完成,因为插件加载顺序不是由编写顺序决定的。真正的依赖获取发生在 prepare 阶段,通过 Registry 按能力名去拿,拿不到就明确报错。这个设计把插件之间的初始化耦合降到了最低。
2.3 事件总线:插件之间不直接打招呼
插件之间如果直接互相调用,就会出现 A 插件 import B 插件、B 插件改了个方法名导致 A 挂掉的连锁反应。Harness 的做法是让所有插件只跟事件总线打交道,插件之间的数据传递全部通过具名事件完成。
核心事件就那几条:session_started、context_built、llm_request、llm_response、tool_call、tool_result、session_finished。框架的主循环负责在正确的时间点发出这些事件,插件负责订阅跟自己相关的事件。
举个例子,观察者插件会订阅 llm_request 和 llm_response,在模型请求发出前记录 payload 快照,在响应回来后记录执行结果。它完全不需要知道模型路由插件的存在。同样,限流策略插件在 llm_request 事件到达时悄悄做判断,如果触发限流则阻止本次调用,其他插件甚至感知不到限流发生过。
class ModelRouter(Plugin): def init(self, registry: Registry, config: dict) -> None: self.models = config["models"] async def route(self, run_context: RunContext) -> ModelProvider: provider = self.select_model(run_context) return provider模型路由插件自己维护小模型快、大模型准、超大模型保底的策略。代码写在一个插件里,不污染主循环。每次切换模型或调整概率,只需要改这个插件的配置。
2.4 为什么选择轻内核而不是重框架
见过不少插件化框架,动辄提供几十个内置扩展点,核心类几百行起步。Harness 的设计方向相反,核心几乎不做任何业务判断。它的核心代码就围绕一件事:把会话状态机推进下去。
状态机的状态总共就那么几个:running、waiting_tool、ended、cancelled、error。插件可以订阅状态变化,但禁止直接修改状态。状态迁移只有框架能触发,这个约束保证了任何插件出错都不会把一个会话带向未知状态。
轻内核的代价是插件治理成本上升。插件一多,依赖关系就会乱。Harness 明确约束依赖只能向下流:核心不依赖插件,插件可依赖核心提供的通用能力,插件之间不允许横向指向。当一个插件需要另一个插件的计算结果时,正确做法是让源插件在事件里带上数据,目标插件订阅事件获取,而不是直接调用。
3. 可回放会话日志:记录的不是信息,是决策过程
3.1 设计目标:让一次运行可以被"原样再看一遍"
做日志系统之前,我先列了三个需求。第一,日志必须能还原事件发生的顺序;第二,日志必须能记录事件发生的瞬时上下文;第三,日志必须能被消费程序读取并驱动一次回放。普通日志只满足第一条,Harness 的会话日志要满足全部三条。
所以 Harness 的会话日志不再是一行文本,而是一个结构化的 JSONL 流。每一个事件占一行,字段固定,携带会话 ID、序号、时间戳、事件类型和 payload。这个设计让日志天生可排序、可过滤、可重放,也方便之后做统计分析。
目录结构一般长这样:
sessions/ sess_20240001.jsonl sess_20240001.meta.json attachments/ tool_result_b7a9.binJSONL 文件里记录的是事件本体,meta 文件里记录会话级的元信息,比如模型版本、插件列表、会话持续时间。大的工具响应不放进 jsonl,而是存到 attachments 目录,事件里只保留引用地址和摘要。
3.2 关键事件与快照设计
可回放日志的记录范围不是随便定的,它必须覆盖一次 Agent 决策的所有关键边沿。我们至少记录五类事件:
- context_built:每次给模型发请求前,记录当前上下文的组装结果。出于体积考虑,快照只保留首尾片段,中间用截断标记表示。
- llm_request / llm_response:模型请求的完整参数和响应结果。响应里如果包含 tool_call 参数,会一起记下来。
- tool_call_start / tool_call_end:工具调用的开始、结束、耗时和状态码。
- policy_triggered:重试、限流、路由生效时单独记一条,不加到普通日志里。
- session_finished:会话结束原因、总耗时、总 token 消耗。
{ "seq": 42, "event": "llm_request", "session_id": "sess_20240001", "trace_id": "trace_7f3a", "ts": 1710000000000, "schema_version": 3, "payload": { "model": "llm-chat-v2", "messages": "[{\"role\":\"system\",...}]", "temperature": 0.2, "prompt_snapshot_start": "<<前512字符>>", "prompt_snapshot_end": "<<后256字符>>", "pre_tokens": 8123, "plugin_version": "agent-v1.4.2" } }3.3 回放引擎的三种模式
回放引擎是消费会话日志的程序。它读取 JSONL,按 seq 顺序把事件重新推给 Agent 框架。核心参数是 mode,决定回放时外部调用如何处理。
模式一是叙述式回放(dry run),只按时间顺序打印事件、查看快照,不做任何真实调用、不调用模型、不执行工具。适合快速看一次会话发生了什么,定位问题的大致范围。
模式二是确定式回放(snapshot replay),模型输出和工具返回值全都从日志里取,不重新调模型、不重新调工具。适合回归测试:改完插件之后,把线上出问题的会话拉回来,看新代码能否复现同样的决策路径。
模式三是干预式回放(synthetic),模型输出从日志取,但工具返回值可以替换成自定义 mock。适合做 what-if 实验:如果第三次调用工具时让 API 返回超时,Agent 会怎么办?
class ReplayEngine: def __init__(self, event_path: str, mode: str = "snapshot") -> None: self.events = load_events(event_path) self.mode = mode def run(self, start_seq: int = 0, end_seq: int | None = None, mock_tools: dict | None = None): with SessionRecorder(active=False) as ctx: for event in self.events: if event.seq < start_seq: continue if end_seq is not None and event.seq >= end_seq: break if event.event in {"llm_request"}: if self.mode == "snapshot": answer = event.snapshot["llm_response"] elif self.mode == "synthetic": answer = mock_tools.get(event.call_id, event.snapshot["llm_response"]) yield ctx.apply_llm_result(event, answer) elif event.event == "tool_call_start": yield ctx.apply_tool_result(event, event.snapshot)这段代码是示意,真实实现里还要处理事件与状态机的映射关系。但核心逻辑已经清楚了:事件被当作"剧本",回放引擎按剧本推进状态机,外部世界的非确定性在这个过程里被完全替代。
实际使用中会提供一个命令入口:
harness replay --session sess_20240001 --mode snapshot --from 0 --to -1它会输出类似这样的回放过程,非常直观:
[seq 0] session_started [seq 1] context_built payload='...' [seq 2] llm_request model='llm-chat-v2' pre_tokens=8123 [seq 3] llm_response output='tool_call: user_lookup' [seq 4] tool_call_start tool='user_lookup' duration_ms=230 status=ok [seq 5] llm_request model='llm-chat-v2' pre_tokens=82913.4 会话日志和 Tracing 不是一回事
这里需要做一点区分:Harness 的会话日志与传统意义的 tracing 是互补关系,不是替代关系。Tracing 关注的是跨服务调用链路,记录一次请求经过了哪些服务、每个服务的耗时、是否有异常,这是"系统视角"。而会话日志记录的是 Agent 的业务决策轨迹,包括模型看到了什么、工具返回了什么、策略为什么生效,这是"业务视角"。
线上排查问题时通常先用 tracing 圈定异常范围,比如发现某个服务耗时飙升;然后再用会话日志回放 Agent 的完整决策链,看模型在那个时间点接收到了什么上下文、做出了什么判断。两套数据合并起来,才能对一次线上异常给出完整解释。Harness 故意不跟 tracing 系统合并,就是为了保持视角的干净。
4. 工程落地中绕不开的坑:插件冲突、日志体积、回放失真
4.1 插件依赖循环与加载顺序
插件多了以后,最常遇到的不是代码 bug,而是启动时报依赖循环。Harness 的每个插件清单里可以声明 depends_on 字段,加载器在启动阶段构建有向图做拓扑排序,同时在发现环的时候立刻报错,不进入运行阶段。
plugins: - name: metrics_store depends_on: [] - name: log_observer depends_on: [metrics_store] - name: model_router depends_on: [] - name: tool_runner depends_on: [model_router] - name: memory_plugin depends_on: []实测下来,这个校验是线上最省心的功能。因为插件之间的依赖关系很多时候要等三四个月才能暴露出来,等到线上跑挂了才发现初始化顺序不对,代价会很高。宁可启动时多花几十毫秒做检查,也不让隐晦的依赖问题在凌晨爆发。
4.2 日志体积与隐私脱敏
会话日志的膨胀速度超出预期。一次多轮 Agent 任务,光上下文快照就可能占几十 KB;工具响应如果包含文件内容或者数据库查询结果,轻松到几 MB。如果全部完整落盘,对象存储半个月就能被撑爆。
Harness 的做法是分级处理:上下文快照只存首尾截断片段,中间用哈希值标记,既保留了完整性的校验能力,又控制了体积。大体积工具响应只记录摘要和长度,完整内容放进独立对象存储,事件 JSONL 里只留引用地址。生产环境日志采用采样策略:调试模式全量记录,线上环境只保留失败会话和 1% 的抽样成功会话。
脱敏必须在写入日志之前完成,作为观察者插件的一个独立环节。对 prompt 里的邮箱、手机号、密钥、用户 ID 做掩码替换,再落盘。隐私策略一旦写在日志读取端而不是写入端,就一定会漏。
| 数据类别 | 记录策略 | 说明 |
|---|---|---|
| 模型请求/响应 | 全量记录,响应过大则截断 | 回放时复用这些数据 |
| 上下文拼装快照 | 首尾截断 + 中间哈希 | 控制体积,保留完整性校验 |
| 工具入参/出参 | 小数据全量,大数据摘要+引用地址 | 大文件进 attachments |
| 敏感字段 | 写入前掩码替换 | 日志读取端不做二次处理 |
4.3 回放失真:如何把"不可复现"变成"确定"
回放遇到的最大挑战是模型输出的不可确定性。即使同一个输入、同一个热区、同一个 prompt,大模型也可能给出不同输出。这意味着即使回放引擎完整还原了上下文,也不能保证模型重新决策后走回原路径。
Harness 的解决方式是快照覆盖:在 snapshot 模式下,所有由模型调用和工具调用产生的值完全来自日志记录,不重新计算。状态机只根据日志里的输出值推进,因此回放结果必然是确定的。
这个设计的代价是回放结果不能验证"模型重新决策正确",它只能验证"框架代码在相同输入下是否正确流转"。对于故障复现和回归测试,这个语义已经足够。需要验证模型决策逻辑本身时,用 synthetic 模式配合 mock 数据做针对性实验。
4.4 旧日志兼容性:schema_version 第一天就要有
会话日志的结构会随着功能迭代而调整,今天新增一个事件类型,明天改动 payload 字段,后天删除一个旧事件。如果不做版本控制,半年之后回放器看到旧日志就只能报错。
Harness 从第一天就在每个事件里带上了 schema_version 字段。回放器读到未知版本时直接拒绝并提示升级工具,而不是尝试猜测语义。每次结构变更都带上一个迁移脚本,旧日志可以通过脚本升级成新格式。到目前我已经见过太多团队因为没有版本号,最终不得不放弃全部历史日志,那才是真的灾难。
4.5 真实案例:用回放定位到"已销毁临时目录"
有一次线上 Agent 反复报错,现象是同一个输入有时成功有时失败,毫无规律。看 trace 只能看到某个工具调用失败,但失败原因被框架捕获后没有在日志里带出上下文。
用会话回放直接定位到那一条 tool_call_start 事件,再往前翻 context_built,发现 Agent 在第四轮时引用了一个很早之前创建的临时目录。按照代码逻辑,该目录应该在第三轮结束时被清理,但清理动作发生在工具执行之前,并不是执行之后,于是产生了竞态窗口。这个问题如果靠翻 print 日志,大概率要猜测很久,但回放日志直接给出了事件顺序,几分钟就锁定了根因。
5. 从单体迁移到 Harness:先切观察者,再切插件
5.1 迁移步骤:每一步都能回退
如果你现在还在单体阶段,不要试图一天把它完整改造成插件化架构。风险太大,而且很可能改到一半业务催需求,最后只能回滚。比较稳妥的路径是按顺序做四步,每一步都保持系统可运行、可回退。
第一步:先埋点,不动架构。在现有代码的关键边沿加上结构化日志输出,统一成 JSONL 格式。这一步甚至不需要引入插件概念,只是把 print 换成结构化记录。但它的价值很大,因为迁移之后的每一步验证,都需要会话级的数据支撑。
第二步:抽工具插件。把所有工具调用整理成一张清单,然后统一收敛到一个 ToolPlugin 里。工具的业务逻辑可以暂时保持不变,只是入口变成插件接口。这个改动对系统行为影响最小,却很能验证插件机制的可用性。
第三步:抽策略插件。把重试、限流、路由判断从主循环里拿出来,放到对应插件中。这一步真正开始减少主循环的负担,也让 Agent 的多分支策略变得更加可测试。
第四步:抽记忆与上下文管理。这是改动最大的一步,因为上下文组装逻辑一旦变化,模型的输入就可能不同。这一步务必结合之前积累的会话日志做回归对比,反复验证新代码与旧代码在相同输入下是否产生相同上下文。
5.2 改造后的实际收益(内部口径)
迁移不是追求架构上的美感,是为了解决三个实际问题:单个工具逻辑变化不影响主循环、会话过程可以被完整回放、策略调整可以只改插件配置而不改代码。
| 能力 | 改造前 | 改造后 |
|---|---|---|
| 接入一个新工具 | 改主循环加判断分支,约 30 分钟 | 新增插件,注册到清单,回放验证一次,约 10 分钟 |
| 排查一次 Agent 线上异常 | 翻 print 日志 + 靠猜,平均 1 小时 | 直接回放对应 session,约 10 分钟 |
| 调整模型路由策略 | 改主循环代码、重新部署 | 改策略插件配置,热加载即可 |
| 测试外部依赖故障时的行为 | 手工改代码模拟错误,风险高 | 用 synthetic 回放注入 mock 响应,安全可重复 |
这个表格的数字是内部团队的实际口径,不是理论值。核心收益不在第一个工具接入节省的 20 分钟,而在排查线上异常时省下的大量翻日志时间,以及"每一个线上问题都能被完整复现"这件事带来的确定性。
5.3 别盲目给小型项目上这套框架
任何框架都有适用边界,Harness 也不是银弹。如果你的 Agent 现在只有一个工具、单轮交互、服务使用者不超过三个人,保持单体反而正确。插件化和可回放日志是有成本的:接口定义、事件命名、日志体积控制、回放工具维护,每一项都在消耗精力。
我建议出现以下三个信号再动手:第一,新增一个工具逻辑时,你开始犹豫要不要改主循环;第二,调试一次 Agent 行为过程超过半小时,而且经常反复看同样的 log;第三,同一个线上问题需要两个人以上坐在一起讨论才能定位。出现其中两条,就值得做结构迁移了。在那之前,把精力花在业务本身,性价比更高。
最后说一点个人体会。Harness 改造真正改变我工作方式的,不是"插件化"这个名词,而是"每一次 Agent 行为都值得被完整记录并回放"这个习惯。现在线上任何一次异常会话,只要把 JSONL 拿到手,我几乎可以在几分钟内还原完整的决策链条。如果你做 Agent 业务已经超过三个月,我强烈建议先把结构化日志和回放能力加上,这个投入比任何花哨的功能都更值得。