piscratch
§ · Nested detail page

Trace Lab 深度解析

这页拆 trace 生成与网页调试器:scripts/generate-traces.ts:166 构造步骤,web/app/trace-debugger.ts:100 展开调试帧,web/app/TraceLab.tsx:40 渲染交互界面。

§ 00 · IMPORT CHAIN

导入链与契约

web/app/trace-types.ts:1 限定 source file 枚举,web/app/trace-types.ts:11 定义 TraceStep,web/app/trace-types.ts:26 定义 TraceDebugFrame。这个 schema 让生成脚本和前端 UI 共享同一条源码定位语言。

web/app/trace-types.ts · L1-51TypeScript
1export type TraceSource = {
2  file: "src/llm.ts" | "src/agent.ts" | "src/tools.ts" | "src/tui.ts" | "src/cli.ts";
3  line: number;
4};
5
6export type TraceContext = {
7  systemPrompt?: string;
8  messages: unknown[];
9};
10
11export type TraceStep = {
12  id: string;
13  label: string;
14  detail: string;
15  kind: "input" | "stream" | "context" | "tool" | "end" | "error";
16  source: TraceSource;
17  event?: Record<string, unknown>;
18  context: TraceContext;
19};
20
21export type TraceCallFrame = {
22  name: string;
23  source: TraceSource;
24};
25
26export type TraceDebugFrame = {
27  id: string;
28  label: string;
29  source: TraceSource;
30  event?: Record<string, unknown>;
31  context: TraceContext;
32  variables: Record<string, unknown>;
33  callStack: TraceCallFrame[];
34};
35
36export type TraceCase = {
37  id: string;
38  number: string;
39  title: string;
40  summary: string;
41  prompt: string;
42  model: string;
43  outcome: string;
44  steps: TraceStep[];
45};
46
47export type TraceMeta = {
48  model: string;
49  generatedAt: string;
50  liveGenerated: boolean;
51};
§ 01 · CORE IMPL

核心实现,逐段拆

▸生成脚本用源码反查行号

scripts/generate-traces.ts:44 读取五个核心源文件,scripts/generate-traces.ts:55 查找 needle 对应行号,scripts/generate-traces.ts:65 返回 TraceSource。这样 trace 数据不靠手写行号,源码改动后生成失败会暴露不一致。

scripts/generate-traces.ts · L44-96TypeScript
44const sourceFiles = Object.fromEntries(
45  await Promise.all(
46    (['src/llm.ts', 'src/agent.ts', 'src/tools.ts', 'src/tui.ts', 'src/cli.ts'] as SourceFile[])
47      .map(async (file) => [file, await readFile(resolve(projectRoot, file), 'utf8')]),
48  ),
49) as Record<SourceFile, string>
50
51function cloneContext(context: Context): TraceContext {
52  return JSON.parse(JSON.stringify(context)) as TraceContext
53}
54
55function findLine(file: SourceFile, needle: string, occurrence = 0): number {
56  const matches = sourceFiles[file]
57    .split('\n')
58    .map((line, index) => ({ line, number: index + 1 }))
59    .filter((entry) => entry.line.includes(needle))
60  const match = occurrence < 0 ? matches.at(occurrence) : matches[occurrence]
61  if (!match) throw new Error(`找不到 trace 源码位置:${file} / ${needle}`)
62  return match.number
63}
64
65function source(file: SourceFile, needle: string, occurrence = 0): TraceSource {
66  return { file, line: findLine(file, needle, occurrence) }
67}
68
69function normalizeText(value: string, workspace: string): string {
70  return value.split(workspace).join('/workspace')
71}
72
73function normalizeValue<T>(value: T, workspace: string): T {
74  return JSON.parse(normalizeText(JSON.stringify(value), workspace)) as T
75}
76
77function messageKind(message: unknown): 'tool_result' | 'assistant' | 'other' {
78  if (!message || typeof message !== 'object') return 'other'
79  const item = message as { role?: string; content?: unknown }
80  if (item.role === 'assistant') return 'assistant'
81  if (item.role === 'user' && Array.isArray(item.content)) {
82    const hasResult = item.content.some((block) => (
83      block && typeof block === 'object' && (block as { type?: string }).type === 'tool_result'
84    ))
85    if (hasResult) return 'tool_result'
86  }
87  return 'other'
88}
89
90function contextStepSource(context: TraceContext): TraceSource {
91  const latest = context.messages.at(-1)
92  if (messageKind(latest) === 'tool_result') {
93    return source('src/agent.ts', 'context.messages.push(buildToolResultMessage(results))', -1)
94  }
95  return source('src/agent.ts', 'context.messages.push(buildAssistantMessage(text, toolCalls))', -1)
96}

