我们是如何实现 spur decision 的:把决策契约内建进 AI 代理工作流引擎

有一段时间,我们的内容流水线每天都在正常跑完。选题、大纲、成稿、发布,一步不缺,产出的文章也确实能看。结论是对的。

有一段时间,我们的内容流水线每天都在正常跑完。选题、大纲、成稿、发布,一步不缺,产出的文章也确实能看。结论是对的。

直到 2026-10-12 排查另一件事时,我们在日志里翻到这样一行:

```

Unknown decision "task-triage"

```

没有人注意它。因为下一行紧接着就是正常推进的步骤,再往后是"文章已发布"。这条 decide 行没有被解析、没有被执行、甚至没有被质疑——但流水线照常走完了全程,文章照常发出去了。

一个**结果正确、过程撒谎**的系统,比一个直接报错退出的系统危险多少倍?报错的系统会逼你在流水线里加一个 `if`;"过程撒谎"的系统只会让你在三个月后的复盘会上,重新读一遍自己写的默认值,然后困惑于"当时为什么选了 A"。

我们把这个系统叫 `spur decision`。它的核心主张只有一句:**决策必须先是一份数据,再是一件执行的事。** 顺序反了,静默就会成为默认态。

本文基于 `@gobing-ai/spur` 与 knowledge-kit 的真实实现,所有 CLI 行为、配置字段和退出码都来自实测输出。

第一幕:那次排查,和一个无法区分的中间态

先说症状。它比预想的轻。

`decisions` 的目录解析走三层:项目里的 `.spur/decisions`、`.spur/config.yaml` 里 `decisions.paths` 显式注册的路径,以及随包安装的共享目录 `config/decisions/`。排查时 `spur decision status --json` 报出来的是这样:

```json

{ "layers": { "project": 0, "registered": 6, "shared": 5 },

"loadErrors": [], "duplicateIds": [] }

```

数字看着很健康:`loadErrors` 空、`duplicateIds` 空。但真正出问题的那三条决策,一条都不在这 15 条里。

我们 `.spur/config.yaml` 的 `decisions.paths` 只写了项目内的目录,漏掉了随 spur 包安装的共享目录。于是共享的 `task-pipeline.yaml` 里的 `task-triage`、`failure-class`、`review-failure-class` 全部不可解析——报 `Unknown decision`,实际加载到的决策里只有 knowledge-kit 自己的八条 `kk-item-*`。

麻烦的地方在于:**这些失败没有让流水线停下。** 每个 `decide` 行都有自己的硬编码默认值,解析失败时流程回落到默认值,继续往下跑。设 `workflows.decideDecisionMaker` 当时是严格 opt-in(默认 `false`),所以输出结果甚至没有变——declared default 照样赢。变的是理由:那三行决策从来不是被算出来的,是被跳过去的。

```mermaid

flowchart LR

subgraph silent["静默回落(旧)"]

A1[decide task-triage] --> B1{目录里能找到?}

B1 -- 否 --> C1[硬编码默认值] --> D1[流程继续 · 理由是假的]

end

subgraph honest["显式失败(新)"]

A2[decide task-triage] --> B2{目录里能找到?}

B2 -- 是 --> E2[按契约执行]

B2 -- 否 --> F2[落到已声明的 fallback] --> G2[status / show 如实报告]

end

```

所以这不是一个 bug,是一类 bug:系统在"**没有决策**"和"**决策结果为 fallback**"之间制造了一个无法区分的中间态。修复方式不是加一个断言,而是把安装包的目录显式加进 `decisions.paths`,让 decide 行变得诚实。教训留在配置文件的注释里:

```yaml

The installed package's own catalogs are listed too ... omitting them left the

shared task-pipeline's `decide task-triage`, `decide failure-class` and

`decide review-failure-class` UNRESOLVABLE — every pipeline run then fell to each

decide's hard-coded default with no catalog row to read (observed 2026-10-12/13).

... the decide row and its reason become honest instead of a resolution failure,

and enabling the decision maker later will actually consult the catalog.

decisions:

paths:

- plugins/kk/decisions

- /Users/robin/node_modules/@gobing-ai/spur/config/decisions/

```

注释里还留着一个 TODO:等安装版的 spur 支持 `bundled:decisions` 前缀展开,就不用再写死本机绝对路径。我更愿意把这类已知限制写在配置里,而不是等它变成下一次事故。

第二幕:为什么"决策"必须先是一份数据

顺着事故往下问一步:既然失败可以静默,决策就必须能被独立校验。校验一份 YAML 的前提是它有 schema,于是每个目录文件的顶部第一行都是同一句话:

