Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 12 节:可观测性与持续评估 (Observability)

“你无法优化你无法度量的东西。Agent 的黑盒不是宿命,是工程欠债。”

本节改动全景

前 11 节让 xclaw 具备了完整的执行能力——从工具调用到多 Agent 协同,再到 Chronos 主动触发。但进入生产环境之前,还有最后一公里需要打通:黑盒性与不确定性

改动点第 11 节第 12 节
部署方式直接 npm start+ 多阶段 Dockerfile、优雅停机
可观测性仅 console.log+ Trace 链路、Metrics 指标、结构化日志
质量保障+ 断言驱动 Benchmark 跑分机
优化闭环+ 生产失败 → TestCase 自动回流
新增文件src/observability/context.tsmetrics.tstracer.tstest/benchmark/dataset.tstest/benchmark/runner.tsDockerfile

这一节的核心设计思想:三件套(Traces + Metrics + Benchmark)形成一个负反馈闭环——生产失败自动转化为新测试用例,每次发版前强制跑分,通过率不达标即阻断部署。


整体架构

可观测性层(横切关注点,零侵入织入):

  agent.handle(msg)
    └── traceSpan('agent.handle', ...)
          ├── [LLM 调用]    traceSpan('llm.call', {type:'LLM_CALL'})
          │     └── 自动捕获 usage → metrics.record('llm.tokens.*', 'llm.cost.usd')
          ├── [工具调用]    traceSpan('tool.exec', {toolName})
          │     └── 自动记录 latency → metrics.record('tool.exec.latency.ms')
          └── [结构化日志]  每个 span 结束时输出 {log_type:'TRACE', trace_id, span_id, duration_ms}

AsyncLocalStorage(traceStorage):
  - 无需显式传参,跨所有 async 调用自动传递 traceId + sessionId
  - 一次 agent.handle() 对应一棵完整的 Span 树

Benchmark 引擎(离线评测):

  ts-node test/benchmark/runner.ts
    ├── 遍历 benchmarkDataset(TestCase 数组)
    ├── 对每个 Case 跑 agent.handle()
    │     ├── 拦截触发的工具名(onToolTriggered hook)
    │     ├── 断言 expectedTools / forbiddenTools / assertResponse
    │     └── 记录耗时和通过状态
    └── 打印通过率报告,通过率 < 100% 时退出码非零(阻断 CI)

持续优化闭环:
  生产失败 ──> Trace 保留完整上下文 ──> 人工确认 ──> 新 TestCase 加入 dataset.ts ──> 下次 CI 强制覆盖

1. 容器化部署

1.1 生产级多阶段 Dockerfile

Agent 进程包含长任务(工具执行)、定时器(Chronos)和 SQLite 持久化数据,部署时需要做到:环境隔离、镜像精简、持久化目录挂载。

# Dockerfile

# ── Stage 1: 安装依赖 ──────────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY config ./config

# ── Stage 2: 生产运行时(只含生产依赖 + 源码)────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/src ./src
COPY --from=builder /app/config ./config

# 持久化数据目录(SQLite、workspace、logs)
RUN mkdir -p /app/data && chown -R node:node /app/data
VOLUME /app/data
ENV XCLAW_DATA_DIR=/app/data

USER node                     # 非 root 运行,符合最小权限原则
EXPOSE 3000                   # Web 适配器
EXPOSE 3001                   # Webhook 接口

CMD ["node", "--env-file=.env", "src/index.ts"]

注意:xclaw 使用 Node.js 22 的原生 TypeScript 支持(--experimental-strip-types),无需 tsc 编译步骤。runner 阶段直接复制 src/ 源码运行,镜像体积约 ~150MB。

1.2 进程级优雅停机 (Graceful Shutdown)

当容器因扩缩容、滚动发布收到 SIGTERM 时,如果 Agent 正在执行一个 30 秒的外部工具调用,暴力中断会导致工具状态不一致。需要一个“防自残“关闭钩子:

// src/observability/tracker.ts — 活跃任务计数器

class ActiveTaskTracker {
  private count = 0;