▸事件被压成可教学步骤

scripts/generate-traces.ts:117 把 AgentEvent 转成展示文案,scripts/generate-traces.ts:152 合并连续 text delta,scripts/generate-traces.ts:166 开始构造 steps,scripts/generate-traces.ts:179 遍历捕获事件并插入 context 写回节点。

scripts/generate-traces.ts · L117-218TypeScript
117function eventPresentation(event: AgentEvent): Pick<TraceStep, 'label' | 'detail' | 'kind'> {
118  switch (event.type) {
119    case 'assistant_text':
120      return {
121        label: '模型流式输出文本',
122        detail: 'stream() 产生 text_delta,agent 将它转成 assistant_text 发给界面。',
123        kind: 'stream',
124      }
125    case 'tool_call':
126      return {
127        label: `模型调用 ${event.name}`,
128        detail: 'agent 收集工具名和参数,同时发出 tool_call 事件。',
129        kind: 'tool',
130      }
131    case 'tool_result':
132      return {
133        label: `${event.name} 返回结果`,
134        detail: '工具已经执行完,结果先作为 AgentEvent 发出,随后写回 Context。',
135        kind: event.result.startsWith('error:') ? 'error' : 'tool',
136      }
137    case 'turn_end':
138      return {
139        label: `本轮结束:${event.stopReason}`,
140        detail: event.stopReason === 'aborted'
141          ? '中断信号抵达当前任务,agent 清理未完成的数据后结束。'
142          : event.stopReason === 'max_tokens'
143            ? '模型碰到输出上限,agent 保留已收到的文本并结束本轮。'
144            : event.stopReason === 'error'
145              ? '请求失败,agent 发出错误事件并停止循环。'
146              : '这一轮没有新的 tool_call,Agent Loop 正常退出。',
147        kind: event.stopReason === 'error' ? 'error' : 'end',
148      }
149  }
150}
151
152function coalesceTextEvents(events: CapturedEvent[]): CapturedEvent[] {
153  const result: CapturedEvent[] = []
154  for (const captured of events) {
155    const previous = result.at(-1)
156    if (captured.event.type === 'assistant_text' && previous?.event.type === 'assistant_text') {
157      previous.event = { type: 'assistant_text', delta: previous.event.delta + captured.event.delta }
158      previous.context = captured.context
159    } else {
160      result.push(captured)
161    }
162  }
163  return result
164}
165
166function buildSteps(initial: TraceContext, captured: CapturedEvent[], finalContext: TraceContext): TraceStep[] {
167  const steps: TraceStep[] = [{
168    id: 'input',
169    label: '用户消息进入 Context',
170    detail: 'CLI 先把这一轮输入追加到 messages,随后把 Context 交给 runAgent()。',
171    kind: 'input',
172    source: source('src/cli.ts', "context.messages.push({ role: 'user', content: text })"),
173    context: initial,
174  }]
175
176  let visibleMessages = initial.messages.length
177  let index = 1
178
179  for (const item of coalesceTextEvents(captured)) {
180    if (item.context.messages.length > visibleMessages) {
181      const context = cloneContext(item.context)
182      steps.push({
183        id: `context-${index++}`,
184        label: messageKind(context.messages.at(-1)) === 'tool_result'
185          ? 'tool_result 写回 Context'
186          : 'assistant message 写回 Context',
187        detail: '这次修改会进入下一轮请求,也会被 session 持久化。',
188        kind: 'context',
189        source: contextStepSource(context),
190        context,
191      })
192      visibleMessages = item.context.messages.length
193    }
194
195    const presentation = eventPresentation(item.event)
196    steps.push({
197      id: `event-${index++}`,
198      ...presentation,
199      source: eventSource(item.event),
200      event: eventCopy(item.event),
201      context: cloneContext(item.context),
202    })
203  }
204
205  if (finalContext.messages.length > visibleMessages) {
206    steps.push({
207      id: `context-${index++}`,
208      label: messageKind(finalContext.messages.at(-1)) === 'tool_result'
209        ? 'tool_result 写回 Context'
210        : 'assistant message 写回 Context',
211      detail: '循环退出前,最后一条消息已经放回 Context。',
212      kind: 'context',
213      source: contextStepSource(finalContext),
214      context: finalContext,
215    })
216  }
217
218  return steps

▸前端调试帧保存变量、调用栈和 context

web/app/trace-debugger.ts:119 的 push 是核心:web/app/trace-debugger.ts:130 收集 text、stopReason、toolCalls、results,web/app/trace-debugger.ts:141 写入 frame,web/app/trace-debugger.ts:148 同步 callStack。它把一个异步 generator 执行过程变成可单步播放的数据结构。

web/app/trace-debugger.ts · L119-198TypeScript
119  const push = (
120    file: SourceFile,
121    needle: string,
122    options: {
123      occurrence?: number;
124      label?: string;
125      event?: Record<string, unknown>;
126      variables?: Record<string, unknown>;
127    } = {},
128  ) => {
129    const position = source(file, needle, options.occurrence ?? 0);
130    const variables = {
131      text: state.text,
132      stopReason: state.stopReason,
133      toolCalls: clone(state.toolCalls),
134      results: clone(state.results),
135      ev: clone(state.ev),
136      "signal.aborted": state.signalAborted,
137      "context.messages.length": context.messages.length,
138      loop: state.loop,
139      ...options.variables,
140    };
141    frames.push({
142      id: `${traceCase.id}-${sequence++}`,
143      label: options.label ?? sourceLabel(position),
144      source: position,
145      event: options.event ? clone(options.event) : undefined,
146      context: clone(context),
147      variables,
148      callStack: callStack(position),
149    });
150  };
151
152  const enterStream = () => {
153    push("src/llm.ts", "const url =", { variables: { model: traceCase.model } });
154    push("src/llm.ts", "const messages = contextToOpenAIMessages(context)");
155    push("src/llm.ts", "const messages: object[] = []");
156    if (context.systemPrompt) push("src/llm.ts", "if (context.systemPrompt)");
157    for (const message of context.messages) {
158      push("src/llm.ts", "for (const msg of context.messages)", { variables: { msg: message } });
159      if (typeof (message as { content?: unknown }).content === "string") {
160        push("src/llm.ts", "if (typeof msg.content === 'string')");
161        push("src/llm.ts", "messages.push({ role: msg.role, content: msg.content })");
162      } else {
163        push("src/llm.ts", "const blocks = msg.content");
164        if ((message as { role?: unknown }).role === "assistant") {
165          push("src/llm.ts", "if (msg.role === 'assistant')");
166          push("src/llm.ts", "const toolCalls: object[] = []");
167          push("src/llm.ts", "let text = ''");
168          for (const block of (message as { content: unknown[] }).content) {
169            push("src/llm.ts", "for (const b of blocks)", { variables: { b: block } });
170          }
171          push("src/llm.ts", "messages.push({ role: 'assistant'");
172        } else {
173          for (const block of (message as { content: unknown[] }).content) {
174            push("src/llm.ts", "for (const b of blocks)", { occurrence: -1, variables: { b: block } });
175            if ((block as { type?: unknown }).type === "tool_result") {
176              push("src/llm.ts", "messages.push({ role: 'tool'");
177            }
178          }
179        }
180      }
181    }
182    push("src/llm.ts", "return messages");
183    push("src/llm.ts", "const body: Record<string, unknown>");
184    push("src/llm.ts", "if (model.maxTokens)");
185    if (["read-file", "edit-and-check", "tool-error"].includes(traceCase.id)) {
186      push("src/llm.ts", "if (opts.tools?.length)");
187    }
188    push("src/llm.ts", "let response: Response");
189    push("src/llm.ts", "response = await fetch(url");
190    push("src/llm.ts", "if (!response.ok || !response.body)");
191    push("src/llm.ts", "const reader = response.body.getReader()");
192    push("src/llm.ts", "const decoder = new TextDecoder()");
193    push("src/llm.ts", "let buf = ''");
194    push("src/llm.ts", "let stopReason: 'end_turn'");
195    push("src/llm.ts", "const toolCallBuffers = new Map");
196    push("src/llm.ts", "while (true)");
197    push("src/llm.ts", "const { done, value } = await reader.read()");
198  };
§ 02 · KEY FLOW

关键流程

live runAgent cases ↓ scripts/generate-traces.ts:166 TraceStep[] ↓ web/app/trace-debugger.ts:100 TraceDebugFrame[] ↓ web/app/TraceLab.tsx:135 interactive source debugger

web/app/TraceLab.tsx:47 取当前 case,web/app/TraceLab.tsx:48 构造 debugFrames,web/app/TraceLab.tsx:72 支持继续到断点,web/app/TraceLab.tsx:102 自动滚动到当前源码行。这让教学页面更像 debugger,而不是静态文章。

web/app/TraceLab.tsx · L40-122TypeScript
40export default function TraceLab() {
41  const [caseIndex, setCaseIndex] = useState(0);
42  const [frameIndex, setFrameIndex] = useState(0);
43  const [breakpoints, setBreakpoints] = useState<ReadonlySet<string>>(() => new Set());
44  const [selectedFile, setSelectedFile] = useState<TraceSource["file"]>("src/tui.ts");
45  const codeScrollRef = useRef<HTMLDivElement>(null);
46
47  const traceCase = traceCases[caseIndex] ?? null;
48  const debugFrames = useMemo(() => traceCase ? buildDebugFrames(traceCase) : [], [traceCase]);
49  const frame = debugFrames[frameIndex] ?? null;
50  const sourceCode = fullRepo[selectedFile] ?? "";
51  const sourceLines = useMemo(() => sourceCode.split("\n"), [sourceCode]);
52  const executableLines = useMemo(() => new Set(
53    debugFrames
54      .filter((item) => item.source.file === selectedFile)
55      .map((item) => item.source.line),
56  ), [debugFrames, selectedFile]);
57
58  const selectCase = (nextIndex: number) => {
59    setCaseIndex(nextIndex);
60    setFrameIndex(0);
61    const first = traceCases[nextIndex] ? buildDebugFrames(traceCases[nextIndex])[0] : null;
62    if (first) setSelectedFile(first.source.file);
63  };
64
65  const goToFrame = (next: number) => {
66    if (!debugFrames.length) return;
67    const bounded = Math.max(0, Math.min(debugFrames.length - 1, next));
68    setFrameIndex(bounded);
69    setSelectedFile(debugFrames[bounded].source.file);
70  };
71
72  const continueToBreakpoint = () => {
73    if (!debugFrames.length || frameIndex >= debugFrames.length - 1) return;
74    const nextHit = debugFrames.findIndex((item, index) => (
75      index > frameIndex && breakpoints.has(breakpointKey(item.source))
76    ));
77    goToFrame(nextHit >= 0 ? nextHit : debugFrames.length - 1);
78  };
79
80  useEffect(() => {
81    const onKeyDown = (event: KeyboardEvent) => {
82      const target = event.target as HTMLElement | null;
83      if (target?.closest("input, textarea, select")) return;
84      if (event.key === "F10") {
85        event.preventDefault();
86        goToFrame(frameIndex + 1);
87      } else if (event.key === "F5") {
88        event.preventDefault();
89        continueToBreakpoint();
90      } else if (!target?.closest("button, a") && event.key === "ArrowRight") {
91        event.preventDefault();
92        goToFrame(frameIndex + 1);
93      } else if (!target?.closest("button, a") && event.key === "ArrowLeft") {
94        event.preventDefault();
95        goToFrame(frameIndex - 1);
96      }
97    };
98    window.addEventListener("keydown", onKeyDown);
99    return () => window.removeEventListener("keydown", onKeyDown);
100  });
101
102  useLayoutEffect(() => {
103    const scroller = codeScrollRef.current;
104    if (!scroller || !frame || selectedFile !== frame.source.file) return;
105    const line = scroller.querySelector<HTMLElement>(`[data-trace-line="${frame.source.line}"]`);
106    if (!line) return;
107    scroller.scrollTo({
108      top: Math.max(0, line.offsetTop - scroller.clientHeight * 0.38),
109      behavior: "auto",
110    });
111  }, [frame, selectedFile]);
112
113  const toggleBreakpoint = (line: number) => {
114    if (!executableLines.has(line)) return;
115    const key = breakpointKey({ file: selectedFile, line });
116    setBreakpoints((current) => {
117      const next = new Set(current);
118      if (next.has(key)) next.delete(key);
119      else next.add(key);
120      return next;
121    });
122  };
§ 03 · EDGE CASES

边界 / 失败模式

  • scripts/generate-traces.ts:35 要求真实 NANOPI_API_KEY 和 NANOPI_BASE_URL,trace 不是离线 mock 默认生成。
  • web/app/trace-debugger.ts:32 找不到源码 needle 会 throw,能及时暴露教学数据和源码不一致。
  • web/app/TraceLab.tsx:113 只允许 executableLines 设置断点,避免用户点无效源码行。
§ 05 · TRACE CONSISTENCY

一致性校验

▸为什么 trace 页面不会轻易漂移

这套 trace 设计的防漂移点分布在生成端和运行端:scripts/generate-traces.ts:55 用源码文本反查行号,scripts/generate-traces.ts:61 找不到 needle 就直接失败;前端侧 web/app/trace-debugger.ts:26 也用同样的 source helper,web/app/trace-debugger.ts:32 在缺失行时抛错。换句话说,文档数据、源码和调试器三者不是弱绑定,只要核心源码移动,生成链路会提醒维护者重新生成 trace。

Generated · 2026-08-13Source · SaladDay/pi-from-scratch← 返回