```yaml

$schema: "@gobing-ai/spur/schemas/decision-catalog.schema.json"

version: 1

defaults:

minConfidence: 0

decisions:

kk-itc-topic-choice:

type: choice

description: Choose the strongest topic candidate from the candidate evidence pool, or defer to operator

parameters:

candidates:

type: string

description: Formatted candidate options with title, rationale, item IDs and source URLs

criteria:

a: Candidate slot a is the strongest and most cohesive technical article topic.

b: Candidate slot b is the strongest and most cohesive technical article topic.

c: Candidate slot c is the strongest and most cohesive technical article topic.

defer: Candidates are insufficient, contradictory, or need explicit operator review.

fallback: defer

```

这条决策里最关键的是 `fallback: defer`:证据不足时的正确动作不是"随便挑一个",而是把问题退回给人。人机边界在这里被写成了数据——不是 prompt 里的一句嘱咐,是引擎读得到、`status` 报得出的一行 YAML。

上面三个字段值得单独说,因为它们就是事故里救命的三个:

- **`fallback`** —— 决定最坏情况落在哪。引擎必须能兑现它,所以它只能是一个**已声明的、有限的**候选集合,不能是运行时随便生成的东西。

- **`minConfidence`** —— 低于门槛就走 fallback。它可以是目录级默认,也可以逐条覆盖。上面这个目录里它是 `0`:模型给什么我们就记什么,观测值另行存储。阈值设成 0 不是懈怠,是显式选择了"这一层不拦"。留白是最坏的默认,`0` 是最好的。

- **`criteria`** —— 把"凭什么"写成可审的文字。`a`/`b`/`c`/`defer` 四条并列,模型和人都读同一份依据。最反直觉的是,散文在这里也是一等契约。

同一份契约还覆盖打分型决策。`plugins/kk/decisions/kk-items.yaml` 里八条 `kk-item-*` 全部是采集条目的分类与打分,例如:

```yaml

kk-item-quality:

type: score

description: How substantive is the item's body (length and sourcedness)?

criteria:

- Empty or unusable body — nothing to read.

- Headline echo — a stub body under 300 characters, source mention only.

- Around 1.5k characters — a short but substantive report.

- ...

fallback: 2

```

`type: score` 的 `criteria` 是从 0 到 5 的有序档位文本,`fallback` 是一个整数 `2`,中位偏保守。分类与打分共用同一份契约,`spur decision show` 用同一套接口读两者。改决策行为的代价从此是改 YAML,不是改引擎。

第三幕:怎么知道系统此刻是诚实的

事故的直接教训可以翻译成两个可观测点:每一层加载了几个目录、有没有加载错误、有没有重复 id。三件事必须在任意时刻可查,而不是等到某次复盘才去翻日志。这就是 `status` 存在的理由:

```bash

spur decision status --json

```

它除了三层目录计数,还报出 maker 侧的全局就绪状态:

```json

{ "loadErrors": [], "duplicateIds": [],

"registeredMakers": ["typesafe", "fm-local", "laya-local", "clef"],

"configDefaultMaker": "fm-local",

"configDecisionMakers": {

"task-triage": "clef",

"failure-class": "typesafe",

"review-failure-class": "fm-local",

"kk-item-quality": "laya-local"

} }

```

第一幕那个"看起来很健康"的 `layers` 计数,必须配上被服务的决策本身一起看才算数。所以 `status` 还会逐条列出每条决策的类型、生效 maker、来源和它所在的那一层。四个动词各自回答一个不同的问题:

| 动词 | 回答的问题 |

| --- | --- |

| `status` | 此刻系统诚实吗——三层齐不齐、错误和重复有没有 |

| `list` | 谁在服役——每个决策来自哪一层 |

| `show <id>` | 这条决策此刻由谁执行,凭什么 |

| `run <id>` | 真跑一次会得到什么 |

第四幕:谁来执行,是另一件事

契约管"决策什么",maker 管"谁来决策"。策略与机制分离,是我们能同时容纳本地模型和确定性规则的前提——否则每加一个新后端,就要往引擎里塞一个 `if`。

路由是两级的:`.spur/config.yaml` 里先给全局默认,再按决策 id 覆盖。

```yaml

decisions:

maker: fm-local

makers:

kk-item-category: fm-local

kk-item-quality: laya-local

kk-item-china-politics: typesafe

kk-item-sensitivity: typesafe

kk-item-audience: typesafe

```