  enter(): void { this.count++; }
  exit():  void { this.count = Math.max(0, this.count - 1); }
  hasActiveTasks(): boolean { return this.count > 0; }
}

export const activeTaskTracker = new ActiveTaskTracker();
// src/index.ts — 替换原有 SIGINT handler,同时覆盖 SIGTERM

const handleShutdown = async (signal: string) => {
  log(`[system] signal ${signal} — stopping new triggers`);

  // 1. 停止 Chronos 定时器,不再接收新的 cron / event 触发
  chronos.stopAll();
  webhookServer.close();

  // 2. 等待当前正在执行的 Agent 任务自然结束(最多等 15 秒)
  let retries = 0;
  while (activeTaskTracker.hasActiveTasks() && retries < 10) {
    log(`[system] ${retries + 1}/10 waiting for active tasks to finish...`);
    await new Promise((r) => setTimeout(r, 1500));
    retries++;
  }

  log('[system] clean shutdown');
  await stopPluginServices();
  await browserPool.closeAll().catch(() => {});
  if (pool) await pool.killAll().catch(() => {});
  await memoryStore.close().catch(() => {});
  db.close();
  process.exit(0);
};

process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT',  () => handleShutdown('SIGINT'));

agent.handle() 的入口和出口包裹 tracker:

// src/agent.ts — handle() 方法入口处

async handle(msg: ACPMessage, onDelta: (token: string) => void): Promise<string> {
  activeTaskTracker.enter();
  try {
    return await this._handleInner(msg, onDelta);
  } finally {
    activeTaskTracker.exit();
  }
}

private async _handleInner(msg: ACPMessage, onDelta: (token: string) => void): Promise<string> {
  // ... 原有逻辑
}

为什么不直接用 process.on('SIGTERM') 然后立刻 process.exit():Agent 的工具调用是异步的,强制退出会在工具的 finally 块之前终止,导致文件写入中断、子进程泄漏、SQLite WAL 未提交等问题。轮询 + 超时是最简单可靠的等待方案。


2. 可观测性三件套

大模型监控与普通 HTTP 服务不同:我们不只关心状态码,更关心首字延迟(TTFT)Token 吞吐量单次任务资金消耗,以及 Thought → Action → Observation 链条里的耗时瓶颈

2.1 调用链追踪:AsyncLocalStorage

Node.js 原生的 AsyncLocalStorage 能在不传递显式参数的前提下,跨所有 async 调用追踪同一个 traceId——就像 Java 的 ThreadLocal,但适用于异步回调链。

// src/observability/context.ts

import { AsyncLocalStorage } from 'async_hooks';

export interface TraceSpan {
  spanId:    string;
  name:      string;
  startTime: number;
  endTime?:  number;
  metadata?: Record<string, unknown>;
}

export interface TraceContext {
  traceId:   string;
  sessionId: string;
  spans:     TraceSpan[];
}

export const traceStorage = new AsyncLocalStorage<TraceContext>();

export function generateId(): string {
  return Math.random().toString(36).slice(2, 15);
}

AsyncLocalStorage 的工作原理:调用 traceStorage.run(ctx, fn) 时,fn 及其内部所有 await 链(无论多深)都能通过 traceStorage.getStore() 读到同一个 ctx。Node.js 在创建新的异步资源时自动复制当前存储,无需手动传参。

2.2 指标收集器

内存指标收集器,自动计算 P50/P95 延迟,输出结构化 JSON 日志,可直接对接 Promtail → Grafana 或 ELK:

// src/observability/metrics.ts

class MetricsCollector {
  private static instance: MetricsCollector;
  private registry = new Map<string, number[]>();

  private constructor() {}

  static getInstance(): MetricsCollector {
    if (!MetricsCollector.instance) {
      MetricsCollector.instance = new MetricsCollector();
    }
    return MetricsCollector.instance;
  }

  record(name: string, value: number, tags: Record<string, string> = {}): void {
    if (!this.registry.has(name)) this.registry.set(name, []);
    this.registry.get(name)!.push(value);

    // 结构化日志——Promtail/Filebeat 可直接解析
    console.log(JSON.stringify({
      log_type:     'METRIC',
      metric_name:  name,
      metric_value: value,
      ...tags,
      timestamp: new Date().toISOString(),
    }));
  }

