Agent开发中的可追溯性设计:从执行链路到结构化证据的完整实践
做 Agent 开发这两年最大的体会是让模型“跑起来”不难难的是跑完之后你还能说清楚它刚才到底干了什么、为什么这么干、每一步的结果能不能信。最近我对 NagaAgent 里的四个核心 Skills 做了一轮针对性调整目标非常明确——让 Agent 的执行结果更容易追溯。这篇文章把这次的完整思路、具体改法、踩过的坑全部写出来给同样在做 Agent 落地、被“黑盒执行”折磨的人一个参考。无论你是在调 agent 框架、写 skills还是研究 agent 架构和编排可追溯性都是绕不开的一环。尤其当你把 Agent 接到真实业务里留给你的不是“演示一下”而是“你给我解释清楚这几步”。我这次调整的四个 Skills分别覆盖了工具调用、记忆管理、任务规划和结果呈现基本构成了一条完整的执行链路。改完之后最直观的感受是以前排查问题靠猜现在靠查。1. 为什么 Agent 的执行结果总是说不清动手之前先聊清楚“追溯”到底在解决什么问题。很多人以为可追溯就是“多打日志”其实不是。Agent 和普通程序最大的区别在于普通程序的执行路径是代码写死的而 Agent 的执行路径是模型现场生成的。同一个问题跑十次可能有十种不同的工具调用顺序甚至十种不同的结论。代码可能撒谎但不会乱撒谎模型不会撒谎但会用自己的逻辑“创造”一条路径而这条路不一定是你想要的那条。1.1 一次事故日志里只有“已执行”三个字我这次重构的导火索很典型。当时 NagaAgent 在客户环境里跑一个数据清洗任务业务方反馈说某批数据被异常改动了要求我们回滚并给出说明。我去翻执行日志发现 ToolCaller 这个 Skill 的记录只有一句话“已调用 update_records”。至于调用了哪个接口、传了什么参数、改了多少条记录、执行前后数据长什么样完全没有任何记录。那一刻我才意识到过去的日志设计是站在“开发调试”角度写的不是站在“业务审计”角度写的。出现问题的第一时间我们拿不出任何能证明“Agent 干了什么”的证据更别提定位是哪一步出的错。可追溯不是锦上添花是 Agent 从玩具走向工具的门槛。1.2 说不清的三个根源黑盒决策、隐式状态、副作用丢失为什么 Agent 的执行结果天然难追溯我总结下来有三个根源第一模型推理是黑盒。Agent 的每一步动作背后是模型基于上下文做出的概率性决策。这个决策过程本身是不可完全复现的我们能记录的只有“决策结果”很难记录“为什么这么决策”。如果不强制模型把决策理由结构化输出那这段推理过程就彻底丢了。第二状态变化是隐式的。Agent 在执行过程中会读记忆、写记忆、更新上下文。但大多数框架的 memory 操作是无感的你在聊天记录里看到的是“Agent 记住了用户偏好”但看不到它是在哪一步、基于什么信息写入的。等到记忆被污染或者写错了你根本无从查起。第三外部副作用没有登记。Agent 调用外部工具会产生副作用比如写文件、发请求、改数据库。这些副作用如果不在调用发生时同步记录下来事后只能靠外部系统自己的日志去猜。而外部日志和 Agent 的执行上下文是割裂的想还原完整链路几乎不可能。可追溯性的本质就是把这三种“看不见的东西”变成“看得见的结构化证据”并且让它们之间能相互关联。2. 调整前的总体设计为什么只改四个 Skills先说 NagaAgent 的 Skills 是什么。在这个项目里Skills 就是 Agent 的能力模块类似“技能插件”。Agent 根据任务目标动态选择需要挂载哪些 Skills。每个 Skill 负责一类具体能力比如查资料、写代码、操作文件、管理记忆等。Skills 之间相互独立通过统一的上下文和工具接口协作。这次没有把所有 Skills 都重写一遍而是挑了四个ToolCaller工具调用、MemoryManager记忆管理、TaskPlanner任务规划、ReportBuilder结果呈现。选择标准很简单——这四个正好覆盖了 Agent 执行链路里最容易“说不清”的四个环节动作、状态、计划、输出。2.1 四个 Skills 在链路中的位置我画过一张执行链路的对应关系这里用文字描述一下TaskPlanner 在最前面负责把大目标拆成子任务决定“先做什么、后做什么”。ToolCaller 在中间负责执行具体动作是 Agent 和外部世界交互的出口。MemoryManager 贯穿全程负责读状态、写状态决定 Agent “记住了什么”。ReportBuilder 在最后负责把执行结果组织成用户能理解的内容。这四者串起来就是 Agent 从“思考”到“行动”再到“反馈”的完整闭环。任何一个环节不可追溯整条链路就会出现断点。比如 TaskPlanner 记得很清楚但 ToolCaller 没记录参数你就不知道计划执行得对不对ToolCaller 记录很详细但 ReportBuilder 输出时没有引用证据用户还是不知道结论怎么来的。2.2 四条调整原则结构化、链路化、可回放、脱敏在动代码之前我先给自己定了四条硬性原则所有调整必须同时满足结构化所有追溯信息必须是结构化数据不能是自然语言描述。自然语言只适合给人看不适合被检索、被分析、被自动化处理。链路化一次执行的所有记录必须通过 trace_id、call_id 这类标识关联起来。只有一条链路上的记录才能回答“这件事的前因后果”。可回放记录不能只看最终状态必须能还原执行过程。比如记忆系统光知道“最后记住了什么”不够还要知道“每一步是怎么变成这样的”。脱敏安全Agent 经常处理敏感数据记录时必须在源头脱敏否则追溯系统本身就会变成数据泄露的口子。这四条原则在后面所有改动里都起到了“方向盘”的作用很多细节决策都是靠它们定的。3. ToolCaller给每次工具调用上一道保险先改的是 ToolCaller因为它是 Agent 动作的出口。外部世界所有可见的变化几乎都是通过工具调用产生的。只要把工具调用记录清楚了追责和定位就解决了一大半。3.1 原来的记录方式为什么不够用改动前ToolCaller 的日志大概长这样[INFO] call_tool invoked with nameupdate_records, args{table: orders, where: {id: 1001}} [INFO] update_records returned success看起来好像有信息参数也打出来了。问题是第一这个日志是散落在运行日志流里的没有和整个任务的上下文绑定第二它只记录了返回值“success”没有记录执行耗时、token 消耗、错误堆栈第三如果有人同时跑多个任务日志流一混你就分不清哪条日志属于哪次执行。更关键的是日志和业务证据是分离的。业务方要的不是“success”而是“执行之前这条数据是多少执行之后变成多少”。这些信息必须在调用发生时由 ToolCaller 主动去采集和记录。3.2 调整后的核心数据结构我给每次工具调用引入了一个统一的 trace 概念核心字段如下{ trace_id: trac_8f3a2b1c4d5e6f7a, call_id: call_001, skill: tool_caller, tool_name: update_records, args: {table: orders, where: {id: 1001}}, args_sensitive: [], result_summary: updated 2 rows, result_snapshot: {before: {...}, after: {...}}, status: success, error: null, token_usage: {prompt: 1200, completion: 340}, duration_ms: 680, timestamp: 2025-04-01T10:23:45.123Z }重点关注几个设计细节args 和 result 分开存。args 是请求参数result 是返回结果两者都要记录因为追溯时需要回答“我们当时请求了什么、实际发生了什么事”。result_snapshot 是可选项。对于写操作类工具如更新数据库、修改文件ToolCaller 会在调用前后做一次状态快照记录 before 和 after。这不是所有工具都必须的但对于关键写操作这个字段是还原现场的核心证据。token_usage 一定要记。Agent 跑得慢或者花费高的时候这个字段能帮助你定位是哪一步、哪个工具消耗了大部分资源。3.3 关键实现思路包装器模式ToolCaller 的改法没有改每个工具的内部逻辑而是加了一层统一的包装器。核心思路是在工具调度入口做拦截def traced_call(tool_name, tool_func, args, trace_ctx): # 记录请求参数和开始时间 trace_ctx.record_request(tool_name, args) start time.time() try: result tool_func(**args) # 成功后记录摘要和结束快照 trace_ctx.record_success( summarybuild_summary(result), snapshotbuilder.after_snapshot(tool_name, args) ) return result except Exception as e: # 失败时必须记录完整异常栈 trace_ctx.record_error(errortraceback.format_exc()) raise finally: trace_ctx.record_duration(time.time() - start)这个包装器的好处是工具本身不用改所有工具自动获得了追溯能力。我见过很多项目在工具里手动打日志结果每个工具的日志格式都不一样最后统计和排查都非常痛苦。统一包装器让所有工具追溯信息的格式保持一致后面的分析和展示工作才会轻松。3.4 数据快照的取舍关于 result_snapshot这里有个必须说清楚的取舍。如果工具返回的数据特别大比如查询了 1 万条记录全量快照会把存储直接打爆。我的做法是引入一个采样和截断机制默认只记录前 50 条记录的快照超出部分统计成行数如果后续排查确实需要全量数据再通过工具内部日志去补齐。这样既保留了追溯能力又控制了存储成本。提示快照层不要做成“所有工具强制全量记录”而是做成可配置的。写操作、数据修改类工具开启前后快照读操作和查询类工具只记录摘要性价比会高很多。4. MemoryManager让“记住了什么”变成审计日志改 MemoryManager 的原因是 Agent 的记忆系统太容易出“暗病”。模型拿到的上下文、它写入的长期记忆、它在某个节点做出的判断都依赖记忆系统中的隐式状态。状态一旦被污染或者写错Agent 后续的行为就会全歪而且难以察觉。4.1 隐式记忆的坑在 NagaAgent 早期版本里MemoryManager 的写入触发逻辑类似“模型觉得重要就存”。模型可以在工具调用的返回值里插一句“remember this: xxx”MemoryManager 就真的去写了。问题在于没有任何审计信息——是谁哪个 Skill、哪个任务触发的写入写入时依据了什么原始数据写之前这个 key 的值是什么写之后变成什么有一次模型把一个用户的关键配置存错了导致后续所有回答都基于错误配置。我们排查了整整一个下午最后不得不用二分法清空记忆来找问题。如果记忆系统能记录每次写入的来源和变更前后值这个排查本来只需要十分钟。4.2 调整方案记忆写操作的完整审计我的改动思路是把 MemoryManager 当作一个数据库来管理所有写操作必须有“审计日志”。每次写入包含以下信息key记忆项的标识value_before写入前的值若无则为 nullvalue_after写入后的值source写入来源可能是 assistant 显式写入、任务总结写入、用户反馈写入等trigger_context触发本次写入的关键上下文摘要比如“用户在第 3 轮对话中明确表示偏好英文”timestamp时间戳write_id与 trace_id 关联核心实现是给所有写入口加一个“先读后写”环节def memory_write(key, value, source, trace_ctx): old_value memory_store.get(key) new_value value # 写入审计日志 audit_logger.record({ write_id: gen_id(), trace_id: trace_ctx.trace_id, key: key, value_before: old_value, value_after: new_value, source: source, trigger_context: trace_ctx.current_context_summary() }) # 执行实际写入 memory_store.set(key, new_value)这个改动最直接的好处是任何时候查询某个记忆 key都能看到它的完整变更历史。再加上 trace_id 关联你还能反查到是哪一次任务执行导致了某条记忆发生变化。4.3 记忆回滚从审计日志到恢复能力审计日志只解决了“看得到”的问题更进一步我还做了“回滚”能力。因为有了 value_before 和 value_after每条记忆变更都可以还原成一条“反向操作”。当发现某次执行写入了错误记忆时可以直接定位到该 trace_id 对应的所有写操作一键执行回滚。回滚的粒度不需要做到逐字符精确我的做法是保留最近 20 个版本的快照加上完整的变更日志。这样绝大多数场景下回到“误写之前的状态”只需要两步确认误写的时间点找到该时间点之前的版本并恢复。注意记忆回滚是高危操作必须在回滚前导出当前记忆快照做备份。否则回滚后想再往前找反而把证据弄丢了。我在踩过一次坑之后把“回滚前自动全量备份”加成了强制逻辑。5. TaskPlanner把计划从自然语言变成结构化对象TaskPlanner 的改动解决了“Agent 的计划不可对账”的问题。改动前TaskPlanner 输出的计划是一段自然语言。比如计划如下 1. 先查询订单数据 2. 然后清洗异常值 3. 最后生成报表这段文字看起来没问题但当你需要追究“为什么第 2 步做了 3 次”或者“计划里没有提到修改数据库但它实际改了数据库”时这段自然语言完全帮不上忙。它的最大问题是计划和执行之间没有任何结构化的对应关系。计划里写的“清洗异常值”和 ToolCaller 里实际调用的工具缺少一条可验证的映射链。5.1 结构化任务对象调整后TaskPlanner 的每个计划项变成了结构化对象{ task_id: task_001, parent_id: null, description: 清洗订单表中的异常值, goal: 确保订单金额字段无负数、无空值, status: completed, depends_on: [task_000], assigned_skill: tool_caller, actual_tool_calls: [call_001, call_002], result_summary: 清洗异常记录 23 条, alternative_reason: null }这段结构里有几个字段值得展开说depends_on 记录了任务依赖关系。这能回答“为什么这个任务在另一个任务之后执行”。assigned_skill 指明了执行该任务的 Skill。把计划和 Skill 绑定是为了 2.2 里说的链路化——一个计划项对应一个 Skill 实例。actual_tool_calls 是执行结果的回填。TaskPlanner 本身不执行工具但执行完成后会把 ToolCaller 生成的 call_id 回填到任务项上。这是计划与执行对账的关键连接点。alternative_reason 是给“计划外行为”留的位置。Agent 实际执行时经常偏离原计划比如原计划是查数据库但模型发现缓存里已有结果就跳过了查询。这个字段记录“为什么偏离了原计划”是追溯计划变更最不可缺的信息。5.2 计划也是一等公民TaskPlanner 的调整指导思想是计划本身也是“需要追溯的数据”而不是临时写在聊天历史里的文字。所以我让 TaskPlanner 每次生成或修改计划时都把计划快照写入独立的 trace 存储。这样一来不仅能看到“最终计划”还能看到“计划在运行过程中是怎么被修改的”。举个例子一个 Agent 任务最初计划 5 步执行到第 3 步时发现数据异常于是临时插入了第 4 步“数据探查”。如果计划不支持版本化事后你看到的计划就是后来修改过的 6 步版本完全不知道最初的计划里根本没有“数据探查”。而版本化之后这一变更过程就变成了有据可查的事实。5.3 计划与执行的差异分析做了上述两点之后我顺手加了一个“计划执行差异分析”功能。它在任务结束后运行一次逐项对比计划任务和实际工具调用输出三类差异已计划且已执行正常。已计划但未执行任务被跳过或取消需要查看 alternative_reason。未计划但已执行出现了计划外行为这是最危险的一类必须立即人工关注。这个差异分析看起来简单但在实际使用中价值极大。有一次 Agent 在计划外调用了一个删除操作的工具就是因为差异分析第一时间标红了那条未计划调用我们才避免了数据损失。6. ReportBuilder让结果学会“自证清白”前面三个 Skills 解决的是执行过程的追溯ReportBuilder 解决的是“结论”的追溯。很多时候用户根本不会去看执行日志他们只看 Agent 最后的报告。如果报告里的每一条结论都能指向具体的执行证据那份报告的可信度会完全不一样。6.1 调整前结论和证据是断开的改动前ReportBuilder 输出的内容是典型的“模型式总结”。比如已完成订单数据清洗共处理异常记录 23 条订单金额字段已恢复正常。这个报告从字面上看没问题。但用户追问“这 23 条异常记录具体是哪些”或者“你说恢复正常依据是什么”的时候报告里完全找不到答案。因为报告在生成时只是把模型的记忆结果组织成了文字并没有关联到 ToolCaller 的快照、MemoryManager 的变更记录或 TaskPlanner 的计划项。6.2 证据链设计调整后ReportBuilder 要求模型在生成每一条关键结论时附带证据引用。核心是一个 evidence_refs 字段{ conclusion: 订单金额字段异常值已清洗, evidence_refs: [ {type: tool_call, call_id: call_001}, {type: memory_write, write_id: write_0032}, {type: task_plan, task_id: task_001} ], confidence: high }这里的类型设计覆盖了前三个 Skills 产生的所有追溯信息工具调用、记忆写入、计划项。报告生成之后用户可以点击任意一条结论查看它的证据链上下文。比如点开 call_id就能看到那次工具调用的完整参数、前后快照和返回结果。这个设计让“报告”本身变成了一个可导航的证据入口而不是一段孤立文字。对于非技术用户他们不需要理解 trace_id 是什么只需要知道“Agent 说改了 23 条记录我能点开看是哪 23 条”。6.3 实现上的小技巧实现 evidence_refs 时我遇到一个典型的 prompt 工程问题直接让模型输出证据引用它经常编造不存在的 call_id。我的解决办法是不让模型凭空生成引用而是事先给它一个“可引用证据列表”。在进入 ReportBuilder 之前框架会把本次执行产生的所有 trace 信息汇总成一个有限的证据清单包含 call_id、write_id、task_id 及简短摘要然后要求模型只能从清单里选引用不能自创。这一步限制极大减少了幻觉引用。实测下来引用可验证率从不足 70% 提升到了接近 100%。7. 踩过的坑与排查速查表这轮改动整体顺利但中间也踩了不少坑。我把最有价值的几个坑写出来希望你们能绕开。7.1 坑一追溯信息太多反而拖垮了正常任务第一版改动里我把每个 Skill 的超详细上下文都写进了 trace。结果发现 Agent 正常执行速度没变但存储暴涨而且因为需要频繁写 trace单个工具调用耗时增加了 40%。后来我砍掉了大量非关键字段给所有追溯信息加了级别配置关键写操作记录全量快照读操作只记录摘要低价值日志干脆不进 trace 存储。7.2 坑二异步工具调用导致时间戳错位NagaAgent 里有不少异步执行的工具回调完成时间和发起调用时间可能会差好几秒。早期记录设计只记录了发起时间结果排查时发现“调用已经返回了 success但快照是调用发起前拍的”直接导致 before/after 对比失真。后来强制要求在回调完成态补写完成时间和最终快照不再依赖发起时间排序。7.3 坑三敏感数据进了追溯日志有一次自查 trace 数据时发现 ToolCaller 的 args 里记录了完整的用户手机号和地址。如果这部分数据被同步到分析平台就是妥妥的安全事故。后来我加了一个 args_sensitive 字段工具注册时必须声明哪些参数属于敏感字段Trace 层在记录前自动打码。给所有工具接入时都做了检查确保没有新增的敏感字段漏登记。7.4 坑四prompt 里放太多追溯指令模型变笨了为了让模型在 ReportBuilder 里输出更规范的结构我一开始塞了一大段 prompt 指令。结果模型确实更规范了但回答质量明显下降逻辑链也变乱了。后来我把所有强制约束从 prompt 里移出来改成通过函数调用function callingSchema 来控制输出格式。模型只需要按 Schema 返回结构化结果Prompt 反而精简了。7.5 排查速查表最后把我在实际排查中经常用的对照表整理出来遇到问题可以直接按表操作现象可能原因优先排查位置报告结论与执行日志不一致ReportBuilder 引用了错误的证据或幻觉引用检查 evidence_refs 是否真实存在记忆被污染回答偏离主题MemoryManager 写入了异常来源按 trace_id 查 memory 审计日志计划中发现未计划的外调工具TaskPlanner 计划变更未记录原因查计划版本 diff 和 alternative_reason工具调用报错但整体任务显示成功ToolCaller 错误被吞掉查 status 为 error 的 trace 记录同步逻辑混乱快照对比失真异步调用未记录完成时间查 duration_ms 与 timestamp 对齐情况追溯数据太小无法定位问题记录级别配置过低提升关键工具的追溯级别并重试8. 一些真实感受这次四个 Skills 的调整前前后后花了两周。最大的收获不是代码本身而是想明白了一件事Agent 的可追溯性从来不是一个日志功能它是一个贯穿执行链路的设计原则。你要在每一步设计数据结构的时候就想着“这条信息将来会被谁查、查什么、怎么查”而不是等出了问题再去翻流水账。按我个人习惯凡是涉及外部副作用的动作追溯级别至少要开到“全量记录快照”这个钱不能省。记忆系统的每次写入务必保留来源和触发上下文不然就是给未来的自己埋雷。最后提个建议任何追溯功能的实现一定要先想清楚“看的人是谁”——开发者看的是调用链业务方看的是证据链两种视角对应的信息组织方式完全不一样。把这两层分开设计Agent 才能真正从“能跑”变成“可信”。