Agent Bug 为什么不可复现

一、不可复现的四个根源

1. 模型采样的随机性

即使 prompt 一字不差,只要 temperature > 0(或没固定 seed),同一次调用两次返回也可能不同。更隐蔽的是 top-p / top-k 采样和推理引擎的 KV-cache 调度差异——它们在数学上幂等,在实际上却“看心情”。

传统做法的盲区:把 prompt 记下来不等于能把那次回答复现出来。你没有记录当时的 seed、当时的 temperature、当时的采样路径。

2. 工具的副作用

Agent 不是只读的。它会:

  • ShellChannel 执行命令、写文件、调外部 API
  • FileChannel 读写一个被“监狱”限制在 rootDir 内的文件系统
  • 改数据库、发消息、下单

这些副作用是不可逆且污染环境的。重跑一次,不仅结果可能不同,连“现场”都被改写了——你没法回到出错前的那个状态。

3. 并发与时序

多 Agent、子 Agent、事件总线(event bus)一上场,调用顺序就不再是确定的:

  • 调度器先跑 A 还是先跑 B?
  • 事件广播到插件 1 和插件 2 的先后?
  • 限流护栏在哪一次调用触发?

任何一个环节的时序抖动,都会让整条执行链分叉。

4. 隐性随机源(最容易被忽视)

Math.random()Date.now()、未注入的时钟/RNG。它们藏在工具实现、缓存 key、重试抖动、ID 生成里。只要有一处裸随机,重放从根上就不可能——你无法“冻结”一个你不知道存在的变量。


二、为什么传统日志救不了

很多框架会说:“我们记录了 session log,可以回看。”

log replay ≠ execution replay。区别在于:

日志回看(log replay) 执行重放(execution replay)
记录内容 “发生了什么”(文本/事件) 每次通道调用的 (输入指纹, 输出快照, 顺序)
能否逐字节复现 ❌ 缺输入指纹与输出对账 ✅ 注入冻结输出,零真实调用
能否在无凭据环境复现 ❌ 仍需真实模型/工具 ✅ 只依赖录制日志

更深层的是架构死结。以主流 Agent harness(如 DeepSeek Harness 类项目)为例:它的插件是无契约的裸插件,事件总线是网状广播session log 只能“回看”,无法“重演”——因为广播顺序、插件内部状态都不可控。要补“重放”,需要重构内核,而它的模型决定了“要么自由要么治理”,重放几乎不可能。

这不是“加个功能”的事,是“换内核范式”的事。


三、确定性重放的工程解法

Orbit Agent Runtime 的解法:把“复现”这件事下沉到网关层,而不是留给上层应用去碰运气。

3.1 录制:在网关统一拦截

所有通道调用(LLM、KV、文件、Shell)都经 ChannelHub.fireChannelCall 分发。在分发路径上,每次调用被记录为一个 ReplayCallRecord

interface ReplayCallRecord {
  orderIndex: number;        // 全局调用顺序,保证调度确定性
  channelKind: ChannelKind;  // 如 LLM_ACCESS / FILE_SYSTEM / SHELL_EXEC
  funcName: string;          // 如 chatRound / writeTextFile
  inputDigest: string;       // 输入的指纹(哈希)
  outputSnapshot: unknown;   // 输出的完整快照
  durationMs: number;
}

关键是:顺序(orderIndex)也一起记录了。这样连“调度抖动”都被冻结。

3.2 重放:零真实调用

重放时,内核挂载 ReplayEngine,把每条调用指向录制日志里的冻结输出:

replayCall(kind, funcName, inputDigest, orderIndex) {
  const record = this.journal.get(orderIndex);
  // 签名校验:channelKind / funcName / inputDigest 必须一致
  if (record.channelKind !== kind || record.funcName !== funcName || record.inputDigest !== inputDigest) {
    throw new ReplayDriftError(`call #${orderIndex} signature mismatch`);
  }
  return structuredClone(record.outputSnapshot); // 注入冻结输出,不碰模型/工具/网络
}

这意味着:

  • 不调模型、不跑工具、不连网络——即使你机器上没装 API key、没装 FileChannel,也能纯重放。
  • 一处签名不一致,立刻抛出 ReplayDriftError,告诉你“第几条调用开始对不上了”。

内核里有个反直觉但关键的设计:重放快速路径被放在“通道 provider 是否存在”的检查之前。也就是说,重放一个轨迹不需要真实通道已安装——只有能力门禁(capability gate)仍然强制,治理不降级。

