跳转到内容

军阀请求邻军援助

军阀甲正在交战,请求相邻的军阀乙出兵支援。一开始,军阀乙知道双方有共同的敌人, 但无法确认补给线是否安全,所以唯一的选项是拒绝。补给线得到确认后,“派兵支援” 成为第二个选项。军阀乙的 AI 给两个选项打分并做出选择,选中的结果会调动军阀乙的军队。

这个示例回答的问题是:AI、玩家或模型做出的选择,怎样按角色的权限校验、记录下来, 并在重放时不必再次询问决策者?

场景是带北洋时期风格的合成数据,不对应具体人物或史实事件。

  • 决策票据(DecisionTicket):一个持久化保存的决策问题, 附带带版本号的决策选项(DecisionOption)列表。
  • 控制者(controller,DecisionControllerBinding): 由谁决策、使用哪个决策策略、以谁的权限行动。
  • 效用策略 WeightedUtilityPolicy:用整数权重为选项打分,并把每个因子的贡献写入 决策轨迹(DecisionTrace)。
  • 选中的选项转成命令,和其他命令走同一套校验。
  • 整个决策的快照恢复与精确重放。
Terminal window
cargo run -p canwu-api --example decision_ticket

程序以 JSON 打印决策轨迹,再确认存档恢复和重放结果。下面的输出做了删减,只保留 send-aid 的因子列表:

{
"id": 1,
"ticket_id": 1,
"ticket_version": 2,
"controller_id": "warlord-b-ai",
"policy": { "kind": "utility", "id": "aid-utility", "version": "1" },
"outcome": { "type": "selected", "option_id": "send-aid" },
"summary": "utility policy selected send-aid",
"evaluations": [
{ "option_id": "decline", "available": true, "score": -40, ... },
{
"option_id": "send-aid",
"available": true,
"score": 250,
"factors": [
{ "factor": "alliance", "value": 90, "weight": 3, "contribution": 270 },
{ "factor": "home_defense", "value": -20, "weight": 1, "contribution": -20 }
]
}
],
"command_request_id": 1
}
snapshot_restore=ok exact_replay=ok

ticket_version 为 2,因为选项被替换过一次。decline 得分为 -40 × 3 + 80 × 1 = -40,send-aid 得分为 90 × 3 − 20 × 1 = 250。

军阀援军决策跨三个结算边界的时序. 查看图表源码.
查看图表源码
sequenceDiagram
  participant Host as 上层应用
  participant Canwu as 参伍
  participant Policy as WeightedUtilityPolicy
  Host->>Canwu: 注册控制者,开启票据(只有 decline)
  Note over Canwu: 边界 1:票据版本 1
  Host->>Canwu: ReplaceOptions(补给线已确认)
  Note over Canwu: 边界 2:票据版本 2
  Host->>Canwu: drive_decision(policy)
  Canwu->>Policy: 为票据版本 2 打分
  Policy-->>Canwu: 选择 send-aid
  Note over Canwu: 边界 3:写入决策轨迹,执行调动命令

代码都在 decision_ticket.rs 中。示例使用 Canwu::demo(1918) 提供的兼容场景(已标记为 deprecated), 通过 Canwu::demo_ids() 取得指挥官、军队和地区的 ID。

open_aid_request 把控制者 warlord-b-ai 绑定到一个决策策略身份、它借用其权限的 指挥官,以及它可以调动的军队:

let controller = DecisionControllerBinding::new(
"warlord-b-ai",
DecisionPolicyIdentity::new(DecisionPolicyKind::Utility, "aid-utility", "1"),
DecisionAuthority::Actor {
actor: ids.commander,
},
)
.with_command_subject(EntityRef::Army(ids.army));

它以 DecisionMutation::RegisterController 提交这个绑定。决策策略本身只拿到票据;签发者、 权限和命令对象都保存在持久化的绑定里。

同一个函数用 DecisionTicketDraft 提交 DecisionMutation::Open。决策上下文记录 军阀乙已知的情况,唯一的选项是 decline:

context: DecisionContext::new(
"beiyang.aid-request.v1",
json!({
"requester": "warlord-a",
"battle": "ongoing-front",
"common_enemy": true
}),
),
options: vec![DecisionOption {
action: DecisionAction::None,
utility_inputs: BTreeMap::from([
("home_defense".to_owned(), 80),
("alliance".to_owned(), -40),
]),
..DecisionOption::new("decline", "Decline aid")
}],
deadline: Some(now + SimDuration::days(2)),

两条变更都通过 enqueue_decision 入队,step_canonical() 结算的边界会开启票据, 版本为 1。实际应用中,应当用军阀乙的角色知识构造 决策上下文,让决策策略只看到军阀乙知道的事实。

refresh_aid_options 提交 DecisionMutation::ReplaceOptions,带上 expected_version: 1、加入 "route_confirmed": true 的新上下文,以及两个选项。 新的 send-aid 选项携带一条序列化命令:

DecisionOption {
action: DecisionAction::Command {
command: serde_json::to_value(Command::OrderMovement {
subject: EntityRef::Army(ids.army),
destination: ids.eastern_territory,
cargo: Vec::new(),
})?,
},
utility_inputs: BTreeMap::from([
("home_defense".to_owned(), -20),
("alliance".to_owned(), 90),
]),
..DecisionOption::new("send-aid", "Send the neighboring army")
},

票据升到版本 2。针对版本 1 准备的 Human、External 或 LLM 答复会被拒绝,过期答复 无法在新的选项集合里生效。在这个示例中,补给线确认是作者设定的场景输入;研究型应用 应为它引用一条证据记录。

Command::OrderMovement 是为这个兼容示例保留的旧命令变体。新应用应使用 canwu-reference-world 的 MovementCommand,或自己插件中的命令,参见 移动军队。票据选项以 JSON 保存命令,两种写法都可以。

resolve_aid_request 构造决策策略:

let policy = WeightedUtilityPolicy::new(
"aid-utility",
"1",
UtilityProfile {
weights: BTreeMap::from([("alliance".to_owned(), 3), ("home_defense".to_owned(), 1)]),
},
);

每个可用选项的得分是其 utility_inputs 中各项 value * weight 之和;没有配置权重的 因子按权重 0 计算。最高分胜出,同分时取 option ID 最小的选项。“同盟”和“本土防御” 代表什么、各占多少权重,由你的领域代码决定。

let evaluation = canwu.drive_decision(
canwu.time(),
0,
DecisionRequestId::new(4),
Some(CommandRequestId::new(1)),
DecisionTicketId::new(1),
&policy,
)?;
assert!(matches!(evaluation, DecisionEvaluation::Prepared(_)));
canwu.step_canonical()?.expect("decision resolution boundary");

drive_decision 对当前票据运行决策策略,并把结果作为决策输入入队。下一个边界中, 参伍写入决策轨迹,并以控制者的权限提交 send-aid 的命令。这条命令同样要通过常规的 权限、修订版本、时间和领域校验。决策策略只返回 option ID,因此换成玩家、外部服务或 LLM,也无法生成新命令或冒用他人权限。

verify_persistence_and_replay 从 JSON 恢复快照,再用 Canwu::replay_from_journal 重建整个运行,断言两者都与原快照一致。重放使用已记录的决策输入和决策尝试,决策策略 不再运行。

在自己的代码中读取结果时,用 Canwu::decision_trace(id) 读取决策轨迹,用 Canwu::decision_attempt(request_id) 读取某次决策请求被接受或拒绝的结果。修订版本 或票据版本过期、或票据已关闭的请求,会记为一次被拒绝的决策尝试(DecisionAttemptRecord),模拟照常推进。

  • 决策策略只能从已有选项中选择,既不能增加选项,也不能改动命令。
  • 替换选项会提升票据版本,针对旧版本准备的答复随之失效。
  • 决策轨迹逐个因子解释了选择的依据,重放时直接复用。
  • 想在同一局面下试另一种决策策略,就从快照派生分支 (fork)并提交新的决策输入。这样得到的是平行现实, 与精确重放分开。
  • 权重。 把 home_defense 改成 5。此时 decline 得分为 -120 + 400 = 280, 高于 send-aid 的 270 - 100 = 170。
  • 决策策略。 把 WeightedUtilityPolicy 换成 canwu-api 中的其他策略类型:
    • OrderedRulePolicy 按有序规则选择或推迟。
    • GuardedUtilityPolicy(前置规则效用策略)先运行有序前置规则,选择、推迟或排除 选项,再为剩余选项打分,并可在分数接近的最高选项之间随机平局决胜(见 随机决策与 LLM 选择)。它的决策轨迹 会记录决策阶段(Guard、Utility 或 Random)和触发的前置规则。
    • QueuedHumanPolicy、QueuedExternalPolicy 和 QueuedLlmPolicy 分别等待玩家、 外部服务或模型针对当前票据版本返回一个 option ID。
  • 决策者不可用。 如果军阀乙死亡或被俘,参伍会在该边界结束时取消以军阀乙为决策者的 未关闭票据,并在军阀乙不可用期间拒绝为其开启新票据。如果只是控制者的权限人物不可用, 参伍取消的是该控制者的未关闭票据。要继续这项决策,就为可用的控制者开启新票据,并把 parent_ticket 设为被取消的票据;这条关联会复制到新的决策轨迹上。详见 接入并驱动模拟运行。
  • 席位继任。 用 with_seat 把军阀乙的控制者绑定到席位,例如 faction-b.leader。军阀乙死亡后,把继任者的控制者绑定到同一席位,以继任者为决策者 开启新票据,并让 parent_ticket 指向被取消的票据。两个控制者占据同一席位,参伍便接受 这条关联。决策者不同、席位也不同的前序票据会被拒绝。

打开可运行示例

阅读决策框架测试