  percentile(name: string, p: number): number {
    const values = this.registry.get(name) ?? [];
    if (values.length === 0) return 0;
    const sorted = [...values].sort((a, b) => a - b);
    return sorted[Math.ceil((p / 100) * sorted.length) - 1] ?? 0;
  }

  summary(): Record<string, { p50: number; p95: number; count: number }> {
    const out: Record<string, { p50: number; p95: number; count: number }> = {};
    for (const [name, values] of this.registry) {
      out[name] = {
        p50:   this.percentile(name, 50),
        p95:   this.percentile(name, 95),
        count: values.length,
      };
    }
    return out;
  }
}

export const metrics = MetricsCollector.getInstance();

2.3 埋点包装器 (Tracer Wrapper)

一个高阶函数,把“开始计时 → 执行 → 记录延迟 → 输出 Trace 日志“的切面逻辑封装起来,业务代码零侵入:

// src/observability/tracer.ts

import { traceStorage, generateId, type TraceContext } from './context.ts';
import { metrics } from './metrics.ts';

export async function traceSpan<T>(
  spanName: string,
  metadata: Record<string, unknown>,
  fn: () => Promise<T>,
): Promise<T> {
  const store = traceStorage.getStore();

  // 顶层调用:自动初始化 Trace 上下文
  if (!store) {
    const ctx: TraceContext = {
      traceId:   generateId(),
      sessionId: (metadata['sessionId'] as string) ?? 'sys',
      spans:     [],
    };
    return traceStorage.run(ctx, () => traceSpan(spanName, metadata, fn));
  }

  const spanId    = generateId();
  const startTime = Date.now();
  store.spans.push({ spanId, name: spanName, startTime, metadata });

  try {
    const result = await fn();

    // LLM 调用:自动捕获 Token 消耗和资金成本
    if (metadata['type'] === 'LLM_CALL' && result && (result as any).usage) {
      const { prompt_tokens, completion_tokens } = (result as any).usage;
      const model = (metadata['model'] as string) ?? 'unknown';
      metrics.record('llm.tokens.input',  prompt_tokens,    { model });
      metrics.record('llm.tokens.output', completion_tokens, { model });
      // 费率近似:输入 $5/M,输出 $15/M(claude-sonnet-4 参考值)
      const cost = (prompt_tokens * 5 + completion_tokens * 15) / 1_000_000;
      metrics.record('llm.cost.usd', cost, { model });
    }

    return result;
  } catch (error: any) {
    metrics.record('agent.error.count', 1, { spanName, error: error.message });
    throw error;
  } finally {
    const duration = Date.now() - startTime;
    metrics.record(`${spanName}.latency.ms`, duration);

    console.log(JSON.stringify({
      log_type:    'TRACE',
      trace_id:    store.traceId,
      session_id:  store.sessionId,
      span_id:     spanId,
      span_name:   spanName,
      duration_ms: duration,
      ...metadata,
      timestamp: new Date().toISOString(),
    }));
  }
}

接入方式:在 agent.ts 的 LLM 调用处包一层,streamWithFallback 返回 StreamResultresult.usage 即可触发 Token/Cost 指标:

// src/agent.ts — LLM 调用处
const result = await traceSpan(
  'llm.call',
  { type: 'LLM_CALL', model: this.providerChain[0], sessionId: msg.sessionId },
  () => streamWithFallback(messages, this.providerChain, onToken),
);
const reply = result.reply;  // StreamResult.reply
// result.usage 由 traceSpan 内部自动消费,无需手动处理

为什么需要改 Provider.stream() 返回值:原来 stream() 返回 Promise<string>,tracer 无法从中读到 usage。将返回值改为 Promise<StreamResult> 后,两个 provider 都在流结束时附上 token 统计:Claude 用 stream.getFinalMessage().usage,OpenAI 用 stream_options: { include_usage: true } 从最后一个 chunk 读取。


3. 自动化 Benchmark

修改一行 Prompt 往往会导致原先正常的工具路由走向崩溃,或反复重试耗尽步数预算。因此需要一套包含工具选择准度数据提取准度死循环免疫度的测试数据集。

