线上 Agent 偶尔会出现一种最难处理的故障用户说它重复发了两封邮件日志里只有最终回答开发者拿同一个问题重新运行模型却选择了另一条路径一切看起来正常。模型输出具有随机性检索索引持续更新网页内容会变化工具依赖的权限和时间也在变化。仅保存用户问题几乎不可能还原当时发生了什么。“确定性重放”常被误解为再次调用同一模型并得到逐字相同的输出。即使固定 temperature 和 seed模型服务版本、批处理、硬件算子与系统提示变化仍可能导致差异。工程上更可靠的目标是记录首次运行的决策输入、模型输出、工具请求、工具回执和状态转移重放时使用这些已记录外部结果让 Agent 控制流以同样顺序执行并验证它是否产生相同状态与产物摘要。这种重放不会重新证明模型今天仍会做出同样选择它证明的是“在当时那些观察结果下运行时为什么走到这个状态”。若要评估新模型或新提示词应从同一输入快照执行一次新的对照运行称为再执行或分叉实验而不是冒充原任务重放。本文构建一个可离线运行的事件日志和记录/重放适配器演示怎样复现工具调用、阻止副作用重复发生并用摘要校验检测代码漂移。示例很小但保留了生产系统必须有的边界。1. 先定义四种容易混淆的能力Trace 是一次运行的关联视图。它把模型调用、工具调用、检索、状态更新等 Span 串在同一个 trace_id 下适合观察耗时和错误。但普通 Trace 可能只保存名称、时间与状态不一定包含重放所需的完整输入和输出。审计日志回答谁在何时做了什么强调不可抵赖、访问控制和保留策略。它通常故意不保存完整敏感正文也不保证能直接喂给程序重跑。审计与重放有交集但目标不同。确定性重放使用已经记录的外部结果重新驱动同一版本控制逻辑。它不访问真实模型、网页、数据库写接口和邮件服务因此可安全重复并能检查事件顺序与状态不变量。再执行则从某个输入快照出发重新调用当前或指定版本的模型和工具。它用于比较模型、提示词、检索索引或代码升级后的差异会产生新的 run_id结果本来就可能不同。把再执行叫重放会让排障结论失真也可能重复外部副作用。2. 为什么“同样输入”其实并不相同用户文本只是 Agent 输入的一小部分。完整输入还包括系统提示词、工具 schema、模型名称与参数、对话历史、用户权限、租户配置、当前时间、环境开关、知识库索引版本以及工具所读取的数据快照。任何一项变化都可能改变路径。比如用户问“今天到期的工单”当前时间必须作为显式输入若代码在运行时直接调用系统时钟第二天重跑必然看到不同工单。又如工具列表按权限动态生成仅保存模型请求正文而没有权限快照开发环境可能多出一个管理员工具规划结果自然不同。因此输入快照不是复制一段 Prompt而是给所有影响决策的依赖建立可识别版本。大对象可以保存内容寻址引用和摘要不必全部塞进事件行但摘要指向的对象必须在保留期内可获取并受同样严格的访问控制。3. 可重放系统的事件边界Agent 运行时可以拆成纯控制逻辑与外部边界。纯控制逻辑包括状态机、动作校验、预算判断和结果合并外部边界包括模型、工具、时钟、随机数、数据库读取和网络响应。记录模式真实调用外部边界并保存结果重放模式按顺序返回记录结果。下图展示两种模式共享同一控制逻辑记录模式重放模式输入快照Agent控制逻辑外部调用适配器模型/工具/时钟追加事件与回执不可变事件日志校验调用名称与参数摘要状态与产物摘要与原运行一致?控制逻辑不能绕过适配器直接读取系统时间或环境变量否则重放仍会受到当前环境影响。最常被遗漏的边界是随机标识、排序不稳定的集合和后台并发完成顺序。它们都应显式注入或记录。4. 一条事件至少保存什么事件需要全局唯一 event_id、run_id、递增 sequence、事件类型、发生时间、输入摘要、输出摘要和载荷引用。与分布式 Trace 关联时再保存 trace_id、span_id 和 parent_span_id。sequence 由单个运行的追加存储保证不能靠时间戳排序因为多个事件可能具有相同时间精度。模型调用事件应记录提供方、模型标识、可获得的模型版本、请求参数、提示词模板版本、消息摘要、工具 schema 版本、响应、usage 和停止原因。完整提示词可能包含隐私应根据数据分级决定加密保存、字段脱敏或只保存摘要。没有原文就不能进行完整内容重放但仍可重放控制流这个能力差异必须标清。工具事件应记录稳定工具名、版本、规范化参数、权限决策、幂等键、结果摘要、错误类别和外部关联标识。发送邮件后回执里的 message_id 比自然语言“发送成功”更有价值。检索事件还应记录索引版本、过滤条件、命中文档标识、块版本和排序分数。5. 事件必须追加不能事后拼接如果任务完成后再从内存组装日志进程崩溃时最重要的最后几步会丢失。关键事件要随着状态变化追加保存。对于副作用至少记录 intent、started、succeeded 或 failed只有一条“调用邮件工具”无法判断邮件是否已经发出。事件与业务状态之间存在双写问题。更稳妥的设计是同一数据库事务中更新任务状态并写 Outbox 事件再由发布进程投递到 Trace 或分析系统。外部副作用不能与本地事务原子提交因此需要幂等键和结果查询接口。崩溃恢复时先查询外部状态而不是盲目再调用。事件载荷应使用 schema_version。新增可选字段通常向后兼容改变字段含义则需要新版本与迁移器。重放器遇到不支持的必需版本应停止不能猜测旧字段的含义。6. 用哈希建立内容身份对输入、参数和输出计算摘要可以检测数据是否改变也能让大对象只保存一次。摘要应基于规范化序列化结果例如 JSON 使用固定键顺序和稳定分隔符。直接对 Python 字典的显示文本哈希可能因版本和顺序变化得到不同结果。哈希不是加密。对手机号、邮箱或短枚举直接求摘要攻击者可以穷举原文。敏感数据需要加密、访问控制和密钥轮换若只需比较相等性可以使用带密钥的 HMAC。事件摘要用于完整性校验不应该被宣传成脱敏方案。下面实现最小事件结构、规范化摘要和内存事件存储。生产系统会换成数据库或对象存储但不变量相同序号连续、已有事件不可修改、载荷能校验摘要。from__future__importannotationsimporthashlibimportjsonfromdataclassesimportdataclassfromdatetimeimportdatetime,timezonefromtypingimportAnyfromuuidimportuuid4defcanonical_json(value:Any)-str:returnjson.dumps(value,ensure_asciiFalse,sort_keysTrue,separators(,,:),)defdigest(value:Any)-str:returnhashlib.sha256(canonical_json(value).encode(utf-8)).hexdigest()dataclass(frozenTrue)classEvent:event_id:strrun_id:strsequence:intevent_type:strrecorded_at:strinput_digest:stroutput_digest:strpayload:dict[str,Any]schema_version:int1classEventLog:def__init__(self,run_id:str)-None:self.run_idrun_id self._events:list[Event][]defappend(self,event_type:str,call_input:dict[str,Any],call_output:dict[str,Any],)-Event:eventEvent(event_idstr(uuid4()),run_idself.run_id,sequencelen(self._events)1,event_typeevent_type,recorded_atdatetime.now(timezone.utc).isoformat(),input_digestdigest(call_input),output_digestdigest(call_output),payload{input:call_input,output:call_output},)self._events.append(event)returneventdefevents(self)-tuple[Event,...]:returntuple(self._events)if__name____main__:logEventLog(run-demo)itemlog.append(clock.read,{},{now:2026-09-28T08:00:00Z})assertitem.sequence1assertitem.output_digestdigest(item.payload[output])assertlog.events()[0]isitem内存列表只用于演示。真正事件库要对(run_id, sequence)和 event_id 建唯一约束并禁止更新修正信息通过追加新事件完成。若允许后台程序静默修改历史载荷重放得到的只是当前希望看到的故事而不是当时现场。7. 记录模式与重放模式适配器的关键规则是记录模式执行真实函数并追加回执重放模式不执行真实函数只消费下一条匹配事件。匹配不仅比较工具名还比较规范化参数摘要。如果当前代码准备调用另一个参数说明控制流已经分叉必须立即报告差异。重放完成时还要确认所有事件已消费。少消费说明新代码提前结束多消费会在读取时越界。两种情况都属于漂移不能因为最终答案碰巧相同就判定重放通过。fromdataclassesimportdataclassfromtypingimportCallable,Literal ModeLiteral[record,replay]classReplayMismatch(RuntimeError):passclassCallBoundary:def__init__(self,mode:Mode,log:EventLog)-None:self.modemode self.loglog self.cursor0defcall(self,name:str,arguments:dict[str,Any],real_call:Callable[[],dict[str,Any]],)-dict[str,Any]:event_typefcall.{name}ifself.moderecord:resultreal_call()self.log.append(event_type,arguments,result)self.cursor1returnresult eventsself.log.events()ifself.cursorlen(events):raiseReplayMismatch(f缺少重放事件{event_type})eventevents[self.cursor]ifevent.event_type!event_type:raiseReplayMismatch(f事件类型不一致期望{event_type}实际{event.event_type})ifevent.input_digest!digest(arguments):raiseReplayMismatch(f调用参数不一致{event_type})ifevent.output_digest!digest(event.payload[output]):raiseReplayMismatch(f事件载荷摘要不一致{event_type})self.cursor1returndict(event.payload[output])defassert_finished(self)-None:ifself.cursor!len(self.log.events()):raiseReplayMismatch(重放结束时仍有未消费事件)dataclass(frozenTrue)classAgentResult:status:strsummary:strreceipt_id:str|Nonedefrun_ticket_agent(boundary:CallBoundary,ticket_id:str,send_enabled:bool,)-AgentResult:ticketboundary.call(ticket.read,{ticket_id:ticket_id},lambda:{owner:ops,severity:high,resolved:False},)ifticket[resolved]:returnAgentResult(completed,工单已经解决无需通知。,None)ifticket[severity]!highornotsend_enabled:returnAgentResult(waiting,需要人工确认是否发送通知。,None)# real_call 代表真实副作用重放模式绝不会执行它。receiptboundary.call(notification.send,{ticket_id:ticket_id,recipient:ticket[owner]},lambda:{message_id:msg-001,accepted:True},)returnAgentResult(completed,高优先级通知已提交。,receipt[message_id])if__name____main__:recorded_logEventLog(run-001)firstrun_ticket_agent(CallBoundary(record,recorded_log),T-100,True)side_effect_calls0defforbidden_side_effect()-dict[str,Any]:globalside_effect_calls side_effect_calls1raiseAssertionError(重放不应执行真实副作用)replayCallBoundary(replay,recorded_log)secondrun_ticket_agent(replay,T-100,True)replay.assert_finished()assertfirstsecondassertside_effect_calls0assertdigest(first.__dict__)digest(second.__dict__)print(second)示例中forbidden_side_effect没有传入实际运行路径因为重放器直接返回记录回执断言的核心是重放期间没有任何外部调用。如果把真实工具封装成对象可以用一个在任何调用时都抛错的 ReplayTransport让测试更直观。8. 模型调用怎样记录模型是外部边界处理方式与工具相同。记录模式保存规范化请求摘要和完整响应对象包括候选文本、结构化工具调用、停止原因和 usage。重放模式直接返回该响应不重新请求模型。这样控制流能够稳定进入相同工具分支。但模型输入往往包含大量敏感内容。可以将消息体加密存放在独立对象存储事件只保存引用、摘要和数据分类访问解密内容需要单独权限和审批。若合规要求禁止长期保存原文系统只能做有限重放验证事件顺序与摘要但无法在新代码上重新构造完整输入。能力说明必须诚实。所谓 seed 应作为请求参数记录但不能把它当成跨版本一致性的保证。供应商可能提供近似确定性声明也可能根本不保证。确定性控制流依赖记录响应而不是依赖再次采样恰好相同。9. 时钟、随机数与标识生成Agent 常在不经意间读取当前时间例如判断是否过期、生成报告日期或筛选“今天”的记录。把now()封装为边界记录模式返回真实 UTC 时间重放模式返回事件中的时间。业务时区另行转换不能保存无时区的模糊时间。随机数和 UUID 也会进入参数、缓存键和数据库记录。若它们影响控制流或最终产物应通过注入的生成器产生并记录。若只是 Trace 的内部 event_id不需要重放一致但必须明确哪些标识是业务身份哪些是观察身份。排序是另一个隐藏随机源。数据库没有 ORDER BY 时不保证行顺序集合和并行任务也可能以不同顺序完成。控制逻辑若依赖“第一个结果”就需要在边界层记录顺序或在纯逻辑中使用稳定排序和明确的并列规则。10. 并发任务如何重放并发执行带来两种顺序计划启动顺序和实际完成顺序。若合并逻辑按完成先后处理重放必须记录 completion 事件若业务不应依赖完成顺序则应在合并前按稳定键排序从根源上减少非确定性。每个子任务可以拥有自己的 span_id 和局部 sequence父任务记录 fork、join 与取消。单一全局 sequence 最容易理解但高并发写入可能成为瓶颈分区事件流更可扩展却需要因果关系字段。不要仅靠毫秒时间戳推导谁先于谁。重放时通常不需要真实并发。按记录顺序返回结果即可验证状态机。若要复现竞态需要专门的调度器按照记录的同步点推进这属于并发故障实验不是每个 Agent 平台第一版必须实现的能力。11. 副作用为什么不能在重放中执行发送邮件、创建工单、付款和修改权限都不可当作普通只读函数。重放再次调用会造成真实损害。记录事件应保存幂等键、外部请求标识与回执重放只消费回执。再执行若确实需要测试副作用应连接沙箱账户并默认禁用发送。记录模式本身也要面对不确定性请求发出后连接断开不知道外部系统是否完成。事件流可以记录effect.requested随后查询外部系统查到结果后追加effect.confirmed。没有确认前不能简单重试。幂等键由任务和逻辑动作稳定生成外部系统支持时可安全复用。“重放环境没有真实凭证”是正确设计。读取历史回执不需要生产 API 密钥。若重放器要求加载生产凭证说明某个外部边界没有被隔离完整。12. 输入快照应该包含哪些版本代码版本可以使用 Git commit 或构建镜像摘要提示词使用模板标识和内容摘要工具使用 schema 版本模型保存提供方标识和可获得的快照版本知识库保存索引版本与文档版本集合策略保存权限和预算策略版本。依赖包锁文件与运行时版本也值得记录。配置不能只保存“当前生产”。Feature Flag 在事故发生后可能已经切换重放必须知道当时命中的值和评估上下文。权限快照保存决策结果与策略版本敏感组织关系可以用引用表示但引用内容必须能按历史时间查询。快照不意味着复制整个数据库。对只读查询可以保存查询、参数、数据快照号和结果回执。若数据库支持时间旅行可保存可恢复的事务时间点否则保存实际返回行的受控副本。选择取决于合规和存储成本但至少要知道当前能否完整还原。13. Trace 与重放日志怎样关联采用 W3C Trace Context 时请求沿服务传递 traceparentAgent 的模型和工具调用成为子 Span。事件记录 trace_id 与 span_id运维可以从延迟图跳到重放数据也可以从某个异常事件查看网络依赖。Span 属性适合低基数检索例如模型名、工具名、状态码和 Token 数不适合保存完整提示词。大载荷放在受控事件存储通过不可猜测引用关联。不要把用户原文塞进所有 Trace 后端可观测平台通常访问面更广、保留策略也不同。采样策略需要特殊处理。普通成功请求可以按比例采样 Trace但重放事件若只在故障发生后才想保存已经来不及。可以为关键状态和副作用保留完整事件为大模型正文使用分级保留或者先短期加密保存超过排障窗口后删除正文、保留摘要。14. 如何检测代码或数据漂移重放器在每个边界比较调用类型与输入摘要能在第一次分叉处停止。例如旧代码先查工单再发通知新代码因为条件变化直接结束重放结束时会发现未消费事件新代码多调用一次搜索则会报告缺少事件。最终还要比较任务状态、产物摘要和关键业务事件。仅比较自然语言答案过于严格也过于宽松措辞变化可能无害而一段相同文字可能隐藏重复发送。应分别定义控制流一致、业务状态一致、内容一致三个层级。对于合法升级可以编写事件迁移器把旧 schema 转成新读取模型但不能覆盖历史原件。迁移器有版本和测试输出新视图或派生事件。若语义已经无法映射应明确该运行不支持新版本重放。15. 从故障现场分叉实验定位问题后开发者往往想验证修复。可以从原输入快照创建新的 run_id选择“模型响应沿用、工具结果沿用”来只测试控制逻辑也可以保留初始输入改用新模型与沙箱工具观察行为变化。每个分叉都记录 parent_run_id 和修改项。例如事故由工具结果为空后仍发送通知造成。第一轮确定性重放证明旧逻辑确实经过该分支第二轮在相同事件上运行修复代码应在发送前停止并把未消费的发送事件报告为预期差异第三轮用新的真实沙箱工具做端到端验证。三种证据各自回答不同问题。不能把历史事件偷偷编辑成修复后的结果再宣称重放通过。事故证据与测试夹具都应不可变新的预期结果存放在测试断言或派生运行中。16. 最小验证矩阵首先验证正常重放相同代码消费全部事件最终状态与摘要一致真实工具调用计数为零。然后修改一个工具参数确认在第一次边界就报告 input_digest 不一致。删除一条事件确认读取时报告缺失篡改载荷确认 output_digest 校验失败。接着测试提前结束和额外调用。提前结束必须报告未消费事件额外调用必须报告日志耗尽。测试同一个重放可运行多次且不改变日志。对于副作用使用一个调用即抛错的传输层证明重放完全离线。再测试隐私和权限无重放权限的用户不能读取载荷只有摘要权限的运维可以看流程但看不到正文解密操作产生审计事件。删除请求到期后若正文已删除应将运行标记为 limited_replay而不是返回找不到文件的内部错误。17. 常见失败模式一只保存最终 Prompt最终 Prompt 可能已经包含工具结果却没有记录工具为什么被调用、参数是什么、权限是否通过和是否发生副作用。它可以帮助分析生成内容却无法复现控制流。多轮 Agent 还可能在上下文压缩时丢掉早期细节。正确做法是记录边界事件和状态转移Prompt 只是模型调用事件的一部分。若存储成本有限优先保留稳定标识、摘要、状态与副作用回执再按数据等级决定是否保存全文。18. 常见失败模式二把日志当事件源普通应用日志是面向人阅读的字符串可能乱序、重复、采样或在格式升级后改变。依靠正则从日志恢复状态非常脆弱。重放事件应该有固定 schema、序号和唯一约束日志可以引用 event_id但不能代替事件库。同样Trace 后端可能根据成本丢弃 Span 或截断属性。若业务要求一定能重放事件先写可靠存储再异步投递可观测平台。观察系统不可用不应阻塞所有业务但副作用前后的关键事件必须有持久保障。19. 常见失败模式三记录过多敏感数据为了“以后什么都能查”保存完整对话、数据库行、访问令牌和工具响应会把排障系统变成高价值数据仓库。访问令牌、Cookie、私钥和一次性凭证不应进入事件个人信息与商业数据按字段分类、加密和设置保留期。重放使用的认证决策应保存结果、主体引用和策略版本而不是保存可再次使用的凭证。需要访问历史加密载荷时采用最小权限、审批和审计。测试环境读取生产事件前还要做数据隔离不能因为是“调试”就突破用途限制。摘要也需要谨慎。低熵字段可能被枚举文件摘要可能泄露某个已知文件是否存在。跨租户不要共享可搜索摘要索引必要时使用租户密钥 HMAC。20. 安全与证据完整性事件库应追加写、限制更新权限并对事件批次构建哈希链或签名以便发现删除与篡改。哈希链不是访问控制仍需加密、身份认证和审计。签名密钥与业务服务权限分离避免被入侵的 Worker 同时伪造历史。重放入口本身可能被滥用来读取历史敏感数据。它需要单独权限、用途说明和速率限制高风险运行要求审批。导出事件时默认删除正文与凭证字段生成一次性、短期下载地址。外部输入仍是不可信的。事件载荷里可能包含提示词注入、恶意 HTML 或终端转义符重放界面必须转义展示重放器把模型与工具输出当数据不能根据载荷中的文本动态导入代码或执行命令。21. 生产化存储与保留策略元数据和小事件适合关系数据库较大加密载荷适合对象存储。数据库保存对象键、摘要、大小、密钥版本和保留期限。写入顺序要避免数据库事件已经可见但对象尚未上传可先上传临时对象事务提交引用后再转正式状态并由清理任务处理孤儿对象。保留期按价值与风险分层。副作用回执和安全审计可能保留较久完整提示词只保留短期普通成功任务只保存摘要事故任务在合规批准后延长。法律保全与用户删除请求出现冲突时由明确政策处理不能由工程师临时决定。索引字段控制在排障所需范围。不要给完整用户文本建立全局搜索。常用检索条件是 run_id、task_id、trace_id、时间范围、工具名、状态和错误码。正文访问走更严格通道。22. 可观测指标应该反映重放能力系统可以统计可完整重放、仅控制流重放和不可重放的任务比例事件写入失败率输入快照缺失率重放首次分叉的位置不同代码版本的重放通过率副作用事件缺少回执的数量。只有“Trace 覆盖率”无法说明能否复现。事件写入延迟也要观察。若关键回执长时间停留在内存崩溃窗口会扩大。摘要计算和加密耗时不能无限阻塞用户路径可以使用有界队列与降级策略但涉及副作用的事件不能静默丢弃。当重放失败时错误应指向第一个不一致事件包括期望类型、实际类型、序号和摘要不要一次输出完整敏感载荷。第一分叉点最有诊断价值后续差异通常只是连锁结果。23. 什么时候不需要完整重放简单无状态问答没有工具、副作用和复杂状态机时保存模型请求摘要、响应和版本可能已经足够。为每次闲聊建设事件源会增加存储与隐私成本。是否需要完整重放取决于业务风险、故障频率和审计要求。一旦 Agent 能发消息、改数据、调用生产工具或跨越多个异步步骤完整事件链的价值会迅速上升。此时只靠应用日志进行事故调查代价通常高于提前设计稳定事件。可以从最小边界开始模型调用、工具调用、状态转移和副作用回执。时钟、随机数、并发调度等依赖在真实故障暴露后逐步纳入但任何未覆盖边界都要在能力文档中列出不能宣称百分之百确定性。24. 重放数据也要做灾难恢复演练事件库本身故障时团队最需要的事故证据可能恰好不可用。备份应同时覆盖事件元数据、加密载荷、密钥版本和对象引用并定期在隔离环境恢复抽样运行。只验证数据库备份成功却没有检查对象存储和解密密钥恢复后仍可能得到一组无法读取的指针。恢复演练要校验事件序号、载荷摘要、对象数量和重放结果记录恢复点目标与恢复时间。跨区域副本也遵守相同数据驻留和删除策略不能以容灾为由无限复制敏感提示词。若某段事件因保留策略已经合法删除备份过期时也应同步删除而不是从旧备份悄悄复活。小结Agent 的确定性重放不是再次询问模型并期待相同文字而是记录首次运行的外部观察结果用这些回执重新驱动同一控制逻辑。Trace 提供关联输入快照固定版本事件日志保存顺序记录/重放适配器隔离模型、工具、时钟和副作用摘要则帮助发现第一次分叉。本文的最小实现已经证明三个关键不变量重放按顺序消费事件参数或载荷变化会立即失败真实副作用不会再次执行。生产化还需可靠事件存储、对象加密、幂等回执、版本迁移和分级保留。把这些基础打牢后团队才能从“这次为什么无法复现”前进到“故障在哪个事件第一次发生”并用同一现场验证修复。参考资料W3C Trace Context 推荐标准OpenTelemetry 官方文档TracesOpenTelemetry生成式 AI 语义约定Temporal 官方文档Workflow 的持久执行与重放Python 官方文档json 模块Python 官方文档hashlib 模块RFC 2104HMACNIST SP 800-92计算机安全日志管理指南