Trace Lab 深度解析
这页拆 trace 生成与网页调试器:scripts/generate-traces.ts:166 构造步骤,web/app/trace-debugger.ts:100 展开调试帧,web/app/TraceLab.tsx:40 渲染交互界面。
导入链与契约
web/app/trace-types.ts:1 限定 source file 枚举,web/app/trace-types.ts:11 定义 TraceStep,web/app/trace-types.ts:26 定义 TraceDebugFrame。这个 schema 让生成脚本和前端 UI 共享同一条源码定位语言。
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};
核心实现,逐段拆
▸生成脚本用源码反查行号
scripts/generate-traces.ts:44 读取五个核心源文件,scripts/generate-traces.ts:55 查找 needle 对应行号,scripts/generate-traces.ts:65 返回 TraceSource。这样 trace 数据不靠手写行号,源码改动后生成失败会暴露不一致。
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 写回节点。
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 执行过程变成可单步播放的数据结构。
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 };
关键流程
web/app/TraceLab.tsx:47 取当前 case,web/app/TraceLab.tsx:48 构造 debugFrames,web/app/TraceLab.tsx:72 支持继续到断点,web/app/TraceLab.tsx:102 自动滚动到当前源码行。这让教学页面更像 debugger,而不是静态文章。
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 };
边界 / 失败模式
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 设置断点,避免用户点无效源码行。
一致性校验
▸为什么 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。