3.3 digest 链对账:银行式核对

重放跑完,内核做一遍 reconcile——像银行对账那样逐条比对原始链与重放链:

interface ReconcileReport {
  originalCount: number;
  replayedCount: number;
  digestChainConsistent: boolean;
  driftAtOrderIndex?: number;   // 第一个分叉点
}

任一条的 channelKind / funcName / inputDigest / 输出 digest 不同,digestChainConsistent 立即变 false,并精确定位到 driftAtOrderIndex——这就是你调试的入口。

3.4 确定性边界在网关层

光记录不够。如果工具实现里藏了裸随机,录制本身就不确定。所以内核定义了三档确定性契约

enum DeterminismLevel {
  DETERMINISTIC = "deterministic",  // 纯函数:同输入同输出
  STOCHASTIC    = "stochastic",     // 含随机:通过 ctx.rng 注入种子
  IO_BOUND      = "io-bound"        // 碰外部状态:重放时注入输出快照
}

随机数和时钟不是直接用 Math.random() / Date.now(),而是从 ctx 注入:

// 注入器:可复现的 PRNG(mulberry32)与单调时钟
class SeededRng { /* seed 相同 → 序列完全相同 */ }
class FixedClock { /* now() 在 base 上单调 +1 */ }

任何通道若直接调 Math.random 都算违规。这一条被 replay_compat CI 门禁套件用“投毒测试”守住——在所有新通道上拦截裸随机,犯规即红。


四、三个公理

确定性重放只是第一公理。Orbit 的内核立在三公理之上,每条都带 CI 验证方式:

公理 内容 验证
A1 可复现 零外部调用重放,逐字节一致,digest 链防篡改 replay_compat 测试套件逐机制用例
A2 可证明 故障影响 = 反向可达闭包,隔离定理 图论单测 + 注册期能力闭包校验
A3 可核算 成本/Token/延迟进统一账本,可重放核算 账本对账测试

任何改动,是让“可复现·可证明·可核算”更强,还是更弱——这是判定标准。


五、它能解决什么,不能解决什么(诚实边界)

能:

  • 把一次出错的运行冻结成文件orbit-trace.jsonl + 脱敏的 .meta.json),随时在任意机器上 100% 复现,无需凭据、无需工具。
  • diff 两条轨迹,精确定位第一个分叉点——是 LLM 回答变了?是文件写入内容变了?还是调用顺序变了?
  • Agent 回归测试:把“黄金轨迹”存进仓库,每次改动后自动 replay 对账。
  • 事故复盘:证明“当时确实发生了什么”,而不是靠回忆。

不能:

  • 不自动修复 bug。它给你一把手术刀,不替你开刀。
  • 不保证模型产出正确。它保证的是“可证明当时确实如此”——把不可言说的随机,变成可审计的事实。
  • 不替代单元测试。它管的是“端到端执行链的可复现”,单点逻辑还得靠单测。

六、动手试试

安装(发布后):

npm i -g orbit-agent-runtime

写一个脚本,默认导出一个 async (ctx) 函数:

// my-agent.mjs
export default async function (ctx) {
  const plan = await ctx.llm.chat("分解这个需求");
  const cached = await ctx.call(ctx.ChannelKind.MEM_KV_STORE, "readEntry", "lastPlan");
  await ctx.call(ctx.ChannelKind.FILE_SYSTEM, "writeTextFile", "plan.md", plan);
  return { plan, cached };
}

录制 → 重放 → 比对,形成确定性闭环:

orbit record my-agent.mjs --out run1.jsonl   # 真实跑一遍,录下每条调用
orbit replay run1.jsonl                       # 零模型调用重放 + digest 链对账
orbit diff run1.jsonl run2.jsonl              # 定位两条运行的分歧点

在 Orbit 控制台(Web 管理台)里,同一个 demo 的真实运行约 300ms,而确定性重放只需约 1ms——因为重放根本不碰模型、不碰磁盘、不碰网络,只是把冻结的输出“还”给你。


结语

Agent bug 不可复现,不是因为开发者粗心,而是因为运行模型从根上假设了“非确定性是默认值”。

Orbit 的选择是反过来:把确定性设为默认值,把非确定性变成需要显式声明的例外(种子注入、输出快照)。于是“复现一个 bug”从“玄学”变成“读一个文件”。

posted @ 2026-08-29 17:23  zzuCharles  阅读(3)  评论(0)    收藏  举报