从零构建 Study(Path B)¶
当没有既有研究覆盖你的问题、也没有可包装的遗留模拟器时走这条路:
原生实现四个核心接口、注册、然后让引擎完成装配。随仓库提供的模板是
studies/opinion_diffusion/——它的 study.yaml 带着
"demonstrates": ["from-scratch-core", …],就是为了让你照着抄。
模板是查出来的,不是写死的
/sv-build-model 并不去找一个叫 opinion_diffusion 的研究。它查
catalog,筛出 reference: true 且 demonstrates 含
from-scratch-core 的研究,再读最接近那个的 model.py 和它的测试。
所以随仓库提供的示例集换一批,skill 不用改;如果你的研究需要某个具体
模式(multi-round-messaging、info-broadcast…),就优先选示范了该
模式的参考研究。每个候选的 teaches 一行会说明它是「什么的范例」。
这一页可以让工作流替你写
在 Claude Code 里,/sv-build-model 会基于你的 study.yaml 完成
本页的全部工作。下面是同样的事情手工做一遍——无论走哪条路都值得读,
因为这就是你将要审阅的内容。
0. 第一件事:把「一步」锚到真实时间¶
在写下任何「每步多少」的量级之前,先决定一步意味着什么,并写进
study.yaml:
选真实行为实际发生的尺度——食堂/外卖的就餐选择是月度,选举意见转变是周度, 住房搬迁是年度——然后让模型里每一个速率都与这个单位自洽:价格漂移、 衰减、预算、到达率、搬迁概率。「漂移 0.02」在「一步」有单位之前毫无意义: 同一个常数当月度值合理,当日度值就荒唐。
不要默默地默认成 abstract
time_unit 默认就是 "abstract" 且不做校验,所以「还没想」和「刻意
选择」在文件里长得一模一样。只有当日历映射确实无意义时——这一步就是
一个纯粹的决策回合——abstract 才是正当的,而且必须在 step_meaning
里把理由写出来。事后再改单位意味着回头核对你写过的每一个常量,这就是
它排在第 0 步而不是第 6 步的原因。
字段细节见一个 Study 的解剖。
1. 实现四个接口¶
四个类都放在研究的 model.py 里。签名来自 socioverse/abc/
(完整细节见 API 参考):
from socioverse.abc import (
EnvironmentProvider, PopulationProvider, DecisionModel, MetricCollector,
)
from socioverse.engine.registry import register
@register("environment", "yourstudy.env")
class YourEnvironmentProvider(EnvironmentProvider):
def reset(self, seed): ... # 从 self.bundle 构建 E_0
def advance_to(self, t): ... # 触发定时事件 + 广播(外生)
def observe_batch(self, agent_ids, t, round_idx=0): ... # 每个 agent 的四象限 Observation
def apply(self, actions): ... # 把行为折回 E(内生)
def agent_state(self, agent_id): ... # 每个 agent 的面板行
@register("population", "yourstudy.pop")
class YourPopulationProvider(PopulationProvider):
def build(self, seed): ... # 确定性 Persona,id 必须持久
@register("decision", "yourstudy.decision")
class YourDecisionModel(DecisionModel):
def decide_batch(self, obs, memories): ... # B = f(P, E) —— 批式,返回 Action[]
@register("collector", "yourstudy.collector")
class YourMetricCollector(MetricCollector):
def collect(self, env, actions, t): ... # 每步一个扁平的指标 dict
def columns(self): ... # 声明的指标名(供报告器使用)
要守住的三条不变式:
- 持久且确定性的 id。
build(seed)每次必须产出相同的 agent、相同的 id——例如f"yourstudy-{i:03d}"。id 是纵向主键;池子运行途中不变。 - 决策必须批式。
decide_batch一次拿到全部观察。如果它调用 LLM, 要做批式调用——绝不逐 agent 循环。 - 两条时间通道分开。 外生变化(定时事件、广播)属于
advance_to(t);内生反馈(agent 自己的行为)属于apply(actions)。
LLM 决策必须给出第一人称的理由¶
当 decide_batch 由 LLM 驱动时,prompt 必须让模型在给出行为的同时,返回
一句用被模拟者自己的口吻写的 reason:这个 persona 会怎么把这个
选择讲给同伴听——用他的语气、他的语言。不是分析师式的第三人称总结,也不是
打分理由。一句话。
然后把它同时落到三处:
| 落在哪 | 为什么 |
|---|---|
payload["reason"] |
随行为进入面板的 action_payload |
Action.rationale=[reason] |
运行时声明的 rationale 通道 |
agent_state(agent_id) |
进 roster.jsonl 和 panel 表的 state——dashboard 的 agent 检视面板读的就是这里 |
可参照的先例是 studies/campus_dining_choice/model.py:它的
build_student_prompt 强制严格 JSON 输出,并且告诉 agent 这个字段是干
什么用的(「reason 会作为你分享给舍友的一句话」):
# prompt 里原样要求的形状:
# {"choice": "canteen"|"delivery"|"cook", "satisfaction": <0~1>, "reason": "<一句话理由>"}
for ob, resp in results:
dec = parse_decision(resp)
if dec is None: # 解析失败 → 维持上月选择
keep = ob.local_physical["state"].get("choice") or "canteen"
dec = {"choice": keep, "satisfaction": …, "reason": "(作答未解析,维持上月选择)"}
source = "fallback"
actions.append(Action(
agent_id=ob.agent_id, step=ob.step, kind="choose_meal",
payload={"choice": dec["choice"], "satisfaction": dec["satisfaction"],
"reason": dec.get("reason", "")},
source=source, rationale=[dec.get("reason") or ""]))
注意连解析失败的兜底分支也写了一条 reason——这个字段永远不会悄悄留空。
这是契约,不是锦上添花
理由是「B 为什么从 f(P, E) 里出来」唯一人类可读的证据。没有它, 一行面板只记录了「042 号 agent 在第 3 个月改点外卖了」,审阅者无从判断 这是推理还是噪声。有了它,这一行就可审计:你能读到人群自己对机制的 叙述,能看出哪些 persona 在扮演 prompt 而不是扮演自己的画像,也能在 报告里引用真实的 agent 原话。规则式决策用一句模板化的 rationale 就够 ——但 LLM 运行时,这个字段绝不能为空。
2. 注册与装配¶
@register(kind, key) 装饰器(kind 取值:environment、population、
decision、collector、store、reporter)把你的类发布到一个字符串
key 之下。产物随后按 key 引用它们:
environment.json→provider_ref: "yourstudy.env"population.json→provider_ref: "yourstudy.pop"simulation.json→decision_ref/collector_ref
确保 import 研究包时装饰器会执行——模板的
studies/opinion_diffusion/__init__.py 只做一件事:import model。
此后装配是通用的(完整代码片段见 快速开始):
沿途有用的引擎助手:
| 助手 | 作用 |
|---|---|
materialize_initial(env_bundle, pop_bundle, seed) |
不运行就实例化 P 和 E₀——人群阶段就是用它写出 roster.jsonl 的 |
build_providers(env_bundle, pop_bundle) |
只解析并实例化 E 和 P |
registry.available(kind) |
列出某个 kind 下注册的全部 key |
3. 编写产物¶
填好四份产物(study.yaml、environment/environment.json、
population/population.json、simulation/simulation.json)——每一份都是
Pydantic schema,validate_handoff(path, Schema) 对畸形内容会大声失败。
见一个 Study 的解剖。
别漏掉 study.yaml 的发现字段(domain、tags、
legacy_simulator: "from_scratch"、provider_refs、adjustable_params、
status、demonstrates):它们让你的研究可被路由——下一个匹配的问题
会得到你研究的一个 fork,而不是又一次重建。
4. 给数字找到根据¶
模型里每个承重常量——阈值、比率、初始分布——都应引用 grounding 侧车里 的一个事实 id 或假设 id。先锚定、再发明:见 真实世界锚定与溯源。
5. 先干跑,再花钱¶
先接一条确定性的决策路径(规则式,或 确定性 LLM 客户端),用固定种子 跑完整个循环。管线验证无误后,再把决策模型切到真实 LLM。