chain 型技能 / 多步链(runner.py 口径)
什么时候读:要把几个技能串成一条链(chain 型技能、工作流「执行技能」、定时任务 steps), 或者要写一个会被放进链里的技能。来源:
backend/core/chain/runner.py。
能做什么、不能做什么
| 做 | 不做(该用工作流) |
|---|---|
| 顺序执行、同层并行、字段引用传值、单步失败策略 | 暂停、等人回话、等外部回调、实例持久化、断点续跑、条件分支、循环 |
判据:全生命周期不超过一次工具调用墙钟(runner.py 核对 2026-10):
- 链墙钟
CHAIN_WALL_SEC= 环境变量,未设时max(30, TOOL_WALL_SEC − 30);runner 里TOOL_WALL_SEC未设时按 240 算, 而对话线roles_chat_helpers未设时按 1800 算——两边默认值不一致,不设环境变量时链默认 210 秒。 - 单步墙钟
CHAIN_STEP_WALL_SEC默认等于链墙钟,作为 policy 传给 pipeline;技能自声明的wall_sec优先,但整链 210 秒照样截断。 - 要让链能跑更久,部署时显式设置
TOOL_WALL_SEC(两处同读一个变量),或单独设CHAIN_WALL_SEC。
三处编排(chain 型技能 / 工作流 automate / _tool_schedule_task steps)共用这一套实现和引用语法,技能在链里和在对话里走同一条 pipeline.execute_skill,行为一致。
步骤定义
{"skill_name": "datatable", "alias": "q", "params": {...},
"on_fail": "abort", "default_value": null}
| 字段 | 规则 |
|---|---|
alias | 可空,缺省 step{N}。只能用字母、数字、下划线、中文(^[A-Za-z0-9_\u4e00-\u9fa5]+$)。经 /skills/chain/save 保存时别名重复直接拒绝;runner 对重复别名的自动改名(alias_2)只兜底手工配置 |
on_fail | abort(默认,整链终止,同层并发一起收掉)/ skip(results 里没有该 alias,下游引用解析为缺失→该参数不传)/ continue(results 里放 default_value) |
default_value | 仅 continue 用。字符串会尝试 json.loads;不是 dict/list 时包成 {"success": false, "error": ..., "value": dv} |
skill_name 为空 | 按失败处理,走 on_fail |
保存校验(/skills/chain/save → skills._validate,与前端 ChainSkillsView.validate() 同规则)
| 规则 | 不过时 |
|---|---|
链名 ^[a-z][a-z0-9_]*$(不允许连字符,与普通技能名不同);不能与本角色可见的已安装技能同名 | 拒绝保存 |
至少一个步骤;description(「什么时候用」)非空;每步选了 skill_name | 拒绝 |
参数或 natural_desc 里的每个 {入参} 必须在链级 input_schema.properties 里声明;声明了却没被任何步骤引用的入参也拒绝 | 拒绝 |
$steps.别名 必须存在且排在当前步之前 | 拒绝(所以经 API 保存的链不会有环) |
$steps.别名.顶层字段:被引用技能声明了 output_schema.properties 时,顶层字段名必须在其中 | 拒绝,并列出可选字段 |
入参名撞保留字段(compat.is_reserved_ctx_key:org_id emp_no db session_id …) | 拒绝 |
| 步骤是另一条链 | 拒绝(链不能套链) |
cost_tier ∈ zero low medium high | 422 |
保存成功即注册为 skill_type="chain"、trigger=explicit 的技能,模型看到的与普通技能无异;enabled=false 等于对模型隐藏。
/skills/chain/test 真执行(会写库发通知),先校验链级 required 入参,可指定 emp_no「以谁的身份跑」。
引用语法(与前端 utils/stepRefs.js 对齐)
{字段名} 取查找面:链级入参 / 工作流上下文 / 触发字段
$steps.别名.字段 取任意前序步骤的具名输出;可多级 $steps.q.data.0.name(数组下标写数字)
$steps.别名 该步骤整体输出
$ctx.字段 同 {字段名}
$prev.字段 旧写法,只兼容老配置,勿新增
任意 token 后可跟 ?? 默认值:$steps.a.x ?? 0 (支持 数字 / true / false / null / 引号字符串)
- 整串就是一个 token → 返回原始类型;token 混在文本里 → 字符串插值(dict/list 序列化成 JSON,None 成空串)。
- 引用取不到且无默认值 → 该参数整体不传,让技能自己的默认值生效。不会把
<MISSING>当字符串塞进去。 - 别名和字段允许中文;不能含
.、空白、{}、$、引号。 - datatable 一侧的对应约束:filters 值里残留未解析的
{xxx}/$steps.时拒绝执行(见 datatable.md §5)。所以写链时引用名必须打对,否则不是「传了个怪字符串」而是直接拒绝。
依赖与并行
按 $steps. 引用图分层:同层互不依赖可并行,层间串行。链内并行度 MAX_CONCURRENT_CHAIN_STEPS(默认 3),独立于外层工具并发。
有环或引用了不存在的别名 → 剩余步骤降级为顺序执行,不抛异常,跑到那步再按 on_fail 处理。配置错误不会让链在启动时炸掉——保存接口已按上节拦住这类配置,只有绕过接口手改库的链才会走到这条降级。
两份 ctx,不要合并
| 内容 | 来源 | |
|---|---|---|
run_ctx | 传给技能的身份 / 连接 | 以调用方为准 |
lookup_ctx | {字段名} 的查找面 | run_ctx 铺平 + 外部入参(模型填的 initial) |
外部入参只进查找面,不进运行上下文;保留字段(RESERVED_CTX_KEYS,定义在 backend/core/skills/compat.py)会被过滤。这是为了让模型不能通过链入参覆盖 org_id / emp_no / db 等。
写「会被放进链里的技能」要注意
- 固定上下文:runner 2026-10 修复前只把
actor_*传给步骤,raw_input/channel/triggered_at*/attachments在链里都是空值; 修复后整套传。依赖这些字段的技能在未打补丁的部署里要能处理空值。 team_role:链 ctx 里有就沿用(对话线调起的链继承对话的值);没有就由 pipeline 按user_id解析(2026-10 补丁),解析不出按 member。
-
在
schema.json里声明x-output-schema({"type":"object","properties":{…}})。链编辑器按它校验$steps.别名.字段的顶层字段名、给出可选字段提示;一旦声明就要写全,漏写的顶层字段会让引用它的链保存失败。 -
chat_mode在链里也是 True(见 datatable.md §8)。删除命中 >10 行会等confirm_token,链里没人能回,该步必然超时。链内删除控制在 ≤10 行。 -
request为 None,链不起子 agent。依赖request的技能在链里直接失败。 -
链内各步共用同一个
session_id(工作区按 role_id/session_id 定位),第 1 步的产物第 2 步能找到。技能写产物仍然只写会话工作区(_workspace(ctx):ctx 里没有workspace_dir时按$AGENT_WORKSPACE_DIR/<role_id>/<session_id>推,各步推出同一目录)。 -
链按
r.ok判成功,即技能返回的success/ok。缺省不算失败——所以技能失败必须显式success: False,否则链把它当成功往下传。 -
单步策略以
SkillRunPolicy(CHAIN_POLICY)为准,只覆盖墙钟。要改链内行为(例如chat_mode)在 policy 上改,不在技能里绕。 -
空结果护栏(规范 §9.3):上一步产出空则中止——靠
on_fail+ 引用缺失→参数不传实现,不是 runner 内建的判断。需要「空即停」时让下一步技能对缺参返回need_param,并把该步on_fail=abort。
返回形状
{"steps": {alias: output}, "last": output, "failed": [alias...],
"ok": bool, "error": str, "duration_ms": int}
ok = 无失败且未 abort;last 是最后一个有结果的步骤的输出。