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 | 删除数据、执行数据库写操作、全局回滚 | 禁止由模型直接执行,必须走现有变更系统 |
人工接管不是流程失败,而是系统设计的一部分。以下情况应立即转人工:证据互相矛盾;涉及数据删除或权限变化;影响跨服务或跨区域;没有已验证的回滚方案;检查结果与历史基线不一致;模型无法引用具体证据。
人工批准也不应只是一个布尔值。审批记录至少要包含动作、目标范围、理由、风险、回滚命令或变更单号、审批人和过期时间。过期后即使计划内容不变,也应该重新确认,因为线上状态可能已经变化。
把可审计性落实到接口和回放
诊断服务应保存一条不可变事件流,而不是只保存最后的结论。事件可以包括告警输入、证据快照、模型提示词版本、模型输出、引用校验结果、命令执行结果、审批记录和最终状态。敏感日志需要在落盘前脱敏,原始数据则按照现有合规策略保留。
模型输出建议限制为结构化对象,例如 hypotheses、checks、proposedActions 三个数组。服务端必须重新校验:证据编号是否存在,检查是否在允许列表中,参数是否符合模式,风险等级是否与动作匹配。模型返回的自然语言只能作为展示内容,不能直接成为执行参数。
回放测试可以从历史事件流重新运行状态机,验证两件事:同样的证据是否产生同样的状态迁移,以及策略升级后哪些动作会从自动执行变为人工审批。不要只测试“成功修复”的案例,还要覆盖超时、证据缺失、重复告警、审批过期、命令非零退出和回滚失败。
在接入现有监控或工单系统时,可以先从只读能力开始。第一阶段只让 AI 归纳告警并生成检查计划;第二阶段允许执行固定的健康检查;第三阶段再针对少数可逆的 L1 动作接入审批。每一步都保留关闭自动化的开关,并确保关闭后不会阻塞人工处置。
总结
- 用状态机限制流程顺序,避免 AI 从告警直接跳到修复命令。
- 每个假设都必须引用稳定的证据编号,引用不存在的证据应立即失败。
- 只读检查使用允许列表、参数校验、超时、输出限制和非 shell 执行。
- 止损动作按影响范围和可逆性分级,L2、L3 默认进入人工接管。
- 保存完整事件流,让诊断过程可以审计、复盘和回放。
- 先建设可靠的证据与审批链,再逐步扩大 AI 的自动化范围。
AI 值班真正有价值的地方,是减少信息整理和重复检查,而不是替人承担未经验证的生产风险。把证据、假设、动作和结果拆开,智能排障才能接入现有服务,同时保留工程系统最重要的可控性。
评论