AI 可以帮助值班人员缩小排查范围,但不应该凭一段自然语言直接执行修复命令。本文用 Node.js 设计一个证据驱动的诊断状态机,把采集、假设、只读检查、止损和复盘串成可回放流程。核心目标不是让模型拥有更大权限,而是让每一步都能说明依据、风险和责任边界。

先解决最容易失控的环节

线上故障中,AI 值班最危险的地方通常不是模型不会生成命令,而是它可能在证据不足时表现得过于确定。例如,看到接口延迟升高,就直接重启服务;看到磁盘使用率上升,就自动删除日志;看到数据库连接错误,就批量终止连接。这些动作有时确实能缓解问题,但也可能掩盖根因、扩大影响,甚至破坏后续取证。

因此,故障处置流程需要把“建议”和“执行”明确分开:

阶段AI 可以做什么默认是否自动执行
采集证据汇总监控、日志、变更和依赖状态是,但只读
形成假设给出可能原因及证据引用
只读检查执行预先允许的检查命令是,需超时和参数限制
低风险止损执行已批准的、可回滚动作视策略而定
高风险变更重启、扩容、删数据、改流量必须人工确认
复盘记录保存输入、输出、结果和操作者

这里的关键不是把模型关在笼子里,而是让它不能跳过状态。任何动作都必须回答三个问题:依据是什么,可能造成什么影响,失败后如何回滚。如果无法回答,就停在人工接管边界。

用状态机约束诊断路径

一个实用的最小状态机可以包含五个状态:COLLECT 采集证据,HYPOTHESIZE 形成假设,READONLY_CHECK 执行只读检查,MITIGATE 申请或执行止损,最后进入 REVIEW 复核。状态迁移由程序控制,不由模型自由决定。

建议将每条证据设置稳定的引用编号,格式如下:

EV-20240518-001
source=metrics.service.api
kind=latency
observed_at=2024-05-18T10:20:00Z
value=p95=1800ms, baseline=350ms
scope=cluster-a

假设只能引用已经存在的证据,例如:

{
  "id": "H-001",
  "claim": "api 服务延迟升高可能与下游支付依赖超时有关",
  "evidence": ["EV-20240518-001", "EV-20240518-002"],
  "confidence": "medium",
  "next_check": "读取支付依赖的超时和错误率"
}

confidence 不是执行权限。即使模型给出 high,也不能越过策略检查。它只用于帮助值班人员排序,而不是替代审批。

一个可运行的 Node.js 诊断骨架

下面的示例只使用 Node.js 内置 API,保存为 diagnostic-loop.js 后可直接运行。它演示了证据记录、状态迁移、允许列表检查和人工确认。示例中的止损动作只生成待审批计划,不会修改系统。

'use strict';

const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execFileAsync = promisify(execFile);

const STATES = Object.freeze({
  COLLECT: 'COLLECT',
  HYPOTHESIZE: 'HYPOTHESIZE',
  READONLY_CHECK: 'READONLY_CHECK',
  MITIGATE: 'MITIGATE',
  REVIEW: 'REVIEW'
});

const evidence = [];
const events = [];
let state = STATES.COLLECT;
let sequence = 0;

function addEvidence(source, kind, value) {
  sequence += 1;
  const item = {
    id: `EV-${String(sequence).padStart(4, '0')}`,
    source,
    kind,
    value,
    observedAt: new Date().toISOString()
  };
  evidence.push(item);
  events.push({ type: 'evidence_added', item });
  return item.id;
}

function transition(next, reason) {
  const allowed = {
    COLLECT: ['HYPOTHESIZE'],
    HYPOTHESIZE: ['READONLY_CHECK'],
    READONLY_CHECK: ['MITIGATE', 'REVIEW'],
    MITIGATE: ['REVIEW'],
    REVIEW: []
  };
  if (!allowed[state].includes(next)) {
    throw new Error(`invalid transition: ${state} -> ${next}`);
  }
  events.push({ type: 'state_changed', from: state, to: next, reason });
  state = next;
}

async function readonlyNodeCheck(expression) {
  const result = await execFileAsync(process.execPath, ['-p', expression], {
    timeout: 3000,
    maxBuffer: 16 * 1024,
    shell: false
  });
  return result.stdout.trim();
}

