轻量 Agent 的日志与审计:结构化日志在 AI 行为追踪中的工程落地
一、"Agent 自己编了一个工具调用然后执行了"——AI 行为的不可追溯性
AI Agent 在执行任务时产生的行为链——LLM 的每一次推理、工具的每一次调用、操作的每一次成功或失败——是理解 Agent 决策过程的核心依据。但在很多 Agent 项目中,日志还停留在console.log(agent.step)的阶段。当用户问"为什么 Agent 刚才调用了那个工具却给了这个回答"时,开发者只能靠猜测。
传统应用的日志是线性的:请求进来 → 处理 → 响应返回。Agent 的日志是多层嵌套的:一个任务触发了 LLM 推理,推理决定调用了工具 A,工具 A 的结果被反馈给 LLM 做第二次推理,第二次推理又调用了工具 B……这是一棵行为树,而不是一条时间线。捕获这棵行为树的完整形态,是 Agent 可观测性的基础。
二、Agent 结构化日志的分层模型:Run → Step → Span
graph TD A[Run: 一次用户任务] --> B1[Step 1: 初始 LLM 推理] A --> B2[Step 2: 工具调用 + 二次推理] A --> B3[Step 3: 最终输出] B1 --> C1[Span: prompt 构建<br/>tokens: 450] B1 --> C2[Span: API 调用<br/>latency: 1200ms] B1 --> C3[Span: 响应解析<br/>tool_calls: 2] B2 --> D1[Span: 工具 get_weather<br/>input: {city: "北京"}] B2 --> D2[Span: 工具执行<br/>latency: 350ms] B2 --> D3[Span: 结果反馈 LLM<br/>context_length: 3200] B3 --> E1[Span: 最终消息生成<br/>tokens: 180]每一层都有明确的含义:
- Run:一次完整的用户请求到最终响应的全过程
- Step:一次 LLM 推理 + 工具调用循环的迭代
- Span:一个原子操作——LLM API 调用、工具执行、prompt 构建
三、结构化日志系统的完整实现
// agent-logger/logger.ts — Agent 结构化日志系统 import { v4 as uuid } from 'uuid'; // 日志级别 type LogLevel = 'debug' | 'info' | 'warn' | 'error'; // Span 类型 type SpanType = | 'llm_call' | 'llm_parse' | 'tool_call' | 'tool_execute' | 'prompt_build' | 'context_compress' | 'memory_retrieve' | 'output_format'; // 核心:Span 数据结构 interface Span { id: string; parentId: string | null; // parent Step 或 Span 的 ID type: SpanType; name: string; startTime: number; endTime: number | null; // null 表示仍在进行中 status: 'running' | 'success' | 'error'; metadata: Record<string, unknown>; // span 特定的元数据 error?: string; tags: string[]; // 用于过滤和聚合 } // Step:一个推理—工具调用循环 interface Step { id: string; runId: string; index: number; // 第几步 input: string; // 该步的输入(可能是上一步的输出) spans: Span[]; modelUsed: string; totalTokens: number; startTime: number; endTime: number | null; } // Run:一次完整任务 interface Run { id: string; sessionId: string; userId: string; userMessage: string; agentType: string; steps: Step[]; status: 'running' | 'completed' | 'failed'; startTime: number; endTime: number | null; metadata: Record<string, unknown>; } // 日志写入器接口 interface LogWriter { writeRun(run: Run): Promise<void>; writeStep(step: Step): Promise<void>; writeSpan(span: Span): Promise<void>; } // 控制台写入器——开发环境使用 class ConsoleWriter implements LogWriter { async writeRun(run: Run) { console.log(JSON.stringify({ _type: 'agent_run', timestamp: new Date().toISOString(), ...run, })); } async writeStep(step: Step) { console.log(JSON.stringify({ _type: 'agent_step', timestamp: new Date().toISOString(), ...step, })); } async writeSpan(span: Span) { if (span.status === 'error') { console.error(JSON.stringify({ _type: 'agent_span', timestamp: new Date().toISOString(), ...span, })); } } } // Elasticsearch 写入器——生产环境使用 class ElasticsearchWriter implements LogWriter { constructor(private indexPrefix: string) {} async writeRun(run: Run) { // await es.index({ index: `${this.indexPrefix}-runs`, body: run }); } async writeStep(step: Step) { // await es.index({ index: `${this.indexPrefix}-steps`, body: step }); } async writeSpan(span: Span) { // await es.index({ index: `${this.indexPrefix}-spans`, body: span }); } } // Agent 日志管理器——核心 API class AgentLogger { private currentRun: Run | null = null; private currentStep: Step | null = null; constructor( private writers: LogWriter[], ) {} // 开始一个新的 Run startRun(params: { sessionId: string; userId: string; userMessage: string; agentType: string; metadata?: Record<string, unknown>; }): Run { this.currentRun = { id: uuid(), sessionId: params.sessionId, userId: params.userId, userMessage: params.userMessage, agentType: params.agentType, steps: [], status: 'running', startTime: Date.now(), endTime: null, metadata: params.metadata || {}, }; this.flushRun(); return this.currentRun; } // 开始一个新的 Step startStep(input: string, modelUsed: string): Step { if (!this.currentRun) { throw new Error('No active run. Call startRun() first.'); } this.currentStep = { id: uuid(), runId: this.currentRun.id, index: this.currentRun.steps.length, input, spans: [], modelUsed, totalTokens: 0, startTime: Date.now(), endTime: null, }; this.currentRun.steps.push(this.currentStep); return this.currentStep; } // 完成一个 Step endStep(output: string, totalTokens: number) { if (!this.currentStep) return; this.currentStep.endTime = Date.now(); this.currentStep.totalTokens = totalTokens; this.flushStep(); } // 创建一个 Span(自动跟踪时间) async traceSpan<T>( type: SpanType, name: string, fn: (span: Span) => Promise<T>, ): Promise<T> { const span: Span = { id: uuid(), parentId: this.currentStep?.id ?? this.currentRun?.id ?? null, type, name, startTime: Date.now(), endTime: null, status: 'running', metadata: {}, tags: [], }; // 将 span 关联到当前 step this.currentStep?.spans.push(span); try { const result = await fn(span); span.status = 'success'; span.endTime = Date.now(); this.flushSpan(span); return result; } catch (error) { span.status = 'error'; span.endTime = Date.now(); span.error = String(error); this.flushSpan(span); throw error; } } // 完成一个 Run endRun(status: 'completed' | 'failed' = 'completed') { if (!this.currentRun) return; this.currentRun.endTime = Date.now(); this.currentRun.status = status; this.flushRun(); } // 审计日志——记录安全相关事件 audit(event: string, details: Record<string, unknown>) { const auditEntry = { timestamp: new Date().toISOString(), runId: this.currentRun?.id, stepId: this.currentStep?.id, event, details, }; // audit 日志写入专用索引,不与其他日志混合 console.log(JSON.stringify({ _type: 'agent_audit', ...auditEntry })); } private flushRun() { if (!this.currentRun) return; for (const writer of this.writers) { writer.writeRun({ ...this.currentRun }).catch(console.error); } } private flushStep() { if (!this.currentStep) return; for (const writer of this.writers) { writer.writeStep({ ...this.currentStep }).catch(console.error); } } private flushSpan(span: Span) { for (const writer of this.writers) { writer.writeSpan({ ...span }).catch(console.error); } } }Agent 中集成日志系统
// agent/runner.ts — Agent 运行器集成日志 class AgentRunner { constructor( private llm: LLMClient, private tools: ToolRegistry, private logger: AgentLogger, ) {} async run(userMessage: string, sessionId: string): Promise<string> { const run = this.logger.startRun({ sessionId, userId: /* from auth context */ 'user-123', userMessage, agentType: 'task-agent', }); try { let currentInput = userMessage; const maxSteps = 10; for (let i = 0; i < maxSteps; i++) { const step = this.logger.startStep(currentInput, 'gpt-4o'); // LLM 调用 span const llmResponse = await this.logger.traceSpan( 'llm_call', `Step ${i + 1} LLM inference`, async (span) => { span.metadata = { model: 'gpt-4o', inputTokens: 450, temperature: 0.1, }; const response = await this.llm.chat([ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: currentInput }, ]); span.metadata = { ...span.metadata, outputTokens: response.usage?.output_tokens, latency: response.latency, }; return response; } ); const toolCalls = llmResponse.tool_calls; if (!toolCalls || toolCalls.length === 0) { const finalOutput = llmResponse.content; this.logger.endStep(finalOutput, llmResponse.usage?.total_tokens || 0); this.logger.audit('agent_completed', { outputLength: finalOutput.length }); this.logger.endRun('completed'); return finalOutput; } // 工具调用 spans for (const tc of toolCalls) { const toolResult = await this.logger.traceSpan( 'tool_execute', `Execute: ${tc.name}`, async (span) => { span.metadata = { toolName: tc.name, arguments: tc.arguments }; span.tags = [tc.name]; try { const result = await this.tools.execute(tc.name, tc.arguments); span.metadata = { ...span.metadata, success: true }; return result; } catch (error) { span.tags.push('error'); this.logger.audit('tool_call_failed', { toolName: tc.name, error: String(error), }); throw error; } } ); // 将工具结果反馈给 LLM currentInput = `Tool ${tc.name} result: ${JSON.stringify(toolResult)}`; } this.logger.endStep(currentInput, llmResponse.usage?.total_tokens || 0); } throw new Error('Max steps exceeded'); } catch (error) { this.logger.audit('agent_failed', { error: String(error) }); this.logger.endRun('failed'); throw error; } } }四、日志系统的边界:Token 消耗、性能开销与隐私合规
日志膨胀问题。一个 5 步的 Agent 运行可能产生 20+ 个 span,每条 span 包含完整的 prompt 和响应。如果 prompt 包含大段的上下文(如 RAG 的检索结果),单次 Run 的日志量可能达到几十 KB。高并发下这会产生可观的存储和 I/O 成本。解决方案:对 span 的metadata做截断——完整 prompt 保留在前 3 个 step 的 span 中,后续 step 仅记录摘要。
性能开销。每次traceSpan都会创建对象和 JSON 序列化。高并发 Agent 场景下,日志写入应使用异步、批量、非阻塞的方式。ConsoleWriter 在开发环境 OK,但在生产环境应替换为批量写入的 Elasticsearch/ClickHouse writer。
隐私合规。Agent 的 prompt 和响应中可能包含用户的敏感信息。日志在写入存储前应过滤 PII(个人身份信息)。审计日志是一个特例——它应当记录完整的操作信息但不包含原始 prompt,以平衡可追溯性和隐私保护。
最后,Agent 日志的价值体现在事后复盘和线上排查。当用户投诉 "Agent 给出了错误答案"时,能够通过 Run ID 拉出完整的行为树,逐 Step、逐 Span 地分析哪个环节出了偏差——这才是结构化日志的真正价值。
五、总结
Agent 的结构化日志应当采用 Run → Step → Span 的三级模型,每一层记录不同的粒度的数据。通过traceSpan自动测量和记录每次操作的耗时和元数据,日志系统在 Agent 代码中自然嵌入,而非事后添加。
落地时先实现 ConsoleWriter 确认日志结构合理,再替换为持久化存储。审计日志独立于性能日志,记录安全相关的关键事件。Agent 的日志不是为了监控面板——它是为了你能在"Agent 做错了"的时候,追溯到错在哪一步、哪个工具调用、哪次 LLM 推理。在这些关键时刻,一条清晰的 Span 比十个模糊的 console.log 有用得多。