ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Agent框架可回放日志设计:Harness层与全插件化实践

Agent框架可回放日志设计:Harness层与全插件化实践 做Agent框架的人大概都经历过这个阶段Demo跑通时觉得世界尽在掌握等真正线上灰度的时候才发现所有问题都藏在主流程的缝隙里。我们这版Agent框架踩得最疼的一个坑是一直没重视会话日志直到有个线上会话跑到第23步工具参数里凭空多出一个字段谁都没法复现只能靠零散的聊天记录猜。那次之后我才下决心把框架里负责承载执行环境和记录执行证据的部分单独抽出来做成独立的Harness层并顺手把日志从聊天记录升级成可回放执行轨迹。这篇就当一次复盘记录重点讲全插件化设计和可回放会话日志这两块。1. 被事故逼出来的Harness层Agent框架里最不该省的一层1.1 第一版框架的差不多能用是怎么崩塌的第一版Agent框架的主流程其实非常简单甚至可以用一句话概括把用户消息丢给模型模型输出工具调用执行工具拿到结果再丢回模型循环直到模型给出最终回复。Demo阶段这套循环跑得很顺畅我们也理所当然地把所有逻辑都揉在一个包里面工具函数直接写在服务代码中模型调用分散在各个业务流程会话历史用普通数据库表存一份JSON。当时觉得这样够了因为Agent本身就是一个循环循环外面不需要太多东西。等到真正接入多个业务方之后问题开始密集出现。第一个新业务要加一个内部数据查询工具本来只是加一个函数的事结果因为工具需要走不同的鉴权体系和限流策略我们不得不在每个调用点处理这些差异。第二个业务要切换模型供应商发现prompt组装、消息格式转换、流式解析散落得到处都是硬切了一周。最崩溃的是晚上查问题用户说会话在某个步骤失败了我们点开日志看到的是一堆无序JSON片段完全没有上下文。那个第23步多字段的事故就是在这种状态下发生的。1.2 Harness层的边界承载Agent而不是指挥Agent业界常说的Harness最初我理解成测试框架里的test harness是一层用来挂载被测对象、控制输入输出、观察内部状态的脚手架。放到Agent场景里也是类似的定位不管Agent用ReAct还是Plan-Execute不管模型来自哪个供应商Harness只负责提供统一的运行环境、挂载插件、记录每一步的关键事件。我们为Harness层划了三条边界Agent的决策逻辑完全留在业务侧Harness不关心每一步为什么这么选只记录发生了什么所有与外部世界的接触包括模型调用、工具执行、记忆读写都通过Harness暴露的插件接口完成不允许业务代码绕过插件层直接发HTTP请求日志由Harness统一采集插件只负责上报事件细节不能自己另起一套日志通道。这个约束从源头保证了回放日志的完整性和同源性。这三条边界导致Agent框架被拆成了两层上面是业务决策层负责想下面是Harness层负责做和记。想的部分可以随时换做和记的部分保持稳定。后来回头看这个拆分最大的收益不是可维护性而是让回放日志成为可能因为所有跟环境相关的交互点都被收口了日志才能被用来完整重建环境。2. 全插件化设计的骨架六个扩展点与一条装配链路2.1 六个核心扩展点拆分Harness层之前我们先把所有可能变化的点列了一张清单。列完之后发现Agent框架的扩展需求其实集中在六个地方模型访问、工具执行、记忆存储、会话元数据、日志输出、生命周期钩子。每一个点都对应一类插件接口。这六个接口没有一个是Agent决策逻辑本身但缺了任何一个工程化都会卡住不抽象模型访问接口切换模型供应商就要改业务代码不抽象记忆存储长短期记忆就没办法在会话回放时被正确重建不抽象日志输出回放引擎就失去数据来源。典型实现也各有侧重比如模型适配器要处理流式与非流式协议差异工具提供者要处理权限与限流记忆适配器可能要同时对接向量库和关系库。扩展点接口职责典型实现ModelClient统一消息格式、流式与非流式调用、重试与降级各模型供应商适配器ToolProvider工具注册、参数校验、调用执行内部搜索、数据查询、权限代理工具MemoryAdapter读写短期与长期记忆内存实现、向量库实现、数据库实现SessionStore保存会话基础信息关系库、对象存储Transport日志落盘和对外投递本地文件、消息队列、对象存储HookPoint在关键节点注入观察和修改审计、监控、动态路由2.2 配置驱动的装配与插件生命周期接口定义只是第一步真正让全插件化成立的是装配机制。我们没有采用代码里到处new插件实例的做法而是用配置驱动装配。每个插件有一个全局唯一的id和type启动时根据配置注册进Harness上下文。harness: plugins: - id: local-tool-search type: tool_provider enabled: true options: endpoint: http://internal-search timeout_ms: 3000 - id: sqlite-memory type: memory_adapter enabled: true options: path: ./data/agent_memory.db - id: jsonl-transport type: transport enabled: true options: path: ./logs/session/ replay_dir: ./logs/replay/配置驱动带来的第一个好处是回放引擎可以在同一套配置里把ModelClient和ToolProvider替换成回放实现而其他插件保持不变。第二个好处是插件版本可以跟着配置走出现问题时能快速通过配置比对定位是哪个插件版本引入的。装配时机上我们定义了Init、Start、Stop三个阶段Init阶段只做资源准备Start阶段才对外提供能力Stop阶段负责释放资源。插件之间有依赖时必须显式声明依赖的plugin id装配器会做拓扑排序后再启动。2.3 组合式设计的选择理由最初也讨论过要不要用插件继承体系比如让每个工具插件继承一个BaseTool然后覆写execute方法。后来我们发现继承会把插件之间的隐式状态粘在一起比如子类改了父类的成员变量、多个插件共享同一个连接池时很难追踪。于是改成组合式插件实现接口插件之间不直接通信数据交换统一走Harness上下文里的事件总线。用这种模式后单元测试变得非常简单每个插件可以独立启动、注入mock数据、断言输出不需要跑整个Agent。写插件的人也不需要理解Agent主循环只需要知道自己那个接口的协议。如果还是觉得抽象可以把Harness理解成电脑主板Agent是CPU插件是插在主板上的各类板卡板卡之间不直接连线一切都通过总线通信换显卡不需要连带着换声卡。3. 从聊天记录到执行轨迹会话日志的事件模型设计3.1 一份典型会话日志长什么样重构前我们的会话记录是一条条消息像普通聊天记录一样按时间排。重构后会话日志变成了一串事件流每行一个事件用JSON Lines格式落盘。下面模拟一份被脱敏过的片段{type:session_started,event_id:ev_001,session_id:sess_93f2,seq:1,ts:2025-01-10T09:21:00Z,data:{agent_id:crm-demo}} {type:user_message,event_id:ev_002,session_id:sess_93f2,seq:2,ts:2025-01-10T09:21:03Z,data:{content:帮我查一下上个月华东区的活跃客户名单}} {type:model_request,event_id:ev_003,session_id:sess_93f2,seq:3,ts:2025-01-10T09:21:05Z,data:{model:internal-llm-a,messages:[{role:user,content:...}],temperature:0.2}} {type:tool_invocation,event_id:ev_004,session_id:sess_93f2,seq:4,ts:2025-01-10T09:21:06Z,data:{tool:customer_query,input:{region:east,status:active,time_range:last_month},output:{count:128,samples:[...]}}} {type:model_response,event_id:ev_005,session_id:sess_93f2,seq:5,ts:2025-01-10T09:21:07Z,data:{role:assistant,content:已为你筛选出128位活跃客户以下是名单前5条...}}可以看到每个事件都包含四个公共字段event_id、session_id、seq、ts。seq是Harness统一分配的自增序号ts只是给人查看用的时间戳排序一律以seq为准。这个设计后面帮了大忙因为分布式环境下不同服务器的时间戳根本无法可靠排序。3.2 事件模型五类核心事件与因果链我们最终只保留了五类核心事件session_started、user_message、model_request、tool_invocation、model_response。很多人会问为什么没有tool_observation因为工具执行结果已经作为tool_invocation事件的output字段记录再拆一个事件反而容易造成顺序错乱。会话结束时补一个session_ended但不算核心决策事件。事件间的关联靠因果结构而不是简单的顺序。比如model_request事件里会携带一个parent_event_id指向触发这次模型调用的上一步事件tool_invocation里也有parent_event_id指向是哪一次模型输出决定调用这个工具。有了这条因果链回放时就能区分是模型主动发起的工具调用还是用户消息触发的被动调用也能在调试时快速从错误工具调用回溯到导致它出现的模型输出。实际排障中这个字段比时间戳好用得多。3.3 存储层要解决的四个工程细节存储层的事说起来琐碎但直接影响日志能不能拿来回放。压缩方面会话日志是写多读少默认按天分片后用gzip落地单文件超过128MB自动滚切避免单个文件过大导致回放时加载过慢。脱敏方面data里出现key、password、credential等字段名时序列化阶段直接替换成[REDACTED]但这里踩过坑后面专门讲。采样方面并不是每个中间推理token都要记录日志级别分为trace和infoinfo只记录用户消息、模型请求摘要、工具调用全文trace才记录流式chunk和模型推理细节。清理方面线上日志默认保留15天事故相关的会话会打标签永久保留。到这里日志已经不再是给人看的纯文本而是一份机器可以读的完整执行证据。4. 回放引擎为什么能做到同一份日志跑出同一个Agent4.1 回放的本质旁路模型与旁路工具回放一个会话很多人第一反应是把日志里的历史消息拼成一个prompt再丢给模型重新生成一次。这在简单对话场景可行但在Agent场景完全不行因为Agent下一步动作依赖的是当时的工具结果、记忆状态和上下文窗口把日志内容重新拼凑后让模型再跑结果大概率会发散。我们采用的方案是把回放当成一次带着剧本的真实演出。启动回放时Harness正常启动Agent决策逻辑原封不动记忆Adapter、SessionStore都是真实实现只有两个插件被替换ModelClient从真实模型调用换成回放模型客户端ToolProvider从真实工具调用换成回放工具提供者。回放模型客户端不调用任何模型只负责从日志事件流里读回当时的model_response回放工具提供者也不执行任何真实业务逻辑只负责把当时tool_invocation里的output原样返回。Agent主循环完全意识不到自己是在回放环境里跑。每走一步Harness会对比当前应该触发的事件和实际触发的事件。如果一致继续如果不一致抛出一个replay_mismatch并输出期望事件和实际事件的差异。这一步是整个回放引擎的核心它不是让Agent重跑一遍而是让Agent在同一个环境里重演一次再通过每一步的比对来暴露当时的隐含状态。4.2 状态快照支持从任意步骤续跑整段回放有两个头疼的问题一是日志太长时回放很慢二是如果只想调试第23步附近的问题每次都从最初用户消息开始跑效率太低。于是我们加入了快照机制。Harness每执行50步或者遇到关键checkpoint会持久化一份状态快照内容包括当前seq、会话上下文摘要、记忆Adapter的状态、插件运行时状态。快照不是全量内存dump而是足以撑起回放下一步的最小状态集。回放时可以传入从第100步开始的指令Harness会加载第100步之前最近的一份快照再顺序注入日志事件直到追上目标seq。整套实现下来大概只增加了15%的复杂度但调试效率提升了一个量级。关于快照有一个容易踩的坑快照保存的是引用还是值。我们一开始图省事直接把内存对象塞进快照结果重启回放后被插件修改过的list对象残留在快照里导致后续事件全部错位。后来统一改为序列化副本宁可多花点存储也要保证快照在加载时是干净状态。4.3 确定性、幂等与副作用控制回放要能被用于测试就必须确定。我们把所有不确定来源都收口到两个地方模型输出从日志读回工具输出从日志读回随机数种子在回放启动前固定时间由Harness统一注入。这样同一份日志在任意机器上重放每一步比对结果都应该完全一致。幂等性分两层。第一层是针对回放引擎自身同一个事件不能因为网络抖动或重复触发而消费两次事件总线里用event_id做了消费记录。第二层是针对真实运行时回放环境里的工具不会真正发起网络请求但为了应付回放工具的Output里包含一个URL业务代码拿到后主动去请求的情况我们在回放模式下默认拦截所有出站HTTP请求只允许访问本地回环地址。这样既能保障安全也能进一步保证确定性。5. 回放不一致的五个真实现场踩坑与修复过程5.1 事件乱序插件各自打点埋下的雷第一次大规模回放测试时我们遇到了一个特别诡异的现象同一个会话在日志里看起来顺序正常但回放引擎跑到某一步就报replay_mismatch。查了两天才发现一些插件不是通过Harness上报事件而是自己在业务代码里调transport.append()并且用了各自服务器的时间戳。A机器时钟快了30秒B机器时钟慢了10秒导致事件流的seq和实际因果顺序不一致。问题一出解决方案反而简单删除所有插件直接写入日志的入口事件来源只能有Harness调度器一个seq统一在事件总线入口分配ts只用于展示。从这以后任何插件想绕过Harness写日志都会被直接打回。5.2 参数原地修改日志里记录的不是真实入参第二个坑更隐蔽。某个工具插件在execute里拿到的入参是一个dict插件内部为了规范数据直接修改了入参对象比如把time_range字段从last_month改成了last_30d。结果日志是在调用前序列化的记录的是last_month但真实工具执行用的是last_30d回放时输出自然对不上。修复方法是在插件调用之前对入参做一次deep copy并且立即序列化到事件data后续不管插件怎么改都不影响日志。这个教训后来被写进了插件开发规范插件收到入参后严禁原地修改要么只读要么先拷贝再改。5.3 敏感信息泄漏一次API Key被写进日志的突发事件有一次联调同事在工具入参里加了一个raw_token字段我们的脱敏规则只匹配token作为完整key的情况结果raw_token没被识别API Key明文落盘了。这是可回放日志最尴尬的问题日志越完整越容易把不该记的东西记下来。修复不是一个正则表达式能解决的。我们做了一套字段级脱敏配置插件可以声明哪些字段是敏感字段序列化时按json path精确匹配另外在Transport里加了一道兜底过滤器检测常见密钥格式并整条mask。还加了一个离线扫描任务定期扫历史日志用高熵正则找出疑似泄漏的字符串。5.4 流式输出与超长会话的工程应对流式模型输出是回放里最容易出问题的地方。真实运行时model_response可能是一堆chunk事件带增量内容回放时如果简单地把所有chunk一次性喂给上层业务代码的流式解析逻辑会直接异常。我们为回放做了分流回放模式下的模型响应不再按chunk逐个吐而是把完整内容组装好后再一次性回调同时保留chunk边界信息避免影响消息解析。超长会话的应对比较简单日志按会话分片快照每50步存一个超过2000步的会话可以分段回放。同时把trace级日志默认关掉线上只保留info级别只有显式对某个会话开打点之后才记录完整trace。6. 把可回放日志当工程资产用测试、复现与性能分析6.1 最小回归闭环一次事故一条用例引入回放日志后最有价值的变化是回归测试方式变了。以前修完一个Agent bug只能靠测试同学手动构造对话路径验证费时且覆盖不全。现在线上发生事故后只要把出问题的那个会话日志拉下来清洗掉敏感信息存成一个.replay文件提交到测试仓库就能变成一条自动回归用例。CI里跑回放断言多了一条不许出现replay_mismatch。我们大概累积了两百多条这样的回放用例每次改动框架主流程或者某个核心插件都要全量跑一遍。这个效率是以前手工测试完全比不了的。6.2 线上问题离线复现的操作路径具体做离线复现时流程大概是这样的用户反馈某次会话行为异常在会话管理后台导出该session的日志文件在本地用同一版本Harness配置加载日志跑回放如果回放正常说明问题出在环境或非确定因素再用trace级日志重新定位如果回放失败replay_mismatch信息会直接指出第几步、期望事件是什么、实际事件是什么按图索骥。这个流程看起来简单但实际过程中最花时间的是配置版本对齐。日志里记录了Harness的版本号和插件版本清单复现时必须用同样的版本启动不然事件格式都可能不同。我们后来把版本信息作为session_started事件data的一部分彻底规避了拿新代码回放旧日志的错位。6.3 日志驱动的问题定位与性能分析回放日志沉淀下来后除了排障还能做不少分析。最常用的是工具调用耗时分析从tool_invocation事件里能直接拿到每个工具的执行耗时和返回体大小聚合后能看出哪个工具老是大请求返回。模型侧也一样model_request和model_response事件能统计出每次会话的模型调用次数、token消耗、哪个用户的会话特别耗token。这些指标以前都要靠监控系统额外埋点现在直接从会话日志里就能算出来。由于事件带有因果链我们还能做更细的分析从最终失败节点反推前置工具调用链一句话就能看到这个Agent是经过哪些步骤才陷入错误状态的。这在prompt调优时特别有用许多模型为什么不选择工具B的问题翻几份回放日志就能找到规律。最后再分享一点个人体会。做回放日志最花时间的地方不是设计事件格式而是约束团队不绕过Harness上报事件、不直接改插件入参、不在日志里夹带敏感数据大部分坑都不是技术问题而是纪律问题。如果你也要在Agent框架里做类似设计我建议把可回放当成一个硬性验收标准来约束架构而不是当成事后加上的调试工具。这个约束从第一天建立和从第200天开始补代价是完全不同的。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表