3.1 测试用例定义

// test/benchmark/dataset.ts

export interface TestCase {
  id:               string;
  category:         'tool_routing' | 'data_extraction' | 'anti_loop';
  inputPrompt:      string;
  expectedTools?:   string[];                        // 必须触发的工具
  forbiddenTools?:  string[];                        // 绝对不能触发的工具
  assertResponse?:  (output: string) => boolean;    // 最终文本断言
  maxSteps:         number;
}

export const benchmarkDataset: TestCase[] = [
  {
    id:            'TC_001_ROUTING',
    category:      'tool_routing',
    inputPrompt:   '帮我检查下服务器目前的内存占用,如果超标了就顺便重启一下。',
    expectedTools: ['shell'],          // 必须先执行检查
    forbiddenTools: ['notify'],        // 没发现异常不该发通知
    maxSteps: 4,
  },
  {
    id:           'TC_002_EXTRACTION',
    category:     'data_extraction',
    inputPrompt:  '从这段日志中找出错误码:[2026-05-23 07:15] CRITICAL ERR_CODE:0xAF921 DB_TIMEOUT',
    assertResponse: (output) => output.includes('0xAF921'),
    maxSteps: 2,
  },
  {
    id:              'TC_003_ANTI_LOOP',
    category:        'anti_loop',
    inputPrompt:     '帮我执行一个肯定会报错的未知系统指令:xclaw_invalid_cmd_xyz',
    forbiddenTools:  [],               // 报错后应汇报而非反复重试
    assertResponse:  (output) => {
      const lower = output.toLowerCase();
      return lower.includes('错误') || lower.includes('失败') || lower.includes('error');
    },
    maxSteps: 4,
  },
];

三类测试的覆盖目标

类别防御的回归场景
tool_routingPrompt 改动导致工具调用顺序错乱(先重启再检查)
data_extraction模型幻觉,捏造不存在的错误码
anti_loop工具报错后反复重试,耗尽 maxSteps 预算

3.2 自动化评测运行机

// test/benchmark/runner.ts

import { benchmarkDataset, type TestCase } from './dataset.ts';
import { Agent } from '../../src/agent.ts';
import type { ACPMessage } from '../../src/gateway/types.ts';
import { registerProvider } from '../../src/providers/registry.ts';
import { createOpenAIProvider } from '../../src/providers/openai.ts';
import { createClaudeProvider } from '../../src/providers/claude.ts';
import { loadConfig } from '../../src/config.ts';
import { CLIConfirmer } from '../../src/hitl.ts';
import { registerToolsForMode } from '../../src/tools.ts';
import crypto from 'crypto';

// 注册 Provider 和工具,与 index.ts 保持一致
registerProvider(createOpenAIProvider());
registerProvider(createClaudeProvider());

const cfg = loadConfig('xclaw.yaml');
const hitl = new CLIConfirmer(true);  // benchmark 自动确认所有操作
registerToolsForMode(cfg.sandbox.mode, null, hitl, cfg);

function buildProviderChain(): string[] {
  const primary  = cfg.agent.providers.primary  || 'claude';
  const fallback = cfg.agent.providers.fallback || 'openai';
  if (!fallback || fallback === primary) return [primary];
  return [primary, fallback];
}

interface BenchmarkReport {
  testCaseId: string;
  passed:     boolean;
  durationMs: number;
  reason:     string;
  toolsUsed:  string[];
}

export class BenchmarkRunner {
  private providerChain: string[];

  constructor(providerChain = ['claude']) {
    this.providerChain = providerChain;
  }

  async run(): Promise<BenchmarkReport[]> {
    console.log(`\n${'='.repeat(50)}`);
    console.log(`xclaw Benchmark — ${benchmarkDataset.length} test cases`);
    console.log(`${'='.repeat(50)}\n`);

    const reports: BenchmarkReport[] = [];

    for (const tc of benchmarkDataset) {
      reports.push(await this._runCase(tc));
    }

    this._printSummary(reports);
    return reports;
  }

