☰
Agent编排中可复用可测试决策的设计与治理实践
2026/10/20 22:02:03 网站建设 项目流程

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 决策调 B,B 决策又调 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 调 B,B 调 C,C 又调 A,运行时直接栈溢出。

预防方法是在定义阶段做依赖检查。我写了一个简单的脚本,扫描所有决策文件,提取它们调用了哪些其他决策,构建有向图,然后做环检测。这个脚本放在 CI 里,每次提交都跑,有环就报错。

如果已经出现了环,拆解的方法是引入一个中间决策,把公共逻辑抽出来。比如 A 和 C 都依赖 B 的某个判断,那就把那个判断单独做成 D,A 和 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 任何东西。构造输入、调用、断言,三步搞定。这种测试写多了,会反过来推动你把决策设计得更纯粹——因为不纯粹的决策测试写起来很麻烦。

最后分享一个小技巧:给决策的输入契约加一个校验函数,在开发环境下每次调用决策时校验输入是否符合契约。这样能在早期发现输入错误,而不是等到决策内部报错才发现。校验函数可以自动从契约生成,不用手写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询