前阵子有人在群里甩了个词问我:“rea 是不是把 react 拼错了?”我愣了一下,然后意识到他说的是我上周提过的一个内部工具。rea 是我在给业务写自动化兜底脚本时反复用到的一个微型决策循环,全称是 Read-Evaluate-Act,意思是读入、评估、执行。后来我把这套循环从具体业务里抽出来,收成了一个带接口定义的公共包,也沉淀出这篇文章。如果你是在做后端服务、运维脚本、边缘设备程序,或者任何一种“需要根据环境状态自动做决定”的场景,这篇内容应该对你有用——它不讲大道理,只讲怎么用 rea 把一坨反复 if-else 的烂账收拾干净。
这套模式其实不算新东西,很多智能体框架里都有类似结构,但大多数介绍都在讲强化学习或者大模型 Agent,离业务开发太远。我想做的,是把 rea 落成一个普通开发也能看懂、能拿起来改的工程模式。你不需要会任何机器学习框架,只要会写基本的函数,就能把它嵌进自己的服务里。下面我从为什么需要这个循环开始,一步步拆到代码、踩坑和优化。
1. 为什么需要rea循环,而不是一把梭的if-else
我最早的做法特别简单:写一个脚本,每隔一段时间拉一次指标,然后用 if-else 判断是否需要处理。最开始只有一条规则,很舒服;后来规则多了,代码开始变得像一棵圣诞树,每次加需求都要小心翼翼地把新分支插到旧分支里。我仔细复盘了一下,硬编码流程至少有四个问题,都是 rea 想解决的。
1.1 硬编码流程的四个痛点
第一个痛点是条件分支指数增长。业务最开始的流程可能是这样:检查日志一看有“写入超时”关键字,就去清一次缓存。这时候一个 if 就够。后来要加上“内存占用过高就重启进程”,再加“请求超时率过高就降级接口”,再后面还要处理“上游依赖抖动就熔断”。每加一个场景,旧的判断体上就会再挂一层判断,函数的圈复杂度一路飙升。圈复杂度一高,测试用例根本没法写全,每次改动都像抽积木。
第二个痛点是上下文丢失。每个 if 分支里算出来的临时结果,比如“最近五分钟超时次数”“上次重启时间”,都要往下游分支传。有人用全局变量,有人往参数对象里塞,传得深了就非常容易串。而且一旦走到了错误分支,所有中间状态都被丢弃,看不到到底是哪条路线被选中,也看不到被丢掉的候选方案长什么样。
第三个痛点是副作用难回滚。脚本很可能已经执行了“清缓存”这个动作,然后到下一步发现条件不满足,想退回上一步?做不到。更难受的是,如果动作执行一半挂了,脚本不会告诉你刚才到底改了什么。在生产环境里,这种不可回滚的流程一旦误触发,恢复全靠手算。
第四个痛点是可观测性差。日志里到处都是“step1 ok”“step2 ok”,根本看不出当前处于哪个决策阶段,更不知道某个关键动作为什么没被选中。等出了问题,只能靠人肉在日志里翻来翻去。
这几件事叠加在一起,导致我后来看到再简单的自动化需求,第一反应都是别再叠 if 了。于是把决策逻辑拆成了固定三步:读入、评估、执行。这就是 rea 的雏形。
1.2 rea循环的基本运行轨迹
rea 把一次决策过程拆成三个固定的阶段:
- Read:读取外部状态,转成一个统一、扁平的事实对象;
- Evaluate:把这些事实喂给一组规则或者策略函数,输出候选动作以及置信度;
- Act:按规则执行候选动作,并把执行结果写回状态,作为下一轮 Read 的输入。
整个过程不断循环:读入、评估、行动、再读入,直到满足退出条件。理解这个循环最好的方式是类比开车:你观察路况和仪表盘(Read),判断当前是加速还是打方向盘(Evaluate),执行踩油门、踩刹车这些操作并感受车的反馈(Act),然后继续观察下一个路口,一直循环到目的地。
和传统 if 流程最大的区别在于:每一个阶段都是独立函数,输入输出都明确。Read 阶段不知道后面要做什么决策,Evaluate 阶段不碰任何外部副作用,Act 阶段只做执行。这样每一块都可以单独测试,也可以单独替换。后面我会用代码说明。
2. rea三阶段的数据契约与核心实现
rea 能跑起来的关键是三个阶段之间要有稳定的数据契约。如果 Read 输出一个嵌套七八层的大对象,Evaluate 里写满 optional chaining 去掏数据,那整个模式很快又会重新变成烂账。我设计契约的时候只坚持一条原则:每个阶段之间只通过“事实对象”“评估结果”“反馈对象”这三种结构通信。
2.1 核心循环骨架代码
先看主循环的骨架,我用 JavaScript 描述:
class ReaAgent { constructor({ reader, evaluator, actor }) { this.reader = reader; this.evaluator = evaluator; this.actor = actor; this.state = {}; this.running = false; } async step() { // Read 阶段 const facts = await this.reader.read(this.state); this.state.lastFacts = facts; // Evaluate 阶段 const result = await this.evaluator.evaluate(facts, this.state); this.state.lastResult = result; // Act 阶段 if (result.actions.length > 0) { const feedback = await this.actor.execute(result.actions, this.state); this.state.lastFeedback = feedback; } return this.state; } async loop(intervalMs, stopCondition) { this.running = true; while (this.running) { const shouldStop = stopCondition ? stopCondition(this.state) : false; if (shouldStop) break; await this.step(); await sleep(intervalMs); } } stop() { this.running = false; } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }你看,核心就这么几十行。真正有技术含量的不是这个类,而是怎么把 Read、Evaluate、Act 三个接口设计得够干净。下面逐个讲。
2.2 Read阶段:如何把环境转化成结构化事实
Read 阶段的职责是“把外部世界翻译成内部语言”。我定义的 reader 接口是一个接收 state、返回 facts 的异步函数:
async function readLogStats(state) { const logs = await fetchLogs({ since: Date.now() - 5 * 60 * 1000 }); const timeoutCount = logs.filter(item => item.includes('write_timeout')).length; const now = Date.now(); return { source: 'log_stats', metricName: 'write_timeout', value: timeoutCount, timestamp: now, windowStart: now - 5 * 60 * 1000, windowEnd: now, }; }有几个设计要点:第一,facts 尽量扁平,不要返回一个多层嵌套的业务对象。扁平的字段可以在 Evaluate 阶段直接被规则函数消费,不需要再写一堆取值代码。第二,facts 要自带时间戳和窗口信息。因为后续做冷却、去重、二次确认都要依赖时间。第三,Read 阶段不能对状态做任何修改,只能读取和返回。如果你在 Read 里塞了缓存清理逻辑,那等于把副作用提前了,后面所有保障都会失效。
这里容易踩的坑是:Read 阶段一遇到外部系统超时,整个循环就抛异常退出。我的做法是在 reader 外面包一层容错:
async function safeRead(reader, state) { try { return { ok: true, data: await reader(state) }; } catch (err) { return { ok: false, data: null, error: err.message }; } }这样即使外部系统抖了,rea 循环依然能继续跑,只是这一轮没有拿到新事实,Evaluate 阶段可以根据“读入失败”这一事实做降级处理。
2.3 Evaluate阶段:策略引擎与置信度评分
Evaluate 阶段本身可以非常简单,也可以非常复杂。最简单的是一个规则函数数组,每个规则接收 facts,返回一个候选动作或 null:
const rules = [ (facts, state) => { if (facts.metricName !== 'write_timeout') return null; if (facts.value > 20 && state.consecutiveOverload >= 3) { return { action: 'restart_worker', params: { reason: 'timeout_high_and_continuous' }, confidence: 0.9, rule: 'rule_restart_on_continuous_timeout', }; } return null; }, (facts, state) => { if (facts.value > 5 && facts.value <= 20) { return { action: 'throttle_requests', params: { ratio: 0.5, durationMs: 60_000 }, confidence: 0.5, rule: 'rule_throttle_on_mid_timeout', }; } return null; }, ];这里我把 confidence 叫做“置信度”。它不是严格的概率,而是用来给候选动作排序的权重。Action 对象里一定要带 action 名称、params、confidence、rule 来源。rule 字段尤其重要,后面排查误判时,一眼就能看到是哪条规则触发的。
Evaluate 阶段返回结果里还应该包含一个 context 对象,专门记本次评估上下文:
const result = { context: { windowStart: facts.windowStart, windowEnd: facts.windowEnd, factsVersion: 1, }, actions: actions.sort((a, b) => b.confidence - a.confidence), };为了防止规则函数堆成大山,我给自己定了一个约定:单个 evaluate 函数不能超过 20 行,超过就拆成多个规则。拆规则的时候,每个规则只做一件事,然后用规则名做唯一的标识。这样出问题时,日志会精确到某条规则,而不是某个模糊的大函数。
2.4 Act阶段:副作用收敛与安全阀
Act 是唯一允许产生副作用的地方。在这个阶段里要考虑的东西反而更琐碎:动作是否在允许列表里?动作的冷却时间过了没有?这个动作和上一个动作是不是同类重复?如果两个候选动作是互斥的,应该怎么选?
我习惯把所有安全校验放在“预执行钩子”里,而不是散落在各个 action 执行函数中:
const actor = { preActionHooks: [ checkActionAllowed, checkCoolingTime, checkDailyLimit, ], async execute(actions, state) { const sortedActions = actions.sort((a, b) => b.confidence - a.confidence); const selected = sortedActions[0]; // 默认只执行得分最高的动作 if (!selected) return { executed: false, reason: 'no_action' }; for (const hook of this.preActionHooks) { const check = hook(selected, state); if (!check.passed) { return { executed: false, reason: check.reason, action: selected.action }; } } const feedback = await runAction(selected.action, selected.params); return { executed: true, action: selected.action, feedback }; }, };这样安全阀集中在一个地方,新加动作只需要注册进允许列表,不需要改动执行流程。一句话总结:可靠性不是靠每个执行函数自己小心,而是靠统一的预执行校验链。
3. 实战:用rea做一个日志异常自愈代理
理论讲完,给一个完整的实战案例。这个案例我是在一个内部服务里真实跑过的,背景是某个无状态业务进程的日志里偶发“写入超时”,磁盘和内存指标都正常,但日志频繁出现。人工处理往往是先看一眼监控,然后手动重启进程。我用 rea 写了一个自愈代理,流程是每 30 秒读一次日志统计,满足条件就重启,不满足就继续观察。
3.1 第一步:定义事件模型
事件模型是整个方案的基石。我定义了一个扁平的事实结构:
{ source: 'service_log', metricName: 'write_timeout', value: 8, windowStart: 1710000000000, windowEnd: 1710000300000, peripheral: { networkDelayHigh: false, diskUsagePercent: 0.6, }, }peripheral 里放的是辅助信号,比如网络延时高不高、磁盘占用多少。这里的请求超时计数是主指标,但后续做二次确认要用到辅助信号。
3.2 第二步:实现Read适配器
这个适配器拉取最近 5 分钟日志,统计“write_timeout”出现的次数,同时采样网络和磁盘指标:
async function readServiceHealth(state) { const now = Date.now(); const logs = await fetchRecentLogs(now - 5 * 60 * 1000, now); const timeoutCount = logs.filter(line => line.includes('write_timeout')).length; return { source: 'service_health', metricName: 'write_timeout', value: timeoutCount, timestamp: now, windowStart: now - 5 * 60 * 1000, windowEnd: now, peripheral: { networkDelayHigh: await isNetworkLatencyHigh(), diskUsagePercent: await getDiskUsage(), }, }; }这里注意一个细节:fetch 日志和采样指标是两个独立操作,如果网络采样失败,不应该影响日志统计。所以在实际代码里我会用 Promise.allSettled 打包,让单个采集失败不会拖垮整个读取。
3.3 第三步:配置评估规则
评估规则有两条核心逻辑:
- 如果 5 分钟内超时次数大于 20,并且连续 3 个窗口都满足,执行 restart,置信度给 0.9;
- 如果超时次数大于 5 且小于等于 20,或者次数不高但网络延迟很高,执行 throttle 限流,置信度给 0.5。
实现时我会维护一个 state.consecutiveOverload 计数器,在每次 evaluate 后更新:
function updateConsecutive(state, isOverload) { if (isOverload) { state.consecutiveOverload = (state.consecutiveOverload || 0) + 1; } else { state.consecutiveOverload = 0; } }然后规则写成:
const rules = [ { name: 'restart_on_continuous_timeout', test: checkContinuous, action: buildRestartAction }, { name: 'throttle_on_mid_timeout', test: checkMidTimeout, action: buildThrottleAction }, ];规则里不直接改 state,只根据 facts 和 state 返回动作。这样每个规则都是纯函数,想用单元测试覆盖非常方便。
3.4 第四步:挂载Act执行器与安全阀
Act 执行器要真的调服务管理接口去重启进程,所以安全阀尤其重要。我加了三个钩子:
- allowed list:只允许 restart_worker、throttle_requests、send_alarm 这三个动作;
- 冷却时间:restart_worker 冷却 10 分钟;
- 每日上限:restart_worker 每天最多 3 次。
执行逻辑:
async function runAction(action, params) { if (action === 'restart_worker') { await callServiceRestart(params); return { restartAt: Date.now() }; } if (action === 'throttle_requests') { await setThrottleRatio(params.ratio, params.durationMs); return { throttled: true, durationMs: params.durationMs }; } return { noop: true }; }跑起来之后,代理会小步快跑地决策:第一轮 Read 到 count=6,Evaluate 觉得还算安全,只做 throttle;过了几分钟 count 持续上涨并超过 20,且连续 3 个窗口满足,Act 执行 restart。整个过程日志里会清楚记录每个阶段的动作来源。
4. 我把代理跑上线后踩到的两个坑
自愈代理看起来不复杂,但真跑到线上,很快就踩了两个值得分享的坑。这两个坑都不是功能 bug,而是“循环决策”模式下才会出现的系统性问题。
4.1 重复触发风暴:状态丢失导致无限重启
上线第一天,某台机器日志开始频繁报“写入超时”,代理每 30 秒扫一次,扫到 count 超过阈值就 restart。结果一个小时内服务被重启了 8 次。我看日志时整个人都懵了:为什么冷却时间没生效?每日上限为什么也没拦住?
排查链路是这样的:先看 Action 预执行钩子,发现冷却时间检查确实返回了 blocked,但被阻断之后,当前轮次的 Evaluate 依然会产生 restart 动作,下一轮 Read 时日志里超时计数依然很高,于是又走到 restart。也就是说,冷却时间只是“拦下了执行”,并没有消灭“决策意图”。更致命的是,restart 后服务瞬时启动,日志采集窗口里还会残留上一轮的旧日志,count 继续超过阈值,形成了死循环。
修复方案是三步:第一,在状态里增加 lastActions 记录,保存最近一次 restart 的触发时间、原因和指纹:
state.lastActions = [ { action: 'restart_worker', at: Date.now(), reasonKey: facts.windowEnd + '_' + facts.value, }, ];第二,在 Evaluate 阶段增加“重复检查”规则,如果本轮产生的动作和 lastActions 里的动作相同,并且还在冷却期内,直接把动作丢弃,而不是继续往 Act 传。第三,把安全阀从“拦截执行”升级为“在规则层就抑制决策”。这样彻底避免了下一轮无意义的重试。
这个坑的本质是:传统 if 脚本里一个动作只执行一次,而 rea 是一个循环,每一轮都在重新决策。如果不把“刚做过什么”反馈给决策层,它就会像一个记忆只有一秒钟的人,反复做同一件事。
4.2 单指标误判:只盯一个数字导致误重启
第二个坑更隐蔽。某次机房网络抖动,日志采集延迟,代理的 Read 阶段拿到的 count 突然飙到 30 多次,远远超过重启阈值,于是代理直接重启了健康服务。重启本身又带来短暂不可用,比原来的抖动还要难收拾。
排查后发现,根因是 Evaluate 阶段只看一个指标:write_timeout 的 count。网络抖动会让日志上传延迟,导致某些窗口的数据堆积到下一个窗口,count 虚高,但磁盘和内存指标完全正常。服务其实不需要重启,只需要等日志积压过去就行了。
我一开始想直接加一条规则“如果网络延迟高就不重启”,但这样治标不治本。后来把评估逻辑改成“二次确认”模式:当某个高风险动作被首次触发时,不是立即产生 restart 动作,而是进入 pending 状态。只有下一个采样窗口里依然满足条件,并且辅助信号也支持确认,才真正生成重启动作。
代码结构大概是:
if (facts.value > 20) { if (state.pendingRestart === undefined) { state.pendingRestart = { firstWindowEnd: facts.windowEnd, firstCount: facts.value, createdAt: Date.now(), }; return { action: 'wait_confirm', confidence: 0.3, rule: 'wait_for_second_window' }; } const windowGapOk = facts.windowEnd - state.pendingRestart.firstWindowEnd > 60_000; const signalOk = facts.peripheral.diskUsagePercent < 0.85 && !facts.peripheral.networkDelayHigh; if (windowGapOk && signalOk) { return { action: 'restart_worker', confidence: 0.95, rule: 'restart_after_confirm' }; } }加了 pending 机制后,误判率降了非常多。即使 count 虚高,只要没有连续两个窗口确认,代理最多只是打一条“待确认”日志,不会碰服务。
5. rea循环还能往哪些方向扩展
到这里,rea 的核心思想已经全讲完了。如果你想继续往下走,我列三个我觉得很值得尝试的方向。
第一个方向是多代理协同。如果每个服务都跑一个 rea 循环,可以把各自的 facts 和 lastActions 写入一个共享的状态存储,让多个代理之间能看到彼此的动作。比如 A 代理刚重启过,B 代理就该在短暂时间内抑制重复重启动作。这个扩展不需要改核心接口,只需要增加一个“共享事实”的 reader。
第二个方向是把 Evaluate 从规则函数升级成可学习的评分函数。当规则越来越多时,手动调 confidence 会变得很难。你可以训练一个轻量模型,输入 facts 向量,输出动作和置信度。不过别急着上模型,先把规则版跑稳,积累一批真实触发日志,再拿这些日志去校验模型。
第三个方向是人工审批兜底。对于一些高风险动作,Act 阶段不自动执行,而是把动作推到一个工单队列,等人点确认后再执行。rea 的接口天然支持这种模式——只要 Act 阶段动作改成“创建审批任务”就行。这在高频操作场景里特别重要。
我个人在实际使用中发现,重写掉了三四个 if-else 脚本之后,代码行数并没有减少,甚至可能更多。但我的改造成本大幅下降了:以前每次调阈值都要翻整个脚本,现在只需要改 Evaluate 的一条规则;以前出了事故没法复现,现在每个阶段的 facts 和候选动作都留在了 state 里,可以回放。
最后再分享一个小技巧:如果你准备在自己项目里落地 rea,千万不要把 reader、evaluator、actor 这三个函数写到同一个文件里,哪怕一开始它们很小。把它们分开,你才能在未来某个深夜需要排查生产事故时,用最短的时间定位问题到底出在读取、判断还是执行。边界清楚,比省几十行代码重要得多。