系列《VeapAI 自然语言操作实战》第 4 篇基础篇 相关源码veap-ui/src/components/AiAgentEngine/调度、状态与写回、veap-ui/src/views/ai/aiAgentProcessInstance/实例与节点执行记录页、veap-cloud/DB/veap.sqlai_agent_ 表族与索引## 一、运维同事问的第一个问题链路刚跑通那天运维同事过来看了一眼问了两句话。第一句「一次命令要跑多久」第二句「跑错了怎么看」这两句话其实是同一个问题。一套编排引擎如果只做到「能跑」运维会在第一次故障时把它停掉——不是因为慢而是因为看不懂。所以我们在设计这套链路时把「快」和「查得到」当成了一件事的两个面让写入变少、让调度变准是为了快把每一次状态变化都留痕、把每个中间产物都暴露出来是为了出问题时不用猜。下面分开说这两条线。## 二、性能线五个机制没有一个靠调参![性能保障机制并发、批量、缓存、上限]### 2.1 并发来自调度规则不是并发编程引擎的调度很朴素每一轮找出所有「依赖已满足且尚未执行」的节点一起派发。没有线程池配置没有并发度参数。你画出什么样的图它就按图跑。没有依赖关系的链天然并行有依赖关系的自然串行。这条链路里真正需要并发的只有一处字段清单和接口清单。它们都要目标表编码互相不依赖于是同时启动。省下的是两三次接口往返的时间——不算多但关键路径上的每一次往返都值得省。更值得说的是**离线预构建**省下的时间表目录、表关系、字典翻译三类资源由离线任务产出运行期直接从上下文资源读不再请求。这三份数据是整条链路上体量最大的输入也是最不变的输入。### 2.2 写回改成批量之后请求数掉了一个量级这是改动效果最直接的一处。一个流程跑下来节点实例的写入量是节点数的量级。第一版是逐个写一次运行几十次请求。后来动了三个地方。**初始化改成批量写。** 节点实例创建改成一次请求批量新增SQL 里固定 14 列、多行 VALUES。主键由服务端预生成返回的主键数组与请求项顺序严格对齐——前端会校验数量与数组形状对不上就抛错不允许出现「主键错位」这种静默污染。**级联跳过改成批量写回。** 分支没走到时下游节点需要标记为「跳过」。原来是逐个更新现在把待写项先收集起来最后发一次批量请求。这个接口的事务语义写得很明确逐条检查影响行数有一条是 0 就抛错并整体回滚。跳过标记不能出现「一半成功一半失败」。**成功路径的两处写回并行发出。** 节点执行成功后要写两处会话变量与节点终态。这两处互不依赖于是并行发出用统一的结果聚合等它们都结束只要有一个失败就抛错不做「部分成功」的妥协。顺带说一句失败语义的取舍批量写回失败会打日志但**不阻断跳过语义**。理由很直白——跳过标记写失败是审计层面的问题不该让已经结束的运行又变成一次失败。这条边界我们讨论过两轮才定下来。### 2.3 缓存三层映射加两级结果知识主题检索这条路上有五级缓存各管一段| 缓存 | 缓存什么 | 说明 || --- | --- | --- || 主题映射 | 资源键到主题 id | 判断挂载的资源能不能解析到主题 || 集合映射 | 主题到向量集合与契约字段 | 检索前的目标解析 || 缓存水位 | 缓存构建标记与耗时 | 只增不减用来判断缓存是否可用 || 检索结果 | 主题 问题 参数 的结果 | 有效期 10 分钟 || 查询向量 | 嵌入模型 内容 的向量 | 有效期 24 小时 |缓存的构建有唯一写入点启动时预热失败即启动失败不留半可用状态、提供手动刷新入口、知识主题同步成功时联动刷新。缓存水位那条还带来一个自愈能力如果读到的水位损坏报错文案会直接指向「刷新缓存」这个动作而不是抛一个看不懂的序列化异常。这是第二章第三节那个坑之后加的。### 2.4 硬顶上限写死在代码里不留给配置有几个上限是硬编码的不给配置项- 查询结果行数硬顶 200 行。模型输出多少都无所谓脚本会把超出的部分削掉- 注入模型用于呈现的数据不超过 50 行超出就截断并在正文里注明「结果已截断共 N 行」- 数据库执行节点单次 SQL 不超过 20 条、查询不超过 5000 行、连接超时 10 秒。为什么不让配因为这些上限保护的是系统本身一次命令把十万行拉进内存或者让模型去读一万行结果都会把整条链路拖垮。使用者需要更多数据时应该用复杂查询或分组统计而不是把列表接口的页大小调大。### 2.5 幂等不该重算的绝不重算向量化那条线上判断「要不要重算」用的是内容 hash 加嵌入模型 ID。内容没变、模型没换就跳过换任意一个就重算。这个设计直接决定了批量重跑的成本。一份资料改了一句话只有那一块重算换个嵌入模型全部重算——因为向量空间变了旧向量不能混用。## 三、可观测线状态、审计、时间线![运行状态机与审计四态实例态、节点态与审计写入点]### 3.1 状态集合收敛到唯一来源编排引擎有两级状态实例状态八种节点状态九种。text实例状态init | running | waiting | paused | debugging | finished | terminated | error节点状态pending | running | waiting | success | failed | skipped | terminated | error | canceled这些状态在代码里只有一个来源文件任何模块都不允许自己写一套状态字面量。听起来是洁癖实际上是踩过坑之后的规矩一旦有两处判断「什么算终态」就会出现「调度器认为结束了、界面认为还在跑」这类灵异现象。实例表上除了状态还留了几个统计字段耗时、token 用量、执行步数、异常次数。它们让「这次运行慢在哪」有了第一手数据不用去翻日志。### 3.2 审计四态每次运行的四个记录点审计只有四个事件开始、结束、终止、出错。写法上有个设计我很喜欢审计写入只有一个入口调用点也数得清——创建实例时写开始进入终态时写结束调度死锁或超限时写出错人工终止时写终止。而且审计是**审计边界**写失败会打错误日志然后把原错误继续往上抛绝不吞掉。这也是全仓库统一的原则——允许用异常捕获的地方只有那么几处且不允许把异常变成静默。### 3.3 死锁诊断三是个经验值引擎有一个自我诊断连续三轮都没有任何节点可调度但仍有节点处于非终态就判定为死锁把实例置为错误并写审计附上全量快照。「连续三轮」这个数字是拍出来的但拍得有依据。一轮调度是同步的、很短的偶尔某轮没有可调度节点是正常的比如某些节点正在等接口返回。连续三轮都没动静基本可以排除正常等待。阈值不能再小了容易误判也没必要更大多等几轮只是白白拖慢失败反馈。诊断信息里带全量快照是因为死锁的根因几乎总在拓扑上而不是在某个节点的代码里。前面提过的那次汇聚死锁就是靠这个快照定位的四个节点的依赖状态列出来一眼能看到「前驱是跳过、而自己在等成功」。### 3.4 执行记录弹窗一个查问题的地方排查问题时真正会用的界面是执行记录弹窗。它分成两半左边是实例树右边是选中节点的执行详情。左边这棵树支持父子展开——循环结构会产生子实例子实例挂在父实例下面。搜索框做了防抖也做了竞态保护连续输入时只有最后一次请求的结果会写进界面先返回的旧响应会被丢弃。这个细节不做的话快速输入时列表会闪回旧数据很难受。右边用列表展示这个节点实例执行了哪些节点默认显示十行行高固定不出现半行截断。每一行能展开输入数据、过程数据、输出数据。![执行记录弹窗左侧实例树右侧节点执行列表]因为时间戳只精确到秒同一秒内可能有多条记录所以排序不能只看时间。我们把排序做成稳定的组合排序顶层按创建时间倒序加主键倒序子级按主键正序。这样翻页不会出现「同一条记录看了两遍」或者「中间漏了一条」。### 3.5 运维动作与删除语义运行中的实例可以被暂停、继续、终止。这三个动作各有唯一实现不做重复入口。删除这件事我们改过一次。第一版是软删除结果运行记录越积越多还出现了「删了流程但实例还在」的状态。后来的做法是明确的终态拦截加级联只有处于终态的实例能被删除删除时按流程实例主键级联清理节点实例、会话变量、审计日志等关联数据全部在一个事务里完成。另外还有一个更彻底的动作叫「全链路清除」按流程定义主键把实例、节点实例、审计日志、会话变量、节点扩展、测试案例一次性清掉。它的语义是「无条件物理删除」只保留主键合法性与流程存在性这两个基础校验。这个按钮权限收紧也做了二次确认因为它不可撤销。### 3.6 测试案例把回归这件事变便宜有一张子表专门放测试案例每条案例是一份启动参数。这个设计的价值在我们改链路时体现出来了。链路里节点多改动一处很容易影响另一条分支。每次改完把十几条案例批量跑一遍比人工点十几次快得多。批量运行的实现值得记一笔多个案例串行执行每个案例的判定按「首因生效」——前置校验失败、节点失败、用户取消、被终止或死锁哪一种先发生就按哪种记结果。如果某个案例被人工终止整批停止剩下的标记为未执行。运行时也不允许关闭弹窗避免「关掉界面以为停了、其实还在跑」。## 四、四个值得较真的判断**跳过标记写失败要不要让运行变失败** 我们最后的答案是不要。跳过是审计信息写失败影响的是记录的完整性不影响这次运行是否成功。但写失败必须打错误日志并保留原错误不能静默。**为什么审计不允许吞异常** 因为审计是排查的最后一根线。如果审计自己出错还被吞掉出问题时会变成「什么都没有」比报错更糟。全仓库统一口径允许用 try/catch 的地方只有审计边界等少数几处且必须重新抛出。**为什么终止一定要落到终态** 因为终止后的实例如果停在「运行中」会挡住后续的操作比如删除、重新运行也会让统计失真。终止动作的语义就是把实例推到终态并把下游未执行节点级联标记为未运行。**为什么缓存水位要以文本口径读写** 这个问题前面章节讲过了这里只留结论跨进程缓存的读写写入端显式转字符串读取端按纯数字文本解析不依赖运行时类型判断。类型推断在跨语言、跨序列化器的场景里靠不住。## 五、这套东西怎么验证想确认性能和可观测都到位可以做四件事一是跑一条查询命令看运行时间线。正常情况应该能看到两个取数节点同时亮起字段清单与接口清单然后依次往下走。![运行时间线两个取数节点同时亮起然后依次往下走]二是打开执行记录弹窗展开一个节点实例看输入、过程、输出三段数据是否齐全耗时字段有没有值。三是跑一条会被拦下的命令比如查询一个不存在的字段看审计里有没有写出错记录错误信息能不能直接读出要修哪一步。四是连着跑一批测试案例看每个案例的判定是否稳定失败案例的错误信息是否指向真实的失败节点。![测试案例批量运行逐案判定首因生效]四件事都通过说明这套链路不只是「能跑」而是「跑完能看懂」。这个系列写到倒数第二篇了。最后一篇不谈新功能只谈边界哪些事这套设计现在做不到以及语音这条路上真正缺的是什么。