阐述如何将 append-only 事件流转换为带因果关系的执行树以辅助调试 AI Agent,涵盖并发、乱序、span 身份稳定性等工程细节。
AI Agent 的追踪通常以事件序列的形式写入,因为只追加的数据结构易于产生:
span_started
span_started
span_ended
span_started
span_ended
span_ended
开发者不想直接调试这种序列。他们想看到因果结构:
research_agent
├─ search_web
├─ query_database
├─ call_finance_api
│ ├─ attempt_1 timeout
│ └─ attempt_2 ok
└─ summarize_results
事件流针对写入做了优化。执行树针对理解做了优化。构建一棵可靠的树不仅仅需要按时间戳排序:事件可能乱序到达,兄弟节点可能并发运行,span 可能不完整,重试可能失败而父操作仍然成功。
每个事件都需要稳定的 trace 和 span 标识。Start 事件建立父子关系;end 事件建立结果和持续时间。
type SpanKind = 'run' | 'model' | 'tool' | 'retrieval' | 'decision' | 'fallback';
type TraceEvent =
| {
event: 'span_started';
traceId: string;
spanId: string;
parentSpanId: string | null;
name: string;
kind: SpanKind;
timestampMs: number;
}
| {
event: 'span_ended';
traceId: string;
spanId: string;
timestampMs: number;
status: 'ok' | 'error' | 'cancelled';
errorCategory?: string;
metadata?: Record<string, string | number | boolean | null>;
};
仅靠时间戳无法建立父子关系。连续发生的两个事件可能是兄弟节点、不相关的并发工作或来自不同 trace 的操作。
Start 和 end 事件可能不会一起到达。缓冲导出器可能先交付 end,而崩溃的进程可能根本不交付 end。
显式表示组装状态:
type AssembledSpan = {
traceId: string;
spanId: string;
parentSpanId: string | null;
name: string;
kind: SpanKind;
startedAtMs: number;
endedAtMs?: number;
status: 'open' | 'ok' | 'error' | 'cancelled';
errorCategory?: string;
metadata: Record<string, string | number | boolean | null>;
};
type AssemblyDiagnostic = {
code:
| 'duplicate_start'
| 'duplicate_end'
| 'end_without_start'
| 'span_left_open';
spanId: string;
};
class SpanAssembler {
private readonly spans = new Map<string, AssembledSpan>();
private readonly pendingEnds = new Map<
string,
Extract<TraceEvent, { event: 'span_ended' }>
>();
readonly diagnostics: AssemblyDiagnostic[] = [];
accept(event: TraceEvent): void {
if (event.event === 'span_started') {
if (this.spans.has(event.spanId)) {
this.diagnostics.push({ code: 'duplicate_start', spanId: event.spanId });
return;
}
const span: AssembledSpan = {
traceId: event.traceId,
spanId: event.spanId,
parentSpanId: event.parentSpanId,
name: event.name,
kind: event.kind,
startedAtMs: event.timestampMs,
status: 'open',
metadata: {},
};
this.spans.set(event.spanId, span);
const pending = this.pendingEnds.get(event.spanId);
if (pending) {
this.pendingEnds.delete(event.spanId);
this.applyEnd(span, pending);
}
return;
}
const span = this.spans.get(event.spanId);
if (!span) {
if (this.pendingEnds.has(event.spanId)) {
this.diagnostics.push({ code: 'duplicate_end', spanId: event.spanId });
return;
}
this.pendingEnds.set(event.spanId, event);
return;
}
if (span.status !== 'open') {
this.diagnostics.push({ code: 'duplicate_end', spanId: event.spanId });
return;
}
this.applyEnd(span, event);
}
private applyEnd(
span: AssembledSpan,
event: Extract<TraceEvent, { event: 'span_ended' }>,
): void {
span.endedAtMs = Math.max(span.startedAtMs, event.timestampMs);
span.status = event.status;
span.errorCategory = event.errorCategory;
span.metadata = event.metadata ?? {};
}
finish(): { spans: AssembledSpan[]; diagnostics: AssemblyDiagnostic[] } {
for (const spanId of this.pendingEnds.keys()) {
this.diagnostics.push({ code: 'end_without_start', spanId });
}
for (const span of this.spans.values()) {
if (span.status === 'open') {
this.diagnostics.push({ code: 'span_left_open', spanId: span.spanId });
}
}
return {
spans: [...this.spans.values()],
diagnostics: [...this.diagnostics],
};
}
}
这个组装器缓冲 end 事件直到其 start 到达。在最终化时,未匹配的 end 和 open span 作为诊断信息保留可见。它不会凭空生成时间戳,也不会将未完成的工作标记为成功。
对于无界的实时流,待处理事件需要大小限制和过期策略。否则畸形或恶意输入可以创建无界的 Map。
一个有效的 trace 通常有一个根,但渲染器应该处理多个根和孤儿节点而不崩溃。
type SpanNode = AssembledSpan & { children: SpanNode[] };
type TraceForest = {
roots: SpanNode[];
orphans: SpanNode[];
duplicateIds: string[];
};
function buildForest(spans: AssembledSpan[]): TraceForest {
const nodes = new Map<string, SpanNode>();
const duplicateIds: string[] = [];
for (const span of spans) {
if (nodes.has(span.spanId)) {
duplicateIds.push(span.spanId);
continue;
}
nodes.set(span.spanId, { ...span, children: [] });
}
const roots: SpanNode[] = [];
const orphans: SpanNode[] = [];
for (const node of nodes.values()) {
if (node.parentSpanId === null) {
roots.push(node);
continue;
}
const parent = nodes.get(node.parentSpanId);
if (!parent) {
orphans.push(node);
continue;
}
parent.children.push(node);
}
const sortChildren = (node: SpanNode): void => {
node.children.sort((a, b) => {
return a.startedAtMs - b.startedAtMs || a.spanId.localeCompare(b.spanId);
});
node.children.forEach(sortChildren);
};
roots.sort((a, b) => a.startedAtMs - b.startedAtMs);
roots.forEach(sortChildren);
return { roots, orphans, duplicateIds };
}
在递归排序或渲染之前验证父子链接是否存在循环。否则当 trace 中 A 是 B 的父节点而 B 是 A 的父节点时会形成无限递归。循环检测可以使用带 visiting 和 visited 集合的深度优先搜索。
孤儿节点应该出现在单独的"未连接 span"部分并附带诊断信息。隐藏它们会使 instrumentation 缺口看起来像缺失的工作。
按开始时间对兄弟节点排序以获得稳定的显示,但不要暗示一个导致了下一个。并行子节点可能完全重叠。
一个有用的 UI 结合了两种视图:
树:父子关系、重试、回退和交接。
时间线:重叠、等待、首次输出时间和关键延迟。
tree timeline
research_agent |----------------------|
├─ search_web |-----|
├─ query_database |-------------|
├─ finance_api |--------|
│ └─ retry |----|
└─ summarize_results |------|
树解释为什么工作发生。时间线解释工作何时发生。
累加每个 span 的耗时通常会高估 trace 时间,原因是:
父 span 包含其子节点的时间。
并行子 span 重叠。
如果根持续 2 秒且包含两个并行的 1 秒工具,对所有三个 span 求和会报告 4 秒。但墙上时钟运行时间仍然只有 2 秒。
使用独立的指标:
Trace 墙上时间:根结束时间减去根开始时间。
Span 耗时:一个操作的结束时间减去开始时间。
自耗时:Span 耗时减去子节点间隔的并集,当需要这种分析时。
关键路径:决定运行完成时间的依赖路径。
计算真正的关键路径需要依赖语义,而不仅仅是父子关系。兄弟工具可能都是必需的,或者任何一个都足够,或者一个可能在另一个成功后被取消。Trace schema 需要在这些决策规则被表示出来之后,UI 才能自信地标注关键路径。
重试是具有独立结果的不同尝试:
load_pricing ok
├─ attempt_1 error: timeout
├─ attempt_2 error: invalid_response
└─ fallback_to_cache ok: age_minutes=18
父节点可以在子节点失败时仍然成功。不要自动将最差的子状态传播给父节点。父节点的状态应该描述操作是否履行了其契约;子状态解释原因。
质量门仍然可以在成功的父节点依赖了过期的回退数据或超过了尝试预算时发出警告。
进程崩溃、客户端断开连接、serverless 调用结束、导出器丢弃事件。一个 open span 是证据,不是杂乱无章。
清晰地渲染不完整的 span,并在已知时包含原因:
generate_answer open: completion event missing
stream_response cancelled: client disconnected
tool_call unknown: adapter ended before callback
不要在 trace 的最后一个时间戳自动关闭每个 open span。那会凭空生成耗时和状态。UI 可以估计可见范围,但应该标注这是估计值。
执行树是有用的回归产物,但精确快照很脆弱。比较持久属性:
必需和禁止的 span 类型
父子关系
尝试和回退计数
模型和 token 预算
open span 或孤儿的出现
适配器和 diagnostics 的存在
忽略随机 ID 并规范化时间戳。对于并发兄弟节点,比较集合或父子关系而非某个精确顺序。
换行分隔的 JSON 非常适合本地 trace,因为每个事件都可以独立追加和重放。读取者可以将事件流式处理通过组装器,而不必将每次运行全部加载到内存中。
当文件变大时按 trace ID 索引或分区。限制保留期,默认避免存储原始 prompt、tool 载荷、检索到的文档、凭据或用户数据。树重构需要的是标识和生命周期,而非完整的应用内容。
在信任执行树之前,验证:
所有事件都属于预期的 trace。
父 ID 已解析或显示为可见的孤儿。
父子链接不包含循环。
Start 和 end 事件不重复。
结束时间不早于开始时间。
open span 保持可见。
数字元数据是有限和有界的。
缺失的适配器能力已报告。
有效载荷和隐私政策在存储前通过。
验证错误应该与 agent 错误分开。畸形的 trace 可能描述一个成功的 agent 运行,同时仍然无法用于调试。
扁平事件不是敌人;它们是实用的存储格式。错误在于将到达顺序视为执行模型。
将生命周期事件组装成 span,验证标识和父子关系,诚实地保留不完整数据,并同时渲染树和时间线视图。然后重试、回退、并行工作和静默失败成为你可检查的系统属性,而不是散布在终端记录中的线索。
这就是从扁平日志到执行树的真正转变:不是更多的遥测,而是可信的结构。