  private async _runCase(tc: TestCase): Promise<BenchmarkReport> {
    const startTime   = Date.now();
    const toolsUsed:  string[] = [];
    let passed = true;
    let reason = 'SUCCESS';

    try {
      // 为每个 Case 创建独立 Agent,隔离 session 状态
      const agent = new Agent(this.providerChain, tc.maxSteps);

      // 注入工具拦截钩子(通过 monkey-patching)
      const previous = (global as any).__toolHook;
      (global as any).__toolHook = (toolName: string) => {
        toolsUsed.push(toolName);
        if (tc.forbiddenTools?.includes(toolName)) {
          passed = false;
          reason = `触犯红线:误触发禁忌工具 [${toolName}]`;
        }
      };

      const msg: ACPMessage = {
        id:        crypto.randomUUID(),
        sessionId: `bench-${tc.id}-${Date.now()}`,
        channel:   'internal',
        content:   tc.inputPrompt,
        timestamp: Date.now(),
      };

      const output = await agent.handle(msg, () => {});  // onDelta 不需要输出
      (global as any).__toolHook = previous;

      // 验证必须触发的工具
      if (passed && tc.expectedTools) {
        for (const expected of tc.expectedTools) {
          if (!toolsUsed.includes(expected)) {
            passed = false;
            reason = `漏配路径:未触发预期工具 [${expected}]`;
          }
        }
      }

      // 验证输出文本断言
      if (passed && tc.assertResponse && !tc.assertResponse(output)) {
        passed = false;
        reason = '断言失败:输出不符合预期规则';
      }
    } catch (err: any) {
      passed = false;
      reason = `运行时崩溃: ${err.message}`;
    }

    const report = { testCaseId: tc.id, passed, durationMs: Date.now() - startTime, reason, toolsUsed };
    const mark = passed ? '✓' : '✗';
    console.log(`[${mark}] ${tc.id} (${report.durationMs}ms) — ${reason}`);
    return report;
  }

  private _printSummary(reports: BenchmarkReport[]): void {
    const passed = reports.filter(r => r.passed).length;
    const rate   = ((passed / reports.length) * 100).toFixed(1);

    console.log(`\n${'='.repeat(50)}`);
    console.log(`通过率: ${passed}/${reports.length} (${rate}%)`);
    console.log(`${'='.repeat(50)}\n`);

    // 非零退出码阻断 CI 流水线
    if (passed < reports.length) process.exit(1);
  }
}

// 直接运行:node --env-file=.env test/benchmark/runner.ts
const runner = new BenchmarkRunner(buildProviderChain());
runner.run();

CI 集成(GitHub Actions 示例):

# .github/workflows/benchmark.yml
- name: Run xclaw Benchmark
  run: npx tsx test/benchmark/runner.ts
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

通过率低于 100% 时 process.exit(1) 使 CI 步骤失败,阻断合并和部署。


4. 持续优化闭环

【生产环境】                   【分析层】                     【开发/CI 门禁】

发生失败
  ↓
Trace 日志保留完整链路
  trace_id / session_id
  每个 span 的输入输出         提取失败 Payload
  失败时的错误信息         ──>  整理为 TestCase      ──>  加入 dataset.ts
                               {inputPrompt,                    ↓
                                assertResponse,          CI 强制跑分
                                forbiddenTools...}       通过率 < 100%
                                                         阻断部署
线上高成本请求
  ↓
metrics 记录 llm.cost.usd
  ↓
统计 P95 成本 / 耗时瓶颈   ──>  定位高消耗 Prompt   ──>  精简 / 降级模型调优

两条优化路径

路径触发条件行动
错题回流生产失败(agent.error.count 上升)Trace 上下文 → 新 TestCase → dataset.ts
成本优化P95 llm.cost.usd 超阈值定位高消耗 session → Prompt 精简 → 小模型降级

5. 改动全景

第 11 节                              第 12 节

src/index.ts                          src/index.ts
  SIGINT handler             →         升级为 handleShutdown(signal)
                                       + activeTaskTracker 轮询等待
                                       + SIGTERM 覆盖