async function main() {
  const latencyId = addEvidence('monitor.api.latency', 'metric', 'p95=1800ms');
  const errorId = addEvidence('monitor.api.errors', 'metric', '5xx=8.2%');
  transition(STATES.HYPOTHESIZE, 'initial metrics collected');

  const hypothesis = {
    id: 'H-0001',
    claim: 'api 进程资源异常或下游依赖变慢,需要先做只读检查',
    evidence: [latencyId, errorId],
    confidence: 'medium'
  };
  events.push({ type: 'hypothesis_created', hypothesis });
  transition(STATES.READONLY_CHECK, 'hypothesis has valid evidence references');

  const memory = await readonlyNodeCheck('JSON.stringify(process.memoryUsage())');
  const uptime = await readonlyNodeCheck('process.uptime()');
  const memoryId = addEvidence('runtime.node', 'readonly_check', `memory=${memory}`);
  const uptimeId = addEvidence('runtime.node', 'readonly_check', `uptime=${uptime}s`);

  const referenced = new Set([...hypothesis.evidence, memoryId, uptimeId]);
  const known = new Set(evidence.map((item) => item.id));
  const allReferencesValid = [...referenced].every((id) => known.has(id));
  if (!allReferencesValid) throw new Error('evidence reference validation failed');

  transition(STATES.MITIGATE, 'readonly checks completed');
  const plan = {
    action: 'route_traffic_to_existing_healthy_instance',
    risk: 'medium',
    requiresHumanApproval: true,
    rollback: 'restore_previous_route',
    evidence: [latencyId, errorId, memoryId, uptimeId]
  };
  events.push({ type: 'mitigation_planned', plan });
  console.log(JSON.stringify({ state, evidence, hypothesis, plan, events }, null, 2));

  transition(STATES.REVIEW, 'plan recorded; execution held for human approval');
  console.log(JSON.stringify({ finalState: state, auditEvents: events.length }, null, 2));
}

main().catch((error) => {
  console.error(JSON.stringify({ state, error: error.message, events }, null, 2));
  process.exitCode = 1;
});

运行命令:

node diagnostic-loop.js

示例中的 execFile 使用参数数组和 shell: false,避免把模型生成的整段字符串交给 shell 解释。允许的检查也应该进一步收敛到固定函数,而不是让模型传入任意表达式。生产环境中,建议为每个检查增加命令名、参数模式、超时时间和输出大小限制,并对返回结果做脱敏。

分级止损与人工接管边界

止损策略不能只用“模型觉得安全”来判断,至少要同时考虑影响范围、可逆性和审批要求。可以采用如下分级:

等级示例处理方式
L0重新采集指标、查询日志自动执行,只读
L1暂停单个异常消费者、切换到已有健康副本可自动生成计划,按服务策略审批
L2重启实例、修改限流、调整流量权重明确人工确认,记录操作者
L3删除数据、执行数据库写操作、全局回滚禁止由模型直接执行,必须走现有变更系统

人工接管不是流程失败,而是系统设计的一部分。以下情况应立即转人工:证据互相矛盾;涉及数据删除或权限变化;影响跨服务或跨区域;没有已验证的回滚方案;检查结果与历史基线不一致;模型无法引用具体证据。

人工批准也不应只是一个布尔值。审批记录至少要包含动作、目标范围、理由、风险、回滚命令或变更单号、审批人和过期时间。过期后即使计划内容不变,也应该重新确认,因为线上状态可能已经变化。

把可审计性落实到接口和回放

诊断服务应保存一条不可变事件流,而不是只保存最后的结论。事件可以包括告警输入、证据快照、模型提示词版本、模型输出、引用校验结果、命令执行结果、审批记录和最终状态。敏感日志需要在落盘前脱敏,原始数据则按照现有合规策略保留。

模型输出建议限制为结构化对象,例如 hypotheseschecksproposedActions 三个数组。服务端必须重新校验:证据编号是否存在,检查是否在允许列表中,参数是否符合模式,风险等级是否与动作匹配。模型返回的自然语言只能作为展示内容,不能直接成为执行参数。

回放测试可以从历史事件流重新运行状态机,验证两件事:同样的证据是否产生同样的状态迁移,以及策略升级后哪些动作会从自动执行变为人工审批。不要只测试“成功修复”的案例,还要覆盖超时、证据缺失、重复告警、审批过期、命令非零退出和回滚失败。

在接入现有监控或工单系统时,可以先从只读能力开始。第一阶段只让 AI 归纳告警并生成检查计划;第二阶段允许执行固定的健康检查;第三阶段再针对少数可逆的 L1 动作接入审批。每一步都保留关闭自动化的开关,并确保关闭后不会阻塞人工处置。

总结

  • 用状态机限制流程顺序,避免 AI 从告警直接跳到修复命令。
  • 每个假设都必须引用稳定的证据编号,引用不存在的证据应立即失败。
  • 只读检查使用允许列表、参数校验、超时、输出限制和非 shell 执行。
  • 止损动作按影响范围和可逆性分级,L2、L3 默认进入人工接管。
  • 保存完整事件流,让诊断过程可以审计、复盘和回放。
  • 先建设可靠的证据与审批链,再逐步扩大 AI 的自动化范围。

AI 值班真正有价值的地方,是减少信息整理和重复检查,而不是替人承担未经验证的生产风险。把证据、假设、动作和结果拆开,智能排障才能接入现有服务,同时保留工程系统最重要的可控性。