资讯动态

Agent编排中可复用可测试决策的设计与治理实践

发布时间:2026/10/20 22:02:13 来源:尧图企业网站定制
1. 为什么 Agent 编排需要“可复用、可测试的决策”1.1 从“能跑就行”到“敢改敢测”的转折点做 Agent 编排的人大概都经历过这个阶段一开始写个能跑通的流程感觉挺爽节点连起来、条件分支加上、工具调用挂上去跑一遍输出看着像那么回事就认为大功告成。但真正进入迭代期之后问题就来了——改一个判断条件整条链路的行为跟着变加一个新工具原来稳定的分支开始抽风想把某个决策逻辑复用到另一个流程里发现它跟上下文耦合得太深拆都拆不出来。这个痛点的根源在于大多数 Agent 编排框架把“决策”写死在流程里了。决策逻辑和流程结构混在一起导致它既不能被单独拿出来测试也不能被干净地复用。你没法对一个散落在十几个节点里的判断逻辑写单元测试也没法把它抽成一个函数让别的流程调用。“用 Jev 为 Agent 编排定义可复用、可测试的决策”这件事核心要解决的就是这个。Jev 在这里扮演的角色是一个把决策逻辑从流程结构中剥离出来的抽象层。它让你把“在什么条件下做什么选择”这件事定义成一个独立的、有明确输入输出的单元然后流程只负责调用这个单元不关心它内部怎么判断。1.2 谁适合参考这套思路如果你正在做以下任何一件事这套思路都值得细看手上有多个 Agent 流程彼此之间有相似的决策逻辑每次都在复制粘贴流程改一次就要全量回归因为没有针对决策的独立测试团队协作时流程结构由一个人维护决策规则由另一个人维护两边经常打架想把决策逻辑做成配置化、可版本管理的东西而不是散落在代码各处不需要你已经在用某个特定框架这套“决策抽象”的思路是通用的Jev 只是实现它的一种载体。下面我会从设计思路、核心细节、实操过程到问题排查把这件事拆开讲透。2. 整体设计思路决策与流程为什么要分家2.1 决策的本质是一个纯函数先把一个概念钉死在 Agent 编排的语境下一个“决策”本质上就是一个纯函数。给它一组输入当前上下文、历史状态、外部信号它返回一个明确的输出走哪条分支、调用哪个工具、是否终止。纯函数意味着什么意味着同样的输入永远得到同样的输出不依赖外部可变状态不产生副作用。这个性质是“可测试”的根基——你可以给定输入断言输出不需要把整个流程跑起来。大多数编排框架的问题在于决策被写成了带副作用的操作判断条件里直接读了全局状态分支里直接调了工具工具调用又改了状态改完的状态又影响下一个判断。这一圈下来你根本没法单独测任何一个决策点。Jev 的设计思路就是把决策收敛成纯函数输入是显式传入的上下文对象输出是一个决策结果对象中间不碰任何外部东西。流程负责准备上下文、执行决策结果、更新状态决策本身只做判断。2.2 可复用意味着决策要脱离流程上下文“可复用”这三个字说起来简单做起来最难的是解耦。一个决策逻辑之所以不能被复用通常是因为它依赖了太多流程特有的东西某个节点的输出格式、某个全局变量的命名、某个隐式的执行顺序。我的做法是给决策定义一个最小的输入契约。比如一个“判断是否需要重试”的决策它的输入不应该是整个流程状态而应该只是{ 尝试次数, 上次错误类型, 已耗时 }这几个字段。这样这个决策就能被任何满足这个输入契约的流程复用不管那个流程是处理订单还是处理工单。Jev 在这里提供的是一个契约声明机制你声明这个决策需要哪些输入字段流程在调用时负责把对应字段填进去。决策内部只认契约不认流程。这就把复用变成了可能——只要两个流程都能提供同样的输入字段同一个决策就能同时服务它们。2.3 可测试意味着决策要能脱离运行时测试一个决策不应该需要启动整个 Agent 运行时。理想状态下你写一个测试用例构造输入对象调用决策函数断言输出对象结束。整个过程是毫秒级的不依赖网络、不依赖数据库、不依赖任何外部服务。这要求决策的实现不能在里面做 IO 操作。如果决策需要外部信息那这个信息应该作为输入传进来而不是决策自己去查。比如“判断用户意图”这个决策如果需要用户画像那画像应该在调用决策之前就查好作为输入的一部分传进去而不是在决策内部去调用户服务。Jev 的决策定义天然支持这种模式因为它把决策的求值过程设计成了同步的、无副作用的。所有异步的、有副作用的操作都被推到流程层去做。这个边界划清楚之后测试就变成了纯粹的输入输出断言。2.4 方案选型为什么是 Jev 而不是硬编码有人可能会问我直接在代码里写 if-else 不也能实现决策吗为什么要引入 Jev 这一层区别在于治理能力。硬编码的 if-else 散落在流程各处你没法统一管理它们的版本、没法统一做变更影响分析、没法统一做灰度发布。而 Jev 把决策变成了有名字、有契约、有版本的实体你可以像管理 API 一样管理决策这个决策当前是哪个版本、哪些流程在用它、改了这个决策会影响哪些流程。另一个区别是组合能力。硬编码的决策很难组合你没法把“判断是否需要重试”和“判断重试次数是否超限”这两个逻辑干净地组合成一个新决策。而 Jev 的决策是可以互相调用的一个决策可以把另一个决策作为子步骤组合出更复杂的判断逻辑同时保持每一层都可测试。3. 核心细节解析决策定义的几个关键设计3.1 输入契约的字段设计原则设计决策的输入契约时最容易犯的错是“把整个上下文塞进去”。我见过不少实现决策的输入就是整个流程的 state 对象决策内部想读什么读什么。这样做短期方便长期是灾难——你永远不知道这个决策到底依赖哪些字段改 state 结构时也不知道会不会影响它。我的原则是输入契约只包含决策真正需要的字段而且字段命名要面向决策语义而不是面向流程结构。比如一个“判断是否升级人工”的决策输入应该是{ 置信度, 用户情绪, 问题复杂度 }而不是{ node3_output, global_var_x, temp_result }。字段类型也要尽量收窄。能用枚举就不用字符串能用布尔就不用枚举。收窄类型的好处是测试用例好写边界情况明确。比如“用户情绪”用枚举positive | neutral | negative就比用字符串好测试时只需要覆盖三种情况。还有一个细节输入契约里不要放可选字段。可选字段意味着决策内部有分支处理“字段缺失”的情况这会让测试矩阵膨胀。如果某个字段确实可能没有那就给它一个明确的默认值在契约层面就定死而不是让决策内部去猜。3.2 决策结果的表达方式决策的输出不应该只是一个布尔值或者一个分支名。我推荐用一个结构化的结果对象至少包含三部分decision决策结论、reason决策理由、metadata附加信息。decision是流程真正用来分支的字段比如retry | escalate | continue。reason是给人看的用于日志和排查比如retry_limit_not_reached。metadata放一些决策过程中算出来的中间值方便流程后续使用比如算出来的重试间隔。为什么要带reason因为 Agent 编排的调试成本很高出了问题你往往只能看日志。如果决策只输出一个分支名你根本不知道为什么走了这条分支。带上 reason 之后日志里一眼就能看出决策依据。metadata的价值在于避免重复计算。如果决策过程中算了一个值流程后续还要用那就通过 metadata 传出去而不是让流程再算一遍。这也符合纯函数的理念——决策只算一次结果显式传递。3.3 决策的组合与嵌套单个决策往往不够用真实场景需要组合。Jev 支持决策之间的调用一个决策可以在内部调用另一个决策把它的结果作为自己判断的一部分。组合时要注意两点。第一是避免循环依赖A 决策调 BB 决策又调 A这会死循环。我的做法是在定义阶段就做依赖检查把决策之间的调用关系建成有向图检测环。第二是控制嵌套深度一般不超过三层再深就应该考虑拆成流程层的编排而不是决策内部的嵌套。组合的常见模式有两种串行组合和并行组合。串行组合是前一个决策的输出作为后一个决策的输入的一部分适合有先后依赖的判断。并行组合是多个决策各自独立判断最后汇总适合多维度评估。Jev 对这两种模式都有支持具体用哪种取决于你的判断逻辑是否可并行。3.4 版本管理与变更影响分析决策一旦被多个流程复用变更就成了敏感操作。你改一个决策的判断条件可能影响所有引用它的流程。所以决策必须有版本流程引用决策时要指定版本。我的实践是决策的每次语义变更都升版本号流程引用时锁定版本。这样改决策不会直接影响线上流程流程可以选择在合适的时候升级到新版本。同时版本管理也让回滚变得简单——出问题了就切回旧版本。变更影响分析靠的是引用关系记录。每个流程引用决策时这个引用关系被记录下来。改决策之前先查一下哪些流程引用了它评估影响范围。这个能力是硬编码 if-else 给不了的也是引入 Jev 这一层的核心收益之一。4. 实操过程从零定义一个可测试的决策4.1 环境准备与依赖确认开始之前确认你的运行环境满足基本要求。Jev 本身是一个轻量的决策编排库对运行时没有特殊要求主流的脚本运行时都能跑。我这边用的是常见的服务端脚本环境版本不要太旧就行。依赖方面Jev 核心包是必须的另外建议加上测试框架和断言库。测试框架用你团队熟悉的就行断言库我习惯用内置的 assert够用且没有额外依赖。如果你要做决策的依赖图检查可能还需要一个图算法库但这个可以自己写不复杂。目录结构我建议这样组织决策定义放一个目录测试放一个目录流程编排放一个目录。决策定义目录下每个决策一个文件文件名就是决策名方便查找。测试目录下按决策名组织测试文件一一对应。4.2 定义第一个决策判断是否需要重试我们从一个最简单的决策开始判断一个失败的操作是否需要重试。这个决策的输入契约包含三个字段尝试次数、最大尝试次数、错误类型。// decisions/shouldRetry.js const shouldRetry { name: shouldRetry, version: 1.0.0, input: { attemptCount: number, maxAttempts: number, errorType: string }, evaluate: (input) { const { attemptCount, maxAttempts, errorType } input; if (attemptCount maxAttempts) { return { decision: stop, reason: max_attempts_reached, metadata: { remaining: 0 } }; } if (errorType fatal) { return { decision: stop, reason: fatal_error, metadata: { remaining: maxAttempts - attemptCount } }; } return { decision: retry, reason: retryable_error, metadata: { remaining: maxAttempts - attemptCount } }; } }; module.exports shouldRetry;这个决策是纯函数输入输出都是普通对象没有任何外部依赖。evaluate函数里只做判断不做 IO不读全局状态。这就是可测试的基础。4.3 为决策写测试用例测试这个决策我们覆盖几个关键场景正常重试、达到上限、致命错误。// tests/shouldRetry.test.js const assert require(assert); const shouldRetry require(../decisions/shouldRetry); function runTest(name, input, expectedDecision, expectedReason) { const result shouldRetry.evaluate(input); assert.strictEqual(result.decision, expectedDecision, ${name}: decision mismatch); assert.strictEqual(result.reason, expectedReason, ${name}: reason mismatch); console.log(PASS: ${name}); } runTest(正常重试, { attemptCount: 1, maxAttempts: 3, errorType: transient }, retry, retryable_error); runTest(达到上限, { attemptCount: 3, maxAttempts: 3, errorType: transient }, stop, max_attempts_reached); runTest(致命错误, { attemptCount: 1, maxAttempts: 3, errorType: fatal }, stop, fatal_error); console.log(All tests passed);注意测试里没有启动任何流程没有 mock 任何服务就是构造输入、调用、断言。这就是决策与流程分家带来的测试便利。整个测试跑下来是毫秒级的可以放在每次提交前跑。4.4 在流程中调用决策决策定义好、测试通过之后在流程里调用它。流程负责准备输入、执行决策、根据决策结果分支。// flows/orderProcessing.js const shouldRetry require(../decisions/shouldRetry); async function processOrder(order, context) { let attemptCount 0; const maxAttempts 3; while (true) { try { const result await callExternalService(order); return { status: success, data: result }; } catch (error) { attemptCount; const decisionResult shouldRetry.evaluate({ attemptCount, maxAttempts, errorType: classifyError(error) }); logDecision(shouldRetry, decisionResult); if (decisionResult.decision stop) { return { status: failed, reason: decisionResult.reason, attempts: attemptCount }; } await sleep(calculateBackoff(attemptCount)); } } }流程层负责了所有有副作用的操作调用外部服务、记录日志、等待。决策层只负责判断。这个边界清晰之后改判断逻辑只动决策文件改流程结构只动流程文件互不干扰。4.5 决策的复用同一个决策服务多个流程现在假设有另一个流程也需要重试判断比如支付处理。它只需要提供同样的输入契约就能复用同一个决策。// flows/paymentProcessing.js const shouldRetry require(../decisions/shouldRetry); async function processPayment(payment, context) { let attemptCount 0; const maxAttempts 5; while (true) { try { const result await chargePayment(payment); return { status: success, data: result }; } catch (error) { attemptCount; const decisionResult shouldRetry.evaluate({ attemptCount, maxAttempts, errorType: classifyPaymentError(error) }); if (decisionResult.decision stop) { return { status: failed, reason: decisionResult.reason }; } await sleep(calculateBackoff(attemptCount)); } } }注意支付流程的 maxAttempts 是 5订单流程是 3但决策逻辑完全一样。这就是复用的价值——决策逻辑只写一遍参数由流程提供。如果重试策略要调整比如增加一种新的可重试错误类型只需要改决策文件两个流程同时生效。5. 常见问题与排查技巧实录5.1 决策测试通过但流程行为不对这是最常见的问题。决策的单元测试全绿但流程跑起来行为不符合预期。原因通常是流程准备输入时出了问题比如字段名写错、类型不对、值算错了。排查方法是在流程调用决策之前把输入对象打出来看一眼。我习惯在决策调用处加一行日志记录输入和输出。这样出问题时对比决策测试用例里的输入和实际流程传入的输入差异一目了然。另一个可能的原因是流程对决策结果的处理有误。比如决策返回了retry但流程的分支判断写成了retry true永远不成立。这种问题靠决策测试是发现不了的需要在流程层也加断言或者日志。5.2 决策之间出现循环依赖当决策开始互相调用时循环依赖就可能出现。A 调 BB 调 CC 又调 A运行时直接栈溢出。预防方法是在定义阶段做依赖检查。我写了一个简单的脚本扫描所有决策文件提取它们调用了哪些其他决策构建有向图然后做环检测。这个脚本放在 CI 里每次提交都跑有环就报错。如果已经出现了环拆解的方法是引入一个中间决策把公共逻辑抽出来。比如 A 和 C 都依赖 B 的某个判断那就把那个判断单独做成 DA 和 C 都调 D而不是互相调。5.3 决策版本升级导致流程行为变化流程锁定了决策的某个版本但决策升级后流程没跟着升级导致预期行为不一致。或者反过来流程升级了决策版本但没做回归测试线上出问题。我的做法是决策版本升级必须附带变更说明写清楚改了什么、影响哪些输入输出。流程升级决策版本时必须跑一遍该流程的集成测试。如果流程没有集成测试那就至少手动验证几个关键场景。另外决策的版本号要遵循语义化版本规范。改了判断逻辑导致输出可能变化升主版本号只是修了 bug 不影响正常输入升补丁版本号。这样流程维护者看到版本号变化就知道影响程度。5.4 决策输入契约字段过多一开始设计决策时很容易把输入契约设计得很大什么字段都往里塞。结果决策变得臃肿测试用例爆炸复用性反而下降。我的经验是如果一个决策的输入字段超过 5 个就应该考虑拆分了。拆分的依据是判断逻辑的内聚性——如果几个字段只影响某一个子判断那就把那个子判断拆成独立决策。比如一个“判断是否升级人工”的决策输入有置信度、用户情绪、问题复杂度、历史交互次数、用户等级、当前排队人数。这六个字段其实可以拆成两个决策一个判断“问题本身是否需要人工”输入置信度和复杂度另一个判断“当前是否适合升级”输入排队人数和用户等级。两个决策各自测试组合使用。5.5 常见问题速查表问题现象可能原因排查方法解决方式决策测试通过但流程不对流程输入准备错误打印决策调用处的输入输出对比测试用例输入修正流程运行时栈溢出决策循环依赖检查决策调用链抽取公共逻辑打破环升级决策后流程异常版本不兼容查看决策变更说明回滚版本或升级流程决策测试用例太多输入契约过大统计输入字段数量拆分决策收窄契约决策复用困难依赖流程特有字段检查输入字段命名改为面向决策语义的字段5.6 几个踩过的坑第一个坑是决策里做了日志记录。一开始觉得在决策里打日志方便排查后来发现这破坏了纯函数性质测试时日志乱输出而且日志本身是副作用。后来把日志移到流程层决策只返回 reason流程根据 reason 打日志。第二个坑是决策返回了可变对象。决策返回的 metadata 对象被流程修改了导致同一个决策结果在不同地方看到的值不一样。后来规定决策返回的对象必须冻结流程要用就自己拷贝一份。第三个坑是决策的输入契约用了流程的 state 类型。流程的 state 是个复杂对象决策输入契约直接引用了它的类型定义。后来流程改了 state 结构决策的测试全挂了。教训是决策的输入契约要独立定义不要依赖流程的类型。6. 决策治理让复用和测试可持续6.1 决策注册表与发现机制当决策数量多起来之后你需要一个地方能查到所有决策有哪些决策、各自什么版本、输入契约是什么、被哪些流程引用。这就是决策注册表。我的做法是维护一个 JSON 文件记录每个决策的元信息。这个文件可以自动生成扫描决策目录提取信息。流程引用决策时从这个注册表里查而不是硬编码路径。这样决策改名或移动位置时只需要改注册表流程不用动。注册表还有一个用途是文档生成。根据注册表自动生成决策文档列出每个决策的输入输出和示例方便新成员快速了解有哪些可复用的决策。6.2 决策的集成测试策略单元测试保证决策本身正确集成测试保证决策在流程中正确。集成测试不需要覆盖所有流程但至少要覆盖每个决策被使用的典型场景。我的策略是每个决策至少有一个集成测试用例构造一个最小流程调用这个决策验证流程行为符合预期。这个最小流程可以复用只是替换不同的决策和输入。集成测试跑得比单元测试慢所以放在单独的测试套件里提交前跑单元测试合并前跑集成测试。这样既保证了质量又不至于让开发过程太慢。6.3 决策变更的灰度与回滚决策变更上线的风险比普通代码变更高因为它影响多个流程。所以决策变更需要灰度。灰度的做法是新版本决策先只让一个非关键流程使用观察一段时间没问题再逐步扩大范围。Jev 的版本机制天然支持这个——流程引用决策时指定版本改版本号就切换了。回滚就是切回旧版本号。因为决策是纯函数回滚不涉及数据迁移切回去立即生效。这也是决策与流程分家的好处之一——决策的回滚成本极低。6.4 决策性能的考量决策是纯函数通常很快但也不是没有性能问题。如果决策内部做了复杂计算或者决策嵌套很深累积起来也可能成为瓶颈。我的做法是对每个决策做基准测试记录它的平均执行时间。如果某个决策超过 1 毫秒就看看能不能优化。优化的方向通常是减少不必要的计算或者把可以预计算的部分移到流程层。另一个性能考量是决策的调用频率。如果某个决策在循环里被调用成千上万次那它的性能就关键了。这种情况下可以考虑把决策结果缓存起来同样的输入直接返回缓存结果。但缓存要小心确保输入不变时输出确实不变否则会引入 bug。6.5 决策的可观测性决策在生产环境跑你需要知道它被调用了多少次、各分支的分布、有没有异常输入。这些可观测性数据靠流程层埋点。我在流程调用决策的地方统一加埋点记录决策名、版本、输入摘要、输出 decision、耗时。这些数据汇总起来能看到每个决策的使用情况和健康度。如果某个决策的某个分支从来没被走到可能是逻辑有问题如果某个决策的异常输入比例很高可能是上游数据质量有问题。可观测性数据也是决策优化的依据。比如发现某个决策的某个分支占比极高可以考虑把这个分支的逻辑前置减少不必要的判断。7. 从单决策到决策网络规模化的思考7.1 决策网络的拓扑结构当决策数量增长到几十个它们之间的调用关系就形成了一个网络。这个网络的拓扑结构直接影响可维护性。健康的决策网络应该是分层的有向无环图。底层是原子决策只依赖输入契约不调用其他决策。中层是组合决策调用底层决策组合出更复杂的判断。顶层是流程专用的决策针对特定流程的场景做定制。不健康的网络是扁平的、互相调用的、有环的。这种网络改一个决策影响一大片测试也难做。所以定期审视决策网络的拓扑把该分层的分层该拆解的拆解。7.2 决策的粒度控制决策的粒度太粗复用性差粒度太细组合成本高。找到合适的粒度是关键。我的经验法则是一个决策应该对应一个明确的判断问题这个问题可以用一句话描述清楚。如果一句话描述不清楚说明粒度太粗如果一句话描述的是一个很琐碎的判断可能粒度太细。比如“判断是否需要重试”是一个合适的粒度。“判断是否需要重试且重试间隔是多少”就太粗了应该拆成两个决策。“判断尝试次数是否小于最大值”又太细了它只是重试判断的一个子条件。7.3 决策的文档与示例决策要被复用文档必须到位。每个决策至少要有用途说明、输入契约、输出说明、示例输入输出、被哪些流程引用。文档最好和代码放在一起改代码时顺手改文档。我习惯在决策文件顶部写注释块包含这些信息。然后用脚本提取注释生成文档站点保证文档和代码同步。示例输入输出特别重要。新成员看决策时最想知道的是“我给它什么它返回什么”。给几个典型示例比长篇大论的说明管用得多。7.4 决策的废弃流程决策也会过时。某个决策可能因为业务变化不再需要或者被更好的决策替代。废弃决策需要流程。废弃流程分三步先标记为废弃文档里注明替代方案然后观察一段时间确认没有流程在引用最后删除。删除前要再查一遍引用关系确保没有遗漏。标记废弃到删除之间要有足够长的过渡期给流程维护者时间迁移。我一般设一个月紧急情况可以缩短但要有明确的迁移计划。8. 一些个人体会这套东西我用了大半年最大的感受是决策与流程分家之后改需求的心里负担小了很多。以前改一个判断条件要通读整个流程生怕漏了什么。现在改决策跑一遍决策测试再跑一遍引用它的流程的集成测试基本就放心了。另一个体会是决策的复用比想象中更容易发生。一开始觉得每个流程的判断逻辑都不一样后来发现其实很多判断是通用的只是之前耦合在流程里看不出来。抽出来之后复用是自然而然的事。测试方面决策的单元测试写起来很爽因为不用 mock 任何东西。构造输入、调用、断言三步搞定。这种测试写多了会反过来推动你把决策设计得更纯粹——因为不纯粹的决策测试写起来很麻烦。最后分享一个小技巧给决策的输入契约加一个校验函数在开发环境下每次调用决策时校验输入是否符合契约。这样能在早期发现输入错误而不是等到决策内部报错才发现。校验函数可以自动从契约生成不用手写。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