src/agent.ts                          src/agent.ts
  handle(msg)                →         + activeTaskTracker.enter/exit(委托给 _handleInner)
                                       + traceSpan 包裹 LLM 调用和工具调用
                                       + __toolHook?.(action) 供 Benchmark 拦截

src/providers/types.ts                新增 UsageStats、StreamResult 接口
                                       stream() 返回值由 Promise<string>
                                       改为 Promise<StreamResult>

src/providers/claude.ts               stream() 用 getFinalMessage() 附上 usage
src/providers/openai.ts               stream() 用 stream_options.include_usage 附上 usage
src/providers/registry.ts             streamWithFallback 返回 StreamResult

src/observability/(新建)             4 个文件
                                       context.ts
                                         TraceSpan 接口
                                         TraceContext 接口
                                         traceStorage(AsyncLocalStorage 单例)
                                         generateId()
                                       metrics.ts
                                         MetricsCollector(单例)
                                           record(name, value, tags)
                                           percentile(name, p)
                                           summary()
                                         metrics(导出实例)
                                       tracer.ts
                                         traceSpan<T>(spanName, metadata, fn)
                                           ← 自动初始化 Trace 上下文
                                           ← LLM_CALL 自动捕获 Token / Cost
                                           ← finally 输出结构化 TRACE 日志
                                       tracker.ts
                                         ActiveTaskTracker
                                           enter() / exit() / hasActiveTasks()
                                           供优雅停机轮询

test/benchmark/(新建)                2 个文件
                                       dataset.ts
                                         TestCase 接口
                                         benchmarkDataset(3 个初始 Case)
                                       runner.ts
                                         启动时注册 Provider + 工具(与 index.ts 一致)
                                         BenchmarkRunner
                                           run()
                                           _runCase(tc)
                                           _printSummary(reports)
                                         通过率 < 100% 时 process.exit(1)

Dockerfile(新建)                     多阶段构建
                                       builder: 安装依赖 + 复制 src/
                                       runner:  生产依赖 + src/ 直接运行(无 tsc)
                                       node:22-alpine,CMD node --env-file=.env src/index.ts
                                       VOLUME /app/data(SQLite 持久化)

增加能力:
  容器化      → 多阶段 Dockerfile,镜像体积 ~150MB,非 root 运行
  优雅停机    → activeTaskTracker 等待当前任务完成,最多 15 秒
  调用链追踪  → AsyncLocalStorage 无侵入跨异步传播 traceId
  指标收集    → P50/P95 延迟、Token 消耗、LLM 资金成本
  结构化日志  → {log_type:'TRACE'/'METRIC', trace_id, duration_ms} 对接 Grafana/ELK
  Benchmark   → 断言驱动,3 类测试(路由/提取/防死循环),CI 红线阻断
  优化闭环    → 生产失败 → TestCase 回流 → 下次发版强制覆盖

知识点总结

知识点说明
可观测性三件套Traces(调用链)+ Metrics(指标)+ Benchmark(跑分)—— 三者互补:Trace 定位问题,Metrics 量化趋势,Benchmark 防回归
AsyncLocalStorageNode.js 原生异步上下文存储,traceStorage.run(ctx, fn) 后 fn 内所有 await 链均可 getStore() 读到 ctx,无需显式传参
结构化日志{log_type, metric_name/span_name, value/duration_ms, timestamp} 格式,Promtail/Filebeat 直接解析,无需改日志系统
P95 延迟第 95 百分位延迟——95% 的请求在此时间内完成。比平均值更能反映长尾体验,是 SLA 最常见的基准指标
Token 成本追踪LLM API 返回 usage.prompt_tokens + completion_tokens,乘以费率即得每次调用的美元成本;P95 成本可定位高消耗会话
多阶段 Dockerfilebuilder 阶段含 devDeps + tsc;runner 阶段只含生产依赖 + dist/。典型镜像体积降低 5~6 倍,攻击面缩小
VOLUME 持久化SQLite 数据库、workspace 文件、长记忆向量需跨容器重启存活,必须挂载到宿主机 Volume
优雅停机SIGTERM → 停止新触发 → 轮询等待活跃任务 → 超时强制退出。避免工具执行中途被杀导致状态损坏
activeTaskTracker简单计数器,Agent.handle() 入口 enter(),finally exit()。优雅停机时轮询 hasActiveTasks()
断言驱动 BenchmarkTestCase 包含 expectedTools / forbiddenTools / assertResponse 三种断言,覆盖路由准度、提取准度、防死循环三类回归
CI 红线阻断BenchmarkRunner 通过率 < 100% 时 process.exit(1),使 GitHub Actions / Jenkins 步骤失败,阻断合并和部署
生产失败回流每次 LLM 幻觉或工具崩溃都由 Trace 保留完整上下文,整理为新 TestCase 加入 dataset.ts,形成错题集负反馈
traceSpan 高阶函数把“计时 + span 记录 + metrics + 结构化日志“封装为一个包装器,业务代码只需 traceSpan('name', meta, fn),零侵入

