资讯详情

资讯详情

决策循环REA:从Read到Act,重构自动化逻辑告别if-else

前阵子有人在群里甩了个词问我“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返回一个候选动作或 nullconst 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 到 count6Evaluate 觉得还算安全只做 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 这三个函数写到同一个文件里哪怕一开始它们很小。把它们分开你才能在未来某个深夜需要排查生产事故时用最短的时间定位问题到底出在读取、判断还是执行。边界清楚比省几十行代码重要得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →