Agent工程落地:从单体混乱到插件化与可回放日志
聊到 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(activeFalse) 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 modelllm-chat-v2 pre_tokens8123 [seq 3] llm_response outputtool_call: user_lookup [seq 4] tool_call_start tooluser_lookup duration_ms230 statusok [seq 5] llm_request modelllm-chat-v2 pre_tokens82913.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 业务已经超过三个月我强烈建议先把结构化日志和回放能力加上这个投入比任何花哨的功能都更值得。