已注册的 maker 目前有四个:`fm-local`(macOS 本地的 Apple Foundation Models,零 API 成本、数据不出机)、`laya-local`、`typesafe`(确定性规则,CI 友好)和 `clef`。本地模型负责需要判断的,确定性规则负责 CI 里不能抖的。`china-politics` / `sensitivity` / `audience` 这三条敏感判断不走 LLM,不是因为它们不重要,而是因为它们更需要每次跑出同一个答案。

可信度来自溯源。`spur decision show kk-item-quality --json` 的末尾是:

```json

"effectiveMaker": { "name": "laya-local", "source": "config-decision" }

```

`source` 会明确告诉你这是来自全局默认(`config-default`)还是逐条覆盖(`config-decision`)。溯源能力本身就是防事故手段——第一幕的事故如果发生在 maker 侧,第一时间就能看出"这条决策压根没人指定过 maker"。`show` 同时还报出契约所在文件的绝对路径:`layer: registered`、`catalog: .../plugins/kk/decisions/kk-items.yaml`。

第五幕:模型可以抖,出口不能炸

有了契约和 maker,还剩一个问题:执行层怎么表现。

```bash

spur decision run <id> --param k=v --evidence <file>

```

证据文件在离开进程之前会先被脱敏(redact)和限界(bound),然后随渲染后的问题走隐式 instructions 通道。脱敏发生在进程内、序列化之前——敏感材料不会以原文出现在任何日志或子进程参数里。`spur decision show` 的参数表里也能看到这条隐式通道的存在:每个决策都有一个 `instructions` 参数,`required: false`、`default: ""`。

退出码语义被刻意做成保守:

| 情形 | 退出码 | 结果 |

| --- | --- | --- |

| accepted / low-confidence / no-backend / timeout / error | 0 | 解析到已声明的 fallback |

| 未知 id、参数缺失或非法、未知或未注册 maker、证据文件不可读 | 1 | 调用方错误 |

后端的一切结果都落在同一格。模型答了、模型没答、后端挂了、超时了,调用方拿到的是同一个退出码,和那个早就写死在 YAML 里的 fallback。分界线划在"调用方有没有搞错",而不是"后端表现好不好"。

这正是第一幕那起事故缺的东西:当时"没有决策"和"决策回落到 fallback"没有分界线,所以失败可以伪装成成功。现在有了这道线,流水线可以把决策调用直接放进脚本里,不怕模型抖动炸掉整条流程——最坏情况是落进一个声明过的兜底分支。

还有一条硬约定:决策执行永不写 workflow 的 `resultFile`。决策的输出只能被显式的下游消费,写文件不是它的副作用。这让决策可以被重跑、被对比、被审计,而不会污染工作流的产物路径。

第六幕:把叙事收成方法论

回头看这六幕,收益是事故倒逼出来的,不是设计出来的:

| 收益 | 来自哪一幕 |

| --- | --- |

| **可审计**:每次决策都有契约 id、生效 maker、置信度门槛、fallback | 第三幕 `status` / `show` |

| **可测试**:`typesafe` 让 CI 里的决策完全确定,LLM 只在需要判断处出场 | 第四幕 maker 映射 |

| **可演进**:调行为改 YAML,不动引擎;新增决策只是加一条目录 | 第二幕 schema 契约 |

| **成本与隐私可控**:默认本地模型,敏感证据先脱敏再出进程 | 第四、五幕 |

| **人机边界清晰**:fallback 显式设计成 `defer` | 第一幕的教训 |

如果要抄,三条就够了:

1. 先让 `status` 全绿,再接流水线。决策目录是可以独立于流水线验证的资产,它有 schema、有 `list`/`show`,不需要跑一次完整任务才发现配置错了。

2. `fallback` 要设计成 `defer`,不要随便挑一个真实结果。前者是"我不知道",后者是伪装成"我知道"。事故的代价差别就在这里。

3. 从 `type: score` 的低风险判断起步。它没有互斥的候选集,评错一档的损失远小于选错一个分支。而恰恰是这个低风险练习,会逼你把 criteria 写成模型和你都能读懂的文字。

收口

现在再看到那行 `Unknown decision "task-triage"`,它已经是一句会被点名的话,而不是一行会消失的日志。`status` 会告诉你哪一层少了目录,`show` 会告诉你每条决策此刻由谁执行、按什么依据执行。

一个"结果正确、过程撒谎"的系统,到底危险多少倍?我们的答案是:它危险到你会开始相信自己的默认值。而默认值会替你做决定,直到某天你需要靠它复盘。

所以,决策系统的默认值必须诚实。不是因为模型会抖,而是因为总有一天,你会需要相信它。