多个 Skill 并发执行时,如何避免上下文数据竞争或不一致?
面试官:“多个 Skill 并发执行时,上下文是共享的,你怎么避免数据竞争或不一致?”
候选人:
“这个问题在并行编排时不可避免。核心原则是:Skill 对上下文的读写必须经过编排引擎这个唯一仲裁者,通过命名空间隔离、乐观锁版本控制和不可变快照来杜绝竞争。 我分四层机制来设计。
机制一:命名空间隔离 —— 从源头防止写冲突
最根本的办法是让并发 Skill 不写同一个键。上下文的 skill_outputs 区域按 Skill 实例划分独立命名空间:
skill_outputs
├── weather_query
│ └── current_temp: 26
├── flight_search
│ └── candidates: [...]
└── hotel_search
└── candidates: [...]
每个 Skill 只能写入自己命名空间下的键,这个限制在元信息声明时就定死了。编排引擎在注入上下文时,给每个 Skill 的是一个写隔离的视图——它看到的是全量数据,但写操作只会落到自己的分区。
这样,天气查询、航班搜索、酒店搜索三个 Skill 就算完全并发执行,各自写各自的地盘,物理上不可能发生写冲突。
机制二:快照读 —— 避免读到脏中间态
写隔离解决了写冲突,但读一致性的问题还在。比如航班搜索正在往上下文里写 candidates,酒店搜索同时想读 flight_search.candidates 来决定酒店位置,它可能读到写了一半的不完整数据。
解决方式是快照读取:当编排引擎启动并发执行时,先给上下文拍一个逻辑快照——不是真的拷贝所有数据,而是记录当前上下文的一个版本号。所有并发 Skill 看到的都是这个快照版本,它们读到的数据保证是并发启动那一刻的一致状态,不会被其他 Skill 写乱。
实现上,上下文内部维护一个单调递增的 version 字段。每次写操作后版本号递增。并发 Skill 读取时,带上启动时的版本号,引擎返回的是该版本下的数据视图。读已提交,但不读未提交。
机制三:乐观锁 + 自动合并 —— 处理必需共享的变量
少数情况下,并发 Skill 确实需要更新同一个共享变量,比如一个全局的“总费用”字段。这时命名空间隔离不够用,就得引入乐观锁。
上下文中每个可共享的键都带一个版本戳。Skill 读到一个值的同时也拿到它的版本号。当它尝试写回时,必须带上“我基于版本 X 修改”,编排引擎做 CAS 检查:如果当前版本和 X 一致,写入成功,版本加 1;如果不一致,说明被其他 Skill 抢先改了,此时有三种策略:
-
自动合并:如果修改的是数值型(如总费用),编排引擎自动做增量加法重试,不需要 Skill 感知。
-
冲突上报:如果是复杂结构,引擎返回冲突错误,让编排器决定是否重试或降级。
-
最后写入者胜:某些非关键数据直接覆盖,如缓存信息。
大多数情况下,命名空间隔离 + 快照读已经消灭了 95% 的竞争。乐观锁只给那 5% 无法拆分的场景兜底。
机制四:编排引擎的锁调度 —— 遇到不可并行的依赖时自动串行
如果编排器在构建 DAG 时检测到两个 Skill 声明了对同一个共享键的写入,并且没有声明合并策略,编排器会认为这是潜在冲突。它会在 DAG 中自动插入一个隐式依赖边,让这两个 Skill 串行执行而非并行。
这不是运行时检测,而是编译期 DAG 分析。编排引擎加载 Skill 元信息时就知道每个 Skill 输入什么键、输出什么键,在生成 DAG 时就提前规避了竞争。只有那些读写没有重叠的 Skill 才会被放入并行批次。