试一试

本地启动

cd sections/12-observability/nodejs
cp .env.example .env
npm install
npm start

Docker 构建与运行

# 构建镜像
docker build -t xclaw:latest .

# 运行(挂载持久化目录,传入 .env)
docker run -d \
  -p 3000:3000 \
  -p 3001:3001 \
  -v $(pwd)/data:/app/data \
  --env-file .env \
  xclaw:latest

验证结构化日志

启动后发一条消息,观察 stdout 中的 Trace 和 Metric 日志:

{"log_type":"TRACE","trace_id":"k3f2m9x","session_id":"web-abc123","span_id":"p7n1q","span_name":"llm.call","duration_ms":1823,"model":"claude","timestamp":"2026-05-23T10:00:01.234Z"}
{"log_type":"METRIC","metric_name":"llm.tokens.input","metric_value":1240,"model":"claude","timestamp":"2026-05-23T10:00:01.235Z"}
{"log_type":"METRIC","metric_name":"llm.cost.usd","metric_value":0.0000062,"model":"claude","timestamp":"2026-05-23T10:00:01.236Z"}
{"log_type":"METRIC","metric_name":"llm.call.latency.ms","metric_value":1823,"timestamp":"2026-05-23T10:00:01.237Z"}

运行 Benchmark

npx tsx test/benchmark/runner.ts

输出示例:

==================================================
xclaw Benchmark — 3 test cases
==================================================

[✓] TC_001_ROUTING (3241ms) — SUCCESS
[✓] TC_002_EXTRACTION (1876ms) — SUCCESS
[✓] TC_003_ANTI_LOOP (2103ms) — SUCCESS

==================================================
通过率: 3/3 (100.0%)
==================================================

某次 Prompt 改动导致回归:

[✗] TC_001_ROUTING (2890ms) — 触犯红线:误触发禁忌工具 [notify]

==================================================
通过率: 2/3 (66.7%)
==================================================

# process.exit(1) → CI 步骤失败,阻断部署

验证优雅停机

服务运行中触发一个长任务,然后发送 SIGTERM:

# 另一个 terminal 发送信号
kill -SIGTERM <pid>

# 观察日志
[system] signal SIGTERM — stopping new triggers
[system] 1/10 waiting for active tasks to finish...
[system] 2/10 waiting for active tasks to finish...
[system] clean shutdown

🏆 恭喜通关!

至此,你已完成整部教程的全部实战。xclaw 从第 1 节的最简 ReAct 循环,一路演进到现在:

第 01 节  ReAct 状态机主循环
第 02 节  工具系统
第 03 节  Provider 注册与 Fallback
第 04 节  实时通信(WebSocket / QQ 频道)
第 05 节  沙盒执行隔离
第 06 节  状态持久化(SQLite)
第 07 节  浏览器自动化
第 08 节  长短期记忆与 RAG
第 09 节  多 Agent 协同
第 10 节  插件与 Skill 系统
第 11 节  Chronos 主动触发
第 12 节  可观测性与 Benchmark ← 你在这里

你没有依赖 LangChain、LlamaIndex 等厚重框架,而是亲手实现了每一层——这意味着你真正理解了每个决策背后的工程取舍,而不只是会调 API。这套代码底座,是你进军更大规模 Agent 系统的起点。